420 lines
19 KiB
Python
420 lines
19 KiB
Python
"""
|
||
Beheer › Database › Toestellen: de tabel Toestellen aanvullen vanuit een Excel-bestand.
|
||
|
||
Werkwijze (twee stappen, zodat er niets in de database komt zonder dat je het gezien hebt):
|
||
1. lees_excel() : het geüploade bestand → ruwe rijen (enkel tekst / ISO-datums)
|
||
2. maak_plan() : ruwe rijen → per rij 'nieuw', 'bijwerken', 'ongewijzigd' of 'fout' (niets wordt bewaard)
|
||
3. voer_uit() : het plan in één transactie uitvoeren (het plan wordt opnieuw gemaakt, nooit blind vertrouwd)
|
||
|
||
Een toestel wordt herkend aan het toestelnummer (Schooleigen_ID) en anders aan het serienummer.
|
||
Er wordt nooit iets verwijderd. Lege cellen wissen nooit een bestaande waarde.
|
||
"""
|
||
import io
|
||
from datetime import date, datetime
|
||
|
||
from database import execute_query, get_db_connection
|
||
from services import toestellen as tst
|
||
|
||
STATUSSEN = ('Beschikbaar', 'Uitgeleend', 'Defect')
|
||
MAX_RIJEN = 5000
|
||
|
||
# (sleutel, kolomkop in het sjabloon, max. lengte, uitleg, andere namen die ook herkend worden)
|
||
KOLOMMEN = [
|
||
('schooleigen_id', 'Toestelnummer', 50,
|
||
"Het eigen toestelnummer van de school (Schooleigen_ID). Uniek. Toestelnummer of serienummer is verplicht.",
|
||
('schooleigen_id', 'schooleigen id', 'kamxm_nummer', 'kamxm nummer', 'kamxm', 'nummer', 'toestel')),
|
||
('serienummer', 'Serienummer', 100,
|
||
"Serienummer van de fabrikant (nodig om het toestel in Google te blokkeren). "
|
||
"Toestelnummer of serienummer is verplicht.",
|
||
('serial', 'serial number', 'serienr', 'serie')),
|
||
('type', 'Type', 100, "Model van het toestel, bv. Lenovo 300e Gen 4.", ('model', 'toesteltype')),
|
||
('status', 'Status', None,
|
||
"Beschikbaar, Uitgeleend of Defect. Leeg bij een nieuw toestel = Beschikbaar.", ()),
|
||
('afschrijfdatum', 'Afschrijfdatum', None,
|
||
"Datum (dd-mm-jjjj) of een Excel-datum. Mag leeg.", ('afschrijving', 'afschrijf datum')),
|
||
('lokaal', 'Lokaal', None,
|
||
"Naam van een lokaal dat al in PIVOT bestaat (zie het tabblad Lokalen). Mag leeg.", ('lokaalnaam', 'lokaal_id')),
|
||
]
|
||
SLEUTELS = [k[0] for k in KOLOMMEN]
|
||
KOPPEN = {k[0]: k[1] for k in KOLOMMEN}
|
||
|
||
# Databasekolom per sleutel (lokaal → Lokaal_ID wordt apart opgezocht)
|
||
DB_KOLOM = {'schooleigen_id': 'Schooleigen_ID', 'serienummer': 'Serienummer', 'type': 'Type',
|
||
'status': 'Status', 'afschrijfdatum': 'Afschrijfdatum', 'lokaal': 'Lokaal_ID'}
|
||
|
||
|
||
class ImportFout(Exception):
|
||
"""Het bestand zelf kan niet gelezen worden (geen rijfout)."""
|
||
|
||
|
||
def _norm(tekst):
|
||
return ' '.join(str(tekst or '').strip().lower().replace('_', ' ').replace('-', ' ').split())
|
||
|
||
|
||
_HERKEN = {}
|
||
for _k, _kop, _m, _u, _alias in KOLOMMEN:
|
||
for _naam in (_k, _kop) + tuple(_alias):
|
||
_HERKEN[_norm(_naam)] = _k
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Sjabloon
|
||
# ---------------------------------------------------------------------------
|
||
def lokalen():
|
||
"""Alle lokalen van de huidige school: [{'Lokaal_ID', 'Naam', 'Campus'}]."""
|
||
return execute_query(
|
||
"SELECT l.Lokaal_ID, l.Naam, c.Campus_naam AS Campus FROM Lokalen l "
|
||
"LEFT JOIN Campus c ON c.Campus_ID = l.Campus_ID ORDER BY l.Naam") or []
|
||
|
||
|
||
def maak_sjabloon(schoolnaam=''):
|
||
"""Excel-sjabloon (bytes): tabblad Toestellen (in te vullen), Uitleg en Lokalen."""
|
||
from openpyxl import Workbook
|
||
from openpyxl.styles import Font, PatternFill, Alignment
|
||
from openpyxl.worksheet.datavalidation import DataValidation
|
||
|
||
wb = Workbook()
|
||
ws = wb.active
|
||
ws.title = 'Toestellen'
|
||
ws.append([k[1] for k in KOLOMMEN])
|
||
kop_vulling = PatternFill('solid', fgColor='1E6FA5')
|
||
for c in ws[1]:
|
||
c.font = Font(bold=True, color='FFFFFF')
|
||
c.fill = kop_vulling
|
||
c.alignment = Alignment(vertical='center')
|
||
breedtes = {'Toestelnummer': 16, 'Serienummer': 20, 'Type': 26, 'Status': 14, 'Afschrijfdatum': 16, 'Lokaal': 18}
|
||
for i, k in enumerate(KOLOMMEN):
|
||
letter = ws.cell(row=1, column=i + 1).column_letter
|
||
ws.column_dimensions[letter].width = breedtes.get(k[1], 16)
|
||
ws.freeze_panes = 'A2'
|
||
ws.row_dimensions[1].height = 22
|
||
|
||
kol = {k[0]: ws.cell(row=1, column=i + 1).column_letter for i, k in enumerate(KOLOMMEN)}
|
||
# Nummers als tekst (anders maakt Excel van 00123 → 123)
|
||
for sleutel in ('schooleigen_id', 'serienummer'):
|
||
for r in range(2, MAX_RIJEN + 2):
|
||
ws[f"{kol[sleutel]}{r}"].number_format = '@'
|
||
for r in range(2, MAX_RIJEN + 2):
|
||
ws[f"{kol['afschrijfdatum']}{r}"].number_format = 'DD-MM-YYYY'
|
||
|
||
dv_status = DataValidation(type='list', formula1='"' + ','.join(STATUSSEN) + '"', allow_blank=True,
|
||
showErrorMessage=True, errorTitle='Status',
|
||
error='Kies Beschikbaar, Uitgeleend of Defect (of laat leeg).')
|
||
dv_status.add(f"{kol['status']}2:{kol['status']}{MAX_RIJEN + 1}")
|
||
ws.add_data_validation(dv_status)
|
||
dv_datum = DataValidation(type='date', operator='greaterThan', formula1='1', allow_blank=True,
|
||
showErrorMessage=True, errorTitle='Afschrijfdatum',
|
||
error='Vul een datum in, bv. 31-08-2030.')
|
||
dv_datum.add(f"{kol['afschrijfdatum']}2:{kol['afschrijfdatum']}{MAX_RIJEN + 1}")
|
||
ws.add_data_validation(dv_datum)
|
||
|
||
# Lokalen van deze school (keuzelijst in de kolom Lokaal)
|
||
wl = wb.create_sheet('Lokalen')
|
||
wl.append(['Lokaal', 'Campus'])
|
||
for c in wl[1]:
|
||
c.font = Font(bold=True)
|
||
lijst = []
|
||
try:
|
||
lijst = lokalen()
|
||
except Exception:
|
||
pass
|
||
for l in lijst:
|
||
wl.append([l['Naam'], l.get('Campus') or ''])
|
||
wl.column_dimensions['A'].width = 24
|
||
wl.column_dimensions['B'].width = 28
|
||
if lijst:
|
||
dv_lokaal = DataValidation(type='list', formula1=f"=Lokalen!$A$2:$A${len(lijst) + 1}", allow_blank=True,
|
||
showErrorMessage=True, errorTitle='Lokaal',
|
||
error='Kies een lokaal uit de lijst (tabblad Lokalen) of laat leeg.')
|
||
dv_lokaal.add(f"{kol['lokaal']}2:{kol['lokaal']}{MAX_RIJEN + 1}")
|
||
ws.add_data_validation(dv_lokaal)
|
||
|
||
# Uitleg
|
||
wu = wb.create_sheet('Uitleg')
|
||
wu.column_dimensions['A'].width = 18
|
||
wu.column_dimensions['B'].width = 95
|
||
wu.append([f"Toestellen importeren in PIVOT{' – ' + schoolnaam if schoolnaam else ''}"])
|
||
wu['A1'].font = Font(bold=True, size=14)
|
||
regels = [
|
||
"Vul het tabblad 'Toestellen' in: één toestel per rij, vanaf rij 2. Laat de kolomkoppen in rij 1 staan.",
|
||
"Een toestel wordt herkend aan het toestelnummer, en anders aan het serienummer.",
|
||
"Bestaat het toestel nog niet, dan wordt het toegevoegd.",
|
||
"Bestaat het al, dan worden (afhankelijk van je keuze bij het uploaden) enkel lege velden aangevuld "
|
||
"of de ingevulde velden overschreven. Een lege cel wist nooit iets.",
|
||
"Er wordt nooit een toestel verwijderd. Je krijgt eerst een voorbeeld te zien voordat er iets bewaard wordt.",
|
||
f"Maximaal {MAX_RIJEN} rijen per bestand.",
|
||
]
|
||
for t in regels:
|
||
wu.append(['', t])
|
||
wu.append([])
|
||
wu.append(['Kolom', 'Uitleg'])
|
||
for c in wu[wu.max_row]:
|
||
c.font = Font(bold=True)
|
||
for _k, kop, maxlen, uitleg, _a in KOLOMMEN:
|
||
wu.append([kop, uitleg + (f" Max. {maxlen} tekens." if maxlen else '')])
|
||
for rij in wu.iter_rows(min_row=2):
|
||
for c in rij:
|
||
c.alignment = Alignment(wrap_text=True, vertical='top')
|
||
wu.append([])
|
||
wu.append(['Voorbeeld', 'Toestelnummer CB-0123 · Serienummer PF3ABC12 · Type Lenovo 300e Gen 4 · '
|
||
'Status Beschikbaar · Afschrijfdatum 31-08-2030 · Lokaal A101'])
|
||
|
||
wb.active = 0
|
||
buf = io.BytesIO()
|
||
wb.save(buf)
|
||
return buf.getvalue()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Excel lezen
|
||
# ---------------------------------------------------------------------------
|
||
def _cel(waarde):
|
||
"""Celwaarde → tekst (datums als ISO 'jjjj-mm-dd', 123.0 → '123')."""
|
||
if waarde is None:
|
||
return ''
|
||
if isinstance(waarde, datetime):
|
||
return waarde.date().isoformat()
|
||
if isinstance(waarde, date):
|
||
return waarde.isoformat()
|
||
if isinstance(waarde, float) and waarde.is_integer():
|
||
return str(int(waarde))
|
||
return str(waarde).strip()
|
||
|
||
|
||
def lees_excel(bestand):
|
||
"""Leest een .xlsx (bestandsobject of bytes). Geeft (rijen, onbekende_kolommen).
|
||
rijen = [{'rij': <rijnummer in Excel>, 'schooleigen_id': '...', ...}] (enkel niet-lege rijen)."""
|
||
from openpyxl import load_workbook
|
||
data = bestand if isinstance(bestand, (bytes, bytearray)) else bestand.read()
|
||
if not data:
|
||
raise ImportFout("Het bestand is leeg.")
|
||
try:
|
||
wb = load_workbook(io.BytesIO(data), read_only=True, data_only=True)
|
||
except Exception:
|
||
raise ImportFout("Dit bestand kan niet gelezen worden. Gebruik een Excel-bestand (.xlsx), "
|
||
"bv. het sjabloon van deze pagina.")
|
||
try:
|
||
ws = wb['Toestellen'] if 'Toestellen' in wb.sheetnames else wb.worksheets[0]
|
||
rijen_iter = ws.iter_rows(values_only=True)
|
||
# Kopregel zoeken in de eerste 10 rijen: de eerste rij met een herkende kolom
|
||
kop_index, onbekend, rijnr = None, [], 0
|
||
for rij in rijen_iter:
|
||
rijnr += 1
|
||
gevonden = {i: _HERKEN[_norm(v)] for i, v in enumerate(rij or ()) if _norm(v) in _HERKEN}
|
||
if gevonden:
|
||
kop_index = {}
|
||
for i, sleutel in gevonden.items():
|
||
kop_index.setdefault(sleutel, i) # dubbele kolom: de eerste telt
|
||
onbekend = [_cel(v) for i, v in enumerate(rij) if _cel(v) and i not in gevonden]
|
||
break
|
||
if rijnr >= 10:
|
||
break
|
||
if not kop_index:
|
||
raise ImportFout("Geen kolomkoppen gevonden. Rij 1 moet de kolomnamen van het sjabloon bevatten "
|
||
f"({', '.join(KOPPEN.values())}).")
|
||
if 'schooleigen_id' not in kop_index and 'serienummer' not in kop_index:
|
||
raise ImportFout("De kolom Toestelnummer of Serienummer ontbreekt.")
|
||
|
||
uit = []
|
||
for rij in rijen_iter:
|
||
rijnr += 1
|
||
waarden = {s: _cel(rij[i]) if rij and i < len(rij) else '' for s, i in kop_index.items()}
|
||
if not any(waarden.values()):
|
||
continue
|
||
if len(uit) >= MAX_RIJEN:
|
||
raise ImportFout(f"Het bestand bevat meer dan {MAX_RIJEN} rijen. Splits het op in kleinere bestanden.")
|
||
uit.append({'rij': rijnr, **{s: waarden.get(s, '') for s in SLEUTELS}})
|
||
return uit, onbekend
|
||
finally:
|
||
wb.close()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Plan maken (niets bewaren)
|
||
# ---------------------------------------------------------------------------
|
||
def _datum_tekst(w):
|
||
"""'2030-08-31' / '31-08-2030' / '31/08/2030' → date, of ValueError."""
|
||
w = (w or '').strip()
|
||
if not w:
|
||
return None
|
||
try:
|
||
return date.fromisoformat(w[:10]) if len(w) >= 10 and w[4] == '-' else tst.lees_datum(w)
|
||
except ValueError:
|
||
return tst.lees_datum(w)
|
||
|
||
|
||
def _toon(w):
|
||
if isinstance(w, date):
|
||
return w.strftime('%d-%m-%Y')
|
||
return '' if w is None else str(w)
|
||
|
||
|
||
def maak_plan(rijen, modus='aanvullen'):
|
||
"""Bepaalt per rij wat er zou gebeuren. modus: 'aanvullen' (enkel lege velden) of 'overschrijven'.
|
||
Geeft {'rijen': [...], 'tellers': {...}}; elke rij heeft 'actie', 'meldingen' en 'waarden' (voor de database)."""
|
||
overschrijven = modus == 'overschrijven'
|
||
bestaand = execute_query("SELECT Toestel_ID, Schooleigen_ID, Serienummer, Type, Status, Afschrijfdatum, Lokaal_ID "
|
||
"FROM Toestellen") or []
|
||
op_nummer = {(r['Schooleigen_ID'] or '').strip().upper(): r for r in bestaand if (r['Schooleigen_ID'] or '').strip()}
|
||
op_serie = {}
|
||
for r in bestaand:
|
||
s = (r['Serienummer'] or '').strip().upper()
|
||
if s:
|
||
op_serie.setdefault(s, r)
|
||
lok = {}
|
||
for l in lokalen():
|
||
lok.setdefault((l['Naam'] or '').strip().upper(), l)
|
||
lok_naam = {l['Lokaal_ID']: l['Naam'] for l in lok.values()}
|
||
|
||
gezien_nummer, gezien_serie = {}, {}
|
||
uit = []
|
||
tellers = {'nieuw': 0, 'bijwerken': 0, 'ongewijzigd': 0, 'fout': 0}
|
||
|
||
for invoer in rijen:
|
||
r = {'rij': invoer.get('rij'), 'invoer': {s: str(invoer.get(s) or '').strip() for s in SLEUTELS},
|
||
'meldingen': [], 'wijzigingen': [], 'actie': None, 'toestel_id': None, 'waarden': {}}
|
||
v = r['invoer']
|
||
fouten = []
|
||
|
||
# --- waarden controleren ---
|
||
nummer, serie = v['schooleigen_id'], v['serienummer']
|
||
if not nummer and not serie:
|
||
fouten.append("Toestelnummer of serienummer is verplicht.")
|
||
for s in ('schooleigen_id', 'serienummer', 'type'):
|
||
maxlen = next(k[2] for k in KOLOMMEN if k[0] == s)
|
||
if len(v[s]) > maxlen:
|
||
fouten.append(f"{KOPPEN[s]} is te lang (max. {maxlen} tekens).")
|
||
status = None
|
||
if v['status']:
|
||
status = next((x for x in STATUSSEN if x.lower() == v['status'].lower()), None)
|
||
if not status:
|
||
fouten.append(f"Status '{v['status']}' is ongeldig (kies {', '.join(STATUSSEN)}).")
|
||
afschrijf = None
|
||
if v['afschrijfdatum']:
|
||
try:
|
||
afschrijf = _datum_tekst(v['afschrijfdatum'])
|
||
except ValueError:
|
||
fouten.append(f"Afschrijfdatum '{v['afschrijfdatum']}' is geen geldige datum (gebruik dd-mm-jjjj).")
|
||
lokaal_id = None
|
||
if v['lokaal']:
|
||
l = lok.get(v['lokaal'].upper())
|
||
if l:
|
||
lokaal_id = l['Lokaal_ID']
|
||
else:
|
||
fouten.append(f"Lokaal '{v['lokaal']}' bestaat niet in PIVOT.")
|
||
|
||
# --- dubbels in het bestand (enkel t.o.v. eerdere rijen zonder fout) ---
|
||
if nummer and gezien_nummer.get(nummer.upper()):
|
||
fouten.append(f"Toestelnummer {nummer} staat al op rij {gezien_nummer[nummer.upper()]}.")
|
||
if serie and gezien_serie.get(serie.upper()):
|
||
fouten.append(f"Serienummer {serie} staat al op rij {gezien_serie[serie.upper()]}.")
|
||
|
||
# --- bestaand toestel? ---
|
||
t_nummer = op_nummer.get(nummer.upper()) if nummer else None
|
||
t_serie = op_serie.get(serie.upper()) if serie else None
|
||
toestel = t_nummer or t_serie
|
||
if t_nummer and t_serie and t_nummer['Toestel_ID'] != t_serie['Toestel_ID']:
|
||
fouten.append(f"Toestelnummer {nummer} en serienummer {serie} horen in PIVOT bij twee verschillende "
|
||
f"toestellen. Pas dit eerst aan in 💻 Alle toestellen.")
|
||
elif toestel:
|
||
db_nummer = (toestel['Schooleigen_ID'] or '').strip()
|
||
db_serie = (toestel['Serienummer'] or '').strip()
|
||
if t_nummer and serie and db_serie and db_serie.upper() != serie.upper():
|
||
fouten.append(f"Toestel {nummer} heeft in PIVOT serienummer {db_serie}, niet {serie}.")
|
||
if not t_nummer and nummer and db_nummer and db_nummer.upper() != nummer.upper():
|
||
fouten.append(f"Serienummer {serie} hoort in PIVOT bij toestel {db_nummer}, niet bij {nummer}.")
|
||
|
||
if fouten:
|
||
r['actie'] = 'fout'
|
||
r['meldingen'] = fouten
|
||
tellers['fout'] += 1
|
||
uit.append(r)
|
||
continue
|
||
|
||
if nummer:
|
||
gezien_nummer[nummer.upper()] = r['rij']
|
||
if serie:
|
||
gezien_serie[serie.upper()] = r['rij']
|
||
|
||
nieuw = {'schooleigen_id': nummer or None, 'serienummer': serie or None, 'type': v['type'] or None,
|
||
'status': status, 'afschrijfdatum': afschrijf, 'lokaal': lokaal_id}
|
||
|
||
if not toestel:
|
||
nieuw['status'] = status or 'Beschikbaar'
|
||
r['actie'] = 'nieuw'
|
||
r['waarden'] = {DB_KOLOM[s]: w for s, w in nieuw.items() if w is not None}
|
||
tellers['nieuw'] += 1
|
||
uit.append(r)
|
||
continue
|
||
|
||
# --- bijwerken ---
|
||
r['toestel_id'] = toestel['Toestel_ID']
|
||
r['label'] = toestel['Schooleigen_ID'] or toestel['Serienummer'] or str(toestel['Toestel_ID'])
|
||
overgeslagen = []
|
||
for s, w in nieuw.items():
|
||
if w is None:
|
||
continue
|
||
kolom = DB_KOLOM[s]
|
||
huidig = toestel[kolom]
|
||
if isinstance(huidig, datetime):
|
||
huidig = huidig.date()
|
||
gelijk = (str(huidig).strip().upper() == str(w).strip().upper()) if huidig not in (None, '') else False
|
||
if gelijk:
|
||
continue
|
||
leeg = huidig is None or (isinstance(huidig, str) and not huidig.strip())
|
||
if s == 'status' and not overschrijven:
|
||
leeg = False # status heeft altijd een waarde: enkel wijzigen bij overschrijven
|
||
if leeg or overschrijven:
|
||
r['waarden'][kolom] = w
|
||
oud = lok_naam.get(huidig, huidig) if s == 'lokaal' else huidig
|
||
nw = lok_naam.get(w, w) if s == 'lokaal' else w
|
||
r['wijzigingen'].append(f"{KOPPEN[s]}: {_toon(oud) or '–'} → {_toon(nw)}")
|
||
else:
|
||
oud = lok_naam.get(huidig, huidig) if s == 'lokaal' else huidig
|
||
overgeslagen.append(f"{KOPPEN[s]} ({_toon(oud)})")
|
||
if overgeslagen:
|
||
r['meldingen'].append("Niet overschreven: " + ', '.join(overgeslagen) + '.')
|
||
if r['waarden']:
|
||
r['actie'] = 'bijwerken'
|
||
tellers['bijwerken'] += 1
|
||
else:
|
||
r['actie'] = 'ongewijzigd'
|
||
tellers['ongewijzigd'] += 1
|
||
uit.append(r)
|
||
|
||
return {'rijen': uit, 'tellers': tellers, 'modus': 'overschrijven' if overschrijven else 'aanvullen'}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Uitvoeren
|
||
# ---------------------------------------------------------------------------
|
||
def voer_uit(rijen, modus, gebruiker=None):
|
||
"""Maakt het plan opnieuw en voert 'nieuw' en 'bijwerken' uit in één transactie.
|
||
Geeft (plan, aantal_nieuw, aantal_bijgewerkt). Bij een databasefout wordt niets bewaard."""
|
||
plan = maak_plan(rijen, modus)
|
||
conn = get_db_connection()
|
||
cur = conn.cursor()
|
||
nieuw = bijgewerkt = 0
|
||
try:
|
||
conn.start_transaction()
|
||
for r in plan['rijen']:
|
||
w = r['waarden']
|
||
if r['actie'] == 'nieuw':
|
||
kolommen = list(w.keys())
|
||
cur.execute(f"INSERT INTO Toestellen ({', '.join(kolommen)}) "
|
||
f"VALUES ({', '.join(['%s'] * len(kolommen))})", tuple(w[k] for k in kolommen))
|
||
nieuw += 1
|
||
elif r['actie'] == 'bijwerken':
|
||
kolommen = list(w.keys())
|
||
cur.execute(f"UPDATE Toestellen SET {', '.join(k + ' = %s' for k in kolommen)} WHERE Toestel_ID = %s",
|
||
tuple(w[k] for k in kolommen) + (r['toestel_id'],))
|
||
bijgewerkt += 1
|
||
conn.commit()
|
||
except Exception:
|
||
conn.rollback()
|
||
raise
|
||
finally:
|
||
cur.close()
|
||
conn.close()
|
||
return plan, nieuw, bijgewerkt
|