Files
Pivot/services/internaat_service.py
T
2026-10-14 12:29:29 +02:00

234 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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.<instellingsnummer van het internaat>
(meerdere instellingsnummers mogen in één token, gescheiden door een spatie).
- GET <API-URL>/pupils?schoolYear=2026-27&refdate=JJJJ-MM-DD → de internen met hun gegevens, relaties, ...
- GET <API-URL>/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