""" LGCC Agenda Module v1.0 — Fase A PVA "Proactieve Agendabesturing" ================================================================== Excel-agenda (jaarprogramma DG) als bron van waarheid: - tabel agenda_items (idempotente migratie bij app-start) - idempotente sync via .xlsx-upload (openpyxl, kolommen flexibel herkend) - statussen blijven behouden bij her-upload op (datum + titel)-match - JSON-endpoints voor dashboard-widgets - alerts-helper "Komende agendapunten" (T-14/T-7/T-2) voor de homepage Integratie in app.py (zelfde pattern als newsletter_module): from agenda_module import register_agenda_routes, init_agenda_db, render_agenda_alerts_html init_agenda_db(db) # bij app-start, idempotent register_agenda_routes(app, page, BASE_PATH, ai_complete, db) Lokaal testen (repo): python3 -m pytest tests/ -v Seed laden (standalone): DB_PATH=/pad/naar/lions_gcc.db python3 agenda_module.py seed data/agenda-seed-nieuwsbrief1.json """ from datetime import datetime, date, timedelta from html import escape as _esc from flask import request, jsonify, session import json import os import re MODULE_VERSION = '1.0' # Kolom-herkenning (flexibel): genormaliseerde header -> veld COLUMN_SYNONYMS = { 'datum': ['datum', 'date', 'begindatum', 'startdatum', 'start', 'wanneer', 'dag', 'datum start'], 'titel': ['titel', 'title', 'onderwerp', 'activiteit', 'evenement', 'naam', 'agenda item', 'agendapunt'], 'type': ['type', 'soort', 'categorie', 'event type', 'agendatype'], 'locatie': ['locatie', 'plaats', 'waar', 'venue', 'stad'], 'beschrijving': ['beschrijving', 'omschrijving', 'details', 'toelichting', 'context', 'notities', 'opmerking', 'opmerkingen', 'voorbereiding', 'voorbereidingsbehoeften'], 'datum_eind': ['t/m', 'tm', 'tot', 'einddatum', 'datum t/m', 'einde', 'tot en met', 'datum eind', 'eind'], 'status': ['status'], } # Statusflow (Fase C): open -> voorbereid -> klaar -> afgerond VALID_STATUSES = ('open', 'voorbereid', 'klaar', 'afgerond') DATE_FORMATS = ('%Y-%m-%d', '%d-%m-%Y', '%d/%m/%Y', '%Y/%m/%d', '%d-%m-%y', '%d %m %Y', '%d %B %Y', '%Y-%m-%d %H:%M', '%d-%m-%Y %H:%M') # ────────────────────────────────────────────────────────────────────────────── # DATABASE — idempotente migratie # ────────────────────────────────────────────────────────────────────────────── def init_agenda_db(db_module): """Maak agenda_items aan indien nog niet bestaand. Veilig om herhaald te draaien (bij app-start).""" conn = db_module.get_db() conn.executescript(""" CREATE TABLE IF NOT EXISTS agenda_items ( id INTEGER PRIMARY KEY AUTOINCREMENT, datum TEXT NOT NULL, datum_eind TEXT DEFAULT '', titel TEXT NOT NULL, type TEXT DEFAULT '', locatie TEXT DEFAULT '', beschrijving TEXT DEFAULT '', status TEXT DEFAULT 'open', aangemaakt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, bijgewerkt TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX IF NOT EXISTS idx_agenda_items_datum ON agenda_items(datum); """) conn.commit() conn.close() # ────────────────────────────────────────────────────────────────────────────── # Helpers: datum-parsing & kolom-mapping # ────────────────────────────────────────────────────────────────────────────── def _norm_header(txt): s = str(txt or '').strip().lower() s = re.sub(r'[.:;]+$', '', s) s = re.sub(r'\s+', ' ', s) return s def _parse_date_cell(cell): """Converteer een Excel-cel naar ISO-datum (YYYY-MM-DD) of None.""" if cell is None: return None if isinstance(cell, datetime): return cell.date().isoformat() if isinstance(cell, date): return cell.isoformat() if isinstance(cell, (int, float)) and not isinstance(cell, bool): # Excel serial date (2016 ~ 42000 ... 2030 ~ 55000) if 40000 <= float(cell) <= 60000: try: from openpyxl.utils.datetime import from_excel return from_excel(cell).date().isoformat() except Exception: return None return None s = str(cell).strip() if not s: return None for fmt in DATE_FORMATS: try: return datetime.strptime(s, fmt).date().isoformat() except ValueError: continue m = re.search(r'(\d{4}-\d{2}-\d{2})', s) # 2026-09-21 in tekst if m: return m.group(1) m = re.search(r'(\d{1,2})-(\d{1,2})-(\d{4})', s) # 21-09-2026 in tekst if m: d, mo, y = int(m.group(1)), int(m.group(2)), int(m.group(3)) try: return date(y, mo, d).isoformat() except ValueError: return None return None def _map_headers(header_row): """Koppel kolom-indexen aan velden via synoniemen. Retourneert {veld: index}.""" mapping = {} for idx, cell in enumerate(header_row): h = _norm_header(cell) if not h: continue for field, synonyms in COLUMN_SYNONYMS.items(): if h in synonyms and field not in mapping: mapping[field] = idx break return mapping def _find_header_row(ws, max_scan=15): """Zoek de header-rij: eerste rij met >= 2 herkende kolomnamen waarvan datum of titel.""" for ridx, row in enumerate(ws.iter_rows(min_row=1, max_row=max_scan, values_only=True)): mapping = _map_headers(row) if len(mapping) >= 2 and ('datum' in mapping or 'titel' in mapping): return ridx, row, mapping return None, None, None # ────────────────────────────────────────────────────────────────────────────── # XLSX-PARSING # ────────────────────────────────────────────────────────────────────────────── def parse_agenda_xlsx(fileobj): """ Parse een .xlsx (bytes of file-object) naar agenda-items. Retourneert (items, fouten): items = [{datum, datum_eind, titel, type, locatie, beschrijving, status}] fouten = [omschrijving per overgeslagen rij — rommeldetectie] """ from openpyxl import load_workbook try: wb = load_workbook(fileobj, data_only=True, read_only=True) except Exception as e: return [], [f'Bestand kon niet als .xlsx gelezen worden: {str(e)[:120]}'] ws = wb.active hridx, header_row, mapping = _find_header_row(ws) if header_row is None: wb.close() return [], ['Geen header-rij gevonden: kolommen "datum" en "titel" (of synoniemen) ontbreken.'] items, fouten = [], [] for rnum, row in enumerate(ws.iter_rows(min_row=hridx + 2, values_only=True), start=hridx + 2): if row is None or all(c is None or str(c).strip() == '' for c in row): continue # lege rij overslaan def cell(field): idx = mapping.get(field) return row[idx] if idx is not None and idx < len(row) else None datum = _parse_date_cell(cell('datum')) titel = str(cell('titel') or '').strip() if not datum and not titel: fouten.append(f'Rij {rnum}: geen datum en geen titel — overgeslagen.') continue if not datum: fouten.append(f'Rij {rnum}: "{titel[:40]}" heeft geen geldige datum — overgeslagen.') continue if not titel: fouten.append(f'Rij {rnum}: datum {datum} heeft geen titel — overgeslagen.') continue status = str(cell('status') or '').strip().lower() if status not in VALID_STATUSES: status = 'open' items.append({ 'datum': datum, 'datum_eind': _parse_date_cell(cell('datum_eind')) or '', 'titel': titel, 'type': str(cell('type') or '').strip(), 'locatie': str(cell('locatie') or '').strip(), 'beschrijving': str(cell('beschrijving') or '').strip(), 'status': status, }) wb.close() return items, fouten # ────────────────────────────────────────────────────────────────────────────── # SYNC — idempotent: opnieuw uploaden vervangt agenda, bewaart statussen # ────────────────────────────────────────────────────────────────────────────── def _item_key(datum, titel): return (datum, titel.strip().casefold()) def sync_agenda_from_items(db_module, items): """ Vervangt de agenda door de gegeven items: - match op (datum + titel): velden bijgewerkt, STATUS BEHOUDEN - nieuwe items: toegevoegd met status 'open' (of status uit upload) - items die niet meer voorkomen: verwijderd Retourneert rapport-dict. """ conn = db_module.get_db() try: existing = {} for row in conn.execute("SELECT id, datum, titel, status FROM agenda_items").fetchall(): existing[_item_key(row['datum'], row['titel'])] = row report = {'toegevoegd': 0, 'bijgewerkt': 0, 'status_behouden': 0, 'verwijderd': 0, 'ongewijzigd': 0} seen = set() for it in items: key = _item_key(it['datum'], it['titel']) seen.add(key) if key in existing: row = existing[key] cur = conn.execute( "UPDATE agenda_items SET datum=?, datum_eind=?, titel=?, type=?, locatie=?, " "beschrijving=?, bijgewerkt=CURRENT_TIMESTAMP WHERE id=?", (it['datum'], it['datum_eind'], it['titel'], it['type'], it['locatie'], it['beschrijving'], row['id']) ) # status bewust NIET overschreven; alleen als oude status 'open' is én upload # een expliciet geldige status meegaf if row['status'] == 'open' and it.get('status') and it['status'] in VALID_STATUSES and it['status'] != 'open': conn.execute("UPDATE agenda_items SET status=? WHERE id=?", (it['status'], row['id'])) report['bijgewerkt'] += 1 else: conn.execute( "INSERT INTO agenda_items (datum, datum_eind, titel, type, locatie, beschrijving, status) " "VALUES (?,?,?,?,?,?,?)", (it['datum'], it['datum_eind'], it['titel'], it['type'], it['locatie'], it['beschrijving'], it.get('status') or 'open') ) report['toegevoegd'] += 1 for key, row in existing.items(): if key not in seen: conn.execute("DELETE FROM agenda_items WHERE id=?", (row['id'],)) report['verwijderd'] += 1 # tel bewaarde niet-open statussen report['status_behouden'] = conn.execute( "SELECT COUNT(*) FROM agenda_items WHERE status != 'open'").fetchone()[0] conn.commit() return report finally: conn.close() # ────────────────────────────────────────────────────────────────────────────── # SEED — JSON-lader (agenda-seed-nieuwsbrief1.json) # ────────────────────────────────────────────────────────────────────────────── def load_agenda_seed(db_module, seed_path, replace_existing=False): """ Laad seed-JSON in agenda_items. - vervangt een bestaande agenda NIET tenzij replace_existing=True - gebruikt dezelfde sync-logica (statussen op datum+titel-match blijven staan) Accepteert item-velden: datum, titel, type, locatie, context/beschrijving, einddatum/datum_eind (signaal_t*-velden worden in Fase A genegeerd). """ with open(seed_path, encoding='utf-8') as f: data = json.load(f) raw_items = data.get('items', data if isinstance(data, list) else []) conn = db_module.get_db() count = conn.execute("SELECT COUNT(*) FROM agenda_items").fetchone()[0] conn.close() if count > 0 and not replace_existing: # sync over bestaande set: bewaart statussen, voegt ontbrekende toe report = sync_agenda_from_items(db_module, [ {'datum': it['datum'], 'datum_eind': it.get('einddatum') or it.get('datum_eind') or '', 'titel': it['titel'], 'type': it.get('type', ''), 'locatie': it.get('locatie', ''), 'beschrijving': it.get('context') or it.get('beschrijving') or '', 'status': 'open'} for it in raw_items ]) report['bron'] = data.get('bron', 'seed') return report # lege tabel: direct insert conn = db_module.get_db() for it in raw_items: conn.execute( "INSERT INTO agenda_items (datum, datum_eind, titel, type, locatie, beschrijving) " "VALUES (?,?,?,?,?,?)", (it['datum'], it.get('einddatum') or it.get('datum_eind') or '', it['titel'], it.get('type', ''), it.get('locatie', ''), it.get('context') or it.get('beschrijving') or '') ) conn.commit() conn.close() return {'toegevoegd': len(raw_items), 'bijgewerkt': 0, 'verwijderd': 0, 'status_behouden': 0, 'bron': data.get('bron', 'seed')} # ────────────────────────────────────────────────────────────────────────────── # QUERY-HELPERS — voor pagina's, alerts & dashboard-widgets # ────────────────────────────────────────────────────────────────────────────── STATUS_BADGE = {'open': ('badge-red', '#DC3545'), 'voorbereid': ('badge-gold', '#F5C518'), 'klaar': ('badge-green', '#28A745'), 'afgerond': ('badge-gray', '#868E96')} def get_all_items(db_module): conn = db_module.get_db() rows = conn.execute("SELECT * FROM agenda_items ORDER BY datum ASC, titel ASC").fetchall() conn.close() return [dict(r) for r in rows] def get_upcoming_items(db_module, days=14, ref_date=None): """ Items waarvan de datum binnen `days` dagen valt (vanaf vandaag of ref_date). Voegt toe: dagen_tot, badge ('T-14'|'T-7'|'T-2'|'vandaag'|'verlopen'). Items met datum_eind in de toekomst tellen als lopend (campagnes e.d.). """ today = ref_date or date.today() horizon = today + timedelta(days=days) conn = db_module.get_db() rows = conn.execute( "SELECT * FROM agenda_items WHERE (date(datum) BETWEEN date(?) AND date(?)) " "OR (datum_eind != '' AND date(datum_eind) >= date(?) AND date(datum) <= date(?)) " "ORDER BY datum ASC", (today.isoformat(), horizon.isoformat(), today.isoformat(), horizon.isoformat()) ).fetchall() conn.close() out = [] for r in rows: item = dict(r) d = date.fromisoformat(item['datum']) diff = (d - today).days item['dagen_tot'] = diff if diff < 0: item['badge'] = 'lopend' if item.get('datum_eind') else 'verlopen' elif diff == 0: item['badge'] = 'vandaag' elif diff <= 2: item['badge'] = 'T-2' elif diff <= 7: item['badge'] = 'T-7' else: item['badge'] = 'T-14' out.append(item) return out def _badge_html(item): """Oranje badge voor T-7/T-2 (urgent), goud voor vandaag/lopend, grijs voor T-14.""" badge = item.get('badge', '') diff = item.get('dagen_tot') if badge in ('T-2',): return f'{badge}' if badge == 'T-7': return f'{badge}' if badge in ('vandaag', 'lopend'): return f'{badge}' if badge == 'verlopen': return f'verlopen' return f'{badge}' def render_agenda_alerts_html(db_module, BASE_PATH='', days=14): """ Compacte alert-lijst "Komende agendapunten" voor de homepage/dashboard. Zelfde stijl als bestaande dashboard-cards (class="card", badges). Kan als string in de dashboard-body geinjecteerd worden: agenda_alerts = render_agenda_alerts_html(db, BASE_PATH) """ items = get_upcoming_items(db_module, days=days) if not items: return '' # geen card als er niets op komst is rows = '' for it in items: d = date.fromisoformat(it['datum']) datum_str = d.strftime('%a %d %b') loc = f' · {_esc(it["locatie"])}' if it['locatie'] else '' tpe = f'{_esc(it["type"])}' if it['type'] else '' rows += (f'
' f'
{_esc(it["titel"])}{tpe}' f'
{datum_str}{loc}
' f'
{_badge_html(it)} ' f'{it["dagen_tot"]}d
') return (f'
' f'
' f'

📅 Komende agendapunten

' f'Volledige agenda →
{rows}
') # ────────────────────────────────────────────────────────────────────────────── # FLASK-ROUTES — registreerbaar pattern als newsletter_module # ────────────────────────────────────────────────────────────────────────────── def register_agenda_routes(app, page_func, BASE_PATH, ai_complete_fn, db_module): """Register alle agenda-routes op de Flask-app. Draait ook de idempotente migratie.""" init_agenda_db(db_module) # migratie bij app-start / registratie — idempotent def _admin_ok(): # De productie-app heeft 1 admin-account + globale before_request login-check; # dit is een extra vangnet voor de upload-route. return bool(session.get('authenticated') or session.get('user_id')) # ── GET /agenda — overzichtspagina ──────────────────────────────────── @app.route('/agenda', methods=['GET']) def agenda_overview(): tab = 'lijst' if request.args.get('tab') != 'komend' else 'komend' all_items = get_all_items(db_module) today = date.today() komende = [i for i in all_items if date.fromisoformat(i['datum']) >= today] report = request.args.get('sync') # na upload: toon flash flash_html = '' if report: try: r = json.loads(report) flash_html = (f'
✅ Sync klaar: ' f'{r.get("toegevoegd", 0)} toegevoegd, {r.get("bijgewerkt", 0)} bijgewerkt, ' f'{r.get("verwijderd", 0)} verwijderd, {r.get("status_behouden", 0)} statussen behouden' + (f' · ⚠️ {len(json.loads(request.args.get("fouten", "[]")))} rijen overgeslagen' if request.args.get('fouten') else '') + '
') except Exception: pass def _dossier_kolom(i): """Fase C-koppeling: dossier-knop/link zodra T-14 bereikt (lean, optioneel).""" try: from dossier_module import render_dossier_kolom return render_dossier_kolom(db_module, i, BASE_PATH) except Exception: return '' # dossier_module niet aanwezig → agenda werkt gewoon door def _row(i): d = date.fromisoformat(i['datum']) dstr = d.strftime('%d-%m-%Y') + (f' t/m {date.fromisoformat(i["datum_eind"]).strftime("%d-%m-%Y")}' if i['datum_eind'] else '') scls, scol = STATUS_BADGE.get(i['status'], ('badge-gray', '#868E96')) deind = f' t/m {i["datum_eind"]}' if i['datum_eind'] else '' return (f'{dstr}' f'{_esc(i["titel"])}' + (f'
{_esc(i["beschrijving"][:120])}' if i['beschrijving'] else '') + '' f'{_esc(i["type"] or "—")}' f'{_esc(i["locatie"] or "—")}' f'{_esc(i["status"])}' f'{_dossier_kolom(i)}') empty = ('' 'Nog geen agenda-items. Upload hieronder de Excel-agenda.') rows_all = ''.join(_row(i) for i in all_items) or empty rows_komend = ''.join(_row(i) for i in komende) or empty upload_card = f'''

📤 Excel-agenda uploaden (sync)

Upload de actuele jaaragenda als .xlsx (kolommen flexibel: datum, titel, type, locatie, beschrijving, t/m-einddatum). Her-uploaden is een idempotente sync: de agenda wordt vervangen, maar statussen (open/voorbereid/klaar/afgerond) blijven behouden op datum + titel-match. Ongeldige rijen worden gemeld, niet geïmporteerd.

''' tabs = ('
' f'' f'' '
') body = f''' {flash_html} {tabs}

{'🔜 Komende agendapunten' if tab == 'komend' else '📋 Volledige agenda'}

{rows_komend if tab == 'komend' else rows_all}
DatumTitelTypeLocatieStatusDossier
{upload_card}''' return page_func(body, page_title='Agenda', active='agenda') # ── POST /agenda/upload — admin-only, idempotente sync ──────────────── @app.route('/agenda/upload', methods=['POST']) def agenda_upload(): if not _admin_ok(): return jsonify({'error': 'Admin-sessie vereist'}), 403 import urllib.parse f = request.files.get('file') if not f or not f.filename: return page_func('

❌ Geen bestand ontvangen

' '

← Terug

', page_title='Agenda', active='agenda'), 400 if not f.filename.lower().endswith('.xlsx'): return page_func('

❌ Alleen .xlsx-bestanden

' '

← Terug

', page_title='Agenda', active='agenda'), 400 try: items, fouten = parse_agenda_xlsx(f) except Exception as e: return page_func(f'

❌ Excel kon niet gelezen worden

' f'

{_esc(str(e)[:200])}

' f'

← Terug

', page_title='Agenda', active='agenda'), 400 if not items: return page_func('

❌ Geen geldige agenda-items gevonden

' f'' f'

← Terug

', page_title='Agenda', active='agenda'), 400 report = sync_agenda_from_items(db_module, items) from flask import redirect as _redirect qs = urllib.parse.urlencode({'sync': json.dumps(report), 'tab': 'lijst', 'fouten': json.dumps(fouten) if fouten else ''}) return _redirect(f'{BASE_PATH}/agenda?{qs}') # ── GET /agenda/items — JSON (volledige lijst) ───────────────────────── @app.route('/agenda/items', methods=['GET']) def agenda_items_json(): items = get_all_items(db_module) return jsonify({'count': len(items), 'items': items}) # ── GET /agenda/api/items — JSON voor dashboard-widgets ──────────────── @app.route('/agenda/api/items', methods=['GET']) def agenda_api_items(): try: days = int(request.args.get('days', 14)) except ValueError: days = 14 items = get_upcoming_items(db_module, days=days) return jsonify({'count': len(items), 'days': days, 'generated_at': datetime.now().isoformat(timespec='seconds'), 'items': [{k: i[k] for k in ('id', 'datum', 'datum_eind', 'titel', 'type', 'locatie', 'status', 'dagen_tot', 'badge')} for i in items]}) # ────────────────────────────────────────────────────────────────────────────── # CLI — standalone seed-lader (buiten Flask-app om) # ────────────────────────────────────────────────────────────────────────────── if __name__ == '__main__': import sys import sqlite3 import types if len(sys.argv) < 2 or sys.argv[1] != 'seed': print("Gebruik: DB_PATH=/pad/naar/lions_gcc.db python3 agenda_module.py seed [--replace]") sys.exit(1) db_path = os.environ.get('DB_PATH', '/app/data/lions_gcc.db') if not os.path.exists(db_path): print(f"❌ DB niet gevonden: {db_path} (zet DB_PATH)") sys.exit(1) def _get_db(): c = sqlite3.connect(db_path) c.row_factory = sqlite3.Row c.execute("PRAGMA journal_mode=WAL") return c _db = types.SimpleNamespace(get_db=_get_db) init_agenda_db(_db) replace = '--replace' in sys.argv rep = load_agenda_seed(_db, sys.argv[2], replace_existing=replace) print(f"✅ Seed geladen uit {sys.argv[2]}: {rep}")