Files
Pivot/services/toestellen_import.py
2026-10-14 13:34:37 +02:00

420 lines
19 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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