Catalogo ›Scuola e PA ›Scuola e formazione
Orario scolastico e sostituzioni
L'orario si **genera da solo** dalle cattedre (con i vincoli veri: giorno libero, ore a disposizione, tetto di ore al giorno) e le assenze diventano un **piano sostituzioni** proposto per equità. Le griglie settimanali di classi e docenti sono pronte da stampare. Tu controlli e adotti: l'app propone, la persona decide. Tutto resta sul tuo dispositivo.
Cosa registra
- italiano, storia
- 1A
- part-time, altra sede...
- 2
- 1
- 2
- matematica
- lun,mar,mer,gio,ven
- 6
- 2
- malattia, permesso...
Come funziona
Questa è un'app Reactive: un semplice documento di testo che il browser trasforma in un'app funzionante — moduli, dati dal vivo, tabelle, statistiche e persino grafici, senza installare né configurare nulla.
- Usala subito. Premi «Usa questa app»: si apre subito nel tuo browser, già pronta, con i dati salvati solo sul tuo dispositivo.
- I tuoi dati restano tuoi. Tutto è memorizzato in locale sul dispositivo, separato per ogni app: niente account, niente server, niente da configurare.
- Rendila tua. Il testo qui sotto è l'app per intero: copialo, cambia campi, viste e parole, e le modifiche diventano subito interattive.
- Condividila come file. Un'app è un semplice file: salvalo, esportalo o condividilo con un link; e con la collaborazione cifrata più persone modificano gli stessi dati in tempo reale.
Il testo dell'app
# 🏫 Orario scolastico e sostituzioni
L'orario si **genera da solo** dalle cattedre (con i vincoli veri: giorno
libero, ore a disposizione, tetto di ore al giorno) e le assenze diventano un
**piano sostituzioni** proposto per equità. Le griglie settimanali di classi e
docenti sono pronte da stampare. Tu controlli e adotti: l'app propone, la
persona decide. Tutto resta sul tuo dispositivo.
::::::page{title="Guida" icon="🧭"}
## Come funziona
:::::steps{}
1. **Dati di base** — inserisci docenti, classi e cattedre (o importale da Excel).
2. **Vincoli e priorità** — indisponibilità e ore a disposizione (rigide), più i desideri: niente ore buche, materie impegnative al mattino, materie distribuite sulla settimana.
3. **Generazione** — il generatore piazza tutte le ore rispettando i vincoli e inseguendo le priorità; rivedi la bozza e adottala.
4. **Moduli da stampare** — le griglie settimanali pronte, una per classe e una per docente.
5. **Assenze** — registri chi manca e quando.
6. **Sostituzioni** — il piano propone il supplente giusto per ogni ora scoperta; lo adotti nel registro.
:::::
:::::grid{cols="3"}
**Docenti**
:count[ndoc]{path="docenti"}
---
**Cattedre**
:count[ncat]{path="cattedre"}
---
**Ore in orario**
:count[nora]{path="orario"}
:::::
## Prova subito con dati di esempio
Una piccola scuola già pronta (12 docenti, 4 classi, 30 ore a settimana):
premi i tre pulsanti qui sotto, poi vai su **Generazione**.
:::::card{}
:::python{into="demo_docenti"}
```python
result = [
{"nome": "Rossi", "materie": "italiano, storia", "giorno_libero": "mer", "max_ore_giorno": ""},
{"nome": "Bianchi", "materie": "italiano, storia", "giorno_libero": "", "max_ore_giorno": ""},
{"nome": "Verdi", "materie": "italiano, storia", "giorno_libero": "lun", "max_ore_giorno": ""},
{"nome": "Ferrari", "materie": "matematica, scienze", "giorno_libero": "", "max_ore_giorno": ""},
{"nome": "Russo", "materie": "matematica, scienze", "giorno_libero": "ven", "max_ore_giorno": ""},
{"nome": "Esposito", "materie": "inglese", "giorno_libero": "", "max_ore_giorno": ""},
{"nome": "Colombo", "materie": "francese", "giorno_libero": "gio", "max_ore_giorno": ""},
{"nome": "Ricci", "materie": "tecnologia", "giorno_libero": "", "max_ore_giorno": ""},
{"nome": "Marino", "materie": "arte", "giorno_libero": "mar", "max_ore_giorno": ""},
{"nome": "Greco", "materie": "musica", "giorno_libero": "", "max_ore_giorno": ""},
{"nome": "Bruno", "materie": "motoria", "giorno_libero": "", "max_ore_giorno": ""},
{"nome": "Gallo", "materie": "religione", "giorno_libero": "lun", "max_ore_giorno": ""},
]
print(f"{len(result)} docenti di esempio pronti.")
```
:::
::adopt{from="demo_docenti" path="docenti" key="nome" label="1. Carica i docenti di esempio"}
:::python{into="demo_classi"}
```python
result = [{"nome": c} for c in ["1A", "1B", "2A", "2B"]]
print("4 classi di esempio pronte.")
```
:::
::adopt{from="demo_classi" path="classi" key="nome" label="2. Carica le classi di esempio"}
:::python{into="demo_cattedre"}
```python
piano = {
"italiano": (6, ["Rossi", "Bianchi", "Verdi", "Bianchi"]),
"storia": (4, ["Verdi", "Rossi", "Bianchi", "Verdi"]),
"matematica": (4, ["Ferrari", "Ferrari", "Russo", "Russo"]),
"scienze": (2, ["Ferrari", "Ferrari", "Russo", "Russo"]),
"inglese": (3, ["Esposito"] * 4),
"francese": (2, ["Colombo"] * 4),
"tecnologia": (2, ["Ricci"] * 4),
"arte": (2, ["Marino"] * 4),
"musica": (2, ["Greco"] * 4),
"motoria": (2, ["Bruno"] * 4),
"religione": (1, ["Gallo"] * 4),
}
classi = ["1A", "1B", "2A", "2B"]
result = []
for materia, (ore, prof) in piano.items():
for i, classe in enumerate(classi):
result.append({"docente": prof[i], "classe": classe, "materia": materia, "ore": str(ore)})
tot = sum(int(r["ore"]) for r in result) // len(classi)
print(f"{len(result)} cattedre di esempio pronte ({tot} ore a settimana per classe).")
```
:::
::adopt{from="demo_cattedre" path="cattedre" replace="true" label="3. Carica le cattedre di esempio"}
:::::
**Multi-utente.** Dal menu utenti in alto puoi attivare la sincronizzazione e
invitare la segreteria o i colleghi: stessa app, stessi dati, in tempo reale.
La **Griglia dati** (menu dati) è il foglio di lavoro per correzioni veloci.
::::::
::::::page{title="Dati di base" icon="📋"}
## Docenti
:::::grid{cols="2"}
::::form{id="fdoc"}
::input{form="fdoc" field="nome" legend="Nome e cognome" required="true"}
::input{form="fdoc" field="materie" legend="Materie" placeholder="italiano, storia"}
:::select{form="fdoc" field="giorno_libero" legend="Giorno libero (vuoto = nessuno)"}
- lun
- mar
- mer
- gio
- ven
- sab
:::
::input{form="fdoc" field="max_ore_giorno" legend="Max ore al giorno (vuoto = tutte)" type="number"}
::add-form{form="fdoc" path="docenti" label="Aggiungi docente"}
::::
---
:::table{path="docenti" headers="Docente,Materie,Giorno libero,Max ore/g" deletable="true" editform="fdoc"}
{nome} | {materie} | {giorno_libero} | {max_ore_giorno}
:::
::excel{path="docenti" key="nome" label="Importa docenti da Excel"}
:::::
## Classi
:::::grid{cols="2"}
::::form{id="fcla"}
::input{form="fcla" field="nome" legend="Classe" placeholder="1A" required="true"}
::add-form{form="fcla" path="classi" label="Aggiungi classe"}
::::
---
:::list{path="classi" deletable="true"}
{nome}
:::
:::::
## Cattedre (chi insegna cosa, a chi, per quante ore)
:::::grid{cols="2"}
::::form{id="fcat"}
::select-from{from="docenti" form="fcat" field="docente" label="nome" value="nome" legend="Docente"}
::select-from{from="classi" form="fcat" field="classe" label="nome" value="nome" legend="Classe"}
::input{form="fcat" field="materia" legend="Materia" required="true"}
::input{form="fcat" field="ore" legend="Ore a settimana" type="number" required="true"}
::input{form="fcat" field="blocco" legend="Ore consecutive (2 = laboratorio appaiato; vuoto = 1)" type="number"}
::add-form{form="fcat" path="cattedre" label="Aggiungi cattedra"}
::::
---
Totale ore da piazzare: :sum[totore]{path="cattedre" field="ore"}
::excel{path="cattedre" label="Importa cattedre da Excel"}
:::::
:::table{path="cattedre" headers="Docente,Classe,Materia,Ore,Consecutive" deletable="true" editform="fcat" pagesize="15"}
{docente} | {classe} | {materia} | {ore} | {blocco}
:::
::::::
::::::page{title="Vincoli e priorità" icon="🔒"}
I **vincoli rigidi** il generatore non li viola mai: se non riesce a
rispettarli tutti, lascia fuori delle ore e te lo dice. Le **priorità** sono
desideri: l'orario resta valido comunque, ma a parità di validità il
generatore sceglie la soluzione che le rispetta di più.
## Indisponibilità (vincolo rigido)
Le ore in cui un docente non è a scuola e non può ricevere lezioni: part-time,
incarichi esterni, altra sede. Diverse dalle ore a disposizione: qui il docente
non c'è, quindi non viene proposto nemmeno come supplente.
:::::grid{cols="2"}
::::form{id="find"}
::select-from{from="docenti" form="find" field="docente" label="nome" value="nome" legend="Docente"}
:::select{form="find" field="giorno" legend="Giorno"}
- lun
- mar
- mer
- gio
- ven
- sab
:::
::input{form="find" field="ora" legend="Ora (1, 2, 3...)" type="number" required="true"}
::input{form="find" field="motivo" legend="Motivo" placeholder="part-time, altra sede..."}
::add-form{form="find" path="indisponibilita" label="Aggiungi indisponibilità"}
::::
---
:::table{path="indisponibilita" headers="Docente,Giorno,Ora,Motivo" deletable="true" editform="find" pagesize="15"}
{docente} | {giorno} | {ora} | {motivo}
:::
::excel{path="indisponibilita" label="Importa da Excel"}
:::::
## Ore a disposizione (riservate alle supplenze)
Le ore in cui un docente è in sede, non insegna, e può coprire un'assenza. Il
generatore NON piazza lezioni lì; il piano sostituzioni le usa per prime.
:::::grid{cols="2"}
::::form{id="fdis"}
::select-from{from="docenti" form="fdis" field="docente" label="nome" value="nome" legend="Docente"}
:::select{form="fdis" field="giorno" legend="Giorno"}
- lun
- mar
- mer
- gio
- ven
- sab
:::
::input{form="fdis" field="ora" legend="Ora (1, 2, 3...)" type="number" required="true"}
::add-form{form="fdis" path="disposizioni" label="Aggiungi disposizione"}
::::
---
:::table{path="disposizioni" headers="Docente,Giorno,Ora" deletable="true" editform="fdis"}
{docente} | {giorno} | {ora}
:::
:::::
## Priorità: quanto contano (0 = ignora, 3 = tieni molto)
Vale l'ultima riga salvata. Vuoti = buche 2, fascia 1, distribuzione 2.
:::::grid{cols="2"}
::::form{id="fpes"}
::input{form="fpes" field="peso_buche" legend="Evita le ore buche dei docenti" type="number" placeholder="2" min="0" max="3"}
::input{form="fpes" field="peso_fascia" legend="Rispetta la fascia oraria delle materie" type="number" placeholder="1" min="0" max="3"}
::input{form="fpes" field="peso_distribuzione" legend="Distribuisci le materie sulla settimana" type="number" placeholder="2" min="0" max="3"}
::add-form{form="fpes" path="pesi" label="Salva le priorità"}
::::
---
:::list{path="pesi"}
buche: {peso_buche} — fascia: {peso_fascia} — distribuzione: {peso_distribuzione}
:::
:::::
## Fascia oraria preferita per materia (priorità)
Le materie impegnative nelle prime ore, quelle leggere in fondo. Conta solo se
il peso "fascia oraria" è maggiore di zero.
:::::grid{cols="2"}
::::form{id="fpre"}
::input{form="fpre" field="materia" legend="Materia" required="true" placeholder="matematica"}
:::select{form="fpre" field="fascia" legend="Preferisci"}
- prime ore
- ultime ore
:::
::add-form{form="fpre" path="preferenze" label="Aggiungi preferenza"}
::::
---
:::table{path="preferenze" headers="Materia,Fascia" deletable="true" editform="fpre"}
{materia} | {fascia}
:::
:::::
::::::
::::::page{title="Generazione" icon="⚙️"}
## Impostazioni
Salva una riga di impostazioni (vale l'ultima). Vuote = da lunedì a venerdì,
6 ore al giorno, max 2 ore della stessa materia al giorno.
:::::grid{cols="2"}
::::form{id="fimp"}
::input{form="fimp" field="giorni" legend="Giorni di lezione" placeholder="lun,mar,mer,gio,ven"}
::input{form="fimp" field="ore_giorno" legend="Ore al giorno" type="number" placeholder="6"}
::input{form="fimp" field="max_ore_materia_giorno" legend="Max ore stessa materia al giorno" type="number" placeholder="2"}
::add-form{form="fimp" path="impostazioni" label="Salva impostazioni"}
::::
---
:::list{path="impostazioni"}
giorni: {giorni} — ore/giorno: {ore_giorno} — max materia/giorno: {max_ore_materia_giorno}
:::
:::::
## Il generatore
Piazza ogni ora di cattedra rispettando i **vincoli rigidi** — mai un docente in
due classi alla stessa ora, una lezione per classe per ora, giorno libero,
indisponibilità, ore a disposizione intoccabili, tetto di ore al giorno, max ore
della stessa materia al giorno, blocchi di ore consecutive — e inseguendo le
**priorità** che hai scelto. Si rigenera da solo quando cambi cattedre, docenti,
vincoli o impostazioni; se qualche ora non entra, te lo dice esplicitamente, e
in fondo trovi un indice di qualità (ore buche). La bozza NON tocca l'orario
ufficiale finché non la adotti col pulsante.
:::python{data="cattedre,docenti,impostazioni,disposizioni,indisponibilita,preferenze,pesi" into="orario_bozza"}
```python
# Generatore d'orario: ricerca locale min-conflicts con vincoli RIGIDI e
# preferenze SOFT pesate. Un'"unità" è un blocco di 1 o 2 ore consecutive
# della stessa cattedra; si parte da un'assegnazione golosa e si riparano i
# conflitti spostando le unità nello slot di costo minimo.
# RIGIDI (peso HARD, non negoziabili): docente mai in due classi alla stessa
# ora, una lezione per classe per ora, giorno libero, indisponibilità, ore a
# disposizione, max ore materia/giorno per classe, tetto ore/giorno docente.
# SOFT (pesi 0-3 scelti dall'utente): ore buche del docente, fascia oraria
# preferita per materia, distribuzione della materia sulla settimana.
import random
GIORNI_TUTTI = ["lun", "mar", "mer", "gio", "ven", "sab"]
HARD = 1000
def _num(v, dflt):
try:
return int(float(str(v).replace(",", ".")))
except (ValueError, TypeError):
return dflt
def _ultima(coll):
righe = data.get(coll) or []
return righe[-1] if righe else {}
imp = _ultima("impostazioni")
giorni = [g.strip().lower()[:3] for g in str(imp.get("giorni", "") or "lun,mar,mer,gio,ven").split(",")]
giorni = [g for g in giorni if g in GIORNI_TUTTI] or GIORNI_TUTTI[:5]
ore_giorno = max(1, min(10, _num(imp.get("ore_giorno"), 6)))
max_materia = max(1, _num(imp.get("max_ore_materia_giorno"), 2))
# Pesi delle preferenze (0 = ignora, 3 = tieni molto).
pesi = _ultima("pesi")
def _peso(campo, dflt):
return max(0, min(3, _num(pesi.get(campo), dflt)))
W_BUCHE = _peso("peso_buche", 2)
W_FASCIA = _peso("peso_fascia", 1)
W_DISTR = _peso("peso_distribuzione", 2)
# Fascia preferita per materia: "prime" (o "mattino") / "ultime".
pref_fascia = {}
for r in data.get("preferenze", []):
m = str(r.get("materia", "")).strip().lower()
f = str(r.get("fascia", "")).strip().lower()
if m and f:
pref_fascia[m] = "prime" if f.startswith(("prim", "matt")) else "ultime"
docenti = {}
for d in data.get("docenti", []):
nome = str(d.get("nome", "")).strip()
if nome:
docenti[nome] = d
def giorno_libero(doc):
return str(docenti.get(doc, {}).get("giorno_libero", "")).strip().lower()[:3]
def max_doc(doc):
n = _num(docenti.get(doc, {}).get("max_ore_giorno"), 0)
return n if n > 0 else ore_giorno
# Slot in cui il docente NON può avere lezione: indisponibilità dichiarate
# (part-time, incarichi) + ore a disposizione (riservate alle supplenze).
vietati = set()
for coll in ("indisponibilita", "disposizioni"):
for r in data.get(coll, []):
doc = str(r.get("docente", "")).strip()
g = str(r.get("giorno", "")).strip().lower()[:3]
o = _num(r.get("ora"), 0)
if doc and g and o:
vietati.add((doc, g, o))
catt = []
for c in data.get("cattedre", []):
doc = str(c.get("docente", "")).strip()
cla = str(c.get("classe", "")).strip()
mat = str(c.get("materia", "")).strip()
n = _num(c.get("ore"), 0)
blocco = max(1, min(_num(c.get("blocco"), 1), ore_giorno))
if doc and cla and mat and n > 0:
catt.append((doc, cla, mat, min(n, len(giorni) * ore_giorno), blocco))
# Un'unità = un blocco di `durata` ore consecutive della stessa cattedra.
unita = []
for doc, cla, mat, n, blocco in catt:
nb = n // blocco if blocco > 1 else 0
for _ in range(nb):
unita.append((doc, cla, mat, blocco))
for _ in range(n - nb * blocco):
unita.append((doc, cla, mat, 1))
result = []
if not unita:
print("Inserisci prima le cattedre (pagina Dati di base).")
else:
N = len(unita)
# Slot ammissibili per unità: il blocco deve stare nel giorno e ogni sua
# ora deve essere libera dai vincoli STATICI (giorno libero, vietati).
ammessi = []
for doc, cla, mat, dur in unita:
gl = giorno_libero(doc)
ok = []
for g in giorni:
if g == gl:
continue
for o in range(1, ore_giorno - dur + 2):
if all((doc, g, h) not in vietati for h in range(o, o + dur)):
ok.append((g, o))
ammessi.append(ok or [(giorni[0], 1)])
def buche(ore):
if not ore:
return 0
return (max(ore) - min(ore) + 1) - len(ore)
def risolvi(seed, iterazioni):
rng = random.Random(seed)
pos = [None] * N
occ_doc, occ_cla, cnt_mat, cnt_dg = {}, {}, {}, {}
ore_dg = {} # (doc, giorno) -> {ora: quante volte}
def metti(i, s):
doc, cla, mat, dur = unita[i]
g, o = s
pos[i] = s
ore = ore_dg.setdefault((doc, g), {})
for h in range(o, o + dur):
occ_doc[(doc, g, h)] = occ_doc.get((doc, g, h), 0) + 1
occ_cla[(cla, g, h)] = occ_cla.get((cla, g, h), 0) + 1
ore[h] = ore.get(h, 0) + 1
cnt_mat[(cla, mat, g)] = cnt_mat.get((cla, mat, g), 0) + dur
cnt_dg[(doc, g)] = cnt_dg.get((doc, g), 0) + dur
def togli(i):
doc, cla, mat, dur = unita[i]
g, o = pos[i]
ore = ore_dg.get((doc, g), {})
for h in range(o, o + dur):
occ_doc[(doc, g, h)] -= 1
occ_cla[(cla, g, h)] -= 1
if ore.get(h, 0) <= 1:
ore.pop(h, None)
else:
ore[h] -= 1
cnt_mat[(cla, mat, g)] -= dur
cnt_dg[(doc, g)] -= dur
pos[i] = None
def costo_rigido(i, s):
doc, cla, mat, dur = unita[i]
g, o = s
v = 0
for h in range(o, o + dur):
v += occ_doc.get((doc, g, h), 0) + occ_cla.get((cla, g, h), 0)
if cnt_mat.get((cla, mat, g), 0) + dur > max_materia:
v += 1
if cnt_dg.get((doc, g), 0) + dur > max_doc(doc):
v += 1
return v
def costo_soft(i, s):
doc, cla, mat, dur = unita[i]
g, o = s
v = 0
if W_DISTR:
# ogni ora già presente della stessa materia quel giorno pesa:
# spinge a distribuire la materia sulla settimana
v += W_DISTR * cnt_mat.get((cla, mat, g), 0)
if W_FASCIA:
f = pref_fascia.get(mat.lower())
if f == "prime":
v += W_FASCIA * sum(h - 1 for h in range(o, o + dur))
elif f == "ultime":
v += W_FASCIA * sum(ore_giorno - h for h in range(o, o + dur))
if W_BUCHE:
ore = ore_dg.get((doc, g)) or {}
prima = buche(ore)
dopo = buche(set(ore) | set(range(o, o + dur)))
v += W_BUCHE * max(0, dopo - prima)
return v
def costo(i, s):
r = costo_rigido(i, s)
return HARD * r + (costo_soft(i, s) if r == 0 else 0)
def sposta(i):
if pos[i] is not None:
togli(i)
cands = ammessi[i]
migliore, cmin = None, None
base = rng.randrange(len(cands))
for k in range(len(cands)):
s = cands[(base + k) % len(cands)]
c = costo(i, s)
if cmin is None or c < cmin:
migliore, cmin = s, c
if c == 0:
break
metti(i, migliore)
return cmin
def limatura(passate):
# Ottimizzazione delle sole preferenze SOFT a orario valido: sposta
# un'unità solo se il costo cala davvero (hill climbing puro, quindi
# i vincoli rigidi restano a zero e la qualità non peggiora mai).
for _ in range(passate):
mosse = 0
ordine = list(range(N))
rng.shuffle(ordine)
for i in ordine:
attuale = pos[i]
togli(i)
c_att = costo(i, attuale)
if c_att == 0:
metti(i, attuale)
continue
scelto, c_best = attuale, c_att
for s in ammessi[i]:
c = costo(i, s)
if c < c_best:
scelto, c_best = s, c
metti(i, scelto)
if c_best < c_att:
mosse += 1
if not mosse:
break
ordine = sorted(range(N), key=lambda i: (len(ammessi[i]), rng.random()))
for i in ordine:
sposta(i)
for it in range(iterazioni):
confl = []
for i in range(N):
s = pos[i]
togli(i)
c = costo_rigido(i, s)
metti(i, s)
if c > 0:
confl.append(i)
if not confl:
break
rng.shuffle(confl)
for i in confl:
if rng.random() < 0.08:
togli(i)
metti(i, rng.choice(ammessi[i]))
else:
sposta(i)
if it % 12 == 11:
for i in rng.sample(range(N), max(1, N // 25)):
sposta(i)
# Orario valido: lima le preferenze (se ne è stata chiesta almeno una).
if W_BUCHE or W_FASCIA or W_DISTR:
limatura(12)
fuori = []
for i in range(N):
s = pos[i]
togli(i)
if costo_rigido(i, s) > 0:
fuori.append(i)
else:
metti(i, s)
tot_buche = sum(buche(set(o)) for o in ore_dg.values())
return pos, fuori, tot_buche
migliore = None
for seed in (7, 23, 51):
pos, fuori, tb = risolvi(seed, 140)
if migliore is None or (len(fuori), tb) < (len(migliore[1]), migliore[2]):
migliore = (pos, fuori, tb)
if not fuori and tb == 0:
break
pos, fuori, tot_buche = migliore
ordg = {g: k for k, g in enumerate(GIORNI_TUTTI)}
righe = []
appaiate_ok, appaiate_tot = 0, 0
for i in range(N):
if pos[i] is None:
continue
doc, cla, mat, dur = unita[i]
g, o = pos[i]
if dur > 1:
appaiate_tot += 1
appaiate_ok += 1 # per costruzione il blocco è consecutivo
for h in range(o, o + dur):
righe.append({"classe": cla, "giorno": g, "ora": str(h), "materia": mat, "docente": doc})
righe.sort(key=lambda r: (r["classe"], ordg.get(r["giorno"], 9), int(r["ora"])))
result = righe
print(f"Orario generato: {len(righe)} ore, "
f"{len({r['classe'] for r in righe})} classi, {len({r['docente'] for r in righe})} docenti.")
if fuori:
mancanti = {}
for i in fuori:
doc, cla, mat, dur = unita[i]
k = f"{cla} · {mat} · {doc}"
mancanti[k] = mancanti.get(k, 0) + dur
print("ATTENZIONE — ore non piazzabili coi vincoli rigidi attuali:")
for k, n in sorted(mancanti.items()):
print(f" - {k}: {n} ora/e")
print("Allenta i vincoli (ore al giorno, giorno libero, indisponibilità) e rigenera.")
else:
print("Nessuna violazione dei vincoli rigidi.")
print(f"Qualità: {tot_buche} ore buche dei docenti"
+ (f", {appaiate_ok}/{appaiate_tot} blocchi di ore consecutive rispettati" if appaiate_tot else ""))
if not fuori:
print("Controlla la bozza e adottala col pulsante qui sotto.")
```
:::
## Bozza proposta
:::table{path="orario_bozza" headers="Classe,Giorno,Ora,Materia,Docente" pagesize="15"}
{classe} | {giorno} | {ora} | {materia} | {docente}
:::
::adopt{from="orario_bozza" path="orario" replace="true" label="Adotta questo orario"}
Adottando, la bozza SOSTITUISCE l'orario ufficiale (quello delle pagine Orario
e Sostituzioni). Ritocchi puntuali dopo l'adozione: menu dati → Griglia dati.
::::::
::::::page{title="Orario" icon="🗓️"}
## Orario per classe
:::group-by{path="orario" field="classe" as="table" headers="Giorno,Ora,Materia,Docente"}
{giorno} | {ora} | {materia} | {docente}
:::
## Orario per docente
:::group-by{path="orario" field="docente" as="table" headers="Giorno,Ora,Classe,Materia"}
{giorno} | {ora} | {classe} | {materia}
:::
## Esplora (tabella pivot)
Trascina i campi per costruire la griglia che vuoi (es. righe = ora, colonne =
giorno) e filtra per classe o docente.
:::explore{path="orario" group-by="ora" split-by="giorno" height="28rem"}
:::
::::::
::::::page{title="Moduli da stampare" icon="🖨️"}
Le griglie settimanali pronte da appendere e distribuire: una per ogni classe
(da consegnare agli studenti) e una per ogni docente. Si aggiornano da sole
quando l'orario cambia. Il pulsante di stampa apre la finestra del browser, da
cui si salva anche in PDF.
## Orario delle classi (per studenti e famiglie)
:::python{data="orario,impostazioni" into="griglia_classi"}
```python
# Trasforma l'orario in una griglia settimanale per classe: una riga per ora,
# una colonna per giorno. La cella porta materia e docente.
GIORNI = ["lun", "mar", "mer", "gio", "ven", "sab"]
ETICH = {"lun": "lun", "mar": "mar", "mer": "mer", "gio": "gio", "ven": "ven", "sab": "sab"}
imp = (data.get("impostazioni") or [{}])[-1] if data.get("impostazioni") else {}
try:
ore_giorno = max(1, min(10, int(float(str(imp.get("ore_giorno", "6") or "6")))))
except ValueError:
ore_giorno = 6
celle, ore_viste = {}, set()
for r in data.get("orario", []):
cla = str(r.get("classe", "")).strip()
g = str(r.get("giorno", "")).strip().lower()[:3]
try:
o = int(float(str(r.get("ora", "0"))))
except ValueError:
continue
if not cla or g not in GIORNI or o < 1:
continue
mat = str(r.get("materia", "")).strip()
doc = str(r.get("docente", "")).strip()
celle[(cla, o, g)] = f"{mat} · {doc}" if doc else mat
ore_viste.add(o)
result = []
if celle:
ultima = max(max(ore_viste), ore_giorno)
for cla in sorted({k[0] for k in celle}):
for o in range(1, ultima + 1):
riga = {"classe": cla, "ora": f"{o}ª"}
for g in GIORNI:
riga[ETICH[g]] = celle.get((cla, o, g), "")
result.append(riga)
print(f"Griglie pronte per {len({k[0] for k in celle})} classi.")
else:
print("Genera e adotta prima l'orario (pagina Generazione).")
```
:::
:::::card{id="stampa-classi"}
:::group-by{path="griglia_classi" field="classe" as="table" headers="Ora,Lunedì,Martedì,Mercoledì,Giovedì,Venerdì,Sabato" stamps="false" search="false"}
{ora} | {lun} | {mar} | {mer} | {gio} | {ven} | {sab}
:::
:::::
::print{target="stampa-classi" label="Stampa l'orario delle classi"}
## Orario dei docenti
:::python{data="orario,impostazioni" into="griglia_docenti"}
```python
# Come sopra, ma per docente: la cella porta classe e materia. Le ore vuote
# sono le ore libere del docente.
GIORNI = ["lun", "mar", "mer", "gio", "ven", "sab"]
imp = (data.get("impostazioni") or [{}])[-1] if data.get("impostazioni") else {}
try:
ore_giorno = max(1, min(10, int(float(str(imp.get("ore_giorno", "6") or "6")))))
except ValueError:
ore_giorno = 6
celle, ore_viste = {}, set()
for r in data.get("orario", []):
doc = str(r.get("docente", "")).strip()
g = str(r.get("giorno", "")).strip().lower()[:3]
try:
o = int(float(str(r.get("ora", "0"))))
except ValueError:
continue
if not doc or g not in GIORNI or o < 1:
continue
cla = str(r.get("classe", "")).strip()
mat = str(r.get("materia", "")).strip()
celle[(doc, o, g)] = f"{cla} · {mat}" if mat else cla
ore_viste.add(o)
result = []
if celle:
ultima = max(max(ore_viste), ore_giorno)
for doc in sorted({k[0] for k in celle}):
for o in range(1, ultima + 1):
riga = {"docente": doc, "ora": f"{o}ª"}
for g in GIORNI:
riga[g] = celle.get((doc, o, g), "")
result.append(riga)
print(f"Griglie pronte per {len({k[0] for k in celle})} docenti.")
else:
print("Genera e adotta prima l'orario (pagina Generazione).")
```
:::
:::::card{id="stampa-docenti"}
:::group-by{path="griglia_docenti" field="docente" as="table" headers="Ora,Lunedì,Martedì,Mercoledì,Giovedì,Venerdì,Sabato" stamps="false" search="false"}
{ora} | {lun} | {mar} | {mer} | {gio} | {ven} | {sab}
:::
:::::
::print{target="stampa-docenti" label="Stampa l'orario dei docenti"}
::::::
::::::page{title="Assenze" icon="🤒"}
## Registra un'assenza
:::::grid{cols="2"}
::::form{id="fass"}
::select-from{from="docenti" form="fass" field="docente" label="nome" value="nome" legend="Docente assente"}
::input{form="fass" field="data" legend="Data" type="date" required="true"}
::input{form="fass" field="ore" legend="Ore (es. 1,2 o 2-4; vuoto = tutto il giorno)"}
::input{form="fass" field="motivo" legend="Motivo" placeholder="malattia, permesso..."}
::add-form{form="fass" path="assenze" label="Registra assenza"}
::::
---
:::table{path="assenze" headers="Docente,Data,Ore,Motivo" deletable="true" editform="fass"}
{docente} | {data} | {ore} | {motivo}
:::
:::::
## Calendario assenze
:::calendar{path="assenze" field="data" view="week" by="docente"}
{docente} — ore {ore} ({motivo})
:::
::::::
::::::page{title="Sostituzioni" icon="🔄"}
## Piano sostituzioni proposto
Per ogni ora scoperta dalle assenze, il piano propone il supplente giusto:
prima chi è **a disposizione** in quell'ora, poi chi è in sede con l'**ora
libera** — mai chi è assente, sta insegnando o ha il giorno libero. A parità,
chi ha fatto meno sostituzioni (equità). Le ore già coperte nel registro
spariscono da sole dal piano.
:::python{data="assenze,orario,docenti,disposizioni,indisponibilita,sostituzioni" into="piano_sost"}
```python
# Piano sostituzioni: propone il supplente per ogni ora scoperta.
import datetime
GIORNI = ["lun", "mar", "mer", "gio", "ven", "sab", "dom"]
def _n(v, d=0):
try:
return int(float(str(v).replace(",", ".")))
except (ValueError, TypeError):
return d
def ore_di(t):
# "1,3" / "2-4" / vuoto o "tutte" = l'intera giornata
t = str(t or "").strip().lower()
if not t or t in ("tutte", "tutto", "giornata"):
return None
out = set()
for p in t.replace(";", ",").split(","):
p = p.strip()
if "-" in p:
a, _, b = p.partition("-")
if _n(a) and _n(b):
out.update(range(_n(a), _n(b) + 1))
elif _n(p):
out.add(_n(p))
return out or None
docs = {str(d.get("nome", "")).strip(): d for d in data.get("docenti", []) if str(d.get("nome", "")).strip()}
gl = {n: str(d.get("giorno_libero", "")).strip().lower()[:3] for n, d in docs.items()}
lez_doc, occupato, in_sede = {}, set(), set()
for r in data.get("orario", []):
doc = str(r.get("docente", "")).strip()
g = str(r.get("giorno", "")).strip().lower()[:3]
o = _n(r.get("ora"))
if doc and g and o:
lez_doc.setdefault((doc, g), []).append(r)
occupato.add((doc, g, o))
in_sede.add((doc, g))
# Indisponibilità: il docente NON è a scuola, quindi non è nemmeno supplente.
nonc = set()
for r in data.get("indisponibilita", []):
doc = str(r.get("docente", "")).strip()
g = str(r.get("giorno", "")).strip().lower()[:3]
o = _n(r.get("ora"))
if doc and g and o:
nonc.add((doc, g, o))
adisp = {}
for r in data.get("disposizioni", []):
doc = str(r.get("docente", "")).strip()
g = str(r.get("giorno", "")).strip().lower()[:3]
o = _n(r.get("ora"))
if doc and g and o:
adisp.setdefault((g, o), set()).add(doc)
in_sede.add((doc, g))
fatte, coperte = {}, set()
for r in data.get("sostituzioni", []):
s = str(r.get("sostituto", "")).strip()
if s:
fatte[s] = fatte.get(s, 0) + 1
coperte.add((str(r.get("data", "")).strip(), _n(r.get("ora")), str(r.get("classe", "")).strip()))
scoperte, assenti_dt = [], {}
for a in data.get("assenze", []):
doc = str(a.get("docente", "")).strip()
dt = str(a.get("data", "")).strip()
if not doc or not dt:
continue
assenti_dt.setdefault(dt, set()).add(doc)
try:
g = GIORNI[datetime.date.fromisoformat(dt).weekday()]
except ValueError:
continue
ore = ore_di(a.get("ore"))
for lez in lez_doc.get((doc, g), []):
o = _n(lez.get("ora"))
if ore is not None and o not in ore:
continue
if (dt, o, str(lez.get("classe", "")).strip()) in coperte:
continue
scoperte.append((dt, g, o, lez))
scoperte.sort(key=lambda s: (s[0], s[2]))
result, proposti = [], {}
for dt, g, o, lez in scoperte:
assenti = assenti_dt.get(dt, set())
usati = proposti.setdefault((dt, o), set())
cands = []
for nome in docs:
if nome in assenti or nome in usati or gl.get(nome) == g:
continue
if (nome, g, o) in occupato or (nome, g, o) in nonc:
continue
if (nome, g) not in in_sede:
continue
a_disp = nome in adisp.get((g, o), set())
cands.append((0 if a_disp else 1, fatte.get(nome, 0), nome,
"a disposizione" if a_disp else "ora libera"))
cands.sort()
sost, crit = "", "NESSUNO"
if cands:
_, _, sost, crit = cands[0]
usati.add(sost)
fatte[sost] = fatte.get(sost, 0) + 1
result.append({
"data": dt, "giorno": g, "ora": str(o),
"classe": str(lez.get("classe", "")).strip(),
"materia": str(lez.get("materia", "")).strip(),
"assente": str(lez.get("docente", "")).strip(),
"sostituto": sost, "criterio": crit,
"alternative": ", ".join(n for _, _, n, _ in cands[1:4]),
})
if result:
senza = sum(1 for r in result if not r["sostituto"])
print(f"Ore da coprire: {len(result)}" + (f" — {senza} SENZA candidato." if senza else ", tutte con un candidato."))
print("Rivedi il piano e adottalo: le righe entrano nel registro sostituzioni.")
else:
print("Nessuna ora scoperta: niente assenze, o sono già tutte coperte.")
```
:::
:::::card{id="piano-oggi"}
:::table{path="piano_sost" headers="Data,Ora,Classe,Materia,Assente,Sostituto,Criterio,Alternative" mark="criterio=NESSUNO:text-error font-bold" pagesize="20"}
{data} | {ora} | {classe} | {materia} | {assente} | {sostituto} | {criterio} | {alternative}
:::
:::::
::adopt{from="piano_sost" path="sostituzioni" label="Adotta il piano nel registro"}
::print{target="piano-oggi" label="Stampa il piano"}
## Registro sostituzioni
Il registro ufficiale: si riempie adottando il piano, oppure a mano (o con la
matita per correggere una proposta adottata, ad esempio cambiando il supplente).
:::::grid{cols="2"}
::::form{id="fsos"}
::input{form="fsos" field="data" legend="Data" type="date" required="true"}
::input{form="fsos" field="ora" legend="Ora" type="number" required="true"}
::select-from{from="classi" form="fsos" field="classe" label="nome" value="nome" legend="Classe"}
::select-from{from="docenti" form="fsos" field="assente" label="nome" value="nome" legend="Docente assente"}
::select-from{from="docenti" form="fsos" field="sostituto" label="nome" value="nome" legend="Sostituto"}
::input{form="fsos" field="criterio" legend="Nota (a disposizione, ora libera, pagata...)"}
::add-form{form="fsos" path="sostituzioni" label="Registra sostituzione"}
::::
---
Sostituzioni registrate: :count[nsos]{path="sostituzioni"}
Assenze registrate: :count[nass]{path="assenze"}
:::::
:::table{path="sostituzioni" headers="Data,Ora,Classe,Assente,Sostituto,Nota" deletable="true" editform="fsos" pagesize="15"}
{data} | {ora} | {classe} | {assente} | {sostituto} | {criterio}
:::
::::::
::::::page{title="Statistiche" icon="📊"}
## Il quadro per docente
Ore di lezione in orario, sostituzioni fatte e ore di assenza: il colpo
d'occhio per distribuire le supplenze con equità.
:::python{data="orario,sostituzioni,assenze,docenti" into="stat_docenti"}
```python
ore_lez, sost_fatte, ore_ass = {}, {}, {}
for r in data.get("orario", []):
d = str(r.get("docente", "")).strip()
if d:
ore_lez[d] = ore_lez.get(d, 0) + 1
for r in data.get("sostituzioni", []):
d = str(r.get("sostituto", "")).strip()
if d:
sost_fatte[d] = sost_fatte.get(d, 0) + 1
for r in data.get("assenze", []):
d = str(r.get("docente", "")).strip()
t = str(r.get("ore", "")).strip()
n = len([p for p in t.replace(";", ",").split(",") if p.strip()]) if t else 6
if "-" in t:
try:
a, b = t.split("-")[:2]
n = max(n, int(b) - int(a) + 1)
except ValueError:
pass
if d:
ore_ass[d] = ore_ass.get(d, 0) + n
nomi = sorted({str(d.get("nome", "")).strip() for d in data.get("docenti", [])} - {""})
result = [{"docente": n, "ore_lezione": str(ore_lez.get(n, 0)),
"sostituzioni_fatte": str(sost_fatte.get(n, 0)),
"ore_assenza": str(ore_ass.get(n, 0))} for n in nomi]
print(f"{len(result)} docenti in quadro.")
```
:::
::chart-bar{data="stat_docenti" x="docente" y="sostituzioni_fatte" title="Sostituzioni fatte (equità)"}
::chart-bar{data="stat_docenti" x="docente" y="ore_assenza" title="Ore di assenza"}
:::table{path="stat_docenti" headers="Docente,Ore lezione,Sostituzioni fatte,Ore assenza"}
{docente} | {ore_lezione} | {sostituzioni_fatte} | {ore_assenza}
:::
::::::