Nova aplikacija "Raspored časova" (raspored)
Više djece, smjene se računaju iz "sidra" (jedan datum + smjena tad; mijenja se sedmično 1,2,1,2...). Odgovara: koje časove ima, kad počinje/završava, koja smjena — za danas/sutra/prekosutra/juče/naziv dana/konkretan datum. - store.py: djeca, sidro->smjena za bilo koji datum, raspored po smjeni/danu, uparivanje imena (padeži), CSV import (kolone dijete..predmet), grid uređivanje. - manifest.py: alat raspored_upit(dijete, kada, sta=casovi|pocetak|kraj|smjena); bosanska gramatika (N časa/časova, akuzativ dana). Sve lokalno (inline). - admin.py + templates: pregled djece (+smjena danas), dodaj/obriši dijete, postavi razred+sidro, grid rasporeda (uredi/dodaj/obriši po času), CSV upload. - Registrovano u load_apps() i ADMIN_APPS. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LzKpYaEsuWDevuNnvSjcFW
This commit is contained in:
@@ -20,11 +20,12 @@ from flask import (
|
||||
# Admin stranice mini-app-ova (lagani moduli iz glavnog paketa, bez openai zavisnosti).
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
from idriz.apps.obroci import admin as _obroci_admin # noqa: E402
|
||||
from idriz.apps.raspored import admin as _raspored_admin # noqa: E402
|
||||
from idriz.apps.superheroji import admin as _superheroji_admin # noqa: E402
|
||||
from idriz.apps.vaktija import admin as _vaktija_admin # noqa: E402
|
||||
|
||||
# Svaki app-admin modul ima BP (Blueprint) i NAV (stavka lijevog menija).
|
||||
ADMIN_APPS = [_superheroji_admin, _obroci_admin, _vaktija_admin]
|
||||
ADMIN_APPS = [_superheroji_admin, _obroci_admin, _vaktija_admin, _raspored_admin]
|
||||
|
||||
# --- Konfiguracija -----------------------------------------------------------
|
||||
|
||||
|
||||
@@ -10,7 +10,8 @@ from .base import AppContext, AppSpec, AppTool # noqa: F401
|
||||
def load_apps() -> list[AppSpec]:
|
||||
from .bojanka import manifest as bojanka
|
||||
from .obroci import manifest as obroci
|
||||
from .raspored import manifest as raspored
|
||||
from .sazetak import manifest as sazetak
|
||||
from .superheroji import manifest as superheroji
|
||||
from .vaktija import manifest as vaktija
|
||||
return [sazetak.SPEC, bojanka.SPEC, superheroji.SPEC, obroci.SPEC, vaktija.SPEC]
|
||||
return [sazetak.SPEC, bojanka.SPEC, superheroji.SPEC, obroci.SPEC, vaktija.SPEC, raspored.SPEC]
|
||||
|
||||
0
idriz/apps/raspored/__init__.py
Normal file
0
idriz/apps/raspored/__init__.py
Normal file
98
idriz/apps/raspored/admin.py
Normal file
98
idriz/apps/raspored/admin.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""Admin za 'Raspored časova': djeca, smjena (sidro), grid rasporeda, CSV import.
|
||||
|
||||
Flask Blueprint, bez openai zavisnosti.
|
||||
"""
|
||||
import datetime as dt
|
||||
|
||||
from flask import Blueprint, flash, redirect, render_template, request, url_for
|
||||
|
||||
from . import store
|
||||
|
||||
BP = Blueprint("raspored", __name__, template_folder="templates", url_prefix="/raspored")
|
||||
NAV = {"endpoint": "raspored.index", "page": "raspored", "title": "Raspored časova", "icon": "📚"}
|
||||
|
||||
BLANK_ROWS = 8 # praznih redova za dodavanje novih časova
|
||||
|
||||
|
||||
@BP.route("/", methods=["GET", "POST"])
|
||||
def index():
|
||||
if request.method == "POST":
|
||||
action = request.form.get("action")
|
||||
if action == "add":
|
||||
if store.add_child(request.form.get("ime", ""), request.form.get("razred", "")):
|
||||
flash(f"Dodano dijete: {request.form.get('ime').strip()}.")
|
||||
else:
|
||||
flash("Dijete već postoji ili ime nije uneseno.")
|
||||
elif action == "delete":
|
||||
store.delete_child(request.form.get("ime", ""))
|
||||
flash(f"Obrisano dijete: {request.form.get('ime')}.")
|
||||
elif action == "csv":
|
||||
text = ""
|
||||
f = request.files.get("csvfile")
|
||||
if f and f.filename:
|
||||
text = f.read().decode("utf-8-sig", errors="replace")
|
||||
elif request.form.get("csvtext", "").strip():
|
||||
text = request.form["csvtext"]
|
||||
if not text.strip():
|
||||
flash("Nije priložen CSV (fajl ili tekst).")
|
||||
else:
|
||||
res = store.import_csv(text)
|
||||
if "error" in res:
|
||||
flash(f"CSV greška: {res['error']}")
|
||||
else:
|
||||
flash(f"Uvezeno: {res['casova']} časova za {', '.join(res['djeca'])}. "
|
||||
f"(Ne zaboravi postaviti smjenu/datum za svako dijete.)")
|
||||
return redirect(url_for("raspored.index"))
|
||||
|
||||
today = dt.date.today()
|
||||
djeca = []
|
||||
for name in store.list_children():
|
||||
ch = store.get_child(name)
|
||||
sm = store.shift_for(name, today)
|
||||
djeca.append({
|
||||
"ime": name,
|
||||
"razred": ch.get("razred", ""),
|
||||
"sidro": ch.get("sidro"),
|
||||
"smjena_danas": sm,
|
||||
"broj_casova": sum(len(v) for s in ch["raspored"].values() for v in s.values()),
|
||||
})
|
||||
return render_template("raspored.html", djeca=djeca, danas=today.isoformat(), active_page="raspored")
|
||||
|
||||
|
||||
@BP.route("/dijete/<ime>", methods=["GET", "POST"])
|
||||
def dijete(ime):
|
||||
ch = store.get_child(ime)
|
||||
if not ch:
|
||||
flash("Nepoznato dijete.")
|
||||
return redirect(url_for("raspored.index"))
|
||||
|
||||
if request.method == "POST":
|
||||
action = request.form.get("action")
|
||||
if action == "osnovno":
|
||||
store.set_osnovno(ime, request.form.get("razred", ""),
|
||||
request.form.get("sidro_datum", ""), request.form.get("sidro_smjena", ""))
|
||||
flash("Osnovni podaci sačuvani.")
|
||||
elif action == "raspored":
|
||||
n = int(request.form.get("nrows", "0"))
|
||||
rows = []
|
||||
for i in range(n):
|
||||
if request.form.get(f"r{i}_del"):
|
||||
continue
|
||||
rows.append({
|
||||
"smjena": request.form.get(f"r{i}_smjena", ""),
|
||||
"dan": request.form.get(f"r{i}_dan", ""),
|
||||
"br": request.form.get(f"r{i}_br", ""),
|
||||
"pocetak": request.form.get(f"r{i}_pocetak", ""),
|
||||
"kraj": request.form.get(f"r{i}_kraj", ""),
|
||||
"predmet": request.form.get(f"r{i}_predmet", ""),
|
||||
})
|
||||
saved = store.set_child_schedule(ime, rows)
|
||||
flash(f"Raspored sačuvan: {saved} časova.")
|
||||
return redirect(url_for("raspored.dijete", ime=ime))
|
||||
|
||||
grid = store.child_rows(ime) + [{} for _ in range(BLANK_ROWS)]
|
||||
return render_template(
|
||||
"raspored_dijete.html",
|
||||
ime=ime, dijete=ch, grid=grid, nrows=len(grid),
|
||||
dani=store.DANI, active_page="raspored",
|
||||
)
|
||||
135
idriz/apps/raspored/manifest.py
Normal file
135
idriz/apps/raspored/manifest.py
Normal file
@@ -0,0 +1,135 @@
|
||||
"""Mini-app 'Raspored časova': odgovara o rasporedu djece za dati dan.
|
||||
|
||||
Podržava više djece; smjena se računa iz sidra (v. store.py). Sva logika je lokalna
|
||||
(bez API-ja), pa je alat inline. Tačan datum se oslanja na sistemski sat (NTP).
|
||||
"""
|
||||
import datetime as dt
|
||||
import logging
|
||||
import re
|
||||
|
||||
from ..base import AppContext, AppSpec, AppTool
|
||||
from . import store
|
||||
|
||||
log = logging.getLogger("idriz.raspored")
|
||||
|
||||
INSTRUCTIONS = """RASPORED ČASOVA (alat: raspored_upit): odgovaraš o školskom rasporedu djece (koje časove imaju, kad počinju/završavaju, koja je smjena). Podržano je više djece.
|
||||
- Kad dijete/roditelj pita o rasporedu ("koje časove Ahmed ima danas?", "kad Hatidža danas počinje/završava školu?", "koja je smjena za Ahmeda sutra?"), pozovi raspored_upit.
|
||||
- Argumenti: `dijete` (ime kako je izgovoreno), `kada` (danas/sutra/prekosutra/juce/prekjuce, naziv dana, ili datum kao "15.09." ili "2026-09-15"), i `sta`: "casovi" (koji časovi), "pocetak" (kad mora biti u školi), "kraj" (kad završava), "smjena".
|
||||
- Ako nije rečeno šta tačno, koristi sta="casovi".
|
||||
- Alat vraća gotovu rečenicu na bosanskom — izgovori je prirodno. Ne izmišljaj časove ni vremena.
|
||||
- Ako alat kaže da dijete nije poznato ili da smjena nije postavljena, prenesi to."""
|
||||
|
||||
SCHEMA = {
|
||||
"type": "function",
|
||||
"name": "raspored_upit",
|
||||
"description": "Raspored časova za jedno dijete i jedan dan (koji časovi, početak, kraj, smjena).",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"dijete": {"type": "string", "description": "Ime djeteta kako je izgovoreno."},
|
||||
"kada": {"type": "string", "description": "danas, sutra, prekosutra, juce, prekjuce, naziv dana, ili datum (npr. '15.09.' ili '2026-09-15'). Prazno = danas."},
|
||||
"sta": {"type": "string", "enum": ["casovi", "pocetak", "kraj", "smjena"],
|
||||
"description": "Šta se traži. Podrazumijevano 'casovi'."},
|
||||
},
|
||||
"required": ["dijete"],
|
||||
},
|
||||
}
|
||||
|
||||
_REL_FRAZA = {0: "danas", 1: "sutra", 2: "prekosutra", -1: "jučer", -2: "prekjučer"}
|
||||
# akuzativ za "u <dan>" (srijeda/subota/nedjelja mijenjaju oblik)
|
||||
_DAN_AKUZATIV = ["ponedjeljak", "utorak", "srijedu", "četvrtak", "petak", "subotu", "nedjelju"]
|
||||
|
||||
|
||||
def _cas_rijec(n: int) -> str:
|
||||
if 11 <= n % 100 <= 14:
|
||||
return "časova"
|
||||
d = n % 10
|
||||
return "čas" if d == 1 else ("časa" if 2 <= d <= 4 else "časova")
|
||||
|
||||
|
||||
def _date_fraza(d: dt.date) -> str:
|
||||
return f"u {_DAN_AKUZATIV[d.weekday()]}, {d.day}.{d.month}."
|
||||
|
||||
|
||||
def _parse_kada(s: str) -> tuple[dt.date, str]:
|
||||
today = dt.date.today()
|
||||
s = (s or "").strip()
|
||||
if not s:
|
||||
return today, "danas"
|
||||
sn = store.norm(s)
|
||||
rel = {"danas": 0, "sutra": 1, "prekosutra": 2, "juce": -1, "jucer": -1, "prekjuce": -2, "prekjucer": -2}
|
||||
if sn in rel:
|
||||
d = today + dt.timedelta(days=rel[sn])
|
||||
return d, _REL_FRAZA[rel[sn]]
|
||||
try:
|
||||
d = dt.date.fromisoformat(s)
|
||||
return d, _date_fraza(d)
|
||||
except ValueError:
|
||||
pass
|
||||
m = re.match(r"^(\d{1,2})\.\s*(\d{1,2})\.?\s*(\d{2,4})?", s)
|
||||
if m:
|
||||
dd, mm = int(m.group(1)), int(m.group(2))
|
||||
yy = m.group(3)
|
||||
year = int(yy) if yy else today.year
|
||||
if year < 100:
|
||||
year += 2000
|
||||
try:
|
||||
d = dt.date(year, mm, dd)
|
||||
return d, _date_fraza(d)
|
||||
except ValueError:
|
||||
pass
|
||||
wd = {"ponedjeljak": 0, "utorak": 1, "srijeda": 2, "cetvrtak": 3, "petak": 4, "subota": 5, "nedjelja": 6, "nedelja": 6}
|
||||
if sn in wd:
|
||||
monday = today - dt.timedelta(days=today.weekday())
|
||||
d = monday + dt.timedelta(days=wd[sn])
|
||||
return d, _date_fraza(d)
|
||||
return today, "danas"
|
||||
|
||||
|
||||
async def raspored_upit(ctx: AppContext, args: dict) -> str:
|
||||
ime_izgovoreno = (args.get("dijete") or "").strip()
|
||||
name = store.match_child(ime_izgovoreno)
|
||||
if not name:
|
||||
djeca = store.list_children()
|
||||
lista = ", ".join(djeca) if djeca else "nijedno dijete nije dodano"
|
||||
return (f"Ne znam za dijete \"{ime_izgovoreno}\". Poznata djeca: {lista}. "
|
||||
f"Reci to i pitaj za jedno od njih (ili dodaj dijete u admin panelu).")
|
||||
|
||||
date, fraza = _parse_kada(args.get("kada", ""))
|
||||
sta = (args.get("sta") or "casovi").strip().lower()
|
||||
res = store.lessons_for(name, date)
|
||||
st = res["status"]
|
||||
|
||||
if st == "vikend":
|
||||
return f'Reci djetetu: "{name} nema nastave {fraza} — vikend je."'
|
||||
if st == "nema_sidra":
|
||||
return (f"Za {name} nije postavljena smjena u admin panelu (treba jedan datum i koja je smjena tad bila), "
|
||||
f"pa ne mogu izračunati raspored. Reci to.")
|
||||
smjena = res["smjena"]
|
||||
if st == "nema_casova":
|
||||
return f'Reci djetetu: "{name} {fraza} nema časova ({smjena}. smjena)."'
|
||||
|
||||
casovi = res["casovi"]
|
||||
prvi, zadnji = casovi[0], casovi[-1]
|
||||
|
||||
if sta == "smjena":
|
||||
return f'Reci djetetu: "{name} je {fraza} u {smjena}. smjeni."'
|
||||
if sta == "pocetak":
|
||||
return f'Reci djetetu: "{name} {fraza} treba biti u školi u {prvi["pocetak"]} ({smjena}. smjena)."'
|
||||
if sta == "kraj":
|
||||
return f'Reci djetetu: "{name} {fraza} završava školu u {zadnji["kraj"]} ({smjena}. smjena)."'
|
||||
|
||||
# casovi — nabroji
|
||||
stavke = "; ".join(f'{c["br"]}. {c["predmet"]} u {c["pocetak"]}' for c in casovi)
|
||||
recenica = (f"{name} {fraza} ima {len(casovi)} {_cas_rijec(len(casovi))} u {smjena}. smjeni "
|
||||
f"(od {prvi['pocetak']} do {zadnji['kraj']}): {stavke}.")
|
||||
return f'Reci djetetu prirodno na bosanskom, možeš i sažeti ako je dugačko: "{recenica}"'
|
||||
|
||||
|
||||
SPEC = AppSpec(
|
||||
name="raspored",
|
||||
title="Raspored časova",
|
||||
instructions=INSTRUCTIONS,
|
||||
greeting="reći raspored časova za djecu (kad počinju/završavaju, koji časovi)",
|
||||
tools=[AppTool(SCHEMA, raspored_upit, "inline")],
|
||||
)
|
||||
282
idriz/apps/raspored/store.py
Normal file
282
idriz/apps/raspored/store.py
Normal file
@@ -0,0 +1,282 @@
|
||||
"""Skladište za app 'Raspored časova': djeca, smjene i raspored.
|
||||
|
||||
Smjena se mijenja sedmično (1,2,1,2...). U bazi se za dijete čuva "sidro":
|
||||
jedan datum + koja je smjena tad bila. Iz toga se računa smjena za bilo koji datum.
|
||||
Bez openai/requests (uvozi ga i admin).
|
||||
"""
|
||||
import csv
|
||||
import datetime as dt
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
import threading
|
||||
import unicodedata
|
||||
from pathlib import Path
|
||||
|
||||
RASPORED_PATH = Path(os.environ.get("IDRIZ_RASPORED_PATH", str(Path.home() / ".local/share/idriz/raspored.json")))
|
||||
|
||||
DANI = ["Ponedjeljak", "Utorak", "Srijeda", "Četvrtak", "Petak"] # radni dani (redoslijed)
|
||||
DAN_PO_WEEKDAY = {0: "Ponedjeljak", 1: "Utorak", 2: "Srijeda", 3: "Četvrtak", 4: "Petak"}
|
||||
DAN_PUNI = ["ponedjeljak", "utorak", "srijeda", "četvrtak", "petak", "subota", "nedjelja"]
|
||||
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
def norm(s: str) -> str:
|
||||
s = unicodedata.normalize("NFKD", s or "").encode("ascii", "ignore").decode()
|
||||
return re.sub(r"[^a-z0-9]+", "", s.lower())
|
||||
|
||||
|
||||
_DAN_NORM = {norm(d): d for d in DANI}
|
||||
|
||||
|
||||
def canonical_dan(s: str) -> str | None:
|
||||
return _DAN_NORM.get(norm(s))
|
||||
|
||||
|
||||
# ---------- učitavanje/snimanje ----------
|
||||
|
||||
def _load() -> dict:
|
||||
try:
|
||||
data = json.loads(RASPORED_PATH.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
data = {}
|
||||
data.setdefault("djeca", {})
|
||||
return data
|
||||
|
||||
|
||||
def _save(data: dict) -> None:
|
||||
with _lock:
|
||||
RASPORED_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(dir=str(RASPORED_PATH.parent), suffix=".tmp")
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
os.replace(tmp, RASPORED_PATH)
|
||||
finally:
|
||||
if os.path.exists(tmp):
|
||||
os.unlink(tmp)
|
||||
|
||||
|
||||
def _empty_raspored() -> dict:
|
||||
return {"1": {d: [] for d in DANI}, "2": {d: [] for d in DANI}}
|
||||
|
||||
|
||||
# ---------- djeca ----------
|
||||
|
||||
def list_children() -> list[str]:
|
||||
return sorted(_load()["djeca"].keys(), key=lambda s: s.lower())
|
||||
|
||||
|
||||
def get_child(name: str) -> dict | None:
|
||||
return _load()["djeca"].get(name)
|
||||
|
||||
|
||||
def add_child(name: str, razred: str = "") -> bool:
|
||||
name = (name or "").strip()
|
||||
if not name:
|
||||
return False
|
||||
data = _load()
|
||||
if name in data["djeca"]:
|
||||
return False
|
||||
data["djeca"][name] = {"razred": razred.strip(), "sidro": None, "raspored": _empty_raspored()}
|
||||
_save(data)
|
||||
return True
|
||||
|
||||
|
||||
def delete_child(name: str) -> None:
|
||||
data = _load()
|
||||
if name in data["djeca"]:
|
||||
del data["djeca"][name]
|
||||
_save(data)
|
||||
|
||||
|
||||
def set_osnovno(name: str, razred: str, sidro_datum: str, sidro_smjena) -> None:
|
||||
"""Postavi razred i sidro (datum + smjena tad). Prazan datum/smjena -> obriši sidro."""
|
||||
data = _load()
|
||||
ch = data["djeca"].get(name)
|
||||
if not ch:
|
||||
return
|
||||
ch["razred"] = (razred or "").strip()
|
||||
try:
|
||||
d = dt.date.fromisoformat((sidro_datum or "").strip())
|
||||
sm = int(sidro_smjena)
|
||||
ch["sidro"] = {"datum": d.isoformat(), "smjena": sm} if sm in (1, 2) else None
|
||||
except (ValueError, TypeError):
|
||||
ch["sidro"] = None
|
||||
_save(data)
|
||||
|
||||
|
||||
def match_child(query: str) -> str | None:
|
||||
"""Toleranto uparivanje imena (padeži/vokativ): najduži zajednički prefiks."""
|
||||
q = norm(query)
|
||||
if not q:
|
||||
return None
|
||||
best, best_len = None, 0
|
||||
for name in _load()["djeca"]:
|
||||
n = norm(name)
|
||||
cp = 0
|
||||
for a, b in zip(q, n):
|
||||
if a == b:
|
||||
cp += 1
|
||||
else:
|
||||
break
|
||||
if n == q:
|
||||
return name
|
||||
if cp > best_len:
|
||||
best_len, best = cp, name
|
||||
# zahtijevaj bar 4 zajednička znaka (ili cijelo kraće ime)
|
||||
if best and best_len >= min(4, len(best)):
|
||||
return best
|
||||
return None
|
||||
|
||||
|
||||
# ---------- raspored (uređivanje) ----------
|
||||
|
||||
def _clean_time(t: str) -> str:
|
||||
t = (t or "").strip()
|
||||
m = re.match(r"^(\d{1,2}):(\d{2})$", t)
|
||||
if m:
|
||||
return f"{int(m.group(1)):02d}:{m.group(2)}"
|
||||
return t
|
||||
|
||||
|
||||
def set_child_schedule(name: str, rows: list[dict]) -> int:
|
||||
"""Zamijeni cijeli raspored djeteta listom časova {smjena,dan,br,pocetak,kraj,predmet}."""
|
||||
data = _load()
|
||||
ch = data["djeca"].get(name)
|
||||
if not ch:
|
||||
return 0
|
||||
rasp = _empty_raspored()
|
||||
n = 0
|
||||
for r in rows:
|
||||
try:
|
||||
sm = int(r.get("smjena"))
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
dan = canonical_dan(r.get("dan", ""))
|
||||
predmet = (r.get("predmet") or "").strip()
|
||||
if sm not in (1, 2) or not dan or not predmet:
|
||||
continue
|
||||
try:
|
||||
br = int(r.get("br"))
|
||||
except (ValueError, TypeError):
|
||||
br = len(rasp[str(sm)][dan]) + 1
|
||||
rasp[str(sm)][dan].append({
|
||||
"br": br,
|
||||
"pocetak": _clean_time(r.get("pocetak", "")),
|
||||
"kraj": _clean_time(r.get("kraj", "")),
|
||||
"predmet": predmet,
|
||||
})
|
||||
n += 1
|
||||
for sm in ("1", "2"):
|
||||
for dan in DANI:
|
||||
rasp[sm][dan].sort(key=lambda c: c["br"])
|
||||
ch["raspored"] = rasp
|
||||
_save(data)
|
||||
return n
|
||||
|
||||
|
||||
def child_rows(name: str) -> list[dict]:
|
||||
"""Svi časovi djeteta kao ravna lista (za admin grid), sortirano smjena/dan/br."""
|
||||
ch = get_child(name)
|
||||
if not ch:
|
||||
return []
|
||||
out = []
|
||||
for sm in ("1", "2"):
|
||||
for dan in DANI:
|
||||
for c in ch["raspored"].get(sm, {}).get(dan, []):
|
||||
out.append({"smjena": int(sm), "dan": dan, "br": c["br"],
|
||||
"pocetak": c["pocetak"], "kraj": c["kraj"], "predmet": c["predmet"]})
|
||||
return out
|
||||
|
||||
|
||||
# ---------- CSV import ----------
|
||||
|
||||
CSV_HEADER = ["dijete", "razred", "smjena", "dan", "redni_broj_casa", "pocetak", "kraj", "predmet"]
|
||||
|
||||
|
||||
def import_csv(text: str) -> dict:
|
||||
"""Uvezi CSV (kolone CSV_HEADER). Zamijeni raspored za svako dijete iz fajla.
|
||||
Zadrži postojeće sidro. Vrati sažetak ili {'error':...}."""
|
||||
try:
|
||||
reader = csv.DictReader(io.StringIO(text))
|
||||
if not reader.fieldnames:
|
||||
return {"error": "Prazan CSV."}
|
||||
headers = [h.strip() for h in reader.fieldnames]
|
||||
missing = [h for h in CSV_HEADER if h not in headers]
|
||||
if missing:
|
||||
return {"error": f"Nedostaju kolone: {', '.join(missing)}."}
|
||||
po_djetetu: dict[str, dict] = {}
|
||||
for raw in reader:
|
||||
row = {k.strip(): (v or "").strip() for k, v in raw.items() if k}
|
||||
ime = row.get("dijete", "").strip()
|
||||
if not ime:
|
||||
continue
|
||||
entry = po_djetetu.setdefault(ime, {"razred": row.get("razred", ""), "rows": []})
|
||||
if row.get("razred"):
|
||||
entry["razred"] = row["razred"].strip()
|
||||
entry["rows"].append({
|
||||
"smjena": row.get("smjena"), "dan": row.get("dan"),
|
||||
"br": row.get("redni_broj_casa"), "pocetak": row.get("pocetak"),
|
||||
"kraj": row.get("kraj"), "predmet": row.get("predmet"),
|
||||
})
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"error": f"Greška pri čitanju CSV-a: {type(e).__name__}"}
|
||||
|
||||
if not po_djetetu:
|
||||
return {"error": "Nema redova u CSV-u."}
|
||||
|
||||
data = _load()
|
||||
total = 0
|
||||
for ime, entry in po_djetetu.items():
|
||||
ch = data["djeca"].get(ime)
|
||||
if not ch:
|
||||
ch = {"razred": "", "sidro": None, "raspored": _empty_raspored()}
|
||||
data["djeca"][ime] = ch
|
||||
if entry["razred"]:
|
||||
ch["razred"] = entry["razred"]
|
||||
_save(data) # snimi novododanu djecu prije set_child_schedule (koji ponovo učitava)
|
||||
for ime, entry in po_djetetu.items():
|
||||
total += set_child_schedule(ime, entry["rows"])
|
||||
return {"djeca": sorted(po_djetetu.keys()), "casova": total}
|
||||
|
||||
|
||||
# ---------- smjena i raspored za datum ----------
|
||||
|
||||
def shift_for(name: str, date: dt.date) -> int | None:
|
||||
ch = get_child(name)
|
||||
if not ch or not ch.get("sidro"):
|
||||
return None
|
||||
try:
|
||||
anchor = dt.date.fromisoformat(ch["sidro"]["datum"])
|
||||
s = int(ch["sidro"]["smjena"])
|
||||
except (ValueError, TypeError, KeyError):
|
||||
return None
|
||||
mon_a = anchor - dt.timedelta(days=anchor.weekday())
|
||||
mon_d = date - dt.timedelta(days=date.weekday())
|
||||
weeks = (mon_d - mon_a).days // 7
|
||||
if weeks % 2 == 0:
|
||||
return s
|
||||
return 2 if s == 1 else 1
|
||||
|
||||
|
||||
def lessons_for(name: str, date: dt.date) -> dict:
|
||||
"""Vrati {'status', 'smjena', 'dan', 'casovi'} za dijete i datum.
|
||||
status: 'ok' | 'nepoznato_dijete' | 'vikend' | 'nema_sidra' | 'nema_casova'."""
|
||||
ch = get_child(name)
|
||||
if not ch:
|
||||
return {"status": "nepoznato_dijete"}
|
||||
if date.weekday() >= 5:
|
||||
return {"status": "vikend"}
|
||||
smjena = shift_for(name, date)
|
||||
if smjena is None:
|
||||
return {"status": "nema_sidra"}
|
||||
dan = DAN_PO_WEEKDAY[date.weekday()]
|
||||
casovi = list(ch["raspored"].get(str(smjena), {}).get(dan, []))
|
||||
casovi.sort(key=lambda c: c["br"])
|
||||
if not casovi:
|
||||
return {"status": "nema_casova", "smjena": smjena, "dan": dan}
|
||||
return {"status": "ok", "smjena": smjena, "dan": dan, "casovi": casovi}
|
||||
79
idriz/apps/raspored/templates/raspored.html
Normal file
79
idriz/apps/raspored/templates/raspored.html
Normal file
@@ -0,0 +1,79 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Raspored časova — Idriz Admin{% endblock %}
|
||||
{% block extra_css %}
|
||||
.panel { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 16px 18px; margin-bottom: 20px; max-width: 720px; }
|
||||
.panel h2 { margin: 0 0 4px; font-size: 15px; }
|
||||
.panel p.hint { color: var(--muted); font-size: 12.5px; margin: 0 0 12px; }
|
||||
table { width: 100%; border-collapse: collapse; background: var(--panel); border: 1px solid var(--line); border-radius: 10px; overflow: hidden; max-width: 720px; margin-bottom: 20px; }
|
||||
th, td { text-align: left; padding: 10px 14px; font-size: 14px; }
|
||||
th { background: #fafbfc; color: var(--muted); font-weight: 600; border-bottom: 1px solid var(--line); }
|
||||
tr:not(:last-child) td { border-bottom: 1px solid var(--line); }
|
||||
a.edit { color: var(--accent); text-decoration: none; font-weight: 600; }
|
||||
input[type=text], textarea { width: 100%; padding: 9px 12px; font-size: 14px; border: 1px solid var(--line); border-radius: 8px; font-family: inherit; }
|
||||
textarea { min-height: 120px; }
|
||||
.row { display: flex; gap: 10px; align-items: flex-end; flex-wrap: wrap; }
|
||||
.row > div { flex: 1; min-width: 140px; }
|
||||
label { display: block; font-size: 12.5px; color: var(--muted); margin-bottom: 4px; }
|
||||
button { padding: 9px 18px; font-size: 14px; font-weight: 600; color: #fff; background: var(--accent); border: none; border-radius: 8px; cursor: pointer; }
|
||||
button:hover { filter: brightness(1.05); }
|
||||
button.small { padding: 5px 10px; font-size: 12.5px; }
|
||||
button.danger { background: var(--bad); }
|
||||
.badge { display:inline-block; padding:2px 9px; border-radius:20px; font-size:12.5px; font-weight:600; }
|
||||
.b1 { background:#e6f0ff; color:#2f6df6; } .b2 { background:#fdf1dd; color:#c9820a; }
|
||||
.muted { color: var(--muted); }
|
||||
.empty { color: var(--muted); font-style: italic; }
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Raspored časova</h1>
|
||||
<p class="sub">Djeca, smjene i raspored. Smjena se mijenja sedmično — postavi je jednim datumom po djetetu.</p>
|
||||
|
||||
{% if djeca %}
|
||||
<table>
|
||||
<thead><tr><th>Dijete</th><th>Razred</th><th>Smjena danas</th><th>Časova</th><th></th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for d in djeca %}
|
||||
<tr>
|
||||
<td><strong>{{ d.ime }}</strong></td>
|
||||
<td>{{ d.razred or "—" }}</td>
|
||||
<td>
|
||||
{% if d.smjena_danas %}<span class="badge b{{ d.smjena_danas }}">{{ d.smjena_danas }}. smjena</span>
|
||||
{% else %}<span class="muted">nema smjene</span>{% endif %}
|
||||
</td>
|
||||
<td>{{ d.broj_casova }}</td>
|
||||
<td><a class="edit" href="{{ url_for('raspored.dijete', ime=d.ime) }}">Uredi ›</a></td>
|
||||
<td>
|
||||
<form method="post" onsubmit="return confirm('Obrisati {{ d.ime }}?');" style="margin:0;">
|
||||
<input type="hidden" name="action" value="delete"><input type="hidden" name="ime" value="{{ d.ime }}">
|
||||
<button type="submit" class="small danger">Obriši</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="empty">Još nema dodane djece.</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="panel">
|
||||
<h2>Dodaj dijete</h2>
|
||||
<form method="post" class="row">
|
||||
<input type="hidden" name="action" value="add">
|
||||
<div><label>Ime</label><input type="text" name="ime" placeholder="npr. Ahmed"></div>
|
||||
<div><label>Razred</label><input type="text" name="razred" placeholder="npr. VI-4"></div>
|
||||
<div style="flex:0;"><button type="submit">Dodaj</button></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h2>Uvoz CSV-a</h2>
|
||||
<p class="hint">Kolone: dijete, razred, smjena, dan, redni_broj_casa, pocetak, kraj, predmet. Zamjenjuje raspored za djecu iz fajla; smjenu/datum postavi ručno po uvozu.</p>
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
<input type="hidden" name="action" value="csv">
|
||||
<input type="file" name="csvfile" accept=".csv,text/csv" style="margin-bottom:10px;">
|
||||
<p class="hint">ili zalijepi CSV ovdje:</p>
|
||||
<textarea name="csvtext" placeholder="dijete,razred,smjena,dan,redni_broj_casa,pocetak,kraj,predmet ..."></textarea>
|
||||
<div style="margin-top:12px;"><button type="submit">Uvezi</button></div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
78
idriz/apps/raspored/templates/raspored_dijete.html
Normal file
78
idriz/apps/raspored/templates/raspored_dijete.html
Normal file
@@ -0,0 +1,78 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ ime }} — Raspored — Idriz Admin{% endblock %}
|
||||
{% block extra_css %}
|
||||
.panel { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 16px 18px; margin-bottom: 20px; max-width: 900px; }
|
||||
.panel h2 { margin: 0 0 12px; font-size: 15px; }
|
||||
.row { display: flex; gap: 12px; align-items: flex-end; flex-wrap: wrap; }
|
||||
.row > div { flex: 0 0 auto; }
|
||||
label { display: block; font-size: 12.5px; color: var(--muted); margin-bottom: 4px; }
|
||||
input, select { padding: 8px 10px; font-size: 14px; border: 1px solid var(--line); border-radius: 7px; font-family: inherit; background:#fff; color:var(--ink); }
|
||||
input[type=text], input[type=date] { min-width: 150px; }
|
||||
button { padding: 9px 18px; font-size: 14px; font-weight: 600; color: #fff; background: var(--accent); border: none; border-radius: 8px; cursor: pointer; }
|
||||
button:hover { filter: brightness(1.05); }
|
||||
a.back { color: var(--muted); text-decoration: none; font-size: 13px; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 13.5px; }
|
||||
th, td { padding: 5px 7px; text-align: left; }
|
||||
th { color: var(--muted); font-weight: 600; font-size: 12px; border-bottom: 1px solid var(--line); }
|
||||
td input[type=text], td input[type=time] { min-width: 0; width: 100%; }
|
||||
.c-sm { width: 70px; } .c-dan { width: 130px; } .c-br { width: 55px; } .c-vr { width: 105px; } .c-del { width: 40px; text-align:center; }
|
||||
.predmet input { width: 100%; }
|
||||
.scroll { overflow-x: auto; }
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<a class="back" href="{{ url_for('raspored.index') }}">‹ Nazad na djecu</a>
|
||||
<h1>{{ ime }}</h1>
|
||||
<p class="sub">Uređivanje osnovnih podataka i rasporeda.</p>
|
||||
|
||||
<div class="panel">
|
||||
<h2>Osnovno i smjena</h2>
|
||||
<form method="post" class="row">
|
||||
<input type="hidden" name="action" value="osnovno">
|
||||
<div><label>Razred</label><input type="text" name="razred" value="{{ dijete.razred }}"></div>
|
||||
<div><label>Datum (referentni)</label><input type="date" name="sidro_datum" value="{{ dijete.sidro.datum if dijete.sidro else '' }}"></div>
|
||||
<div><label>Smjena tog datuma</label>
|
||||
<select name="sidro_smjena">
|
||||
<option value="1" {{ 'selected' if dijete.sidro and dijete.sidro.smjena == 1 else '' }}>1. smjena</option>
|
||||
<option value="2" {{ 'selected' if dijete.sidro and dijete.sidro.smjena == 2 else '' }}>2. smjena</option>
|
||||
</select>
|
||||
</div>
|
||||
<div><label> </label><button type="submit">Sačuvaj</button></div>
|
||||
</form>
|
||||
<p class="sub" style="margin-top:10px;">Iz tog jednog datuma se računa smjena za svaki dan (mijenja se sedmično).</p>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h2>Raspored (uredi, dodaj, obriši)</h2>
|
||||
<form method="post">
|
||||
<input type="hidden" name="action" value="raspored">
|
||||
<input type="hidden" name="nrows" value="{{ nrows }}">
|
||||
<div class="scroll">
|
||||
<table>
|
||||
<thead><tr><th class="c-sm">Smjena</th><th class="c-dan">Dan</th><th class="c-br">Br.</th><th class="c-vr">Početak</th><th class="c-vr">Kraj</th><th class="predmet">Predmet</th><th class="c-del">Obriši</th></tr></thead>
|
||||
<tbody>
|
||||
{% for r in grid %}
|
||||
{% set i = loop.index0 %}
|
||||
<tr>
|
||||
<td class="c-sm"><select name="r{{ i }}_smjena">
|
||||
<option value="1" {{ 'selected' if r.smjena == 1 else '' }}>1</option>
|
||||
<option value="2" {{ 'selected' if r.smjena == 2 else '' }}>2</option>
|
||||
</select></td>
|
||||
<td class="c-dan"><select name="r{{ i }}_dan">
|
||||
<option value="" {{ 'selected' if not r.dan else '' }}>—</option>
|
||||
{% for d in dani %}<option value="{{ d }}" {{ 'selected' if r.dan == d else '' }}>{{ d }}</option>{% endfor %}
|
||||
</select></td>
|
||||
<td class="c-br"><input type="text" name="r{{ i }}_br" value="{{ r.br if r.br is defined else '' }}"></td>
|
||||
<td class="c-vr"><input type="time" name="r{{ i }}_pocetak" value="{{ r.pocetak if r.pocetak is defined else '' }}"></td>
|
||||
<td class="c-vr"><input type="time" name="r{{ i }}_kraj" value="{{ r.kraj if r.kraj is defined else '' }}"></td>
|
||||
<td class="predmet"><input type="text" name="r{{ i }}_predmet" value="{{ r.predmet if r.predmet is defined else '' }}"></td>
|
||||
<td class="c-del"><input type="checkbox" name="r{{ i }}_del"></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div style="margin-top:14px;"><button type="submit">Sačuvaj raspored</button></div>
|
||||
</form>
|
||||
<p class="sub" style="margin-top:10px;">Prazni redovi na dnu su za dodavanje novih časova. Za brisanje označi "Obriši" pa Sačuvaj.</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user