commit 536ff43ce4a245500446a427bedfb51be2a1b785 Author: Kristof Date: Wed Oct 14 12:29:29 2026 +0200 Initial commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..61e71c8 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,38 @@ +# Include any files or directories that you don't want to be copied to your +# container here (e.g., local build artifacts, temporary files, etc.). +# +# For more help, visit the .dockerignore file reference guide at +# https://docs.docker.com/go/build-context-dockerignore/ + +**/.DS_Store +**/__pycache__ +**/.venv +**/.classpath +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/bin +**/charts +**/docker-compose* +**/compose.y*ml +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +LICENSE +README.md +geheimen +import +config.ini +**/*api_key*.json diff --git a/DATABASE.md b/DATABASE.md new file mode 100644 index 0000000..a773242 --- /dev/null +++ b/DATABASE.md @@ -0,0 +1,199 @@ +# PIVOT-database (structuur 2026-27) + +De database volgt `pivot_database_structuur_26-27` en is verdeeld zoals PIVOT 1.1.0 met meerdere scholen werkt: + +| Database | Inhoud | +|---|---| +| **centraal** (`pivot`, zie `config.ini › centrale_database`) | `Scholen`, `Gebruikers`, `Veiligheidsgroepen`, `Onderdelen`, `Gebruiker_rechten`, `Leerlingenaantal_tellingen`, `Personeelsaantal_tellingen`, `Personeelsaantal_huidig`, `IP_blokkades`, `PIVOT_instellingen`, `Koppeling_tests`, `Handleidingen`, `Handleidingen_versies`, `Logs`, `Bugs`, `Status` | +| **één per school** (bv. `pivot_sg_antwerpen`) | `Campus`, `Lokalen`, `Wifi`, `Toestellen`, `Signpost_DB`, `Opmerkingen`, `Signpost_herstellingen_DB`, `Leerlingen`, `Eigendom`, `Co_accounts`, `CO2_Metingen`, `Schooljaar` + de tabellen van de modules (2FA, afwezigheden, personeel, licenties, mailbox-delegatie) | + +## Installeren + +``` +mysql -u -p < sql/installatie_pivot_26-27.sql +``` + +Daarmee heb je: + +- de centrale database `pivot` +- de school **Scholengroep Antwerpen** (code `sg-antwerpen`) met database `pivot_sg_antwerpen` +- de gebruiker **Marijn**, wachtwoord `Binnenin10`, rol **Administrator** + +Je kan het script ook in MySQL Workbench of phpMyAdmin importeren. Het mag meermaals uitgevoerd worden. + +**Andere naam voor de centrale database** (bv. `pivot_test`)? Pas dan de naam aan op **beide** regels +(`CREATE DATABASE` én `USE`) onder *1. Centrale database*, en zet dezelfde naam in `config.ini › centrale_database`. + +**Gebruik een nieuwe, lege database.** Staan er nog tabellen van de vorige versie in (bv. `scholen` of +`gebruikers`), dan stopt het script met de melding *"STOP: deze database bevat nog oude PIVOT-tabellen"*, +en PIVOT start niet met die database. MySQL op Windows maakt geen onderscheid tussen `Scholen` en `scholen`, +dus anders zou de nieuwe tabel stilletjes niet aangemaakt worden. +Log daarna in, kies *Scholengroep Antwerpen* en vul via **🏫 Alle scholen beheren › Bewerken** het e-maildomein, +Smartschool, Informat, Google, ... in. + +Losse bestanden: + +- `sql/centraal.sql`: enkel de centrale tabellen +- `sql/school.sql`: de tabellen van één school (uit te voeren in een lege database) + +## Een database voor een nieuwe school + +Kies één van deze manieren: + +1. **In PIVOT**: 🏫 Alle scholen beheren › Nieuwe school › *Database aanmaken?* › **Ja, nieuwe lege PIVOT-database**. + Vergeten? Open de school via *Bewerken*: onder *Database* staat dan **🗄️ Database nu aanmaken**. +2. **Opdrachtregel**: `python beheer.py nieuwe-schooldatabase pivot_` +3. **Met de hand**: `CREATE DATABASE pivot_;` en daarna `sql/school.sql` in die database uitvoeren. + +De MySQL-gebruiker uit `config.ini` moet daarvoor databases mogen aanmaken. + +## Een kopie van een school + +🏫 Alle scholen beheren › Nieuwe school › *Database aanmaken?* › **Ja, kopie van … (met gegevens)**. +PIVOT kopieert alle tabellen mét gegevens naar een **nieuwe** database (bestaat ze al en bevat ze tabellen, dan stopt het). +Vink aan wat je nog wil overnemen: + +- **Koppelingen en instellingen** (Smartschool, Informat, Google-sleutel, …): na het aanmaken kom je op *Bewerken* om ze na te kijken. +- **Gebruikersrechten**: wie rechten heeft in de bronschool, krijgt dezelfde rechten in de kopie. + +Een testkopie zet je best op *niet actief*: dan lopen er geen geplande synchronisaties (afwezigheden, Skore, 2FA) voor. +Op de opdrachtregel: `python beheer.py kopieer-school BRONCODE pivot_` (enkel de database). + +## Rechten + +Iedereen heeft **één account** voor alle scholen (tabel `Gebruikers`). Per school vink je aan op welke **onderdelen** +iemand rechten heeft (tabel `Gebruiker_rechten`: gebruiker × school × onderdeel). +Zo kan iemand in school A *2FA tijdelijk uitzetten* en in school B niet. + +- **Administrator** (Veiligheidsgroep 1): alle rechten in alle scholen. Beheert gebruikers, rechten en scholen. +- **Gebruiker** (Veiligheidsgroep 2): enkel wat per school aangevinkt is. Zonder vinkje in een school kan hij er niet inloggen. +- Wie in een school het recht **Gebruikers & rechten** heeft, kan in díe school gebruikers aanmaken en hun rechten instellen. + Administrators wijzigen kan hij niet. +- Leerkrachten zonder account die met Google inloggen, krijgen zoals vroeger enkel de personeelsmodule. + +Een wijziging in de rechten geldt meteen, ook als de gebruiker al ingelogd is. +Wie in meerdere scholen rechten heeft, wisselt bovenaan van school. + +Beheren kan in PIVOT via **⚙️ Beheer › 🔐 Gebruikers & rechten**, of op de opdrachtregel: + +``` +python beheer.py gebruiker jan --email jan@school.be # nieuwe gebruiker (vraagt wachtwoord) +python beheer.py gebruiker marijn --admin # administrator maken +python beheer.py rechten jan sg-antwerpen tweestaps_uitzetten co2 +python beheer.py rechten jan sg-antwerpen # rechten tonen +python beheer.py rechten jan sg-antwerpen alles | geen +``` + +| Onderdeel (code) | Wat | +|---|---| +| `eigendom_bekijken` | Eigendom bekijken | +| `toestellen_uitdelen` | Toestellen uitdelen en inleveren | +| `toestellen_beheren` | Toestelleninventaris beheren | +| `toestel_blokkeren` | Chromebook blokkeren / deblokkeren | +| `herstellingen` | Herstellingen | +| `signpost` | Signpost-toestellen | +| `leerlingen_bekijken` | Leerlingen bekijken en bewerken | +| `leerlingen_synchroniseren` | Leerlingen toevoegen en synchroniseren (Informat, Google) | +| `google_wachtwoord` | Google-wachtwoord resetten | +| `google_accounts` | Google-accounts aanmaken | +| `tweestaps_codes` | 2FA: back-upcodes tonen / aanmaken | +| `tweestaps_uitzetten` | 2FA tijdelijk uitzetten (uitzondering) | +| `google_licenties` | Google-licenties | +| `gmail_delegatie` | Mailbox-delegatie | +| `verzendlijsten` | Verzendlijsten (Google Groepen) bekijken, aanmaken en aanpassen | +| `smartschool` | Smartschool-wachtwoorden & info | +| `smartschool_activiteit` | Activiteit co-accounts & berichten | +| `afwezigheden` | Afwezigheden leerlingen (B-codes) | +| `laatkomers` | Laatkomers (codes voor te laat uit Smartschool) | +| `skore` | Skore-synchronisatie starten en plannen | +| `personeel_directie` | Personeel: aanvragen goedkeuren | +| `personeel_secretariaat` | Personeel: dagoverzicht | +| `co2` | CO2-sensoren | +| `qr_codes` | QR-code generator | +| `bugs_bekijken` / `bugs_beheren` | Bugs bekijken / status wijzigen | +| `nieuw_schooljaar` | Nieuw schooljaar (accounts & toestellen) | +| `leerlingenaantal` | Leerlingenaantallen van alle scholen (blok *Leerlingen*) | +| `personeelsaantal` | Personeelsaantallen van alle scholen (blok *Personeel*) | +| `gopro` | GO! Pro: personeel van de school uit Informat, rollen en export (blok *Personeel*) | +| `koppelingen` | Koppelingen van deze school | +| `gebruikersbeheer` | Gebruikers & rechten van deze school | + +Een nieuw onderdeel voeg je toe in `rechten.py › ONDERDELEN`. Bij het opstarten komt het automatisch in de tabel `Onderdelen`. + +## Verschillen met de PDF + +Wat PIVOT extra nodig heeft, staat in `sql/school.sql` gemarkeerd met `[+]`. + +- **Scholen**: `Instellingsnummer` is het officiële nummer van de school; het nummer voor de Informat-koppeling + staat apart in `Informat_instellingsnummer` (die twee verschillen). `Onderwijsniveau` (`kleuter`, `basis` of `secundair`) + bepaalt de subtotalen bij de leerlingenaantallen. Scholen heeft naast de PDF-velden ook `Code`, `Hostnamen`, `Actief`, `Db_naam`, `Instellingen` (versleutelde koppelingen) + en `Google_sleutel`. `Domein` mag meerdere domeinen bevatten, gescheiden door komma's. +- **Gebruikers** bevat `Passwoordhash` (werkzeug-hash, nooit het wachtwoord zelf) plus `Actief`. + `Veiligheidsgroep` verwijst naar de tabel `Veiligheidsgroepen`. +- **Leerlingenaantal_tellingen** (extra): de leerlingenaantallen per school op een gekozen peildatum +- **gmail_delegaties** / **gmail_delegaties_scan** (extra, per school): alle lopende e-mailmachtigingen (Mailbox-delegatie), opgehaald door alle mailboxen af te gaan (dagelijks of met 🔄 Vernieuwen) en meteen bijgewerkt bij wijzigingen in PIVOT +- **internaat_aanwezigheid** (extra, per school): aanwezigheidsregister van het internaat (per intern, dag en moment ochtend/avond) +- **gmail_label_log** / **gmail_label_items** (extra, per school): opdrachten "e-mails met een label naar een andere mailbox" en welke berichten al gekopieerd zijn (zodat een herstart ze overslaat). Vereist het Google-bereik `gmail.modify`. +- **gmail_handtekening** / **gmail_handtekening_log** (extra, per school): Gmail › Handtekeningen: het HTML-sjabloon, het logo (publiek op `/handtekening-logo//`), de gekozen OU's en de schakelaar voor nachtelijk opnieuw toepassen (één rij, ID = 1), en per gebruiker wanneer de handtekening gezet werd. Functie en telefoon staan niet in PIVOT maar in Google. Vereist `gmail.settings.basic` en `admin.directory.user`. +- **contact_lijsten** / **contact_lijst_items** (extra, per school): Gmail › Contacten › Contactlijsten: een label met vaste contacten dat PIVOT in de persoonlijke contacten van iedereen in de gekozen OU's zet (People API, scope `contacts`). PIVOT herkent zijn contacten in de mailboxen aan het eigen veld `PIVOT` = `lijst:#`. De zichtbaarheid in het adresboek staat enkel in Google. +- **Leerlingen.Vertrokken_op** (extra): datum waarop de leerling bij "Informat & SS sync" niet meer actief was in Informat (NULL = actief). Vertrokken leerlingen worden niet verwijderd, enkel verborgen in de lijst. + (Scholengroep › Leerlingenaantallen › *Telling op peildatum*). Wordt bij het opstarten automatisch aangemaakt. +- **Personeelsaantal_huidig** en **Personeelsaantal_tellingen** (extra): de personeelsaantallen per school, nu en op + een gekozen peildatum (Personeel › Personeelsaantallen). Geteld uit de Informat Personeel-API: wie op die dag een + lopende opdracht heeft. Worden bij het opstarten automatisch aangemaakt. +- **GoPro_verzameltabel** (extra, centraal): de groene personeelsleden (account in Google) van alle scholen samen voor één GO! Pro-CSV; wie hierin staat, wordt op de GO! Pro-pagina van zijn school niet meer getoond (herkend aan school + personeelslid of aan het e-mailadres); met de schakelaar 🌙 op de GO! Pro-pagina (PIVOT_instellingen `gopro_nachtelijk`) gebeurt het verzamelen elke nacht tussen 2 en 6 uur voor alle scholen; kolom `Soort`: `groen` (gaat mee in de CSV), `genegeerd` (rode lijst: nooit meer tonen, niet exporteren) of `verzonden` (met ✅ Markeer als verzonden: al naar GO! Pro gestuurd, nooit opnieuw toevoegen; `Verzonden`, `Verzonden_door`) +- **IP_blokkades** (extra): IP-adressen en reeksen die geweigerd (of altijd toegelaten) worden, voor alle scholen; bij "altijd toestaan" kunnen één of meer scholen aangeduid worden (`Scholen`, codes met komma gescheiden, ter info) + (Beheer › Beveiliging › IP-blokkades, enkel administrators; een gebruiker met het recht `ip_whitelist` ziet enkel de toegestane adressen van zijn school, voegt er toe voor die school en verwijdert enkel zijn eigen regels — `Gebruiker_ID`). Staat PIVOT achter een reverse proxy, zet dan + `config.ini › [algemeen] proxy_aantal` op het aantal proxy's (meestal 1), anders ziet PIVOT het adres van de proxy. + De schakelaar *Enkel "Altijd toestaan" binnenlaten* staat in **PIVOT_instellingen** (`ip_enkel_toegestaan` = 1). + Toch buitengesloten? Zet hem rechtstreeks in de database uit: + `UPDATE PIVOT_instellingen SET Waarde = '0' WHERE Sleutel = 'ip_enkel_toegestaan';` (geldt binnen 30 seconden). +- **Handleidingen** en **Handleidingen_versies** (extra): de handleidingen onder 📘 Handleidingen op het dashboard + (HTML, met schermafbeeldingen) en de laatste 30 vorige versies per handleiding. PIVOT levert er twee mee + (2FA-uitzondering en mailbox-delegatie in Google Admin); die komen bij het opstarten in de tabel. + Enkel administrators kunnen ze bewerken. +- **Logs** bevat ook `School_ID`, `Actie` en `IP`. PIVOT schrijft er elke bezochte pagina in + (uit te zetten met `config.ini › logboek = nee`). +- **Bugs** bevat ook wie de bug meldde (`Gebruiker_ID`, `Gebruikersnaam`) en in welke school. +- **Toestellen** bevat `Serienummer`, `Type`, `Status` en `Geblokkeerd` (nodig om te blokkeren in Google en voor uitdelen/inleveren). + `Geblokkeerd` (1 = geblokkeerd in Google Admin) wordt vanzelf toegevoegd; de knop in 💻 Alle toestellen en 🔒 bij Eigendom + houden hem bij, en "Chromebooks ophalen uit Google" neemt de status uit Google over. + `Schooleigen_ID` is het toestelnummer van de school; in de code heette dat vroeger `KAMXM_nummer`. +- **Signpost_DB**: één rij per toestel (`Toestel_ID` = sleutel). Het veld "Aankoopberdrag" heet `Aankoopbedrag`. + De synchronisatie met Signpost maakt een toestel aan als het serienummer nog niet bestaat. +- **Opmerkingen** en **Signpost_herstellingen_DB** verwijzen naar `Toestellen.Toestel_ID`. Dat is hetzelfde nummer als + `Signpost_DB.Toestel_ID`, maar zo werkt het ook voor toestellen die niet via Signpost gekocht zijn. + Herstellingen hebben ook een `Datum` en de leerling (`Leerling_ID`). +- **Eigendom** houdt ook de geschiedenis bij: `Datum_inlevering` leeg betekent dat de leerling het toestel nu heeft. + De vroegere tabel `Eigendom_archief` is niet meer nodig. +- **Co-accounts** heet `Co_accounts` en "Laatste aanmelding" heet `Laatste_aanmelding` (geen streepjes of spaties in namen). +- **Campus.School_ID** verwijst naar `Scholen.School_ID` in de centrale database. Een harde foreign key kan niet, + want een schooldatabase kan op een andere server staan. +- **Lokalen.Campus_ID** mag leeg zijn: een CO2-sensor met een nieuwe lokaalnaam maakt automatisch een lokaal aan. + Koppel het daarna aan een campus. +- **Wifi** kreeg een optionele `Campus_ID`. +- **Schooljaar** (`2026` → `2026-27`) is een extra tabel voor `Leerlingen.Schooljaar_ID`. +- **smartschool_coaccounts** (extra, per school): alle co-accounts uit Smartschool met klas, leerling en laatste + aanmelding (Smartschool › Co-accounts). Wordt automatisch aangemaakt en één keer per dag opnieuw opgehaald. +- **gopro_keuzes** (extra, per school): de GO! Pro-functie en -rol per personeelslid (`Person_ID` uit Informat). + Wordt automatisch aangemaakt; zonder keuze is het *Leerkracht* (een directeur: *Directeur*). + +## Windows → Linux: tabelnamen met hoofdletters + +MySQL op Windows bewaart tabelnamen in kleine letters (`lower_case_table_names = 1`); op Linux (`= 0`) zijn +`toestellen` en `Toestellen` verschillende tabellen. Bij het opstarten (en bij **Testen** van een school) herstelt +PIVOT dit automatisch voor de centrale database en alle schooldatabases (`services/tabelnamen.py`), enkel op een +server met `lower_case_table_names = 0` en enkel voor de tabellen die PIVOT kent: + +- alleen `toestellen` bestaat → hernoemd naar `Toestellen`; +- beide bestaan en `Toestellen` is leeg → de lege tabel weg, `toestellen` hernoemd; +- beide bevatten gegevens → niets gewijzigd, melding in het serverlogboek (zelf samenvoegen). + +Wat er gebeurde, staat in het serverlogboek (regels die beginnen met `PIVOT tabelnamen:`). + +## Aantal verbindingen ("Too many connections") + +PIVOT houdt per database (centraal + elke school) een pool van verbindingen open: `config.ini › [database] › +pool_size` (standaard 3). In totaal dus ongeveer (aantal scholen + 1) × pool_size per draaiend PIVOT-proces. Dat moet +onder `max_connections` van MySQL blijven (standaard 151). Krijg je "1040 Too many connections", verhoog dan +`max_connections` in `my.ini` / `my.cnf` (bv. `max_connections = 500`) of verlaag `pool_size`. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e8abc08 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,51 @@ +# syntax=docker/dockerfile:1 + +# Comments are provided throughout this file to help you get started. +# If you need more help, visit the Dockerfile reference guide at +# https://docs.docker.com/go/dockerfile-reference/ + +# Want to help us make this template better? Share your feedback here: https://forms.gle/ybq9Krt8jtBL3iCk7 + +ARG PYTHON_VERSION=3.12.2 +FROM python:${PYTHON_VERSION}-slim as base + +# Prevents Python from writing pyc files. +ENV PYTHONDONTWRITEBYTECODE=1 + +# Keeps Python from buffering stdout and stderr to avoid situations where +# the application crashes without emitting any logs due to buffering. +ENV PYTHONUNBUFFERED=1 + +WORKDIR /app + +# Create a non-privileged user that the app will run under. +# See https://docs.docker.com/go/dockerfile-user-best-practices/ +ARG UID=10001 +RUN adduser \ + --disabled-password \ + --gecos "" \ + --home "/nonexistent" \ + --shell "/sbin/nologin" \ + --no-create-home \ + --uid "${UID}" \ + appuser + +# Download dependencies as a separate step to take advantage of Docker's caching. +# Leverage a cache mount to /root/.cache/pip to speed up subsequent builds. +# Leverage a bind mount to requirements.txt to avoid having to copy them into +# into this layer. +RUN --mount=type=cache,target=/root/.cache/pip \ + --mount=type=bind,source=requirements.txt,target=requirements.txt \ + python -m pip install -r requirements.txt + +# Switch to the non-privileged user to run the application. +USER appuser + +# Copy the source code into the container. +COPY . . + +# Expose the port that the application listens on. +EXPOSE 5001 + +# Run the application. +CMD python3 app.py diff --git a/MEERDERE_SCHOLEN.md b/MEERDERE_SCHOLEN.md new file mode 100644 index 0000000..d0e30d2 --- /dev/null +++ b/MEERDERE_SCHOLEN.md @@ -0,0 +1,69 @@ +# PIVOT voor meerdere scholen (versie 1.1.0) + +Eén PIVOT voor alle scholen. Zie **DATABASE.md** voor de databasestructuur (2026-27), de installatie en de rechten. + +Elke school heeft: + +- een **eigen database** (zelfde tabellen, eigen gegevens). De PIVOT-gebruikers en hun rechten per school staan centraal; +- een **eigen e-maildomein** (of meerdere). Bij *Inloggen met Google* wordt de school daaraan herkend; +- **eigen koppelingen**: Smartschool (webservice-wachtwoord, OneRoster), Informat (instellingsnummer, client-ID/secret), + Google (serviceaccount-sleutel, admin, SMTP, OU's, 2FA), Signpost/M4S, CO2-netwerk en personeelsinstellingen. + +Alles wat per school verschilt staat in de tabel `Scholen` van de centrale database. Geheimen (wachtwoorden, +client-secrets en de Google-sleutel) worden daar **versleuteld** bewaard. `config.ini` bevat enkel nog wat voor alle scholen geldt. + +## Eerste installatie / overzetten van 1.0.9 + +1. **Versleutelingssleutel maken** en veilig bewaren (zonder deze sleutel zijn de opgeslagen geheimen onleesbaar): + ``` + python beheer.py nieuwe-sleutel + ``` + Zet ze in de omgevingsvariabele `PIVOT_SLEUTEL` (of laat PIVOT ze zelf aanmaken in `geheimen/pivot.key`). + Zet ook `FLASK_SECRET_KEY` (willekeurige lange tekst) en `PIVOT_DB_PASSWORD`. +2. **config.ini invullen**: databaseserver, `superbeheerders` (Google-accounts van de scholengroep-ICT), + en eventueel een gedeelde Google-login (`[google_sso]`). +3. **Databases aanmaken**: `mysql -u -p < sql/installatie_pivot_26-27.sql` + (centrale database `pivot`, school Scholengroep Antwerpen + database, administrator Marijn). Zie DATABASE.md. +4. **Bestaande school importeren** uit de oude config.ini (alle wachtwoorden worden versleuteld overgenomen): + ``` + python beheer.py importeer-config pad/naar/oude/config.ini --google-sleutel pad/naar/kamxm_api_key.json + ``` +5. Starten, inloggen met Google als superbeheerder → **🏫 Alle scholen beheren** → **Nieuwe school** voor de andere scholen. + Bij een nieuwe school kan PIVOT de database meteen aanmaken (lege standaardstructuur, of een volledige kopie + van een bestaande school, mét gegevens en desgewenst met haar koppelingen en gebruikersrechten). Geef daarna via **🔐 Gebruikers & rechten** de juiste mensen rechten in die school. +6. Per school: **🩺 Testen** controleert database, Smartschool, OneRoster, Informat, Google en Signpost. + +## Wie mag wat + +| Rol | Hoe | Mag | +|---|---|---| +| Administrator | `Gebruikers.Veiligheidsgroep = 1` (bv. Marijn), of Google-account in `config.ini › superbeheerders` | alles in alle scholen: scholen toevoegen, gebruikers en rechten beheren, wisselen tussen scholen | +| Gebruiker | `Veiligheidsgroep = 2` + rechten per school | enkel de aangevinkte onderdelen, enkel in die scholen (zie DATABASE.md) | +| Leerkracht | Google-login, in de personeels-OU's, geen account | enkel de personeelsmodule | + +Een administrator kan ook met gebruikersnaam en wachtwoord inloggen. Geef die rol dus enkel aan wie dat echt nodig heeft, +met een sterk wachtwoord. + +## Hoe wordt de school bepaald? + +1. **Eigen webadres**: krijgt een school een hostnaam (bv. `pivot.school.be`), dan ligt de school daar vast. +2. **Google-login**: het domein van het e-mailadres bepaalt de school. +3. **Gebruikersnaam/wachtwoord**: de gebruiker kiest de school in een keuzelijst (die wordt onthouden op het toestel). + +## Google-login + +- **Gedeeld** (aanrader): één OAuth-client (type *Webtoepassing*, toestemmingsscherm *Extern*) in `config.ini › [google_sso]`. + Werkt voor alle domeinen. Redirect-URI: `https:///callback`. +- **Per school**: vul bij de school *Google-login client-ID/secret* in (bv. een *Interne* app in de Workspace van die school). + +## Achtergrondtaken + +De dagelijkse synchronisatie van afwezigheden en het opruimen van 2FA-uitzonderingen lopen nu voor **alle actieve +scholen** (elk op het eigen sync-uur). Scholen zonder Smartschool-wachtwoord of Google-sleutel worden overgeslagen. + +## Aandachtspunten + +- **CO2-sensoren**: de scanner zoekt op het netwerk van de school. Draait PIVOT centraal, dan moet de server die netwerken kunnen bereiken. +- **Schooljaar**: leeg laten = automatisch (wisselt op 1 september). De Informat-telling gebruikt nu *vandaag + 1 maand* als + referentiedatum (vroeger vast `2027-1-3`) en het schooljaar van de school (vroeger vast `2025-26`). +- De kolom `KAMXM_nummer` behoudt die naam in elke schooldatabase (het is een interne kolomnaam). In de schermen staat "Toestelnummer". diff --git a/OVERDRACHT.md b/OVERDRACHT.md new file mode 100644 index 0000000..7a5f101 --- /dev/null +++ b/OVERDRACHT.md @@ -0,0 +1,111 @@ +# PIVOT 1.1.0 (meerdere scholen) – overdracht + +Dit document vat samen hoe PIVOT in elkaar zit, welke afspraken er in de code gelden en wat er recent bijkwam. +Het is bedoeld voor wie verder werkt aan PIVOT (zelf, of samen met een AI-assistent: geef dan dit bestand en de +zip van het project mee). Lees ook **MEERDERE_SCHOLEN.md** (installatie, rollen) en **DATABASE.md** (tabellen). + +> ⚠️ `config.ini` en de map `geheimen/` bevatten echte wachtwoorden en sleutels. Deel ze niet en zet ze nooit in +> een chat, ticket of e-mail. Geef een collega een kopie van het project **zonder** die bestanden en laat hem/haar +> een eigen `config.ini` invullen. + +--- + +## 1. Architectuur in het kort + +- **Flask-app** (`app.py`) met ±40 blueprints (`blueprints/`) en de logica in `services/`; HTML in `templates/` + (Jinja, `base.html` + `static/global.css`). +- **Multi-tenant** (`tenancy.py`): één centrale database (`Scholen`, `Gebruikers`, rechten, logs, ...) en één database + per school (toestellen, leerlingen, ...). + - `huidige_school()` = de school van het verzoek; `in_school(school)` / `met_school(fn)` voor achtergrondthreads. + - Instellingen per school = `Veld(...)` in `tenancy.VELDEN` (verschijnen vanzelf in 🔌 Koppelingen); + lezen met `school.get('SLEUTEL')` of `Config.SLEUTEL` (metaclass leest de huidige school). + - `school.cache` = per-school geheugen (wordt leeggemaakt bij wijzigingen aan de school). + - `school_singleton(cls, init)` = één object per school (gebruikt voor achtergrondtaken met `status`-dict). + - `google_credentials(scopes, subject)` = serviceaccount van de school, gedelegeerd naar `subject`. +- **Database** (`database.py`): `execute_query` (schooldatabase), `central_query` (centraal); `commit=True` geeft + het aantal rijen terug. Pools per database (`config.ini › [database] › pool_size`, standaard 3). +- **Rechten** (`rechten.py`): `ONDERDELEN` = lijst (code, naam, categorie); routes met `@recht_vereist('code')`, + templates met `{% if mag('code') %}`. Een nieuw onderdeel verschijnt vanzelf in 🔐 Gebruikers & rechten. + `log(...)` schrijft in het logboek. +- **Achtergrondtaken**: threads gestart in `app.py` (enkel in het echte proces, niet in de reloader): + afwezigheden, 2FA, Skore, GO! Pro (nachtelijk verzamelen), Gmail-handtekeningen (nachtelijk). Taken die maar één keer + per dag mogen lopen, "claimen" de dag met een `UPDATE ... WHERE` (werkt ook met meerdere processen). +- **Opstarten**: `tenancy.ensure_schema()` (centrale tabellen) en `services/tabelnamen.py` (zie §5). + +## 2. Afspraken in de code + +- Alles in het **Nederlands** (namen, meldingen, commentaar). +- Tabellen die een module zelf nodig heeft: `CREATE TABLE IF NOT EXISTS` in een `ensure_tables()`/`*_ensure()` in de + service, én dezelfde DDL in `sql/school.sql` of `sql/centraal.sql` + `sql/installatie_pivot_26-27.sql`, én een regel + in `DATABASE.md`. Kolommen die later bijkomen: `SHOW COLUMNS` + `ALTER TABLE` in de ensure-functie. +- Tabelnamen met hoofdletters moeten exact kloppen (Linux!); zie §5. +- **Templates**: + - `{% block main_class %}wide{% endblock %}` voor brede tabellen; tabellen met zoeken/sorteren/paginering in de + browser (helpers `perPagina(sleutel, standaard)` en `perPaginaKiezer(...)` uit `base.html`). + - In HTML-attributen (`onsubmit="..."`, `onchange`, `data-...`) altijd `|tojson|forceescape`, anders breekt het + attribuut op de aanhalingstekens (bevestigingsvragen werkten daardoor niet meer). + - Elementen die met het `hidden`-attribuut verborgen worden: zet `[hidden] { display: none !important; }` in de + pagina-CSS (knopstijlen overschrijven het anders). +- **CSV-exports**: `;` als scheidingsteken en UTF-8 met BOM (Excel). **Excel**: openpyxl. +- **Google-fouten** vertalen naar begrijpelijke meldingen (`vriendelijk(e)` in de services) mét de scope die ontbreekt. + +## 3. Nieuw onderdeel toevoegen (checklist) + +1. `services/_service.py` (logica, tabellen) en `blueprints/_bp.py` (routes). +2. Blueprint registreren in `app.py`. +3. Recht in `rechten.ONDERDELEN` (code, naam, categorie). +4. Link in het juiste blok van `templates/dashboard.html` (`{% if mag('code') %}`; voeg de code ook toe aan de + `mag(...)`-voorwaarde van het blok). +5. Nieuwe Google-scope? Toevoegen aan de lijst + tabel in `services/handleidingen_standaard.py` (de handleiding in + de database wordt **niet** automatisch bijgewerkt: zelf aanpassen in 📘 Handleidingen). +6. SQL-bestanden en `DATABASE.md` bijwerken. + +## 4. Overzicht van recente onderdelen + +| Onderdeel | Bestanden | Opmerkingen | +|---|---|---| +| IP-blokkades / whitelist | `ipblokkade_bp.py`, `services/ipblokkade.py` | gewone gebruikers met recht `ip_whitelist` zien/beheren enkel hun eigen school | +| Aanmelden | `blueprints/auth.py` | geen schoolkeuze meer: laatste school → domein → cookie → laagste instellingsnummer; domeinen mogen gedeeld worden door meerdere scholen | +| GO! Pro + verzameltabel | `gopro_bp.py`, `gopro_service.py`, `gopro*.html` | verzameltabel (centraal), rode lijst, verzonden, nachtelijk verzamelen; periode = lopend schooljaar (1/9–31/8) | +| Leerlingfacturatie | `facturatie_bp.py`, `facturatie_service.py` | Informat Invoice API (GraphQL); scope per toegangsgroep (`_`), wordt zelf gezocht | +| Internaat | `internaat_bp.py`, `internaat_service.py`, `aanwezigheid_service.py` | Internaat WEB API; aanwezigheden per week; Smartschool-check "voornaam achternaam" | +| Gmail | `gmail_delegatie_bp.py`, `gmail_labels_bp.py`, `handtekeningen_bp.py`, `contacten_bp.py`, `verzendlijsten_bp.py` | delegaties (+ overzicht), mails verplaatsen, handtekeningen (functie/telefoon uit Google, nachtelijk), contactlijsten in mailboxen + zichtbaarheid adresboek | +| Google | `google_licenties_bp.py`, `gedeelde_drives_bp.py` | licenties (customerId: domein of klant-ID), gedeelde drives: leden/rechten, "waar is iemand lid van", gebruiker vervangen (zelfde rol) | +| Smartschool | `sgr_groepen_bp.py`, `sgr_groepen_service.py` | subgroepen + leden van een hoofdgroep (standaard "groepen GO-Antwerpen") | +| Basisonderwijs | `basisonderwijs_bp.py`, `bingel_service.py` | blok enkel bij Onderwijsniveau = basis; Bingel-Excel (klas uit Informat-inschrijvingen, klasnummer alfabetisch) | +| Toestellen | `devices.py`, `inventaris.html`, `toestellen.html` | eigendom: alle leerlingen + filter met/zonder toestel, historie per leerling en per toestel, knoppen uitdelen/inleveren; Alle toestellen: blokkeren/deblokkeren in Google (kolom `Toestellen.Geblokkeerd`) | +| Dashboard | `dashboard.html` | blokken Gmail, Internaat, Basisonderwijs, Leerlingfacturatie, Links (website + Smartschool) | + +Benodigde Google-scopes (domeinbrede delegatie) staan in 📘 Handleidingen; recent bijgekomen: +`gmail.modify`, `gmail.settings.basic`, `contacts`, `drive`. De bijbehorende API's (Gmail, People, Drive, +Enterprise License Manager) moeten aanstaan in het Cloud-project van het serviceaccount. + +## 5. Omgeving en installatie-aandachtspunten + +- **Windows → Linux**: MySQL op Windows bewaart tabelnamen in kleine letters; op Linux (`lower_case_table_names = 0`) + herstelt `services/tabelnamen.py` dat bij het opstarten en bij 🩺 Testen (enkel voor de tabellen die PIVOT kent; + zijn beide versies gevuld, dan enkel een melding in het logboek). +- **"1040 Too many connections"**: (aantal scholen + 1) × `pool_size` per PIVOT-proces moet onder `max_connections` + van MySQL blijven. Oplossing: `SET PERSIST max_connections = 500;` of `pool_size` verlagen. +- **Productie**: liefst nginx (HTTPS, Let's Encrypt) + gunicorn/waitress i.p.v. de Flask-ontwikkelserver, en de + poort van PIVOT enkel intern openzetten. Het logo van Gmail-handtekeningen (`/handtekening-logo/...`) moet wel + publiek bereikbaar zijn (de IP-blokkering slaat die route over). + +## 6. Nog niet getest tegen de echte omgeving / openstaand + +Onderstaande onderdelen werden enkel getest met nagebootste antwoorden; bij het eerste echte gebruik goed nakijken: + +- **Bingel**: het veld met de klas in de Informat-inschrijvingen (`inschrKlassen`); de pagina toont de veldnamen + die Informat teruggeeft als iets leeg blijft. +- **SGR groepen**: formaat van `getAllGroupsAndClasses` (XML/base64) en de leden per groep. +- **Contactlijsten** (People API), **gedeelde drives** (Drive API), **handtekeningen** (Gmail sendAs). +- **Licenties**: terugval op de klant-ID als het schooldomein geen primair domein is. +- **Leerlingfacturatie**: automatisch zoeken van de scope (identityserver). +- Informat-fout "SqlDateTime overflow" bij één school = foute datum in Informat zelf (niet in PIVOT). + +## 7. Verder werken + +- Elke wijziging werd telkens als volledige zip van `Pivot_1.1.0` aangeleverd (zonder `__pycache__`). +- Bij een nieuwe vraag aan een AI-assistent: geef dit bestand, `DATABASE.md`, `MEERDERE_SCHOLEN.md` en de + betrokken bestanden mee (of de hele zip zonder `config.ini`/`geheimen/`), en beschrijf wat je wil met een + schermafbeelding erbij. diff --git a/README.Docker.md b/README.Docker.md new file mode 100644 index 0000000..54cff9e --- /dev/null +++ b/README.Docker.md @@ -0,0 +1,22 @@ +### Building and running your application + +When you're ready, start your application by running: +`docker compose up --build`. + +Your application will be available at http://localhost:5001. + +### Deploying your application to the cloud + +First, build your image, e.g.: `docker build -t myapp .`. +If your cloud uses a different CPU architecture than your development +machine (e.g., you are on a Mac M1 and your cloud provider is amd64), +you'll want to build the image for that platform, e.g.: +`docker build --platform=linux/amd64 -t myapp .`. + +Then, push it to your registry, e.g. `docker push myregistry.com/myapp`. + +Consult Docker's [getting started](https://docs.docker.com/go/get-started-sharing/) +docs for more detail on building and pushing. + +### References +* [Docker's Python guide](https://docs.docker.com/language/python/) \ No newline at end of file diff --git a/app.py b/app.py new file mode 100644 index 0000000..fbfd840 --- /dev/null +++ b/app.py @@ -0,0 +1,301 @@ +import os +from flask import Flask, render_template, redirect, url_for +from config import Config +import tenancy +import rechten +from tenancy import school_op_code, school_voor_host, GeenSchoolFout, KoppelingOntbreekt + +#----------------------------------------------------- +# Blueprints importeren +#----------------------------------------------------- +from blueprints.devices import devices_bp +from blueprints.students import students_bp +from blueprints.repairs import repairs_bp +from blueprints.auth import auth_bp, oauth # OAuth toegevoegd aan import +from blueprints.dashboard_bp import main_bp +from blueprints.google_admin import google_bp +from blueprints.admin import admin_bp +from blueprints.tools import tools_bp +from blueprints.co2_sensor import co2_sensor_bp +from blueprints.smartschool_bp import smartschool_bp +from blueprints.leerlingenaantal_bp import leerlingenaantal_bp +from blueprints.handleidingen_bp import handleidingen_bp, lijst as handleidingen_lijst, mag_lezen as handleidingen_mag_lezen +from blueprints.nieuw_schooljaar_accounts_bp import account_verplaatsen_bp +from blueprints.nieuw_schooljaar_toestellen_bp import toestellen_verplaatsen_bp +from blueprints.qr_code_bp import qr_code_bp +from blueprints.smartschool_activiteit_bp import ss_bp +from services.smartschool_api import SmartschoolService +from blueprints.bugreport_bp import bugreport_bp +from blueprints.bugreport_overzicht_bp import bugreport_overzicht_bp +from blueprints.signpost_devices_bp import signpost_devices_bp +from blueprints.google_licenties_bp import google_licenties_bp +from blueprints.gmail_delegatie_bp import gmail_delegatie_bp +from blueprints.afwezigheden_bp import afwezigheden_bp +from services.afwezigheden_service import AfwezighedenService +from blueprints.tweestaps_bp import tweestaps_bp +from services.tweestaps_service import TweestapsService +from blueprints.personeel_bp import personeel_bp +from blueprints.scholen_bp import scholen_bp +from blueprints.skore_bp import skore_bp +from services import skore_service +from services import personeel_service +from flask import session, request, flash, g +#------------------------------------------------------ +# Services importeren +#------------------------------------------------------ +from services.co2_service import CO2Scanner + + +def create_app(): + # Maak de app aan + app = Flask(__name__) + app.config.from_object(Config) + if Config.PROXY_AANTAL: + # Achter een reverse proxy: het echte IP-adres komt uit X-Forwarded-For (enkel van die proxy's vertrouwd) + from werkzeug.middleware.proxy_fix import ProxyFix + n = Config.PROXY_AANTAL + app.wsgi_app = ProxyFix(app.wsgi_app, x_for=n, x_proto=n, x_host=n) + # Handleidingen bevatten schermafbeeldingen (base64); Flask laat standaard maar 500 kB formulierdata toe + app.config['MAX_FORM_MEMORY_SIZE'] = 30 * 1024 * 1024 + + #------------------------------------------------------ + # OAuth Initialisatie + #------------------------------------------------------ + oauth.init_app(app) + + + # Google-login: clients worden per school (of gedeeld) aangemaakt in blueprints/auth.py + + # Centrale tabellen (Scholen, Gebruikers, rechten, Logs, Bugs) aanmaken indien nodig + try: + tenancy.ensure_schema() + except Exception as e: + print(f"PIVOT: centrale database niet bereikbaar bij opstarten: {e}", flush=True) + # Databases van de scholen: tabelnamen met hoofdletters herstellen (Windows → Linux), zie services/tabelnamen.py + try: + from services.tabelnamen import herstel_alle_scholen + herstel_alle_scholen() + except Exception as e: + print(f"PIVOT: tabelnamen van de scholen nakijken mislukt: {e}", flush=True) + + #------------------------------------------------------ + # Blueprints registreren + #------------------------------------------------------ + # Registreert alle url prefixen voor zichtbaarheid en om naamconflicten te vermijden + app.register_blueprint(auth_bp) + app.register_blueprint(main_bp) + app.register_blueprint(devices_bp, url_prefix='/devices') + app.register_blueprint(students_bp, url_prefix='/leerlingen') + app.register_blueprint(repairs_bp, url_prefix='/herstellingen') + app.register_blueprint(google_bp, url_prefix='/google') + app.register_blueprint(admin_bp, url_prefix='/admin') + app.register_blueprint(tools_bp, url_prefix='/tools') + app.register_blueprint(co2_sensor_bp, url_prefix='/co2') + app.register_blueprint(smartschool_bp, url_prefix='/ss') + + + #------------------------- + app.register_blueprint(leerlingenaantal_bp, url_prefix='/scholengroep') + from blueprints.gopro_bp import gopro_bp + app.register_blueprint(gopro_bp, url_prefix='/scholengroep/gopro') + from blueprints.facturatie_bp import facturatie_bp + app.register_blueprint(facturatie_bp, url_prefix='/leerlingfacturatie') + from blueprints.gmail_labels_bp import gmail_labels_bp + app.register_blueprint(gmail_labels_bp, url_prefix='/google/labels-verplaatsen') + from blueprints.handtekeningen_bp import handtekeningen_bp, handtekening_logo_bp + app.register_blueprint(handtekeningen_bp, url_prefix='/google/handtekeningen') + app.register_blueprint(handtekening_logo_bp) + from blueprints.contacten_bp import contacten_bp + from blueprints.gedeelde_drives_bp import gedeelde_drives_bp + app.register_blueprint(gedeelde_drives_bp, url_prefix='/google/gedeelde-drives') + from blueprints.basisonderwijs_bp import basisonderwijs_bp + app.register_blueprint(basisonderwijs_bp, url_prefix='/basisonderwijs') + app.register_blueprint(contacten_bp, url_prefix='/google/contacten') + from blueprints.internaat_bp import internaat_bp + app.register_blueprint(internaat_bp, url_prefix='/internaat') + from blueprints.sgr_groepen_bp import sgr_groepen_bp + app.register_blueprint(sgr_groepen_bp, url_prefix='/smartschool/sgr-groepen') + from blueprints.personeelsaantal_bp import personeelsaantal_bp + app.register_blueprint(personeelsaantal_bp, url_prefix='/personeelsaantallen') + #--------------------------- + + #------------------------- + app.register_blueprint(account_verplaatsen_bp, url_prefix='/nieuw_schooljaar') + #--------------------------- + + #---------------------------- + app.register_blueprint(toestellen_verplaatsen_bp, url_prefix='/nieuw_schooljaar_toestellen') + #--------------------------- + + #--------------------------- + app.register_blueprint(qr_code_bp, url_prefix='/qr_code') + #---------------------------- + + #----------------------------- + app.register_blueprint(ss_bp,url_prefix='/activiteit') + #------------------------------ + + #----------------------------- + app.register_blueprint(bugreport_bp,url_prefix='/bugreport') + app.register_blueprint(bugreport_overzicht_bp,url_prefix='/bugreport_overzicht') + #------------------------------ + app.register_blueprint(signpost_devices_bp,url_prefix='/signpost_devices') + app.register_blueprint(google_licenties_bp, url_prefix='/google_licenties') + app.register_blueprint(gmail_delegatie_bp, url_prefix='/gmail_delegatie') + app.register_blueprint(afwezigheden_bp, url_prefix='/afwezigheden') + app.register_blueprint(tweestaps_bp, url_prefix='/2fa') + app.register_blueprint(personeel_bp, url_prefix='/personeel') + app.register_blueprint(scholen_bp, url_prefix='/scholen') + app.register_blueprint(skore_bp, url_prefix='/skore') + app.register_blueprint(handleidingen_bp, url_prefix='/handleidingen') + from blueprints.verzendlijsten_bp import verzendlijsten_bp + app.register_blueprint(verzendlijsten_bp, url_prefix='/google/verzendlijsten') + from blueprints.ipblokkade_bp import ipblokkade_bp + app.register_blueprint(ipblokkade_bp, url_prefix='/beheer/ip-blokkades') + + #------------------------------------------------------ + # Welke school? (per request) + #------------------------------------------------------ + # Endpoints die zonder gekozen school werken + ZONDER_SCHOOL = ('static', 'index', 'auth.', 'scholen.') + # Publieke pagina's (zonder login): school via hostnaam of ?school=code + PUBLIEK = ('tools.',) + + # IP-blokkades: als eerste controle, voor elke aanvraag (ook de loginpagina en statische bestanden) + @app.before_request + def ip_blokkade(): + from services import ipblokkade + if request.endpoint == 'handtekening_logo.logo': + return None # het logo van de Gmail-handtekening moet voor iedereen (ook Google) bereikbaar zijn + if ipblokkade.controleer(request.remote_addr): + return ("Toegang geweigerd" + "
" + "

Toegang geweigerd

Je hebt geen toegang tot deze site.

", 403) + return None + + @app.before_request + def bepaal_school(): + ep = request.endpoint or '' + host_school = school_voor_host(request.host) + g.school = None + + if session.get('auth'): + code = session.get('school') + school = school_op_code(code) if code else None + if code and school is None: + session.clear() + flash("Je school is niet (meer) actief in PIVOT. Log opnieuw in.", "error") + return redirect(url_for('auth.login')) + if host_school and school and host_school.code != school.code and not session.get('superbeheerder'): + # Sessie van een andere school op het adres van deze school → opnieuw inloggen + session.clear() + return redirect(url_for('auth.login')) + g.school = school + if school is None and not ep.startswith(ZONDER_SCHOOL): + # Superbeheerder zonder gekozen school + return redirect(url_for('scholen.overzicht')) + else: + g.school = host_school + if g.school is None and ep.startswith(PUBLIEK): + g.school = school_op_code(request.args.get('school')) + if g.school is None and ep.startswith(PUBLIEK): + flash("Kies eerst een school.", "error") + return redirect(url_for('auth.login')) + return None + + @app.errorhandler(GeenSchoolFout) + def geen_school(e): + session.clear() + flash(str(e), "error") + return redirect(url_for('auth.login')) + + @app.errorhandler(KoppelingOntbreekt) + def koppeling_ontbreekt(e): + flash(str(e), "error") + return redirect(url_for('main.dashboard')) + + # Leerkrachten die via Google inloggen (groep 5) hebben enkel toegang tot de personeelsmodule + @app.before_request + def beperk_leerkrachten(): + if session.get('security_group') != personeel_service.LEERKRACHT_GROEP: + return None + ep = request.endpoint or '' + if ep == 'static' or ep.startswith('personeel.') or ep.startswith('auth.'): + return None + if ep not in ('main.dashboard', 'index'): + flash("Je hebt enkel toegang tot de module afwezigheden en activiteiten.", "error") + return redirect(url_for('personeel.mijn')) + + # Dagelijkse synchronisatie van afwezigheden (B-codes) uit Smartschool. + # In debugmodus start Flask twee processen; enkel het 'echte' proces start de taak. + # Eén achtergrondtaak per soort, die telkens alle scholen overloopt. + if not app.debug or os.environ.get('WERKZEUG_RUN_MAIN') == 'true': + AfwezighedenService.start_scheduler() + TweestapsService.start_scheduler() # verlopen 2FA-uitzonderingen automatisch beëindigen + skore_service.start_scheduler() # geplande Skore-synchronisatie per school + from services import gopro_service + gopro_service.start_scheduler() # GO! Pro: 's nachts de groene naar de verzameltabel (als de schakelaar aan staat) + from services import handtekening_service + handtekening_service.start_scheduler() # Gmail-handtekeningen 's nachts opnieuw zetten (per school, schakelaar) + #------------------------------ + + @app.context_processor + def inject_school_info(): + school = g.get('school') + info = { + 'school': school, + 'version': app.config.get('APP_VERSION'), + 'is_superbeheerder': bool(session.get('superbeheerder')), + 'school_name': school.naam if school else '', + 'school_name_short': school.code if school else '', + 'contact_email': school.get('CONTACT_EMAIL') if school else '', + 'personeel_rollen': set(), + # Rechten: {% if mag('herstellingen') %} ... {% endif %} + 'mag': rechten.heeft_recht, + 'is_admin': rechten.is_admin(), + 'andere_scholen': [], + 'handleidingen_lijst': handleidingen_lijst, + 'mag_handleidingen': handleidingen_mag_lezen, # functie: enkel opgehaald waar gebruikt (dashboard) + } + if school and session.get('auth'): + info['personeel_rollen'] = personeel_service.rollen(session) + info['andere_scholen'] = mijn_andere_scholen(school) + return info + + def mijn_andere_scholen(school): + """Andere scholen waarin de ingelogde gebruiker rechten heeft (om te wisselen).""" + try: + if rechten.is_admin(): + return [s for s in tenancy.alle_scholen() if s.code != school.code] + gid = session.get('gebruiker_id') + if not gid: + return [] + ids = rechten.scholen_met_rechten(gid) + return [s for s in tenancy.alle_scholen() if s.id in ids and s.code != school.code] + except Exception: + return [] + + # Logboek: elke bezochte pagina van een ingelogde gebruiker → tabel Logs (centrale database) + LOG_OVERSLAAN = ('static', 'co2_sensor.get_scan_status', 'co2_sensor.get_cleanup_status', + 'afwezigheden.status') + LOGBOEK = Config.LOGBOEK + + @app.after_request + def logboek(response): + if LOGBOEK and session.get('auth') and request.endpoint and request.endpoint not in LOG_OVERSLAAN \ + and response.status_code < 500: + rechten.log(actie=request.method if request.method != 'GET' else None) + return response + + # Definieert de root, wordt uitgevoerd zodra iemand op de startpagina komt + @app.route('/') + def index(): + return redirect(url_for('auth.login')) + + return app + +# Start de applicatie +if __name__ == "__main__": + app = create_app() + # Debug aanzetten via config.ini › [algemeen] › debug = ja + app.run(debug=Config.DEBUG, host='0.0.0.0', port=5001) \ No newline at end of file diff --git a/beheer.py b/beheer.py new file mode 100644 index 0000000..4406cb5 --- /dev/null +++ b/beheer.py @@ -0,0 +1,292 @@ +""" +Beheer van PIVOT vanaf de opdrachtregel. + + python beheer.py init + Maakt de tabellen van de centrale database aan (Scholen, Gebruikers, rechten, Logs, Bugs). + + python beheer.py nieuwe-schooldatabase DATABASE + Maakt een lege schooldatabase met de standaardstructuur (sql/school.sql). + + python beheer.py gebruiker NAAM [--wachtwoord W] [--email E] [--admin] + Maakt een PIVOT-gebruiker aan, of zet een nieuw wachtwoord / maakt hem administrator. + + python beheer.py rechten NAAM SCHOOLCODE [onderdeel ...|alles|geen] + Toont of zet de rechten van een gebruiker in een school. + + python beheer.py importeer-config OUDE_CONFIG.ini [--google-sleutel sleutel.json] [--code kamxm] [--db Pivot_26-27] + Zet een school uit een config.ini van PIVOT 1.0.x in het scholenregister + (alle wachtwoorden en koppelingen worden versleuteld overgenomen). + + python beheer.py lijst + Toont alle scholen. + + python beheer.py kopieer-school BRONCODE NIEUWE_DATABASE + Maakt een nieuwe database als volledige kopie (tabellen + gegevens) van de database van BRONCODE. + Registreer ze daarna als school via PIVOT (Nieuwe school › Database aanmaken? › Nee, bestaat al). + + python beheer.py nieuwe-sleutel + Toont een nieuwe versleutelingssleutel (voor PIVOT_SLEUTEL). +""" +import argparse +import configparser +import json +import os +import sys + +from cryptography.fernet import Fernet + +# Oude config.ini → velden in het scholenregister +MAPPING = { + ('schoolgegevens', 'admin_email'): 'ADMIN_EMAIL', + ('schooljaar', 'huidig_schooljaar'): 'SCHOOLYEAR', + ('google', 'ou_nieuwe_lln'): 'STUDENT_OU', + ('google', 'google_sso_clientid'): 'GOOGLE_SSO_CLIENTID', + ('google', 'google_sso_clientsecret'): 'GOOGLE_SSO_CLIENTSECRET', + ('google', 'google_smtp_server'): 'GOOGLE_SMTP_SERVER', + ('google', 'google_smtp_port'): 'GOOGLE_SMTP_PORT', + ('google', 'google_sender_adres'): 'GOOGLE_SMTP_SENDER_adres', + ('google', 'google_email_passsword'): 'GOOGLE_SMTP_EMAIL_PASSWORD', + ('google', 'google_api_scopes'): 'GOOGLE_API_SCOPES_RAW', + ('google', 'tweestaps_uitzondering_groep'): 'TWEESTAPS_UITZONDERING_GROEP', + ('google', 'tweestaps_ous'): 'TWEESTAPS_OUS', + ('google', 'tweestaps_groepen'): 'TWEESTAPS_GROEPEN_RAW', + ('informat', 'client_id'): 'INFORMAT_ID', + ('informat', 'client_secret'): 'INFORMAT_SECRET', + ('informat', 'intitute_number'): 'INFORMAT_INST_NUMBER', + ('informat', 'autho_url'): 'INFORMAT_AUTH_URL', + ('informat', 'api_base'): 'INFORMAT_API_BASE', + ('informat', 'scope'): 'INFORMAT_SCOPE', + ('smartschool', 'ss_client_id'): 'SS_CLIENT_ID', + ('smartschool', 'ss_client_secret'): 'SS_CLIENT_SECRET', + ('smartschool', 'ss_api_pw'): 'SS_API_PW', + ('smartschool', 'ss_api_platform'): 'SS_API_PLATFORM', + ('smartschool', 'afwezigheid_codes'): 'AFWEZIGHEID_CODES', + ('smartschool', 'afwezigheid_sync_uur'): 'AFWEZIGHEID_SYNC_UUR', + ('CO2_sensor', 'co2_netwerk'): 'CO2_NETWORK', + ('CO2_sensor', 'co2_beginadres'): 'CO2_START_ADDR', + ('CO2_sensor', 'co2_eindadres'): 'CO2_END_ADDR', + ('CO2_sensor', 'co2_waardedrempel'): 'CO2_THRESHOLD', + ('CO2_sensor', 'co2_scan_interval'): 'CO2_INTERVAL', + ('personeel', 'personeel_ous'): 'PERSONEEL_OUS', + ('personeel', 'directie'): 'PERSONEEL_DIRECTIE', + ('personeel', 'secretariaat'): 'PERSONEEL_SECRETARIAAT', + ('personeel', 'secretariaat_mail'): 'PERSONEEL_SECRETARIAAT_MAIL', + ('personeel', 'mails_versturen'): 'PERSONEEL_MAILS', + ('m4s', 'm4s_base_data_url'): 'M4S_BASE_URL', + ('m4s', 'm4s_auth_url'): 'M4S_AUTH_URL', + ('m4s', 'm4s_clientid'): 'M4S_CLIENT_ID', + ('m4s', 'm4s_clientsecret'): 'M4S_CLIENT_SECRET', + ('m4s', 'm4s_institutionguid'): 'M4S_INSTITUTIONGUID', + ('m4s', 'm4s_repair_location_type'): 'M4S_REPAIR_LOCATION_TYPE', + ('m4s', 'm4s_repair_status'): 'M4S_REPAIR_STATUS', + ('m4s', 'm4s_truth_statement'): 'M4S_TRUTH_STATEMENT', + ('m4s', 'm4s_repair_at_institution'): 'M4S_REPAIR_AT_INTITUTION', + ('m4s', 'm4s_contact_first_name'): 'M4S_CONTACT_FIRST_NAME', + ('m4s', 'm4s_contact_last_name'): 'M4S_CONTACT_LAST_NAME', + ('m4s', 'm4s_contact_email'): 'M4S_CONTACT_EMAIL', + ('m4s', 'm4s_contact_phone'): 'M4S_CONTACT_PHONE', + ('m4s', 'm4s_repair_street'): 'M4S_REPAIR_STREET', + ('m4s', 'm4s_repair_streetnumber'): 'M4S_REPAIR_STREETNUMBER', + ('m4s', 'm4s_repair_zipcode'): 'M4S_REPAIR_ZIPCODE', + ('m4s', 'm4s_repair_city'): 'M4S_REPAIIR_CITY', + ('m4s', 'm4s_repair_countrycode'): 'M4S_REPAIR_COUNTRYCODE', + ('m4s', 'm4s_language_code'): 'M4S_LANGUAGE_CODE', + ('m4s', 'm4s_billing_first_name'): 'M4S_BILLING_FIRST_NAME', + ('m4s', 'm4s_billing_last_name'): 'M4S_BILLING_LAST_NAME', + ('m4s', 'm4s_billing_email'): 'M4S_BILLING_EMAIL', + ('m4s', 'm4s_billing_phone'): 'M4S_BILLING_PHONE', +} + + +def lees_oude_config(pad): + cp = configparser.ConfigParser() + if not cp.read(pad, encoding='utf-8'): + sys.exit(f"Kan {pad} niet lezen.") + waarden = {} + for (sectie, key), veld in MAPPING.items(): + if cp.has_option(sectie, key): + v = cp.get(sectie, key).strip() + if v and v != 'None': + waarden[veld] = v + # Smartschool-platform zonder https:// en pad + if 'SS_API_PLATFORM' in waarden: + waarden['SS_API_PLATFORM'] = waarden['SS_API_PLATFORM'].replace('https://', '').replace('http://', '').split('/')[0] + return cp, waarden + + +def cmd_importeer(args): + import tenancy + from config import Config + cp, waarden = lees_oude_config(args.config) + code = (args.code or cp.get('schoolgegevens', 'school_afkorting', fallback='')).strip().lower() + naam = cp.get('schoolgegevens', 'schoolnaam', fallback=code).strip() + domein = cp.get('schoolgegevens', 'schooldomein', fallback='').strip().lower() + db_naam = args.db or cp.get('database', 'database_name', fallback='').strip() + if not code or not domein or not db_naam: + sys.exit("Code, schooldomein of databasenaam ontbreekt (gebruik --code / --db).") + + # Eigen databaseserver/gebruiker enkel overnemen als die verschilt van de nieuwe config.ini + for key, veld, standaard in [('host', 'DB_HOST', Config.DB_HOST), ('user', 'DB_USER', Config.DB_USER), + ('password', 'DB_PASSWORD', Config.DB_PASSWORD)]: + v = cp.get('database', key, fallback='').strip() + if v and v != standaard: + waarden[veld] = v + + # Google-serviceaccount + sleutel = None + kandidaten = [args.google_sleutel] if args.google_sleutel else [ + cp.get('google', 'location_linux', fallback=''), cp.get('google', 'location', fallback='')] + for pad in kandidaten: + if pad and os.path.exists(pad): + with open(pad, encoding='utf-8') as f: + sleutel = json.load(f) + print(f"Google-sleutel gelezen uit {pad}") + break + if not sleutel: + print("⚠ Geen Google-sleutel gevonden; upload hem later via /scholen (of gebruik --google-sleutel).") + + tenancy.ensure_schema() + bestaand = tenancy.school_op_code(code, ook_inactief=True) + if bestaand and not args.overschrijven: + sys.exit(f"School '{code}' bestaat al. Gebruik --overschrijven om bij te werken.") + tenancy.opslaan(code, naam, domein, '', db_naam, True, waarden, sleutel, + gebruiker='beheer.py', nieuw=not bestaand) + print(f"✓ School {naam} ({code}) opgeslagen: domein {domein}, database {db_naam}, " + f"{len(waarden)} instellingen{', Google-sleutel' if sleutel else ''}.") + + +def cmd_lijst(args): + import tenancy + tenancy.ensure_schema() + scholen = tenancy.alle_scholen(ook_inactief=True) + if not scholen: + print("Nog geen scholen.") + for s in scholen: + print(f"{s.code:<12} {s.naam:<40} {','.join(s.domeinen):<30} db={s.db_naam:<20} " + f"{'actief' if s.actief else 'INACTIEF'}{' google✓' if s.google_sleutel else ''}") + + +def cmd_kopieer(args): + import tenancy + from blueprints.scholen_bp import kopieer_school_db + bron = tenancy.school_op_code(args.bron, ook_inactief=True) + if not bron: + sys.exit(f"Onbekende school {args.bron}") + ok, msg = kopieer_school_db(bron, args.database, {}) + print(msg) + sys.exit(0 if ok else 1) + + +def cmd_nieuwe_schooldatabase(args): + from blueprints.scholen_bp import maak_schooldatabase + ok, msg = maak_schooldatabase(args.database) + print(msg) + sys.exit(0 if ok else 1) + + +def cmd_gebruiker(args): + import getpass + import tenancy + import rechten + from database import central_query + from werkzeug.security import generate_password_hash + tenancy.ensure_schema() + pw = args.wachtwoord + bestaand = rechten.zoek_gebruiker(args.naam) + if pw is None and not bestaand: + pw = getpass.getpass("Wachtwoord: ") + groep = rechten.ADMIN if args.admin else None + if bestaand: + gid = bestaand['Gebruiker_ID'] + if pw: + central_query("UPDATE Gebruikers SET Passwoordhash = %s WHERE Gebruiker_ID = %s", + (generate_password_hash(pw), gid), commit=True) + if args.email: + central_query("UPDATE Gebruikers SET Emailadres = %s WHERE Gebruiker_ID = %s", + (args.email.lower(), gid), commit=True) + if groep: + central_query("UPDATE Gebruikers SET Veiligheidsgroep = %s, Actief = 1 WHERE Gebruiker_ID = %s", + (groep, gid), commit=True) + print(f"✓ Gebruiker {bestaand['Gebruikersnaam']} bijgewerkt.") + else: + central_query("INSERT INTO Gebruikers (Gebruikersnaam, Veiligheidsgroep, Passwoordhash, Emailadres) " + "VALUES (%s, %s, %s, %s)", + (args.naam, groep or rechten.GEBRUIKER, generate_password_hash(pw) if pw else None, + args.email.lower() if args.email else None), commit=True) + print(f"✓ Gebruiker {args.naam} aangemaakt ({'Administrator' if groep else 'Gebruiker'}).") + + +def cmd_rechten(args): + import tenancy + import rechten + g = rechten.zoek_gebruiker(args.naam) + school = tenancy.school_op_code(args.school, ook_inactief=True) + if not g or not school: + sys.exit("Onbekende gebruiker of school.") + if args.onderdelen: + if args.onderdelen == ['alles']: + codes = rechten.CODES + elif args.onderdelen == ['geen']: + codes = [] + else: + onbekend = [c for c in args.onderdelen if c not in rechten.CODES] + if onbekend: + sys.exit(f"Onbekende onderdelen: {', '.join(onbekend)}\nMogelijk: {', '.join(rechten.CODES)}") + codes = args.onderdelen + rechten.zet_rechten(g['Gebruiker_ID'], school.id, codes, door='beheer.py') + huidig = rechten.rechten_van(g['Gebruiker_ID'], school.id) + print(f"{g['Gebruikersnaam']} in {school.naam}:") + for code, naam, cat in rechten.ONDERDELEN: + print(f" [{'x' if code in huidig else ' '}] {code:<26} {naam}") + + +def main(): + p = argparse.ArgumentParser(description="PIVOT beheer") + sub = p.add_subparsers(dest='cmd', required=True) + sub.add_parser('init') + i = sub.add_parser('importeer-config') + i.add_argument('config') + i.add_argument('--code') + i.add_argument('--db') + i.add_argument('--google-sleutel') + i.add_argument('--overschrijven', action='store_true') + sub.add_parser('lijst') + k = sub.add_parser('kopieer-school', aliases=['kopieer-schema']) + k.add_argument('bron') + k.add_argument('database') + sub.add_parser('nieuwe-sleutel') + n = sub.add_parser('nieuwe-schooldatabase') + n.add_argument('database') + gb = sub.add_parser('gebruiker') + gb.add_argument('naam') + gb.add_argument('--wachtwoord') + gb.add_argument('--email') + gb.add_argument('--admin', action='store_true') + r = sub.add_parser('rechten') + r.add_argument('naam') + r.add_argument('school') + r.add_argument('onderdelen', nargs='*') + args = p.parse_args() + + if args.cmd == 'nieuwe-sleutel': + print(Fernet.generate_key().decode()) + elif args.cmd == 'init': + import tenancy + tenancy.ensure_schema() + print("✓ Centrale tabellen aangemaakt (of bestonden al).") + elif args.cmd == 'importeer-config': + cmd_importeer(args) + elif args.cmd == 'lijst': + cmd_lijst(args) + elif args.cmd in ('kopieer-school', 'kopieer-schema'): + cmd_kopieer(args) + elif args.cmd == 'nieuwe-schooldatabase': + cmd_nieuwe_schooldatabase(args) + elif args.cmd == 'gebruiker': + cmd_gebruiker(args) + elif args.cmd == 'rechten': + cmd_rechten(args) + + +if __name__ == '__main__': + main() diff --git a/blueprints/__init__.py b/blueprints/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/blueprints/admin.py b/blueprints/admin.py new file mode 100644 index 0000000..83fa8a5 --- /dev/null +++ b/blueprints/admin.py @@ -0,0 +1,291 @@ +""" +Gebruikers & rechten. + +- Administrators: alle gebruikers, alle scholen, rol (Administrator/Gebruiker) wijzigen. +- Gebruikers met het recht 'gebruikersbeheer' in een school: gebruikers aanmaken en hun + rechten in DIE school(en) instellen. Ze kunnen geen administrators wijzigen. + +Gebruikers staan in de centrale database (één account voor alle scholen); de rechten per +school in Gebruiker_rechten. +""" +from collections import OrderedDict + +from flask import Blueprint, render_template, request, session, redirect, url_for, flash +from werkzeug.security import generate_password_hash + +import rechten +from rechten import ADMIN, GEBRUIKER, VEILIGHEIDSGROEPEN, ONDERDELEN, is_admin +from database import central_query, central_insert +from tenancy import alle_scholen, school_op_code + +admin_bp = Blueprint('admin', __name__) + +MIN_WACHTWOORD = 8 + + +# ------------------------------------------------------------------ +# Hulpfuncties +# ------------------------------------------------------------------ +def beheerbare_scholen(): + """Scholen waarvan de ingelogde gebruiker de rechten mag instellen.""" + scholen = alle_scholen(ook_inactief=True) + if is_admin(): + return scholen + gid = session.get('gebruiker_id') + if not gid: + return [] + rows = central_query( + "SELECT r.School_ID FROM Gebruiker_rechten r JOIN Onderdelen o ON o.Onderdeel_ID = r.Onderdeel_ID " + "WHERE r.Gebruiker_ID = %s AND o.Code = 'gebruikersbeheer'", (gid,)) + ids = {r['School_ID'] for r in rows} + return [s for s in scholen if s.id in ids] + + +def _toegang(): + """None als de gebruiker hier mag komen, anders een redirect.""" + if not session.get('auth') or not session.get('school'): + return redirect(url_for('auth.login')) + if not is_admin() and not rechten.heeft_recht('gebruikersbeheer'): + flash("Je hebt geen rechten om gebruikers te beheren.", "error") + return redirect(url_for('main.dashboard')) + return None + + +def _aantal_admins(behalve=None): + rows = central_query("SELECT COUNT(*) AS n FROM Gebruikers WHERE Veiligheidsgroep = %s AND Actief = 1 " + "AND Gebruiker_ID <> %s", (ADMIN, behalve or 0)) + return rows[0]['n'] + + +def _onderdelen_per_categorie(): + groepen = OrderedDict() + for code, naam, cat in ONDERDELEN: + groepen.setdefault(cat, []).append((code, naam)) + return groepen + + +def _wachtwoord_ok(pw, pw2): + if pw != pw2: + return "De wachtwoorden komen niet overeen." + if len(pw) < MIN_WACHTWOORD: + return f"Het wachtwoord moet minstens {MIN_WACHTWOORD} tekens hebben." + return None + + +# ------------------------------------------------------------------ +# Overzicht + nieuwe gebruiker +# ------------------------------------------------------------------ +@admin_bp.route("/settings", methods=["GET"]) +def settings(): + weg = _toegang() + if weg: + return weg + users = central_query( + "SELECT g.Gebruiker_ID, g.Gebruikersnaam, g.Emailadres, g.Veiligheidsgroep, g.Actief, g.Last_login, " + " v.Naam AS Rol " + "FROM Gebruikers g JOIN Veiligheidsgroepen v ON v.Veiligheidsgroep = g.Veiligheidsgroep " + "ORDER BY g.Veiligheidsgroep, g.Gebruikersnaam") + if not is_admin(): + users = [u for u in users if int(u['Veiligheidsgroep']) != ADMIN] + + # Aantal rechten per gebruiker per school (voor het overzicht) + per = {} + for r in central_query("SELECT Gebruiker_ID, School_ID, COUNT(*) AS n FROM Gebruiker_rechten " + "GROUP BY Gebruiker_ID, School_ID"): + per.setdefault(r['Gebruiker_ID'], {})[r['School_ID']] = r['n'] + scholen = {s.id: s for s in alle_scholen(ook_inactief=True)} + for u in users: + u['scholen'] = sorted(((scholen[sid].naam, n) for sid, n in per.get(u['Gebruiker_ID'], {}).items() + if sid in scholen), key=lambda x: (-x[1], x[0].lower())) + + return render_template("admin_accounts.html", users=users, groepen=VEILIGHEIDSGROEPEN, + ADMIN=ADMIN, totaal_onderdelen=len(ONDERDELEN)) + + +@admin_bp.route("/gebruiker/nieuw", methods=["POST"]) +def nieuw(): + weg = _toegang() + if weg: + return weg + naam = (request.form.get('username') or '').strip() + email = (request.form.get('email') or '').strip().lower() or None + pw = request.form.get('password') or '' + pw2 = request.form.get('password_confirm') or '' + groep = int(request.form.get('veiligheidsgroep') or GEBRUIKER) + if groep not in VEILIGHEIDSGROEPEN or (groep == ADMIN and not is_admin()): + groep = GEBRUIKER + + fout = None + if not naam: + fout = "Vul een gebruikersnaam in." + elif not pw and not email: + fout = "Geef een wachtwoord, of een e-mailadres om met Google in te loggen." + elif pw: + fout = _wachtwoord_ok(pw, pw2) + if not fout and rechten.zoek_gebruiker(naam): + fout = f"Er bestaat al een gebruiker '{naam}'." + if not fout and email and rechten.zoek_gebruiker(email): + fout = f"Het e-mailadres {email} is al in gebruik." + if fout: + flash(fout, "error") + return redirect(url_for('admin.settings', nieuw=1)) + + nieuw_id = central_insert( + "INSERT INTO Gebruikers (Gebruikersnaam, Veiligheidsgroep, Passwoordhash, Emailadres, Actief) " + "VALUES (%s, %s, %s, %s, 1)", + (naam, groep, generate_password_hash(pw) if pw else None, email)) + rechten.log(actie=f"Gebruiker {naam} aangemaakt") + if groep == ADMIN: + flash(f"Administrator '{naam}' aangemaakt.", "success") + return redirect(url_for('admin.settings')) + flash(f"Gebruiker '{naam}' aangemaakt. Vink nu aan wat hij per school mag.", "success") + return redirect(url_for('admin.gebruiker_rechten', gid=nieuw_id)) + + +# ------------------------------------------------------------------ +# Eén gebruiker: account (gegevens, rol, wachtwoord) +# ------------------------------------------------------------------ +def _gebruiker_of_terug(gid): + """(gebruiker, None) of (None, redirect).""" + u = rechten.gebruiker_op_id(gid) + if not u: + flash("Onbekende gebruiker.", "error") + return None, redirect(url_for('admin.settings')) + if int(u['Veiligheidsgroep']) == ADMIN and not is_admin(): + flash("Enkel een administrator kan een administrator wijzigen.", "error") + return None, redirect(url_for('admin.settings')) + return u, None + + +@admin_bp.route("/gebruiker/", methods=["GET", "POST"]) +def gebruiker(gid): + weg = _toegang() + if weg: + return weg + u, weg = _gebruiker_of_terug(gid) + if weg: + return weg + zelf = gid == session.get('gebruiker_id') + if request.method == 'POST': + return _bewaar_account(u, zelf) + return render_template("admin_gebruiker.html", u=u, groepen=VEILIGHEIDSGROEPEN, ADMIN=ADMIN, zelf=zelf, + min_wachtwoord=MIN_WACHTWOORD) + + +# ------------------------------------------------------------------ +# Eén gebruiker: rechten, één school tegelijk +# ------------------------------------------------------------------ +@admin_bp.route("/gebruiker//rechten", methods=["GET", "POST"]) +def gebruiker_rechten(gid): + weg = _toegang() + if weg: + return weg + u, weg = _gebruiker_of_terug(gid) + if weg: + return weg + scholen = beheerbare_scholen() + per_id = {s.id: s for s in scholen} + aantallen = {} + for r in central_query("SELECT School_ID, COUNT(*) AS n FROM Gebruiker_rechten WHERE Gebruiker_ID = %s " + "GROUP BY School_ID", (gid,)): + aantallen[r['School_ID']] = r['n'] + + if request.method == 'POST': + sid = request.form.get('school_id', type=int) + if sid not in per_id: + flash("Je mag de rechten in deze school niet instellen.", "error") + return redirect(url_for('admin.gebruiker_rechten', gid=gid)) + codes = [c for c in rechten.CODES if request.form.get(f"r|{c}")] + doel = [sid] + if request.form.get('ook_alle'): + doel = list(per_id) + else: + doel += [int(x) for x in request.form.getlist('ook') if x.isdigit() and int(x) in per_id] + doel = list(dict.fromkeys(doel)) + door = session.get('user') + for school_id in doel: + rechten.zet_rechten(gid, school_id, codes, door=door) + if len(doel) == 1: + rechten.log(actie=f"Rechten van {u['Gebruikersnaam']} in {per_id[sid].naam} gewijzigd") + flash(f"Rechten van {u['Gebruikersnaam']} in {per_id[sid].naam} opgeslagen.", "success") + else: + rechten.log(actie=f"Rechten van {u['Gebruikersnaam']} in {len(doel)} scholen gewijzigd") + flash(f"Rechten van {u['Gebruikersnaam']} opgeslagen in {len(doel)} scholen.", "success") + return redirect(url_for('admin.gebruiker_rechten', gid=gid, school=sid)) + + # Welke school tonen: gevraagd > huidige school > eerste school met rechten > eerste school + gekozen = request.args.get('school', type=int) + if gekozen not in per_id: + huidig = school_op_code(session.get('school') or '') + huidig_id = huidig.id if huidig else None + if huidig_id in per_id and (aantallen.get(huidig_id) or not aantallen): + gekozen = huidig_id + else: + met = [s.id for s in scholen if aantallen.get(s.id)] + gekozen = met[0] if met else (scholen[0].id if scholen else None) + + lijst = sorted(scholen, key=lambda s: (not aantallen.get(s.id), s.naam.lower())) + return render_template("admin_rechten.html", u=u, scholen=lijst, gekozen=per_id.get(gekozen), + aantallen=aantallen, huidig=rechten.rechten_van(gid, gekozen) if gekozen else set(), + categorieen=_onderdelen_per_categorie(), ADMIN=ADMIN, totaal=len(ONDERDELEN)) + + +def _bewaar_account(u, zelf): + gid = u['Gebruiker_ID'] + email = (request.form.get('email') or '').strip().lower() or None + pw = request.form.get('new_password') or '' + pw2 = request.form.get('new_password_confirm') or '' + actief = 1 if request.form.get('actief') else 0 + groep = int(u['Veiligheidsgroep']) + if is_admin() and request.form.get('veiligheidsgroep'): + groep = int(request.form['veiligheidsgroep']) + if groep not in VEILIGHEIDSGROEPEN: + groep = int(u['Veiligheidsgroep']) + + terug = redirect(url_for('admin.gebruiker', gid=gid)) + if zelf and (not actief or groep != int(u['Veiligheidsgroep'])): + flash("Je kan je eigen rol niet wijzigen en jezelf niet uitschakelen.", "error") + return terug + if int(u['Veiligheidsgroep']) == ADMIN and (groep != ADMIN or not actief) and _aantal_admins(behalve=gid) == 0: + flash("Er moet minstens één actieve administrator overblijven.", "error") + return terug + if email: + ander = rechten.zoek_gebruiker(email) + if ander and ander['Gebruiker_ID'] != gid: + flash(f"Het e-mailadres {email} is al in gebruik.", "error") + return terug + if pw: + fout = _wachtwoord_ok(pw, pw2) + if fout: + flash(fout, "error") + return terug + central_query("UPDATE Gebruikers SET Passwoordhash = %s WHERE Gebruiker_ID = %s", + (generate_password_hash(pw), gid), commit=True) + + central_query("UPDATE Gebruikers SET Emailadres = %s, Veiligheidsgroep = %s, Actief = %s WHERE Gebruiker_ID = %s", + (email, groep, actief, gid), commit=True) + rechten.log(actie=f"Account {u['Gebruikersnaam']} gewijzigd") + flash(f"Account van {u['Gebruikersnaam']} opgeslagen.", "success") + return terug + + +@admin_bp.route("/gebruiker//verwijderen", methods=["POST"]) +def verwijderen(gid): + weg = _toegang() + if weg: + return weg + u = rechten.gebruiker_op_id(gid) + if not u: + return redirect(url_for('admin.settings')) + if not is_admin(): + flash("Enkel een administrator kan gebruikers verwijderen.", "error") + elif gid == session.get('gebruiker_id'): + flash("Je kan jezelf niet verwijderen.", "error") + elif int(u['Veiligheidsgroep']) == ADMIN and _aantal_admins(behalve=gid) == 0: + flash("Er moet minstens één actieve administrator overblijven.", "error") + else: + central_query("DELETE FROM Gebruikers WHERE Gebruiker_ID = %s", (gid,), commit=True) + rechten.log(actie=f"Gebruiker {u['Gebruikersnaam']} verwijderd") + flash(f"Gebruiker '{u['Gebruikersnaam']}' is verwijderd.", "success") + return redirect(url_for('admin.settings')) + return redirect(url_for('admin.gebruiker', gid=gid)) diff --git a/blueprints/afwezigheden_bp.py b/blueprints/afwezigheden_bp.py new file mode 100644 index 0000000..d4225e5 --- /dev/null +++ b/blueprints/afwezigheden_bp.py @@ -0,0 +1,157 @@ +import csv +import io +from datetime import date, datetime +from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, Response +from tenancy import huidige_school, per_school +from blueprints.auth import login_required +from rechten import recht_vereist +from services.afwezigheden_service import AfwezighedenService, schooljaar_start + +afwezigheden_bp = Blueprint('afwezigheden', __name__) +service = per_school(AfwezighedenService) + +_tables_ready = set() # scholen waarvoor de tabellen al gecontroleerd zijn + + +def _ensure_tables(): + if huidige_school().code not in _tables_ready: + try: + service.ensure_tables() + _tables_ready.add(huidige_school().code) + except Exception as e: + flash(f"Kon de afwezigheidstabellen niet aanmaken: {e}", "error") + + +def _filters(): + """Leest de filters uit de URL, met het huidige schooljaar als standaard.""" + def parse(v, standaard): + try: + return datetime.strptime(v, '%Y-%m-%d').date() if v else standaard + except ValueError: + return standaard + van = parse(request.args.get('van'), schooljaar_start()) + tot = parse(request.args.get('tot'), date.today()) + if van > tot: + van, tot = tot, van + code = (request.args.get('code') or '').upper() or None + if code and code not in service.codes: + code = None + try: + minimum = max(1, int(request.args.get('min') or 1)) + except ValueError: + minimum = 1 + return dict(van=van, tot=tot, code=code, zoek=(request.args.get('zoek') or '').strip(), minimum=minimum) + + +#--------------------------------------------- +# Overzicht +#--------------------------------------------- +@afwezigheden_bp.route('/', methods=['GET']) +@login_required +@recht_vereist('afwezigheden') +def overzicht(): + _ensure_tables() + f = _filters() + rows, fout, laatste = [], None, {'m': None, 'd': None} + try: + rows = service.overzicht(f['van'], f['tot'], f['code'], f['zoek'], f['minimum']) + laatste = service.laatste_sync() + except Exception as e: + fout = f"Gegevens konden niet gelezen worden: {e}" + + totaal_halve_dagen = sum(r['Halve_dagen'] for r in rows) + return render_template('afwezigheden.html', rows=rows, f=f, fout=fout, laatste=laatste, + codes=service.codes, status=service.status, + totaal_halve_dagen=totaal_halve_dagen) + + +#--------------------------------------------- +# Detail per leerling (JSON, voor het uitklappen in de tabel) +#--------------------------------------------- +@afwezigheden_bp.route('/leerling/.json', methods=['GET']) +@login_required +@recht_vereist('afwezigheden') +def leerling(internnummer): + f = _filters() + dagen = service.detail(internnummer, f['van'], f['tot'], f['code']) + return jsonify([{'datum': d['Datum'].strftime('%d-%m-%Y'), + 'weekdag': ['ma', 'di', 'wo', 'do', 'vr', 'za', 'zo'][d['Datum'].weekday()], + 'vm': d['VM'], 'nm': d['NM']} for d in dagen]) + + +#--------------------------------------------- +# Synchronisatie +#--------------------------------------------- +@afwezigheden_bp.route('/sync', methods=['POST']) +@login_required +@recht_vereist('afwezigheden', 'laatkomers') +def sync(): + if not huidige_school().heeft('SS_API_PLATFORM', 'SS_API_PW'): + flash("Smartschool is nog niet ingesteld voor deze school: vul het platform (bv. school.smartschool.be) " + "en het webservice-wachtwoord in via 🔌 Koppelingen van deze school.", "error") + return redirect(url_for('afwezigheden.laatkomers' if request.form.get('terug') == 'laatkomers' + else 'afwezigheden.overzicht')) + _ensure_tables() + volledig = request.form.get('volledig') == '1' + if service.sync_in_background(volledig=volledig): + flash("Synchronisatie met Smartschool gestart. Dit kan enkele minuten duren.", "info") + else: + flash("Er loopt al een synchronisatie.", "warning") + if request.form.get('terug') == 'laatkomers': + return redirect(url_for('afwezigheden.laatkomers', **{k: v for k, v in request.args.items()})) + return redirect(url_for('afwezigheden.overzicht', **{k: v for k, v in request.args.items()})) + + +@afwezigheden_bp.route('/status.json', methods=['GET']) +@login_required +@recht_vereist('afwezigheden', 'laatkomers') +def status(): + s = service.status + return jsonify(bezig=s['bezig'], voortgang=s['voortgang'], totaal=s['totaal'], + resultaat=s['laatste_resultaat'], fout=s['laatste_fout']) + + +#--------------------------------------------- +# Export +#--------------------------------------------- +@afwezigheden_bp.route('/export.csv', methods=['GET']) +@login_required +@recht_vereist('afwezigheden') +def export_csv(): + f = _filters() + rows = service.overzicht(f['van'], f['tot'], f['code'], f['zoek'], f['minimum']) + buf = io.StringIO() + w = csv.writer(buf, delimiter=';') + w.writerow(['Internnummer', 'Voornaam', 'Naam', 'Gebruikersnaam', 'Halve dagen', 'Dagen', + 'Eerste', 'Laatste', 'Datums']) + for r in rows: + dagen = service.detail(r['Internnummer'], f['van'], f['tot'], f['code']) + datums = ', '.join(d['Datum'].strftime('%d/%m') + ('' if d['VM'] and d['NM'] else (' VM' if d['VM'] else ' NM')) + for d in sorted(dagen, key=lambda x: x['Datum'])) + w.writerow([r['Internnummer'], r['Voornaam'] or '', r['Naam'] or '', r['Gebruikersnaam'] or '', + r['Halve_dagen'], r['Dagen'], r['Eerste'].strftime('%d-%m-%Y'), + r['Laatste'].strftime('%d-%m-%Y'), datums]) + naam = f"afwezigheden_{f['code'] or '-'.join(service.codes)}_{f['van']:%Y%m%d}-{f['tot']:%Y%m%d}.csv" + return Response('' + buf.getvalue(), mimetype='text/csv', + headers={'Content-Disposition': f'attachment; filename={naam}'}) + + +#--------------------------------------------- +# Laatkomers +#--------------------------------------------- +@afwezigheden_bp.route('/laatkomers', methods=['GET']) +@login_required +@recht_vereist('laatkomers') +def laatkomers(): + """Alle leerlingen die dit schooljaar (of in de gekozen periode) te laat waren.""" + _ensure_tables() + f = _filters() + rows, fout, laatste = [], None, {'m': None, 'd': None} + try: + rows = service.laatkomers(f['van'], f['tot']) + laatste = service.laatste_sync() + except Exception as e: + fout = f"Gegevens konden niet gelezen worden: {e}" + return render_template('laatkomers.html', rows=rows, f=f, fout=fout, laatste=laatste, + codes=service.laat_codes, status=service.status, + totaal=sum(r['Aantal'] for r in rows)) diff --git a/blueprints/auth.py b/blueprints/auth.py new file mode 100644 index 0000000..bb48532 --- /dev/null +++ b/blueprints/auth.py @@ -0,0 +1,326 @@ +from flask import Blueprint, render_template, request, session, redirect, url_for, flash, current_app, make_response +from werkzeug.security import check_password_hash +import time +import functools +from authlib.integrations.flask_client import OAuth +from config import Config +from tenancy import (alle_scholen, school_op_code, school_voor_email, scholen_voor_email, school_voor_host, in_school, + huidige_school) +import rechten +from rechten import recht_vereist, ADMIN, GEBRUIKER + +auth_bp = Blueprint('auth', __name__) +oauth = OAuth() + +SCHOOL_COOKIE = 'pivot_school' # onthoudt de laatst gekozen school op dit toestel +GOOGLE_METADATA = 'https://accounts.google.com/.well-known/openid-configuration' + + +def _google_client(school=None): + """ + Google-login client: die van de school als die een eigen OAuth-client heeft, + anders de gedeelde client uit config.ini. Geeft (naam, client) of (None, None). + """ + if school and school.heeft('GOOGLE_SSO_CLIENTID', 'GOOGLE_SSO_CLIENTSECRET'): + naam = f"google_{school.code}_{''.join(c for c in school.versie if c.isdigit())}" + cid, secret = school.get('GOOGLE_SSO_CLIENTID'), school.get('GOOGLE_SSO_CLIENTSECRET') + elif Config.SSO_CLIENT_ID and Config.SSO_CLIENT_SECRET: + naam, cid, secret = 'google', Config.SSO_CLIENT_ID, Config.SSO_CLIENT_SECRET + else: + return None, None + client = oauth.create_client(naam) + if client is None: + client = oauth.register(name=naam, client_id=cid, client_secret=secret, + server_metadata_url=GOOGLE_METADATA, + client_kwargs={'scope': 'openid email profile'}) + return naam, client + + +def gekozen_school(): + """School die vastligt via de hostnaam, of anders de laatst gekozen school (cookie).""" + return school_voor_host(request.host) or school_op_code(request.cookies.get(SCHOOL_COOKIE)) + + +def _nummer(school): + """Sorteersleutel: laagste officiële instellingsnummer eerst; scholen zonder nummer achteraan.""" + nr = ''.join(c for c in str(school.get('INSTELLINGSNUMMER') or '') if c.isdigit()) + return (0, int(nr), school.naam.lower()) if nr else (1, 0, school.naam.lower()) + + +def standaard_school(scholen=None): + """De school met het laagste instellingsnummer (voor wie nog nooit aangemeld was).""" + scholen = alle_scholen() if scholen is None else list(scholen) + return min(scholen, key=_nummer) if scholen else None + + +def kies_school(gebruiker=None, toegang=None, ook=()): + """ + De school die na het aanmelden geladen wordt (het aanmeldscherm vraagt geen school meer): + 1) de laatst actieve school van de gebruiker, + 2) voor een nieuwe gebruiker: de school van zijn e-maildomein (+ eventueel de scholen in `ook`), + 3) de laatst gekozen school op dit toestel (cookie), 4) als laatste redmiddel: het laagste instellingsnummer. + Telkens enkel scholen waarvoor toegang(school) True is (als `toegang` gegeven is). + """ + ok = toegang or (lambda s: True) + domein = _volgens_domein((gebruiker or {}).get('Emailadres')) + for s in [school_op_code((gebruiker or {}).get('Laatste_school')), *domein, *ook, + school_op_code(request.cookies.get(SCHOOL_COOKIE))]: + if s and ok(s): + return s + return standaard_school(s for s in alle_scholen() if ok(s)) + + +def _volgens_domein(email): + """Scholen met het e-maildomein (een domein kan door meerdere scholen gedeeld worden): de laatst gekozen + school op dit toestel eerst, dan op instellingsnummer.""" + lijst = sorted(scholen_voor_email(email), key=_nummer) + cookie = request.cookies.get(SCHOOL_COOKIE) + return sorted(lijst, key=lambda s: s.code != cookie) + + +def is_superbeheerder(email): + return (email or '').lower() in Config.SUPERBEHEERDERS + + +def _login_ok(school, user, naam, groep, superbeheerder=False, gebruiker=None): + """ + Zet de sessie. `gebruiker` is de rij uit de centrale tabel Gebruikers (of None voor een + superbeheerder uit config.ini of een leerkracht zonder PIVOT-account). + Administrators (Veiligheidsgroep 1) zijn superbeheerder: alle rechten in alle scholen. + """ + admin = bool(gebruiker and int(gebruiker['Veiligheidsgroep']) == ADMIN) + session.clear() + session["auth"] = True + session["user"] = user + session["naam"] = naam + session["gebruiker_id"] = gebruiker['Gebruiker_ID'] if gebruiker else None + session["admin"] = admin or bool(superbeheerder) + session["superbeheerder"] = admin or bool(superbeheerder) + if groep is None and gebruiker: + groep = ADMIN if session["admin"] else GEBRUIKER + session["security_group"] = int(groep) if groep is not None else None + session["school"] = school.code if school else None + if gebruiker: + rechten.registreer_login(gebruiker['Gebruiker_ID'], request.remote_addr) + if school: + rechten.bewaar_laatste_school(gebruiker['Gebruiker_ID'], school.code) + + +def _onthoud_school(response, school): + if school: + response.set_cookie(SCHOOL_COOKIE, school.code, max_age=365 * 24 * 3600, samesite='Lax', + secure=request.is_secure, httponly=True) + return response + + +#---------------------------------------------- +# BRUTE FORCE BEVEILIGING +#---------------------------------------------- +attempts = {} + +def client_ip(): + return request.remote_addr + +def is_locked(): + ip = client_ip() + if ip in attempts and attempts[ip]['count'] >= 5: + elapsed = time.time() - attempts[ip]['last_time'] + if elapsed < 60: + return True, int(60 - elapsed) + else: + attempts.pop(ip) + return False, 0 + +def record_failure(): + ip = client_ip() + if ip not in attempts: + attempts[ip] = {'count': 1, 'last_time': time.time()} + else: + attempts[ip]['count'] += 1 + attempts[ip]['last_time'] = time.time() + +#---------------------------------------------- +# --- GOOGLE SSO ROUTES --- +#---------------------------------------------- + +@auth_bp.route('/google-login') +def google_login(): + """Start het Google inlogproces. De school volgt uit de hostnaam, ?school=code of het e-maildomein.""" + vast = school_voor_host(request.host) or school_op_code(request.args.get('school')) + # Zonder vaste school: de Google-client van de laatst gekozen school op dit toestel (of de standaardschool). + # Welke school na het aanmelden geladen wordt, beslist de callback. + school = vast or school_op_code(request.cookies.get(SCHOOL_COOKIE)) or standaard_school() + naam, client = _google_client(school) + if not client: + flash("Inloggen met Google is (nog) niet ingesteld.", "error") + return redirect(url_for('auth.login')) + session['sso'] = {'client': naam, 'school': vast.code if vast else ''} + extra = {'prompt': 'select_account'} + if vast and len(vast.domeinen) == 1: + extra['hd'] = school.domein # Google toont enkel accounts van dit domein + redirect_uri = url_for('auth.callback', _external=True) + return client.authorize_redirect(redirect_uri, **extra) + + +@auth_bp.route('/callback') +def callback(): + """Verwerkt het antwoord van Google en bepaalt de school op basis van het e-maildomein.""" + sso = session.pop('sso', None) or {} + try: + client = oauth.create_client(sso.get('client') or 'google') + token = client.authorize_access_token() + user_info = token.get('userinfo') or {} + except Exception as e: + flash("Fout bij ophalen van Google gegevens.", "error") + return redirect(url_for('auth.login')) + + email = (user_info.get('email') or '').lower() + if not email or user_info.get('email_verified') is False: + flash("Google gaf geen geverifieerd e-mailadres terug.", "error") + return redirect(url_for('auth.login')) + naam = user_info.get('name') or email + super_ = is_superbeheerder(email) + + # Geregistreerde PIVOT-gebruiker (centrale tabel Gebruikers)? + try: + gebruiker = rechten.zoek_gebruiker(email) + except Exception: + flash("De centrale PIVOT-database is niet bereikbaar.", "error") + return redirect(url_for('auth.login')) + if gebruiker and not gebruiker['Actief']: + gebruiker = None + + def heeft_toegang(s): + return bool(gebruiker) and (super_ or rechten.mag_in_school(gebruiker, s)) + + gekozen = school_op_code(sso.get('school')) # enkel als de school vastligt (hostnaam) + domeinscholen = _volgens_domein(email) # meerdere scholen kunnen hetzelfde domein hebben + volgens_domein = next((s for s in domeinscholen if heeft_toegang(s)), None) or \ + (domeinscholen[0] if domeinscholen else None) + if gekozen: + school = gekozen + elif gebruiker and heeft_toegang_ergens(gebruiker, super_): + # Laatst actieve school → school van het e-maildomein → laatst gekozen op dit toestel → laagste nummer + school = kies_school(gebruiker, heeft_toegang, ook=domeinscholen) + elif super_: + school = kies_school(None, None, ook=domeinscholen) + else: + school = volgens_domein + + if gekozen and gekozen not in domeinscholen and not super_ and not heeft_toegang(gekozen): + flash(f"{email} hoort niet bij {gekozen.naam}.", "error") + return redirect(url_for('auth.login')) + + if school is None: + if super_ or (gebruiker and int(gebruiker['Veiligheidsgroep']) == ADMIN): + # Superbeheerder van wie het domein bij geen school hoort: meteen naar scholenbeheer + _login_ok(None, email, naam, None, superbeheerder=True, gebruiker=gebruiker) + flash("Ingelogd als superbeheerder. Kies of maak een school.", "success") + return redirect(url_for('scholen.overzicht')) + flash(f"Het domein van {email} hoort bij geen enkele school in PIVOT.", "error") + return redirect(url_for('auth.login')) + + # 1) PIVOT-gebruiker met rechten in deze school + if heeft_toegang(school): + _login_ok(school, gebruiker['Gebruikersnaam'], naam, None, super_, gebruiker=gebruiker) + flash(f"Succesvol ingelogd via Google: {gebruiker['Gebruikersnaam']}", "success") + return _onthoud_school(redirect(url_for("main.dashboard")), school) + + # 2) Superbeheerder van de scholengroep (config.ini) → beheerder in elke school + if super_: + _login_ok(school, email, naam, ADMIN, True) + return _onthoud_school(redirect(url_for("main.dashboard")), school) + + # 3) Personeelslid van de school (personeels-OU's) → beperkte toegang tot de personeelsmodule. + # Delen meerdere scholen het domein, dan de eerste school waar hij personeelslid is. + from services.personeel_service import is_personeelslid, LEERKRACHT_GROEP + for kandidaat in [school] + [s for s in domeinscholen if s is not school and not gekozen]: + with in_school(kandidaat): + if is_personeelslid(email): + _login_ok(kandidaat, email, naam, LEERKRACHT_GROEP) + return _onthoud_school(redirect(url_for("personeel.mijn")), kandidaat) + + flash(f"Toegang geweigerd: {email} is niet geregistreerd in PIVOT van {school.naam}.", "error") + return redirect(url_for('auth.login')) + +def heeft_toegang_ergens(gebruiker, super_=False): + return bool(gebruiker) and (super_ or any(rechten.mag_in_school(gebruiker, s) for s in alle_scholen())) + + +#---------------------------------------------- +# --- HANDMATIGE LOGIN/LOGOUT --- +#---------------------------------------------- + +def _login_pagina(school=None): + vast = school_voor_host(request.host) + return render_template("pin.html", + scholen=alle_scholen(), + gekozen=vast or school or gekozen_school(), + school_vast=bool(vast), + google_aan=bool(Config.SSO_CLIENT_ID) or any( + s.heeft('GOOGLE_SSO_CLIENTID') for s in alle_scholen())) + + +@auth_bp.route("/login", methods=["GET", "POST"]) +def login(): + locked, wait_time = is_locked() + if locked: + flash(f"Te veel pogingen. Probeer opnieuw over {wait_time} seconden.", "error") + return _login_pagina() + + if request.method == "POST": + entered_username = request.form.get("username", "").strip() + entered_password = request.form.get("password", "") + school = school_voor_host(request.host) # ligt de school vast via de hostnaam? + + if not entered_username or not entered_password: + record_failure() + flash("Vul zowel gebruikersnaam als wachtwoord in.", "error") + return _login_pagina(school) + + try: + gebruiker = rechten.zoek_gebruiker(entered_username) + except Exception: + flash("De centrale PIVOT-database is niet bereikbaar.", "error") + return _login_pagina(school) + + if (gebruiker and gebruiker['Actief'] and gebruiker['Passwoordhash'] + and check_password_hash(gebruiker['Passwoordhash'], entered_password)): + attempts.pop(client_ip(), None) + if school and not rechten.mag_in_school(gebruiker, school): + flash(f"Je hebt geen rechten in {school.naam}. Vraag een administrator om toegang.", "error") + return _login_pagina(school) + # Geen school gevraagd: laatst actieve school, anders de school van het e-maildomein + school = school or kies_school(gebruiker, lambda s: rechten.mag_in_school(gebruiker, s)) + if not school: + flash("Je hebt nog geen rechten in een school. Vraag een administrator om toegang.", "error") + return _login_pagina() + _login_ok(school, gebruiker['Gebruikersnaam'], gebruiker['Gebruikersnaam'], None, gebruiker=gebruiker) + flash(f"Welkom terug, {gebruiker['Gebruikersnaam']}!", "success") + return _onthoud_school(redirect(url_for("main.dashboard")), school) + + record_failure() + flash("Ongeldige inloggegevens.", "error") + return _login_pagina(school) + + return _login_pagina() + +@auth_bp.route('/logout') +def logout(): + session.clear() + flash("Je bent nu uitgelogd.", "success") + return redirect(url_for('auth.login')) + +#---------------------------------------------- +# --- DECORATORS --- +#---------------------------------------------- + +def login_required(view_func): + @functools.wraps(view_func) + def wrapped_view(*args, **kwargs): + if not session.get("auth") or not session.get("school"): + return redirect(url_for("auth.login")) + return view_func(*args, **kwargs) + return wrapped_view + +# Rechten per school: zie rechten.py (recht_vereist, heeft_recht, admin_vereist) diff --git a/blueprints/basisonderwijs_bp.py b/blueprints/basisonderwijs_bp.py new file mode 100644 index 0000000..b0ef269 --- /dev/null +++ b/blueprints/basisonderwijs_bp.py @@ -0,0 +1,67 @@ +"""Basisonderwijs › Bingel: leerlingenlijst uit Informat als Excel voor de import in Bingel.""" +import io +from datetime import date + +from flask import Blueprint, render_template, request, Response, redirect, url_for, flash + +from blueprints.auth import login_required +from rechten import recht_vereist +from services import bingel_service as bs +from tenancy import huidige_school + +basisonderwijs_bp = Blueprint('basisonderwijs', __name__) +KOLOMMEN = ['Klas', 'Voornaam', 'Familienaam', 'Wachtwoord', 'Klasnummer', 'Geslacht'] + + +@basisonderwijs_bp.route('/bingel', methods=['GET']) +@login_required +@recht_vereist('bingel') +def bingel(): + rijen, voorbeeld, fout, waarschuwing = [], {}, None, None + try: + rijen, voorbeeld, waarschuwing = bs.leerlingen(vernieuw=bool(request.args.get('vernieuw'))) + except bs.BingelFout as e: + fout = str(e) + except Exception as e: + fout = f"Leerlingen ophalen uit Informat mislukt: {e}" + if request.args.get('vernieuw'): + return redirect(url_for('basisonderwijs.bingel')) + klassen = sorted({r['klas'] for r in rijen}, key=str.lower) + return render_template('bingel.html', rijen=rijen, klassen=klassen, voorbeeld=voorbeeld, fout=fout, waarschuwing=waarschuwing, + kolommen=KOLOMMEN, school_naam=huidige_school().naam, + zonder_klas=sum(1 for r in rijen if not r['klas']), + zonder_geslacht=sum(1 for r in rijen if r['geslacht'] not in ('M', 'V'))) + + +@basisonderwijs_bp.route('/bingel/export.xlsx', methods=['GET']) +@login_required +@recht_vereist('bingel') +def bingel_export(): + from openpyxl import Workbook + from openpyxl.styles import Font + try: + rijen, _, _ = bs.leerlingen() + except Exception as e: + flash(str(e), "error") + return redirect(url_for('basisonderwijs.bingel')) + gekozen = request.args.getlist('klas') # aangevinkte klassen (niets = alle) + if gekozen: + rijen = [r for r in rijen if r['klas'] in gekozen] + klas = gekozen[0] if len(gekozen) == 1 else '' + wb = Workbook() + ws = wb.active + ws.title = 'Leerlingen' + ws.append(KOLOMMEN) + for c in ws[1]: + c.font = Font(bold=True) + for r in rijen: + ws.append([r['klas'], r['voornaam'], r['naam'], '', r['klasnummer'], r['geslacht']]) + for kol, breedte in zip('ABCDEF', (12, 18, 22, 14, 11, 10)): + ws.column_dimensions[kol].width = breedte + ws.freeze_panes = 'A2' + buf = io.BytesIO() + wb.save(buf) + deel = ''.join(c if c.isalnum() else '_' for c in klas) + '_' if klas else '' + return Response(buf.getvalue(), mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + headers={'Content-Disposition': f"attachment; filename=Bingel_{huidige_school().code}_{deel}" + f"{date.today():%Y%m%d}.xlsx"}) diff --git a/blueprints/bugreport_bp.py b/blueprints/bugreport_bp.py new file mode 100644 index 0000000..2069b76 --- /dev/null +++ b/blueprints/bugreport_bp.py @@ -0,0 +1,74 @@ +from datetime import datetime +from flask import Blueprint, render_template, request, redirect, url_for, flash, session +from database import central_query +from blueprints.auth import login_required +from rechten import recht_vereist + +bugreport_bp = Blueprint('bugreport', __name__) + +@bugreport_bp.route('/', methods=['GET']) +@login_required +def bugreport(): + """Toont het bugreport formulier.""" + + if 'security_group' not in session: + flash('Je moet eerst inloggen om een bug te rapporteren.', 'error') + return redirect(url_for('auth.login')) + + return render_template('bugreport.html') + + +@bugreport_bp.route('/submit', methods=['POST']) +@login_required +def bugreport_submit(): + """Verwerkt het ingezonden bugreport en slaat het op in de database.""" + + + # Gegevens ophalen uit het HTML formulier + tijdstip_str = request.form.get('tijdstip') + omschrijving = request.form.get('omschrijving') + foutcode = request.form.get('foutcode') # Kan leeg zijn (None of lege string) + + # Automatische gegevens + gebruiker_id = session["user"] # Of session.get('email'), afhankelijk van je login-systeem + status = 'Nieuw' + + # Validatie (html5 required is een back-up, server-side validatie is veiliger) + if not tijdstip_str or not omschrijving: + flash('Tijdstip en omschrijving zijn verplichte velden!', 'error') + return redirect(url_for('bugreport.bugreport_page')) + + try: + # Converteren van datetime-local string (YYYY-MM-DDTHH:MM) naar MySQL formaat + tijdstip = datetime.strptime(tijdstip_str, '%Y-%m-%dT%H:%M') + except ValueError: + flash('Ongeldig datum- of tijdformaat ontvangen.', 'error') + return redirect(url_for('bugreport.bugreport_page')) + + # SQL query om de bug op te slaan + # Pas de tabelnaam ('bugs') en kolomnamen aan naar jouw exacte database-schema + # Centrale database: Bugs (Bugstatus_ID 1 = Nieuw) + from tenancy import huidige_school + school = huidige_school(verplicht=False) + query = """ + INSERT INTO Bugs (Timestamp, Omschrijving, Foutcode, Bugstatus_ID, Gebruiker_ID, Gebruikersnaam, School_ID) + VALUES (%s, %s, %s, 1, %s, %s, %s) + """ + params = (tijdstip, omschrijving, foutcode or None, session.get('gebruiker_id'), gebruiker_id, + school.id if school else None) + + try: + # Voer de query uit met commit=True om de data op te slaan + central_query(query, params, commit=True) + + # Succesmelding sturen en doorverwijzen naar het dashboard + flash('Je bugreport is succesvol ingediend! Bedankt voor je feedback.', 'success') + return redirect(url_for('main.dashboard')) + + except Exception as e: + # Log de fout eventueel in de console voor debugging + print(f"Database error tijdens bugreport: {e}") + + # Foutmelding tonen aan de gebruiker + flash('Er ging iets mis bij het opslaan in de database. Probeer het later opnieuw.', 'error') + return redirect(url_for('bugreport.bugreport_page')) \ No newline at end of file diff --git a/blueprints/bugreport_overzicht_bp.py b/blueprints/bugreport_overzicht_bp.py new file mode 100644 index 0000000..2dc1539 --- /dev/null +++ b/blueprints/bugreport_overzicht_bp.py @@ -0,0 +1,62 @@ +from flask import Blueprint, render_template, request, session, redirect, url_for, flash, jsonify +from database import central_query +from blueprints.auth import login_required +from rechten import recht_vereist + + + +# We noemen de blueprint 'devices' +bugreport_overzicht_bp = Blueprint('bugreport_overzicht', __name__) + +#------------------------------------------------- +# --- ROUTE TOESTELLEN (Inventaris Toestellen) --- +#------------------------------------------------- +@bugreport_overzicht_bp.route("/", methods=["GET", "POST"]) +@login_required +@recht_vereist('bugs_bekijken', 'bugs_beheren') +def bugreport_overzicht(): + query = request.form.get("query", "") if request.method == "POST" else request.args.get("query", "") + message = None + + search_term = f"%{query}%" + # Bugs staan in de centrale database (gelden voor PIVOT, niet voor één school) + sql = """ + SELECT * FROM ( + SELECT b.Bug_ID, b.Gebruikersnaam AS Gebruiker, b.Timestamp AS Datum, b.Omschrijving, b.Foutcode, + st.Omschrijving AS Status, sc.School_naam AS School + FROM Bugs b + JOIN Status st ON st.Bugstatus_ID = b.Bugstatus_ID + LEFT JOIN Scholen sc ON sc.School_ID = b.School_ID + ) x + WHERE CONCAT_WS(' ', Bug_ID, Gebruiker, Datum, Omschrijving, Foutcode, Status, School) LIKE %s + ORDER BY Bug_ID DESC + """ + + results = central_query(sql, (search_term,)) + + + if query and not results: + message = f"Geen resultaten gevonden voor '{query}'." + elif not results: + message = "Er zijn momenteel geen bugreports." + + return render_template("bugreport_overzicht.html", results=results, query=query, message=message) + +#--------------------------------- +# --- STATUS UPDATES --- +#--------------------------------- +@bugreport_overzicht_bp.route("/update_bugreport", methods=["POST"]) +@login_required +@recht_vereist('bugs_beheren') +def bugreport_wijzig_status(): + Bug_ID = request.form.get("Bug_ID") + new_status = request.form.get("new_status") + query = request.form.get("query", "") + + if new_status in ["Nieuw", "In behandeling", "Fixed"]: + central_query("UPDATE Bugs SET Bugstatus_ID = (SELECT Bugstatus_ID FROM Status WHERE Omschrijving = %s) " + "WHERE Bug_ID = %s", (new_status, Bug_ID), commit=True) + flash(f"Status van {Bug_ID} gewijzigd naar {new_status}", "success") + + return redirect(url_for("bugreport_overzicht.bugreport_overzicht", query=query)) + diff --git a/blueprints/co2_sensor.py b/blueprints/co2_sensor.py new file mode 100644 index 0000000..fee17b6 --- /dev/null +++ b/blueprints/co2_sensor.py @@ -0,0 +1,125 @@ +from flask import Blueprint, render_template, redirect, url_for, flash, jsonify, request +from services.co2_service import CO2Scanner +from database import execute_query +from blueprints.auth import login_required +from rechten import recht_vereist +from tenancy import per_school + +co2_sensor_bp = Blueprint('co2_sensor', __name__) +scanner = per_school(CO2Scanner) # één scanner per school + +#---------------------------------------------- +# Route voor het CO2 dashboard +#---------------------------------------------- + +@co2_sensor_bp.route('/dashboard') +@login_required +@recht_vereist('co2') +def dashboard(): + # haalt de meest recente record voor elke ruimte + query = """ + SELECT lk.Naam AS lokaal, m.ppm, m.Timestamp AS timestamp + FROM CO2_Metingen m + JOIN Lokalen lk ON lk.Lokaal_ID = m.Lokaal_ID + WHERE m.Meting_ID IN (SELECT MAX(Meting_ID) FROM CO2_Metingen GROUP BY Lokaal_ID) + ORDER BY lk.Naam ASC + """ + sensors = execute_query(query) + return render_template('CO2_sensor.html', sensors=sensors) + +#--------------------------------------------- +# Routes en Logica +#---------------------------------------------- +# Verwijderd record ouder dan 7 dagen uit DB +def perform_cleanup(): + """Verwijder data ouder dan 7 dagen uit de database.""" + query = "DELETE FROM CO2_Metingen WHERE Timestamp < NOW() - INTERVAL 7 DAY" + try: + execute_query(query, commit=True) + print("Database opruiming voltooid.") + except Exception as e: + print(f"Fout bij database opruiming: {e}") + + +# Toggleknop voor automatisch verwijderen +@co2_sensor_bp.route('/toggle_auto_delete', methods=['POST']) +@login_required +@recht_vereist('co2') +def toggle_auto_delete(): + """Schakelt het automatisch verwijderen van oude data aan/uit.""" + data = request.get_json() + is_active = data.get('active', False) + + # Hier kun je een variabele in de scanner of een config opslaan + # Voor nu simuleren we de status-bevestiging + scanner.auto_delete = is_active + + if is_active: + perform_cleanup() + + return jsonify({'active': scanner.auto_delete}) + + +@co2_sensor_bp.route('/get_cleanup_status') +@login_required +@recht_vereist('co2') +def get_cleanup_status(): + """Geeft de huidige status van de auto-delete instelling terug.""" + # Zorg dat 'auto_delete' bestaat in je CO2Scanner klasse of gebruik een globale + status = getattr(scanner, 'auto_delete', False) + return jsonify({'active': status}) + + +# Eenmalige scan uitvoeren +@co2_sensor_bp.route('/scan') +@login_required +@recht_vereist('co2') +def scan_co2(): + """Trigger één handmatige scan-ronde.""" + scanner.perform_scan() + flash("Handmatige scan voltooid.", "success") + return redirect(url_for('co2_sensor.dashboard')) + +# Start herhalende scan +@co2_sensor_bp.route('/toggle_continuous_scan', methods=['POST']) +@login_required +@recht_vereist('co2') +def toggle_continuous_scan(): + """Schakelt de achtergrond-thread aan of uit via de website.""" + data = request.get_json() + if data.get('active'): + scanner.start() + else: + scanner.stop() + return jsonify({'active': scanner.active}) + +@co2_sensor_bp.route('/get_scan_status') +@login_required +@recht_vereist('co2') +def get_scan_status(): + """Zorgt dat de switch in de HTML op de juiste stand staat bij laden.""" + return jsonify({'active': scanner.active}) + + +#----------------------------------------------- +# Opent grafiekvenster met 50 waarden +#----------------------------------------------- +@co2_sensor_bp.route('/grafiek/') +@login_required +@recht_vereist('co2') +def grafiek(lokaal): + # Haal de laatste 50 metingen op (oud → nieuw) + results = execute_query( + "SELECT ppm, timestamp FROM (" + " SELECT m.ppm, m.Timestamp AS timestamp FROM CO2_Metingen m " + " JOIN Lokalen lk ON lk.Lokaal_ID = m.Lokaal_ID WHERE lk.Naam = %s " + " ORDER BY m.Timestamp DESC LIMIT 50) x ORDER BY timestamp ASC", + (lokaal,) + ) + + # Split de data voor Chart.js + # We gebruiken ASC zodat de grafiek van links (oud) naar rechts (nieuw) loopt + labels = [row['timestamp'].strftime("%d-%m %H:%M:%S") if hasattr(row['timestamp'], 'strftime') else str(row['timestamp']) for row in results] + values = [row['ppm'] for row in results] + + return render_template('grafiek.html', lokaal=lokaal, labels=labels, data=values) \ No newline at end of file diff --git a/blueprints/contacten_bp.py b/blueprints/contacten_bp.py new file mode 100644 index 0000000..4695e89 --- /dev/null +++ b/blueprints/contacten_bp.py @@ -0,0 +1,115 @@ +"""Gmail › Contacten: contactlijsten in de mailboxen en het adresboek van de organisatie (zichtbaar/verborgen).""" + +from flask import Blueprint, render_template, request, session, jsonify + +from blueprints.auth import login_required +from rechten import recht_vereist, log +from services import contacten_service as cs + +contacten_bp = Blueprint('contacten', __name__) +TABS = ('lijsten', 'adresboek') + + +def _door(): + return session.get('naam') or session.get('user') + + +@contacten_bp.route('/', methods=['GET']) +@login_required +@recht_vereist('gmail_contacten') +def overzicht(): + tab = request.args.get('tab') if request.args.get('tab') in TABS else TABS[0] + ctx = {'tab': tab, 'fout': None, 'status': cs.LijstSync().status} + try: + if tab == 'adresboek': + ctx['ous'] = cs.ou_lijst() + ctx['ou'] = request.args.get('ou') or '' + ctx['met_sub'] = 'sub' not in request.args or '1' in request.args.getlist('sub') + ctx['gebruikers'] = cs.gebruikers_in([ctx['ou']], ctx['met_sub']) if ctx['ou'] else [] + else: + ctx['lijsten'] = cs.lijsten() + lid = request.args.get('lijst', type=int) + ctx['gekozen'] = cs.lijst(lid) if lid else None + ctx['nieuw'] = bool(request.args.get('nieuw')) + if ctx['gekozen'] or ctx['nieuw']: + ctx['ous'] = cs.ou_lijst() + except cs.ContactFout as e: + ctx['fout'] = str(e) + except Exception as e: + ctx['fout'] = cs.vriendelijk(e) + return render_template('contacten.html', **ctx) + + +# --------------------------------------------------------------------- +# 1. Adresboek +# --------------------------------------------------------------------- +@contacten_bp.route('/adresboek', methods=['POST']) +@login_required +@recht_vereist('gmail_contacten') +def adresboek(): + d = request.get_json(silent=True) or {} + emails = [e for e in d.get('emails') or [] if isinstance(e, str)][:2000] + if not emails: + return jsonify(ok=False, fout="Niemand gekozen."), 400 + try: + fouten = cs.zet_zichtbaar(emails, bool(d.get('zichtbaar'))) + except Exception as e: + return jsonify(ok=False, fout=cs.vriendelijk(e)), 400 + log(f"Contacten: {len(emails)} gebruiker(s) {'zichtbaar' if d.get('zichtbaar') else 'verborgen'} in het adresboek") + return jsonify(ok=not fouten, fouten=fouten, aantal=len(emails) - len(fouten)) + + +# --------------------------------------------------------------------- +# 2. Contactlijsten in de mailboxen +# --------------------------------------------------------------------- +@contacten_bp.route('/lijst/bewaren', methods=['POST']) +@login_required +@recht_vereist('gmail_contacten') +def lijst_bewaren(): + d = request.get_json(silent=True) or {} + lid = int(d['id']) if str(d.get('id') or '').isdigit() else None + oud = cs.lijst(lid) if lid else None + try: + lid = cs.lijst_bewaren(lid, d.get('naam'), [o for o in d.get('ous') or [] if isinstance(o, str)], + bool(d.get('met_sub')), [i for i in d.get('items') or [] if isinstance(i, dict)], _door()) + except cs.ContactFout as e: + return jsonify(ok=False, fout=str(e)), 400 + log(f"Contacten: contactlijst {d.get('naam')} bewaard") + gestart = False + # Stond de lijst al in de mailboxen? Dan meteen bijwerken (ook bij wie er door een OU-wijziging uit valt) + if oud and oud.get('Laatste_sync'): + gestart = cs.LijstSync().start(lid, oude_naam=oud['Naam'], oude_ous=oud['ous']) + return jsonify(ok=True, id=lid, gestart=gestart) + + +@contacten_bp.route('/lijst//sync', methods=['POST']) +@login_required +@recht_vereist('gmail_contacten') +def lijst_sync(lijst_id): + if not cs.LijstSync().start(lijst_id): + return jsonify(ok=False, fout="Er loopt al een opdracht."), 409 + log(f"Contacten: contactlijst {lijst_id} naar de mailboxen") + return jsonify(ok=True) + + +@contacten_bp.route('/lijst//verwijderen', methods=['POST']) +@login_required +@recht_vereist('gmail_contacten') +def lijst_verwijderen(lijst_id): + l = cs.lijst(lijst_id) + if not l: + return jsonify(ok=False, fout="Lijst niet gevonden."), 404 + log(f"Contacten: contactlijst {l['Naam']} verwijderd") + if l.get('Laatste_sync'): + if not cs.LijstSync().start(lijst_id, verwijderen=True): + return jsonify(ok=False, fout="Er loopt al een opdracht."), 409 + return jsonify(ok=True, gestart=True) + cs.lijst_verwijderen_db(lijst_id) + return jsonify(ok=True, gestart=False) + + +@contacten_bp.route('/status.json', methods=['GET']) +@login_required +@recht_vereist('gmail_contacten') +def status(): + return jsonify(cs.LijstSync().status) diff --git a/blueprints/dashboard_bp.py b/blueprints/dashboard_bp.py new file mode 100644 index 0000000..eadea7c --- /dev/null +++ b/blueprints/dashboard_bp.py @@ -0,0 +1,19 @@ +from flask import Blueprint, render_template, session +from blueprints.auth import login_required + +main_bp = Blueprint('main', __name__) + +#--------------------------------------------- +# Functie open dahboard +#--------------------------------------------- +@main_bp.route('/dashboard') +@login_required +def dashboard(): + # We halen de naam en de groep uit de sessie die in auth.py zijn opgeslagen + user_name = session.get('user') + user_group = session.get('security_group') + + # We geven 'current_user' door zodat je HTML {% if current_user == 'admin' %} werkt + return render_template('dashboard.html', + current_user=user_name, + security_group=user_group) \ No newline at end of file diff --git a/blueprints/devices.py b/blueprints/devices.py new file mode 100644 index 0000000..99dd023 --- /dev/null +++ b/blueprints/devices.py @@ -0,0 +1,603 @@ +import sys +from flask import Blueprint, render_template, request, session, redirect, url_for, flash, jsonify +from database import execute_query +from services import CIP_inleveren +from services import CIP_uitdelen +from blueprints.auth import login_required +from rechten import recht_vereist +from database import execute_query, execute_insert, get_db_connection +from services import toestellen as tst +from blueprints.google_admin import google_service +from googleapiclient.errors import HttpError + + +# We noemen de blueprint 'devices' +devices_bp = Blueprint('devices', __name__) + +#------------------------------------------------- +# --- ROUTE TOESTELLEN (Inventaris Toestellen) --- +#------------------------------------------------- +@devices_bp.route("/inventaris", methods=["GET", "POST"]) +@login_required +@recht_vereist('toestellen_beheren') +def inventaris_toestellen(): + query = request.form.get("query", "") if request.method == "POST" else request.args.get("query", "") + message = None + + search_term = f"%{query}%" + try: + tst.ensure_geblokkeerd() + geblokkeerd = "t.Geblokkeerd" + except Exception as e: # bv. geen ALTER-rechten: pagina blijft werken + sys.stderr.write(f"Kolom Toestellen.Geblokkeerd: {e}\n") + geblokkeerd = "0" + sql = f""" + SELECT * FROM ( + SELECT t.Toestel_ID, t.Schooleigen_ID AS KAMXM_nummer, sp.Signpostnummer, t.Serienummer, t.Type, + DATE_FORMAT(sp.Garantie, '%d-%m-%Y') AS Garantie, t.Status, {geblokkeerd} AS Geblokkeerd, + {tst.LAATSTE_OPMERKING} AS Opmerking + FROM Toestellen t + LEFT JOIN Signpost_DB sp ON sp.Toestel_ID = t.Toestel_ID + ) x + WHERE CONCAT_WS(' ', KAMXM_nummer, Signpostnummer, Serienummer, Type, Opmerking) LIKE %s + ORDER BY KAMXM_nummer IS NULL, KAMXM_nummer + """ + + results = execute_query(sql, (search_term,)) + + + if query and not results: + message = f"Geen resultaten gevonden voor '{query}'." + elif not results: + message = "Er zijn momenteel geen toestellen in de inventaris." + + return render_template("toestellen.html", results=results, query=query, message=message) + +@devices_bp.route("/google_import", methods=["POST"]) +@login_required +@recht_vereist('toestellen_beheren') +def google_import(): + """Chromebooks uit Google Admin in de inventaris zetten: nieuwe serienummers toevoegen en een ontbrekend model + aanvullen. Er wordt niets verwijderd of overschreven.""" + from config import Config + # Leeg = alle Chromebooks van het domein; anders enkel deze OU's (met sub-OU's) + ous = [o.strip() for o in str(Config.CHROMEBOOK_OUS or '').split(',') if o.strip()] + try: + toestellen = google_service.lijst_chromebooks(ous) + except HttpError as e: + fout = str(e) + if 'INVALID_OU_ID' in fout or 'Invalid Input: OrgUnit' in fout or ('400' in fout and ous): + flash(f"Een OU bestaat niet in Google: {', '.join(ous)}. Pas 'OU's van de Chromebooks' aan bij " + "🔌 Koppelingen van deze school (volledig pad, bv. /ChromeBooks Medewerkers).", "error") + else: + flash(f"Chromebooks ophalen bij Google mislukt: {e}", "error") + return redirect(url_for("devices.inventaris_toestellen")) + except Exception as e: + flash(f"Chromebooks ophalen bij Google mislukt: {e}", "error") + return redirect(url_for("devices.inventaris_toestellen")) + + try: + tst.ensure_geblokkeerd() + kol = "Geblokkeerd" + except Exception: + kol = "NULL AS Geblokkeerd" + bestaand = {(r['Serienummer'] or '').strip().upper(): r for r in + execute_query(f"SELECT Toestel_ID, Serienummer, Type, {kol} FROM Toestellen WHERE Serienummer IS NOT NULL")} + nieuw = aangevuld = blok_bijgewerkt = 0 + fouten = [] + # Het veld Item-ID (Asset ID) uit Google wordt bewust NIET overgenomen: het wordt soms voor andere info gebruikt + # (bv. een lokaal) en is dan niet uniek. Het toestelnummer van de school vul je in de inventaris of via Signpost. + for d in toestellen: + serie = d['serialNumber'].strip() + model = (d.get('model') or '').strip()[:100] or None + try: + rij = bestaand.get(serie.upper()) + in_google = 1 if d.get('status') == 'DISABLED' else 0 + if not rij: + nieuw_id = execute_insert("INSERT INTO Toestellen (Serienummer, Type, Status) VALUES (%s, %s, 'Beschikbaar')", + (serie, model)) + if in_google and kol == "Geblokkeerd": + tst.zet_geblokkeerd(nieuw_id, True) + bestaand[serie.upper()] = {'Type': model} + nieuw += 1 + continue + if model and not rij['Type']: + execute_query("UPDATE Toestellen SET Type = %s WHERE Toestel_ID = %s", (model, rij['Toestel_ID']), + commit=True) + aangevuld += 1 + # Blokkeerstatus overnemen uit Google (daar kan het ook rechtstreeks gewijzigd zijn) + if rij.get('Geblokkeerd') is not None and int(rij['Geblokkeerd'] or 0) != in_google: + tst.zet_geblokkeerd(rij['Toestel_ID'], in_google) + blok_bijgewerkt += 1 + except Exception as e: + fouten.append(f"{serie}: {e}") + sys.stderr.write(f"Chromebook-import {serie}: {e}\n") + bereik = f" uit {', '.join(ous)}" if ous else "" + flash(f"{len(toestellen)} Chromebook(s) gevonden in Google{bereik}: {nieuw} nieuw in de inventaris, " + f"{aangevuld} aangevuld, {len(toestellen) - nieuw - aangevuld - len(fouten)} stonden er al." + + (f" Blokkeerstatus bijgewerkt bij {blok_bijgewerkt} toestel(len)." if blok_bijgewerkt else ""), "success") + if fouten: + flash(f"{len(fouten)} toestel(len) konden niet verwerkt worden: " + "; ".join(fouten[:5]) + + (" …" if len(fouten) > 5 else ""), "error") + return redirect(url_for("devices.inventaris_toestellen")) + + +@devices_bp.route("/google_info", methods=["GET"]) +@login_required +@recht_vereist('toestellen_beheren') +def google_info(): + """JSON met gebruiksinfo uit Google Admin voor één toestel (voor de knop 'Google-info').""" + try: + info, fout = google_service.chromebook_info(request.args.get('serie', '')) + except Exception as e: + info, fout = None, f"Google gaf een fout: {e}" + if fout: + return jsonify(ok=False, fout=fout) + # Naam van de leerling erbij als het e-mailadres in de leerlingenlijst staat + emails = [g['email'] for g in info['gebruikers'] if g['email']] + namen = {} + if emails: + try: + # Gebruikersnaam staat er soms als volledig e-mailadres, soms enkel het deel vóór de @ + sleutels = list(dict.fromkeys([e.lower() for e in emails] + [e.split('@')[0].lower() for e in emails])) + plek = ','.join(['%s'] * len(sleutels)) + for r in execute_query(f"SELECT Gebruikersnaam, Voornaam, Naam FROM Leerlingen WHERE Gebruikersnaam IN ({plek})", + tuple(sleutels)): + namen[(r['Gebruikersnaam'] or '').lower()] = f"{r.get('Voornaam') or ''} {r.get('Naam') or ''}".strip() + except Exception: + pass + for g in info['gebruikers']: + g['naam'] = namen.get(g['email'].lower()) or namen.get(g['email'].split('@')[0].lower(), '') + return jsonify(ok=True, info=info) + + +#----------------------------------------------------- +# Functie achter bewerk toestel knop +#----------------------------------------------------- +@devices_bp.route("/bewerk_toestel", methods=["POST"]) +@login_required +@recht_vereist('toestellen_beheren') +def bewerk_toestel(): + """Verwerkt de POST-gegevens om een toestelrecord bij te werken.""" + + # Gebruik de gegevens uit het formulier + toestel_id = request.form.get("toestel_id") + schooleigen_id = (request.form.get("schooleigen_id") or "").strip() or None + serienummer = request.form.get("serienummer") + signpostnummer = request.form.get("signpostnummer") + type_toestel = request.form.get("type") + garantie = request.form.get("garantie") + opmerking = request.form.get("opmerking") + query = request.form.get("query", "") + + if not toestel_id: + flash("Fout: Uniek nummer ontbreekt. Kan toestel niet bijwerken.", "error") + return redirect(url_for("devices.inventaris_toestellen")) + + try: + tst.lees_datum(garantie) # eerst valideren, zodat er niets half bewaard wordt + execute_query("UPDATE Toestellen SET Schooleigen_ID = %s, Serienummer = %s, Type = %s WHERE Toestel_ID = %s", + (schooleigen_id, (serienummer or '').strip() or None, (type_toestel or '').strip() or None, + toestel_id), commit=True) + tst.bewaar_signpost(toestel_id, signpostnummer, garantie) + tst.voeg_opmerking_toe(toestel_id, opmerking, session.get('user')) + flash(f"Toestel {schooleigen_id or serienummer} succesvol bijgewerkt.", "success") + + except ValueError as err: + flash(str(err), "error") + except Exception as err: + flash(f"Databasefout bij het bewerken van het toestel: {err}", "error") + + # BELANGRIJK: Gebruik de blueprint prefix 'devices.' in de redirect + return redirect(url_for("devices.inventaris_toestellen", query=query)) + + +#------------------------------------------------- +# --- ROUTE EIGENDOM (koppeling toestel-leerling) --- +#------------------------------------------------- +@devices_bp.route("/eigendom", methods=["GET", "POST"]) +@login_required +@recht_vereist('eigendom_bekijken') +def inventaris_eigendom(): + query = (request.form.get("query") or request.args.get("query") or "").strip() + + # Alle huidige leerlingen, met het toestel dat ze nu hebben (of zonder toestel). Vertrokken leerlingen enkel + # als ze nog een toestel hebben. Een leerling met twee toestellen staat er twee keer in. + # Met zoekterm: elk woord moet voorkomen in internnummer, naam, gebruikersnaam, SS-nummer, toestel of opmerkingen. + try: + from services.sync_service import zorg_vertrokken_kolom + zorg_vertrokken_kolom() + vertrokken = "l.Vertrokken_op IS NULL OR " + except Exception: + vertrokken = "" + sql = f""" + SELECT * FROM ( + SELECT e.Eigendom_ID, l.Internnummer AS internnummer, l.Voornaam, l.Naam, l.Gebruikersnaam, l.SS_nummer, + t.Schooleigen_ID AS kamxm_nummer, t.Serienummer, e.Datum_toekenning, + IFNULL(e.Status, 0) AS locked, (e.Eigendom_ID IS NOT NULL) AS heeft_toestel, + (SELECT GROUP_CONCAT(o.Opmerking ORDER BY o.Timestamp SEPARATOR ' | ') + FROM Opmerkingen o WHERE o.Toestel_ID = t.Toestel_ID) AS opmerkingen + FROM Leerlingen l + LEFT JOIN Eigendom e ON e.Internnummer = l.Internnummer AND e.Datum_inlevering IS NULL + LEFT JOIN Toestellen t ON t.Toestel_ID = e.Toestel_ID + WHERE {vertrokken}e.Eigendom_ID IS NOT NULL + ) x + """ + woorden = query.split()[:5] + if woorden: + sql += " WHERE " + " AND ".join( + ["LOWER(CONCAT_WS(' ', internnummer, Voornaam, Naam, Gebruikersnaam, SS_nummer, IFNULL(kamxm_nummer, ''), " + "IFNULL(Serienummer, ''), IFNULL(opmerkingen, ''))) LIKE %s"] * len(woorden)) + sql += " ORDER BY heeft_toestel DESC, Datum_toekenning DESC, Naam, Voornaam, internnummer" + results = execute_query(sql, tuple(f"%{w.lower()}%" for w in woorden)) + + return render_template("inventaris.html", results=results, query=query) + +@devices_bp.route("/eigendom/historie/", methods=["GET"]) +@login_required +@recht_vereist('eigendom_bekijken') +def eigendom_historie(internnummer): + """Alle toestellen die een leerling ooit gehad heeft (ook ingeleverde), nieuwste eerst.""" + from datetime import date + rows = execute_query(""" + SELECT e.Eigendom_ID, e.Datum_toekenning, e.Datum_inlevering, IFNULL(e.Status, 0) AS locked, + t.Schooleigen_ID, t.Serienummer, t.Type, + (SELECT GROUP_CONCAT(o.Opmerking ORDER BY o.Timestamp SEPARATOR ' | ') + FROM Opmerkingen o WHERE o.Toestel_ID = t.Toestel_ID) AS opmerkingen + FROM Eigendom e INNER JOIN Toestellen t ON t.Toestel_ID = e.Toestel_ID + WHERE e.Internnummer = %s + ORDER BY e.Datum_inlevering IS NOT NULL, e.Datum_toekenning DESC, e.Eigendom_ID DESC""", (internnummer,)) or [] + leerling = (execute_query("SELECT Voornaam, Naam, Gebruikersnaam FROM Leerlingen WHERE Internnummer = %s", + (internnummer,)) or [{}])[0] + + def d(x): + return x.strftime('%d-%m-%Y') if hasattr(x, 'strftime') else (str(x) if x else '') + + uit = [] + for r in rows: + begin, einde = r['Datum_toekenning'], r['Datum_inlevering'] + dagen = None + if hasattr(begin, 'toordinal'): + dagen = ((einde if hasattr(einde, 'toordinal') else date.today()) - begin).days + uit.append({'toestel': r['Schooleigen_ID'] or '', 'serienummer': r['Serienummer'] or '', 'type': r['Type'] or '', + 'van': d(begin), 'tot': d(einde), 'huidig': not einde, 'dagen': dagen, + 'geblokkeerd': int(r['locked'] or 0) == 1, 'opmerkingen': r['opmerkingen'] or ''}) + return jsonify(ok=True, internnummer=internnummer, + naam=f"{leerling.get('Voornaam') or ''} {leerling.get('Naam') or ''}".strip(), + gebruikersnaam=leerling.get('Gebruikersnaam') or '', toestellen=uit) + + +@devices_bp.route("/inventaris/historie/", methods=["GET"]) +@login_required +@recht_vereist('toestellen_beheren', 'eigendom_bekijken') +def toestel_historie(toestel_id): + """Alle leerlingen die dit toestel ooit gehad hebben, nieuwste eerst.""" + from datetime import date + rows = execute_query(""" + SELECT e.Internnummer, e.Datum_toekenning, e.Datum_inlevering, IFNULL(e.Status, 0) AS locked, + l.Voornaam, l.Naam, l.Gebruikersnaam + FROM Eigendom e LEFT JOIN Leerlingen l ON l.Internnummer = e.Internnummer + WHERE e.Toestel_ID = %s + ORDER BY e.Datum_inlevering IS NOT NULL, e.Datum_toekenning DESC, e.Eigendom_ID DESC""", (toestel_id,)) or [] + + def d(x): + return x.strftime('%d-%m-%Y') if hasattr(x, 'strftime') else (str(x) if x else '') + + uit = [] + for r in rows: + begin, einde = r['Datum_toekenning'], r['Datum_inlevering'] + dagen = ((einde if hasattr(einde, 'toordinal') else date.today()) - begin).days if hasattr(begin, 'toordinal') else None + uit.append({'internnummer': r['Internnummer'], 'naam': f"{r['Voornaam'] or ''} {r['Naam'] or ''}".strip(), + 'gebruikersnaam': r['Gebruikersnaam'] or '', 'van': d(begin), 'tot': d(einde), 'huidig': not einde, + 'dagen': dagen, 'geblokkeerd': int(r['locked'] or 0) == 1}) + return jsonify(ok=True, eigenaars=uit) + + +#------------------------------------------------- +# --- ROUTE UITDELEN --- +#------------------------------------------------- +@devices_bp.route('/uitdelen', methods=['GET']) +@login_required +@recht_vereist('toestellen_uitdelen') +def uitdelen_page(): + return render_template('uitdelen.html', message=None) + +@devices_bp.route('/zoek_leerlingen', methods=['GET']) +@login_required +@recht_vereist('toestellen_uitdelen', 'toestellen_beheren') +def zoek_leerlingen(): + """Suggesties voor het veld 'Internnummer' (max. 15): op internnummer, naam, voornaam of gebruikersnaam. + Geeft ook het toestel dat de leerling nu heeft.""" + q = (request.args.get('q') or '').strip() + if not q: + return jsonify([]) + woorden = q.split()[:4] + # Elk woord moet voorkomen in internnummer, gebruikersnaam of naam (in eender welke volgorde) + # LOWER(): zoeken zonder onderscheid tussen hoofd- en kleine letters, ongeacht de collation van CONCAT_WS + voorwaarden = " AND ".join(["LOWER(CONCAT_WS(' ', l.Internnummer, l.SS_nummer, l.Gebruikersnaam, l.Voornaam, l.Naam)) LIKE %s"] * len(woorden)) + rows = execute_query( + "SELECT l.Internnummer, l.Voornaam, l.Naam, l.Gebruikersnaam, " + " GROUP_CONCAT(COALESCE(t.Schooleigen_ID, t.Serienummer) SEPARATOR ', ') AS Toestellen " + "FROM Leerlingen l " + "LEFT JOIN Eigendom e ON e.Internnummer = l.Internnummer AND e.Datum_inlevering IS NULL " + "LEFT JOIN Toestellen t ON t.Toestel_ID = e.Toestel_ID " + f"WHERE {voorwaarden} " + "GROUP BY l.Internnummer, l.Voornaam, l.Naam, l.Gebruikersnaam " + "ORDER BY (l.Internnummer LIKE %s) DESC, l.Naam, l.Voornaam LIMIT 15", + tuple(f"%{w.lower()}%" for w in woorden) + (f"{q}%",)) + return jsonify([{'intern': r['Internnummer'], 'naam': f"{r['Voornaam'] or ''} {r['Naam'] or ''}".strip(), + 'gebruiker': r['Gebruikersnaam'] or '', 'toestellen': r['Toestellen'] or ''} for r in rows]) + + +@devices_bp.route('/zoek_toestellen', methods=['GET']) +@login_required +@recht_vereist('toestellen_uitdelen', 'toestellen_beheren') +def zoek_toestellen(): + """Suggesties voor het zoekveld 'Toestelnummer' (max. 15): op toestelnummer of serienummer.""" + q = (request.args.get('q') or '').strip() + if len(q) < 1: + return jsonify([]) + # Uitdelen: beschikbare toestellen eerst; inleveren: uitgeleende toestellen eerst + voorkeur = {'uitgeleend': "e.Internnummer IS NULL, ", 'beschikbaar': "e.Internnummer IS NOT NULL, "}.get( + request.args.get('voorkeur'), "") + rows = execute_query( + "SELECT t.Schooleigen_ID, t.Serienummer, t.Type, t.Status, e.Internnummer " + "FROM Toestellen t LEFT JOIN Eigendom e ON e.Toestel_ID = t.Toestel_ID AND e.Datum_inlevering IS NULL " + "WHERE t.Schooleigen_ID LIKE %s OR t.Serienummer LIKE %s " + f"ORDER BY {voorkeur}t.Schooleigen_ID IS NULL, (t.Schooleigen_ID LIKE %s) DESC, t.Schooleigen_ID, t.Serienummer " + "LIMIT 15", + (f"%{q}%", f"%{q}%", f"{q}%")) + return jsonify([{'nummer': r['Schooleigen_ID'] or '', 'serie': r['Serienummer'] or '', 'type': r['Type'] or '', + 'status': r['Status'] or '', 'bij': r['Internnummer'] or ''} for r in rows]) + +# Functie van knop Ingeven (Uitdelen) +@devices_bp.route('/uitdelen_submit', methods=['POST']) +@login_required +@recht_vereist('toestellen_uitdelen') +def uitdelen_submit(): + internnummer = request.form.get('internnummer') + #TODO-------------------------------------------- + try: + internnummer= int(internnummer) + + except: + internnummer = str(internnummer) + #-------------------------------------------- + serienummer = request.form.get('serienummer') + action_type = request.form.get('action') + + success = CIP_uitdelen.uitdelen(internnummer, serienummer) + + if success: + flash(f"Toestel met nummer {serienummer} succesvol uitgedeeld!", "success") + else: + # Als het mislukt, tonen we de foutmelding die in de service is gezet + flash(CIP_uitdelen.foutmelding, "error") + + return redirect(url_for('devices.uitdelen_page')) + +#------------------------------------------------- +# --- ROUTE INLEVEREN --- +#------------------------------------------------- +@devices_bp.route('/inleveren', methods=['GET']) +@login_required +@recht_vereist('toestellen_uitdelen') +def inleveren_page(): + return render_template('inleveren.html', message=None) + +# Functie van knop Ingeven (Inleveren) +@devices_bp.route('/inleveren_submit', methods=['POST']) +@login_required +@recht_vereist('toestellen_uitdelen') +def inleveren_submit(): + internnummer = request.form.get('internnummer') + serienummer = request.form.get('serienummer') + tab2_schade_text = request.form.get('schade_type', "") + action_type = request.form.get('action') + try: + internnummer= int(internnummer) + + except: + internnummer = str(internnummer) + if action_type: + CIP_inleveren.Inleveren(internnummer, serienummer, action_type, tab2_schade_text) + + return render_template('inleveren.html', message=CIP_inleveren.foutmelding) + +#--------------------------------- +# --- STATUS UPDATES --- +#--------------------------------- +@devices_bp.route("/update_toestel_status", methods=["POST"]) +@login_required +@recht_vereist('toestellen_beheren') +def wijzig_status(): + toestel_id = request.form.get("toestel_id") + new_status = request.form.get("new_status") + query = request.form.get("query", "") + + if toestel_id and new_status in ["Beschikbaar", "Uitgeleend", "Defect"]: + execute_query("UPDATE Toestellen SET Status = %s WHERE Toestel_ID = %s", (new_status, toestel_id), commit=True) + rij = execute_query("SELECT Schooleigen_ID, Serienummer FROM Toestellen WHERE Toestel_ID = %s", (toestel_id,)) + label = (rij[0]['Schooleigen_ID'] or rij[0]['Serienummer']) if rij else toestel_id + flash(f"Status van {label} gewijzigd naar {new_status}", "success") + + return redirect(url_for("devices.inventaris_toestellen", query=query)) + +#------------------------------------------------- +# --- ROUTE BLOKKEREN / DEBLOKKEREN --- +#------------------------------------------------- +@devices_bp.route('/eigendom/bewerken', methods=['POST']) +@login_required +@recht_vereist('toestellen_uitdelen') +def eigendom_bewerken(): + """Een lopende toekenning aanpassen: andere leerling en/of ander toestel.""" + query = request.form.get('query', '') + terug = redirect(url_for('devices.inventaris_eigendom', query=query or None)) + eigendom_id = request.form.get('eigendom_id', type=int) + rows = execute_query("SELECT * FROM Eigendom WHERE Eigendom_ID = %s AND Datum_inlevering IS NULL", (eigendom_id,)) + if not rows: + flash("Deze toekenning bestaat niet (meer) of is al ingeleverd.", "error") + return terug + oud = rows[0] + + leerling = tst.zoek_leerling((request.form.get('internnummer') or '').strip()) + if not leerling: + flash("Internnummer niet gevonden in de leerlingenlijst.", "error") + return terug + toestel = tst.zoek_toestel((request.form.get('toestelnummer') or '').strip()) + if not toestel: + flash("Toestelnummer (of serienummer) niet gevonden in de toestellenlijst.", "error") + return terug + + nieuw_toestel = toestel['Toestel_ID'] != oud['Toestel_ID'] + if nieuw_toestel: + bezet = execute_query("SELECT Internnummer FROM Eigendom WHERE Toestel_ID = %s AND Datum_inlevering IS NULL " + "AND Eigendom_ID <> %s", (toestel['Toestel_ID'], eigendom_id)) + if bezet: + flash(f"Toestel {toestel.get('Schooleigen_ID') or toestel.get('Serienummer')} is al uitgeleend aan " + f"{bezet[0]['Internnummer']}. Lever het eerst in.", "error") + return terug + if not nieuw_toestel and str(leerling['Internnummer']) == str(oud['Internnummer']): + flash("Er is niets gewijzigd.", "info") + return terug + + execute_query("UPDATE Eigendom SET Internnummer = %s, Toestel_ID = %s WHERE Eigendom_ID = %s", + (leerling['Internnummer'], toestel['Toestel_ID'], eigendom_id), commit=True) + if nieuw_toestel: + # Het vorige toestel is weer vrij, het nieuwe staat op uitgeleend + execute_query("UPDATE Toestellen SET Status = 'Beschikbaar' WHERE Toestel_ID = %s AND Status = 'Uitgeleend'", + (oud['Toestel_ID'],), commit=True) + execute_query("UPDATE Toestellen SET Status = 'Uitgeleend' WHERE Toestel_ID = %s", (toestel['Toestel_ID'],), + commit=True) + naam = f"{leerling.get('Voornaam') or ''} {leerling.get('Naam') or ''}".strip() or leerling['Internnummer'] + flash(f"Toekenning aangepast: {toestel.get('Schooleigen_ID') or toestel.get('Serienummer')} → {naam} " + f"({leerling['Internnummer']}).", "success") + return terug + + +@devices_bp.route('/toggle_lock', methods=['POST']) +@login_required +@recht_vereist('toestel_blokkeren') +def toggle_lock(): + # Haal de gegevens op uit het formulier en strip ze direct + internnummer = request.form.get('internnummer', '').strip() + query = request.form.get('query', '') + + # 1. Haal de huidige leerling/toestel koppeling op + rows = execute_query( + "SELECT e.Eigendom_ID, e.Status, e.Toestel_ID, t.Schooleigen_ID, t.Serienummer FROM Eigendom e " + "JOIN Toestellen t ON t.Toestel_ID = e.Toestel_ID " + "WHERE e.Internnummer = %s AND e.Datum_inlevering IS NULL ORDER BY e.Datum_toekenning DESC", + (internnummer,)) + + if not rows: + flash("Geen gegevens gevonden in de Eigendom tabel.", "error") + return redirect(url_for('devices.inventaris_eigendom', query=query)) + + current_status = int(rows[0]['Status']) if rows[0]['Status'] is not None else 0 + eigendom_id = rows[0]['Eigendom_ID'] + kamxm_nummer = str(rows[0]['Schooleigen_ID'] or '').strip() + + # 2. Serienummer van het toestel + serienummer = str(rows[0]['Serienummer']).strip() if rows[0]['Serienummer'] else None + + if not serienummer: + flash(f"Kan geen serienummer vinden voor toestel {kamxm_nummer}.", "error") + return redirect(url_for('devices.inventaris_eigendom', query=query)) + + # 3. Vraag het Device ID op bij Google met een 'gecleand' serienummer + # We gebruiken strip() om de \t (tab) en spaties te verwijderen + clean_serial = serienummer.strip() + device_id, error = google_service.get_device_id_by_serial(clean_serial) + + if device_id: + # Als status 0 is (unlocked) -> moet hij naar disable (True) + should_lock = (current_status == 0) + success, api_message = google_service.toggle_device_status(device_id, lock=should_lock) + + if success: + new_status = 1 if should_lock else 0 + execute_query("UPDATE Eigendom SET Status=%s WHERE Eigendom_ID=%s", (new_status, eigendom_id), commit=True) + try: + tst.zet_geblokkeerd(rows[0]['Toestel_ID'], should_lock) + except Exception as e: + sys.stderr.write(f"Toestellen.Geblokkeerd bijwerken: {e}\n") + flash(api_message, "success") + else: + flash(f"Google API Fout: {api_message}", "error") + else: + # Hier wordt de 'Invalid Input' nu voorkomen of getoond als het serienummer echt fout is + flash(f"Google Zoekfout: {error}", "error") + + return redirect(url_for('devices.inventaris_eigendom', query=query)) + +@devices_bp.route('/inventaris/blokkeren', methods=['POST']) +@login_required +@recht_vereist('toestel_blokkeren') +def toestel_blokkeren(): + """Blokkeert of deblokkeert één Chromebook in Google (knop in Alle toestellen). + actie = 'blokkeren' of 'deblokkeren'. Met fetch (X-Requested-With) komt er JSON terug, anders een redirect.""" + from rechten import log + ajax = request.headers.get('X-Requested-With') == 'fetch' + query = request.form.get('query', '') + actie = request.form.get('actie', '') + + def klaar(ok, melding, geblokkeerd=None): + if ajax: + return jsonify(ok=ok, melding=melding, geblokkeerd=geblokkeerd) + flash(melding, "success" if ok else "error") + return redirect(url_for('devices.inventaris_toestellen', query=query)) + + if actie not in ('blokkeren', 'deblokkeren'): + return klaar(False, "Onbekende actie.") + blokkeren = actie == 'blokkeren' + try: + toestel_id = int(request.form.get('toestel_id', '')) + except ValueError: + return klaar(False, "Onbekend toestel.") + rows = execute_query("SELECT Toestel_ID, Schooleigen_ID, Serienummer FROM Toestellen WHERE Toestel_ID = %s", + (toestel_id,)) + if not rows: + return klaar(False, "Toestel niet gevonden.") + t = rows[0] + label = t['Schooleigen_ID'] or t['Serienummer'] or str(toestel_id) + if not (t['Serienummer'] or '').strip(): + return klaar(False, f"Toestel {label} heeft geen serienummer; vul het eerst in via ✏️ Bewerken.") + try: + toestel, fout = google_service.zoek_chromebook(t['Serienummer']) + except Exception as e: + toestel, fout = None, f"Google gaf een fout: {e}" + if not toestel: + return klaar(False, fout) + status = toestel['status'] + if status == 'DEPROVISIONED': + return klaar(False, f"Toestel {label} is uitgeschreven in Google Admin en kan niet (ge)blokkeerd worden.") + gewenst = 'DISABLED' if blokkeren else 'ACTIVE' + if status != gewenst: + ok, melding = google_service.toggle_device_status(toestel['id'], lock=blokkeren) + if not ok: + return klaar(False, melding) + try: + tst.zet_geblokkeerd(toestel_id, blokkeren) + except Exception as e: + sys.stderr.write(f"Toestellen.Geblokkeerd bijwerken: {e}\n") + log(f"Chromebook {label} {'geblokkeerd' if blokkeren else 'gedeblokkeerd'}") + if status == gewenst: + return klaar(True, f"Toestel {label} was in Google al {'geblokkeerd' if blokkeren else 'actief'}.", blokkeren) + return klaar(True, f"Toestel {label} is {'geblokkeerd' if blokkeren else 'gedeblokkeerd'}.", blokkeren) + + +#------------------------------------------------- +# --- HELPER gebruikt in Toggle lock functie --- +#------------------------------------------------- +def get_serienummer_from_kamxm(kamxm_nummer): + if not kamxm_nummer: + return None + result = execute_query("SELECT Serienummer FROM Toestellen WHERE Schooleigen_ID=%s", (kamxm_nummer,)) + if result and result[0]['Serienummer']: + # Alvast een basis strip() + return str(result[0]['Serienummer']).strip() + return None + + + diff --git a/blueprints/facturatie_bp.py b/blueprints/facturatie_bp.py new file mode 100644 index 0000000..730119a --- /dev/null +++ b/blueprints/facturatie_bp.py @@ -0,0 +1,96 @@ +"""Leerlingfacturatie: facturen en creditnota's van de leerlingen van de actieve school (Informat Rekeningen API).""" +import csv +import io +import time +from datetime import date + +from flask import Blueprint, render_template, request, Response, redirect, url_for, flash + +from blueprints.auth import login_required +from rechten import recht_vereist +from services.facturatie_service import FacturatieService, FacturatieFout, schooljaren +from tenancy import huidige_school + +facturatie_bp = Blueprint('facturatie', __name__) + +STATUS_LABELS = {'open': 'Open', 'gedeeltelijk': 'Gedeeltelijk betaald', 'betaald': 'Betaald', 'credit': 'Creditnota'} + + +def _schooljaar(): + jaren = schooljaren() + gekozen = request.args.get('schooljaar') or '' + return gekozen if gekozen in jaren else jaren[0], jaren + + +@facturatie_bp.route("/", methods=["GET"]) +@login_required +@recht_vereist('leerlingfacturatie') +def overzicht(): + school = huidige_school() + schooljaar, jaren = _schooljaar() + rijen, groepen, fout, opgehaald = [], [], None, None + if not school.heeft('INFORMAT_ID', 'INFORMAT_SECRET'): + fout = "Informat is nog niet ingesteld voor deze school (🔌 Koppelingen › Informat)." + else: + try: + rijen, groepen, t = FacturatieService().facturen(schooljaar, vernieuw=bool(request.args.get('vernieuw'))) + opgehaald = time.strftime('%d-%m-%Y %H:%M', time.localtime(t)) + except FacturatieFout as e: + fout = str(e) + except Exception as e: + fout = f"Facturen ophalen mislukt: {e}" + return render_template("facturatie.html", rijen=rijen, groepen=groepen, fout=fout, opgehaald=opgehaald, + schooljaar=schooljaar, jaren=jaren, school_naam=school.naam, labels=STATUS_LABELS, + today=date.today()) + + +@facturatie_bp.route("/pdf//", methods=["GET"]) +@login_required +@recht_vereist('leerlingfacturatie') +def pdf(toegangsgroep, invoice_id): + try: + inhoud = FacturatieService().pdf(toegangsgroep, invoice_id) + except FacturatieFout as e: + flash(str(e), "error") + return redirect(url_for('facturatie.overzicht', schooljaar=request.args.get('schooljaar'))) + except Exception as e: + flash(f"De PDF kon niet opgehaald worden: {e}", "error") + return redirect(url_for('facturatie.overzicht', schooljaar=request.args.get('schooljaar'))) + naam = (request.args.get('naam') or 'factuur').replace('"', '') + return Response(inhoud, mimetype='application/pdf', + headers={'Content-Disposition': f'inline; filename="{naam}.pdf"'}) + + +@facturatie_bp.route("/export.csv", methods=["GET"]) +@login_required +@recht_vereist('leerlingfacturatie') +def export(): + """CSV met dezelfde filters als de tabel: ?schooljaar=&groep=&status=&zoek=""" + schooljaar, _ = _schooljaar() + try: + rijen, _, _ = FacturatieService().facturen(schooljaar) + except Exception as e: + flash(str(e), "error") + return redirect(url_for('facturatie.overzicht', schooljaar=schooljaar)) + groep, status = request.args.get('groep') or '', request.args.get('status') or '' + zoek = (request.args.get('zoek') or '').strip().lower() + if groep: + rijen = [r for r in rijen if str(r['groep_id']) == groep] + if status == 'onbetaald': + rijen = [r for r in rijen if r['status'] in ('open', 'gedeeltelijk')] + elif status: + rijen = [r for r in rijen if r['status'] == status] + if zoek: + rijen = [r for r in rijen if zoek in ' '.join(str(r[k]) for k in ('naam', 'voornaam', 'stamnummer', 'klas', 'groep', 'ogm')).lower()] + bedrag = lambda x: f"{x:.2f}".replace('.', ',') + datum = lambda d: d.strftime('%d/%m/%Y') if d else '' + buf = io.StringIO() + w = csv.writer(buf, delimiter=';') + w.writerow(['Naam', 'Voornaam', 'Stamnummer', 'Klas', 'Factuurgroep', 'Soort', 'Datum', 'Vervaldag', 'Totaal', 'Betaald', + 'Open', 'Status', 'Gestructureerde mededeling']) + for r in rijen: + w.writerow([r['naam'], r['voornaam'], r['stamnummer'], r['klas'], r['groep'], 'Creditnota' if r['type'] == 'CREDIT_NOTE' else 'Factuur', + datum(r['datum']), datum(r['vervaldag']), bedrag(r['totaal'] * r['teken']), bedrag(r['betaald'] * r['teken']), bedrag(r['open'] * r['teken']), + STATUS_LABELS.get(r['status'], r['status']), r['ogm']]) + return Response('' + buf.getvalue(), mimetype='text/csv; charset=utf-8', headers={ + 'Content-Disposition': f"attachment; filename=Facturen_{huidige_school().code}_{schooljaar}_{date.today():%Y%m%d}.csv"}) diff --git a/blueprints/gedeelde_drives_bp.py b/blueprints/gedeelde_drives_bp.py new file mode 100644 index 0000000..481cdeb --- /dev/null +++ b/blueprints/gedeelde_drives_bp.py @@ -0,0 +1,116 @@ +"""Google › Gedeelde drives: overzicht, leden en rechten beheren.""" +import time + +from flask import Blueprint, render_template, request, redirect, url_for, flash + +from blueprints.auth import login_required +from rechten import recht_vereist, log +from services import gedeelde_drives_service as gd + +gedeelde_drives_bp = Blueprint('gedeelde_drives', __name__) + + +@gedeelde_drives_bp.route('/', methods=['GET']) +@login_required +@recht_vereist('gedeelde_drives') +def overzicht(): + drives, fout, opgehaald, gekozen, leden = [], None, None, None, [] + did = request.args.get('drive') or '' + vervang, lid, lid_van = None, (request.args.get('lid') or '').strip().lower(), None + try: + if lid: + lid_van = gd.drives_van(lid) + elif request.args.get('oud'): + oud, nieuw = request.args.get('oud').strip().lower(), (request.args.get('nieuw') or '').strip().lower() + vervang = {'oud': oud, 'nieuw': nieuw, 'drives': gd.drives_van(oud)} + elif did: + gekozen, leden = gd.drive(did) + else: + drives, t = gd.overzicht(vernieuw=bool(request.args.get('vernieuw'))) + opgehaald = time.strftime('%d-%m-%Y %H:%M', time.localtime(t)) + if request.args.get('vernieuw'): + return redirect(url_for('gedeelde_drives.overzicht')) + except gd.DriveFout as e: + fout = str(e) + except Exception as e: + fout = gd.vriendelijk(e) + return render_template('gedeelde_drives.html', drives=drives, fout=fout, opgehaald=opgehaald, gekozen=gekozen, + leden=leden, rollen=gd.ROLLEN, vervang=vervang, resultaat=None, lid=lid, lid_van=lid_van) + + +@gedeelde_drives_bp.route('/vervangen', methods=['POST']) +@login_required +@recht_vereist('gedeelde_drives') +def vervangen(): + oud, nieuw = request.form.get('oud'), request.form.get('nieuw') + try: + res = gd.vervang(oud, nieuw, request.form.getlist('drive'), request.form.get('verwijderen') == '1', + request.form.get('melding') == '1') + except gd.DriveFout as e: + flash(str(e), "error") + return redirect(url_for('gedeelde_drives.overzicht', oud=oud, nieuw=nieuw)) + ok = sum(1 for r in res if r['ok']) + log(f"Gedeelde drives: {oud} vervangen door {nieuw} op {ok} drive(s)") + return render_template('gedeelde_drives.html', drives=[], fout=None, opgehaald=None, gekozen=None, leden=[], + rollen=gd.ROLLEN, vervang={'oud': oud, 'nieuw': nieuw, 'drives': []}, resultaat=res, + lid='', lid_van=None) + + +def _terug(): + return redirect(url_for('gedeelde_drives.overzicht', drive=request.form.get('drive'))) + + +@gedeelde_drives_bp.route('/lid/toevoegen', methods=['POST']) +@login_required +@recht_vereist('gedeelde_drives') +def lid_toevoegen(): + did, email, rol = request.form.get('drive'), request.form.get('email'), request.form.get('rol') + try: + gd.lid_toevoegen(did, email, rol, request.form.get('melding') == '1') + log(f"Gedeelde drive {request.form.get('naam')}: {email} toegevoegd als {gd.ROL_NAAM.get(rol)}") + flash(f"{email} toegevoegd als {gd.ROL_NAAM.get(rol)}.", "success") + except gd.DriveFout as e: + flash(str(e), "error") + return _terug() + + +@gedeelde_drives_bp.route('/lid/wijzigen', methods=['POST']) +@login_required +@recht_vereist('gedeelde_drives') +def lid_wijzigen(): + rol = request.form.get('rol') + try: + gd.lid_wijzigen(request.form.get('drive'), request.form.get('perm'), rol) + log(f"Gedeelde drive {request.form.get('naam')}: {request.form.get('email')} → {gd.ROL_NAAM.get(rol)}") + flash(f"{request.form.get('email')} is nu {gd.ROL_NAAM.get(rol)}.", "success") + except gd.DriveFout as e: + flash(str(e), "error") + return _terug() + + +@gedeelde_drives_bp.route('/lid/verwijderen', methods=['POST']) +@login_required +@recht_vereist('gedeelde_drives') +def lid_verwijderen(): + try: + gd.lid_verwijderen(request.form.get('drive'), request.form.get('perm')) + log(f"Gedeelde drive {request.form.get('naam')}: {request.form.get('email')} verwijderd") + flash(f"{request.form.get('email')} verwijderd uit de drive.", "success") + except gd.DriveFout as e: + flash(str(e), "error") + return _terug() + + +@gedeelde_drives_bp.route('/nieuw', methods=['POST']) +@login_required +@recht_vereist('gedeelde_drives') +def nieuw(): + naam = request.form.get('naam') + try: + did = gd.drive_maken(naam, (request.form.get('beheerder') or '').strip() or None) + log(f"Gedeelde drive {naam} aangemaakt") + flash(f"Gedeelde drive '{naam}' aangemaakt.", "success") + return redirect(url_for('gedeelde_drives.overzicht', drive=did)) + except gd.DriveFout as e: + flash(str(e), "error") + return redirect(url_for('gedeelde_drives.overzicht')) diff --git a/blueprints/gmail_delegatie_bp.py b/blueprints/gmail_delegatie_bp.py new file mode 100644 index 0000000..816aed5 --- /dev/null +++ b/blueprints/gmail_delegatie_bp.py @@ -0,0 +1,156 @@ +from flask import Blueprint, render_template, request, redirect, url_for, flash, session, jsonify +from tenancy import huidige_school, per_school +from blueprints.auth import login_required +from rechten import recht_vereist +from services.gmail_delegation_service import GmailDelegationService, DelegationError, MAX_DELEGATES +from services.gmail_delegatie_overzicht import DelegatieOverzicht + +gmail_delegatie_bp = Blueprint('gmail_delegatie', __name__) +delegation_service = per_school(GmailDelegationService) + + +_table_ready = set() # scholen waarvoor de tabellen al gecontroleerd zijn + + +def _ensure_table(): + if huidige_school().code not in _table_ready: + try: + delegation_service.ensure_table() + _table_ready.add(huidige_school().code) + except Exception as e: + flash(f"Kon tabel gmail_delegatie_log niet aanmaken: {e}", "error") + + +def _safe_log(actie, mailbox, gedelegeerde): + try: + delegation_service.log(session.get('user'), actie, mailbox, gedelegeerde) + except Exception as e: + flash(f"Wijziging is uitgevoerd, maar kon niet gelogd worden: {e}", "warning") + + +#--------------------------------------------- +# Overzicht: mailbox zoeken en gedelegeerden tonen +#--------------------------------------------- +@gmail_delegatie_bp.route('/', methods=['GET']) +@login_required +@recht_vereist('gmail_delegatie') +def overzicht(): + _ensure_table() + zoek = (request.args.get('zoek') or '').strip() + mailbox_param = (request.args.get('mailbox') or '').strip() + + zoekresultaten, mailbox, delegates, fout = [], None, [], None + + # Zoekterm zonder @ = zoeken op naam; met @ = direct die mailbox openen + if zoek and '@' not in zoek: + zoekresultaten = delegation_service.search_users(zoek) + if not zoekresultaten: + fout = f"Geen gebruikers gevonden voor '{zoek}'." + elif zoek and not mailbox_param: + mailbox_param = zoek + + if mailbox_param: + try: + mailbox = delegation_service.resolve_user(mailbox_param) + delegates = delegation_service.list_delegates(mailbox['email']) + DelegatieOverzicht().bewaar_mailbox(mailbox['email'], mailbox['naam'], delegates) # overzicht actueel houden + except DelegationError as e: + fout = str(e) + + try: + geschiedenis = delegation_service.history(mailbox['email'] if mailbox else None) + except Exception: + geschiedenis = [] + + # Alle lopende machtigingen (achtergrondtaak, vanzelf vernieuwd als ze ouder zijn dan een dag) + ov = DelegatieOverzicht() + machtigingen, laatst, ov_fout = [], None, None + try: + ov.ensure_table() + if ov.verouderd(): + ov.sync_op_achtergrond() + machtigingen, laatst = ov.alle(), ov.laatst_opgehaald() + except Exception as e: + ov_fout = f"Het overzicht kon niet gelezen worden: {e}" + + return render_template('gmail_delegatie.html', zoek=zoek, zoekresultaten=zoekresultaten, + mailbox=mailbox, delegates=delegates, fout=fout, + geschiedenis=geschiedenis, max_delegates=MAX_DELEGATES, + machtigingen=machtigingen, laatst=laatst, ov_fout=ov_fout, ov_status=ov.status) + + +@gmail_delegatie_bp.route('/overzicht/vernieuwen', methods=['POST']) +@login_required +@recht_vereist('gmail_delegatie') +def overzicht_vernieuwen(): + if DelegatieOverzicht().sync_op_achtergrond(): + flash("Alle mailboxen worden nagekeken. Dit kan enkele minuten duren; de tabel vernieuwt vanzelf.", "info") + else: + flash("Er loopt al een ophaling.", "warning") + return redirect(url_for('gmail_delegatie.overzicht')) + + +@gmail_delegatie_bp.route('/overzicht/status.json', methods=['GET']) +@login_required +@recht_vereist('gmail_delegatie') +def overzicht_status(): + s = DelegatieOverzicht().status + return jsonify(bezig=s['bezig'], voortgang=s['voortgang'], totaal=s['totaal'], fout=s['fout'], resultaat=s['resultaat']) + + +def _ververs_mailbox(mailbox): + """Na een wijziging: de rijen van deze mailbox in het overzicht opnieuw opvragen.""" + try: + m = delegation_service.resolve_user(mailbox) + DelegatieOverzicht().bewaar_mailbox(m['email'], m['naam'], delegation_service.list_delegates(m['email'])) + except Exception: + pass + + +#--------------------------------------------- +# Gedelegeerde toevoegen +#--------------------------------------------- +@gmail_delegatie_bp.route('/toevoegen', methods=['POST']) +@login_required +@recht_vereist('gmail_delegatie') +def toevoegen(): + _ensure_table() + mailbox_in = request.form.get('mailbox', '') + delegate_in = request.form.get('delegate', '') + try: + mailbox = delegation_service.resolve_user(mailbox_in) + delegate = delegation_service.resolve_user(delegate_in) + if delegate['geschorst']: + raise DelegationError(f"{delegate['email']} is geschorst en kan geen toegang krijgen.") + if len(delegation_service.list_delegates(mailbox['email'])) >= MAX_DELEGATES: + raise DelegationError(f"{mailbox['email']} heeft al het maximum van {MAX_DELEGATES} gedelegeerden.") + delegation_service.add_delegate(mailbox['email'], delegate['email']) + _safe_log('toegevoegd', mailbox['email'], delegate['email']) + _ververs_mailbox(mailbox['email']) + extra = f" (alias {delegate_in.strip()} omgezet naar hoofdadres)" if delegate['is_alias'] else "" + flash(f"{delegate['email']} heeft nu toegang tot {mailbox['email']}{extra}. " + "Het kan tot een minuut duren voor de mailbox zichtbaar is.", "success") + return redirect(url_for('gmail_delegatie.overzicht', mailbox=mailbox['email'])) + except DelegationError as e: + flash(str(e), "error") + return redirect(url_for('gmail_delegatie.overzicht', mailbox=mailbox_in.strip())) + + +#--------------------------------------------- +# Gedelegeerde verwijderen +#--------------------------------------------- +@gmail_delegatie_bp.route('/verwijderen', methods=['POST']) +@login_required +@recht_vereist('gmail_delegatie') +def verwijderen(): + _ensure_table() + mailbox = (request.form.get('mailbox') or '').strip().lower() + delegate = (request.form.get('delegate') or '').strip().lower() + try: + delegation_service.remove_delegate(mailbox, delegate) + _safe_log('verwijderd', mailbox, delegate) + _ververs_mailbox(mailbox) + flash(f"Toegang van {delegate} tot {mailbox} is ingetrokken.", "success") + except DelegationError as e: + flash(str(e), "error") + return redirect(url_for('gmail_delegatie.overzicht', mailbox=mailbox)) diff --git a/blueprints/gmail_labels_bp.py b/blueprints/gmail_labels_bp.py new file mode 100644 index 0000000..a7cdc4b --- /dev/null +++ b/blueprints/gmail_labels_bp.py @@ -0,0 +1,84 @@ +"""Google › E-mails met een label verplaatsen of kopiëren naar een andere mailbox.""" +from flask import Blueprint, render_template, request, redirect, url_for, flash, session, jsonify + +from blueprints.auth import login_required +from rechten import recht_vereist, log +from services import gmail_label_service as gl +from services.gmail_delegation_service import GmailDelegationService, DelegationError + +gmail_labels_bp = Blueprint('gmail_labels', __name__) + + +def _mailbox(waarde): + """E-mailadres → {'email', 'naam', ...} via Google (hoofdadres, ook als er een alias ingegeven werd).""" + return GmailDelegationService().resolve_user(waarde) + + +@gmail_labels_bp.route('/', methods=['GET']) +@login_required +@recht_vereist('gmail_labels') +def overzicht(): + bron_in = (request.args.get('bron') or '').strip() + doel_in = (request.args.get('doel') or '').strip() + label_id = (request.args.get('label') or '').strip() + bron = doel = None + labels, fout, gekozen = [], None, None + if bron_in: + try: + bron = _mailbox(bron_in) + labels = gl.labels(bron['email']) + gekozen = next((l for l in labels if l['id'] == label_id), None) + except (DelegationError, gl.LabelFout) as e: + fout = str(e) + except Exception as e: + fout = gl.vriendelijk(e) + if doel_in and not fout: + try: + doel = _mailbox(doel_in) + if bron and doel['email'] == bron['email']: + fout, doel = "De doelmailbox moet een andere mailbox zijn dan de bronmailbox.", None + except DelegationError as e: + fout = str(e) + try: + geschiedenis = gl.geschiedenis() + except Exception: + geschiedenis = [] + return render_template('gmail_labels.html', bron_in=bron_in, doel_in=doel_in, bron=bron, doel=doel, + labels=labels, gekozen=gekozen, fout=fout, status=gl.LabelVerplaatser().status, + geschiedenis=geschiedenis, scope=gl.SCOPE) + + +@gmail_labels_bp.route('/start', methods=['POST']) +@login_required +@recht_vereist('gmail_labels') +def start(): + f = request.form + terug = dict(bron=f.get('bron'), doel=f.get('doel'), label=f.get('label')) + modus = 'verplaatsen' if f.get('modus') == 'verplaatsen' else 'kopieren' + try: + bron, doel = _mailbox(f.get('bron')), _mailbox(f.get('doel')) + if bron['email'] == doel['email']: + raise DelegationError("De doelmailbox moet een andere mailbox zijn dan de bronmailbox.") + label = next((l for l in gl.labels(bron['email']) if l['id'] == f.get('label')), None) + if not label: + raise gl.LabelFout("Dat label bestaat niet (meer) in de bronmailbox.") + except (DelegationError, gl.LabelFout) as e: + flash(str(e), "error") + return redirect(url_for('gmail_labels.overzicht', **terug)) + door = session.get('naam') or session.get('user') + if gl.LabelVerplaatser().start(bron['email'], label['id'], label['naam'], doel['email'], modus, door): + log(f"Gmail: label '{label['naam']}' {modus} van {bron['email']} naar {doel['email']}") + flash(f"Gestart: de berichten met label '{label['naam']}' worden {'verplaatst' if modus == 'verplaatsen' else 'gekopieerd'} " + f"naar {doel['email']}. Je mag deze pagina sluiten; het gaat verder op de achtergrond.", "info") + else: + flash("Er loopt al een opdracht. Wacht tot die klaar is.", "warning") + return redirect(url_for('gmail_labels.overzicht', **terug)) + + +@gmail_labels_bp.route('/status.json', methods=['GET']) +@login_required +@recht_vereist('gmail_labels') +def status(): + s = gl.LabelVerplaatser().status + return jsonify({k: s.get(k) for k in ('bezig', 'voortgang', 'totaal', 'gelukt', 'overgeslagen', 'fouten', 'fout', + 'resultaat', 'opdracht', 'foutvoorbeelden')}) diff --git a/blueprints/google_admin.py b/blueprints/google_admin.py new file mode 100644 index 0000000..af03787 --- /dev/null +++ b/blueprints/google_admin.py @@ -0,0 +1,112 @@ +from flask import Blueprint, request, flash, redirect, url_for, render_template, make_response, jsonify +from services.google_service import GoogleAdminService +from blueprints.auth import login_required +from rechten import recht_vereist +import rechten +from config import Config +from tenancy import per_school +from datetime import datetime +import io +from xhtml2pdf import pisa + +google_bp = Blueprint('google', __name__) +google_service = per_school(GoogleAdminService) + +#------------------------------------------------- +# Passwoord reset route +#------------------------------------------------- +@google_bp.route('/reset_pw', methods=['POST']) +@login_required +@recht_vereist('google_wachtwoord') +def reset_pw(): + email = request.form.get('email') + nieuw_wachtwoord = Config.STANDAARD_WACHTWOORD + + if not email: + flash("Geen e-mailadres opgegeven.", "error") + return redirect(url_for('google.password_reset')) + + success, message = google_service.change_password(email, nieuw_wachtwoord) + + if success: + rechten.log(actie=f"PW Reset voor {email}") # tabel Logs (centrale database) + flash(f"Wachtwoord voor {email} is gereset naar {nieuw_wachtwoord}", "success") + else: + flash(f"Google API Fout: {message}", "error") + + return redirect(url_for('google.password_reset')) + +#-------------------------------------------------- +# Route voor pagina google gebruikers +#-------------------------------------------------- + +@google_bp.route('/google_account_beheer', methods=['GET', 'POST']) +@login_required +@recht_vereist('google_wachtwoord', 'google_accounts') +def password_reset(): + results = [] + query = "" + if request.method == 'POST': + query = request.form.get('query') + results = google_service.search_users(query) + return render_template('google_beheer.html', results=results, query=query) + + +# Logica voor apply password +@google_bp.route('/apply_password', methods=['POST']) +@login_required +@recht_vereist('google_wachtwoord') +def apply_password(): + email = request.form.get('email') + new_password = request.form.get('new_password') + + success, message = google_service.change_password(email, new_password) + if success: + return "OK", 200 + else: + return message, 500 + +# Logica voor google account aanmaken +@google_bp.route('/aanmaken_google_account', methods=['POST']) +@login_required +@recht_vereist('google_accounts') +def aanmaken_google_account(): + first_name = request.form.get('first_name') + last_name = request.form.get('last_name') + password = request.form.get('password') + account_type = request.form.get('account_type') + + + # Gebruik de config vanuit de service + target_ou = Config.OU_NIEUWE_LEERKRACHTEN if account_type == "Leerkracht" else f"/{google_service.conf.OU_NEW_STUDENTS}" + + template_html = "pdf_template_leerkracht.html" if account_type == "Leerkracht" else "pdf_template_leerling.html" + + success, result = google_service.create_user(first_name, last_name, password, target_ou) + + if success: + context = { + 'naam': f"{first_name} {last_name}", + 'email': result, + 'wachtwoord': password, + 'datum': datetime.now().strftime("%d/%m/%Y") + } + html_string = render_template(template_html, **context) + pdf_buffer = io.BytesIO() + + # Gebruik pisa om de HTML om te zetten naar PDF + pisa_status = pisa.CreatePDF(io.StringIO(html_string), dest=pdf_buffer) + + if pisa_status.err: + flash("Fout bij het genereren van PDF", "error") + return redirect(url_for('google.password_reset')) + + pdf_buffer.seek(0) + response = make_response(pdf_buffer.read()) + response.headers['Content-Type'] = 'application/pdf' + response.headers['Content-Disposition'] = f'inline; filename=account_{first_name}.pdf' + return response + else: + flash(f"Google Fout: {result}", "error") + return redirect(url_for('google.password_reset')) + diff --git a/blueprints/google_licenties_bp.py b/blueprints/google_licenties_bp.py new file mode 100644 index 0000000..1df6fa3 --- /dev/null +++ b/blueprints/google_licenties_bp.py @@ -0,0 +1,95 @@ +import csv +import io +from datetime import datetime +from flask import Blueprint, render_template, request, redirect, url_for, flash, session, Response +from tenancy import huidige_school, per_school +from blueprints.auth import login_required +from rechten import recht_vereist +from services.google_license_service import GoogleLicenseService, SKU_NAMES + +google_licenties_bp = Blueprint('google_licenties', __name__) +license_service = per_school(GoogleLicenseService) + +# Tabel aanmaken bij de eerste request (niet bij import, zodat de app ook start zonder DB) +_table_ready = set() # scholen waarvoor de tabellen al gecontroleerd zijn + + +def _ensure_table(): + if huidige_school().code not in _table_ready: + try: + license_service.ensure_table() + _table_ready.add(huidige_school().code) + except Exception as e: + flash(f"Kon tabel google_licenties niet aanmaken: {e}", "error") + + +#--------------------------------------------- +# Overzichtspagina +#--------------------------------------------- +@google_licenties_bp.route('/', methods=['GET']) +@login_required +@recht_vereist('google_licenties') +def overzicht(): + _ensure_table() + data, fout = license_service.get_overview(force=request.args.get('vernieuw') == '1') + if request.args.get('vernieuw') == '1': + return redirect(url_for('google_licenties.overzicht')) + + opgehaald = None + if data: + opgehaald = datetime.fromtimestamp(data['opgehaald']).strftime('%d-%m-%Y %H:%M') + return render_template('google_licenties.html', data=data, fout=fout, + opgehaald=opgehaald, sku_names=SKU_NAMES) + + +#--------------------------------------------- +# Aangekochte aantallen opslaan +#--------------------------------------------- +@google_licenties_bp.route('/aangekocht', methods=['POST']) +@login_required +@recht_vereist('google_licenties') +def aangekocht_opslaan(): + _ensure_table() + gewijzigd = 0 + for key, waarde in request.form.items(): + if not key.startswith('aangekocht_'): + continue + sku_id = key.replace('aangekocht_', '', 1) + if sku_id not in SKU_NAMES: + continue + waarde = (waarde or '').strip() + if waarde == '': + continue + if not waarde.isdigit(): + flash(f"Ongeldig aantal voor {SKU_NAMES[sku_id]}: '{waarde}'", "error") + continue + license_service.set_purchased(sku_id, int(waarde), session.get('user')) + gewijzigd += 1 + + license_service.clear_cache() + if gewijzigd: + flash("Aangekochte aantallen opgeslagen.", "success") + return redirect(url_for('google_licenties.overzicht')) + + +#--------------------------------------------- +# Export van alle toegewezen licenties +#--------------------------------------------- +@google_licenties_bp.route('/export.csv', methods=['GET']) +@login_required +@recht_vereist('google_licenties') +def export_csv(): + data, fout = license_service.get_overview() + if fout or not data: + flash(fout or "Geen gegevens om te exporteren.", "error") + return redirect(url_for('google_licenties.overzicht')) + + buf = io.StringIO() + w = csv.writer(buf, delimiter=';') + w.writerow(['E-mail', 'Naam', 'Organisatie-eenheid', 'Licentie', 'SKU', 'Geschorst', 'Gearchiveerd', 'Laatste login']) + for u in data['users']: + w.writerow([u['email'], u['naam'], u['ou'], u['sku_naam'], u['sku_id'], + 'ja' if u['geschorst'] else 'nee', 'ja' if u['gearchiveerd'] else 'nee', u['laatste_login']]) + bestandsnaam = f"google_licenties_{datetime.now():%Y%m%d}.csv" + return Response('' + buf.getvalue(), mimetype='text/csv', + headers={'Content-Disposition': f'attachment; filename={bestandsnaam}'}) diff --git a/blueprints/gopro_bp.py b/blueprints/gopro_bp.py new file mode 100644 index 0000000..1828db5 --- /dev/null +++ b/blueprints/gopro_bp.py @@ -0,0 +1,375 @@ +""" +Scholengroep › GO! Pro: personeelsleden van de actieve school uit Informat, met een rol per personeelslid en +functie, en een export in de kolommen van het GO! Pro-importbestand. +""" +import csv +import io +import time +from datetime import date + +from flask import Blueprint, render_template, request, jsonify, Response, session, redirect, url_for, flash + +from blueprints.auth import login_required +from config import Config +from rechten import recht_vereist +from services import gopro_service as gp +from tenancy import huidige_school + +gopro_bp = Blueprint('gopro', __name__) + +CACHE_SECONDEN = 15 * 60 # Informat niet bij elke klik opnieuw bevragen + + +def _rijen(vernieuw=False): + """Personeel van de actieve school (15 minuten bewaard per school). De rollen komen altijd vers uit de database.""" + school = huidige_school() + bewaard = school.cache.get('gopro') + if vernieuw or not bewaard or time.time() - bewaard[0] > CACHE_SECONDEN: + rijen = gp.GoProService().personeel(school.domein, Config.INSTELLINGSNUMMER) + bewaard = (time.time(), rijen) + school.cache['gopro'] = bewaard + gekozen = gp.keuzes() + for r in bewaard[1]: + gp.pas_keuzes_toe(r, gekozen) + gp.markeer_dubbel(bewaard[1]) + return bewaard[1], bewaard[0] + + +def _niet_verzameld(rijen, per_soort=False): + """Laat de personeelsleden weg die al in de verzameltabel of in de rode lijst (genegeerd) staan. + Geeft (rijen, aantal weggelaten) of met per_soort=True (rijen, {'groen': n, 'genegeerd': m}).""" + sleutels = gp.verzameld_sleutels() + code = huidige_school().code + over, weg = [], {'groen': 0, 'genegeerd': 0} + for r in rijen: + soort = gp.is_verzameld(r, code, sleutels) + if soort: + weg[soort] = weg.get(soort, 0) + 1 + else: + over.append(r) + return over, (weg if per_soort else len(rijen) - len(over)) + + +@gopro_bp.route("/", methods=["GET"]) +@login_required +@recht_vereist('gopro') +def overzicht(): + school = huidige_school() + rijen, fout, opgehaald, verzameld_hier = [], None, None, {'groen': 0, 'genegeerd': 0, 'verzonden': 0} + if not school.heeft('INFORMAT_INST_NUMBER', 'INFORMAT_ID', 'INFORMAT_SECRET'): + fout = "Informat is nog niet ingesteld voor deze school (🔌 Koppelingen › Informat)." + else: + try: + rijen, t = _rijen(vernieuw=bool(request.args.get('vernieuw'))) + rijen, verzameld_hier = _niet_verzameld(rijen, per_soort=True) # verzameld of genegeerd: niet meer tonen + opgehaald = time.strftime('%d-%m-%Y %H:%M', time.localtime(t)) + except Exception as e: + fout = str(e) + return render_template("gopro.html", rijen=rijen, fout=fout, opgehaald=opgehaald, rollen=gp.ROLLEN, + domein=school.domein, school_naam=school.naam, + dubbel=sum(1 for r in rijen if r['dubbel']), verzameld_hier=verzameld_hier, + verzameld_totaal=len(gp.verzameld_alle()), + genegeerd_totaal=len(gp.verzameld_alle(soort='genegeerd')), + verzonden_school=gp.aantal_verzonden(school.code), + nacht=gp.nacht_instelling(), periode=gp.schooljaar_periode()) + + +@gopro_bp.route("/verplaats", methods=["POST"]) +@login_required +@recht_vereist('gopro') +def verplaats(): + """Eén personeelslid met de gegevens uit het venster (aangepast of niet) naar de verzameltabel.""" + from datetime import datetime as _dt + data = request.get_json(silent=True) or {} + pid = str(data.get('person_id') or '') + try: + rijen, _ = _rijen() + except Exception as e: + return jsonify(ok=False, fout=str(e)), 400 + rij = next((r for r in rijen if str(r['person_id']) == pid), None) + if not rij: + return jsonify(ok=False, fout="Personeelslid niet gevonden (haal de lijst opnieuw op)."), 404 + + def tekst(veld, maxlen): + return str(data.get(veld) or '').strip()[:maxlen] + + def datum(veld): + w = tekst(veld, 10) + if not w: + return None + try: + return _dt.strptime(w, '%Y-%m-%d').date() + except ValueError: + raise ValueError(f"Ongeldige datum: {w}") + + try: + nieuw = dict(rij) + nieuw.update(voornaam=tekst('voornaam', 100), achternaam=tekst('achternaam', 100), + email=tekst('email', 255).lower(), upn=tekst('upn', 255).lower(), + instellingsnummer=tekst('instellingsnummer', 20), functie=tekst('functie', 100), + rol=tekst('rol', 100), start=datum('start'), einde=datum('einde'), telefoon=tekst('telefoon', 50)) + except ValueError as e: + return jsonify(ok=False, fout=str(e)), 400 + if not nieuw['voornaam'] or not nieuw['achternaam']: + return jsonify(ok=False, fout="Voornaam en achternaam zijn verplicht."), 400 + if nieuw['email'] and '@' not in nieuw['email']: + return jsonify(ok=False, fout="Het e-mailadres is niet geldig."), 400 + if nieuw['start'] and nieuw['einde'] and nieuw['einde'] < nieuw['start']: + return jsonify(ok=False, fout="De einddatum ligt voor de startdatum."), 400 + toegevoegd, _ = gp.verzamel_toevoegen([nieuw], huidige_school(), session.get('naam') or session.get('user')) + if not toegevoegd: + return jsonify(ok=False, fout=f"{nieuw['email']} staat al in de verzameltabel of op de rode lijst."), 409 + return jsonify(ok=True, totaal=len(gp.verzameld_alle())) + + +@gopro_bp.route("/nachtelijk", methods=["POST"]) +@login_required +@recht_vereist('gopro') +def nachtelijk(): + """Schakelaar: elke nacht van alle scholen de groene naar de verzameltabel (of ?nu=1: meteen één keer).""" + door = session.get('naam') or session.get('user') + if request.form.get('nu') == '1': + if gp.verzamel_alle_scholen_op_achtergrond(door): + flash("De groene personeelsleden van alle scholen worden nu verzameld. Dit kan enkele minuten duren; " + "het resultaat verschijnt onder de schakelaar.", "info") + else: + flash("Er loopt al een verzameling.", "warning") + return redirect(url_for('gopro.overzicht')) + aan = request.form.get('aan') == '1' + gp.zet_nacht(aan, door) + from rechten import log + log(f"GO! Pro: nachtelijk verzamelen {'aangezet' if aan else 'uitgezet'}") + flash("Elke nacht (tussen 2 en 6 uur) worden de groene personeelsleden van alle scholen automatisch naar de " + "verzameltabel gezet." if aan else "Het automatisch verzamelen 's nachts staat uit.", "success") + return redirect(url_for('gopro.overzicht')) + + +@gopro_bp.route("/negeer", methods=["POST"]) +@login_required +@recht_vereist('gopro') +def negeer(): + """Eén personeelslid naar de rode lijst: wordt genegeerd (niet geëxporteerd en niet meer getoond).""" + data = request.get_json(silent=True) or request.form + pid = str(data.get('person_id') or '') + try: + rijen, _ = _rijen() + except Exception as e: + return jsonify(ok=False, fout=str(e)), 400 + rij = next((r for r in rijen if str(r['person_id']) == pid), None) + if not rij: + return jsonify(ok=False, fout="Personeelslid niet gevonden (haal de lijst opnieuw op)."), 404 + toegevoegd, _ = gp.verzamel_toevoegen([rij], huidige_school(), session.get('naam') or session.get('user'), + soort='genegeerd') + return jsonify(ok=True, toegevoegd=toegevoegd, totaal=len(gp.verzameld_alle(soort='genegeerd'))) + + +@gopro_bp.route("/keuze", methods=["POST"]) +@login_required +@recht_vereist('gopro') +def keuze(): + """Bewaart de gekozen functie of rol van één personeelslid (veld = functie | rol).""" + data = request.get_json(silent=True) or request.form + pid, veld = data.get('person_id') or '', data.get('veld') or '' + try: + gp.keuze_opslaan(pid, veld, data.get('waarde') or '', session.get('user')) + except ValueError as e: + return jsonify(ok=False, fout=str(e)), 400 + # De waarde die nu geldt (bij een leeg gemaakt e-mail/UPN-veld: weer het standaardadres) + waarde = data.get('waarde') + bewaard = huidige_school().cache.get('gopro') + if bewaard and veld in ('email', 'upn'): + rij = next((r for r in bewaard[1] if r['person_id'] == pid), None) + if rij: + gp.pas_keuzes_toe(rij, gp.keuzes()) + waarde = rij[veld] + return jsonify(ok=True, waarde=waarde) + + +@gopro_bp.route("/google.json", methods=["POST"]) +@login_required +@recht_vereist('gopro') +def google_check(): + """Welke van de doorgestuurde adressen bestaan in Google Workspace: {adres: 'primair' | 'alias'}.""" + data = request.get_json(silent=True) or {} + gevraagd = [str(a).strip().lower() for a in data.get('adressen') or [] if a][:5000] + try: + bestaand = gp.google_adressen(vernieuw=bool(data.get('vernieuw'))) + except Exception as e: + return jsonify(ok=False, fout=f"Google kon niet bevraagd worden: {e}"), 200 + return jsonify(ok=True, adressen={a: bestaand[a] for a in set(gevraagd) if a in bestaand}) + + +@gopro_bp.route("/export.csv", methods=["GET"]) +@login_required +@recht_vereist('gopro') +def export(): + """CSV in de kolommen van het GO! Pro-importbestand (; als scheidingsteken, UTF-8, datums dd/mm/jjjj).""" + try: + rijen, _ = _rijen() + rijen, _ = _niet_verzameld(rijen) + except Exception as e: + flash(str(e), "error") + return redirect(url_for('gopro.overzicht')) + # ?kleur=account|alias|geen → enkel die rijen, net als de kleurfilter op de pagina + kleur = request.args.get('kleur') or '' + if kleur in ('account', 'alias', 'geen'): + try: + google = gp.google_adressen() + except Exception as e: + flash(f"Google kon niet bevraagd worden, dus de kleurfilter kan niet toegepast worden: {e}", "error") + return redirect(url_for('gopro.overzicht')) + rijen = [r for r in rijen if gp.google_kleur(r, google) == kleur] + datum = lambda d: d.strftime('%d/%m/%Y') if d else '' + buf = io.StringIO() + w = csv.writer(buf, delimiter=';') + w.writerow(gp.EXPORT_KOLOMMEN) + for r in rijen: + w.writerow([r['voornaam'], r['achternaam'], r['email'], r['upn'], r['email'], r['instellingsnummer'], + r['functie'], r['rol'], datum(r['start']), datum(r['einde']), r['telefoon']]) + return Response('\ufeff' + buf.getvalue(), mimetype='text/csv; charset=utf-8', headers={ + 'Content-Disposition': f"attachment; filename=GO_Pro_{huidige_school().code}" + f"{'_' + {'account': 'met_account', 'alias': 'alias', 'geen': 'zonder_account'}[kleur] if kleur in ('account', 'alias', 'geen') else ''}" + f"_{date.today():%Y%m%d}.csv"}) + + +def _csv(rijen, bestandsnaam): + """CSV in de kolommen van het GO! Pro-importbestand. `rijen` = [(voornaam, achternaam, email, upn, instelling, + functie, rol, start, einde, telefoon)].""" + datum = lambda d: d.strftime('%d/%m/%Y') if d else '' + buf = io.StringIO() + w = csv.writer(buf, delimiter=';') + w.writerow(gp.EXPORT_KOLOMMEN) + for vn, an, em, upn, inst, fu, rol, st, ei, tel in rijen: + w.writerow([vn, an, em, upn, em, inst, fu, rol, datum(st), datum(ei), tel]) + return Response('\ufeff' + buf.getvalue(), mimetype='text/csv; charset=utf-8', + headers={'Content-Disposition': f"attachment; filename={bestandsnaam}"}) + + +# --------------------------------------------------------------------- +# Verzameltabel: de groene personeelsleden van alle scholen samen +# --------------------------------------------------------------------- +@gopro_bp.route("/verzamel", methods=["POST"]) +@login_required +@recht_vereist('gopro') +def verzamel(): + """Zet alle personeelsleden met een account in Google (groen) van de actieve school in de verzameltabel.""" + school = huidige_school() + try: + rijen, _ = _rijen() + rijen, _ = _niet_verzameld(rijen) + google = gp.google_adressen() + except Exception as e: + flash(f"Kon niet verzamelen: {e}", "error") + return redirect(url_for('gopro.overzicht')) + groen = [r for r in rijen if gp.google_kleur(r, google) == 'account'] + if not groen: + flash("Er zijn geen (nieuwe) groene personeelsleden om toe te voegen.", "warning") + return redirect(url_for('gopro.overzicht')) + toegevoegd, overgeslagen = gp.verzamel_toevoegen(groen, school, session.get('naam') or session.get('user')) + from rechten import log + log(f"GO! Pro: {toegevoegd} personeelsleden van {school.code} naar de verzameltabel") + flash(f"{toegevoegd} personeelsleden van {school.naam} toegevoegd aan de verzameltabel" + f"{f' ({overgeslagen} stonden er al in met hetzelfde e-mailadres)' if overgeslagen else ''}. " + "Ze worden hier niet meer getoond.", "success") + return redirect(url_for('gopro.overzicht')) + + +LIJSTEN = {'groen': 'groen', 'rood': 'genegeerd', 'verzonden': 'verzonden'} +LIJSTNAMEN = {'groen': 'de verzameltabel', 'rood': 'de rode lijst', 'verzonden': 'de lijst met verzonden personeelsleden'} + + +def _lijst(waarde): + return waarde if waarde in LIJSTEN else 'groen' + + +@gopro_bp.route("/verzameltabel", methods=["GET"]) +@login_required +@recht_vereist('gopro') +def verzameltabel(): + """?lijst=rood → de rode lijst (genegeerd), ?lijst=verzonden → al verzonden, anders de groene verzameltabel.""" + lijst = _lijst(request.args.get('lijst')) + rijen = gp.verzameld_alle(soort=LIJSTEN[lijst]) + scholen = sorted({(r['School_code'], r['School_naam'] or r['School_code']) for r in rijen}, key=lambda x: x[1].lower()) + aantal = {k: (len(rijen) if k == lijst else len(gp.verzameld_alle(soort=v))) for k, v in LIJSTEN.items()} + return render_template("gopro_verzameld.html", rijen=rijen, scholen=scholen, rollen=gp.ROLLEN, + huidige=huidige_school().code, lijst=lijst, rood=lijst == 'rood', + verzonden=lijst == 'verzonden', aantal=aantal, + aantal_groen=aantal['groen'], aantal_rood=aantal['rood']) + + +@gopro_bp.route("/verzameltabel/naar-rood", methods=["POST"]) +@login_required +@recht_vereist('gopro') +def verzameltabel_naar_rood(): + """Eén personeelslid uit de verzameltabel naar de rode lijst (genegeerd: niet exporteren, nooit meer tonen).""" + from database import central_query + from rechten import log + gp.verzamel_ensure() + n = central_query("UPDATE GoPro_verzameltabel SET Soort = 'genegeerd', Door = %s, Toegevoegd = NOW() " + "WHERE ID = %s AND Soort = 'groen'", + (session.get('naam') or session.get('user'), request.form.get('id')), commit=True) + if n: + log(f"GO! Pro: {request.form.get('naam')} van de verzameltabel naar de rode lijst") + melding = (f"{request.form.get('naam')} staat nu op de rode lijst." if n + else "Dit personeelslid staat niet (meer) in de verzameltabel.") + if request.headers.get('X-Requested-With') == 'fetch': # vanuit de tabel: zoekopdracht blijft staan + return jsonify(ok=bool(n), melding=melding) + flash(melding, "success" if n else "error") + return redirect(url_for('gopro.verzameltabel')) + + +@gopro_bp.route("/verzameltabel/verzonden", methods=["POST"]) +@login_required +@recht_vereist('gopro') +def verzameltabel_verzonden(): + """De verzameltabel (of één school, school=...) markeren als verzonden naar GO! Pro.""" + from rechten import log + code = request.form.get('school') or None + n = gp.markeer_verzonden(session.get('naam') or session.get('user'), code) + log(f"GO! Pro: {n} personeelsleden{' van ' + code if code else ''} gemarkeerd als verzonden") + flash(f"{n} personeelsleden gemarkeerd als verzonden. Ze worden niet meer aan de verzameltabel toegevoegd.", "success") + return redirect(url_for('gopro.verzameltabel')) + + +@gopro_bp.route("/verzameltabel/export.csv", methods=["GET"]) +@login_required +@recht_vereist('gopro') +def verzameltabel_export(): + """Eén CSV voor GO! Pro met alle scholen (of ?school=code voor één school).""" + code = request.args.get('school') or None + lijst = 'verzonden' if request.args.get('lijst') == 'verzonden' else 'groen' + rijen = gp.verzameld_alle(code, LIJSTEN[lijst]) + return _csv([(r['Voornaam'], r['Achternaam'], r['Email'], r['UPN'], r['Instellingsnummer'], r['Functie'], r['Rol'], + r['Startdatum'], r['Einddatum'], r['Telefoon']) for r in rijen], + f"GO_Pro_{'verzonden_' if lijst == 'verzonden' else ''}{code or 'alle_scholen'}_{date.today():%Y%m%d}.csv") + + +@gopro_bp.route("/verzameltabel/verwijderen", methods=["POST"]) +@login_required +@recht_vereist('gopro') +def verzameltabel_verwijderen(): + """Eén rij (id=...), alle rijen van één school (school=...) of alles (alles=1) uit de verzameltabel halen. + Die personeelsleden verschijnen dan weer op de GO! Pro-pagina van hun school.""" + from database import central_query + from rechten import log + gp.verzamel_ensure() + lijst = _lijst(request.form.get('lijst')) + soort, naam = LIJSTEN[lijst], LIJSTNAMEN[lijst] + if request.form.get('id'): + n = central_query("DELETE FROM GoPro_verzameltabel WHERE ID = %s AND Soort = %s", + (request.form.get('id'), soort), commit=True) + if request.headers.get('X-Requested-With') == 'fetch': # vanuit de tabel: zoekopdracht blijft staan + return jsonify(ok=bool(n), melding=f"Personeelslid uit {naam} gehaald." if n else "Niet (meer) gevonden.") + flash(f"Personeelslid uit {naam} gehaald.", "success") + elif request.form.get('school'): + code = request.form.get('school') + n = len(gp.verzameld_alle(code, soort)) + central_query("DELETE FROM GoPro_verzameltabel WHERE School_code = %s AND Soort = %s", (code, soort), commit=True) + log(f"GO! Pro: {n} rijen van {code} uit {naam} gehaald") + flash(f"{n} personeelsleden van deze school uit {naam} gehaald.", "success") + elif request.form.get('alles') == '1': + n = len(gp.verzameld_alle(soort=soort)) + central_query("DELETE FROM GoPro_verzameltabel WHERE Soort = %s", (soort,), commit=True) + log(f"GO! Pro: {naam} leeggemaakt ({n} rijen)") + flash(f"{naam[0].upper() + naam[1:]} leeggemaakt ({n} personeelsleden).", "success") + return redirect(url_for('gopro.verzameltabel', lijst=None if lijst == 'groen' else lijst)) diff --git a/blueprints/handleidingen_bp.py b/blueprints/handleidingen_bp.py new file mode 100644 index 0000000..989e150 --- /dev/null +++ b/blueprints/handleidingen_bp.py @@ -0,0 +1,273 @@ +""" +📘 Handleidingen: stappenplannen voor het beheer (bv. Google Admin). + +- Lezen: iedereen met een PIVOT-account (handleidingen met Zichtbaar = 'admins' enkel administrators). + Leerkrachten die enkel met Google inloggen (zonder PIVOT-account) zien ze niet. +- Aanmaken, bewerken, verwijderen, vorige versie terugzetten: enkel administrators. + +Handleidingen staan in de centrale database: ze gelden voor alle scholen. +Elke opslag bewaart de vorige tekst in Handleidingen_versies. +""" +import re +import unicodedata + +from flask import Blueprint, render_template, request, redirect, url_for, flash, session, abort + +import rechten +from blueprints.auth import login_required +from database import central_query, central_insert +from rechten import admin_vereist, is_admin +from services.html_schoon import schoon + +handleidingen_bp = Blueprint('handleidingen', __name__) + +ZICHTBAAR = {'iedereen': 'Iedereen met een PIVOT-account', 'admins': 'Enkel administrators'} +MAX_VERSIES = 30 + + +def mag_lezen(): + return is_admin() or bool(session.get('gebruiker_id')) + + +def _zichtbaar_filter(): + return ("Zichtbaar IN ('iedereen', 'admins')" if is_admin() else "Zichtbaar = 'iedereen'") + + +def lijst(): + """Zichtbare handleidingen voor de ingelogde gebruiker (ook gebruikt door het dashboard).""" + if not mag_lezen(): + return [] + try: + return central_query(f"SELECT Handleiding_ID, Slug, Titel, Categorie, Samenvatting, Zichtbaar, Gewijzigd_op, " + f"Gewijzigd_door FROM Handleidingen WHERE {_zichtbaar_filter()} " + f"ORDER BY Volgorde, Titel") + except Exception: + return [] + + +def _maak_slug(titel, behalve=None): + s = unicodedata.normalize('NFKD', titel).encode('ascii', 'ignore').decode().lower() + s = re.sub(r'[^a-z0-9]+', '-', s).strip('-')[:80] or 'handleiding' + if s == 'nieuw': # botst met de route /handleidingen/nieuw + s = 'nieuw-1' + kandidaat, n = s, 2 + while central_query("SELECT 1 FROM Handleidingen WHERE Slug = %s AND Handleiding_ID <> %s", + (kandidaat, behalve or 0)): + kandidaat, n = f"{s}-{n}", n + 1 + return kandidaat + + +def _haal(slug): + rows = central_query(f"SELECT * FROM Handleidingen WHERE Slug = %s AND {_zichtbaar_filter()}", (slug,)) + return rows[0] if rows else None + + +def _inhoudstafel(html): + """[(id, tekst, niveau)] van de h2/h3-koppen; koppen zonder id krijgen er een.""" + gezien, toc = set(), [] + + def vervang(m): + tag, attrs, tekst = m.group(1), m.group(2) or '', m.group(3) + platte = re.sub(r'<[^>]+>', '', tekst).strip() + idm = re.search(r'id="([^"]+)"', attrs) + hid = idm.group(1) if idm else (re.sub(r'[^a-z0-9]+', '-', platte.lower()).strip('-') or 'kop') + basis, n = hid, 2 + while hid in gezien: + hid, n = f"{basis}-{n}", n + 1 + gezien.add(hid) + toc.append((hid, platte, int(tag[1]))) + attrs = re.sub(r'\s*id="[^"]*"', '', attrs) + return f'<{tag} id="{hid}"{attrs}>{tekst}' + + html = re.sub(r'<(h[23])((?:\s[^>]*)?)>(.*?)', vervang, schoon(html or ''), flags=re.S) + return html, toc + + +# ------------------------------------------------------------------ +# Lezen +# ------------------------------------------------------------------ +@handleidingen_bp.route('/', methods=['GET']) +@login_required +def overzicht(): + if not mag_lezen(): + flash("Handleidingen zijn enkel beschikbaar voor PIVOT-gebruikers.", "error") + return redirect(url_for('main.dashboard')) + q = (request.args.get('q') or '').strip() + items = lijst() + if q: + treffers = {r['Handleiding_ID'] for r in central_query( + f"SELECT Handleiding_ID FROM Handleidingen WHERE {_zichtbaar_filter()} AND " + "(Titel LIKE %s OR Samenvatting LIKE %s OR Inhoud LIKE %s)", (f"%{q}%",) * 3)} + items = [h for h in items if h['Handleiding_ID'] in treffers] + per_cat = {} + for h in items: + per_cat.setdefault(h['Categorie'] or 'Algemeen', []).append(h) + return render_template('handleidingen.html', per_cat=per_cat, q=q, ZICHTBAAR=ZICHTBAAR) + + +@handleidingen_bp.route('/', methods=['GET']) +@login_required +def bekijk(slug): + if not mag_lezen(): + return redirect(url_for('main.dashboard')) + h = _haal(slug) + if not h: + flash("Deze handleiding bestaat niet (meer).", "error") + return redirect(url_for('handleidingen.overzicht')) + inhoud, toc = _inhoudstafel(h['Inhoud']) + return render_template('handleiding.html', h=h, inhoud=inhoud, toc=toc, ZICHTBAAR=ZICHTBAAR) + + +# ------------------------------------------------------------------ +# Bewerken (enkel administrators) +# ------------------------------------------------------------------ +def _formulier(): + titel = (request.form.get('titel') or '').strip()[:200] + return { + 'Titel': titel, + 'Categorie': (request.form.get('categorie') or '').strip()[:100] or None, + 'Samenvatting': (request.form.get('samenvatting') or '').strip()[:300] or None, + 'Inhoud': schoon(request.form.get('inhoud') or ''), + 'Zichtbaar': request.form.get('zichtbaar') if request.form.get('zichtbaar') in ZICHTBAAR else 'iedereen', + 'Volgorde': request.form.get('volgorde', type=int) or 0, + } + + +def _te_groot(e): + t = str(e) + if 'max_allowed_packet' in t or 'too large' in t.lower() or '1153' in t: + return (": de handleiding is te groot voor de database (te veel of te grote afbeeldingen). Verdeel ze over " + "meerdere handleidingen, of verhoog max_allowed_packet in MySQL.") + return f": {t}" + + +def _categorieen(): + return [r['Categorie'] for r in central_query( + "SELECT DISTINCT Categorie FROM Handleidingen WHERE Categorie IS NOT NULL AND Zichtbaar <> 'weg' ORDER BY Categorie")] + + +@handleidingen_bp.route('/nieuw', methods=['GET', 'POST']) +@login_required +@admin_vereist +def nieuw(): + if request.method == 'POST': + f = _formulier() + if not f['Titel']: + flash("Geef de handleiding een titel.", "error") + return render_template('handleiding_bewerken.html', h=f, nieuw=True, categorieen=_categorieen(), + ZICHTBAAR=ZICHTBAAR, versies=[]) + slug = _maak_slug(f['Titel']) + try: + central_insert("INSERT INTO Handleidingen (Slug, Titel, Categorie, Samenvatting, Inhoud, Zichtbaar, " + "Volgorde, Gewijzigd_op, Gewijzigd_door) VALUES (%s, %s, %s, %s, %s, %s, %s, NOW(), %s)", + (slug, f['Titel'], f['Categorie'], f['Samenvatting'], f['Inhoud'], f['Zichtbaar'], + f['Volgorde'], session.get('user'))) + except Exception as e: + flash(f"Opslaan mislukt{_te_groot(e)}", "error") + return render_template('handleiding_bewerken.html', h=f, nieuw=True, categorieen=_categorieen(), + ZICHTBAAR=ZICHTBAAR, versies=[]) + rechten.log(actie=f"Handleiding '{f['Titel']}' aangemaakt") + flash("Handleiding aangemaakt.", "success") + return redirect(url_for('handleidingen.bekijk', slug=slug)) + leeg = {'Titel': '', 'Categorie': request.args.get('categorie') or '', 'Samenvatting': '', 'Inhoud': '', + 'Zichtbaar': 'iedereen', 'Volgorde': 100} + return render_template('handleiding_bewerken.html', h=leeg, nieuw=True, categorieen=_categorieen(), + ZICHTBAAR=ZICHTBAAR, versies=[]) + + +@handleidingen_bp.route('//bewerken', methods=['GET', 'POST']) +@login_required +@admin_vereist +def bewerken(slug): + h = _haal(slug) + if not h: + abort(404) + if request.method == 'POST': + f = _formulier() + if not f['Titel']: + flash("De titel mag niet leeg zijn.", "error") + return redirect(url_for('handleidingen.bewerken', slug=slug)) + if f['Titel'] != h['Titel'] or f['Inhoud'] != (h['Inhoud'] or ''): + central_query("INSERT INTO Handleidingen_versies (Handleiding_ID, Titel, Inhoud, Opgeslagen_op, Door) " + "VALUES (%s, %s, %s, %s, %s)", + (h['Handleiding_ID'], h['Titel'], h['Inhoud'], h['Gewijzigd_op'], h['Gewijzigd_door']), + commit=True) + _versies_opruimen(h['Handleiding_ID']) + try: + central_query("UPDATE Handleidingen SET Titel = %s, Categorie = %s, Samenvatting = %s, Inhoud = %s, " + "Zichtbaar = %s, Volgorde = %s, Gewijzigd_op = NOW(), Gewijzigd_door = %s " + "WHERE Handleiding_ID = %s", + (f['Titel'], f['Categorie'], f['Samenvatting'], f['Inhoud'], f['Zichtbaar'], f['Volgorde'], + session.get('user'), h['Handleiding_ID']), commit=True) + except Exception as e: + flash(f"Opslaan mislukt{_te_groot(e)}", "error") + return redirect(url_for('handleidingen.bewerken', slug=slug)) + rechten.log(actie=f"Handleiding '{f['Titel']}' bewerkt") + flash("Handleiding opgeslagen.", "success") + if request.form.get('verder'): + return redirect(url_for('handleidingen.bewerken', slug=slug)) + return redirect(url_for('handleidingen.bekijk', slug=slug)) + versies = central_query("SELECT Versie_ID, Titel, Opgeslagen_op, Door FROM Handleidingen_versies " + "WHERE Handleiding_ID = %s ORDER BY Opgeslagen_op DESC, Versie_ID DESC", + (h['Handleiding_ID'],)) + h['Inhoud'] = schoon(h['Inhoud'] or '') + return render_template('handleiding_bewerken.html', h=h, nieuw=False, categorieen=_categorieen(), + ZICHTBAAR=ZICHTBAAR, versies=versies) + + +def _versies_opruimen(hid): + oud = central_query("SELECT Versie_ID FROM Handleidingen_versies WHERE Handleiding_ID = %s " + "ORDER BY Opgeslagen_op DESC, Versie_ID DESC", (hid,))[MAX_VERSIES:] + for r in oud: + central_query("DELETE FROM Handleidingen_versies WHERE Versie_ID = %s", (r['Versie_ID'],), commit=True) + + +@handleidingen_bp.route('//versie/', methods=['GET']) +@login_required +@admin_vereist +def versie(slug, vid): + h = _haal(slug) + rows = central_query("SELECT * FROM Handleidingen_versies WHERE Versie_ID = %s AND Handleiding_ID = %s", + (vid, h['Handleiding_ID'] if h else 0)) + if not h or not rows: + abort(404) + v = rows[0] + inhoud, toc = _inhoudstafel(v['Inhoud']) + return render_template('handleiding.html', h=h, inhoud=inhoud, toc=toc, versie=v, ZICHTBAAR=ZICHTBAAR) + + +@handleidingen_bp.route('//versie//terugzetten', methods=['POST']) +@login_required +@admin_vereist +def terugzetten(slug, vid): + h = _haal(slug) + rows = central_query("SELECT * FROM Handleidingen_versies WHERE Versie_ID = %s AND Handleiding_ID = %s", + (vid, h['Handleiding_ID'] if h else 0)) + if not h or not rows: + abort(404) + v = rows[0] + central_query("INSERT INTO Handleidingen_versies (Handleiding_ID, Titel, Inhoud, Opgeslagen_op, Door) " + "VALUES (%s, %s, %s, %s, %s)", + (h['Handleiding_ID'], h['Titel'], h['Inhoud'], h['Gewijzigd_op'], h['Gewijzigd_door']), commit=True) + central_query("UPDATE Handleidingen SET Titel = %s, Inhoud = %s, Gewijzigd_op = NOW(), Gewijzigd_door = %s " + "WHERE Handleiding_ID = %s", (v['Titel'], v['Inhoud'], session.get('user'), h['Handleiding_ID']), + commit=True) + _versies_opruimen(h['Handleiding_ID']) + rechten.log(actie=f"Handleiding '{v['Titel']}': versie van {v['Opgeslagen_op']:%d-%m-%Y %H:%M} teruggezet") + flash(f"Versie van {v['Opgeslagen_op']:%d-%m-%Y %H:%M} teruggezet. De vorige tekst staat bij de versies.", "success") + return redirect(url_for('handleidingen.bekijk', slug=slug)) + + +@handleidingen_bp.route('//verwijderen', methods=['POST']) +@login_required +@admin_vereist +def verwijderen(slug): + h = _haal(slug) + if not h: + abort(404) + # Verbergen i.p.v. wissen: zo komt een meegeleverde handleiding niet terug bij het opstarten + central_query("UPDATE Handleidingen SET Zichtbaar = 'weg', Gewijzigd_op = NOW(), Gewijzigd_door = %s " + "WHERE Handleiding_ID = %s", (session.get('user'), h['Handleiding_ID']), commit=True) + rechten.log(actie=f"Handleiding '{h['Titel']}' verwijderd") + flash(f"Handleiding '{h['Titel']}' verwijderd.", "success") + return redirect(url_for('handleidingen.overzicht')) diff --git a/blueprints/handtekeningen_bp.py b/blueprints/handtekeningen_bp.py new file mode 100644 index 0000000..f78110f --- /dev/null +++ b/blueprints/handtekeningen_bp.py @@ -0,0 +1,179 @@ +"""Gmail › Handtekeningen: sjabloon, logo, OU's, functie/telefoon (Google) en toepassen in Gmail.""" +import time + +from flask import Blueprint, render_template, request, redirect, url_for, flash, session, jsonify, Response, abort + +from blueprints.auth import login_required +from rechten import recht_vereist, log +from services import handtekening_service as hs +from tenancy import huidige_school, school_op_code, in_school + +handtekeningen_bp = Blueprint('handtekeningen', __name__) +handtekening_logo_bp = Blueprint('handtekening_logo', __name__) # publiek (Gmail haalt het logo op) + +CACHE_SECONDEN = 5 * 60 + + +def _door(): + return session.get('naam') or session.get('user') + + +def _gebruikers(inst, vernieuw=False): + school = huidige_school() + sleutel = ('handtekening_gebruikers', tuple(inst['ous']), bool(inst.get('Met_sub_ous'))) + bewaard = school.cache.get('handtekening_gebruikers') + if bewaard and not vernieuw and bewaard[0] == sleutel and time.time() - bewaard[1] < CACHE_SECONDEN: + return bewaard[2] + lijst = hs.gebruikers(inst['ous'], bool(inst.get('Met_sub_ous'))) + school.cache['handtekening_gebruikers'] = (sleutel, time.time(), lijst) + return lijst + + +@handtekeningen_bp.route('/', methods=['GET']) +@login_required +@recht_vereist('gmail_handtekeningen') +def overzicht(): + inst = hs.instellingen() + fout_ou = fout_gebruikers = None + try: + ous = hs.ou_lijst() + except hs.HandtekeningFout as e: + ous, fout_ou = inst['ous'], str(e) + gebruikers = [] + if inst['ous']: + try: + gebruikers = _gebruikers(inst, vernieuw=bool(request.args.get('vernieuw'))) + except Exception as e: + fout_gebruikers = str(e) + if request.args.get('vernieuw'): + return redirect(url_for('handtekeningen.overzicht')) + logboek = hs.logboek() + return render_template('handtekeningen.html', inst=inst, ous=ous, fout_ou=fout_ou, gebruikers=gebruikers, + fout_gebruikers=fout_gebruikers, logboek=logboek, velden=hs.VELDEN, + standaard_sjabloon=hs.STANDAARD_SJABLOON, max_tekens=hs.MAX_TEKENS, + publiek_voorstel=request.host_url.rstrip('/'), status=hs.Toepasser().status, + school_gegevens=hs.gegevens({})) + + +@handtekeningen_bp.route('/sjabloon', methods=['POST']) +@login_required +@recht_vereist('gmail_handtekeningen') +def sjabloon(): + hs.ensure_tables() + try: + hs.bewaar(request.form.get('sjabloon'), request.form.getlist('ou'), request.form.get('met_sub') == '1', + request.form.get('logo_url'), request.form.get('logo_breedte') or 120, + request.form.get('publiek_adres'), _door()) + bestand = request.files.get('logo') + if bestand and bestand.filename: + hs.bewaar_logo(bestand.read(), bestand.mimetype) + elif request.form.get('logo_wissen') == '1': + hs.verwijder_logo() + huidige_school().cache.pop('handtekening_gebruikers', None) + log("Gmail-handtekening: sjabloon en instellingen bewaard") + flash("Handtekening en instellingen bewaard. Klik op ▶ Toepassen om ze in Gmail te zetten.", "success") + except hs.HandtekeningFout as e: + flash(str(e), "error") + except Exception as e: + flash(f"Bewaren mislukt: {e}", "error") + return redirect(url_for('handtekeningen.overzicht')) + + +@handtekeningen_bp.route('/voorbeeld', methods=['POST']) +@login_required +@recht_vereist('gmail_handtekeningen') +def voorbeeld(): + """Live voorbeeld met het (nog niet bewaarde) sjabloon uit de editor en de gegevens van één gebruiker.""" + d = request.get_json(silent=True) or {} + inst = hs.instellingen() + if d.get('logo_url') is not None: + inst['Logo_url'] = (d.get('logo_url') or '').strip() or None + if d.get('publiek_adres') is not None: + inst['Publiek_adres'] = (d.get('publiek_adres') or '').strip().rstrip('/') or None + try: + inst['Logo_breedte'] = int(d.get('logo_breedte') or inst.get('Logo_breedte') or 120) + except ValueError: + pass + inst['logo_link'] = hs.logo_link(inst) + # In het voorbeeld het geüploade logo via de eigen (ingelogde) sessie tonen, ook als het publiek adres nog niet klopt + if not inst.get('Logo_url') and inst.get('heeft_logo') and inst.get('Logo_sleutel'): + inst['logo_link'] = url_for('handtekening_logo.logo', code=huidige_school().code, sleutel=inst['Logo_sleutel'], + ext=hs.LOGO_TYPES.get(inst.get('Logo_type'), 'png')) + g = d.get('gebruiker') or {} + tekst = hs.vul_in(d.get('sjabloon') if d.get('sjabloon') is not None else inst.get('Sjabloon'), hs.gegevens(g), inst) + return jsonify(ok=True, html=tekst, tekens=len(tekst)) + + +@handtekeningen_bp.route('/gebruiker', methods=['POST']) +@login_required +@recht_vereist('gmail_handtekeningen') +def gebruiker(): + """Functie en telefoon van één gebruiker in Google bewaren.""" + d = request.get_json(silent=True) or {} + email = (d.get('email') or '').strip().lower() + functie, telefoon = (d.get('functie') or '').strip()[:100], (d.get('telefoon') or '').strip()[:40] + if not email: + return jsonify(ok=False, fout="Geen gebruiker."), 400 + try: + hs.bewaar_in_google(email, functie, telefoon) + except hs.HandtekeningFout as e: + return jsonify(ok=False, fout=str(e)), 400 + bewaard = huidige_school().cache.get('handtekening_gebruikers') + if bewaard: + for u in bewaard[2]: + if u['email'] == email: + u.update(functie=functie, telefoon=telefoon) + log(f"Gmail-handtekening: functie/telefoon van {email} aangepast in Google") + return jsonify(ok=True) + + +@handtekeningen_bp.route('/toepassen', methods=['POST']) +@login_required +@recht_vereist('gmail_handtekeningen') +def toepassen(): + d = request.get_json(silent=True) or {} + emails = None if d.get('alle') else [e for e in (d.get('emails') or []) if isinstance(e, str)] + if emails is not None and not emails: + return jsonify(ok=False, fout="Niemand geselecteerd."), 400 + if not hs.Toepasser().start(emails, _door()): + return jsonify(ok=False, fout="Er loopt al een opdracht."), 409 + log(f"Gmail-handtekening toepassen ({'iedereen' if emails is None else len(emails)})") + return jsonify(ok=True) + + +@handtekeningen_bp.route('/status.json', methods=['GET']) +@login_required +@recht_vereist('gmail_handtekeningen') +def status(): + s = dict(hs.Toepasser().status) + if not s['bezig']: + s['logboek'] = {k: {'ok': bool(v['Ok']), 'melding': v['Melding'] or '', + 'op': v['Toegepast'].strftime('%d-%m-%Y %H:%M')} for k, v in hs.logboek().items()} + return jsonify(s) + + +@handtekeningen_bp.route('/nachtelijk', methods=['POST']) +@login_required +@recht_vereist('gmail_handtekeningen') +def nachtelijk(): + aan = request.form.get('aan') == '1' + hs.zet_nacht(aan, _door()) + log(f"Gmail-handtekening: nachtelijk opnieuw toepassen {'aan' if aan else 'uit'}") + flash("Elke nacht wordt de handtekening opnieuw gezet bij wie ze gewijzigd heeft." if aan + else "Het nachtelijk opnieuw toepassen staat uit.", "success") + return redirect(url_for('handtekeningen.overzicht')) + + +# --------------------------------------------------------------------- +# Publiek: het logo (Gmail haalt het op via een link; geen aanmelding) +# --------------------------------------------------------------------- +@handtekening_logo_bp.route('/handtekening-logo//.', methods=['GET']) +def logo(code, sleutel, ext): + school = school_op_code(code) + if not school or not sleutel.isalnum(): + abort(404) + with in_school(school): + data, soort = hs.logo_bestand(sleutel) + if not data: + abort(404) + return Response(bytes(data), mimetype=soort, headers={'Cache-Control': 'public, max-age=86400'}) diff --git a/blueprints/internaat_bp.py b/blueprints/internaat_bp.py new file mode 100644 index 0000000..a8a3f9e --- /dev/null +++ b/blueprints/internaat_bp.py @@ -0,0 +1,229 @@ +"""Internaat: lijst van alle internen van de actieve school (Informat Internaat WEB API).""" +import csv +import io +import time +from datetime import date + +from flask import Blueprint, render_template, request, Response, redirect, url_for, flash + +from blueprints.auth import login_required +from rechten import recht_vereist +from services.facturatie_service import schooljaren +from services.internaat_service import InternaatService, InternaatFout +from tenancy import huidige_school + +internaat_bp = Blueprint('internaat', __name__) + + +def _keuzes(): + jaren = schooljaren() + schooljaar = request.args.get('schooljaar') if request.args.get('schooljaar') in jaren else jaren[0] + try: + peildatum = date.fromisoformat(request.args.get('peildatum') or '') + except ValueError: + peildatum = date.today() + return schooljaar, jaren, peildatum + + +@internaat_bp.route("/internen", methods=["GET"]) +@login_required +@recht_vereist('internen') +def internen(): + school = huidige_school() + schooljaar, jaren, peildatum = _keuzes() + rijen, fout, opgehaald = [], None, None + if not school.heeft('INFORMAT_ID', 'INFORMAT_SECRET'): + fout = "Informat is nog niet ingesteld voor deze school (🔌 Koppelingen › Informat)." + else: + try: + rijen, t = InternaatService().internen(schooljaar, peildatum, vernieuw=bool(request.args.get('vernieuw'))) + opgehaald = time.strftime('%d-%m-%Y %H:%M', time.localtime(t)) + except InternaatFout as e: + fout = str(e) + except Exception as e: + fout = f"Internen ophalen mislukt: {e}" + internaten = sorted({r['internaat'] for r in rijen}) + return render_template("internen.html", rijen=rijen, fout=fout, opgehaald=opgehaald, schooljaar=schooljaar, + jaren=jaren, peildatum=peildatum, internaten=internaten, school_naam=school.naam) + + +@internaat_bp.route("/internen/smartschool.json", methods=["POST"]) +@login_required +@recht_vereist('internen') +def internen_smartschool(): + """Welke internen bestaan als gebruiker in Smartschool? {pid: gebruikersnaam | null}""" + from flask import jsonify + from services.internaat_service import smartschool_status + personen = (request.get_json(silent=True) or {}).get('personen') or [] + try: + return jsonify(ok=True, status=smartschool_status(personen[:2000])) + except InternaatFout as e: + return jsonify(ok=False, fout=str(e)) + except Exception as e: + return jsonify(ok=False, fout=f"Smartschool kon niet bevraagd worden: {e}") + + +@internaat_bp.route("/internen/export.csv", methods=["GET"]) +@login_required +@recht_vereist('internen') +def export(): + schooljaar, _, peildatum = _keuzes() + try: + rijen, _ = InternaatService().internen(schooljaar, peildatum) + except Exception as e: + flash(str(e), "error") + return redirect(url_for('internaat.internen', schooljaar=schooljaar)) + internaat = request.args.get('internaat') or '' + zoek = (request.args.get('zoek') or '').strip().lower() + if internaat: + rijen = [r for r in rijen if r['internaat'] == internaat] + if zoek: + rijen = [r for r in rijen if zoek in ' '.join(str(r[k]) for k in ('naam', 'voornaam', 'stamnummer', 'kamer', 'vestiging', 'gemeente')).lower()] + datum = lambda d: d.strftime('%d/%m/%Y') if d else '' + buf = io.StringIO() + w = csv.writer(buf, delimiter=';') + w.writerow(['Naam', 'Voornaam', 'Geboortedatum', 'Geslacht', 'Stamnummer', 'Internaat', 'Vestiging', 'Kamer', + 'Ingeschreven van', 'Ingeschreven tot', 'Financiering', 'Woonplaats', 'Contact 1', 'Gsm 1', 'E-mail 1', + 'Contact 2', 'Gsm 2', 'E-mail 2', 'Smartschool']) + ss_cache = huidige_school().cache.get('ss_internen') or {} + def ss(r): + hit = ss_cache.get((str(r['id']), r['voornaam'], r['naam'])) + return '' if not hit else (hit[1] or 'niet gevonden') + for r in rijen: + o = (r['ouders'] + [{}, {}])[:2] + w.writerow([r['naam'], r['voornaam'], datum(r['geboortedatum']), r['geslacht'], r['stamnummer'], r['internaat'], + r['vestiging'], r['kamer'], datum(r['begin']), datum(r['einde']), r['financiering'], r['gemeente']] + + sum(([f"{x.get('naam', '')} ({x['type']})" if x.get('type') else x.get('naam', ''), x.get('gsm', ''), + x.get('email', '')] for x in o), []) + [ss(r)]) + return Response('' + buf.getvalue(), mimetype='text/csv; charset=utf-8', headers={ + 'Content-Disposition': f"attachment; filename=Internen_{huidige_school().code}_{peildatum:%Y%m%d}.csv"}) + + +# --------------------------------------------------------------------- +# Aanwezigheden (register per week: ochtend en avond) +# --------------------------------------------------------------------- +from datetime import timedelta +from flask import jsonify, session +from services import aanwezigheid_service as aw + + +def _week(): + try: + d = date.fromisoformat(request.args.get('week') or '') + except ValueError: + d = date.today() + return aw.maandag_van(d) + + +def _internen_van_week(maandag): + """Internen die in de week ingeschreven zijn (peildatum: vandaag als die in de week valt, anders woensdag).""" + vandaag = date.today() + peildatum = vandaag if maandag <= vandaag <= maandag + timedelta(days=6) else maandag + timedelta(days=2) + rijen, _ = InternaatService().internen(aw.schooljaar_van(peildatum), peildatum) + meerdere = len({r['internaat'] for r in rijen}) > 1 + for r in rijen: + r['groep'] = aw.groep_van(r, meerdere) + r['pid'] = str(r['id']) + return rijen + + +@internaat_bp.route("/aanwezigheden", methods=["GET"]) +@login_required +@recht_vereist('internaat_aanwezigheid') +def aanwezigheden(): + school = huidige_school() + maandag = _week() + rijen, fout, data = [], None, {} + try: + rijen = _internen_van_week(maandag) + data = aw.week(maandag) + except InternaatFout as e: + fout = str(e) + except Exception as e: + fout = f"Internen ophalen mislukt: {e}" + groepen = {} + for r in sorted(rijen, key=lambda r: (r['groep'].lower(), r['naam'].lower(), r['voornaam'].lower())): + groepen.setdefault(r['groep'], []).append(r) + dagen = [maandag + timedelta(days=i) for i in range(7)] + return render_template("aanwezigheden.html", groepen=groepen, data=data, dagen=dagen, maandag=maandag, + fout=fout, school_naam=school.naam, vandaag=date.today(), dagnamen=aw.DAGEN, + dagnamen_voluit=aw.DAGEN_VOLUIT, aantal=len(rijen), + vorige=maandag - timedelta(days=7), volgende=maandag + timedelta(days=7), + weeknr=maandag.isocalendar()[1]) + + +@internaat_bp.route("/aanwezigheden/zet", methods=["POST"]) +@login_required +@recht_vereist('internaat_aanwezigheid') +def aanwezigheid_zet(): + d = request.get_json(silent=True) or {} + try: + datum = date.fromisoformat(str(d.get('datum') or '')) + aw.zet(datum, str(d.get('persoon') or '')[:64], d.get('moment'), d.get('status'), d.get('naam'), + session.get('naam') or session.get('user')) + except ValueError as e: + return jsonify(ok=False, fout=str(e) or 'Ongeldige gegevens.'), 400 + except Exception as e: + return jsonify(ok=False, fout=f"Bewaren mislukt: {e}"), 500 + return jsonify(ok=True) + + +@internaat_bp.route("/aanwezigheden/export.xlsx", methods=["GET"]) +@login_required +@recht_vereist('internaat_aanwezigheid') +def aanwezigheden_export(): + """Excel van de week, met dezelfde filters als op het scherm (?groep=&zoek=).""" + from openpyxl import Workbook + from openpyxl.styles import Font, PatternFill, Alignment + maandag = _week() + try: + rijen = _internen_van_week(maandag) + data = aw.week(maandag) + except Exception as e: + flash(str(e), "error") + return redirect(url_for('internaat.aanwezigheden', week=maandag.isoformat())) + groep, zoek = request.args.get('groep') or '', (request.args.get('zoek') or '').strip().lower() + rijen = [r for r in rijen if (not groep or r['groep'] == groep) + and (not zoek or zoek in f"{r['naam']} {r['voornaam']}".lower())] + rijen.sort(key=lambda r: (r['groep'].lower(), r['naam'].lower(), r['voornaam'].lower())) + labels = {'aanwezig': 'Aanwezig', 'afwezig': 'Afwezig'} + kleur = {'aanwezig': PatternFill('solid', fgColor='D7EDDF'), 'afwezig': PatternFill('solid', fgColor='F6D5CF')} + wb = Workbook() + ws = wb.active + ws.title = f"Week {maandag.isocalendar()[1]}" + kop = ['Naam', 'Voornaam', 'Groep', 'Kamer'] + for i, dag in enumerate(aw.DAGEN_VOLUIT): + d = maandag + timedelta(days=i) + kop += [f"{dag} {d:%d/%m} ochtend", f"{dag} {d:%d/%m} avond"] + ws.append(kop) + for c in ws[1]: + c.font = Font(bold=True) + c.alignment = Alignment(wrap_text=True, vertical='top') + vorige_groep = None + for r in rijen: + if r['groep'] != vorige_groep: + ws.append([f"Groep: {r['groep']}"]) + ws.cell(ws.max_row, 1).font = Font(bold=True) + vorige_groep = r['groep'] + rij = [r['naam'], r['voornaam'], r['groep'], r['kamer']] + for i in range(7): + for m in aw.MOMENTEN: + rij.append(labels.get(data.get(f"{r['pid']}:{i}:{m}"), '')) + ws.append(rij) + for c in ws[ws.max_row][4:]: + for k, f in kleur.items(): + if c.value == labels[k]: + c.fill = f + ws.column_dimensions['A'].width = 22 + ws.column_dimensions['B'].width = 16 + ws.column_dimensions['C'].width = 16 + ws.column_dimensions['D'].width = 22 + from openpyxl.utils import get_column_letter + for i in range(5, 5 + 14): + ws.column_dimensions[get_column_letter(i)].width = 13 + ws.freeze_panes = 'C2' + buf = io.BytesIO() + wb.save(buf) + return Response(buf.getvalue(), mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + headers={'Content-Disposition': f"attachment; filename=Aanwezigheden_{huidige_school().code}_" + f"{maandag:%Y}-W{maandag.isocalendar()[1]:02d}.xlsx"}) diff --git a/blueprints/ipblokkade_bp.py b/blueprints/ipblokkade_bp.py new file mode 100644 index 0000000..9354acb --- /dev/null +++ b/blueprints/ipblokkade_bp.py @@ -0,0 +1,285 @@ +""" +Beheer › IP-blokkades. Geldt voor alle scholen. + +- Administrators zien en beheren alles (blokkeren, altijd toestaan, schakelaar, testen). +- Een gewone gebruiker met het recht 'ip_whitelist' ziet enkel de toegestane adressen van de actieve school, + kan een adres toevoegen (school = actieve school, opmerking = zijn naam) en enkel zijn eigen regels verwijderen. +""" +import functools + +from flask import Blueprint, render_template, request, flash, redirect, url_for, session + +from blueprints.auth import login_required +from database import central_query +from rechten import admin_vereist, recht_vereist, is_admin, log +from services import ipblokkade as ipb +from tenancy import alle_scholen, school_op_code, huidige_school + +ipblokkade_bp = Blueprint('ipblokkade', __name__) + + +def _schoolnaam(code): + s = school_op_code(code, ook_inactief=True) + return s.naam if s else code + + +def admin_of_whitelist(view): + """Administrators altijd (ook zonder gekozen school); anderen met het recht 'ip_whitelist' in de actieve school.""" + beschermd = recht_vereist('ip_whitelist')(view) + + @functools.wraps(view) + def wrapped(*a, **kw): + return view(*a, **kw) if session.get('auth') and is_admin() else beschermd(*a, **kw) + return wrapped + + +# Gewone gebruikers mogen hoogstens zo'n reeks toestaan (een ruimere reeks laat te veel adressen altijd binnen) +MAX_PREFIX_GEBRUIKER = {4: 24, 6: 64} + + +def _eigen(r): + """Mag de ingelogde (gewone) gebruiker deze regel verwijderen? Enkel als hij hem zelf toevoegde.""" + gid = session.get('gebruiker_id') + return bool(gid) and r.get('Gebruiker_ID') == gid and r.get('Soort') == 'toestaan' + + +def _gekozen_scholen(): + """De aangevinkte scholen uit het formulier (enkel bestaande schoolcodes).""" + return ipb.scholen_waarde(c for c in request.form.getlist('scholen') if school_op_code(c, ook_inactief=True)) + + +@ipblokkade_bp.route("/", methods=["GET"]) +@login_required +@admin_of_whitelist +def overzicht(): + ipb.ensure_table() + rijen = central_query("SELECT * FROM IP_blokkades ORDER BY Soort, Reeks") or [] + if not is_admin(): + return _overzicht_gebruiker(rijen) + import ipaddress + for r in rijen: # sorteersleutel: numeriek op adres (zodat 9.x vóór 85.x komt), dan op grootte van de reeks + try: + net = ipaddress.ip_network(r['Reeks'], strict=False) + r['Sorteer'] = f"{net.version}-{int(net.network_address):040d}-{net.prefixlen:03d}" + except ValueError: + r['Sorteer'] = r['Reeks'] + r['Scholen_codes'] = ipb.scholen_codes(r.get('Scholen')) + r['Scholen_namen'] = sorted((_schoolnaam(c) for c in r['Scholen_codes']), key=str.lower) + test_ip = (request.args.get('test') or '').strip() + test = None + if test_ip: + try: + lijst = ipb.regels(vers=True) + geweigerd, rid = ipb.beoordeel(test_ip, lijst) + regel = next((r for r in rijen if r['ID'] == rid), None) + test = {'ip': test_ip, 'geweigerd': geweigerd, 'regel': regel} + except Exception as e: + flash(f"Test mislukt: {e}", "error") + modus = ipb.instelling(ipb.MODUS_SLEUTEL) + teller = ipb.instelling(ipb.TELLER_SLEUTEL) + scholen = sorted(alle_scholen(ook_inactief=True), key=lambda s: s.naam.lower()) + return render_template("ipblokkade.html", rijen=rijen, mijn_ip=request.remote_addr, test=test, scholen=scholen, + blokkeren=[r for r in rijen if r['Soort'] == 'blokkeren'], + toestaan=[r for r in rijen if r['Soort'] == 'toestaan'], + enkel_toegestaan=bool(modus and modus['Waarde'] == '1'), modus=modus, + lokaal=ipb.is_lokaal(request.remote_addr), + modus_geweigerd=int(teller['Waarde']) if teller and str(teller['Waarde']).isdigit() else 0) + + +def _overzicht_gebruiker(rijen): + """Gewone gebruiker: enkel de toegestane adressen van de actieve school.""" + school = huidige_school() + eigen_school = [r for r in rijen if r['Soort'] == 'toestaan' and school.code in ipb.scholen_codes(r.get('Scholen'))] + import ipaddress + for r in eigen_school: + try: + net = ipaddress.ip_network(r['Reeks'], strict=False) + r['Sorteer'] = f"{net.version}-{int(net.network_address):040d}-{net.prefixlen:03d}" + except ValueError: + r['Sorteer'] = r['Reeks'] + r['Eigen'] = _eigen(r) + return render_template("ipblokkade_school.html", rijen=eigen_school, school_naam=school.naam, + mijn_ip=request.remote_addr, lokaal=ipb.is_lokaal(request.remote_addr), + naam=session.get('naam') or session.get('user')) + + +@ipblokkade_bp.route("/modus", methods=["POST"]) +@login_required +@admin_vereist +def modus(): + """Schakelaar: enkel nog adressen uit "Altijd toestaan" binnenlaten.""" + ipb.ensure_table() + aan = request.form.get('aan') == '1' + if aan: + mijn = request.remote_addr + if ipb.is_lokaal(mijn): + flash(f"PIVOT ziet jouw adres als {mijn}. Dan draait PIVOT achter een reverse proxy (of test je lokaal) en " + "lijkt elke bezoeker van dat adres te komen: de witte lijst zou dan niets tegenhouden of iedereen " + "buitensluiten. Zet eerst in config.ini › [algemeen] proxy_aantal = 1 en herstart PIVOT; bij " + "'Jouw IP-adres' moet je echte adres verschijnen.", "error") + return redirect(url_for('ipblokkade.overzicht')) + lijst = ipb.regels(vers=True) + if not any(sz == 'toestaan' for _, sz, _ in lijst): + flash("Voeg eerst minstens één adres of reeks toe bij 'Altijd toestaan'.", "error") + return redirect(url_for('ipblokkade.overzicht')) + if ipb.beoordeel(mijn, lijst, enkel_toe=True)[0]: + flash(f"Jouw eigen adres ({mijn}) staat niet bij 'Altijd toestaan': dan sluit je jezelf buiten. " + "Voeg het eerst toe.", "error") + return redirect(url_for('ipblokkade.overzicht')) + ipb.zet_enkel_toegestaan(aan, session.get('user')) + flash("Enkel nog adressen uit 'Altijd toestaan' worden binnengelaten." if aan + else "Iedereen wordt weer binnengelaten, behalve de geblokkeerde adressen.", "success" if not aan else "warning") + return redirect(url_for('ipblokkade.overzicht')) + + +@ipblokkade_bp.route("/toevoegen", methods=["POST"]) +@login_required +@admin_of_whitelist +def toevoegen(): + if not is_admin(): + return _toevoegen_gebruiker() + soort = 'toestaan' if request.form.get('soort') == 'toestaan' else 'blokkeren' + try: + net = ipb.normaliseer(request.form.get('reeks')) + except ipb.OngeldigeReeks as e: + flash(str(e), "error") + return redirect(url_for('ipblokkade.overzicht')) + ipb.ensure_table() + if soort == 'blokkeren': + # Niet jezelf buitensluiten: weigeren als je eigen adres erin valt (tenzij het uitdrukkelijk toegestaan is) + lijst = ipb.regels(vers=True) + [(0, 'blokkeren', net)] + if ipb.beoordeel(request.remote_addr, lijst)[0]: + flash(f"Deze reeks bevat je eigen IP-adres ({request.remote_addr}): dan sluit je jezelf buiten. " + "Voeg eerst je eigen adres toe als uitzondering (toestaan), of kies een kleinere reeks.", "error") + return redirect(url_for('ipblokkade.overzicht')) + bestaat = central_query("SELECT ID FROM IP_blokkades WHERE Reeks = %s AND Soort = %s", (str(net), soort)) + if bestaat: + flash(f"{net} staat al in de lijst.", "warning") + return redirect(url_for('ipblokkade.overzicht')) + # Bij "Altijd toestaan" kan je aanduiden bij welke school/scholen het adres hoort (enkel ter info in de tabel) + scholen = _gekozen_scholen() if soort == 'toestaan' else None + central_query("INSERT INTO IP_blokkades (Reeks, Soort, Opmerking, Scholen, Aangemaakt, Door, Gebruiker_ID) " + "VALUES (%s, %s, %s, %s, NOW(), %s, %s)", + (str(net), soort, (request.form.get('opmerking') or '').strip()[:255] or None, + scholen, session.get('user'), session.get('gebruiker_id')), + commit=True) + ipb.vergeet_cache() + aantal = net.num_addresses + getal = f"{aantal:,}".replace(',', '.') + flash(f"{net} toegevoegd ({'geblokkeerd' if soort == 'blokkeren' else 'altijd toegestaan'}" + f"{', ' + ', '.join(_schoolnaam(c) for c in ipb.scholen_codes(scholen)) if scholen else ''}, {getal} adres{'sen' if aantal != 1 else ''}).", "success") + return redirect(url_for('ipblokkade.overzicht')) + + +def _toevoegen_gebruiker(): + """Gewone gebruiker: enkel 'Altijd toestaan', school = actieve school, opmerking = zijn naam.""" + terug = redirect(url_for('ipblokkade.overzicht')) + try: + net = ipb.normaliseer(request.form.get('reeks')) + except ipb.OngeldigeReeks as e: + flash(str(e), "error") + return terug + grens = MAX_PREFIX_GEBRUIKER[net.version] + if net.prefixlen < grens: + flash(f"{net} is een te grote reeks. Je kan één adres toevoegen of hoogstens een /{grens}-reeks " + f"(bv. {'85.12.3.' if net.version == 4 else 'een /64'}). Vraag een administrator voor een grotere reeks.", "error") + return terug + ipb.ensure_table() + school = huidige_school() + naam = (session.get('naam') or session.get('user') or '').strip()[:255] or None + bestaat = central_query("SELECT ID, Scholen FROM IP_blokkades WHERE Reeks = %s AND Soort = 'toestaan'", (str(net),)) + if bestaat: + codes = ipb.scholen_codes(bestaat[0]['Scholen']) + if school.code in codes: + flash(f"{net} staat al in de lijst van {school.naam}.", "warning") + else: + # Al toegestaan voor een andere school: deze school erbij zetten (de regel blijft van wie hem maakte) + central_query("UPDATE IP_blokkades SET Scholen = %s WHERE ID = %s", + (ipb.scholen_waarde(codes + [school.code]), bestaat[0]['ID']), commit=True) + flash(f"{net} was al toegestaan voor een andere school en staat nu ook bij {school.naam}.", "success") + return terug + central_query("INSERT INTO IP_blokkades (Reeks, Soort, Opmerking, Scholen, Aangemaakt, Door, Gebruiker_ID) " + "VALUES (%s, 'toestaan', %s, %s, NOW(), %s, %s)", + (str(net), naam, school.code, session.get('user'), session.get('gebruiker_id')), commit=True) + ipb.vergeet_cache() + log(f"IP toegestaan voor {school.code}: {net}") + flash(f"{net} toegevoegd aan de toegestane adressen van {school.naam}.", "success") + return terug + + +@ipblokkade_bp.route("/leegmaken", methods=["POST"]) +@login_required +@admin_vereist +def leegmaken(): + """Verwijdert alle 'blokkeren'-regels. 'Altijd toestaan' en de schakelaar blijven ongemoeid.""" + ipb.ensure_table() + rij = central_query("SELECT COUNT(*) AS n FROM IP_blokkades WHERE Soort = 'blokkeren'") + aantal = int(rij[0]['n']) if rij else 0 + if not aantal: + flash("De bloklijst is al leeg.", "warning") + return redirect(url_for('ipblokkade.overzicht')) + central_query("DELETE FROM IP_blokkades WHERE Soort = 'blokkeren'", commit=True) + ipb.vergeet_cache() + try: + from rechten import log + log(f"Bloklijst IP-blokkades leeggemaakt ({aantal} regels)") + except Exception: + pass + flash(f"Bloklijst leeggemaakt: {aantal} regel{'s' if aantal != 1 else ''} verwijderd.", "success") + return redirect(url_for('ipblokkade.overzicht')) + + +@ipblokkade_bp.route("//bewerken", methods=["POST"]) +@login_required +@admin_vereist +def bewerken(regel_id): + """Reeks, scholen en opmerking van een bestaande regel aanpassen (de soort blijft dezelfde).""" + terug = redirect(url_for('ipblokkade.overzicht')) + ipb.ensure_table() + rij = central_query("SELECT ID, Soort FROM IP_blokkades WHERE ID = %s", (regel_id,)) + if not rij: + flash("Deze regel bestaat niet meer.", "error") + return terug + soort = rij[0]['Soort'] + try: + net = ipb.normaliseer(request.form.get('reeks')) + except ipb.OngeldigeReeks as e: + flash(str(e), "error") + return terug + if central_query("SELECT ID FROM IP_blokkades WHERE Reeks = %s AND Soort = %s AND ID <> %s", (str(net), soort, regel_id)): + flash(f"{net} staat al in de lijst.", "warning") + return terug + # Niet jezelf buitensluiten: met de aangepaste regel moet je eigen adres nog binnen mogen + mijn = request.remote_addr + nieuw = [r for r in ipb.regels(vers=True) if r[0] != regel_id] + [(regel_id, soort, net)] + if ipb.beoordeel(mijn, nieuw, enkel_toe=ipb.enkel_toegestaan())[0] and not ipb.beoordeel(mijn, ipb.regels())[0]: + flash(f"Met deze aanpassing zou je eigen IP-adres ({mijn}) geweigerd worden: dan sluit je jezelf buiten.", "error") + return terug + central_query("UPDATE IP_blokkades SET Reeks = %s, Opmerking = %s, Scholen = %s WHERE ID = %s", + (str(net), (request.form.get('opmerking') or '').strip()[:255] or None, + _gekozen_scholen() if soort == 'toestaan' else None, regel_id), commit=True) + ipb.vergeet_cache() + flash(f"{net} bijgewerkt.", "success") + return terug + + +@ipblokkade_bp.route("//verwijderen", methods=["POST"]) +@login_required +@admin_of_whitelist +def verwijderen(regel_id): + if not is_admin(): + rij = central_query("SELECT ID, Soort, Gebruiker_ID FROM IP_blokkades WHERE ID = %s", (regel_id,)) + if not rij or not _eigen(rij[0]): + flash("Je kan enkel adressen verwijderen die je zelf toegevoegd hebt.", "error") + return redirect(url_for('ipblokkade.overzicht')) + # Staat de witte-lijstmodus aan, dan mag je de regel die jou zelf binnenlaat niet verwijderen + if ipb.enkel_toegestaan(vers=True): + rest = [r for r in ipb.regels(vers=True) if r[0] != regel_id] + if ipb.beoordeel(request.remote_addr, rest, enkel_toe=True)[0]: + flash("Deze regel laat jou zelf binnen terwijl enkel 'Altijd toestaan' geldt: verwijderen zou je " + "buitensluiten. Zet eerst de schakelaar uit of voeg je adres apart toe.", "error") + return redirect(url_for('ipblokkade.overzicht')) + central_query("DELETE FROM IP_blokkades WHERE ID = %s", (regel_id,), commit=True) + ipb.vergeet_cache() + flash("Regel verwijderd.", "success") + return redirect(url_for('ipblokkade.overzicht')) diff --git a/blueprints/leerlingenaantal_bp.py b/blueprints/leerlingenaantal_bp.py new file mode 100644 index 0000000..b255e88 --- /dev/null +++ b/blueprints/leerlingenaantal_bp.py @@ -0,0 +1,273 @@ +""" +Scholengroep: leerlingenaantallen van alle scholen in PIVOT. + +Elke school wordt geteld met haar EIGEN Informat-koppeling (instellingsnummer, +client-ID en secret uit het scholenregister). Het resultaat wordt bewaard in de +centrale database, zodat elke school het overzicht van de scholengroep ziet. + +- Huidig aantal: peildatum vandaag + 1 maand (kolommen in Scholen). +- Telling op peildatum: een gekozen datum (bv. 1 februari), bewaard in Leerlingenaantal_tellingen, + zodat je ze later opnieuw kan bekijken en exporteren. +""" +import csv +import io +import json +from datetime import date, datetime + +from flask import Blueprint, render_template, request, flash, redirect, url_for, session, Response +from blueprints.auth import login_required +from database import central_query +from rechten import recht_vereist +from services.sync_service import SyncService +from tenancy import alle_scholen, school_op_code, in_school, herlaad, ONDERWIJSNIVEAUS + +leerlingenaantal_bp = Blueprint('leerlingenaantal', __name__) + +INFORMAT_VELDEN = ('INFORMAT_INST_NUMBER', 'INFORMAT_ID', 'INFORMAT_SECRET') + +# Teksten en routes voor het gedeelde sjabloon leerlingenaantal.html (ook gebruikt door de personeelsaantallen) +WEERGAVE = dict(ep='leerlingenaantal', pagina='leerlingenaantal_page', titel='Leerlingenaantallen', + eenheid='Leerlingen', kort='lln.') + + +def _datum(waarde): + try: + return datetime.strptime((waarde or '').strip(), '%Y-%m-%d').date() + except ValueError: + return None + + +def _fmt(dt, leeg=''): + if not dt: + return leeg + return dt.strftime('%d-%m-%Y %H:%M') if hasattr(dt, 'strftime') else str(dt) + + +def _detail(tekst): + try: + return json.loads(tekst) if tekst else [] + except ValueError: + return [] + + +NIVEAUS = list(ONDERWIJSNIVEAUS) + [('', 'Onderwijsniveau niet ingesteld')] +NIVEAU_LABEL = dict(NIVEAUS) + + +def _niveau(school): + n = school.waarden.get('ONDERWIJSNIVEAU') or '' + return n if n in NIVEAU_LABEL else '' + + +def _som(rijen, veld): + return sum(r[veld] for r in rijen if isinstance(r[veld], int)) + + +def _groepen(rows, veld): + """Rijen per onderwijsniveau (basis, secundair, niet ingesteld) met subtotaal; lege groepen vallen weg.""" + uit = [] + for code, label in NIVEAUS: + rijen = [r for r in rows if r['Niveau'] == code] + if rijen: + uit.append({'code': code, 'label': label, 'rijen': rijen, 'subtotaal': _som(rijen, veld)}) + return uit + + +def _huidig(query=''): + rows = [] + for s in alle_scholen(): + if query and query.lower() not in s.naam.lower(): + continue + rows.append({ + 'Code': s.code, + 'Naam_school': s.naam, + 'Niveau': _niveau(s), + 'Leerlingenaantal': s.leerlingenaantal if s.leerlingenaantal is not None else '–', + 'Huidig': s.leerlingenaantal if s.leerlingenaantal is not None else '–', + 'Laatste_synchronisatie': s.leerlingenaantal_sync, + 'Informat': s.heeft(*INFORMAT_VELDEN), + 'Campussen': s.leerlingenaantal_detail or [], # [{'naam', 'nummer', 'aantal'}] bij meerdere campussen + }) + return rows + + +def _telling(peildatum, query=''): + """Alle actieve scholen met hun aantal op de peildatum (None = niet geteld).""" + per = {r['School_ID']: r for r in central_query( + "SELECT School_ID, Aantal, Detail, Schooljaar, Opgevraagd_op, Door FROM Leerlingenaantal_tellingen " + "WHERE Peildatum = %s", (peildatum,))} + rows = [] + for s in alle_scholen(): + if query and query.lower() not in s.naam.lower(): + continue + t = per.get(s.id) or {} + rows.append({'Code': s.code, 'Naam_school': s.naam, 'Niveau': _niveau(s), 'Aantal': t.get('Aantal'), + 'Schooljaar': t.get('Schooljaar'), 'Opgevraagd_op': t.get('Opgevraagd_op'), + 'Door': t.get('Door'), 'Informat': s.heeft(*INFORMAT_VELDEN), + 'Campussen': _detail(t.get('Detail'))}) + return rows + + +def _peildata(): + return central_query("SELECT Peildatum, COUNT(*) AS Scholen, SUM(Aantal) AS Totaal, MAX(Opgevraagd_op) AS Laatst " + "FROM Leerlingenaantal_tellingen GROUP BY Peildatum ORDER BY Peildatum DESC") + + +@leerlingenaantal_bp.route("/leerlingenaantal", methods=["GET", "POST"]) +@login_required +@recht_vereist('leerlingenaantal') +def leerlingenaantal_page(): + query = (request.values.get("query") or "").strip() + herlaad(force=True) + results = _huidig(query) + totaal = _som(results, 'Leerlingenaantal') + + peildatum = _datum(request.args.get('peildatum')) + telling = _telling(peildatum, query) if peildatum else None + telling_totaal = _som(telling, 'Aantal') if telling else 0 + return render_template("leerlingenaantal.html", results=results, query=query, totaal=totaal, + groepen=_groepen(results, 'Leerlingenaantal'), + telling_groepen=_groepen(telling, 'Aantal') if telling else [], + peildatum=peildatum, telling=telling, telling_totaal=telling_totaal, + peildata=_peildata(), vandaag=date.today(), **WEERGAVE) + + +@leerlingenaantal_bp.route("/sync-aanvragen", methods=["POST"]) +@login_required +@recht_vereist('leerlingenaantal') +def sync_aanvragen(): + codes = [s.code for s in alle_scholen()] if request.form.get("alle") else [request.form.get("school_code")] + for code in codes: + school = school_op_code(code) + if not school: + continue + if not school.heeft(*INFORMAT_VELDEN): + flash(f"{school.naam}: Informat is nog niet ingesteld.", "error") + continue + with in_school(school): + success, message = SyncService().sync_student_count() + flash(f"{school.naam}: {message}", "success" if success else "error") + return redirect(url_for('leerlingenaantal.leerlingenaantal_page')) + + +@leerlingenaantal_bp.route("/leerlingenaantal/peildatum", methods=["POST"]) +@login_required +@recht_vereist('leerlingenaantal') +def tel_peildatum(): + """Telt de leerlingen op de gekozen datum, voor alle scholen of één school.""" + peildatum = _datum(request.form.get('peildatum')) + if not peildatum: + flash("Kies een geldige datum.", "error") + return redirect(url_for('leerlingenaantal.leerlingenaantal_page')) + code = request.form.get('school_code') + scholen = [school_op_code(code)] if code else alle_scholen() + door = session.get('user') + gelukt = 0 + for school in scholen: + if not school: + continue + if not school.heeft(*INFORMAT_VELDEN): + if code: + flash(f"{school.naam}: Informat is nog niet ingesteld.", "error") + continue + with in_school(school): + ok, res = SyncService().tel_op_peildatum(peildatum, door) + if ok: + gelukt += 1 + else: + flash(f"{school.naam}: {res}", "error") + if gelukt: + flash(f"Leerlingen geteld op {peildatum:%d-%m-%Y} voor {gelukt} school/scholen.", "success") + return redirect(url_for('leerlingenaantal.leerlingenaantal_page', peildatum=peildatum.isoformat())) + + +@leerlingenaantal_bp.route("/leerlingenaantal/peildatum/verwijderen", methods=["POST"]) +@login_required +@recht_vereist('leerlingenaantal') +def verwijder_peildatum(): + peildatum = _datum(request.form.get('peildatum')) + if peildatum: + central_query("DELETE FROM Leerlingenaantal_tellingen WHERE Peildatum = %s", (peildatum,), commit=True) + flash(f"Telling van {peildatum:%d-%m-%Y} verwijderd.", "success") + return redirect(url_for('leerlingenaantal.leerlingenaantal_page')) + + +# ------------------------------------------------------------------ +# CSV +# ------------------------------------------------------------------ +def _csv(naam, kop, rijen): + buf = io.StringIO() + w = csv.writer(buf, delimiter=';') # ; zodat Excel (Belgische instellingen) de kolommen meteen splitst + w.writerow(kop) + w.writerows(rijen) + return Response('' + buf.getvalue(), mimetype='text/csv', + headers={'Content-Disposition': f'attachment; filename={naam}'}) + + +@leerlingenaantal_bp.route("/leerlingenaantal/export.csv", methods=["GET"]) +@login_required +@recht_vereist('leerlingenaantal') +def export_csv(): + """?peildatum=YYYY-MM-DD → die telling; ?alle=1 → alle peildata naast elkaar; anders het huidige aantal.""" + query = (request.args.get('query') or '').strip() + + if request.args.get('alle'): + data = central_query("SELECT School_ID, Peildatum, Aantal, Detail FROM Leerlingenaantal_tellingen ORDER BY Peildatum") + datums = sorted({r['Peildatum'] for r in data}) + per = {(r['School_ID'], r['Peildatum']): r['Aantal'] for r in data} + # campus-aantallen: {(school_id, campusnaam, datum): aantal} + campus, campusnamen = {}, {} + for r in data: + for d in _detail(r['Detail']): + campus[(r['School_ID'], d['naam'], r['Peildatum'])] = d['aantal'] + campusnamen.setdefault(r['School_ID'], []) + if d['naam'] not in campusnamen[r['School_ID']]: + campusnamen[r['School_ID']].append(d['naam']) + scholen = [s for s in alle_scholen() if not query or query.lower() in s.naam.lower()] + rijen = [] + for code, label in NIVEAUS: + groep = [s for s in scholen if _niveau(s) == code] + if not groep: + continue + for s in groep: + rijen.append([label, s.naam, ''] + [per.get((s.id, d), '') for d in datums]) + for naam in campusnamen.get(s.id, []): + rijen.append([label, s.naam, naam] + [campus.get((s.id, naam, d), '') for d in datums]) + if code: + rijen.append([f"Subtotaal {label}", '', ''] + [sum(per.get((s.id, d)) or 0 for s in groep) for d in datums]) + rijen.append(['Totaal', '', ''] + [sum(per.get((s.id, d)) or 0 for s in scholen) for d in datums]) + return _csv(f"leerlingenaantallen_alle_peildata_{date.today():%Y%m%d}.csv", + ['Onderwijsniveau', 'School', 'Campus'] + [f"{d:%d-%m-%Y}" for d in datums], rijen) + + peildatum = _datum(request.args.get('peildatum')) + if peildatum: + rows = _telling(peildatum, query) + rijen = [] + for g in _groepen(rows, 'Aantal'): + for r in g['rijen']: + rijen.append([g['label'], r['Naam_school'], '', '' if r['Aantal'] is None else r['Aantal'], + r['Schooljaar'] or '', _fmt(r['Opgevraagd_op'], 'niet geteld'), r['Door'] or '']) + for c in r['Campussen']: + rijen.append([g['label'], r['Naam_school'], c['naam'], c['aantal'], r['Schooljaar'] or '', '', '']) + if g['code']: + rijen.append([f"Subtotaal {g['label']}", '', '', g['subtotaal'], '', '', '']) + rijen.append(['Totaal', '', '', _som(rows, 'Aantal'), '', '', '']) + return _csv(f"leerlingenaantallen_{peildatum:%Y%m%d}.csv", + ['Onderwijsniveau', 'School', 'Campus', f"Leerlingen op {peildatum:%d-%m-%Y}", 'Schooljaar', + 'Opgevraagd op', 'Door'], rijen) + + rows = _huidig(query) + rijen = [] + for g in _groepen(rows, 'Leerlingenaantal'): + for r in g['rijen']: + rijen.append([g['label'], r['Naam_school'], '', + r['Leerlingenaantal'] if isinstance(r['Leerlingenaantal'], int) else '', + _fmt(r['Laatste_synchronisatie'], 'nog nooit')]) + for c in r['Campussen']: + rijen.append([g['label'], r['Naam_school'], c['naam'], c['aantal'], '']) + if g['code']: + rijen.append([f"Subtotaal {g['label']}", '', '', g['subtotaal'], '']) + rijen.append(['Totaal', '', '', _som(rows, 'Leerlingenaantal'), '']) + return _csv(f"leerlingenaantallen_{date.today():%Y%m%d}.csv", + ['Onderwijsniveau', 'School', 'Campus', 'Leerlingen', 'Laatste synchronisatie'], rijen) + diff --git a/blueprints/nieuw_schooljaar_accounts_bp.py b/blueprints/nieuw_schooljaar_accounts_bp.py new file mode 100644 index 0000000..2eb7599 --- /dev/null +++ b/blueprints/nieuw_schooljaar_accounts_bp.py @@ -0,0 +1,51 @@ +from flask import Blueprint, render_template, request, session, redirect, url_for, flash +from services.google_service import GoogleAdminService +from blueprints.auth import login_required +from rechten import recht_vereist +from tenancy import per_school + +account_verplaatsen_bp = Blueprint('accounts_verplaatsen', __name__) +google = per_school(GoogleAdminService) + +@account_verplaatsen_bp.route('/beheer/verplaatsen', methods=['GET', 'POST']) +@login_required +@recht_vereist('nieuw_schooljaar') +def accounts_verplaatsen(): + if request.method == 'POST': + huidige_ou = request.form.get('huidige_ou') + nieuwe_ou = request.form.get('nieuwe_ou') + move_type = request.form.get('move_type') # 'current' of 'all' + + # Als move_type 'current' is, zou je hier je database/Informat lijst ophalen + enrolled_list = [] + if move_type == 'current': + # Voorbeeld: enrolled_list = db.get_active_students_emails() + pass + + only_enrolled = (move_type == 'current') + + success, message = google.move_users_ou( + huidige_ou, + nieuwe_ou, + only_enrolled=only_enrolled, + enrolled_emails=enrolled_list + ) + + flash(message) + return redirect(url_for('accounts_verplaatsen.accounts_verplaatsen')) + + # Voor de GET: vul de dropdowns + org_units = google.list_org_units() + return render_template('nieuw_schooljaar_accounts.html', ous=org_units) + +@account_verplaatsen_bp.route('/beheer/archiveren-inactief', methods=['POST']) +@login_required +@recht_vereist('nieuw_schooljaar') +def archiveren_inactief(): + source_ou = request.form.get('bron_ou') + days_inactive = int(request.form.get('inactief_periode')) + + success, message = google.archive_inactive_users(source_ou, days_inactive) + + flash(message, "success" if success else "danger") + return redirect(url_for('accounts_verplaatsen.accounts_verplaatsen')) \ No newline at end of file diff --git a/blueprints/nieuw_schooljaar_toestellen_bp.py b/blueprints/nieuw_schooljaar_toestellen_bp.py new file mode 100644 index 0000000..1c2e4c8 --- /dev/null +++ b/blueprints/nieuw_schooljaar_toestellen_bp.py @@ -0,0 +1,39 @@ +from flask import Blueprint, render_template, request, flash, redirect, url_for +from services.google_service import GoogleAdminService +from blueprints.auth import login_required +from rechten import recht_vereist + +# We noemen de blueprint 'toestellen_verplaatsen' +toestellen_verplaatsen_bp = Blueprint('toestellen_verplaatsen', __name__) + +@toestellen_verplaatsen_bp.route('/toestellen', methods=['GET', 'POST']) +@login_required +@recht_vereist('nieuw_schooljaar') +def move_toestellen(): + google_service = GoogleAdminService() + org_units = google_service.list_org_units() + + # Standaard is de verplaatsen tab actief + return render_template('nieuw_schooljaar_toestellen.html', + org_units=org_units, + active_tab='verplaatsen') + +@toestellen_verplaatsen_bp.route('/blokkeren', methods=['POST']) +@login_required +@recht_vereist('nieuw_schooljaar') +def blokkeer_toestellen(): + google_service = GoogleAdminService() + selected_ou = request.form.get('ou_select_blokkeer') + actie = request.form.get('actie') + + success, message = google_service.toggle_ou_devices_status(selected_ou, block=(actie == 'blokkeer')) + + flash(message, "success" if success else "danger") + + # Haal opnieuw de OU's op voor de template + org_units = google_service.list_org_units() + + # Cruciaal: we geven 'blokkeren' mee als actieve tab + return render_template('nieuw_schooljaar_toestellen.html', + org_units=org_units, + active_tab='blokkeren') \ No newline at end of file diff --git a/blueprints/personeel_bp.py b/blueprints/personeel_bp.py new file mode 100644 index 0000000..89701c1 --- /dev/null +++ b/blueprints/personeel_bp.py @@ -0,0 +1,318 @@ +from datetime import date, datetime, timedelta +from decimal import Decimal, InvalidOperation +from functools import wraps + +from flask import Blueprint, render_template, request, redirect, url_for, flash, session, abort +from tenancy import huidige_school, per_school +from blueprints.auth import login_required +from services import personeel_service as P + +personeel_bp = Blueprint('personeel', __name__) + +_tables_ready = set() # scholen waarvoor de tabellen al gecontroleerd zijn + + +def _ensure_tables(): + if huidige_school().code not in _tables_ready: + try: + P.ensure_tables() + _tables_ready.add(huidige_school().code) + except Exception as e: + flash(f"Kon de personeelstabellen niet aanmaken: {e}", "error") + + +def _me(): + return (session.get('user') or '').lower(), session.get('naam') or session.get('user') + + +def rol_vereist(rol): + def deco(f): + @wraps(f) + def wrapped(*a, **kw): + if rol not in P.rollen(session): + flash("Je hebt geen toegang tot deze pagina.", "error") + return redirect(url_for('personeel.mijn')) + return f(*a, **kw) + return wrapped + return deco + + +@personeel_bp.context_processor +def _ctx(): + r = P.rollen(session) if session.get('auth') else set() + return {'p_rollen': r, 'SOORTEN': P.SOORTEN, 'STATUS': P.STATUS, 'DAGDELEN': P.DAGDELEN, + 'p_te_beoordelen': P.aantal_te_beoordelen() if 'directie' in r else 0} + + +def _link(aid): + return url_for('personeel.detail', aid=aid, _external=True) + + +def _mag_zien(item): + me, _ = _me() + r = P.rollen(session) + return (item['Email'] == me or 'secretariaat' in r + or ('directie' in r and P.SOORTEN[item['Soort']]['goedkeuring'])) + + +def _laad(aid): + item = P.get(aid) + if not item: + abort(404) + if not _mag_zien(item): + flash("Je hebt geen toegang tot deze aanvraag.", "error") + return None + return item + + +#--------------------------------------------- +# Mijn meldingen en aanvragen +#--------------------------------------------- +@personeel_bp.route('/', methods=['GET']) +@login_required +def mijn(): + _ensure_tables() + me, _ = _me() + try: + items = P.mijn(me) + except Exception as e: + items = [] + flash(f"Kon je aanvragen niet ophalen: {e}", "error") + return render_template('personeel_mijn.html', items=items, vandaag=date.today()) + + +#--------------------------------------------- +# Nieuwe melding / aanvraag +#--------------------------------------------- +@personeel_bp.route('/nieuw', methods=['GET', 'POST']) +@login_required +def nieuw(): + _ensure_tables() + soort = request.values.get('soort', 'afwezigheid') + if soort not in P.SOORTEN: + soort = 'afwezigheid' + me, mijn_naam = _me() + is_secr = 'secretariaat' in P.rollen(session) + form = request.form + + if request.method == 'POST': + fouten = [] + try: + van = datetime.strptime(form.get('van', ''), '%Y-%m-%d').date() + tot = datetime.strptime(form.get('tot') or form.get('van', ''), '%Y-%m-%d').date() + if tot < van: + fouten.append("De einddatum ligt voor de begindatum.") + except ValueError: + van = tot = None + fouten.append("Kies een geldige datum.") + + titel = (form.get('titel') or '').strip() + if soort == 'afwezigheid' and not titel: + titel = 'Afwezig' + if not titel: + fouten.append("Geef een titel op.") + + dagdeel = form.get('dagdeel', 'dag') + if dagdeel not in P.DAGDELEN: + dagdeel = 'dag' + lesuren = (form.get('lesuren') or '').strip() + if dagdeel == 'uren' and not lesuren: + fouten.append("Vul in welke lesuren het gaat (bv. 3-5).") + + klassen = form.getlist('klassen') + extra = [k.strip() for k in (form.get('klassen_extra') or '').split(',') if k.strip()] + klassen = list(dict.fromkeys(klassen + extra)) + if soort == 'uitstap' and not klassen: + fouten.append("Kies welke klassen mee op uitstap gaan.") + + kost = None + if (form.get('kost') or '').strip(): + try: + kost = Decimal(form['kost'].replace(',', '.').replace('€', '').strip()) + except InvalidOperation: + fouten.append("De kost is geen geldig bedrag.") + + # Secretariaat kan melden namens een leerkracht + email, naam = me, mijn_naam + namens = (form.get('namens') or '').strip().lower() + if namens and namens != me: + if not is_secr: + fouten.append("Je kan enkel voor jezelf een melding maken.") + else: + email, naam = namens, namens + try: + from services.google_service import GoogleAdminService + u = GoogleAdminService().get_user_info(namens) + if not u: + fouten.append(f"{namens} bestaat niet in Google Workspace.") + else: + email = u['primaryEmail'].lower() + naam = (u.get('name') or {}).get('fullName') or email + except Exception: + pass + + if fouten: + for f in fouten: + flash(f, "error") + else: + data = dict(soort=soort, email=email, naam=naam, titel=titel[:255], + omschrijving=(form.get('omschrijving') or '').strip() or None, + locatie=(form.get('locatie') or '').strip()[:255] or None, + van=van, tot=tot, dagdeel=dagdeel, lesuren=lesuren[:50] or None, + klassen=klassen, begeleiders=(form.get('begeleiders') or '').strip() or None, kost=kost) + aid = P.aanmaken(data, me, mijn_naam) + P.mail_nieuw(P.get(aid), _link(aid)) + if P.SOORTEN[soort]['goedkeuring']: + flash("Je aanvraag is ingediend. De directie krijgt een melding; je ontvangt een mail zodra er een beslissing is.", "success") + else: + flash("De afwezigheid is gemeld aan het secretariaat.", "success") + return redirect(url_for('personeel.detail', aid=aid)) + + return render_template('personeel_nieuw.html', soort=soort, form=form, klassen=P.klassen(), + is_secr=is_secr, vandaag=date.today()) + + +#--------------------------------------------- +# Detail + acties +#--------------------------------------------- +@personeel_bp.route('/', methods=['GET']) +@login_required +def detail(aid): + _ensure_tables() + item = _laad(aid) + if item is None: + return redirect(url_for('personeel.mijn')) + me, _ = _me() + return render_template('personeel_detail.html', item=item, reacties=P.reacties(aid), + is_eigenaar=item['Email'] == me, vandaag=date.today()) + + +@personeel_bp.route('//beslissing', methods=['POST']) +@login_required +@rol_vereist('directie') +def beslissing(aid): + item = _laad(aid) + if item is None: + return redirect(url_for('personeel.mijn')) + actie = request.form.get('actie') + tekst = (request.form.get('tekst') or '').strip() + if actie not in ('goedgekeurd', 'geweigerd', 'vraag'): + abort(400) + if not P.SOORTEN[item['Soort']]['goedkeuring'] or item['Status'] not in ('ingediend', 'vraag'): + flash("Over deze aanvraag kan geen beslissing (meer) genomen worden.", "error") + return redirect(url_for('personeel.detail', aid=aid)) + if actie in ('geweigerd', 'vraag') and not tekst: + flash("Geef wat uitleg mee bij een weigering of vraag.", "error") + return redirect(url_for('personeel.detail', aid=aid)) + me, naam = _me() + P.wijzig_status(aid, actie, me, naam, actie, tekst or None) + P.mail_beslissing(P.get(aid), actie, tekst, _link(aid)) + flash({'goedgekeurd': "Aanvraag goedgekeurd.", 'geweigerd': "Aanvraag geweigerd.", + 'vraag': "Je vraag is naar de leerkracht gestuurd."}[actie], "success") + nxt = request.form.get('volgende') + return redirect(url_for('personeel.beoordelen') if nxt else url_for('personeel.detail', aid=aid)) + + +@personeel_bp.route('//antwoord', methods=['POST']) +@login_required +def antwoord(aid): + item = _laad(aid) + if item is None: + return redirect(url_for('personeel.mijn')) + me, naam = _me() + tekst = (request.form.get('tekst') or '').strip() + if item['Email'] != me or item['Status'] != 'vraag' or not tekst: + flash("Antwoorden kan enkel op je eigen aanvraag met een openstaande vraag.", "error") + return redirect(url_for('personeel.detail', aid=aid)) + P.wijzig_status(aid, 'ingediend', me, naam, 'antwoord', tekst) + P.mail_antwoord(P.get(aid), tekst, _link(aid)) + flash("Je antwoord is verstuurd; de aanvraag wacht opnieuw op de directie.", "success") + return redirect(url_for('personeel.detail', aid=aid)) + + +@personeel_bp.route('//commentaar', methods=['POST']) +@login_required +def commentaar(aid): + item = _laad(aid) + if item is None: + return redirect(url_for('personeel.mijn')) + tekst = (request.form.get('tekst') or '').strip() + if tekst: + me, naam = _me() + P.commentaar(aid, me, naam, tekst) + return redirect(url_for('personeel.detail', aid=aid)) + + +@personeel_bp.route('//annuleren', methods=['POST']) +@login_required +def annuleren(aid): + item = _laad(aid) + if item is None: + return redirect(url_for('personeel.mijn')) + me, naam = _me() + if not (item['Email'] == me or 'secretariaat' in P.rollen(session)): + flash("Je kan deze aanvraag niet annuleren.", "error") + elif item['Status'] in ('geannuleerd', 'geweigerd'): + flash("Deze aanvraag is al afgesloten.", "error") + else: + P.mail_geannuleerd(item, _link(aid)) + P.wijzig_status(aid, 'geannuleerd', me, naam, 'geannuleerd', (request.form.get('tekst') or '').strip() or None) + flash("Geannuleerd.", "success") + return redirect(url_for('personeel.detail', aid=aid)) + + +@personeel_bp.route('//vervanging', methods=['POST']) +@login_required +@rol_vereist('secretariaat') +def vervanging(aid): + item = _laad(aid) + if item is None: + return redirect(url_for('personeel.mijn')) + me, naam = _me() + P.vervanging(aid, request.form.get('ok') == '1', request.form.get('info'), me, naam) + terug = request.form.get('terug') + return redirect(terug if terug and terug.startswith('/') else url_for('personeel.detail', aid=aid)) + + +#--------------------------------------------- +# Directie: te beoordelen +#--------------------------------------------- +@personeel_bp.route('/beoordelen', methods=['GET']) +@login_required +@rol_vereist('directie') +def beoordelen(): + _ensure_tables() + return render_template('personeel_beoordelen.html', open_items=P.te_beoordelen(), recent=P.recent_beslist()) + + +#--------------------------------------------- +# Secretariaat: dagoverzicht +#--------------------------------------------- +@personeel_bp.route('/dagoverzicht', methods=['GET']) +@login_required +@rol_vereist('secretariaat') +def dagoverzicht(): + _ensure_tables() + try: + dag = datetime.strptime(request.args.get('datum', ''), '%Y-%m-%d').date() + except ValueError: + dag = date.today() + weergave = request.args.get('weergave', 'dag') + if weergave == 'week': + van = dag - timedelta(days=dag.weekday()) + tot = van + timedelta(days=4) + stap = timedelta(days=7) + else: + weergave = 'dag' + if dag.weekday() >= 5: # weekend → volgende maandag + dag = dag + timedelta(days=7 - dag.weekday()) + van = tot = dag + stap = timedelta(days=1) + vorige, volgende = van - stap, van + stap + if weergave == 'dag': + while vorige.weekday() >= 5: + vorige -= timedelta(days=1) + while volgende.weekday() >= 5: + volgende += timedelta(days=1) + return render_template('personeel_dagoverzicht.html', dagen=P.dagoverzicht(van, tot), weergave=weergave, + dag=dag, van=van, tot=tot, vorige=vorige, volgende=volgende, vandaag=date.today()) diff --git a/blueprints/personeelsaantal_bp.py b/blueprints/personeelsaantal_bp.py new file mode 100644 index 0000000..cbabf81 --- /dev/null +++ b/blueprints/personeelsaantal_bp.py @@ -0,0 +1,232 @@ +""" +Personeel › Personeelsaantallen: hetzelfde overzicht als de leerlingenaantallen, maar voor het personeel. + +Elke school wordt geteld met haar eigen Informat-koppeling (Personeel-API, zie services/gopro_service.py): +een personeelslid telt mee als het op de peildatum een lopende opdracht heeft (één keer per persoon). +- Huidig aantal: vandaag, bewaard in Personeelsaantal_huidig. +- Telling op peildatum: bewaard in Personeelsaantal_tellingen, later opnieuw te bekijken en te exporteren. +Het sjabloon is gedeeld met de leerlingenaantallen (leerlingenaantal.html). +""" +import csv +import io +import json +from datetime import date + +from flask import Blueprint, render_template, request, flash, redirect, url_for, session, Response +from blueprints.auth import login_required +from blueprints.leerlingenaantal_bp import (INFORMAT_VELDEN, NIVEAUS, _datum, _detail, _fmt, _groepen, _niveau, + _som) +from database import central_query +from rechten import recht_vereist +from services.gopro_service import GoProService +from tenancy import alle_scholen, school_op_code, in_school, _automatisch_schooljaar + +personeelsaantal_bp = Blueprint('personeelsaantal', __name__) + +WEERGAVE = dict(ep='personeelsaantal', pagina='personeelsaantal_page', titel='Personeelsaantallen', + eenheid='Personeelsleden', kort='pers.') + + +def _huidig(query=''): + per = {r['School_ID']: r for r in central_query( + "SELECT School_ID, Aantal, Detail, Gesynchroniseerd FROM Personeelsaantal_huidig")} + rows = [] + for s in alle_scholen(): + if query and query.lower() not in s.naam.lower(): + continue + h = per.get(s.id) or {} + rows.append({'Code': s.code, 'Naam_school': s.naam, 'Niveau': _niveau(s), + 'Huidig': h['Aantal'] if h else '–', 'Laatste_synchronisatie': h.get('Gesynchroniseerd'), + 'Informat': s.heeft(*INFORMAT_VELDEN), 'Campussen': _detail(h.get('Detail'))}) + return rows + + +def _telling(peildatum, query=''): + per = {r['School_ID']: r for r in central_query( + "SELECT School_ID, Aantal, Detail, Schooljaar, Opgevraagd_op, Door FROM Personeelsaantal_tellingen " + "WHERE Peildatum = %s", (peildatum,))} + rows = [] + for s in alle_scholen(): + if query and query.lower() not in s.naam.lower(): + continue + t = per.get(s.id) or {} + rows.append({'Code': s.code, 'Naam_school': s.naam, 'Niveau': _niveau(s), 'Aantal': t.get('Aantal'), + 'Schooljaar': t.get('Schooljaar'), 'Opgevraagd_op': t.get('Opgevraagd_op'), + 'Door': t.get('Door'), 'Informat': s.heeft(*INFORMAT_VELDEN), + 'Campussen': _detail(t.get('Detail'))}) + return rows + + +def _peildata(): + return central_query("SELECT Peildatum, COUNT(*) AS Scholen, SUM(Aantal) AS Totaal, MAX(Opgevraagd_op) AS Laatst " + "FROM Personeelsaantal_tellingen GROUP BY Peildatum ORDER BY Peildatum DESC") + + +def _tel(school, peildatum): + """(ok, aantal of foutmelding, detail) voor één school.""" + try: + with in_school(school): + totaal, detail = GoProService().tel(peildatum) + return True, totaal, detail + except Exception as e: + return False, str(e), None + + +@personeelsaantal_bp.route("/", methods=["GET"]) +@login_required +@recht_vereist('personeelsaantal') +def personeelsaantal_page(): + query = (request.values.get("query") or "").strip() + results = _huidig(query) + peildatum = _datum(request.args.get('peildatum')) + telling = _telling(peildatum, query) if peildatum else None + return render_template("leerlingenaantal.html", results=results, query=query, totaal=_som(results, 'Huidig'), + groepen=_groepen(results, 'Huidig'), + telling_groepen=_groepen(telling, 'Aantal') if telling else [], + peildatum=peildatum, telling=telling, + telling_totaal=_som(telling, 'Aantal') if telling else 0, + peildata=_peildata(), vandaag=date.today(), **WEERGAVE) + + +@personeelsaantal_bp.route("/sync-aanvragen", methods=["POST"]) +@login_required +@recht_vereist('personeelsaantal') +def sync_aanvragen(): + codes = [s.code for s in alle_scholen()] if request.form.get("alle") else [request.form.get("school_code")] + for code in codes: + school = school_op_code(code) + if not school: + continue + if not school.heeft(*INFORMAT_VELDEN): + if not request.form.get("alle"): + flash(f"{school.naam}: Informat is nog niet ingesteld.", "error") + continue + ok, res, detail = _tel(school, date.today()) + if not ok: + flash(f"{school.naam}: {res}", "error") + continue + central_query( + "INSERT INTO Personeelsaantal_huidig (School_ID, Aantal, Detail, Gesynchroniseerd) VALUES (%s, %s, %s, NOW()) " + "ON DUPLICATE KEY UPDATE Aantal = VALUES(Aantal), Detail = VALUES(Detail), Gesynchroniseerd = NOW()", + (school.id, res, json.dumps(detail) if detail else None), commit=True) + flash(f"{school.naam}: {res} personeelsleden" + (" (" + ", ".join(f"{d['naam']}: {d['aantal']}" for d in detail) + + ")" if detail else "") + ".", "success") + return redirect(url_for('personeelsaantal.personeelsaantal_page')) + + +@personeelsaantal_bp.route("/peildatum", methods=["POST"]) +@login_required +@recht_vereist('personeelsaantal') +def tel_peildatum(): + peildatum = _datum(request.form.get('peildatum')) + if not peildatum: + flash("Kies een geldige datum.", "error") + return redirect(url_for('personeelsaantal.personeelsaantal_page')) + code = request.form.get('school_code') + scholen = [school_op_code(code)] if code else alle_scholen() + gelukt = 0 + for school in scholen: + if not school or not school.heeft(*INFORMAT_VELDEN): + if school and code: + flash(f"{school.naam}: Informat is nog niet ingesteld.", "error") + continue + ok, res, detail = _tel(school, peildatum) + if not ok: + flash(f"{school.naam}: {res}", "error") + continue + central_query( + "INSERT INTO Personeelsaantal_tellingen (School_ID, Peildatum, Schooljaar, Aantal, Detail, Opgevraagd_op, Door) " + "VALUES (%s, %s, %s, %s, %s, NOW(), %s) ON DUPLICATE KEY UPDATE Schooljaar = VALUES(Schooljaar), " + "Aantal = VALUES(Aantal), Detail = VALUES(Detail), Opgevraagd_op = NOW(), Door = VALUES(Door)", + (school.id, peildatum, _automatisch_schooljaar(peildatum), res, json.dumps(detail) if detail else None, + session.get('user')), commit=True) + gelukt += 1 + if gelukt: + flash(f"Personeel geteld op {peildatum:%d-%m-%Y} voor {gelukt} school/scholen.", "success") + return redirect(url_for('personeelsaantal.personeelsaantal_page', peildatum=peildatum.isoformat())) + + +@personeelsaantal_bp.route("/peildatum/verwijderen", methods=["POST"]) +@login_required +@recht_vereist('personeelsaantal') +def verwijder_peildatum(): + peildatum = _datum(request.form.get('peildatum')) + if peildatum: + central_query("DELETE FROM Personeelsaantal_tellingen WHERE Peildatum = %s", (peildatum,), commit=True) + flash(f"Telling van {peildatum:%d-%m-%Y} verwijderd.", "success") + return redirect(url_for('personeelsaantal.personeelsaantal_page')) + + +def _csv(naam, kop, rijen): + buf = io.StringIO() + w = csv.writer(buf, delimiter=';') + w.writerow(kop) + w.writerows(rijen) + return Response('' + buf.getvalue(), mimetype='text/csv', + headers={'Content-Disposition': f'attachment; filename={naam}'}) + + +@personeelsaantal_bp.route("/export.csv", methods=["GET"]) +@login_required +@recht_vereist('personeelsaantal') +def export_csv(): + """?peildatum=YYYY-MM-DD → die telling; ?alle=1 → alle peildata naast elkaar; anders het huidige aantal.""" + query = (request.args.get('query') or '').strip() + + if request.args.get('alle'): + data = central_query("SELECT School_ID, Peildatum, Aantal, Detail FROM Personeelsaantal_tellingen ORDER BY Peildatum") + datums = sorted({r['Peildatum'] for r in data}) + per = {(r['School_ID'], r['Peildatum']): r['Aantal'] for r in data} + campus, campusnamen = {}, {} + for r in data: + for d in _detail(r['Detail']): + campus[(r['School_ID'], d['naam'], r['Peildatum'])] = d['aantal'] + campusnamen.setdefault(r['School_ID'], []) + if d['naam'] not in campusnamen[r['School_ID']]: + campusnamen[r['School_ID']].append(d['naam']) + scholen = [s for s in alle_scholen() if not query or query.lower() in s.naam.lower()] + rijen = [] + for code, label in NIVEAUS: + groep = [s for s in scholen if _niveau(s) == code] + if not groep: + continue + for s in groep: + rijen.append([label, s.naam, ''] + [per.get((s.id, d), '') for d in datums]) + for naam in campusnamen.get(s.id, []): + rijen.append([label, s.naam, naam] + [campus.get((s.id, naam, d), '') for d in datums]) + if code: + rijen.append([f"Subtotaal {label}", '', ''] + [sum(per.get((s.id, d)) or 0 for s in groep) for d in datums]) + rijen.append(['Totaal', '', ''] + [sum(per.get((s.id, d)) or 0 for s in scholen) for d in datums]) + return _csv(f"personeelsaantallen_alle_peildata_{date.today():%Y%m%d}.csv", + ['Onderwijsniveau', 'School', 'Instelling'] + [f"{d:%d-%m-%Y}" for d in datums], rijen) + + peildatum = _datum(request.args.get('peildatum')) + if peildatum: + rows = _telling(peildatum, query) + rijen = [] + for g in _groepen(rows, 'Aantal'): + for r in g['rijen']: + rijen.append([g['label'], r['Naam_school'], '', '' if r['Aantal'] is None else r['Aantal'], + r['Schooljaar'] or '', _fmt(r['Opgevraagd_op'], 'niet geteld'), r['Door'] or '']) + for c in r['Campussen']: + rijen.append([g['label'], r['Naam_school'], c['naam'], c['aantal'], r['Schooljaar'] or '', '', '']) + if g['code']: + rijen.append([f"Subtotaal {g['label']}", '', '', g['subtotaal'], '', '', '']) + rijen.append(['Totaal', '', '', _som(rows, 'Aantal'), '', '', '']) + return _csv(f"personeelsaantallen_{peildatum:%Y%m%d}.csv", + ['Onderwijsniveau', 'School', 'Instelling', f"Personeelsleden op {peildatum:%d-%m-%Y}", 'Schooljaar', + 'Opgevraagd op', 'Door'], rijen) + + rows = _huidig(query) + rijen = [] + for g in _groepen(rows, 'Huidig'): + for r in g['rijen']: + rijen.append([g['label'], r['Naam_school'], '', r['Huidig'] if isinstance(r['Huidig'], int) else '', + _fmt(r['Laatste_synchronisatie'], 'nog nooit')]) + for c in r['Campussen']: + rijen.append([g['label'], r['Naam_school'], c['naam'], c['aantal'], '']) + if g['code']: + rijen.append([f"Subtotaal {g['label']}", '', '', g['subtotaal'], '']) + rijen.append(['Totaal', '', '', _som(rows, 'Huidig'), '']) + return _csv(f"personeelsaantallen_{date.today():%Y%m%d}.csv", + ['Onderwijsniveau', 'School', 'Instelling', 'Personeelsleden', 'Laatste synchronisatie'], rijen) diff --git a/blueprints/qr_code_bp.py b/blueprints/qr_code_bp.py new file mode 100644 index 0000000..5e97b1e --- /dev/null +++ b/blueprints/qr_code_bp.py @@ -0,0 +1,125 @@ +import io +import zipfile +import qrcode +import pandas as pd +from flask import Blueprint, render_template, request, send_file, flash, redirect, url_for +from PIL import Image, ImageDraw, ImageFont +from blueprints.auth import login_required +from rechten import recht_vereist # Zorg dat dit pad klopt naar je auth_utils.py + +# definieren blueprint +qr_code_bp = Blueprint('qr_generator', __name__, template_folder='templates') + +def generate_qr_images(excel_file, show_top, show_bottom): + # Overslaan van de header rij + df = pd.read_excel(excel_file, header=0) + + zip_buffer = io.BytesIO() + + try: + # Try loading your local or system Arial font + font = ImageFont.truetype("arial.ttf", 20) + except IOError: + try: + + font = ImageFont.load_default_font(size=20) + except AttributeError: + # Absolute fallback if Pillow is outdated, but textbbox WILL fail if this hits + font = ImageFont.load_default() + + with zipfile.ZipFile(zip_buffer, "a", zipfile.ZIP_DEFLATED) as zip_file: + for index, row in df.iterrows(): + # Gebruik .iloc voor veiligheid tegen kolomnamen + qr_text = str(row.iloc[0]) if not pd.isna(row.iloc[0]) else "" + top_text = str(row.iloc[1]) if not pd.isna(row.iloc[1]) else "" + bottom_text = str(row.iloc[2]) if not pd.isna(row.iloc[2]) else "" + + if qr_text.strip() == "": + continue + + qr = qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_H) + qr.add_data(qr_text) + qr.make(fit=True) + qr_img = qr.make_image(fill_color="black", back_color="white").convert("RGB") + + qr_width, qr_height = qr_img.size + padding = 40 + total_height = qr_height + (2 * padding) + + canvas = Image.new("RGB", (qr_width + 40, total_height + 40), "white") + draw = ImageDraw.Draw(canvas) + + qr_x = (canvas.width - qr_width) // 2 + qr_y = padding + canvas.paste(qr_img, (qr_x, qr_y)) + + if show_top and top_text: + bbox = draw.textbbox((0, 0), top_text, font=font) + text_w = bbox[2] - bbox[0] + draw.text(((canvas.width - text_w) / 2, 5), top_text, fill="black", font=font) + + if show_bottom and bottom_text: + bbox = draw.textbbox((0, 0), bottom_text, font=font) + text_w = bbox[2] - bbox[0] + draw.text(((canvas.width - text_w) / 2, qr_y + qr_height + 5), bottom_text, fill="black", font=font) + + img_io = io.BytesIO() + canvas.save(img_io, 'PNG') + img_io.seek(0) + zip_file.writestr(f"qr_{index+1}.png", img_io.getvalue()) + + zip_buffer.seek(0) + return zip_buffer + +@qr_code_bp.route('/') +@login_required +@recht_vereist('qr_codes') +def qr_generator(): + + return render_template('qr_code.html') + +@qr_code_bp.route('/download-template') +@login_required +@recht_vereist('qr_codes') +def download_template(): + df = pd.DataFrame(columns=["QR-Code (Link/ID)", "Koptekst", "Voettekst"]) + + buffer = io.BytesIO() + with pd.ExcelWriter(buffer, engine='openpyxl') as writer: + df.to_excel(writer, index=False) + + buffer.seek(0) + + return send_file( + buffer, + as_attachment=True, + download_name='sjabloon_qr.xlsx', + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + ) + +@qr_code_bp.route('/upload', methods=['POST']) +@login_required +@recht_vereist('qr_codes') +def upload_file(): + if 'file' not in request.files: + flash("Geen bestand geselecteerd", "warning") + return redirect(url_for('qr_generator.qr_generator')) + + file = request.files['file'] + show_top = request.form.get('show_top') == 'on' + show_bottom = request.form.get('show_bottom') == 'on' + + if file and file.filename != '': + try: + zip_data = generate_qr_images(file, show_top, show_bottom) + return send_file( + zip_data, + mimetype='application/zip', + as_attachment=True, + download_name='qr_codes.zip' + ) + except Exception as e: + flash(f"Fout bij verwerken: {str(e)}", "danger") + return redirect(url_for('qr_generator.qr_generator')) + + return redirect(url_for('qr_generator.qr_generator')) \ No newline at end of file diff --git a/blueprints/repairs.py b/blueprints/repairs.py new file mode 100644 index 0000000..9f4df57 --- /dev/null +++ b/blueprints/repairs.py @@ -0,0 +1,145 @@ +from flask import Blueprint, render_template, request, session, redirect, url_for, flash +from database import execute_query +from blueprints.auth import login_required +from rechten import recht_vereist +from services import CIP_inleveren # Voor de Ingeven_herstelling logica +from services.signpost_byod_service import get_cases_by_serial +from services import toestellen as tst + +# Herstellingen met de kolomnamen die herstellingen.html verwacht +HERSTELLINGEN_SQL = """ + SELECT h.Incident_ID AS Incident_nr, t.Schooleigen_ID AS KAMXM_nummer, t.Serienummer, + h.Omschrijving AS Soort_herstelling, h.Status_herstelling AS Status, h.Datum, h.Kostprijs, + l.Internnummer + FROM Signpost_herstellingen_DB h + JOIN Toestellen t ON t.Toestel_ID = h.Toestel_ID + LEFT JOIN Leerlingen l ON l.Leerling_ID = h.Leerling_ID +""" + +# Definieer de blueprint +repairs_bp = Blueprint('repairs', __name__) + +#--------------------------------------------------- +# --- ROUTE: OVERZICHT & ZOEKEN --- +#---------------------------------------------------- +@repairs_bp.route('/herstellingen', methods=['GET', 'POST']) +@login_required +@recht_vereist('herstellingen') +def herstellingen(): + query = None + results = [] + + # Haal de zoekterm op bij een POST request + if request.method == 'POST': + query = request.form.get('query') + sql = HERSTELLINGEN_SQL + " WHERE t.Schooleigen_ID LIKE %s OR h.Omschrijving LIKE %s ORDER BY h.Incident_ID DESC" + results = execute_query(sql, (f'%{query}%', f'%{query}%')) + else: + # Toon standaard alle herstellingen + results = execute_query(HERSTELLINGEN_SQL + " ORDER BY h.Incident_ID DESC") + + return render_template('herstellingen.html', results=results, query=query) + +#------------------------------------------------ +# --- ROUTE: TOEVOEGEN HERSTELLING --- +#------------------------------------------------ +@repairs_bp.route('/repairs.add_herstelling', methods=['POST']) +@login_required +@recht_vereist('herstellingen') +def add_herstelling(): + kamxm_nummer = request.form.get('kamxm_nummer') + soort_herstelling = request.form.get('soort_herstelling') + + if not kamxm_nummer or not soort_herstelling: + flash("Vul alle velden in!", "error") + return redirect(url_for('repairs.herstellingen')) + + try: + # Gebruik de logica uit de CIP_inleveren module + CIP_inleveren.Ingeven_herstelling(kamxm_nummer, soort_herstelling) + if CIP_inleveren.foutmelding: + flash(CIP_inleveren.foutmelding) + except Exception as e: + flash(f"Er is een fout opgetreden: {str(e)}", "error") + + return redirect(url_for('repairs.herstellingen')) + +#functie achter wijzig herstellingen +@repairs_bp.route('/wijzig_status_herstelling', methods=['POST']) +@login_required +@recht_vereist('herstellingen') +def wijzig_status_herstelling(): + # Haal alle gegevens op uit het formulier + kamxm_nummer = request.form.get('kamxm_nummer') + nieuwe_status = request.form.get('new_status') + incident_nr = request.form.get("incident_nr") # Zorg dat dit uit je modal komt + + # Validatie: we hebben nu alle drie de velden nodig voor een unieke update + if not kamxm_nummer or not nieuwe_status or not incident_nr: + flash("Fout: Ontbrekende gegevens (Toestel, Status of Incident nr).", "error") + return redirect(url_for('repairs.herstellingen')) + + try: + # Update alleen de rij waar zowel KAMXM als Incident_nr overeenkomen + execute_query("UPDATE Signpost_herstellingen_DB SET Status_herstelling = %s WHERE Incident_ID = %s", + (nieuwe_status, incident_nr), commit=True) + + flash(f"Status van incident {incident_nr} ({kamxm_nummer}) is nu: {nieuwe_status}", "success") + except Exception as err: + flash(f"Database fout: {err}", "error") + + return redirect(url_for('repairs.herstellingen')) + +#----------------------------------------------- +# --- ROUTE: GEGEVENS BEWERKEN --- +#----------------------------------------------- +@repairs_bp.route('/bewerk_herstelling', methods=['POST']) +@login_required +@recht_vereist('herstellingen') +def bewerk_herstelling(): + # Haal de originele sleutels op om de juiste rij te vinden + old_kamxm = request.form.get('kamxm_nummer_original') + incident_nr = request.form.get('incident_nr') # Dit is essentieel voor de unieke selectie + + # Haal de nieuwe waarden op + new_kamxm = request.form.get('kamxm_nummer') + soort = request.form.get('soort_herstelling') + datum = request.form.get('datum') + + try: + toestel = tst.zoek_toestel(new_kamxm) + if not toestel: + flash(f"Toestel {new_kamxm} niet gevonden.", "error") + return redirect(url_for('repairs.herstellingen')) + execute_query("UPDATE Signpost_herstellingen_DB SET Toestel_ID = %s, Omschrijving = %s, Datum = %s " + "WHERE Incident_ID = %s", + (toestel['Toestel_ID'], soort, datum or None, incident_nr), commit=True) + flash(f"Herstelling {incident_nr} voor {new_kamxm} succesvol bijgewerkt!", "success") + except Exception as err: + flash(f"Fout bij bijwerken: {err}", "error") + + return redirect(url_for('repairs.herstellingen')) + +#-------------------------------------------------- +#--- ROUTE: Signpost herstelling +#-------------------------------------------------- +@repairs_bp.route('/herstellingen/signpost_search', methods=['POST']) +@login_required +@recht_vereist('herstellingen') +def signpost_search(): + serial = request.form.get('serial_number') + signpost_results = [] + + if serial: + signpost_results = get_cases_by_serial(serial) + if not signpost_results: + flash(f"Geen Signpost cases gevonden voor {serial}", "info") + + # We sturen de gebruiker terug naar de tab, maar met de resultaten + # Tip: Om de tab 'active' te houden na een POST, kun je een parameter meesturen + results = execute_query(HERSTELLINGEN_SQL + " ORDER BY h.Incident_ID DESC") + return render_template('herstellingen.html', + results=results, + signpost_results=signpost_results, + active_tab='signpost-tab', + searched_serial=serial) \ No newline at end of file diff --git a/blueprints/scholen_bp.py b/blueprints/scholen_bp.py new file mode 100644 index 0000000..78bd6d5 --- /dev/null +++ b/blueprints/scholen_bp.py @@ -0,0 +1,683 @@ +""" +Scholenbeheer. + +- Superbeheerders (administrators in Gebruikers, of config.ini › superbeheerders via Google): + scholen toevoegen, alle instellingen wijzigen, tussen alle scholen wisselen. +- Gebruikers met het recht 'koppelingen' in een school: de koppelingen van DIE school wijzigen + (wachtwoorden, Informat, Google, ...), maar niet de code, domeinen of database. +- Iedereen kan wisselen naar een school waarin hij rechten heeft. +""" +import functools +import json +import os +import re +import sys + +import requests +from flask import Blueprint, render_template, request, redirect, url_for, flash, session, g + +import tenancy +from tenancy import VELDEN, VELD, GROEPEN, alle_scholen, school_op_code, in_school +from database import central_query, execute_query, get_central_connection, vergeet_pools +import rechten + +scholen_bp = Blueprint('scholen', __name__) + +CODE_RE = re.compile(r'^[a-z0-9_-]{2,32}$') +DB_RE = re.compile(r'^[A-Za-z0-9_-]{1,64}$') +# Velden die enkel een superbeheerder mag wijzigen +ENKEL_SUPER_GROEPEN = {'database'} + + +def superbeheerder_required(f): + @functools.wraps(f) + def wrapped(*a, **kw): + if not session.get('auth') or not session.get('superbeheerder'): + flash("Enkel voor superbeheerders van de scholengroep.", "error") + return redirect(url_for('auth.login')) + return f(*a, **kw) + return wrapped + + +# Koppelingen van de eigen school: recht 'koppelingen' in die school +schoolbeheerder_required = rechten.recht_vereist('koppelingen') + + +# ------------------------------------------------------------------ +# Overzicht en wisselen +# ------------------------------------------------------------------ +@scholen_bp.route('/') +@superbeheerder_required +def overzicht(): + tenancy.herlaad(force=True) + scholen = alle_scholen(ook_inactief=True) + # Onderverdeeld per onderwijsniveau; scholen zonder (geldig) niveau komen in de laatste groep + niveaus = list(tenancy.ONDERWIJSNIVEAUS) + [('', 'Onderwijsniveau niet gekozen')] + codes = {c for c, _ in tenancy.ONDERWIJSNIVEAUS} + niveau = lambda sch: sch.waarden.get('ONDERWIJSNIVEAU') if sch.waarden.get('ONDERWIJSNIVEAU') in codes else '' + groepen = [{'code': code or 'geen', 'label': label, 'scholen': [x for x in scholen if niveau(x) == code]} + for code, label in niveaus] + tests = laatste_tests() + return render_template('scholen.html', scholen=scholen, groepen=[g for g in groepen if g['scholen']], + badges={x.code: koppeling_badges(x, tests.get(x.id, {})) for x in scholen}) + + +# ------------------------------------------------------------------ +# Resultaten van de koppelingstest (voor de badges in het overzicht) +# ------------------------------------------------------------------ +# Badge in het overzicht → onderdelen uit test_school() die erbij horen +BADGES = [ + ('Smartschool', ('SS_API_PLATFORM', 'SS_API_PW'), ('Smartschool webservices', 'Smartschool OneRoster')), + ('Informat', ('INFORMAT_INST_NUMBER', 'INFORMAT_ID', 'INFORMAT_SECRET'), ('Informat',)), + ('Google', None, ('Google Workspace',)), + ('Signpost', ('M4S_CLIENT_ID', 'M4S_CLIENT_SECRET'), ('Signpost / M4S',)), +] + + +def _informat_ingesteld(school): + """Informat telt als ingesteld met client-ID/secret en een instellingsnummer van de school óf van het internaat.""" + return school.heeft('INFORMAT_ID', 'INFORMAT_SECRET') and ( + school.heeft('INFORMAT_INST_NUMBER') or school.heeft('INFORMAT_INTERNAAT_NUMMER')) + + +def bewaar_test(school, resultaten): + for onderdeel, ok, detail in resultaten: + central_query( + "INSERT INTO Koppeling_tests (School_ID, Onderdeel, Ok, Detail, Getest_op) VALUES (%s, %s, %s, %s, NOW()) " + "ON DUPLICATE KEY UPDATE Ok = VALUES(Ok), Detail = VALUES(Detail), Getest_op = NOW()", + (school.id, onderdeel, None if ok is None else int(bool(ok)), (detail or '')[:500]), commit=True) + + +def laatste_tests(): + """{school_id: {onderdeel: rij}}""" + uit = {} + try: + for r in central_query("SELECT School_ID, Onderdeel, Ok, Detail, Getest_op FROM Koppeling_tests") or []: + uit.setdefault(r['School_ID'], {})[r['Onderdeel']] = r + except Exception: + pass + return uit + + +def koppeling_badges(school, tests): + """[{label, toestand: ok|fout|ongetest|niet, titel}] volgens de laatste test. Een test van vóór de laatste + wijziging aan de school telt niet meer (dan 'ongetest').""" + from datetime import datetime + try: + gewijzigd = datetime.fromisoformat(school.versie).replace(microsecond=0) if school.versie else None + except ValueError: + gewijzigd = None + uit = [] + for label, velden, onderdelen in BADGES: + if label == 'Informat': + ingesteld = _informat_ingesteld(school) + else: + ingesteld = school.google_sleutel is not None if velden is None else school.heeft(*velden) + if not ingesteld: + uit.append({'label': label, 'toestand': 'niet', 'titel': 'niet ingesteld'}) + continue + rijen = [tests[o] for o in onderdelen if o in tests and tests[o]['Ok'] is not None] + if not rijen or (gewijzigd and any(r['Getest_op'] < gewijzigd for r in rijen)): + uit.append({'label': label, 'toestand': 'ongetest', + 'titel': 'ingesteld, maar nog niet getest sinds de laatste wijziging – klik op Testen'}) + continue + fout = [r for r in rijen if not r['Ok']] + wanneer = max(r['Getest_op'] for r in rijen).strftime('%d-%m-%Y %H:%M') + if fout: + uit.append({'label': label, 'toestand': 'fout', + 'titel': f"test mislukt op {wanneer}: " + ' · '.join(r['Detail'] for r in fout)[:300]}) + else: + uit.append({'label': label, 'toestand': 'ok', 'titel': f"test geslaagd op {wanneer}"}) + return uit + + +@scholen_bp.route('/wissel/') +def wissel(code): + """Naar een andere school gaan: superbeheerders overal, anderen waar ze rechten hebben.""" + if not session.get('auth'): + return redirect(url_for('auth.login')) + school = school_op_code(code) + if not school: + flash("Onbekende of inactieve school.", "error") + return redirect(url_for('main.dashboard')) + if not session.get('superbeheerder'): + gid = session.get('gebruiker_id') + if not gid or school.id not in rechten.scholen_met_rechten(gid): + flash(f"Je hebt geen rechten in {school.naam}.", "error") + return redirect(url_for('main.dashboard')) + session['school'] = school.code + rechten.bewaar_laatste_school(session.get('gebruiker_id'), school.code) # volgende keer meteen deze school + flash(f"Je werkt nu in {school.naam}.", "success") + from blueprints.auth import _onthoud_school + return _onthoud_school(redirect(url_for('main.dashboard')), school) + + +# ------------------------------------------------------------------ +# Formulier +# ------------------------------------------------------------------ +# Velden die een uur van de dag zijn (0–23) +UUR_VELDEN = {'AFWEZIGHEID_SYNC_UUR'} + + +def _controleer_waarden(waarden): + """Geeft een foutmelding als een getal-veld ongeldig is, anders None.""" + for v in VELDEN: + w = str(waarden.get(v.key) or '').strip() + if v.soort != 'getal' or not w: + continue + if not w.isdigit(): + return f"'{v.label}' moet een getal zijn (nu: {w})." + if v.key in UUR_VELDEN and not 0 <= int(w) <= 23: + return f"'{v.label}' moet een uur tussen 0 en 23 zijn (nu: {w})." + return None + + +def _form_waarden(school, mag_alles): + """Leest het formulier. Lege geheimen = ongewijzigd laten (tenzij 'wissen' aangevinkt).""" + oud = dict(school.waarden) if school else {} + nieuw = dict(oud) + for v in VELDEN: + if v.groep in ENKEL_SUPER_GROEPEN and not mag_alles: + continue + naam = f"v_{v.key}" + if v.soort == 'janee': + nieuw[v.key] = 'ja' if request.form.get(naam) else 'nee' + continue + waarde = (request.form.get(naam) or '').strip() + if v.soort == 'geheim': + if request.form.get(f"wis_{v.key}"): + nieuw.pop(v.key, None) + elif waarde: + nieuw[v.key] = waarde + continue + if waarde: + nieuw[v.key] = waarde + else: + nieuw.pop(v.key, None) + return nieuw + + +def _google_sleutel_uit_form(): + """Geeft (sleutel_dict | None, foutmelding | None).""" + bestand = request.files.get('google_sleutel') + tekst = '' + if bestand and bestand.filename: + tekst = bestand.read().decode('utf-8', errors='replace') + elif (request.form.get('google_sleutel_tekst') or '').strip(): + tekst = request.form['google_sleutel_tekst'].strip() + if not tekst: + return None, None + try: + data = json.loads(tekst) + except ValueError: + return None, "De Google-sleutel is geen geldig JSON-bestand." + if data.get('type') != 'service_account' or not data.get('private_key') or not data.get('client_email'): + return None, "Dit is geen sleutel van een Google-serviceaccount (type 'service_account')." + return data, None + + +def database_status(school): + """'ok' (PIVOT-tabellen aanwezig), 'leeg' (database bestaat, tabellen ontbreken) of 'ontbreekt'.""" + import mysql.connector + cfg = school.db_config() + from config import Config + try: + conn = mysql.connector.connect(host=cfg['host'], port=Config.DB_PORT, user=cfg['user'], + password=cfg['password'], connection_timeout=5) + except Exception: + return 'onbekend' + try: + cur = conn.cursor() + cur.execute("SELECT COUNT(*) FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = %s", (school.db_naam,)) + if not cur.fetchone()[0]: + return 'ontbreekt' + cur.execute("SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = %s " + "AND LOWER(TABLE_NAME) = 'toestellen'", (school.db_naam,)) + return 'ok' if cur.fetchone()[0] else 'leeg' + except Exception: + return 'onbekend' + finally: + conn.close() + + +def _render_form(school, nieuw, mag_alles, form=None): + return render_template('school_bewerken.html', school=school, nieuw=nieuw, mag_alles=mag_alles, + db_status=database_status(school) if school else None, + groepen=GROEPEN, velden=VELDEN, enkel_super=ENKEL_SUPER_GROEPEN, + form=form or {}, andere_scholen=[s for s in alle_scholen(ook_inactief=True) if not school or s.code != school.code]) + + +@scholen_bp.route('/nieuw', methods=['GET', 'POST']) +@superbeheerder_required +def nieuw(): + if request.method == 'GET': + return _render_form(None, True, True) + + code = (request.form.get('code') or '').strip().lower() + naam = (request.form.get('naam') or '').strip() + db_naam = (request.form.get('db_naam') or '').strip() + fouten = [] + if not CODE_RE.match(code): + fouten.append("De code mag enkel kleine letters, cijfers, - en _ bevatten (2-32 tekens).") + elif school_op_code(code, ook_inactief=True): + fouten.append(f"Er bestaat al een school met code '{code}'.") + if not naam: + fouten.append("Vul de naam van de school in.") + if not DB_RE.match(db_naam): + fouten.append("Ongeldige databasenaam.") + domeinen, fout = _domeinen(request.form.get('domeinen')) + if fout: + fouten.append(fout) + sleutel, fout = _google_sleutel_uit_form() + if fout: + fouten.append(fout) + if fouten: + for f in fouten: + flash(f, "error") + return _render_form(None, True, True, form=request.form) + + waarden = _form_waarden(None, True) + fout = _controleer_waarden(waarden) + if fout: + flash(fout, "error") + return _render_form(None, True, True, form=request.form) + + # Optioneel: database aanmaken (standaardstructuur, of een volledige kopie van een bestaande school) + keuze = request.form.get('kopieer_schema_van') + bron = school_op_code(keuze, ook_inactief=True) if keuze and keuze != STANDAARD else None + if keuze == STANDAARD: + ok, msg = maak_schooldatabase(db_naam, waarden) + flash(msg, "success" if ok else "error") + if not ok: + return _render_form(None, True, True, form=request.form) + elif bron: + if request.form.get('kopie_koppelingen'): + # Koppelingen en instellingen van de bron overnemen (database-server blijft dezelfde); + # aanpassen kan meteen daarna op de pagina Bewerken. + waarden = dict(bron.waarden) + sleutel = sleutel or bron.google_sleutel + ok, msg = kopieer_school_db(bron, db_naam, waarden) + flash(msg, "success" if ok else "error") + if not ok: + return _render_form(None, True, True, form=request.form) + + tenancy.opslaan(code, naam, ','.join(domeinen), _hostnamen(request.form.get('hostnamen')), db_naam, + bool(request.form.get('actief')), waarden, sleutel, gebruiker=session.get('user'), nieuw=True) + flash(f"School {naam} toegevoegd.", "success") + if bron: + if request.form.get('kopie_koppelingen'): + flash(f"Koppelingen en instellingen van {bron.naam} overgenomen. Kijk ze hieronder na" + + (" (de school staat actief: geplande synchronisaties lopen ook voor de kopie)." + if request.form.get('actief') else "."), "info") + if request.form.get('kopie_rechten'): + n = kopieer_rechten(bron, code, session.get('user')) + flash(f"Rechten van {n} gebruiker(s) overgenomen van {bron.naam}.", "success") + return redirect(url_for('scholen.bewerken', code=code)) + + +@scholen_bp.route('//bewerken', methods=['GET', 'POST']) +@superbeheerder_required +def bewerken(code): + return _bewerk(code, mag_alles=True) + + +@scholen_bp.route('//database-aanmaken', methods=['POST']) +@superbeheerder_required +def database_aanmaken(code): + """Database van een bestaande school (alsnog) aanmaken met de standaardstructuur. + Veilig om te herhalen: bestaande tabellen en gegevens blijven behouden.""" + school = school_op_code(code, ook_inactief=True) + if not school: + flash("Onbekende school.", "error") + return redirect(url_for('scholen.overzicht')) + ok, msg = maak_schooldatabase(school.db_naam, school.waarden) + vergeet_pools(school.db_naam) + flash(msg, "success" if ok else "error") + return redirect(url_for('scholen.bewerken', code=school.code)) + + +@scholen_bp.route('/mijn-school', methods=['GET', 'POST']) +@schoolbeheerder_required +def mijn_school(): + return _bewerk(session['school'], mag_alles=bool(session.get('superbeheerder'))) + + +def _bewerk(code, mag_alles): + school = school_op_code(code, ook_inactief=True) + if not school: + flash("Onbekende school.", "error") + return redirect(url_for('main.dashboard')) + if request.method == 'GET': + return _render_form(school, False, mag_alles) + + if request.form.get('wis_google_sleutel'): + tenancy.wis_google_sleutel(school.code, session.get('user')) + school = school_op_code(code, ook_inactief=True) + + sleutel, fout = _google_sleutel_uit_form() + if fout: + flash(fout, "error") + return _render_form(school, False, mag_alles, form=request.form) + + if mag_alles: + naam = (request.form.get('naam') or '').strip() or school.naam + domeinen, fout = _domeinen(request.form.get('domeinen'), school.code) + db_naam = (request.form.get('db_naam') or '').strip() + if fout or not DB_RE.match(db_naam): + flash(fout or "Ongeldige databasenaam.", "error") + return _render_form(school, False, mag_alles, form=request.form) + hostnamen = _hostnamen(request.form.get('hostnamen')) + actief = bool(request.form.get('actief')) + else: + naam, domeinen, db_naam = school.naam, school.domeinen, school.db_naam + hostnamen, actief = ','.join(school.hostnamen), school.actief + + waarden = _form_waarden(school, mag_alles) + fout = _controleer_waarden(waarden) + if fout: + flash(fout, "error") + return _render_form(school, False, mag_alles, form=request.form) + tenancy.opslaan(school.code, naam, ','.join(domeinen), hostnamen, db_naam, actief, waarden, sleutel, + gebruiker=session.get('user')) + vergeet_pools() + flash(f"Instellingen van {naam} opgeslagen.", "success") + if request.endpoint == 'scholen.bewerken': + return redirect(url_for('scholen.bewerken', code=school.code)) + return redirect(url_for('scholen.mijn_school')) + + +def _domeinen(tekst, eigen_code=None): + domeinen = [d.strip().lower().lstrip('@') for d in re.split(r'[,;\s]+', tekst or '') if d.strip()] + if not domeinen: + return [], "Geef minstens één e-maildomein op (bv. school.be)." + for d in domeinen: + if not re.match(r'^[a-z0-9.-]+\.[a-z]{2,}$', d): + return [], f"'{d}' is geen geldig domein." + # Meerdere scholen mogen hetzelfde domein delen (bv. basis- en secundaire school van één campus). + # Wie zich met Google aanmeldt, komt dan in de school waar hij toegang toe heeft (zie auth.callback). + return domeinen, None + + +def _hostnamen(tekst): + return ','.join(h.strip().lower() for h in re.split(r'[,;\s]+', tekst or '') if h.strip()) + + +# ------------------------------------------------------------------ +# Database aanmaken +# ------------------------------------------------------------------ +STANDAARD = '__standaard__' +SCHOOL_SQL = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'sql', 'school.sql') + + +def sql_statements(pad): + """Leest een .sql-bestand en geeft de afzonderlijke statements (commentaar weggelaten).""" + with open(pad, encoding='utf-8') as f: + regels = [r for r in f.read().split('\n') if not r.strip().startswith('--')] + return [st.strip() for st in re.split(r';\s*\n', '\n'.join(regels) + '\n') if st.strip()] + + +def maak_schooldatabase(db_naam, waarden=None): + """Maakt db_naam aan met de standaardstructuur uit sql/school.sql (lege tabellen).""" + import mysql.connector + from config import Config + waarden = waarden or {} + if not DB_RE.match(db_naam or ''): + return False, "Ongeldige databasenaam." + try: + conn = mysql.connector.connect( + host=waarden.get('DB_HOST') or Config.DB_HOST, port=Config.DB_PORT, + user=waarden.get('DB_USER') or Config.DB_USER, + password=waarden.get('DB_PASSWORD') or Config.DB_PASSWORD, connection_timeout=10, + charset='utf8mb4', collation='utf8mb4_unicode_ci') + except Exception as e: + return False, f"Kan niet verbinden met de databaseserver: {e}" + cur = conn.cursor() + try: + cur.execute(f"CREATE DATABASE IF NOT EXISTS `{db_naam}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci") + cur.execute(f"USE `{db_naam}`") + stmts = sql_statements(SCHOOL_SQL) + for st in stmts: + cur.execute(st) + conn.commit() + n = sum(1 for st in stmts if st.upper().startswith('CREATE TABLE')) + return True, f"Database {db_naam} aangemaakt met de standaardstructuur ({n} tabellen)." + except Exception as e: + conn.rollback() + return False, f"Database aanmaken mislukt: {e}" + finally: + cur.close() + conn.close() +def kopieer_school_db(bron, db_naam, waarden): + """Maakt db_naam aan (zelfde server) als volledige kopie van de database van `bron`: alle tabellen mét gegevens. + De doeldatabase moet nieuw of leeg zijn. Mislukt het, dan wordt een database die we zelf aanmaakten weer verwijderd.""" + bron_db = bron.db_naam + if waarden.get('DB_HOST') and waarden.get('DB_HOST') != (bron.waarden.get('DB_HOST') or ''): + return False, "Een school kopiëren kan enkel binnen dezelfde databaseserver." + if db_naam == bron_db: + return False, "Kies een andere databasenaam dan die van de school die je kopieert." + conn = get_central_connection() + cur = conn.cursor() + zelf_aangemaakt = False + try: + cur.execute("SELECT 1 FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = %s", (db_naam,)) + bestond = bool(cur.fetchall()) + if bestond: + cur.execute(f"SHOW FULL TABLES FROM `{db_naam}` WHERE Table_type = 'BASE TABLE'") + if cur.fetchall(): + return False, (f"De database {db_naam} bestaat al en bevat tabellen. Kies een nieuwe databasenaam " + "om er een kopie in te zetten.") + else: + cur.execute(f"CREATE DATABASE `{db_naam}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci") + zelf_aangemaakt = True + cur.execute(f"SHOW FULL TABLES FROM `{bron_db}` WHERE Table_type = 'BASE TABLE'") + tabellen = [r[0] for r in cur.fetchall()] + cur.execute("SET FOREIGN_KEY_CHECKS = 0") + rijen = 0 + for t in tabellen: + cur.execute(f"CREATE TABLE `{db_naam}`.`{t}` LIKE `{bron_db}`.`{t}`") + cur.execute(f"INSERT INTO `{db_naam}`.`{t}` SELECT * FROM `{bron_db}`.`{t}`") + rijen += max(cur.rowcount or 0, 0) + cur.execute("SET FOREIGN_KEY_CHECKS = 1") + conn.commit() + return True, (f"Database {db_naam} aangemaakt als kopie van {bron.naam}: " + f"{len(tabellen)} tabellen, {rijen} rijen gegevens.") + except Exception as e: + try: + conn.rollback() + cur.execute("SET FOREIGN_KEY_CHECKS = 1") + if zelf_aangemaakt: + cur.execute(f"DROP DATABASE IF EXISTS `{db_naam}`") + except Exception: + pass + return False, f"School kopiëren mislukt: {e}" + finally: + cur.close() + conn.close() + + +def kopieer_rechten(bron, nieuwe_code, door=None): + """Geeft iedereen die rechten heeft in `bron` dezelfde rechten in de nieuwe school.""" + nieuw = school_op_code(nieuwe_code, ook_inactief=True) + if not nieuw: + return 0 + central_query("INSERT IGNORE INTO Gebruiker_rechten (Gebruiker_ID, School_ID, Onderdeel_ID, Toegekend_op, Toegekend_door) " + "SELECT Gebruiker_ID, %s, Onderdeel_ID, NOW(), %s FROM Gebruiker_rechten WHERE School_ID = %s", + (nieuw.id, door, bron.id), commit=True) + return central_query("SELECT COUNT(DISTINCT Gebruiker_ID) AS n FROM Gebruiker_rechten WHERE School_ID = %s", + (nieuw.id,))[0]['n'] + + +# ------------------------------------------------------------------ +# Verbindingstest +# ------------------------------------------------------------------ +def test_school(school): + """Test elke ingestelde koppeling. Geeft een lijst (onderdeel, ok|None, detail).""" + res = [] + with in_school(school): + # Database (eerst tabelnamen met hoofdletters herstellen, voor een database die van Windows komt) + try: + from services.tabelnamen import herstel_school + for m in herstel_school(school): + res.append(('Tabelnamen', 'mislukt' not in m and 'samen' not in m, m)) + except Exception as e: + res.append(('Tabelnamen', False, str(e)[:200])) + try: + n = execute_query("SELECT COUNT(*) AS n FROM information_schema.tables WHERE table_schema = DATABASE()")[0]['n'] + heeft = execute_query("SHOW TABLES LIKE 'Toestellen'") + res.append(('Database', bool(heeft), f"{school.db_naam}: {n} tabellen" + + ('' if heeft else " – tabel Toestellen ontbreekt (python beheer.py nieuwe-schooldatabase)"))) + except Exception as e: + res.append(('Database', False, str(e))) + + # Smartschool webservices + if school.heeft('SS_API_PLATFORM', 'SS_API_PW'): + try: + from services.smartschool_api import SmartschoolService + from config import Config + ss = SmartschoolService(Config.SS_API_PLATFORM, Config.SS_API_PW) + raw = ss.client.service.getClassListJson(Config.SS_API_PW) + if isinstance(raw, (int, float)) or (isinstance(raw, str) and raw.strip().lstrip('-').isdigit()): + res.append(('Smartschool webservices', False, f"foutcode {raw} (wachtwoord of rechten)")) + else: + res.append(('Smartschool webservices', True, Config.SS_API_PLATFORM)) + except Exception as e: + res.append(('Smartschool webservices', False, str(e)[:200])) + else: + res.append(('Smartschool webservices', None, 'niet ingesteld')) + + # Smartschool OneRoster + if school.heeft('SS_API_PLATFORM', 'SS_CLIENT_ID', 'SS_CLIENT_SECRET'): + try: + from config import Config + r = requests.post(Config.SS_TOKEN_URL, data={"grant_type": "client_credentials"}, + auth=(Config.SS_CLIENT_ID, Config.SS_CLIENT_SECRET), timeout=10) + res.append(('Smartschool OneRoster', r.ok and bool(r.json().get('access_token')), + 'token ontvangen' if r.ok else f"HTTP {r.status_code}")) + except Exception as e: + res.append(('Smartschool OneRoster', False, str(e)[:200])) + else: + res.append(('Smartschool OneRoster', None, 'niet ingesteld')) + + # Informat: eerst de leerlingen-API; lukt die niet, dan de Internaat-API (een internaat gebruikt in + # Informat geen leerlingen maar internen, dus de leerlingen-scope wordt daar geweigerd) + if _informat_ingesteld(school): + fout = None + if school.heeft('INFORMAT_INST_NUMBER'): + try: + from services.sync_service import SyncService + if SyncService().get_informat_token(): + res.append(('Informat', True, 'token ontvangen (leerlingen)')) + else: + fout = 'authenticatie mislukt' + except Exception as e: + fout = str(e)[:200] + else: + fout = 'geen instellingsnummer voor leerlingen' + if fout is not None: + try: + from services.internaat_service import InternaatService + svc = InternaatService() + if not svc.nummers: + raise RuntimeError('geen instellingsnummer') + for nr in svc.nummers: + svc._token(nr) + res.append(('Informat', True, f"token ontvangen (internaat {', '.join(svc.nummers)})")) + except Exception as e: + res.append(('Informat', False, f"{fout}; internaat: {str(e)[:150]}")) + else: + res.append(('Informat', None, 'niet ingesteld')) + + # Google + if school.google_sleutel and not school.heeft('ADMIN_EMAIL'): + res.append(('Google Workspace', False, "Sleutel staat erin, maar 'Beheerder (Google-admin)' is nog niet " + "ingevuld (Algemeen). PIVOT moet weten namens welk beheerdersaccount het werkt.")) + elif school.google_sleutel: + try: + from services.google_service import GoogleAdminService + svc = GoogleAdminService() + svc.service.users().list(customer='my_customer', maxResults=1).execute() + res.append(('Google Workspace', True, f"namens {school.get('ADMIN_EMAIL')}")) + except Exception as e: + res.append(('Google Workspace', False, google_fout_uitleg(str(e), school))) + else: + res.append(('Google Workspace', None, 'geen serviceaccount-sleutel')) + + # Signpost + if school.heeft('M4S_CLIENT_ID', 'M4S_CLIENT_SECRET'): + from services.signpost_byod_service import get_signpost_token + ok = bool(get_signpost_token()) + res.append(('Signpost / M4S', ok, 'token ontvangen' if ok else 'authenticatie mislukt')) + else: + res.append(('Signpost / M4S', None, 'niet ingesteld')) + return res + + +def google_fout_uitleg(fout, school): + """Vertaalt de meest voorkomende Google-fouten naar wat je moet nakijken.""" + admin = school.get('ADMIN_EMAIL') + f = fout.lower() + # Bij een HttpError staat de eigenlijke reden na 'returned "...": die tonen i.p.v. de lange URL + m = re.search(r'returned "([^"]+)"', fout) + reden = m.group(1) if m else fout + if 'has not been used' in f or 'is disabled' in f or 'accessnotconfigured' in f or 'service_disabled' in f: + uitleg = ("De Admin SDK API staat nog niet aan in het Google Cloud-project van het serviceaccount " + "(console.cloud.google.com › API's en services › Bibliotheek › Admin SDK API › Inschakelen).") + elif 'unauthorized_client' in f: + uitleg = ("De domeinbrede delegatie is (nog) niet in orde: controleer in admin.google.com › Beveiliging › " + "API-beheer › Domeinbrede delegatie de client-ID (Unieke ID van het serviceaccount) en de OAuth-bereiken. " + "Een nieuwe instelling kan tot een uur duren.") + elif 'invalid_grant' in f: + uitleg = (f"Google kent het beheerdersaccount '{admin}' niet of het is geschorst. " + "Vul bij 'Beheerder (Google-admin)' een bestaand account van dit domein in.") + elif 'not authorized' in f or '403' in f: + uitleg = (f"'{admin}' mag de gebruikerslijst niet lezen in Google. Controleer in admin.google.com › Account › " + "Beheerdersrollen dat dit account superbeheerder is (een gewone gebruiker of een beheerder met een " + "beperkte rol volstaat niet), en dat het in dezelfde Google-organisatie zit als de domeinbrede delegatie.") + elif 'invalid input' in f: + uitleg = (f"Google aanvaardt '{admin}' niet als beheerder van dit domein. Controleer of 'Beheerder (Google-admin)' " + "een echt beheerdersaccount is (geen groep of alias) van hetzelfde domein als het serviceaccount.") + else: + return fout[:300] + return f"{uitleg} (Google: {reden[:200]})" + + +@scholen_bp.route('//test', methods=['POST']) +def test(code): + if not session.get('auth'): + return redirect(url_for('auth.login')) + if not session.get('superbeheerder') and not rechten.heeft_recht('koppelingen'): + flash("Onvoldoende rechten voor deze actie.", "error") + return redirect(url_for('main.dashboard')) + if not session.get('superbeheerder') and code != session.get('school'): + flash("Je kan enkel je eigen school testen.", "error") + return redirect(url_for('main.dashboard')) + school = school_op_code(code, ook_inactief=True) + if not school: + flash("Onbekende school.", "error") + return redirect(url_for('main.dashboard')) + resultaten = test_school(school) + try: + bewaar_test(school, resultaten) + except Exception as e: + sys.stderr.write(f"PIVOT: testresultaat bewaren mislukt: {e}\n") + return render_template('school_test.html', school=school, resultaten=resultaten) + + +@scholen_bp.route('/alles-testen', methods=['POST']) +@superbeheerder_required +def alles_testen(): + """Test de koppelingen van alle actieve scholen en werkt de badges in het overzicht bij.""" + goed, fout = 0, [] + for school in alle_scholen(): + try: + res = test_school(school) + bewaar_test(school, res) + if any(ok is False for _, ok, _ in res): + fout.append(school.naam) + else: + goed += 1 + except Exception as e: + fout.append(f"{school.naam} ({e})") + flash(f"{goed} school/scholen zonder fouten." + (f" Met fouten: {', '.join(fout)}." if fout else ""), + "warning" if fout else "success") + return redirect(url_for('scholen.overzicht')) diff --git a/blueprints/sgr_groepen_bp.py b/blueprints/sgr_groepen_bp.py new file mode 100644 index 0000000..ee0e084 --- /dev/null +++ b/blueprints/sgr_groepen_bp.py @@ -0,0 +1,65 @@ +"""Smartschool › SGR groepen: subgroepen van de hoofdgroep (standaard 'groepen GO-Antwerpen') met hun leden.""" +import csv +import io +import time +from datetime import date + +from flask import Blueprint, render_template, request, Response, redirect, url_for, flash + +from blueprints.auth import login_required +from rechten import recht_vereist +from services import sgr_groepen_service as sgr +from tenancy import huidige_school + +sgr_groepen_bp = Blueprint('sgr_groepen', __name__) + + +def _gevraagd(): + return (request.args.get('groep') or '').strip() or None + + +@sgr_groepen_bp.route("/", methods=["GET"]) +@login_required +@recht_vereist('sgr_groepen') +def overzicht(): + school = huidige_school() + standaard = (school.get('SS_SGR_GROEP') or sgr.STANDAARD_GROEP).strip() + gevraagd = _gevraagd() + hoofd, groepen, fout, opgehaald = None, [], None, None + try: + hoofd, groepen, t = sgr.overzicht(gevraagd, vernieuw=bool(request.args.get('vernieuw'))) + opgehaald = time.strftime('%d-%m-%Y %H:%M', time.localtime(t)) + except sgr.SgrFout as e: + fout = str(e) + except Exception as e: + fout = f"Groepen ophalen uit Smartschool mislukt: {e}" + if request.args.get('vernieuw'): + return redirect(url_for('sgr_groepen.overzicht', groep=gevraagd)) + leden = [l for g in groepen for l in g['leden']] + return render_template("sgr_groepen.html", hoofd=hoofd, groepen=groepen, fout=fout, opgehaald=opgehaald, + groep=gevraagd or standaard, standaard=standaard, school_naam=school.naam, + aantal_leden=len(leden), + uniek=len({l['gebruikersnaam'] or (l['naam'], l['voornaam']) for l in leden})) + + +@sgr_groepen_bp.route("/export.csv", methods=["GET"]) +@login_required +@recht_vereist('sgr_groepen') +def export(): + try: + hoofd, groepen, _ = sgr.overzicht(_gevraagd()) + except Exception as e: + flash(f"Kon niet exporteren: {e}", "error") + return redirect(url_for('sgr_groepen.overzicht', groep=_gevraagd())) + buf = io.StringIO() + w = csv.writer(buf, delimiter=';') + w.writerow(['Groep', 'Pad', 'Groepscode', 'Naam', 'Voornaam', 'Gebruikersnaam', 'Rol', 'E-mail']) + for g in groepen: + pad = ' › '.join(g['pad'] + [g['naam']]) + if not g['leden']: + w.writerow([g['naam'], pad, g['code'], '', '', '', '', '']) + for l in g['leden']: + w.writerow([g['naam'], pad, g['code'], l['naam'], l['voornaam'], l['gebruikersnaam'], l['rol'], l['email']]) + naam = ''.join(c if c.isalnum() else '_' for c in hoofd['naam'])[:40] + return Response('' + buf.getvalue(), mimetype='text/csv', + headers={'Content-Disposition': f'attachment; filename=SGR_{naam}_{date.today():%Y%m%d}.csv'}) diff --git a/blueprints/signpost_devices_bp.py b/blueprints/signpost_devices_bp.py new file mode 100644 index 0000000..f44b6eb --- /dev/null +++ b/blueprints/signpost_devices_bp.py @@ -0,0 +1,135 @@ +import requests +from flask import Blueprint, render_template, request, flash, redirect, url_for +from database import execute_query +from services import toestellen as tst +from blueprints.auth import login_required +from rechten import recht_vereist +# Importeer de benodigdheden uit je bestaande service-bestand +from services.signpost_byod_service import BASE_DATA_URL, get_signpost_token, signpost_token_met_reden + +signpost_devices_bp = Blueprint('signpost_devices', __name__) + +def sync_signpost_devices_to_db(): + """ + Haalt alle toestellen op via de Signpost-API en voegt toestellen die + nog niet bestaan (op basis van serienummer) toe aan Signpost_database. + """ + token, fout = signpost_token_met_reden() + if not token: + return False, fout + + headers = { + "Authorization": f"Bearer {token}", + "Accept": "application/json" + } + + page_number = 1 + page_size = 100 + toegevoegd_teller = 0 + overgeslagen_teller = 0 + + print("Starten met synchroniseren van toestellen naar Signpost_database...") + + while True: + params = { + "$top": page_size, + "$skip": (page_number - 1) * page_size + } + + url = "https://byod-api.signpost.be/v1/devices" + + try: + res = requests.get(url, headers=headers, params=params, timeout=15) + if res.status_code != 200: + print(f"Fout bij pagina {page_number}: {res.status_code}") + return False, f"Signpost API gaf foutcode {res.status_code} op pagina {page_number}." + + data = res.json() + items = data.get("items", []) + + if not items: + break + + for item in items: + signpost_nummer = item.get("signpostLabel") + serienummer = item.get("serialNumber") + model = item.get("modelName") + warranty_void_raw = item.get("warrantyVoidAt") + + garantie = warranty_void_raw.split("T")[0] if warranty_void_raw else None + + if not serienummer: + continue + + # Bestaat het toestel al (op serienummer)? Zo niet: aanmaken in Toestellen. + bestaand = execute_query( + "SELECT t.Toestel_ID, sp.Toestel_ID AS heeft_signpost FROM Toestellen t " + "LEFT JOIN Signpost_DB sp ON sp.Toestel_ID = t.Toestel_ID WHERE t.Serienummer = %s LIMIT 1", + (serienummer,)) + if bestaand and bestaand[0]['heeft_signpost']: + overgeslagen_teller += 1 + continue + toestel_id = bestaand[0]['Toestel_ID'] if bestaand else tst.nieuw_toestel( + serienummer=serienummer, type_toestel=model) + execute_query( + "INSERT INTO Signpost_DB (Toestel_ID, Signpostnummer, Garantie) VALUES (%s, %s, %s) " + "ON DUPLICATE KEY UPDATE Signpostnummer = VALUES(Signpostnummer), Garantie = VALUES(Garantie)", + (toestel_id, signpost_nummer or None, garantie), commit=True) + toegevoegd_teller += 1 + + if len(items) < page_size: + break + + page_number += 1 + + except Exception as e: + import traceback + print(f"Fout tijdens database/API sync: {e}") + traceback.print_exc() # Dit print de échte boosdoener in je terminal (bijv. IndexError of MySQL-fout) + return False, f"Er is een fout opgetreden tijdens de synchronisatie: {str(e)}" + + return True, f"Synchronisatie voltooid! {toegevoegd_teller} nieuw(e) toestel(len) toegevoegd. {overgeslagen_teller} bestaand(e) toestel(len) overgeslagen." + +@signpost_devices_bp.route('/signpost_database', methods=['GET', 'POST']) +@login_required +@recht_vereist('signpost') +def signpost_database_overzicht(): + # Komt overeen met jouw methode om de query op te vangen bij GET of POST + query = request.form.get('query') if request.method == 'POST' else request.args.get('query') + + # De basis-query aangepast naar de tabel Signpost_database en de gevraagde kolommen + basis_sql = """ + SELECT sp.Signpostnummer, t.Serienummer AS Device_serienummer, t.Type AS Device_model, sp.Garantie, + sp.Aankoopbedrag, sp.Aankoopdatum, t.Schooleigen_ID + FROM Signpost_DB sp + JOIN Toestellen t ON t.Toestel_ID = sp.Toestel_ID + """ + + if query: + # Als er gezocht wordt, zoeken we veilig op serienummer of signpostnummer + sql = basis_sql + " WHERE t.Serienummer LIKE %s OR sp.Signpostnummer LIKE %s ORDER BY Signpostnummer DESC" + params = (f"%{query}%", f"%{query}%") + results = execute_query(sql, params) + else: + # Als er geen zoekterm is, halen we alles op + sql = basis_sql + " ORDER BY Signpostnummer DESC" + results = execute_query(sql) + + # We sturen de resultaten door naar de HTML-pagina + return render_template('signpost_devices.html', results=results, query=query) + + +@signpost_devices_bp.route('/sync_signpost', methods=['POST']) +@login_required +@recht_vereist('signpost') +def sync_signpost(): + # Voer de vernieuwde synchronisatie uit + succes, melding = sync_signpost_devices_to_db() + + if succes: + flash(melding, 'success') + else: + flash(melding, 'danger') + + # Stuur de gebruiker terug naar het overzicht + return redirect(url_for('signpost_devices.signpost_database_overzicht')) \ No newline at end of file diff --git a/blueprints/skore_bp.py b/blueprints/skore_bp.py new file mode 100644 index 0000000..b0bff51 --- /dev/null +++ b/blueprints/skore_bp.py @@ -0,0 +1,66 @@ +"""Skore-synchronisatie in Smartschool: manueel starten, planning instellen en logboek bekijken.""" +from flask import Blueprint, render_template, request, redirect, url_for, flash, session + +from blueprints.auth import login_required +from rechten import recht_vereist +import rechten +from services import skore_service as skore + +skore_bp = Blueprint('skore', __name__) + + +@skore_bp.route('/', methods=['GET']) +@login_required +@recht_vereist('skore') +def overzicht(): + try: + p = skore.planning() + log = skore.logboek() + except Exception as e: + flash(f"Kon de Skore-gegevens niet lezen: {e}", "error") + p, log = None, [] + try: + loopt = skore.bezig() + except Exception: + loopt = False + return render_template('skore.html', planning=p, log=log, frequenties=skore.FREQUENTIES, loopt=loopt, + weekdagen=skore.WEEKDAGEN, volgende=skore.volgende_moment(p) if p else None) + + +@skore_bp.route('/start', methods=['POST']) +@login_required +@recht_vereist('skore') +def start(): + rij = skore.start(session.get('user'), manier='manueel') + rechten.log(actie="Skore-synchronisatie gestart") + if rij['Status'] == 'mislukt': + flash(rij['Melding'], "error") + else: + flash("Skore-synchronisatie gestart bij Smartschool. PIVOT volgt de status automatisch op; " + "deze pagina ververst vanzelf tot ze klaar is.", "success") + return redirect(url_for('skore.overzicht')) + + +@skore_bp.route('/status/', methods=['POST']) +@login_required +@recht_vereist('skore') +def status(log_id): + rij = skore.controleer(log_id) + if rij: + flash(f"Status: {rij['Status']}", "info") + return redirect(url_for('skore.overzicht')) + + +@skore_bp.route('/planning', methods=['POST']) +@login_required +@recht_vereist('skore') +def planning(): + f = request.form + try: + skore.bewaar_planning(f.get('frequentie', 'uit'), f.get('uur', 6), f.get('minuut', 0), + f.get('weekdag', 0), f.get('dag_van_maand', 1), session.get('user')) + rechten.log(actie=f"Skore-planning: {f.get('frequentie')}") + flash("Planning opgeslagen.", "success") + except (ValueError, TypeError) as e: + flash(str(e) or "Ongeldige planning.", "error") + return redirect(url_for('skore.overzicht')) diff --git a/blueprints/smartschool_activiteit_bp.py b/blueprints/smartschool_activiteit_bp.py new file mode 100644 index 0000000..0d8d302 --- /dev/null +++ b/blueprints/smartschool_activiteit_bp.py @@ -0,0 +1,104 @@ +from flask import Blueprint, render_template, request, flash, jsonify, redirect, url_for, Response +from services.smartschool_api import SmartschoolService +from config import Config +from services.google_service import GoogleAdminService +from blueprints.auth import login_required +from rechten import recht_vereist +from services.coaccounts_service import CoAccountsService +from tenancy import huidige_school +# Definieer de blueprint +ss_bp = Blueprint('smartschool', __name__) + + + + +@ss_bp.route('/coaccount-overzicht', methods=['GET']) +@login_required +@recht_vereist('smartschool_activiteit') +def coaccount_overzicht(): + """Alle co-accounts van alle leerlingen (klas, leerling, laatste aanmelding). Standaard enkel wie nog nooit + aanmeldde. De gegevens worden op de achtergrond opgehaald als ze ouder zijn dan een dag.""" + svc = CoAccountsService() + rows, fout, laatst = [], None, None + ingesteld = huidige_school().heeft('SS_API_PLATFORM', 'SS_API_PW') + try: + svc.ensure_tables() + if ingesteld and svc.verouderd(): + svc.sync_op_achtergrond() + rows, laatst = svc.alle(), svc.laatst_opgehaald() + except Exception as e: + fout = f"Gegevens konden niet gelezen worden: {e}" + return render_template('smartschool_activiteit.html', rows=rows, fout=fout, laatst=laatst, + ingesteld=ingesteld, status=svc.status, + nooit=sum(1 for r in rows if not r['Laatste_login']), + leerlingen=len({r['Internnummer'] for r in rows})) + + +@ss_bp.route('/coaccount-overzicht/ophalen', methods=['POST']) +@login_required +@recht_vereist('smartschool_activiteit') +def coaccount_ophalen(): + if not huidige_school().heeft('SS_API_PLATFORM', 'SS_API_PW'): + flash("Smartschool is nog niet ingesteld voor deze school (🔌 Koppelingen › Smartschool).", "error") + elif CoAccountsService().sync_op_achtergrond(): + flash("Co-accounts worden opgehaald uit Smartschool. Dit kan enkele minuten duren.", "info") + else: + flash("Er loopt al een ophaling.", "warning") + return redirect(url_for('smartschool.coaccount_overzicht')) + + +@ss_bp.route('/coaccount-overzicht/status.json', methods=['GET']) +@login_required +@recht_vereist('smartschool_activiteit') +def coaccount_status(): + s = CoAccountsService().status + return jsonify(bezig=s['bezig'], voortgang=s['voortgang'], totaal=s['totaal'], fout=s['fout'], + resultaat=s['resultaat']) + + +@ss_bp.route('/coaccount-overzicht/export.csv', methods=['GET']) +@login_required +@recht_vereist('smartschool_activiteit') +def coaccount_export(): + """CSV met dezelfde filters als de tabel: ?nooit=1 (enkel nog nooit aangemeld) en ?zoek=...""" + import csv, io + from datetime import date + rows = CoAccountsService().alle() + if request.args.get('nooit') == '1': + rows = [r for r in rows if not r['Laatste_login']] + zoek = (request.args.get('zoek') or '').strip().lower() + if zoek: + velden = ('Klas', 'Leerling_voornaam', 'Leerling_naam', 'Gebruikersnaam', 'Internnummer', 'Voornaam', 'Naam', 'Email') + rows = [r for r in rows if zoek in ' '.join(str(r[v] or '') for v in velden).lower()] + buf = io.StringIO() + w = csv.writer(buf, delimiter=';') # ; zodat Excel (Belgische instellingen) de kolommen meteen splitst + w.writerow(['Klas', 'Leerling naam', 'Leerling voornaam', 'Internnummer', 'Co-account voornaam', 'Co-account naam', + 'Type', 'E-mail', 'Laatste aanmelding']) + for r in rows: + w.writerow([r['Klas'] or '', r['Leerling_naam'] or '', r['Leerling_voornaam'] or '', r['Internnummer'], + r['Voornaam'] or '', r['Naam'] or '', r['Type'] or '', r['Email'] or '', + r['Laatste_login'].strftime('%d-%m-%Y %H:%M') if r['Laatste_login'] else 'Nooit']) + naam = f"co-accounts{'_nooit_aangemeld' if request.args.get('nooit') == '1' else ''}_{date.today():%Y%m%d}.csv" + return Response('\ufeff' + buf.getvalue(), mimetype='text/csv; charset=utf-8', + headers={'Content-Disposition': f'attachment; filename={naam}'}) + + +@ss_bp.route('/send-reminder-email', methods=['POST']) +@login_required +@recht_vereist('smartschool_activiteit') +def send_reminder(): + data = request.json + email = data.get('email') + subject = data.get('subject') + message_body = data.get('message') + + if not email or "@" not in email: + return jsonify({"status": "error", "message": "Geen geldig e-mailadres gevonden voor dit co-account."}), 400 + + email_service = GoogleAdminService() + success, msg = email_service.send_custom_mail(email, subject, message_body) + + if success: + return jsonify({"status": "success", "message": msg}) + else: + return jsonify({"status": "error", "message": f"Fout bij verzenden: {msg}"}), 500 \ No newline at end of file diff --git a/blueprints/smartschool_bp.py b/blueprints/smartschool_bp.py new file mode 100644 index 0000000..57ff223 --- /dev/null +++ b/blueprints/smartschool_bp.py @@ -0,0 +1,108 @@ +from flask import Blueprint, render_template, request, jsonify, make_response, flash +from io import BytesIO # <-- Deze regel lost de NameError op +from xhtml2pdf import pisa +from datetime import datetime +from services.smartschool_api import SmartschoolService +from config import Config +from database import execute_query +from tenancy import per_school +from blueprints.auth import login_required +from rechten import recht_vereist + +smartschool_bp = Blueprint("/ss",__name__) + +# Eén Smartschool-client per school (met het webservice-wachtwoord van die school) +ss_service = per_school(lambda: SmartschoolService( + platform=Config.SS_API_PLATFORM, + webservice_pwd=Config.SS_API_PW +)) + +@smartschool_bp.route('/smartschool', methods=['GET', 'POST']) +@login_required +@recht_vereist('smartschool') +def smartschool(): + results = [] + query = (request.form.get('query') or '').strip() + if request.method == 'POST' and query: + # 1) Exact internnummer of gebruikersnaam: rechtstreeks bij Smartschool + try: + user_data = ss_service.get_student_details(query) + except Exception as e: + user_data = None + flash(f"Smartschool is niet bereikbaar of nog niet ingesteld voor deze school ({e}).", "error") + if user_data: + results = [user_data] + else: + # 2) Deel van een naam, voornaam of gebruikersnaam: zoeken in de leerlingen van PIVOT + results = zoek_leerlingen_lokaal(query) + return render_template('smartschool_beheer.html', results=results, query=query) + + +MAX_RESULTATEN = 25 + + +def zoek_leerlingen_lokaal(query): + """Leerlingen uit de tabel Leerlingen (gevuld door de Informat-sync) die op de zoekterm lijken. + Geeft dicts met dezelfde sleutels als Smartschool (internnummer, voornaam, naam, gebruikersnaam).""" + zoek = f"%{query}%" + rows = execute_query( + "SELECT Internnummer, Voornaam, Naam, Gebruikersnaam FROM Leerlingen " + "WHERE Internnummer = %s OR Gebruikersnaam LIKE %s OR Voornaam LIKE %s OR Naam LIKE %s " + " OR CONCAT_WS(' ', Voornaam, Naam) LIKE %s OR CONCAT_WS(' ', Naam, Voornaam) LIKE %s " + "ORDER BY Naam, Voornaam LIMIT %s", + (query, zoek, zoek, zoek, zoek, zoek, MAX_RESULTATEN)) + return [{'internnummer': r['Internnummer'], 'voornaam': r['Voornaam'] or '', 'naam': r['Naam'] or '', + 'gebruikersnaam': r['Gebruikersnaam'] or ''} for r in rows] + +@smartschool_bp.route('/smartschool/reset', methods=['POST']) +@login_required +@recht_vereist('smartschool') +def smartschool_reset(): + identifier = request.form.get('username') + new_pass = request.form.get('new_password') + acc_type = int(request.form.get('account_type', 0)) + generate_pdf = request.form.get('generate_pdf') == 'true' + display_name = request.form.get('display_name') # Ontvang de naam uit de browser + + result = ss_service.reset_password(identifier, new_pass, acc_type) + + if result == "0": + if generate_pdf: + # We gebruiken de display_name die we van de frontend hebben gekregen + # Voor de e-mail label kunnen we nog steeds student details ophalen indien nodig + student_data = ss_service.get_student_details(identifier) + + email_label = student_data.get('gebruikersnaam') if acc_type == 0 else f"Co-account van {student_data['voornaam']}" + + rendered_html = render_template( + 'pdf_template_leerling.html', + naam=display_name, + datum=datetime.now().strftime("%d/%m/%Y"), + email=email_label, + wachtwoord=new_pass + ) + + pdf_out = BytesIO() + pisa_status = pisa.CreatePDF(rendered_html, dest=pdf_out) + + if pisa_status.err: + return jsonify({"status": "error", "message": "PDF generatie mislukt"}) + + pdf_out.seek(0) + response = make_response(pdf_out.read()) + response.headers['Content-Type'] = 'application/pdf' + response.headers['Content-Disposition'] = f'attachment; filename=Account_{identifier}.pdf' + return response + + return jsonify({"status": "success"}) + + return jsonify({"status": "error", "message": f"Code {result}"}) + +@smartschool_bp.route('/smartschool/coaccounts', methods=['POST']) +@login_required +@recht_vereist('smartschool') +def smartschool_coaccounts(): + username = request.form.get('username') + # Gebruik de nieuwe parser functie + co_accounts = ss_service.get_co_accounts_from_details(username) + return jsonify({"status": "success", "accounts": co_accounts}) \ No newline at end of file diff --git a/blueprints/students.py b/blueprints/students.py new file mode 100644 index 0000000..dfd5cc4 --- /dev/null +++ b/blueprints/students.py @@ -0,0 +1,190 @@ +from flask import Blueprint, render_template, request, session, redirect, url_for, flash +from database import execute_query, get_db_connection +from blueprints.auth import login_required +from rechten import recht_vereist +from services.google_service import GoogleAdminService +from services.sync_service import SyncService, InformatSmartschoolSync +from config import Config +students_bp = Blueprint('students', __name__) + +#----------------------------------------------- +# --- ROUTE: INVENTARIS LEERLINGEN --- +#----------------------------------------------- +@students_bp.route('/inventaris', methods=["GET", "POST"]) +@login_required +@recht_vereist('leerlingen_bekijken') +def inventaris_leerlingen(): + query = request.form.get('query') if request.method == 'POST' else request.args.get('query') + toon_vertrokken = (request.form.get('vertrokken') if request.method == 'POST' else request.args.get('vertrokken')) == '1' + from services.sync_service import zorg_vertrokken_kolom + zorg_vertrokken_kolom() + + # De basis-query waarin we de tabellen 'Leerlingen' (l) en 'Schooljaar' (s) aan elkaar koppelen + basis_sql = """ + SELECT + l.Internnummer, + l.Gebruikersnaam, + l.Voornaam, + l.Naam, + l.SS_nummer, + l.Google_adres, + l.Vertrokken_op, + s.Schooljaar AS Schooljaar -- Hier halen we de tekst (bijv. '2025-2026') op! + FROM Leerlingen l + LEFT JOIN Schooljaar s ON l.Schooljaar_ID = s.Schooljaar_ID + """ + + voorwaarden, params = [], [] + if query: + voorwaarden.append("(l.Gebruikersnaam LIKE %s OR l.Internnummer LIKE %s OR l.Naam LIKE %s OR l.Voornaam LIKE %s)") + params += [f"%{query}%"] * 4 + if not toon_vertrokken: + voorwaarden.append("l.Vertrokken_op IS NULL") + sql = basis_sql + (" WHERE " + " AND ".join(voorwaarden) if voorwaarden else "") + data = execute_query(sql, tuple(params)) + vertrokken = (execute_query("SELECT COUNT(*) AS n FROM Leerlingen WHERE Vertrokken_op IS NOT NULL") or [{'n': 0}])[0]['n'] + + return render_template('Inventaris_Leerlingen.html', results=data, query=query, + toon_vertrokken=toon_vertrokken, aantal_vertrokken=vertrokken) + +#---------------------------------------- +# --- ROUTE: BEWERK GEBRUIKER --- +#---------------------------------------- +@students_bp.route("/bewerk_gebruiker", methods=["POST"]) +@login_required +@recht_vereist('leerlingen_bekijken') +def bewerk_gebruiker(): + print("bewerken wordt uitgevoerd") + internnummer = request.form.get("internnummer") + gebruikersnaam = request.form.get("gebruikersnaam") + ss_nummer = request.form.get("ss_nummer") + google_adres = request.form.get('google_adres') + naam= request.form.get('naam') + voornaam= request.form.get('voornaam') + query = request.form.get("query", "") + + if not internnummer: + flash("Fout: Geen internnummer ontvangen.", "error") + return redirect(url_for("students.inventaris_leerlingen")) + + sql = "UPDATE Leerlingen SET Gebruikersnaam = %s, SS_nummer = %s, Google_adres = %s, Naam = %s, Voornaam= %s WHERE Internnummer = %s" + rows_affected = execute_query(sql, (gebruikersnaam, ss_nummer, google_adres, naam, voornaam, internnummer ), commit=True) + + if rows_affected: + flash(f"Gegevens voor {internnummer} succesvol bijgewerkt!", "success") + else: + flash("Er zijn geen wijzigingen opgeslagen.", "info") + + return redirect(url_for("students.inventaris_leerlingen", query=query)) + +#---------------------------------------------------- +# --- ROUTE: SYNC GOOGLE ACCOUNTS --- +#---------------------------------------------------- +@students_bp.route("/sync_google_accounts", methods=["GET", "POST"]) +@login_required +@recht_vereist('leerlingen_synchroniseren') +def sync_google_accounts(): + """Maakt Google accounts aan voor leerlingen die er nog geen hebben.""" + google = GoogleAdminService() + + # Haal leerlingen op die nog geen email (SS_nummer) hebben + lln_zonder_mail = execute_query("SELECT Internnummer, Gebruikersnaam FROM Leerlingen WHERE SS_nummer IS NULL OR SS_nummer = ''") + + created_count = 0 + error_count = 0 + + for lln in lln_zonder_mail: + gebruikersnaam_db = lln['Gebruikersnaam'] + # Splits naam (bijv: "Janssens.Jan") + if "." in gebruikersnaam_db: + achternaam, voornaam = gebruikersnaam_db.split(".", 1) + else: + achternaam, voornaam = gebruikersnaam_db, "lln" + + # Genereer wachtwoord (bijv: 12345Jan) + password = f"{lln['Internnummer']}{voornaam.capitalize()}" + + # Gebruik je service om de gebruiker aan te maken + success, message = google.create_user( + first_name=voornaam, + last_name=achternaam, + password=password, + org_unit_path=google.conf.OU_NEW_STUDENTS + ) + + if success: + # Update database met het nieuwe emailadres + email = f"{voornaam.lower()}.{achternaam.lower()}@{google.conf.SCHOOL_DOMAIN}" + execute_query("UPDATE Leerlingen SET SS_nummer = %s WHERE Internnummer = %s", + (email, lln['Internnummer']), commit=True) + created_count += 1 + else: + print(f"Fout voor {gebruikersnaam_db}: {message}") + error_count += 1 + + flash(f"Sync voltooid: {created_count} accounts aangemaakt, {error_count} fouten.", "success") + return redirect(url_for("students.inventaris_leerlingen")) + +#------------------------------------------------------------------- +# --- ROUTE: SYNC INFORMAT (Gebruikmakend van SyncService) --- +#------------------------------------------------------------------- +@students_bp.route("/sync_informat", methods=["GET", "POST"]) +@login_required +@recht_vereist('leerlingen_synchroniseren') +def sync_informat(): + """Haalt de nieuwste leerlinggegevens op uit Informat.""" + #syncer = SyncService() + syncer = InformatSmartschoolSync() + #success, result = syncer.sync_leerlingen_informat() + success, result = syncer.sync_actieve_leerlingen() + + if success: + flash(f"Synchronisatie gelukt: {result['melding']}", "success") + if result.get('smartschool_fout'): + flash("Smartschool kon niet bevraagd worden, dus gebruikersnamen en Smartschool-nummers zijn niet " + f"aangepast (enkel de gegevens uit Informat). Reden: {result['smartschool_fout']}", "warning") + else: + flash(f"Synchronisatie mislukt: {result}", "error") + + return redirect(url_for("students.inventaris_leerlingen")) + +#------------------------------------------------------------------- +# --- Leerling manueel toevoegen +#------------------------------------------------------------------- + +@students_bp.route('/registreer_leerling', methods=['POST']) +@login_required +@recht_vereist('leerlingen_synchroniseren') +def registreer_leerling(): + print('Toevoegen gestart') + # 1. Haal de gegevens uit het formulier op + internnummer = request.form.get('inter_id') + gebruikersnaam = request.form.get('gebruikersnaam') + ss_nummer = request.form.get('ss_nummer') + google_adres = request.form.get('google_adres') + schooljaar_id= Config.SCHOOLYEAR[:4] + naam= request.form.get('naam') + voornaam= request.form.get('voornaam') + + # 2. Validatie + if not internnummer or not gebruikersnaam: + flash('Zowel Internnummer als Gebruikersnaam zijn vereist.', 'danger') + return redirect(url_for('students.inventaris_leerlingen')) + + # 3. Opslaan in de database + try: + # We gebruiken geparametriseerde queries (%s) om SQL-injectie te voorkomen + sql = "INSERT INTO Leerlingen (Internnummer, Gebruikersnaam, Naam, Voornaam, SS_nummer, Google_adres, Schooljaar_ID) " \ + "VALUES (%s, %s, %s, %s, %s, %s, %s)" + params = (internnummer, gebruikersnaam,naam, voornaam, ss_nummer,google_adres, schooljaar_id) + + execute_query(sql, params, commit=True) + + flash(f'Leerling {gebruikersnaam} (ID: {internnummer}) succesvol toegevoegd aan de database!', 'success') + except Exception as e: + # Als bijvoorbeeld het internnummer al bestaat (Duplicate Entry) + print(f"Database error: {e}") + flash('Fout bij het registreren: Mogelijk bestaat dit Internnummer al.', 'danger') + + # 4. Keer terug naar het overzicht + return redirect(url_for('students.inventaris_leerlingen')) \ No newline at end of file diff --git a/blueprints/tools.py b/blueprints/tools.py new file mode 100644 index 0000000..913cf2f --- /dev/null +++ b/blueprints/tools.py @@ -0,0 +1,44 @@ +from flask import Blueprint, render_template, request, session, redirect, url_for, flash +from blueprints.auth import login_required +from tenancy import huidige_school + +tools_bp = Blueprint('tools', __name__) + + +def _staat(): + """Internetstatus en notitie: per school bijgehouden (in het geheugen).""" + return huidige_school().object('internet_status', lambda: {'status': 1, 'notitie': ''}) + + +@tools_bp.route('/internet') +def internet_status(): + """Toont de huidige status van de internetverbinding.""" + staat = _staat() + return render_template( + 'internet_connection.html', + current_status=staat['status'], + current_note=staat['notitie'], + current_user=session.get('user') + ) + + +@tools_bp.route('/toggle_internet', methods=['POST']) +def toggle_internet(): + """Wijzigt de status van de internetverbinding.""" + new_status = request.form.get('new_status', type=int) + + if new_status in [0, 1]: + _staat()['status'] = new_status + flash(f"Status gewijzigd naar {'Online' if new_status == 1 else 'Offline'}", "success") + else: + flash("Ongeldige statuswaarde.", "error") + + return redirect(url_for('tools.internet_status')) + + +@tools_bp.route('/save_note', methods=['POST']) +def save_note(): + """Slaat de korte tekst op.""" + _staat()['notitie'] = request.form.get('status_note', '') + flash("Notitie bijgewerkt!", "success") + return redirect(url_for('tools.internet_status')) diff --git a/blueprints/tweestaps_bp.py b/blueprints/tweestaps_bp.py new file mode 100644 index 0000000..d9c4a38 --- /dev/null +++ b/blueprints/tweestaps_bp.py @@ -0,0 +1,177 @@ +from datetime import datetime +from flask import Blueprint, render_template, request, redirect, url_for, flash, session +from tenancy import huidige_school, per_school +from blueprints.auth import login_required +from rechten import recht_vereist +from config import Config +from services.tweestaps_service import TweestapsService, TweestapsError + +tweestaps_bp = Blueprint('tweestaps', __name__) +LOG_PER_PAGINA = 25 # standaard; te kiezen: 10 / 25 / 50 / 100 / alles (0) +LOG_KEUZES = (10, 25, 50, 100, 0) +service = per_school(TweestapsService) + +_tables_ready = set() # scholen waarvoor de tabellen al gecontroleerd zijn + + +def _ensure_tables(): + if huidige_school().code not in _tables_ready: + try: + service.ensure_tables() + _tables_ready.add(huidige_school().code) + except Exception as e: + flash(f"Kon de 2FA-tabellen niet aanmaken: {e}", "error") + + +def _render(email=None, codes=None, code_actie=None, fout=None): + """Bouwt de pagina op; codes worden enkel in deze response getoond, nooit bewaard of gelogd.""" + tab = request.args.get('tab', 'hulp') + zoek = (request.args.get('zoek') or '').strip() + email = email or (request.args.get('email') or '').strip() + gebruiker, uitzondering, zoekresultaten, reset_wacht = None, None, [], None + + if zoek and '@' not in zoek: + zoekresultaten = service.search_users(zoek) + if not zoekresultaten: + fout = fout or f"Geen gebruikers gevonden voor '{zoek}'." + elif zoek: + email = zoek + + if email: + try: + gebruiker = service.get_user(email) + uitzondering = service.active_for(gebruiker['email']) + reset_wacht = service.reset_bezig(gebruiker['email']) + except TweestapsError as e: + fout = fout or str(e) + + try: + actief = service.active_exceptions() + # Aantal per pagina: uit ?logper=..., anders de vorige keuze (sessie), anders 25 + per = request.args.get('logper', type=int) + if per in LOG_KEUZES: + session['tweestaps_logper'] = per + per = session.get('tweestaps_logper', LOG_PER_PAGINA) + grootte = per or 100000 # 0 = alles + log_pagina = max(1, request.args.get('logpagina', 1, type=int) or 1) + logboek, log_totaal = service.recent_log(log_pagina, grootte) + log_paginas = max(1, -(-log_totaal // grootte)) + if log_pagina > log_paginas: + log_pagina = log_paginas + logboek, log_totaal = service.recent_log(log_pagina, grootte) + except Exception: + actief, logboek, log_totaal, log_pagina, log_paginas, per = [], [], 0, 1, 1, LOG_PER_PAGINA + + lijst, lijst_fout = None, None + if tab == 'lijst': + try: + lijst = service.niet_ingesteld(force=request.args.get('vernieuw') == '1') + lijst_fout = service.ou_melding() # sommige OU's bestaan niet: lijst tonen + waarschuwing + except TweestapsError as e: + lijst_fout = str(e) + except Exception as e: + lijst_fout = TweestapsService._friendly(e) + + return render_template('tweestaps.html', tab=tab, zoek=zoek, zoekresultaten=zoekresultaten, + gebruiker=gebruiker, uitzondering=uitzondering, reset_wacht=reset_wacht, codes=codes, code_actie=code_actie, + fout=fout, actief=actief, logboek=logboek, log_totaal=log_totaal, log_pagina=log_pagina, log_per=per, log_keuzes=LOG_KEUZES, + log_paginas=log_paginas, lijst=lijst, lijst_fout=lijst_fout, + einde=service.einde_vandaag(), groep=service.exception_group, + ous=Config.TWEESTAPS_OUS, nu=datetime.now()) + + +@tweestaps_bp.route('/', methods=['GET']) +@login_required +@recht_vereist('tweestaps_codes', 'tweestaps_uitzetten') +def overzicht(): + _ensure_tables() + return _render() + + +#--------------------------------------------- +# Back-upcodes +#--------------------------------------------- +@tweestaps_bp.route('/codes', methods=['POST']) +@login_required +@recht_vereist('tweestaps_codes') +def codes(): + _ensure_tables() + email = request.form.get('email', '') + actie = request.form.get('actie', 'tonen') + door = session.get('user') + try: + user = service.get_user(email) + if actie == 'ongeldig': + service.invalidate_codes(user['email']) + service.log(door, 'codes ongeldig gemaakt', user['email']) + flash(f"Alle back-upcodes van {user['email']} zijn ongeldig gemaakt.", "success") + return redirect(url_for('tweestaps.overzicht', email=user['email'])) + + bestaand = [] if actie == 'nieuw' else service.list_codes(user['email']) + if bestaand: + lijst = bestaand + service.log(door, 'code getoond', user['email']) + else: + lijst = service.generate_codes(user['email']) + service.log(door, 'nieuwe codes', user['email'], f"{len(lijst)} codes") + return _render(email=user['email'], codes=lijst, code_actie='nieuw' if not bestaand else 'bestaand') + except TweestapsError as e: + return _render(email=email, fout=str(e)) + + +#--------------------------------------------- +# Tijdelijke uitzondering +#--------------------------------------------- +@tweestaps_bp.route('/uitzondering', methods=['POST']) +@login_required +@recht_vereist('tweestaps_uitzetten') +def uitzondering(): + _ensure_tables() + email = request.form.get('email', '') + reden = (request.form.get('reden') or '').strip() + eindtijd = service.einde_vandaag() + try: + user = service.grant_exception(email, eindtijd, session.get('user'), reden) + flash(f"{user['email']} kan tot {eindtijd:%H:%M} zonder 2FA inloggen. Daarna zet PIVOT 2FA automatisch " + "terug aan. Let op: het kan enkele minuten duren voor Google de uitzondering toepast.", "success") + except TweestapsError as e: + flash(str(e), "error") + return redirect(url_for('tweestaps.overzicht', email=email.strip())) + + +@tweestaps_bp.route('/reset', methods=['POST']) +@login_required +@recht_vereist('tweestaps_uitzetten') +def reset(): + """2FA resetten voor iemand met een nieuwe of verloren telefoon.""" + _ensure_tables() + email = request.form.get('email', '') + eindtijd = service.einde_vandaag() + try: + user = service.reset_2fa(email, eindtijd, session.get('user'), (request.form.get('reden') or '').strip()) + if user.get('wacht'): + flash(f"{user['email']} staat nu in de uitzonderingsgroep, maar Google past dat pas na enkele minuten toe. " + "PIVOT probeert de 2FA-reset elke minuut opnieuw (tot de uitzondering afloopt om " + f"{eindtijd:%H:%M}). Je hoeft niets meer te doen; vernieuw deze pagina om de status te zien.", "info") + return redirect(url_for('tweestaps.overzicht', email=email.strip())) + tekst = f"2FA is uitgezet voor {user['email']}. Hij kan nu inloggen en 2FA opnieuw instellen met zijn nieuwe telefoon" + if user.get('uitzondering'): + tekst += f" (vóór {eindtijd:%H:%M}; daarna is 2FA weer verplicht)" + flash(tekst + ". Het kan enkele minuten duren voor Google dit toepast.", "success") + except TweestapsError as e: + flash(str(e), "error") + return redirect(url_for('tweestaps.overzicht', email=email.strip())) + + +@tweestaps_bp.route('/uitzondering//stop', methods=['POST']) +@login_required +@recht_vereist('tweestaps_uitzetten') +def stop_uitzondering(uitz_id): + _ensure_tables() + try: + email = service.end_exception(uitz_id, session.get('user')) + flash(f"2FA is opnieuw verplicht voor {email}.", "success") + except TweestapsError as e: + flash(str(e), "error") + terug = request.form.get('terug') + return redirect(url_for('tweestaps.overzicht', email=terug) if terug else url_for('tweestaps.overzicht')) diff --git a/blueprints/verzendlijsten_bp.py b/blueprints/verzendlijsten_bp.py new file mode 100644 index 0000000..68c3826 --- /dev/null +++ b/blueprints/verzendlijsten_bp.py @@ -0,0 +1,124 @@ +"""Google › Verzendlijsten: Google Groepen van de school bekijken, aanmaken en aanpassen (leden en rollen).""" +from flask import Blueprint, render_template, request, flash, redirect, url_for, session + +from blueprints.auth import login_required +from rechten import recht_vereist, log +from services.verzendlijsten_service import (VerzendlijstenService, VerzendlijstFout, ROLLEN, WIE_MAILT, adressen) +from tenancy import huidige_school + +verzendlijsten_bp = Blueprint('verzendlijsten', __name__) + + +@verzendlijsten_bp.route("/", methods=["GET"]) +@login_required +@recht_vereist('verzendlijsten') +def overzicht(): + lijsten, fout = [], None + try: + lijsten = VerzendlijstenService().alle() + except Exception as e: + fout = str(e) + return render_template("verzendlijsten.html", lijsten=lijsten, fout=fout, + domeinen=huidige_school().domeinen, wie_mailt=WIE_MAILT) + + +@verzendlijsten_bp.route("/nieuw", methods=["POST"]) +@login_required +@recht_vereist('verzendlijsten') +def nieuw(): + deel = (request.form.get('adres') or '').strip().lower() + domein = (request.form.get('domein') or '').strip().lower() + email = deel if '@' in deel else f"{deel}@{domein}" + svc = VerzendlijstenService() + try: + svc.aanmaken(email, request.form.get('naam'), request.form.get('beschrijving')) + except VerzendlijstFout as e: + flash(str(e), "error") + return redirect(url_for('verzendlijsten.overzicht')) + log(f"Verzendlijst aangemaakt: {email}") + melding = f"Verzendlijst {email} aangemaakt." + wie = request.form.get('wie_mailt') + if wie: + try: + svc.zet_wie_mailt(email, wie) + except VerzendlijstFout as e: + melding += f" 'Wie mag mailen' kon niet ingesteld worden: {e}" + flash(melding + " Google kan er een minuutje over doen voor ze overal zichtbaar is.", "success") + return redirect(url_for('verzendlijsten.detail', email=email)) + + +@verzendlijsten_bp.route("/", methods=["GET"]) +@login_required +@recht_vereist('verzendlijsten') +def detail(email): + svc = VerzendlijstenService() + try: + groep = svc.groep(email) + leden = svc.leden(email) + except VerzendlijstFout as e: + flash(str(e), "error") + return redirect(url_for('verzendlijsten.overzicht')) + return render_template("verzendlijst.html", groep=groep, leden=leden, rollen=ROLLEN, + wie_mailt=WIE_MAILT, huidig_wie=svc.wie_mailt(email)) + + +@verzendlijsten_bp.route("//bewerken", methods=["POST"]) +@login_required +@recht_vereist('verzendlijsten') +def bewerken(email): + svc = VerzendlijstenService() + try: + svc.bijwerken(email, request.form.get('naam'), request.form.get('beschrijving')) + if request.form.get('wie_mailt'): + svc.zet_wie_mailt(email, request.form.get('wie_mailt')) + log(f"Verzendlijst aangepast: {email}") + flash("Verzendlijst bijgewerkt.", "success") + except VerzendlijstFout as e: + flash(str(e), "error") + return redirect(url_for('verzendlijsten.detail', email=email)) + + +@verzendlijsten_bp.route("//leden/toevoegen", methods=["POST"]) +@login_required +@recht_vereist('verzendlijsten') +def leden_toevoegen(email): + goed, ongeldig = adressen(request.form.get('adressen')) + if ongeldig: + flash("Geen geldig e-mailadres, overgeslagen: " + ", ".join(ongeldig), "warning") + if goed: + gelukt, fouten = VerzendlijstenService().leden_toevoegen(email, goed, request.form.get('rol') or 'MEMBER') + if gelukt: + log(f"Verzendlijst {email}: {gelukt} lid/leden toegevoegd") + flash(f"{gelukt} lid/leden toegevoegd.", "success") + for adres, fout in fouten: + flash(f"{adres}: {fout}", "error") + elif not ongeldig: + flash("Vul minstens één e-mailadres in.", "error") + return redirect(url_for('verzendlijsten.detail', email=email)) + + +@verzendlijsten_bp.route("//leden/rol", methods=["POST"]) +@login_required +@recht_vereist('verzendlijsten') +def lid_rol(email): + lid = request.form.get('lid') or '' + try: + VerzendlijstenService().lid_rol(email, lid, request.form.get('rol')) + flash(f"Rol van {lid} aangepast.", "success") + except VerzendlijstFout as e: + flash(str(e), "error") + return redirect(url_for('verzendlijsten.detail', email=email)) + + +@verzendlijsten_bp.route("//leden/verwijderen", methods=["POST"]) +@login_required +@recht_vereist('verzendlijsten') +def lid_verwijderen(email): + lid = request.form.get('lid') or '' + try: + VerzendlijstenService().lid_verwijderen(email, lid) + log(f"Verzendlijst {email}: {lid} verwijderd") + flash(f"{lid} uit de verzendlijst gehaald.", "success") + except VerzendlijstFout as e: + flash(str(e), "error") + return redirect(url_for('verzendlijsten.detail', email=email)) diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..fabf3de --- /dev/null +++ b/compose.yaml @@ -0,0 +1,27 @@ +# PIVOT – één container voor alle scholen. +# +# Vóór de eerste start: +# 1. config.ini invullen (database-server, superbeheerders, gedeelde Google-login) +# 2. .env aanmaken naast dit bestand met minstens: +# PIVOT_DB_PASSWORD=... +# PIVOT_SLEUTEL=... (python beheer.py nieuwe-sleutel) ← BACK-UP NEMEN +# FLASK_SECRET_KEY=... (willekeurige lange tekst) +# 3. docker compose up -d --build +# 4. bestaande school overzetten: +# docker compose exec server python beheer.py importeer-config /app/import/config.ini \ +# --google-sleutel /app/import/kamxm_api_key.json +services: + server: + build: + context: . + ports: + - 5001:5001 + env_file: + - .env + environment: + - PIVOT_GEHEIMEN=/app/geheimen + volumes: + - ./config.ini:/app/config.ini:ro + - ./geheimen:/app/geheimen + - ./import:/app/import:ro + restart: unless-stopped diff --git a/config.ini b/config.ini new file mode 100644 index 0000000..ab345e5 --- /dev/null +++ b/config.ini @@ -0,0 +1,42 @@ +# ===================================================================== +# PIVOT – algemene instellingen (gelden voor ALLE scholen) +# +# Alles wat per school verschilt (Smartschool, Informat, Google, Signpost, +# CO2, personeel, database van de school, ...) staat NIET meer hier, maar +# in het scholenregister: in PIVOT via 🏫 Scholen (superbeheerder) of +# 🔌 Koppelingen van deze school (beheerder van een school). +# +# Geheimen kan je ook als omgevingsvariabele meegeven i.p.v. hier: +# PIVOT_DB_PASSWORD, PIVOT_SSO_CLIENT_SECRET, FLASK_SECRET_KEY, PIVOT_SLEUTEL +# ===================================================================== + +[database] +# Server en standaardlogin: voor de centrale database én voor elke schooldatabase +# (per school te overschrijven in het scholenregister) +host = localhost +port = 3306 +user = marijn +password =Binnenin10! +# Database met de lijst van scholen (wordt automatisch ingericht) +centrale_database = pivot + +[google_sso] +# Gedeelde 'Inloggen met Google' voor alle scholen (OAuth-client van het type +# 'Webtoepassing', toestemmingsscherm 'Extern'). Leeg laten als elke school een +# eigen client gebruikt (in te stellen per school). +# Toegestane redirect-URI: https:///callback +client_id = +client_secret = + +[algemeen] +app_version = 1.1.0 +# Superbeheerders van de scholengroep (Google-accounts, komma-gescheiden): +# mogen scholen toevoegen/wijzigen en tussen scholen wisselen. +superbeheerders = ict@kamerksem.be +# Geheime sleutel voor sessies (liever via FLASK_SECRET_KEY) +secret_key = +debug = nee +# Elke bezochte pagina bewaren in de tabel Logs (ja/nee) +logboek = ja +# Aantal reverse proxies vóór PIVOT (nginx, IIS, firewall met https). 0 = PIVOT staat rechtstreeks op het net. +proxy_aantal = 0 diff --git a/config.py b/config.py new file mode 100644 index 0000000..8c8fb05 --- /dev/null +++ b/config.py @@ -0,0 +1,107 @@ +""" +Instellingen. + +config.ini bevat enkel nog wat voor ALLE scholen geldt (database-server, centrale +database, gedeelde Google-login, superbeheerders). Alles wat per school verschilt +staat in de centrale database (zie tenancy.py) en wordt beheerd via /scholen. + +`Config.SS_API_PW`, `Config.SCHOOL_DOMAIN`, ... geven de waarde van de school +waarvoor de huidige request (of achtergrondtaak) loopt. + +Geheimen kunnen ook via omgevingsvariabelen gezet worden (handig voor Docker): + PIVOT_DB_PASSWORD, FLASK_SECRET_KEY, PIVOT_SLEUTEL, PIVOT_SSO_CLIENT_SECRET +""" +import os +import configparser + +base_dir = os.path.dirname(os.path.abspath(__file__)) +config_path = os.environ.get('PIVOT_CONFIG', os.path.join(base_dir, 'config.ini')) + +config_file = configparser.ConfigParser() +config_file.read(config_path, encoding='utf-8') + + +def _get(section, key, fallback='', env=None): + if env and os.environ.get(env): + return os.environ[env] + return config_file.get(section, key, fallback=fallback) + + +def _secret_key(): + """FLASK_SECRET_KEY, config.ini, of anders een willekeurige sleutel die bewaard wordt in geheimen/flask.key. + (Nooit een vaste standaardsleutel: daarmee kan iemand een sessie voor eender welke school vervalsen.)""" + sleutel = os.environ.get("FLASK_SECRET_KEY") or _get('algemeen', 'secret_key') + if sleutel: + return sleutel + import secrets + pad = os.path.join(os.environ.get('PIVOT_GEHEIMEN', os.path.join(base_dir, 'geheimen')), 'flask.key') + if os.path.exists(pad): + with open(pad) as f: + return f.read().strip() + os.makedirs(os.path.dirname(pad), exist_ok=True) + sleutel = secrets.token_hex(32) + with open(pad, 'w') as f: + f.write(sleutel) + return sleutel + + +class _SchoolConfigMeta(type): + """Onbekende attributen op de klasse → instelling van de huidige school.""" + def __getattr__(cls, naam): + if naam.startswith('__'): + raise AttributeError(naam) + if naam == 'GOOGLE_API_LOCATION': + raise AttributeError("GOOGLE_API_LOCATION bestaat niet meer: gebruik tenancy.google_credentials()") + from tenancy import huidige_school, VELD, AFGELEID + if naam not in VELD and naam not in AFGELEID: + raise AttributeError(naam) + return huidige_school().get(naam) + + +class Config(metaclass=_SchoolConfigMeta): + """Algemene instellingen (klasse-attributen) + per-school instellingen (via de metaclass).""" + + # Flask + SECRET_KEY = _secret_key() + DEBUG = _get('algemeen', 'debug', 'nee').strip().lower() in ('ja', 'yes', 'true', '1', 'on') + SESSION_COOKIE_PATH = '/' + SESSION_PERMANENT = True + + # Database-server: standaard voor alle schooldatabases + de centrale database + DB_HOST = _get('database', 'host', 'localhost', env='PIVOT_DB_HOST') + DB_PORT = int(_get('database', 'port', '3306', env='PIVOT_DB_PORT')) + DB_USER = _get('database', 'user', '', env='PIVOT_DB_USER') + DB_PASSWORD = _get('database', 'password', '', env='PIVOT_DB_PASSWORD') + DB_CENTRAAL = _get('database', 'centrale_database', 'pivot_centraal', env='PIVOT_DB_CENTRAAL') + # Verbindingen per database in de pool. Elke pool opent die meteen allemaal: aantal scholen x pool_size + # (+ centraal) moet onder max_connections van MySQL blijven (standaard 151). + DB_POOL_SIZE = max(1, min(32, int(_get('database', 'pool_size', '3', env='PIVOT_DB_POOL_SIZE') or 3))) + + # Gedeelde Google-login (één OAuth-client voor alle scholen; per school te overschrijven) + SSO_CLIENT_ID = _get('google_sso', 'client_id', env='PIVOT_SSO_CLIENT_ID') + SSO_CLIENT_SECRET = _get('google_sso', 'client_secret', env='PIVOT_SSO_CLIENT_SECRET') + + # Superbeheerders: mogen scholen beheren en tussen scholen wisselen (Google-login) + SUPERBEHEERDERS = [x.strip().lower() for x in + _get('algemeen', 'superbeheerders', env='PIVOT_SUPERBEHEERDERS').replace(';', ',').split(',') + if x.strip()] + + APP_VERSION = _get('algemeen', 'app_version', '1.1.0') + + # Elke bezochte pagina bewaren in de tabel Logs (centrale database) + LOGBOEK = _get('algemeen', 'logboek', 'ja').strip().lower() in ('ja', 'yes', 'true', '1', 'on') + # Aantal reverse proxies (nginx, IIS, firewall met https, ...) vóór PIVOT. 0 = PIVOT staat rechtstreeks op het net. + # Nodig om het echte IP-adres van de bezoeker te kennen (IP-blokkades, logboek, inlogbeveiliging). + try: + PROXY_AANTAL = max(0, int(_get('algemeen', 'proxy_aantal', '0') or 0)) + except ValueError: + PROXY_AANTAL = 0 + + def __getattr__(self, naam): + # Config().SS_API_PW werkt ook (zoals in de bestaande services) + return getattr(type(self), naam) + + @property + def DB_CONFIG(self): + from tenancy import huidige_school + return huidige_school().db_config() diff --git a/database.py b/database.py new file mode 100644 index 0000000..c600afa --- /dev/null +++ b/database.py @@ -0,0 +1,106 @@ +""" +Databaseverbindingen. + +- execute_query / get_db_connection → de database van de HUIDIGE school +- central_query / get_central_connection → de centrale database (Scholen, Gebruikers, rechten, Logs, Bugs) + +Per database wordt lui een connection pool aangemaakt. +""" +import threading + +import mysql.connector +from mysql.connector import pooling + +from config import Config + +_pools = {} +_lock = threading.Lock() +POOL_SIZE = Config.DB_POOL_SIZE # config.ini › [database] › pool_size (standaard 3) + + +def _pool(host, user, password, database, port=None): + key = (host, port or Config.DB_PORT, user, database) + pool = _pools.get(key) + if pool is None: + with _lock: + pool = _pools.get(key) + if pool is None: + naam = f"p{len(_pools)}_{database}"[:60] + pool = pooling.MySQLConnectionPool( + pool_name=naam, pool_size=POOL_SIZE, + host=host, port=port or Config.DB_PORT, user=user, password=password, database=database, + connection_timeout=10, + # zelfde collatie als de tabellen (sql/*.sql); anders geeft MySQL 8 bij vergelijkingen + # "Illegal mix of collations (utf8mb4_0900_ai_ci / utf8mb4_unicode_ci)" + charset='utf8mb4', collation='utf8mb4_unicode_ci', + # autocommit: elke query ziet meteen de laatste gegevens (ook na wijzigingen via een + # andere verbinding uit de pool). Wie meerdere statements samen wil, gebruikt + # conn.start_transaction() ... conn.commit(). + autocommit=True) + _pools[key] = pool + return pool + + +def vergeet_pools(database=None): + """Na het wijzigen van de DB-gegevens van een school: pools opnieuw laten opbouwen.""" + with _lock: + for k in [k for k in _pools if database is None or k[3] == database]: + _pools.pop(k, None) + + +def get_db_connection(): + """Verbinding met de database van de huidige school.""" + from tenancy import huidige_school + cfg = huidige_school().db_config() + return _pool(cfg['host'], cfg['user'], cfg['password'], cfg['database']).get_connection() + + +def get_central_connection(): + return _pool(Config.DB_HOST, Config.DB_USER, Config.DB_PASSWORD, Config.DB_CENTRAAL).get_connection() + + +def _run(conn, query, params, commit): + cursor = conn.cursor(dictionary=True) + try: + cursor.execute(query, params) + if commit: + conn.commit() + return cursor.rowcount + return cursor.fetchall() + finally: + cursor.close() + conn.close() # terug naar de pool + + +def execute_query(query, params=(), commit=False): + return _run(get_db_connection(), query, params, commit) + + +def execute_insert(query, params=()): + """INSERT in de database van de huidige school; geeft het nieuwe AUTO_INCREMENT-id terug.""" + conn = get_db_connection() + cursor = conn.cursor() + try: + cursor.execute(query, params) + conn.commit() + return cursor.lastrowid + finally: + cursor.close() + conn.close() + + +def central_query(query, params=(), commit=False): + return _run(get_central_connection(), query, params, commit) + + +def central_insert(query, params=()): + """INSERT in de centrale database; geeft het nieuwe AUTO_INCREMENT-id terug.""" + conn = get_central_connection() + cursor = conn.cursor() + try: + cursor.execute(query, params) + conn.commit() + return cursor.lastrowid + finally: + cursor.close() + conn.close() diff --git a/geheimen/flask.key b/geheimen/flask.key new file mode 100644 index 0000000..5767c82 --- /dev/null +++ b/geheimen/flask.key @@ -0,0 +1 @@ +100c148b256ee08843e482847b4a5d1f86797b380957c476ce6a29f1de2dd7cb \ No newline at end of file diff --git a/rechten.py b/rechten.py new file mode 100644 index 0000000..5ce12c0 --- /dev/null +++ b/rechten.py @@ -0,0 +1,474 @@ +""" +Rechtensysteem: wie mag wat, in welke school. + +Gebruikers staan in de CENTRALE database (één account voor alle scholen). +Per gebruiker wordt per school bijgehouden op welke onderdelen hij rechten heeft: + + Gebruiker_rechten (Gebruiker_ID, School_ID, Onderdeel_ID) + +Zo kan iemand in school A '2FA tijdelijk uitzetten' mogen en in school B niet. + +Veiligheidsgroepen: + 1 = Administrator : alle rechten in alle scholen, beheert gebruikers, rechten en scholen + 2 = Gebruiker : enkel de rechten die per school zijn aangevinkt + 5 = Leerkracht : (geen account) personeelslid dat met Google inlogt → enkel de personeelsmodule + +Gebruik in de code: + @recht_vereist('herstellingen') # route + if heeft_recht('tweestaps_uitzetten'): ... # in Python + {% if mag('co2') %} ... {% endif %} # in templates +""" +import functools +import sys +from collections import OrderedDict + +from flask import session, g, flash, redirect, url_for, request, has_request_context + +ADMIN = 1 +GEBRUIKER = 2 + +VEILIGHEIDSGROEPEN = OrderedDict([ + (ADMIN, 'Administrator'), + (GEBRUIKER, 'Gebruiker'), +]) + +# (code, naam, categorie). De volgorde hier is de volgorde op het scherm. +# Nieuwe onderdelen worden bij het opstarten automatisch in de tabel Onderdelen gezet. +ONDERDELEN = [ + # --- Chromebooks --- + ('eigendom_bekijken', 'Eigendom bekijken', 'Chromebooks'), + ('toestellen_uitdelen', 'Toestellen uitdelen en inleveren', 'Chromebooks'), + ('toestellen_beheren', 'Toestelleninventaris beheren', 'Chromebooks'), + ('toestel_blokkeren', 'Chromebook blokkeren / deblokkeren', 'Chromebooks'), + ('herstellingen', 'Herstellingen', 'Chromebooks'), + ('signpost', 'Signpost-toestellen', 'Chromebooks'), + # --- Leerlingen --- + ('leerlingen_bekijken', 'Leerlingen bekijken en bewerken', 'Leerlingen'), + ('leerlingen_synchroniseren', 'Leerlingen toevoegen en synchroniseren', 'Leerlingen'), + ('leerlingenaantal', 'Leerlingenaantallen (alle scholen)', 'Leerlingen'), + # --- Basisonderwijs (blok enkel zichtbaar bij een basisschool) --- + ('bingel', 'Bingel: leerlingenlijst (Excel)', 'Basisonderwijs'), + # --- Internaat --- + ('internen', 'Internaat: internen bekijken', 'Internaat'), + ('internaat_aanwezigheid', 'Internaat: aanwezigheden registreren', 'Internaat'), + # --- Leerlingfacturatie --- + ('leerlingfacturatie', 'Leerlingfacturatie: facturen bekijken', 'Leerlingfacturatie'), + # --- Google --- + ('google_wachtwoord', 'Google-wachtwoord resetten', 'Google'), + ('google_accounts', 'Google-accounts aanmaken', 'Google'), + ('tweestaps_codes', '2FA: back-upcodes tonen / aanmaken', 'Google'), + ('tweestaps_uitzetten', '2FA tijdelijk uitzetten (uitzondering)', 'Google'), + ('google_licenties', 'Google-licenties', 'Google'), + ('gmail_delegatie', 'Mailbox-delegatie', 'Google'), + ('verzendlijsten', 'Verzendlijsten (Google Groepen) beheren', 'Google'), + ('gmail_labels', 'E-mails met een label naar een andere mailbox', 'Google'), + ('gmail_handtekeningen', 'Gmail-handtekeningen instellen (functie/telefoon)', 'Google'), + ('gedeelde_drives', 'Gedeelde drives: leden en rechten beheren', 'Google'), + ('gmail_contacten', 'Contacten: contactlijsten in mailboxen, adresboek', 'Google'), + # --- Smartschool --- + ('smartschool', 'Smartschool-wachtwoorden & info', 'Smartschool'), + ('smartschool_activiteit', 'Activiteit co-accounts & berichten', 'Smartschool'), + ('afwezigheden', 'Afwezigheden leerlingen (B-codes)', 'Smartschool'), + ('laatkomers', 'Laatkomers', 'Smartschool'), + ('skore', 'Skore-synchronisatie (starten en plannen)', 'Smartschool'), + ('sgr_groepen', 'SGR groepen: subgroepen en leden bekijken', 'Smartschool'), + # --- Personeel --- + ('personeel_directie', 'Personeel: aanvragen goedkeuren (directie)', 'Personeel'), + ('personeel_secretariaat', 'Personeel: dagoverzicht (secretariaat)', 'Personeel'), + ('gopro', 'GO! Pro: personeel en rollen', 'Personeel'), + ('personeelsaantal', 'Personeelsaantallen (alle scholen)', 'Personeel'), + # --- Tools --- + ('qr_codes', 'QR-code generator', 'Tools'), + # --- IoT --- + ('co2', 'CO2-sensoren', 'IoT'), + # --- Scholengroep --- + # --- Beheer --- + ('bugs_bekijken', 'Bugs bekijken', 'Beheer'), + ('bugs_beheren', 'Status van bugs wijzigen', 'Beheer'), + ('nieuw_schooljaar', 'Nieuw schooljaar (accounts & toestellen)', 'Beheer'), + ('ip_whitelist', 'IP-adressen toestaan voor deze school', 'Beheer'), + # --- PIVOT --- + ('gebruikersbeheer', 'Gebruikers & rechten van deze school', 'PIVOT'), + ('koppelingen', 'Koppelingen van deze school', 'PIVOT'), +] +CODES = [o[0] for o in ONDERDELEN] + + +# ===================================================================== +# Tabellen (centrale database) +# ===================================================================== +SCHEMA = [ + """CREATE TABLE IF NOT EXISTS Veiligheidsgroepen ( + Veiligheidsgroep TINYINT NOT NULL PRIMARY KEY, + Naam VARCHAR(50) NOT NULL, + Omschrijving VARCHAR(255) NULL + ) ENGINE=InnoDB""", + """CREATE TABLE IF NOT EXISTS Gebruikers ( + Gebruiker_ID INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + Gebruikersnaam VARCHAR(100) NOT NULL, + Veiligheidsgroep TINYINT NOT NULL DEFAULT 2, + Passwoordhash VARCHAR(255) NULL, + Emailadres VARCHAR(255) NULL, + Last_login DATETIME NULL, + IP_laatste_login VARCHAR(45) NULL, + Laatste_school VARCHAR(32) NULL, + Actief TINYINT NOT NULL DEFAULT 1, + UNIQUE KEY uq_gebruikersnaam (Gebruikersnaam), + UNIQUE KEY uq_emailadres (Emailadres), + CONSTRAINT fk_gebruikers_groep FOREIGN KEY (Veiligheidsgroep) + REFERENCES Veiligheidsgroepen (Veiligheidsgroep) + ) ENGINE=InnoDB""", + """CREATE TABLE IF NOT EXISTS Onderdelen ( + Onderdeel_ID INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + Code VARCHAR(50) NOT NULL, + Naam VARCHAR(255) NOT NULL, + Categorie VARCHAR(50) NOT NULL, + Volgorde INT NOT NULL DEFAULT 0, + UNIQUE KEY uq_onderdeel_code (Code) + ) ENGINE=InnoDB""", + """CREATE TABLE IF NOT EXISTS Gebruiker_rechten ( + Gebruiker_ID INT NOT NULL, + School_ID INT NOT NULL, + Onderdeel_ID INT NOT NULL, + Toegekend_op DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + Toegekend_door VARCHAR(100) NULL, + PRIMARY KEY (Gebruiker_ID, School_ID, Onderdeel_ID), + KEY idx_rechten_school (School_ID), + KEY idx_rechten_onderdeel (Onderdeel_ID), + CONSTRAINT fk_rechten_gebruiker FOREIGN KEY (Gebruiker_ID) + REFERENCES Gebruikers (Gebruiker_ID) ON DELETE CASCADE, + CONSTRAINT fk_rechten_school FOREIGN KEY (School_ID) + REFERENCES Scholen (School_ID) ON DELETE CASCADE, + CONSTRAINT fk_rechten_onderdeel FOREIGN KEY (Onderdeel_ID) + REFERENCES Onderdelen (Onderdeel_ID) ON DELETE CASCADE + ) ENGINE=InnoDB""", + """CREATE TABLE IF NOT EXISTS Handleidingen ( + Handleiding_ID INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + Slug VARCHAR(100) NOT NULL, + Titel VARCHAR(200) NOT NULL, + Categorie VARCHAR(100) NULL, + Samenvatting VARCHAR(300) NULL, + Inhoud MEDIUMTEXT NULL, + Zichtbaar VARCHAR(10) NOT NULL DEFAULT 'iedereen', + Volgorde INT NOT NULL DEFAULT 0, + Gewijzigd_op DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + Gewijzigd_door VARCHAR(100) NULL, + UNIQUE KEY uq_handleiding_slug (Slug) + ) ENGINE=InnoDB""", + """CREATE TABLE IF NOT EXISTS Handleidingen_versies ( + Versie_ID INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + Handleiding_ID INT NOT NULL, + Titel VARCHAR(200) NOT NULL, + Inhoud MEDIUMTEXT NULL, + Opgeslagen_op DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + Door VARCHAR(100) NULL, + KEY idx_versie_handleiding (Handleiding_ID, Opgeslagen_op), + CONSTRAINT fk_versie_handleiding FOREIGN KEY (Handleiding_ID) + REFERENCES Handleidingen (Handleiding_ID) ON DELETE CASCADE + ) ENGINE=InnoDB""", + """CREATE TABLE IF NOT EXISTS Leerlingenaantal_tellingen ( + Telling_ID INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + School_ID INT NOT NULL, + Peildatum DATE NOT NULL, + Schooljaar VARCHAR(10) NOT NULL, + Aantal INT NOT NULL, + Detail TEXT NULL, + Opgevraagd_op DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + Door VARCHAR(100) NULL, + UNIQUE KEY uq_telling (School_ID, Peildatum), + KEY idx_telling_datum (Peildatum), + CONSTRAINT fk_telling_school FOREIGN KEY (School_ID) + REFERENCES Scholen (School_ID) ON DELETE CASCADE + ) ENGINE=InnoDB""", + """CREATE TABLE IF NOT EXISTS Personeelsaantal_tellingen ( + Telling_ID INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + School_ID INT NOT NULL, + Peildatum DATE NOT NULL, + Schooljaar VARCHAR(10) NOT NULL, + Aantal INT NOT NULL, + Detail TEXT NULL, + Opgevraagd_op DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + Door VARCHAR(100) NULL, + UNIQUE KEY uq_ptelling (School_ID, Peildatum), + KEY idx_ptelling_datum (Peildatum), + CONSTRAINT fk_ptelling_school FOREIGN KEY (School_ID) + REFERENCES Scholen (School_ID) ON DELETE CASCADE + ) ENGINE=InnoDB""", + """CREATE TABLE IF NOT EXISTS Personeelsaantal_huidig ( + School_ID INT NOT NULL PRIMARY KEY, + Aantal INT NOT NULL, + Detail TEXT NULL, + Gesynchroniseerd DATETIME NOT NULL, + CONSTRAINT fk_phuidig_school FOREIGN KEY (School_ID) + REFERENCES Scholen (School_ID) ON DELETE CASCADE + ) ENGINE=InnoDB""", + """CREATE TABLE IF NOT EXISTS PIVOT_instellingen ( + Sleutel VARCHAR(50) NOT NULL PRIMARY KEY, + Waarde VARCHAR(255) NULL, + Bijgewerkt DATETIME NULL, + Door VARCHAR(100) NULL + ) ENGINE=InnoDB""", + """CREATE TABLE IF NOT EXISTS IP_blokkades ( + ID INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + Reeks VARCHAR(64) NOT NULL, + Soort VARCHAR(10) NOT NULL DEFAULT 'blokkeren', + Opmerking VARCHAR(255) NULL, + Scholen VARCHAR(1000) NULL, + Aangemaakt DATETIME NOT NULL, + Door VARCHAR(100) NULL, + Gebruiker_ID INT NULL, + Aantal_geweigerd INT NOT NULL DEFAULT 0, + Laatst_geweigerd DATETIME NULL, + UNIQUE KEY uq_ip_reeks (Reeks, Soort) + ) ENGINE=InnoDB""", + """CREATE TABLE IF NOT EXISTS Koppeling_tests ( + School_ID INT NOT NULL, + Onderdeel VARCHAR(50) NOT NULL, + Ok TINYINT NULL, + Detail VARCHAR(500) NULL, + Getest_op DATETIME NOT NULL, + PRIMARY KEY (School_ID, Onderdeel), + CONSTRAINT fk_ktest_school FOREIGN KEY (School_ID) REFERENCES Scholen (School_ID) ON DELETE CASCADE + ) ENGINE=InnoDB""", + """CREATE TABLE IF NOT EXISTS Logs ( + Log_ID BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + Timestamp DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + Bezochte_url VARCHAR(2048) NOT NULL, + Gebruiker_ID INT NULL, + School_ID INT NULL, + Actie VARCHAR(255) NULL, + IP VARCHAR(45) NULL, + KEY idx_logs_tijd (Timestamp), + KEY idx_logs_gebruiker (Gebruiker_ID), + CONSTRAINT fk_logs_gebruiker FOREIGN KEY (Gebruiker_ID) + REFERENCES Gebruikers (Gebruiker_ID) ON DELETE SET NULL, + CONSTRAINT fk_logs_school FOREIGN KEY (School_ID) + REFERENCES Scholen (School_ID) ON DELETE SET NULL + ) ENGINE=InnoDB""", + """CREATE TABLE IF NOT EXISTS Status ( + Bugstatus_ID TINYINT NOT NULL PRIMARY KEY, + Omschrijving VARCHAR(50) NOT NULL, + UNIQUE KEY uq_status_omschrijving (Omschrijving) + ) ENGINE=InnoDB""", + """CREATE TABLE IF NOT EXISTS Bugs ( + Bug_ID INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + Timestamp DATETIME NOT NULL, + Omschrijving TEXT NOT NULL, + Foutcode VARCHAR(100) NULL, + Bugstatus_ID TINYINT NOT NULL DEFAULT 1, + Gebruiker_ID INT NULL, + Gebruikersnaam VARCHAR(255) NULL, + School_ID INT NULL, + Gemeld_op DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_bugs_status (Bugstatus_ID), + CONSTRAINT fk_bugs_status FOREIGN KEY (Bugstatus_ID) REFERENCES Status (Bugstatus_ID), + CONSTRAINT fk_bugs_gebruiker FOREIGN KEY (Gebruiker_ID) + REFERENCES Gebruikers (Gebruiker_ID) ON DELETE SET NULL, + CONSTRAINT fk_bugs_school FOREIGN KEY (School_ID) + REFERENCES Scholen (School_ID) ON DELETE SET NULL + ) ENGINE=InnoDB""", +] + +BUGSTATUS = [(1, 'Nieuw'), (2, 'In behandeling'), (3, 'Fixed')] + + +def ensure_schema(): + """Maakt de tabellen aan en zet de vaste lijsten (groepen, onderdelen, bugstatussen) goed.""" + from database import central_query + for sql in SCHEMA: + central_query(sql, commit=True) + # Bestaande tabel Gebruikers van vóór "laatst actieve school" (het aanmeldscherm vraagt geen school meer) + if 'laatste_school' not in {r['Field'].lower() for r in central_query("SHOW COLUMNS FROM Gebruikers") or []}: + central_query("ALTER TABLE Gebruikers ADD COLUMN Laatste_school VARCHAR(32) NULL AFTER IP_laatste_login", commit=True) + for nr, naam in VEILIGHEIDSGROEPEN.items(): + central_query("INSERT INTO Veiligheidsgroepen (Veiligheidsgroep, Naam) VALUES (%s, %s) " + "ON DUPLICATE KEY UPDATE Naam = VALUES(Naam)", (nr, naam), commit=True) + for i, (code, naam, cat) in enumerate(ONDERDELEN, start=1): + central_query("INSERT INTO Onderdelen (Code, Naam, Categorie, Volgorde) VALUES (%s, %s, %s, %s) " + "ON DUPLICATE KEY UPDATE Naam = VALUES(Naam), Categorie = VALUES(Categorie), " + "Volgorde = VALUES(Volgorde)", (code, naam, cat, i * 10), commit=True) + for nr, oms in BUGSTATUS: + central_query("INSERT INTO Status (Bugstatus_ID, Omschrijving) VALUES (%s, %s) " + "ON DUPLICATE KEY UPDATE Omschrijving = VALUES(Omschrijving)", (nr, oms), commit=True) + # Standaardhandleidingen die nog niet bestaan (daarna beheer je ze in PIVOT; verwijderen = verbergen) + from services.handleidingen_standaard import STANDAARD + bestaand = {r['Slug'] for r in central_query("SELECT Slug FROM Handleidingen")} + for i, h in enumerate(STANDAARD, start=1): + if h['slug'] not in bestaand: + central_query("INSERT IGNORE INTO Handleidingen (Slug, Titel, Categorie, Samenvatting, Inhoud, Volgorde, " + "Gewijzigd_door) VALUES (%s, %s, %s, %s, %s, %s, 'PIVOT')", + (h['slug'], h['titel'], h['categorie'], h['samenvatting'], h['inhoud'].strip(), i * 10), + commit=True) + + +# ===================================================================== +# Gebruikers +# ===================================================================== +def zoek_gebruiker(naam_of_email): + """Actieve of inactieve gebruiker op gebruikersnaam of e-mailadres (hoofdletterongevoelig).""" + from database import central_query + w = (naam_of_email or '').strip() + if not w: + return None + rows = central_query("SELECT * FROM Gebruikers WHERE Gebruikersnaam = %s OR Emailadres = %s " + "ORDER BY Gebruikersnaam = %s DESC LIMIT 1", (w, w.lower(), w)) + return rows[0] if rows else None + + +def gebruiker_op_id(gid): + from database import central_query + rows = central_query("SELECT * FROM Gebruikers WHERE Gebruiker_ID = %s", (gid,)) + return rows[0] if rows else None + + +def registreer_login(gid, ip): + from database import central_query + try: + central_query("UPDATE Gebruikers SET Last_login = NOW(), IP_laatste_login = %s WHERE Gebruiker_ID = %s", + (ip, gid), commit=True) + except Exception as e: + sys.stderr.write(f"PIVOT: laatste login niet bewaard: {e}\n") + + +def bewaar_laatste_school(gid, code): + """Onthoudt de school waarin de gebruiker het laatst werkte (die wordt bij de volgende aanmelding geladen).""" + from database import central_query + if not gid or not code: + return + try: + central_query("UPDATE Gebruikers SET Laatste_school = %s WHERE Gebruiker_ID = %s", (code, gid), commit=True) + except Exception as e: + sys.stderr.write(f"PIVOT: laatst actieve school niet bewaard: {e}\n") + + +def rechten_van(gid, school_id): + """Set met codes van onderdelen waarop gebruiker `gid` rechten heeft in school `school_id`.""" + from database import central_query + rows = central_query( + "SELECT o.Code FROM Gebruiker_rechten r JOIN Onderdelen o ON o.Onderdeel_ID = r.Onderdeel_ID " + "WHERE r.Gebruiker_ID = %s AND r.School_ID = %s", (gid, school_id)) + return {r['Code'] for r in rows} + + +def scholen_met_rechten(gid): + """School_ID's waar de gebruiker minstens één recht heeft.""" + from database import central_query + rows = central_query("SELECT DISTINCT School_ID FROM Gebruiker_rechten WHERE Gebruiker_ID = %s", (gid,)) + return {r['School_ID'] for r in rows} + + +def mag_in_school(gebruiker, school): + """Mag deze gebruiker (rij uit Gebruikers) in deze school inloggen?""" + if not gebruiker or not school: + return False + if int(gebruiker['Veiligheidsgroep']) == ADMIN: + return True + return school.id in scholen_met_rechten(gebruiker['Gebruiker_ID']) + + +def zet_rechten(gid, school_id, codes, door=None): + """Vervangt alle rechten van gebruiker `gid` in school `school_id` door `codes`.""" + from database import get_central_connection + codes = [c for c in codes if c in CODES] + conn = get_central_connection() + cur = conn.cursor() + try: + conn.start_transaction() + cur.execute("DELETE FROM Gebruiker_rechten WHERE Gebruiker_ID = %s AND School_ID = %s", (gid, school_id)) + if codes: + plek = ','.join(['%s'] * len(codes)) + cur.execute( + f"INSERT INTO Gebruiker_rechten (Gebruiker_ID, School_ID, Onderdeel_ID, Toegekend_door) " + f"SELECT %s, %s, Onderdeel_ID, %s FROM Onderdelen WHERE Code IN ({plek})", + (gid, school_id, door, *codes)) + conn.commit() + except Exception: + conn.rollback() + raise + finally: + cur.close() + conn.close() + + +# ===================================================================== +# Huidige gebruiker +# ===================================================================== +def is_admin(): + return bool(session.get('admin')) or bool(session.get('superbeheerder')) + + +def huidige_rechten(): + """Rechten van de ingelogde gebruiker in de huidige school (één keer per request opgehaald).""" + if not has_request_context() or not session.get('auth'): + return set() + if is_admin(): + return set(CODES) + if '_rechten' in g: + return g._rechten + gid = session.get('gebruiker_id') + school = g.get('school') + rechten = set() + if gid and school: + try: + rechten = rechten_van(gid, school.id) + except Exception as e: + sys.stderr.write(f"PIVOT: rechten ophalen mislukt: {e}\n") + g._rechten = rechten + return rechten + + +def heeft_recht(*codes): + """True als de ingelogde gebruiker in de huidige school minstens één van deze rechten heeft.""" + rechten = huidige_rechten() + return any(c in rechten for c in codes) + + +def recht_vereist(*codes): + """Decorator voor routes: de gebruiker moet in de huidige school één van deze rechten hebben.""" + for c in codes: + if c not in CODES: + raise ValueError(f"Onbekend onderdeel '{c}'") + + def decorator(view): + @functools.wraps(view) + def wrapped(*a, **kw): + if not session.get('auth') or not session.get('school'): + return redirect(url_for('auth.login')) + if not heeft_recht(*codes): + flash("Je hebt geen rechten voor dit onderdeel in deze school.", "error") + return redirect(url_for('main.dashboard')) + return view(*a, **kw) + return wrapped + return decorator + + +def admin_vereist(view): + @functools.wraps(view) + def wrapped(*a, **kw): + if not session.get('auth'): + return redirect(url_for('auth.login')) + if not is_admin(): + flash("Enkel voor administrators.", "error") + return redirect(url_for('main.dashboard')) + return view(*a, **kw) + return wrapped + + +# ===================================================================== +# Logboek (tabel Logs) +# ===================================================================== +def log(actie=None, url=None): + """Schrijft een regel in Logs. Fouten worden genegeerd (loggen mag PIVOT nooit blokkeren).""" + from database import central_query + try: + school = g.get('school') if has_request_context() else None + central_query( + "INSERT INTO Logs (Bezochte_url, Gebruiker_ID, School_ID, Actie, IP) VALUES (%s, %s, %s, %s, %s)", + ((url or (request.full_path.rstrip('?') if has_request_context() else ''))[:2048], + session.get('gebruiker_id') if has_request_context() else None, + school.id if school else None, + (actie or '')[:255] or None, + request.remote_addr if has_request_context() else None), + commit=True) + except Exception as e: + sys.stderr.write(f"PIVOT: loggen mislukt: {e}\n") diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..0ff42a9 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,17 @@ +# Getest met Python 3.11 – 3.13 +Flask>=3.0,<4 +Authlib>=1.3,<2 +google-auth>=2.25 +google-auth-oauthlib>=1.1 +google-auth-httplib2>=0.2 +google-api-python-client>=2.100 +requests>=2.32 +mysql-connector-python>=8.4 +xhtml2pdf>=0.2.16 +python-dateutil>=2.8.2 +# zeep 4.3+ nodig voor Python 3.13 (oudere versies gebruiken de verwijderde module 'cgi') +zeep>=4.3.1 +qrcode>=8.1 +pandas>=2.2.3 +openpyxl>=3.1.2 +cryptography>=41 \ No newline at end of file diff --git a/services/CIP_inleveren.py b/services/CIP_inleveren.py new file mode 100644 index 0000000..c2e3cdc --- /dev/null +++ b/services/CIP_inleveren.py @@ -0,0 +1,66 @@ +# CIP_inleveren.py – toestel terug inleveren (en eventueel herstelling registreren) +from database import execute_query +from datetime import date +from services import toestellen as tst + +foutmelding = "" + + +def Inleveren(internnummer, toestelnummer, action_type, tab2_schade_text): + global foutmelding + + # 1. Zoek leerling op (zet om naar officieel Internnummer) + leerling = tst.zoek_leerling(internnummer) + if not leerling: + foutmelding = "Error: Internnummer niet gevonden." + return + + # 2. Zoek het toestel en de huidige toekenning aan deze leerling + toestel = tst.zoek_toestel(toestelnummer) + if not toestel: + foutmelding = "Error: Toestelnummer niet gevonden in de toestellenlijst." + return + eigendom = execute_query( + "SELECT Eigendom_ID FROM Eigendom WHERE Toestel_ID = %s AND Internnummer = %s AND Datum_inlevering IS NULL", + (toestel['Toestel_ID'], leerling['Internnummer'])) + if not eigendom: + foutmelding = "Error: Dit toestel staat niet als 'uitgeleend' aan deze leerling in de database." + return + + # 3. Verwerk de inlevering + try: + _verwerk_inlevering(eigendom[0]['Eigendom_ID'], leerling, toestel['Toestel_ID'], action_type, tab2_schade_text) + foutmelding = "Toestel succesvol ingeleverd." + except Exception as e: + foutmelding = f"Systeemfout: {str(e)}" + + +def _verwerk_inlevering(eigendom_id, leerling, toestel_id, action_type, schade_text): + vandaag = date.today() + + # A. Toekenning afsluiten (blijft bewaard als geschiedenis) + execute_query("UPDATE Eigendom SET Datum_inlevering = %s WHERE Eigendom_ID = %s", (vandaag, eigendom_id), commit=True) + + # B. Status en eventueel herstelling + if action_type == "Schade": + execute_query( + "INSERT INTO Signpost_herstellingen_DB (Toestel_ID, Omschrijving, Status_herstelling, Datum, Leerling_ID) " + "VALUES (%s, %s, 'geregistreerd', %s, %s)", + (toestel_id, schade_text, vandaag, leerling['Leerling_ID']), commit=True) + execute_query("UPDATE Toestellen SET Status = 'Defect' WHERE Toestel_ID = %s", (toestel_id,), commit=True) + else: + execute_query("UPDATE Toestellen SET Status = 'Beschikbaar' WHERE Toestel_ID = %s", (toestel_id,), commit=True) + + +def Ingeven_herstelling(toestelnummer, soort_herstelling): + """Herstelling registreren zonder inlevering door een leerling.""" + global foutmelding + toestel = tst.zoek_toestel(toestelnummer) + if not toestel: + foutmelding = f"Toestel {toestelnummer} niet gevonden." + return + execute_query( + "INSERT INTO Signpost_herstellingen_DB (Toestel_ID, Omschrijving, Status_herstelling, Datum) " + "VALUES (%s, %s, 'In afwachting', %s)", + (toestel['Toestel_ID'], soort_herstelling, date.today()), commit=True) + foutmelding = "Herstelling ingegeven" diff --git a/services/CIP_uitdelen.py b/services/CIP_uitdelen.py new file mode 100644 index 0000000..485e6ba --- /dev/null +++ b/services/CIP_uitdelen.py @@ -0,0 +1,51 @@ +# CIP_uitdelen.py – toestel uitdelen aan een leerling +from database import execute_query +from datetime import date +from services import toestellen as tst + +foutmelding = "" + + +def uitdelen(internnummer, toestelnummer): + """`toestelnummer` = eigen toestelnummer van de school (Schooleigen_ID) of serienummer.""" + global foutmelding + + # 1. Validatie van input + if not internnummer or not toestelnummer: + foutmelding = "Error: Vul zowel het Internnummer als het toestelnummer in." + return False + + # 2. Check of leerling bestaat + leerling = tst.zoek_leerling(internnummer) + if not leerling: + foutmelding = "Error: Internnummer niet gevonden in de leerlingenlijst." + return False + + # 3. Check of toestel bestaat + toestel = tst.zoek_toestel(toestelnummer) + if not toestel: + foutmelding = "Error: Toestelnummer niet gevonden in de toestellenlijst." + return False + + # 4. Check of toestel al is uitgegeven + huidig = tst.actieve_eigendom(toestel['Toestel_ID']) + if huidig: + foutmelding = f"Error: Toestel is al toegekend aan {huidig['Internnummer']}" + return False + + # 5. Alles OK -> Uitvoeren + try: + _verwerk_uitdeling(leerling['Internnummer'], toestel['Toestel_ID']) + foutmelding = "Toestel succesvol uitgedeeld!" + return True + except Exception as e: + foutmelding = f"Systeemfout: {str(e)}" + return False + + +def _verwerk_uitdeling(internnummer, toestel_id): + """Nieuwe rij in Eigendom (zonder Datum_inlevering) en toestel op 'Uitgeleend'.""" + execute_query( + "INSERT INTO Eigendom (Internnummer, Toestel_ID, Status, Datum_toekenning) VALUES (%s, %s, %s, %s)", + (internnummer, toestel_id, 1 if tst.is_geblokkeerd(toestel_id) else 0, date.today()), commit=True) + execute_query("UPDATE Toestellen SET Status = 'Uitgeleend' WHERE Toestel_ID = %s", (toestel_id,), commit=True) diff --git a/services/__init__.py b/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/aanwezigheid_service.py b/services/aanwezigheid_service.py new file mode 100644 index 0000000..00ef3a0 --- /dev/null +++ b/services/aanwezigheid_service.py @@ -0,0 +1,69 @@ +""" +Internaat › Aanwezigheden: per intern, per dag een ochtend- en een avondcontrole (aanwezig / afwezig / nog niet +gecontroleerd). De internen komen uit Informat (Internaat-API); de registraties staan in de schooldatabase +(tabel internaat_aanwezigheid) en zijn dus gedeeld met alle opvoeders. +""" +from datetime import date, timedelta + +from database import execute_query + +MOMENTEN = ('ochtend', 'avond') +STATUSSEN = ('aanwezig', 'afwezig') # 'leeg' = geen rij +DAGEN = ['Ma', 'Di', 'Wo', 'Do', 'Vr', 'Za', 'Zo'] +DAGEN_VOLUIT = ['Maandag', 'Dinsdag', 'Woensdag', 'Donderdag', 'Vrijdag', 'Zaterdag', 'Zondag'] + + +def ensure_table(): + execute_query(""" + CREATE TABLE IF NOT EXISTS internaat_aanwezigheid ( + Datum DATE NOT NULL, + Persoon_ID VARCHAR(64) NOT NULL, + Moment VARCHAR(10) NOT NULL, + Status VARCHAR(10) NOT NULL, + Naam VARCHAR(200) NULL, + Bijgewerkt DATETIME NOT NULL, + Door VARCHAR(255) NULL, + PRIMARY KEY (Datum, Persoon_ID, Moment), + KEY idx_aanw_persoon (Persoon_ID) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci""", commit=True) + + +def maandag_van(d): + return d - timedelta(days=d.weekday()) + + +def schooljaar_van(d): + start = d.year if d.month >= 9 else d.year - 1 + return f"{start}-{str(start + 1)[-2:]}" + + +def week(maandag): + """{'::': status} voor de week vanaf `maandag`.""" + ensure_table() + rijen = execute_query("SELECT Datum, Persoon_ID, Moment, Status FROM internaat_aanwezigheid " + "WHERE Datum BETWEEN %s AND %s", (maandag, maandag + timedelta(days=6))) or [] + return {f"{r['Persoon_ID']}:{(r['Datum'] - maandag).days}:{r['Moment']}": r['Status'] for r in rijen} + + +def zet(datum, persoon_id, moment, status, naam=None, door=None): + """Bewaart één controle. status 'leeg' wist de registratie.""" + if moment not in MOMENTEN: + raise ValueError("Onbekend moment.") + ensure_table() + if status == 'leeg': + execute_query("DELETE FROM internaat_aanwezigheid WHERE Datum = %s AND Persoon_ID = %s AND Moment = %s", + (datum, persoon_id, moment), commit=True) + return + if status not in STATUSSEN: + raise ValueError("Onbekende status.") + execute_query("INSERT INTO internaat_aanwezigheid (Datum, Persoon_ID, Moment, Status, Naam, Bijgewerkt, Door) " + "VALUES (%s, %s, %s, %s, %s, NOW(), %s) ON DUPLICATE KEY UPDATE Status = VALUES(Status), " + "Naam = VALUES(Naam), Bijgewerkt = NOW(), Door = VALUES(Door)", + (datum, persoon_id, moment, status, (naam or '')[:200] or None, door), commit=True) + + +def groep_van(r, meerdere_internaten=False): + """Groep in het register: het blok van de kamer, anders de vestiging (met het internaat ervoor als er meerdere zijn).""" + blok = (r.get('kamer') or '').split(' verd.')[0].split(' nr.')[0] + groep = blok.replace('blok', 'Blok') if blok.startswith('blok') else (r.get('vestiging') or 'Zonder kamer') + return f"{r['internaat']} · {groep}" if meerdere_internaten else groep diff --git a/services/afwezigheden_service.py b/services/afwezigheden_service.py new file mode 100644 index 0000000..3880728 --- /dev/null +++ b/services/afwezigheden_service.py @@ -0,0 +1,296 @@ +""" +Afwezigheden (o.a. B-codes) uit Smartschool ophalen en bewaren. + +Werking +- Per schooldag wordt `getAbsentsWithInternalNumberByDate` aangeroepen. + Smartschool geeft per leerling (internnummer) de code van de voormiddag (am) + en namiddag (pm) terug. +- Enkel de codes uit config.ini (standaard: B) worden bewaard in de tabel + `smartschool_afwezigheden`. Andere codes worden genegeerd. +- Eén keer per dag (standaard 06:00) synchroniseert een achtergrondtaak. + De laatste 14 dagen worden telkens opnieuw opgehaald, zodat correcties + in Smartschool ook in PIVOT terechtkomen. + +Vereist in Smartschool: + Beheer › Webservices › het webservice-wachtwoord moet het recht + 'Afwezigheden' hebben. +""" +import json +import sys +import threading +import time +from datetime import date, datetime, timedelta + +from config import Config +from database import execute_query +from tenancy import school_singleton, alle_scholen, in_school, met_school + +HERHAAL_DAGEN = 14 # laatste X dagen altijd opnieuw ophalen +DAGDEEL = {'am': 'VM', 'pm': 'NM'} + + +def schooljaar_start(vandaag=None): + vandaag = vandaag or date.today() + jaar = vandaag.year if vandaag.month >= 9 else vandaag.year - 1 + return date(jaar, 9, 1) + + +class AfwezighedenService: + _scheduler = None # één achtergrondtaak voor alle scholen + + def __new__(cls): + # één instantie per school (eigen status, lock en Smartschool-client) + def init(inst): + inst._lock = threading.Lock() + inst._client = None + inst.status = {'bezig': False, 'gestart': None, 'voortgang': 0, 'totaal': 0, + 'laatste_fout': None, 'laatste_resultaat': None} + return school_singleton(cls, init) + + # ---------------- configuratie ---------------- + @property + def codes(self): + return [c.strip().upper() for c in Config.AFWEZIGHEID_CODES.split(',') if c.strip()] + + @property + def laat_codes(self): + """Codes die 'te laat' betekenen (Laatkomers). Ze worden samen met de afwezigheidscodes opgehaald.""" + return [c.strip().upper() for c in str(Config.LAATKOMER_CODES or '').split(',') if c.strip()] + + def _ss(self): + if self._client is None: + from services.smartschool_api import SmartschoolService + if not (Config.SS_API_PLATFORM and Config.SS_API_PW): + raise RuntimeError("Smartschool is nog niet ingesteld voor deze school " + "(platform en webservice-wachtwoord via 🔌 Koppelingen van deze school).") + self._client = SmartschoolService(platform=Config.SS_API_PLATFORM, + webservice_pwd=Config.SS_API_PW) + return self._client + + # ---------------- database ---------------- + @staticmethod + def ensure_tables(): + execute_query(""" + CREATE TABLE IF NOT EXISTS smartschool_afwezigheden ( + Datum DATE NOT NULL, + Internnummer VARCHAR(32) NOT NULL, + Dagdeel CHAR(2) NOT NULL, + Code VARCHAR(8) NOT NULL, + PRIMARY KEY (Datum, Internnummer, Dagdeel), + INDEX idx_intern (Internnummer), + INDEX idx_code (Code) + )""", commit=True) + execute_query(""" + CREATE TABLE IF NOT EXISTS smartschool_afwezigheden_sync ( + Datum DATE NOT NULL PRIMARY KEY, + Opgehaald DATETIME NOT NULL, + Aantal INT NOT NULL DEFAULT 0 + )""", commit=True) + + # ---------------- Smartschool ---------------- + def fetch_day(self, dag): + """Geeft {internnummer: {'VM': code, 'NM': code}} voor één dag (alleen de gezochte codes).""" + raw = self._ss().client.service.getAbsentsWithInternalNumberByDate( + Config.SS_API_PW, dag.strftime('%Y-%m-%d')) + + if raw is None or raw == '': + return {} + if isinstance(raw, (int, float)) or (isinstance(raw, str) and raw.strip().lstrip('-').isdigit()): + # Smartschool geeft bij fouten een numerieke foutcode terug + raise RuntimeError(f"Smartschool foutcode {raw} (controleer het recht 'Afwezigheden' " + "voor het webservice-wachtwoord).") + data = json.loads(raw) if isinstance(raw, str) else raw + if not isinstance(data, dict): + return {} + + gezocht = set(self.codes) | set(self.laat_codes) + result = {} + for intern, delen in data.items(): + if not isinstance(delen, dict): + continue + for key, label in DAGDEEL.items(): + val = delen.get(key) + if isinstance(val, dict): # variant met alias: {'code': 'B', 'alias': ...} + val = val.get('code') + code = (val or '').strip().upper() + if code in gezocht: + result.setdefault(str(intern).strip(), {})[label] = code + return result + + def _store_day(self, dag, per_leerling): + execute_query("DELETE FROM smartschool_afwezigheden WHERE Datum = %s", (dag,), commit=True) + aantal = 0 + for intern, delen in per_leerling.items(): + for dagdeel, code in delen.items(): + execute_query( + "INSERT INTO smartschool_afwezigheden (Datum, Internnummer, Dagdeel, Code) " + "VALUES (%s, %s, %s, %s)", (dag, intern, dagdeel, code), commit=True) + aantal += 1 + execute_query( + "INSERT INTO smartschool_afwezigheden_sync (Datum, Opgehaald, Aantal) VALUES (%s, NOW(), %s) " + "ON DUPLICATE KEY UPDATE Opgehaald = NOW(), Aantal = VALUES(Aantal)", (dag, aantal), commit=True) + return aantal + + # ---------------- synchronisatie ---------------- + def dagen_te_synchroniseren(self, vandaag=None, volledig=False): + vandaag = vandaag or date.today() + start = schooljaar_start(vandaag) + alle = [start + timedelta(days=i) for i in range((vandaag - start).days + 1)] + schooldagen = [d for d in alle if d.weekday() < 5] # ma–vr + if volledig: + return schooldagen + rows = execute_query("SELECT Datum FROM smartschool_afwezigheden_sync WHERE Datum >= %s", (start,)) + gehad = {r['Datum'] for r in rows} + grens = vandaag - timedelta(days=HERHAAL_DAGEN) + return [d for d in schooldagen if d not in gehad or d >= grens] + + def sync(self, volledig=False): + """Synchroniseert (blokkerend). Geeft (ok, bericht) terug.""" + if not self._lock.acquire(blocking=False): + return False, "Er loopt al een synchronisatie." + try: + self.ensure_tables() + dagen = self.dagen_te_synchroniseren(volledig=volledig) + self.status.update(bezig=True, gestart=datetime.now(), voortgang=0, + totaal=len(dagen), laatste_fout=None) + gevonden, fouten = 0, 0 + for i, dag in enumerate(dagen, 1): + try: + gevonden += self._store_day(dag, self.fetch_day(dag)) + except Exception as e: + fouten += 1 + self.status['laatste_fout'] = str(e) + sys.stderr.write(f"Afwezigheden {dag}: {e}\n") + self.status['voortgang'] = i + if fouten >= 3 and fouten == i: # eerste 3 dagen allemaal fout → stoppen + break + if fouten and fouten == self.status['voortgang']: + msg = f"Synchronisatie mislukt: {self.status['laatste_fout']}" + ok = False + else: + msg = (f"{len(dagen)} schooldag(en) gecontroleerd, {gevonden} halve dag(en) met code " + f"{'/'.join(self.codes)} op die dagen" + (f", {fouten} dag(en) met fouten." if fouten else ".")) + ok = True + self.status['laatste_resultaat'] = msg + return ok, msg + finally: + self.status['bezig'] = False + self._lock.release() + + def sync_in_background(self, volledig=False): + if self.status['bezig']: + return False + self.status.update(bezig=True, voortgang=0, totaal=0) # meteen tonen op de pagina + threading.Thread(target=met_school(self.sync), kwargs={'volledig': volledig}, daemon=True).start() + return True + + def dagelijkse_controle(self, nu=None): + """Start de synchronisatie als het sync-uur van deze school voorbij is en er vandaag nog niet gesynchroniseerd werd.""" + nu = nu or datetime.now() + if nu.hour < Config.AFWEZIGHEID_SYNC_UUR: + return + self.ensure_tables() + rows = execute_query("SELECT MAX(Opgehaald) AS m FROM smartschool_afwezigheden_sync") + laatst = rows[0]['m'] if rows else None + if not laatst or laatst.date() < nu.date(): + print(f"[{Config.SCHOOLNAME_SHORT}] Afwezigheden: dagelijkse synchronisatie gestart.", flush=True) + ok, msg = self.sync() + print(f"[{Config.SCHOOLNAME_SHORT}] Afwezigheden: {msg}", flush=True) + + @classmethod + def start_scheduler(cls): + """Eén achtergrondtaak die elk kwartier alle scholen met een Smartschool-koppeling overloopt.""" + if AfwezighedenService._scheduler: + return + + def loop(): + time.sleep(30) # app eerst laten opstarten + gedaan = {} # school → laatste dag waarop gecontroleerd + while True: + nu = datetime.now() + for school in alle_scholen(): + if not school.heeft('SS_API_PLATFORM', 'SS_API_PW') or gedaan.get(school.code) == nu.date(): + continue + try: + with in_school(school): + if nu.hour >= Config.AFWEZIGHEID_SYNC_UUR: + cls().dagelijkse_controle(nu) + gedaan[school.code] = nu.date() + except Exception as e: + sys.stderr.write(f"[{school.code}] Afwezigheden scheduler: {e}\n") + time.sleep(15 * 60) + + AfwezighedenService._scheduler = threading.Thread(target=loop, daemon=True) + AfwezighedenService._scheduler.start() + + # ---------------- opvragen ---------------- + def laatste_sync(self): + rows = execute_query("SELECT MAX(Opgehaald) AS m, MAX(Datum) AS d FROM smartschool_afwezigheden_sync") + return rows[0] if rows else {'m': None, 'd': None} + + def overzicht(self, van, tot, code=None, zoek=None, minimum=1): + params = [van, tot] + where = "a.Datum BETWEEN %s AND %s" + codes = [code] if code else self.codes # laatkomerscodes horen niet bij de afwezigheden + where += f" AND a.Code IN ({', '.join(['%s'] * len(codes))})" + params.extend(codes) + sql = f""" + SELECT a.Internnummer, + MAX(l.Voornaam) AS Voornaam, MAX(l.Naam) AS Naam, MAX(l.Gebruikersnaam) AS Gebruikersnaam, + COUNT(*) AS Halve_dagen, + COUNT(DISTINCT a.Datum) AS Dagen, + MIN(a.Datum) AS Eerste, MAX(a.Datum) AS Laatste + FROM smartschool_afwezigheden a + LEFT JOIN Leerlingen l ON l.Internnummer = a.Internnummer + WHERE {where} + GROUP BY a.Internnummer + HAVING COUNT(*) >= %s + ORDER BY Halve_dagen DESC, Laatste DESC + """ + params.append(int(minimum or 1)) + rows = execute_query(sql, tuple(params)) + if zoek: + z = zoek.lower() + rows = [r for r in rows if z in ' '.join(str(r.get(k) or '') for k in + ('Internnummer', 'Voornaam', 'Naam', 'Gebruikersnaam')).lower()] + return rows + + def detail(self, internnummer, van, tot, code=None): + params = [internnummer, van, tot] + codes = [code] if code else self.codes + extra = f" AND Code IN ({', '.join(['%s'] * len(codes))})" + params.extend(codes) + rows = execute_query( + "SELECT Datum, Dagdeel, Code FROM smartschool_afwezigheden " + f"WHERE Internnummer = %s AND Datum BETWEEN %s AND %s{extra} ORDER BY Datum DESC, Dagdeel", + tuple(params)) + # samenvoegen per dag + per_dag = {} + for r in rows: + d = per_dag.setdefault(r['Datum'], {'Datum': r['Datum'], 'VM': None, 'NM': None}) + d[r['Dagdeel']] = r['Code'] + return list(per_dag.values()) + + def laatkomers(self, van, tot): + """Alle leerlingen die tussen van en tot te laat waren: aantal keer, eerste en laatste datum en de lijst + van data (nieuwste eerst). Eén keer te laat = één halve dag met een laatkomerscode.""" + codes = self.laat_codes + if not codes: + return [] + rows = execute_query( + f"""SELECT a.Internnummer, a.Datum, a.Dagdeel, + l.Voornaam, l.Naam, l.Gebruikersnaam + FROM smartschool_afwezigheden a + LEFT JOIN Leerlingen l ON l.Internnummer = a.Internnummer + WHERE a.Datum BETWEEN %s AND %s AND a.Code IN ({', '.join(['%s'] * len(codes))}) + ORDER BY a.Datum DESC, a.Dagdeel""", (van, tot, *codes)) + per = {} + for r in rows: + l = per.setdefault(r['Internnummer'], { + 'Internnummer': r['Internnummer'], 'Voornaam': r['Voornaam'], 'Naam': r['Naam'], + 'Gebruikersnaam': r['Gebruikersnaam'], 'Aantal': 0, 'Laatste': r['Datum'], 'Eerste': r['Datum'], + 'Data': []}) + l['Aantal'] += 1 + l['Eerste'] = r['Datum'] + l['Data'].append((r['Datum'], r['Dagdeel'])) + return sorted(per.values(), key=lambda x: (-x['Aantal'], -x['Laatste'].toordinal())) diff --git a/services/bingel_service.py b/services/bingel_service.py new file mode 100644 index 0000000..ca2851f --- /dev/null +++ b/services/bingel_service.py @@ -0,0 +1,186 @@ +""" +Basisonderwijs › Bingel: leerlingenlijst uit Informat (alle instellingsnummers/campussen van de school) voor de import +in Bingel. Kolommen: Klas, Voornaam, Familienaam, Wachtwoord (leeg), Klasnummer, Geslacht (M/V). + +Informat-leerlingen-API: + GET /students?schoolYear=&refdate= → de leerlingen (naam, voornaam, geslacht, inschrijvingsId) + GET /registrations?schoolYear=&refdate= → de inschrijvingen, met de klas +Het klasnummer haalt PIVOT niet uit Informat: binnen elke klas nummert het alfabetisch (familienaam, voornaam). +Gekoppeld op inschrijvingsId (anders op pPersoon). De velden worden op verschillende plaatsen en namen gezocht +(zie KANDIDATEN), bij een lijst (bv. klassen in de loop van het jaar) eerst de lopende. +""" +import json +import time +from datetime import date + +from config import Config + +KANDIDATEN = { + 'klas': ('klascode', 'klasCode', 'klas', 'klasnaam', 'klasNaam', 'className', 'class'), + 'geslacht': ('geslacht', 'gender', 'sex'), + 'voornaam': ('voornaam', 'firstName', 'roepnaam'), + 'naam': ('naam', 'lastName', 'familienaam'), +} +CACHE_SECONDEN = 10 * 60 + + +class BingelFout(Exception): + pass + + +def _datum(w): + try: + return date.fromisoformat(str(w)[:10]) + except (TypeError, ValueError): + return None + + +def _lopend(item, vandaag): + """Is deze inschrijving lopend op `vandaag` (begin/einde onder verschillende namen)?""" + if not isinstance(item, dict): + return True + begin = next((_datum(item[k]) for k in ('begindatum', 'beginDatum', 'startDate', 'van') if item.get(k)), None) + einde = next((_datum(item[k]) for k in ('einddatum', 'eindDatum', 'endDate', 'tot') if item.get(k)), None) + return (not begin or begin <= vandaag) and (not einde or einde >= vandaag) + + +def _zoek(obj, namen, vandaag, diepte=0): + """Eerste niet-lege waarde voor een van `namen`: eerst op dit niveau, dan in (lopende) geneste lijsten/dicts.""" + if not isinstance(obj, dict) or diepte > 3: + return None + for n in namen: + w = obj.get(n) + if isinstance(w, dict): # bv. "klas": {"code": "3A", "naam": "3de leerjaar A"} + w = w.get('code') or w.get('naam') or w.get('name') + if w not in (None, '') and not isinstance(w, (dict, list)): + return w + for w in obj.values(): + kinderen = [x for x in w if isinstance(x, dict)] if isinstance(w, list) else ([w] if isinstance(w, dict) else []) + kinderen.sort(key=lambda x: not _lopend(x, vandaag)) # lopende inschrijving eerst + for k in kinderen: + gevonden = _zoek(k, namen, vandaag, diepte + 1) + if gevonden not in (None, ''): + return gevonden + return None + + +def _geslacht(w): + t = str(w or '').strip().upper() + if t in ('M', 'MAN', 'MANNELIJK', 'JONGEN', 'MALE', '1'): + return 'M' + if t in ('V', 'F', 'VROUW', 'VROUWELIJK', 'MEISJE', 'FEMALE', '2'): + return 'V' + return t[:1] + + +def _nummer(w): + try: + return int(str(w).strip()) + except (TypeError, ValueError): + return str(w).strip() if w not in (None, '') else None + + +def _inschrijvingen(svc, nummer, refdate): + """Inschrijvingen (met klas) van één instellingsnummer. Geeft (lijst, fout).""" + import requests + from datetime import datetime, timezone + token = svc.get_informat_token(nummer) + if not token: + return [], f"Informat-authenticatie mislukt ({nummer})" + headers = {'Authorization': f'BEARER {token}', 'InstituteNo': nummer, 'Api-Version': '1', 'Accept': 'application/json', + 'Timestamp': datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%S.000Z')} + url = f"{svc.api_base.strip().rstrip('/')}/registrations" + params = {'schoolYear': Config.SCHOOLYEAR, 'refdate': refdate} + try: + r = requests.get(url, params=params, headers=headers, timeout=60) + if r.status_code == 405: + r = requests.post(url, params=params, headers=headers, timeout=60) + except Exception as e: + return [], f"inschrijvingen niet bereikbaar: {e}" + if r.status_code == 404: + return [], None + if r.status_code != 200: + return [], f"inschrijvingen: HTTP {r.status_code} {r.text[:150]}" + data = r.json() + return (data if isinstance(data, list) else (data.get('registrations') or data.get('items') or data.get('data') or [])), None + + +def leerlingen(vernieuw=False): + """[{'klas','voornaam','naam','klasnummer','geslacht'}] gesorteerd op klas en klasnummer (alfabetisch), plus een voorbeeld + van de ruwe velden van de eerste leerling (om te controleren). Bewaard voor 10 minuten.""" + from tenancy import huidige_school + from services.sync_service import SyncService + school = huidige_school() + bewaard = school.cache.get('bingel') + if bewaard and not vernieuw and time.time() - bewaard[0] < CACHE_SECONDEN: + return bewaard[1], bewaard[2], bewaard[3] + if not school.heeft('INFORMAT_INST_NUMBER', 'INFORMAT_ID', 'INFORMAT_SECRET'): + raise BingelFout("Informat is nog niet ingesteld voor deze school (🔌 Koppelingen › Informat).") + svc, vandaag = SyncService(), date.today() + ruw, gezien, inschr, waarschuwing = [], set(), {}, None + for nummer, naam in (svc.campussen or [(svc.institute_no, '')]): + ok, lijst = svc.informat_lijst(vandaag.isoformat(), Config.SCHOOLYEAR, nummer) + if not ok: + raise BingelFout(f"{naam + ': ' if naam else ''}{lijst}") + reg, fout = _inschrijvingen(svc, nummer, vandaag.isoformat()) + if fout: + waarschuwing = f"Klassen konden niet opgehaald worden ({fout})." + for ins in sorted(reg, key=lambda x: _lopend(x, vandaag)): # lopende inschrijving wint + for k in ('inschrijvingsId', 'pInschrijving', 'id'): + if ins.get(k) not in (None, ''): + inschr[('i', str(ins[k]))] = ins + if ins.get('pPersoon') not in (None, ''): + inschr[('p', str(ins['pPersoon']))] = ins + for st in lijst: + sleutel = str(st.get('pPersoon') or st.get('internNummer') or st.get('id') or id(st)) + if sleutel not in gezien: + gezien.add(sleutel) + ruw.append(st) + uit = [] + for st in ruw: + ins = inschr.get(('i', str(st.get('inschrijvingsId')))) or inschr.get(('p', str(st.get('pPersoon')))) or {} + uit.append({'klas': str(_zoek(ins, KANDIDATEN['klas'], vandaag) or _zoek(st, KANDIDATEN['klas'], vandaag) or '').strip(), + 'voornaam': str(_zoek(st, KANDIDATEN['voornaam'], vandaag) or '').strip(), + 'naam': str(_zoek(st, KANDIDATEN['naam'], vandaag) or '').strip(), + 'klasnummer': None, + 'geslacht': _geslacht(_zoek(st, KANDIDATEN['geslacht'], vandaag))}) + # Klasnummer: alfabetisch binnen de klas (familienaam, dan voornaam) + uit.sort(key=lambda r: (not r['klas'], r['klas'].lower(), r['naam'].lower(), r['voornaam'].lower())) + vorige, nr = None, 0 + for r in uit: + nr = nr + 1 if r['klas'] == vorige else 1 + vorige = r['klas'] + r['klasnummer'] = nr if r['klas'] else None + # Om na te kijken: de velden van een inschrijving en een voorbeeld van een klas-item (eerste niet-lege) + alle_ins = list({id(v): v for v in inschr.values()}.values()) + met_klas = next((i for i in alle_ins if _klas_items(i, vandaag)), None) + klas_item = _klas_items(met_klas, vandaag)[0] if met_klas else None + voorbeeld = {'leerling': _velden(ruw[0]) if ruw else [], + 'inschrijving': _velden(met_klas or (alle_ins[0] if alle_ins else {})), + 'klas_item': json.dumps(klas_item, ensure_ascii=False, default=str)[:600] if klas_item else ''} + school.cache['bingel'] = (time.time(), uit, voorbeeld, waarschuwing) + return uit, voorbeeld, waarschuwing + + +def _klas_items(ins, vandaag): + """De klas-items van een inschrijving (bv. inschrKlassen), lopende eerst.""" + items = [] + for k, v in (ins or {}).items(): + if 'klas' in k.lower() and isinstance(v, list): + items += [x for x in v if isinstance(x, dict)] + return sorted(items, key=lambda x: not _lopend(x, vandaag)) + + +def _velden(obj, prefix=''): + """Namen van de velden van één leerling (met geneste velden als a.b), om te zien wat Informat teruggeeft.""" + uit = [] + if isinstance(obj, dict): + for k, v in obj.items(): + pad = f"{prefix}{k}" + if isinstance(v, dict): + uit += _velden(v, pad + '.') + elif isinstance(v, list) and v and isinstance(v[0], dict): + uit += _velden(v[0], pad + '.') + else: + uit.append(pad) + return uit diff --git a/services/co2_service.py b/services/co2_service.py new file mode 100644 index 0000000..db2f55d --- /dev/null +++ b/services/co2_service.py @@ -0,0 +1,81 @@ +import threading +import time +import requests +import re +from database import execute_query +from config import Config +from tenancy import school_singleton, met_school + + +def lokaal_id(naam): + """Lokaal_ID voor een lokaalnaam; maakt het lokaal aan (zonder campus) als het nog niet bestaat.""" + from database import execute_insert + rows = execute_query("SELECT Lokaal_ID FROM Lokalen WHERE Naam = %s ORDER BY Lokaal_ID LIMIT 1", (naam,)) + if rows: + return rows[0]['Lokaal_ID'] + return execute_insert("INSERT INTO Lokalen (Naam) VALUES (%s)", (naam,)) + + +class CO2Scanner: + def __new__(cls): + # één scanner per school (elke school heeft een eigen netwerkbereik) + def init(inst): + inst.active = False + inst.thread = None + inst.auto_delete = False + inst.conf = Config() + return school_singleton(cls, init) + #start service + def start(self): + if not self.active: + self.active = True + self.thread = threading.Thread(target=met_school(self._run_scan_loop), daemon=True) + self.thread.start() + print("CO2 Scanner Service gestart.") + + #killswitch + def stop(self): + self.active = False + print("CO2 Scanner Service gestopt.") + #houd service in actie + def _run_scan_loop(self): + while self.active: + self.perform_scan() + # Wacht het aantal minuten uit je config.ini (bijv. 5) + time.sleep(self.conf.CO2_INTERVAL * 60) + #functie die effectieve taak uitvoerd + def perform_scan(self): + """De logica uit je originele script om IP's af te gaan.""" + start_ip = self.conf.CO2_START_ADDR + end_ip = self.conf.CO2_END_ADDR + network = self.conf.CO2_NETWORK + if not network: + return + + for i in range(start_ip, end_ip + 1): + if not self.active and threading.current_thread() == self.thread: + break + + ip = f"{network}{i}" + url = f"http://{ip}/v1/constellation" + + try: + # Time-out kort houden zodat de scan niet uren duurt + response = requests.get(url, timeout=1.5) + if response.status_code == 200: + data = response.text + # Regex uit je originele script + conc_match = re.search(r'"concentration":\s*{\s*"unit":\s*"ppm",\s*"value":\s*(\d+)', data) + name_match = re.search(r'"device_name":\s*{\s*"value":\s*"(.*?)"', data) + + if conc_match: + ppm = int(conc_match.group(1)) + lokaal = name_match.group(1) if name_match else ip + + # Opslaan in DB (lokaal wordt aangemaakt als het nog niet bestaat) + execute_query( + "INSERT INTO CO2_Metingen (Lokaal_ID, ppm, Timestamp) VALUES (%s, %s, NOW())", + (lokaal_id(lokaal), ppm), commit=True + ) + except: + continue \ No newline at end of file diff --git a/services/coaccounts_service.py b/services/coaccounts_service.py new file mode 100644 index 0000000..3e5ad11 --- /dev/null +++ b/services/coaccounts_service.py @@ -0,0 +1,200 @@ +""" +Co-accounts van alle leerlingen uit Smartschool, met klas en laatste aanmelding. + +Werking (achtergrondtaak, per school): +1. getClassListJson → de officiële klassen +2. getAllAccountsExtended → de leerlingen per klas (met hun co-accountvelden, als Smartschool die meegeeft) +3. getUserDetailsByUsername → enkel voor leerlingen waarvan de co-accountvelden ontbraken +Het resultaat komt in de tabel smartschool_coaccounts (telkens volledig vervangen). +Wordt opnieuw opgehaald als de gegevens ouder zijn dan VERVERS_UREN, of met de knop op de pagina. +""" +import json +import sys +import threading +from datetime import datetime + +from config import Config +from database import execute_query +from tenancy import school_singleton, met_school + +VERVERS_UREN = 24 +MAX_CO = 6 + + +def _json(raw): + if raw is None or raw == '': + return None + if isinstance(raw, (int, float)) or (isinstance(raw, str) and raw.strip().lstrip('-').isdigit()): + raise RuntimeError(f"Smartschool foutcode {raw}") + return json.loads(raw) if isinstance(raw, str) else raw + + +def _lijst(data): + if isinstance(data, dict): + for k in ('accounts', 'users', 'items'): + if isinstance(data.get(k), list): + return data[k] + return list(data.values()) + return data or [] + + +def _login(waarde): + """'2026-09-12 08:01:22' → datetime; leeg, '0000-00-00…' of ongeldig → None.""" + t = str(waarde or '').strip() + if not t or t.startswith('0000') or t.lower() in ('none', 'nooit', 'never'): + return None + for fmt in ('%Y-%m-%d %H:%M:%S', '%Y-%m-%dT%H:%M:%S', '%Y-%m-%d %H:%M', '%Y-%m-%d', '%d-%m-%Y %H:%M:%S', '%d/%m/%Y %H:%M'): + try: + return datetime.strptime(t[:19], fmt) + except ValueError: + continue + return None + + +def co_accounts_uit(data): + """Co-accounts (1-6) uit de gegevens van één leerling. Lege lijst als er geen co-accountvelden zijn.""" + uit = [] + for i in range(1, MAX_CO + 1): + voornaam = str(data.get(f"voornaam_coaccount{i}") or '').strip() + naam = str(data.get(f"naam_coaccount{i}") or '').strip() + if not (voornaam or naam): + continue + uit.append({'nr': i, 'voornaam': voornaam, 'naam': naam, + 'type': str(data.get(f"type_coaccount{i}") or '').strip(), + 'email': str(data.get(f"email_coaccount{i}") or '').strip(), + 'login': _login(data.get(f"last_successful_login_coaccount{i}"))}) + return uit + + +def heeft_co_velden(data): + return any(k.startswith('voornaam_coaccount') for k in data) + + +class CoAccountsService: + def __new__(cls): + def init(inst): + inst._lock = threading.Lock() + inst.status = {'bezig': False, 'voortgang': 0, 'totaal': 0, 'fout': None, 'resultaat': None} + return school_singleton(cls, init) + + @staticmethod + def ensure_tables(): + execute_query(""" + CREATE TABLE IF NOT EXISTS smartschool_coaccounts ( + Internnummer VARCHAR(32) NOT NULL, + Nr TINYINT NOT NULL, + Gebruikersnaam VARCHAR(100) NULL, + Leerling_naam VARCHAR(100) NULL, + Leerling_voornaam VARCHAR(100) NULL, + Klas VARCHAR(50) NULL, + Voornaam VARCHAR(100) NULL, + Naam VARCHAR(100) NULL, + Type VARCHAR(50) NULL, + Email VARCHAR(255) NULL, + Laatste_login DATETIME NULL, + Opgehaald DATETIME NOT NULL, + PRIMARY KEY (Internnummer, Nr), + KEY idx_coacc_klas (Klas) + )""", commit=True) + + # ---------------- opvragen ---------------- + def laatst_opgehaald(self): + rows = execute_query("SELECT MAX(Opgehaald) AS m FROM smartschool_coaccounts") + return rows[0]['m'] if rows else None + + def alle(self): + return execute_query( + "SELECT * FROM smartschool_coaccounts ORDER BY Klas, Leerling_naam, Leerling_voornaam, Nr") or [] + + def verouderd(self): + laatst = self.laatst_opgehaald() + return not laatst or (datetime.now() - laatst).total_seconds() > VERVERS_UREN * 3600 + + # ---------------- ophalen uit Smartschool ---------------- + def _leerlingen(self, ss): + """[(account-dict, klasnaam)] voor alle leerlingen in een officiële klas (elke leerling één keer).""" + klassen = [] + for k in _lijst(_json(ss.client.service.getClassListJson(Config.SS_API_PW))): + if not isinstance(k, dict): + continue + if str(k.get('isKlas', k.get('isOfficial', True))).lower() in ('false', '0', 'none'): + continue + code = str(k.get('code') or '').strip() + if code: + klassen.append((code, str(k.get('name') or code).strip())) + klassen.sort(key=lambda x: x[1]) + self.status.update(totaal=len(klassen), voortgang=0) + gezien, uit = set(), [] + for i, (code, naam) in enumerate(klassen, 1): + try: + leden = _lijst(_json(ss.client.service.getAllAccountsExtended(Config.SS_API_PW, code, '0'))) + except Exception as e: + sys.stderr.write(f"Co-accounts: klas {naam}: {e}\n") + leden = [] + for a in leden: + if not isinstance(a, dict): + continue + rol = str(a.get('basisrol') or a.get('basisRol') or '').lower() + if rol and rol not in ('1', 'leerling', 'student'): + continue + sleutel = str(a.get('internnummer') or a.get('gebruikersnaam') or '').strip() + if sleutel and sleutel not in gezien: + gezien.add(sleutel) + uit.append((a, naam)) + self.status['voortgang'] = i + return uit + + def sync(self): + if not self._lock.acquire(blocking=False): + return False, "Er loopt al een ophaling." + try: + from services.smartschool_api import SmartschoolService + self.status.update(bezig=True, voortgang=0, totaal=0, fout=None) + self.ensure_tables() + ss = SmartschoolService(Config.SS_API_PLATFORM, Config.SS_API_PW) + leerlingen = self._leerlingen(ss) + # Leerlingen zonder co-accountvelden in de klaslijst: details apart opvragen + zonder = [i for i, (a, _) in enumerate(leerlingen) if not heeft_co_velden(a)] + self.status.update(totaal=self.status['totaal'] + len(zonder)) + for n, i in enumerate(zonder, 1): + a, klas = leerlingen[i] + try: + d = _json(ss.client.service.getUserDetailsByUsername(Config.SS_API_PW, a.get('gebruikersnaam'))) + if isinstance(d, dict): + leerlingen[i] = ({**a, **d}, klas) + except Exception as e: + sys.stderr.write(f"Co-accounts: {a.get('gebruikersnaam')}: {e}\n") + self.status['voortgang'] += 1 + nu = datetime.now() + rijen = [] + for a, klas in leerlingen: + intern = str(a.get('internnummer') or a.get('gebruikersnaam') or '').strip() + for c in co_accounts_uit(a): + rijen.append((intern, c['nr'], a.get('gebruikersnaam'), a.get('naam'), a.get('voornaam'), klas, + c['voornaam'], c['naam'], c['type'], c['email'], c['login'], nu)) + if not leerlingen: + raise RuntimeError("Smartschool gaf geen leerlingen terug. Heeft het webservice-wachtwoord de " + "rechten voor gebruikers en klassen?") + execute_query("DELETE FROM smartschool_coaccounts", commit=True) + for r in rijen: + execute_query("INSERT INTO smartschool_coaccounts (Internnummer, Nr, Gebruikersnaam, Leerling_naam, " + "Leerling_voornaam, Klas, Voornaam, Naam, Type, Email, Laatste_login, Opgehaald) " + "VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)", r, commit=True) + nooit = sum(1 for r in rijen if not r[10]) + msg = (f"{len(leerlingen)} leerlingen en {len(rijen)} co-accounts opgehaald; " + f"{nooit} co-account(s) nog nooit aangemeld.") + self.status['resultaat'] = msg + return True, msg + except Exception as e: + self.status['fout'] = str(e) + return False, f"Ophalen mislukt: {e}" + finally: + self.status['bezig'] = False + self._lock.release() + + def sync_op_achtergrond(self): + if self.status['bezig']: + return False + self.status.update(bezig=True, voortgang=0, totaal=0) + threading.Thread(target=met_school(self.sync), daemon=True).start() + return True diff --git a/services/contacten_service.py b/services/contacten_service.py new file mode 100644 index 0000000..4ada095 --- /dev/null +++ b/services/contacten_service.py @@ -0,0 +1,391 @@ +""" +Gmail › Contacten: twee soorten contactbeheer in Google Workspace. + +1. Adresboek van de organisatie (Directory API, admin.directory.user) + Per gebruiker 'includeInGlobalAddressList': zichtbaar of verborgen in het adresboek en bij het aanvullen van adressen. + +2. Contactlijsten in de mailboxen (People API, https://www.googleapis.com/auth/contacts) + Een lijst (label) met contacten die PIVOT in de persoonlijke contacten van elke gebruiker in de gekozen OU's zet en + bijhoudt. PIVOT herkent zijn eigen contacten aan een eigen veld (userDefined 'PIVOT'), en past enkel die aan. +""" +import hashlib +import json +import sys +import threading +import time +from datetime import datetime + +from config import Config +from database import execute_query +from tenancy import school_singleton, met_school, google_credentials, huidige_school + +SCOPE_PEOPLE = 'https://www.googleapis.com/auth/contacts' +PIVOT_SLEUTEL = 'PIVOT' + + +class ContactFout(Exception): + pass + + +def vriendelijk(e, scope=None): + t = str(e) + laag = t.lower() + if 'unauthorized_client' in laag or 'access_denied' in laag or 'invalid_scope' in laag: + return ("Google weigert de toegang: voeg in de Admin-console › Beveiliging › API-beheer › Domeinbrede delegatie " + f"het bereik {scope or '(zie handleiding)'} toe bij het serviceaccount van PIVOT.") + if 'has not been used' in laag or 'is disabled' in laag or 'accessnotconfigured' in laag or 'service_disabled' in laag: + return ("De nodige API staat niet aan in het Google Cloud-project van het serviceaccount " + "(voor contactlijsten: People API).") + return t[:300] + + +def _directory(): + from googleapiclient.discovery import build + creds = google_credentials(Config.GOOGLE_API_SCOPES, subject=Config.GOOGLE_ADMIN_EMAIL) + return build('admin', 'directory_v1', credentials=creds, static_discovery=False, cache_discovery=False) + + +def ou_lijst(): + try: + resp = _directory().orgunits().list(customerId='my_customer', type='all').execute() + except Exception as e: + raise ContactFout(f"OU's ophalen mislukt: {vriendelijk(e)}") + return sorted([o['orgUnitPath'] for o in resp.get('organizationUnits', [])] + ['/'], key=str.lower) + + +def gebruikers_in(ous, met_sub=True, velden='primaryEmail,name,orgUnitPath,includeInGlobalAddressList,suspended'): + api, uit, gezien = _directory(), [], set() + for ou in ous: + token = None + while True: + params = dict(customer='my_customer', maxResults=500, pageToken=token, + fields=f'nextPageToken,users({velden})') + if ou != '/': + params['query'] = "orgUnitPath='" + ou.replace("\\", "\\\\").replace("'", "\\'") + "'" + try: + resp = api.users().list(**params).execute() + except Exception as e: + raise ContactFout(f"Gebruikers van {ou} ophalen mislukt: {vriendelijk(e)}") + for u in resp.get('users', []): + pad = u.get('orgUnitPath') or '/' + if (not met_sub and pad != ou) or u['primaryEmail'].lower() in gezien: + continue + gezien.add(u['primaryEmail'].lower()) + n = u.get('name') or {} + uit.append({'email': u['primaryEmail'].lower(), 'naam': n.get('fullName') or u['primaryEmail'], + 'achternaam': n.get('familyName', ''), 'ou': pad, 'geschorst': bool(u.get('suspended')), + 'zichtbaar': u.get('includeInGlobalAddressList', True) is not False}) + token = resp.get('nextPageToken') + if not token: + break + return sorted(uit, key=lambda x: (x['achternaam'].lower(), x['naam'].lower())) + + +# ===================================================================== +# 1. Adresboek: zichtbaar of verborgen +# ===================================================================== +def zet_zichtbaar(emails, zichtbaar): + api, fouten = _directory(), [] + for e in emails: + try: + api.users().patch(userKey=e, body={'includeInGlobalAddressList': bool(zichtbaar)}).execute() + except Exception as ex: + fouten.append(f"{e}: {vriendelijk(ex)}") + return fouten + + +# ===================================================================== +# 2. Contactlijsten in de mailboxen (People API) +# ===================================================================== +def ensure_tables(): + execute_query(""" + CREATE TABLE IF NOT EXISTS contact_lijsten ( + ID INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + Naam VARCHAR(100) NOT NULL, + OUs TEXT NULL, + Met_sub_ous TINYINT NOT NULL DEFAULT 1, + Bijgewerkt DATETIME NULL, + Door VARCHAR(255) NULL, + Laatste_sync DATETIME NULL, + Resultaat VARCHAR(500) NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci""", commit=True) + execute_query(""" + CREATE TABLE IF NOT EXISTS contact_lijst_items ( + ID INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + Lijst_ID INT NOT NULL, + Voornaam VARCHAR(100) NULL, + Achternaam VARCHAR(100) NULL, + Organisatie VARCHAR(150) NULL, + Functie VARCHAR(150) NULL, + Email VARCHAR(255) NULL, + Telefoon VARCHAR(60) NULL, + Notitie VARCHAR(500) NULL, + KEY idx_cli_lijst (Lijst_ID) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci""", commit=True) + + +def _schoon(c): + uit = {k: (str(c.get(k) or '').strip())[:500] for k in + ('voornaam', 'achternaam', 'organisatie', 'functie', 'email', 'telefoon', 'notitie')} + if not (uit['voornaam'] or uit['achternaam'] or uit['organisatie']): + raise ContactFout("Geef bij elk contact minstens een naam of een organisatie.") + if uit['email'] and '@' not in uit['email']: + raise ContactFout(f"Ongeldig e-mailadres: {uit['email']}") + return uit + + +def _ous(r): + try: + return [o for o in json.loads(r.get('OUs') or '[]') if isinstance(o, str)] + except ValueError: + return [] + + +def lijsten(): + ensure_tables() + rijen = execute_query("SELECT l.*, (SELECT COUNT(*) FROM contact_lijst_items i WHERE i.Lijst_ID = l.ID) AS aantal " + "FROM contact_lijsten l ORDER BY l.Naam") or [] + for r in rijen: + r['ous'] = _ous(r) + return rijen + + +def lijst(lijst_id): + ensure_tables() + r = execute_query("SELECT * FROM contact_lijsten WHERE ID = %s", (lijst_id,)) + if not r: + return None + r = r[0] + r['ous'] = _ous(r) + r['items'] = execute_query("SELECT * FROM contact_lijst_items WHERE Lijst_ID = %s ORDER BY Achternaam, Voornaam, " + "Organisatie", (lijst_id,)) or [] + return r + + +def lijst_bewaren(lijst_id, naam, ous, met_sub, items, door): + ensure_tables() + naam = (naam or '').strip()[:100] + if not naam: + raise ContactFout("Geef de lijst een naam (die wordt het label in Google Contacten).") + schoon = [] + for i in items: + if any(str(i.get(k) or '').strip() for k in i if k != 'id'): + c = _schoon(i) + c['id'] = int(i['id']) if str(i.get('id') or '').isdigit() else None + schoon.append(c) + if lijst_id: + execute_query("UPDATE contact_lijsten SET Naam = %s, OUs = %s, Met_sub_ous = %s, Bijgewerkt = NOW(), Door = %s " + "WHERE ID = %s", (naam, json.dumps(sorted(set(ous))), 1 if met_sub else 0, door, lijst_id), + commit=True) + else: + from database import execute_insert + lijst_id = execute_insert("INSERT INTO contact_lijsten (Naam, OUs, Met_sub_ous, Bijgewerkt, Door) " + "VALUES (%s, %s, %s, NOW(), %s)", + (naam, json.dumps(sorted(set(ous))), 1 if met_sub else 0, door)) + # Bestaande contacten behouden hun ID (zo herkent PIVOT ze in de mailboxen en past het ze aan i.p.v. opnieuw te maken) + bestaand = {r['ID'] for r in execute_query("SELECT ID FROM contact_lijst_items WHERE Lijst_ID = %s", (lijst_id,)) or []} + behouden = set() + velden = (lambda i: (i['voornaam'], i['achternaam'], i['organisatie'], i['functie'], i['email'], i['telefoon'], + i['notitie'])) + for i in schoon: + if i['id'] in bestaand: + execute_query("UPDATE contact_lijst_items SET Voornaam = %s, Achternaam = %s, Organisatie = %s, Functie = %s, " + "Email = %s, Telefoon = %s, Notitie = %s WHERE ID = %s AND Lijst_ID = %s", + velden(i) + (i['id'], lijst_id), commit=True) + behouden.add(i['id']) + else: + execute_query("INSERT INTO contact_lijst_items (Lijst_ID, Voornaam, Achternaam, Organisatie, Functie, Email, " + "Telefoon, Notitie) VALUES (%s, %s, %s, %s, %s, %s, %s, %s)", (lijst_id,) + velden(i), commit=True) + for weg in bestaand - behouden: + execute_query("DELETE FROM contact_lijst_items WHERE ID = %s", (weg,), commit=True) + return lijst_id + + +def lijst_verwijderen_db(lijst_id): + execute_query("DELETE FROM contact_lijst_items WHERE Lijst_ID = %s", (lijst_id,), commit=True) + execute_query("DELETE FROM contact_lijsten WHERE ID = %s", (lijst_id,), commit=True) + + +def _people(mailbox): + from googleapiclient.discovery import build + creds = google_credentials([SCOPE_PEOPLE], subject=mailbox) + try: + return build('people', 'v1', credentials=creds, static_discovery=True, cache_discovery=False) + except Exception: + return build('people', 'v1', credentials=creds, static_discovery=False, cache_discovery=False) + + +def _persoon(item, merk, groep): + p = {'names': [{'givenName': item['Voornaam'] or '', 'familyName': item['Achternaam'] or ''}], + 'userDefined': [{'key': PIVOT_SLEUTEL, 'value': merk}], + 'memberships': [{'contactGroupMembership': {'contactGroupResourceName': groep}}]} + if not (item['Voornaam'] or item['Achternaam']): + p['names'] = [{'givenName': item['Organisatie'] or item['Email'] or ''}] + if item['Email']: + p['emailAddresses'] = [{'value': item['Email'], 'type': 'work'}] + if item['Telefoon']: + p['phoneNumbers'] = [{'value': item['Telefoon'], 'type': 'work'}] + if item['Organisatie'] or item['Functie']: + p['organizations'] = [{'name': item['Organisatie'] or '', 'title': item['Functie'] or ''}] + if item['Notitie']: + p['biographies'] = [{'value': item['Notitie'], 'contentType': 'TEXT_PLAIN'}] + return p + + +def _hash(item): + return hashlib.sha1(json.dumps([item[k] for k in ('Voornaam', 'Achternaam', 'Organisatie', 'Functie', 'Email', + 'Telefoon', 'Notitie')]).encode()).hexdigest()[:12] + + +PERSON_FIELDS = 'names,emailAddresses,phoneNumbers,organizations,biographies,userDefined,memberships' + + +def _herhaal(fn, pogingen=5): + for poging in range(pogingen): + try: + return fn() + except Exception as e: + status = getattr(getattr(e, 'resp', None), 'status', None) + if status in (429, 500, 503) and poging < pogingen - 1: + time.sleep(2 ** poging + 1) + continue + raise + + +def _pivot_contacten(svc, lijst_id): + """{item-sleutel: person} van de contacten die PIVOT voor deze lijst in deze mailbox zette.""" + uit, token = {}, None + prefix = f"lijst{lijst_id}:" + while True: + resp = _herhaal(lambda: svc.people().connections().list( + resourceName='people/me', pageSize=1000, pageToken=token, personFields=PERSON_FIELDS).execute()) + for p in resp.get('connections', []): + merk = next((u.get('value', '') for u in p.get('userDefined', []) if u.get('key') == PIVOT_SLEUTEL), '') + if merk.startswith(prefix): + uit[merk.split('#')[0]] = (merk, p) + token = resp.get('nextPageToken') + if not token: + return uit + + +def _groep(svc, naam, maak=True): + resp = _herhaal(lambda: svc.contactGroups().list(pageSize=1000).execute()) + for g in resp.get('contactGroups', []): + if g.get('groupType') == 'USER_CONTACT_GROUP' and g.get('name') == naam: + return g['resourceName'] + if not maak: + return None + return _herhaal(lambda: svc.contactGroups().create(body={'contactGroup': {'name': naam}}).execute())['resourceName'] + + +def sync_mailbox(email, l, verwijderen=False, oude_naam=None): + """Zet de contacten van lijst `l` in de mailbox (of haalt ze weg). Geeft (toegevoegd, gewijzigd, verwijderd).""" + svc = _people(email) + bestaand = _pivot_contacten(svc, l['ID']) + toegevoegd = gewijzigd = verwijderd = 0 + gewenst = {} if verwijderen else {f"lijst{l['ID']}:{i['ID']}": i for i in l['items']} + groep = None + if gewenst: + groep = _groep(svc, l['Naam']) + if oude_naam and oude_naam != l['Naam']: + oud = _groep(svc, oude_naam, maak=False) + if oud and oud != groep: + _herhaal(lambda: svc.contactGroups().delete(resourceName=oud, deleteContacts=False).execute()) + for sleutel, item in gewenst.items(): + merk = f"{sleutel}#{_hash(item)}" + if sleutel in bestaand: + oud_merk, p = bestaand[sleutel] + in_groep = any(m.get('contactGroupMembership', {}).get('contactGroupResourceName') == groep + for m in p.get('memberships', [])) + if oud_merk == merk and in_groep: + continue + body = _persoon(item, merk, groep) + body['etag'] = p.get('etag') + body['memberships'] = [m for m in p.get('memberships', []) + if m.get('contactGroupMembership', {}).get('contactGroupResourceName') != groep] \ + + body['memberships'] + _herhaal(lambda: svc.people().updateContact( + resourceName=p['resourceName'], updatePersonFields=PERSON_FIELDS, body=body).execute()) + gewijzigd += 1 + else: + _herhaal(lambda: svc.people().createContact(body=_persoon(item, merk, groep)).execute()) + toegevoegd += 1 + for sleutel, (merk, p) in bestaand.items(): + if sleutel not in gewenst: + _herhaal(lambda: svc.people().deleteContact(resourceName=p['resourceName']).execute()) + verwijderd += 1 + if verwijderen: + g = _groep(svc, l['Naam'], maak=False) + if g: + try: + _herhaal(lambda: svc.contactGroups().delete(resourceName=g, deleteContacts=False).execute()) + except Exception: + pass + return toegevoegd, gewijzigd, verwijderd + + +class LijstSync: + def __new__(cls): + def init(inst): + inst._lock = threading.Lock() + inst.status = {'bezig': False, 'lijst': None, 'voortgang': 0, 'totaal': 0, 'fouten': 0, 'fout': None, + 'resultaat': None, 'foutvoorbeelden': []} + return school_singleton(cls, init) + + def run(self, lijst_id, verwijderen=False, oude_naam=None, oude_ous=None): + if not self._lock.acquire(blocking=False): + return False, "Er loopt al een opdracht." + s = self.status + try: + l = lijst(lijst_id) + if not l: + raise ContactFout("Lijst niet gevonden.") + s.update(bezig=True, lijst=l['Naam'], voortgang=0, totaal=0, fouten=0, fout=None, resultaat=None, + foutvoorbeelden=[]) + ontvangers = gebruikers_in(l['ous'], bool(l['Met_sub_ous']), 'primaryEmail,name,orgUnitPath,suspended') + ontvangers = [u for u in ontvangers if not u['geschorst']] + # Wie niet meer in de gekozen OU's zit (OU's gewijzigd): de contacten van deze lijst weghalen + weg = [] + if oude_ous is not None: + nu = {u['email'] for u in ontvangers} + weg = [u for u in gebruikers_in(oude_ous, True, 'primaryEmail,name,orgUnitPath,suspended') + if u['email'] not in nu and not u['geschorst']] + s['totaal'] = len(ontvangers) + len(weg) + t = {'toegevoegd': 0, 'gewijzigd': 0, 'verwijderd': 0} + for u, enkel_weg in [(u, verwijderen) for u in ontvangers] + [(u, True) for u in weg]: + try: + a, b, c = sync_mailbox(u['email'], l, verwijderen=enkel_weg, oude_naam=oude_naam) + t['toegevoegd'] += a; t['gewijzigd'] += b; t['verwijderd'] += c + except Exception as e: + s['fouten'] += 1 + if len(s['foutvoorbeelden']) < 6: + s['foutvoorbeelden'].append(f"{u['email']}: {vriendelijk(e, SCOPE_PEOPLE)[:180]}") + sys.stderr.write(f"Contactlijst {l['Naam']} → {u['email']}: {e}\n") + s['voortgang'] += 1 + if verwijderen: + msg = f"Lijst '{l['Naam']}' uit {len(ontvangers)} mailbox(en) gehaald ({t['verwijderd']} contacten)" + else: + msg = (f"Lijst '{l['Naam']}' in {len(ontvangers)} mailbox(en): {t['toegevoegd']} toegevoegd, " + f"{t['gewijzigd']} bijgewerkt, {t['verwijderd']} verwijderd") + if s['fouten']: + msg += f", {s['fouten']} mailbox(en) mislukt" + s['resultaat'] = msg + '.' + if verwijderen and not s['fouten']: + lijst_verwijderen_db(lijst_id) + else: + execute_query("UPDATE contact_lijsten SET Laatste_sync = NOW(), Resultaat = %s WHERE ID = %s", + (s['resultaat'][:500], lijst_id), commit=True) + return True, s['resultaat'] + except Exception as e: + s['fout'] = vriendelijk(e, SCOPE_PEOPLE) + return False, s['fout'] + finally: + s['bezig'] = False + self._lock.release() + + def start(self, *args, **kw): + if self.status['bezig']: + return False + self.status.update(bezig=True, voortgang=0, totaal=0, fout=None, resultaat=None) + threading.Thread(target=met_school(lambda: self.run(*args, **kw)), daemon=True).start() + return True diff --git a/services/facturatie_service.py b/services/facturatie_service.py new file mode 100644 index 0000000..b81ded7 --- /dev/null +++ b/services/facturatie_service.py @@ -0,0 +1,405 @@ +""" +Leerlingfacturatie: de facturen en creditnota's van de leerlingen uit Informat (Rekeningen API / Invoice API). + +- Token: zelfde identityserver en client-ID/secret als de leerlingen-API, met scope + api_informat_invoices.invoices.read._ + Die scope krijgt de client zodra de school in Informat de app toegang geeft tot de rekeningen (toegangsgroep); + Informat stuurt dan een e-mail naar de eigenaar van de app. De accessGroupIdentifier in de bevragingen en + PDF-adressen is het deel na "read.": _. + Staat ze niet in de schoolinstellingen, dan vraagt PIVOT een token zonder scope en leest de toegekende scopes. +- API: GraphQL op /graphql (standaard https://api.informatsoftware.be/invoices/v1/graphql). Per schooljaar de factuurgroepen (enkel type STUDENT) en per groep de + geboekte facturen en creditnota's (totaal, betaald, open, OGM, leerling). +- PDF: /download///pdf (400 = nog niet aangemaakt: dan eerst .../create/...). +Het resultaat wordt 10 minuten per school en schooljaar bewaard. +""" +import re +import time +from datetime import date + +import requests + +from config import Config + +STANDAARD_API = 'https://api.informatsoftware.be/invoices/v1' # centraal APIM-adres uit de implementatiegids +SCOPE_RE = re.compile(r'api_informat_invoices\.invoices\.read\.([0-9a-fA-F-]{36})_(\w+)') +CACHE_SECONDEN = 600 +TIMEOUT = (15, 120) + +Q_GROEPEN = """ +query groepen($schoolYear: String!, $accessGroupIdentifier: String!) { + accessGroup(accessGroupIdentifier: $accessGroupIdentifier) { + invoiceGroups(schoolYear: $schoolYear, where: {invoiceGroupType: {eq: STUDENT}}, order: [{name: ASC}]) { + id name invoiceGroupType schoolYear fromDate toDate + } + } +}""" + +Q_FACTUREN = """ +query facturen($schoolYear: String!, $accessGroupIdentifier: String!, $invoiceGroupId: Int!) { + accessGroup(accessGroupIdentifier: $accessGroupIdentifier) { + invoiceGroups(schoolYear: $schoolYear, where: {id: {eq: $invoiceGroupId}}) { + id name + invoices(order: [{printDate: DESC}]) { + invoiceId invoiceType printDate dueDate total paid open paymentReference paymentReferenceInvoice + recipient { databaseId firstName lastName stamNumber class classNumber } + } + } + } +}""" + + +Q_NAAM = """ +query naam($accessGroupIdentifier: String!) { + accessGroup(accessGroupIdentifier: $accessGroupIdentifier) { guid name } +}""" + +MAX_PROBEER = 400 # meer kandidaat-scopes dan dit proberen we niet één voor één + + +class FacturatieFout(Exception): + pass + + +def schooljaren(aantal=3): + """['2026-27', '2025-26', ...] vanaf het huidige schooljaar.""" + vandaag = date.today() + start = vandaag.year if vandaag.month >= 9 else vandaag.year - 1 + return [f"{j}-{str(j + 1)[-2:]}" for j in range(start, start - aantal, -1)] + + +def _datum(w): + if not w: + return None + try: + return date.fromisoformat(str(w)[:10]) + except ValueError: + return None + + +def _bedrag(w): + try: + return round(float(w or 0), 2) + except (TypeError, ValueError): + return 0.0 + + +def status_van(f): + if f['type'] == 'CREDIT_NOTE': + return 'credit' + if abs(f['open']) < 0.005: + return 'betaald' + if abs(f['betaald']) >= 0.005: + return 'gedeeltelijk' + return 'open' + + +def _scopes_uit(js): + """Scopes uit een token-antwoord: het veld 'scope' of, als dat ontbreekt, de claim 'scope' in het JWT.""" + import base64, json + scope = js.get('scope') + if not scope: + try: + deel = js['access_token'].split('.')[1] + claims = json.loads(base64.urlsafe_b64decode(deel + '=' * (-len(deel) % 4))) + scope = claims.get('scope') + except Exception: + scope = None + if isinstance(scope, str): + scope = scope.split() + return [str(x) for x in (scope or [])] + + +class FacturatieService: + def __init__(self): + from tenancy import huidige_school + self.school = huidige_school() + self.base = (self.school.get('INFORMAT_FACTUUR_API') or STANDAARD_API).rstrip('/') + + # ---------------- authenticatie ---------------- + def _token_aanvraag(self, scope=None): + data = {'grant_type': 'client_credentials', 'client_id': Config.INFORMAT_ID, + 'client_secret': Config.INFORMAT_SECRET} + if scope: + data['scope'] = scope + try: + r = requests.post(Config.INFORMAT_AUTH_URL, data=data, timeout=15) + except Exception as e: + raise FacturatieFout(f"Informat (identityserver) is niet bereikbaar: {e}") + try: + js = r.json() + except Exception: + js = {} + if r.status_code != 200 or not js.get('access_token'): + fout = js.get('error') or f"HTTP {r.status_code}" + if fout == 'invalid_scope': + raise FacturatieFout(f"Informat weigert de scope {scope or '(geen)'} (invalid_scope). Heeft de school in " + "Informat de app toegang gegeven tot de rekeningen (toegangsgroep)?") + if fout == 'invalid_client': + raise FacturatieFout("Informat kent deze client-ID/secret niet (invalid_client). Kijk Koppelingen › Informat na.") + raise FacturatieFout(f"Aanmelden bij Informat mislukt: {fout}") + return js + + def scopes(self): + """[(scope, toegangsgroep-GUID)] uit de instellingen, of opgezocht bij Informat.""" + ingesteld = [x.strip() for x in re.split(r'[,\s]+', self.school.get('INFORMAT_FACTUUR_SCOPE') or '') if x.strip()] + if ingesteld: + uit = [] + for sc in ingesteld: + m = SCOPE_RE.search(sc) + if not m: + raise FacturatieFout(f"'{sc}' is geen geldige scope voor de Rekeningen API " + "(verwacht: api_informat_invoices.invoices.read._).") + uit.append((m.group(0), f"{m.group(1)}_{m.group(2)}")) # accessGroupIdentifier = _ + return uit + bewaard = self.school.cache.get('factuur_scopes') + if bewaard and time.time() - bewaard[0] < 3600: + return bewaard[1] + uit = self._bewaarde_scopes() + if not uit: + uit, info = self.zoek_scopes() + if not uit: + raise FacturatieFout("PIVOT kon de scope van de Rekeningen API niet zelf vinden. " + info + " Vul bij " + "Koppelingen › Informat de 'Scope Rekeningen API' in " + "(api_informat_invoices.invoices.read._). " + "Informat mailt die scope naar de eigenaar van de app zodra de school toegang geeft.") + self._bewaar_scopes(uit) + self.school.cache['factuur_scopes'] = (time.time(), uit) + return uit + + # ---------------- scope zelf zoeken ---------------- + def _groepnamen(self): + """De ingevulde naam/namen van de toegangsgroep(en) in Rekeningen (veld 'Toegangsgroep Rekeningen').""" + return [x.strip() for x in str(self.school.get('INFORMAT_FACTUUR_GROEP') or '').split(';') if x.strip()] + + def _instelling_sleutel(self): + # Hangt ook af van de ingevulde toegangsgroep: pas je die aan, dan zoekt PIVOT opnieuw + import zlib + groep = ';'.join(n.lower() for n in self._groepnamen()) + return f"factuur_scope_{self.school.code}"[:40] + f"_{zlib.crc32(groep.encode()):08x}" + + def _bewaarde_scopes(self): + """Eerder gevonden scopes van deze school (centrale tabel PIVOT_instellingen).""" + try: + from database import central_query + rij = central_query("SELECT Waarde FROM PIVOT_instellingen WHERE Sleutel = %s", (self._instelling_sleutel(),)) + except Exception: + return [] + waarde = (rij[0]['Waarde'] if rij else '') or '' + return [(f"api_informat_invoices.invoices.read.{g}_{i}", f"{g}_{i}") for g, i in SCOPE_RE.findall(waarde)] + + def _bewaar_scopes(self, uit): + try: + from database import central_query + from services import ipblokkade + ipblokkade.ensure_table() + central_query("INSERT INTO PIVOT_instellingen (Sleutel, Waarde, Bijgewerkt, Door) VALUES (%s, %s, NOW(), 'PIVOT') " + "ON DUPLICATE KEY UPDATE Waarde = VALUES(Waarde), Bijgewerkt = NOW()", + (self._instelling_sleutel(), ' '.join(sc for sc, _ in uit)[:255]), commit=True) + except Exception: + pass + + def vergeet_scopes(self): + self.school.cache.pop('factuur_scopes', None) + try: + from database import central_query + central_query("DELETE FROM PIVOT_instellingen WHERE Sleutel = %s", (self._instelling_sleutel(),), commit=True) + except Exception: + pass + + def _discovery_scopes(self): + """Alle rekeningen-scopes die de identityserver publiek bekendmaakt (.well-known/openid-configuration).""" + basis = Config.INFORMAT_AUTH_URL.split('/connect/')[0].rstrip('/') + r = requests.get(f"{basis}/.well-known/openid-configuration", timeout=30) + r.raise_for_status() + return [sc for sc in (r.json().get('scopes_supported') or []) if SCOPE_RE.fullmatch(str(sc))] + + def _mag(self, scope): + """True als deze client een token voor `scope` krijgt (loopt in een aparte thread: school meegeven).""" + from tenancy import in_school + try: + with in_school(self.school): + self._token(scope) + return True + except FacturatieFout: + return False + except Exception: + return False + + def _naam_toegangsgroep(self, scope, ag): + try: + data = self._gql(scope, Q_NAAM, {'accessGroupIdentifier': ag}) + return ((data.get('accessGroup') or {}).get('name') or '').strip() + except FacturatieFout: + return '' + + def zoek_scopes(self): + """Zoekt de rekeningen-scope(s) van deze school. Geeft ([(scope, accessGroupIdentifier)], uitleg). + 1) token zonder scope: soms staan de toegekende scopes in het antwoord of in het token; + 2) anders: de publieke lijst van scopes van de identityserver, en daarvan enkel die waarvoor deze client + een token krijgt (de toegangsgroepen die scholen aan deze app gaven); + 3) heeft de client toegang tot meer dan één school: de toegangsgroep waarvan de naam het instellingsnummer + (of de naam) van deze school bevat, bv. '000448 BS Irishof Kapellen'.""" + info = '' + kandidaten = [] + try: + kandidaten = [sc for sc in _scopes_uit(self._token_aanvraag(None)) if SCOPE_RE.fullmatch(sc)] + except FacturatieFout: + pass + if not kandidaten: + try: + alle = self._discovery_scopes() + except Exception as e: + return [], f"De lijst van scopes van Informat kon niet gelezen worden ({e})." + if not alle: + return [], "Informat maakt de rekeningen-scopes niet publiek bekend." + if len(alle) > MAX_PROBEER: + return [], f"Informat kent {len(alle)} rekeningen-scopes; te veel om ze allemaal te proberen." + from concurrent.futures import ThreadPoolExecutor + with ThreadPoolExecutor(max_workers=8) as pool: + kandidaten = [sc for sc, ok in zip(alle, pool.map(self._mag, alle)) if ok] + if not kandidaten: + return [], f"Deze Informat-client krijgt voor geen enkele van de {len(alle)} rekeningen-scopes toegang." + paren = [(sc, '_'.join(SCOPE_RE.fullmatch(sc).groups())) for sc in dict.fromkeys(kandidaten)] + gevraagd = [n.lower() for n in self._groepnamen()] + if gevraagd: + # Toegangsgroep ingevuld: enkel de groep(en) met precies die naam (of die de naam bevatten) + namen, juist = [], [] + for sc, ag in paren: + gnaam = self._naam_toegangsgroep(sc, ag) + namen.append(gnaam or ag) + laag = ' '.join(gnaam.lower().split()) + if any(laag == g or g in laag for g in (' '.join(x.split()) for x in gevraagd)): + juist.append((sc, ag)) + if juist: + return juist, '' + return [], ("Geen toegangsgroep gevonden met de naam '" + '; '.join(self._groepnamen()) + "'. Deze client heeft " + "toegang tot: " + ', '.join(namen) + ".") + if len(paren) == 1: + return paren, '' + # Meerdere toegangsgroepen: die van deze school kiezen op naam + from tenancy import informat_campussen + nummers = {n for n, _ in informat_campussen(self.school.waarden.get('INFORMAT_INST_NUMBER'))} + nummers |= {str(self.school.get('INSTELLINGSNUMMER') or '').strip()} - {''} + naam = (self.school.naam or '').lower() + eigen, namen = [], [] + for sc, ag in paren: + gnaam = self._naam_toegangsgroep(sc, ag) + namen.append(gnaam or ag) + laag = gnaam.lower() + if any(n and (n in laag or n.lstrip('0') and re.search(rf'\b0*{re.escape(n.lstrip("0"))}\b', laag)) for n in nummers) \ + or (naam and naam in laag): + eigen.append((sc, ag)) + if eigen: + return eigen, '' + return [], ("Deze client heeft toegang tot meerdere toegangsgroepen (" + ', '.join(namen) + "), maar geen " + "enkele naam bevat het instellingsnummer van deze school.") + + def _token(self, scope): + sleutel = ('factuur_token', scope) + bewaard = self.school.cache.get(sleutel) + if bewaard and time.time() < bewaard[0]: + return bewaard[1] + js = self._token_aanvraag(scope) + self.school.cache[sleutel] = (time.time() + int(js.get('expires_in') or 3600) - 60, js['access_token']) + return js['access_token'] + + # ---------------- GraphQL ---------------- + def _gql(self, scope, query, variabelen): + headers = {'Authorization': f"BEARER {self._token(scope)}", 'Content-Type': 'application/json', + 'Accept': 'application/json'} + try: + r = requests.post(f"{self.base}/graphql", json={'query': query, 'variables': variabelen}, + headers=headers, timeout=TIMEOUT) + except requests.exceptions.Timeout: + raise FacturatieFout("De Rekeningen API van Informat antwoordde niet op tijd. Probeer later opnieuw.") + except Exception as e: + raise FacturatieFout(f"De Rekeningen API van Informat is niet bereikbaar: {e}") + if r.status_code == 429: + raise FacturatieFout("Informat meldt te veel aanvragen (429). Probeer over een minuut opnieuw.") + if r.status_code in (401, 403): + raise FacturatieFout(f"Informat weigert de toegang tot de rekeningen ({r.status_code}).") + if not r.text.strip(): + return {} + try: + js = r.json() + except Exception: + raise FacturatieFout(f"Onverwacht antwoord van de Rekeningen API (HTTP {r.status_code}): {r.text[:200]}") + if js.get('errors'): + raise FacturatieFout("Rekeningen API: " + '; '.join(str(e.get('message', e)) for e in js['errors'])[:400]) + return js.get('data') or {} + + def groepen(self, schooljaar): + """Factuurgroepen van type STUDENT: [{scope, toegangsgroep, id, naam}].""" + uit = [] + for scope, ag in self.scopes(): + data = self._gql(scope, Q_GROEPEN, {'schoolYear': schooljaar, 'accessGroupIdentifier': ag}) + for g in ((data.get('accessGroup') or {}).get('invoiceGroups') or []): + if str(g.get('invoiceGroupType') or '').upper() == 'STUDENT': + uit.append({'scope': scope, 'toegangsgroep': ag, 'id': g.get('id'), 'naam': g.get('name') or f"Groep {g.get('id')}"}) + uit.sort(key=lambda g: str(g['naam']).lower()) + return uit + + def facturen(self, schooljaar, vernieuw=False): + """(facturen, groepen, opgehaald) voor alle leerlingen-factuurgroepen van het schooljaar.""" + sleutel = ('facturen', schooljaar) + bewaard = self.school.cache.get(sleutel) + if bewaard and not vernieuw and time.time() - bewaard[0] < CACHE_SECONDEN: + return bewaard[1], bewaard[2], bewaard[0] + groepen = self.groepen(schooljaar) + rijen = [] + for g in groepen: + data = self._gql(g['scope'], Q_FACTUREN, {'schoolYear': schooljaar, 'accessGroupIdentifier': g['toegangsgroep'], + 'invoiceGroupId': g['id']}) + for grp in ((data.get('accessGroup') or {}).get('invoiceGroups') or []): + for f in grp.get('invoices') or []: + ont = f.get('recipient') or {} + rij = { + 'id': f.get('invoiceId'), + 'toegangsgroep': g['toegangsgroep'], + 'groep_id': g['id'], 'groep': g['naam'], + 'type': str(f.get('invoiceType') or 'INVOICE').upper(), + 'datum': _datum(f.get('printDate')), 'vervaldag': _datum(f.get('dueDate')), + 'totaal': _bedrag(f.get('total')), 'betaald': _bedrag(f.get('paid')), 'open': _bedrag(f.get('open')), + 'ogm': f.get('paymentReference') or '', + 'ogm_factuur': f.get('paymentReferenceInvoice') or '', # bij een creditnota: die van de factuur + 'klas': ont.get('class') or '', 'klasnummer': ont.get('classNumber'), + 'voornaam': (ont.get('firstName') or '').strip(), 'naam': (ont.get('lastName') or '').strip(), + 'stamnummer': ont.get('stamNumber') or '', 'leerling_id': ont.get('databaseId'), + } + rij['status'] = status_van(rij) + rij['teken'] = -1 if rij['type'] == 'CREDIT_NOTE' else 1 # creditnota's: positief bedrag in Informat + rijen.append(rij) + rijen.sort(key=lambda r: (r['naam'].lower(), r['voornaam'].lower(), r['datum'] or date.min)) + nu = time.time() + self.school.cache[sleutel] = (nu, rijen, groepen) + return rijen, groepen, nu + + # ---------------- PDF ---------------- + def pdf(self, toegangsgroep, invoice_id, pogingen=8): + """De PDF van één factuur (bytes), via .../download/{accessGroupIdentifier}/{invoiceId}/pdf. + Bestaat die nog niet (Informat antwoordt dan 400), dan eerst .../create/... (202) en even wachten.""" + scope = next((sc for sc, ag in self.scopes() if ag == toegangsgroep), None) + if not scope: + raise FacturatieFout("Onbekende toegangsgroep voor deze factuur.") + auth = {'Authorization': f"BEARER {self._token(scope)}"} + download = f"{self.base}/download/{toegangsgroep}/{invoice_id}/pdf" + aanmaken = f"{self.base}/create/{toegangsgroep}/{invoice_id}/pdf" + aangevraagd = False + for poging in range(pogingen + 1): + r = requests.get(download, headers=auth, timeout=TIMEOUT) + if r.status_code == 200 and r.content: + return r.content + if r.status_code not in (400, 404): + raise FacturatieFout(f"De PDF kon niet opgehaald worden (HTTP {r.status_code}).") + if not aangevraagd: + c = requests.post(aanmaken, headers=auth, timeout=TIMEOUT) + if c.status_code == 405: + c = requests.get(aanmaken, headers=auth, timeout=TIMEOUT) + if c.status_code not in (200, 201, 202, 204): + raise FacturatieFout(f"Informat kon de PDF niet aanmaken (HTTP {c.status_code}). Controleer of het " + "factuuradres van deze factuur een titel en naam heeft.") + aangevraagd = True + if poging < pogingen: + time.sleep(3) + raise FacturatieFout("Informat is de PDF nog aan het aanmaken (dat kan tot 2 minuten duren). " + "Probeer zo dadelijk opnieuw.") diff --git a/services/gedeelde_drives_service.py b/services/gedeelde_drives_service.py new file mode 100644 index 0000000..27022ee --- /dev/null +++ b/services/gedeelde_drives_service.py @@ -0,0 +1,280 @@ +""" +Google › Gedeelde drives: alle gedeelde drives van het domein, wie er rechten op heeft, en leden beheren. + +Google Drive API v3 namens de beheerder (domeinbrede delegatie), met useDomainAdminAccess=True zodat ook drives +zichtbaar zijn waar de beheerder zelf geen lid van is: + drives.list / drives.create + permissions.list / create / update / delete (supportsAllDrives=True) +Vereist: https://www.googleapis.com/auth/drive (en de Google Drive API aan in het Cloud-project). +""" +import threading +import time +import uuid +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime + +from config import Config +from tenancy import google_credentials, huidige_school, in_school + +SCOPE = 'https://www.googleapis.com/auth/drive' +CACHE_SECONDEN = 10 * 60 +ROLLEN = [('organizer', 'Beheerder'), ('fileOrganizer', 'Inhoudsbeheerder'), ('writer', 'Bijdrager'), + ('commenter', 'Reageerder'), ('reader', 'Lezer')] +ROL_NAAM = dict(ROLLEN) +PERM_VELDEN = 'nextPageToken,permissions(id,type,role,emailAddress,domain,displayName,deleted)' + + +class DriveFout(Exception): + pass + + +def vriendelijk(e): + t = str(e) + laag = t.lower() + if 'unauthorized_client' in laag or 'access_denied' in laag: + return ("Google weigert de toegang: voeg in de Admin-console › Beveiliging › API-beheer › Domeinbrede delegatie " + f"het bereik {SCOPE} toe bij het serviceaccount van PIVOT.") + if 'has not been used' in laag or 'is disabled' in laag or 'accessnotconfigured' in laag: + return "De Google Drive API staat niet aan in het Google Cloud-project van het serviceaccount." + if 'insufficientadministratorprivileges' in laag or 'domain administrator' in laag: + return "De beheerder (Koppelingen › Algemeen) heeft geen beheerdersrechten voor Drive in het Admin-console." + if 'cannotremovelastorganizer' in laag or 'last organizer' in laag: + return "Dit is de laatste beheerder van de drive; voeg eerst een andere beheerder toe." + if 'invalidsharingrequest' in laag or 'shareoutnotpermitted' in laag or 'sharingrateLimitExceeded'.lower() in laag: + return "Google laat dit delen niet toe (bv. delen buiten het domein staat uit voor deze drive of organisatie)." + if 'notfound' in laag.replace(' ', '') or ' 404' in t: + return "Niet gevonden (bestaat de drive, de gebruiker of de groep nog?)." + return t[:300] + + +def _drive(): + from googleapiclient.discovery import build + creds = google_credentials([SCOPE], subject=Config.GOOGLE_ADMIN_EMAIL) + try: + return build('drive', 'v3', credentials=creds, static_discovery=True, cache_discovery=False) + except Exception: + return build('drive', 'v3', credentials=creds, static_discovery=False, cache_discovery=False) + + +def _eigen_domeinen(): + s = huidige_school() + d = {x.lower() for x in (getattr(s, 'domeinen', None) or [s.domein]) if x} + if Config.GOOGLE_ADMIN_EMAIL: + d.add(Config.GOOGLE_ADMIN_EMAIL.lower().partition('@')[2]) + return d + + +def _is_extern(p, domeinen): + if p.get('type') == 'anyone': + return True + if p.get('type') == 'domain': + return (p.get('domain') or '').lower() not in domeinen + mail = (p.get('emailAddress') or '').lower() + return bool(mail) and mail.partition('@')[2] not in domeinen + + +def _leden(svc, drive_id): + uit, token = [], None + while True: + resp = svc.permissions().list(fileId=drive_id, supportsAllDrives=True, useDomainAdminAccess=True, + pageSize=100, pageToken=token, fields=PERM_VELDEN).execute() + uit += resp.get('permissions', []) + token = resp.get('nextPageToken') + if not token: + return uit + + +def _samenvatting(d, leden, domeinen): + return {'id': d['id'], 'naam': d.get('name', ''), 'aangemaakt': (d.get('createdTime') or '')[:10], + 'verborgen': bool(d.get('hidden')), 'leden': len(leden), + 'beheerders': sum(1 for p in leden if p.get('role') == 'organizer'), + 'extern': sum(1 for p in leden if _is_extern(p, domeinen)), + 'groepen': sum(1 for p in leden if p.get('type') == 'group'), + 'fout': None} + + +def overzicht(vernieuw=False): + """[{id, naam, aangemaakt, leden, beheerders, extern, groepen, verborgen, fout}] gesorteerd op naam.""" + school = huidige_school() + bewaard = school.cache.get('gedeelde_drives') + if bewaard and not vernieuw and time.time() - bewaard[0] < CACHE_SECONDEN: + return bewaard[1], bewaard[0] + try: + svc = _drive() + drives, token = [], None + while True: + resp = svc.drives().list(useDomainAdminAccess=True, pageSize=100, pageToken=token, + fields='nextPageToken,drives(id,name,createdTime,hidden)').execute() + drives += resp.get('drives', []) + token = resp.get('nextPageToken') + if not token: + break + except Exception as e: + raise DriveFout(vriendelijk(e)) + domeinen = _eigen_domeinen() + lokaal = threading.local() + alle_leden = {} + + def info(d): + with in_school(school): + if not hasattr(lokaal, 'svc'): + lokaal.svc = _drive() + try: + leden = _leden(lokaal.svc, d['id']) + alle_leden[d['id']] = leden + return _samenvatting(d, leden, domeinen) + except Exception as e: + r = _samenvatting(d, [], domeinen) + r['fout'] = vriendelijk(e) + return r + + with ThreadPoolExecutor(max_workers=6) as pool: + uit = list(pool.map(info, drives)) + uit.sort(key=lambda d: d['naam'].lower()) + nu = time.time() + school.cache['gedeelde_drives'] = (nu, uit) + school.cache['gedeelde_drives_leden'] = alle_leden + return uit, nu + + +def _vergeet(): + huidige_school().cache.pop('gedeelde_drives', None) + + +def drive(drive_id): + """(drive-info, leden) van één drive. Leden gesorteerd: beheerders eerst, dan op naam.""" + svc = _drive() + try: + d = svc.drives().get(driveId=drive_id, useDomainAdminAccess=True, fields='id,name,createdTime,hidden').execute() + leden = _leden(svc, drive_id) + except Exception as e: + raise DriveFout(vriendelijk(e)) + domeinen = _eigen_domeinen() + volgorde = {r: i for i, (r, _) in enumerate(ROLLEN)} + rijen = [{'id': p['id'], 'type': p.get('type'), 'rol': p.get('role'), 'rol_naam': ROL_NAAM.get(p.get('role'), p.get('role')), + 'email': p.get('emailAddress') or (p.get('domain') if p.get('type') == 'domain' else ''), + 'naam': p.get('displayName') or '', 'extern': _is_extern(p, domeinen), 'verwijderd': bool(p.get('deleted'))} + for p in leden] + rijen.sort(key=lambda r: (volgorde.get(r['rol'], 9), (r['naam'] or r['email']).lower())) + return _samenvatting(d, leden, domeinen), rijen + + +def _soort(email): + """'group' als het adres een Google-groep is, anders 'user'.""" + try: + from googleapiclient.discovery import build + creds = google_credentials(Config.GOOGLE_API_SCOPES, subject=Config.GOOGLE_ADMIN_EMAIL) + build('admin', 'directory_v1', credentials=creds, static_discovery=False, cache_discovery=False) \ + .groups().get(groupKey=email, fields='id').execute() + return 'group' + except Exception: + return 'user' + + +def lid_toevoegen(drive_id, email, rol, melding=False): + email = (email or '').strip().lower() + if '@' not in email: + raise DriveFout("Geef een geldig e-mailadres (persoon of groep).") + if rol not in ROL_NAAM: + raise DriveFout("Onbekende rol.") + try: + _drive().permissions().create(fileId=drive_id, supportsAllDrives=True, useDomainAdminAccess=True, + sendNotificationEmail=bool(melding), fields='id', + body={'type': _soort(email), 'role': rol, 'emailAddress': email}).execute() + except Exception as e: + raise DriveFout(vriendelijk(e)) + _vergeet() + + +def lid_wijzigen(drive_id, perm_id, rol): + if rol not in ROL_NAAM: + raise DriveFout("Onbekende rol.") + try: + _drive().permissions().update(fileId=drive_id, permissionId=perm_id, supportsAllDrives=True, + useDomainAdminAccess=True, body={'role': rol}, fields='id').execute() + except Exception as e: + raise DriveFout(vriendelijk(e)) + _vergeet() + + +def lid_verwijderen(drive_id, perm_id): + try: + _drive().permissions().delete(fileId=drive_id, permissionId=perm_id, supportsAllDrives=True, + useDomainAdminAccess=True).execute() + except Exception as e: + raise DriveFout(vriendelijk(e)) + _vergeet() + + +def drive_maken(naam, beheerder=None): + """Nieuwe gedeelde drive (de PIVOT-beheerder wordt eerst beheerder); optioneel meteen een andere beheerder.""" + naam = (naam or '').strip() + if not naam: + raise DriveFout("Geef de drive een naam.") + try: + d = _drive().drives().create(requestId=str(uuid.uuid4()), body={'name': naam[:100]}, fields='id,name').execute() + except Exception as e: + raise DriveFout(vriendelijk(e)) + _vergeet() + if beheerder: + lid_toevoegen(d['id'], beheerder, 'organizer') + return d['id'] + + +# --------------------------------------------------------------------- +# Een gebruiker op alle gedeelde drives vervangen door een andere +# --------------------------------------------------------------------- +def drives_van(email, vernieuw=True): + """Alle gedeelde drives waar `email` lid van is: [{drive, naam, perm, rol, rol_naam}] (vers opgehaald).""" + email = (email or '').strip().lower() + drives, _ = overzicht(vernieuw=vernieuw) + leden = huidige_school().cache.get('gedeelde_drives_leden') or {} + uit = [] + for d in drives: + for p in leden.get(d['id'], []): + adres = (p.get('emailAddress') or '').lower() + # Met een @: exact dat adres; zonder @: een deel van het adres of de naam (bv. 'peeters') + if adres == email or ('@' not in email and email and + (email in adres or email in (p.get('displayName') or '').lower())): + uit.append({'drive': d['id'], 'naam': d['naam'], 'perm': p['id'], 'rol': p.get('role'), + 'rol_naam': ROL_NAAM.get(p.get('role'), p.get('role')), 'email': adres, + 'wie': p.get('displayName') or adres}) + return uit + + +def vervang(oud, nieuw, drive_ids, oud_verwijderen=True, melding=False): + """Op elke gekozen drive: `nieuw` krijgt exact de rol van `oud` (ook als hij al lid was), daarna wordt `oud` + verwijderd (optioneel). Geeft [{naam, ok, melding}].""" + oud, nieuw = (oud or '').strip().lower(), (nieuw or '').strip().lower() + if '@' not in nieuw: + raise DriveFout("Geef een geldig e-mailadres voor de nieuwe gebruiker.") + if oud == nieuw: + raise DriveFout("De oude en de nieuwe gebruiker zijn dezelfde.") + svc, soort, uit = _drive(), _soort(nieuw), [] + kies = set(drive_ids) + for t in drives_van(oud, vernieuw=False): + if t['drive'] not in kies: + continue + try: + leden = _leden(svc, t['drive']) + bestaand = next((p for p in leden if (p.get('emailAddress') or '').lower() == nieuw), None) + if bestaand is None: + svc.permissions().create(fileId=t['drive'], supportsAllDrives=True, useDomainAdminAccess=True, + sendNotificationEmail=bool(melding), fields='id', + body={'type': soort, 'role': t['rol'], 'emailAddress': nieuw}).execute() + tekst = f"{nieuw} toegevoegd als {t['rol_naam']}" + elif bestaand.get('role') != t['rol']: # altijd exact dezelfde rechten als de oude gebruiker + svc.permissions().update(fileId=t['drive'], permissionId=bestaand['id'], supportsAllDrives=True, + useDomainAdminAccess=True, body={'role': t['rol']}, fields='id').execute() + tekst = f"{nieuw} was al lid, nu {t['rol_naam']}" + else: + tekst = f"{nieuw} was al lid ({ROL_NAAM.get(bestaand.get('role'))})" + if oud_verwijderen: + svc.permissions().delete(fileId=t['drive'], permissionId=t['perm'], supportsAllDrives=True, + useDomainAdminAccess=True).execute() + tekst += f", {oud} verwijderd" + uit.append({'naam': t['naam'], 'ok': True, 'melding': tekst}) + except Exception as e: + uit.append({'naam': t['naam'], 'ok': False, 'melding': vriendelijk(e)}) + _vergeet() + return uit diff --git a/services/gmail_delegatie_overzicht.py b/services/gmail_delegatie_overzicht.py new file mode 100644 index 0000000..1c732c3 --- /dev/null +++ b/services/gmail_delegatie_overzicht.py @@ -0,0 +1,169 @@ +""" +Overzicht van alle lopende e-mailmachtigingen (Gmail-delegaties) van de school. + +Google heeft geen lijst "alle delegaties van het domein": je kan ze enkel per mailbox opvragen +(users.settings.delegates.list, in naam van de eigenaar). Daarom loopt er een achtergrondtaak die +alle actieve (niet-geschorste) gebruikers afgaat, met een paar parallelle verzoeken. +Het resultaat komt in de tabel gmail_delegaties en wordt: + - volledig vernieuwd met de knop op de pagina, of vanzelf als het ouder is dan VERVERS_UREN; + - meteen bijgewerkt als in PIVOT een machtiging toegevoegd of ingetrokken wordt, of als je een mailbox opent. +""" +import sys +import threading +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime + +from database import execute_query +from tenancy import school_singleton, met_school, huidige_school, in_school + +VERVERS_UREN = 24 +PARALLEL = 8 + + +class DelegatieOverzicht: + def __new__(cls): + def init(inst): + inst._lock = threading.Lock() + inst.status = {'bezig': False, 'voortgang': 0, 'totaal': 0, 'fout': None, 'resultaat': None} + return school_singleton(cls, init) + + @staticmethod + def ensure_table(): + execute_query(""" + CREATE TABLE IF NOT EXISTS gmail_delegaties ( + Mailbox VARCHAR(255) NOT NULL, + Mailbox_naam VARCHAR(255) NULL, + Gedelegeerde VARCHAR(255) NOT NULL, + Gedelegeerde_naam VARCHAR(255) NULL, + Status VARCHAR(20) NULL, + Opgehaald DATETIME NOT NULL, + PRIMARY KEY (Mailbox, Gedelegeerde), + KEY idx_gmail_deleg_gedelegeerde (Gedelegeerde) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci""", commit=True) + execute_query("CREATE TABLE IF NOT EXISTS gmail_delegaties_scan (ID TINYINT NOT NULL PRIMARY KEY, " + "Opgehaald DATETIME NULL, Mailboxen INT NULL, Fouten INT NULL) ENGINE=InnoDB", commit=True) + + # ---------------- opvragen ---------------- + def alle(self): + return execute_query("SELECT * FROM gmail_delegaties ORDER BY Mailbox, Gedelegeerde") or [] + + def laatst_opgehaald(self): + rij = execute_query("SELECT Opgehaald, Mailboxen, Fouten FROM gmail_delegaties_scan WHERE ID = 1") + return rij[0] if rij else None + + def verouderd(self): + laatst = self.laatst_opgehaald() + return not laatst or not laatst['Opgehaald'] or \ + (datetime.now() - laatst['Opgehaald']).total_seconds() > VERVERS_UREN * 3600 + + # ---------------- één mailbox bijwerken (na toevoegen/intrekken/openen in PIVOT) ---------------- + def bewaar_mailbox(self, mailbox, naam, delegates, namen=None): + """Vervangt de rijen van één mailbox door de opgegeven gedelegeerden [{'email','status'}].""" + try: + self.ensure_table() + # Namen die we al kennen (uit de volledige ophaling) behouden + bekend = {} + for r in execute_query("SELECT Mailbox, Mailbox_naam, Gedelegeerde, Gedelegeerde_naam FROM gmail_delegaties") or []: + if r['Mailbox_naam']: + bekend[r['Mailbox'].lower()] = r['Mailbox_naam'] + if r['Gedelegeerde_naam']: + bekend[r['Gedelegeerde'].lower()] = r['Gedelegeerde_naam'] + namen = {**bekend, **(namen or {})} + execute_query("DELETE FROM gmail_delegaties WHERE Mailbox = %s", (mailbox,), commit=True) + for d in delegates: + execute_query("INSERT INTO gmail_delegaties (Mailbox, Mailbox_naam, Gedelegeerde, Gedelegeerde_naam, " + "Status, Opgehaald) VALUES (%s, %s, %s, %s, %s, NOW())", + (mailbox, naam, d['email'], namen.get(d['email'].lower()) or d.get('naam'), + d.get('status')), commit=True) + except Exception as e: + sys.stderr.write(f"Delegaties: mailbox {mailbox} niet bewaard: {e}\n") + + # ---------------- alles ophalen uit Google ---------------- + def _gebruikers(self, directory): + uit, token = [], None + while True: + resp = directory.users().list(customer='my_customer', maxResults=500, pageToken=token, + fields='nextPageToken,users(primaryEmail,name/fullName,suspended)').execute() + for u in resp.get('users', []): + uit.append({'email': u['primaryEmail'].lower(), 'naam': (u.get('name') or {}).get('fullName', ''), + 'geschorst': bool(u.get('suspended'))}) + token = resp.get('nextPageToken') + if not token: + return uit + + def sync(self): + if not self._lock.acquire(blocking=False): + return False, "Er loopt al een ophaling." + try: + from services.gmail_delegation_service import GmailDelegationService, DelegationError + self.status.update(bezig=True, voortgang=0, totaal=0, fout=None, resultaat=None) + self.ensure_table() + svc = GmailDelegationService() + gebruikers = self._gebruikers(svc._directory_api()) + namen = {u['email']: u['naam'] for u in gebruikers} + actief = [u for u in gebruikers if not u['geschorst']] + self.status['totaal'] = len(actief) + school = huidige_school() + fouten, rechtenfout, gelukt = [], [], [] + + def een(u): + # Krijgt Google telkens dezelfde rechtenfout, dan niet alle gebruikers blijven proberen + if len(rechtenfout) >= 3 and not gelukt: + self.status['voortgang'] += 1 + return u, None + with in_school(school): + try: + delegates = svc.list_delegates(u['email']) + gelukt.append(1) + return u, delegates + except DelegationError as e: + tekst = str(e) + if 'serviceaccount mist een Gmail-recht' in tekst or 'Gmail API staat nog niet aan' in tekst: + rechtenfout.append(tekst) + fouten.append(u['email']) + return u, None + except Exception: + fouten.append(u['email']) + return u, None + finally: + self.status['voortgang'] += 1 + + resultaten = [] + with ThreadPoolExecutor(max_workers=PARALLEL) as pool: + for u, delegates in pool.map(een, actief): + if delegates: + resultaten.append((u, delegates)) + if rechtenfout and not gelukt: + raise RuntimeError(rechtenfout[0]) + + nu = datetime.now() + execute_query("DELETE FROM gmail_delegaties", commit=True) + aantal = 0 + for u, delegates in resultaten: + for d in delegates: + execute_query("INSERT INTO gmail_delegaties (Mailbox, Mailbox_naam, Gedelegeerde, Gedelegeerde_naam, " + "Status, Opgehaald) VALUES (%s, %s, %s, %s, %s, %s)", + (u['email'], u['naam'], d['email'], namen.get(d['email'].lower()), d['status'], nu), + commit=True) + aantal += 1 + execute_query("INSERT INTO gmail_delegaties_scan (ID, Opgehaald, Mailboxen, Fouten) VALUES (1, %s, %s, %s) " + "ON DUPLICATE KEY UPDATE Opgehaald = VALUES(Opgehaald), Mailboxen = VALUES(Mailboxen), " + "Fouten = VALUES(Fouten)", (nu, len(actief), len(fouten)), commit=True) + msg = f"{len(actief)} mailboxen nagekeken: {aantal} machtiging(en) bij {len(resultaten)} mailbox(en)." + if fouten: + msg += f" {len(fouten)} mailbox(en) konden niet bevraagd worden (bv. zonder Gmail-licentie)." + self.status['resultaat'] = msg + return True, msg + except Exception as e: + self.status['fout'] = str(e) + return False, f"Ophalen mislukt: {e}" + finally: + self.status['bezig'] = False + self._lock.release() + + def sync_op_achtergrond(self): + if self.status['bezig']: + return False + self.status.update(bezig=True, voortgang=0, totaal=0, fout=None) + threading.Thread(target=met_school(self.sync), daemon=True).start() + return True diff --git a/services/gmail_delegation_service.py b/services/gmail_delegation_service.py new file mode 100644 index 0000000..467f4d3 --- /dev/null +++ b/services/gmail_delegation_service.py @@ -0,0 +1,199 @@ +""" +Gmail-delegatie: andere gebruikers toegang geven tot een mailbox. + +Werkt via de Gmail API (users.settings.delegates). Het service-account handelt +in naam van de EIGENAAR van de mailbox (de delegator). + +Vereist (eenmalig): + 1. Admin-console › Beveiliging › API-beheer › Domeinbrede delegatie: + scope toevoegen aan het service-account: + https://www.googleapis.com/auth/gmail.settings.sharing + 2. Admin-console › Apps › Google Workspace › Gmail › Gebruikersinstellingen: + 'E-maildelegatie' inschakelen voor de betrokken organisatie-eenheden. + +Beperkingen van Google: + - de gedelegeerde moet in dezelfde Google Workspace-organisatie zitten + - max. 25 gedelegeerden per mailbox, max. 10 mailboxen per gedelegeerde + - het kan tot 1 minuut duren voor de toegang actief is +""" +import sys +from googleapiclient.discovery import build +from googleapiclient.errors import HttpError +from config import Config +from database import execute_query +from tenancy import google_credentials + +# Toevoegen/verwijderen van gedelegeerden: gmail.settings.sharing. +# Opvragen (delegates.list/get): gmail.settings.basic (sharing volstaat daar NIET). +GMAIL_SCOPE = 'https://www.googleapis.com/auth/gmail.settings.sharing' +GMAIL_LEES_SCOPE = 'https://www.googleapis.com/auth/gmail.settings.basic' +MAX_DELEGATES = 25 + +STATUS_LABELS = { + 'accepted': 'Actief', + 'pending': 'In afwachting', + 'rejected': 'Geweigerd', + 'expired': 'Verlopen', +} + + +class DelegationError(Exception): + pass + + +class GmailDelegationService: + def __init__(self, directory_service=None): + # Bestaande Directory-client hergebruiken om adressen/aliassen op te zoeken + self._directory = directory_service + + # ---------- clients ---------- + def _gmail_for(self, mailbox, scope=GMAIL_SCOPE): + creds = google_credentials([scope], subject=mailbox) + try: # meegeleverde API-beschrijving: geen extra verzoek naar Google per mailbox + return build('gmail', 'v1', credentials=creds, static_discovery=True, cache_discovery=False) + except Exception: + return build('gmail', 'v1', credentials=creds, static_discovery=False, cache_discovery=False) + + def _directory_api(self): + if self._directory is None: + from services.google_service import GoogleAdminService + self._directory = GoogleAdminService().service + return self._directory + + # ---------- hulpfuncties ---------- + @staticmethod + def _friendly_error(e, mailbox=None, delegate=None): + msg = str(e) + status = getattr(getattr(e, 'resp', None), 'status', None) + if 'unauthorized_client' in msg or 'access_denied' in msg or 'insufficient authentication scopes' in msg.lower(): + return ("Google weigert de toegang: het serviceaccount mist een Gmail-recht. Voeg in admin.google.com › " + "Beveiliging › Toegangs- en gegevensbeheer › API-beheer › Domeinbrede delegatie bij het serviceaccount " + f"deze twee bereiken toe: {GMAIL_LEES_SCOPE} en {GMAIL_SCOPE}. Het kan enkele minuten duren voor dit werkt.") + if status == 409: + return f"{delegate} heeft al toegang tot {mailbox}." + laag = msg.lower() + reden = msg.split('returned', 1)[-1].strip(' ">')[:200] if 'returned' in msg else msg[:200] + if 'has not been used' in laag or 'is disabled' in laag or 'accessnotconfigured' in laag: + return ("De Gmail API staat nog niet aan in het Google Cloud-project van het serviceaccount " + "(console.cloud.google.com › API's en services › Bibliotheek › Gmail API › Inschakelen). " + f"Google: {reden}") + if 'mail service not enabled' in laag or 'failedprecondition' in laag or 'precondition' in laag: + return (f"{mailbox or 'Deze gebruiker'} heeft (nog) geen werkende Gmail-mailbox: controleer of Gmail aanstaat " + "en of de gebruiker een licentie heeft. Een net aangemaakt account kan tot 24 uur nodig hebben. " + f"Google: {reden}") + if 'delegation denied' in laag or 'not allowed' in laag or 'delegation is not allowed' in laag: + return ("Delegatie is niet toegestaan voor deze gebruiker. Controleer of 'E-maildelegatie' aanstaat " + "(Admin-console › Apps › Google Workspace › Gmail › Gebruikersinstellingen) voor de OU van de " + "mailbox én van de gedelegeerde. Een gewijzigde instelling kan tot 24 uur nodig hebben. " + f"Google: {reden}") + if status == 403: + return f"Google weigert deze actie (403). Google: {reden}" + if status == 400 and 'limit' in msg.lower(): + return "Limiet bereikt: max. 25 gedelegeerden per mailbox en max. 10 mailboxen per gedelegeerde." + if status == 400: + return f"Google weigert deze delegatie: {getattr(e, 'reason', '') or msg}" + if status == 404: + return "Gebruiker of delegatie niet gevonden." + return msg + + def resolve_user(self, email): + """Zoekt de gebruiker op in Google en geeft het PRIMAIRE adres terug (aliassen werken niet bij delegatie).""" + email = (email or '').strip().lower() + if not email or '@' not in email: + raise DelegationError("Geef een volledig e-mailadres op.") + try: + u = self._directory_api().users().get(userKey=email).execute() + except HttpError as e: + if getattr(e.resp, 'status', None) == 404: + raise DelegationError(f"{email} bestaat niet in jullie Google Workspace. " + "Delegeren naar externe (bv. @gmail.com) adressen kan niet.") + raise DelegationError(self._friendly_error(e)) + return { + 'email': u['primaryEmail'].lower(), + 'naam': (u.get('name') or {}).get('fullName', ''), + 'ou': u.get('orgUnitPath', ''), + 'geschorst': bool(u.get('suspended')), + 'is_alias': u['primaryEmail'].lower() != email, + } + + def search_users(self, query): + """Gebruikers zoeken op naam of adres (voor de zoekbox).""" + q = query.strip() + try: + resp = self._directory_api().users().list( + customer='my_customer', query=q, maxResults=15, orderBy='email').execute() + except Exception as e: + sys.stderr.write(f"Zoeken mislukt: {e}\n") + return [] + return [{'email': u['primaryEmail'], 'naam': (u.get('name') or {}).get('fullName', ''), + 'ou': u.get('orgUnitPath', '')} for u in resp.get('users', [])] + + # ---------- delegaties ---------- + def list_delegates(self, mailbox): + try: + resp = self._gmail_for(mailbox, GMAIL_LEES_SCOPE).users().settings().delegates().list(userId='me').execute() + except HttpError as e: + raise DelegationError(self._friendly_error(e, mailbox)) + except Exception as e: + raise DelegationError(self._friendly_error(e, mailbox)) + result = [] + for d in resp.get('delegates', []): + status = d.get('verificationStatus', '') + result.append({'email': d.get('delegateEmail', ''), + 'status': status, + 'status_label': STATUS_LABELS.get(status, status)}) + result.sort(key=lambda d: d['email']) + return result + + def add_delegate(self, mailbox, delegate): + if mailbox == delegate: + raise DelegationError("Een mailbox kan niet aan zichzelf gedelegeerd worden.") + try: + self._gmail_for(mailbox).users().settings().delegates().create( + userId='me', body={'delegateEmail': delegate}).execute() + except HttpError as e: + raise DelegationError(self._friendly_error(e, mailbox, delegate)) + except Exception as e: + raise DelegationError(self._friendly_error(e, mailbox, delegate)) + + def remove_delegate(self, mailbox, delegate): + try: + self._gmail_for(mailbox).users().settings().delegates().delete( + userId='me', delegateEmail=delegate).execute() + except HttpError as e: + raise DelegationError(self._friendly_error(e, mailbox, delegate)) + except Exception as e: + raise DelegationError(self._friendly_error(e, mailbox, delegate)) + + # ---------- logboek ---------- + @staticmethod + def ensure_table(): + execute_query(""" + CREATE TABLE IF NOT EXISTS gmail_delegatie_log ( + ID INT AUTO_INCREMENT PRIMARY KEY, + Tijdstip DATETIME NOT NULL, + Gebruiker VARCHAR(100) NULL, + Actie VARCHAR(20) NOT NULL, + Mailbox VARCHAR(255) NOT NULL, + Gedelegeerde VARCHAR(255) NOT NULL, + INDEX idx_mailbox (Mailbox), + INDEX idx_tijdstip (Tijdstip) + )""", commit=True) + + @staticmethod + def log(gebruiker, actie, mailbox, gedelegeerde): + execute_query( + "INSERT INTO gmail_delegatie_log (Tijdstip, Gebruiker, Actie, Mailbox, Gedelegeerde) " + "VALUES (NOW(), %s, %s, %s, %s)", + (gebruiker, actie, mailbox, gedelegeerde), commit=True) + + @staticmethod + def history(mailbox=None, limit=25): + if mailbox: + return execute_query( + "SELECT Tijdstip, Gebruiker, Actie, Mailbox, Gedelegeerde FROM gmail_delegatie_log " + "WHERE Mailbox = %s OR Gedelegeerde = %s ORDER BY Tijdstip DESC LIMIT %s", + (mailbox, mailbox, int(limit))) + return execute_query( + "SELECT Tijdstip, Gebruiker, Actie, Mailbox, Gedelegeerde FROM gmail_delegatie_log " + "ORDER BY Tijdstip DESC LIMIT %s", (int(limit),)) diff --git a/services/gmail_label_service.py b/services/gmail_label_service.py new file mode 100644 index 0000000..046622e --- /dev/null +++ b/services/gmail_label_service.py @@ -0,0 +1,237 @@ +""" +Google › E-mails met een label verplaatsen: alle berichten met een bepaald label uit een mailbox kopiëren of +verplaatsen naar een andere mailbox van dezelfde Google Workspace. + +Werking (Gmail API, het serviceaccount werkt in naam van de mailbox via domeinbrede delegatie): +1. bron: users.messages.list(labelIds=[label]) → alle berichten met het label +2. bron: users.messages.get(format=raw) → het volledige bericht (met bijlagen, datum, afzender ...) +3. doel: users.messages.import(raw, labelIds=[...]) → in de doelmailbox, onder hetzelfde label + (de originele datum blijft, geen spamfilter; gelezen/ongelezen en ster gaan mee) +4. bij 'verplaatsen': bron: users.messages.trash → in de bronmailbox naar de prullenbak (30 dagen terug te halen) +Elk bericht dat al gekopieerd werd naar dezelfde doelmailbox, wordt overgeslagen (tabel gmail_label_items), +zodat je een onderbroken opdracht gewoon opnieuw kan starten. + +Vereist (eenmalig, Admin-console › Beveiliging › API-beheer › Domeinbrede delegatie, bij het serviceaccount): + https://www.googleapis.com/auth/gmail.modify +""" +import sys +import threading +import time +from datetime import datetime + +from database import execute_query +from tenancy import school_singleton, met_school, google_credentials + +SCOPE = 'https://www.googleapis.com/auth/gmail.modify' +MEEGAAN = ('UNREAD', 'STARRED', 'IMPORTANT') # systeemlabels die mee naar de doelmailbox gaan + + +class LabelFout(Exception): + pass + + +def _gmail(mailbox): + from googleapiclient.discovery import build + creds = google_credentials([SCOPE], subject=mailbox) + try: + return build('gmail', 'v1', credentials=creds, static_discovery=True, cache_discovery=False) + except Exception: + return build('gmail', 'v1', credentials=creds, static_discovery=False, cache_discovery=False) + + +def vriendelijk(e, mailbox=None): + tekst = str(e) + laag = tekst.lower() + if 'unauthorized_client' in laag or 'insufficient' in laag or 'access_denied' in laag: + return ("Google weigert de toegang: het serviceaccount mist het Gmail-recht om berichten te lezen en te " + "importeren. Voeg in admin.google.com › Beveiliging › Toegangs- en gegevensbeheer › API-beheer › " + f"Domeinbrede delegatie bij het serviceaccount dit bereik toe: {SCOPE} (het kan enkele minuten duren).") + if 'has not been used' in laag or 'is disabled' in laag or 'accessnotconfigured' in laag: + return ("De Gmail API staat nog niet aan in het Google Cloud-project van het serviceaccount " + "(console.cloud.google.com › API's en services › Bibliotheek › Gmail API › Inschakelen).") + if 'mail service not enabled' in laag or 'failedprecondition' in laag: + return f"{mailbox or 'Deze gebruiker'} heeft geen werkende Gmail-mailbox (geen licentie of Gmail uitgeschakeld)." + if 'notfound' in laag.replace(' ', '') or ' 404' in tekst: + return "Niet gevonden (bestaat de mailbox of het label nog?)." + return tekst[:300] + + +def ensure_tables(): + execute_query(""" + CREATE TABLE IF NOT EXISTS gmail_label_log ( + ID INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + Gestart DATETIME NOT NULL, + Klaar DATETIME NULL, + Door VARCHAR(255) NULL, + Bron VARCHAR(255) NOT NULL, + Label VARCHAR(255) NOT NULL, + Doel VARCHAR(255) NOT NULL, + Modus VARCHAR(12) NOT NULL, + Totaal INT NULL, + Gelukt INT NULL, + Overgeslagen INT NULL, + Fouten INT NULL, + Melding VARCHAR(500) NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci""", commit=True) + execute_query(""" + CREATE TABLE IF NOT EXISTS gmail_label_items ( + Bron VARCHAR(255) NOT NULL, + Bron_ID VARCHAR(64) NOT NULL, + Doel VARCHAR(255) NOT NULL, + Doel_ID VARCHAR(64) NULL, + Gekopieerd DATETIME NOT NULL, + PRIMARY KEY (Bron, Bron_ID, Doel) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci""", commit=True) + + +def labels(mailbox): + """Eigen labels van een mailbox, met het aantal berichten: [{'id', 'naam', 'aantal'}], alfabetisch.""" + try: + g = _gmail(mailbox) + lijst = g.users().labels().list(userId='me').execute().get('labels', []) + uit = [] + for l in lijst: + if l.get('type') != 'user': + continue + try: + info = g.users().labels().get(userId='me', id=l['id']).execute() + aantal = info.get('messagesTotal') + except Exception: + aantal = None + uit.append({'id': l['id'], 'naam': l.get('name', ''), 'aantal': aantal}) + except Exception as e: + raise LabelFout(vriendelijk(e, mailbox)) + return sorted(uit, key=lambda l: l['naam'].lower()) + + +def geschiedenis(limiet=20): + ensure_tables() + return execute_query("SELECT * FROM gmail_label_log ORDER BY Gestart DESC LIMIT %s", (int(limiet),)) or [] + + +class LabelVerplaatser: + def __new__(cls): + def init(inst): + inst._lock = threading.Lock() + inst.status = {'bezig': False, 'voortgang': 0, 'totaal': 0, 'gelukt': 0, 'overgeslagen': 0, 'fouten': 0, + 'fout': None, 'resultaat': None, 'opdracht': None, 'foutvoorbeelden': []} + return school_singleton(cls, init) + + # ---------------- hulpfuncties ---------------- + @staticmethod + def _alle_ids(g, label_id): + ids, token = [], None + while True: + resp = g.users().messages().list(userId='me', labelIds=[label_id], maxResults=500, pageToken=token, + includeSpamTrash=False).execute() + ids += [m['id'] for m in resp.get('messages', [])] + token = resp.get('nextPageToken') + if not token: + return ids + + @staticmethod + def _doel_label(g, naam): + """Id van het label `naam` in de doelmailbox (aangemaakt als het nog niet bestaat, ook sublabels).""" + bestaand = {l['name']: l['id'] for l in g.users().labels().list(userId='me').execute().get('labels', [])} + if naam in bestaand: + return bestaand[naam] + # Bovenliggende labels eerst aanmaken (A, A/B, A/B/C), zodat de nesting in Gmail klopt + delen = naam.split('/') + for i in range(1, len(delen) + 1): + pad = '/'.join(delen[:i]) + if pad not in bestaand: + nieuw = g.users().labels().create(userId='me', body={ + 'name': pad, 'labelListVisibility': 'labelShow', 'messageListVisibility': 'show'}).execute() + bestaand[pad] = nieuw['id'] + return bestaand[naam] + + @staticmethod + def _herhaal(fn, pogingen=4): + """Google-aanroep met een paar nieuwe pogingen bij 429/5xx (te snel / tijdelijk).""" + for poging in range(pogingen): + try: + return fn() + except Exception as e: + status = getattr(getattr(e, 'resp', None), 'status', None) + if status in (429, 500, 502, 503) and poging < pogingen - 1: + time.sleep(2 ** poging) + continue + raise + + # ---------------- opdracht ---------------- + def run(self, bron, label_id, label_naam, doel, modus, door): + if not self._lock.acquire(blocking=False): + return False, "Er loopt al een opdracht." + log_id = None + try: + ensure_tables() + self.status.update(bezig=True, voortgang=0, totaal=0, gelukt=0, overgeslagen=0, fouten=0, prullenbak=0, fout=None, + resultaat=None, foutvoorbeelden=[], + opdracht={'bron': bron, 'label': label_naam, 'doel': doel, 'modus': modus}) + execute_query("INSERT INTO gmail_label_log (Gestart, Door, Bron, Label, Doel, Modus) " + "VALUES (NOW(), %s, %s, %s, %s, %s)", (door, bron, label_naam, doel, modus), commit=True) + log_id = (execute_query("SELECT MAX(ID) AS id FROM gmail_label_log WHERE Bron = %s AND Doel = %s", + (bron, doel)) or [{}])[0].get('id') + try: + gb, gd = _gmail(bron), _gmail(doel) + ids = self._alle_ids(gb, label_id) + doel_label = self._doel_label(gd, label_naam) + except Exception as e: + raise LabelFout(vriendelijk(e)) + self.status['totaal'] = len(ids) + al = {r['Bron_ID'] for r in execute_query( + "SELECT Bron_ID FROM gmail_label_items WHERE Bron = %s AND Doel = %s", (bron, doel)) or []} + for mid in ids: + try: + if mid in al: + self.status['overgeslagen'] += 1 + else: + bericht = self._herhaal(lambda: gb.users().messages().get(userId='me', id=mid, format='raw').execute()) + extra = [l for l in bericht.get('labelIds', []) if l in MEEGAAN] + nieuw = self._herhaal(lambda: gd.users().messages().import_( + userId='me', body={'raw': bericht['raw'], 'labelIds': [doel_label] + extra}, + internalDateSource='dateHeader', neverMarkSpam=True, processForCalendar=False).execute()) + execute_query("INSERT IGNORE INTO gmail_label_items (Bron, Bron_ID, Doel, Doel_ID, Gekopieerd) " + "VALUES (%s, %s, %s, %s, NOW())", (bron, mid, doel, nieuw.get('id')), commit=True) + self.status['gelukt'] += 1 + if modus == 'verplaatsen': + self._herhaal(lambda: gb.users().messages().trash(userId='me', id=mid).execute()) + self.status['prullenbak'] = self.status.get('prullenbak', 0) + 1 + except Exception as e: + self.status['fouten'] += 1 + if len(self.status['foutvoorbeelden']) < 5: + self.status['foutvoorbeelden'].append(vriendelijk(e)[:200]) + sys.stderr.write(f"Label verplaatsen {bron} → {doel}, bericht {mid}: {e}\n") + self.status['voortgang'] += 1 + s = self.status + msg = f"Label '{label_naam}' van {bron} naar {doel}: {s['gelukt']} bericht(en) gekopieerd" + if s['overgeslagen']: + msg += f", {s['overgeslagen']} stonden er al" + if modus == 'verplaatsen': + msg += f", {s.get('prullenbak', 0)} in de bronmailbox naar de prullenbak" + if s['fouten']: + msg += f", {s['fouten']} mislukt" + s['resultaat'] = msg + '.' + return True, s['resultaat'] + except Exception as e: + self.status['fout'] = vriendelijk(e) + return False, self.status['fout'] + finally: + s = self.status + if log_id: + try: + execute_query("UPDATE gmail_label_log SET Klaar = NOW(), Totaal = %s, Gelukt = %s, Overgeslagen = %s, " + "Fouten = %s, Melding = %s WHERE ID = %s", + (s['totaal'], s['gelukt'], s['overgeslagen'], s['fouten'], + (s['fout'] or s['resultaat'] or '')[:500], log_id), commit=True) + except Exception: + pass + s['bezig'] = False + self._lock.release() + + def start(self, *args): + if self.status['bezig']: + return False + self.status.update(bezig=True, voortgang=0, totaal=0, fout=None, resultaat=None) + threading.Thread(target=met_school(lambda: self.run(*args)), daemon=True).start() + return True diff --git a/services/google_license_service.py b/services/google_license_service.py new file mode 100644 index 0000000..354dc1e --- /dev/null +++ b/services/google_license_service.py @@ -0,0 +1,276 @@ +""" +Google Workspace for Education – licentie-overzicht. + +Haalt via de Enterprise License Manager API op welke gebruikers een +licentie hebben. Google geeft het aantal AANGEKOCHTE licenties niet vrij +via de API; dat aantal wordt in PIVOT bijgehouden (tabel google_licenties) +en door een admin ingevuld. + +Vereist (eenmalig, Google Admin-console): + Beveiliging > Toegangs- en gegevensbeheer > API-beheer > + Domeinbrede delegatie > service-account bewerken en deze scope toevoegen: + https://www.googleapis.com/auth/apps.licensing +""" +import sys +import time +from googleapiclient.discovery import build +from config import Config +from database import execute_query +from tenancy import google_credentials + +LICENSING_SCOPE = 'https://www.googleapis.com/auth/apps.licensing' + +# Product- en SKU-ID's volgens Google: +# https://developers.google.com/workspace/admin/licensing/v1/how-tos/products +EDU_PRODUCT_ID = '101031' +SKU_NAMES = { + '1010310008': 'Education Plus', + '1010310009': 'Education Plus (Staff)', + '1010310010': 'Education Plus (Extra Student)', + '1010310005': 'Education Standard', + '1010310006': 'Education Standard (Staff)', + '1010310007': 'Education Standard (Extra Student)', +} +# Deze SKU's worden altijd getoond, ook als er (nog) niemand een licentie heeft +DEFAULT_SKUS = ['1010310008'] +# Licenties die de scholengroep niet gebruikt: niet tonen, tenzij iemand er toch (per vergissing) één heeft +VERBORGEN_SKUS = {'1010310009'} # Education Plus (Staff) + +CACHE_SECONDS = 10 * 60 + + +def _soort_fout(e): + """'scope' = delegatie/scope ontbreekt, 'api' = API niet ingeschakeld, anders None.""" + laag = str(e).lower() + if 'unauthorized_client' in laag or 'access_denied' in laag or 'invalid_scope' in laag: + return 'scope' + if 'accessnotconfigured' in laag or 'has not been used' in laag or 'is disabled' in laag \ + or 'service_disabled' in laag: + return 'api' + return None + + +def _google_reden(e): + """De foutboodschap van Google zelf (zonder de lange URL).""" + try: + import json + inhoud = json.loads(getattr(e, 'content', b'') or b'{}') + fout = inhoud.get('error') or {} + if isinstance(fout, dict) and fout.get('message'): + return f"{getattr(getattr(e, 'resp', None), 'status', '')} {fout['message']}".strip() + except Exception: + pass + return str(e)[:250] + + +class GoogleLicenseService: + def __init__(self, directory_service=None): + self._cache = None # (timestamp, data) + self._licensing = None + self._klant = None # customerId die werkt + # Bestaande Directory-client hergebruiken (scopes zijn daar al geautoriseerd) + self._directory = directory_service + + # ---------- API-clients (lui aangemaakt, zodat de app ook start zonder scope) ---------- + def _creds(self, scope): + return google_credentials([scope], subject=Config.GOOGLE_ADMIN_EMAIL) + + def _licensing_api(self): + if self._licensing is None: + self._licensing = build('licensing', 'v1', credentials=self._creds(LICENSING_SCOPE), + static_discovery=False, cache_discovery=False) + return self._licensing + + def _directory_api(self): + if self._directory is None: + from services.google_service import GoogleAdminService + self._directory = GoogleAdminService().service + return self._directory + + # ---------- Database: aangekochte aantallen ---------- + @staticmethod + def ensure_table(): + execute_query(""" + CREATE TABLE IF NOT EXISTS google_licenties ( + SKU_ID VARCHAR(32) NOT NULL PRIMARY KEY, + Naam VARCHAR(128) NOT NULL, + Aangekocht INT NOT NULL DEFAULT 0, + Bijgewerkt DATETIME NULL, + Bijgewerkt_door VARCHAR(100) NULL + )""", commit=True) + + def get_purchased(self): + rows = execute_query("SELECT SKU_ID, Naam, Aangekocht, Bijgewerkt, Bijgewerkt_door FROM google_licenties") + return {r['SKU_ID']: r for r in rows} + + def set_purchased(self, sku_id, aantal, gebruiker): + naam = SKU_NAMES.get(sku_id, sku_id) + execute_query(""" + INSERT INTO google_licenties (SKU_ID, Naam, Aangekocht, Bijgewerkt, Bijgewerkt_door) + VALUES (%s, %s, %s, NOW(), %s) + ON DUPLICATE KEY UPDATE Aangekocht = VALUES(Aangekocht), Naam = VALUES(Naam), + Bijgewerkt = NOW(), Bijgewerkt_door = VALUES(Bijgewerkt_door) + """, (sku_id, naam, int(aantal), gebruiker), commit=True) + + # ---------- Google: toegewezen licenties ---------- + def _klant_kandidaten(self): + """Waarden om als customerId te proberen. De Licensing API wil het PRIMAIRE domein van de Google-klant + (of de klant-ID, bv. C01abc23d). Het domein dat in PIVOT bij de school staat, is soms een secundair domein + of alias: dan antwoordt Google met 403/400. Daarom ook de klant-ID van de beheerder proberen.""" + kandidaten = [Config.SCHOOL_DOMAIN] + try: + u = self._directory_api().users().get(userKey=Config.GOOGLE_ADMIN_EMAIL, + fields='customerId,primaryEmail').execute() + kandidaten += [u.get('customerId'), (u.get('primaryEmail') or '').partition('@')[2]] + except Exception as e: + sys.stderr.write(f"Licenties: klant-ID van de beheerder niet op te halen: {e}\n") + kandidaten.append((Config.GOOGLE_ADMIN_EMAIL or '').partition('@')[2]) + return [k for k in dict.fromkeys(k.strip() for k in kandidaten if k and k.strip())] + + def _list_assignments(self): + api = self._licensing_api() + laatste = None + for klant in (self._klant_kandidaten() if not getattr(self, '_klant', None) else [self._klant]): + assignments, token = [], None + try: + while True: + resp = api.licenseAssignments().listForProduct( + productId=EDU_PRODUCT_ID, customerId=klant, maxResults=1000, pageToken=token).execute() + assignments.extend(resp.get('items', [])) + token = resp.get('nextPageToken') + if not token: + self._klant = klant # onthouden welke waarde werkt + return assignments + except Exception as e: + status = getattr(getattr(e, 'resp', None), 'status', None) + if status not in (400, 403, 404) or _soort_fout(e) in ('scope', 'api'): + raise + sys.stderr.write(f"Licenties met customerId={klant} mislukt: {e}\n") + laatste = e + raise laatste or RuntimeError("Geen Google-klant gevonden om licenties voor op te vragen.") + + def _uitleg(self, e): + """Begrijpelijke melding met de echte reden van Google en wat je kan nakijken.""" + from tenancy import huidige_school + sleutel = huidige_school().google_sleutel or {} + client_id = sleutel.get('client_id', '?') + project = sleutel.get('project_id', '?') + admin = Config.GOOGLE_ADMIN_EMAIL + soort = _soort_fout(e) + reden = _google_reden(e) + if soort == 'scope': + return (f"Google weigert de toegang (unauthorized_client): voor client-ID {client_id} staat de scope " + f"{LICENSING_SCOPE} niet in de domeinbrede delegatie. Kijk na in de Admin-console van het domein van " + f"{admin} (Beveiliging › API-beheer › Domeinbrede delegatie) dat de scope bij exact deze client-ID " + "staat — elke school met een eigen JSON-sleutel heeft een eigen client-ID. Een wijziging kan tot " + "een uur duren.") + if soort == 'api': + return (f"De Enterprise License Manager API staat niet aan in Google Cloud-project '{project}' " + "(console.cloud.google.com › API's en services › Bibliotheek › 'Enterprise License Manager API' " + f"› Inschakelen). Google: {reden}") + return (f"Google weigert de licenties ({reden}). De delegatie zelf werkt, dus de scope is in orde. " + f"Mogelijke oorzaken: {admin} is geen hoofdbeheerder (super admin) van het domein, of het domein " + f"'{Config.SCHOOL_DOMAIN}' is niet het primaire domein van de Google-klant (ook de klant-ID werd " + f"geprobeerd), of deze school heeft geen Education Standard/Plus-licenties. Service-account: " + f"client-ID {client_id}, project '{project}'.") + + def _user_details(self): + """Alle gebruikers met OU en status, zodat we per licentie context kunnen tonen.""" + api = self._directory_api() + users, token = {}, None + while True: + resp = api.users().list( + customer='my_customer', maxResults=500, pageToken=token, + projection='basic', + fields='nextPageToken,users(primaryEmail,name/fullName,orgUnitPath,suspended,archived,lastLoginTime)' + ).execute() + for u in resp.get('users', []): + users[u['primaryEmail'].lower()] = u + token = resp.get('nextPageToken') + if not token: + return users + + def get_overview(self, force=False): + """ + Geeft (data, fout) terug. data = { + 'skus': [ {sku_id, naam, in_gebruik, aangekocht, vrij, procent, geschorst, ...}, ... ], + 'users': [ {email, naam, ou, sku_id, sku_naam, geschorst, gearchiveerd, laatste_login}, ... ], + 'opgehaald': timestamp + } + """ + if not force and self._cache and time.time() - self._cache[0] < CACHE_SECONDS: + return self._cache[1], None + + try: + assignments = self._list_assignments() + except Exception as e: + sys.stderr.write(f"Licenties ophalen mislukt: {e}\n") + self._licensing = None # volgende keer opnieuw opbouwen (bv. na een nieuwe JSON-sleutel) + self._klant = None + try: + return None, self._uitleg(e) + except Exception: + return None, str(e)[:400] + + try: + directory = self._user_details() + except Exception as e: + sys.stderr.write(f"Gebruikersdetails ophalen mislukt (niet kritisch): {e}\n") + directory = {} + + try: + purchased = self.get_purchased() + except Exception as e: + sys.stderr.write(f"Tabel google_licenties niet leesbaar: {e}\n") + purchased = {} + + users, counts, suspended_counts = [], {}, {} + for a in assignments: + sku = a.get('skuId') + email = (a.get('userId') or '').lower() + d = directory.get(email, {}) + geschorst = bool(d.get('suspended')) + gearchiveerd = bool(d.get('archived')) + counts[sku] = counts.get(sku, 0) + 1 + if geschorst or gearchiveerd: + suspended_counts[sku] = suspended_counts.get(sku, 0) + 1 + users.append({ + 'email': email, + 'naam': (d.get('name') or {}).get('fullName', ''), + 'ou': d.get('orgUnitPath', ''), + 'sku_id': sku, + 'sku_naam': SKU_NAMES.get(sku, a.get('skuName', sku)), + 'geschorst': geschorst, + 'gearchiveerd': gearchiveerd, + 'onbekend': not d, + 'laatste_login': (d.get('lastLoginTime') or '')[:10].replace('1970-01-01', ''), + }) + users.sort(key=lambda u: (u['sku_naam'], u['ou'], u['email'])) + + sku_ids = list(dict.fromkeys(DEFAULT_SKUS + list(counts) + list(purchased))) + skus = [] + for sku in sku_ids: + in_gebruik = counts.get(sku, 0) + if sku in VERBORGEN_SKUS and not in_gebruik: + continue + aangekocht = int(purchased.get(sku, {}).get('Aangekocht') or 0) + vrij = aangekocht - in_gebruik + procent = round(in_gebruik / aangekocht * 100) if aangekocht else None + skus.append({ + 'sku_id': sku, + 'naam': SKU_NAMES.get(sku, purchased.get(sku, {}).get('Naam', sku)), + 'in_gebruik': in_gebruik, + 'aangekocht': aangekocht, + 'vrij': vrij, + 'procent': procent, + 'geschorst': suspended_counts.get(sku, 0), + 'bijgewerkt': purchased.get(sku, {}).get('Bijgewerkt'), + 'bijgewerkt_door': purchased.get(sku, {}).get('Bijgewerkt_door'), + }) + + data = {'skus': skus, 'users': users, 'opgehaald': time.time()} + self._cache = (time.time(), data) + return data, None + + def clear_cache(self): + self._cache = None diff --git a/services/google_service.py b/services/google_service.py new file mode 100644 index 0000000..a514105 --- /dev/null +++ b/services/google_service.py @@ -0,0 +1,391 @@ +from googleapiclient.discovery import build +from config import Config +from tenancy import google_credentials +import sys +from datetime import datetime, timedelta, timezone +import smtplib +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart + +class GoogleAdminService: + def __init__(self): + #lijst met scopes + self.conf = Config() + self.server = Config.GOOGLE_SMTP_SERVER + self.port = Config.GOOGLE_SMTP_PORT + self.sender = Config.GOOGLE_SMTP_SENDER_adres + # Serviceaccount van de huidige school, gedelegeerd naar de admin van die school + self.delegated_creds = google_credentials(self.conf.GOOGLE_API_SCOPES, subject=self.conf.GOOGLE_ADMIN_EMAIL) + self.service = build('admin', 'directory_v1', credentials=self.delegated_creds,static_discovery=False) + + #functie die passwoord aanpast + def change_password(self, email, new_password): + """Wijzigt het wachtwoord van een gebruiker in Google Workspace.""" + try: + self.service.users().update( + userKey=email, + body={'password': new_password, 'changePasswordAtNextLogin': True} + ).execute() + return True, "Wachtwoord succesvol gewijzigd." + except Exception as e: + return False, str(e) + + #Functie die info ophaalt + def get_user_info(self, email): + """Haalt info op over een specifieke gebruiker.""" + try: + user = self.service.users().get(userKey=email).execute() + return user + except: + return None + #Functie die nieuwe gebruiker aanmaakt + def create_user(self, first_name, last_name, password, org_unit_path): + try: + primary_email = f"{first_name.lower()}.{last_name.lower()}@{self.conf.SCHOOL_DOMAIN}" + user_body = { + 'primaryEmail': primary_email, + 'name': {'givenName': first_name.capitalize(), 'familyName': last_name.capitalize()}, + 'password': password, + 'orgUnitPath': org_unit_path, + 'changePasswordAtNextLogin': True + } + self.service.users().insert(body=user_body).execute() + return True, primary_email + except Exception as e: + return False, str(e) + #functie die opzoek of genruiger reeds aawezig is + def search_users(self, query_string): + """ + Zoekt gebruikers in Google Workspace op basis van een query. + Bijv: query_string="name:Jan*" of "email:jan@school.be" + """ + try: + results = self.service.users().list( + customer='my_customer', + query=query_string, + maxResults=10, + orderBy='email' + ).execute() + + return results.get('users', []) + except Exception as e: + print(f"Fout bij zoeken in Google: {e}") + return [] + + #functie die toestelstatus aanpast (disabled/enabled) + def toggle_device_status(self, device_id, lock=True): + print("My debug message", flush=True) + """Blokkeert (disable) of deblokkeert (reenable) een ChromeOS device.""" + try: + import sys + #print die gebruikt word als debug check + sys.stderr.write(f"DEBUG: Calling Google API for ID: {device_id}\n") + action_type = 'disable' if lock else 'reenable' + + + + self.service.chromeosdevices().action( + customerId='my_customer', + resourceId=device_id, + body={'action': action_type} + ).execute() + + return True, f"Toestel status succesvol gewijzigd naar {action_type}" + except Exception as e: + import sys + sys.stderr.write(f"\n--- GOOGLE API ERROR: {str(e)} ---\n") + return False, f"Google API Fout: {str(e)}" + + #Functie die na het ingeven van de serienummer het DeviceID ophaalt (dit is een andere string dan het serienummer en essentieel om het toestel te kunnen blokkeren) + def lijst_chromebooks(self, ous=None): + """Alle ingeschreven Chromebooks (niet uitgeschreven), eventueel beperkt tot OU's (incl. sub-OU's). + Geeft een lijst dicts met serialNumber, model, status, orgUnitPath, annotatedAssetId.""" + velden = 'nextPageToken,chromeosdevices(serialNumber,model,status,orgUnitPath,annotatedAssetId)' + toestellen, gezien = [], set() + for ou in (ous or [None]): + token = None + while True: + params = dict(customerId='my_customer', maxResults=200, projection='BASIC', fields=velden, + pageToken=token) + if ou: + params.update(orgUnitPath=ou, includeChildOrgunits=True) + resp = self.service.chromeosdevices().list(**params).execute() + for d in resp.get('chromeosdevices', []): + serie = (d.get('serialNumber') or '').strip() + if not serie or serie in gezien or d.get('status') == 'DEPROVISIONED': + continue + gezien.add(serie) + toestellen.append(d) + token = resp.get('nextPageToken') + if not token: + break + return toestellen + + def chromebook_info(self, serienummer): + """Gebruiksinfo van één Chromebook: recente gebruikers, laatste synchronisatie, actieve tijd per dag. + Geeft (dict, None) of (None, foutmelding).""" + serie = str(serienummer or '').strip() + if not serie: + return None, "Dit toestel heeft geen serienummer." + velden = ('chromeosdevices(serialNumber,model,status,orgUnitPath,lastSync,lastEnrollmentTime,osVersion,' + 'recentUsers,activeTimeRanges,annotatedUser,annotatedLocation)') + devices = [] + for query in (f"id:{serie}", serie): # id: = zoeken op serienummer + resp = self.service.chromeosdevices().list(customerId='my_customer', query=query, projection='FULL', + maxResults=5, fields=velden).execute() + devices = [d for d in resp.get('chromeosdevices', []) + if (d.get('serialNumber') or '').strip().upper() == serie.upper()] + if devices: + break + if not devices: + return None, f"Geen Chromebook met serienummer {serie} gevonden in Google Admin." + d = devices[0] + return { + 'serienummer': d.get('serialNumber'), + 'model': d.get('model'), + 'status': d.get('status'), + 'ou': d.get('orgUnitPath'), + 'os': d.get('osVersion'), + 'laatste_sync': d.get('lastSync'), + 'ingeschreven': d.get('lastEnrollmentTime'), + 'gebruikers': [{'email': u.get('email') or '', 'beheerd': u.get('type') == 'USER_TYPE_MANAGED'} + for u in d.get('recentUsers', [])], + 'actief': sorted(({'datum': r.get('date'), 'minuten': round(int(r.get('activeTime') or 0) / 60000)} + for r in d.get('activeTimeRanges', [])), key=lambda r: r['datum'] or '', + reverse=True)[:14], + }, None + + def zoek_chromebook(self, serienummer): + """(deviceId, status) van de Chromebook met exact dit serienummer, of (None, foutmelding). + Status: ACTIVE, DISABLED, DEPROVISIONED, ...""" + serie = str(serienummer or '').strip().replace('\t', '').replace('\n', '').replace('\r', '') + if not serie: + return None, "Dit toestel heeft geen serienummer." + gevonden = [] + for query in (f"id:{serie}", serie): # id: = zoeken op serienummer + resp = self.service.chromeosdevices().list(customerId='my_customer', query=query, maxResults=10, + projection='BASIC', + fields='chromeosdevices(deviceId,serialNumber,status)').execute() + gevonden = [d for d in resp.get('chromeosdevices', []) + if (d.get('serialNumber') or '').strip().upper() == serie.upper()] + if gevonden: + break + if not gevonden: + return None, f"Geen Chromebook met serienummer {serie} gevonden in Google Admin." + # Bij een opnieuw ingeschreven toestel: de niet-uitgeschreven versie + d = next((x for x in gevonden if x.get('status') != 'DEPROVISIONED'), gevonden[0]) + return {'id': d.get('deviceId'), 'status': d.get('status')}, None + + def get_device_id_by_serial(self, serienummer): + """Zoekt het deviceId op basis van het fysieke serienummer.""" + print(f"DEBUG: Zoeken naar serienummer: '{serienummer}'", flush=True) + try: + + # Verwijder spaties, tabs (\t) en newlines (\n) + clean_serial = str(serienummer).strip().replace('\t', '').replace('\n', '').replace('\r', '') + + # Voer de API call uit met het schone serienummer + results = self.service.chromeosdevices().list( + customerId='my_customer', + query=f"{clean_serial}" + ).execute() + + devices = results.get('chromeosdevices', []) + + if devices: + dev_id = devices[0].get('deviceId') + sys.stderr.write(f"DEBUG: Toestel gevonden! ID: {dev_id}\n") + return dev_id, None + + return None, f"Device met serienummer {clean_serial} niet gevonden." + + except Exception as e: + sys.stderr.write(f"DEBUG: Fout in get_device_id_by_serial: {str(e)}\n") + return None, str(e) + + def list_org_units(self): + """Haalt alle Organizational Units op en sorteert ze alfabetisch.""" + try: + results = self.service.orgunits().list(customerId='my_customer', type='all').execute() + org_units = results.get('organizationUnits', []) + + # Sorteren op het volledige pad (bijv. /Leerlingen komt na /Beheer) + org_units.sort(key=lambda x: x.get('orgUnitPath', '').lower()) + + return org_units + except Exception as e: + import sys + sys.stderr.write(f"Fout bij ophalen OUs: {e}\n") + return [] + + def move_users_ou(self, source_ou, target_ou, only_enrolled=True, enrolled_emails=None): + """ + Verplaatst gebruikers van source naar target. + enrolled_emails: een lijst met e-mailadressen die volgens Informat/database ingeschreven zijn. + """ + users_moved = 0 + try: + # We zoeken specifiek gebruikers in de bron-OU om API-quota te besparen + query = f"orgUnitPath='{source_ou}'" + results = self.service.users().list(customer='my_customer', query=query).execute() + users = results.get('users', []) + + for user in users: + user_email = user.get('primaryEmail') + + # Logica voor de radiobuttons uit je HTML + should_move = False + if not only_enrolled: + should_move = True # "Alle leerlingen" + elif enrolled_emails and user_email in enrolled_emails: + should_move = True # "Enkel ingeschreven leerlingen" + + if should_move: + self.service.users().update( + userKey=user_email, + body={'orgUnitPath': target_ou} + ).execute() + users_moved += 1 + + return True, f"{users_moved} gebruikers succesvol verplaatst." + except Exception as e: + return False, f"Fout bij verplaatsen: {str(e)}" + + def move_chromebooks_ou(self, source_ou, target_ou): + """Verplaatst alle Chromebooks van de bron-OU naar de doel-OU.""" + devices_moved = 0 + next_page_token = None + try: + while True: + # We halen alle toestellen op en filteren handmatig op de OU + results = self.service.chromeosdevices().list( + customerId='my_customer', + pageToken=next_page_token, + maxResults=100 + ).execute() + + devices = results.get('chromeosdevices', []) + for device in devices: + if device.get('orgUnitPath') == source_ou: + device_id = device['deviceId'] + self.service.chromeosdevices().update( + customerId='my_customer', + deviceId=device_id, + body={'orgUnitPath': target_ou} + ).execute() + devices_moved += 1 + + next_page_token = results.get('nextPageToken') + if not next_page_token: + break + return True, f"{devices_moved} Chromebooks succesvol verplaatst." + except Exception as e: + return False, f"Fout bij verplaatsen Chromebooks: {str(e)}" + + def toggle_ou_devices_status(self, org_unit_path, block=True): + """ + Blokkeert of deblokkeert alle ChromeOS toestellen in een specifieke OU. + Voorkomt 412 errors door de huidige status te controleren. + """ + devices_processed = 0 + action_type = 'disable' if block else 'reenable' + # De status die het toestel AL heeft als de actie overbodig is + forbidden_status = 'DISABLED' if block else 'ACTIVE' + + next_page_token = None + + try: + while True: + results = self.service.chromeosdevices().list( + customerId='my_customer', + pageToken=next_page_token, + maxResults=100 + ).execute() + + devices = results.get('chromeosdevices', []) + + for device in devices: + if device.get('orgUnitPath') == org_unit_path: + current_status = device.get('status') # bijv. 'ACTIVE' of 'DISABLED' + device_id = device['deviceId'] + serial = device.get('serialNumber', 'Onbekend') + + # Alleen actie ondernemen als de status nog niet is + if current_status != forbidden_status: + try: + self.service.chromeosdevices().action( + customerId='my_customer', + resourceId=device_id, + body={'action': action_type} + ).execute() + devices_processed += 1 + except Exception as e: + print(f"Kon {serial} niet wijzigen: {e}") + else: + print(f"Overgeslagen: {serial} is al {current_status}") + + next_page_token = results.get('nextPageToken') + if not next_page_token: + break + + status_tekst = "geblokkeerd" if block else "gedeblokkeerd" + return True, f"{devices_processed} toestellen succesvol {status_tekst}. Toestellen die al de juiste status hadden zijn overgeslagen." + except Exception as e: + return False, f"Algemene fout: {str(e)}" + + def send_custom_mail(self, receiver_email, subject, body): + msg = MIMEMultipart() + msg['From'] = self.sender + msg['To'] = receiver_email + msg['Subject'] = subject + + msg.attach(MIMEText(body, 'html')) # We sturen het als HTML voor mooie opmaak + + try: + with smtplib.SMTP(self.server, self.port) as server: + server.starttls() + # Geen login nodig voor de relay indien geconfigureerd op IP + wachtwoord = Config.GOOGLE_SMTP_EMAIL_PASSWORD + if wachtwoord and wachtwoord != 'None': + server.login(self.sender, wachtwoord) + server.sendmail(self.sender, receiver_email, msg.as_string()) + return True, "Email succesvol verzonden" + except Exception as e: + return False, str(e) + + def archive_inactive_users(self, source_ou, days_inactive): + """Archiveert gebruikers die X dagen niet zijn ingelogd, zonder van OU te wisselen.""" + users_archived = 0 + cutoff_date = datetime.now(timezone.utc) - timedelta(days=days_inactive) + + try: + # Zoek gebruikers in de bron-OU + query = f"orgUnitPath='{source_ou}'" + results = self.service.users().list(customer='my_customer', query=query).execute() + users = results.get('users', []) + + for user in users: + last_login_str = user.get('lastLoginTime') + user_email = user.get('primaryEmail') + + should_archive = False + if last_login_str: + last_login_dt = datetime.fromisoformat(last_login_str.replace('Z', '+00:00')) + if last_login_dt < cutoff_date: + should_archive = True + else: + # Nooit ingelogd = inactief + should_archive = True + + if should_archive: + # We sturen enkel 'archived': True en GEEN 'orgUnitPath' + self.service.users().update( + userKey=user_email, + body={'archived': True} + ).execute() + users_archived += 1 + + return True, f"{users_archived} gebruikers succesvol gearchiveerd (OU ongewijzigd)." + except Exception as e: + return False, f"Fout bij archiveren: {str(e)}" + \ No newline at end of file diff --git a/services/gopro_service.py b/services/gopro_service.py new file mode 100644 index 0000000..bbb0922 --- /dev/null +++ b/services/gopro_service.py @@ -0,0 +1,619 @@ +""" +GO! Pro: personeelsleden van de actieve school uit Informat (Personeel WEB API, versie 2). + +- GET /employees → naam, voornaam, isActive, comnrs (telefoonnummers), ... +- GET /employees/assignments → opdrachten met ambt (functie), begindatum en einddatum +Scope: api_informat_sas_personeel.personeel.{instellingsnummer} (zelfde client-ID/secret als de leerlingen-API, +maar de client moet ook deze scope hebben). + +Per personeelslid komt er één rij. Functie en rol kies je in PIVOT uit dezelfde lijst (standaard Leerkracht, +een directeur Directeur) en worden per personeelslid bewaard in de schooldatabase (tabel gopro_keuzes). +""" +import re +import unicodedata +from datetime import date, datetime, timezone + +import requests + +from config import Config +from database import execute_query +from services.sync_service import informat_get + +ROLLEN = [ + 'Algemeen Directeur', 'Coordinerend Directeur', 'Communicatie', 'Financiën', 'Infrastructuur', 'HR', 'ICT', + 'Preventie en veiligheid', 'MVD', 'Administratief', 'RVB-lid', 'RVB', 'Voorzitter RVB', 'Beleidsondersteuner', + 'Directeur Coordinator', 'Directeur', 'Internationalisering', 'Leerkracht', 'GO Studio Schoolbeheerder', + 'Medewerker SGR', 'Adjunct-directeur', 'Administratief school', 'Beleidsondersteuner school', +] +STANDAARD_ROL = 'Leerkracht' + + + +def standaard_rol(ambten): + """Functie en rol zolang er in PIVOT niets gekozen is: een directeur blijft Directeur (adjunct-directeur blijft + Adjunct-directeur), alle anderen Leerkracht. `ambten` = de ambten uit Informat (lopende opdrachten + hoofdambt).""" + tekst = ' '.join(a.lower() for a in ambten if a) + if 'adjunct' in tekst and 'directeur' in tekst: + return 'Adjunct-directeur' + if 'directeur' in tekst: + return 'Directeur' + return STANDAARD_ROL + + +# Kolommen van het GO! Pro-importbestand (zelfde namen en volgorde) +EXPORT_KOLOMMEN = ['Naam', 'Achternaam', 'AanmeldEmailadres', 'UPN', 'CommunicatiefEmailadres', + 'OnderwijsinstellingNr', 'Functie', 'rol', 'StartDatum', 'EindDatum', 'ProfessioneelTelefoonNr'] + + +# --------------------------------------------------------------------- +# Hulpfuncties +# --------------------------------------------------------------------- +def naamdeel(tekst): + """'Van den Broeck' → 'vandenbroeck', 'Zoë' → 'zoe': kleine letters, geen accenten, spaties of leestekens + (een koppelteken blijft staan).""" + t = unicodedata.normalize('NFKD', str(tekst or '')).encode('ascii', 'ignore').decode() + return re.sub(r'[^a-z0-9-]', '', t.lower()).strip('-') + + +def upn_van(voornaam, achternaam): + v, a = naamdeel(voornaam), naamdeel(achternaam) + return f"{v}.{a}" if v and a else (v or a) + + +def _datum(waarde): + """'2026-09-01T00:00:00' → date; leeg of ongeldig → None.""" + if not waarde: + return None + try: + return datetime.strptime(str(waarde)[:10], '%Y-%m-%d').date() + except ValueError: + return None + + +def _lijst(data, *sleutels): + if isinstance(data, list): + return data + for s in sleutels: + if isinstance(data, dict) and isinstance(data.get(s), list): + return data[s] + return [] + + +def _telefoon(personeelslid): + """Enkel een professioneel nummer (type 'Werk'): privénummers gaan niet naar GO! Pro.""" + for c in personeelslid.get('comnrs') or []: + if str(c.get('type') or '').strip().lower() == 'werk' and c.get('nr'): + return str(c['nr']).strip() + return '' + + +# --------------------------------------------------------------------- +# Keuzes voor functie en rol (schooldatabase) +# --------------------------------------------------------------------- +VELDEN = ('functie', 'rol', 'email', 'upn') +ADRES_RE = re.compile(r'^[^@\s]+@[^@\s]+\.[^@\s]+$') + + +def ensure_tables(): + execute_query(""" + CREATE TABLE IF NOT EXISTS gopro_keuzes ( + Person_ID VARCHAR(64) NOT NULL PRIMARY KEY, + Functie VARCHAR(100) NULL, + Rol VARCHAR(100) NULL, + Email VARCHAR(255) NULL, + UPN VARCHAR(255) NULL, + Bijgewerkt DATETIME NOT NULL, + Door VARCHAR(255) NULL + )""", commit=True) + # Bestaande tabel van vóór de bewerkbare e-mail/UPN + kolommen = {r['Field'].lower() for r in execute_query("SHOW COLUMNS FROM gopro_keuzes") or []} + for kolom in ('Email', 'UPN'): + if kolom.lower() not in kolommen: + execute_query(f"ALTER TABLE gopro_keuzes ADD COLUMN {kolom} VARCHAR(255) NULL AFTER Rol", commit=True) + + +def keuzes(): + """{person_id: {'functie': ..., 'rol': ...}} met wat in PIVOT gekozen is (None = niets gekozen). + Rollen uit de vroegere tabel gopro_rollen (indien die bestaat) tellen mee als er nog geen nieuwe keuze is.""" + ensure_tables() + uit = {r['Person_ID']: {'functie': r['Functie'], 'rol': r['Rol'], 'email': r['Email'], 'upn': r['UPN']} + for r in execute_query("SELECT Person_ID, Functie, Rol, Email, UPN FROM gopro_keuzes") or []} + if execute_query("SHOW TABLES LIKE 'gopro_rollen'"): + for r in execute_query("SELECT Person_ID, Rol FROM gopro_rollen ORDER BY Bijgewerkt DESC") or []: + k = uit.setdefault(r['Person_ID'], {'functie': None, 'rol': None, 'email': None, 'upn': None}) + k['rol'] = k['rol'] or r['Rol'] + return uit + + +def keuze_opslaan(person_id, veld, waarde, door=None): + """Bewaart één keuze. Voor e-mail en UPN: leeg = terug naar het standaardadres (voornaam.achternaam@domein).""" + if veld not in VELDEN: + raise ValueError(f"Onbekend veld '{veld}'") + if not person_id: + raise ValueError("Geen personeelslid opgegeven") + if veld in ('email', 'upn'): + waarde = (waarde or '').strip().lower() or None + if waarde and not ADRES_RE.match(waarde): + raise ValueError(f"'{waarde}' is geen geldig e-mailadres.") + elif waarde not in ROLLEN: + raise ValueError(f"Onbekende waarde '{waarde}'") + ensure_tables() + kolom = {'functie': 'Functie', 'rol': 'Rol', 'email': 'Email', 'upn': 'UPN'}[veld] + execute_query( + f"INSERT INTO gopro_keuzes (Person_ID, {kolom}, Bijgewerkt, Door) VALUES (%s, %s, NOW(), %s) " + f"ON DUPLICATE KEY UPDATE {kolom} = VALUES({kolom}), Bijgewerkt = NOW(), Door = VALUES(Door)", + (person_id, waarde, door), commit=True) + + +def pas_keuzes_toe(rij, gekozen): + """Zet functie en rol van een rij: wat in PIVOT gekozen is, anders de standaard uit Informat.""" + k = gekozen.get(rij['person_id']) or {} + rij['functie'] = k.get('functie') or rij['standaard'] + rij['rol'] = k.get('rol') or rij['standaard'] + rij['email'] = k.get('email') or rij['standaard_email'] + rij['upn'] = k.get('upn') or rij['standaard_upn'] + rij['email_aangepast'] = bool(k.get('email')) + rij['upn_aangepast'] = bool(k.get('upn')) + return rij + + +def markeer_dubbel(rijen): + """Rijen waarvan het e-mailadres ook bij een ander personeelslid staat (bv. zelfde naam) krijgen 'dubbel'.""" + per_mail = {} + for r in rijen: + per_mail.setdefault(r['email'], set()).add(r['person_id']) + for r in rijen: + r['dubbel'] = bool(r['email']) and len(per_mail[r['email']]) > 1 + return rijen + + +def google_kleur(rij, google): + """'account' als e-mail of UPN een account is in Google, 'alias' als één ervan een alias is, anders 'geen'. + Zelfde regel als de kleuren op de pagina.""" + soorten = [google.get((rij.get(v) or '').strip().lower()) for v in ('email', 'upn')] + return 'account' if 'primair' in soorten else 'alias' if 'alias' in soorten else 'geen' + + +def google_adressen(vernieuw=False): + """{adres: 'primair' | 'alias'} voor alle gebruikers in Google Workspace van de huidige school, 10 minuten + bewaard. Gebruikt de bestaande Directory-koppeling (scope admin.directory.user).""" + import time + from tenancy import huidige_school + school = huidige_school() + bewaard = school.cache.get('gopro_google') + if bewaard and not vernieuw and time.time() - bewaard[0] < 600: + return bewaard[1] + from services.google_service import GoogleAdminService + users = GoogleAdminService().service.users() + adressen, token = {}, None + while True: + res = users.list(customer='my_customer', maxResults=500, pageToken=token, + fields='nextPageToken,users(primaryEmail,aliases)').execute() + for u in res.get('users', []): + for a in u.get('aliases') or []: + adressen.setdefault(str(a).lower(), 'alias') + if u.get('primaryEmail'): + adressen[str(u['primaryEmail']).lower()] = 'primair' + token = res.get('nextPageToken') + if not token: + break + school.cache['gopro_google'] = (time.time(), adressen) + return adressen + + +# --------------------------------------------------------------------- +# Informat +# --------------------------------------------------------------------- +class GoProService: + def __init__(self): + self.api = str(Config.INFORMAT_PERSONEEL_API or '').rstrip('/') + self.scope_basis = re.sub(r'\.\d+$', '', str(Config.INFORMAT_PERSONEEL_SCOPE or '').strip()) + self.nummers = [n for n, _ in (Config.INFORMAT_CAMPUSSEN or [])] or [Config.INFORMAT_INST_NUMBER] + + def _token(self, nummer): + try: + r = requests.post(Config.INFORMAT_AUTH_URL, timeout=15, data={ + 'grant_type': 'client_credentials', 'client_id': Config.INFORMAT_ID, + 'client_secret': Config.INFORMAT_SECRET, 'scope': f"{self.scope_basis}.{nummer}"}) + except requests.RequestException as e: + raise RuntimeError(f"Informat niet bereikbaar: {e}") + try: + antwoord = r.json() + except ValueError: + antwoord = {} + if r.status_code == 200 and antwoord.get('access_token'): + return antwoord['access_token'] + # Het echte antwoord van de Informat-aanmeldserver tonen (bv. invalid_scope of invalid_client) + reden = ' – '.join(str(antwoord[k]) for k in ('error', 'error_description') if antwoord.get(k)) \ + or r.text[:200] or f"HTTP {r.status_code}" + uitleg = { + 'invalid_scope': f"de client heeft de scope {self.scope_basis}.{nummer} (nog) niet.", + 'invalid_client': "de client-ID of het client-secret van deze school klopt niet.", + 'unauthorized_client': "deze client mag zich niet op deze manier aanmelden.", + }.get(antwoord.get('error'), "") + raise RuntimeError( + f"Aanmelden bij de Informat Personeel-API mislukt voor instellingsnummer {nummer} " + f"(scope {self.scope_basis}.{nummer}). Informat antwoordt: {reden}" + (f". Dat betekent: {uitleg}" if uitleg else "")) + + def _get(self, pad, nummer, token, schooljaar): + headers = {'Authorization': f'BEARER {token}', 'InstituteNo': nummer, 'Api-Version': '2', + 'Accept': 'application/json', + 'Timestamp': datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%S.000Z')} + r = informat_get(f"{self.api}{pad}?schoolYear={schooljaar}", headers) + if r.status_code in (401, 403): + raise RuntimeError(f"Informat weigert de toegang tot {pad} (instellingsnummer {nummer}). Heeft de client " + f"de scope {self.scope_basis}.{nummer}?") + if r.status_code != 200: + raise RuntimeError(f"Informat Personeel-API fout {r.status_code} op {pad} " + f"(instellingsnummer {nummer}): {r.text[:300]}") + return r.json() + + def personeel(self, domein, instellingsnummer_officieel='', schooljaar=None, vandaag=None): + """Lijst van rijen (dicts) voor het scherm en de export, gesorteerd op achternaam en voornaam.""" + schooljaar = schooljaar or Config.SCHOOLYEAR + vandaag = vandaag or date.today() + domein = (domein or '').lstrip('@') + gekozen = keuzes() + rijen, gezien = [], set() + + for nummer in self.nummers: + token = self._token(nummer) + leden = _lijst(self._get('/employees', nummer, token, schooljaar), 'employees', 'items', 'data') + opdrachten = _lijst(self._get('/employees/assignments', nummer, token, schooljaar), + 'assignments', 'items', 'data') + + # Lopende opdrachten per personeelslid (alle ambten samen): vroegste begin, laatste einde + per = {} + for o in opdrachten: + begin, einde = _datum(o.get('begindatum')), _datum(o.get('einddatum')) + if begin and begin > vandaag or einde and einde < vandaag: + continue + f = per.setdefault(str(o.get('personId')), {'begin': begin, 'einde': einde, 'open': einde is None, + 'ambten': set()}) + f['ambten'].add((o.get('ambt') or '').strip()) + if begin and (not f['begin'] or begin < f['begin']): + f['begin'] = begin + if einde is None: + f['open'] = True + elif not f['open'] and (not f['einde'] or einde > f['einde']): + f['einde'] = einde + + for p in leden: + pid = str(p.get('personId') or p.get('pPersoon') or '') + if not pid or p.get('isActive') is False or p.get('isOverleden'): + continue + voornaam, achternaam = (p.get('voornaam') or '').strip(), (p.get('naam') or '').strip() + # Eén rij per persoon, ook als die in meer dan één instellingsnummer voorkomt + # (daar kan Informat een ander personId geven: dan herkennen we de persoon aan naam + geboortedatum) + geboren = str(p.get('geboortedatum') or '')[:10] + persoon = (naamdeel(voornaam), naamdeel(achternaam), geboren) if geboren else ('pid', pid) + f = per.get(pid) or {'begin': None, 'einde': None, 'open': True, 'ambten': set()} + ambten = f['ambten'] | {((p.get('hoofdAmbt') or {}).get('naam') or '').strip()} + if pid in gezien or persoon in gezien: + bestaand = next(r for r in rijen if r['person_id'] == pid or r['_persoon'] == persoon) + if f['begin'] and (not bestaand['start'] or f['begin'] < bestaand['start']): + bestaand['start'] = f['begin'] + if f['open']: + bestaand['einde'] = None + elif bestaand['einde'] and f['einde'] and f['einde'] > bestaand['einde']: + bestaand['einde'] = f['einde'] + bestaand['telefoon'] = bestaand['telefoon'] or _telefoon(p) + nieuw = standaard_rol(ambten) # bv. directeur in het tweede instellingsnummer + if nieuw != STANDAARD_ROL and bestaand['standaard'] == STANDAARD_ROL: + bestaand['standaard'] = nieuw + pas_keuzes_toe(bestaand, gekozen) + continue + gezien.update((pid, persoon)) + upn = upn_van(voornaam, achternaam) + rijen.append({ + 'id': pid, + 'person_id': pid, + '_persoon': persoon, + 'voornaam': voornaam, + 'achternaam': achternaam, + 'standaard_email': f"{upn}@{domein}" if upn and domein else '', + 'standaard_upn': f"{upn}@{domein}" if upn and domein else upn, # UPN = het volledige e-mailadres + 'instellingsnummer': instellingsnummer_officieel or nummer, + 'standaard': standaard_rol(ambten), + 'start': f['begin'], + 'einde': None if f['open'] else f['einde'], + 'telefoon': _telefoon(p), + }) + pas_keuzes_toe(rijen[-1], gekozen) + + markeer_dubbel(rijen) + rijen.sort(key=lambda r: (naamdeel(r['achternaam']), naamdeel(r['voornaam']))) + return rijen + + def tel(self, peildatum): + """Aantal personeelsleden met een lopende opdracht op `peildatum` (één keer per persoon, ook als die in + meerdere instellingsnummers werkt). Geeft (totaal, detail) met detail = [{'naam', 'nummer', 'aantal'}] + per instellingsnummer als de school er meer dan één heeft, anders None.""" + from tenancy import _automatisch_schooljaar + schooljaar = _automatisch_schooljaar(peildatum) + namen = dict(Config.INFORMAT_CAMPUSSEN or []) + alle, detail = set(), [] + for nummer in self.nummers: + token = self._token(nummer) + leden = _lijst(self._get('/employees', nummer, token, schooljaar), 'employees', 'items', 'data') + opdrachten = _lijst(self._get('/employees/assignments', nummer, token, schooljaar), + 'assignments', 'items', 'data') + # personId → persoon (naam + geboortedatum), om dezelfde persoon in een ander nummer te herkennen + persoon = {} + for p in leden: + pid = str(p.get('personId') or p.get('pPersoon') or '') + geboren = str(p.get('geboortedatum') or '')[:10] + persoon[pid] = ((naamdeel(p.get('voornaam')), naamdeel(p.get('naam')), geboren) if geboren + else (nummer, pid)) + hier = set() + for o in opdrachten: + begin, einde = _datum(o.get('begindatum')), _datum(o.get('einddatum')) + if (begin and begin > peildatum) or (einde and einde < peildatum): + continue + pid = str(o.get('personId') or '') + if pid: + hier.add(persoon.get(pid, (nummer, pid))) + alle |= hier + detail.append({'naam': namen.get(nummer) or nummer, 'nummer': nummer, 'aantal': len(hier)}) + return len(alle), (detail if len(detail) > 1 else None) + + +# --------------------------------------------------------------------- +# Verzameltabel (centrale database): personeel van alle scholen samen, voor één grote GO! Pro-import +# --------------------------------------------------------------------- +def schooljaar_periode(vandaag=None): + """(1 september, 31 augustus) van het lopende schooljaar: in de verzameltabel heeft iedereen deze periode.""" + vandaag = vandaag or date.today() + jaar = vandaag.year if vandaag.month >= 9 else vandaag.year - 1 + return date(jaar, 9, 1), date(jaar + 1, 8, 31) + + +_periode_gezet = {'dag': None} + + +def verzamel_ensure(): + from database import central_query + central_query(""" + CREATE TABLE IF NOT EXISTS GoPro_verzameltabel ( + ID INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + School_code VARCHAR(32) NOT NULL, + School_naam VARCHAR(255) NULL, + Person_ID VARCHAR(64) NOT NULL, + Voornaam VARCHAR(100) NULL, + Achternaam VARCHAR(100) NULL, + Email VARCHAR(255) NULL, + UPN VARCHAR(255) NULL, + Instellingsnummer VARCHAR(20) NULL, + Functie VARCHAR(100) NULL, + Rol VARCHAR(100) NULL, + Startdatum DATE NULL, + Einddatum DATE NULL, + Telefoon VARCHAR(50) NULL, + Toegevoegd DATETIME NOT NULL, + Door VARCHAR(255) NULL, + Soort VARCHAR(10) NOT NULL DEFAULT 'groen', + Verzonden DATETIME NULL, + Verzonden_door VARCHAR(255) NULL, + UNIQUE KEY uq_gopro_verz (School_code, Person_ID), + KEY idx_gopro_verz_email (Email) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci""", commit=True) + # Soort: 'groen' = gaat mee in de GO! Pro-CSV, 'genegeerd' = rode lijst (nooit meer tonen, niet exporteren), + # 'verzonden' = al naar GO! Pro doorgestuurd (niet meer in de verzameltabel, nooit opnieuw toevoegen) + kolommen = {r['Field'].lower() for r in central_query("SHOW COLUMNS FROM GoPro_verzameltabel") or []} + if 'soort' not in kolommen: + central_query("ALTER TABLE GoPro_verzameltabel ADD COLUMN Soort VARCHAR(10) NOT NULL DEFAULT 'groen'", commit=True) + if 'verzonden' not in kolommen: + central_query("ALTER TABLE GoPro_verzameltabel ADD COLUMN Verzonden DATETIME NULL, " + "ADD COLUMN Verzonden_door VARCHAR(255) NULL", commit=True) + # Iedereen in de verzameltabel: periode = het lopende schooljaar (1 keer per dag nagekeken) + if _periode_gezet['dag'] != date.today(): + start, einde = schooljaar_periode() + central_query("UPDATE GoPro_verzameltabel SET Startdatum = %s, Einddatum = %s WHERE Soort = 'groen' " + "AND (Startdatum IS NULL OR Einddatum IS NULL OR Startdatum <> %s OR Einddatum <> %s)", + (start, einde, start, einde), commit=True) + _periode_gezet['dag'] = date.today() + + +def verzameld_sleutels(): + """({(school_code, person_id): soort}, {e-mailadres: soort}) van alles in de verzameltabel (groen én genegeerd).""" + from database import central_query + verzamel_ensure() + rijen = central_query("SELECT School_code, Person_ID, Email, Soort FROM GoPro_verzameltabel") or [] + return ({(r['School_code'], str(r['Person_ID'])): r['Soort'] for r in rijen}, + {(r['Email'] or '').strip().lower(): r['Soort'] for r in rijen if r['Email']}) + + +def is_verzameld(rij, school_code, sleutels): + """In welke lijst staat dit personeelslid al ('groen' / 'genegeerd'), of None. + Herkend aan school + personeelslid, of aan het e-mailadres.""" + paren, mails = sleutels + return paren.get((school_code, str(rij['person_id']))) or mails.get((rij.get('email') or '').strip().lower()) + + +def verzamel_toevoegen(rijen, school, door=None, soort='groen'): + """Voegt de rijen toe aan de verzameltabel (soort 'groen') of aan de rode lijst ('genegeerd'). + Geeft (toegevoegd, overgeslagen).""" + from database import central_query + sleutels = verzameld_sleutels() + periode = schooljaar_periode() # iedereen: 1 september – 31 augustus van het lopende schooljaar + toegevoegd = overgeslagen = 0 + for r in rijen: + if is_verzameld(r, school.code, sleutels): + overgeslagen += 1 + continue + central_query( + "INSERT INTO GoPro_verzameltabel (School_code, School_naam, Person_ID, Voornaam, Achternaam, Email, UPN, " + "Instellingsnummer, Functie, Rol, Startdatum, Einddatum, Telefoon, Toegevoegd, Door, Soort) " + "VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW(), %s, %s)", + (school.code, school.naam, str(r['person_id']), r['voornaam'], r['achternaam'], r['email'], r['upn'], + r['instellingsnummer'], r['functie'], r['rol'], periode[0], periode[1], r['telefoon'], door, soort), + commit=True) + sleutels[0][(school.code, str(r['person_id']))] = soort + if r.get('email'): + sleutels[1][r['email'].strip().lower()] = soort + toegevoegd += 1 + return toegevoegd, overgeslagen + + +def verzameld_alle(school_code=None, soort='groen'): + from database import central_query + verzamel_ensure() + if school_code: + return central_query("SELECT * FROM GoPro_verzameltabel WHERE School_code = %s AND Soort = %s " + "ORDER BY Achternaam, Voornaam", (school_code, soort)) or [] + return central_query("SELECT * FROM GoPro_verzameltabel WHERE Soort = %s " + "ORDER BY School_naam, Achternaam, Voornaam", (soort,)) or [] + + +def markeer_verzonden(door=None, school_code=None): + """Alle personeelsleden in de verzameltabel (of enkel die van één school) als verzonden markeren. + Ze verdwijnen uit de verzameltabel en worden nooit opnieuw toegevoegd. Geeft het aantal.""" + from database import central_query + verzamel_ensure() + n = len(verzameld_alle(school_code)) + if school_code: + central_query("UPDATE GoPro_verzameltabel SET Soort = 'verzonden', Verzonden = NOW(), Verzonden_door = %s " + "WHERE Soort = 'groen' AND School_code = %s", (door, school_code), commit=True) + else: + central_query("UPDATE GoPro_verzameltabel SET Soort = 'verzonden', Verzonden = NOW(), Verzonden_door = %s " + "WHERE Soort = 'groen'", (door,), commit=True) + return n + + +def aantal_verzonden(school_code): + from database import central_query + verzamel_ensure() + r = central_query("SELECT COUNT(*) AS n FROM GoPro_verzameltabel WHERE School_code = %s AND Soort = 'verzonden'", + (school_code,)) or [{}] + return int(r[0].get('n') or 0) + + +# --------------------------------------------------------------------- +# Nachtelijke verzameling: elke nacht van alle scholen de groene personeelsleden naar de verzameltabel +# --------------------------------------------------------------------- +NACHT_SLEUTEL = 'gopro_nachtelijk' # '1' = aan +NACHT_DAG_SLEUTEL = 'gopro_nachtelijk_dag' # laatste datum waarop het liep (zodat het één keer per nacht loopt) +NACHT_RESULTAAT_SLEUTEL = 'gopro_nachtelijk_resultaat' +NACHT_UUR_VAN, NACHT_UUR_TOT = 2, 6 # tussen 02:00 en 06:00 +NACHT_DOOR = 'Automatisch (nacht)' +_nacht = {'bezig': False} + + +def _instelling_ensure(): + from services import ipblokkade + ipblokkade.ensure_table() # maakt PIVOT_instellingen aan als die nog niet bestaat + + +def nacht_instelling(): + """{'aan': bool, 'door', 'bijgewerkt', 'resultaat', 'resultaat_op'}""" + from database import central_query + _instelling_ensure() + rijen = {r['Sleutel']: r for r in central_query( + "SELECT Sleutel, Waarde, Bijgewerkt, Door FROM PIVOT_instellingen WHERE Sleutel IN (%s, %s)", + (NACHT_SLEUTEL, NACHT_RESULTAAT_SLEUTEL)) or []} + aan, res = rijen.get(NACHT_SLEUTEL) or {}, rijen.get(NACHT_RESULTAAT_SLEUTEL) or {} + return {'aan': aan.get('Waarde') == '1', 'door': aan.get('Door'), 'bijgewerkt': aan.get('Bijgewerkt'), + 'resultaat': res.get('Waarde'), 'resultaat_op': res.get('Bijgewerkt'), 'bezig': _nacht['bezig']} + + +def zet_nacht(aan, door=None): + from database import central_query + _instelling_ensure() + central_query("INSERT INTO PIVOT_instellingen (Sleutel, Waarde, Bijgewerkt, Door) VALUES (%s, %s, NOW(), %s) " + "ON DUPLICATE KEY UPDATE Waarde = VALUES(Waarde), Bijgewerkt = NOW(), Door = VALUES(Door)", + (NACHT_SLEUTEL, '1' if aan else '0', door), commit=True) + + +def verzamel_groene_van_school(school, door=NACHT_DOOR): + """Haalt het personeel van `school` op (Informat), kijkt na wie een account heeft in Google en zet de groene + die nog niet in de verzameltabel of op de rode lijst staan in de verzameltabel. Geeft het aantal toegevoegd.""" + from tenancy import in_school + with in_school(school): + rijen = GoProService().personeel(school.domein, Config.INSTELLINGSNUMMER) + school.cache.pop('gopro', None) # de GO! Pro-pagina haalt daarna vers op + google = google_adressen(vernieuw=True) + sleutels = verzameld_sleutels() + groen = [r for r in rijen if not is_verzameld(r, school.code, sleutels) and google_kleur(r, google) == 'account'] + toegevoegd, _ = verzamel_toevoegen(groen, school, door) + return toegevoegd + + +def verzamel_alle_scholen(door=NACHT_DOOR): + """Voor elke actieve school met Informat en een e-maildomein. Geeft een korte samenvatting.""" + from database import central_query + from tenancy import alle_scholen + from rechten import log + _nacht['bezig'] = True + try: + totaal, scholen, fouten, details = 0, 0, [], [] + for school in alle_scholen(): + if not school.heeft('INFORMAT_INST_NUMBER', 'INFORMAT_ID', 'INFORMAT_SECRET') or not school.domein: + continue + try: + n = verzamel_groene_van_school(school, door) + totaal += n + scholen += 1 + details.append(f"{school.naam}: {n}") + except Exception as e: + fouten.append(f"{school.naam}: {str(e)[:120]}") + import sys + sys.stderr.write(f"[{school.code}] GO! Pro nachtelijk verzamelen: {e}\n") + samenvatting = f"{totaal} toegevoegd uit {scholen} school/scholen" + if fouten: + samenvatting += f"; {len(fouten)} mislukt ({' | '.join(fouten)})" + try: + log(f"GO! Pro automatisch verzamelen: {samenvatting}. " + '; '.join(details), url='(nachtelijke taak)') + except Exception: + pass + _instelling_ensure() + central_query("INSERT INTO PIVOT_instellingen (Sleutel, Waarde, Bijgewerkt, Door) VALUES (%s, %s, NOW(), %s) " + "ON DUPLICATE KEY UPDATE Waarde = VALUES(Waarde), Bijgewerkt = NOW(), Door = VALUES(Door)", + (NACHT_RESULTAAT_SLEUTEL, samenvatting[:255], door), commit=True) + return samenvatting + finally: + _nacht['bezig'] = False + + +def _nacht_claim(vandaag): + """True als dit proces de run van vandaag mag doen (ook als PIVOT in meerdere processen draait).""" + from database import central_query + central_query("INSERT IGNORE INTO PIVOT_instellingen (Sleutel, Waarde, Bijgewerkt) VALUES (%s, '', NOW())", + (NACHT_DAG_SLEUTEL,), commit=True) + return central_query("UPDATE PIVOT_instellingen SET Waarde = %s, Bijgewerkt = NOW() " + "WHERE Sleutel = %s AND (Waarde IS NULL OR Waarde <> %s)", + (vandaag, NACHT_DAG_SLEUTEL, vandaag), commit=True) == 1 + + +_nacht_thread = None + + +def start_scheduler(): + """Achtergrondtaak: elk kwartier kijken of het nacht is en de schakelaar aan staat.""" + import threading, time, sys + global _nacht_thread + if _nacht_thread: + return + + def loop(): + time.sleep(60) + while True: + try: + nu = datetime.now() + if NACHT_UUR_VAN <= nu.hour < NACHT_UUR_TOT and nacht_instelling()['aan'] \ + and _nacht_claim(nu.strftime('%Y-%m-%d')): + verzamel_alle_scholen() + except Exception as e: + sys.stderr.write(f"GO! Pro nachtelijke taak: {e}\n") + time.sleep(15 * 60) + + _nacht_thread = threading.Thread(target=loop, daemon=True) + _nacht_thread.start() + + +def verzamel_alle_scholen_op_achtergrond(door): + import threading + if _nacht['bezig']: + return False + _nacht['bezig'] = True + threading.Thread(target=lambda: verzamel_alle_scholen(door), daemon=True).start() + return True diff --git a/services/handleidingen_standaard.py b/services/handleidingen_standaard.py new file mode 100644 index 0000000..5671364 --- /dev/null +++ b/services/handleidingen_standaard.py @@ -0,0 +1,347 @@ +""" +Handleidingen die PIVOT meelevert. Bij het opstarten komt elke handleiding waarvan de slug nog niet bestaat in de +tabel Handleidingen. Daarna pas je ze aan in PIVOT zelf (📘 Handleidingen › Bewerken); wijzigingen hier overschrijven niets. +Een verwijderde handleiding blijft verborgen (Zichtbaar = 'weg') en komt dus niet terug. +""" + +TWEESTAPS_UITZONDERING = """ +

Met deze handleiding stel je in Google Admin alles in zodat PIVOT iemand tijdelijk zonder 2FA (tot 23:59 dezelfde dag) kan laten inloggen +(uitzondering) en 2FA kan resetten bij een nieuwe telefoon. Je doet dit één keer per domein; daarna enkel opnieuw +als er een organisatie-eenheid (OU) bijkomt.

+ +
Hoe werkt het? PIVOT zet een gebruiker in de groep 2fa-uitzondering. Voor die groep staat +"Afdwingen" op Uit. Zolang de gebruiker in de groep zit, mag hij zonder 2FA inloggen. Om 23:59 dezelfde dag haalt PIVOT +hem er automatisch weer uit.
+ +

Stap 1 – De groep aanmaken

+
    +
  1. Ga naar admin.google.com › Directory › Groepen › Groep maken.
  2. +
  3. Naam: 2fa-uitzondering, e-mailadres: 2fa-uitzondering@jouwdomein.be.
  4. +
  5. Bij de instellingen van de groep: +
      +
    • Wie kan lid worden: Alleen uitgenodigde gebruikers.
    • +
    • Wie kan berichten plaatsen: Groepseigenaren (de groep dient niet om te mailen).
    • +
    • Wie kan leden bekijken: Groepsbeheerders.
    • +
    +
  6. +
  7. Voeg zelf geen vaste leden toe. PIVOT voegt mensen toe en haalt ze er weer uit.
  8. +
+ +

Stap 2 – 2FA uitzetten voor de groep, in élke OU waar 2FA verplicht is

+
Let op: een 2FA-instelling op een onderliggende OU gaat altijd vóór een groepsinstelling +hoger in de boom. Stel de groep daarom in bij elke OU waar 2FA afgedwongen wordt, ook bij sub-OU's.
+
    +
  1. Ga naar Beveiliging › Authenticatie › Verificatie in 2 stappen (2-Step Verification).
  2. +
  3. Kies links onder Organisatie-eenheden de OU, bv. GO-Antwerpen. Klap met het pijltje open om sub-OU's te zien.
  4. +
  5. Klik links onderaan bij Groepen op de groep 2fa-uitzondering (of voeg ze toe via "Groepen zoeken"). + Bovenaan moet nu staan: "Instellingen tonen voor gebruikers in [jouw OU] (organisatie-eenheid) die zich ook in + 2fa-uitzondering (groep) bevinden".
  6. +
  7. Laat Gebruikers toestaan tweestapsverificatie aan te zetten aangevinkt.
  8. +
  9. Zet Afdwingen op Uit.
  10. +
  11. Klik op Overschrijven (of Opslaan). Onder "Verificatie" moet nu Overschreven staan, niet "Overgenomen".
  12. +
  13. Herhaal stap 2 tot 6 voor elke OU en sub-OU waar 2FA op Aan staat.
  14. +
+
Tip: per OU kan je maar één groep instellen. Gebruik daarom overal dezelfde groep 2fa-uitzondering.
+ +

Stap 3 – Rechten voor PIVOT (domeinbrede delegatie)

+
    +
  1. Ga naar Beveiliging › Toegangs- en gegevensbeheer › API-beheer › Domeinbrede delegatie beheren.
  2. +
  3. Klik bij het service-account van PIVOT op Bewerken.
  4. +
  5. Controleer dat minstens deze scopes erbij staan (in het veld gescheiden door komma's): +
      +
    • https://www.googleapis.com/auth/admin.directory.user
    • +
    • https://www.googleapis.com/auth/admin.directory.user.security (2FA resetten, back-upcodes)
    • +
    • https://www.googleapis.com/auth/admin.directory.group.member (in/uit de groep zetten)
    • +
    • https://www.googleapis.com/auth/admin.directory.orgunit
    • +
    +
  6. +
  7. Klik op Autoriseren. Het kan enkele minuten duren voor Google dit toepast.
  8. +
+ +

Stap 4 – De groep invullen in PIVOT

+
    +
  1. Open in PIVOT 🔌 Koppelingen van deze school › 🛡️ 2FA-hulp.
  2. +
  3. Vul bij Uitzonderingsgroep het e-mailadres van de groep in: 2fa-uitzondering@jouwdomein.be.
  4. +
  5. Opslaan en Testen.
  6. +
+ +

Stap 5 – Testen met een testaccount

+
    +
  1. Geef in PIVOT (🛡️ 2FA-hulp) een testgebruiker een tijdelijke uitzondering (loopt tot 23:59).
  2. +
  3. Kijk in Google Admin bij Gebruikers › [testgebruiker] › Groepen: 2fa-uitzondering moet in de lijst staan.
  4. +
  5. Kijk bij Gebruikers › [testgebruiker] › Beveiliging › Tweestapsverificatie. + Zolang daar "Afgedwongen in uw hele organisatie" staat, heeft Google de uitzondering nog niet toegepast.
  6. +
+
Geduld: Google past een nieuwe groepsinstelling meestal binnen enkele minuten toe, maar het kan +tot 24 uur duren, zeker bij een nieuwe groep of een nieuw account. PIVOT blijft een 2FA-reset elke minuut opnieuw proberen +zolang de uitzondering loopt.
+ +

Iemand heeft een nieuwe telefoon

+

Gebruik eerst een back-upcode: die werkt meteen, ook als 2FA afgedwongen wordt.

+
    +
  1. PIVOT: 🛡️ 2FA-hulp › zoek de gebruiker › Back-upcodes tonen / aanmaken + (of in Google Admin: Gebruikers › gebruiker › Beveiliging › Tweestapsverificatie › Back-upverificatiecodes ophalen).
  2. +
  3. Geef de gebruiker één code. Hij logt in met zijn wachtwoord en die code.
  4. +
  5. Daarna koppelt hij zijn nieuwe telefoon via myaccount.google.com › Beveiliging + › Verificatie in 2 stappen.
  6. +
+

Lukt dat niet, gebruik dan in PIVOT 📵 2FA resetten. PIVOT zet de gebruiker dan in de uitzonderingsgroep en zet 2FA uit +zodra Google de uitzondering toepast.

+ +

Problemen oplossen

+ + + + + + + + + + + + + + +
Wat zie je?OorzaakOplossing
Gebruiker zit in de groep, maar 2FA blijft "Afgedwongen"De gebruiker staat in een (sub-)OU waar de groep niet ingesteld is, of Google past de instelling nog toe.Controleer de OU van de gebruiker (Gebruikers › gebruiker, links onder "Organisatie-eenheid") en voer stap 2 uit voor + precies die OU. Wacht anders tot 24 uur.
"2-Step Verification cannot be turned off: … enforced"Google dwingt 2FA nog af voor deze gebruiker.Zie hierboven. PIVOT probeert automatisch opnieuw.
"Google weigert de toegang" / unauthorized_clientEen scope ontbreekt.Stap 3.
"Gebruiker of groep niet gevonden"Het e-mailadres van de groep in PIVOT klopt niet.Stap 4.
+""" + +MAIL_DELEGATIE = """ +

Met deze handleiding stel je Google Admin zo in dat PIVOT (✉️ Mailbox-delegatie) iemand toegang kan geven tot de +mailbox van iemand anders, bv. het secretariaat tot de mailbox van de directie. De gedelegeerde kan dan lezen, versturen en verwijderen +namens de eigenaar, maar kent het wachtwoord niet en kan de instellingen niet wijzigen.

+ +

Stap 1 – Maildelegatie toelaten

+
    +
  1. Ga naar admin.google.com › Apps › Google Workspace › Gmail › Gebruikersinstellingen.
  2. +
  3. Kies links de organisatie-eenheid van de mailboxen die gedeeld moeten worden (bv. GO-Antwerpen). + Kies de hoogste OU als het voor iedereen mag; sub-OU's nemen de instelling over, tenzij ze ze zelf overschrijven.
  4. +
  5. Open het onderdeel Maildelegatie (Mail delegation).
  6. +
  7. Vink aan: Gebruikers toestaan toegang tot hun mailbox te delegeren aan andere gebruikers in het domein.
  8. +
  9. Kies hoe verzonden mails getoond worden: +
      +
    • De accounteigenaar en de gedelegeerde die de e-mail heeft verzonden tonen (aangeraden: de ontvanger ziet wie echt schreef), of
    • +
    • Alleen de accounteigenaar tonen.
    • +
    +
  10. +
  11. Klik op Opslaan.
  12. +
+
Let op: staat maildelegatie in een sub-OU op "Overschreven" en uit, dan werkt delegatie daar niet, +ook al staat ze hoger in de boom aan. Controleer de OU van de mailbox-eigenaar.
+ +

Stap 2 – Gmail API aanzetten (eenmalig)

+
    +
  1. Ga naar console.cloud.google.com › API's › Gmail API.
  2. +
  3. Kies bovenaan het project van het PIVOT-service-account.
  4. +
  5. Klik op Inschakelen (staat er "Beheren", dan is het al aan).
  6. +
+ +

Stap 3 – Rechten voor PIVOT (domeinbrede delegatie)

+
    +
  1. Ga in Google Admin naar Beveiliging › Toegangs- en gegevensbeheer › API-beheer › Domeinbrede delegatie beheren.
  2. +
  3. Klik bij het service-account van PIVOT op Bewerken.
  4. +
  5. Voeg deze twee scopes toe aan de bestaande lijst (gescheiden door komma's; verwijder de andere niet): +
      +
    • https://www.googleapis.com/auth/gmail.settings.sharing (delegatie toevoegen en verwijderen)
    • +
    • https://www.googleapis.com/auth/gmail.settings.basic (bestaande delegaties tonen)
    • +
    +
  6. +
  7. Klik op Autoriseren.
  8. +
+
Tip: ontbreekt gmail.settings.basic, dan kan PIVOT wel delegaties toevoegen maar de lijst niet tonen +("insufficient authentication scopes").
+ +

Stap 4 – Rechten in PIVOT

+
    +
  1. Geef wie delegaties mag beheren het recht Mailbox-delegatie in de juiste school + (⚙️ Beheer › 🔐 Gebruikers & rechten › 🔐 Rechten).
  2. +
  3. Test in ✉️ Mailbox-delegatie met twee testaccounts: geef account B toegang tot de mailbox van account A.
  4. +
+ +

Goed om te weten

+
    +
  • Een delegatie via PIVOT werkt meestal na ongeveer een minuut. De gedelegeerde hoeft niets te bevestigen.
  • +
  • Een nieuwe instelling in Google Admin (stap 1) kan tot 24 uur duren voor ze overal geldt.
  • +
  • De gedelegeerde opent de mailbox in Gmail via zijn profielfoto rechtsboven › de gedeelde mailbox kiezen.
  • +
  • Eigenaar en gedelegeerde moeten in dezelfde Google Workspace-organisatie zitten. Gebruik het + primaire e-mailadres, geen alias.
  • +
  • Een mailbox kan maximaal 25 gedelegeerden hebben; iemand kan maximaal 10 mailboxen openen + (Google kan deze limieten per organisatie anders instellen).
  • +
+ +

Problemen oplossen

+ + + + + + + + + + + + +
Wat zie je?OorzaakOplossing
"insufficient authentication scopes" bij het tonengmail.settings.basic ontbreekt.Stap 3.
"unauthorized_client" / Google weigert de toeganggmail.settings.sharing ontbreekt of is nog niet toegepast.Stap 3, en enkele minuten wachten.
Gmail API has not been used / is disabledGmail API staat uit in het Cloud-project.Stap 2.
Toevoegen mislukt voor één persoon, voor anderen lukt hetMaildelegatie staat uit in de OU van die mailbox-eigenaar, of er zijn al 25 gedelegeerden.Stap 1 voor die OU.
Gedelegeerde ziet de mailbox nietNog niet toegepast, of hij kijkt niet via zijn profielfoto.Even wachten, opnieuw inloggen in Gmail.
+""" + +GOOGLE_KOPPELING = """ +

PIVOT praat met Google via een serviceaccount: een technisch account in Google Cloud dat namens een beheerder +(bv. ict@school.be) handelt. Daarvoor zijn er drie plaatsen: Google Cloud Console (project, API's, +serviceaccount, sleutel), Google Admin (domeinbrede delegatie) en PIVOT (sleutel uploaden). +Optioneel stel je ook Inloggen met Google in.

+ +
Wie? Je hebt een account nodig dat in Google Cloud projecten mag aanmaken en in Google Admin +superbeheerder is. Je doet dit één keer; meerdere scholen in hetzelfde Google-domein mogen dezelfde sleutel gebruiken.
+ +

Stap 1 – Project aanmaken

+
    +
  1. Ga naar console.cloud.google.com en log in met je beheerdersaccount.
  2. +
  3. Klik bovenaan op de projectkiezer › Nieuw project.
  4. +
  5. Naam: bv. PIVOT. Organisatie: jullie domein. Klik op Maken.
  6. +
  7. Controleer bovenaan dat het nieuwe project gekozen is voor je verder gaat.
  8. +
+ +

Stap 2 – API's inschakelen

+

Ga naar API's en services › Bibliotheek, zoek elke API en klik op Inschakelen:

+ + + + + + + + +
APINodig voor
Admin SDK APIGebruikers, wachtwoorden, OU's, Chromebooks, groepen, 2FA
Gmail APIMailbox-delegatie
Enterprise License Manager APIGoogle-licenties
Groups Settings APIVerzendlijsten: wie mag mailen naar de lijst
+ +

Stap 3 – Serviceaccount aanmaken

+
    +
  1. Ga naar IAM en beheer › Serviceaccounts › Serviceaccount maken.
  2. +
  3. Naam: bv. pivot. Klik op Maken en doorgaan.
  4. +
  5. De stappen "Rollen" en "Gebruikerstoegang" laat je leeg (PIVOT werkt via domeinbrede delegatie, niet via + Cloud-rollen). Klik op Klaar.
  6. +
  7. Open het serviceaccount en noteer de Unieke ID (een lang getal, ook "OAuth 2-client-ID" genoemd). + Die heb je nodig in stap 5.
  8. +
+ +

Stap 4 – JSON-sleutel maken

+
    +
  1. Open het serviceaccount › tabblad Sleutels › Sleutel toevoegen › Nieuwe sleutel maken.
  2. +
  3. Kies JSON › Maken. Het bestand wordt gedownload.
  4. +
  5. Bewaar het veilig: wie dit bestand heeft, kan alles wat PIVOT kan. Verwijder het van je pc na stap 6.
  6. +
+
"Het maken van serviceaccountsleutels is uitgeschakeld"? Nieuwe Google Cloud-organisaties blokkeren +sleutels standaard met de organisatiebeleidsregel iam.disableServiceAccountKeyCreation +(of iam.managed.disableServiceAccountKeyCreation). Zet ze uit voor enkel dit project: +
    +
  1. Geef jezelf op organisatieniveau de rol Beheerder van organisatiebeleid (Organization Policy Administrator) + via IAM en beheer › IAM (kies bovenaan de organisatie, niet het project).
  2. +
  3. Kies bovenaan opnieuw het project PIVOT › IAM en beheer › Organisatiebeleid.
  4. +
  5. Zoek disableServiceAccountKeyCreation › Beleid beheren › Beleid van bovenliggend item + overschrijven › regel toevoegen › Afdwingen: Uit › Beleid instellen.
  6. +
  7. Wacht enkele minuten (soms tot een uur) en probeer stap 4 opnieuw.
  8. +
+
+ +

Stap 5 – Domeinbrede delegatie in Google Admin

+
    +
  1. Ga naar admin.google.com › Beveiliging › Toegangs- en + gegevensbeheer › API-beheer › Domeinbrede delegatie beheren.
  2. +
  3. Klik op Nieuwe toevoegen.
  4. +
  5. Client-ID: de Unieke ID uit stap 3 (PIVOT toont ze ook na het uploaden van de sleutel).
  6. +
  7. OAuth-bereiken: plak deze regel in zijn geheel: +
    https://www.googleapis.com/auth/admin.directory.user,https://www.googleapis.com/auth/admin.directory.user.security,https://www.googleapis.com/auth/admin.directory.group.member,https://www.googleapis.com/auth/admin.directory.orgunit,https://www.googleapis.com/auth/admin.directory.device.chromeos,https://www.googleapis.com/auth/admin.directory.device.chromeos.readonly,https://www.googleapis.com/auth/admin.directory.device.mobile,https://www.googleapis.com/auth/admin.directory.device.mobile.readonly,https://www.googleapis.com/auth/apps.licensing,https://www.googleapis.com/auth/gmail.settings.sharing,https://www.googleapis.com/auth/gmail.settings.basic,https://www.googleapis.com/auth/admin.directory.group,https://www.googleapis.com/auth/apps.groups.settings,https://www.googleapis.com/auth/gmail.modify,https://www.googleapis.com/auth/contacts,https://www.googleapis.com/auth/drive
    +
  8. +
  9. Klik op Autoriseren. Het kan enkele minuten duren voor Google dit toepast.
  10. +
+ + + + + + + + + + + + + + + + +
Scope (zonder https://www.googleapis.com/auth/)Waarvoor in PIVOT
admin.directory.userAccounts aanmaken, wachtwoord resetten, leerlingen
admin.directory.user.security2FA: back-upcodes, 2FA resetten
admin.directory.group.member2FA-uitzonderingsgroep
admin.directory.orgunitOrganisatie-eenheden
admin.directory.device.chromeos (+ .readonly)Chromebooks blokkeren/deblokkeren, opzoeken
admin.directory.device.mobile (+ .readonly)Mobiele toestellen
apps.licensingGoogle-licenties
gmail.settings.sharingMailbox-delegatie toevoegen/verwijderen
gmail.settings.basicMailbox-delegaties tonen, Gmail-handtekeningen instellen
gmail.modifyE-mails met een label kopiëren/verplaatsen naar een andere mailbox
driveGedeelde drives: overzicht, leden en rechten (Google Drive API moet aanstaan)
contactsContactlijsten in de mailboxen van gebruikers (People API moet aanstaan)
+
Tip: Bestaat er al een regel voor deze client-ID, klik dan op Bewerken en +vervang de lijst; anders heb je er twee.
+ +

Stap 6 – Sleutel en beheerder instellen in PIVOT

+
    +
  1. Open in PIVOT 🏫 Alle scholen beheren › de school › Bewerken + (of 🔌 Koppelingen van deze school).
  2. +
  3. Bij Algemeen › Beheerder (Google-admin): het account waarnamens PIVOT werkt, bv. ict@school.be. + Dit moet een superbeheerder zijn (of een beheerder met de nodige rechten) in Google Admin.
  4. +
  5. Bij 🔑 Google Workspace › Serviceaccount (JSON-sleutel): upload het JSON-bestand uit stap 4. + PIVOT bewaart het versleuteld en toont daarna het e-mailadres en de client-ID van het serviceaccount.
  6. +
  7. Opslaan, en daarna op 🩺 Testen klikken. Alles in orde? Verwijder het JSON-bestand van je pc.
  8. +
+ +

Stap 7 (optioneel) – Inloggen met Google

+

Hiermee loggen leerkrachten en PIVOT-gebruikers in met hun Google-account in plaats van een wachtwoord.

+
    +
  1. In de Cloud Console (zelfde project): API's en services › OAuth-toestemmingsscherm + (in de nieuwe versie: Google Auth Platform › Branding). Kies als doelgroep Intern, + app-naam PIVOT, ondersteunings-e-mail en je ontwikkelaarsadres. Opslaan.
  2. +
  3. Inloggegevens (of Google Auth Platform › Clients) › Inloggegevens maken › OAuth-client-ID.
  4. +
  5. Type: Webapplicatie, naam PIVOT login.
  6. +
  7. Bij Geautoriseerde omleidings-URI's: het adres van PIVOT + /callback, + bv. https://pivot.school.be/callback. Gebruik je meerdere adressen (per school een eigen hostnaam), voeg ze allemaal toe.
  8. +
  9. Klik op Maken en kopieer de client-ID en het clientgeheim.
  10. +
  11. In PIVOT: vul ze in bij de school onder 🔑 Google Workspace › Google-login client-ID / client-secret, + of één keer voor alle scholen in config.ini onder [google_sso].
  12. +
+ +

Problemen oplossen

+ + + + + + + + + + + + + + + + +
Wat zie je?OorzaakOplossing
unauthorized_client / "Client is unauthorized to retrieve access tokens"Client-ID of scope ontbreekt in de domeinbrede delegatie, of nog niet toegepast.Stap 5 nakijken (juiste Unieke ID, alle scopes), enkele minuten wachten.
"Invalid Input" / "Not Authorized to access this resource"Het beheerdersaccount klopt niet of is geen beheerder.Stap 6, Beheerder (Google-admin).
"<API> has not been used in project … or it is disabled"De API staat niet aan.Stap 2, in het juiste project.
"insufficient authentication scopes"Eén scope ontbreekt (vaak een Gmail-scope).Stap 5.
"redirect_uri_mismatch" bij inloggen met GoogleHet adres van PIVOT staat niet bij de omleidings-URI's.Stap 7.4: exact hetzelfde adres (https, hostnaam, /callback).
Sleutel maken lukt nietOrganisatiebeleid blokkeert sleutels.Kader bij stap 4.
+""" + +STANDAARD = [ + { + 'slug': 'google-koppeling-cloud-console', + 'titel': 'Google-koppeling instellen (Google Cloud Console)', + 'categorie': 'Google Workspace', + 'samenvatting': 'Project, API\'s, serviceaccount en JSON-sleutel, domeinbrede delegatie met alle scopes, ' + 'sleutel in PIVOT en inloggen met Google.', + 'inhoud': GOOGLE_KOPPELING, + }, + { + 'slug': '2fa-uitzondering-google-admin', + 'titel': '2FA-uitzondering instellen in Google Admin', + 'categorie': 'Google Workspace', + 'samenvatting': 'Groep 2fa-uitzondering aanmaken, 2FA uitzetten per OU, rechten voor PIVOT en testen.', + 'inhoud': TWEESTAPS_UITZONDERING, + }, + { + 'slug': 'mailbox-delegatie-google-admin', + 'titel': 'Mailbox-delegatie instellen in Google Admin', + 'categorie': 'Google Workspace', + 'samenvatting': 'Maildelegatie toelaten per OU, Gmail API, scopes voor PIVOT en testen.', + 'inhoud': MAIL_DELEGATIE, + }, +] diff --git a/services/handtekening_service.py b/services/handtekening_service.py new file mode 100644 index 0000000..8acf7cd --- /dev/null +++ b/services/handtekening_service.py @@ -0,0 +1,443 @@ +""" +Gmail › Handtekeningen: één HTML-sjabloon per school, ingevuld per gebruiker en ingesteld in Gmail. + +- Gebruikers: alle accounts in de gekozen OU's (Directory API, optioneel met de onderliggende OU's). +- Functie en telefoon komen uit Google (gebruikersprofiel: organizations[].title en phones[]) en worden daar ook + bewaard als je ze in PIVOT aanpast. Zo staan ze ook in het adresboek van de organisatie. +- Toepassen: Gmail API users.settings.sendAs.patch(signature) namens de gebruiker, voor het hoofdadres en de aliassen. +- Logo: geüpload in PIVOT (publiek op /handtekening-logo//) of een eigen publieke link. + Gmail toont geen ingesloten (base64) afbeeldingen in handtekeningen, enkel afbeeldingen via een link. +- Nachtelijk opnieuw toepassen (schakelaar): gebruikers kunnen hun handtekening in Gmail zelf wijzigen, + zo staat de juiste er de volgende ochtend terug. + +Vereist (domeinbrede delegatie): https://www.googleapis.com/auth/gmail.settings.basic +en voor functie/telefoon https://www.googleapis.com/auth/admin.directory.user (staat al in de standaardlijst). +""" +import hashlib +import html +import json +import re +import secrets +import sys +import threading +import time +from datetime import datetime + +from config import Config +from database import execute_query +from tenancy import school_singleton, met_school, google_credentials, huidige_school + +SCOPE = 'https://www.googleapis.com/auth/gmail.settings.basic' +MAX_TEKENS = 10000 # limiet van Gmail voor een handtekening +MAX_LOGO = 512 * 1024 +LOGO_TYPES = {'image/png': 'png', 'image/jpeg': 'jpg', 'image/gif': 'gif'} +NACHT_UUR_VAN, NACHT_UUR_TOT = 3, 6 + +VELDEN = [ # (sleutel, uitleg) + ('voornaam', 'Voornaam'), ('achternaam', 'Achternaam'), ('naam', 'Voornaam en achternaam'), + ('functie', 'Functie (uit Google)'), ('telefoon', 'Telefoon (uit Google)'), ('email', 'E-mailadres'), + ('schoolnaam', 'Naam van de school'), ('schooladres', 'Adres van de school'), ('logo', 'Logo (afbeelding)'), +] + +STANDAARD_SJABLOON = """ + + + + +
{logo} +
{voornaam} {achternaam}
+{#functie}
{functie}
{/functie} +
{schoolnaam}
+
{schooladres}
+{#telefoon}
Tel. {telefoon}
{/telefoon} + +
""" + + +class HandtekeningFout(Exception): + pass + + +# --------------------------------------------------------------------- +# Opslag (database van de school) +# --------------------------------------------------------------------- +def ensure_tables(): + execute_query(""" + CREATE TABLE IF NOT EXISTS gmail_handtekening ( + ID TINYINT NOT NULL PRIMARY KEY, + Sjabloon MEDIUMTEXT NULL, + OUs TEXT NULL, + Met_sub_ous TINYINT NOT NULL DEFAULT 1, + Logo MEDIUMBLOB NULL, + Logo_type VARCHAR(40) NULL, + Logo_sleutel VARCHAR(40) NULL, + Logo_url VARCHAR(500) NULL, + Logo_breedte INT NOT NULL DEFAULT 120, + Publiek_adres VARCHAR(255) NULL, + Nachtelijk TINYINT NOT NULL DEFAULT 0, + Laatste_nacht DATE NULL, + Nacht_resultaat VARCHAR(500) NULL, + Bijgewerkt DATETIME NULL, + Door VARCHAR(255) NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci""", commit=True) + execute_query(""" + CREATE TABLE IF NOT EXISTS gmail_handtekening_log ( + Email VARCHAR(255) NOT NULL PRIMARY KEY, + Toegepast DATETIME NOT NULL, + Ok TINYINT NOT NULL, + Melding VARCHAR(500) NULL, + Hash CHAR(40) NULL, + Door VARCHAR(255) NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci""", commit=True) + execute_query("INSERT IGNORE INTO gmail_handtekening (ID, Sjabloon) VALUES (1, %s)", (STANDAARD_SJABLOON,), commit=True) + + +def instellingen(): + ensure_tables() + r = (execute_query("SELECT ID, Sjabloon, OUs, Met_sub_ous, Logo_type, Logo_sleutel, Logo_url, Logo_breedte, " + "Publiek_adres, Nachtelijk, Laatste_nacht, Nacht_resultaat, Bijgewerkt, Door, " + "Logo IS NOT NULL AS heeft_logo FROM gmail_handtekening WHERE ID = 1") or [{}])[0] + try: + r['ous'] = [o for o in json.loads(r.get('OUs') or '[]') if isinstance(o, str)] + except ValueError: + r['ous'] = [] + r['logo_link'] = logo_link(r) + return r + + +def bewaar(sjabloon, ous, met_sub, logo_url, logo_breedte, publiek_adres, door): + sjabloon = (sjabloon or '').strip() + if len(sjabloon) > 50000: + raise HandtekeningFout("Het sjabloon is te lang.") + execute_query("UPDATE gmail_handtekening SET Sjabloon = %s, OUs = %s, Met_sub_ous = %s, Logo_url = %s, " + "Logo_breedte = %s, Publiek_adres = %s, Bijgewerkt = NOW(), Door = %s WHERE ID = 1", + (sjabloon, json.dumps(sorted(set(ous))), 1 if met_sub else 0, (logo_url or '').strip() or None, + max(20, min(600, int(logo_breedte or 120))), (publiek_adres or '').strip().rstrip('/') or None, door), + commit=True) + + +def bewaar_logo(data, mimetype): + if mimetype not in LOGO_TYPES: + raise HandtekeningFout("Enkel PNG, JPG of GIF als logo.") + if len(data) > MAX_LOGO: + raise HandtekeningFout(f"Het logo is te groot (max. {MAX_LOGO // 1024} kB).") + execute_query("UPDATE gmail_handtekening SET Logo = %s, Logo_type = %s, Logo_sleutel = %s WHERE ID = 1", + (data, mimetype, secrets.token_hex(12)), commit=True) + + +def verwijder_logo(): + execute_query("UPDATE gmail_handtekening SET Logo = NULL, Logo_type = NULL, Logo_sleutel = NULL WHERE ID = 1", + commit=True) + + +def logo_bestand(sleutel): + ensure_tables() + r = execute_query("SELECT Logo, Logo_type FROM gmail_handtekening WHERE ID = 1 AND Logo_sleutel = %s", (sleutel,)) + return (r[0]['Logo'], r[0]['Logo_type']) if r and r[0]['Logo'] else (None, None) + + +def logo_link(inst): + """Publieke link naar het logo: eigen link, of het geüploade logo via PIVOT.""" + if inst.get('Logo_url'): + return inst['Logo_url'] + if inst.get('heeft_logo') and inst.get('Logo_sleutel') and inst.get('Publiek_adres'): + ext = LOGO_TYPES.get(inst.get('Logo_type'), 'png') + return f"{inst['Publiek_adres']}/handtekening-logo/{huidige_school().code}/{inst['Logo_sleutel']}.{ext}" + return '' + + +def zet_nacht(aan, door): + execute_query("UPDATE gmail_handtekening SET Nachtelijk = %s, Door = %s WHERE ID = 1", (1 if aan else 0, door), + commit=True) + + +def logboek(): + ensure_tables() + return {r['Email'].lower(): r for r in execute_query("SELECT * FROM gmail_handtekening_log") or []} + + +# --------------------------------------------------------------------- +# Sjabloon invullen +# --------------------------------------------------------------------- +def gegevens(gebruiker): + s = huidige_school() + return {'voornaam': gebruiker.get('voornaam', ''), 'achternaam': gebruiker.get('achternaam', ''), + 'naam': f"{gebruiker.get('voornaam', '')} {gebruiker.get('achternaam', '')}".strip(), + 'functie': gebruiker.get('functie', ''), 'telefoon': gebruiker.get('telefoon', ''), + 'email': gebruiker.get('email', ''), 'schoolnaam': s.naam or '', 'schooladres': s.get('ADRES') or ''} + + +def vul_in(sjabloon, waarden, inst): + """{veld} → waarde (HTML-veilig); {#veld}...{/veld} valt weg als het veld leeg is; {logo} → .""" + def blok(m): + return m.group(2) if (waarden.get(m.group(1)) or '').strip() or (m.group(1) == 'logo' and inst['logo_link']) else '' + uit = re.sub(r'\{#(\w+)\}(.*?)\{/\1\}', blok, sjabloon or '', flags=re.S) + link = inst.get('logo_link') or '' + logo = (f'{html.escape(waarden.get(') if link else '' + + def veld(m): + k = m.group(1) + if k == 'logo': + return logo + if k in waarden: + w = html.escape(waarden[k] or '') + return w.replace('\n', '
') if k == 'schooladres' else w + return m.group(0) + return re.sub(r'\{(\w+)\}', veld, uit) + + +# --------------------------------------------------------------------- +# Google: gebruikers, functie en telefoon +# --------------------------------------------------------------------- +def _directory(): + from googleapiclient.discovery import build + creds = google_credentials(Config.GOOGLE_API_SCOPES, subject=Config.GOOGLE_ADMIN_EMAIL) + return build('admin', 'directory_v1', credentials=creds, static_discovery=False, cache_discovery=False) + + +def _gmail(mailbox): + from googleapiclient.discovery import build + creds = google_credentials([SCOPE], subject=mailbox) + try: + return build('gmail', 'v1', credentials=creds, static_discovery=True, cache_discovery=False) + except Exception: + return build('gmail', 'v1', credentials=creds, static_discovery=False, cache_discovery=False) + + +def vriendelijk(e): + t = str(e) + laag = t.lower() + if 'unauthorized_client' in laag or 'access_denied' in laag: + return (f"Google weigert de toegang: voeg in de Admin-console › Beveiliging › API-beheer › Domeinbrede delegatie " + f"het bereik {SCOPE} toe bij het serviceaccount van PIVOT.") + if 'has not been used' in laag or 'is disabled' in laag or 'accessnotconfigured' in laag: + return "De Gmail API staat niet aan in het Google Cloud-project van het serviceaccount." + if 'mail service not enabled' in laag or 'failedprecondition' in laag: + return "Deze gebruiker heeft geen Gmail (geen licentie of Gmail uitgeschakeld)." + if 'not authorized to access this resource' in laag or 'insufficient' in laag: + return "Google weigert het: de beheerder mag deze gebruiker niet aanpassen (scope admin.directory.user?)." + return t[:250] + + +def ou_lijst(): + try: + resp = _directory().orgunits().list(customerId='my_customer', type='all').execute() + except Exception as e: + raise HandtekeningFout(f"OU's ophalen mislukt: {vriendelijk(e)}") + return sorted([o['orgUnitPath'] for o in resp.get('organizationUnits', [])] + ['/'], key=str.lower) + + +def _functie(u): + orgs = u.get('organizations') or [] + org = next((o for o in orgs if o.get('primary')), orgs[0] if orgs else {}) + return (org.get('title') or '').strip() + + +def _telefoon(u): + tel = u.get('phones') or [] + t = next((p for p in tel if p.get('type') == 'work'), None) or next((p for p in tel if p.get('primary')), None) \ + or (tel[0] if tel else {}) + return (t.get('value') or '').strip() + + +def gebruikers(ous, met_sub=True): + """Alle (niet-geschorste) gebruikers in de OU's: [{'email','voornaam','achternaam','ou','functie','telefoon'}].""" + if not ous: + return [] + api, uit, gezien = _directory(), [], set() + for ou in ous: + token = None + while True: + try: + params = dict(customer='my_customer', maxResults=500, pageToken=token, projection='full', + fields='nextPageToken,users(primaryEmail,name,orgUnitPath,organizations,phones,suspended,archived)') + if ou != '/': + params['query'] = "orgUnitPath='" + ou.replace("\\", "\\\\").replace("'", "\\'") + "'" + resp = api.users().list(**params).execute() + except Exception as e: + raise HandtekeningFout(f"Gebruikers van {ou} ophalen mislukt: {vriendelijk(e)}") + for u in resp.get('users', []): + pad = u.get('orgUnitPath') or '/' + if not met_sub and pad != ou: + continue + if u.get('suspended') or u.get('archived'): + continue + email = u['primaryEmail'].lower() + if email in gezien: + continue + gezien.add(email) + n = u.get('name') or {} + uit.append({'email': email, 'voornaam': n.get('givenName', ''), 'achternaam': n.get('familyName', ''), + 'ou': pad, 'functie': _functie(u), 'telefoon': _telefoon(u)}) + token = resp.get('nextPageToken') + if not token: + break + return sorted(uit, key=lambda x: (x['achternaam'].lower(), x['voornaam'].lower())) + + +def bewaar_in_google(email, functie, telefoon): + """Functie (primaire organisatie) en werktelefoon van een gebruiker in Google aanpassen; de rest blijft.""" + api = _directory() + try: + u = api.users().get(userKey=email, projection='full', fields='organizations,phones').execute() + orgs = u.get('organizations') or [] + org = next((o for o in orgs if o.get('primary')), orgs[0] if orgs else None) + if org is None: + if functie: + orgs = [{'title': functie, 'primary': True, 'customType': ''}] + else: + org['title'] = functie + tel = [p for p in (u.get('phones') or []) if p.get('type') != 'work'] + if telefoon: + tel.insert(0, {'value': telefoon, 'type': 'work', 'primary': not any(p.get('primary') for p in tel)}) + api.users().patch(userKey=email, body={'organizations': orgs, 'phones': tel or []}).execute() + except Exception as e: + raise HandtekeningFout(f"{email}: {vriendelijk(e)}") + + +def pas_toe(gebruiker, inst, door=None, alleen_gewijzigd=False): + """Zet de handtekening voor het hoofdadres en de aliassen. Geeft 'ok' / 'ongewijzigd' of gooit HandtekeningFout.""" + tekst = vul_in(inst.get('Sjabloon') or '', gegevens(gebruiker), inst) + if len(tekst) > MAX_TEKENS: + raise HandtekeningFout(f"De handtekening is te lang voor Gmail ({len(tekst)} tekens, max. {MAX_TEKENS}).") + h = hashlib.sha1(tekst.encode('utf-8')).hexdigest() + email = gebruiker['email'] + if alleen_gewijzigd: + oud = execute_query("SELECT Hash, Ok FROM gmail_handtekening_log WHERE Email = %s", (email,)) + if oud and oud[0]['Ok'] and oud[0]['Hash'] == h and _huidige_in_gmail_gelijk(email, tekst): + return 'ongewijzigd' + try: + g = _gmail(email) + adressen = g.users().settings().sendAs().list(userId='me').execute().get('sendAs', []) + for a in adressen: + if a.get('isPrimary') or a.get('verificationStatus') == 'accepted' or a.get('treatAsAlias'): + g.users().settings().sendAs().patch(userId='me', sendAsEmail=a['sendAsEmail'], + body={'signature': tekst}).execute() + melding, ok = None, 1 + except Exception as e: + melding, ok = vriendelijk(e), 0 + execute_query("INSERT INTO gmail_handtekening_log (Email, Toegepast, Ok, Melding, Hash, Door) " + "VALUES (%s, NOW(), %s, %s, %s, %s) ON DUPLICATE KEY UPDATE Toegepast = NOW(), Ok = VALUES(Ok), " + "Melding = VALUES(Melding), Hash = VALUES(Hash), Door = VALUES(Door)", + (email, ok, melding, h if ok else None, door), commit=True) + if not ok: + raise HandtekeningFout(melding) + return 'ok' + + +def _huidige_in_gmail_gelijk(email, tekst): + """Staat de handtekening nog ongewijzigd in Gmail? (De gebruiker kan ze zelf aangepast hebben.)""" + try: + for a in _gmail(email).users().settings().sendAs().list(userId='me').execute().get('sendAs', []): + if a.get('isPrimary'): + gewoon = lambda x: re.sub(r'\s+', '', html.unescape(x or '')).lower() + return gewoon(a.get('signature')) == gewoon(tekst) + except Exception: + pass + return False + + +# --------------------------------------------------------------------- +# Toepassen op de achtergrond (met voortgang) +# --------------------------------------------------------------------- +class Toepasser: + def __new__(cls): + def init(inst): + inst._lock = threading.Lock() + inst.status = {'bezig': False, 'voortgang': 0, 'totaal': 0, 'gelukt': 0, 'ongewijzigd': 0, 'fouten': 0, + 'fout': None, 'resultaat': None, 'foutvoorbeelden': []} + return school_singleton(cls, init) + + def run(self, emails=None, door=None, alleen_gewijzigd=False): + if not self._lock.acquire(blocking=False): + return False, "Er loopt al een opdracht." + s = self.status + try: + s.update(bezig=True, voortgang=0, totaal=0, gelukt=0, ongewijzigd=0, fouten=0, fout=None, resultaat=None, + foutvoorbeelden=[]) + inst = instellingen() + lijst = gebruikers(inst['ous'], bool(inst.get('Met_sub_ous'))) + if emails is not None: + kies = {e.lower() for e in emails} + lijst = [u for u in lijst if u['email'] in kies] + s['totaal'] = len(lijst) + for u in lijst: + try: + if pas_toe(u, inst, door, alleen_gewijzigd) == 'ongewijzigd': + s['ongewijzigd'] += 1 + else: + s['gelukt'] += 1 + except Exception as e: + s['fouten'] += 1 + if len(s['foutvoorbeelden']) < 6: + s['foutvoorbeelden'].append(f"{u['email']}: {str(e)[:180]}") + s['voortgang'] += 1 + msg = f"Handtekening ingesteld voor {s['gelukt']} gebruiker(s)" + if s['ongewijzigd']: + msg += f", {s['ongewijzigd']} waren al in orde" + if s['fouten']: + msg += f", {s['fouten']} mislukt" + s['resultaat'] = msg + '.' + return True, s['resultaat'] + except Exception as e: + s['fout'] = str(e)[:400] + return False, s['fout'] + finally: + s['bezig'] = False + self._lock.release() + + def start(self, emails=None, door=None): + if self.status['bezig']: + return False + self.status.update(bezig=True, voortgang=0, totaal=0, fout=None, resultaat=None) + threading.Thread(target=met_school(lambda: self.run(emails, door)), daemon=True).start() + return True + + +# --------------------------------------------------------------------- +# Nachtelijk opnieuw toepassen (per school met de schakelaar aan) +# --------------------------------------------------------------------- +def _nacht_claim(vandaag): + return execute_query("UPDATE gmail_handtekening SET Laatste_nacht = %s WHERE ID = 1 AND Nachtelijk = 1 " + "AND (Laatste_nacht IS NULL OR Laatste_nacht < %s)", (vandaag, vandaag), commit=True) == 1 + + +def nacht_voor_alle_scholen(): + from tenancy import alle_scholen, in_school + vandaag = datetime.now().strftime('%Y-%m-%d') + for school in alle_scholen(): + if not school.google_sleutel: + continue + with in_school(school): + try: + ensure_tables() + if not _nacht_claim(vandaag): + continue + ok, msg = Toepasser().run(door='PIVOT (nachtelijk)', alleen_gewijzigd=True) + execute_query("UPDATE gmail_handtekening SET Nacht_resultaat = %s WHERE ID = 1", + (f"{datetime.now():%d-%m-%Y %H:%M}: {msg}"[:500],), commit=True) + except Exception as e: + sys.stderr.write(f"Handtekeningen nachtelijk ({school.code}): {e}\n") + + +_thread = None + + +def start_scheduler(): + global _thread + if _thread: + return + + def loop(): + time.sleep(90) + while True: + try: + if NACHT_UUR_VAN <= datetime.now().hour < NACHT_UUR_TOT: + nacht_voor_alle_scholen() + except Exception as e: + sys.stderr.write(f"Handtekeningen nachtelijke taak: {e}\n") + time.sleep(15 * 60) + + _thread = threading.Thread(target=loop, daemon=True) + _thread.start() diff --git a/services/html_schoon.py b/services/html_schoon.py new file mode 100644 index 0000000..4520acc --- /dev/null +++ b/services/html_schoon.py @@ -0,0 +1,115 @@ +""" +Maakt HTML uit de handleiding-editor veilig: enkel een vaste lijst tags en attributen blijft over. +Scripts, event-handlers (onclick, ...), stijlen en javascript:-links verdwijnen. +""" +from html import escape +from html.parser import HTMLParser + +TOEGELATEN = { + 'p': (), 'br': (), 'hr': (), 'div': (), 'span': (), + 'h2': ('id',), 'h3': ('id',), 'h4': ('id',), + 'strong': (), 'b': (), 'em': (), 'i': (), 'u': (), 's': (), 'mark': (), 'kbd': (), 'sub': (), 'sup': (), + 'ul': (), 'ol': ('start',), 'li': (), + 'blockquote': (), 'code': (), 'pre': (), + 'a': ('href', 'title', 'target'), + 'img': ('src', 'alt', 'title', 'width'), + 'table': (), 'thead': (), 'tbody': (), 'tr': (), 'th': ('colspan', 'rowspan'), 'td': ('colspan', 'rowspan'), + 'figure': (), 'figcaption': (), +} +LEEG = {'br', 'hr', 'img'} +KLASSEN = {'tip', 'let-op', 'info'} # kaders in de editor +WEGGOOIEN_MET_INHOUD = {'script', 'style', 'iframe', 'object', 'embed', 'noscript', 'template', 'svg', 'math'} +# Word/Docs plakt deze tags; de inhoud blijft, de tag niet +VERTALING = {'b': 'strong', 'i': 'em', 'h1': 'h2', 'h5': 'h4', 'h6': 'h4'} + + +def _veilige_url(url, afbeelding=False): + u = (url or '').strip() + laag = u.lower().replace('\x00', '') + if afbeelding and laag.startswith('data:image/') and ';base64,' in laag and not laag.startswith('data:image/svg'): + return u + if laag.startswith(('http://', 'https://', '/', '#')) or (not afbeelding and laag.startswith('mailto:')): + return u + return None + + +class _Schoonmaker(HTMLParser): + def __init__(self): + super().__init__(convert_charrefs=True) + self.uit = [] + self.open = [] + self.weg = 0 + + def handle_starttag(self, tag, attrs): + if tag in WEGGOOIEN_MET_INHOUD: + if tag not in LEEG: + self.weg += 1 + return + if self.weg: + return + tag = VERTALING.get(tag, tag) + if tag not in TOEGELATEN: + return + delen = [tag] + for naam, waarde in attrs: + naam = (naam or '').lower() + if naam == 'class': + klassen = [k for k in (waarde or '').split() if k in KLASSEN] + if klassen: + delen.append(f'class="{escape(" ".join(klassen))}"') + continue + if naam not in TOEGELATEN[tag]: + continue + if naam in ('href', 'src'): + waarde = _veilige_url(waarde, afbeelding=(naam == 'src')) + if waarde is None: + continue + if naam == 'target': + waarde = '_blank' + if naam in ('width', 'colspan', 'rowspan', 'start') and not str(waarde or '').isdigit(): + continue + delen.append(f'{naam}="{escape(waarde or "", quote=True)}"') + if tag == 'a' and any(d.startswith('target=') for d in delen): + delen.append('rel="noopener noreferrer"') + if tag == 'img' and not any(d.startswith('src=') for d in delen): + return + self.uit.append('<' + ' '.join(delen) + '>') + if tag not in LEEG: + self.open.append(tag) + + def handle_startendtag(self, tag, attrs): + self.handle_starttag(tag, attrs) + tag = VERTALING.get(tag, tag) + if tag not in LEEG and self.open and self.open[-1] == tag: + self.handle_endtag(tag) + + def handle_endtag(self, tag): + if tag in WEGGOOIEN_MET_INHOUD: + self.weg = max(0, self.weg - 1) + return + if self.weg: + return + tag = VERTALING.get(tag, tag) + if tag not in TOEGELATEN or tag in LEEG or tag not in self.open: + return + while self.open: + t = self.open.pop() + self.uit.append(f'') + if t == tag: + break + + def handle_data(self, data): + if not self.weg: + self.uit.append(escape(data, quote=False)) + + def resultaat(self): + while self.open: + self.uit.append(f'') + return ''.join(self.uit) + + +def schoon(html): + p = _Schoonmaker() + p.feed(html or '') + p.close() + return p.resultaat().strip() diff --git a/services/internaat_service.py b/services/internaat_service.py new file mode 100644 index 0000000..b0b6708 --- /dev/null +++ b/services/internaat_service.py @@ -0,0 +1,233 @@ +""" +Internaat: de internen uit Informat (Internaat WEB API, versie 1). + +- Token: zelfde identityserver en client-ID/secret als de andere Informat-API's, scope + api_informat_sas_internaat. + (meerdere instellingsnummers mogen in één token, gescheiden door een spatie). +- GET /pupils?schoolYear=2026-27&refdate=JJJJ-MM-DD → de internen met hun gegevens, relaties, ... +- GET /registrations?schoolYear=2026-27 → de inschrijvingen (internaat, vestiging, kamers, ...) + Headers: InstituteNo (instellingsnummer) en Api-Version: 1. 404 = geen gegevens. +Het resultaat wordt 10 minuten per school, schooljaar en peildatum bewaard. +""" +import time +from datetime import date + +import requests + +from config import Config + +CACHE_SECONDEN = 600 +TIMEOUT = (15, 180) +STANDAARD_API = 'https://internaatapi.informatsoftware.be' +STANDAARD_SCOPE = 'api_informat_sas_internaat' + + +class InternaatFout(Exception): + pass + + +def _datum(w): + if not w: + return None + try: + return date.fromisoformat(str(w)[:10]) + except ValueError: + return None + + +def _lopend(item, peildatum): + """Loopt dit (inschrijving of kamer) op de peildatum?""" + begin, einde = _datum(item.get('begindatum')), _datum(item.get('einddatum')) + return (not begin or begin <= peildatum) and (not einde or einde >= peildatum) + + +class InternaatService: + def __init__(self): + from tenancy import huidige_school, informat_campussen + self.school = huidige_school() + self.base = (self.school.get('INFORMAT_INTERNAAT_API') or STANDAARD_API).rstrip('/') + scope = (self.school.get('INFORMAT_INTERNAAT_SCOPE') or STANDAARD_SCOPE).strip() + self.scope_basis = scope.rsplit('.', 1)[0] if scope.rsplit('.', 1)[-1].isdigit() else scope + # Instellingsnummer(s) van het internaat; leeg = die van de Informat-koppeling van de school + eigen = informat_campussen(self.school.waarden.get('INFORMAT_INTERNAAT_NUMMER')) + self.nummers = eigen or informat_campussen(self.school.waarden.get('INFORMAT_INST_NUMBER')) + + # ---------------- authenticatie ---------------- + def _token(self, nummer): + sleutel = ('internaat_token', nummer) + bewaard = self.school.cache.get(sleutel) + if bewaard and time.time() < bewaard[0]: + return bewaard[1] + scope = f"{self.scope_basis}.{nummer}" + try: + r = requests.post(Config.INFORMAT_AUTH_URL, data={ + 'grant_type': 'client_credentials', 'client_id': Config.INFORMAT_ID, + 'client_secret': Config.INFORMAT_SECRET, 'scope': scope}, timeout=15) + except Exception as e: + raise InternaatFout(f"Informat (identityserver) is niet bereikbaar: {e}") + try: + js = r.json() + except Exception: + js = {} + if r.status_code != 200 or not js.get('access_token'): + fout = js.get('error') or f"HTTP {r.status_code}" + if fout == 'invalid_scope': + raise InternaatFout(f"Informat weigert de scope {scope} (invalid_scope). Heeft het internaat met " + f"instellingsnummer {nummer} in Informat de app toegang gegeven tot de Internaat-API? " + "Klopt het instellingsnummer van het internaat (Koppelingen › Informat)?") + if fout == 'invalid_client': + raise InternaatFout("Informat kent deze client-ID/secret niet (invalid_client). Kijk Koppelingen › Informat na.") + raise InternaatFout(f"Aanmelden bij de Internaat-API mislukt: {fout}") + self.school.cache[sleutel] = (time.time() + int(js.get('expires_in') or 3600) - 60, js['access_token']) + return js['access_token'] + + def _get(self, pad, nummer, params): + headers = {'Authorization': f"BEARER {self._token(nummer)}", 'InstituteNo': nummer, 'Api-Version': '1', + 'Accept': 'application/json'} + url = f"{self.base}{pad}" + try: + r = requests.get(url, params=params, headers=headers, timeout=TIMEOUT) + if r.status_code == 405: # de voorbeelden in de handleiding gebruiken POST + r = requests.post(url, params=params, headers=headers, timeout=TIMEOUT) + except requests.exceptions.Timeout: + raise InternaatFout("De Internaat-API van Informat antwoordde niet op tijd. Probeer later opnieuw.") + except Exception as e: + raise InternaatFout(f"De Internaat-API van Informat is niet bereikbaar: {e}") + if r.status_code == 404: + return [] # volgens Informat: geen gegevens + if r.status_code in (401, 403): + raise InternaatFout(f"Informat weigert de toegang tot de internen van {nummer} ({r.status_code}).") + if r.status_code != 200: + raise InternaatFout(f"Internaat-API ({nummer}): HTTP {r.status_code} {r.text[:200]}") + try: + data = r.json() + except Exception: + raise InternaatFout(f"Onverwacht antwoord van de Internaat-API: {r.text[:200]}") + return data if isinstance(data, list) else (data.get('items') or data.get('data') or []) + + # ---------------- internen ---------------- + def internen(self, schooljaar, peildatum=None, vernieuw=False): + """(rijen, opgehaald) met één rij per intern die op de peildatum ingeschreven is.""" + if not self.nummers: + raise InternaatFout("Er is nog geen instellingsnummer voor het internaat ingesteld (Koppelingen › Informat).") + peildatum = peildatum or date.today() + sleutel = ('internen', schooljaar, peildatum.isoformat()) + bewaard = self.school.cache.get(sleutel) + if bewaard and not vernieuw and time.time() - bewaard[0] < CACHE_SECONDEN: + return bewaard[1], bewaard[0] + rijen, gezien = [], set() + for nummer, campusnaam in self.nummers: + inschrijvingen = {} + for ins in self._get('/registrations', nummer, {'schoolYear': schooljaar}): + if _lopend(ins, peildatum) or str(ins.get('pPersoon')) not in inschrijvingen: + inschrijvingen[str(ins.get('pPersoon'))] = ins + for p in self._get('/pupils', nummer, {'schoolYear': schooljaar, 'refdate': peildatum.isoformat()}): + sleutel_p = str(p.get('persoonId') or p.get('pPersoon')) + if sleutel_p in gezien: # zelfde intern via een tweede instellingsnummer + continue + gezien.add(sleutel_p) + ins = inschrijvingen.get(str(p.get('pPersoon'))) or {} + kamer = next((k for k in ins.get('kamers') or [] if _lopend(k, peildatum)), None) + ouders = [] + for rel in sorted(p.get('relaties') or [], key=lambda x: (x.get('lpv') or 9)): + if rel.get('isOverleden'): + continue + gsm = next((c.get('nr') for c in rel.get('comnrs') or [] if (c.get('soort') or '').lower() == 'gsm'), None) \ + or next((c.get('nr') for c in rel.get('comnrs') or []), None) + mail = next((e.get('email') for e in rel.get('emails') or [] if e.get('schoolcom')), None) \ + or next((e.get('email') for e in rel.get('emails') or []), None) + ouders.append({'type': rel.get('type') or '', 'naam': f"{rel.get('voornaam') or ''} {rel.get('naam') or ''}".strip(), + 'gsm': gsm or '', 'email': mail or ''}) + adres = next((a for a in p.get('adressen') or [] if a.get('isVerblijf')), None) or \ + next(iter(p.get('adressen') or []), None) or {} + rijen.append({ + 'id': p.get('pPersoon'), + 'naam': (p.get('naam') or '').strip(), 'voornaam': (p.get('voornaam') or '').strip(), + 'geboortedatum': _datum(p.get('geboortedatum')), 'geslacht': p.get('geslacht') or '', + 'stamnummer': ins.get('stamnr') or '', + 'internaat': ins.get('internaat') or campusnaam or nummer, 'nummer': nummer, + 'vestiging': ins.get('vestiging') or '', + 'begin': _datum(ins.get('begindatum')), 'einde': _datum(ins.get('einddatum')), + 'financiering': ins.get('fin') or '', + 'kamer': ' '.join(x for x in ( + f"blok {kamer.get('blok')}" if kamer and kamer.get('blok') else '', + f"verd. {kamer.get('verdiep')}" if kamer and kamer.get('verdiep') not in (None, '') else '', + f"nr. {kamer.get('nr')}" if kamer and kamer.get('nr') not in (None, '') else '') if x), + 'kamer_sort': f"{(kamer or {}).get('blok') or ''}-{str((kamer or {}).get('verdiep') or '').zfill(3)}-{str((kamer or {}).get('nr') or '').zfill(5)}" if kamer else '', + 'ouders': ouders, + 'gemeente': ' '.join(x for x in (adres.get('postcode'), adres.get('gemeente')) if x), + 'huisdokter': ' – '.join(x for x in (p.get('huisdokter'), p.get('telefoonHuisdokter')) if x), + }) + rijen.sort(key=lambda r: (r['naam'].lower(), r['voornaam'].lower())) + nu = time.time() + self.school.cache[sleutel] = (nu, rijen) + return rijen, nu + + +# --------------------------------------------------------------------- +# Bestaat de intern in Smartschool? (gebruikersnaam = "voornaam achternaam") +# --------------------------------------------------------------------- +SS_CACHE_SECONDEN = 3600 + + +def _naamdeel(tekst): + """'Van den Broeck' → 'vandenbroeck', 'Zoë' → 'zoe'.""" + import unicodedata + t = unicodedata.normalize('NFKD', str(tekst or '')).encode('ascii', 'ignore').decode().lower() + return ''.join(c for c in t if c.isalnum() or c == '-') + + +def ss_kandidaten(voornaam, naam): + """Mogelijke Smartschool-gebruikersnamen: eerst "voornaam achternaam" (met spatie), dan varianten.""" + v, n = _naamdeel(voornaam), _naamdeel(naam) + vol = ' '.join(f"{str(voornaam).strip()} {str(naam).strip()}".split()) + # Op het internaat: "voornaam achternaam" met een spatie (eerst zoals in Informat, dan in kleine letters) + uit = [vol, vol.lower(), f"{v} {n}", + f"{v}.{n}", f"{v}.{n.replace('-', '')}", f"{v.replace('-', '')}.{n.replace('-', '')}", f"{v}{n}"] + return [x for x in dict.fromkeys(uit) if x.strip('. ')] + + +def smartschool_status(personen): + """personen: [{'pid', 'voornaam', 'naam'}] → {pid: gebruikersnaam of None}. 1 uur bewaard per school.""" + import threading + from concurrent.futures import ThreadPoolExecutor + from tenancy import huidige_school, in_school + school = huidige_school() + if not school.heeft('SS_API_PLATFORM', 'SS_API_PW'): + raise InternaatFout("Smartschool is nog niet ingesteld voor deze school (🔌 Koppelingen › Smartschool).") + cache = school.cache.setdefault('ss_internen', {}) + nu = time.time() + uit, te_doen = {}, [] + for p in personen: + sleutel = (str(p.get('pid')), p.get('voornaam') or '', p.get('naam') or '') + hit = cache.get(sleutel) + if hit and nu - hit[0] < SS_CACHE_SECONDEN: + uit[sleutel[0]] = hit[1] + else: + te_doen.append(sleutel) + if not te_doen: + return uit + lokaal = threading.local() + + def zoek(sleutel): + pid, voornaam, naam = sleutel + with in_school(school): + if not hasattr(lokaal, 'ss'): + from services.smartschool_api import SmartschoolService + lokaal.ss = SmartschoolService(Config.SS_API_PLATFORM, Config.SS_API_PW) + gevonden = None + for kandidaat in ss_kandidaten(voornaam, naam): + try: + raw = lokaal.ss.client.service.getUserDetailsByUsername(Config.SS_API_PW, kandidaat) + except Exception: + continue + if isinstance(raw, str) and raw.strip().startswith('{'): + gevonden = kandidaat + break + return sleutel, gevonden + + with ThreadPoolExecutor(max_workers=4) as pool: + for sleutel, gevonden in pool.map(zoek, te_doen): + cache[sleutel] = (time.time(), gevonden) + uit[sleutel[0]] = gevonden + return uit diff --git a/services/ipblokkade.py b/services/ipblokkade.py new file mode 100644 index 0000000..8eb3aca --- /dev/null +++ b/services/ipblokkade.py @@ -0,0 +1,196 @@ +""" +IP-blokkades: aanvragen van bepaalde IP-adressen of reeksen weigeren, voor alle scholen. + +Regels staan in de centrale tabel IP_blokkades: +- Soort 'blokkeren' → elke aanvraag van zo'n adres krijgt 403 (ook de loginpagina). +- Soort 'toestaan' → uitzondering: dit adres mag altijd, ook als het in een geblokkeerde reeks valt. + +Een reeks kan je schrijven als: + 85. → alles wat met 85. begint (85.0.0.0/8) + 85.12. → 85.12.0.0/16 + 85.12.3. → 85.12.3.0/24 + 85.12.3.4 → één adres + 85.0.0.0/8 → CIDR-notatie (ook IPv6, bv. 2a02:1810::/32) +De lijst wordt 30 seconden in het geheugen gehouden; een wijziging in PIVOT geldt meteen. +Lukt het lezen van de database niet, dan wordt niemand geweigerd (de site blijft bereikbaar). +""" +import ipaddress +import sys +import threading +import time + +CACHE_SECONDEN = 30 +_cache = {'t': 0, 'regels': []} +_lock = threading.Lock() + + +class OngeldigeReeks(ValueError): + pass + + +def normaliseer(reeks): + """Tekst van de gebruiker → ip_network. '85.' → 85.0.0.0/8, '85.12.3.4' → 85.12.3.4/32.""" + t = str(reeks or '').strip().replace(' ', '') + if not t: + raise OngeldigeReeks("Vul een IP-adres of reeks in.") + if '/' not in t and ':' not in t: + delen = t.rstrip('.').split('.') if t.endswith('.') or t.count('.') < 3 else None + if delen is not None: + if not 1 <= len(delen) <= 3 or not all(d.isdigit() and 0 <= int(d) <= 255 for d in delen): + raise OngeldigeReeks(f"'{reeks}' is geen geldige reeks. Voorbeelden: 85. 85.12. 85.12.3.4 85.0.0.0/8") + t = '.'.join(delen + ['0'] * (4 - len(delen))) + f"/{8 * len(delen)}" + try: + return ipaddress.ip_network(t, strict=False) + except ValueError: + raise OngeldigeReeks(f"'{reeks}' is geen geldig IP-adres of geldige reeks. " + "Voorbeelden: 85. 85.12. 85.12.3.4 85.0.0.0/8") + + +def ensure_table(): + from database import central_query + central_query(""" + CREATE TABLE IF NOT EXISTS PIVOT_instellingen ( + Sleutel VARCHAR(50) NOT NULL PRIMARY KEY, + Waarde VARCHAR(255) NULL, + Bijgewerkt DATETIME NULL, + Door VARCHAR(100) NULL + ) ENGINE=InnoDB""", commit=True) + central_query(""" + CREATE TABLE IF NOT EXISTS IP_blokkades ( + ID INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + Reeks VARCHAR(64) NOT NULL, + Soort VARCHAR(10) NOT NULL DEFAULT 'blokkeren', + Opmerking VARCHAR(255) NULL, + Scholen VARCHAR(1000) NULL, + Aangemaakt DATETIME NOT NULL, + Door VARCHAR(100) NULL, + Gebruiker_ID INT NULL, + Aantal_geweigerd INT NOT NULL DEFAULT 0, + Laatst_geweigerd DATETIME NULL, + UNIQUE KEY uq_ip_reeks (Reeks, Soort) + ) ENGINE=InnoDB""", commit=True) + # Bestaande tabel van vóór de schoolkeuze bij "Altijd toestaan" (of met de eerdere kolom School_code: één school) + kolommen = {r['Field'].lower() for r in central_query("SHOW COLUMNS FROM IP_blokkades") or []} + if 'scholen' not in kolommen: + central_query("ALTER TABLE IP_blokkades ADD COLUMN Scholen VARCHAR(1000) NULL AFTER Opmerking", commit=True) + if 'school_code' in kolommen: + central_query("UPDATE IP_blokkades SET Scholen = School_code WHERE School_code IS NOT NULL", commit=True) + if 'school_code' in kolommen: + central_query("ALTER TABLE IP_blokkades DROP COLUMN School_code", commit=True) + if 'gebruiker_id' not in kolommen: # wie de regel toevoegde (gewone gebruikers mogen enkel hun eigen regels verwijderen) + central_query("ALTER TABLE IP_blokkades ADD COLUMN Gebruiker_ID INT NULL AFTER Door", commit=True) + + +def scholen_codes(waarde): + """'a,b' (kolom Scholen) → ['a', 'b'].""" + return [c for c in (x.strip() for x in str(waarde or '').split(',')) if c] + + +def scholen_waarde(codes): + """['a', 'b'] → 'a,b' (of None), zonder dubbels, in de gegeven volgorde.""" + uit = [] + for c in codes or []: + c = str(c).strip() + if c and c not in uit: + uit.append(c) + return ','.join(uit)[:1000] or None + + +MODUS_SLEUTEL = 'ip_enkel_toegestaan' # '1' = enkel adressen uit "Altijd toestaan" mogen binnen +TELLER_SLEUTEL = 'ip_whitelist_geweigerd' # aantal weigeringen door die modus + + +def _laad(): + from database import central_query + uit = [] + for r in central_query("SELECT ID, Reeks, Soort FROM IP_blokkades") or []: + try: + uit.append((r['ID'], r['Soort'], ipaddress.ip_network(r['Reeks'], strict=False))) + except ValueError: + continue + rij = central_query("SELECT Waarde FROM PIVOT_instellingen WHERE Sleutel = %s", (MODUS_SLEUTEL,)) + _cache.update(t=time.time(), regels=uit, enkel_toegestaan=bool(rij and rij[0]['Waarde'] == '1')) + + +def regels(vers=False): + """[(id, soort, ip_network)] uit de database (gecachet).""" + if vers or time.time() - _cache['t'] >= CACHE_SECONDEN: + with _lock: + if vers or time.time() - _cache['t'] >= CACHE_SECONDEN: + _laad() + return _cache['regels'] + + +def enkel_toegestaan(vers=False): + """True als de modus "enkel Altijd toestaan" aan staat.""" + regels(vers) + return _cache.get('enkel_toegestaan', False) + + +def zet_enkel_toegestaan(aan, door=None): + from database import central_query + central_query("INSERT INTO PIVOT_instellingen (Sleutel, Waarde, Bijgewerkt, Door) VALUES (%s, %s, NOW(), %s) " + "ON DUPLICATE KEY UPDATE Waarde = VALUES(Waarde), Bijgewerkt = NOW(), Door = VALUES(Door)", + (MODUS_SLEUTEL, '1' if aan else '0', door), commit=True) + vergeet_cache() + + +def instelling(sleutel): + from database import central_query + rij = central_query("SELECT Waarde, Bijgewerkt, Door FROM PIVOT_instellingen WHERE Sleutel = %s", (sleutel,)) + return rij[0] if rij else None + + +def is_lokaal(ip): + """127.0.0.1 of ::1: dan ziet PIVOT waarschijnlijk een reverse proxy i.p.v. de echte bezoeker.""" + try: + a = ipaddress.ip_address(str(ip or '').strip()) + except ValueError: + return False + return a.is_loopback + + +def vergeet_cache(): + _cache['t'] = 0 + + +def beoordeel(ip, lijst=None, enkel_toe=None): + """Geeft (geweigerd, regel_id). Een 'toestaan'-regel wint altijd van een 'blokkeren'-regel. + In de modus "enkel Altijd toestaan" wordt alles geweigerd wat niet op die lijst staat (regel_id = 'modus').""" + try: + adres = ipaddress.ip_address(str(ip or '').strip()) + except ValueError: + return False, None + if getattr(adres, 'ipv4_mapped', None): # ::ffff:85.1.2.3 → 85.1.2.3 + adres = adres.ipv4_mapped + lijst = regels() if lijst is None else lijst + if any(s == 'toestaan' and adres in net for _, s, net in lijst if net.version == adres.version): + return False, None + if enkel_toegestaan() if enkel_toe is None else enkel_toe: + return True, 'modus' + for rid, s, net in lijst: + if s == 'blokkeren' and net.version == adres.version and adres in net: + return True, rid + return False, None + + +def controleer(ip): + """Voor elke aanvraag: True = weigeren. Telt de weigering bij de regel. Faalt nooit (fout → doorlaten).""" + try: + geweigerd, rid = beoordeel(ip) + except Exception as e: + sys.stderr.write(f"PIVOT: IP-blokkades lezen mislukt: {e}\n") + return False + if geweigerd: + try: + from database import central_query + if rid == 'modus': + central_query("INSERT INTO PIVOT_instellingen (Sleutel, Waarde, Bijgewerkt) VALUES (%s, '1', NOW()) " + "ON DUPLICATE KEY UPDATE Waarde = CAST(Waarde AS UNSIGNED) + 1, Bijgewerkt = NOW()", + (TELLER_SLEUTEL,), commit=True) + else: + central_query("UPDATE IP_blokkades SET Aantal_geweigerd = Aantal_geweigerd + 1, " + "Laatst_geweigerd = NOW() WHERE ID = %s", (rid,), commit=True) + except Exception: + pass + return geweigerd diff --git a/services/personeel_service.py b/services/personeel_service.py new file mode 100644 index 0000000..04d7060 --- /dev/null +++ b/services/personeel_service.py @@ -0,0 +1,371 @@ +""" +Personeel: afwezigheden van leerkrachten, nascholingen en uitstappen. + +Rollen (config.ini › [personeel]): + - leerkracht : iedereen in personeel_ous die met Google inlogt (PIVOT-groep 5). + Ziet en beheert enkel zijn eigen meldingen/aanvragen. + - directie : e-mailadressen in 'directie' → keurt nascholingen en uitstappen goed. + - secretariaat : e-mailadressen in 'secretariaat' → dagoverzicht, vervangingen, + melden namens een leerkracht. + PIVOT-gebruikers met het recht 'personeel_secretariaat' / 'personeel_directie' in deze + school krijgen die rol ook. Administrators hebben de secretariaatsrechten, en ook de + directierechten zolang er nog geen directie is ingesteld. + +Soorten: + - afwezigheid : geen goedkeuring nodig, meteen zichtbaar voor het secretariaat + - nascholing : goedkeuring door de directie + - uitstap : goedkeuring door de directie (klassen + begeleiders zijn weg) +""" +import json +import sys +import threading +import time +from datetime import date, timedelta + +from config import Config +from database import execute_query, get_db_connection +from tenancy import huidige_school, met_school + +LEERKRACHT_GROEP = 5 + +SOORTEN = { + 'afwezigheid': {'label': 'Afwezigheid', 'icoon': '🤒', 'goedkeuring': False}, + 'nascholing': {'label': 'Nascholing', 'icoon': '🎓', 'goedkeuring': True}, + 'uitstap': {'label': 'Uitstap', 'icoon': '🚌', 'goedkeuring': True}, +} +STATUS = { + 'gemeld': ('Gemeld', 'info'), + 'ingediend': ('Wacht op directie', 'warning'), + 'vraag': ('Vraag van directie', 'warning'), + 'goedgekeurd': ('Goedgekeurd', 'success'), + 'geweigerd': ('Geweigerd', 'danger'), + 'geannuleerd': ('Geannuleerd', ''), +} +DAGDELEN = {'dag': 'Hele dag', 'vm': 'Voormiddag', 'nm': 'Namiddag', 'uren': 'Bepaalde lesuren'} +# Statussen die in het dagoverzicht meetellen als "is weg" +ACTIEF = ('gemeld', 'goedgekeurd') + + +def _lijst(waarde): + return [x.strip().lower() for x in (waarde or '').replace(';', ',').split(',') if x.strip()] + + +# ---------------- rollen ---------------- +def directie(): + return _lijst(Config.PERSONEEL_DIRECTIE) + + +def secretariaat(): + return _lijst(Config.PERSONEEL_SECRETARIAAT) + + +def rollen(sess): + """Rollen in de personeelsmodule: via de e-maillijsten van de school of via de PIVOT-rechten + 'personeel_directie' / 'personeel_secretariaat' (Gebruikers & rechten).""" + from rechten import heeft_recht, is_admin + email = (sess.get('user') or '').lower() + admin = is_admin() + r = {'leerkracht'} + if email in directie() or (heeft_recht('personeel_directie') and (not admin or not directie())): + r.add('directie') + if email in secretariaat() or heeft_recht('personeel_secretariaat'): + r.add('secretariaat') + return r + + +_ou_cache = {} + + +def is_personeelslid(email): + """True als het Google-account in een van de personeels-OU's zit (cache 10 min).""" + email = (email or '').lower() + school = huidige_school() + if email.rsplit('@', 1)[-1] not in school.domeinen: + return False + sleutel = (school.code, email) + hit = _ou_cache.get(sleutel) + if hit and time.time() - hit[0] < 600: + return hit[1] + ok = False + try: + from services.google_service import GoogleAdminService + u = GoogleAdminService().get_user_info(email) or {} + ou = u.get('orgUnitPath', '') + ok = (not u.get('suspended')) and any(ou == p or ou.startswith(p.rstrip('/') + '/') + for p in [x.strip() for x in Config.PERSONEEL_OUS.split(',') if x.strip()]) + except Exception as e: + sys.stderr.write(f"Personeel: OU-controle mislukt voor {email}: {e}\n") + _ou_cache[sleutel] = (time.time(), ok) + return ok + + +# ---------------- klassen uit Smartschool ---------------- +def klassen(): + """Officiële klassen uit Smartschool (cache 6 uur, per school). Lege lijst als het niet lukt.""" + _klassen_cache = huidige_school().object('klassen_cache', lambda: {'t': 0, 'lijst': []}) + if _klassen_cache['lijst'] and time.time() - _klassen_cache['t'] < 6 * 3600: + return _klassen_cache['lijst'] + try: + from services.smartschool_api import SmartschoolService + ss = SmartschoolService(platform=Config.SS_API_PLATFORM, webservice_pwd=Config.SS_API_PW) + raw = ss.client.service.getClassListJson(Config.SS_API_PW) + data = json.loads(raw) if isinstance(raw, str) else raw + if isinstance(data, dict): + data = list(data.values()) + namen = [] + for k in data or []: + if not isinstance(k, dict): + continue + officieel = k.get('isKlas', k.get('isOfficial', True)) + if str(officieel).lower() in ('false', '0', 'none'): + continue + naam = (k.get('name') or k.get('code') or '').strip() + if naam: + namen.append(naam) + lijst = sorted(set(namen), key=lambda n: (len(n) > 3, n)) + if lijst: + _klassen_cache.update(t=time.time(), lijst=lijst) + return lijst + except Exception as e: + sys.stderr.write(f"Personeel: klassenlijst uit Smartschool mislukt: {e}\n") + return _klassen_cache['lijst'] + + +# ---------------- database ---------------- +def ensure_tables(): + execute_query(""" + CREATE TABLE IF NOT EXISTS personeel_aanvragen ( + ID INT AUTO_INCREMENT PRIMARY KEY, + Soort VARCHAR(20) NOT NULL, + Status VARCHAR(20) NOT NULL, + Email VARCHAR(255) NOT NULL, + Naam VARCHAR(255) NULL, + Ingediend_door VARCHAR(255) NULL, + Titel VARCHAR(255) NOT NULL, + Omschrijving TEXT NULL, + Locatie VARCHAR(255) NULL, + Van_datum DATE NOT NULL, + Tot_datum DATE NOT NULL, + Dagdeel VARCHAR(10) NOT NULL DEFAULT 'dag', + Lesuren VARCHAR(50) NULL, + Klassen TEXT NULL, + Begeleiders TEXT NULL, + Kost DECIMAL(10,2) NULL, + Vervanging_ok TINYINT(1) NOT NULL DEFAULT 0, + Vervanging_info VARCHAR(255) NULL, + Aangemaakt DATETIME NOT NULL, + Bijgewerkt DATETIME NOT NULL, + INDEX idx_status (Status), + INDEX idx_datum (Van_datum, Tot_datum), + INDEX idx_email (Email) + )""", commit=True) + execute_query(""" + CREATE TABLE IF NOT EXISTS personeel_reacties ( + ID INT AUTO_INCREMENT PRIMARY KEY, + Aanvraag_ID INT NOT NULL, + Tijdstip DATETIME NOT NULL, + Email VARCHAR(255) NULL, + Naam VARCHAR(255) NULL, + Actie VARCHAR(30) NOT NULL, + Tekst TEXT NULL, + INDEX idx_aanvraag (Aanvraag_ID) + )""", commit=True) + + +def _reactie(aanvraag_id, email, naam, actie, tekst=None): + execute_query("INSERT INTO personeel_reacties (Aanvraag_ID, Tijdstip, Email, Naam, Actie, Tekst) " + "VALUES (%s, NOW(), %s, %s, %s, %s)", (aanvraag_id, email, naam, actie, tekst), commit=True) + + +def aanmaken(data, door_email, door_naam): + soort = data['soort'] + status = 'ingediend' if SOORTEN[soort]['goedkeuring'] else 'gemeld' + conn = get_db_connection() + cur = conn.cursor() + try: + cur.execute(""" + INSERT INTO personeel_aanvragen (Soort, Status, Email, Naam, Ingediend_door, Titel, Omschrijving, Locatie, + Van_datum, Tot_datum, Dagdeel, Lesuren, Klassen, Begeleiders, Kost, Aangemaakt, Bijgewerkt) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,NOW(),NOW())""", + (soort, status, data['email'], data['naam'], door_email if door_email != data['email'] else None, + data['titel'], data.get('omschrijving'), data.get('locatie'), data['van'], data['tot'], + data['dagdeel'], data.get('lesuren'), ', '.join(data.get('klassen') or []), + data.get('begeleiders'), data.get('kost'))) + conn.commit() + aid = cur.lastrowid + finally: + cur.close() + conn.close() + tekst = None if door_email == data['email'] else f"Gemeld door {door_naam or door_email}" + _reactie(aid, door_email, door_naam, 'gemeld' if status == 'gemeld' else 'ingediend', tekst) + return aid + + +def get(aid): + rows = execute_query("SELECT * FROM personeel_aanvragen WHERE ID = %s", (aid,)) + return rows[0] if rows else None + + +def reacties(aid): + return execute_query( + "SELECT Tijdstip, Email, Naam, Actie, Tekst FROM personeel_reacties WHERE Aanvraag_ID = %s ORDER BY Tijdstip, ID", + (aid,)) + + +def wijzig_status(aid, nieuwe_status, door_email, door_naam, actie, tekst=None): + execute_query("UPDATE personeel_aanvragen SET Status = %s, Bijgewerkt = NOW() WHERE ID = %s", + (nieuwe_status, aid), commit=True) + _reactie(aid, door_email, door_naam, actie, tekst) + + +def commentaar(aid, door_email, door_naam, tekst): + execute_query("UPDATE personeel_aanvragen SET Bijgewerkt = NOW() WHERE ID = %s", (aid,), commit=True) + _reactie(aid, door_email, door_naam, 'commentaar', tekst) + + +def vervanging(aid, ok, info, door_email, door_naam): + execute_query("UPDATE personeel_aanvragen SET Vervanging_ok = %s, Vervanging_info = %s, Bijgewerkt = NOW() " + "WHERE ID = %s", (1 if ok else 0, (info or '')[:255] or None, aid), commit=True) + _reactie(aid, door_email, door_naam, 'vervanging', + ('Vervanging geregeld' if ok else 'Vervanging nog niet geregeld') + (f": {info}" if info else '')) + + +def mijn(email): + return execute_query("SELECT * FROM personeel_aanvragen WHERE Email = %s ORDER BY Van_datum DESC, ID DESC LIMIT 200", + (email,)) + + +def te_beoordelen(): + return execute_query("SELECT * FROM personeel_aanvragen WHERE Status IN ('ingediend','vraag') " + "ORDER BY Status = 'vraag', Van_datum, ID") + + +def recent_beslist(limit=20): + return execute_query("SELECT * FROM personeel_aanvragen WHERE Status IN ('goedgekeurd','geweigerd') " + "AND Soort <> 'afwezigheid' ORDER BY Bijgewerkt DESC LIMIT %s", (int(limit),)) + + +def periode(van, tot, statussen=ACTIEF): + ph = ','.join(['%s'] * len(statussen)) + return execute_query( + f"SELECT * FROM personeel_aanvragen WHERE Status IN ({ph}) AND Van_datum <= %s AND Tot_datum >= %s " + "ORDER BY Van_datum, Naam", tuple(statussen) + (tot, van)) + + +def aantal_te_beoordelen(): + try: + return execute_query("SELECT COUNT(*) AS n FROM personeel_aanvragen WHERE Status = 'ingediend'")[0]['n'] + except Exception: + return 0 + + +# ---------------- dagoverzicht ---------------- +def dagoverzicht(van, tot): + """Per dag: wie is weg en welke klassen zijn weg (enkel gemeld/goedgekeurd), plus wat nog wacht.""" + items = periode(van, tot) + wachtend = periode(van, tot, ('ingediend', 'vraag')) + dagen = [] + d = van + while d <= tot: + if d.weekday() < 5: + vandaag = [i for i in items if i['Van_datum'] <= d <= i['Tot_datum']] + personen, klassen_weg = [], [] + for i in vandaag: + personen.append({'naam': i['Naam'] or i['Email'], 'email': i['Email'], 'item': i, 'rol': None}) + if i['Soort'] == 'uitstap': + for b in [x.strip() for x in (i['Begeleiders'] or '').split(',') if x.strip()]: + personen.append({'naam': b, 'email': None, 'item': i, 'rol': 'begeleider'}) + for k in [x.strip() for x in (i['Klassen'] or '').split(',') if x.strip()]: + klassen_weg.append({'klas': k, 'item': i}) + dagen.append({ + 'datum': d, 'personen': personen, + 'klassen': sorted(klassen_weg, key=lambda k: k['klas']), + 'wachtend': [w for w in wachtend if w['Van_datum'] <= d <= w['Tot_datum']], + 'open_vervanging': sum(1 for i in vandaag if i['Klassen'] and i['Soort'] != 'uitstap' and not i['Vervanging_ok']), + }) + d += timedelta(days=1) + return dagen + + +# ---------------- e-mail ---------------- +def _mail_async(ontvangers, onderwerp, html): + ontvangers = sorted({o for o in ontvangers if o and '@' in o}) + if not Config.PERSONEEL_MAILS or not ontvangers: + return + + def verstuur(): + try: + from services.google_service import GoogleAdminService + svc = GoogleAdminService() + for o in ontvangers: + ok, msg = svc.send_custom_mail(o, onderwerp, html) + if not ok: + sys.stderr.write(f"Personeel: mail naar {o} mislukt: {msg}\n") + except Exception as e: + sys.stderr.write(f"Personeel: mail mislukt: {e}\n") + + threading.Thread(target=met_school(verstuur), daemon=True).start() + + +def _mailtekst(item, intro, link, extra=None): + soort = SOORTEN[item['Soort']] + periode_txt = item['Van_datum'].strftime('%d/%m/%Y') + if item['Tot_datum'] != item['Van_datum']: + periode_txt += ' – ' + item['Tot_datum'].strftime('%d/%m/%Y') + dagdeel = DAGDELEN.get(item['Dagdeel'], '') + if item['Dagdeel'] == 'uren' and item['Lesuren']: + dagdeel += f" ({item['Lesuren']})" + rijen = [('Wie', item['Naam'] or item['Email']), ('Wat', f"{soort['icoon']} {soort['label']}: {item['Titel']}"), + ('Wanneer', f"{periode_txt}, {dagdeel}")] + if item['Klassen']: + rijen.append(('Klassen', item['Klassen'])) + if item['Begeleiders']: + rijen.append(('Begeleiders', item['Begeleiders'])) + if item['Kost']: + rijen.append(('Kost', f"€ {item['Kost']}")) + tabel = ''.join(f"{k}{v}" + for k, v in rijen) + extra_html = f"

{extra}

" if extra else '' + return (f"
" + f"

{intro}

{tabel}
{extra_html}" + f"

" + f"Openen in PIVOT

") + + +def secretariaat_adressen(): + return _lijst(Config.PERSONEEL_SECRETARIAAT_MAIL) or secretariaat() + + +def mail_nieuw(item, link): + soort = SOORTEN[item['Soort']] + if soort['goedkeuring']: + _mail_async(directie(), f"Nieuwe aanvraag {soort['label'].lower()}: {item['Naam']} – {item['Titel']}", + _mailtekst(item, f"Er is een nieuwe aanvraag voor een {soort['label'].lower()} die je goedkeuring vraagt.", link)) + else: + _mail_async(secretariaat_adressen(), f"Afwezig: {item['Naam']} ({item['Van_datum']:%d/%m})", + _mailtekst(item, "Er is een afwezigheid gemeld.", link, item['Omschrijving'])) + + +def mail_beslissing(item, beslissing, tekst, link): + soort = SOORTEN[item['Soort']] + woorden = {'goedgekeurd': 'is goedgekeurd ✅', 'geweigerd': 'is geweigerd ❌', 'vraag': 'heeft een vraag van de directie ❓'} + _mail_async([item['Email']], f"Je aanvraag {soort['label'].lower()} {woorden[beslissing]}", + _mailtekst(item, f"Je aanvraag {item['Titel']} {woorden[beslissing]}.", link, + f"Commentaar: {tekst}" if tekst else None)) + if beslissing == 'goedgekeurd': + _mail_async(secretariaat_adressen(), f"Goedgekeurd: {soort['label'].lower()} {item['Naam']} ({item['Van_datum']:%d/%m})", + _mailtekst(item, f"Een {soort['label'].lower()} is goedgekeurd en staat nu in het dagoverzicht.", link)) + + +def mail_antwoord(item, tekst, link): + _mail_async(directie(), f"Antwoord van {item['Naam']}: {item['Titel']}", + _mailtekst(item, "De leerkracht heeft geantwoord op je vraag; de aanvraag wacht opnieuw op je beslissing.", + link, tekst)) + + +def mail_geannuleerd(item, link): + ontvangers = secretariaat_adressen() if item['Status'] in ('gemeld', 'goedgekeurd') else [] + if SOORTEN[item['Soort']]['goedkeuring']: + ontvangers = ontvangers + directie() + _mail_async(ontvangers, f"Geannuleerd: {SOORTEN[item['Soort']]['label'].lower()} {item['Naam']} ({item['Van_datum']:%d/%m})", + _mailtekst(item, "Deze melding/aanvraag is geannuleerd.", link)) diff --git a/services/sgr_groepen_service.py b/services/sgr_groepen_service.py new file mode 100644 index 0000000..a40b5c8 --- /dev/null +++ b/services/sgr_groepen_service.py @@ -0,0 +1,193 @@ +""" +Smartschool › SGR groepen: de subgroepen van één hoofdgroep (standaard 'groepen GO-Antwerpen') met hun huidige leden. + +Werking (Smartschool-webservices V3): +1. getAllGroupsAndClasses → de volledige boom van groepen en klassen (XML, meestal base64-gecodeerd) +2. de hoofdgroep zoeken op naam of code, en alle groepen eronder verzamelen (ook dieper geneste) +3. getAllAccountsExtended(code, '0') → de rechtstreekse leden van elke (sub)groep +Het resultaat wordt 10 minuten per school bewaard (knop 'Opnieuw ophalen' om te vernieuwen). +""" +import base64 +import json +import threading +import time +import xml.etree.ElementTree as ET +from concurrent.futures import ThreadPoolExecutor + +from config import Config + +STANDAARD_GROEP = 'groepen GO-Antwerpen' +CACHE_SECONDEN = 10 * 60 +ROLLEN = {'1': 'Leerling', '2': 'Leerkracht', '3': 'Directie', '4': 'Andere', '5': 'Andere', '6': 'Andere'} + + +class SgrFout(Exception): + pass + + +def _foutcode(raw): + return isinstance(raw, (int, float)) or (isinstance(raw, str) and raw.strip().lstrip('-').isdigit()) + + +def _tekst(raw): + """Antwoord van Smartschool → tekst (base64 wordt gedecodeerd als dat XML/JSON oplevert).""" + if isinstance(raw, bytes): + raw = raw.decode('utf-8', 'replace') + t = str(raw or '').strip() + if t and t[0] not in '<{[': + try: + d = base64.b64decode(t, validate=False).decode('utf-8', 'replace').strip() + if d[:1] in ('<', '{', '['): + return d + except Exception: + pass + return t + + +def _kind(el, *namen): + for n in namen: + for c in el: + if c.tag.lower() == n: + return c + return None + + +def _veld(el, *namen): + c = _kind(el, *namen) + return (c.text or '').strip() if c is not None and c.text else '' + + +def _groepen_xml(root): + """XML-boom → [{'naam','code','omschrijving','type','kinderen':[...]}].""" + def groep(el): + details = _kind(el, 'details') + d = details if details is not None else el + kids_el = _kind(el, 'children') + kinderen = [groep(c) for c in (kids_el if kids_el is not None else el) if c.tag.lower() == 'group'] + return {'naam': _veld(d, 'name', 'naam'), 'code': _veld(d, 'code'), 'omschrijving': _veld(d, 'desc', 'description'), + 'type': _veld(d, 'type'), 'kinderen': kinderen} + if root.tag.lower() == 'group': + return [groep(root)] + return [groep(c) for c in root if c.tag.lower() == 'group'] + + +def _groepen_json(data): + def groep(g): + kids = g.get('children') or g.get('groups') or [] + if isinstance(kids, dict): + kids = kids.get('group') or list(kids.values()) + if isinstance(kids, dict): + kids = [kids] + d = g.get('details') or g + return {'naam': str(d.get('name') or ''), 'code': str(d.get('code') or ''), 'omschrijving': str(d.get('desc') or ''), + 'type': str(d.get('type') or ''), 'kinderen': [groep(k) for k in kids if isinstance(k, dict)]} + if isinstance(data, dict): + data = data.get('groups') or data.get('group') or [data] + if isinstance(data, dict): + data = [data] + return [groep(g) for g in data if isinstance(g, dict)] + + +def boom(ss): + raw = ss.client.service.getAllGroupsAndClasses(Config.SS_API_PW) + if _foutcode(raw): + raise SgrFout(f"Smartschool weigert de groepenlijst (foutcode {raw}). Heeft het webservice-wachtwoord het recht " + "'getAllGroupsAndClasses'?") + t = _tekst(raw) + try: + return _groepen_xml(ET.fromstring(t)) if t.startswith('<') else _groepen_json(json.loads(t)) + except Exception as e: + raise SgrFout(f"Kon de groepenlijst van Smartschool niet lezen: {e}") + + +def zoek(groepen, naam): + """Eerste groep (diepte-eerst) waarvan naam of code overeenkomt (hoofdletterongevoelig).""" + n = (naam or '').strip().lower() + for g in groepen: + if g['naam'].lower() == n or g['code'].lower() == n: + return g + hit = zoek(g['kinderen'], naam) + if hit: + return hit + return None + + +def plat(groep, pad=()): + """Alle groepen onder `groep` (niet de groep zelf), met hun pad en diepte, in boomvolgorde.""" + uit = [] + for k in sorted(groep['kinderen'], key=lambda g: g['naam'].lower()): + uit.append({**k, 'pad': list(pad), 'diepte': len(pad)}) + uit += plat(k, pad + (k['naam'],)) + return uit + + +def _leden(ss, code, recursief='0'): + raw = ss.client.service.getAllAccountsExtended(Config.SS_API_PW, code, recursief) + if _foutcode(raw): + raise SgrFout(f"foutcode {raw}") + t = _tekst(raw) + if not t: + return [] + data = json.loads(t) + if isinstance(data, dict): + data = next((data[k] for k in ('accounts', 'users', 'items') if isinstance(data.get(k), list)), list(data.values())) + uit = [] + for a in data or []: + if not isinstance(a, dict): + continue + rol = str(a.get('basisrol') or a.get('basisRol') or '').strip() + uit.append({'voornaam': str(a.get('voornaam') or '').strip(), 'naam': str(a.get('naam') or '').strip(), + 'gebruikersnaam': str(a.get('gebruikersnaam') or '').strip(), + 'rol': ROLLEN.get(rol, rol), 'email': str(a.get('emailadres') or a.get('email') or '').strip(), + 'status': str(a.get('status') or '').strip()}) + return sorted(uit, key=lambda x: (x['naam'].lower(), x['voornaam'].lower())) + + +def overzicht(hoofdgroep=None, vernieuw=False): + """(hoofdgroep-dict, [subgroepen met 'leden' of 'fout'], opgehaald-tijdstip).""" + from tenancy import huidige_school, in_school + from services.smartschool_api import SmartschoolService + school = huidige_school() + if not school.heeft('SS_API_PLATFORM', 'SS_API_PW'): + raise SgrFout("Smartschool is nog niet ingesteld voor deze school (🔌 Koppelingen › Smartschool).") + hoofdgroep = (hoofdgroep or school.get('SS_SGR_GROEP') or STANDAARD_GROEP).strip() + sleutel = ('sgr_groepen', hoofdgroep.lower()) + bewaard = school.cache.get(sleutel) + if bewaard and not vernieuw and time.time() - bewaard[0] < CACHE_SECONDEN: + return bewaard[1], bewaard[2], bewaard[0] + + try: + ss = SmartschoolService(Config.SS_API_PLATFORM, Config.SS_API_PW) + except Exception as e: + raise SgrFout(f"Smartschool is niet bereikbaar: {e}") + hoofd = zoek(boom(ss), hoofdgroep) + if not hoofd: + raise SgrFout(f"De groep '{hoofdgroep}' bestaat niet in Smartschool ({Config.SS_API_PLATFORM}).") + groepen = plat(hoofd) + + lokaal = threading.local() + + def haal(g): + with in_school(school): + if not hasattr(lokaal, 'ss'): + lokaal.ss = SmartschoolService(Config.SS_API_PLATFORM, Config.SS_API_PW) + try: + if not g['code']: + return {**g, 'leden': [], 'fout': 'geen code'} + leden = _leden(lokaal.ss, g['code']) + if not leden and not g['kinderen']: + # Groep zonder subgroepen: rechtstreeks = recursief; sommige platformen geven enkel zo de leden + leden = _leden(lokaal.ss, g['code'], '1') + return {**g, 'leden': leden, 'fout': None} + except Exception as e: + return {**g, 'leden': [], 'fout': str(e)[:200]} + + with ThreadPoolExecutor(max_workers=4) as pool: + resultaat = list(pool.map(haal, groepen)) + for g in resultaat: + g.pop('kinderen', None) + g['aantal_sub'] = sum(1 for x in resultaat if x['pad'][:len(g['pad']) + 1] == g['pad'] + [g['naam']]) + info = {'naam': hoofd['naam'], 'code': hoofd['code'], 'omschrijving': hoofd['omschrijving']} + nu = time.time() + school.cache[sleutel] = (nu, info, resultaat) + return info, resultaat, nu diff --git a/services/signpost_byod_service.py b/services/signpost_byod_service.py new file mode 100644 index 0000000..27adc61 --- /dev/null +++ b/services/signpost_byod_service.py @@ -0,0 +1,140 @@ +import requests +from config import Config + +BASE_DATA_URL = "https://byod-api.signpost.be/v1" + + +def get_signpost_token(): + """Token met de Signpost/M4S-gegevens van de huidige school (None bij een fout).""" + return signpost_token_met_reden()[0] + + +def signpost_token_met_reden(): + """Geeft (token, None) of (None, leesbare foutmelding).""" + if not (Config.M4S_CLIENT_ID and Config.M4S_CLIENT_SECRET): + return None, ("Signpost is nog niet ingesteld voor deze school: vul de client-ID en het client-secret in " + "via 🔌 Koppelingen van deze school › Signpost / M4S.") + auth_payload = { + "grant_type": "client_credentials", + "client_id": Config.M4S_CLIENT_ID, + "client_secret": Config.M4S_CLIENT_SECRET, + "scope": "openid", + "resource": "management_portal:api" + } + try: + response = requests.post(Config.M4S_AUTH_URL, data=auth_payload, timeout=15) + except requests.exceptions.RequestException as e: + return None, f"Signpost ({Config.M4S_AUTH_URL}) is niet bereikbaar: {e}" + if response.status_code in (400, 401, 403): + detail = '' + try: + j = response.json() + detail = j.get('error_description') or j.get('error') or '' + except ValueError: + pass + return None, (f"Signpost weigert de aanmelding (HTTP {response.status_code}{': ' + detail if detail else ''}). " + "Controleer de client-ID en het client-secret bij 🔌 Koppelingen van deze school.") + if not response.ok: + return None, f"Signpost gaf foutcode {response.status_code} bij het aanmelden." + token = response.json().get("access_token") + return (token, None) if token else (None, "Signpost gaf geen token terug.") + + +#------------------------------------------------------------------------------------------------- +#herstelling signpost doorgeven +#------------------------------------------------------------------------------------------------- + +def create_signpost_case(serial_number, description, damage_flags=None): + """ + Maakt een nieuw herstellingsdossier aan bij Signpost. + :param serial_number: Het serienummer van het toestel. + :param description: Omschrijving van het probleem. + :param damage_flags: Dict met booleans (bv. {'waterDamage': True}) + """ + token = get_signpost_token() + if not token: + return False, "Authenticatie mislukt." + + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + "Accept": "application/json" + } + + # Bouw het payload op basis van de Config klasse + payload = { + "languageCode": getattr(Config, "M4S_LANGUAGE_CODE", "nl-BE"), + "description": description, + "status": getattr(Config, "M4S_REPAIR_STATUS", "Nieuw"), + "repairLocationType": int(getattr(Config, "M4S_REPAIR_LOCATION_TYPE", 1)), + "serialNumber": serial_number, + "institutionGuid": Config.M4S_INSTITUTIONGUID, + "address": { + "street": Config.M4S_REPAIR_STREET, + "number": Config.M4S_REPAIR_STREETNUMBER, + "zipCode": Config.M4S_REPAIR_ZIPCODE, + "city": Config.M4S_REPAIIR_CITY, + "countryCode": Config.M4S_REPAIR_COUNTRYCODE, + "repairAtInstitution": True + }, + "contacts": [{ + "firstName": Config.M4S_CONTACT_FIRST_NAME, + "lastName": Config.M4S_CONTACT_LAST_NAME, + "email": Config.M4S_CONTACT_EMAIL, + "phone": Config.M4S_CONTACT_PHONE + }], + "billingContact": { + "firstName": Config.M4S_BILLING_FIRST_NAME, + "lastName": Config.M4S_BILLING_LAST_NAME, + "email": Config.M4S_BILLING_EMAIL, + "phone": Config.M4S_BILLING_PHONE, + "street": Config.M4S_REPAIR_STREET, + "number": Config.M4S_REPAIR_STREETNUMBER, + "zipCode": Config.M4S_REPAIR_ZIPCODE, + "city": Config.M4S_REPAIIR_CITY, + "countryCode": Config.M4S_REPAIR_COUNTRYCODE + } + } + + # Voeg schade-indicatoren toe indien meegegeven + if damage_flags: + payload.update(damage_flags) + + try: + res = requests.post(f"{BASE_DATA_URL}/field-service/cases", headers=headers, json=payload, timeout=15) + if res.status_code in [200, 201]: + return True, res.json().get("ourReference", "Succesvol aangemaakt") + else: + return False, f"API Fout {res.status_code}: {res.text}" + except Exception as e: + return False, f"Verbindingsfout: {str(e)}" + + + +#------------------------------------------------------------------------------------------------- +# +#------------------------------------------------------------------------------------------------- + + +def get_cases_by_serial(serial_number): + token = get_signpost_token() + if not token: + return [] + + headers = { + "Authorization": f"Bearer {token}", + "Accept": "application/json" + } + params = {"$search": serial_number} + + try: + res = requests.get(f"{BASE_DATA_URL}/field-service/cases", headers=headers, params=params, timeout=15) + if res.status_code == 200: + return res.json().get("items", []) + return [] + except Exception as e: + print(f"Signpost API Fout: {e}") + return [] + + + \ No newline at end of file diff --git a/services/skore_service.py b/services/skore_service.py new file mode 100644 index 0000000..b3e7c9e --- /dev/null +++ b/services/skore_service.py @@ -0,0 +1,326 @@ +""" +Skore-synchronisatie in Smartschool (klassen en leerlingen van Smartschool → Skore). + +Smartschool-webservices: + startSkoreSync(accesscode) → start de synchronisatie, geeft een taak-ID (serviceId) terug + checkStatus(accesscode, serviceId) → status van die taak + +Per school: + - skore_planning : één rij met de planning (uit / dagelijks / weekdagen / wekelijks / maandelijks + uur) + - skore_log : elke gestarte synchronisatie (manueel of gepland) met taak-ID en laatste status + +Eén achtergrondtaak overloopt elke minuut alle scholen en start de synchronisatie wanneer de planning dat vraagt. +""" +import json +import sys +import threading +import time +from datetime import datetime, timedelta + +from config import Config +from database import execute_query, execute_insert +from tenancy import alle_scholen, in_school + +FREQUENTIES = [ + ('uit', 'Niet automatisch (enkel manueel)'), + ('dagelijks', 'Elke dag'), + ('weekdagen', 'Elke weekdag (ma–vr)'), + ('wekelijks', 'Eén keer per week'), + ('maandelijks', 'Eén keer per maand'), +] +WEEKDAGEN = ['maandag', 'dinsdag', 'woensdag', 'donderdag', 'vrijdag', 'zaterdag', 'zondag'] +# Een geplande synchronisatie die gemist werd (PIVOT stond uit) wordt nog ingehaald tot zoveel uur later. +INHAAL_UREN = 6 +# Statuscodes van checkStatus die we kennen (vastgesteld: 2 = "SYNC IS COMPLETED") +STATUSCODES = {2: 'voltooid'} +# Automatische opvolging na een start: elke 30 s de status opvragen, maximaal 45 minuten +OPVOLG_INTERVAL = 30 +OPVOLG_MAX = 45 * 60 +EINDSTATUS = ('voltooid', 'mislukt') + +TABELLEN = [ + """CREATE TABLE IF NOT EXISTS skore_planning ( + ID TINYINT NOT NULL PRIMARY KEY, + Frequentie VARCHAR(20) NOT NULL DEFAULT 'uit', + Uur TINYINT NOT NULL DEFAULT 6, + Minuut TINYINT NOT NULL DEFAULT 0, + Weekdag TINYINT NOT NULL DEFAULT 0, + Dag_van_maand TINYINT NOT NULL DEFAULT 1, + Laatst_gepland DATETIME NULL, + Bijgewerkt DATETIME NULL, + Bijgewerkt_door VARCHAR(255) NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci""", + """CREATE TABLE IF NOT EXISTS skore_log ( + ID INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + Gestart DATETIME NOT NULL, + Door VARCHAR(255) NULL, + Manier VARCHAR(20) NOT NULL, + Service_ID VARCHAR(100) NULL, + Status VARCHAR(30) NOT NULL, + Melding TEXT NULL, + Bijgewerkt DATETIME NULL, + INDEX idx_skore_gestart (Gestart) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci""", +] + + +class SkoreFout(RuntimeError): + pass + + +def ensure_tables(): + for sql in TABELLEN: + execute_query(sql, commit=True) + execute_query("INSERT IGNORE INTO skore_planning (ID) VALUES (1)", commit=True) + + +# ===================================================================== +# Smartschool +# ===================================================================== +def _client(): + if not (Config.SS_API_PLATFORM and Config.SS_API_PW): + raise SkoreFout("Smartschool is nog niet ingesteld voor deze school (platform en webservice-wachtwoord " + "via 🔌 Koppelingen van deze school).") + from services.smartschool_api import SmartschoolService + return SmartschoolService(Config.SS_API_PLATFORM, Config.SS_API_PW).client + + +def _json(raw): + """Smartschool geeft JSON als tekst terug; geeft een dict (of None).""" + if isinstance(raw, dict): + return raw + try: + data = json.loads(raw) if isinstance(raw, str) and raw.strip().startswith('{') else None + except ValueError: + data = None + return data if isinstance(data, dict) else None + + +def service_id_uit(raw): + """Taak-ID uit het antwoord van startSkoreSync, bv. {"serviceId":"db18…","message":"SERVICE STARTED"}. + Werkt ook voor oude logregels waarin het volledige antwoord als ID bewaard werd.""" + data = _json(raw) + if data is not None: + return str(data.get('serviceId') or data.get('serviceID') or data.get('id') or '').strip() or None + if raw in (None, ''): + return None + return str(raw).strip() or None + + +def _is_foutcode(raw): + return isinstance(raw, (int, float)) or (isinstance(raw, str) and raw.strip().lstrip('-').isdigit()) + + +def _foutcode_tekst(client, code): + """Smartschool geeft bij fouten een getal; probeer de omschrijving op te halen.""" + try: + lijst = json.loads(client.service.returnJsonErrorCodes()) + tekst = lijst.get(str(code).strip()) if isinstance(lijst, dict) else None + if tekst: + return f"Smartschool-fout {code}: {tekst}" + except Exception: + pass + return (f"Smartschool-fout {code}. Controleer of het webservice-wachtwoord het recht heeft om de " + "Skore-synchronisatie te starten (Smartschool › Beheer › Webservices).") + + +def _status_uit(raw): + """Zet het antwoord van checkStatus om naar (status, melding). + + Smartschool antwoordt met {"status": , "message": }. Wat de getallen betekenen is niet + publiek gedocumenteerd; daarom leiden we 'voltooid' of 'mislukt' enkel af uit de tekst, en tonen we anders + de statuscode zelf (bv. 'code 0').""" + tekst = raw if isinstance(raw, str) else json.dumps(raw, default=str) if raw is not None else '' + data = _json(raw) + code = data.get('status') if data else None + bericht = str((data or {}).get('message') or (data or {}).get('state') or '') if data else tekst + laag = bericht.lower() + if any(w in laag for w in ('error', 'fout', 'failed', 'mislukt')): + status = 'mislukt' + elif any(w in laag for w in ('done', 'finished', 'completed', 'klaar', 'voltooid', 'success')): + status = 'voltooid' + elif any(w in laag for w in ('running', 'busy', 'bezig', 'started', 'queue', 'wacht')): + status = 'bezig' + elif code is not None and str(code).strip().isdigit() and int(code) in STATUSCODES: + status = STATUSCODES[int(code)] + elif code is not None: + status = f"code {code}" + else: + status = 'onbekend' + melding = f"Smartschool: status {code}" + (f", {bericht}" if bericht else " (geen bericht)") if data else tekst + return status[:30], melding[:2000] + + +def start(door, manier='manueel'): + """Start de Skore-synchronisatie. Geeft de rij uit skore_log terug.""" + ensure_tables() + nu = datetime.now() + try: + client = _client() + raw = client.service.startSkoreSync(Config.SS_API_PW) + if _is_foutcode(raw) and str(raw).strip() not in ('', '0'): + raise SkoreFout(_foutcode_tekst(client, raw)) + service_id = service_id_uit(raw) + bericht = (_json(raw) or {}).get('message') + if not service_id: + raise SkoreFout(f"Smartschool startte de synchronisatie niet: {bericht or raw}") + status = 'gestart' + melding = f"Synchronisatie gestart bij Smartschool ({bericht})." if bericht else "Synchronisatie gestart bij Smartschool." + except SkoreFout as e: + service_id, status, melding = None, 'mislukt', str(e) + except Exception as e: + service_id, status, melding = None, 'mislukt', f"Smartschool niet bereikbaar: {e}" + log_id = execute_insert( + "INSERT INTO skore_log (Gestart, Door, Manier, Service_ID, Status, Melding, Bijgewerkt) " + "VALUES (%s, %s, %s, %s, %s, %s, %s)", (nu, door, manier, service_id, status, melding, nu)) + rij = execute_query("SELECT * FROM skore_log WHERE ID = %s", (log_id,))[0] + if rij['Service_ID']: + volg_op(log_id) + return rij + + +def volg_op(log_id): + """Vraagt op de achtergrond de status op tot de synchronisatie klaar of mislukt is.""" + from tenancy import met_school + + def loop(): + begin = time.time() + while time.time() - begin < OPVOLG_MAX: + time.sleep(OPVOLG_INTERVAL) + try: + rij = controleer(log_id) + except Exception as e: + sys.stderr.write(f"[{Config.SCHOOLNAME_SHORT}] Skore-opvolging: {e}\n") + continue + if not rij or rij['Status'] in EINDSTATUS: + return + threading.Thread(target=met_school(loop), daemon=True).start() + + +def bezig(): + """True als er een synchronisatie loopt die nog geen eindstatus heeft (laatste 45 minuten).""" + rows = execute_query("SELECT Status, Gestart FROM skore_log WHERE Service_ID IS NOT NULL " + "ORDER BY Gestart DESC, ID DESC LIMIT 1") + return bool(rows) and rows[0]['Status'] not in EINDSTATUS and \ + datetime.now() - rows[0]['Gestart'] < timedelta(seconds=OPVOLG_MAX) + + +def controleer(log_id): + """Vraagt de status van een gestarte synchronisatie op bij Smartschool en bewaart die.""" + rows = execute_query("SELECT * FROM skore_log WHERE ID = %s", (log_id,)) + if not rows or not rows[0]['Service_ID']: + return rows[0] if rows else None + try: + client = _client() + sid = service_id_uit(rows[0]['Service_ID']) + if sid != rows[0]['Service_ID']: + execute_query("UPDATE skore_log SET Service_ID = %s WHERE ID = %s", (sid, log_id), commit=True) + raw = client.service.checkStatus(Config.SS_API_PW, sid) + if _is_foutcode(raw) and str(raw).strip() not in ('', '0'): + status, melding = 'onbekend', _foutcode_tekst(client, raw) + else: + status, melding = _status_uit(raw) + except Exception as e: + status, melding = rows[0]['Status'], f"Status opvragen mislukt: {e}" + execute_query("UPDATE skore_log SET Status = %s, Melding = %s, Bijgewerkt = %s WHERE ID = %s", + (status, melding, datetime.now(), log_id), commit=True) + return execute_query("SELECT * FROM skore_log WHERE ID = %s", (log_id,))[0] + + +def logboek(n=25): + ensure_tables() + return execute_query("SELECT * FROM skore_log ORDER BY Gestart DESC, ID DESC LIMIT %s", (n,)) + + +# ===================================================================== +# Planning +# ===================================================================== +def planning(): + ensure_tables() + return execute_query("SELECT * FROM skore_planning WHERE ID = 1")[0] + + +def bewaar_planning(frequentie, uur, minuut, weekdag, dag_van_maand, door): + if frequentie not in dict(FREQUENTIES): + raise ValueError("Onbekende frequentie.") + uur, minuut, weekdag, dag = int(uur), int(minuut), int(weekdag), int(dag_van_maand) + if not (0 <= uur <= 23 and 0 <= minuut <= 59 and 0 <= weekdag <= 6 and 1 <= dag <= 28): + raise ValueError("Ongeldig tijdstip of ongeldige dag.") + ensure_tables() + # Laatst_gepland op 'nu' zetten: een planning die je net aanpast, start niet meteen voor een moment in het verleden + execute_query( + "UPDATE skore_planning SET Frequentie = %s, Uur = %s, Minuut = %s, Weekdag = %s, Dag_van_maand = %s, " + "Laatst_gepland = %s, Bijgewerkt = %s, Bijgewerkt_door = %s WHERE ID = 1", + (frequentie, uur, minuut, weekdag, dag, datetime.now(), datetime.now(), door), commit=True) + + +def laatste_moment(p, nu): + """Het laatste geplande moment op of vóór `nu` volgens planning `p` (of None als 'uit').""" + f = p['Frequentie'] + if f == 'uit': + return None + for terug in range(0, 62): + dag = (nu - timedelta(days=terug)).date() + moment = datetime(dag.year, dag.month, dag.day, int(p['Uur']), int(p['Minuut'])) + if moment > nu: + continue + if f == 'dagelijks' \ + or (f == 'weekdagen' and dag.weekday() < 5) \ + or (f == 'wekelijks' and dag.weekday() == int(p['Weekdag'])) \ + or (f == 'maandelijks' and dag.day == int(p['Dag_van_maand'])): + return moment + return None + + +def volgende_moment(p, nu=None): + """Het eerstvolgende geplande moment na `nu` (voor op de pagina).""" + nu = nu or datetime.now() + if p['Frequentie'] == 'uit': + return None + for vooruit in range(0, 62): + dag = (nu + timedelta(days=vooruit)).date() + moment = datetime(dag.year, dag.month, dag.day, int(p['Uur']), int(p['Minuut'])) + if moment > nu and laatste_moment(p, moment) == moment: + return moment + return None + + +def geplande_controle(nu=None): + """Start de synchronisatie als er een gepland moment voorbij is dat nog niet uitgevoerd werd.""" + nu = nu or datetime.now() + p = planning() + moment = laatste_moment(p, nu) + if not moment or nu - moment > timedelta(hours=INHAAL_UREN): + return None + if p['Laatst_gepland'] and p['Laatst_gepland'] >= moment: + return None + execute_query("UPDATE skore_planning SET Laatst_gepland = %s WHERE ID = 1", (nu,), commit=True) + rij = start('automatisch', manier='gepland') + print(f"[{Config.SCHOOLNAME_SHORT}] Skore-synchronisatie (gepland): {rij['Status']} – {rij['Melding']}", flush=True) + return rij + + +_scheduler = None + + +def start_scheduler(): + """Eén achtergrondtaak die elke minuut alle scholen met een Smartschool-koppeling overloopt.""" + global _scheduler + if _scheduler: + return + + def loop(): + time.sleep(45) # app eerst laten opstarten + while True: + for school in alle_scholen(): + if not school.heeft('SS_API_PLATFORM', 'SS_API_PW'): + continue + try: + with in_school(school): + geplande_controle() + except Exception as e: + sys.stderr.write(f"[{school.code}] Skore-planning: {e}\n") + time.sleep(60) + + _scheduler = threading.Thread(target=loop, daemon=True) + _scheduler.start() diff --git a/services/smartschool_api.py b/services/smartschool_api.py new file mode 100644 index 0000000..b7b4a8e --- /dev/null +++ b/services/smartschool_api.py @@ -0,0 +1,122 @@ +import zeep +import json + +class SmartschoolService: + def __init__(self, platform, webservice_pwd): + #----------------------------------- + clean_platform = platform.replace("https://", "").replace("http://", "").split('/')[0] + + self.platform = clean_platform + + #------------------------------ + #self.platform = platform + self.webservice_pwd = webservice_pwd + self.wsdl_url = f"https://{self.platform}/Webservices/V3?wsdl" + self.client = zeep.Client(wsdl=self.wsdl_url) + + def get_student_details(self, search_term): + """Zoekt een student op basis van internnummer of gebruikersnaam.""" + try: + # Poging 1: Via internnummer + raw_result = self.client.service.getUserDetails(self.webservice_pwd, search_term) + if raw_result and isinstance(raw_result, str) and raw_result.startswith('{'): + return json.loads(raw_result) + + # Poging 2: Via gebruikersnaam + raw_result_alt = self.client.service.getUserDetailsByUsername(self.webservice_pwd, search_term) + if raw_result_alt and isinstance(raw_result_alt, str) and raw_result_alt.startswith('{'): + return json.loads(raw_result_alt) + + return None + except Exception as e: + print(f"Service Error (GetDetails): {e}") + return None + + def get_co_accounts_from_details(self, username): + """Haalt co-accounts op en onthoudt hun index (1-6) voor wachtwoordresets.""" + try: + raw_result = self.client.service.getUserDetailsByUsername(self.webservice_pwd, username) + if raw_result and isinstance(raw_result, str) and raw_result.startswith('{'): + data = json.loads(raw_result) + co_accounts = [] + for i in range(1, 7): + voornaam = data.get(f"voornaam_coaccount{i}") + if voornaam: + co_accounts.append({ + "voornaam": voornaam, + "naam": data.get(f"naam_coaccount{i}", ""), + "type": data.get(f"type_coaccount{i}", "Co-account"), + "index": i, # Belangrijk voor savePassword + "parent_internnummer": data.get("internnummer") # Nodig voor de reset actie + }) + return co_accounts + return [] + except Exception as e: + print(f"Service Error (Co-Accounts): {e}") + return [] + + def reset_password(self, identifier, new_password, account_type=0): + """ + account_type: 0 voor leerling, 1-6 voor co-accounts. + """ + try: + # Gebruik de parameters uit je werkende voorbeeld + result_code = self.client.service.savePassword( + self.webservice_pwd, + identifier, + new_password, + account_type, + 0 + ) + return str(result_code) + except Exception as e: + print(f"Service Error (ResetPassword): {e}") + return "ERROR" + + def get_co_accounts_with_login(self, username): + """Haalt co-accounts op met de exacte velden uit de Smartschool JSON.""" + try: + # Gebruik de bestaande methode om de JSON op te halen + raw_result = self.client.service.getUserDetailsByUsername(self.webservice_pwd, username) + + if raw_result and isinstance(raw_result, str) and raw_result.startswith('{'): + data = json.loads(raw_result) + co_accounts = [] + + # Smartschool ondersteunt tot 6 co-accounts + for i in range(1, 7): + voornaam = data.get(f"voornaam_coaccount{i}") + # Alleen toevoegen als er daadwerkelijk een voornaam is ingevuld (account bestaat) + if voornaam and voornaam.strip() != "": + # Gebruik het specifieke veld uit jouw JSON voorbeeld + last_login = data.get(f"last_successful_login_coaccount{i}") + + co_accounts.append({ + "voornaam": voornaam, + "naam": data.get(f"naam_coaccount{i}", ""), + "type": data.get(f"type_coaccount{i}", "Onbekend"), + "last_login": last_login if last_login else "Nooit ingelogd", + "email": data.get(f"email_coaccount{i}", ""), + "index": i + }) + return co_accounts + return [] + except Exception as e: + print(f"Fout bij ophalen co-account status: {e}") + return [] + + def get_coaccount_report(self, search_query): + """ + Bundelt alle logica: zoekt student, formatteert naam en haalt co-accounts op. + Geeft een tuple terug: (student_naam, resultaten_lijst) + """ + student_data = self.get_student_details(search_query) + + if not student_data: + return None, [] + + student_name = f"{student_data.get('voornaam')} {student_data.get('naam')}" + username = student_data.get('gebruikersnaam') + results = self.get_co_accounts_with_login(username) + + return student_name, results \ No newline at end of file diff --git a/services/sync_service.py b/services/sync_service.py new file mode 100644 index 0000000..5f84dad --- /dev/null +++ b/services/sync_service.py @@ -0,0 +1,549 @@ +import json +import requests +from datetime import datetime, timezone # Essentieel voor de timestamp +from dateutil.relativedelta import relativedelta +from config import Config +from database import execute_query + +# Informat geeft de volledige leerlingenlijst soms pas na een minuut of langer terug. +# (verbinden, lezen) in seconden +INFORMAT_TIMEOUT = (15, 300) +ONEROSTER_TIMEOUT = (15, 120) + + +def informat_get(url, headers, pogingen=2): + """GET naar Informat met ruime wachttijd; bij een time-out nog één keer opnieuw.""" + for poging in range(1, pogingen + 1): + try: + return requests.get(url, headers=headers, timeout=INFORMAT_TIMEOUT) + except requests.exceptions.Timeout: + if poging == pogingen: + raise RuntimeError( + f"Informat antwoordde niet binnen {INFORMAT_TIMEOUT[1] // 60} minuten " + f"({pogingen} pogingen). Probeer het later opnieuw; Informat is soms traag of in onderhoud.") + print(f"[Informat] time-out, nieuwe poging ({poging + 1}/{pogingen})...") + + +def zorg_vertrokken_kolom(): + """Kolom Leerlingen.Vertrokken_op toevoegen in een bestaande schooldatabase (één keer per school).""" + try: + from tenancy import huidige_school + school = huidige_school() + except Exception: + school = None + if school is not None and school.cache.get('lln_vertrokken_kolom'): + return + kolommen = {r['Field'].lower() for r in execute_query("SHOW COLUMNS FROM Leerlingen") or []} + if 'vertrokken_op' not in kolommen: + execute_query("ALTER TABLE Leerlingen ADD COLUMN Vertrokken_op DATE NULL AFTER Schooljaar_ID", commit=True) + if school is not None: + school.cache['lln_vertrokken_kolom'] = True + + +def veld_waarden(obj, pad): + """Alle waarden op een pad met punten, ook door lijsten heen: veld_waarden(st, 'inschrijvingen.vestiging').""" + huidig = [obj] + for deel in pad.split('.'): + volgende = [] + for o in huidig: + items = o if isinstance(o, list) else [o] + for it in items: + if isinstance(it, dict) and deel in it: + volgende.append(it[deel]) + huidig = volgende + uit = [] + for h in huidig: + uit.extend(h if isinstance(h, list) else [h]) + return uit + + +class SyncService: + def __init__(self): + self.conf = Config() + # Informat-gegevens van de huidige school (beheerd via /scholen). + # Een school kan meerdere Informat-instellingsnummers hebben (één per campus). + self.campussen = Config.INFORMAT_CAMPUSSEN or [] + self.institute_no = Config.INFORMAT_INST_NUMBER + self.auth_url = Config.INFORMAT_AUTH_URL + self.api_base = Config.INFORMAT_API_BASE + # De scope MOET het instellingsnummer bevatten + self.scope = Config.INFORMAT_FULL_SCOPE + + def get_informat_token(self, nummer=None): + """Haalt een token op met de scope van het (eerste of opgegeven) instellingsnummer.""" + from tenancy import informat_scope + scope = informat_scope(Config.INFORMAT_SCOPE, nummer) if nummer else self.scope + payload = { + 'grant_type': 'client_credentials', + 'client_id': self.conf.INFORMAT_ID, + 'client_secret': self.conf.INFORMAT_SECRET, + 'scope': scope + } + try: + response = requests.post(self.auth_url, data=payload, timeout=10) + if response.status_code == 200: + return response.json().get('access_token') + except Exception: + return None + return None + + def tel_leerlingen(self, refdate, schooljaar, nummer=None): + """Aantal leerlingen in Informat op `refdate` (YYYY-MM-DD) voor `schooljaar` (bv. 2026-27), voor één + instellingsnummer (standaard het eerste). Geeft (True, aantal) of (False, foutmelding).""" + nummer = nummer or self.institute_no + token = self.get_informat_token(nummer) + if not token: + return False, f"Authenticatie bij Informat mislukt (instellingsnummer {nummer})." + url = f"{self.api_base}/students/?schoolYear={schooljaar}&refdate={refdate}" + headers = { + 'Authorization': f'BEARER {token}', + 'InstituteNo': nummer, + 'Timestamp': datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%S.000Z'), # verplicht bij Informat + 'Api-Version': '1', + 'Accept': 'application/json' + } + try: + response = informat_get(url, headers) + except Exception as e: + return False, f"Fout tijdens tellen: {e}" + if response.status_code != 200: + return False, f"API fout {response.status_code} (instellingsnummer {nummer}): {response.text[:300]}" + data = response.json() + lijst = data if isinstance(data, list) else data.get('students', []) + self._laatste_lijst = lijst # voor tellen per campusveld en de veldentest + return True, len(lijst) + + def informat_lijst(self, refdate, schooljaar, nummer=None): + """De volledige leerlingenlijst van Informat (zelfde oproep als het tellen). (True, lijst) of (False, fout).""" + ok, res = self.tel_leerlingen(refdate, schooljaar, nummer) + return (True, self._laatste_lijst) if ok else (False, res) + + def tel_locatie(self, refdate, schooljaar, locatie_id): + """Aantal leerlingen op één schoollocatie (Discimus-nummer) via de Informat Student API + (GET /smartschool-users). Geeft (True, aantal) of (False, foutmelding). + Let op: deze API telt enkel leerlingen met een actieve inschrijving (gerealiseerd of parallel, niet GON) + op een locatie die aan het Smartschool-platform gekoppeld is.""" + platform = (Config.SS_API_PLATFORM or '').strip() + if not platform: + return False, ("Voor het tellen per campus moet het Smartschool-platform ingevuld zijn " + "(🔌 Koppelingen › Smartschool).") + token = self.get_informat_token(self.institute_no) + if not token: + return False, f"Authenticatie bij Informat mislukt (instellingsnummer {self.institute_no})." + url = f"{str(Config.INFORMAT_STUDENT_API).rstrip('/')}/smartschool-users" + headers = {'Authorization': f'Bearer {token}', 'instituteNo': self.institute_no, 'Accept': 'application/json'} + # Informat vergelijkt het platformadres letterlijk met wat er in Informat staat. Dat kan met of zonder + # https:// of een / op het einde opgeslagen zijn: probeer de varianten tot er één gekend is (en onthoud die). + varianten = list(dict.fromkeys([v for v in (getattr(self, '_ss_platform_url', None), f"https://{platform}", + platform, f"https://{platform}/", f"http://{platform}") if v])) + response, geprobeerd = None, [] + for platform_url in varianten: + params = {'smartschoolPlatformUrl': platform_url, 'schoolYear': schooljaar, + 'locationIdDiscimus': int(locatie_id) if str(locatie_id).isdigit() else locatie_id, + 'referenceDate': refdate} + try: + for poging in (1, 2): + try: + response = requests.get(url, params=params, headers=headers, timeout=INFORMAT_TIMEOUT) + break + except requests.exceptions.Timeout: + if poging == 2: + raise RuntimeError("Informat antwoordde niet op tijd.") + except Exception as e: + return False, f"Fout tijdens tellen: {e}" + geprobeerd.append(platform_url) + if response.status_code == 409 and 'smartschoolplatform' in response.text.lower(): + continue # platform onder dit adres niet gekend: volgende variant + self._ss_platform_url = platform_url + break + if response.status_code != 200: + detail = '' + try: + detail = response.json().get('detail') or '' + except ValueError: + detail = response.text[:200] + if response.status_code == 409 and 'smartschoolplatform' in response.text.lower(): + return False, (f"Informat kent het Smartschool-platform niet voor instellingsnummer {self.institute_no} " + f"en schooljaar {schooljaar} (geprobeerd: {', '.join(geprobeerd)}). Is in Informat " + "voor dit instellingsnummer en dit schooljaar een Smartschool-koppeling actief?") + return False, f"Student API fout {response.status_code} (campus {locatie_id}): {detail}" + data = response.json() + lijst = data if isinstance(data, list) else data.get('items', []) + # Eén leerling kan meer dan één inschrijving hebben: uniek op persoon + return True, len({u['ppersoon'] if u.get('ppersoon') is not None else (u.get('persoonId') or id(u)) + for u in lijst}) + + def tel_per_campus(self, refdate, schooljaar): + """Geeft (True, totaal, detail) of (False, fout, None). detail = [{'naam', 'nummer', 'aantal'}] als de + school meerdere campussen heeft, anders None. + - Campussen (Discimus-nummers) ingesteld → per locatie geteld via de Student API. + - Anders: per instellingsnummer (meestal één) via de leerlingen-API zoals voorheen.""" + locaties = Config.INFORMAT_LOCATIES or [] + veld = (Config.INFORMAT_CAMPUS_VELD or '').strip() + if veld: + return self._tel_op_veld(refdate, schooljaar, veld, dict(locaties)) + if locaties: + telling = [(loc, naam, lambda loc=loc: self.tel_locatie(refdate, schooljaar, loc)) for loc, naam in locaties] + else: + campussen = self.campussen or [(self.institute_no, '')] + telling = [(nr, naam, lambda nr=nr: self.tel_leerlingen(refdate, schooljaar, nr)) for nr, naam in campussen] + detail, totaal = [], 0 + for nummer, naam, tel in telling: + ok, res = tel() + if not ok: + return False, (f"{naam}: {res}" if len(telling) > 1 else res), None + detail.append({'naam': naam or nummer, 'nummer': nummer, 'aantal': res}) + totaal += res + return True, totaal, (detail if len(detail) > 1 else None) + + def _tel_op_veld(self, refdate, schooljaar, veld, namen): + """Haalt de leerlingenlijst één keer op en telt per waarde van `veld` (pad met punten, bv. + 'inschrijvingen.vestiging'). `namen` vertaalt een waarde naar een campusnaam (uit 'Campussen').""" + lijst, gezien = [], set() + for nummer, naam in (self.campussen or [(self.institute_no, '')]): # alle instellingsnummers van de school + ok, deel = self.informat_lijst(refdate, schooljaar, nummer) + if not ok: + return False, (f"{naam}: {deel}" if len(self.campussen) > 1 else deel), None + for st in deel: + sleutel = str(st.get('pPersoon') or st.get('id') or id(st)) + if sleutel not in gezien: + gezien.add(sleutel) + lijst.append(st) + per = {} + for st in lijst: + for w in set(str(v) for v in veld_waarden(st, veld) if v not in (None, '')) or {'?'}: + per[w] = per.get(w, 0) + 1 + volgorde = [w for w in namen if w in per] + sorted(w for w in per if w not in namen) + detail = [{'naam': namen.get(w) or (f"Onbekend ({veld} leeg)" if w == '?' else f"Campus {w}"), + 'nummer': '' if w == '?' else w, 'aantal': per[w]} for w in volgorde] + return True, len(lijst), (detail if len(detail) > 1 else None) + + def sync_student_count(self, school_naam=None): + """Huidig aantal (peildatum vandaag + 1 maand, zodat net ingeschreven leerlingen meetellen).""" + refdate = (datetime.now() + relativedelta(months=1)).strftime('%Y-%m-%d') + ok, res, detail = self.tel_per_campus(refdate, Config.SCHOOLYEAR) + if not ok: + return False, res + # Bewaren bij de school in de centrale database (overzicht scholengroep) + from database import central_query + central_query("UPDATE Scholen SET Leerlingenaantal = %s, Leerlingenaantal_detail = %s, " + "Leerlingenaantal_sync = NOW() WHERE Code = %s", + (res, json.dumps(detail) if detail else None, Config.SCHOOLNAME_SHORT), commit=True) + if detail: + return True, f"Synchronisatie voltooid: {res} leerlingen (" + ", ".join( + f"{d['naam']}: {d['aantal']}" for d in detail) + ")." + return True, f"Synchronisatie voltooid: {res} leerlingen." + + def tel_op_peildatum(self, peildatum, door=None): + """Telt de leerlingen op een gekozen datum en bewaart het resultaat in Leerlingenaantal_tellingen.""" + from database import central_query + from tenancy import huidige_school, _automatisch_schooljaar + schooljaar = _automatisch_schooljaar(peildatum) + ok, res, detail = self.tel_per_campus(peildatum.strftime('%Y-%m-%d'), schooljaar) + if not ok: + return False, res + central_query( + "INSERT INTO Leerlingenaantal_tellingen (School_ID, Peildatum, Schooljaar, Aantal, Detail, Opgevraagd_op, Door) " + "VALUES (%s, %s, %s, %s, %s, NOW(), %s) " + "ON DUPLICATE KEY UPDATE Schooljaar = VALUES(Schooljaar), Aantal = VALUES(Aantal), Detail = VALUES(Detail), " + "Opgevraagd_op = NOW(), Door = VALUES(Door)", + (huidige_school().id, peildatum, schooljaar, res, json.dumps(detail) if detail else None, door), + commit=True) + return True, res + + #Haal ss token op + def get_smartschool_token(self): + """Haalt een OAuth2 token op bij Smartschool.""" + resp_ss = requests.post( + self.conf.SS_TOKEN_URL, + data={"grant_type": "client_credentials"}, + auth=(self.conf.SS_CLIENT_ID, self.conf.SS_CLIENT_SECRET) + ) + return resp_ss.json().get("access_token") + + #Vergelijkt leerlingenlijst uit informat met namen uit DB en vult eventuele ontbrekende leerlingen aan met gegevens van Informat (Internnummer) en Smartschool (Gebruikersnaam) + def sync_leerlingen_informat(self): + """ + Synchroniseert leerlingen door Informat data te vergelijken met de DB + en ontbrekende data aan te vullen via Smartschool. + """ + try: + # 1. Haal Informat data op + inf_token = self.get_informat_token() + + #Ik zet als parameter voor het ophalen huidige datum +1 maand om leerlingen die net ingeschreven zijn en pas na een vakantie starten mee te kunnen rekenen + future_date = datetime.now() + relativedelta(months=1) + refdate = future_date.strftime('%Y-%m-%d') + + + inf_url = f"{self.api_base}/students/?schoolYear={self.conf.INFORMAT_SCHOOLYEAR}&refdate={refdate}" + inf_headers = { + 'Authorization': f'BEARER {inf_token}', + 'InstituteNo': self.institute_no, + 'Api-Version': '1', + 'Accept': 'application/json' + } + inf_data = requests.get(inf_url, headers=inf_headers).json() + + if not inf_data or 'students' not in inf_data: + return False, "Geen data van Informat ontvangen" + + # 2. Haal Bestaande Database data op + # We gebruiken execute_query om alle bestaande Internnummers op te halen + db_rows = execute_query("SELECT Internnummer FROM Leerlingen") + db_numbers = {str(row['Internnummer']) for row in db_rows} if db_rows else set() + + # 3. Zoek ontbrekende ID's (Informat - Database) + json_numbers = {str(s['pPersoon']) for s in inf_data['students'] if 'pPersoon' in s} + missing_ids = json_numbers - db_numbers + + if not missing_ids: + return True, "Iedereen is al gesynchroniseerd." + + # 4. Haal Smartschool data op voor de match + ss_token = self.get_smartschool_token() + ss_res = requests.get( + f"{self.conf.SS_BASE_URL}/students", + headers={"Authorization": f"Bearer {ss_token}"} + ) + ss_students = ss_res.json().get("users", []) + + added_count = 0 + for m_id in missing_ids: + found_username = None + for ss_student in ss_students: + ss_internal = str(ss_student.get("metadata", {}).get("smsc.internalNumber", "")) + + if ss_internal.strip() == m_id.strip(): + found_username = ss_student.get("username") + break + + if found_username: + # 5. DATABASE INVOER + execute_query( + "INSERT INTO Leerlingen (Internnummer, Gebruikersnaam, ss_nummer) VALUES (%s, %s, %s)", + (m_id, found_username, "Niet ingesteld"), + commit=True + ) + added_count += 1 + + return True, f"Succes! {added_count} leerlingen toegevoegd." + + except Exception as e: + return False, f"Fout tijdens synchronisatie: {str(e)}" + + + + + +#---------------------Nieuwe aanpak------------------------ +import requests +from datetime import datetime, timezone +from config import Config +from database import execute_query + +class InformatSmartschoolSync: + + + def __init__(self): + self.conf = Config() + # Informat-gegevens van de huidige school (beheerd via /scholen) + self.institute_no = Config.INFORMAT_INST_NUMBER + self.auth_url = Config.INFORMAT_AUTH_URL + self.api_base = Config.INFORMAT_API_BASE + # De scope MOET het instellingsnummer bevatten + self.scope = Config.INFORMAT_FULL_SCOPE + self.campussen = Config.INFORMAT_CAMPUSSEN or [(self.institute_no, '')] + + def get_informat_token(self, nummer=None): + """Haalt token op met de scope van het (eerste of opgegeven) instellingsnummer.""" + from tenancy import informat_scope + payload = { + 'grant_type': 'client_credentials', + 'client_id': self.conf.INFORMAT_ID, + 'client_secret': self.conf.INFORMAT_SECRET, + 'scope': informat_scope(Config.INFORMAT_SCOPE, nummer) if nummer else self.scope + } + try: + response = requests.post(self.auth_url, data=payload, timeout=10) + if response.status_code == 200: + return response.json().get('access_token') + except Exception as e: + print(f"Fout bij ophalen Informat token: {e}") + return None + + def get_smartschool_oneroster_students(self): + """Haalt actieve leerlingen op uit de Smartschool OneRoster API (v1p1). Bij een fout: {}.""" + mapping, fout = self.smartschool_koppeling() + if fout: + print(f"Fout bij ophalen Smartschool OneRoster data: {fout}") + return mapping + + def smartschool_koppeling(self): + """{internnummer: {'username', 'ss_nummer'}} uit Smartschool OneRoster. Geeft (mapping, None) of ({}, fout).""" + if not (self.conf.SS_CLIENT_ID and self.conf.SS_CLIENT_SECRET): + return {}, "de OneRoster-koppeling van Smartschool is niet ingesteld" + try: + token_url = self.conf.SS_TOKEN_URL.strip() + if not token_url.startswith(("http://", "https://")): + token_url = f"https://{token_url}" + + resp_ss = requests.post( + token_url, + data={"grant_type": "client_credentials"}, + auth=(self.conf.SS_CLIENT_ID, self.conf.SS_CLIENT_SECRET), + timeout=10 + ) + resp_ss.raise_for_status() + ss_token = resp_ss.json().get("access_token") + + if not ss_token: + return {}, "geen access_token ontvangen van Smartschool" + + headers = { + "Authorization": f"Bearer {ss_token}", + "Accept": "application/json" + } + + base_url = self.conf.SS_BASE_URL.strip() + if not base_url.startswith(("http://", "https://")): + base_url = f"https://{base_url}" + + base_url = base_url.rstrip('/') + endpoint_url = f"{base_url}/students" + + response = requests.get(endpoint_url, headers=headers, timeout=ONEROSTER_TIMEOUT) + response.raise_for_status() + + json_data = response.json() + all_students = json_data.get("users", []) + + ss_mapping = {} + for student in all_students: + metadata = student.get("metadata", {}) + internal_number = metadata.get("smsc.internalNumber") + username = student.get("username") + sourced_id = student.get("sourcedId") + + if internal_number: + ss_mapping[str(internal_number).strip()] = { + "username": username, + "ss_nummer": sourced_id if sourced_id else "Niet ingesteld" + } + if not ss_mapping: + return {}, "Smartschool gaf geen enkele leerling met een internnummer terug" + return ss_mapping, None + + except Exception as e: + return {}, str(e) + + def _informat_leerlingen(self, nummer, school_year, refdate): + """Leerlingenlijst van één instellingsnummer. Geeft (lijst, None) of (None, fout).""" + inf_token = self.get_informat_token(nummer) + if not inf_token: + return None, f"Informat authenticatie mislukt (instellingsnummer {nummer})." + base_url_inf = self.api_base.strip().rstrip('/') + url = f"{base_url_inf}/students/?schoolYear={school_year}&refdate={refdate}" + headers = { + 'Authorization': f'BEARER {inf_token}', + 'InstituteNo': nummer, + 'Timestamp': datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%S.000Z'), + 'Api-Version': '1', + 'Accept': 'application/json' + } + response = informat_get(url, headers) + if response.status_code != 200: + return None, f"Informat API fout {response.status_code} (instellingsnummer {nummer}): {response.text}" + json_data = response.json() + return (json_data if isinstance(json_data, list) else json_data.get('students', json_data.get('items', []))), None + + def sync_actieve_leerlingen(self): + """ + Haalt actieve leerlingen op bij Informat (alle campussen / instellingsnummers van de school), + koppelt Smartschool OneRoster info en update de database 'Leerlingen'. + """ + school_year = Config.SCHOOLYEAR + # +1 maand: ook leerlingen die net ingeschreven zijn en pas na een vakantie starten + refdate = (datetime.now() + relativedelta(months=1)).strftime('%Y-%m-%d') + + students_list, gezien = [], set() + try: + for nummer, naam in self.campussen: + lijst, fout = self._informat_leerlingen(nummer, school_year, refdate) + if fout: + return False, (f"{naam}: {fout}" if len(self.campussen) > 1 else fout) + for st in lijst: + sleutel = str(st.get('pPersoon') or st.get('internNummer') or st.get('id', '')).strip() + if sleutel and sleutel not in gezien: + gezien.add(sleutel) + students_list.append(st) + except Exception as e: + return False, f"Fout opgetreden tijdens de synchronisatie: {str(e)}" + + if not students_list: + # Nooit iedereen als vertrokken markeren omdat Informat (tijdelijk) een lege lijst gaf + return False, "Informat gaf geen enkele actieve leerling terug; er is niets aangepast." + + # Smartschool (gebruikersnaam en Smartschool-nummer). Lukt dat niet, dan blijven die velden zoals ze waren. + ss_leerlingen, ss_fout = self.smartschool_koppeling() + + try: + print(f"[DEBUG] Aantal actieve leerlingen gevonden in Informat: {len(students_list)}") + zorg_vertrokken_kolom() + + db_rows = execute_query("SELECT Internnummer FROM Leerlingen") + bestaande_internnummers = {str(row['Internnummer']).strip() for row in db_rows} if db_rows else set() + + toegevoegd_teller = bijgewerkt_teller = 0 + schooljaar_id = Config.SCHOOLYEAR[:4] + actief = set() + + for student in students_list: + internnummer = str(student.get('pPersoon') or student.get('internNummer') or student.get('id', '')).strip() + voornaam = (student.get('voornaam' if 'voornaam' in student else 'firstName') or '').strip() + naam = (student.get('naam' if 'naam' in student else 'lastName') or '').strip() + if not internnummer or internnummer == "None": + continue + actief.add(internnummer) + ss_info = ss_leerlingen.get(internnummer) + standaard_gebruikersnaam = f"{voornaam}.{naam}".replace(" ", "").lower() + + if internnummer in bestaande_internnummers: + if ss_info: + execute_query(""" + UPDATE Leerlingen SET Gebruikersnaam = %s, Voornaam = %s, Naam = %s, SS_nummer = %s, + Schooljaar_ID = %s, Vertrokken_op = NULL + WHERE Internnummer = %s""", + (ss_info.get("username") or standaard_gebruikersnaam, voornaam, naam, + ss_info.get("ss_nummer") or "Niet ingesteld", schooljaar_id, internnummer), commit=True) + else: + # Niet (of niet bereikbaar) in Smartschool: gebruikersnaam en Smartschool-nummer niet overschrijven + execute_query(""" + UPDATE Leerlingen SET Voornaam = %s, Naam = %s, Schooljaar_ID = %s, Vertrokken_op = NULL + WHERE Internnummer = %s""", (voornaam, naam, schooljaar_id, internnummer), commit=True) + bijgewerkt_teller += 1 + else: + ss_info = ss_info or {} + execute_query(""" + INSERT INTO Leerlingen (Internnummer, Gebruikersnaam, Voornaam, Naam, SS_nummer, Schooljaar_ID) + VALUES (%s, %s, %s, %s, %s, %s)""", + (internnummer, ss_info.get("username") or standaard_gebruikersnaam, voornaam, naam, + ss_info.get("ss_nummer") or "Niet ingesteld", schooljaar_id), commit=True) + bestaande_internnummers.add(internnummer) + toegevoegd_teller += 1 + + # Leerlingen die niet meer actief zijn in Informat: als vertrokken markeren (niet verwijderen) + nog_actief = execute_query("SELECT Internnummer FROM Leerlingen WHERE Vertrokken_op IS NULL") or [] + weg = [r['Internnummer'] for r in nog_actief if str(r['Internnummer']).strip() not in actief] + for k in range(0, len(weg), 500): + deel = weg[k:k + 500] + execute_query(f"UPDATE Leerlingen SET Vertrokken_op = CURDATE() WHERE Internnummer IN " + f"({', '.join(['%s'] * len(deel))})", tuple(deel), commit=True) + + melding = f"{toegevoegd_teller} nieuw, {bijgewerkt_teller} bijgewerkt" + if weg: + melding += f", {len(weg)} als vertrokken gemarkeerd" + return True, {'melding': melding + '.', 'smartschool_fout': ss_fout} + + except Exception as e: + return False, f"Fout opgetreden tijdens de synchronisatie: {str(e)}" diff --git a/services/tabelnamen.py b/services/tabelnamen.py new file mode 100644 index 0000000..52cfef4 --- /dev/null +++ b/services/tabelnamen.py @@ -0,0 +1,148 @@ +""" +Tabelnamen met hoofdletters herstellen (Windows → Linux). + +MySQL op Windows bewaart tabelnamen standaard in kleine letters (lower_case_table_names = 1) en maakt hoofdletters +niet uit. Op Linux (lower_case_table_names = 0) zijn 'toestellen' en 'Toestellen' twee verschillende tabellen. +Een database die van Windows komt, heeft dan enkel kleine letters, terwijl PIVOT 'Toestellen' zoekt (en een lege +nieuwe aanmaakt). + +Bij het opstarten loopt PIVOT daarom de centrale database en de database van elke school na. Enkel op een server +waar hoofdletters tellen (lower_case_table_names = 0), en enkel voor de tabellen die PIVOT kent: + - alleen 'toestellen' bestaat → hernoemd naar 'Toestellen' + - beide bestaan, 'Toestellen' is leeg → de lege 'Toestellen' weg, 'toestellen' hernoemd naar 'Toestellen' + - beide bestaan, 'toestellen' is leeg → niets nodig ('Toestellen' wordt gebruikt), enkel gemeld + - beide bevatten gegevens → niets aangeraakt, gemeld in het logboek (handmatig samenvoegen) +Op Windows (of met lower_case_table_names = 1 of 2) doet dit niets. +""" +import glob +import os +import re +import sys + +_RE = re.compile(r"CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS\s+`?([A-Za-z_][A-Za-z0-9_]*)`?", re.I) +_namen = None + + +def verwachte_namen(): + """Alle tabelnamen met minstens één hoofdletter, zoals PIVOT ze aanmaakt (uit de SQL-bestanden en de code).""" + global _namen + if _namen is None: + basis = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + bestanden = glob.glob(os.path.join(basis, 'sql', '*.sql')) + glob.glob(os.path.join(basis, '*.py')) \ + + glob.glob(os.path.join(basis, 'services', '*.py')) + glob.glob(os.path.join(basis, 'blueprints', '*.py')) + namen = set() + for pad in bestanden: + try: + with open(pad, encoding='utf-8', errors='ignore') as f: + namen.update(n for n in _RE.findall(f.read()) if n != n.lower()) + except OSError: + continue + _namen = sorted(namen) + return _namen + + +def _q(cur, sql, params=()): + cur.execute(sql, params) + return cur.fetchall() if cur.with_rows else [] + + +def herstel(conn, label): + """Herstelt de tabelnamen in de database van `conn`. Geeft een lijst meldingen (leeg = niets gedaan).""" + meldingen = [] + cur = conn.cursor() + try: + lctn = _q(cur, "SELECT @@lower_case_table_names") + if not lctn or int(lctn[0][0]) != 0: + return meldingen # Windows/macOS-gedrag: hoofdletters maken niet uit + bestaand = [r[0] for r in _q(cur, "SELECT TABLE_NAME FROM information_schema.TABLES " + "WHERE TABLE_SCHEMA = DATABASE() AND TABLE_TYPE = 'BASE TABLE'")] + if not bestaand: + return meldingen + per_klein = {} + for t in bestaand: + per_klein.setdefault(t.lower(), []).append(t) + te_doen = [] + for naam in verwachte_namen(): + varianten = [t for t in per_klein.get(naam.lower(), []) if t != naam] + if not varianten: + continue + if len(varianten) > 1: + meldingen.append(f"{label}: meerdere varianten van {naam} ({', '.join(varianten)}), niets gewijzigd") + continue + te_doen.append((naam, varianten[0], naam in per_klein.get(naam.lower(), []))) + if not te_doen: + return meldingen + + aantal = lambda t: int(_q(cur, f"SELECT COUNT(*) FROM `{t}`")[0][0]) + _q(cur, "SET FOREIGN_KEY_CHECKS = 0") + try: + for naam, oud, juiste_bestaat in te_doen: + try: + if juiste_bestaat: + n_juist, n_oud = aantal(naam), aantal(oud) + if n_oud == 0: + meldingen.append(f"{label}: {oud} is leeg en wordt niet gebruikt ({naam} wel); " + f"je mag {oud} verwijderen") + continue + if n_juist > 0: + meldingen.append(f"{label}: zowel {oud} ({n_oud} rijen) als {naam} ({n_juist} rijen) " + f"bevatten gegevens; niets gewijzigd, voeg ze zelf samen") + continue + _q(cur, f"DROP TABLE `{naam}`") + _q(cur, f"RENAME TABLE `{oud}` TO `{naam}`") + meldingen.append(f"{label}: lege {naam} vervangen door {oud} ({n_oud} rijen)") + else: + _q(cur, f"RENAME TABLE `{oud}` TO `{naam}`") + meldingen.append(f"{label}: {oud} hernoemd naar {naam}") + except Exception as e: + meldingen.append(f"{label}: {oud} → {naam} mislukt: {e}") + finally: + _q(cur, "SET FOREIGN_KEY_CHECKS = 1") + conn.commit() + finally: + cur.close() + return meldingen + + +def _verbinding(host, user, password, database): + """Eén losse verbinding (geen pool): bij het opstarten zou een pool per school anders meteen + 5 verbindingen per school openen en blijven vasthouden ("Too many connections").""" + import mysql.connector + from config import Config + return mysql.connector.connect(host=host, port=Config.DB_PORT, user=user, password=password, database=database, + connection_timeout=10, charset='utf8mb4', collation='utf8mb4_unicode_ci') + + +def herstel_centraal(): + from config import Config + conn = _verbinding(Config.DB_HOST, Config.DB_USER, Config.DB_PASSWORD, Config.DB_CENTRAAL) + try: + return _log(herstel(conn, 'centrale database')) + finally: + conn.close() + + +def herstel_school(school): + cfg = school.db_config() + conn = _verbinding(cfg['host'], cfg['user'], cfg['password'], cfg['database']) + try: + return _log(herstel(conn, f"school {school.code}")) + finally: + conn.close() + + +def herstel_alle_scholen(): + from tenancy import alle_scholen + uit = [] + for school in alle_scholen(ook_inactief=True): + try: + uit += herstel_school(school) + except Exception as e: + uit += _log([f"school {school.code}: tabelnamen nakijken mislukt: {e}"]) + return uit + + +def _log(meldingen): + for m in meldingen: + print(f"PIVOT tabelnamen: {m}", file=sys.stderr, flush=True) + return meldingen diff --git a/services/toestellen.py b/services/toestellen.py new file mode 100644 index 0000000..6ef786e --- /dev/null +++ b/services/toestellen.py @@ -0,0 +1,121 @@ +""" +Hulpfuncties rond toestellen (tabellen Toestellen, Signpost_DB, Opmerkingen, Eigendom). + +Het toestelnummer dat gebruikers intypen (vroeger 'KAMXM_nummer') is Toestellen.Schooleigen_ID. +""" +from datetime import datetime, date + +from database import execute_query, execute_insert + +# Laatste opmerking van een toestel (subquery, t = alias van Toestellen) +LAATSTE_OPMERKING = ("(SELECT o.Opmerking FROM Opmerkingen o WHERE o.Toestel_ID = t.Toestel_ID " + "ORDER BY o.Timestamp DESC, o.Opmerking_ID DESC LIMIT 1)") + + +def zoek_toestel(nummer): + """Toestel op toestelnummer (Schooleigen_ID) of, als dat niet bestaat, op serienummer.""" + nummer = str(nummer or '').strip() + if not nummer: + return None + rows = execute_query("SELECT * FROM Toestellen WHERE Schooleigen_ID = %s", (nummer,)) + if not rows: + rows = execute_query("SELECT * FROM Toestellen WHERE Serienummer = %s", (nummer,)) + return rows[0] if rows else None + + +def zoek_leerling(nummer): + """Leerling op internnummer of Smartschool-nummer.""" + rows = execute_query("SELECT * FROM Leerlingen WHERE Internnummer = %s OR SS_nummer = %s LIMIT 1", + (str(nummer).strip(), str(nummer).strip())) + return rows[0] if rows else None + + +def laatste_opmerking(toestel_id): + rows = execute_query("SELECT Opmerking FROM Opmerkingen WHERE Toestel_ID = %s " + "ORDER BY Timestamp DESC, Opmerking_ID DESC LIMIT 1", (toestel_id,)) + return rows[0]['Opmerking'] if rows else None + + +def voeg_opmerking_toe(toestel_id, tekst, gebruiker=None): + """Bewaart een opmerking als die verschilt van de laatste (zo blijft de geschiedenis bewaard).""" + tekst = (tekst or '').strip() + if tekst and tekst != (laatste_opmerking(toestel_id) or ''): + execute_query("INSERT INTO Opmerkingen (Toestel_ID, Opmerking, Timestamp, Gebruiker) VALUES (%s, %s, NOW(), %s)", + (toestel_id, tekst, gebruiker), commit=True) + + +def lees_datum(waarde): + """'2027-08-31', '31-08-2027' of '31/08/2027' → date. Leeg → None. Ongeldig → ValueError.""" + if isinstance(waarde, date): + return waarde + w = (waarde or '').strip() + if not w: + return None + for fmt in ('%Y-%m-%d', '%d-%m-%Y', '%d/%m/%Y', '%d.%m.%Y'): + try: + return datetime.strptime(w, fmt).date() + except ValueError: + pass + raise ValueError(f"'{w}' is geen geldige datum (gebruik dd-mm-jjjj).") + + +def bewaar_signpost(toestel_id, signpostnummer=None, garantie=None): + """Maakt/werkt de Signpost-gegevens van een toestel bij (enkel ingevulde velden).""" + signpostnummer = (signpostnummer or '').strip() or None + garantie = lees_datum(garantie) + if not signpostnummer and not garantie: + return + execute_query( + "INSERT INTO Signpost_DB (Toestel_ID, Signpostnummer, Garantie) VALUES (%s, %s, %s) " + "ON DUPLICATE KEY UPDATE Signpostnummer = COALESCE(VALUES(Signpostnummer), Signpostnummer), " + "Garantie = COALESCE(VALUES(Garantie), Garantie)", + (toestel_id, signpostnummer, garantie), commit=True) + + +def nieuw_toestel(schooleigen_id=None, serienummer=None, type_toestel=None, status='Beschikbaar'): + return execute_insert( + "INSERT INTO Toestellen (Schooleigen_ID, Serienummer, Type, Status) VALUES (%s, %s, %s, %s)", + ((schooleigen_id or None), (serienummer or None), (type_toestel or None), status)) + + +def actieve_eigendom(toestel_id): + """Huidige toekenning van een toestel (Datum_inlevering leeg) of None.""" + rows = execute_query("SELECT * FROM Eigendom WHERE Toestel_ID = %s AND Datum_inlevering IS NULL " + "ORDER BY Datum_toekenning DESC LIMIT 1", (toestel_id,)) + return rows[0] if rows else None + + +# --------------------------------------------------------------------------- +# Geblokkeerd in Google (kolom Toestellen.Geblokkeerd) +# --------------------------------------------------------------------------- +def ensure_geblokkeerd(): + """Voegt de kolom Toestellen.Geblokkeerd toe als die nog niet bestaat. Bij het toevoegen krijgen toestellen die + bij een leerling geblokkeerd staan (Eigendom.Status = 1) meteen de waarde 1. Eén keer per school en proces.""" + from tenancy import huidige_school + school = huidige_school() + if school.cache.get('kol_geblokkeerd'): + return + if not execute_query("SHOW COLUMNS FROM Toestellen LIKE 'Geblokkeerd'"): + execute_query("ALTER TABLE Toestellen ADD COLUMN Geblokkeerd TINYINT(1) NOT NULL DEFAULT 0 AFTER Status", + commit=True) + execute_query("UPDATE Toestellen t JOIN Eigendom e ON e.Toestel_ID = t.Toestel_ID " + "AND e.Datum_inlevering IS NULL AND e.Status = 1 SET t.Geblokkeerd = 1", commit=True) + school.cache['kol_geblokkeerd'] = True + + +def zet_geblokkeerd(toestel_id, geblokkeerd): + """Bewaart de blokkeerstatus van een toestel, ook bij de huidige eigenaar (Eigendom.Status).""" + ensure_geblokkeerd() + w = 1 if geblokkeerd else 0 + execute_query("UPDATE Toestellen SET Geblokkeerd = %s WHERE Toestel_ID = %s", (w, toestel_id), commit=True) + execute_query("UPDATE Eigendom SET Status = %s WHERE Toestel_ID = %s AND Datum_inlevering IS NULL", + (w, toestel_id), commit=True) + + +def is_geblokkeerd(toestel_id): + try: + ensure_geblokkeerd() + rows = execute_query("SELECT Geblokkeerd FROM Toestellen WHERE Toestel_ID = %s", (toestel_id,)) + return bool(rows and int(rows[0]['Geblokkeerd'] or 0)) + except Exception: + return False diff --git a/services/tweestaps_service.py b/services/tweestaps_service.py new file mode 100644 index 0000000..e976ea6 --- /dev/null +++ b/services/tweestaps_service.py @@ -0,0 +1,515 @@ +""" +2FA-hulp: back-upcodes, tijdelijke uitzonderingen en overzicht van wie 2FA nog niet heeft ingesteld. + +Vereist (eenmalig, Google Admin-console): + 1. Beveiliging › API-beheer › Domeinbrede delegatie: aan het service-account toevoegen + https://www.googleapis.com/auth/admin.directory.user.security (back-upcodes) + https://www.googleapis.com/auth/admin.directory.group.member (uitzonderingsgroep) + 2. Een groep aanmaken, bv. 2fa-uitzondering@school.be, en bij + Beveiliging › Authenticatie › Verificatie in 2 stappen voor DIE GROEP + 'Afdwingen' op UIT zetten (groepsinstellingen gaan boven de OU-instelling). + 3. Het adres van die groep invullen in PIVOT › 🔌 Koppelingen van deze school › 🛡️ 2FA-hulp › Uitzonderingsgroep +""" +import sys +import threading +import time +from datetime import datetime, timedelta + +from googleapiclient.discovery import build +from googleapiclient.errors import HttpError +from config import Config +from database import execute_query +from tenancy import google_credentials, school_singleton, alle_scholen, in_school + +SECURITY_SCOPE = 'https://www.googleapis.com/auth/admin.directory.user.security' +GROUP_MEMBER_SCOPE = 'https://www.googleapis.com/auth/admin.directory.group.member' +RESET_INTERVAL = 60 # wachtende 2FA-reset elke minuut opnieuw proberen +CHECK_INTERVAL = 5 * 60 # elke 5 minuten verlopen uitzonderingen opruimen +LIJST_CACHE = 10 * 60 + + + +def _is_afgedwongen_fout(e): + """Google weigert turnOff zolang 2FA nog afgedwongen wordt (uitzonderingsgroep nog niet toegepast).""" + msg = str(e).lower() + return 'cannot be turned off' in msg or ('enforced' in msg and 'admin policy' in msg) + +def _is_netwerkfout(e): + """Tijdelijke verbindingsfout (SSL, time-out, Google 5xx): later opnieuw proberen.""" + import socket, ssl + if isinstance(e, (ssl.SSLError, socket.timeout, ConnectionError, TimeoutError, OSError)): + return True + status = getattr(getattr(e, 'resp', None), 'status', None) + try: + return int(status) >= 500 + except (TypeError, ValueError): + return False + + +class TweestapsError(Exception): + pass + + +class TweestapsService: + _scheduler = None # één achtergrondtaak voor alle scholen + + def __new__(cls): + # één instantie per school + def init(inst): + inst._clients = threading.local() # Google-clients (httplib2) zijn niet thread-safe: één per thread + inst._directory = None + inst._lock = threading.Lock() + inst._lijst_cache = None + return school_singleton(cls, init) + + # ---------------- clients ---------------- + def _build(self, scope): + creds = google_credentials([scope], subject=Config.GOOGLE_ADMIN_EMAIL) + return build('admin', 'directory_v1', credentials=creds, static_discovery=False, cache_discovery=False) + + def _client(self, naam, scope): + client = getattr(self._clients, naam, None) + if client is None: + client = self._build(scope) + setattr(self._clients, naam, client) + return client + + def _security_api(self): + return self._client('security', SECURITY_SCOPE) + + def _group_api(self): + return self._client('groups', GROUP_MEMBER_SCOPE) + + def _directory_api(self): + if self._directory is None: + from services.google_service import GoogleAdminService + self._directory = GoogleAdminService().service + return self._directory + + @staticmethod + def _friendly(e, scope=None): + msg = str(e) + status = getattr(getattr(e, 'resp', None), 'status', None) + if 'unauthorized_client' in msg or 'access_denied' in msg: + return (f"Google weigert de toegang. Voeg de scope {scope} toe aan de domeinbrede delegatie " + "van het service-account." if scope else "Google weigert de toegang (scope ontbreekt).") + if _is_afgedwongen_fout(e): + return ("Google dwingt 2FA nog af voor deze gebruiker. De uitzonderingsgroep is nog niet " + "toegepast (dat kan enkele minuten duren).") + if 'INVALID_OU_ID' in msg: + return ("Een organisatie-eenheid bestaat niet in Google. Controleer de OU's bij 🔌 Koppelingen van " + "deze school › 🛡️ 2FA-hulp.") + if status == 404: + return "Gebruiker of groep niet gevonden." + if status == 403: + return "Geen toestemming voor deze actie bij Google." + return msg + + ontbrekende_ous = [] + + def ou_melding(self): + if not self.ontbrekende_ous: + return None + return (f"Deze organisatie-eenheid bestaat niet in Google: {', '.join(self.ontbrekende_ous)}. " + "Pas ze aan bij 🔌 Koppelingen van deze school › 🛡️ 2FA-hulp › \"OU's voor 'nog geen 2FA'\" " + "(het volledige pad zoals in admin.google.com, bv. /Personeel/Leerkrachten; meerdere met komma's).") + + @property + def exception_group(self): + return (Config.TWEESTAPS_UITZONDERING_GROEP or '').strip() + + # ---------------- gebruiker opzoeken ---------------- + def get_user(self, email): + email = (email or '').strip().lower() + if '@' not in email: + raise TweestapsError("Geef een volledig e-mailadres op.") + try: + u = self._directory_api().users().get(userKey=email).execute() + except HttpError as e: + if getattr(e.resp, 'status', None) == 404: + raise TweestapsError(f"{email} bestaat niet in jullie Google Workspace.") + raise TweestapsError(self._friendly(e)) + return { + 'email': u['primaryEmail'].lower(), + 'naam': (u.get('name') or {}).get('fullName', ''), + 'ou': u.get('orgUnitPath', ''), + 'ingesteld': bool(u.get('isEnrolledIn2Sv')), + 'afgedwongen': bool(u.get('isEnforcedIn2Sv')), + 'geschorst': bool(u.get('suspended')), + 'laatste_login': (u.get('lastLoginTime') or '')[:16].replace('T', ' ').replace('1970-01-01 00:00', ''), + } + + def search_users(self, query): + try: + resp = self._directory_api().users().list( + customer='my_customer', query=query.strip(), maxResults=15, orderBy='email').execute() + except Exception as e: + sys.stderr.write(f"2FA zoeken: {e}\n") + return [] + return [{'email': u['primaryEmail'], 'naam': (u.get('name') or {}).get('fullName', ''), + 'ou': u.get('orgUnitPath', '')} for u in resp.get('users', [])] + + # ---------------- back-upcodes ---------------- + def list_codes(self, email): + try: + resp = self._security_api().verificationCodes().list(userKey=email).execute() + except Exception as e: + raise TweestapsError(self._friendly(e, SECURITY_SCOPE)) + return [i['verificationCode'] for i in resp.get('items', []) if i.get('verificationCode')] + + def generate_codes(self, email): + """Maakt een nieuwe set (de oude set wordt daardoor ongeldig) en geeft de codes terug.""" + try: + self._security_api().verificationCodes().generate(userKey=email).execute() + except Exception as e: + raise TweestapsError(self._friendly(e, SECURITY_SCOPE)) + return self.list_codes(email) + + def invalidate_codes(self, email): + try: + self._security_api().verificationCodes().invalidate(userKey=email).execute() + except Exception as e: + raise TweestapsError(self._friendly(e, SECURITY_SCOPE)) + + # ---------------- uitzonderingsgroep ---------------- + def _add_to_group(self, email): + if not self.exception_group: + raise TweestapsError("Er is nog geen uitzonderingsgroep ingesteld " + "(🔌 Koppelingen van deze school › 🛡️ 2FA-hulp › Uitzonderingsgroep).") + try: + self._group_api().members().insert( + groupKey=self.exception_group, body={'email': email, 'role': 'MEMBER'}).execute() + except HttpError as e: + if getattr(e.resp, 'status', None) == 409: # zit er al in → prima + return + raise TweestapsError(self._friendly(e, GROUP_MEMBER_SCOPE)) + except Exception as e: + raise TweestapsError(self._friendly(e, GROUP_MEMBER_SCOPE)) + + def _remove_from_group(self, email): + try: + self._group_api().members().delete(groupKey=self.exception_group, memberKey=email).execute() + except HttpError as e: + if getattr(e.resp, 'status', None) == 404: # zit er al niet meer in → prima + return + raise TweestapsError(self._friendly(e, GROUP_MEMBER_SCOPE)) + except Exception as e: + raise TweestapsError(self._friendly(e, GROUP_MEMBER_SCOPE)) + + # ---------------- database ---------------- + @staticmethod + def ensure_tables(): + execute_query(""" + CREATE TABLE IF NOT EXISTS tweestaps_uitzonderingen ( + ID INT AUTO_INCREMENT PRIMARY KEY, + Email VARCHAR(255) NOT NULL, + Naam VARCHAR(255) NULL, + Reden VARCHAR(255) NULL, + Van DATETIME NOT NULL, + Tot DATETIME NOT NULL, + Door VARCHAR(100) NULL, + Actief TINYINT(1) NOT NULL DEFAULT 1, + Beeindigd_op DATETIME NULL, + Beeindigd_door VARCHAR(100) NULL, + INDEX idx_actief (Actief, Tot), + INDEX idx_email (Email) + )""", commit=True) + execute_query(""" + CREATE TABLE IF NOT EXISTS tweestaps_log ( + ID INT AUTO_INCREMENT PRIMARY KEY, + Tijdstip DATETIME NOT NULL, + Door VARCHAR(100) NULL, + Actie VARCHAR(40) NOT NULL, + Email VARCHAR(255) NOT NULL, + Details VARCHAR(255) NULL, + INDEX idx_tijdstip (Tijdstip) + )""", commit=True) + execute_query(""" + CREATE TABLE IF NOT EXISTS tweestaps_resets ( + ID INT AUTO_INCREMENT PRIMARY KEY, + Email VARCHAR(255) NOT NULL, + Gevraagd_op DATETIME NOT NULL, + Door VARCHAR(100) NULL, + Reden VARCHAR(255) NULL, + Tot DATETIME NOT NULL, + Status VARCHAR(10) NOT NULL DEFAULT 'bezig', + Pogingen INT NOT NULL DEFAULT 0, + Laatste_fout VARCHAR(255) NULL, + Klaar_op DATETIME NULL, + INDEX idx_status (Status), + INDEX idx_email (Email) + )""", commit=True) + + @staticmethod + def log(door, actie, email, details=None): + try: + execute_query("INSERT INTO tweestaps_log (Tijdstip, Door, Actie, Email, Details) VALUES (NOW(), %s, %s, %s, %s)", + (door, actie, email, details), commit=True) + except Exception as e: + sys.stderr.write(f"2FA log: {e}\n") + + @staticmethod + def recent_log(pagina=1, per_pagina=10): + """Eén pagina van het logboek (nieuwste eerst). Geeft (rijen, totaal aantal).""" + totaal = execute_query("SELECT COUNT(*) AS n FROM tweestaps_log")[0]['n'] + pagina = max(1, int(pagina or 1)) + rijen = execute_query("SELECT Tijdstip, Door, Actie, Email, Details FROM tweestaps_log " + "ORDER BY Tijdstip DESC, ID DESC LIMIT %s OFFSET %s", + (int(per_pagina), (pagina - 1) * int(per_pagina))) + return rijen, totaal + + @staticmethod + def active_exceptions(): + return execute_query("SELECT ID, Email, Naam, Reden, Van, Tot, Door FROM tweestaps_uitzonderingen " + "WHERE Actief = 1 ORDER BY Tot") + + @staticmethod + def active_for(email): + rows = execute_query("SELECT ID, Tot FROM tweestaps_uitzonderingen WHERE Actief = 1 AND Email = %s " + "ORDER BY Tot DESC LIMIT 1", (email,)) + return rows[0] if rows else None + + # ---------------- uitzonderingen ---------------- + @staticmethod + def einde_vandaag(nu=None): + """Een uitzondering (of 2FA-reset) loopt altijd tot 23:59 dezelfde dag.""" + return (nu or datetime.now()).replace(hour=23, minute=59, second=0, microsecond=0) + + def grant_exception(self, email, eindtijd, door, reden=None): + user = self.get_user(email) + if user['geschorst']: + raise TweestapsError(f"{user['email']} is geschorst.") + bestaand = self.active_for(user['email']) + self._add_to_group(user['email']) + if bestaand: + execute_query("UPDATE tweestaps_uitzonderingen SET Tot = %s WHERE ID = %s", + (eindtijd, bestaand['ID']), commit=True) + else: + execute_query( + "INSERT INTO tweestaps_uitzonderingen (Email, Naam, Reden, Van, Tot, Door, Actief) " + "VALUES (%s, %s, %s, NOW(), %s, %s, 1)", + (user['email'], user['naam'], (reden or '')[:255], eindtijd, door), commit=True) + self.log(door, 'uitzondering', user['email'], f"tot {eindtijd:%d-%m %H:%M}" + (f" – {reden}" if reden else '')) + self._lijst_cache = None + return user + + def reset_2fa(self, email, eindtijd, door, reden=None): + """ + 2FA resetten (nieuwe telefoon, toestel kwijt): 2FA uitzetten voor de gebruiker zodat hij het opnieuw + kan instellen. Wordt 2FA afgedwongen, dan krijgt hij eerst een tijdelijke uitzondering; anders zou hij + na het uitzetten niet meer kunnen inloggen om 2FA opnieuw in te stellen. + Google: users.twoStepVerification.turnOff (scope admin.directory.user.security). + """ + user = self.get_user(email) + if user['geschorst']: + raise TweestapsError(f"{user['email']} is geschorst.") + if not user['ingesteld']: + raise TweestapsError(f"{user['email']} heeft nog geen 2FA ingesteld; er valt niets te resetten. " + "Moet hij 2FA nog instellen maar kan hij niet inloggen, geef dan een tijdelijke uitzondering.") + uitzondering = False + if user['afgedwongen']: + if not self.exception_group: + raise TweestapsError("2FA wordt afgedwongen voor deze gebruiker. Stel eerst een uitzonderingsgroep in " + "(🔌 Koppelingen van deze school › 🛡️ 2FA-hulp), anders kan hij na de reset niet " + "meer inloggen om 2FA opnieuw in te stellen.") + self.grant_exception(user['email'], eindtijd, door, reden or '2FA-reset') + uitzondering = True + try: + self._turn_off(user['email']) + except Exception as e: + if uitzondering and _is_afgedwongen_fout(e): + # Google past de uitzonderingsgroep pas na enkele minuten toe → op de achtergrond opnieuw proberen. + self._plan_reset(user['email'], eindtijd, door, reden) + user['uitzondering'] = True + user['wacht'] = True + return user + raise TweestapsError(f"2FA uitzetten mislukt: {self._friendly(e, SECURITY_SCOPE)}" + + (" De tijdelijke uitzondering blijft lopen tot ze afloopt." if uitzondering else "")) + self._lijst_cache = None + self.log(door, '2FA gereset', user['email'], reden or None) + user['uitzondering'] = uitzondering + user['wacht'] = False + return user + + def _turn_off(self, email): + self._security_api().twoStepVerification().turnOff(userKey=email).execute() + + # ---------------- 2FA-reset die op Google wacht ---------------- + def _plan_reset(self, email, tot, door, reden): + bestaand = self.reset_bezig(email) + if bestaand: + execute_query("UPDATE tweestaps_resets SET Tot = %s WHERE ID = %s", (tot, bestaand['ID']), commit=True) + rid = bestaand['ID'] + else: + execute_query( + "INSERT INTO tweestaps_resets (Email, Gevraagd_op, Door, Reden, Tot, Status, Pogingen) " + "VALUES (%s, NOW(), %s, %s, %s, 'bezig', 1)", (email, door, (reden or '')[:255], tot), commit=True) + rid = execute_query("SELECT ID FROM tweestaps_resets WHERE Email = %s AND Status = 'bezig' " + "ORDER BY ID DESC LIMIT 1", (email,))[0]['ID'] + self.log(door, '2FA reset wacht', email, 'Google past de uitzondering nog toe; PIVOT probeert opnieuw') + self._volg_reset_op(rid) + + @staticmethod + def reset_bezig(email=None): + if email: + rows = execute_query("SELECT * FROM tweestaps_resets WHERE Status = 'bezig' AND Email = %s " + "ORDER BY ID DESC LIMIT 1", ((email or '').strip().lower(),)) + return rows[0] if rows else None + return execute_query("SELECT * FROM tweestaps_resets WHERE Status = 'bezig' ORDER BY Gevraagd_op") + + def probeer_reset(self, rij): + """Eén poging voor een wachtende reset. Geeft 'klaar', 'mislukt' of 'bezig' terug.""" + email = rij['Email'] + try: + self._turn_off(email) + except Exception as e: + nog_tijd = rij['Tot'] > datetime.now() + tijdelijk = _is_afgedwongen_fout(e) or _is_netwerkfout(e) + if _is_netwerkfout(e): + self._clients.security = None # verbinding kapot → volgende poging met een nieuwe client + if tijdelijk and nog_tijd: + execute_query("UPDATE tweestaps_resets SET Pogingen = Pogingen + 1, Laatste_fout = %s WHERE ID = %s", + (self._friendly(e)[:255], rij['ID']), commit=True) + return 'bezig' + fout = (self._friendly(e, SECURITY_SCOPE) if nog_tijd else + "Google heeft de uitzondering niet toegepast voor ze afliep. Controleer of 'Afdwingen' op 'Uit' " + "staat voor de uitzonderingsgroep in admin.google.com en probeer opnieuw.") + execute_query("UPDATE tweestaps_resets SET Status = 'mislukt', Pogingen = Pogingen + 1, Laatste_fout = %s, " + "Klaar_op = NOW() WHERE ID = %s", (fout[:255], rij['ID']), commit=True) + self.log('automatisch', '2FA reset mislukt', email, fout[:255]) + return 'mislukt' + execute_query("UPDATE tweestaps_resets SET Status = 'klaar', Pogingen = Pogingen + 1, Laatste_fout = NULL, " + "Klaar_op = NOW() WHERE ID = %s", (rij['ID'],), commit=True) + self.log(rij['Door'] or 'automatisch', '2FA gereset', email, + (rij['Reden'] or '') + f" (na {rij['Pogingen'] + 1} pogingen)") + self._lijst_cache = None + return 'klaar' + + def verwerk_resets(self): + """Alle wachtende resets van deze school één keer proberen (vangnet van de 5-minutenplanner).""" + n = 0 + for rij in self.reset_bezig(): + if self.probeer_reset(rij) == 'klaar': + n += 1 + return n + + def _volg_reset_op(self, rid): + from tenancy import met_school + svc = self + + def loop(): + while True: + time.sleep(RESET_INTERVAL) + try: + rows = execute_query("SELECT * FROM tweestaps_resets WHERE ID = %s", (rid,)) + if not rows or rows[0]['Status'] != 'bezig': + return + if svc.probeer_reset(rows[0]) != 'bezig': + return + except Exception as e: + sys.stderr.write(f"2FA-reset {rid}: {e}\n") + threading.Thread(target=met_school(loop), daemon=True).start() + + def end_exception(self, uitz_id, door): + rows = execute_query("SELECT Email FROM tweestaps_uitzonderingen WHERE ID = %s AND Actief = 1", (uitz_id,)) + if not rows: + raise TweestapsError("Deze uitzondering is al beëindigd.") + email = rows[0]['Email'] + self._remove_from_group(email) + execute_query("UPDATE tweestaps_uitzonderingen SET Actief = 0, Beeindigd_op = NOW(), Beeindigd_door = %s " + "WHERE ID = %s", (door, uitz_id), commit=True) + self.log(door, 'uitzondering beëindigd', email) + self._lijst_cache = None + return email + + def expire_due(self): + """Haalt iedereen met een verlopen uitzondering terug uit de groep. Geeft het aantal terug.""" + if not self._lock.acquire(blocking=False): + return 0 + try: + rows = execute_query("SELECT ID, Email FROM tweestaps_uitzonderingen WHERE Actief = 1 AND Tot <= NOW()") + klaar = 0 + for r in rows: + try: + self._remove_from_group(r['Email']) + execute_query("UPDATE tweestaps_uitzonderingen SET Actief = 0, Beeindigd_op = NOW(), " + "Beeindigd_door = 'automatisch' WHERE ID = %s", (r['ID'],), commit=True) + self.log('automatisch', 'uitzondering verlopen', r['Email']) + klaar += 1 + except Exception as e: + # blijft actief → volgende ronde opnieuw proberen + sys.stderr.write(f"2FA: kon {r['Email']} niet uit de groep halen: {e}\n") + if klaar: + self._lijst_cache = None + return klaar + finally: + self._lock.release() + + @classmethod + def start_scheduler(cls): + """Ruimt elke 5 minuten verlopen uitzonderingen op, voor elke school met een Google-sleutel.""" + if TweestapsService._scheduler: + return + + def loop(): + time.sleep(20) + while True: + for school in alle_scholen(): + if not school.google_sleutel: + continue + try: + with in_school(school): + svc = cls() + svc.ensure_tables() + svc.verwerk_resets() + n = svc.expire_due() + if n: + print(f"[{school.code}] 2FA: {n} verlopen uitzondering(en) opgeruimd.", flush=True) + except Exception as e: + sys.stderr.write(f"[{school.code}] 2FA scheduler: {e}\n") + time.sleep(CHECK_INTERVAL) + + TweestapsService._scheduler = threading.Thread(target=loop, daemon=True) + TweestapsService._scheduler.start() + + # ---------------- wie heeft 2FA nog niet ingesteld ---------------- + def niet_ingesteld(self, force=False): + if not force and self._lijst_cache and time.time() - self._lijst_cache[0] < LIJST_CACHE: + return self._lijst_cache[1] + result = [] + ous = [o.strip() for o in Config.TWEESTAPS_OUS.split(',') if o.strip()] + self.ontbrekende_ous = [] + for ou in ous: + token = None + while True: + try: + resp = self._directory_api().users().list( + customer='my_customer', query=f"orgUnitPath='{ou}' isSuspended=false", + maxResults=500, pageToken=token, projection='basic', + fields='nextPageToken,users(primaryEmail,name/fullName,orgUnitPath,isEnrolledIn2Sv,' + 'isEnforcedIn2Sv,archived,lastLoginTime,creationTime)').execute() + except Exception as e: + if 'INVALID_OU_ID' in str(e): + self.ontbrekende_ous.append(ou) # deze OU bestaat niet in Google: overslaan + break + raise + for u in resp.get('users', []): + if u.get('archived') or u.get('isEnrolledIn2Sv'): + continue + result.append({ + 'email': u['primaryEmail'].lower(), + 'naam': (u.get('name') or {}).get('fullName', ''), + 'ou': u.get('orgUnitPath', ''), + 'afgedwongen': bool(u.get('isEnforcedIn2Sv')), + 'laatste_login': (u.get('lastLoginTime') or '')[:10].replace('1970-01-01', ''), + 'aangemaakt': (u.get('creationTime') or '')[:10], + }) + token = resp.get('nextPageToken') + if not token: + break + if ous and len(self.ontbrekende_ous) == len(ous): + raise TweestapsError(self.ou_melding()) + uniek = {r['email']: r for r in result} + lijst = sorted(uniek.values(), key=lambda r: (r['ou'], r['naam'])) + self._lijst_cache = (time.time(), lijst) + return lijst diff --git a/services/verzendlijsten_service.py b/services/verzendlijsten_service.py new file mode 100644 index 0000000..670fc31 --- /dev/null +++ b/services/verzendlijsten_service.py @@ -0,0 +1,173 @@ +""" +Verzendlijsten (Google Groepen) van de huidige school beheren via de Admin SDK. + +Directory API (scope admin.directory.group) → groepen en leden: bekijken, aanmaken, aanpassen +Groups Settings API (scope apps.groups.settings) → "wie mag e-mails sturen naar de lijst" (optioneel: ontbreekt de + scope, dan werkt de rest gewoon en wordt dit veld verborgen) +Beide scopes moeten in Google Admin › Beveiliging › API-beheer › Domeinbrede delegatie bij het serviceaccount staan. +""" +import re +import threading + +from googleapiclient.discovery import build + +from config import Config +from tenancy import google_credentials, school_singleton + +GROUP_SCOPE = 'https://www.googleapis.com/auth/admin.directory.group' +SETTINGS_SCOPE = 'https://www.googleapis.com/auth/apps.groups.settings' + +ROLLEN = [('MEMBER', 'Lid'), ('MANAGER', 'Beheerder'), ('OWNER', 'Eigenaar')] +WIE_MAILT = [ + ('ANYONE_CAN_POST', 'Iedereen, ook van buiten de school'), + ('ALL_IN_DOMAIN_CAN_POST', 'Iedereen van de school'), + ('ALL_MEMBERS_CAN_POST', 'Enkel leden van de lijst'), + ('ALL_MANAGERS_CAN_POST', 'Enkel beheerders en eigenaars'), +] +EMAIL_RE = re.compile(r'^[^@\s]+@[^@\s]+\.[^@\s]+$') + + +class VerzendlijstFout(Exception): + pass + + +def _uitleg(e): + """Google-fout → begrijpelijke melding.""" + t = str(e) + laag = t.lower() + if 'unauthorized_client' in laag or 'insufficient authentication scopes' in laag or 'access_denied' in laag: + return ("Google weigert de toegang tot de groepen. Voeg in Google Admin › Beveiliging › API-beheer › " + f"Domeinbrede delegatie bij het serviceaccount de scope {GROUP_SCOPE} toe " + f"(en voor 'wie mag mailen' ook {SETTINGS_SCOPE}). Een wijziging kan enkele minuten duren.") + if 'entity already exists' in laag or 'duplicate' in laag or 'member already exists' in laag: + return "Dat adres bestaat al (als groep, gebruiker of lid)." + if 'resource not found' in laag or 'notfound' in laag.replace(' ', ''): + return "Niet gevonden in Google (bestaat het adres of de groep wel?)." + if 'domain' in laag and ('not found' in laag or 'invalid' in laag): + return "Dat domein hoort niet bij jullie Google Workspace." + m = re.search(r'"message":\s*"([^"]+)"', t) or re.search(r'returned "([^"]+)"', t) + return f"Google: {m.group(1) if m else t[:300]}" + + +def adressen(tekst): + """Komma-, puntkomma-, spatie- of regelgescheiden lijst → [geldige adressen], [ongeldige stukken].""" + goed, fout = [], [] + for d in re.split(r'[\s,;]+', str(tekst or '')): + d = d.strip().strip('<>').lower() + if not d: + continue + (goed if EMAIL_RE.match(d) else fout).append(d) + return list(dict.fromkeys(goed)), fout + + +class VerzendlijstenService: + def __new__(cls): + def init(inst): + inst._clients = threading.local() # Google-clients zijn niet thread-safe: één per thread + return school_singleton(cls, init) + + def _api(self, naam, scope, api, versie): + client = getattr(self._clients, naam, None) + if client is None: + creds = google_credentials([scope], subject=Config.GOOGLE_ADMIN_EMAIL) + client = build(api, versie, credentials=creds, static_discovery=False, cache_discovery=False) + setattr(self._clients, naam, client) + return client + + def _dir(self): + return self._api('directory', GROUP_SCOPE, 'admin', 'directory_v1') + + def _settings(self): + return self._api('settings', SETTINGS_SCOPE, 'groupssettings', 'v1') + + def _uitvoeren(self, verzoek): + try: + return verzoek.execute() + except Exception as e: + raise VerzendlijstFout(_uitleg(e)) + + # ---------------- groepen ---------------- + def alle(self): + uit, token = [], None + while True: + res = self._uitvoeren(self._dir().groups().list(customer='my_customer', maxResults=200, pageToken=token, + orderBy='email')) + for g in res.get('groups', []): + uit.append({'email': g.get('email', ''), 'naam': g.get('name', ''), + 'beschrijving': g.get('description', ''), + 'leden': int(g.get('directMembersCount') or 0), 'aliassen': g.get('aliases', [])}) + token = res.get('nextPageToken') + if not token: + return uit + + def groep(self, email): + g = self._uitvoeren(self._dir().groups().get(groupKey=email)) + return {'email': g.get('email', ''), 'naam': g.get('name', ''), 'beschrijving': g.get('description', ''), + 'leden': int(g.get('directMembersCount') or 0), 'aliassen': g.get('aliases', [])} + + def aanmaken(self, email, naam, beschrijving=''): + email = (email or '').strip().lower() + if not EMAIL_RE.match(email): + raise VerzendlijstFout("Geef een geldig e-mailadres voor de verzendlijst.") + if not (naam or '').strip(): + raise VerzendlijstFout("Geef de verzendlijst een naam.") + self._uitvoeren(self._dir().groups().insert(body={'email': email, 'name': naam.strip(), + 'description': (beschrijving or '').strip()})) + return email + + def bijwerken(self, email, naam, beschrijving): + if not (naam or '').strip(): + raise VerzendlijstFout("De naam mag niet leeg zijn.") + self._uitvoeren(self._dir().groups().patch(groupKey=email, body={ + 'name': naam.strip(), 'description': (beschrijving or '').strip()})) + + # ---------------- wie mag mailen (Groups Settings API) ---------------- + def wie_mailt(self, email): + """Huidige instelling, of None als de Groups Settings-scope (nog) ontbreekt.""" + try: + s = self._settings().groups().get(groupUniqueId=email, alt='json').execute() + return s.get('whoCanPostMessage') + except Exception: + return None + + def zet_wie_mailt(self, email, waarde): + if waarde not in dict(WIE_MAILT): + raise VerzendlijstFout("Onbekende keuze voor 'wie mag mailen'.") + body = {'whoCanPostMessage': waarde} + if waarde == 'ANYONE_CAN_POST': + body['allowExternalMembers'] = 'false' # extern mailen mag, extern lid worden niet automatisch + self._uitvoeren(self._settings().groups().patch(groupUniqueId=email, body=body)) + + # ---------------- leden ---------------- + def leden(self, email): + uit, token = [], None + while True: + res = self._uitvoeren(self._dir().members().list(groupKey=email, maxResults=200, pageToken=token)) + for m in res.get('members', []): + uit.append({'email': m.get('email', ''), 'rol': m.get('role', 'MEMBER'), + 'soort': m.get('type', 'USER'), 'status': m.get('status', '')}) + token = res.get('nextPageToken') + if not token: + volgorde = {'OWNER': 0, 'MANAGER': 1, 'MEMBER': 2} + return sorted(uit, key=lambda m: (volgorde.get(m['rol'], 3), m['email'])) + + def leden_toevoegen(self, email, lijst, rol='MEMBER'): + """Geeft (toegevoegd, [(adres, fout)]).""" + if rol not in dict(ROLLEN): + rol = 'MEMBER' + gelukt, fouten = 0, [] + for adres in lijst: + try: + self._uitvoeren(self._dir().members().insert(groupKey=email, body={'email': adres, 'role': rol})) + gelukt += 1 + except VerzendlijstFout as e: + fouten.append((adres, str(e))) + return gelukt, fouten + + def lid_rol(self, email, lid, rol): + if rol not in dict(ROLLEN): + raise VerzendlijstFout("Onbekende rol.") + self._uitvoeren(self._dir().members().patch(groupKey=email, memberKey=lid, body={'role': rol})) + + def lid_verwijderen(self, email, lid): + self._uitvoeren(self._dir().members().delete(groupKey=email, memberKey=lid)) diff --git a/sql/centraal.sql b/sql/centraal.sql new file mode 100644 index 0000000..14e7706 --- /dev/null +++ b/sql/centraal.sql @@ -0,0 +1,314 @@ +-- ===================================================================== +-- PIVOT 1.1.0 – CENTRALE database (structuur schooljaar 2026-27) +-- +-- Bevat wat voor ALLE scholen geldt: +-- Scholen de scholen (+ hun koppelingen, versleuteld) +-- Gebruikers één account per persoon, voor alle scholen +-- Veiligheidsgroepen 1 = Administrator, 2 = Gebruiker +-- Onderdelen de onderdelen van PIVOT waarop je rechten kan geven +-- Gebruiker_rechten wie mag welk onderdeel, in welke school +-- Logs bezochte pagina's en acties +-- Bugs, Status gemelde bugs +-- +-- De naam van deze database staat in config.ini › [database] › centrale_database. +-- Dit bestand is gegenereerd uit tenancy.py en rechten.py; PIVOT maakt +-- dezelfde tabellen ook zelf aan bij het opstarten (python beheer.py init). +-- ===================================================================== + +-- USE `pivot`; + +-- Veiligheidscontrole: stop als deze database nog OUDE tabellen met dezelfde naam bevat +-- (bv. 'scholen' van PIVOT 1.1.0). MySQL op Windows maakt geen onderscheid tussen +-- hoofdletters en kleine letters, dus 'Scholen' en 'scholen' zijn dezelfde tabel. +DROP PROCEDURE IF EXISTS pivot_controle_oude_tabellen; +DELIMITER // +CREATE PROCEDURE pivot_controle_oude_tabellen() +BEGIN + DECLARE oud TEXT; + SELECT GROUP_CONCAT(n.tabel SEPARATOR ', ') INTO oud + FROM (SELECT 'scholen' AS tabel, 'school_id' AS kolom UNION ALL + SELECT 'gebruikers', 'gebruiker_id' UNION ALL SELECT 'logs', 'log_id' UNION ALL + SELECT 'status', 'bugstatus_id' UNION ALL SELECT 'bugs', 'bug_id') n + WHERE EXISTS (SELECT 1 FROM information_schema.TABLES t + WHERE t.TABLE_SCHEMA = DATABASE() AND LOWER(t.TABLE_NAME) = n.tabel) + AND NOT EXISTS (SELECT 1 FROM information_schema.COLUMNS c + WHERE c.TABLE_SCHEMA = DATABASE() AND LOWER(c.TABLE_NAME) = n.tabel + AND LOWER(c.COLUMN_NAME) = n.kolom); + IF oud IS NOT NULL THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = + 'STOP: deze database bevat nog oude PIVOT-tabellen (scholen/gebruikers/logs/status/bugs). Kies een nieuwe, lege database.'; + END IF; +END // +DELIMITER ; +CALL pivot_controle_oude_tabellen(); +DROP PROCEDURE pivot_controle_oude_tabellen; + +CREATE TABLE IF NOT EXISTS Scholen ( + School_ID INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + Code VARCHAR(32) NOT NULL, + School_naam VARCHAR(255) NOT NULL, + Smartschool_platform_url VARCHAR(255) NULL, + Adres VARCHAR(255) NULL, + Instellingsnummer VARCHAR(20) NULL, + Informat_instellingsnummer VARCHAR(500) NULL, + Onderwijsniveau VARCHAR(20) NULL, + Domein VARCHAR(500) NOT NULL DEFAULT '', + Hostnamen VARCHAR(500) NOT NULL DEFAULT '', + Actief TINYINT NOT NULL DEFAULT 1, + Db_naam VARCHAR(128) NOT NULL, + Instellingen MEDIUMTEXT NULL, + Google_sleutel MEDIUMTEXT NULL, + Leerlingenaantal INT NULL, + Leerlingenaantal_sync DATETIME NULL, + Leerlingenaantal_detail TEXT NULL, + Bijgewerkt DATETIME(6) NULL, + Bijgewerkt_door VARCHAR(255) NULL, + UNIQUE KEY uq_scholen_code (Code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS Veiligheidsgroepen ( + Veiligheidsgroep TINYINT NOT NULL PRIMARY KEY, + Naam VARCHAR(50) NOT NULL, + Omschrijving VARCHAR(255) NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS Gebruikers ( + Gebruiker_ID INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + Gebruikersnaam VARCHAR(100) NOT NULL, + Veiligheidsgroep TINYINT NOT NULL DEFAULT 2, + Passwoordhash VARCHAR(255) NULL, + Emailadres VARCHAR(255) NULL, + Last_login DATETIME NULL, + IP_laatste_login VARCHAR(45) NULL, + Laatste_school VARCHAR(32) NULL, + Actief TINYINT NOT NULL DEFAULT 1, + UNIQUE KEY uq_gebruikersnaam (Gebruikersnaam), + UNIQUE KEY uq_emailadres (Emailadres), + CONSTRAINT fk_gebruikers_groep FOREIGN KEY (Veiligheidsgroep) + REFERENCES Veiligheidsgroepen (Veiligheidsgroep) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS Onderdelen ( + Onderdeel_ID INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + Code VARCHAR(50) NOT NULL, + Naam VARCHAR(255) NOT NULL, + Categorie VARCHAR(50) NOT NULL, + Volgorde INT NOT NULL DEFAULT 0, + UNIQUE KEY uq_onderdeel_code (Code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS Gebruiker_rechten ( + Gebruiker_ID INT NOT NULL, + School_ID INT NOT NULL, + Onderdeel_ID INT NOT NULL, + Toegekend_op DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + Toegekend_door VARCHAR(100) NULL, + PRIMARY KEY (Gebruiker_ID, School_ID, Onderdeel_ID), + KEY idx_rechten_school (School_ID), + KEY idx_rechten_onderdeel (Onderdeel_ID), + CONSTRAINT fk_rechten_gebruiker FOREIGN KEY (Gebruiker_ID) + REFERENCES Gebruikers (Gebruiker_ID) ON DELETE CASCADE, + CONSTRAINT fk_rechten_school FOREIGN KEY (School_ID) + REFERENCES Scholen (School_ID) ON DELETE CASCADE, + CONSTRAINT fk_rechten_onderdeel FOREIGN KEY (Onderdeel_ID) + REFERENCES Onderdelen (Onderdeel_ID) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS Handleidingen ( + Handleiding_ID INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + Slug VARCHAR(100) NOT NULL, + Titel VARCHAR(200) NOT NULL, + Categorie VARCHAR(100) NULL, + Samenvatting VARCHAR(300) NULL, + Inhoud MEDIUMTEXT NULL, + Zichtbaar VARCHAR(10) NOT NULL DEFAULT 'iedereen', + Volgorde INT NOT NULL DEFAULT 0, + Gewijzigd_op DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + Gewijzigd_door VARCHAR(100) NULL, + UNIQUE KEY uq_handleiding_slug (Slug) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS Handleidingen_versies ( + Versie_ID INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + Handleiding_ID INT NOT NULL, + Titel VARCHAR(200) NOT NULL, + Inhoud MEDIUMTEXT NULL, + Opgeslagen_op DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + Door VARCHAR(100) NULL, + KEY idx_versie_handleiding (Handleiding_ID, Opgeslagen_op), + CONSTRAINT fk_versie_handleiding FOREIGN KEY (Handleiding_ID) + REFERENCES Handleidingen (Handleiding_ID) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS Leerlingenaantal_tellingen ( + Telling_ID INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + School_ID INT NOT NULL, + Peildatum DATE NOT NULL, + Schooljaar VARCHAR(10) NOT NULL, + Aantal INT NOT NULL, + Detail TEXT NULL, + Opgevraagd_op DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + Door VARCHAR(100) NULL, + UNIQUE KEY uq_telling (School_ID, Peildatum), + KEY idx_telling_datum (Peildatum), + CONSTRAINT fk_telling_school FOREIGN KEY (School_ID) + REFERENCES Scholen (School_ID) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS Personeelsaantal_tellingen ( + Telling_ID INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + School_ID INT NOT NULL, + Peildatum DATE NOT NULL, + Schooljaar VARCHAR(10) NOT NULL, + Aantal INT NOT NULL, + Detail TEXT NULL, + Opgevraagd_op DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + Door VARCHAR(100) NULL, + UNIQUE KEY uq_ptelling (School_ID, Peildatum), + KEY idx_ptelling_datum (Peildatum), + CONSTRAINT fk_ptelling_school FOREIGN KEY (School_ID) + REFERENCES Scholen (School_ID) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS Personeelsaantal_huidig ( + School_ID INT NOT NULL PRIMARY KEY, + Aantal INT NOT NULL, + Detail TEXT NULL, + Gesynchroniseerd DATETIME NOT NULL, + CONSTRAINT fk_phuidig_school FOREIGN KEY (School_ID) + REFERENCES Scholen (School_ID) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS PIVOT_instellingen ( + Sleutel VARCHAR(50) NOT NULL PRIMARY KEY, + Waarde VARCHAR(255) NULL, + Bijgewerkt DATETIME NULL, + Door VARCHAR(100) NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS IP_blokkades ( + ID INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + Reeks VARCHAR(64) NOT NULL, + Soort VARCHAR(10) NOT NULL DEFAULT 'blokkeren', + Opmerking VARCHAR(255) NULL, + Scholen VARCHAR(1000) NULL, + Aangemaakt DATETIME NOT NULL, + Door VARCHAR(100) NULL, + Gebruiker_ID INT NULL, + Aantal_geweigerd INT NOT NULL DEFAULT 0, + Laatst_geweigerd DATETIME NULL, + UNIQUE KEY uq_ip_reeks (Reeks, Soort) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS GoPro_verzameltabel ( + ID INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + School_code VARCHAR(32) NOT NULL, + School_naam VARCHAR(255) NULL, + Person_ID VARCHAR(64) NOT NULL, + Voornaam VARCHAR(100) NULL, + Achternaam VARCHAR(100) NULL, + Email VARCHAR(255) NULL, + UPN VARCHAR(255) NULL, + Instellingsnummer VARCHAR(20) NULL, + Functie VARCHAR(100) NULL, + Rol VARCHAR(100) NULL, + Startdatum DATE NULL, + Einddatum DATE NULL, + Telefoon VARCHAR(50) NULL, + Toegevoegd DATETIME NOT NULL, + Door VARCHAR(255) NULL, + Soort VARCHAR(10) NOT NULL DEFAULT 'groen', -- groen = in de CSV, genegeerd = rode lijst, verzonden = al naar GO! Pro + Verzonden DATETIME NULL, + Verzonden_door VARCHAR(255) NULL, + UNIQUE KEY uq_gopro_verz (School_code, Person_ID), + KEY idx_gopro_verz_email (Email) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS Koppeling_tests ( + School_ID INT NOT NULL, + Onderdeel VARCHAR(50) NOT NULL, + Ok TINYINT NULL, + Detail VARCHAR(500) NULL, + Getest_op DATETIME NOT NULL, + PRIMARY KEY (School_ID, Onderdeel), + CONSTRAINT fk_ktest_school FOREIGN KEY (School_ID) REFERENCES Scholen (School_ID) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS Logs ( + Log_ID BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + Timestamp DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + Bezochte_url VARCHAR(2048) NOT NULL, + Gebruiker_ID INT NULL, + School_ID INT NULL, + Actie VARCHAR(255) NULL, + IP VARCHAR(45) NULL, + KEY idx_logs_tijd (Timestamp), + KEY idx_logs_gebruiker (Gebruiker_ID), + CONSTRAINT fk_logs_gebruiker FOREIGN KEY (Gebruiker_ID) + REFERENCES Gebruikers (Gebruiker_ID) ON DELETE SET NULL, + CONSTRAINT fk_logs_school FOREIGN KEY (School_ID) + REFERENCES Scholen (School_ID) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS Status ( + Bugstatus_ID TINYINT NOT NULL PRIMARY KEY, + Omschrijving VARCHAR(50) NOT NULL, + UNIQUE KEY uq_status_omschrijving (Omschrijving) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS Bugs ( + Bug_ID INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + Timestamp DATETIME NOT NULL, + Omschrijving TEXT NOT NULL, + Foutcode VARCHAR(100) NULL, + Bugstatus_ID TINYINT NOT NULL DEFAULT 1, + Gebruiker_ID INT NULL, + Gebruikersnaam VARCHAR(255) NULL, + School_ID INT NULL, + Gemeld_op DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_bugs_status (Bugstatus_ID), + CONSTRAINT fk_bugs_status FOREIGN KEY (Bugstatus_ID) REFERENCES Status (Bugstatus_ID), + CONSTRAINT fk_bugs_gebruiker FOREIGN KEY (Gebruiker_ID) + REFERENCES Gebruikers (Gebruiker_ID) ON DELETE SET NULL, + CONSTRAINT fk_bugs_school FOREIGN KEY (School_ID) + REFERENCES Scholen (School_ID) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT IGNORE INTO Veiligheidsgroepen (Veiligheidsgroep, Naam, Omschrijving) VALUES + (1, 'Administrator', 'Alle rechten in alle scholen; beheert gebruikers, rechten en scholen'), + (2, 'Gebruiker', 'Enkel de rechten die per school zijn aangevinkt'); + +INSERT IGNORE INTO Onderdelen (Code, Naam, Categorie, Volgorde) VALUES + ('eigendom_bekijken', 'Eigendom bekijken', 'Chromebooks', 10), + ('toestellen_uitdelen', 'Toestellen uitdelen en inleveren', 'Chromebooks', 20), + ('toestellen_beheren', 'Toestelleninventaris beheren', 'Chromebooks', 30), + ('toestel_blokkeren', 'Chromebook blokkeren / deblokkeren', 'Chromebooks', 40), + ('herstellingen', 'Herstellingen', 'Chromebooks', 50), + ('signpost', 'Signpost-toestellen', 'Chromebooks', 60), + ('leerlingen_bekijken', 'Leerlingen bekijken en bewerken', 'Leerlingen', 70), + ('leerlingen_synchroniseren', 'Leerlingen toevoegen en synchroniseren', 'Leerlingen', 80), + ('google_wachtwoord', 'Google-wachtwoord resetten', 'Google', 90), + ('google_accounts', 'Google-accounts aanmaken', 'Google', 100), + ('tweestaps_codes', '2FA: back-upcodes tonen / aanmaken', 'Google', 110), + ('tweestaps_uitzetten', '2FA tijdelijk uitzetten (uitzondering)', 'Google', 120), + ('google_licenties', 'Google-licenties', 'Google', 130), + ('gmail_delegatie', 'Mailbox-delegatie', 'Google', 140), + ('smartschool', 'Smartschool-wachtwoorden & info', 'Smartschool', 150), + ('smartschool_activiteit', 'Activiteit co-accounts & berichten', 'Smartschool', 160), + ('afwezigheden', 'Afwezigheden leerlingen (B-codes)', 'Smartschool', 170), + ('skore', 'Skore-synchronisatie (starten en plannen)', 'Smartschool', 180), + ('personeel_directie', 'Personeel: aanvragen goedkeuren (directie)', 'Personeel', 190), + ('personeel_secretariaat', 'Personeel: dagoverzicht (secretariaat)', 'Personeel', 200), + ('qr_codes', 'QR-code generator', 'Tools', 210), + ('co2', 'CO2-sensoren', 'IoT', 220), + ('leerlingenaantal', 'Leerlingenaantallen (alle scholen)', 'Scholengroep', 230), + ('bugs_bekijken', 'Bugs bekijken', 'Beheer', 240), + ('bugs_beheren', 'Status van bugs wijzigen', 'Beheer', 250), + ('nieuw_schooljaar', 'Nieuw schooljaar (accounts & toestellen)', 'Beheer', 260), + ('gebruikersbeheer', 'Gebruikers & rechten van deze school', 'PIVOT', 270), + ('koppelingen', 'Koppelingen van deze school', 'PIVOT', 280); + +INSERT IGNORE INTO Status (Bugstatus_ID, Omschrijving) VALUES + (1, 'Nieuw'), + (2, 'In behandeling'), + (3, 'Fixed'); diff --git a/sql/installatie_pivot_26-27.sql b/sql/installatie_pivot_26-27.sql new file mode 100644 index 0000000..3aebeea --- /dev/null +++ b/sql/installatie_pivot_26-27.sql @@ -0,0 +1,805 @@ +-- ===================================================================== +-- PIVOT 1.1.0 – VOLLEDIGE INSTALLATIE (schooljaar 2026-27) +-- +-- Maakt in één keer aan: +-- 1. de centrale database `pivot` (Scholen, Gebruikers, rechten, Logs, Bugs) +-- 2. de school 'Scholengroep Antwerpen' (code: sg-antwerpen) +-- 3. de gebruiker Marijn (Administrator) wachtwoord: Binnenin10 +-- 4. de database van die school `pivot_sg_antwerpen` (Toestellen, Leerlingen, ...) +-- +-- Uitvoeren met: mysql -u -p < installatie_pivot_26-27.sql +-- (of in MySQL Workbench / phpMyAdmin: dit bestand importeren) +-- +-- ANDERE NAAM VOOR DE CENTRALE DATABASE (bv. pivot_test)? +-- Vervang `pivot` op BEIDE regels onder "1. Centrale database" +-- (CREATE DATABASE én USE) en zet dezelfde naam in config.ini +-- › [database] › centrale_database. +-- Het script stopt met een duidelijke melding als de gekozen database nog +-- oude tabellen bevat (bv. 'scholen' van de vorige versie). +-- ===================================================================== + +SET NAMES utf8mb4; + +-- --------------------------------------------------------------------- +-- 1. Centrale database +-- --------------------------------------------------------------------- +CREATE DATABASE IF NOT EXISTS `pivot` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +USE `pivot`; + +-- Veiligheidscontrole: stop als deze database nog OUDE tabellen met dezelfde naam bevat +-- (bv. 'scholen' van PIVOT 1.1.0). MySQL op Windows maakt geen onderscheid tussen +-- hoofdletters en kleine letters, dus 'Scholen' en 'scholen' zijn dezelfde tabel. +DROP PROCEDURE IF EXISTS pivot_controle_oude_tabellen; +DELIMITER // +CREATE PROCEDURE pivot_controle_oude_tabellen() +BEGIN + DECLARE oud TEXT; + SELECT GROUP_CONCAT(n.tabel SEPARATOR ', ') INTO oud + FROM (SELECT 'scholen' AS tabel, 'school_id' AS kolom UNION ALL + SELECT 'gebruikers', 'gebruiker_id' UNION ALL SELECT 'logs', 'log_id' UNION ALL + SELECT 'status', 'bugstatus_id' UNION ALL SELECT 'bugs', 'bug_id') n + WHERE EXISTS (SELECT 1 FROM information_schema.TABLES t + WHERE t.TABLE_SCHEMA = DATABASE() AND LOWER(t.TABLE_NAME) = n.tabel) + AND NOT EXISTS (SELECT 1 FROM information_schema.COLUMNS c + WHERE c.TABLE_SCHEMA = DATABASE() AND LOWER(c.TABLE_NAME) = n.tabel + AND LOWER(c.COLUMN_NAME) = n.kolom); + IF oud IS NOT NULL THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = + 'STOP: deze database bevat nog oude PIVOT-tabellen (scholen/gebruikers/logs/status/bugs). Kies een nieuwe, lege database.'; + END IF; +END // +DELIMITER ; +CALL pivot_controle_oude_tabellen(); +DROP PROCEDURE pivot_controle_oude_tabellen; + +CREATE TABLE IF NOT EXISTS Scholen ( + School_ID INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + Code VARCHAR(32) NOT NULL, + School_naam VARCHAR(255) NOT NULL, + Smartschool_platform_url VARCHAR(255) NULL, + Adres VARCHAR(255) NULL, + Instellingsnummer VARCHAR(20) NULL, + Informat_instellingsnummer VARCHAR(500) NULL, + Onderwijsniveau VARCHAR(20) NULL, + Domein VARCHAR(500) NOT NULL DEFAULT '', + Hostnamen VARCHAR(500) NOT NULL DEFAULT '', + Actief TINYINT NOT NULL DEFAULT 1, + Db_naam VARCHAR(128) NOT NULL, + Instellingen MEDIUMTEXT NULL, + Google_sleutel MEDIUMTEXT NULL, + Leerlingenaantal INT NULL, + Leerlingenaantal_sync DATETIME NULL, + Leerlingenaantal_detail TEXT NULL, + Bijgewerkt DATETIME(6) NULL, + Bijgewerkt_door VARCHAR(255) NULL, + UNIQUE KEY uq_scholen_code (Code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS Veiligheidsgroepen ( + Veiligheidsgroep TINYINT NOT NULL PRIMARY KEY, + Naam VARCHAR(50) NOT NULL, + Omschrijving VARCHAR(255) NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS Gebruikers ( + Gebruiker_ID INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + Gebruikersnaam VARCHAR(100) NOT NULL, + Veiligheidsgroep TINYINT NOT NULL DEFAULT 2, + Passwoordhash VARCHAR(255) NULL, + Emailadres VARCHAR(255) NULL, + Last_login DATETIME NULL, + IP_laatste_login VARCHAR(45) NULL, + Laatste_school VARCHAR(32) NULL, + Actief TINYINT NOT NULL DEFAULT 1, + UNIQUE KEY uq_gebruikersnaam (Gebruikersnaam), + UNIQUE KEY uq_emailadres (Emailadres), + CONSTRAINT fk_gebruikers_groep FOREIGN KEY (Veiligheidsgroep) + REFERENCES Veiligheidsgroepen (Veiligheidsgroep) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS Onderdelen ( + Onderdeel_ID INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + Code VARCHAR(50) NOT NULL, + Naam VARCHAR(255) NOT NULL, + Categorie VARCHAR(50) NOT NULL, + Volgorde INT NOT NULL DEFAULT 0, + UNIQUE KEY uq_onderdeel_code (Code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS Gebruiker_rechten ( + Gebruiker_ID INT NOT NULL, + School_ID INT NOT NULL, + Onderdeel_ID INT NOT NULL, + Toegekend_op DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + Toegekend_door VARCHAR(100) NULL, + PRIMARY KEY (Gebruiker_ID, School_ID, Onderdeel_ID), + KEY idx_rechten_school (School_ID), + KEY idx_rechten_onderdeel (Onderdeel_ID), + CONSTRAINT fk_rechten_gebruiker FOREIGN KEY (Gebruiker_ID) + REFERENCES Gebruikers (Gebruiker_ID) ON DELETE CASCADE, + CONSTRAINT fk_rechten_school FOREIGN KEY (School_ID) + REFERENCES Scholen (School_ID) ON DELETE CASCADE, + CONSTRAINT fk_rechten_onderdeel FOREIGN KEY (Onderdeel_ID) + REFERENCES Onderdelen (Onderdeel_ID) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS Handleidingen ( + Handleiding_ID INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + Slug VARCHAR(100) NOT NULL, + Titel VARCHAR(200) NOT NULL, + Categorie VARCHAR(100) NULL, + Samenvatting VARCHAR(300) NULL, + Inhoud MEDIUMTEXT NULL, + Zichtbaar VARCHAR(10) NOT NULL DEFAULT 'iedereen', + Volgorde INT NOT NULL DEFAULT 0, + Gewijzigd_op DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + Gewijzigd_door VARCHAR(100) NULL, + UNIQUE KEY uq_handleiding_slug (Slug) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS Handleidingen_versies ( + Versie_ID INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + Handleiding_ID INT NOT NULL, + Titel VARCHAR(200) NOT NULL, + Inhoud MEDIUMTEXT NULL, + Opgeslagen_op DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + Door VARCHAR(100) NULL, + KEY idx_versie_handleiding (Handleiding_ID, Opgeslagen_op), + CONSTRAINT fk_versie_handleiding FOREIGN KEY (Handleiding_ID) + REFERENCES Handleidingen (Handleiding_ID) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS Leerlingenaantal_tellingen ( + Telling_ID INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + School_ID INT NOT NULL, + Peildatum DATE NOT NULL, + Schooljaar VARCHAR(10) NOT NULL, + Aantal INT NOT NULL, + Detail TEXT NULL, + Opgevraagd_op DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + Door VARCHAR(100) NULL, + UNIQUE KEY uq_telling (School_ID, Peildatum), + KEY idx_telling_datum (Peildatum), + CONSTRAINT fk_telling_school FOREIGN KEY (School_ID) + REFERENCES Scholen (School_ID) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS Personeelsaantal_tellingen ( + Telling_ID INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + School_ID INT NOT NULL, + Peildatum DATE NOT NULL, + Schooljaar VARCHAR(10) NOT NULL, + Aantal INT NOT NULL, + Detail TEXT NULL, + Opgevraagd_op DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + Door VARCHAR(100) NULL, + UNIQUE KEY uq_ptelling (School_ID, Peildatum), + KEY idx_ptelling_datum (Peildatum), + CONSTRAINT fk_ptelling_school FOREIGN KEY (School_ID) + REFERENCES Scholen (School_ID) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS Personeelsaantal_huidig ( + School_ID INT NOT NULL PRIMARY KEY, + Aantal INT NOT NULL, + Detail TEXT NULL, + Gesynchroniseerd DATETIME NOT NULL, + CONSTRAINT fk_phuidig_school FOREIGN KEY (School_ID) + REFERENCES Scholen (School_ID) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS PIVOT_instellingen ( + Sleutel VARCHAR(50) NOT NULL PRIMARY KEY, + Waarde VARCHAR(255) NULL, + Bijgewerkt DATETIME NULL, + Door VARCHAR(100) NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS IP_blokkades ( + ID INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + Reeks VARCHAR(64) NOT NULL, + Soort VARCHAR(10) NOT NULL DEFAULT 'blokkeren', + Opmerking VARCHAR(255) NULL, + Scholen VARCHAR(1000) NULL, + Aangemaakt DATETIME NOT NULL, + Door VARCHAR(100) NULL, + Gebruiker_ID INT NULL, + Aantal_geweigerd INT NOT NULL DEFAULT 0, + Laatst_geweigerd DATETIME NULL, + UNIQUE KEY uq_ip_reeks (Reeks, Soort) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS GoPro_verzameltabel ( + ID INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + School_code VARCHAR(32) NOT NULL, + School_naam VARCHAR(255) NULL, + Person_ID VARCHAR(64) NOT NULL, + Voornaam VARCHAR(100) NULL, + Achternaam VARCHAR(100) NULL, + Email VARCHAR(255) NULL, + UPN VARCHAR(255) NULL, + Instellingsnummer VARCHAR(20) NULL, + Functie VARCHAR(100) NULL, + Rol VARCHAR(100) NULL, + Startdatum DATE NULL, + Einddatum DATE NULL, + Telefoon VARCHAR(50) NULL, + Toegevoegd DATETIME NOT NULL, + Door VARCHAR(255) NULL, + Soort VARCHAR(10) NOT NULL DEFAULT 'groen', -- groen = in de CSV, genegeerd = rode lijst, verzonden = al naar GO! Pro + Verzonden DATETIME NULL, + Verzonden_door VARCHAR(255) NULL, + UNIQUE KEY uq_gopro_verz (School_code, Person_ID), + KEY idx_gopro_verz_email (Email) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS Koppeling_tests ( + School_ID INT NOT NULL, + Onderdeel VARCHAR(50) NOT NULL, + Ok TINYINT NULL, + Detail VARCHAR(500) NULL, + Getest_op DATETIME NOT NULL, + PRIMARY KEY (School_ID, Onderdeel), + CONSTRAINT fk_ktest_school FOREIGN KEY (School_ID) REFERENCES Scholen (School_ID) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS Logs ( + Log_ID BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + Timestamp DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + Bezochte_url VARCHAR(2048) NOT NULL, + Gebruiker_ID INT NULL, + School_ID INT NULL, + Actie VARCHAR(255) NULL, + IP VARCHAR(45) NULL, + KEY idx_logs_tijd (Timestamp), + KEY idx_logs_gebruiker (Gebruiker_ID), + CONSTRAINT fk_logs_gebruiker FOREIGN KEY (Gebruiker_ID) + REFERENCES Gebruikers (Gebruiker_ID) ON DELETE SET NULL, + CONSTRAINT fk_logs_school FOREIGN KEY (School_ID) + REFERENCES Scholen (School_ID) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS Status ( + Bugstatus_ID TINYINT NOT NULL PRIMARY KEY, + Omschrijving VARCHAR(50) NOT NULL, + UNIQUE KEY uq_status_omschrijving (Omschrijving) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS Bugs ( + Bug_ID INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + Timestamp DATETIME NOT NULL, + Omschrijving TEXT NOT NULL, + Foutcode VARCHAR(100) NULL, + Bugstatus_ID TINYINT NOT NULL DEFAULT 1, + Gebruiker_ID INT NULL, + Gebruikersnaam VARCHAR(255) NULL, + School_ID INT NULL, + Gemeld_op DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_bugs_status (Bugstatus_ID), + CONSTRAINT fk_bugs_status FOREIGN KEY (Bugstatus_ID) REFERENCES Status (Bugstatus_ID), + CONSTRAINT fk_bugs_gebruiker FOREIGN KEY (Gebruiker_ID) + REFERENCES Gebruikers (Gebruiker_ID) ON DELETE SET NULL, + CONSTRAINT fk_bugs_school FOREIGN KEY (School_ID) + REFERENCES Scholen (School_ID) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT IGNORE INTO Veiligheidsgroepen (Veiligheidsgroep, Naam, Omschrijving) VALUES + (1, 'Administrator', 'Alle rechten in alle scholen; beheert gebruikers, rechten en scholen'), + (2, 'Gebruiker', 'Enkel de rechten die per school zijn aangevinkt'); + +INSERT IGNORE INTO Onderdelen (Code, Naam, Categorie, Volgorde) VALUES + ('eigendom_bekijken', 'Eigendom bekijken', 'Chromebooks', 10), + ('toestellen_uitdelen', 'Toestellen uitdelen en inleveren', 'Chromebooks', 20), + ('toestellen_beheren', 'Toestelleninventaris beheren', 'Chromebooks', 30), + ('toestel_blokkeren', 'Chromebook blokkeren / deblokkeren', 'Chromebooks', 40), + ('herstellingen', 'Herstellingen', 'Chromebooks', 50), + ('signpost', 'Signpost-toestellen', 'Chromebooks', 60), + ('leerlingen_bekijken', 'Leerlingen bekijken en bewerken', 'Leerlingen', 70), + ('leerlingen_synchroniseren', 'Leerlingen toevoegen en synchroniseren', 'Leerlingen', 80), + ('google_wachtwoord', 'Google-wachtwoord resetten', 'Google', 90), + ('google_accounts', 'Google-accounts aanmaken', 'Google', 100), + ('tweestaps_codes', '2FA: back-upcodes tonen / aanmaken', 'Google', 110), + ('tweestaps_uitzetten', '2FA tijdelijk uitzetten (uitzondering)', 'Google', 120), + ('google_licenties', 'Google-licenties', 'Google', 130), + ('gmail_delegatie', 'Mailbox-delegatie', 'Google', 140), + ('smartschool', 'Smartschool-wachtwoorden & info', 'Smartschool', 150), + ('smartschool_activiteit', 'Activiteit co-accounts & berichten', 'Smartschool', 160), + ('afwezigheden', 'Afwezigheden leerlingen (B-codes)', 'Smartschool', 170), + ('skore', 'Skore-synchronisatie (starten en plannen)', 'Smartschool', 180), + ('personeel_directie', 'Personeel: aanvragen goedkeuren (directie)', 'Personeel', 190), + ('personeel_secretariaat', 'Personeel: dagoverzicht (secretariaat)', 'Personeel', 200), + ('qr_codes', 'QR-code generator', 'Tools', 210), + ('co2', 'CO2-sensoren', 'IoT', 220), + ('leerlingenaantal', 'Leerlingenaantallen (alle scholen)', 'Scholengroep', 230), + ('bugs_bekijken', 'Bugs bekijken', 'Beheer', 240), + ('bugs_beheren', 'Status van bugs wijzigen', 'Beheer', 250), + ('nieuw_schooljaar', 'Nieuw schooljaar (accounts & toestellen)', 'Beheer', 260), + ('gebruikersbeheer', 'Gebruikers & rechten van deze school', 'PIVOT', 270), + ('koppelingen', 'Koppelingen van deze school', 'PIVOT', 280); + +INSERT IGNORE INTO Status (Bugstatus_ID, Omschrijving) VALUES + (1, 'Nieuw'), + (2, 'In behandeling'), + (3, 'Fixed'); + +-- --------------------------------------------------------------------- +-- 2. School: Scholengroep Antwerpen +-- Domein, Smartschool, Informat, Google, ... vul je daarna in via +-- PIVOT › 🏫 Alle scholen beheren › Bewerken. +-- --------------------------------------------------------------------- +INSERT INTO Scholen (Code, School_naam, Domein, Hostnamen, Actief, Db_naam, Instellingen, Bijgewerkt, Bijgewerkt_door) +VALUES ('sg-antwerpen', 'Scholengroep Antwerpen', '', '', 1, 'pivot_sg_antwerpen', '{}', NOW(6), 'installatie') +ON DUPLICATE KEY UPDATE School_naam = 'Scholengroep Antwerpen'; + +-- --------------------------------------------------------------------- +-- 3. Gebruiker Marijn – Administrator (alle rechten in alle scholen) +-- Wachtwoord 'Binnenin10' (enkel als hash bewaard; wijzigen kan in PIVOT +-- › Gebruikers & rechten). +-- --------------------------------------------------------------------- +INSERT INTO Gebruikers (Gebruikersnaam, Veiligheidsgroep, Passwoordhash, Emailadres, Actief) +VALUES ('Marijn', 1, 'scrypt:32768:8:1$FuAWb0nan2iFSInz$18994a1f3faf96e071b0cf1bded9a32daaf481c3cd92583e08d87c6e88b018be41d4aca7369108ae01ddb2f6523ab00817f60a12b41b2d255f91ad64857e4856', NULL, 1) +ON DUPLICATE KEY UPDATE Veiligheidsgroep = 1, Passwoordhash = 'scrypt:32768:8:1$FuAWb0nan2iFSInz$18994a1f3faf96e071b0cf1bded9a32daaf481c3cd92583e08d87c6e88b018be41d4aca7369108ae01ddb2f6523ab00817f60a12b41b2d255f91ad64857e4856', Actief = 1; + +-- Een administrator heeft automatisch alle rechten in elke school; deze rijen +-- maken dat ook zichtbaar in het overzicht (en blijven gelden als hij ooit +-- gewone gebruiker wordt). +INSERT IGNORE INTO Gebruiker_rechten (Gebruiker_ID, School_ID, Onderdeel_ID, Toegekend_door) +SELECT g.Gebruiker_ID, s.School_ID, o.Onderdeel_ID, 'installatie' +FROM Gebruikers g CROSS JOIN Scholen s CROSS JOIN Onderdelen o +WHERE g.Gebruikersnaam = 'Marijn' AND s.Code = 'sg-antwerpen'; + +-- --------------------------------------------------------------------- +-- 4. Database van Scholengroep Antwerpen +-- --------------------------------------------------------------------- +CREATE DATABASE IF NOT EXISTS `pivot_sg_antwerpen` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +USE `pivot_sg_antwerpen`; + +-- ===================================================================== +-- PIVOT 1.1.0 – database van ÉÉN school (structuur schooljaar 2026-27) +-- +-- Elke school in PIVOT heeft een eigen database met deze tabellen. +-- Gebruik: USE ; SOURCE school.sql; +-- of: python beheer.py nieuwe-schooldatabase +-- +-- Tabellen uit de PDF: Campus, Lokalen, Wifi, Toestellen, Signpost_DB, +-- Opmerkingen, Signpost_herstellingen_DB, Leerlingen, Eigendom, +-- Co_accounts, CO2_Metingen. +-- Aanvullingen die PIVOT nodig heeft staan gemarkeerd met -- [+] +-- (Scholen, Gebruikers, Logs, Bugs en Status staan in de centrale database.) +-- ===================================================================== + +-- --------------------------------------------------------------------- +-- Schooljaren [+] (Leerlingen.Schooljaar_ID = beginjaar, bv. 2026 → '2026-27') +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS Schooljaar ( + Schooljaar_ID SMALLINT NOT NULL PRIMARY KEY, + Schooljaar VARCHAR(9) NOT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT IGNORE INTO Schooljaar (Schooljaar_ID, Schooljaar) VALUES + (2022, '2022-23'), (2023, '2023-24'), (2024, '2024-25'), (2025, '2025-26'), + (2026, '2026-27'), (2027, '2027-28'), (2028, '2028-29'), (2029, '2029-30'), + (2030, '2030-31'), (2031, '2031-32'), (2032, '2032-33'), (2033, '2033-34'), + (2034, '2034-35'), (2035, '2035-36'); + +-- --------------------------------------------------------------------- +-- Campus → Lokalen +-- School_ID verwijst naar Scholen.School_ID in de CENTRALE database. +-- (Geen harde foreign key: een schooldatabase kan op een andere server staan.) +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS Campus ( + Campus_ID INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + School_ID INT NOT NULL, + Campus_naam VARCHAR(255) NOT NULL, + Adres VARCHAR(255) NULL, + KEY idx_campus_school (School_ID) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS Lokalen ( + Lokaal_ID INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + Naam VARCHAR(100) NOT NULL, + Campus_ID INT NULL, -- leeg = nog niet aan een campus gekoppeld (bv. nieuw CO2-lokaal) + Verdieping VARCHAR(20) NULL, + KEY idx_lokalen_naam (Naam), + CONSTRAINT fk_lokalen_campus FOREIGN KEY (Campus_ID) + REFERENCES Campus (Campus_ID) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS Wifi ( + Wifi_ID INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + Campus_ID INT NULL, -- [+] op welke campus dit netwerk hoort + Naam_Netwerk VARCHAR(100) NOT NULL, + Wifi_ww VARCHAR(255) NULL, + CONSTRAINT fk_wifi_campus FOREIGN KEY (Campus_ID) + REFERENCES Campus (Campus_ID) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- --------------------------------------------------------------------- +-- Toestellen +-- Schooleigen_ID = het eigen toestelnummer van de school (vroeger KAMXM_nummer). +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS Toestellen ( + Toestel_ID INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + Schooleigen_ID VARCHAR(50) NULL, + Serienummer VARCHAR(100) NULL, -- [+] nodig om het toestel in Google te (de)blokkeren + Type VARCHAR(100) NULL, -- [+] model + Status VARCHAR(20) NOT NULL DEFAULT 'Beschikbaar', -- [+] Beschikbaar / Uitgeleend / Defect + Geblokkeerd TINYINT(1) NOT NULL DEFAULT 0, -- [+] 1 = geblokkeerd in Google Admin + Afschrijfdatum DATE NULL, + Lokaal_ID INT NULL, + UNIQUE KEY uq_toestellen_schooleigen (Schooleigen_ID), + KEY idx_toestellen_serienummer (Serienummer), + KEY idx_toestellen_status (Status), + CONSTRAINT fk_toestellen_lokaal FOREIGN KEY (Lokaal_ID) + REFERENCES Lokalen (Lokaal_ID) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Gegevens van Signpost (1 rij per toestel dat via Signpost aangekocht is) +CREATE TABLE IF NOT EXISTS Signpost_DB ( + Toestel_ID INT NOT NULL PRIMARY KEY, + Signpostnummer VARCHAR(50) NULL, + Aankoopbedrag DECIMAL(10,2) NULL, + Aankoopdatum DATE NULL, + Garantie DATE NULL, + UNIQUE KEY uq_signpost_nummer (Signpostnummer), + CONSTRAINT fk_signpost_toestel FOREIGN KEY (Toestel_ID) + REFERENCES Toestellen (Toestel_ID) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS Opmerkingen ( + Opmerking_ID INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + Toestel_ID INT NOT NULL, + Opmerking TEXT NOT NULL, + Timestamp DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + Gebruiker VARCHAR(255) NULL, -- [+] wie de opmerking schreef + KEY idx_opmerkingen_toestel (Toestel_ID, Timestamp), + CONSTRAINT fk_opmerkingen_toestel FOREIGN KEY (Toestel_ID) + REFERENCES Toestellen (Toestel_ID) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- --------------------------------------------------------------------- +-- Leerlingen +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS Leerlingen ( + Leerling_ID INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + Internnummer VARCHAR(32) NOT NULL, + Gebruikersnaam VARCHAR(100) NULL, + Naam VARCHAR(100) NULL, + Voornaam VARCHAR(100) NULL, + SS_nummer VARCHAR(255) NULL, + Google_adres VARCHAR(255) NULL, + Schooljaar_ID SMALLINT NULL, -- [+] zie tabel Schooljaar + Vertrokken_op DATE NULL, -- niet meer actief in Informat sinds (NULL = actief) + UNIQUE KEY uq_leerlingen_internnummer (Internnummer), + KEY idx_leerlingen_gebruikersnaam (Gebruikersnaam) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS Co_accounts ( + Co_account_ID INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + Internnummer VARCHAR(32) NOT NULL, + Naam VARCHAR(100) NULL, + Voornaam VARCHAR(100) NULL, + Laatste_aanmelding DATETIME NULL, + Emailadres_co_accounts VARCHAR(255) NULL, + KEY idx_coaccounts_intern (Internnummer), + CONSTRAINT fk_coaccounts_leerling FOREIGN KEY (Internnummer) + REFERENCES Leerlingen (Internnummer) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- --------------------------------------------------------------------- +-- Eigendom: welk toestel heeft welke leerling (ook de geschiedenis). +-- Datum_inlevering leeg = de leerling heeft het toestel nu. +-- (Vervangt de vroegere tabel Eigendom_archief.) +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS Eigendom ( + Eigendom_ID INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + Internnummer VARCHAR(32) NOT NULL, + Toestel_ID INT NOT NULL, + Status TINYINT NOT NULL DEFAULT 0, -- 0 = actief, 1 = geblokkeerd in Google + Datum_toekenning DATE NOT NULL, + Datum_inlevering DATE NULL, + KEY idx_eigendom_leerling (Internnummer, Datum_inlevering), + KEY idx_eigendom_toestel (Toestel_ID, Datum_inlevering), + CONSTRAINT fk_eigendom_leerling FOREIGN KEY (Internnummer) + REFERENCES Leerlingen (Internnummer) ON UPDATE CASCADE, + CONSTRAINT fk_eigendom_toestel FOREIGN KEY (Toestel_ID) + REFERENCES Toestellen (Toestel_ID) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- --------------------------------------------------------------------- +-- Herstellingen (via Signpost of intern) +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS Signpost_herstellingen_DB ( + Incident_ID INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + Toestel_ID INT NOT NULL, + Omschrijving TEXT NULL, + Kostprijs DECIMAL(10,2) NULL, + Status_herstelling VARCHAR(30) NOT NULL DEFAULT 'geregistreerd', + Datum DATE NULL, -- [+] datum van de melding + Leerling_ID INT NULL, -- [+] leerling die het toestel binnenbracht + KEY idx_herstellingen_toestel (Toestel_ID), + CONSTRAINT fk_herstellingen_toestel FOREIGN KEY (Toestel_ID) + REFERENCES Toestellen (Toestel_ID) ON DELETE CASCADE, + CONSTRAINT fk_herstellingen_leerling FOREIGN KEY (Leerling_ID) + REFERENCES Leerlingen (Leerling_ID) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- --------------------------------------------------------------------- +-- CO2-metingen per lokaal +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS CO2_Metingen ( + Meting_ID BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + Lokaal_ID INT NOT NULL, + ppm INT NOT NULL, + Timestamp DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_co2_lokaal_tijd (Lokaal_ID, Timestamp), + KEY idx_co2_tijd (Timestamp), + CONSTRAINT fk_co2_lokaal FOREIGN KEY (Lokaal_ID) + REFERENCES Lokalen (Lokaal_ID) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ===================================================================== +-- [+] Tabellen van PIVOT-modules die niet in de PDF staan +-- (PIVOT maakt ze ook zelf aan bij het eerste gebruik) +-- ===================================================================== +CREATE TABLE IF NOT EXISTS smartschool_afwezigheden ( + Datum DATE NOT NULL, + Internnummer VARCHAR(32) NOT NULL, + Dagdeel CHAR(2) NOT NULL, + Code VARCHAR(8) NOT NULL, + PRIMARY KEY (Datum, Internnummer, Dagdeel), + INDEX idx_intern (Internnummer), + INDEX idx_code (Code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS smartschool_afwezigheden_sync ( + Datum DATE NOT NULL PRIMARY KEY, + Opgehaald DATETIME NOT NULL, + Aantal INT NOT NULL DEFAULT 0 +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS tweestaps_uitzonderingen ( + ID INT AUTO_INCREMENT PRIMARY KEY, + Email VARCHAR(255) NOT NULL, + Naam VARCHAR(255) NULL, + Reden VARCHAR(255) NULL, + Van DATETIME NOT NULL, + Tot DATETIME NOT NULL, + Door VARCHAR(100) NULL, + Actief TINYINT NOT NULL DEFAULT 1, + Beeindigd_op DATETIME NULL, + Beeindigd_door VARCHAR(100) NULL, + INDEX idx_actief (Actief, Tot), + INDEX idx_email (Email) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS tweestaps_log ( + ID INT AUTO_INCREMENT PRIMARY KEY, + Tijdstip DATETIME NOT NULL, + Door VARCHAR(100) NULL, + Actie VARCHAR(40) NOT NULL, + Email VARCHAR(255) NOT NULL, + Details VARCHAR(255) NULL, + INDEX idx_tijdstip (Tijdstip) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS gmail_delegatie_log ( + ID INT AUTO_INCREMENT PRIMARY KEY, + Tijdstip DATETIME NOT NULL, + Gebruiker VARCHAR(100) NULL, + Actie VARCHAR(20) NOT NULL, + Mailbox VARCHAR(255) NOT NULL, + Gedelegeerde VARCHAR(255) NOT NULL, + INDEX idx_mailbox (Mailbox), + INDEX idx_tijdstip (Tijdstip) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS gmail_delegaties ( + Mailbox VARCHAR(255) NOT NULL, + Mailbox_naam VARCHAR(255) NULL, + Gedelegeerde VARCHAR(255) NOT NULL, + Gedelegeerde_naam VARCHAR(255) NULL, + Status VARCHAR(20) NULL, + Opgehaald DATETIME NOT NULL, + PRIMARY KEY (Mailbox, Gedelegeerde), + KEY idx_gmail_deleg_gedelegeerde (Gedelegeerde) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS gmail_delegaties_scan ( + ID TINYINT NOT NULL PRIMARY KEY, + Opgehaald DATETIME NULL, + Mailboxen INT NULL, + Fouten INT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS internaat_aanwezigheid ( + Datum DATE NOT NULL, + Persoon_ID VARCHAR(64) NOT NULL, -- pPersoon/persoonId uit de Internaat-API van Informat + Moment VARCHAR(10) NOT NULL, -- ochtend | avond + Status VARCHAR(10) NOT NULL, -- aanwezig | afwezig (geen rij = nog niet gecontroleerd) + Naam VARCHAR(200) NULL, + Bijgewerkt DATETIME NOT NULL, + Door VARCHAR(255) NULL, + PRIMARY KEY (Datum, Persoon_ID, Moment), + KEY idx_aanw_persoon (Persoon_ID) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS gmail_label_log ( + ID INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + Gestart DATETIME NOT NULL, + Klaar DATETIME NULL, + Door VARCHAR(255) NULL, + Bron VARCHAR(255) NOT NULL, + Label VARCHAR(255) NOT NULL, + Doel VARCHAR(255) NOT NULL, + Modus VARCHAR(12) NOT NULL, -- kopieren | verplaatsen + Totaal INT NULL, + Gelukt INT NULL, + Overgeslagen INT NULL, + Fouten INT NULL, + Melding VARCHAR(500) NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS gmail_label_items ( + Bron VARCHAR(255) NOT NULL, + Bron_ID VARCHAR(64) NOT NULL, -- Gmail-id in de bronmailbox + Doel VARCHAR(255) NOT NULL, + Doel_ID VARCHAR(64) NULL, -- Gmail-id van de kopie in de doelmailbox + Gekopieerd DATETIME NOT NULL, + PRIMARY KEY (Bron, Bron_ID, Doel) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Gmail-handtekeningen: sjabloon, logo en gekozen OU's (één rij) en wanneer de handtekening per gebruiker gezet werd +CREATE TABLE IF NOT EXISTS gmail_handtekening ( + ID TINYINT NOT NULL PRIMARY KEY, + Sjabloon MEDIUMTEXT NULL, + OUs TEXT NULL, + Met_sub_ous TINYINT NOT NULL DEFAULT 1, + Logo MEDIUMBLOB NULL, + Logo_type VARCHAR(40) NULL, + Logo_sleutel VARCHAR(40) NULL, + Logo_url VARCHAR(500) NULL, + Logo_breedte INT NOT NULL DEFAULT 120, + Publiek_adres VARCHAR(255) NULL, + Nachtelijk TINYINT NOT NULL DEFAULT 0, + Laatste_nacht DATE NULL, + Nacht_resultaat VARCHAR(500) NULL, + Bijgewerkt DATETIME NULL, + Door VARCHAR(255) NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS gmail_handtekening_log ( + Email VARCHAR(255) NOT NULL PRIMARY KEY, + Toegepast DATETIME NOT NULL, + Ok TINYINT NOT NULL, + Melding VARCHAR(500) NULL, + Hash CHAR(40) NULL, + Door VARCHAR(255) NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Gmail › Contacten: contactlijsten die PIVOT in de mailboxen van de gekozen OU's zet +CREATE TABLE IF NOT EXISTS contact_lijsten ( + ID INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + Naam VARCHAR(100) NOT NULL, -- label in Google Contacten + OUs TEXT NULL, -- JSON-lijst van OU-paden + Met_sub_ous TINYINT NOT NULL DEFAULT 1, + Bijgewerkt DATETIME NULL, + Door VARCHAR(255) NULL, + Laatste_sync DATETIME NULL, + Resultaat VARCHAR(500) NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS contact_lijst_items ( + ID INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + Lijst_ID INT NOT NULL, + Voornaam VARCHAR(100) NULL, + Achternaam VARCHAR(100) NULL, + Organisatie VARCHAR(150) NULL, + Functie VARCHAR(150) NULL, + Email VARCHAR(255) NULL, + Telefoon VARCHAR(60) NULL, + Notitie VARCHAR(500) NULL, + KEY idx_cli_lijst (Lijst_ID) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + + + +CREATE TABLE IF NOT EXISTS google_licenties ( + SKU_ID VARCHAR(32) NOT NULL PRIMARY KEY, + Naam VARCHAR(128) NOT NULL, + Aangekocht INT NOT NULL DEFAULT 0, + Bijgewerkt DATETIME NULL, + Bijgewerkt_door VARCHAR(100) NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS personeel_aanvragen ( + ID INT AUTO_INCREMENT PRIMARY KEY, + Soort VARCHAR(20) NOT NULL, + Status VARCHAR(20) NOT NULL, + Email VARCHAR(255) NOT NULL, + Naam VARCHAR(255) NULL, + Ingediend_door VARCHAR(255) NULL, + Titel VARCHAR(255) NOT NULL, + Omschrijving TEXT NULL, + Locatie VARCHAR(255) NULL, + Van_datum DATE NOT NULL, + Tot_datum DATE NOT NULL, + Dagdeel VARCHAR(10) NOT NULL DEFAULT 'dag', + Lesuren VARCHAR(50) NULL, + Klassen TEXT NULL, + Begeleiders TEXT NULL, + Kost DECIMAL(10,2) NULL, + Vervanging_ok TINYINT NOT NULL DEFAULT 0, + Vervanging_info VARCHAR(255) NULL, + Aangemaakt DATETIME NOT NULL, + Bijgewerkt DATETIME NOT NULL, + INDEX idx_status (Status), + INDEX idx_datum (Van_datum, Tot_datum), + INDEX idx_email (Email) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS personeel_reacties ( + ID INT AUTO_INCREMENT PRIMARY KEY, + Aanvraag_ID INT NOT NULL, + Tijdstip DATETIME NOT NULL, + Email VARCHAR(255) NULL, + Naam VARCHAR(255) NULL, + Actie VARCHAR(30) NOT NULL, + Tekst TEXT NULL, + INDEX idx_aanvraag (Aanvraag_ID) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Skore-synchronisatie (planning + logboek) +CREATE TABLE IF NOT EXISTS skore_planning ( + ID TINYINT NOT NULL PRIMARY KEY, + Frequentie VARCHAR(20) NOT NULL DEFAULT 'uit', + Uur TINYINT NOT NULL DEFAULT 6, + Minuut TINYINT NOT NULL DEFAULT 0, + Weekdag TINYINT NOT NULL DEFAULT 0, + Dag_van_maand TINYINT NOT NULL DEFAULT 1, + Laatst_gepland DATETIME NULL, + Bijgewerkt DATETIME NULL, + Bijgewerkt_door VARCHAR(255) NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS skore_log ( + ID INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + Gestart DATETIME NOT NULL, + Door VARCHAR(255) NULL, + Manier VARCHAR(20) NOT NULL, + Service_ID VARCHAR(100) NULL, + Status VARCHAR(30) NOT NULL, + Melding TEXT NULL, + Bijgewerkt DATETIME NULL, + INDEX idx_skore_gestart (Gestart) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT IGNORE INTO skore_planning (ID) VALUES (1); + +CREATE TABLE IF NOT EXISTS smartschool_coaccounts ( + Internnummer VARCHAR(32) NOT NULL, + Nr TINYINT NOT NULL, + Gebruikersnaam VARCHAR(100) NULL, + Leerling_naam VARCHAR(100) NULL, + Leerling_voornaam VARCHAR(100) NULL, + Klas VARCHAR(50) NULL, + Voornaam VARCHAR(100) NULL, + Naam VARCHAR(100) NULL, + Type VARCHAR(50) NULL, + Email VARCHAR(255) NULL, + Laatste_login DATETIME NULL, + Opgehaald DATETIME NOT NULL, + PRIMARY KEY (Internnummer, Nr), + KEY idx_coacc_klas (Klas) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + + +CREATE TABLE IF NOT EXISTS gopro_keuzes ( + Person_ID VARCHAR(64) NOT NULL PRIMARY KEY, + Functie VARCHAR(100) NULL, + Rol VARCHAR(100) NULL, + Bijgewerkt DATETIME NOT NULL, + Door VARCHAR(255) NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + + diff --git a/sql/school.sql b/sql/school.sql new file mode 100644 index 0000000..cf4b6d0 --- /dev/null +++ b/sql/school.sql @@ -0,0 +1,448 @@ +-- ===================================================================== +-- PIVOT 1.1.0 – database van ÉÉN school (structuur schooljaar 2026-27) +-- +-- Elke school in PIVOT heeft een eigen database met deze tabellen. +-- Gebruik: USE ; SOURCE school.sql; +-- of: python beheer.py nieuwe-schooldatabase +-- +-- Tabellen uit de PDF: Campus, Lokalen, Wifi, Toestellen, Signpost_DB, +-- Opmerkingen, Signpost_herstellingen_DB, Leerlingen, Eigendom, +-- Co_accounts, CO2_Metingen. +-- Aanvullingen die PIVOT nodig heeft staan gemarkeerd met -- [+] +-- (Scholen, Gebruikers, Logs, Bugs en Status staan in de centrale database.) +-- ===================================================================== + +-- --------------------------------------------------------------------- +-- Schooljaren [+] (Leerlingen.Schooljaar_ID = beginjaar, bv. 2026 → '2026-27') +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS Schooljaar ( + Schooljaar_ID SMALLINT NOT NULL PRIMARY KEY, + Schooljaar VARCHAR(9) NOT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT IGNORE INTO Schooljaar (Schooljaar_ID, Schooljaar) VALUES + (2022, '2022-23'), (2023, '2023-24'), (2024, '2024-25'), (2025, '2025-26'), + (2026, '2026-27'), (2027, '2027-28'), (2028, '2028-29'), (2029, '2029-30'), + (2030, '2030-31'), (2031, '2031-32'), (2032, '2032-33'), (2033, '2033-34'), + (2034, '2034-35'), (2035, '2035-36'); + +-- --------------------------------------------------------------------- +-- Campus → Lokalen +-- School_ID verwijst naar Scholen.School_ID in de CENTRALE database. +-- (Geen harde foreign key: een schooldatabase kan op een andere server staan.) +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS Campus ( + Campus_ID INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + School_ID INT NOT NULL, + Campus_naam VARCHAR(255) NOT NULL, + Adres VARCHAR(255) NULL, + KEY idx_campus_school (School_ID) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS Lokalen ( + Lokaal_ID INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + Naam VARCHAR(100) NOT NULL, + Campus_ID INT NULL, -- leeg = nog niet aan een campus gekoppeld (bv. nieuw CO2-lokaal) + Verdieping VARCHAR(20) NULL, + KEY idx_lokalen_naam (Naam), + CONSTRAINT fk_lokalen_campus FOREIGN KEY (Campus_ID) + REFERENCES Campus (Campus_ID) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS Wifi ( + Wifi_ID INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + Campus_ID INT NULL, -- [+] op welke campus dit netwerk hoort + Naam_Netwerk VARCHAR(100) NOT NULL, + Wifi_ww VARCHAR(255) NULL, + CONSTRAINT fk_wifi_campus FOREIGN KEY (Campus_ID) + REFERENCES Campus (Campus_ID) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- --------------------------------------------------------------------- +-- Toestellen +-- Schooleigen_ID = het eigen toestelnummer van de school (vroeger KAMXM_nummer). +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS Toestellen ( + Toestel_ID INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + Schooleigen_ID VARCHAR(50) NULL, + Serienummer VARCHAR(100) NULL, -- [+] nodig om het toestel in Google te (de)blokkeren + Type VARCHAR(100) NULL, -- [+] model + Status VARCHAR(20) NOT NULL DEFAULT 'Beschikbaar', -- [+] Beschikbaar / Uitgeleend / Defect + Geblokkeerd TINYINT(1) NOT NULL DEFAULT 0, -- [+] 1 = geblokkeerd in Google Admin + Afschrijfdatum DATE NULL, + Lokaal_ID INT NULL, + UNIQUE KEY uq_toestellen_schooleigen (Schooleigen_ID), + KEY idx_toestellen_serienummer (Serienummer), + KEY idx_toestellen_status (Status), + CONSTRAINT fk_toestellen_lokaal FOREIGN KEY (Lokaal_ID) + REFERENCES Lokalen (Lokaal_ID) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Gegevens van Signpost (1 rij per toestel dat via Signpost aangekocht is) +CREATE TABLE IF NOT EXISTS Signpost_DB ( + Toestel_ID INT NOT NULL PRIMARY KEY, + Signpostnummer VARCHAR(50) NULL, + Aankoopbedrag DECIMAL(10,2) NULL, + Aankoopdatum DATE NULL, + Garantie DATE NULL, + UNIQUE KEY uq_signpost_nummer (Signpostnummer), + CONSTRAINT fk_signpost_toestel FOREIGN KEY (Toestel_ID) + REFERENCES Toestellen (Toestel_ID) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS Opmerkingen ( + Opmerking_ID INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + Toestel_ID INT NOT NULL, + Opmerking TEXT NOT NULL, + Timestamp DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + Gebruiker VARCHAR(255) NULL, -- [+] wie de opmerking schreef + KEY idx_opmerkingen_toestel (Toestel_ID, Timestamp), + CONSTRAINT fk_opmerkingen_toestel FOREIGN KEY (Toestel_ID) + REFERENCES Toestellen (Toestel_ID) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- --------------------------------------------------------------------- +-- Leerlingen +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS Leerlingen ( + Leerling_ID INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + Internnummer VARCHAR(32) NOT NULL, + Gebruikersnaam VARCHAR(100) NULL, + Naam VARCHAR(100) NULL, + Voornaam VARCHAR(100) NULL, + SS_nummer VARCHAR(255) NULL, + Google_adres VARCHAR(255) NULL, + Schooljaar_ID SMALLINT NULL, -- [+] zie tabel Schooljaar + Vertrokken_op DATE NULL, -- niet meer actief in Informat sinds (NULL = actief) + UNIQUE KEY uq_leerlingen_internnummer (Internnummer), + KEY idx_leerlingen_gebruikersnaam (Gebruikersnaam) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS Co_accounts ( + Co_account_ID INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + Internnummer VARCHAR(32) NOT NULL, + Naam VARCHAR(100) NULL, + Voornaam VARCHAR(100) NULL, + Laatste_aanmelding DATETIME NULL, + Emailadres_co_accounts VARCHAR(255) NULL, + KEY idx_coaccounts_intern (Internnummer), + CONSTRAINT fk_coaccounts_leerling FOREIGN KEY (Internnummer) + REFERENCES Leerlingen (Internnummer) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- --------------------------------------------------------------------- +-- Eigendom: welk toestel heeft welke leerling (ook de geschiedenis). +-- Datum_inlevering leeg = de leerling heeft het toestel nu. +-- (Vervangt de vroegere tabel Eigendom_archief.) +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS Eigendom ( + Eigendom_ID INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + Internnummer VARCHAR(32) NOT NULL, + Toestel_ID INT NOT NULL, + Status TINYINT NOT NULL DEFAULT 0, -- 0 = actief, 1 = geblokkeerd in Google + Datum_toekenning DATE NOT NULL, + Datum_inlevering DATE NULL, + KEY idx_eigendom_leerling (Internnummer, Datum_inlevering), + KEY idx_eigendom_toestel (Toestel_ID, Datum_inlevering), + CONSTRAINT fk_eigendom_leerling FOREIGN KEY (Internnummer) + REFERENCES Leerlingen (Internnummer) ON UPDATE CASCADE, + CONSTRAINT fk_eigendom_toestel FOREIGN KEY (Toestel_ID) + REFERENCES Toestellen (Toestel_ID) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- --------------------------------------------------------------------- +-- Herstellingen (via Signpost of intern) +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS Signpost_herstellingen_DB ( + Incident_ID INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + Toestel_ID INT NOT NULL, + Omschrijving TEXT NULL, + Kostprijs DECIMAL(10,2) NULL, + Status_herstelling VARCHAR(30) NOT NULL DEFAULT 'geregistreerd', + Datum DATE NULL, -- [+] datum van de melding + Leerling_ID INT NULL, -- [+] leerling die het toestel binnenbracht + KEY idx_herstellingen_toestel (Toestel_ID), + CONSTRAINT fk_herstellingen_toestel FOREIGN KEY (Toestel_ID) + REFERENCES Toestellen (Toestel_ID) ON DELETE CASCADE, + CONSTRAINT fk_herstellingen_leerling FOREIGN KEY (Leerling_ID) + REFERENCES Leerlingen (Leerling_ID) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- --------------------------------------------------------------------- +-- CO2-metingen per lokaal +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS CO2_Metingen ( + Meting_ID BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + Lokaal_ID INT NOT NULL, + ppm INT NOT NULL, + Timestamp DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_co2_lokaal_tijd (Lokaal_ID, Timestamp), + KEY idx_co2_tijd (Timestamp), + CONSTRAINT fk_co2_lokaal FOREIGN KEY (Lokaal_ID) + REFERENCES Lokalen (Lokaal_ID) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ===================================================================== +-- [+] Tabellen van PIVOT-modules die niet in de PDF staan +-- (PIVOT maakt ze ook zelf aan bij het eerste gebruik) +-- ===================================================================== +CREATE TABLE IF NOT EXISTS smartschool_afwezigheden ( + Datum DATE NOT NULL, + Internnummer VARCHAR(32) NOT NULL, + Dagdeel CHAR(2) NOT NULL, + Code VARCHAR(8) NOT NULL, + PRIMARY KEY (Datum, Internnummer, Dagdeel), + INDEX idx_intern (Internnummer), + INDEX idx_code (Code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS smartschool_afwezigheden_sync ( + Datum DATE NOT NULL PRIMARY KEY, + Opgehaald DATETIME NOT NULL, + Aantal INT NOT NULL DEFAULT 0 +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS tweestaps_uitzonderingen ( + ID INT AUTO_INCREMENT PRIMARY KEY, + Email VARCHAR(255) NOT NULL, + Naam VARCHAR(255) NULL, + Reden VARCHAR(255) NULL, + Van DATETIME NOT NULL, + Tot DATETIME NOT NULL, + Door VARCHAR(100) NULL, + Actief TINYINT NOT NULL DEFAULT 1, + Beeindigd_op DATETIME NULL, + Beeindigd_door VARCHAR(100) NULL, + INDEX idx_actief (Actief, Tot), + INDEX idx_email (Email) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS tweestaps_log ( + ID INT AUTO_INCREMENT PRIMARY KEY, + Tijdstip DATETIME NOT NULL, + Door VARCHAR(100) NULL, + Actie VARCHAR(40) NOT NULL, + Email VARCHAR(255) NOT NULL, + Details VARCHAR(255) NULL, + INDEX idx_tijdstip (Tijdstip) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS gmail_delegatie_log ( + ID INT AUTO_INCREMENT PRIMARY KEY, + Tijdstip DATETIME NOT NULL, + Gebruiker VARCHAR(100) NULL, + Actie VARCHAR(20) NOT NULL, + Mailbox VARCHAR(255) NOT NULL, + Gedelegeerde VARCHAR(255) NOT NULL, + INDEX idx_mailbox (Mailbox), + INDEX idx_tijdstip (Tijdstip) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS gmail_delegaties ( + Mailbox VARCHAR(255) NOT NULL, + Mailbox_naam VARCHAR(255) NULL, + Gedelegeerde VARCHAR(255) NOT NULL, + Gedelegeerde_naam VARCHAR(255) NULL, + Status VARCHAR(20) NULL, + Opgehaald DATETIME NOT NULL, + PRIMARY KEY (Mailbox, Gedelegeerde), + KEY idx_gmail_deleg_gedelegeerde (Gedelegeerde) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS gmail_delegaties_scan ( + ID TINYINT NOT NULL PRIMARY KEY, + Opgehaald DATETIME NULL, + Mailboxen INT NULL, + Fouten INT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS internaat_aanwezigheid ( + Datum DATE NOT NULL, + Persoon_ID VARCHAR(64) NOT NULL, -- pPersoon/persoonId uit de Internaat-API van Informat + Moment VARCHAR(10) NOT NULL, -- ochtend | avond + Status VARCHAR(10) NOT NULL, -- aanwezig | afwezig (geen rij = nog niet gecontroleerd) + Naam VARCHAR(200) NULL, + Bijgewerkt DATETIME NOT NULL, + Door VARCHAR(255) NULL, + PRIMARY KEY (Datum, Persoon_ID, Moment), + KEY idx_aanw_persoon (Persoon_ID) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS gmail_label_log ( + ID INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + Gestart DATETIME NOT NULL, + Klaar DATETIME NULL, + Door VARCHAR(255) NULL, + Bron VARCHAR(255) NOT NULL, + Label VARCHAR(255) NOT NULL, + Doel VARCHAR(255) NOT NULL, + Modus VARCHAR(12) NOT NULL, -- kopieren | verplaatsen + Totaal INT NULL, + Gelukt INT NULL, + Overgeslagen INT NULL, + Fouten INT NULL, + Melding VARCHAR(500) NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS gmail_label_items ( + Bron VARCHAR(255) NOT NULL, + Bron_ID VARCHAR(64) NOT NULL, -- Gmail-id in de bronmailbox + Doel VARCHAR(255) NOT NULL, + Doel_ID VARCHAR(64) NULL, -- Gmail-id van de kopie in de doelmailbox + Gekopieerd DATETIME NOT NULL, + PRIMARY KEY (Bron, Bron_ID, Doel) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Gmail-handtekeningen: sjabloon, logo en gekozen OU's (één rij) en wanneer de handtekening per gebruiker gezet werd +CREATE TABLE IF NOT EXISTS gmail_handtekening ( + ID TINYINT NOT NULL PRIMARY KEY, + Sjabloon MEDIUMTEXT NULL, + OUs TEXT NULL, + Met_sub_ous TINYINT NOT NULL DEFAULT 1, + Logo MEDIUMBLOB NULL, + Logo_type VARCHAR(40) NULL, + Logo_sleutel VARCHAR(40) NULL, + Logo_url VARCHAR(500) NULL, + Logo_breedte INT NOT NULL DEFAULT 120, + Publiek_adres VARCHAR(255) NULL, + Nachtelijk TINYINT NOT NULL DEFAULT 0, + Laatste_nacht DATE NULL, + Nacht_resultaat VARCHAR(500) NULL, + Bijgewerkt DATETIME NULL, + Door VARCHAR(255) NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS gmail_handtekening_log ( + Email VARCHAR(255) NOT NULL PRIMARY KEY, + Toegepast DATETIME NOT NULL, + Ok TINYINT NOT NULL, + Melding VARCHAR(500) NULL, + Hash CHAR(40) NULL, + Door VARCHAR(255) NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Gmail › Contacten: contactlijsten die PIVOT in de mailboxen van de gekozen OU's zet +CREATE TABLE IF NOT EXISTS contact_lijsten ( + ID INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + Naam VARCHAR(100) NOT NULL, -- label in Google Contacten + OUs TEXT NULL, -- JSON-lijst van OU-paden + Met_sub_ous TINYINT NOT NULL DEFAULT 1, + Bijgewerkt DATETIME NULL, + Door VARCHAR(255) NULL, + Laatste_sync DATETIME NULL, + Resultaat VARCHAR(500) NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS contact_lijst_items ( + ID INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + Lijst_ID INT NOT NULL, + Voornaam VARCHAR(100) NULL, + Achternaam VARCHAR(100) NULL, + Organisatie VARCHAR(150) NULL, + Functie VARCHAR(150) NULL, + Email VARCHAR(255) NULL, + Telefoon VARCHAR(60) NULL, + Notitie VARCHAR(500) NULL, + KEY idx_cli_lijst (Lijst_ID) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + + + +CREATE TABLE IF NOT EXISTS google_licenties ( + SKU_ID VARCHAR(32) NOT NULL PRIMARY KEY, + Naam VARCHAR(128) NOT NULL, + Aangekocht INT NOT NULL DEFAULT 0, + Bijgewerkt DATETIME NULL, + Bijgewerkt_door VARCHAR(100) NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS personeel_aanvragen ( + ID INT AUTO_INCREMENT PRIMARY KEY, + Soort VARCHAR(20) NOT NULL, + Status VARCHAR(20) NOT NULL, + Email VARCHAR(255) NOT NULL, + Naam VARCHAR(255) NULL, + Ingediend_door VARCHAR(255) NULL, + Titel VARCHAR(255) NOT NULL, + Omschrijving TEXT NULL, + Locatie VARCHAR(255) NULL, + Van_datum DATE NOT NULL, + Tot_datum DATE NOT NULL, + Dagdeel VARCHAR(10) NOT NULL DEFAULT 'dag', + Lesuren VARCHAR(50) NULL, + Klassen TEXT NULL, + Begeleiders TEXT NULL, + Kost DECIMAL(10,2) NULL, + Vervanging_ok TINYINT NOT NULL DEFAULT 0, + Vervanging_info VARCHAR(255) NULL, + Aangemaakt DATETIME NOT NULL, + Bijgewerkt DATETIME NOT NULL, + INDEX idx_status (Status), + INDEX idx_datum (Van_datum, Tot_datum), + INDEX idx_email (Email) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS personeel_reacties ( + ID INT AUTO_INCREMENT PRIMARY KEY, + Aanvraag_ID INT NOT NULL, + Tijdstip DATETIME NOT NULL, + Email VARCHAR(255) NULL, + Naam VARCHAR(255) NULL, + Actie VARCHAR(30) NOT NULL, + Tekst TEXT NULL, + INDEX idx_aanvraag (Aanvraag_ID) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Skore-synchronisatie (planning + logboek) +CREATE TABLE IF NOT EXISTS skore_planning ( + ID TINYINT NOT NULL PRIMARY KEY, + Frequentie VARCHAR(20) NOT NULL DEFAULT 'uit', + Uur TINYINT NOT NULL DEFAULT 6, + Minuut TINYINT NOT NULL DEFAULT 0, + Weekdag TINYINT NOT NULL DEFAULT 0, + Dag_van_maand TINYINT NOT NULL DEFAULT 1, + Laatst_gepland DATETIME NULL, + Bijgewerkt DATETIME NULL, + Bijgewerkt_door VARCHAR(255) NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS skore_log ( + ID INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + Gestart DATETIME NOT NULL, + Door VARCHAR(255) NULL, + Manier VARCHAR(20) NOT NULL, + Service_ID VARCHAR(100) NULL, + Status VARCHAR(30) NOT NULL, + Melding TEXT NULL, + Bijgewerkt DATETIME NULL, + INDEX idx_skore_gestart (Gestart) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT IGNORE INTO skore_planning (ID) VALUES (1); + +CREATE TABLE IF NOT EXISTS smartschool_coaccounts ( + Internnummer VARCHAR(32) NOT NULL, + Nr TINYINT NOT NULL, + Gebruikersnaam VARCHAR(100) NULL, + Leerling_naam VARCHAR(100) NULL, + Leerling_voornaam VARCHAR(100) NULL, + Klas VARCHAR(50) NULL, + Voornaam VARCHAR(100) NULL, + Naam VARCHAR(100) NULL, + Type VARCHAR(50) NULL, + Email VARCHAR(255) NULL, + Laatste_login DATETIME NULL, + Opgehaald DATETIME NOT NULL, + PRIMARY KEY (Internnummer, Nr), + KEY idx_coacc_klas (Klas) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + + +CREATE TABLE IF NOT EXISTS gopro_keuzes ( + Person_ID VARCHAR(64) NOT NULL PRIMARY KEY, + Functie VARCHAR(100) NULL, + Rol VARCHAR(100) NULL, + Bijgewerkt DATETIME NOT NULL, + Door VARCHAR(255) NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + diff --git a/static/PIVOT_transparant_logo.png b/static/PIVOT_transparant_logo.png new file mode 100644 index 0000000..e6b17e1 Binary files /dev/null and b/static/PIVOT_transparant_logo.png differ diff --git a/static/global.css b/static/global.css new file mode 100644 index 0000000..c2d08b8 --- /dev/null +++ b/static/global.css @@ -0,0 +1,589 @@ +/* ========================================================================== + PIVOT – GLOBALE HUISSTIJL + Eén stylesheet voor alle pagina's. Pagina's erven van templates/base.html + en gebruiken de klassen hieronder; paginaspecifieke CSS blijft beperkt + tot {% block extra_css %}. + ========================================================================== */ + +/* ---------- 1. Design tokens ---------- */ +:root { + /* Merkkleuren */ + --primary-color: #1e6fa5; + --primary-dark: #17577f; + --primary-light: #e8f1f8; + --background-color: #F4F7FA; + + /* Neutralen */ + --surface: #ffffff; + --surface-muted: #f8fafc; + --border: #e2e8f0; + --border-strong: #cbd5e1; + --text: #1e293b; + --text-muted: #64748b; + + /* Statuskleuren */ + --success: #16a34a; --success-bg: #dcfce7; --success-text: #166534; + --warning: #eab308; --warning-bg: #fef9c3; --warning-text: #854d0e; + --danger: #dc2626; --danger-bg: #fee2e2; --danger-text: #991b1b; + --info: #2563eb; --info-bg: #dbeafe; --info-text: #1e40af; + --accent: #f1c709; /* geel accent (toevoegen / bewerken) */ + --bug: #16a34a; /* groen accent voor bug-pagina's */ + + /* Vorm */ + --radius-sm: 6px; + --radius: 10px; + --radius-lg: 14px; + --shadow-sm: 0 1px 2px rgba(15, 23, 42, .06); + --shadow: 0 1px 3px rgba(15, 23, 42, .08), 0 4px 12px rgba(15, 23, 42, .05); + --shadow-lg: 0 10px 30px rgba(15, 23, 42, .18); + + --header-height: 64px; + --page-width: 1200px; + --font: "Segoe UI", system-ui, -apple-system, Roboto, Arial, sans-serif; +} + +/* ---------- 2. Basis ---------- */ +*, *::before, *::after { box-sizing: border-box; } + +html { overflow-y: scroll; } + +body { + margin: 0; + min-height: 100vh; + display: flex; + flex-direction: column; + font-family: var(--font); + font-size: 15px; + line-height: 1.5; + color: var(--text); + background-color: var(--background-color); +} + +h1, h2, h3, h4 { margin: 0 0 .6em; line-height: 1.25; color: var(--text); } +h2 { font-size: 1.35rem; } +h3 { font-size: 1.1rem; } +p { margin: 0 0 1em; } +a { color: var(--primary-color); } + +.muted { color: var(--text-muted); } +.small { font-size: .85rem; } +.text-center { text-align: center; } +.text-right { text-align: right; } +.nowrap { white-space: nowrap; } +.mono { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: .85em; } +.hidden { display: none !important; } + +/* ---------- 3. Header ---------- */ +.app-header { + position: sticky; + top: 0; + z-index: 1000; + height: var(--header-height); + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + gap: 16px; + padding: 0 24px; + background: var(--primary-color); + color: #fff; + box-shadow: 0 2px 8px rgba(0, 0, 0, .15); +} +.app-header.header--bug { background: var(--bug); } + +.header-left, .header-right { display: flex; align-items: center; gap: 12px; min-width: 0; } +.header-right { justify-content: flex-end; } + +.brand { + display: flex; align-items: center; gap: 10px; + color: #fff; text-decoration: none; font-weight: 700; letter-spacing: 1px; + white-space: nowrap; +} +.brand-mark { + width: 38px; height: 38px; border-radius: 50%; + background: #fff; padding: 4px; + display: grid; place-items: center; +} +.brand-mark img { width: 100%; height: 100%; object-fit: contain; } +.brand-name { font-size: 18px; } +.brand-school { font-weight: 400; opacity: .8; letter-spacing: 0; font-size: 14px; } + +.page-title { + margin: 0; + font-size: 22px; + font-weight: 700; + color: #fff; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + text-align: center; +} + +.header-user { font-size: 14px; opacity: .9; white-space: nowrap; } + +.home-btn, .logout-btn, .header-btn { + display: inline-flex; align-items: center; gap: 6px; + height: 36px; padding: 0 14px; + border: 0; border-radius: var(--radius-sm); + font: inherit; font-size: 14px; font-weight: 600; + text-decoration: none; cursor: pointer; white-space: nowrap; + transition: background-color .15s; +} +.home-btn, .header-btn { background: rgba(255, 255, 255, .16); color: #fff; } +.home-btn:hover, .header-btn:hover { background: rgba(255, 255, 255, .28); } +.logout-btn { background: #fff; color: var(--danger); } +.logout-btn:hover { background: var(--danger-bg); } +.app-header form { margin: 0; } + +/* ---------- 4. Pagina-layout ---------- */ +.app-main { + flex: 1; + width: 100%; + max-width: var(--page-width); + margin: 0 auto; + padding: 28px 24px 40px; +} +.app-main.narrow { max-width: 760px; } +.app-main.medium { max-width: 980px; } +.app-main.wide { max-width: 1400px; } + +.page-head { + display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; + gap: 12px; margin-bottom: 18px; +} +.page-head h2 { margin: 0; } +.page-head .actions { display: flex; flex-wrap: wrap; gap: 8px; } + +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow); + padding: 24px; +} +.card + .card { margin-top: 20px; } +.card-title { font-size: 1.1rem; margin: 0 0 14px; } +.card.subtle { background: var(--surface-muted); box-shadow: none; } + +/* Twee-koloms layout (formulier + scanner/info) */ +.split { + display: grid; + grid-template-columns: 3fr 2fr; + gap: 20px; + align-items: start; +} +.split > .card + .card { margin-top: 0; } +@media (max-width: 860px) { + .split { grid-template-columns: 1fr; } + .split .order-first-mobile { order: -1; } +} + +/* ---------- 5. Formulieren ---------- */ +label { font-weight: 600; font-size: 14px; } + +input:not([type=checkbox]):not([type=radio]):not([type=hidden]):not([type=file]), +select, textarea { + width: 100%; + padding: 10px 12px; + font: inherit; + font-size: 15px; + color: var(--text); + background: #fff; + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + transition: border-color .15s, box-shadow .15s; +} +input:focus, select:focus, textarea:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 3px rgba(30, 111, 165, .18); +} +input[readonly], input:disabled, textarea:disabled, select:disabled { + background: var(--surface-muted); color: var(--text-muted); cursor: not-allowed; +} +textarea { min-height: 100px; resize: vertical; } +input[type=checkbox], input[type=radio] { width: 18px; height: 18px; accent-color: var(--primary-color); cursor: pointer; } +input[type=file] { + width: 100%; padding: 12px; font: inherit; + background: var(--surface-muted); + border: 1px dashed var(--primary-color); border-radius: var(--radius-sm); +} + +.field { margin-bottom: 16px; } +.field > label { display: block; margin-bottom: 6px; } +.field-hint { font-size: 13px; color: var(--text-muted); margin-top: 4px; } + +.check { + display: flex; align-items: center; gap: 10px; + font-weight: 500; cursor: pointer; +} +.check-box { + display: flex; align-items: center; gap: 10px; + padding: 12px 14px; margin: 4px 0 16px; + background: var(--surface-muted); border: 1px solid var(--border); + border-radius: var(--radius-sm); font-weight: 500; cursor: pointer; +} + +/* Zoekbalk: invoerveld + knop op één lijn */ +.search-bar { + display: flex; flex-wrap: wrap; gap: 8px; margin: 0 0 16px; +} +.search-bar input:not([type=hidden]) { flex: 1 1 260px; width: auto; } + +.input-group { display: flex; gap: 6px; } +.input-group input { flex: 1; } + +.password-wrap { position: relative; } +.password-wrap input { padding-right: 44px !important; } +.password-toggle { + position: absolute; right: 6px; top: 50%; transform: translateY(-50%); + background: none !important; border: 0; padding: 4px 8px; font-size: 16px; + color: var(--text-muted); cursor: pointer; box-shadow: none; +} + +/* ---------- 6. Knoppen ---------- */ +button, .btn { + display: inline-flex; align-items: center; justify-content: center; gap: 6px; + padding: 9px 16px; + font: inherit; font-size: 14px; font-weight: 600; line-height: 1.3; + color: #fff; + background-color: var(--primary-color); + border: 1px solid transparent; + border-radius: var(--radius-sm); + text-decoration: none; + cursor: pointer; + white-space: nowrap; + transition: background-color .15s, border-color .15s, transform .05s; +} +button:hover, .btn:hover { background-color: var(--primary-dark); } +button:active, .btn:active { transform: translateY(1px); } +button:disabled, .btn:disabled { opacity: .5; cursor: not-allowed; } + +.btn-secondary { background: #475569; } +.btn-secondary:hover { background: #334155; } +.btn-light { background: #fff; color: var(--text); border-color: var(--border-strong); } +.btn-light:hover { background: var(--surface-muted); } +.btn-success { background: var(--success); } +.btn-success:hover { background: #15803d; } +.btn-warning { background: var(--accent); color: #3f3200; } +.btn-warning:hover { background: #d9b308; } +.btn-danger { background: var(--danger); } +.btn-danger:hover { background: #b91c1c; } +.btn-ghost { background: transparent; color: var(--text-muted); } +.btn-ghost:hover { background: var(--surface-muted); color: var(--text); } +.btn-link { background: none; color: var(--danger); padding: 6px; text-decoration: underline; } +.btn-link:hover { background: none; color: #991b1b; } +.btn-sm { padding: 6px 10px; font-size: 13px; } +.btn-lg { padding: 13px 22px; font-size: 16px; } +.btn-block { display: flex; width: 100%; } +.btn-block + .btn-block { margin-top: 10px; } + +/* Oude klassen die nog gebruikt worden */ +.add-btn { background: var(--accent); color: #3f3200; } +.add-btn:hover { background: #d9b308; } +.btn-bewerk { background: var(--accent); color: #3f3200; } +.btn-bewerk:hover { background: #d9b308; } + +/* ---------- 7. Tabellen ---------- */ +.table-wrap { + width: 100%; + overflow-x: auto; + border: 1px solid var(--border); + border-radius: var(--radius); + margin-top: 12px; +} +table { width: 100%; border-collapse: collapse; background: #fff; text-align: left; } +th, td { padding: 11px 14px; border-bottom: 1px solid var(--border); vertical-align: middle; } +th { + background: var(--surface-muted); + font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; + color: var(--text-muted); white-space: nowrap; +} +tbody tr:last-child td { border-bottom: 0; } +tbody tr:hover { background: #f8fbfe; } +td.actions, th.actions { text-align: right; white-space: nowrap; } +td.actions form { display: inline; margin: 0; } +td.actions > * + * { margin-left: 6px; } +td.center, th.center { text-align: center; } +.empty-row td { text-align: center; color: var(--text-muted); padding: 32px; } + +/* Kolommen tonen/verbergen */ +.column-toggles { + display: flex; flex-wrap: wrap; gap: 8px; + margin: 4px 0 8px; +} +.column-toggles label { + display: inline-flex; align-items: center; gap: 6px; + padding: 5px 12px; font-size: 13px; font-weight: 500; + background: var(--surface-muted); border: 1px solid var(--border); + border-radius: 999px; cursor: pointer; user-select: none; +} +.column-toggles input { width: 15px; height: 15px; } + +/* ---------- 8. Badges ---------- */ +.badge { + display: inline-flex; align-items: center; gap: 4px; + padding: 3px 10px; border-radius: 999px; + font-size: 12.5px; font-weight: 600; white-space: nowrap; + background: #e2e8f0; color: #334155; +} +.badge-success { background: var(--success-bg); color: var(--success-text); } +.badge-warning { background: var(--warning-bg); color: var(--warning-text); } +.badge-danger { background: var(--danger-bg); color: var(--danger-text); } +.badge-info { background: var(--info-bg); color: var(--info-text); } + +/* ---------- 9. Flash-berichten ---------- */ +.flash-container { display: flex; flex-direction: column; gap: 8px; margin-bottom: 18px; } +.flash-message { + display: flex; align-items: flex-start; gap: 10px; + padding: 12px 16px; + border-radius: var(--radius); + border: 1px solid transparent; + font-weight: 500; + background: var(--info-bg); color: var(--info-text); border-color: #bfdbfe; + animation: flashIn .3s ease; +} +.flash-message.success { background: var(--success-bg); color: var(--success-text); border-color: #bbf7d0; } +.flash-message.error, .flash-message.danger { background: var(--danger-bg); color: var(--danger-text); border-color: #fecaca; } +.flash-message.warning { background: var(--warning-bg); color: var(--warning-text); border-color: #fde68a; } +.flash-message .flash-close { + margin-left: auto; padding: 0 4px; background: none; color: inherit; + font-size: 18px; line-height: 1; opacity: .6; +} +.flash-message .flash-close:hover { opacity: 1; background: none; } +@keyframes flashIn { from { opacity: 0; transform: translateY(-6px); } to { opacity: 1; transform: none; } } + +/* Inline meldingen (bv. in een modal of loginformulier) */ +.alert { padding: 10px 14px; border-radius: var(--radius-sm); margin-bottom: 14px; font-size: 14px; text-align: left; + background: var(--info-bg); color: var(--info-text); } +.alert-success { background: var(--success-bg); color: var(--success-text); } +.alert-danger, .alert-error { background: var(--danger-bg); color: var(--danger-text); } +.alert-warning { background: var(--warning-bg); color: var(--warning-text); } + +/* ---------- 10. Tabs ---------- */ +.tabs { + display: flex; gap: 4px; flex-wrap: wrap; + border-bottom: 1px solid var(--border); + margin-bottom: 0; +} +.tab-link { + background: transparent; color: var(--text-muted); + border: 0; border-bottom: 3px solid transparent; border-radius: 0; + padding: 12px 18px; font-weight: 600; +} +.tab-link:hover { background: transparent; color: var(--text); } +.tab-link.active { color: var(--primary-color); border-bottom-color: var(--primary-color); } +.tab-content { + display: none; + background: var(--surface); + border: 1px solid var(--border); border-top: 0; + border-radius: 0 0 var(--radius-lg) var(--radius-lg); + box-shadow: var(--shadow); + padding: 24px; +} +.tab-content.active { display: block; } +/* Losstaande tabs (tabs staan in de page-head i.p.v. vast aan de kaart) */ +.tab-content.card { border-top: 1px solid var(--border); border-radius: var(--radius-lg); } +.tab-content + .tab-content { margin-top: 0; } +.page-head .tabs { border-bottom: 0; } + +/* ---------- 11. Modals ---------- */ +/* .modal wordt via JS op display:block gezet, .modal-overlay op display:flex */ +.modal, .modal-overlay { + display: none; + position: fixed; inset: 0; + z-index: 2000; + overflow-y: auto; + padding: 6vh 16px; + background: rgba(15, 23, 42, .5); +} +.modal-overlay { align-items: flex-start; justify-content: center; } +.modal-content { + position: relative; + width: 100%; + max-width: 480px; + margin: 0 auto; + background: var(--surface); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + padding: 26px; + text-align: left; + animation: modalIn .18s ease; +} +.modal-content.wide { max-width: 680px; } +.modal-content h2, .modal-content h3 { margin: 0 0 18px; padding-right: 30px; color: var(--primary-color); } +.modal-footer { display: flex; justify-content: flex-end; gap: 8px; margin-top: 20px; } +.close, .close-btn { + position: absolute; top: 14px; right: 16px; + width: 32px; height: 32px; display: grid; place-items: center; + font-size: 24px; line-height: 1; color: var(--text-muted); + border-radius: 50%; cursor: pointer; +} +.close:hover, .close-btn:hover { background: var(--surface-muted); color: var(--text); } +@keyframes modalIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } } + +/* Grote statusknoppen in modals */ +.status-button { display: flex; width: 100%; padding: 14px; margin: 10px 0 0; font-size: 16px; border-radius: var(--radius); } +.status-success { background: var(--success); } +.status-success:hover { background: #15803d; } +.status-info { background: var(--info); } +.status-info:hover { background: #1d4ed8; } +.status-warning { background: #f59e0b; color: #fff; } +.status-warning:hover { background: #d97706; } +.status-danger { background: var(--danger); } +.status-danger:hover { background: #b91c1c; } + +/* ---------- 12. Schakelaar (toggle) ---------- */ +.switch { position: relative; display: inline-block; width: 46px; height: 26px; flex-shrink: 0; } +.switch input { opacity: 0; width: 0; height: 0; } +.slider { position: absolute; inset: 0; cursor: pointer; background: var(--border-strong); border-radius: 26px; transition: .25s; } +.slider::before { content: ""; position: absolute; width: 20px; height: 20px; left: 3px; top: 3px; background: #fff; border-radius: 50%; transition: .25s; box-shadow: 0 1px 2px rgba(0,0,0,.2); } +.switch input:checked + .slider { background: var(--success); } +.switch input:checked + .slider::before { transform: translateX(20px); } +.switch-row { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--border); } +.switch-row:last-of-type { border-bottom: 0; } + +/* ---------- 13. Laadscherm ---------- */ +#loader-overlay { + display: none; + position: fixed; inset: 0; + z-index: 9999; + flex-direction: column; justify-content: center; align-items: center; gap: 16px; + background: rgba(255, 255, 255, .85); + backdrop-filter: blur(2px); +} +.spinner { + width: 52px; height: 52px; + border: 5px solid var(--primary-light); + border-top-color: var(--primary-color); + border-radius: 50%; + animation: spin .9s linear infinite; +} +.loader-text { font-weight: 600; color: var(--primary-color); font-size: 17px; text-align: center; } +.loader-sub { color: var(--text-muted); font-size: 14px; margin-top: -8px; } +@keyframes spin { to { transform: rotate(360deg); } } + +/* ---------- 14. Footer ---------- */ +.app-footer { + padding: 16px 24px; + font-size: 12.5px; + color: var(--text-muted); + text-align: right; +} + +/* ---------- 15. Dashboard ---------- */ +.dash-greeting { margin-bottom: 24px; } +.dash-greeting h2 { margin: 0 0 2px; font-size: 1.5rem; } +.dash-section-title { + display: flex; align-items: center; gap: 10px; + margin: 32px 0 14px; font-size: 13px; font-weight: 700; + letter-spacing: .08em; text-transform: uppercase; color: var(--text-muted); +} +.dash-section-title::after { content: ""; flex: 1; height: 1px; background: var(--border); } +.dash-section-title.admin { color: var(--danger); } +.dash-section-title.admin::after { background: #fecaca; } + +.tile-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); + gap: 18px; +} +.tile { + display: flex; flex-direction: column; + background: var(--surface); + border: 1px solid var(--border); + border-top: 4px solid var(--primary-color); + border-radius: var(--radius-lg); + box-shadow: var(--shadow); + overflow: hidden; +} +.tile-head { display: flex; align-items: center; gap: 12px; padding: 18px 18px 10px; } +.tile-icon { + width: 42px; height: 42px; flex-shrink: 0; + display: grid; place-items: center; font-size: 22px; + background: var(--primary-light); border-radius: 10px; +} +.tile-head h3 { margin: 0; font-size: 1.05rem; color: var(--primary-color); } +.tile-links { list-style: none; margin: 0; padding: 4px 8px 10px; } +.tile-links a { + display: flex; align-items: center; gap: 10px; + padding: 9px 10px; border-radius: var(--radius-sm); + color: var(--text); text-decoration: none; font-weight: 500; + transition: background-color .12s, color .12s; +} +.tile-links a::after { content: "›"; margin-left: auto; color: var(--border-strong); font-size: 18px; transition: transform .12s; } +.tile-links a:hover { background: var(--primary-light); color: var(--primary-color); } +.tile-links a:hover::after { color: var(--primary-color); transform: translateX(2px); } + +.tile.tile-bug { border-top-color: var(--bug); } +.tile.tile-bug .tile-icon { background: var(--success-bg); } +.tile.tile-bug h3 { color: var(--success-text); } +.tile.tile-bug .tile-links a:hover { background: var(--success-bg); color: var(--success-text); } +.tile.tile-tools { border-top-color: #0d9488; } +.tile.tile-tools .tile-icon { background: #ccfbf1; } +.tile.tile-tools h3 { color: #0f766e; } +.tile.tile-tools .tile-links a:hover { background: #ccfbf1; color: #0f766e; } +.tile.tile-scholengroep { border-top-color: #7c3aed; } +.tile.tile-scholengroep .tile-icon { background: #ede9fe; } +.tile.tile-scholengroep h3 { color: #5b21b6; } +.tile.tile-scholengroep .tile-links a:hover { background: #ede9fe; color: #5b21b6; } +.tile.tile-handleiding { border-top-color: var(--info); } +.tile.tile-handleiding .tile-icon { background: var(--info-bg); } +.tile.tile-handleiding h3 { color: var(--info-text); } +.tile.tile-handleiding .tile-links a:hover { background: var(--info-bg); color: var(--info-text); } + +.tile.tile-admin { border-top-color: var(--danger); } +.tile.tile-admin .tile-icon { background: var(--danger-bg); } +.tile.tile-admin h3 { color: var(--danger-text); } +.tile.tile-admin .tile-links a:hover { background: var(--danger-bg); color: var(--danger-text); } + +/* ---------- 16. Loginpagina ---------- */ +body.auth-page { background: linear-gradient(160deg, #eaf2f9 0%, var(--background-color) 55%); } +.auth-wrap { + flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; + padding: 32px 16px; +} +.auth-logo { width: 100%; max-width: 380px; margin-bottom: 28px; } +.auth-card { width: 100%; max-width: 380px; } +.auth-card h2 { text-align: center; margin-bottom: 20px; } +.separator { display: flex; align-items: center; gap: 10px; margin: 18px 0; color: var(--text-muted); font-size: 13px; } +.separator::before, .separator::after { content: ""; flex: 1; border-bottom: 1px solid var(--border); } +.google-btn { + display: flex; align-items: center; justify-content: center; gap: 10px; + padding: 10px; border: 1px solid #dadce0; border-radius: var(--radius-sm); + background: #fff; color: #3c4043; font-weight: 600; text-decoration: none; + transition: background-color .15s, box-shadow .15s; +} +.google-btn:hover { background: #f8f9fa; box-shadow: 0 1px 3px rgba(60,64,67,.3); } +.google-btn img { width: 18px; height: 18px; } + +/* ---------- 17. Responsief ---------- */ +@media (max-width: 900px) { + .brand-school, .header-user { display: none; } +} +@media (max-width: 720px) { + .app-header { + height: auto; + grid-template-columns: 1fr auto; + grid-template-areas: "left right" "title title"; + padding: 10px 16px; + row-gap: 8px; + } + .header-left { grid-area: left; } + .header-right { grid-area: right; } + .page-title { grid-area: title; font-size: 18px; text-align: left; } + .brand-name { display: none; } + .app-main { padding: 20px 16px 32px; } + .card, .tab-content { padding: 18px; } + .modal-content { padding: 20px; } +} + +/* ---------- 18. Print ---------- */ +@media print { + .app-header, .app-footer, .page-head .actions, .search-bar, .column-toggles { display: none !important; } + body { background: #fff; } + .card { box-shadow: none; border: 0; padding: 0; } +} + +/* Keuze aantal rijen per pagina (perPaginaKiezer) */ +.per-pagina { display: inline-flex; align-items: center; gap: 6px; font-size: .85rem; color: var(--text-muted); margin: 0; font-weight: 400; white-space: nowrap; } +.per-pagina select { width: auto; padding: 4px 8px; font-size: .85rem; } diff --git a/static/img/go-logo.png b/static/img/go-logo.png new file mode 100644 index 0000000..e3c6614 Binary files /dev/null and b/static/img/go-logo.png differ diff --git a/static/pivot_logo.png b/static/pivot_logo.png new file mode 100644 index 0000000..124c212 Binary files /dev/null and b/static/pivot_logo.png differ diff --git a/static/pivot_mark.png b/static/pivot_mark.png new file mode 100644 index 0000000..611929f Binary files /dev/null and b/static/pivot_mark.png differ diff --git a/templates/CO2_sensor.html b/templates/CO2_sensor.html new file mode 100644 index 0000000..6c80d14 --- /dev/null +++ b/templates/CO2_sensor.html @@ -0,0 +1,126 @@ +{% extends "base.html" %} + +{% block title %}CO2-sensoren{% endblock %} +{% block page_title %}CO2-sensoren{% endblock %} +{% block main_class %}medium{% endblock %} +{% block loader_text %}Netwerk wordt gescand...{% endblock %} +{% block loader_sub %}
Dit kan tot 60 seconden duren. Even geduld a.u.b.
{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
+
+

🌬️ Actuele CO2-metingen per lokaal

+ +
+ +
+
+ + Continu scannen + Uit +
+
+ + Resultaten ouder dan 7 dagen automatisch verwijderen + Uit +
+
+ +
+ + + + + + + + + + + {% for row in sensors %} + + + + + + + {% else %} + + {% endfor %} + +
LokaalCO2StatusActie
{{ row.lokaal }}{{ row.ppm }} ppm + {% if row.ppm < 800 %}Goed + {% elif row.ppm < 1200 %}Ventileren + {% else %}Kritiek + {% endif %} + + 📊 Grafiek +
Nog geen metingen. Start een handmatige scan.
+
+
+{% endblock %} + +{% block footer %} +
{{ school_name_short }} – PIVOT – Versie {{ version }} – CO2-monitor
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/Inventaris_Leerlingen.html b/templates/Inventaris_Leerlingen.html new file mode 100644 index 0000000..5b5a27d --- /dev/null +++ b/templates/Inventaris_Leerlingen.html @@ -0,0 +1,153 @@ +{% extends "base.html" %} + +{% block title %}Leerlingenbeheer{% endblock %} +{% block page_title %}Leerlingenbeheer{% endblock %} +{% block main_class %}wide{% endblock %} +{% block extra_css %} + +{% endblock %} +{% block loader_text %}Synchroniseren met Informat & Smartschool... Even geduld.{% endblock %} + +{% block content %} +
+
+

👥 Leerlingen

+
+ +
+ +
+
+
+ + + {% if aantal_vertrokken %} +

+ {% if toon_vertrokken %}Vertrokken leerlingen worden getoond ({{ aantal_vertrokken }}). + Verbergen + {% else %}{{ aantal_vertrokken }} leerling{{ 'en' if aantal_vertrokken != 1 }} niet meer actief in Informat (vertrokken) verborgen. + Toon ook vertrokken leerlingen{% endif %} +

+ {% endif %} + +
+ + + + + + + + + + + + + + + {% if results %} + {% for row in results %} + + + + + + + + + + + {% endfor %} + {% else %} + + {% endif %} + +
InternnummerGebruikersnaamVoornaamNaamSmartschool nr.Google e-mailadresSchooljaarActies
{{ row['Internnummer'] }}{% if row['Vertrokken_op'] %} + vertrokken{% endif %}{{ row['Gebruikersnaam'] or '' }}{{ row['Voornaam'] or '' }}{{ row['Naam'] or '' }}{{ row['SS_nummer'] or '' }}{{ row['Google_adres'] or '' }}{{ row['Schooljaar'] or '' }} + +
Geen leerlingen gevonden in de database.
+
+
+{% endblock %} + +{% block modals %} + + + +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/_handleiding_stijl.html b/templates/_handleiding_stijl.html new file mode 100644 index 0000000..cd75c8c --- /dev/null +++ b/templates/_handleiding_stijl.html @@ -0,0 +1,30 @@ + diff --git a/templates/_personeel.html b/templates/_personeel.html new file mode 100644 index 0000000..1d3acc0 --- /dev/null +++ b/templates/_personeel.html @@ -0,0 +1,32 @@ +{# Gedeelde stukjes voor de personeelsmodule #} + +{% macro status_badge(status) -%} + {%- set s = STATUS.get(status, (status, '')) -%} + {{ s[0] }} +{%- endmacro %} + +{% macro soort_label(soort) -%} + {%- set s = SOORTEN.get(soort) -%}{{ s.icoon }} {{ s.label }} +{%- endmacro %} + +{% macro periode(i) -%} + {%- if i.Van_datum == i.Tot_datum -%} + {{ ['ma','di','wo','do','vr','za','zo'][i.Van_datum.weekday()] }} {{ i.Van_datum.strftime('%d/%m/%Y') }} + {%- else -%} + {{ i.Van_datum.strftime('%d/%m') }} – {{ i.Tot_datum.strftime('%d/%m/%Y') }} + {%- endif -%} + · {{ DAGDELEN.get(i.Dagdeel, '') }}{% if i.Dagdeel == 'uren' and i.Lesuren %} ({{ i.Lesuren }}){% endif %} +{%- endmacro %} + +{% macro subnav(actief) %} +
+ 📋 Mijn aanvragen + {% if 'directie' in p_rollen %} + + ✅ Te beoordelen{% if p_te_beoordelen %} {{ p_te_beoordelen }}{% endif %} + {% endif %} + {% if 'secretariaat' in p_rollen %} + 🗓️ Dagoverzicht + {% endif %} +
+{% endmacro %} diff --git a/templates/_toestel_zoekveld.html b/templates/_toestel_zoekveld.html new file mode 100644 index 0000000..49cb33c --- /dev/null +++ b/templates/_toestel_zoekveld.html @@ -0,0 +1,90 @@ +{# Zoekvelden met suggesties voor uitdelen / inleveren: + - Internnummer ( +
    ): zoeken op internnummer of naam + - Toestelnummer ( +
      ): zoeken op toestel- of serienummer + Opties: toestel_voorkeur = 'beschikbaar' | 'uitgeleend' (die eerst tonen), + toestel_vul_intern = bij inleveren het andere veld automatisch invullen. #} + + diff --git a/templates/aanwezigheden.html b/templates/aanwezigheden.html new file mode 100644 index 0000000..ba6a599 --- /dev/null +++ b/templates/aanwezigheden.html @@ -0,0 +1,247 @@ +{% extends "base.html" %} + +{% block title %}Aanwezigheden internaat{% endblock %} +{% block page_title %}Aanwezigheden internaat{% endblock %} +{% block main_class %}wide{% endblock %} +{% block loader_text %}Register laden...{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
      +
      +

      ✅ Aanwezigheden · {{ school_naam }}

      +
      + ← +
      + {{ dagen[0].day }} {{ ['jan','feb','mrt','apr','mei','jun','jul','aug','sep','okt','nov','dec'][dagen[0].month - 1] }} – + {{ dagen[6].day }} {{ ['jan','feb','mrt','apr','mei','jun','jul','aug','sep','okt','nov','dec'][dagen[6].month - 1] }} + {{ 'Deze week' if dagen[0] <= vandaag <= dagen[6] else 'Week ' ~ weeknr }} +
      + → + {% if not (dagen[0] <= vandaag <= dagen[6]) %} + Vandaag + {% endif %} +
      +
      + + {% if fout %} +
      {{ fout }}
      + {% elif not groepen %} +

      Er zijn deze week geen internen ingeschreven in Informat.

      + {% else %} +
      + +
      + Aanwezig + Afwezig + Nog niet gecontroleerd +
      +
      + +
      + + + + + {% for d in dagen %} + + {% endfor %} + + + {% for d in dagen %} + + + {% endfor %} + + + {% for groep, leden in groepen.items() %} + + + {% for r in leden %} + + + {% for d in dagen %}{% set i = loop.index0 %} + {% for m in ('ochtend', 'avond') %} + {% set st = data.get(r.pid ~ ':' ~ i ~ ':' ~ m, 'leeg') %} + + {% endfor %}{% endfor %} + + {% endfor %} + + {% endfor %} +
      Intern{{ dagnamen[loop.index0] }}{{ d.strftime('%d/%m') }}
      OA
      + +
      {{ r.naam }} {{ r.voornaam }}{{ r.kamer or r.vestiging }} + +
      +
      + +
      + 0 aanwezig + 0 afwezig + 0 nog te controleren + +
      +

      Klik op een vakje om te wisselen: nog niet gecontroleerd → aanwezig → afwezig. + Alles wordt meteen bewaard en is zichtbaar voor alle collega's met dit recht. De internen komen uit Informat.

      + {% endif %} +
      +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/admin_accounts.html b/templates/admin_accounts.html new file mode 100644 index 0000000..7b19cef --- /dev/null +++ b/templates/admin_accounts.html @@ -0,0 +1,143 @@ +{% extends "base.html" %} + +{% block title %}Gebruikers & rechten{% endblock %} +{% block page_title %}Gebruikers & rechten{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
      +
      +

      🔧 PIVOT-gebruikers

      +
      + +
      +
      +

      Eén account per persoon, voor alle scholen. Per school vink je aan op welke onderdelen iemand rechten heeft. + {% if is_admin %}Een administrator heeft altijd alle rechten in alle scholen.{% endif %}

      + +
      + + + + + + + + + + + + {% for u in users %} + + + + + + + + {% else %} + + {% endfor %} + +
      GebruikerRolRechten per schoolLaatste loginBeheren
      + {{ u.Gebruikersnaam }} + {% if not u.Actief %}uitgeschakeld{% endif %} + {% if u.Emailadres %}
      {{ u.Emailadres }}
      {% endif %} +
      {{ u.Rol }} + {% if u.Veiligheidsgroep == ADMIN %} + Alle rechten in alle scholen + {% else %} + {% for naam, n in u.scholen[:3] %} + 🏫 {{ naam }} · {{ n }} + {% else %} + Nog geen rechten + {% endfor %} + {% if u.scholen|length > 3 %} + +{{ u.scholen|length - 3 }} scholen + {% endif %} + {% endif %} + {{ u.Last_login.strftime('%d-%m-%Y %H:%M') if u.Last_login else '–' }} +
      + {% if u.Veiligheidsgroep != ADMIN %} + 🔐 Rechten + {% endif %} + 👤 Account +
      +
      Nog geen gebruikers.
      +
      +
      +{% endblock %} + +{% block modals %} + +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/admin_gebruiker.html b/templates/admin_gebruiker.html new file mode 100644 index 0000000..abf94e7 --- /dev/null +++ b/templates/admin_gebruiker.html @@ -0,0 +1,75 @@ +{% extends "base.html" %} + +{% block title %}{{ u.Gebruikersnaam }} – account{% endblock %} +{% block page_title %}Gebruiker: {{ u.Gebruikersnaam }}{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +← Alle gebruikers + +{# ---------------- Account ---------------- #} +
      +
      +

      👤 Account van {{ u.Gebruikersnaam }}

      + {% if u.Veiligheidsgroep != ADMIN %} + 🔐 Rechten + {% endif %} +
      +
      + + +
      + Laatste login: {{ u.Last_login.strftime('%d-%m-%Y %H:%M') if u.Last_login else 'nog nooit' }}{% if u.IP_laatste_login %} vanaf {{ u.IP_laatste_login }}{% endif %} +
      + +
      + + {% if is_admin and not zelf %} +
      + +
      + {% endif %} +
      +{% endblock %} diff --git a/templates/admin_rechten.html b/templates/admin_rechten.html new file mode 100644 index 0000000..06422d4 --- /dev/null +++ b/templates/admin_rechten.html @@ -0,0 +1,201 @@ +{% extends "base.html" %} + +{% block title %}{{ u.Gebruikersnaam }} – rechten{% endblock %} +{% block page_title %}Gebruiker: {{ u.Gebruikersnaam }}{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +← Alle gebruikers + +
      +
      +

      🔐 Rechten van {{ u.Gebruikersnaam }}

      + 👤 Account +
      + + {% if u.Veiligheidsgroep == ADMIN %} +
      {{ u.Gebruikersnaam }} is administrator en heeft daardoor altijd alle rechten in alle scholen. + De vinkjes hieronder gelden pas als de rol ooit "Gebruiker" wordt.
      + {% endif %} + + {% if not scholen %} +

      Je mag in geen enkele school rechten instellen.

      + {% else %} +
      + {# ---------- Scholen ---------- #} +
      +
      + + +
      + +
      + + {# ---------- Rechten van de gekozen school ---------- #} +
      + + + {% if gekozen %} +
      + +
      +

      🏫 {{ gekozen.naam }}

      +
      + + + +
      +
      +

      Zonder vinkje in deze school kan {{ u.Gebruikersnaam }} er niet inloggen.

      + + {% for cat, items in categorieen.items() %} +
      +
      + {{ cat }} + + + + +
      +
      + {% set ci = loop.index %} + {% for code, naam in items %} + + {% endfor %} +
      +
      + {% endfor %} + + {% if scholen|length > 1 %} +
      + 📋 Deze rechten ook toepassen op andere scholen… +

      De aangevinkte scholen krijgen exact dezelfde rechten als {{ gekozen.naam }}. Wat {{ u.Gebruikersnaam }} daar nu mag, wordt overschreven.

      + + +
      + {% for s in scholen if s.id != gekozen.id %} + + {% endfor %} +
      +
      + {% endif %} + + +
      + {% endif %} +
      +
      + {% endif %} +
      +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/afwezigheden.html b/templates/afwezigheden.html new file mode 100644 index 0000000..4107d2b --- /dev/null +++ b/templates/afwezigheden.html @@ -0,0 +1,208 @@ +{% extends "base.html" %} + +{% block title %}Afwezigheden{% endblock %} +{% block page_title %}Afwezigheden ({{ codes|join('/') }}-codes){% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +{# ---------- Kerncijfers ---------- #} +
      +
      +
      {{ rows|length }}
      +
      Leerlingen met {{ f.code or codes|join('/') }}
      +
      +
      +
      {{ totaal_halve_dagen }}
      +
      Halve dagen
      + {% if rows %}
      gem. {{ '%.1f'|format(totaal_halve_dagen / rows|length) }} per leerling
      {% endif %} +
      +
      +
      + {% if laatste.m %}{{ laatste.m.strftime('%d-%m-%Y %H:%M') }}{% else %}Nog nooit{% endif %} +
      +
      Laatst gesynchroniseerd
      +
      Automatisch elke ochtend
      +
      +
      + +{% if fout %}
      {{ fout }}
      {% endif %} + +{% if not laatste.m and not status.bezig %} +
      + Er zijn nog geen gegevens opgehaald uit Smartschool. Klik op Nu synchroniseren om het huidige schooljaar op te halen + (de eerste keer duurt dit enkele minuten). +
      +{% endif %} + +{# ---------- Synchronisatie ---------- #} +
      +
      +
      + +
      +
      + + {% if status.bezig %}Bezig…{% elif status.laatste_resultaat %}{{ status.laatste_resultaat }}{% endif %} + +
      +
      + +{# ---------- Filters ---------- #} +
      +
      +
      +
      + + +
      +
      + + +
      +
      + + +
      +
      + + +
      + {% if codes|length > 1 %} +
      + + +
      + {% endif %} +
      + + Wissen +
      +
      +
      +
      + +{# ---------- Ranglijst ---------- #} +
      +
      +

      📋 Leerlingen

      +
      + {% if rows %}⬇️ Export CSV{% endif %} +
      +
      +
      + + + + + + + + + + + + + {% for r in rows %} + + + + + + + + + {% else %} + + {% endfor %} + +
      LeerlingInternnummerHalve dagenDagenEersteLaatste
      {% if r.Naam or r.Voornaam %}{{ r.Naam }} {{ r.Voornaam }}{% else %}Onbekend in PIVOT{% endif %} + {% if r.Gebruikersnaam %} · {{ r.Gebruikersnaam }}{% endif %}{{ r.Internnummer }}{{ r.Halve_dagen }}{{ r.Dagen }}{{ r.Eerste.strftime('%d-%m-%Y') }}{{ r.Laatste.strftime('%d-%m-%Y') }}
      + {% if laatste.m %}Geen leerlingen met {{ f.code or codes|join('/') }}-codes in deze periode.{% else %}Nog geen gegevens.{% endif %} +
      +
      +

      Klik op een leerling om de datums te zien. Vanaf 5 halve dagen kleurt het aantal rood.

      +
      +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..281aab0 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,147 @@ + + + + + + {% block title %}PIVOT{% endblock %} – {{ school_name_short|upper or 'PIVOT' }} + + + {% block head %}{% endblock %} + + {% block extra_css %}{% endblock %} + + + + {# ---------- Laadscherm (gedeeld) ---------- #} +
      +
      +
      {% block loader_text %}Verwerken...{% endblock %}
      + {% block loader_sub %}{% endblock %} +
      + + {# ---------- Header ---------- #} + {% block header %} +
      +
      + + + PIVOT + + {% block header_nav %} + 🏠 Home + {% endblock %} +
      + +

      {% block page_title %}{% endblock %}

      + +
      + {% if school %} + {% if is_superbeheerder %} + 🏫 {{ school.naam }} ⇄ + {% elif andere_scholen %} + + {% else %} + 🏫 {{ school.naam }} + {% endif %} + {% endif %} + {% if session.get('user') %} + 👤 {{ session.get('user') }} + {% endif %} +
      + +
      +
      +
      + {% endblock %} + +
      + {# ---------- Flash-berichten (gedeeld) ---------- #} + {% block flashes %} + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
      + {% for category, message in messages %} + + {% endfor %} +
      + {% endif %} + {% endwith %} + {% endblock %} + + {% block content %}{% endblock %} +
      + + {% block modals %}{% endblock %} + + {% block footer %} +
      + {% if school %}{{ school.naam }} – {% endif %}PIVOT – Versie {{ version }} +
      + {% endblock %} + + + {% block scripts %}{% endblock %} + + diff --git a/templates/bingel.html b/templates/bingel.html new file mode 100644 index 0000000..0dbe38d --- /dev/null +++ b/templates/bingel.html @@ -0,0 +1,179 @@ +{% extends "base.html" %} + +{% block title %}Bingel{% endblock %} +{% block page_title %}Bingel{% endblock %} +{% block main_class %}wide{% endblock %} +{% block loader_text %}Leerlingen ophalen uit Informat...{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
      +
      +

      📗 Bingel · {{ school_naam }}

      +
      + {% if rijen %}⬇️ Excel alle klassen{% endif %} + 🔄 Opnieuw ophalen +
      +
      +

      Alle leerlingen uit Informat, klaar voor de import in Bingel. Kolommen in de Excel: + {{ kolommen|join(', ') }}. Het wachtwoord blijft leeg; het klasnummer is alfabetisch binnen de klas (familienaam, voornaam).

      + + {% if fout %} +
      {{ fout }}
      + {% elif not rijen %} +

      Informat gaf geen leerlingen terug.

      + {% else %} + {% if waarschuwing %}
      {{ waarschuwing }}
      {% endif %} +
      + + + {% if zonder_klas %}{% endif %} + {% if zonder_geslacht %}{% endif %} +
      +
      +
      + Klassen in de lijst + + +
      +
      + {% for k in klassen %} + + {% endfor %} +
      +
      +
      + + +
      +
      + + {% for k in kolommen %}{% endfor %} + + {% for r in rijen %} + + + + + + + + + {% endfor %} + +
      {{ k }}{% if k != 'Wachtwoord' %} {% endif %}
      {{ r.klas }}{{ r.voornaam }}{{ r.naam }}{{ r.klasnummer if r.klasnummer is not none else '' }}{{ r.geslacht }}
      +
      +
      +
      + +
      + {% if (zonder_klas or zonder_nummer or zonder_geslacht) and voorbeeld %} +
      + Velden die Informat teruggeeft (om na te kijken waarom iets leeg blijft) +

      Leerling: {{ voorbeeld.leerling|join(', ') }}

      +

      Inschrijving: {{ voorbeeld.inschrijving|join(', ') or '— geen inschrijvingen ontvangen —' }}

      + {% if voorbeeld.klas_item %}

      Voorbeeld van een klas (inschrKlassen): {{ voorbeeld.klas_item }}

      {% endif %} +
      + {% endif %} + {% endif %} +
      +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/bugreport.html b/templates/bugreport.html new file mode 100644 index 0000000..d51d40d --- /dev/null +++ b/templates/bugreport.html @@ -0,0 +1,53 @@ +{% extends "base.html" %} + +{% block title %}Bug rapporteren{% endblock %} +{% block page_title %}🐞 Bug rapporteren{% endblock %} +{% block header_class %}header--bug{% endblock %} +{% block main_class %}medium{% endblock %} +{% block loader_text %}Bugreport verwerken...{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
      +
      +

      Fout of bug rapporteren

      + +
      +
      + + +
      +
      + + +
      +
      + + +
      + +
      +
      + +
      +

      ℹ️ Informatie

      +

      Heeft de applicatie een probleem of werkt er iets niet naar behoren? Meld het direct aan het supportteam.

      +
      + Automatische gegevens +
        +
      • Gebruiker: wordt automatisch gekoppeld aan jouw account.
      • +
      • Status: staat standaard op Nieuw.
      • +
      +
      +

      + Probeer de omschrijving zo nauwkeurig mogelijk in te vullen, zodat we het probleem snel kunnen reproduceren en oplossen. +

      +
      +
      +{% endblock %} diff --git a/templates/bugreport_overzicht.html b/templates/bugreport_overzicht.html new file mode 100644 index 0000000..939419a --- /dev/null +++ b/templates/bugreport_overzicht.html @@ -0,0 +1,102 @@ +{% extends "base.html" %} + +{% block title %}Bugoverzicht{% endblock %} +{% block page_title %}🐞 Bugoverzicht{% endblock %} +{% block header_class %}header--bug{% endblock %} +{% block main_class %}wide{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
      +
      +

      Ingediende bugreports

      + +
      + +
      + + + + + + + + + + + + + + {% for bug in results %} + + + + + + + + + + {% else %} + + {% endfor %} + +
      IDGebruikerDatum / tijdOmschrijvingFoutcodeStatusActie
      #{{ bug.Bug_ID }}{{ bug.Gebruiker }}{{ bug.Datum.strftime('%d-%m-%Y %H:%M') if bug.Datum else '' }}{{ bug.Omschrijving }} + {% if bug.Foutcode %}{{ bug.Foutcode }} + {% else %}Geen{% endif %} + + {% if bug.Status == 'Fixed' %}✅ Fixed + {% elif bug.Status == 'In behandeling' %}🧑‍💻 In behandeling + {% else %}ℹ️ Nieuw + {% endif %} + + +
      Er zijn momenteel geen bugreports.
      +
      +
      +{% endblock %} + +{% block modals %} + +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/contacten.html b/templates/contacten.html new file mode 100644 index 0000000..ab46d03 --- /dev/null +++ b/templates/contacten.html @@ -0,0 +1,352 @@ +{% extends "base.html" %} + +{% block title %}Contacten{% endblock %} +{% block page_title %}Contacten{% endblock %} +{% block main_class %}wide{% endblock %} +{% block loader_text %}Bezig met Google...{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} + + +{% if fout %}
      {{ fout }}
      {% endif %} + +{# ======================= 1. Contactlijsten ======================= #} +{% if tab == 'lijsten' %} +
      +

      📇 Contactlijsten in de mailboxen

      +

      Een lijst is een label in Google Contacten (bv. "School - Directie") met vaste contacten. + PIVOT zet die contacten in de persoonlijke contacten van iedereen in de gekozen OU's en houdt ze bij: aanpassen of verwijderen + in de lijst doet PIVOT ook in de mailboxen. Andere contacten van de gebruikers blijven onaangeroerd.

      + +
      +
      {{ status.lijst or '' }} · {{ status.voortgang }} / {{ status.totaal }} mailboxen{% if status.fouten %} · ⚠️ {{ status.fouten }}{% endif %}
      +
      +
      {{ status.resultaat or '' }}
      +
      {{ status.fout or '' }}
      +
        {% for f in status.foutvoorbeelden %}
      • {{ f }}
      • {% endfor %}
      +
      + + {% if lijsten is defined %} + + {% endif %} +
      + +{% if gekozen or nieuw %} +{% set l = gekozen or {} %} +
      +
      +

      {{ '✏️ ' ~ l.Naam if gekozen else '➕ Nieuwe contactlijst' }}

      +
      + {% if gekozen %} + + + {% endif %} + +
      +
      + {% if gekozen and gekozen.Resultaat %}

      Laatste keer: {{ gekozen.Resultaat }}

      {% endif %} +
      +
      +
      +
      + {% if gekozen and gekozen.Laatste_sync %}

      Wijzig je de naam of de OU's, dan past PIVOT dat na het bewaren ook in de mailboxen aan.

      {% endif %} +
      +
      + +
      + {% for ou in ous %}{% endfor %} +
      + +
      +
      + +

      Contacten in deze lijst

      +
      + + + +
      VoornaamAchternaamOrganisatieFunctieE-mailTelefoonNotitie
      +
      +
      + + Tip: plak meerdere regels uit Excel (voornaam, achternaam, organisatie, functie, e-mail, telefoon) in het eerste veld. +
      +

      Vereist in de domeinbrede delegatie: https://www.googleapis.com/auth/contacts, en de + People API moet aanstaan in het Google Cloud-project van het serviceaccount.

      +
      +{% endif %} +{% endif %} + +{# ======================= 2. Adresboek ======================= #} +{% if tab == 'adresboek' %} +
      +

      👥 Adresboek van de organisatie

      +

      Wie verborgen is, verschijnt niet meer in het adresboek van de school en niet bij het + aanvullen van e-mailadressen in Gmail (bv. leerlingen verbergen voor elkaar). De mailbox zelf blijft gewoon werken.

      +
      + + + + +
      + + {% if ou and gebruikers is defined %} + {% if gebruikers %} + {% set verborgen = gebruikers|rejectattr('zichtbaar')|list|length %} +
      + + {{ gebruikers|length }} gebruiker(s) · {{ verborgen }} verborgen +
      + + + + +
      +
      +
      + + + + + {% for u in gebruikers %} + + + + + + + + + {% endfor %} + +
      NaamE-mailOUIn het adresboek
      {{ u.naam }}{% if u.geschorst %} geschorst{% endif %}{{ u.email }}{{ u.ou }}{% if u.zichtbaar %}zichtbaar{% else %}verborgen{% endif %}
      +
      + {% else %}

      Geen gebruikers in deze OU.

      {% endif %} + {% elif not fout %}

      Kies een OU.

      {% endif %} +

      Of het adresboek voor iedereen zichtbaar is, stel je in de Admin-console in + (Directory › Instellingen voor directory › Zichtbaarheid); dat kan niet via de API.

      +
      +{% endif %} +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/dashboard.html b/templates/dashboard.html new file mode 100644 index 0000000..2ca1642 --- /dev/null +++ b/templates/dashboard.html @@ -0,0 +1,257 @@ +{% extends "base.html" %} + +{% block title %}Dashboard{% endblock %} +{% block page_title %}Dashboard{% endblock %} +{% block header_nav %}{% endblock %} + +{% block content %} + +
      + {% if mag('toestellen_beheren', 'eigendom_bekijken', 'toestellen_uitdelen', 'herstellingen', 'signpost') %} +
      +
      + {# Het icoon van Chrome zelf (google.com/chrome); lukt dat niet, dan het gewone icoon #} + +

      Chromebooks

      + +
      + {% endif %} + + {% if mag('google_wachtwoord', 'google_accounts', 'tweestaps_codes', 'tweestaps_uitzetten', 'google_licenties', 'gedeelde_drives') %} +
      +
      + {# Het icoon van Google zelf (google.com); lukt dat niet, dan het gewone icoon #} + +

      Google

      + +
      + {% endif %} + + {% if mag('gmail_delegatie', 'verzendlijsten', 'gmail_labels', 'gmail_handtekeningen', 'gmail_contacten') %} +
      +
      + {# Het icoon van Gmail zelf; lukt dat niet, dan het gewone icoon #} + +

      Gmail

      + +
      + {% endif %} + + {% if mag('smartschool', 'smartschool_activiteit', 'afwezigheden', 'laatkomers', 'skore', 'sgr_groepen') %} + {% set ss_platform = (school.waarden.get('SS_API_PLATFORM') if school else '') or '' %} + {% set ss_platform = ss_platform.replace('https://', '').replace('http://', '').strip('/') %} +
      +
      + {# Het icoon van het eigen Smartschool-platform; lukt dat niet, dan het gewone icoon #} + {% if ss_platform %}{% else %}🏫{% endif %} +

      Smartschool

      + +
      + {% endif %} + + {% if mag('leerlingen_bekijken', 'leerlingenaantal') %} +
      +
      🎒

      Leerlingen

      + +
      + {% endif %} + + {% if mag('internen', 'internaat_aanwezigheid') %} +
      +
      🛏️

      Internaat

      + +
      + {% endif %} + + {% if school and school.waarden.get('ONDERWIJSNIVEAU') == 'basis' and mag('bingel') %} +
      +
      🎨

      Basisonderwijs

      + +
      + {% endif %} + + {% if mag('leerlingfacturatie') %} +
      +
      🧾

      Leerlingfacturatie

      + +
      + {% endif %} + + {% if mag('gopro', 'personeelsaantal') %} +
      +
      🧑‍🏫

      Personeel

      + +
      + {% endif %} + +
      +
      🗓️

      Afwezigheden & activiteiten

      + +
      + +
      +
      🐞

      Bugs

      + +
      + + {% if mag_handleidingen() %} + {% set hls = handleidingen_lijst() %} +
      +
      📘

      Handleidingen

      + +
      + {% endif %} + +
      +
      + +

      Scholengroep

      + +
      + + {% if mag('qr_codes') %} +
      +
      🧰

      Tools

      + +
      + {% endif %} + + {% if school %} + {% set web_domein = (school.domein or '').strip().lower() %} + {% set ss_url = ((school.waarden.get('SS_API_PLATFORM') or '').replace('https://', '').replace('http://', '').strip().strip('/')) %} + {% if web_domein or ss_url %} +
      +
      🏫

      Links

      + +
      + {% endif %} + {% endif %} +
      + +{% if mag('co2', 'koppelingen', 'gebruikersbeheer', 'nieuw_schooljaar', 'ip_whitelist') or is_admin or is_superbeheerder %} +
      ⚙️ Beheer
      + +
      + {% if mag('gebruikersbeheer', 'koppelingen') or is_superbeheerder %} +
      +
      🏢

      PIVOT

      + +
      + {% endif %} + + {% if is_admin or mag('ip_whitelist') %} +
      +
      🛡️

      Beveiliging

      + +
      + {% endif %} + + {% if mag('co2') or is_admin %} +
      +
      🌐

      IoT

      + +
      + {% endif %} + + {% if mag('nieuw_schooljaar') %} +
      +
      📚

      Nieuw schooljaar

      + +
      + {% endif %} +
      +{% endif %} +{% endblock %} diff --git a/templates/facturatie.html b/templates/facturatie.html new file mode 100644 index 0000000..76ba698 --- /dev/null +++ b/templates/facturatie.html @@ -0,0 +1,213 @@ +{% extends "base.html" %} + +{% block title %}Leerlingfacturatie{% endblock %} +{% block page_title %}Leerlingfacturatie{% endblock %} +{% block main_class %}wide{% endblock %} +{% block loader_text %}Facturen ophalen uit Informat...{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
      +
      +

      🧾 Facturen leerlingen · {{ school_naam }}

      +
      +
      + +
      + {% if rijen %}⬇️ CSV{% endif %} + 🔄 Opnieuw ophalen +
      +
      + + {% if fout %} +
      {{ fout }}
      + {% elif not rijen %} +

      Geen geboekte facturen gevonden voor schooljaar {{ schooljaar }}{% if groepen %} ({{ groepen|length }} factuurgroep(en) voor leerlingen){% endif %}.

      + {% else %} +
      +
      Facturen0
      +
      Totaal€ 0,00
      +
      Betaald€ 0,00
      +
      Nog te betalen€ 0,00
      +
      + +
      +
      + + + + +
      +
      +
      + + {% if groepen|length > 1 %} + + {% endif %} + + Opgehaald uit Informat op {{ opgehaald }} +
      + +
      + + + + + + + + + + + + + + + + + + {% for r in rijen %} + {% set vervallen = r.status in ('open', 'gedeeltelijk') and r.vervaldag and r.vervaldag < today %} + + + + + + + + + + + + + + {% endfor %} + +
      Leerling Klas Factuurgroep Datum Vervaldag Totaal Betaald Open Status Mededeling
      {{ r.naam }} {{ r.voornaam }}{% if r.stamnummer %}
      stamnr. {{ r.stamnummer }}{% endif %}
      {{ r.klas }}{% if r.klasnummer %} nr. {{ r.klasnummer }}{% endif %}{{ r.groep }}{{ r.datum.strftime('%d-%m-%Y') if r.datum else '' }}{{ r.vervaldag.strftime('%d-%m-%Y') if r.vervaldag else '' }}{{ '− ' if r.teken < 0 }}{{ '€ %.2f'|format(r.totaal)|replace('.', ',') }}{{ '€ %.2f'|format(r.betaald)|replace('.', ',') }}{% if r.open %}{{ '€ %.2f'|format(r.open)|replace('.', ',') }}{% else %}€ 0,00{% endif %} + {% if r.status == 'betaald' %}Betaald + {% elif r.status == 'credit' %}Creditnota + {% elif vervallen %}Vervallen + {% elif r.status == 'gedeeltelijk' %}Gedeeltelijk + {% else %}Open{% endif %} + {% if r.ogm|length == 12 %}+++{{ r.ogm[:3] }}/{{ r.ogm[3:7] }}/{{ r.ogm[7:] }}+++{% else %}{{ r.ogm }}{% endif %} + {%- if r.ogm_factuur %}
      bij {{ r.ogm_factuur }}{% endif %}
      + 📄 PDF +
      +
      +
      +
      + +
      + {% endif %} +
      +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/gedeelde_drives.html b/templates/gedeelde_drives.html new file mode 100644 index 0000000..3f2ed68 --- /dev/null +++ b/templates/gedeelde_drives.html @@ -0,0 +1,320 @@ +{% extends "base.html" %} + +{% block title %}Gedeelde drives{% endblock %} +{% block page_title %}Gedeelde drives{% endblock %} +{% block main_class %}wide{% endblock %} +{% block loader_text %}Gedeelde drives ophalen bij Google...{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +{% if lid_van is not none %} +{# ======================= Waar is iemand lid van? ======================= #} +
      +
      +

      🔎 {{ lid }}

      + +
      + {% if fout %}
      {{ fout }}
      + {% elif not lid_van %} +

      {{ lid }} is op geen enkele gedeelde drive lid (rechtstreeks; lidmaatschap via een Google-groep telt hier niet mee).

      + {% else %} +

      Lid van {{ lid_van|length }} gedeelde drive(s) (rechtstreeks; lidmaatschap via een Google-groep telt hier niet mee).

      +
      + {% set meerdere = (lid_van|map(attribute='email')|unique|list|length) > 1 or '@' not in lid %} + {% if meerdere %}{% endif %} + {% for d in lid_van %} + + {% if meerdere %}{% endif %} + + + {% endfor %} +
      DriveLidRol
      {{ d.naam }}{{ d.wie }}{% if d.wie != d.email %}
      {{ d.email }}{% endif %}
      {{ d.rol_naam }}Openen in Drive ↗
      + {% if '@' in lid %} +
      + + 🔁 Op al deze drives vervangen door + + +
      + {% endif %} + {% endif %} +
      + +{% elif vervang %} +{# ======================= Gebruiker vervangen ======================= #} +
      +
      +

      🔁 {{ vervang.oud }} → {{ vervang.nieuw or '?' }}

      + +
      + {% if fout %}
      {{ fout }}
      + {% elif resultaat is not none %} + {% set ok = resultaat|selectattr('ok')|list|length %} +
      {{ ok }} van {{ resultaat|length }} drive(s) aangepast.
      +
      + + {% for r in resultaat %}{% endfor %} +
      DriveResultaat
      {{ r.naam }}{{ '✅' if r.ok else '⚠️' }}{{ r.melding }}
      + {% elif not vervang.drives %} +

      {{ vervang.oud }} is op geen enkele gedeelde drive lid.

      + {% else %} +

      {{ vervang.oud }} is lid van {{ vervang.drives|length }} gedeelde drive(s). + {{ vervang.nieuw }} krijgt op elke aangevinkte drive exact dezelfde rol (ook als die er al lid van was).

      +
      + +
      + + + {% for d in vervang.drives %} + + + {% endfor %} +
      DriveRol van {{ vervang.oud }}
      {{ d.naam }}{{ d.rol_naam }}
      +
      + + + +
      +
      + {% endif %} +
      + +{% elif gekozen %} +{# ======================= Detail van één drive ======================= #} +
      +
      +

      🗂️ {{ gekozen.naam }}

      + +
      +

      {{ gekozen.leden }} lid/leden · {{ gekozen.beheerders }} beheerder(s) + {% if gekozen.aangemaakt %} · aangemaakt {{ gekozen.aangemaakt }}{% endif %}

      + {% if not gekozen.beheerders %}
      ⚠️ Deze drive heeft geen beheerder. Voeg hieronder iemand toe als Beheerder.
      {% endif %} + {% if gekozen.extern %}
      🌍 {{ gekozen.extern }} lid/leden van buiten de school.
      {% endif %} + +
      + +
      +
      +
      +
      + + +
      + +
      + + + + {% for l in leden %} + + + + + + + + {% else %} + + {% endfor %} + +
      NaamE-mailSoortRol
      {{ l.naam or '—' }}{% if l.extern %} extern{% endif %} + {% if l.verwijderd %} account verwijderd{% endif %}{{ l.email }}{{ {'user': 'persoon', 'group': 'groep', 'domain': 'domein', 'anyone': 'iedereen met link'}.get(l.type, l.type) }} +
      + + + +
      +
      +
      + + + +
      +
      Deze drive heeft geen leden.
      +
      +

      Beheerder: alles, ook leden beheren · Inhoudsbeheerder: + bestanden toevoegen, bewerken, verplaatsen en verwijderen · Bijdrager: toevoegen en bewerken · + Reageerder: bekijken en reageren · Lezer: enkel bekijken.

      +
      + +{% else %} +{# ======================= Overzicht ======================= #} +
      +
      +

      🗂️ Gedeelde drives ({{ drives|length }})

      +
      + + 🔄 Opnieuw ophalen +
      +
      +
      + 🔎 Waar is iemand lid van? + + +
      +
      + 🔁 Gebruiker vervangen + + → + + +
      + {% if fout %} +
      {{ fout }}
      + {% elif not drives %} +

      Er zijn nog geen gedeelde drives in dit domein.

      + {% else %} + {% set zonder = drives|selectattr('beheerders', 'equalto', 0)|list|length %} + {% set ext = drives|selectattr('extern')|list|length %} +
      + +
      + + {% if zonder %}{% endif %} + {% if ext %}{% endif %} +
      + + Opgehaald op {{ opgehaald }} +
      +
      + + + + + + + + + + + {% for d in drives %} + + + + + + + + + {% endfor %} + +
      Naam Leden Beheerders Groepen Extern Aangemaakt
      {{ d.naam }}{% if d.fout %} leden niet op te halen{% endif %}{{ d.leden }}{% if d.beheerders %}{{ d.beheerders }}{% else %}geen{% endif %}{{ d.groepen or '' }}{% if d.extern %}{{ d.extern }}{% endif %}{{ d.aangemaakt }}
      +
      +
      +
      + +
      + {% endif %} +

      Vereist in de domeinbrede delegatie: https://www.googleapis.com/auth/drive, + en de Google Drive API moet aanstaan in het Cloud-project van het serviceaccount.

      +
      +{% endif %} +{% endblock %} + +{% block modals %} + +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/gmail_delegatie.html b/templates/gmail_delegatie.html new file mode 100644 index 0000000..890ab1a --- /dev/null +++ b/templates/gmail_delegatie.html @@ -0,0 +1,273 @@ +{% extends "base.html" %} + +{% block title %}Mailbox-delegatie{% endblock %} +{% block page_title %}Mailbox-delegatie{% endblock %} +{% block main_class %}medium{% endblock %} +{% block loader_text %}Bezig met Google...{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +{# ---------- Zoeken ---------- #} +
      +

      ✉️ Welke mailbox?

      + + + {% if zoekresultaten %} +

      Kies een mailbox:

      + + {% endif %} +
      + +{% if fout %} +
      {{ fout }}
      +{% endif %} + +{# ---------- Gedelegeerden van de gekozen mailbox ---------- #} +{% if mailbox %} +
      +
      + {{ (mailbox.naam or mailbox.email)[:1]|upper }} +
      + {{ mailbox.naam or mailbox.email }} + {{ mailbox.email }}{% if mailbox.ou %} · {{ mailbox.ou }}{% endif %} + {% if mailbox.geschorst %}Geschorst{% endif %} +
      + {{ delegates|length }} / {{ max_delegates }} gedelegeerden +
      + +
      + + + + + + {% for d in delegates %} + + + + + + {% else %} + + {% endfor %} + +
      Heeft toegangStatusActie
      {{ d.email }} + {% if d.status == 'accepted' %}{{ d.status_label }} + {% elif d.status == 'pending' %}{{ d.status_label }} + {% else %}{{ d.status_label }}{% endif %} + +
      + + + +
      +
      Niemand anders heeft toegang tot deze mailbox.
      +
      + + {% if delegates|length < max_delegates %} +
      + + + +
      +

      Enkel accounts van jullie eigen Google Workspace. De collega ziet de mailbox binnen ongeveer een minuut via het profielmenu in Gmail.

      + {% else %} +
      Deze mailbox heeft het maximum van {{ max_delegates }} gedelegeerden bereikt.
      + {% endif %} +
      +{% endif %} + +{# ---------- Alle lopende machtigingen ---------- #} +
      +
      +

      📋 Lopende machtigingen ({{ machtigingen|length }})

      +
      + +
      +
      +

      + {% if laatst and laatst.Opgehaald %}Alle mailboxen nagekeken op {{ laatst.Opgehaald.strftime('%d-%m-%Y om %H:%M') }} + ({{ laatst.Mailboxen }} mailboxen{% if laatst.Fouten %}, {{ laatst.Fouten }} niet bevraagbaar{% endif %}). + Wijzigingen via PIVOT staan er meteen in. + {% else %}Nog niet opgehaald.{% endif %} +

      + {% if ov_fout %}
      {{ ov_fout }}
      {% endif %} + {% if ov_status.fout and not ov_status.bezig %}
      Ophalen mislukt: {{ ov_status.fout }}
      {% endif %} +
      + Mailboxen nakijken... +
      +
      + + {% if machtigingen %} +
      + + +
      +
      + + + + + + + + + + + {% for m in machtigingen %} + + + + + + + {% endfor %} + +
      Mailbox Heeft toegang Status
      {{ m.Mailbox_naam or m.Mailbox }}{% if m.Mailbox_naam %}{{ m.Mailbox }}{% endif %}{{ m.Gedelegeerde_naam or m.Gedelegeerde }}{% if m.Gedelegeerde_naam %}{{ m.Gedelegeerde }}{% endif %} + {% if m.Status == 'accepted' %}Actief + {% elif m.Status == 'pending' %}In afwachting + {% elif m.Status == 'rejected' %}Geweigerd + {% elif m.Status == 'expired' %}Verlopen + {% else %}{{ m.Status or '?' }}{% endif %} + Openen
      +
      +
      +
      + +
      + {% elif laatst and laatst.Opgehaald %} +

      Er zijn geen lopende machtigingen.

      + {% endif %} +
      + +{# ---------- Logboek ---------- #} +{% if geschiedenis %} +
      +

      🕑 {{ 'Wijzigingen voor deze mailbox' if mailbox else 'Recente wijzigingen' }}

      +
      + + + + {% for h in geschiedenis %} + + + + + + + + {% endfor %} + +
      TijdstipDoorActieMailboxGedelegeerde
      {{ h.Tijdstip.strftime('%d-%m-%Y %H:%M') if h.Tijdstip else '' }}{{ h.Gebruiker or '?' }}{% if h.Actie == 'toegevoegd' %}Toegevoegd{% else %}Ingetrokken{% endif %}{{ h.Mailbox }}{{ h.Gedelegeerde }}
      +
      +
      +{% endif %} +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/gmail_labels.html b/templates/gmail_labels.html new file mode 100644 index 0000000..3c544d4 --- /dev/null +++ b/templates/gmail_labels.html @@ -0,0 +1,159 @@ +{% extends "base.html" %} + +{% block title %}Mails verplaatsen{% endblock %} +{% block page_title %}Mails verplaatsen{% endblock %} +{% block main_class %}medium{% endblock %} +{% block loader_text %}Bezig met Google...{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
      +

      🏷️ Berichten met een label naar een andere mailbox

      +

      Kies de mailbox en het label. PIVOT kopieert elk bericht met dat label (met bijlagen, + originele datum, gelezen/ongelezen en ster) naar de doelmailbox, onder een label met dezelfde naam.

      + +
      +
      +
      + + +
      + +
      + + {% if bron %} +
      📬 {{ bron.naam or bron.email }} · {{ bron.email }}{% if bron.ou %} · {{ bron.ou }}{% endif %}
      + {% if labels %} +
      + + +
      +
      + + +
      +
      + {% elif not fout %} +

      Deze mailbox heeft geen eigen labels.

      + {% endif %} + {% endif %} +
      + + {% if fout %}
      {{ fout }}
      {% endif %} + + {% if bron and doel and gekozen %} +
      + + + + +
      + + +
      +
      + {{ gekozen.aantal if gekozen.aantal is not none else '?' }} bericht(en) met label {{ gekozen.naam }} + {{ bron.email }} → {{ doel.email }} + +
      +
      + {% endif %} +
      + +{% if status.bezig or status.resultaat or status.fout %} +
      +

      ⏳ Opdracht

      + {% if status.opdracht %}

      Label {{ status.opdracht.label }}: + {{ status.opdracht.bron }} → {{ status.opdracht.doel }} ({{ 'verplaatsen' if status.opdracht.modus == 'verplaatsen' else 'kopiëren' }})

      {% endif %} +
      +
      + {{ status.voortgang }} / {{ status.totaal }} + ✅ {{ status.gelukt }} gelukt + ⏭️ {{ status.overgeslagen }} stonden er al + ⚠️ {{ status.fouten }} mislukt +
      +
      {{ status.resultaat or '' }}
      +
      {{ status.fout or '' }}
      +
        {% for f in status.foutvoorbeelden %}
      • {{ f }}
      • {% endfor %}
      +
      +{% endif %} + +{% if geschiedenis %} +
      +

      🕑 Vorige opdrachten

      +
      + + + + {% for h in geschiedenis %} + + + + + + + + + + {% endfor %} + +
      GestartDoorLabelVan → naarSoortGeluktMislukt
      {{ h.Gestart.strftime('%d-%m-%Y %H:%M') }}{% if not h.Klaar %} bezig{% endif %}{{ h.Door or '' }}{{ h.Label }}{{ h.Bron }} → {{ h.Doel }}{{ 'Verplaatst' if h.Modus == 'verplaatsen' else 'Gekopieerd' }}{{ h.Gelukt if h.Gelukt is not none else '' }}{% if h.Overgeslagen %} (+{{ h.Overgeslagen }}){% endif %}{{ h.Fouten or '' }}
      +
      +
      +{% endif %} + +

      Eenmalig nodig in de Google Admin-console (Beveiliging › API-beheer › Domeinbrede delegatie, bij het + serviceaccount van PIVOT): het bereik {{ scope }}. Een onderbroken opdracht kan je gewoon opnieuw starten: + berichten die al gekopieerd werden, worden overgeslagen.

      +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/google_beheer.html b/templates/google_beheer.html new file mode 100644 index 0000000..8632e44 --- /dev/null +++ b/templates/google_beheer.html @@ -0,0 +1,162 @@ +{% extends "base.html" %} + +{% block title %}Google beheer{% endblock %} +{% block page_title %}Google accountbeheer{% endblock %} +{% block main_class %}medium{% endblock %} +{% block loader_text %}Verzoek verwerken...{% endblock %} + +{% block content %} +
      +
      +

      🔍 Zoek Google-account

      +
      + +
      +
      + + + + {% if results %} +
      + + + + + + + + + + {% for user in results %} + + + + + + {% endfor %} + +
      NaamPrimair e-mailadresActie
      {{ user.name.fullName }}{{ user.primaryEmail }} + +
      +
      + {% elif query %} +

      Geen resultaten gevonden voor "{{ query }}".

      + {% endif %} +
      +{% endblock %} + +{% block modals %} + + + +{% endblock %} + +{% block footer %} +
      {{ school_name_short }} – PIVOT – Versie {{ version }} – Google Admin Tools
      +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/google_licenties.html b/templates/google_licenties.html new file mode 100644 index 0000000..24d70bc --- /dev/null +++ b/templates/google_licenties.html @@ -0,0 +1,269 @@ +{% extends "base.html" %} + +{% block title %}Google-licenties{% endblock %} +{% block page_title %}Google-licenties{% endblock %} +{% block loader_text %}Licenties ophalen bij Google...{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
      +
      +

      🎓 Google Workspace for Education-licenties

      + {% if opgehaald %}
      Gegevens van Google opgehaald om {{ opgehaald }}
      {% endif %} +
      +
      + + {% if data %}⬇️ Export CSV{% endif %} + 🔄 Vernieuwen +
      +
      + +{% if fout %} +
      Licenties konden niet opgehaald worden.
      {{ fout }}
      +{% endif %} + +{% if data %} +
      + {% for s in data.skus %} + {% set state = 'over' if s.aangekocht and s.vrij < 0 else ('low' if s.procent is not none and s.procent >= 90 else '') %} +
      +
      {{ s.naam }}
      +
      SKU {{ s.sku_id }}
      + +
      +
      {{ s.in_gebruik }}
      In gebruik
      +
      {{ s.aangekocht if s.aangekocht else '–' }}
      Aangekocht
      +
      {{ s.vrij if s.aangekocht else '–' }}
      {{ 'Tekort' if state == 'over' else 'Vrij' }}
      +
      + + {% if s.aangekocht %} +
      +
      + {{ s.procent }}% gebruikt + {% if s.bijgewerkt %}aantal: {{ s.bijgewerkt.strftime('%d-%m-%Y') }}{% endif %} +
      + {% else %} +
      Vul het aangekochte aantal in om te zien hoeveel er nog vrij zijn.
      + {% endif %} + + {% if state == 'over' %} +
      {{ -s.vrij }} licentie(s) meer toegewezen dan aangekocht.
      + {% endif %} + {% if s.geschorst %} +
      + {{ s.geschorst }} op geschorste of gearchiveerde accounts – + bekijken +
      + {% endif %} +
      + {% endfor %} +
      + +
      +

      Toegewezen licenties

      + +
      + + + +
      + +
      + + + + + + + + + + + + + {% for u in data.users %} + + + + + + + {# volgorde bij sorteren: actief → geschorst → gearchiveerd → onbekend #} + + + {% else %} + + {% endfor %} + + +
      Naam E-mail Organisatie-eenheid Licentie Laatste login Account
      {{ u.naam }}{{ u.email }}{{ u.ou }}{{ u.sku_naam }}{{ u.laatste_login or 'Nooit' }} + {% if u.gearchiveerd %}Gearchiveerd + {% elif u.geschorst %}Geschorst + {% elif u.onbekend %}Onbekend + {% else %}Actief{% endif %} +
      Er zijn nog geen Education-licenties toegewezen.
      +
      +
      +
      +
      + +
      + +
      +
      +{% endif %} +{% endblock %} + +{% block modals %} + +{% endblock %} + +{% block scripts %} + + +{% endblock %} diff --git a/templates/gopro.html b/templates/gopro.html new file mode 100644 index 0000000..1f8e714 --- /dev/null +++ b/templates/gopro.html @@ -0,0 +1,460 @@ +{% extends "base.html" %} + +{% block title %}GO! Pro{% endblock %} +{% block page_title %}GO! Pro{% endblock %} +{% block loader_text %}Personeel ophalen uit Informat...{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
      +
      +

      🪪 Personeel {{ school_naam }}

      + +
      + +
      +
      + 🌙 Elke nacht automatisch verzamelen + + {% if nacht.aan %}Staat aan: elke nacht tussen 2 en 6 uur worden van alle scholen de groene personeelsleden + naar de verzameltabel gezet (wie op de rode lijst staat niet){% if nacht.door %} · aangezet door {{ nacht.door }}{% endif %}. + {% else %}Staat uit: enkel met de knop ➕ Groene naar verzameltabel, per school.{% endif %} + {% if nacht.bezig %}
      ⏳ Er loopt nu een verzameling voor alle scholen… + {% elif nacht.resultaat %}
      Laatste keer ({{ nacht.resultaat_op.strftime('%d-%m-%Y %H:%M') if nacht.resultaat_op else '?' }}): {{ nacht.resultaat }}{% endif %} +
      +
      +
      +
      + + +
      +
      + + + + +
      +
      +
      + + {% if fout %} +
      {{ fout }}
      + {% else %} +
      + + + + +
      + {% if not domein %} +
      Deze school heeft nog geen e-maildomein: vul het in via 🏫 Alle scholen beheren › Bewerken.
      + {% endif %} + {% if verzameld_hier.groen or verzameld_hier.genegeerd or verzameld_hier.verzonden %} +
      Niet meer getoond: + {% if verzameld_hier.groen %}{{ verzameld_hier.groen }} in de verzameltabel{% endif %} + {%- if verzameld_hier.genegeerd %}{{ ', ' if verzameld_hier.groen }}{{ verzameld_hier.genegeerd }} op de rode lijst{% endif -%} + {%- if verzameld_hier.verzonden %}{{ ', ' if verzameld_hier.groen or verzameld_hier.genegeerd }}{{ verzameld_hier.verzonden }} al verzonden{% endif -%} + .
      + {% endif %} + {% if dubbel %} +
      ⚠️ {{ dubbel }} rij(en) met een e-mailadres dat ook bij een ander personeelslid voorkomt (zelfde naam). Die zijn gemarkeerd.
      + {% endif %} + +
      + + Opgehaald uit Informat op {{ opgehaald }} +
      +
      + Controleren in Google… + +
      + +
      + + + + + + + + + + + + + + + + {% for r in rijen %} + + + + {% for veld in ('email', 'upn') %} + + {% endfor %} + {% for veld, label in (('functie', 'Functie'), ('rol', 'Rol')) %} + + {% endfor %} + + + + + {% else %} + + {% endfor %} + +
      Voornaam Achternaam E-mail UPN Functie Rol Startdatum functie Einddatum functie Telefoon
      {{ r.voornaam }}{{ r.achternaam }} + + {% if veld == 'email' and r.dubbel %}dubbel{% endif %} + + + {{ r.start.strftime('%d-%m-%Y') if r.start else '' }}{{ r.einde.strftime('%d-%m-%Y') if r.einde else '' }}{{ r.telefoon }}
      Informat gaf geen actieve personeelsleden terug.
      +
      +
      +
      +
      + +
      + +
      + {% endif %} +
      + +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/gopro_verzameld.html b/templates/gopro_verzameld.html new file mode 100644 index 0000000..a60705d --- /dev/null +++ b/templates/gopro_verzameld.html @@ -0,0 +1,291 @@ +{% extends "base.html" %} + +{% block title %}GO! Pro verzameltabel{% endblock %} +{% block page_title %}GO! Pro verzameltabel{% endblock %} +{% block main_class %}wide{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +{% set lijstnaam = 'de rode lijst' if rood else ('de lijst met verzonden personeelsleden' if verzonden else 'de verzameltabel') %} + +
      +
      +

      {{ '🚫 Rode lijst GO! Pro' if rood else ('📨 Verzonden naar GO! Pro' if verzonden else '📚 Verzameltabel GO! Pro') }} ({{ rijen|length }})

      +
      + ← Personeel van deze school + {% if rijen and not rood %} + ⬇️ CSV alle scholen + {% endif %} +
      +
      + {% if rood %} +

      Personeelsleden die je met 🚫 naast hun naam op de GO! Pro-pagina hebt + weggezet. Ze worden genegeerd: ze komen niet in de CSV en verschijnen niet meer op de GO! Pro-pagina van hun school, + ook niet na een nieuwe ophaling. Haal je iemand hier weg, dan verschijnt hij daar opnieuw.

      + {% elif verzonden %} +

      Personeelsleden die al naar GO! Pro doorgestuurd zijn (met + ✅ Markeer als verzonden in de verzameltabel). Ze worden nooit opnieuw aan de verzameltabel toegevoegd, + ook niet door de nachtelijke verzameling, en verschijnen niet meer op de GO! Pro-pagina van hun school. Haal je iemand + hier weg, dan verschijnt hij daar opnieuw.

      + {% else %} +

      De groene personeelsleden (account bestaat in Google) van elke school die je hier + verzamelt, komen samen in één tabel voor één grote GO! Pro-import. Wie hier staat, wordt op de GO! Pro-pagina van zijn + school niet meer getoond. Haal je iemand hier weg, dan verschijnt hij daar opnieuw.

      + {% endif %} + + {% if scholen %} +
      + {% for code, naam in scholen %} + + {% endfor %} +
      + {% endif %} + + {% if rijen %} +
      + + + + +
      + +
      + + + + + + + + + + + + + + + + {% for r in rijen %} + + + + + + + + + {% if verzonden and r.Verzonden %} + + {% else %} + + {% endif %} + + + {% endfor %} + +
      School Voornaam Achternaam E-mail / UPN Instelling Functie / rol Periode {{ 'Verzonden' if verzonden else 'Toegevoegd' }}
      {{ r.School_naam or r.School_code }}{{ r.Voornaam or '' }}{{ r.Achternaam or '' }}{{ (r.Email or '')|replace('@', '@\u200b') }}{% if r.UPN and r.UPN != r.Email %}
      UPN: {{ r.UPN }}{% endif %}
      {{ r.Instellingsnummer or '' }}{{ r.Functie or '' }}{% if r.Rol and r.Rol != r.Functie %}
      {{ r.Rol }}{% endif %}
      {{ r.Startdatum.strftime('%d-%m-%Y') if r.Startdatum else '' }}{% if r.Einddatum %}
      tot {{ r.Einddatum.strftime('%d-%m-%Y') }}{% endif %}
      {{ r.Verzonden.strftime('%d-%m-%Y') }}{{ r.Toegevoegd.strftime('%d-%m-%Y') }} + {% if lijst == 'groen' %} + {% set volle_naam = (r.Voornaam or '') ~ ' ' ~ (r.Achternaam or '') %} +
      + + +
      + {% endif %} +
      + + {% if lijst != 'groen' %}{% endif %} + +
      +
      +
      +
      +
      + +
      + +
      + +
      + + {% if lijst != 'groen' %}{% endif %} + +
      + {% if lijst == 'groen' %} +
      + + +
      + {% endif %} +
      + {% else %} + {% if verzonden %} +

      Er is nog niemand als verzonden gemarkeerd.

      + {% elif rood %} +

      De rode lijst is nog leeg. Klik op GO! Pro op 🚫 naast een naam om iemand te negeren.

      + {% else %} +

      De verzameltabel is nog leeg. Open GO! Pro in een school en klik op + ➕ Groene naar verzameltabel.

      + {% endif %} + {% endif %} +
      +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/grafiek.html b/templates/grafiek.html new file mode 100644 index 0000000..b647ed2 --- /dev/null +++ b/templates/grafiek.html @@ -0,0 +1,80 @@ +{% extends "base.html" %} + +{% block title %}CO2-verloop {{ lokaal }}{% endblock %} +{% block page_title %}CO2-verloop: {{ lokaal }}{% endblock %} +{% block main_class %}medium{% endblock %} + +{% block head %} + +{% endblock %} + +{% block content %} +
      +
      +

      📊 {{ lokaal }}

      +
      + ⬅ Terug + +
      +
      + +
      + +
      +
      +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/handleiding.html b/templates/handleiding.html new file mode 100644 index 0000000..5bb533d --- /dev/null +++ b/templates/handleiding.html @@ -0,0 +1,71 @@ +{% extends "base.html" %} + +{% block title %}{{ h.Titel }}{% endblock %} +{% block page_title %}Handleidingen{% endblock %} + +{% block extra_css %} +{% include "_handleiding_stijl.html" %} + +{% endblock %} + +{% block content %} +← Alle handleidingen + +
      + {% if versie %} +
      + Je bekijkt een oudere versie van {{ versie.Opgeslagen_op.strftime('%d-%m-%Y %H:%M') }}{% if versie.Door %} ({{ versie.Door }}){% endif %}. + +
      + +
      + Huidige versie +
      +
      + {% endif %} + +
      +

      {{ versie.Titel if versie else h.Titel }}

      +
      + + {% if is_admin and not versie %} + ✏️ Bewerken + {% endif %} +
      +
      +
      + {% if h.Categorie %}{{ h.Categorie }} · {% endif %} + Bijgewerkt {{ h.Gewijzigd_op.strftime('%d-%m-%Y %H:%M') }}{% if h.Gewijzigd_door %} door {{ h.Gewijzigd_door }}{% endif %} + {% if h.Zichtbaar == 'admins' %} · 🔒 enkel admins{% endif %} +
      + +
      +
      + {{ inhoud|safe }} + {% if not inhoud %}

      Deze handleiding is nog leeg.

      {% endif %} +
      + {% if toc|length > 1 %} + + {% endif %} +
      +
      +{% endblock %} diff --git a/templates/handleiding_bewerken.html b/templates/handleiding_bewerken.html new file mode 100644 index 0000000..844aca8 --- /dev/null +++ b/templates/handleiding_bewerken.html @@ -0,0 +1,271 @@ +{% extends "base.html" %} + +{% block title %}{{ 'Nieuwe handleiding' if nieuw else 'Bewerken: ' ~ h.Titel }}{% endblock %} +{% block page_title %}Handleidingen{% endblock %} + +{% block extra_css %} +{% include "_handleiding_stijl.html" %} + +{% endblock %} + +{% block content %} +← Terug + +
      +

      {{ '➕ Nieuwe handleiding' if nieuw else '✏️ Handleiding bewerken' }}

      + +
      +
      +
      + + +
      +
      + + + {% for c in categorieen %} +
      +
      + + +
      +
      + + +
      +
      +
      + + +
      + + +
      + +
      {{ h.Inhoud|safe if h.Inhoud else '' }}
      + +
      + + +

      Tip: plak een schermafbeelding rechtstreeks met Ctrl+V. Klik op een afbeelding en druk Delete om ze te verwijderen. + Grote afbeeldingen worden automatisch verkleind.

      + +
      + + {% if not nieuw %}{% endif %} + Annuleren +
      +
      + + {% if not nieuw %} +
      + +
      + {% endif %} +
      + +{% if versies %} +
      +

      🕘 Vorige versies

      +

      Bij elke opslag wordt de vorige tekst bewaard (de laatste 30). Open een versie om ze te bekijken of terug te zetten.

      + +
      +{% endif %} +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/handleidingen.html b/templates/handleidingen.html new file mode 100644 index 0000000..0dfe4c7 --- /dev/null +++ b/templates/handleidingen.html @@ -0,0 +1,52 @@ +{% extends "base.html" %} + +{% block title %}Handleidingen{% endblock %} +{% block page_title %}Handleidingen{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
      +
      +

      📘 Handleidingen

      + {% if is_admin %} + ➕ Nieuwe handleiding + {% endif %} +
      + + + + {% for cat, items in per_cat.items() %} +
      {{ cat }}
      + + {% else %} +

      {% if q %}Geen handleidingen gevonden voor "{{ q }}".{% else %}Er zijn nog geen handleidingen.{% endif %}

      + {% endfor %} +
      +{% endblock %} diff --git a/templates/handtekeningen.html b/templates/handtekeningen.html new file mode 100644 index 0000000..102b9e2 --- /dev/null +++ b/templates/handtekeningen.html @@ -0,0 +1,506 @@ +{% extends "base.html" %} + +{% block title %}Handtekeningen{% endblock %} +{% block page_title %}Handtekeningen{% endblock %} +{% block main_class %}wide{% endblock %} +{% block loader_text %}Bezig met Google...{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +{# ===================== Sjabloon ===================== #} +
      +
      +

      ✍️ Algemene handtekening

      +
      + +
      +
      +

      Eén handtekening voor iedereen in de gekozen OU's. Klik op een veld hieronder om het in te + voegen; PIVOT vult het per persoon in. Zet een stuk tussen {{ '{#functie}' }} en {{ '{/functie}' }} (of + telefoon, ...) om het weg te laten als dat veld leeg is.

      + +
      +
      +
      + + + + + + + + + + + + + + + + + + +
      +
      + +
      + {% for k, uitleg in velden %}{% endfor %} +
      +
      + +
      +
      +
      + 👁 Voorbeeld + +
      +
      +
      Beste collega,

      Zo ziet je e-mail eruit met de handtekening.

      Met vriendelijke groeten,
      +
      --
      +
      +
      +
      +
      +
      +
      + +
      +
      + +
      + {% if inst.heeft_logo and inst.Logo_sleutel %} + Logo + + {% endif %} + +
      +

      PNG, JPG of GIF, max. 512 kB. Of geef hieronder een eigen publieke link (bv. van de schoolwebsite).

      + +
      + + pixels +
      +
      +
      + + +

      Gmail haalt een geüpload logo op via {{ (inst.Publiek_adres or publiek_voorstel) }}/handtekening-logo/.... + Dat adres moet van buitenaf (internet) bereikbaar zijn; de IP-blokkering geldt niet voor het logo. + Is PIVOT enkel intern bereikbaar, gebruik dan een eigen link naar het logo.

      + {% if inst.logo_link %}

      Huidige logolink: {{ inst.logo_link }}

      {% endif %} + +

      {{ school_gegevens.schoolnaam }}
      {{ school_gegevens.schooladres or '— geen adres ingevuld —' }}
      + Aan te passen via 🏫 Alle scholen beheren › Bewerken › Adres.

      +
      +
      + + {% if fout_ou %}
      {{ fout_ou }}
      {% endif %} + +
      + {% for ou in ous %} + + {% endfor %} +
      + +
      +
      +
      + {% if inst.Bijgewerkt %}

      Laatst bewaard op {{ inst.Bijgewerkt.strftime('%d-%m-%Y %H:%M') }}{% if inst.Door %} door {{ inst.Door }}{% endif %}.

      {% endif %} +
      + +{# ===================== Gebruikers ===================== #} +
      +
      +

      👥 Gebruikers ({{ gebruikers|length }})

      + +
      + +
      +
      + 🌙 Elke nacht opnieuw toepassen + {% if inst.Nachtelijk %}Staat aan: elke nacht (tussen 3 en 6 uur) zet PIVOT de handtekening + opnieuw bij wie ze in Gmail zelf gewijzigd heeft, of bij wie de gegevens veranderd zijn.{% else %}Staat uit: de handtekening + wordt enkel gezet met de knoppen hieronder. Gebruikers kunnen ze in Gmail zelf nog aanpassen.{% endif %} + {% if inst.Nacht_resultaat %}
      Laatste keer: {{ inst.Nacht_resultaat }}{% endif %}
      +
      +
      + + +
      +
      + + {% if not inst.ous %} +

      Kies hierboven één of meer OU's en klik op 💾 Bewaren.

      + {% elif fout_gebruikers %} +
      {{ fout_gebruikers }}
      + {% elif not gebruikers %} +

      Geen (actieve) gebruikers in de gekozen OU's.

      + {% else %} +

      Functie en telefoon komen uit Google. Pas je ze hier aan, dan worden ze ook in Google + bewaard (en staan ze in het adresboek van de organisatie).

      +
      + + +
      + + + +
      +
      + +
      +
      +
      {{ status.voortgang }} / {{ status.totaal }} · + ✅ {{ status.gelukt }} · ⏭️ {{ status.ongewijzigd }} · ⚠️ {{ status.fouten }}
      +
      {{ status.resultaat or '' }}
      +
      {{ status.fout or '' }}
      +
        +
        + +
        + + + + + + + + + + {% for u in gebruikers %} + {% set l = logboek.get(u.email) %} + + + + + + + + + + + {% endfor %} + +
        NaamE-mailOUFunctieTelefoonGmail
        {{ u.achternaam }} {{ u.voornaam }}{{ u.email }}{{ u.ou }} + {% if not l %}— + {% elif l.Ok %}✓ + {% else %}✕{% endif %} + + + + +
        +
        +
        +
        + +
        + {% endif %} +
        + +

        Eenmalig nodig in de Google Admin-console (Beveiliging › API-beheer › Domeinbrede delegatie, bij het serviceaccount + van PIVOT): https://www.googleapis.com/auth/gmail.settings.basic (handtekening zetten) en + https://www.googleapis.com/auth/admin.directory.user (functie en telefoon).

        +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/herstellingen.html b/templates/herstellingen.html new file mode 100644 index 0000000..20fd324 --- /dev/null +++ b/templates/herstellingen.html @@ -0,0 +1,235 @@ +{% extends "base.html" %} + +{% block title %}Herstellingen{% endblock %} +{% block page_title %}Herstellingen & Signpost{% endblock %} +{% block loader_text %}Signpost data ophalen... even geduld.{% endblock %} + +{% block content %} +
        +
        + + +
        +
        + {% if active_tab == 'signpost-tab' %} + + {% else %} + + {% endif %} +
        +
        + +
        +

        🛠️ Overzicht herstellingen

        + + + {% if results %} +
        + + + + + + + + + + + + + {% for row in results %} + + + + + + + + + {% endfor %} + +
        Incident nrSerienummerSoort herstellingStatusDatumActies
        {{ row['Incident_nr'] or '' }}{{ row['KAMXM_nummer'] }}{{ row['Soort_herstelling'] or '' }} + {% if row['Status'] == 'In afwachting' %}⏳ In afwachting + {% elif row['Status'] == 'In herstelling' %}🔧 In herstelling + {% elif row['Status'] == 'Voltooid' %}✅ Voltooid + {% else %}{{ row['Status'] }} + {% endif %} + {{ row['Datum'] or '' }} + + +
        +
        + {% elif query %} +

        Geen herstellingen gevonden voor "{{ query }}".

        + {% endif %} +
        + +
        +

        🚩 Signpost schademeldingen

        + + + {% if signpost_results %} +
        + + + + + + + + + + + + {% for case in signpost_results %} + + + + + + + + {% endfor %} + +
        ReferentieStatusOmschrijvingDatumPrijs
        {{ case.get('ourReference', 'N/A') }}{{ case.get('status', 'Onbekend') }}{{ case.get('description', 'Geen omschrijving') }}{{ case.get('createdAt', '')[:10] }}€ {{ case.get('totalPrice', '0.00') }}
        +
        + {% endif %} +
        +{% endblock %} + +{% block modals %} + + + + + + + +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/inleveren.html b/templates/inleveren.html new file mode 100644 index 0000000..b4c36ee --- /dev/null +++ b/templates/inleveren.html @@ -0,0 +1,190 @@ +{% extends "base.html" %} + +{% block title %}Inleveren{% endblock %} +{% block page_title %}Toestel inleveren{% endblock %} +{% block main_class %}medium{% endblock %} +{% block loader_text %}Inlevering verwerken...{% endblock %} + +{% block head %} + +{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
        +
        +

        Toestel inleveren

        + + {% if message %} + {% set ok = 'succesvol' in message or 'ingegeven' in message %} +
        {{ message }}
        + {% endif %} + +
        + + +
        + + +
          + {% if request.args.get('naam') %}

          👤 {{ request.args.get('naam') }}

          {% endif %} +
          +
          + + +
            +
            + + + +
            + + +
            + + +
            +
            + +
            +

            📷 Scanner

            + + {# Zonder https geeft de browser geen live camera: dan een foto nemen met de camera-app en de code daaruit lezen #} + + + +
            + +
            +
            Gescande waarde
            + - +
            + + +
            +
            +
            +
            +{% endblock %} + +{% block scripts %} +{% set toestel_voorkeur = 'uitgeleend' %}{% set toestel_vul_intern = true %}{% include '_toestel_zoekveld.html' %} + +{% endblock %} diff --git a/templates/internen.html b/templates/internen.html new file mode 100644 index 0000000..dd5144c --- /dev/null +++ b/templates/internen.html @@ -0,0 +1,217 @@ +{% extends "base.html" %} + +{% block title %}Internen{% endblock %} +{% block page_title %}Internen{% endblock %} +{% block main_class %}wide{% endblock %} +{% block loader_text %}Internen ophalen uit Informat...{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
            +
            +

            🛏️ Internen · {{ school_naam }}

            +
            +
            + + + +
            + {% if rijen %}⬇️ CSV{% endif %} + 🔄 Opnieuw ophalen +
            +
            + + {% if fout %} +
            {{ fout }}
            + {% elif not rijen %} +

            Geen internen ingeschreven op {{ peildatum.strftime('%d-%m-%Y') }} (schooljaar {{ schooljaar }}).

            + {% else %} +
            + Internen {{ rijen|length }} + Jongens {{ rijen|selectattr('geslacht', 'equalto', 'M')|list|length }} + Meisjes {{ rijen|selectattr('geslacht', 'equalto', 'V')|list|length }} + Niet in Smartschool … + +
            +
            + + {% if internaten|length > 1 %} + + {% endif %} + + Opgehaald uit Informat op {{ opgehaald }} +
            + +
            + + + + + + + + + {% if internaten|length > 1 %}{% endif %} + + + + + + + + + {% for r in rijen %} + + + + + + + {% if internaten|length > 1 %}{% endif %} + + + + + + + {% endfor %} + +
            Naam Voornaam Smartschool Geboren M/V Internaat Vestiging Kamer Ingeschreven Woonplaats Contactpersonen
            {{ r.naam }}{% if r.stamnummer %}
            stamnr. {{ r.stamnummer }}{% endif %}
            {{ r.voornaam }}…{{ r.geboortedatum.strftime('%d-%m-%Y') if r.geboortedatum else '' }}{{ r.geslacht }}{{ r.internaat }}{{ r.vestiging }}{{ r.kamer or '–' }}{{ r.begin.strftime('%d-%m-%Y') if r.begin else '' }}{% if r.einde %}
            tot {{ r.einde.strftime('%d-%m-%Y') }}{% endif %} + {% if r.financiering %}
            {{ r.financiering }}{% endif %}
            {{ r.gemeente }} + {% for o in r.ouders[:3] %} + {{ o.naam }}{% if o.type %} ({{ o.type|lower }}){% endif %} + {% if o.gsm %}· {{ o.gsm }}{% endif %} + {% if o.email %}· ✉️{% endif %} + {% endfor %} +
            +
            +
            +
            + +
            + {% endif %} +
            +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/internet_connection.html b/templates/internet_connection.html new file mode 100644 index 0000000..23164b0 --- /dev/null +++ b/templates/internet_connection.html @@ -0,0 +1,56 @@ +{% extends "base.html" %} + +{% block title %}Internetstatus{% endblock %} +{% block page_title %}Internetstatus{% endblock %} +{% block main_class %}narrow{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
            + {% set status_text = 'Online' if current_status == 1 else 'Offline' %} + {% set status_class = 'online' if current_status == 1 else 'offline' %} + +
            + Internet: {{ status_text }} +
            + + {% if current_note %} +
            {{ current_note }}
            + {% endif %} + + {% if session.get('security_group')|int == 1 %} +
            +
            + {% if current_status == 1 %} + + {% else %} + + {% endif %} +
            + +
            +
            + + +
            + +
            +
            + {% endif %} +
            +{% endblock %} diff --git a/templates/inventaris.html b/templates/inventaris.html new file mode 100644 index 0000000..621e45d --- /dev/null +++ b/templates/inventaris.html @@ -0,0 +1,310 @@ +{% extends "base.html" %} + +{% block title %}Eigendom{% endblock %} +{% block page_title %}Eigendom{% endblock %} +{% block main_class %}wide{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
            +

            🏷️ Toestellen bij leerlingen

            + + + + {% if message %} +
            {{ message }}
            + {% endif %} + + {% if results %} + {% set met = results|selectattr('heeft_toestel')|list|length %} +
            + + + +
            +
            + + + + + + + + +
            + +
            + + + + + + + + + + + + + + + + {% for row in results %} + + + + + + + + + + + + {% endfor %} + +
            Internnummer Naam Gebruikersnaam SS_nummer Toestelnummer Serienummer Sinds Status
            {{ row['internnummer'] }}{{ row['Voornaam'] or '' }} {{ row['Naam'] or '' }}{{ row['Gebruikersnaam'] or '' }}{{ row['SS_nummer'] or '' }}{{ row['kamxm_nummer'] or ('' if row['heeft_toestel'] else 'geen toestel') }}{{ row['Serienummer'] or '' }}{{ row['Datum_toekenning'].strftime('%d-%m-%Y') if row['Datum_toekenning'] and row['Datum_toekenning'].strftime is defined else (row['Datum_toekenning'] or '') }} + {% if not row['heeft_toestel'] %}— + {% elif row['locked'] | int == 1 %}🔒 Geblokkeerd + {% else %}🔓 Actief + {% endif %} + + + {% if mag('toestel_blokkeren') and row['heeft_toestel'] %} +
            + + + {% if row['locked'] | int == 1 %} + + {% else %} + + {% endif %} +
            + {% endif %} + {% if mag('toestellen_uitdelen') and row['heeft_toestel'] %} + + {% endif %} + {% if mag('toestellen_uitdelen') %} + {% if row['heeft_toestel'] %} + 📥 Inleveren + {% else %} + 📤 Uitdelen + {% endif %} + {% endif %} + +
            +
            +
            +
            +
            +
            + +
            + +
            + {% elif query %} +

            Geen resultaten gevonden voor "{{ query }}".

            + {% else %} +

            Er zijn nog geen leerlingen.

            + {% endif %} +
            +{% endblock %} + +{% block modals %} + + +{% endblock %} + +{% block scripts %} +{% if mag('toestellen_uitdelen') %} +{% set toestel_voorkeur = 'beschikbaar' %}{% include '_toestel_zoekveld.html' %} + +{% endif %} + +{% endblock %} diff --git a/templates/ipblokkade.html b/templates/ipblokkade.html new file mode 100644 index 0000000..f6d4273 --- /dev/null +++ b/templates/ipblokkade.html @@ -0,0 +1,365 @@ +{% extends "base.html" %} + +{% block title %}IP-blokkades{% endblock %} +{% block page_title %}IP-blokkades{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% macro schoolkiezer(gekozen=[], formid='') %} +
            + — geen school — +
            + {% if scholen|length > 8 %}{% endif %} + {% for s in scholen %} + + {% else %} +
            Er zijn nog geen scholen.
            + {% endfor %} + {% if scholen %}
            {% endif %} +
            +
            +{% endmacro %} + +{% block content %} +
            +
            +

            🚫 IP-blokkades

            + Jouw IP-adres: {{ mijn_ip }} +
            +

            Geldt voor alle scholen. Een geblokkeerd adres krijgt bij elke aanvraag + "Toegang geweigerd", ook op de loginpagina. Een adres bij Altijd toestaan mag altijd, ook als het in een + geblokkeerde reeks valt.

            + +
            +
            + + +
            +
            + + +
            + +
            + + +
            + +
            + + {% if lokaal %} +
            PIVOT ziet jouw adres als {{ mijn_ip }}. + Waarschijnlijk draait PIVOT achter een reverse proxy, en dan lijkt elke bezoeker van dat adres te komen. Zet in + config.ini › [algemeen] proxy_aantal = 1 en herstart PIVOT, anders werken de IP-regels niet zoals bedoeld.
            + {% endif %} + +
            +
            + Enkel "Altijd toestaan" binnenlaten + + {% if enkel_toegestaan %}Staat aan: elk adres dat niet bij Altijd toestaan staat, wordt geweigerd + ({{ modus_geweigerd }} keer sinds de teller begon){% if modus and modus.Door %} · aangezet door {{ modus.Door }}{% endif %}. + {% else %}Staat uit: iedereen mag binnen, behalve de geblokkeerde adressen.{% endif %} + +
            +
            + + + + +
            +
            +
            + +{% for titel, lijst, leeg, sleutel in (('🚫 Geblokkeerd', blokkeren, 'Er is nog niets geblokkeerd.', 'blok'), + ('✅ Altijd toestaan', toestaan, 'Geen uitzonderingen.', 'toe')) %} +
            +
            +

            {{ titel }} ({{ lijst|length }})

            + {% if sleutel == 'blok' and lijst %} +
            + +
            + {% endif %} +
            + {% if lijst|length > 10 %}{% endif %} +
            + + + + + {% if sleutel == 'toe' %}{% endif %} + + + {% if lijst is sameas blokkeren %} + {% endif %} + + + + + {% for r in lijst %} + {% set bf = 'bew-' ~ r.ID %} + + {% if sleutel == 'toe' %} + + + + {% else %} + + + {% endif %} + + {% if lijst is sameas blokkeren %} + + + {% endif %} + + + {% else %} + + {% endfor %} + +
            Reeks School Opmerking Toegevoegd Geweigerd Laatst geweigerd
            {{ r.Reeks }} + {% for n in r.Scholen_namen %}{{ n }}{% else %}–{% endfor %} + {{ schoolkiezer(r.Scholen_codes, bf) }}{{ r.Opmerking or '' }} + {{ r.Reeks }}{{ r.Opmerking or '' }}{{ r.Aangemaakt.strftime('%d-%m-%Y %H:%M') }}{% if r.Door %} · {{ r.Door }}{% endif %}{{ r.Aantal_geweigerd }}{{ r.Laatst_geweigerd.strftime('%d-%m-%Y %H:%M') if r.Laatst_geweigerd else '–' }} + + {% if sleutel == 'toe' %}{% endif %} +
            + +
            +
            + {% if sleutel == 'toe' %} + +
            + +
            + +
            + {% endif %} +
            {{ leeg }}
            +
            + {% if lijst %} +
            +
            +
            + +
            + +
            + {% endif %} +
            +{% endfor %} + +
            +

            🔎 Een adres testen

            +
            + + + {% if test %} + {% if test.geweigerd %} + 🚫 Geweigerd door {{ test.regel.Reeks if test.regel else 'een regel' }} + {% else %} + ✅ Toegelaten + {% endif %} + {% endif %} +
            +
            +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/ipblokkade_school.html b/templates/ipblokkade_school.html new file mode 100644 index 0000000..bc4b786 --- /dev/null +++ b/templates/ipblokkade_school.html @@ -0,0 +1,143 @@ +{% extends "base.html" %} + +{% block title %}Toegestane IP-adressen{% endblock %} +{% block page_title %}Toegestane IP-adressen{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
            +
            +

            ✅ Toegestane IP-adressen · {{ school_naam }}

            + Jouw IP-adres: {{ mijn_ip }} + {% if not lokaal %}{% endif %} +
            +

            Een adres in deze lijst mag PIVOT altijd openen. Je voegt het toe voor + {{ school_naam }}, met jouw naam ({{ naam }}) als opmerking. Je kan enkel de adressen verwijderen die je zelf toegevoegd hebt.

            + +
            +
            + + +
            + +
            +
            + +
            +

            Lijst ({{ rijen|length }})

            + {% if rijen|length > 10 %}{% endif %} +
            + + + + + + + + + + + {% for r in rijen %} + + + + + + + {% else %} + + {% endfor %} + +
            IP-adres Opmerking Toegevoegd
            {{ r.Reeks }}{{ r.Opmerking or '' }}{{ r.Aangemaakt.strftime('%d-%m-%Y %H:%M') }} + {% if r.Eigen %} +
            + +
            + {% endif %} +
            Er zijn nog geen toegestane adressen voor {{ school_naam }}.
            +
            + {% if rijen %} +
            +
            +
            + +
            + +
            + {% endif %} +
            +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/laatkomers.html b/templates/laatkomers.html new file mode 100644 index 0000000..85bc248 --- /dev/null +++ b/templates/laatkomers.html @@ -0,0 +1,231 @@ +{% extends "base.html" %} + +{% block title %}Laatkomers{% endblock %} +{% block page_title %}Laatkomers{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +{# ---------- Kerncijfers ---------- #} +
            +
            +
            {{ rows|length }}
            +
            Leerlingen te laat
            +
            +
            +
            {{ totaal }}
            +
            Keer te laat
            + {% if rows %}
            gem. {{ '%.1f'|format(totaal / rows|length) }} per leerling
            {% endif %} +
            +
            +
            + {% if laatste.m %}{{ laatste.m.strftime('%d-%m-%Y %H:%M') }}{% else %}Nog nooit{% endif %} +
            +
            Laatst gesynchroniseerd
            +
            Automatisch elke ochtend
            +
            +
            + +{% if fout %}
            {{ fout }}
            {% endif %} +{% if not codes %} +
            Er zijn geen codes voor "te laat" ingesteld. Vul ze in bij 🔌 Koppelingen › Smartschool › Codes voor te laat.
            +{% endif %} + +{# ---------- Synchronisatie en periode ---------- #} +
            +
            +
            + + +
            +
            + + + +
            +
            + + {% if status.bezig %}Bezig…{% elif status.laatste_resultaat %}{{ status.laatste_resultaat }}{% endif %} + +
            +
            +
            + + +
            +
            + + +
            + +
            +
            + +{# ---------- Tabel ---------- #} +
            +
            + +
            +
            + + + + + + + + + + + + + {% for r in rows %} + + + + + + + + + {% else %} + + {% endfor %} + +
            Leerling Internnummer Aantal keer te laat Laatst te laat Eerst te laat Data
            + {% if r.Naam or r.Voornaam %}{{ r.Naam }} {{ r.Voornaam }}{% else %}Onbekende leerling{% endif %} + {{ r.Internnummer }}{{ r.Aantal }}{{ r.Laatste.strftime('%d-%m-%Y') }}{{ r.Eerste.strftime('%d-%m-%Y') }} +
            Toon {{ r.Data|length }} +
            + {% for d, dd in r.Data %}{{ ['ma','di','wo','do','vr','za','zo'][d.weekday()] }} {{ d.strftime('%d-%m') }} {{ dd }}{% endfor %} +
            +
            +
            Geen laatkomers in deze periode{% if not laatste.m %} (er is nog niets opgehaald uit Smartschool){% endif %}.
            +
            +
            +
            +
            + +
            + +
            +
            +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/leerlingenaantal.html b/templates/leerlingenaantal.html new file mode 100644 index 0000000..fc07c78 --- /dev/null +++ b/templates/leerlingenaantal.html @@ -0,0 +1,198 @@ +{% extends "base.html" %} + +{% block title %}{{ titel }}{% endblock %} +{% block page_title %}{{ titel }}{% endblock %} +{% block loader_text %}Bezig met tellen via Informat API...{% endblock %} + +{% block content %} + + +{# ---------- Telling op peildatum ---------- #} +
            +
            +

            📅 Telling op peildatum

            + {% if peildata %} + ⬇️ CSV alle peildata + {% endif %} +
            + +
            +
            + + +
            + +
            + + {% if peildata %} + + {% endif %} + + {% if telling %} +
            +

            {{ eenheid }} op {{ peildatum.strftime('%d-%m-%Y') }}

            +
            + ⬇️ CSV-export +
            + + +
            +
            +
            +
            + + + + + + + + + + {% for g in telling_groepen %} + + + {% for row in g.rijen %} + + + + + + + {% for c in row.Campussen %} + + + + + + {% endfor %} + {% endfor %} + {% if g.code %} + + {% endif %} + + {% endfor %} + + + +
            School{{ eenheid }}OpgevraagdActie
            {{ g.label }}
            {{ row.Naam_school }}{{ row.Aantal if row.Aantal is not none else '–' }}{% if row.Opgevraagd_op %}{{ row.Opgevraagd_op.strftime('%d-%m-%Y %H:%M') }}{% if row.Door %} · {{ row.Door }}{% endif %}{% else %}Niet geteld{% endif %} + {% if row.Informat %} +
            + + + +
            + {% else %} + Informat niet ingesteld + {% endif %} +
            ↳ {{ c.naam }}{{ c.aantal }}
            Subtotaal {{ g.label|lower }}{{ g.subtotaal }}
            Totaal scholengroep{{ telling_totaal }}
            +
            + {% endif %} +
            + +{# ---------- Huidig aantal ---------- #} +
            +
            +

            🏢 Scholengroep: huidig aantal

            +
            + ⬇️ CSV-export +
            + + +
            +
            +
            + + + +
            + + + + + + + + + + {% for g in groepen %} + + + {% for row in g.rijen %} + + + + + + + {% for c in row['Campussen'] %} + + + + + + {% endfor %} + {% endfor %} + {% if g.code %} + + {% endif %} + + {% else %} + + {% endfor %} + {% if results %} + + + + {% endif %} +
            School{{ eenheid }}Laatste synchronisatieActie
            {{ g.label }}
            {{ row['Naam_school'] }}{{ row['Huidig'] }}{{ row['Laatste_synchronisatie'] or 'Nog nooit' }} + {% if row['Informat'] %} +
            + + +
            + {% else %} + Informat niet ingesteld + {% endif %} +
            ↳ {{ c.naam }}{{ c.aantal }}
            Subtotaal {{ g.label|lower }}{{ g.subtotaal }}
            Geen scholen gevonden.
            Totaal scholengroep{{ totaal }}
            +
            +
            +{% endblock %} diff --git a/templates/nieuw_schooljaar_accounts.html b/templates/nieuw_schooljaar_accounts.html new file mode 100644 index 0000000..3a4b9eb --- /dev/null +++ b/templates/nieuw_schooljaar_accounts.html @@ -0,0 +1,113 @@ +{% extends "base.html" %} + +{% block title %}Nieuw schooljaar – accounts{% endblock %} +{% block page_title %}Nieuw schooljaar – leerlingaccounts{% endblock %} +{% block main_class %}medium{% endblock %} +{% block loader_text %}Bezig met verwerken... Even geduld.{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
            + + + +
            + +
            +

            Gebruikers van OU verplaatsen

            +
            +
            +
            + + +
            + ⟶ +
            + + +
            +
            +
            + + +
            +
            +
            +
            + +
            +

            Gebruikers archiveren

            +
            +
            +
            + + +
            +
            +
            + + +
            +
            +
            +
            + +
            +

            Inactieve gebruikers archiveren

            +

            Gebruikers in de geselecteerde OU die langer dan de gekozen periode niet zijn ingelogd, worden gearchiveerd.

            +
            +
            +
            + + +
            + +
            + + +
            +
            +
            +
            +
            +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/nieuw_schooljaar_toestellen.html b/templates/nieuw_schooljaar_toestellen.html new file mode 100644 index 0000000..b175f5b --- /dev/null +++ b/templates/nieuw_schooljaar_toestellen.html @@ -0,0 +1,93 @@ +{% extends "base.html" %} + +{% block title %}Nieuw schooljaar – toestellen{% endblock %} +{% block page_title %}Nieuw schooljaar – toestellen{% endblock %} +{% block main_class %}medium{% endblock %} +{% block loader_text %}Bezig met verwerken... Even geduld.{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
            + + +
            + +
            +

            Chromebooks van OU verplaatsen

            +
            +
            +
            + + +
            + ⟶ +
            + + +
            +
            +
            +
            +
            + +
            +

            Chromebooks blokkeren of deblokkeren

            +

            Selecteer een OU om de status van alle aanwezige Chromebooks te wijzigen.

            +
            +
            +
            + + +
            +
            +
            + + +
            +
            +
            +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/pdf_template_leerkracht.html b/templates/pdf_template_leerkracht.html new file mode 100644 index 0000000..139c4c2 --- /dev/null +++ b/templates/pdf_template_leerkracht.html @@ -0,0 +1,112 @@ + + + + + + + + + + + +
            + + +

            {{ school_name_short|upper }} Google Account

            +
            + +
            +

            Beste {{ naam }},

            + +

            Je account voor de schoolomgeving is aangemaakt op {{ datum }}.

            + + + +

            Hiermee kan je gebruikmaken van alle schoolgebonden google toepassingen (Drive, Gmail, Classroom, Agenda...).

            + +

            Let op: Je zult gevraagd worden om je wachtwoord te wijzigen bij de eerste keer inloggen.

            + + +
            + +

            Met vriendelijke groeten,

            + + + + + + +
            + K. Verbruggen
            + ICT coördinator
            + {{ contact_email }} +
            + B. Elewaut
            + Directeur
            + directie@kamkersem.be +
            + + \ No newline at end of file diff --git a/templates/pdf_template_leerling.html b/templates/pdf_template_leerling.html new file mode 100644 index 0000000..12cd840 --- /dev/null +++ b/templates/pdf_template_leerling.html @@ -0,0 +1,110 @@ + + + + + + + + + + + + +
            + Logo + +

            {{ school_name_short|upper }} Google Account

            +
            + +
            +

            Beste {{ naam }},

            + +

            Je account voor de schoolomgeving is aangemaakt/bijgewerkt op {{ datum }}.

            + + + +

            Hiermee kan je aanmelden op je Chromebook en andere schooldiensten.

            + +

            Let op: Indien dit een nieuw account is, moet je je wachtwoord wijzigen bij de eerste keer inloggen.

            + + +
            + +

            Met vriendelijke groeten,

            + + + + + + +
            + K. Verbruggen
            + ICT coördinator
            + {{ contact_email }} +
            + B. Elewaut
            + Directeur
            + directie@kamkersem.be +
            + + \ No newline at end of file diff --git a/templates/personeel_beoordelen.html b/templates/personeel_beoordelen.html new file mode 100644 index 0000000..1fbe237 --- /dev/null +++ b/templates/personeel_beoordelen.html @@ -0,0 +1,68 @@ +{% extends "base.html" %} +{% from "_personeel.html" import subnav, status_badge, soort_label, periode with context %} + +{% block title %}Te beoordelen{% endblock %} +{% block page_title %}Te beoordelen{% endblock %} +{% block main_class %}medium{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +{{ subnav('beoordelen') }} + +{% for i in open_items %} +
            +
            +
            +

            {{ soort_label(i.Soort) }}: {{ i.Titel }}

            +
            {{ i.Naam }} · {{ periode(i) }}{% if i.Kost %} · € {{ '%.2f'|format(i.Kost)|replace('.', ',') }}{% endif %}
            + {% if i.Klassen %}
            {% for k in i.Klassen.split(',') %}{{ k.strip() }}{% endfor %}
            {% endif %} +
            +
            {{ status_badge(i.Status) }}
            + {% if i.Omschrijving %}
            {{ i.Omschrijving }}
            {% endif %} +
            + + + + + +
            +
            +
            +{% else %} +

            🎉 Er wacht niets op een beslissing.

            +{% endfor %} + +{% if recent %} +
            +

            Recent beslist

            +
            + + + + {% for i in recent %} + + + + + + + + {% endfor %} + +
            SoortWieTitelWanneerBeslissing
            {{ soort_label(i.Soort) }}{{ i.Naam }}{{ i.Titel }}{{ periode(i) }}{{ status_badge(i.Status) }}
            +
            +
            +{% endif %} +{% endblock %} diff --git a/templates/personeel_dagoverzicht.html b/templates/personeel_dagoverzicht.html new file mode 100644 index 0000000..2b37682 --- /dev/null +++ b/templates/personeel_dagoverzicht.html @@ -0,0 +1,134 @@ +{% extends "base.html" %} +{% from "_personeel.html" import subnav, status_badge, soort_label with context %} + +{% block title %}Dagoverzicht{% endblock %} +{% block page_title %}Dagoverzicht secretariaat{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +{{ subnav('dagoverzicht') }} + + + +{% set weekdagen = ['maandag','dinsdag','woensdag','donderdag','vrijdag','zaterdag','zondag'] %} +{% for d in dagen %} +
            +
            +

            {{ weekdagen[d.datum.weekday()] }} {{ d.datum.strftime('%d/%m') }}

            + {{ d.personen|length }} afwezig · {{ d.klassen|length }} klas(sen) weg + {% if d.open_vervanging %} · {{ d.open_vervanging }} vervanging(en) te regelen{% endif %} +
            +
            +
            +

            👤 Afwezige personeelsleden

            + {% for p in d.personen %} + {% set i = p.item %} +
            +
            +
            {{ p.naam }}
            +
            + {% if p.rol == 'begeleider' %}🚌 begeleider uitstap "{{ i.Titel }}" ({{ i.Naam }}) + {% else %}{{ soort_label(i.Soort) }}{% if i.Soort != 'afwezigheid' %}: {{ i.Titel }}{% endif %}{% endif %} + · {{ DAGDELEN.get(i.Dagdeel) }}{% if i.Dagdeel == 'uren' and i.Lesuren %} ({{ i.Lesuren }}){% endif %} +
            + {% if p.rol != 'begeleider' and i.Soort != 'uitstap' and i.Klassen %} +
            {% for k in i.Klassen.split(',') %}{{ k.strip() }}{% endfor %}
            + {% endif %} + {% if p.rol != 'begeleider' and i.Soort == 'afwezigheid' and i.Omschrijving %}
            💬 {{ i.Omschrijving }}
            {% endif %} +
            +
            + {% if p.rol != 'begeleider' and i.Soort != 'uitstap' and i.Klassen %} +
            + + + {% if i.Vervanging_ok %} + ✓ Vervanging + + {% else %} + Vervanging te regelen + + {% endif %} +
            + {% endif %} + +
            +
            + {% else %} +
            Niemand afwezig. 🎉
            + {% endfor %} +
            +
            +

            🚌 Klassen weg

            + {% for k in d.klassen %} +
            + {{ k.klas }} + {{ k.item.Titel }}{% if k.item.Locatie %} · {{ k.item.Locatie }}{% endif %}
            + {{ DAGDELEN.get(k.item.Dagdeel) }}{% if k.item.Dagdeel == 'uren' and k.item.Lesuren %} ({{ k.item.Lesuren }}){% endif %} · {{ k.item.Naam }}
            +
            + {% else %} +
            Geen klassen weg.
            + {% endfor %} + {% if d.wachtend %} +
            ⏳ Nog niet goedgekeurd: + {% for w in d.wachtend %}{{ SOORTEN[w.Soort].icoon }} {{ w.Naam }} – {{ w.Titel }}{{ ', ' if not loop.last }}{% endfor %} +
            + {% endif %} +
            +
            +
            +{% endfor %} +{% endblock %} diff --git a/templates/personeel_detail.html b/templates/personeel_detail.html new file mode 100644 index 0000000..c72e4c5 --- /dev/null +++ b/templates/personeel_detail.html @@ -0,0 +1,134 @@ +{% extends "base.html" %} +{% from "_personeel.html" import subnav, status_badge, soort_label, periode with context %} + +{% block title %}{{ SOORTEN[item.Soort].label }} – {{ item.Naam }}{% endblock %} +{% block page_title %}{{ SOORTEN[item.Soort].icoon }} {{ SOORTEN[item.Soort].label }}{% endblock %} +{% block main_class %}medium{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +{{ subnav('') }} + +
            +
            +
            +

            {{ item.Titel }}

            +
            {{ item.Naam or item.Email }}{% if item.Ingediend_door %} · gemeld door {{ item.Ingediend_door }}{% endif %}
            +
            +
            {{ status_badge(item.Status) }}
            +
            +
            +
            Soort
            {{ soort_label(item.Soort) }}
            +
            Wanneer
            {{ periode(item) }}
            + {% if item.Locatie %}
            Locatie
            {{ item.Locatie }}
            {% endif %} + {% if item.Klassen %}
            {{ 'Klassen mee' if item.Soort == 'uitstap' else 'Klassen' }}
            +
            {% for k in item.Klassen.split(',') %}{{ k.strip() }}{% endfor %}
            {% endif %} + {% if item.Begeleiders %}
            Begeleiders
            {{ item.Begeleiders }}
            {% endif %} + {% if item.Kost %}
            Kost
            € {{ '%.2f'|format(item.Kost)|replace('.', ',') }}
            {% endif %} + {% if item.Omschrijving %}
            {{ 'Opmerking' if item.Soort == 'afwezigheid' else 'Toelichting' }}
            {{ item.Omschrijving }}
            {% endif %} + {% if 'secretariaat' in p_rollen and item.Klassen and item.Soort != 'uitstap' %} +
            Vervanging
            +
            {% if item.Vervanging_ok %}✓ Geregeld{% else %}Nog te regelen{% endif %} + {% if item.Vervanging_info %} · {{ item.Vervanging_info }}{% endif %}
            + {% endif %} +
            +
            + +{# ---------- Directie: beslissen ---------- #} +{% if 'directie' in p_rollen and SOORTEN[item.Soort].goedkeuring and item.Status in ('ingediend', 'vraag') %} +
            +

            Beslissing directie

            + {% if item.Status == 'vraag' %}

            Je hebt een vraag gesteld; de leerkracht heeft nog niet geantwoord. Je kan toch al beslissen.

            {% endif %} +
            +
            + + +
            +
            + + + +
            +
            +
            +{% endif %} + +{# ---------- Leerkracht: antwoorden op vraag ---------- #} +{% if is_eigenaar and item.Status == 'vraag' %} +
            +

            ❓ De directie heeft een vraag

            +
            +
            + +
            +
            +{% endif %} + +{# ---------- Secretariaat: vervanging ---------- #} +{% if 'secretariaat' in p_rollen and item.Status in ('gemeld', 'goedgekeurd') and item.Soort != 'uitstap' %} +
            +

            🔁 Vervanging

            + +
            +{% endif %} + +{# ---------- Verloop + commentaar ---------- #} +
            +

            Verloop

            +
              + {% set labels = {'ingediend': 'diende de aanvraag in', 'gemeld': 'meldde de afwezigheid', 'goedgekeurd': 'keurde goed', + 'geweigerd': 'weigerde', 'vraag': 'stelde een vraag', 'antwoord': 'antwoordde', + 'commentaar': 'schreef', 'geannuleerd': 'annuleerde', 'vervanging': 'vervanging'} %} + {% for r in reacties %} +
            • + + {{ r.Naam or r.Email or '?' }} {{ labels.get(r.Actie, r.Actie) }} + · {{ r.Tijdstip.strftime('%d/%m %H:%M') }} + {% if r.Tekst %}
              {{ r.Tekst }}
              {% endif %} +
            • + {% endfor %} +
            + {% if item.Status not in ('geannuleerd',) %} + + {% endif %} +
            + +{% if (is_eigenaar or 'secretariaat' in p_rollen) and item.Status not in ('geannuleerd', 'geweigerd') and item.Tot_datum >= vandaag %} +
            + +
            +{% endif %} +{% endblock %} diff --git a/templates/personeel_mijn.html b/templates/personeel_mijn.html new file mode 100644 index 0000000..cf76346 --- /dev/null +++ b/templates/personeel_mijn.html @@ -0,0 +1,57 @@ +{% extends "base.html" %} +{% from "_personeel.html" import subnav, status_badge, soort_label, periode with context %} + +{% block title %}Afwezigheden & activiteiten{% endblock %} +{% block page_title %}Afwezigheden & activiteiten{% endblock %} +{% block main_class %}medium{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +{{ subnav('mijn') }} + + + +
            +

            Mijn meldingen en aanvragen

            +
            + + + + {% for i in items %} + + + + + + + {% else %} + + {% endfor %} + +
            SoortTitelWanneerStatus
            {{ soort_label(i.Soort) }}{{ i.Titel }} + {% if i.Ingediend_door %}
            gemeld door {{ i.Ingediend_door }}
            {% endif %}
            {{ periode(i) }}{{ status_badge(i.Status) }}
            Je hebt nog geen meldingen of aanvragen.
            +
            +
            +{% endblock %} diff --git a/templates/personeel_nieuw.html b/templates/personeel_nieuw.html new file mode 100644 index 0000000..16a7309 --- /dev/null +++ b/templates/personeel_nieuw.html @@ -0,0 +1,172 @@ +{% extends "base.html" %} +{% from "_personeel.html" import subnav with context %} + +{% block title %}{{ SOORTEN[soort].label }}{% endblock %} +{% block page_title %}{{ SOORTEN[soort].icoon }} {{ 'Afwezigheid melden' if soort == 'afwezigheid' else SOORTEN[soort].label ~ ' aanvragen' }}{% endblock %} +{% block main_class %}medium{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +{{ subnav('') }} + +
            + {% for key, s in SOORTEN.items() %} + {{ s.icoon }} {{ s.label }} + {% endfor %} +
            + +
            + + +
            + {% if soort == 'afwezigheid' %}Het secretariaat ziet je afwezigheid meteen en krijgt een mail. Geen goedkeuring nodig. + {% elif soort == 'nascholing' %}De directie krijgt je aanvraag en kan goedkeuren, weigeren of een vraag stellen. Je krijgt een mail bij elke beslissing. + {% else %}De directie keurt de uitstap goed. Daarna ziet het secretariaat welke klassen en begeleiders weg zijn.{% endif %} +
            + + {% if is_secr %} +
            + + +
            + {% endif %} + + {% if soort != 'afwezigheid' %} +
            + + +
            + {% else %} +
            + + +
            + {% endif %} + +
            +
            + + +
            +
            + + +
            +
            + +
            + +
            + {% for key, label in DAGDELEN.items() %} + + {% endfor %} +
            +
            + + + {% if soort != 'afwezigheid' %} +
            +
            + + +
            +
            + + +
            +
            + {% endif %} + +
            + +
            + {% if klassen %} + +
            + {% set gekozen = form.getlist('klassen') if form else [] %} + {% for k in klassen %} + + {% endfor %} +
            +
            + + {% else %} + +
            De klassenlijst uit Smartschool kon niet geladen worden; typ de klassen in.
            + {% endif %} +
            +
            + + {% if soort == 'uitstap' %} +
            + + +
            Begeleiders verschijnen ook als afwezig in het dagoverzicht van het secretariaat.
            +
            + {% endif %} + +
            + + +
            + + +
            +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/pin.html b/templates/pin.html new file mode 100644 index 0000000..80d0804 --- /dev/null +++ b/templates/pin.html @@ -0,0 +1,50 @@ +{% extends "base.html" %} + +{% block title %}Inloggen{% endblock %} +{% block body_class %}auth-page{% endblock %} +{% block header %}{% endblock %} +{% block main_class %}auth-wrap{% endblock %} +{% block flashes %}{% endblock %} + +{% block content %} + + +
            +

            🔐 Inloggen

            + + {% with messages = get_flashed_messages(with_categories=true) %} + {% for category, message in messages %} +
            {{ message }}
            + {% endfor %} + {% endwith %} + + {% if school_vast %} +

            🏫 {{ gekozen.naam }}

            + {% endif %} + +
            + + +
            +
            + + +
            + + + {% if google_aan %} +
            of
            + + + + Inloggen met Google + + {% endif %} +
            +{% endblock %} + +{% block footer %} +
            + PIVOT – Versie {{ version }} – Kristof Verbruggen +
            +{% endblock %} diff --git a/templates/qr_code.html b/templates/qr_code.html new file mode 100644 index 0000000..79203f6 --- /dev/null +++ b/templates/qr_code.html @@ -0,0 +1,53 @@ +{% extends "base.html" %} + +{% block title %}QR-generator{% endblock %} +{% block page_title %}QR-codegenerator{% endblock %} +{% block main_class %}narrow{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
            +
            +
            +

            💡 QR-codes maken

            +

            Maak eenvoudig QR-codes aan vanuit een Excel-bestand.

            +
            +
            + +
              +
            1. +

              Download het sjabloon

              + 📥 Sjabloon (.xlsx) +
            2. +
            3. +

              Upload je ingevulde bestand

              +
              + + +
              + + +
              + + +
              +
            4. +
            +
            +{% endblock %} diff --git a/templates/scholen.html b/templates/scholen.html new file mode 100644 index 0000000..48181e0 --- /dev/null +++ b/templates/scholen.html @@ -0,0 +1,100 @@ +{% extends "base.html" %} + +{% block title %}Scholen{% endblock %} + +{% block page_title %}Scholen in PIVOT{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
            +
            +

            🏫 Scholen

            +
            +
            + +
            + ➕ Nieuwe school +
            +
            + +
            + + {% for g in groepen %} + + + {% for s in g.scholen %} + + + + + + {% endfor %} + + {% else %} + + + + {% endfor %} +
            + +
            + {{ s.naam }} + {% if not s.actief %}inactief{% endif %} + {% if school and school.code == s.code %}huidige{% endif %} + + {% for b in badges[s.code] %} + {{ {'ok': '✓', 'fout': '✗', 'ongetest': '?'}.get(b.toestand, '–') }} {{ b.label }} + {% endfor %} + + {% if s.actief %} + ↪ Openen + {% endif %} + ✏️ Bewerken +
            + +
            +
            Nog geen scholen. Voeg er een toe, of importeer de oude config.ini met python beheer.py importeer-config.
            +
            +
            +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/school_bewerken.html b/templates/school_bewerken.html new file mode 100644 index 0000000..5244632 --- /dev/null +++ b/templates/school_bewerken.html @@ -0,0 +1,201 @@ +{% extends "base.html" %} + +{% block title %}{{ 'Nieuwe school' if nieuw else school.naam }}{% endblock %} +{% block page_title %}{{ 'Nieuwe school' if nieuw else 'Instellingen school' }}{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{# Ingevulde waarde; is er nog niets ingevuld, dan staat de standaardwaarde al klaar in het veld. #} +{% macro waarde(key, standaard='') -%} +{%- if form.get('v_' ~ key) is not none -%}{{ form.get('v_' ~ key) }} +{%- elif school and school.waarden.get(key) not in (none, '') -%}{{ school.waarden.get(key) }} +{%- else -%}{{ standaard }}{%- endif -%} +{%- endmacro %} + +{% block content %} +
            + +
            +
            +

            🏫 {{ 'Nieuwe school' if nieuw else school.naam }}

            + {% if not nieuw %} +
            + +
            + {% endif %} +
            + + {% if mag_alles %} +
            +
            + + {% if nieuw %} + + Korte, vaste afkorting (kleine letters). Kan later niet meer wijzigen. + {% else %} + + {% endif %} +
            +
            + + +
            +
            + + + Hiermee wordt de school herkend bij Google-login. Het eerste domein is het hoofddomein (nieuwe accounts). +
            +
            + + + Wie PIVOT via dit adres opent, komt meteen bij deze school terecht. +
            +
            + + + {% if not nieuw %} + {% if db_status == 'ok' %} + ✓ Database bestaat en heeft de PIVOT-tabellen. + {% elif db_status in ('ontbreekt', 'leeg') %} + ⚠️ {{ 'Deze database bestaat nog niet.' if db_status == 'ontbreekt' else 'Deze database heeft (nog) niet de PIVOT-tabellen.' }} + + {% endif %} + {% endif %} +
            + {% if nieuw %} +
            + + + +
            + + {% endif %} +
            + + +
            +
            + {% else %} +

            Code {{ school.code }} · domein(en) {{ school.domeinen|join(', ') }} · database {{ school.db_naam }}. + Deze gegevens wijzigt de superbeheerder van de scholengroep.

            + {% endif %} +
            + +{% for groep, titel in groepen %} + {% if groep not in enkel_super or mag_alles %} +
            +
            +

            {{ titel }}

            + + {% if groep == 'google' %} +
            + + {% if school and school.google_sleutel %} +

            ✓ ingesteld + {{ school.google_sleutel.client_email }}

            +

            Client-ID voor domeinbrede delegatie (Admin-console › Beveiliging › API-beheer): + {{ school.google_sleutel.client_id }}

            + + {% else %} +

            nog niet ingesteld

            + {% endif %} + + Upload het JSON-bestand van het serviceaccount van deze school. Het wordt versleuteld bewaard en is nooit via de website op te vragen. +
            + {% endif %} + +
            + {% for v in velden if v.groep == groep %} +
            + + {% if v.soort == 'geheim' %} + {% set ingesteld = school and school.waarden.get(v.key) %} + + {% if ingesteld %} + + {% endif %} + {% elif v.soort == 'janee' %} + {% set aan = (school.get(v.key) if school else v.standaard) %} + + {% elif v.soort == 'keuze' %} + {% set gekozen = waarde(v.key) %} + + {% elif v.soort == 'tekstvak' %} + + {% else %} + + {% endif %} + {% if v.hulp %}{{ v.hulp }}{% endif %} +
            + {% endfor %} +
            +
            +
            + {% endif %} +{% endfor %} + +
            + + {% if session.get('superbeheerder') %} + Annuleren + {% else %} + Annuleren + {% endif %} +
            +
            + +{% if not nieuw %} +
            +{% if mag_alles %} +
            +{% endif %} +{% endif %} +{% endblock %} diff --git a/templates/school_test.html b/templates/school_test.html new file mode 100644 index 0000000..99837e7 --- /dev/null +++ b/templates/school_test.html @@ -0,0 +1,40 @@ +{% extends "base.html" %} + +{% block title %}Verbindingstest{% endblock %} +{% block page_title %}Verbindingstest{% endblock %} + +{% block content %} +
            +

            🩺 {{ school.naam }}

            +
            + + + + {% for onderdeel, ok, detail in resultaten %} + + + + + + {% endfor %} + +
            OnderdeelResultaatDetails
            {{ onderdeel }} + {% if ok is none %}niet ingesteld + {% elif ok %}✓ OK + {% else %}✗ Fout{% endif %} + {{ detail }}
            +
            +
            +
            + +
            + {% if session.get('superbeheerder') %} + ✏️ Instellingen + ← Alle scholen + {% else %} + ← Instellingen + {% endif %} +
            +
            +{% endblock %} diff --git a/templates/sgr_groepen.html b/templates/sgr_groepen.html new file mode 100644 index 0000000..a28cf46 --- /dev/null +++ b/templates/sgr_groepen.html @@ -0,0 +1,151 @@ +{% extends "base.html" %} + +{% block title %}SGR groepen{% endblock %} +{% block page_title %}SGR groepen{% endblock %} +{% block main_class %}wide{% endblock %} +{% block loader_text %}Groepen ophalen uit Smartschool...{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
            +
            +

            👥 SGR groep in Smartschool

            +
            +
            + + +
            + {% if groepen %}⬇️ CSV{% endif %} + 🔄 Opnieuw ophalen +
            +
            + + {% if fout %} +
            {{ fout }}
            + {% elif not groepen %} +

            De groep {{ hoofd.naam }} bestaat in Smartschool, maar heeft geen subgroepen.

            + {% else %} +
            + Subgroepen {{ groepen|length }} + Lidmaatschappen {{ aantal_leden }} + Verschillende personen {{ uniek }} + {% set leeg = groepen|selectattr('leden', 'equalto', [])|selectattr('fout', 'none')|list|length %} + {% if leeg %}Zonder leden {{ leeg }}{% endif %} +
            +
            + + + + + Opgehaald uit Smartschool op {{ opgehaald }} +
            + +
            + {% for g in groepen %} +
            + + {{ g.naam }} + {% if g.code and g.code != g.naam %}{{ g.code }}{% endif %} + {% if g.pad %}in {{ g.pad|join(' › ') }}{% endif %} + {% if g.fout %} + leden niet op te halen + {% else %} + {{ g.leden|length }} {{ 'lid' if g.leden|length == 1 else 'leden' }}{% if g.aantal_sub %} · {{ g.aantal_sub }} subgroep{{ 'en' if g.aantal_sub != 1 }}{% endif %} + {% endif %} + +
            + {% if g.omschrijving and g.omschrijving != g.naam %}

            {{ g.omschrijving }}

            {% endif %} + {% if g.fout %} +
            Leden konden niet opgehaald worden: {{ g.fout }}
            + {% elif not g.leden %} +

            Deze groep heeft momenteel geen rechtstreekse leden.

            + {% else %} +
            + + + + {% for l in g.leden %} + + + + + + + + {% endfor %} + +
            NaamVoornaamGebruikersnaamRolE-mail
            {{ l.naam }}{{ l.voornaam }}{{ l.gebruikersnaam }}{% if l.rol %}{{ l.rol }}{% endif %}{{ l.email }}
            +
            + {% endif %} +
            +
            + {% endfor %} +
            + {% endif %} +

            Toont alle groepen onder {{ groep }} in Smartschool met hun huidige, + rechtstreekse leden. De standaard hoofdgroep ({{ standaard }}) stel je in via 🔌 Koppelingen › Smartschool. + Gegevens worden 10 minuten bewaard.

            +
            +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/signpost_devices.html b/templates/signpost_devices.html new file mode 100644 index 0000000..9fe9a02 --- /dev/null +++ b/templates/signpost_devices.html @@ -0,0 +1,61 @@ +{% extends "base.html" %} + +{% block title %}Signpost DB{% endblock %} +{% block page_title %}Signpost-database{% endblock %} +{% block loader_text %}Synchroniseren met Signpost...{% endblock %} + +{% block content %} +
            +
            +

            📋 Signpost-toestellen

            +
            +
            + +
            +
            +
            + + + + {% if message %} +
            {{ message }}
            + {% endif %} + + {% if results %} +
            + + + + + + + + + + + + + {% for row in results %} + + + + + + + + + {% endfor %} + +
            SignpostnummerSerienummerModelAankoopbedragAankoopdatumGarantie
            {{ row['Signpostnummer'] if row['Signpostnummer'] is defined else row[0] }}{{ row['Device_serienummer'] if row['Device_serienummer'] is defined else row[1] }}{{ row['Device_model'] if row['Device_model'] is defined else row[2] }} + {% set bedrag = row['Aankoopbedrag'] if row['Aankoopbedrag'] is defined else row[3] %} + {% if bedrag %}€ {{ bedrag }}{% else %}-{% endif %} + {{ row['Aankoopdatum'] if row['Aankoopdatum'] is defined else row[4] }}{{ row['Garantie'] if row['Garantie'] is defined else row[5] }}
            +
            + {% else %} +

            Geen records gevonden in de database.

            + {% endif %} +
            +{% endblock %} diff --git a/templates/skore.html b/templates/skore.html new file mode 100644 index 0000000..4efc828 --- /dev/null +++ b/templates/skore.html @@ -0,0 +1,122 @@ +{% extends "base.html" %} + +{% block title %}Skore-synchronisatie{% endblock %} +{% block page_title %}Skore-synchronisatie{% endblock %} +{% block main_class %}medium{% endblock %} +{% block head %}{% if loopt %}{% endif %}{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
            +
            +

            🔁 Skore-synchronisatie

            +
            +
            + +
            +
            +
            +

            Synchroniseert de klassen en leerlingen van Smartschool naar Skore (Smartschool-webservice + startSkoreSync). Smartschool voert de synchronisatie op de achtergrond uit; + PIVOT vraagt de status daarna zelf op tot de synchronisatie klaar is.

            + {% if loopt %}
            ⏳ Er loopt een synchronisatie. Deze pagina ververst elke 20 seconden.
            {% endif %} +
            + +{% if planning %} +
            +

            🗓️ Automatisch synchroniseren

            +
            +
            +
            + + +
            +
            + + +
            +
            + + +
            +
            + +
            + : + +
            +
            +
            +

            + {% if volgende %}Volgende automatische synchronisatie: {{ volgende.strftime('%d-%m-%Y om %H:%M') }}. + {% else %}Er is geen automatische synchronisatie gepland.{% endif %} + PIVOT moet op dat moment draaien; een gemiste synchronisatie wordt nog tot 6 uur later ingehaald. +

            + +
            +
            +{% endif %} + +
            +

            🕑 Laatste synchronisaties

            +
            + + + + {% for r in log %} + + + + + + + + {% else %} + + {% endfor %} + +
            GestartDoorStatusMelding
            {{ r.Gestart.strftime('%d-%m-%Y %H:%M') }}{{ r.Door or '' }}{% if r.Manier == 'gepland' %} gepland{% endif %}{{ r.Status }}{{ r.Melding or '' }}{% if r.Service_ID %}
            taak {{ r.Service_ID }}
            {% endif %}
            + {% if r.Service_ID %} +
            + +
            + {% endif %} +
            Nog geen synchronisaties.
            +
            +
            +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/smartschool_activiteit.html b/templates/smartschool_activiteit.html new file mode 100644 index 0000000..3998a91 --- /dev/null +++ b/templates/smartschool_activiteit.html @@ -0,0 +1,282 @@ +{% extends "base.html" %} + +{% block title %}Co-accounts{% endblock %} +{% block page_title %}Co-accounts{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
            +
            +
            {{ nooit }}
            +
            Nog nooit aangemeld
            + {% if rows %}
            van {{ rows|length }} co-accounts
            {% endif %} +
            +
            +
            {{ leerlingen }}
            +
            Leerlingen met co-accounts
            +
            +
            +
            {% if laatst %}{{ laatst.strftime('%d-%m-%Y %H:%M') }}{% else %}Nog nooit{% endif %}
            +
            Opgehaald uit Smartschool
            +
            Automatisch één keer per dag
            +
            +
            + +{% if fout %}
            {{ fout }}
            {% endif %} +{% if not ingesteld %} +
            Smartschool is nog niet ingesteld voor deze school (🔌 Koppelingen › Smartschool).
            +{% endif %} + +
            +
            +
            + +
            + {% if rows %}⬇️ CSV-export{% endif %} +
            + + {% if status.bezig %}Bezig met ophalen…{% elif status.fout %}⚠️ {{ status.fout }}{% elif status.resultaat %}{{ status.resultaat }}{% endif %} + +
            +
            + +
            +
            + + +
            +
            + + + + + + + + + + + + + + {% for r in rows %} + + + + + + + + + + {% else %} + + {% endfor %} + +
            Klas Leerling Co-account Type E-mail Laatste aanmelding Actie
            {{ r.Klas or '–' }}{{ r.Leerling_naam }} {{ r.Leerling_voornaam }}{{ r.Voornaam }} {{ r.Naam }}{{ r.Type or '' }}{{ r.Email or '' }} + {% if r.Laatste_login %}{{ r.Laatste_login.strftime('%d-%m-%Y %H:%M') }}{% else %}Nooit{% endif %} + + +
            {% if status.bezig %}De co-accounts worden opgehaald uit Smartschool…{% else %}Nog geen co-accounts opgehaald.{% endif %}
            +
            +
            +
            +
            + +
            + +
            +
            +{% endblock %} + +{% block modals %} + +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/smartschool_beheer.html b/templates/smartschool_beheer.html new file mode 100644 index 0000000..26dc23f --- /dev/null +++ b/templates/smartschool_beheer.html @@ -0,0 +1,180 @@ +{% extends "base.html" %} + +{% block title %}Smartschool beheer{% endblock %} +{% block page_title %}Smartschool beheer{% endblock %} +{% block main_class %}medium{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
            +

            🏫 Wachtwoord & info

            + + + + {% if results %} +
            + + + + + + + + + + + {% for user in results %} + + + + + + + {% endfor %} + +
            InternnummerNaamGebruikersnaamActies
            {{ user.internnummer }}{{ user.voornaam }} {{ user.naam }}{{ user.gebruikersnaam }} + + +
            +
            + {% elif query %} +

            Geen resultaten gevonden voor "{{ query }}".

            +

            Op een deel van een naam zoekt PIVOT in de leerlingenlijst. Staat die nog leeg, + haal dan eerst de leerlingen op via Leerlingen › Informat & SS sync, of zoek op het volledige internnummer of de gebruikersnaam.

            + {% endif %} +
            +{% endblock %} + +{% block modals %} + + + +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/template_leerkracht.html b/templates/template_leerkracht.html new file mode 100644 index 0000000..49641b0 --- /dev/null +++ b/templates/template_leerkracht.html @@ -0,0 +1,56 @@ + + + + + + +
            +

            Nieuw Google Account

            +
            + +
            +

            Beste {{ naam }},

            + +

            Je account voor de schoolomgeving is aangemaakt op {{ datum }}.

            + + + +

            Hiermee kan je gebruikmaken van alle schoolgebonden google toepassingen (Drive, Gmail, Classroom, Agenda...).

            + +

            Let op: Je zult gevraagd worden om je wachtwoord te wijzigen bij de eerste keer inloggen.

            + + +
            + +

            Met vriendelijke groeten,

            + + + + + + +
            + K. Verbruggen
            + ICT coördinator
            + {{ contact_email }} +
            + B. Elewaut
            + Directeur
            + directie@kamkersem.be +
            + + \ No newline at end of file diff --git a/templates/toestellen.html b/templates/toestellen.html new file mode 100644 index 0000000..b1baa6d --- /dev/null +++ b/templates/toestellen.html @@ -0,0 +1,416 @@ +{% extends "base.html" %} + +{% block title %}Chromebooks{% endblock %} +{% block page_title %}Chromebooks{% endblock %} +{% block main_class %}wide{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
            +
            +

            💻 Alle toestellen

            +
            + +
            +
            + + + + {% if message %} +
            {{ message }}
            + {% endif %} + + {% if results %} +
            + + + + + + + + +
            + +
            + + + + + + + + + + + + + + + + {% for row in results %} + {% set g = (row['Garantie'] or '').split('-') %} + + + + + + + + + + + + {% endfor %} + +
            Toestelnummer Signpostnummer Serienummer Type Garantie Opmerking Status
            {{ row['KAMXM_nummer'] or '' }}{{ row['Signpostnummer'] or '' }}{{ row['Serienummer'] or '' }}{{ row['Type'] or '' }}{{ row['Garantie'] or '' }}{{ row['Opmerking'] or '' }} + {% if row['Status'] == 'Defect' %}🛠️ Defect + {% elif row['Status'] == 'Uitgeleend' %}🧑‍💻 Uitgeleend + {% else %}✅ Beschikbaar + {% endif %} + 🔒 Geblokkeerd + + + + {% if mag('toestel_blokkeren') and row['Serienummer'] %} + {% set blok = row['Geblokkeerd']|int == 1 %} + + {% endif %} + {% if row['Serienummer'] %} + + {% endif %} + + + + +
            +
            +
            +
            +
            + +
            + +
            + {% elif query %} +

            Geen resultaten gevonden voor "{{ query }}".

            + {% endif %} +
            +{% endblock %} + +{% block modals %} + + + + + + + +{% endblock %} + +{% block scripts %} + + +{% endblock %} diff --git a/templates/tweestaps.html b/templates/tweestaps.html new file mode 100644 index 0000000..7a7f524 --- /dev/null +++ b/templates/tweestaps.html @@ -0,0 +1,354 @@ +{% extends "base.html" %} + +{% block title %}2FA-hulp{% endblock %} +{% block page_title %}2FA-hulp{% endblock %} +{% block main_class %}{{ '' if tab == 'lijst' else 'medium' }}{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} + + +{% if tab != 'lijst' %} +
            + {# ---------- Zoeken ---------- #} + + + {% if zoekresultaten %} + + {% endif %} + + {% if fout %}
            {{ fout }}
            {% endif %} + + {# ---------- Gekozen gebruiker ---------- #} + {% if gebruiker %} +
            +
            + {{ (gebruiker.naam or gebruiker.email)[:1]|upper }} +
            + {{ gebruiker.naam }} + {{ gebruiker.email }} · {{ gebruiker.ou }} +
            +
            + {% if gebruiker.ingesteld %}✓ 2FA ingesteld + {% else %}2FA niet ingesteld{% endif %} + {% if uitzondering %}⏳ Uitzondering tot {{ uitzondering.Tot.strftime('%H:%M') }} + {% elif gebruiker.afgedwongen %}Verplicht{% endif %} + {% if gebruiker.geschorst %}Geschorst{% endif %} +
            +
            + + {# ---------- Codes tonen ---------- #} + {% if codes %} +
            +
            Back-upcode voor {{ gebruiker.email }}
            +
            {{ codes[0][:4] }} {{ codes[0][4:] }}
            +
            Typ deze code bij "Probeer een andere manier" › "Voer een van je back-upcodes van 8 cijfers in". Elke code werkt maar één keer.
            +
            + Alle {{ codes|length }} geldige codes tonen +
            {% for c in codes %}{{ c[:4] }} {{ c[4:] }}{% endfor %}
            +
            +
            + +
            + {% if code_actie == 'nieuw' %}
            Er is een nieuwe set aangemaakt; oudere codes van deze gebruiker werken niet meer.
            {% endif %} +
            + {% endif %} + +
            + {% if mag('tweestaps_codes') %} +
            +

            📱 Telefoon vergeten

            +

            Geef een back-upcode. 2FA blijft gewoon aan; de code werkt één keer.

            +
            + + + + +
            +
            + {% endif %} + + {% if mag('tweestaps_uitzetten') and reset_wacht %} +
            +

            ⏳ 2FA-reset wacht op Google

            +

            Gevraagd om {{ reset_wacht.Gevraagd_op.strftime('%H:%M') }} door {{ reset_wacht.Door or '?' }}. + {{ gebruiker.email }} staat al in de uitzonderingsgroep, maar Google past dat pas na enkele minuten toe. + PIVOT probeert elke minuut opnieuw (al {{ reset_wacht.Pogingen }} poging(en)), tot {{ reset_wacht.Tot.strftime('%H:%M') }}. + Je hoeft niets te doen.

            +

            Google meldt nu: 2FA afgedwongen voor deze gebruiker: {{ 'ja (uitzondering nog niet toegepast)' if gebruiker.afgedwongen else 'nee, de reset volgt bij de volgende poging' }}. + {% if reset_wacht.Laatste_fout %}
            Laatste antwoord van Google: {{ reset_wacht.Laatste_fout }}{% endif %}

            +

            Moet de gebruiker nu meteen binnen? Toon hierboven een back-upcode: daarmee logt hij in en stelt hij zijn nieuwe telefoon in via myaccount.google.com › Beveiliging › Authenticatie in 2 stappen. Dat werkt meteen, ook als 2FA afgedwongen wordt.

            + 🔄 Status vernieuwen +
            + {% elif mag('tweestaps_uitzetten') and gebruiker.ingesteld %} +
            +

            📵 Nieuwe telefoon: 2FA resetten

            +

            💡 Snelste weg bij een nieuwe telefoon: geef de gebruiker een back-upcode (hierboven). Hij logt daarmee in en koppelt zelf zijn nieuwe telefoon. Resetten is pas nodig als dat niet lukt.

            +

            Zet 2FA uit zodat de gebruiker het opnieuw kan instellen met een nieuw toestel. Oude back-upcodes en het oude toestel werken daarna niet meer. + {% if gebruiker.afgedwongen %}Omdat 2FA verplicht is, krijgt hij ook een tijdelijke uitzondering tot vandaag {{ einde.strftime('%H:%M') }} om opnieuw in te stellen.{% endif %}

            +
            + +
            + +
            + +
            +
            + {% endif %} + + {% if mag('tweestaps_uitzetten') %} +
            +

            ⏳ Tijdelijk zonder 2FA

            + {% if uitzondering %} +

            Deze gebruiker kan tot {{ uitzondering.Tot.strftime('%d-%m %H:%M') }} zonder 2FA inloggen. Daarna wordt 2FA automatisch terug verplicht.

            +
            + {% if uitzondering.Tot < einde %} +
            + + +
            + {% endif %} +
            + + +
            +
            + {% elif not groep %} +

            Nog niet ingesteld: vul de Uitzonderingsgroep in bij + {% if mag('koppelingen') %}🔌 Koppelingen van deze school{% else %}🔌 Koppelingen van deze school{% endif %} + › 🛡️ 2FA-hulp (een Google-groep waarvoor 2FA niet afgedwongen wordt).

            + {% else %} +

            Voor wie 2FA nog moet instellen, of als een back-upcode niet lukt. De uitzondering loopt tot + vandaag {{ einde.strftime('%H:%M') }}; daarna zet PIVOT 2FA automatisch terug aan.

            +
            + +
            + +
            + +
            + {% endif %} +
            + {% endif %} +
            +
            + {% endif %} +
            + +{# ---------- Lopende uitzonderingen ---------- #} +
            +

            ⏳ Lopende uitzonderingen

            + {% if actief %} +
            + + + + {% for u in actief %} + + + + + + + + {% endfor %} + +
            GebruikerTotGegeven doorRedenActie
            {{ u.Naam or u.Email }}
            {{ u.Email }}
            + {{ u.Tot.strftime('%H:%M') if u.Tot.date() == nu.date() else u.Tot.strftime('%d-%m %H:%M') }} + {% if u.Tot <= nu %}wordt beëindigd{% endif %} + {{ u.Door or '?' }}{{ u.Reden or '' }} + {% if mag('tweestaps_uitzetten') %} +
            + +
            + {% endif %} +
            +
            + {% else %} +

            Geen lopende uitzonderingen. Iedereen moet 2FA gebruiken.

            + {% endif %} +
            + +{# ---------- Logboek ---------- #} +{% if logboek %} +
            +
            +

            🕑 Recente acties

            + {{ log_totaal }} actie(s) +
            +
            + + + + {% for l in logboek %} + + + + + + + + {% endfor %} + +
            TijdstipDoorActieGebruikerDetails
            {{ l.Tijdstip.strftime('%d-%m %H:%M') }}{{ l.Door or '?' }}{{ l.Actie }}{{ l.Email }}{{ l.Details or '' }}
            +
            + {% set extra = {'email': gebruiker.email} if gebruiker else {} %} +
            +
            + {% if gebruiker %}{% endif %} + +
            + {% if log_paginas > 1 %} + + {% endif %} +
            +
            +{% endif %} + +{% else %} +{# ---------- Tab: nog geen 2FA ingesteld ---------- #} +
            +
            +
            +

            Nog geen 2FA ingesteld

            +
            Actieve accounts in {{ ous }} zonder 2FA
            +
            + +
            + {% if lijst_fout %}
            {{ lijst_fout }}
            {% endif %} + {% if lijst is not none %} +
            + + + + + + + {% set uitz = actief|map(attribute='Email')|list %} + {% for u in lijst %} + + + + + + {# volgorde bij sorteren: kan niet inloggen → uitzondering → nog niet verplicht; daarbinnen op naam #} + + + + {% else %} + + {% endfor %} + +
            Naam E-mail OULaatste login Status
            {{ u.naam }}{{ u.email }}{{ u.ou }}{{ u.laatste_login or 'Nooit' }} + {% if u.email in uitz %}Uitzondering + {% elif u.afgedwongen %}Kan niet inloggen + {% else %}Nog niet verplicht{% endif %} + Helpen
            🎉 Iedereen heeft 2FA ingesteld.
            +
            + {% if lijst %}

            {{ lijst|length }} account(s). "Kan niet inloggen": voor deze mensen wordt 2FA afgedwongen maar ze hebben het nog niet ingesteld. Geef ze een tijdelijke uitzondering zodat ze het kunnen instellen.

            {% endif %} + {% endif %} +
            +{% endif %} +{% endblock %} + +{% block scripts %} + + +{% endblock %} diff --git a/templates/uitdelen.html b/templates/uitdelen.html new file mode 100644 index 0000000..44a9596 --- /dev/null +++ b/templates/uitdelen.html @@ -0,0 +1,158 @@ +{% extends "base.html" %} + +{% block title %}Uitlenen{% endblock %} +{% block page_title %}Toestel uitdelen{% endblock %} +{% block main_class %}medium{% endblock %} + +{% block head %} + +{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
            +
            +

            Toestelgegevens

            +
            +
            + + +
              + {% if request.args.get('naam') %}

              👤 {{ request.args.get('naam') }}

              {% endif %} +
              +
              + + +
                +
                + + + +
                +
                + +
                +

                📷 Scanner

                + + {# Zonder https geeft de browser geen live camera: dan een foto nemen met de camera-app en de code daaruit lezen #} + + + +
                + +
                +
                Gescande waarde
                + - +
                + + +
                +
                +
                +
                +{% endblock %} + +{% block scripts %} +{% set toestel_voorkeur = 'beschikbaar' %}{% include '_toestel_zoekveld.html' %} + +{% endblock %} diff --git a/templates/verzendlijst.html b/templates/verzendlijst.html new file mode 100644 index 0000000..5bc5934 --- /dev/null +++ b/templates/verzendlijst.html @@ -0,0 +1,119 @@ +{% extends "base.html" %} + +{% block title %}{{ groep.naam }}{% endblock %} +{% block page_title %}Verzendlijst{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +

                ← Alle verzendlijsten

                + +
                +
                +

                📮 {{ groep.naam }}

                +

                {{ groep.email }}

                + {% if groep.aliassen %}

                Ook bereikbaar via: {{ groep.aliassen|join(', ') }}

                {% endif %} + +
                +
                + + +
                +
                + + +
                + {% if huidig_wie %} +
                + + +
                + {% else %} +

                "Wie mag mailen" kan hier niet getoond worden: daarvoor moet de scope + apps.groups.settings bij het serviceaccount staan en de Groups Settings API aan staan.

                + {% endif %} + +
                +
                + +
                +
                +

                👥 Leden ({{ leden|length }})

                +
                +
                +
                + + +
                +
                + + +
                +
                + + {% if leden|length > 10 %} + + {% endif %} +
                + + + + {% for m in leden %} + + + + + + {% else %} + + {% endfor %} + +
                E-mailadresSoortRol
                {{ m.email }}{{ {'USER': 'Gebruiker', 'GROUP': 'Verzendlijst', 'CUSTOMER': 'Hele organisatie', 'EXTERNAL': 'Extern'}.get(m.soort, m.soort) }} + +
                + + +
                +
                + + +
                +
                +
                Deze verzendlijst heeft nog geen leden.
                +
                +
                +
                +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/verzendlijsten.html b/templates/verzendlijsten.html new file mode 100644 index 0000000..ff2f0e9 --- /dev/null +++ b/templates/verzendlijsten.html @@ -0,0 +1,166 @@ +{% extends "base.html" %} + +{% block title %}Verzendlijsten{% endblock %} +{% block page_title %}Verzendlijsten{% endblock %} +{% block loader_text %}Verzendlijsten ophalen bij Google...{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
                +
                + ➕ Nieuwe verzendlijst +
                +
                + +
                + + {% if domeinen %} + @ + + {% endif %} +
                +
                +
                + + +
                +
                + + +
                +
                + + +
                +
                +
                +
                +
                + +
                +
                +

                📮 Verzendlijsten

                + 🔄 Vernieuwen +
                + + {% if fout %} +
                {{ fout }}
                + {% else %} +
                + +
                +
                + + + + + + + + + + + + {% for g in lijsten %} + + + + + + + + {% else %} + + {% endfor %} + +
                Naam E-mailadres Leden Beschrijving
                {{ g.naam }}{{ g.email }}{% if g.aliassen %}
                ook: {{ g.aliassen|join(', ') }}{% endif %}
                {{ g.leden }}{{ g.beschrijving }}✏️ Openen
                Er zijn nog geen verzendlijsten in Google.
                +
                +
                +
                +
                + +
                + +
                + {% endif %} +
                +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/tenancy.py b/tenancy.py new file mode 100644 index 0000000..67d7813 --- /dev/null +++ b/tenancy.py @@ -0,0 +1,747 @@ +""" +Meerdere scholen in één PIVOT. + +Elke school staat als één rij in de tabel `Scholen` van de CENTRALE database +(config.ini › [database] › centrale_database). Per school worden bijgehouden: + - code, naam, e-maildomein(en) en eventueel eigen hostnamen + - de eigen PIVOT-database van die school + - alle koppelingen (Smartschool, Informat, Google, Signpost, ...) in `instellingen` + - het Google-serviceaccount (JSON-sleutel) +Geheimen (wachtwoorden, client secrets, de Google-sleutel) worden versleuteld +opgeslagen met de sleutel uit de omgevingsvariabele PIVOT_SLEUTEL (of het +bestand geheimen/pivot.key). + +De 'huidige school' wordt per request bepaald (zie app.py) en kan buiten een +request gezet worden met `in_school(school)` (achtergrondtaken). +`Config.SS_API_PW` e.d. geven automatisch de waarde van de huidige school. +""" +import contextlib +import contextvars +import json +import re +import os +import sys +import threading +import time +from dataclasses import dataclass, field +from datetime import date + +from cryptography.fernet import Fernet, InvalidToken + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) + + +class GeenSchoolFout(RuntimeError): + """Er is geen school gekozen voor deze actie.""" + + +class KoppelingOntbreekt(RuntimeError): + """Een koppeling (bv. de Google-sleutel) is voor deze school nog niet ingesteld.""" + + +# ===================================================================== +# Velden per school +# ===================================================================== +@dataclass +class Veld: + key: str # naam zoals gebruikt in de code (Config.) + label: str + groep: str + soort: str = 'tekst' # tekst | geheim | getal | janee | tekstvak | keuze + standaard: object = '' + hulp: str = '' + keuzes: tuple = () # bij soort 'keuze': ((waarde, label), ...) + + +GROEPEN = [ + ('algemeen', '🏫 Algemeen'), + ('database', '🗄️ Database'), + ('smartschool', '📘 Smartschool'), + ('informat', '📗 Informat'), + ('google', '🔑 Google Workspace'), + ('tweestaps', '🛡️ 2FA-hulp'), + ('personeel', '👩‍🏫 Personeel'), + ('co2', '🌬️ CO2-sensoren'), + ('m4s', '🔧 Signpost / M4S'), +] + +ONDERWIJSNIVEAUS = (('kleuter', 'Kleuteronderwijs'), ('basis', 'Basisonderwijs'), ('secundair', 'Secundair onderwijs')) + +VELDEN = [ + # --- algemeen --- + Veld('ADRES', 'Adres', 'algemeen', hulp='Straat, nummer, postcode en gemeente'), + Veld('ONDERWIJSNIVEAU', 'Onderwijsniveau', 'algemeen', 'keuze', keuzes=ONDERWIJSNIVEAUS, + hulp='Voor de subtotalen bij de leerlingenaantallen'), + Veld('INSTELLINGSNUMMER', 'Instellingsnummer (officieel)', 'algemeen', + hulp='Officieel instellingsnummer van de school (Onderwijs Vlaanderen)'), + Veld('ADMIN_EMAIL', 'Beheerder (Google-admin)', 'algemeen', + hulp='Account waarin het serviceaccount handelt, bv. ict@school.be'), + Veld('CONTACT_EMAIL', 'Contactadres op brieven/PDF', 'algemeen', + hulp='Leeg = beheerder'), + Veld('SCHOOLYEAR', 'Schooljaar', 'algemeen', + hulp='bv. 2026-27. Leeg = automatisch (wisselt op 1 september)'), + Veld('STANDAARD_WACHTWOORD', 'Startwachtwoord bij Google-reset', 'algemeen', 'tekst', 'Welkom01!', + hulp='Leeg = Welkom01!. Gebruikers moeten het bij de eerste login wijzigen'), + + # --- database --- + Veld('DB_HOST', 'Server', 'database', hulp='Leeg = dezelfde server als de centrale database'), + Veld('DB_USER', 'Gebruiker', 'database', hulp='Leeg = standaardgebruiker uit config.ini'), + Veld('DB_PASSWORD', 'Wachtwoord', 'database', 'geheim', hulp='Leeg = standaardwachtwoord uit config.ini'), + + # --- smartschool --- + Veld('SS_API_PLATFORM', 'Platform', 'smartschool', hulp='bv. school.smartschool.be'), + Veld('SS_API_PW', 'Webservice-wachtwoord', 'smartschool', 'geheim', + hulp='Smartschool › Beheer › Webservices'), + Veld('SS_CLIENT_ID', 'OneRoster client-ID', 'smartschool'), + Veld('SS_CLIENT_SECRET', 'OneRoster client-secret', 'smartschool', 'geheim'), + Veld('AFWEZIGHEID_CODES', 'Afwezigheidscodes bewaren', 'smartschool', standaard='B', + hulp='Komma-gescheiden, bv. B of B,R'), + Veld('LAATKOMER_CODES', 'Codes voor te laat', 'smartschool', standaard='L', + hulp='Afwezigheidscodes in Smartschool die "te laat" betekenen (voor Laatkomers), komma-gescheiden, bv. L'), + Veld('AFWEZIGHEID_SYNC_UUR', 'Uur dagelijkse synchronisatie', 'smartschool', 'getal', 6), + Veld('SS_SGR_GROEP', 'Hoofdgroep SGR groepen', 'smartschool', standaard='groepen GO-Antwerpen', + hulp='Naam of code van de Smartschool-groep waarvan PIVOT de subgroepen en hun leden toont (Smartschool › SGR groepen)'), + + # --- informat --- + Veld('INFORMAT_INST_NUMBER', 'Instellingsnummer Informat', 'informat', + hulp='Nummer waarmee PIVOT zich bij Informat aanmeldt, bv. 040923. Haalt de school haar leerlingen uit ' + 'meer dan één instellingsnummer, zet ze dan allemaal hier met een naam: nummer=naam, gescheiden door ' + 'komma\'s, bv. 040774=Atheneum Irishof, 040791=Middenschool. PIVOT telt ze samen en toont ze apart.'), + Veld('INFORMAT_CAMPUSSEN_RAW', 'Campussen', 'informat', + hulp='Enkel voor een school met meerdere campussen: code=naam per campus, bv. 1=Campus Kapellen, ' + '3=Campus Stabroek. De code is de waarde in het Campusveld hieronder (of anders het Discimus-nummer uit ' + 'iLeerlingen › Algemeen › Structuur).'), + Veld('INFORMAT_ID', 'Client-ID', 'informat'), + Veld('INFORMAT_SECRET', 'Client-secret', 'informat', 'geheim'), + Veld('INFORMAT_SCOPE', 'Scope', 'informat', standaard='api_informat_sas_leerlingen.leerlingen', + hulp='Het instellingsnummer wordt er automatisch achter gezet'), + Veld('INFORMAT_AUTH_URL', 'Token-URL', 'informat', standaard='https://www.identityserver.be/connect/token'), + Veld('INFORMAT_API_BASE', 'API-URL', 'informat', standaard='https://leerlingenapi.informatsoftware.be/1'), + Veld('INFORMAT_CAMPUS_VELD', 'Campusveld in de leerlingenlijst', 'informat', + hulp='Veld in de Informat-leerlingenlijst dat de campus aangeeft, bv. vestnrDiscimus. Ingevuld: PIVOT telt per waarde van dit veld; de namen haalt ' + 'het uit Campussen (waarde=naam).'), + Veld('INFORMAT_STUDENT_API', 'API-URL Student API (campussen)', 'informat', + standaard='https://studentapi.informatsoftware.be/api/v1'), + Veld('INFORMAT_PERSONEEL_API', 'API-URL Personeel API (GO! Pro)', 'informat', + standaard='https://personeelsapi.informatsoftware.be'), + Veld('INFORMAT_PERSONEEL_SCOPE', 'Scope Personeel API', 'informat', standaard='api_informat_sas_personeel.personeel', + hulp='Het instellingsnummer wordt er automatisch achter gezet. De Informat-client moet deze scope hebben'), + Veld('INFORMAT_FACTUUR_API', 'API-URL Rekeningen API (leerlingfacturatie)', 'informat', + standaard='https://api.informatsoftware.be/invoices/v1', + hulp='GraphQL op /graphql, PDF\'s op /download/...'), + Veld('INFORMAT_INTERNAAT_NUMMER', 'Instellingsnummer internaat', 'informat', + hulp='Instellingsnummer van het internaat in Informat (bv. 123456). Meer dan één: 123456=Internaat A, 234567=Internaat B. ' + 'Leeg = het instellingsnummer van de Informat-koppeling hierboven'), + Veld('INFORMAT_INTERNAAT_API', 'API-URL Internaat API', 'informat', standaard='https://internaatapi.informatsoftware.be'), + Veld('INFORMAT_INTERNAAT_SCOPE', 'Scope Internaat API', 'informat', standaard='api_informat_sas_internaat', + hulp='Het instellingsnummer van het internaat wordt er automatisch achter gezet'), + Veld('INFORMAT_FACTUUR_GROEP', 'Toegangsgroep Rekeningen', 'informat', + hulp='Naam van de toegangsgroep in Rekeningen, zoals in Informat (bv. 000448 BS Irishof Kapellen). PIVOT zoekt ' + 'daarmee zelf de juiste scope. Meer dan één: scheiden met een puntkomma'), + Veld('INFORMAT_FACTUUR_SCOPE', 'Scope Rekeningen API', 'informat', + hulp='De volledige scope, bv. api_informat_invoices.invoices.read.2949957b-5dc1-4acc-a7cf-ba322b62cb12_123456 ' + '(toegangsgroep-GUID _ installatie-ID). Informat mailt die naar de eigenaar van de app zodra de school ' + 'toegang geeft tot de rekeningen. Leeg = PIVOT probeert hem zelf te vinden. Meer dan één: komma ertussen'), + + # --- google --- + Veld('GOOGLE_SSO_CLIENTID', 'Google-login client-ID', 'google', + hulp='Leeg = de gedeelde Google-login uit config.ini'), + Veld('GOOGLE_SSO_CLIENTSECRET', 'Google-login client-secret', 'google', 'geheim'), + Veld('STUDENT_OU', 'OU nieuwe leerlingen', 'google', standaard='Leerlingen/Leerlingen', + hulp='Het schooljaar wordt er automatisch achter gezet'), + Veld('OU_NIEUWE_LEERKRACHTEN', 'OU nieuwe leerkrachten', 'google', standaard='/Leerkrachten'), + Veld('CHROMEBOOK_OUS', 'OU\'s van de Chromebooks', 'google', + hulp='Voor "Chromebooks ophalen uit Google": volledige paden, gescheiden door komma\'s (sub-OU\'s tellen mee), ' + 'bv. /ChromeBooks Medewerkers, /ChromeBooks Leerlingen. Leeg = alle Chromebooks van het domein.'), + Veld('GOOGLE_SMTP_SERVER', 'SMTP-server', 'google', standaard='smtp-relay.gmail.com'), + Veld('GOOGLE_SMTP_PORT', 'SMTP-poort', 'google', 'getal', 587), + Veld('GOOGLE_SMTP_SENDER_adres', 'Afzender e-mails', 'google', hulp='bv. noreply@school.be'), + Veld('GOOGLE_SMTP_EMAIL_PASSWORD', 'SMTP-wachtwoord', 'google', 'geheim', + hulp='Leeg bij een relay die op IP-adres werkt'), + Veld('GOOGLE_API_SCOPES_RAW', 'API-scopes', 'google', 'tekstvak', + '\n'.join([ + 'https://www.googleapis.com/auth/admin.directory.user', + 'https://www.googleapis.com/auth/admin.directory.device.chromeos', + 'https://www.googleapis.com/auth/admin.directory.device.mobile', + 'https://www.googleapis.com/auth/admin.directory.device.chromeos.readonly', + 'https://www.googleapis.com/auth/admin.directory.device.mobile.readonly', + 'https://www.googleapis.com/auth/admin.directory.orgunit', + ])), + + # --- 2FA --- + Veld('TWEESTAPS_UITZONDERING_GROEP', 'Uitzonderingsgroep', 'tweestaps', + hulp='Groep waarvoor 2FA NIET wordt afgedwongen'), + Veld('TWEESTAPS_OUS', 'OU\'s voor "nog geen 2FA"', 'tweestaps', standaard='/Leerkrachten'), + + # --- personeel --- + Veld('PERSONEEL_OUS', 'Personeels-OU\'s', 'personeel', standaard='/Leerkrachten'), + Veld('PERSONEEL_DIRECTIE', 'Directie (e-mails)', 'personeel'), + Veld('PERSONEEL_SECRETARIAAT', 'Secretariaat (e-mails)', 'personeel'), + Veld('PERSONEEL_SECRETARIAAT_MAIL', 'Gedeeld secretariaatsadres', 'personeel'), + Veld('PERSONEEL_MAILS', 'E-mails versturen', 'personeel', 'janee', True), + + # --- CO2 --- + Veld('CO2_NETWORK', 'Netwerk (prefix)', 'co2', hulp='bv. 10.166.0. — moet bereikbaar zijn vanaf de PIVOT-server'), + Veld('CO2_START_ADDR', 'Beginadres', 'co2', 'getal', 80), + Veld('CO2_END_ADDR', 'Eindadres', 'co2', 'getal', 155), + Veld('CO2_THRESHOLD', 'Drempel (ppm)', 'co2', 'getal', 900), + Veld('CO2_INTERVAL', 'Scaninterval (min)', 'co2', 'getal', 5), + + # --- M4S / Signpost --- + Veld('M4S_CLIENT_ID', 'Client-ID', 'm4s'), + Veld('M4S_CLIENT_SECRET', 'Client-secret', 'm4s', 'geheim'), + Veld('M4S_INSTITUTIONGUID', 'Institution GUID', 'm4s'), + Veld('M4S_AUTH_URL', 'Token-URL', 'm4s', standaard='https://idp.signpost.eu/oauth2/token'), + Veld('M4S_BASE_URL', 'API-URL', 'm4s', standaard='https://byod-api.signpost.be/v1/field-service'), + Veld('M4S_REPAIR_LOCATION_TYPE', 'Locatietype herstelling', 'm4s', standaard='100'), + Veld('M4S_REPAIR_STATUS', 'Status nieuwe herstelling', 'm4s', standaard='new'), + Veld('M4S_TRUTH_STATEMENT', 'Truth statement', 'm4s', standaard='True'), + Veld('M4S_REPAIR_AT_INTITUTION', 'Herstelling op school', 'm4s', standaard='True'), + Veld('M4S_LANGUAGE_CODE', 'Taalcode', 'm4s', standaard='nl'), + Veld('M4S_CONTACT_FIRST_NAME', 'Contact voornaam', 'm4s'), + Veld('M4S_CONTACT_LAST_NAME', 'Contact achternaam', 'm4s'), + Veld('M4S_CONTACT_EMAIL', 'Contact e-mail', 'm4s'), + Veld('M4S_CONTACT_PHONE', 'Contact telefoon', 'm4s'), + Veld('M4S_REPAIR_STREET', 'Straat', 'm4s'), + Veld('M4S_REPAIR_STREETNUMBER', 'Huisnummer', 'm4s'), + Veld('M4S_REPAIR_ZIPCODE', 'Postcode', 'm4s'), + Veld('M4S_REPAIIR_CITY', 'Gemeente', 'm4s'), + Veld('M4S_REPAIR_COUNTRYCODE', 'Landcode', 'm4s', standaard='BE'), + Veld('M4S_BILLING_FIRST_NAME', 'Facturatie voornaam', 'm4s'), + Veld('M4S_BILLING_LAST_NAME', 'Facturatie achternaam', 'm4s'), + Veld('M4S_BILLING_EMAIL', 'Facturatie e-mail', 'm4s'), + Veld('M4S_BILLING_PHONE', 'Facturatie telefoon', 'm4s'), +] +VELD = {v.key: v for v in VELDEN} + + +def informat_campussen(waarde): + """'040923=Campus Centrum, 041000=Campus Hoogboom' → [('040923', 'Campus Centrum'), ('041000', 'Campus Hoogboom')]. + Zonder naam krijgt een campus 'Campus '; met één nummer is de naam leeg.""" + delen = [d.strip() for d in str(waarde or '').replace(';', ',').split(',') if d.strip()] + uit = [] + for d in delen: + nummer, _, naam = d.partition('=') + nummer, naam = nummer.strip(), naam.strip() + if nummer and nummer not in [n for n, _ in uit]: + uit.append((nummer, naam or (f"Campus {nummer}" if len(delen) > 1 else ''))) + return uit + + +def informat_scope(basis, nummer): + """Informat-scope voor één instellingsnummer: '.'. Eindigt de ingestelde scope al op + '.' (een instellingsnummer), dan wordt dat vervangen.""" + basis = re.sub(r'\.\d+$', '', str(basis or '').strip()) + return f"{basis}.{nummer}" + + +def _automatisch_schooljaar(vandaag=None): + vandaag = vandaag or date.today() + start = vandaag.year if vandaag.month >= 9 else vandaag.year - 1 + return f"{start}-{str(start + 1)[-2:]}" + + +def _lijst(waarde): + return [x.strip() for x in str(waarde or '').replace(';', ',').split(',') if x.strip()] + + +# Afgeleide waarden: oude Config-namen die uit andere velden berekend worden +def _afgeleid(): + return { + 'SCHOOL_DOMAIN': lambda s: s.domein, + 'SCHOOLNAME': lambda s: s.naam, + 'SCHOOLNAME_SHORT': lambda s: s.code, + 'GOOGLE_ADMIN_EMAIL': lambda s: s.get('ADMIN_EMAIL'), + 'CONTACT_EMAIL': lambda s: s.waarden.get('CONTACT_EMAIL') or s.get('ADMIN_EMAIL'), + 'SCHOOLYEAR': lambda s: s.waarden.get('SCHOOLYEAR') or _automatisch_schooljaar(), + 'INFORMAT_SCHOOLYEAR': lambda s: s.get('SCHOOLYEAR'), + 'OU_NEW_STUDENTS': lambda s: f"{s.get('STUDENT_OU')} {s.get('SCHOOLYEAR')}", + 'GOOGLE_API_SCOPES': lambda s: [x.strip() for x in str(s.get('GOOGLE_API_SCOPES_RAW')).split() if x.strip()], + 'SS_API_PLATFORM': lambda s: (s.waarden.get('SS_API_PLATFORM') or '').replace('https://', '') + .replace('http://', '').split('/')[0], + 'SS_WSDL_URL': lambda s: f"https://{s.get('SS_API_PLATFORM')}/Webservices/V3?wsdl", + 'SS_TOKEN_URL': lambda s: f"https://{s.get('SS_API_PLATFORM')}/ims/oneroster/token", + 'SS_BASE_URL': lambda s: f"https://{s.get('SS_API_PLATFORM')}/ims/oneroster/v1p1", + # Eén of meer Informat-instellingsnummers: [(nummer, naam), ...] + 'INFORMAT_CAMPUSSEN': lambda s: informat_campussen(s.waarden.get('INFORMAT_INST_NUMBER')), + # Campussen (vestigingen) binnen het instellingsnummer, op Discimus-nummer: [(discimus_id, naam), ...] + 'INFORMAT_LOCATIES': lambda s: informat_campussen(s.waarden.get('INFORMAT_CAMPUSSEN_RAW')), + # Eerste nummer: voor alles wat maar één nummer kent + 'INFORMAT_INST_NUMBER': lambda s: (informat_campussen(s.waarden.get('INFORMAT_INST_NUMBER')) or [('', '')])[0][0], + 'INFORMAT_FULL_SCOPE': lambda s: informat_scope(s.get('INFORMAT_SCOPE'), s.get('INFORMAT_INST_NUMBER')), + 'DB_NAME': lambda s: s.db_naam, + } + + +AFGELEID = _afgeleid() + + +# ===================================================================== +# Versleuteling +# ===================================================================== +_fernet = None +_ENC = 'enc:' + + +def _sleutelbestand(): + return os.environ.get('PIVOT_SLEUTEL_BESTAND', + os.path.join(os.environ.get('PIVOT_GEHEIMEN', os.path.join(BASE_DIR, 'geheimen')), 'pivot.key')) + + +def fernet(): + global _fernet + if _fernet is None: + sleutel = os.environ.get('PIVOT_SLEUTEL', '').strip() + if not sleutel: + pad = _sleutelbestand() + if os.path.exists(pad): + with open(pad) as f: + sleutel = f.read().strip() + else: + os.makedirs(os.path.dirname(pad), exist_ok=True) + sleutel = Fernet.generate_key().decode() + with open(pad, 'w') as f: + f.write(sleutel) + try: + os.chmod(pad, 0o600) + except OSError: + pass + sys.stderr.write(f"PIVOT: nieuwe versleutelingssleutel aangemaakt in {pad}. " + "MAAK HIER EEN BACK-UP VAN: zonder deze sleutel zijn de opgeslagen geheimen onleesbaar.\n") + _fernet = Fernet(sleutel.encode()) + return _fernet + + +def versleutel(tekst): + if tekst in (None, ''): + return '' + return _ENC + fernet().encrypt(str(tekst).encode()).decode() + + +def ontsleutel(waarde): + if not isinstance(waarde, str) or not waarde.startswith(_ENC): + return waarde + try: + return fernet().decrypt(waarde[len(_ENC):].encode()).decode() + except InvalidToken: + sys.stderr.write("PIVOT: kan een geheim niet ontsleutelen (verkeerde PIVOT_SLEUTEL?).\n") + return '' + + +# ===================================================================== +# School +# ===================================================================== +class School: + def __init__(self, row): + self.id = row['School_ID'] + self.code = row['Code'] + self.naam = row['School_naam'] + self.domeinen = [d.lower().lstrip('@') for d in _lijst(row.get('Domein'))] + self.hostnamen = [h.lower() for h in _lijst(row.get('Hostnamen'))] + self.actief = bool(row.get('Actief', 1)) + self.db_naam = row['Db_naam'] + self.versie = str(row.get('Bijgewerkt') or '') + self.leerlingenaantal = row.get('Leerlingenaantal') + self.leerlingenaantal_sync = row.get('Leerlingenaantal_sync') + self.leerlingenaantal_detail = _json_of_none(row.get('Leerlingenaantal_detail')) + ruw = json.loads(row.get('Instellingen') or '{}') + self.waarden = {k: ontsleutel(v) for k, v in ruw.items()} + for key, kolom in KOLOM_VELDEN.items(): + if row.get(kolom): + self.waarden[key] = row[kolom] + sleutel = ontsleutel(row.get('Google_sleutel') or '') + self.google_sleutel = json.loads(sleutel) if sleutel else None + self.cache = {} # per-school objecten (services, caches); leeg na elke wijziging + self._lock = threading.RLock() # herbruikbaar: een object kan bij aanmaak zelf een object vragen + + @property + def domein(self): + return self.domeinen[0] if self.domeinen else '' + + def get(self, key): + if key in AFGELEID: + return AFGELEID[key](self) + veld = VELD.get(key) + if veld is None: + raise AttributeError(f"Onbekende instelling '{key}'") + waarde = self.waarden.get(key, '') + if waarde in (None, ''): + waarde = veld.standaard + if veld.soort == 'getal': + try: + return int(waarde) + except (TypeError, ValueError): + return int(veld.standaard or 0) + if veld.soort == 'janee': + return str(waarde).lower() in ('1', 'true', 'ja', 'yes', 'on') + return waarde + + def heeft(self, *keys): + """True als alle opgegeven instellingen ingevuld zijn.""" + return all(str(self.waarden.get(k) or '').strip() for k in keys) + + def db_config(self): + from config import Config + return { + 'host': self.waarden.get('DB_HOST') or Config.DB_HOST, + 'user': self.waarden.get('DB_USER') or Config.DB_USER, + 'password': self.waarden.get('DB_PASSWORD') or Config.DB_PASSWORD, + 'database': self.db_naam, + } + + def object(self, sleutel, maak): + """Eén object per school (bv. een service) bijhouden; `maak()` loopt binnen de schoolcontext.""" + obj = self.cache.get(sleutel) + if obj is None: + with self._lock: + obj = self.cache.get(sleutel) + if obj is None: + with in_school(self): + obj = maak() + self.cache[sleutel] = obj + return obj + + def __repr__(self): + return f"" + + +# ===================================================================== +# Register (uit de centrale database) +# ===================================================================== +_register = {'t': 0, 'scholen': {}} +_register_lock = threading.Lock() +CACHE_SECONDEN = 30 + +# Tabel Scholen (zie de PDF-structuur): de velden School_naam, Smartschool_platform_url, Adres, +# Instellingsnummer en Domein staan als echte kolommen; alle andere koppelingen in `Instellingen` (JSON). +SCHEMA = """ +CREATE TABLE IF NOT EXISTS Scholen ( + School_ID INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + Code VARCHAR(32) NOT NULL, + School_naam VARCHAR(255) NOT NULL, + Smartschool_platform_url VARCHAR(255) NULL, + Adres VARCHAR(255) NULL, + Instellingsnummer VARCHAR(20) NULL, + Informat_instellingsnummer VARCHAR(500) NULL, + Onderwijsniveau VARCHAR(20) NULL, + Domein VARCHAR(500) NOT NULL DEFAULT '', + Hostnamen VARCHAR(500) NOT NULL DEFAULT '', + Actief TINYINT NOT NULL DEFAULT 1, + Db_naam VARCHAR(128) NOT NULL, + Instellingen MEDIUMTEXT NULL, + Google_sleutel MEDIUMTEXT NULL, + Leerlingenaantal INT NULL, + Leerlingenaantal_sync DATETIME NULL, + Leerlingenaantal_detail TEXT NULL, + Bijgewerkt DATETIME(6) NULL, + Bijgewerkt_door VARCHAR(255) NULL, + UNIQUE KEY uq_scholen_code (Code) +) ENGINE=InnoDB""" + +# Velden die als eigen kolom in Scholen staan i.p.v. in de JSON `Instellingen` +KOLOM_VELDEN = { + 'SS_API_PLATFORM': 'Smartschool_platform_url', + 'INSTELLINGSNUMMER': 'Instellingsnummer', # officieel nummer + 'INFORMAT_INST_NUMBER': 'Informat_instellingsnummer', # nummer voor de Informat-koppeling + 'ADRES': 'Adres', + 'ONDERWIJSNIVEAU': 'Onderwijsniveau', # kleuter | basis | secundair +} + + +def ensure_schema(): + """Centrale database: Scholen + gebruikers, rechten, logs en bugs (zie rechten.py).""" + from database import central_query + import rechten + # Database die van Windows komt (tabelnamen in kleine letters) op een Linux-server: eerst de namen herstellen, + # anders maakt PIVOT hieronder lege tabellen met hoofdletters aan naast de echte + try: + from services.tabelnamen import herstel_centraal + herstel_centraal() + except Exception as e: + sys.stderr.write(f"PIVOT: tabelnamen centrale database nakijken mislukt: {e}\n") + controleer_oude_tabellen() # eerst controleren, dan pas aanmaken + central_query(SCHEMA, commit=True) + _migreer_informat_nummer() + rechten.ensure_schema() + _kolommen_bijwerken() + + +def _json_of_none(t): + try: + return json.loads(t) if t else None + except ValueError: + return None + + +def _kolommen_bijwerken(): + """Kolommen die later bijkwamen of groter werden (bestaande installaties).""" + from database import central_query + kol = {(r['TABLE_NAME'].lower(), r['COLUMN_NAME'].lower()): r for r in central_query( + "SELECT TABLE_NAME, COLUMN_NAME, CHARACTER_MAXIMUM_LENGTH FROM information_schema.COLUMNS " + "WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN ('Scholen', 'Leerlingenaantal_tellingen')")} + r = kol.get(('scholen', 'informat_instellingsnummer')) + if r and (r['CHARACTER_MAXIMUM_LENGTH'] or 0) < 500: + central_query("ALTER TABLE Scholen MODIFY Informat_instellingsnummer VARCHAR(500) NULL", commit=True) + if ('scholen', 'leerlingenaantal_detail') not in kol: + central_query("ALTER TABLE Scholen ADD COLUMN Leerlingenaantal_detail TEXT NULL AFTER Leerlingenaantal_sync", + commit=True) + if ('scholen', 'onderwijsniveau') not in kol: + central_query("ALTER TABLE Scholen ADD COLUMN Onderwijsniveau VARCHAR(20) NULL AFTER Informat_instellingsnummer", + commit=True) + if ('leerlingenaantal_tellingen', 'detail') not in kol: + central_query("ALTER TABLE Leerlingenaantal_tellingen ADD COLUMN Detail TEXT NULL AFTER Aantal", commit=True) + + +def _migreer_informat_nummer(): + """Vroeger stond het Informat-nummer in de kolom Instellingsnummer. Die kolom is nu voor het officiële + nummer; het Informat-nummer krijgt een eigen kolom. Eénmalig: enkel als die kolom nog niet bestaat.""" + from database import central_query + bestaat = central_query("SELECT COUNT(*) AS n FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() " + "AND TABLE_NAME = 'Scholen' AND COLUMN_NAME = 'Informat_instellingsnummer'")[0]['n'] + if bestaat: + return + central_query("ALTER TABLE Scholen ADD COLUMN Informat_instellingsnummer VARCHAR(500) NULL AFTER Instellingsnummer", + commit=True) + central_query("UPDATE Scholen SET Informat_instellingsnummer = Instellingsnummer, Instellingsnummer = NULL, " + "Bijgewerkt = NOW(6) WHERE Instellingsnummer IS NOT NULL", commit=True) + + +# Tabel → kolom die in de nieuwe structuur moet bestaan. Ontbreekt die, dan staat er nog een +# oude tabel met dezelfde naam (MySQL op Windows maakt geen onderscheid tussen hoofd- en kleine letters). +NIEUWE_KOLOMMEN = {'Scholen': 'School_ID', 'Gebruikers': 'Gebruiker_ID', 'Logs': 'Log_ID', + 'Status': 'Bugstatus_ID', 'Bugs': 'Bug_ID'} + + +def controleer_oude_tabellen(): + """Stopt als er een tabel met dezelfde naam bestaat, maar zonder de nieuwe structuur (en geen nieuwe ernaast).""" + from database import central_query + oud = [] + for tabel, kolom in NIEUWE_KOLOMMEN.items(): + rows = central_query( + "SELECT COUNT(*) AS n, COALESCE(SUM(LOWER(COLUMN_NAME) = LOWER(%s)), 0) AS heeft " + "FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND LOWER(TABLE_NAME) = LOWER(%s)", + (kolom, tabel)) + if rows and rows[0]['n'] and not int(rows[0]['heeft']): + oud.append(tabel) + if oud: + raise RuntimeError( + f"De centrale database bevat nog oude tabellen met dezelfde naam: {', '.join(oud)}. " + "Gebruik een nieuwe, lege database (config.ini › centrale_database) of hernoem die tabellen eerst.") + + +def herlaad(force=False): + """Leest de scholen opnieuw in. Ongewijzigde scholen behouden hun object (en dus hun caches).""" + from database import central_query + with _register_lock: + if not force and time.time() - _register['t'] < CACHE_SECONDEN: + return _register['scholen'] + try: + rows = central_query("SELECT * FROM Scholen ORDER BY School_naam") + except Exception as e: + sys.stderr.write(f"PIVOT: scholen inlezen mislukt: {e}\n") + return _register['scholen'] + oud = _register['scholen'] + nieuw = {} + for r in rows: + s = oud.get(r['Code']) + if s is None or s.versie != str(r.get('Bijgewerkt') or ''): + try: + s = School(r) + except Exception as e: + sys.stderr.write(f"PIVOT: school {r.get('Code')} kan niet geladen worden: {e}\n") + continue + else: + s.leerlingenaantal = r.get('Leerlingenaantal') + s.leerlingenaantal_sync = r.get('Leerlingenaantal_sync') + s.leerlingenaantal_detail = _json_of_none(r.get('Leerlingenaantal_detail')) + nieuw[r['Code']] = s + _register.update(t=time.time(), scholen=nieuw) + return nieuw + + +def alle_scholen(ook_inactief=False): + return [s for s in herlaad().values() if ook_inactief or s.actief] + + +def school_op_code(code, ook_inactief=False): + s = herlaad().get(code or '') + return s if s and (ook_inactief or s.actief) else None + + +def scholen_voor_email(email): + """Alle (actieve) scholen met het e-maildomein van `email`. Meerdere scholen mogen hetzelfde domein delen.""" + domein = (email or '').lower().rsplit('@', 1)[-1] + return [s for s in alle_scholen() if domein and domein in s.domeinen] + + +def school_voor_email(email): + """De (eerste) school met dit e-maildomein; zie scholen_voor_email als het domein gedeeld wordt.""" + lijst = scholen_voor_email(email) + return lijst[0] if lijst else None + + +def school_voor_host(host): + host = (host or '').lower().split(':')[0] + for s in alle_scholen(): + if host in s.hostnamen: + return s + return None + + +def opslaan(code, naam, domeinen, hostnamen, db_naam, actief, waarden, google_sleutel=None, + gebruiker=None, nieuw=False): + """Schrijft een school weg. `waarden` bevat de leesbare waarden; geheimen worden hier versleuteld.""" + from database import central_query + opgeslagen, kolommen = {}, {k: None for k in KOLOM_VELDEN.values()} + for k, v in waarden.items(): + if k not in VELD or v in (None, ''): + continue + if k in KOLOM_VELDEN: + kolommen[KOLOM_VELDEN[k]] = v + continue + opgeslagen[k] = versleutel(v) if VELD[k].soort == 'geheim' else v + sleutel_enc = versleutel(json.dumps(google_sleutel)) if google_sleutel else None + kol = list(kolommen) + if nieuw: + central_query( + "INSERT INTO Scholen (Code, School_naam, Domein, Hostnamen, Db_naam, Actief, Instellingen, Google_sleutel, " + f"{', '.join(kol)}, Bijgewerkt, Bijgewerkt_door) " + f"VALUES (%s,%s,%s,%s,%s,%s,%s,%s,{','.join(['%s'] * len(kol))},NOW(6),%s)", + (code, naam, domeinen, hostnamen, db_naam, int(bool(actief)), json.dumps(opgeslagen), + sleutel_enc, *[kolommen[k] for k in kol], gebruiker), commit=True) + else: + central_query( + "UPDATE Scholen SET School_naam=%s, Domein=%s, Hostnamen=%s, Db_naam=%s, Actief=%s, Instellingen=%s, " + f"Google_sleutel=COALESCE(%s, Google_sleutel), {', '.join(k + '=%s' for k in kol)}, " + "Bijgewerkt=NOW(6), Bijgewerkt_door=%s WHERE Code=%s", + (naam, domeinen, hostnamen, db_naam, int(bool(actief)), json.dumps(opgeslagen), + sleutel_enc, *[kolommen[k] for k in kol], gebruiker, code), commit=True) + herlaad(force=True) + + +def wis_google_sleutel(code, gebruiker=None): + from database import central_query + central_query("UPDATE Scholen SET Google_sleutel=NULL, Bijgewerkt=NOW(6), Bijgewerkt_door=%s WHERE Code=%s", + (gebruiker, code), commit=True) + herlaad(force=True) + + +# ===================================================================== +# Huidige school +# ===================================================================== +_huidige = contextvars.ContextVar('pivot_school', default=None) + + +def huidige_school(verplicht=True): + s = _huidige.get() + if s is None: + try: + from flask import g, has_request_context + if has_request_context(): + s = g.get('school') + except ImportError: + pass + if s is None and verplicht: + raise GeenSchoolFout("Er is geen school gekozen. Log opnieuw in.") + return s + + +@contextlib.contextmanager +def in_school(school): + token = _huidige.set(school) + try: + yield school + finally: + _huidige.reset(token) + + +def met_school(fn): + """Verpakt `fn` zodat hij in een andere thread in de huidige school loopt.""" + school = huidige_school() + + def wrapper(*a, **kw): + with in_school(school): + return fn(*a, **kw) + return wrapper + + +def voor_elke_school(fn, *, vereist=()): + """Voert fn() uit voor elke actieve school (die de opgegeven instellingen heeft).""" + for s in alle_scholen(): + if vereist and not s.heeft(*vereist): + continue + try: + with in_school(s): + fn() + except Exception as e: + sys.stderr.write(f"[{s.code}] {getattr(fn, '__qualname__', fn)}: {e}\n") + + +class per_school: + """ + Vervanger voor een module-niveau object (bv. `service = per_school(GoogleAdminService)`). + Elke school krijgt een eigen instantie, aangemaakt bij het eerste gebruik. + """ + def __init__(self, maak): + self._maak = maak + + def get(self): + return huidige_school().object(('per_school', id(self)), self._maak) + + def __getattr__(self, naam): + return getattr(self.get(), naam) + + def __setattr__(self, naam, waarde): + if naam.startswith('_'): + object.__setattr__(self, naam, waarde) + else: + setattr(self.get(), naam, waarde) + + +def school_singleton(cls, init): + """Eén instantie van `cls` per school. `init(inst)` zet de begintoestand.""" + def maak(): + inst = object.__new__(cls) + init(inst) + return inst + return huidige_school().object(('singleton', cls.__qualname__), maak) + + +class SchoolLijst: + """Lijst die bij elk gebruik uit de huidige school gelezen wordt (bv. toegangsgroepen).""" + def __init__(self, key): + self.key = key + + def _w(self): + return huidige_school().get(self.key) + + def __contains__(self, x): + return x in self._w() + + def __iter__(self): + return iter(self._w()) + + +# ===================================================================== +# Google-serviceaccount +# ===================================================================== +_GEEN = object() + + +def google_credentials(scopes, subject=_GEEN): + """Credentials van het serviceaccount van de huidige school, gedelegeerd naar `subject`. + Wordt een subject gevraagd maar is het leeg (bv. 'Beheerder (Google-admin)' niet ingevuld), dan stoppen + we met een duidelijke melding: zonder subject geeft Google enkel een vaag 'Invalid Input'.""" + from google.oauth2 import service_account + s = huidige_school() + if not s.google_sleutel: + raise KoppelingOntbreekt(f"Voor {s.naam} is nog geen Google-serviceaccount (JSON-sleutel) ingesteld. " + "Een beheerder kan die toevoegen via 🔌 Koppelingen van deze school.") + if subject is not _GEEN and not (subject or '').strip(): + raise KoppelingOntbreekt(f"Voor {s.naam} is 'Beheerder (Google-admin)' nog niet ingevuld " + "(🔌 Koppelingen van deze school › Algemeen, bv. ict@school.be). " + "PIVOT werkt in Google namens dat account.") + if subject is _GEEN: + subject = None + creds = service_account.Credentials.from_service_account_info(s.google_sleutel, scopes=list(scopes)) + return creds.with_subject(subject) if subject else creds