""" LGCC Dossier Module v1.0 — Fase C PVA "Proactieve Agendabesturing" =================================================================== AI-voorbereidingsdossier per agenda-item (volgens DOSSIER_DESIGN.md): - tabel agenda_dossiers (idempotente migratie bij registratie) - genereer_dossier(): ai_complete-prompt per agendatype + kennisbank-context (max 3 fragmenten via LIKE-zoekgang op knowledge_docs — géén embeddings, lean) - statusflow concept → definitief (+ 'pending' bij AI-storing; nooit fake-content) - eindredactie: textarea op de detailpagina slaat bewerkte inhoud op als inhoud_md - koppelingen naar bestaande flows (geen duplicaten): · 'Gebruik in nieuwsbrief' → seed in de bestaande newsletter_content_pool · 'Social-ready' → JSON-export in /api/social/generate-payload-formaat Integratie in app.py (zelfde registreerpatroon als agenda_module): from dossier_module import register_dossier_routes, init_dossier_db register_dossier_routes(app, page, BASE_PATH, ai_complete, db) # draait ook de migratie Lokaal testen (repo): python3 -m pytest tests/test_dossier.py -v """ from datetime import date from html import escape as _esc from flask import request, jsonify, session, redirect from urllib.parse import urlencode import re MODULE_VERSION = '1.0' # Statusflow: pending (AI-storing, retry) → concept (AI-levering) → definitief (DG-afvink) DOSSIER_STATUSES = ('concept', 'definitief', 'pending') # Handmatig toegestane overgangen (pending wordt alleen door het systeem gezet) USER_TRANSITIONS = {'concept': {'definitief'}, 'definitief': {'concept'}} STATUS_BADGE = { 'concept': ('badge-gold', '#F5C518'), 'definitief': ('badge-green', '#28A745'), 'pending': ('badge-red', '#DC3545'), } # AI-model-label (ai_complete kiest intern het model; dit is traceerbaarheid) AI_MODEL_LABEL = 'ai_complete' # ────────────────────────────────────────────────────────────────────────────── # DATABASE — idempotente migratie # ────────────────────────────────────────────────────────────────────────────── def init_dossier_db(db_module): """Maak agenda_dossiers aan indien nog niet bestaand. Veilig herhaaldelijk te draaien.""" conn = db_module.get_db() conn.executescript(""" CREATE TABLE IF NOT EXISTS agenda_dossiers ( id INTEGER PRIMARY KEY AUTOINCREMENT, agenda_item_id INTEGER NOT NULL, gegenereerd_op TIMESTAMP DEFAULT CURRENT_TIMESTAMP, type TEXT DEFAULT '', status TEXT DEFAULT 'concept', inhoud_md TEXT DEFAULT '', ai_model TEXT DEFAULT '' ); CREATE INDEX IF NOT EXISTS idx_agenda_dossiers_item ON agenda_dossiers(agenda_item_id); """) conn.commit() conn.close() # ────────────────────────────────────────────────────────────────────────────── # Type-normalisatie (agenda-item-type → dossier-template) # ────────────────────────────────────────────────────────────────────────────── def norm_type(agenda_type): """Koppel het vrije agenda-type aan een template uit DOSSIER_DESIGN.md.""" t = (agenda_type or '').strip().lower() if any(k in t for k in ('kabinets', 'kabinet', 'gmt', 'glt', 'gst', 'bestuursvergadering')): return 'kabinetsvergadering' if 'gouverneursraad' in t: return 'gouverneursraad' if 'convent' in t or 'europaforum' in t: return 'conventie' if 'zone' in t: return 'zonebijeenkomst' if 'nieuwsbrief' in t or 'newsletter' in t: return 'nieuwsbrief' if 'campagne' in t: return 'campagne' if 'training' in t or 'workshop' in t: return 'training' return 'standaard' # Type-specifieke focus per DOSSIER_DESIGN.md ("Per type") TYPE_FOCUS = { 'kabinetsvergadering': ( '- Agenda-opbouw van de vergadering (opening, jaarthema-moment, per team)\n' '- Verwachte bespreekpunten per team (GMT / GLT / GST)\n' '- Aansluiting op het jaarthema "Samen Verder Bouwen"\n' '- Actiepunten uit de vorige vergadering (opvolging)' ), 'gouverneursraad': ( '- Beleidsstemmingen die op de agenda kunnen komen\n' '- Clubstatus: aandachtspunten per club/zone\n' '- Notulen-vragenlijst: welke punten uit de vorige notulen zijn nog open' ), 'conventie': ( '- Reislogistiek (heen/terug, verblijf, timing)\n' '- Speech van 5 minuten (opbouw + kernboodschap)\n' '- Vertegenwoordiging MD110: wie vertegenwoordigt wat\n' '- Netwerkdoelen: wie moet de DG deze conventie spreken' ), 'zonebijeenkomst': ( '- Zone-info: bijzonderheden per zone\n' '- Clubprestaties: recente hoogtepunten per club\n' '- Boodschap per zone (kickoffjaar — inspireer en activeer)' ), 'nieuwsbrief': ( '- Sectie-indeling volgens nieuwsbrief #1 als template\n' '- Agenda-extract: komende activiteiten die de moeite waard zijn\n' '- Campagne-status: waar staan de lopende campagnes\n' '- Call-to-action per team (GMT/GLT/GST-contacten)' ), 'campagne': ( '- KPI-momenten: bereik en NAWE-leads\n' '- Materialenstatus: wat is beschikbaar, wat ontbreekt\n' '- Tussenevaluatie-momenten inplannen' ), 'training': ( '- Deelnemerslijst-vragen: wie komt, wie ontbreekt nog\n' '- Locatie en logistiek\n' '- Uitnodigingsworkflow (T-20 = uitnodiging versturen!)' ), 'standaard': ( '- Wat is hiervoor concreet nodig (mensen, materialen, tijd)\n' '- Wat kan misgaan en hoe voorkom je dat' ), } # ────────────────────────────────────────────────────────────────────────────── # Kennisbank-context — lean LIKE-zoekgang (géén embeddings) # ────────────────────────────────────────────────────────────────────────────── STOPWOORDEN = { 'de', 'het', 'een', 'en', 'van', 'in', 'op', 'voor', 'met', 'te', 'dat', 'die', 'the', 'and', 'for', 'with', 'vanuit', 'naar', 'bij', 'aan', 'is', 'zijn', } def _zoekwoorden(item): """Belangrijkste woorden uit titel, type en beschrijving (voor LIKE-zoekgang).""" tekst = ' '.join(filter(None, [item.get('titel', ''), item.get('type', ''), item.get('beschrijving', '')])).lower() woorden = [w for w in re.findall(r'[a-zà-ÿ]{4,}', tekst) if w not in STOPWOORDEN] out, seen = [], set() for w in woorden: # uniek, volgorde van belangrijkheid (titel eerst) if w not in seen: out.append(w) seen.add(w) return out[:6] def zoek_kennisbank_fragmenten(db_module, item, max_frag=3): """ Max `max_frag` relevante fragmenten uit de kennisbank (tabel knowledge_docs). Lean: LIKE op original_name (titel) én content_text; fragment van ~600 tekens rond de eerste match. Tabel niet aanwezig of geen treffers → [] (context is optioneel; generatie draait gewoon door). """ woorden = _zoekwoorden(item) if not woorden: return [] conn = db_module.get_db() fragmenten, gezien = [], set() try: for w in woorden: if len(fragmenten) >= max_frag: break pat = f'%{w}%' try: rows = conn.execute( "SELECT id, original_name, content_text FROM knowledge_docs " "WHERE original_name LIKE ? OR content_text LIKE ? LIMIT 3", (pat, pat)).fetchall() except Exception: break # tabel bestaat niet → geen context for r in rows: if r['id'] in gezien: continue gezien.add(r['id']) tekst = (r['content_text'] or '').strip() if not tekst: continue pos = tekst.lower().find(w) start = max(0, pos - 250) if pos >= 0 else 0 fragmenten.append({ 'titel': r['original_name'], 'fragment': '…' + tekst[start:start + 600] + '…', }) if len(fragmenten) >= max_frag: break finally: conn.close() return fragmenten # ────────────────────────────────────────────────────────────────────────────── # GENERATIE — ai_complete met prompt per type (DOSSIER_DESIGN.md) # ────────────────────────────────────────────────────────────────────────────── SYSTEM_PROMPT = ( 'Je bent de persoonlijke voorbereidingsassistent van de Districtsgouverneur van ' 'Lions Clubs International, District 110AN (Nederland), lionsjaar 2026-2027, ' 'met jaarthema "Samen Verder Bouwen". Je maakt beknopte, praktische ' 'voorbereidingsdossiers in het Nederlands, in Markdown.\n' 'Regels:\n' '1. Gebruik EXACT deze vijf koppen (##-niveau, in deze volgorde): ' '"Kernkaart", "Context", "Checklist voorbereiding", "Talking points", ' '"Open vragen aan de DG".\n' '2. Kernkaart: wat, wanneer, waar, wie aanwezig, rol van de DG — als tabel of bullets.\n' '3. Checklist: concrete acties mét deadline (T-x afleiden uit de datum).\n' '4. Talking points: 3-5 punten in de warme, verbindende toon van "Samen Verder Bouwen".\n' '5. Verzin NOOIT feiten. Wat je niet weet uit de meegegeven context komt onder ' '"Open vragen aan de DG". Dat is een kwaliteit, geen tekortkoming.\n' '6. Output: uitsluitend het Markdown-dossier, geen voor- of nawerk.' ) def _build_messages(item, kennis_fragmenten): """Bouw de [system, user]-messages voor ai_complete volgens DOSSIER_DESIGN.md.""" dtype = norm_type(item.get('type', '')) datum = item.get('datum', '') datum_eind = item.get('datum_eind') or '' dagen_tot = '' try: diff = (date.fromisoformat(datum) - date.today()).days dagen_tot = (f'\nTeller: het evenement is over {diff} dagen (T-{max(diff, 0)}).' if diff >= 0 else '\nLet op: de datum is al geweest.') except (ValueError, TypeError): pass if kennis_fragmenten: kb = ('\n\nRELEVANTE KENNISBANK-FRAGMENTEN (Lions 110AN):\n' + '\n\n'.join(f'### {f["titel"]}\n{f["fragment"]}' for f in kennis_fragmenten) + '\n(Gebruik deze fragmenten als feitelijke context waar relevant; ' 'noem de brontitel bij feiten.)') else: kb = ('\n\nRELEVANTE KENNISBANK-FRAGMENTEN: geen gevonden — verzin niets, ' 'zet onbekende zaken onder "Open vragen aan de DG".') user = f"""Bereid het volgende agenda-item voor de Districtsgouverneur voor. AGENDA-ITEM: - Titel: {item.get('titel', '')} - Type: {item.get('type', '') or 'onbekend'} (dossiertype: {dtype}) - Datum: {datum}{' t/m ' + datum_eind if datum_eind else ''}{dagen_tot} - Locatie: {item.get('locatie', '') or 'onbekend'} - Beschrijving/voorbereidingsbehoeften: {item.get('beschrijving', '') or '(geen)'} SPECIFIEKE AANDACHTSPUNTEN VOOR DIT TYPE ({dtype}): {TYPE_FOCUS[dtype]}{kb} Genereer nu het volledige dossier in Markdown met de vijf vaste koppen.""" return [{'role': 'system', 'content': SYSTEM_PROMPT}, {'role': 'user', 'content': user}], dtype def genereer_dossier(agenda_item, ai_complete_fn, db_module): """ Genereert de dossierinhoud (Markdown) voor `agenda_item` via ai_complete. Raist bij AI-storing/leeg antwoord — de caller zet dan status 'pending'. NOOIT fake-content. Retourneert (inhoud_md, dossiertype, messages). """ fragmenten = zoek_kennisbank_fragmenten(db_module, agenda_item) messages, dtype = _build_messages(agenda_item, fragmenten) inhoud = ai_complete_fn(messages) if not inhoud or not str(inhoud).strip(): raise RuntimeError('AI gaf een lege respons terug') return str(inhoud).strip(), dtype, messages # ────────────────────────────────────────────────────────────────────────────── # QUERY-HELPERS # ────────────────────────────────────────────────────────────────────────────── def get_dossier(db_module, dossier_id): """Dossier + bijbehorend agenda-item (JOIN) als dict, of None.""" conn = db_module.get_db() row = conn.execute( "SELECT d.*, a.datum AS item_datum, a.datum_eind AS item_datum_eind, " "a.titel AS item_titel, a.type AS item_type, a.locatie AS item_locatie, " "a.beschrijving AS item_beschrijving, a.status AS item_status " "FROM agenda_dossiers d LEFT JOIN agenda_items a ON a.id = d.agenda_item_id " "WHERE d.id = ?", (dossier_id,)).fetchone() conn.close() return dict(row) if row else None def get_dossier_voor_item(db_module, item_id): """Nieuwste dossier behorend bij een agenda-item (of None).""" conn = db_module.get_db() row = conn.execute( "SELECT * FROM agenda_dossiers WHERE agenda_item_id = ? ORDER BY id DESC LIMIT 1", (item_id,)).fetchone() conn.close() return dict(row) if row else None def _upsert_dossier(db_module, item_id, dtype, status, inhoud_md, ai_model): """ Eén dossier per agenda-item: nieuw → INSERT, bestaand → UPDATE. inhoud_md=None betekent: bestaande inhoud behouden (bij mislukte regeneratie). Retourneert dossier-id. """ conn = db_module.get_db() try: row = conn.execute("SELECT id, inhoud_md FROM agenda_dossiers WHERE agenda_item_id=? " "ORDER BY id DESC LIMIT 1", (item_id,)).fetchone() if row: nieuwe_inhoud = inhoud_md if inhoud_md is not None else (row['inhoud_md'] or '') conn.execute( "UPDATE agenda_dossiers SET gegenereerd_op=CURRENT_TIMESTAMP, type=?, " "status=?, inhoud_md=?, ai_model=? WHERE id=?", (dtype, status, nieuwe_inhoud, ai_model, row['id'])) dossier_id = row['id'] else: cur = conn.execute( "INSERT INTO agenda_dossiers (agenda_item_id, gegenereerd_op, type, status, " "inhoud_md, ai_model) VALUES (?, CURRENT_TIMESTAMP, ?, ?, ?, ?)", (item_id, dtype, status, inhoud_md or '', ai_model)) dossier_id = cur.lastrowid conn.commit() return dossier_id finally: conn.close() def _extract_section(md, naam): """Haal de inhoud van een '## Naam'-sectie uit het Markdown-dossier.""" m = re.search(rf'^##\s*{re.escape(naam)}\s*$(.*?)(?=^##\s|\Z)', md or '', re.M | re.I | re.S) return m.group(1).strip() # ────────────────────────────────────────────────────────────────────────────── # FLASK-ROUTES — zelfde registreerpatroon als agenda_module # ────────────────────────────────────────────────────────────────────────────── def register_dossier_routes(app, page_func, BASE_PATH, ai_complete_fn, db_module, ai_model_label=AI_MODEL_LABEL): """Register alle dossier-routes op de Flask-app. Draait ook de idempotente migratie.""" init_dossier_db(db_module) def _admin_ok(): # Zelfde vangnet als agenda_module: de productie-app heeft een globale # before_request login-check; dit is de extra guard op dossier-acties. return bool(session.get('authenticated') or session.get('user_id')) def _get_item(item_id): conn = db_module.get_db() row = conn.execute("SELECT * FROM agenda_items WHERE id=?", (item_id,)).fetchone() conn.close() return dict(row) if row else None def _flash_redirect(dossier_id, msg, soort='success'): return redirect(f'{BASE_PATH}/agenda/dossier/{dossier_id}?' + urlencode({'flash': msg, 'fsoort': soort})) def _badge(status): kleur = STATUS_BADGE.get(status, ('badge-gray', '#868E96'))[1] return f'{_esc(status)}' # ── POST /agenda/dossier//genereer — admin-only AI-generatie ── @app.route('/agenda/dossier//genereer', methods=['POST']) def dossier_genereer(item_id): if not _admin_ok(): return jsonify({'error': 'Admin-sessie vereist'}), 403 item = _get_item(item_id) if not item: return page_func('

❌ Agenda-item niet gevonden

' f'

← Terug naar agenda

', page_title='Dossier', active='agenda'), 404 try: inhoud, dtype, _ = genereer_dossier(item, ai_complete_fn, db_module) dossier_id = _upsert_dossier(db_module, item_id, dtype, 'concept', inhoud, ai_model_label) return _flash_redirect(dossier_id, '✅ Dossier gegenereerd als concept — eindredactie staat klaar.') except Exception as e: # AI-storing: status 'pending', bestaande inhoud behouden, nooit fake-content bestaand = get_dossier_voor_item(db_module, item_id) dtype = norm_type(item.get('type', '')) dossier_id = _upsert_dossier(db_module, item_id, dtype, 'pending', None, ai_model_label) return _flash_redirect( dossier_id, f'⚠️ AI-generatie mislukt ({str(e)[:80]}) — dossier staat op pending. ' 'Probeer het later opnieuw via Regenereren.' + (' Vorige inhoud is behouden.' if bestaand and (bestaand['inhoud_md'] or '') else ''), 'error') # ── GET /agenda/dossier/ — detailpagina ──────────────────────────── @app.route('/agenda/dossier/', methods=['GET']) def dossier_detail(dossier_id): d = get_dossier(db_module, dossier_id) if not d: return page_func('

❌ Dossier niet gevonden

' f'

← Terug naar agenda

', page_title='Dossier', active='agenda'), 404 item_id = d['agenda_item_id'] status = d['status'] or 'concept' inhoud = d['inhoud_md'] or '' datum_str = d.get('item_datum') or '?' flash_html = '' if request.args.get('flash'): fcls = 'flash-success' if request.args.get('fsoort') != 'error' else 'flash-error' flash_html = f'
{_esc(request.args["flash"])}
' if status == 'pending': status_uitleg = ('

Pending: de AI ' 'kon dit dossier (nog) niet genereren. Er is géén inhoud verzonnen — ' 'klik op Regenereren om het later opnieuw te proberen.

') else: status_uitleg = ('

Concept = AI-levering, ' 'Definitief = door de DG afgevinkt. Niets verlaat het systeem zonder jouw eindredactie.

') acties = f'''
{f'
' f'' f'
' if status != 'pending' else ''}
📱 Social-ready (JSON) Naar Social Generator →
''' body = f''' {flash_html}

📂 Voorbereidingsdossier

{_badge(status)} {_esc(d.get('type') or 'standaard')}

{_esc(d.get('item_titel') or '(agenda-item verwijderd)')}

📅 {_esc(datum_str)}{(" t/m " + _esc(d.get('item_datum_eind'))) if d.get('item_datum_eind') else ''} {(' · 📍 ' + _esc(d['item_locatie'])) if d.get('item_locatie') else ''} {(' · 🏷️ ' + _esc(d['item_type'])) if d.get('item_type') else ''}
Gegenereerd: {_esc(str(d.get('gegenereerd_op') or '')[:19])} {(' · model: ' + _esc(d.get('ai_model') or '—'))} · ← agenda

{status_uitleg} {acties}

📄 Dossierinhoud

{_esc(inhoud) if inhoud else '(nog geen inhoud — zie pending-melding hierboven)'}

✏️ Eindredactie

Bewerk de inhoud naar eigen inzicht (Markdown). Opslaan overschrijft inhoud_md; daarna kun je het dossier definitief maken.

''' return page_func(body, page_title='Dossier', active='agenda') # ── POST /agenda/dossier//status — statusflow concept↔definitief ── @app.route('/agenda/dossier//status', methods=['POST']) def dossier_status(dossier_id): if not _admin_ok(): return jsonify({'error': 'Admin-sessie vereist'}), 403 d = get_dossier(db_module, dossier_id) if not d: return jsonify({'error': 'Dossier niet gevonden'}), 404 nieuw = (request.form.get('status') or '').strip().lower() huidig = d['status'] or 'concept' if nieuw not in DOSSIER_STATUSES: return jsonify({'error': f'Ongeldige status "{nieuw}"' }), 400 if nieuw not in USER_TRANSITIONS.get(huidig, set()): return jsonify({'error': f'Transitie {huidig} → {nieuw} is niet toegestaan'}), 400 conn = db_module.get_db() conn.execute("UPDATE agenda_dossiers SET status=? WHERE id=?", (nieuw, dossier_id)) conn.commit() conn.close() return _flash_redirect(dossier_id, '✅ Dossier definitief gemaakt.' if nieuw == 'definitief' else '↩️ Dossier teruggezet naar concept.') # ── POST /agenda/dossier//bewerken — eindredactie opslaan ────────── @app.route('/agenda/dossier//bewerken', methods=['POST']) def dossier_bewerken(dossier_id): if not _admin_ok(): return jsonify({'error': 'Admin-sessie vereist'}), 403 d = get_dossier(db_module, dossier_id) if not d: return jsonify({'error': 'Dossier niet gevonden'}), 404 inhoud = request.form.get('inhoud_md') if inhoud is None or not inhoud.strip(): return _flash_redirect(dossier_id, '❌ Lege inhoud wordt niet opgeslagen.', 'error') conn = db_module.get_db() conn.execute("UPDATE agenda_dossiers SET inhoud_md=? WHERE id=?", (inhoud, dossier_id)) conn.commit() conn.close() return _flash_redirect(dossier_id, '💾 Eindredactie opgeslagen als inhoud_md.') # ── POST /agenda/dossier//naar-nieuwsbrief — seed in bestaande pool ─ @app.route('/agenda/dossier//naar-nieuwsbrief', methods=['POST']) def dossier_naar_nieuwsbrief(dossier_id): if not _admin_ok(): return jsonify({'error': 'Admin-sessie vereist'}), 403 d = get_dossier(db_module, dossier_id) if not d or not (d['inhoud_md'] or '').strip(): return _flash_redirect(dossier_id, '❌ Geen dossierinhoud om te delen — genereer eerst een dossier.', 'error') titel = f"Dossier: {d.get('item_titel') or 'agenda-item'} ({d.get('item_datum') or '?'})" categorie = 'agenda' tags = 'agenda,dossier,' + (norm_type(d.get('item_type', '')) or 'standaard') try: conn = db_module.get_db() conn.execute( "INSERT INTO newsletter_content_pool (title, content, content_type, category, tags) " "VALUES (?,?,?,?,?)", (titel, d['inhoud_md'][:50000], 'text', categorie, tags)) conn.commit() conn.close() except Exception as e: return _flash_redirect(dossier_id, f'❌ Kon niet toevoegen aan nieuwsbrief-pool: {str(e)[:80]}', 'error') return _flash_redirect(dossier_id, '📰 Als seed toegevoegd aan de nieuwsbrief-content-pool — ' f'selecteer het item in de nieuwsbrief-generator.') # ── GET /agenda/dossier//social-export — JSON voor Social Generator ─ @app.route('/agenda/dossier//social-export', methods=['GET']) def dossier_social_export(dossier_id): d = get_dossier(db_module, dossier_id) if not d: return jsonify({'error': 'Dossier niet gevonden'}), 404 tp = _extract_section(d['inhoud_md'] or '', 'Talking points') if not tp: tp = (d['inhoud_md'] or '')[:1500] titel = d.get('item_titel') or 'Lions agenda-item' omschrijving = (f"Talking points uit voorbereidingsdossier ({d.get('item_datum')}, " f"{d.get('item_type') or 'activiteit'}):\n{tp}") payloads = [{'title': titel, 'description': omschrijving, 'platform': p, 'language': 'nl', 'tone': 'inspirerend'} for p in ('instagram', 'linkedin', 'facebook')] resp = jsonify({ 'dossier_id': dossier_id, 'agenda_item_id': d['agenda_item_id'], 'status': d['status'], 'titel': titel, 'talking_points': tp, 'social_generator': { 'endpoint': f'{BASE_PATH}/api/social/generate', 'methode': 'POST (JSON)', 'uitleg': 'Stuur één payload per platform naar de bestaande Social Generator; ' 'die slaat de post zelf op in social_posts.', 'payloads': payloads, }, }) resp.headers['Content-Disposition'] = f'attachment; filename="dossier-{dossier_id}-social.json"' return resp # ────────────────────────────────────────────────────────────────────────────── # AGENDA-PAGINA-KOPPELING — dossier-knop/link per item zodra T-14 bereikt # ────────────────────────────────────────────────────────────────────────────── def render_dossier_kolom(db_module, item, BASE_PATH=''): """ HTML voor de Dossier-kolom op /agenda (gebruikt door agenda_module._row()): - dossier bestaat → link naar detail (met statuskleur) - geen dossier én startdatum binnen 14 dagen (T-14 bereikt) → genereer-knop - anders → leeg (lean: geen ruis op de agenda) """ d = get_dossier_voor_item(db_module, item['id']) if d: kleur = STATUS_BADGE.get(d['status'], ('', '#868E96'))[1] return (f'' f'📂 dossier ({_esc(d["status"])})') try: diff = (date.fromisoformat(item['datum']) - date.today()).days except (ValueError, TypeError, KeyError): return '' if diff <= 14: return (f'
' f'' f'
') return ''