diff --git a/app.py b/app.py index cb9377af..b59c1a25 100644 --- a/app.py +++ b/app.py @@ -380,6 +380,15 @@ async def lifespan(app: FastAPI): from modules.shared.auditLogger import registerAuditLogCleanupScheduler registerAuditLogCleanupScheduler() + # Recover background jobs that were RUNNING when the previous worker died + try: + from modules.serviceCenter.services.serviceBackgroundJobs.mainBackgroundJobService import ( + recoverInterruptedJobs, + ) + recoverInterruptedJobs() + except Exception as e: + logger.warning(f"BackgroundJob recovery failed (non-critical): {e}") + yield # --- Stop Managers --- @@ -627,6 +636,9 @@ app.include_router(billingRouter) from modules.routes.routeSubscription import router as subscriptionRouter app.include_router(subscriptionRouter) +from modules.routes.routeJobs import router as jobsRouter +app.include_router(jobsRouter) + # ============================================================================ # SYSTEM ROUTES (Navigation, etc.) # ============================================================================ diff --git a/demoData/pwg/_generateScans.py b/demoData/pwg/_generateScans.py new file mode 100644 index 00000000..c93eda55 --- /dev/null +++ b/demoData/pwg/_generateScans.py @@ -0,0 +1,125 @@ +"""Generate the 3 fictitious PWG scan PDFs used by the pilot demo. + +Run: python _generateScans.py + +Produces: + scans/mieter01-bestaetigt.pdf -> all fields ok, signed + scans/mieter02-abweichung-betrag.pdf -> rent on scan != journal lines + scans/mieter03-keine-unterschrift.pdf -> hasSignature=false +""" +from pathlib import Path + +from reportlab.lib.pagesizes import A4 +from reportlab.pdfgen import canvas + + +def _renderForm(outPath: Path, *, tenantName: str, tenantAddress: str, + objectAddress: str, period: str, rentChf: float, + tenantNotes: str, hasSignature: bool) -> None: + c = canvas.Canvas(str(outPath), pagesize=A4) + w, h = A4 + margin = 60 + y = h - margin + + c.setFont("Helvetica-Bold", 16) + c.drawString(margin, y, "Stiftung PWG") + y -= 18 + c.setFont("Helvetica", 10) + c.drawString(margin, y, "Postfach 1234 · 8000 Zürich") + y -= 30 + + c.setFont("Helvetica-Bold", 14) + c.drawString(margin, y, f"Jahresmietzinsbestätigung {period}") + y -= 28 + + c.setFont("Helvetica", 11) + c.drawString(margin, y, "Sehr geehrte Damen und Herren,") + y -= 18 + c.drawString(margin, y, "hiermit bestätige ich die nachstehenden Angaben für die o.g. Periode:") + y -= 28 + + rows = [ + ("Mieter / in:", tenantName), + ("Wohnadresse:", tenantAddress), + ("Mietobjekt:", objectAddress), + ("Periode:", period), + ("Bestätigter Mietzins (CHF, monatlich):", f"{rentChf:.2f}"), + ("Anmerkungen:", tenantNotes or "(keine)"), + ] + c.setFont("Helvetica", 11) + for lab, val in rows: + c.drawString(margin, y, lab) + c.drawString(margin + 220, y, str(val)) + y -= 18 + y -= 28 + + c.drawString(margin, y, "Ort, Datum: Zürich, 12.04.2026") + y -= 28 + c.drawString(margin, y, "Unterschrift Mieter / in:") + y -= 36 + + if hasSignature: + c.setFont("Helvetica-Oblique", 14) + c.drawString(margin + 220, y + 24, _signatureFor(tenantName)) + else: + c.setFont("Helvetica", 9) + c.drawString(margin + 220, y + 24, "(handschriftlich)") + c.line(margin + 215, y + 22, margin + 415, y + 22) + + c.showPage() + c.save() + + +def _signatureFor(name: str) -> str: + parts = name.split() + if not parts: + return "____" + return parts[0][0] + ". " + parts[-1] + + +def _main() -> None: + here = Path(__file__).resolve().parent + outDir = here / "scans" + outDir.mkdir(parents=True, exist_ok=True) + + # 1) bestätigt — exakt passend zu seed (Anna Müller, 1850.00) + _renderForm( + outDir / "mieter01-bestaetigt.pdf", + tenantName="Anna Müller", + tenantAddress="Bahnhofstrasse 12, 8001 Zürich", + objectAddress="Bahnhofstrasse 12, 3.OG, 8001 Zürich", + period="2026", + rentChf=1850.00, + tenantNotes="", + hasSignature=True, + ) + + # 2) abweichung_betrag — Mieter trägt 2300 ein, Buchhaltung sagt 2200 + _renderForm( + outDir / "mieter02-abweichung-betrag.pdf", + tenantName="Beat Schneider", + tenantAddress="Limmatquai 45, 8001 Zürich", + objectAddress="Limmatquai 45, 1.OG, 8001 Zürich", + period="2026", + rentChf=2300.00, + tenantNotes="Mietzins gemäss letzter Indexanpassung — bitte prüfen.", + hasSignature=True, + ) + + # 3) keine_unterschrift — Carla Weber, 1650 stimmt, aber nicht unterschrieben + _renderForm( + outDir / "mieter03-keine-unterschrift.pdf", + tenantName="Carla Weber", + tenantAddress="Seestrasse 88, 8002 Zürich", + objectAddress="Seestrasse 88, EG, 8002 Zürich", + period="2026", + rentChf=1650.00, + tenantNotes="", + hasSignature=False, + ) + + print(f"Generated 3 scans in {outDir}") + + +if __name__ == "__main__": + _main() diff --git a/demoData/pwg/_seedTrusteeData.json b/demoData/pwg/_seedTrusteeData.json new file mode 100644 index 00000000..78c6d332 --- /dev/null +++ b/demoData/pwg/_seedTrusteeData.json @@ -0,0 +1,68 @@ +{ + "_comment": "PWG-Demo Seed-Daten — fiktive Mieter (Debitoren) und Mietzins-Buchungen 2026 für Trustee-Feature. Wird von pwgDemo2026.py idempotent geladen.", + "rentAccount": "6000", + "rentAccountLabel": "Mietzinsertrag Wohnen", + "year": 2026, + "tenants": [ + { + "contactNumber": "10001", + "name": "Anna Müller", + "address": "Bahnhofstrasse 12", + "zip": "8001", + "city": "Zürich", + "country": "CH", + "email": "anna.mueller@example.ch", + "monthlyRentChf": 1850.00, + "scenario": "bestaetigt", + "_note": "Stimmt exakt — erwarteter Pilot-Status 'bestaetigt'" + }, + { + "contactNumber": "10002", + "name": "Beat Schneider", + "address": "Limmatquai 45", + "zip": "8001", + "city": "Zürich", + "country": "CH", + "email": "beat.schneider@example.ch", + "monthlyRentChf": 2200.00, + "scenario": "abweichung_betrag", + "_note": "Scan zeigt 2300 CHF/Monat (Mieter nicht über Erhöhung informiert) — erwarteter Status 'abweichung_betrag'" + }, + { + "contactNumber": "10003", + "name": "Carla Weber", + "address": "Seestrasse 88", + "zip": "8002", + "city": "Zürich", + "country": "CH", + "email": "carla.weber@example.ch", + "monthlyRentChf": 1650.00, + "scenario": "keine_unterschrift", + "_note": "Scan ist ohne Unterschrift — erwarteter Status 'keine_unterschrift'" + }, + { + "contactNumber": "10004", + "name": "Daniel Keller", + "address": "Hardturmstrasse 200", + "zip": "8005", + "city": "Zürich", + "country": "CH", + "email": "daniel.keller@example.ch", + "monthlyRentChf": 2450.00, + "scenario": "kein_scan", + "_note": "Hat noch nicht zurückgesendet — taucht nicht im Pilot-Run auf" + }, + { + "contactNumber": "10005", + "name": "Elena Fischer", + "address": "Rämistrasse 71", + "zip": "8001", + "city": "Zürich", + "country": "CH", + "email": "elena.fischer@example.ch", + "monthlyRentChf": 1990.00, + "scenario": "kein_scan", + "_note": "Reserve-Mieter für spätere Demo-Erweiterungen" + } + ] +} diff --git a/demoData/pwg/scans/mieter01-bestaetigt.pdf b/demoData/pwg/scans/mieter01-bestaetigt.pdf new file mode 100644 index 00000000..591a901f --- /dev/null +++ b/demoData/pwg/scans/mieter01-bestaetigt.pdf @@ -0,0 +1,80 @@ +%PDF-1.3 +%“Œ‹ž ReportLab Generated PDF document http://www.reportlab.com +1 0 obj +<< +/F1 2 0 R /F2 3 0 R /F3 4 0 R +>> +endobj +2 0 obj +<< +/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font +>> +endobj +3 0 obj +<< +/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font +>> +endobj +4 0 obj +<< +/BaseFont /Helvetica-Oblique /Encoding /WinAnsiEncoding /Name /F3 /Subtype /Type1 /Type /Font +>> +endobj +5 0 obj +<< +/Contents 9 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 8 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +6 0 obj +<< +/PageMode /UseNone /Pages 8 0 R /Type /Catalog +>> +endobj +7 0 obj +<< +/Author (anonymous) /CreationDate (D:20260420002638-01'00') /Creator (ReportLab PDF Library - www.reportlab.com) /Keywords () /ModDate (D:20260420002638-01'00') /Producer (ReportLab PDF Library - www.reportlab.com) + /Subject (unspecified) /Title (untitled) /Trapped /False +>> +endobj +8 0 obj +<< +/Count 1 /Kids [ 5 0 R ] /Type /Pages +>> +endobj +9 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 605 +>> +stream +Gat$u_2b!='YNm9]OOh_`s.;Y\Ku+!X/aQ:.b.-A/gNQpRp[N%>l++NBXO3A:fg1WZM\=sbo<,Q[3'29Es](/@'O@[I#'OcS8a:5_Y<8fh=lJSmJ`RLh*-1@#UuhX,=8I86m^'+)4?n^b2N-d3/?],U+[TZQ@ZJ8,<0,Yi>eoPABDBdLBA$k+0Ik*9&VW;5@Mh:Ji.!#+`k%CJr^^%]YVpL:\WM.^h5>]]TUiL[_3bUPl*u7tL)fSq&ABG:._)GlSks3%?6@q<#fWg]-m\(U)KAD%ZQqC(%lgdge#dauIVqb%d[b>@jSh2'HC<+`WqKT\j."HGbZ/,'GI@L]d5Gq#Bu(=GEa'j*$L`Rna35kpC)q-)VX=iB?Q>cb;U14X_hGR&cJicR65LLeK?KTlcegm"M*#IBaRqVfL6:M.[Wh$KLqAK0+g#D*30YbcTZBVL*J+KQ8j4'43h]r`7UAqHR_2FMW4U(].V2NG5u__ND;RK6I;:rW6,"=tf~>endstream +endobj +xref +0 10 +0000000000 65535 f +0000000073 00000 n +0000000124 00000 n +0000000231 00000 n +0000000343 00000 n +0000000458 00000 n +0000000661 00000 n +0000000729 00000 n +0000001025 00000 n +0000001084 00000 n +trailer +<< +/ID +[<621e745f4154d3ac7a42de07bdd8794e><621e745f4154d3ac7a42de07bdd8794e>] +% ReportLab generated PDF document -- digest (http://www.reportlab.com) + +/Info 7 0 R +/Root 6 0 R +/Size 10 +>> +startxref +1779 +%%EOF diff --git a/demoData/pwg/scans/mieter02-abweichung-betrag.pdf b/demoData/pwg/scans/mieter02-abweichung-betrag.pdf new file mode 100644 index 00000000..0b35e872 --- /dev/null +++ b/demoData/pwg/scans/mieter02-abweichung-betrag.pdf @@ -0,0 +1,80 @@ +%PDF-1.3 +%“Œ‹ž ReportLab Generated PDF document http://www.reportlab.com +1 0 obj +<< +/F1 2 0 R /F2 3 0 R /F3 4 0 R +>> +endobj +2 0 obj +<< +/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font +>> +endobj +3 0 obj +<< +/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font +>> +endobj +4 0 obj +<< +/BaseFont /Helvetica-Oblique /Encoding /WinAnsiEncoding /Name /F3 /Subtype /Type1 /Type /Font +>> +endobj +5 0 obj +<< +/Contents 9 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 8 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +6 0 obj +<< +/PageMode /UseNone /Pages 8 0 R /Type /Catalog +>> +endobj +7 0 obj +<< +/Author (anonymous) /CreationDate (D:20260420002638-01'00') /Creator (ReportLab PDF Library - www.reportlab.com) /Keywords () /ModDate (D:20260420002638-01'00') /Producer (ReportLab PDF Library - www.reportlab.com) + /Subject (unspecified) /Title (untitled) /Trapped /False +>> +endobj +8 0 obj +<< +/Count 1 /Kids [ 5 0 R ] /Type /Pages +>> +endobj +9 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 645 +>> +stream +Gat$u;/=o?&:X)O\Araq7?SD+a]l*Rm7'_NodC6`..P8W>KNG3^t>i_Ce?:WES)tdE9P%)]=[OMJ;0,-k>h\dEgU/f?l\_X0L5j&\*u7>lf&mdg;Ok]pMom2O]%QZN+CTcK3Z=iK3(.L2\iD9Y:h#JK)F(Z;IH.9AG%RA'dZ8Tl(;M;Z.lg7m%'r?#V+#+[C[+hXgYl(%>:Lj%@c-Y$GTZ`"76>Gs6G*oW%,BOGaN\3XoX9SV137[hSKN*;q*b!REa+VYE_685)jc=;j2%+poDP+1suFj9/'1o)>"7]VsjQiC>b3a;5CmR!8e_A&5;*gb0YK9R*C%hIFKTIS?Lf./'.4>sU0AXJ?:'Ki%F;f7lOdf8#o"_'B(%Dp*n'!q.>=Br1X_In@U1sS''A`Wjehl1+L*1tN,2no:=PnEL:G0[+39KTbr2jZmOrqY\k!kL,7^BBtD`;*O?sX)7aI6USk9`Ike3IM.son+Et.<>Zi+<03="'oQ`85>71#[^?PT*K9I,oI;ls,.0QF=X7oSNc#8qr<64SCKL~>endstream +endobj +xref +0 10 +0000000000 65535 f +0000000073 00000 n +0000000124 00000 n +0000000231 00000 n +0000000343 00000 n +0000000458 00000 n +0000000661 00000 n +0000000729 00000 n +0000001025 00000 n +0000001084 00000 n +trailer +<< +/ID +[] +% ReportLab generated PDF document -- digest (http://www.reportlab.com) + +/Info 7 0 R +/Root 6 0 R +/Size 10 +>> +startxref +1819 +%%EOF diff --git a/demoData/pwg/scans/mieter03-keine-unterschrift.pdf b/demoData/pwg/scans/mieter03-keine-unterschrift.pdf new file mode 100644 index 00000000..d402b559 --- /dev/null +++ b/demoData/pwg/scans/mieter03-keine-unterschrift.pdf @@ -0,0 +1,74 @@ +%PDF-1.3 +%“Œ‹ž ReportLab Generated PDF document http://www.reportlab.com +1 0 obj +<< +/F1 2 0 R /F2 3 0 R +>> +endobj +2 0 obj +<< +/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font +>> +endobj +3 0 obj +<< +/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font +>> +endobj +4 0 obj +<< +/Contents 8 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 7 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +5 0 obj +<< +/PageMode /UseNone /Pages 7 0 R /Type /Catalog +>> +endobj +6 0 obj +<< +/Author (anonymous) /CreationDate (D:20260420002638-01'00') /Creator (ReportLab PDF Library - www.reportlab.com) /Keywords () /ModDate (D:20260420002638-01'00') /Producer (ReportLab PDF Library - www.reportlab.com) + /Subject (unspecified) /Title (untitled) /Trapped /False +>> +endobj +7 0 obj +<< +/Count 1 /Kids [ 4 0 R ] /Type /Pages +>> +endobj +8 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 629 +>> +stream +Gat$u9okbt'YNU1]OOh_epoJMDS+[%:t8PjKdtN.M\BF4Rp[N%>l*c%BN.Y4;--:2/AITuo>V8jfI,n>q[27)KtHLJJe6?4"Os?2IYXhCeua]=Y\nmRL])OYCu^UM"D+]L%$mi4Mg_c9Z*W=TB25q0p'VtnW+DO[lI4"^GhEIMZS%r+4-427/j88s-'(Bb"Di(5HFd8E`+E5?9&t.@c*c7+LKh&MCQ'%;!]]r.FG*TWE*:(lfNGob^n\G/l;h/P5/$kYZ($gE_$jH%mJdC=!KQ!_4S3&rBD-KT3+VX$f4PVo=p]8U1:+q/mK$e4@cA%V:!]??hl@+Wd@MMo'pV'V2F!p8Qn>0Qg]@?"`j7&8S?#Y.\n>pfT2>Qb:NYh\qGUODRXM1&D$AAhDi`&H4"4_,'nf.p$SEU*J@`KCfZ[as)_0uXW;~>endstream +endobj +xref +0 9 +0000000000 65535 f +0000000073 00000 n +0000000114 00000 n +0000000221 00000 n +0000000333 00000 n +0000000536 00000 n +0000000604 00000 n +0000000900 00000 n +0000000959 00000 n +trailer +<< +/ID +[<9b415a84726399a7dd006f60068c5362><9b415a84726399a7dd006f60068c5362>] +% ReportLab generated PDF document -- digest (http://www.reportlab.com) + +/Info 6 0 R +/Root 5 0 R +/Size 9 +>> +startxref +1678 +%%EOF diff --git a/demoData/workflows/pwg-mietzinsbestaetigung-pilot.workflow.json b/demoData/workflows/pwg-mietzinsbestaetigung-pilot.workflow.json new file mode 100644 index 00000000..8a5a7f60 --- /dev/null +++ b/demoData/workflows/pwg-mietzinsbestaetigung-pilot.workflow.json @@ -0,0 +1,152 @@ +{ + "$schemaVersion": "1.0", + "$kind": "poweron.workflow", + "$exportedAt": "2026-04-16T10:00:00Z", + "$gatewayVersion": "demo-2026-04", + "label": "PWG Pilot: Jahresmietzinsbestätigung", + "description": "Verarbeitet gescannte Rückantworten der Jahresmietzinsbestätigungen: OCR, Abgleich gegen Trustee-DB (Mieter + Mietzins-Buchungen), AI-Klassifikation pro Scan und Zustellung als CSV-Anhang im Outlook-Draft an die Sachbearbeitung. Pilot-Lieferung Sommer 2026.", + "tags": ["pwg", "pilot", "mietzins", "trustee", "ocr"], + "templateScope": "instance", + "sharedReadOnly": false, + "notifyOnFailure": true, + "graph": { + "nodes": [ + { + "id": "n1", + "type": "trigger.manual", + "x": 50, + "y": 200, + "title": "Manueller Start", + "parameters": {} + }, + { + "id": "n2", + "type": "sharepoint.listFiles", + "x": 320, + "y": 200, + "title": "Scan-Ordner auflisten", + "parameters": { + "connectionReference": "", + "pathQuery": "PWG/Mietzinsbestaetigungen/Scans-Eingang" + } + }, + { + "id": "n3", + "type": "flow.loop", + "x": 590, + "y": 200, + "title": "Pro Scan-Dokument", + "parameters": { + "level": 1, + "concurrency": 1 + } + }, + { + "id": "n4", + "type": "sharepoint.downloadFile", + "x": 860, + "y": 200, + "title": "PDF/Bild laden", + "parameters": { + "connectionReference": "", + "pathQuery": "{{loop.item.path}}" + } + }, + { + "id": "n5", + "type": "trustee.extractFromFiles", + "x": 1130, + "y": 200, + "title": "OCR & Felder extrahieren", + "parameters": { + "featureInstanceId": "", + "prompt": "Extrahiere die folgenden Felder aus dieser Jahresmietzinsbestätigung und antworte als JSON: tenantName (string), tenantAddress (string), objectAddress (string), confirmedRentAmount (number|null in CHF), currency ('CHF'), period (string z.B. '2026'), tenantNotes (string|null - alle handschriftlichen Anmerkungen oder Korrekturen), hasSignature (boolean - ist eine Unterschrift vorhanden?), documentDate (ISO date|null), ocrConfidence (number 0-1)." + } + }, + { + "id": "n6", + "type": "trustee.queryData", + "x": 1400, + "y": 200, + "title": "Referenzdaten Trustee-DB", + "parameters": { + "featureInstanceId": "", + "mode": "lookup", + "entity": "tenantWithRent", + "tenantNameRef": "{{n5.output.tenantName}}", + "tenantAddressRef": "{{n5.output.tenantAddress}}", + "period": "{{n5.output.period}}", + "rentAccountPattern": "6000-6099" + } + }, + { + "id": "n7", + "type": "ai.prompt", + "x": 1670, + "y": 200, + "title": "Prüfung & Klassifikation", + "parameters": { + "outputFormat": "json", + "simpleMode": false, + "documentList": "{{n5.output}}", + "context": "{{n6.output}}", + "aiPrompt": "Du bist ein Sachbearbeitungs-Assistent der Stiftung PWG. Deine Aufgabe ist es, eine eingescannte und OCR-extrahierte Jahresmietzinsbestätigung gegen die Stammdaten der Buchhaltung (Trustee-Feature) abzugleichen.\n\nEingaben:\n1. SCAN_DATEN (extrahiert per OCR aus dem Rückantwort-Dokument):\n{{scan}}\n\n2. REFERENZ_DATEN (aus Trustee-DB für diesen Mieter; ggf. leer wenn nicht eindeutig zuordenbar):\n{{reference}}\n\nVorgehen:\n1. Prüfe Identität: Stimmt SCAN_DATEN.tenantName + SCAN_DATEN.tenantAddress mit einem Datensatz in REFERENZ_DATEN.contacts überein? (Toleranz: kleine Tippfehler, Umlaute, Abkürzungen).\n2. Prüfe Mietzinsbetrag: Stimmt SCAN_DATEN.confirmedRentAmount mit dem aus REFERENZ_DATEN.expectedRentAmount erwarteten Mietzins überein? (Toleranz: ±1 CHF Rundung).\n3. Prüfe Unterschrift: hasSignature muss true sein.\n4. Prüfe OCR-Qualität: ocrConfidence < 0.6 -> 'unleserlich'.\n\nKlassifiziere in EXAKT EINEN Status:\n- 'bestaetigt': Identität stimmt, Betrag stimmt, Unterschrift vorhanden.\n- 'abweichung_betrag': Identität ok, Unterschrift ok, Betrag weicht ab.\n- 'abweichung_anmerkung': tenantNotes enthält substantielle Anmerkung (nicht leer, nicht reine Bestätigung).\n- 'keine_unterschrift': hasSignature == false.\n- 'unleserlich': OCR-Qualität ungenügend ODER Pflichtfelder fehlen.\n- 'kein_match': Mieter nicht in REFERENZ_DATEN auffindbar.\n\nBei Status != 'bestaetigt': Generiere einen kurzen, höflichen Antwortvorschlag (deutsch, Sie-Form, max. 5 Sätze, PWG-Stil) für die Sachbearbeitung. Bei 'bestaetigt': antwortVorschlag = null.\n\nAntworte AUSSCHLIESSLICH als JSON nach folgendem Schema:\n{\n \"tenantName\": string,\n \"objectAddress\": string,\n \"status\": \"bestaetigt\" | \"abweichung_betrag\" | \"abweichung_anmerkung\" | \"keine_unterschrift\" | \"unleserlich\" | \"kein_match\",\n \"scanRentAmount\": number | null,\n \"expectedRentAmount\": number | null,\n \"delta\": number | null,\n \"tenantNotes\": string | null,\n \"antwortVorschlag\": string | null,\n \"matchConfidence\": number,\n \"auditEvidence\": string\n}" + } + }, + { + "id": "n8", + "type": "data.aggregate", + "x": 1940, + "y": 200, + "title": "Ergebnisse sammeln (im Loop)", + "parameters": { + "mode": "collect" + } + }, + { + "id": "n9", + "type": "data.consolidate", + "x": 2210, + "y": 200, + "title": "CSV bauen (nach Loop)", + "parameters": { + "mode": "csvJoin", + "separator": "\n" + } + }, + { + "id": "n10", + "type": "email.draftEmail", + "x": 2480, + "y": 200, + "title": "Draft an Sachbearbeitung", + "parameters": { + "connectionReference": "", + "to": "sachbearbeiter@pwg.ch", + "subject": "Mietzinsbestätigungen Auswertung {{currentDate}}", + "body": "Hallo,\n\nim Anhang die Auswertung der eingegangenen Jahresmietzinsbestätigungen.\nPro Scan eine Zeile mit Status, Betragsabgleich und (bei Abweichung) Antwortvorschlag.\n\nBitte die Zeilen mit Status != 'bestaetigt' manuell sichten.\n\nFreundliche Grüße,\nPWG Automation", + "emailStyle": "business", + "attachments": [ + { + "name": "mietzinsbestaetigungen-auswertung", + "mimeType": "text/csv", + "csvFromVariable": "n9.output" + } + ] + } + } + ], + "connections": [ + {"source": "n1", "target": "n2", "sourceOutput": 0, "targetInput": 0}, + {"source": "n2", "target": "n3", "sourceOutput": 0, "targetInput": 0}, + {"source": "n3", "target": "n4", "sourceOutput": 0, "targetInput": 0}, + {"source": "n4", "target": "n5", "sourceOutput": 0, "targetInput": 0}, + {"source": "n5", "target": "n6", "sourceOutput": 0, "targetInput": 0}, + {"source": "n6", "target": "n7", "sourceOutput": 0, "targetInput": 0}, + {"source": "n7", "target": "n8", "sourceOutput": 0, "targetInput": 0}, + {"source": "n8", "target": "n9", "sourceOutput": 0, "targetInput": 0}, + {"source": "n9", "target": "n10", "sourceOutput": 0, "targetInput": 0} + ] + }, + "invocations": [] +} diff --git a/modules/datamodels/datamodelBackgroundJob.py b/modules/datamodels/datamodelBackgroundJob.py new file mode 100644 index 00000000..45a26b2c --- /dev/null +++ b/modules/datamodels/datamodelBackgroundJob.py @@ -0,0 +1,130 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +"""Background job models: generic, reusable infrastructure for long-running tasks. + +A `BackgroundJob` record tracks the lifecycle of one async task that must not block +the calling HTTP request. Any caller (HTTP route, AI tool, scheduled task) can: + +1. Register a handler once via `registerJobHandler(jobType, handler)`. +2. Submit work via `startJob(jobType, payload, ...)` which returns a `jobId` + immediately and runs the handler in the background. +3. Poll `getJobStatus(jobId)` (HTTP `GET /api/jobs/{jobId}`) until `status` is + one of {SUCCESS, ERROR, CANCELLED}. + +See `modules.serviceCenter.services.serviceBackgroundJobs.mainBackgroundJobService`. +""" + +from typing import Any, Dict, Optional +from enum import Enum +from datetime import datetime, timezone +import uuid + +from pydantic import Field + +from modules.datamodels.datamodelBase import PowerOnModel +from modules.shared.i18nRegistry import i18nModel + + +class BackgroundJobStatusEnum(str, Enum): + """Lifecycle status of a background job.""" + PENDING = "PENDING" + RUNNING = "RUNNING" + SUCCESS = "SUCCESS" + ERROR = "ERROR" + CANCELLED = "CANCELLED" + + +TERMINAL_JOB_STATUSES = { + BackgroundJobStatusEnum.SUCCESS, + BackgroundJobStatusEnum.ERROR, + BackgroundJobStatusEnum.CANCELLED, +} + + +@i18nModel("Hintergrund-Job") +class BackgroundJob(PowerOnModel): + """Generic record describing a long-running asynchronous task. + + Scope: the combination of `mandateId` and optionally `featureInstanceId` + is used for access control on `GET /api/jobs/{jobId}`. + """ + + id: str = Field( + default_factory=lambda: str(uuid.uuid4()), + description="Primary key", + json_schema_extra={"label": "ID"}, + ) + jobType: str = Field( + ..., + description="Handler key registered via registerJobHandler() (e.g. 'trusteeAccountingSync')", + json_schema_extra={"label": "Typ"}, + ) + mandateId: Optional[str] = Field( + None, + description="Mandate scope (used for access checks). None for system-wide jobs.", + json_schema_extra={ + "label": "Mandanten-ID", + "fk_target": {"db": "poweron_app", "table": "Mandate"}, + }, + ) + featureInstanceId: Optional[str] = Field( + None, + description="Feature instance scope (optional)", + json_schema_extra={ + "label": "Feature-Instanz", + "fk_target": {"db": "poweron_app", "table": "FeatureInstance"}, + }, + ) + triggeredBy: Optional[str] = Field( + None, + description="UserId or 'ai-tool:' / 'scheduler:'", + json_schema_extra={"label": "Ausgeloest von"}, + ) + + status: str = Field( + default=BackgroundJobStatusEnum.PENDING.value, + description="Current lifecycle status", + json_schema_extra={"label": "Status"}, + ) + progress: int = Field( + default=0, + description="Progress 0..100 (best-effort; may stay 0 for handlers that cannot estimate)", + json_schema_extra={"label": "Fortschritt"}, + ) + progressMessage: Optional[str] = Field( + None, + description="Human-readable current step (e.g. 'Importing journal entries...')", + json_schema_extra={"label": "Fortschritts-Nachricht"}, + ) + + payload: Dict[str, Any] = Field( + default_factory=dict, + description="Job input parameters (JSON)", + json_schema_extra={"label": "Eingabe"}, + ) + result: Optional[Dict[str, Any]] = Field( + None, + description="Handler return value on success (JSON)", + json_schema_extra={"label": "Ergebnis"}, + ) + errorMessage: Optional[str] = Field( + None, + description="Truncated error message on failure (full stack trace in logs)", + json_schema_extra={"label": "Fehler"}, + ) + + createdAt: datetime = Field( + default_factory=lambda: datetime.now(timezone.utc), + description="When the job was submitted", + json_schema_extra={"label": "Eingereicht"}, + ) + startedAt: Optional[datetime] = Field( + None, + description="When the handler began running", + json_schema_extra={"label": "Gestartet"}, + ) + finishedAt: Optional[datetime] = Field( + None, + description="When the handler reached a terminal status", + json_schema_extra={"label": "Beendet"}, + ) diff --git a/modules/demoConfigs/pwgDemo2026.py b/modules/demoConfigs/pwgDemo2026.py new file mode 100644 index 00000000..e3aeea51 --- /dev/null +++ b/modules/demoConfigs/pwgDemo2026.py @@ -0,0 +1,768 @@ +"""PWG Pilot Demo (April 2026) + +Bootstraps a complete PWG-Pilot demo environment in an empty dev/demo install: + + - 1 mandate "Stiftung PWG" + - 1 SysAdmin demo user "pwg.demo" + - 4 features: workspace, trustee (BUHA PWG), graphicalEditor (PWG Automationen), + neutralization (Datenschutz) + - Trustee seed-data (5 fictitious tenants with monthly rent journal lines for + the current year, loaded from ``demoData/pwg/_seedTrusteeData.json``) + - Pilot workflow imported from + ``demoData/workflows/pwg-mietzinsbestaetigung-pilot.workflow.json`` + (active=false — user activates manually after triggering once). + +Idempotent: ``load()`` skips anything that already exists; ``remove()`` deletes +mandate, user, seed data and imported workflow cleanly. + +Pattern: subclass of :class:`_BaseDemoConfig`, auto-discovered by +``demoConfigs/__init__.py``. See ``investorDemo2026.py`` for the reference +implementation we mirror here. +""" + +import json +import logging +import uuid +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Optional + +from modules.demoConfigs._baseDemoConfig import _BaseDemoConfig + +logger = logging.getLogger(__name__) + +_DEMO_PREFIX = "demo-pwg2026" + +_MANDATE_PWG = { + "name": "stiftung-pwg", + "label": "Stiftung PWG", +} + +_USER = { + "username": "pwg.demo", + "email": "pwg.demo@poweron.swiss", + "fullName": "PWG Demo Sachbearbeiter", + "password": "pwg.demo.2026", + "language": "de", +} + +_FEATURES_PWG = [ + {"code": "workspace", "label": "Dokumentenablage PWG"}, + {"code": "trustee", "label": "Buchhaltung PWG"}, + {"code": "graphicalEditor", "label": "PWG Automationen"}, + {"code": "neutralization", "label": "Datenschutz"}, +] + +# Filename markers used to identify the imported pilot workflow on remove(). +_PILOT_WORKFLOW_LABEL = "PWG Pilot: Jahresmietzinsbestätigung" +_PILOT_WORKFLOW_FILE = "pwg-mietzinsbestaetigung-pilot.workflow.json" +_SEED_TRUSTEE_FILE = "_seedTrusteeData.json" + + +class PwgDemo2026(_BaseDemoConfig): + code = "pwg-demo-2026" + label = "PWG Pilot Demo (Mietzinsbestätigungen)" + description = ( + "Stiftung PWG, ein Demo-Sachbearbeiter, Trustee mit fiktiven Mietern, " + "Graph-Editor mit dem Pilot-Workflow für Jahresmietzinsbestätigungen " + "(als File importiert, active=false). Idempotent." + ) + + # ------------------------------------------------------------------ + # load + # ------------------------------------------------------------------ + def load(self, db) -> Dict[str, Any]: + summary: Dict[str, Any] = {"created": [], "skipped": [], "errors": []} + + try: + mandateId = self._ensureMandate(db, _MANDATE_PWG, summary) + userId = self._ensureUser(db, summary) + self._ensurePlatformAdminFlag(db, userId, summary) + + if mandateId and userId: + self._ensureMembership(db, userId, mandateId, _MANDATE_PWG["label"], summary) + self._ensureFeatures(db, mandateId, _MANDATE_PWG["label"], _FEATURES_PWG, summary) + self._ensureFeatureAccess(db, userId, mandateId, _MANDATE_PWG["label"], summary) + self._ensureNeutralizationConfig(db, mandateId, userId, summary) + self._ensureBilling(db, mandateId, _MANDATE_PWG["label"], summary) + + trusteeInstanceId = self._getFeatureInstanceId(db, mandateId, "trustee", "Buchhaltung PWG") + if trusteeInstanceId: + self._ensureTrusteeSeed(mandateId, trusteeInstanceId, summary) + + graphInstanceId = self._getFeatureInstanceId(db, mandateId, "graphicalEditor", "PWG Automationen") + if graphInstanceId: + self._ensurePilotWorkflow(mandateId, graphInstanceId, summary) + + except Exception as e: + logger.error(f"PWG demo load failed: {e}", exc_info=True) + summary["errors"].append(str(e)) + + return summary + + # ------------------------------------------------------------------ + # remove + # ------------------------------------------------------------------ + def remove(self, db) -> Dict[str, Any]: + summary: Dict[str, Any] = {"removed": [], "errors": []} + + from modules.datamodels.datamodelMembership import UserMandate + from modules.datamodels.datamodelUam import Mandate, UserInDB + + try: + existing = db.getRecordset(Mandate, recordFilter={"name": _MANDATE_PWG["name"]}) + for m in existing: + mid = m.get("id") + self._removeMandateData(db, mid, _MANDATE_PWG["label"], summary) + db.recordDelete(Mandate, mid) + summary["removed"].append(f"Mandate {_MANDATE_PWG['label']} ({mid})") + logger.info(f"Removed mandate {_MANDATE_PWG['label']} ({mid})") + except Exception as e: + summary["errors"].append(f"Remove mandate {_MANDATE_PWG['label']}: {e}") + + try: + existing = db.getRecordset(UserInDB, recordFilter={"username": _USER["username"]}) + for u in existing: + uid = u.get("id") + memberships = db.getRecordset(UserMandate, recordFilter={"userId": uid}) or [] + for mem in memberships: + try: + db.recordDelete(UserMandate, mem.get("id")) + except Exception: + pass + db.recordDelete(UserInDB, uid) + summary["removed"].append(f"User {_USER['username']} ({uid})") + logger.info(f"Removed user {_USER['username']} ({uid})") + except Exception as e: + summary["errors"].append(f"Remove user: {e}") + + return summary + + # ================================================================== + # — load helpers (mostly mirrors of investorDemo2026.py) + # ================================================================== + + def _ensureMandate(self, db, mandateDef: Dict, summary: Dict) -> Optional[str]: + from modules.datamodels.datamodelUam import Mandate + from modules.interfaces.interfaceBootstrap import copySystemRolesToMandate + + existing = db.getRecordset(Mandate, recordFilter={"name": mandateDef["name"]}) + if existing: + mid = existing[0].get("id") + summary["skipped"].append(f"Mandate {mandateDef['label']} exists ({mid})") + return mid + + mandate = Mandate(name=mandateDef["name"], label=mandateDef["label"], enabled=True) + created = db.recordCreate(Mandate, mandate) + mid = created.get("id") + logger.info(f"Created mandate {mandateDef['label']} ({mid})") + summary["created"].append(f"Mandate {mandateDef['label']}") + copySystemRolesToMandate(db, mid) + return mid + + def _ensureUser(self, db, summary: Dict) -> Optional[str]: + from modules.datamodels.datamodelUam import AuthAuthority, UserInDB + from passlib.context import CryptContext + + existing = db.getRecordset(UserInDB, recordFilter={"username": _USER["username"]}) + if existing: + uid = existing[0].get("id") + summary["skipped"].append(f"User {_USER['username']} exists ({uid})") + return uid + + pwdContext = CryptContext(schemes=["argon2"], deprecated="auto") + user = UserInDB( + username=_USER["username"], + email=_USER["email"], + fullName=_USER["fullName"], + enabled=True, + language=_USER["language"], + isSysAdmin=True, + authenticationAuthority=AuthAuthority.LOCAL, + hashedPassword=pwdContext.hash(_USER["password"]), + ) + created = db.recordCreate(UserInDB, user) + uid = created.get("id") + logger.info(f"Created user {_USER['username']} ({uid})") + summary["created"].append(f"User {_USER['fullName']}") + return uid + + def _ensurePlatformAdminFlag(self, db, userId: Optional[str], summary: Dict): + from modules.datamodels.datamodelUam import UserInDB + if not userId: + return + existing = db.getRecord(UserInDB, userId) + if not existing: + summary["errors"].append(f"User {userId} not found — cannot set isPlatformAdmin") + return + currentFlag = bool(existing.get("isPlatformAdmin", False)) if isinstance(existing, dict) else bool(getattr(existing, "isPlatformAdmin", False)) + if currentFlag: + summary["skipped"].append("isPlatformAdmin already set") + return + db.recordModify(UserInDB, userId, {"isPlatformAdmin": True}) + summary["created"].append("isPlatformAdmin flag") + + def _ensureMembership(self, db, userId: str, mandateId: str, mandateLabel: str, summary: Dict): + from modules.datamodels.datamodelMembership import UserMandate, UserMandateRole + from modules.datamodels.datamodelRbac import Role + + existing = db.getRecordset(UserMandate, recordFilter={"userId": userId, "mandateId": mandateId}) + if existing: + userMandateId = existing[0].get("id") + summary["skipped"].append(f"Membership {_USER['username']} -> {mandateLabel} exists") + else: + um = UserMandate(userId=userId, mandateId=mandateId, enabled=True) + created = db.recordCreate(UserMandate, um) + userMandateId = created.get("id") + summary["created"].append(f"Membership {_USER['username']} -> {mandateLabel}") + + adminRoles = db.getRecordset(Role, recordFilter={"mandateId": mandateId, "roleLabel": "admin"}) + if adminRoles: + adminRoleId = adminRoles[0].get("id") + existingRole = db.getRecordset(UserMandateRole, recordFilter={"userMandateId": userMandateId, "roleId": adminRoleId}) + if not existingRole: + umr = UserMandateRole(userMandateId=userMandateId, roleId=adminRoleId) + db.recordCreate(UserMandateRole, umr) + + def _ensureFeatures(self, db, mandateId: str, mandateLabel: str, featureDefs: List[Dict], summary: Dict): + from modules.interfaces.interfaceFeatures import getFeatureInterface + + fi = getFeatureInterface(db) + existingInstances = fi.getFeatureInstancesForMandate(mandateId) + existingLabels = { + (inst.label if hasattr(inst, "label") else inst.get("label", "")) + for inst in existingInstances + } + + for featureDef in featureDefs: + code = featureDef["code"] + instanceLabel = featureDef["label"] + if instanceLabel in existingLabels: + summary["skipped"].append(f"Feature '{instanceLabel}' in {mandateLabel} exists") + continue + try: + fi.createFeatureInstance( + featureCode=code, + mandateId=mandateId, + label=instanceLabel, + enabled=True, + copyTemplateRoles=True, + ) + summary["created"].append(f"Feature '{instanceLabel}' in {mandateLabel}") + except Exception as e: + summary["errors"].append(f"Feature '{instanceLabel}' in {mandateLabel}: {e}") + + def _ensureFeatureAccess(self, db, userId: str, mandateId: str, mandateLabel: str, summary: Dict): + from modules.datamodels.datamodelFeatures import FeatureInstance + from modules.datamodels.datamodelMembership import FeatureAccess, FeatureAccessRole + from modules.datamodels.datamodelRbac import Role + + instances = db.getRecordset(FeatureInstance, recordFilter={"mandateId": mandateId}) or [] + + for inst in instances: + instId = inst.get("id") + featureCode = inst.get("featureCode", "") + if not instId: + continue + + existing = db.getRecordset(FeatureAccess, recordFilter={"userId": userId, "featureInstanceId": instId}) + if existing: + featureAccessId = existing[0].get("id") + summary["skipped"].append(f"FeatureAccess {featureCode} in {mandateLabel} exists") + else: + fa = FeatureAccess(userId=userId, featureInstanceId=instId, enabled=True) + created = db.recordCreate(FeatureAccess, fa) + featureAccessId = created.get("id") + summary["created"].append(f"FeatureAccess {featureCode} in {mandateLabel}") + + adminRoleLabel = f"{featureCode}-admin" + adminRoles = db.getRecordset(Role, recordFilter={ + "featureInstanceId": instId, + "roleLabel": adminRoleLabel, + }) + if adminRoles: + adminRoleId = adminRoles[0].get("id") + existingRole = db.getRecordset(FeatureAccessRole, recordFilter={ + "featureAccessId": featureAccessId, + "roleId": adminRoleId, + }) + if not existingRole: + far = FeatureAccessRole(featureAccessId=featureAccessId, roleId=adminRoleId) + db.recordCreate(FeatureAccessRole, far) + + def _ensureNeutralizationConfig(self, db, mandateId: Optional[str], userId: Optional[str], summary: Dict): + if not mandateId or not userId: + return + from modules.datamodels.datamodelFeatures import FeatureInstance + instances = db.getRecordset(FeatureInstance, recordFilter={"mandateId": mandateId, "featureCode": "neutralization"}) + if not instances: + return + instanceId = instances[0].get("id") + try: + from modules.features.neutralization.datamodelFeatureNeutralizer import DataNeutraliserConfig + existing = db.getRecordset(DataNeutraliserConfig, recordFilter={"featureInstanceId": instanceId}) + if existing: + summary["skipped"].append(f"Neutralization config for mandate {mandateId} exists") + return + config = DataNeutraliserConfig( + featureInstanceId=instanceId, + mandateId=mandateId, + userId=userId, + enabled=True, + scope="featureInstance", + ) + db.recordCreate(DataNeutraliserConfig, config) + summary["created"].append(f"Neutralization config for mandate {mandateId}") + except Exception as e: + summary["errors"].append(f"Neutralization config: {e}") + + def _ensureBilling(self, db, mandateId: Optional[str], mandateLabel: str, summary: Dict): + if not mandateId: + return + try: + from modules.datamodels.datamodelBilling import BillingSettings + from modules.interfaces.interfaceDbBilling import _getRootInterface + + billingInterface = _getRootInterface() + existingSettings = billingInterface.getSettings(mandateId) + if existingSettings: + summary["skipped"].append(f"Billing for {mandateLabel} exists") + return + settings = BillingSettings( + mandateId=mandateId, + warningThresholdPercent=10.0, + notifyOnWarning=True, + ) + billingInterface.db.recordCreate(BillingSettings, settings) + summary["created"].append(f"Billing settings for {mandateLabel}") + except Exception as e: + summary["errors"].append(f"Billing for {mandateLabel}: {e}") + + def _getFeatureInstanceId(self, db, mandateId: str, featureCode: str, label: str) -> Optional[str]: + from modules.datamodels.datamodelFeatures import FeatureInstance + instances = db.getRecordset(FeatureInstance, recordFilter={ + "mandateId": mandateId, + "featureCode": featureCode, + "label": label, + }) or [] + if instances: + return instances[0].get("id") + # fallback: any instance of that feature in the mandate + instances = db.getRecordset(FeatureInstance, recordFilter={ + "mandateId": mandateId, + "featureCode": featureCode, + }) or [] + return instances[0].get("id") if instances else None + + # ------------------------------------------------------------------ + # PWG-specific helpers — Trustee seed-data + pilot-workflow import + # ------------------------------------------------------------------ + + def _ensureTrusteeSeed(self, mandateId: str, featureInstanceId: str, summary: Dict): + """Idempotently load 5 fictitious tenants and their 12-month rent + journal lines into the trustee database for this feature instance. + + Skips any tenant whose contact (matched by name+address) already + exists, so re-running ``load()`` is safe. + """ + seedPath = _demoDataDir() / "pwg" / _SEED_TRUSTEE_FILE + if not seedPath.is_file(): + summary["errors"].append(f"PWG seed file missing: {seedPath}") + return + try: + seed = json.loads(seedPath.read_text(encoding="utf-8")) + except Exception as exc: + summary["errors"].append(f"PWG seed file unreadable: {exc}") + return + + try: + trusteeDb = _openTrusteeDb() + except Exception as exc: + summary["errors"].append(f"Trustee DB connection failed: {exc}") + return + + from modules.features.trustee.datamodelFeatureTrustee import ( + TrusteeDataAccount, + TrusteeDataContact, + TrusteeDataJournalEntry, + TrusteeDataJournalLine, + ) + + rentAccountNumber = str(seed.get("rentAccount", "6000")) + year = int(seed.get("year", datetime.now().year)) + + # 1) Ensure rent account exists once + existingAccounts = trusteeDb.getRecordset(TrusteeDataAccount, recordFilter={ + "featureInstanceId": featureInstanceId, + "accountNumber": rentAccountNumber, + }) or [] + if not existingAccounts: + trusteeDb.recordCreate(TrusteeDataAccount, TrusteeDataAccount( + accountNumber=rentAccountNumber, + label=str(seed.get("rentAccountLabel", "Mietzinsertrag")), + accountType="revenue", + accountGroup="rental_income", + currency="CHF", + isActive=True, + mandateId=mandateId, + featureInstanceId=featureInstanceId, + )) + summary["created"].append(f"Trustee account {rentAccountNumber}") + + # 2) Ensure contacts + monthly journal entries + createdTenants = 0 + skippedTenants = 0 + for tenant in seed.get("tenants", []): + name = tenant.get("name", "") + address = tenant.get("address", "") + if not name: + continue + existing = trusteeDb.getRecordset(TrusteeDataContact, recordFilter={ + "featureInstanceId": featureInstanceId, + "name": name, + "address": address, + }) or [] + if existing: + skippedTenants += 1 + continue + + contact = TrusteeDataContact( + externalId=tenant.get("contactNumber"), + contactType="customer", + contactNumber=tenant.get("contactNumber"), + name=name, + address=address, + zip=tenant.get("zip"), + city=tenant.get("city"), + country=tenant.get("country"), + email=tenant.get("email"), + mandateId=mandateId, + featureInstanceId=featureInstanceId, + ) + trusteeDb.recordCreate(TrusteeDataContact, contact) + createdTenants += 1 + + # 12 monthly rent bookings (credit on rent account) + monthlyRent = float(tenant.get("monthlyRentChf") or 0.0) + if monthlyRent <= 0: + continue + for month in range(1, 13): + bookingDate = f"{year}-{month:02d}-01" + entryRef = f"PWG-{tenant.get('contactNumber')}-{year}{month:02d}" + entry = TrusteeDataJournalEntry( + externalId=entryRef, + bookingDate=bookingDate, + reference=entryRef, + description=f"Mietzins {month:02d}/{year} {name}", + currency="CHF", + totalAmount=monthlyRent, + mandateId=mandateId, + featureInstanceId=featureInstanceId, + ) + createdEntry = trusteeDb.recordCreate(TrusteeDataJournalEntry, entry) + line = TrusteeDataJournalLine( + journalEntryId=createdEntry.get("id"), + accountNumber=rentAccountNumber, + debitAmount=0.0, + creditAmount=monthlyRent, + currency="CHF", + description=f"Mietzins {month:02d}/{year} {name} ({tenant.get('contactNumber')})", + mandateId=mandateId, + featureInstanceId=featureInstanceId, + ) + trusteeDb.recordCreate(TrusteeDataJournalLine, line) + + if createdTenants: + summary["created"].append(f"PWG seed: {createdTenants} tenants × 12 monthly journal lines") + if skippedTenants: + summary["skipped"].append(f"PWG seed: {skippedTenants} tenants already present") + + def _ensurePilotWorkflow(self, mandateId: str, featureInstanceId: str, summary: Dict): + """Import the pilot workflow JSON into the graphical-editor DB. + + Uses the schema-aware import pipeline introduced in Phase 1 + (``_workflowFileSchema.envelopeToWorkflowData`` + + ``GraphicalEditorObjects.importWorkflowFromDict``). The workflow is + always created with ``active=False`` so a manual trigger is required + — this matches the demo-bootstrap safety default. + """ + envelopePath = _demoDataDir() / "workflows" / _PILOT_WORKFLOW_FILE + if not envelopePath.is_file(): + summary["errors"].append(f"Pilot workflow file missing: {envelopePath}") + return + try: + envelope = json.loads(envelopePath.read_text(encoding="utf-8")) + except Exception as exc: + summary["errors"].append(f"Pilot workflow file unreadable: {exc}") + return + + try: + geDb = _openGraphicalEditorDb() + except Exception as exc: + summary["errors"].append(f"GraphicalEditor DB connection failed: {exc}") + return + + from modules.features.graphicalEditor._workflowFileSchema import ( + envelopeToWorkflowData, + validateFileEnvelope, + ) + from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import AutoWorkflow + from modules.features.graphicalEditor.nodeRegistry import STATIC_NODE_TYPES + + existing = geDb.getRecordset(AutoWorkflow, recordFilter={ + "mandateId": mandateId, + "featureInstanceId": featureInstanceId, + "label": _PILOT_WORKFLOW_LABEL, + }) or [] + if existing: + summary["skipped"].append(f"Pilot workflow already imported ({existing[0].get('id')})") + return + + knownTypes = [n.get("id") for n in STATIC_NODE_TYPES if isinstance(n, dict) and n.get("id")] + try: + normalized, warnings = validateFileEnvelope(envelope, knownNodeTypes=knownTypes) + except Exception as exc: + summary["errors"].append(f"Pilot workflow envelope invalid: {exc}") + return + if warnings: + summary["created"].append(f"Pilot workflow warnings: {warnings}") + + data = envelopeToWorkflowData( + normalized, + mandateId=mandateId, + featureInstanceId=featureInstanceId, + ) + # Inject the trustee feature-instance id into the parameters so the + # node runtime resolves it without manual editor cleanup. + trusteeInstanceId = self._guessTrusteeInstanceId(mandateId) + if trusteeInstanceId: + for node in data.get("graph", {}).get("nodes", []) or []: + params = node.get("parameters") or {} + if "featureInstanceId" in params and not params["featureInstanceId"]: + params["featureInstanceId"] = trusteeInstanceId + node["parameters"] = params + + # Force-import: AutoWorkflow.create accepts our envelope-derived data + # (graph, label, invocations, …) verbatim; we add ids/timestamps that + # AutoWorkflow expects. + record = AutoWorkflow( + id=str(uuid.uuid4()), + mandateId=mandateId, + featureInstanceId=featureInstanceId, + label=data.get("label") or _PILOT_WORKFLOW_LABEL, + description=data.get("description") or "", + tags=data.get("tags") or [], + graph=data.get("graph") or {"nodes": [], "connections": []}, + invocations=data.get("invocations") or [], + templateScope=data.get("templateScope") or "instance", + sharedReadOnly=bool(data.get("sharedReadOnly")), + notifyOnFailure=bool(data.get("notifyOnFailure", True)), + active=False, + ) + created = geDb.recordCreate(AutoWorkflow, record) + summary["created"].append(f"Pilot workflow imported (active=false, id={created.get('id')})") + logger.info(f"Imported pilot workflow into graphicalEditor instance {featureInstanceId}") + + def _guessTrusteeInstanceId(self, mandateId: str) -> Optional[str]: + """Return the first trustee feature-instance id of the given mandate. + + The demo only ever creates one trustee feature in this mandate, so a + first-hit lookup is sufficient and avoids depending on the label. + """ + try: + from modules.connectors.connectorDbPostgre import DatabaseConnector + from modules.datamodels.datamodelFeatures import FeatureInstance + from modules.shared.configuration import APP_CONFIG + appDb = DatabaseConnector( + dbHost=APP_CONFIG.get("DB_HOST", "localhost"), + dbDatabase="poweron_app", + dbUser=APP_CONFIG.get("DB_USER"), + dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD"), + dbPort=int(APP_CONFIG.get("DB_PORT", 5432)), + userId=None, + ) + instances = appDb.getRecordset(FeatureInstance, recordFilter={ + "mandateId": mandateId, + "featureCode": "trustee", + }) or [] + return instances[0].get("id") if instances else None + except Exception as exc: + logger.warning(f"Could not resolve trustee instance for mandate {mandateId}: {exc}") + return None + + # ------------------------------------------------------------------ + # remove helpers + # ------------------------------------------------------------------ + + def _removeMandateData(self, db, mandateId: str, mandateLabel: str, summary: Dict): + """Cascade-delete everything created by load() for this mandate.""" + from modules.datamodels.datamodelBilling import BillingSettings + from modules.datamodels.datamodelChat import ChatLog, ChatMessage, ChatWorkflow + from modules.datamodels.datamodelFeatures import FeatureInstance + from modules.datamodels.datamodelMembership import ( + FeatureAccess, + FeatureAccessRole, + UserMandate, + UserMandateRole, + ) + from modules.datamodels.datamodelRbac import AccessRule, Role + + instances = db.getRecordset(FeatureInstance, recordFilter={"mandateId": mandateId}) or [] + for inst in instances: + instId = inst.get("id") + featureCode = inst.get("featureCode", "") + if not instId: + continue + + if featureCode == "graphicalEditor": + self._removeGraphicalEditorData(instId, mandateId, mandateLabel, summary) + if featureCode == "trustee": + self._removeTrusteeSeed(instId, mandateLabel, summary) + if featureCode == "neutralization": + self._removeNeutralizationData(db, instId, mandateLabel, summary) + + chatWorkflows = db.getRecordset(ChatWorkflow, recordFilter={"featureInstanceId": instId}) or [] + for wf in chatWorkflows: + wfId = wf.get("id") + for msg in db.getRecordset(ChatMessage, recordFilter={"workflowId": wfId}) or []: + db.recordDelete(ChatMessage, msg.get("id")) + for log in db.getRecordset(ChatLog, recordFilter={"workflowId": wfId}) or []: + db.recordDelete(ChatLog, log.get("id")) + db.recordDelete(ChatWorkflow, wfId) + + accesses = db.getRecordset(FeatureAccess, recordFilter={"featureInstanceId": instId}) or [] + for access in accesses: + for role in db.getRecordset(FeatureAccessRole, recordFilter={"featureAccessId": access.get("id")}) or []: + db.recordDelete(FeatureAccessRole, role.get("id")) + db.recordDelete(FeatureAccess, access.get("id")) + + db.recordDelete(FeatureInstance, instId) + summary["removed"].append(f"FeatureInstance {featureCode} in {mandateLabel}") + + memberships = db.getRecordset(UserMandate, recordFilter={"mandateId": mandateId}) or [] + for um in memberships: + for umr in db.getRecordset(UserMandateRole, recordFilter={"userMandateId": um.get("id")}) or []: + db.recordDelete(UserMandateRole, umr.get("id")) + db.recordDelete(UserMandate, um.get("id")) + + roles = db.getRecordset(Role, recordFilter={"mandateId": mandateId}) or [] + for role in roles: + for rule in db.getRecordset(AccessRule, recordFilter={"roleId": role.get("id")}) or []: + db.recordDelete(AccessRule, rule.get("id")) + db.recordDelete(Role, role.get("id")) + + try: + from modules.interfaces.interfaceDbBilling import _getRootInterface + billingDb = _getRootInterface().db + billingSettings = billingDb.getRecordset(BillingSettings, recordFilter={"mandateId": mandateId}) or [] + for bs in billingSettings: + billingDb.recordDelete(BillingSettings, bs.get("id")) + except Exception as e: + summary["errors"].append(f"Billing cleanup for {mandateLabel}: {e}") + + def _removeGraphicalEditorData(self, featureInstanceId: str, mandateId: str, mandateLabel: str, summary: Dict): + try: + from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import ( + AutoRun, + AutoStepLog, + AutoTask, + AutoVersion, + AutoWorkflow, + ) + geDb = _openGraphicalEditorDb() + workflows = geDb.getRecordset(AutoWorkflow, recordFilter={ + "mandateId": mandateId, + "featureInstanceId": featureInstanceId, + }) or [] + for wf in workflows: + wfId = wf.get("id") + for version in geDb.getRecordset(AutoVersion, recordFilter={"workflowId": wfId}) or []: + geDb.recordDelete(AutoVersion, version.get("id")) + for run in geDb.getRecordset(AutoRun, recordFilter={"workflowId": wfId}) or []: + runId = run.get("id") + for step in geDb.getRecordset(AutoStepLog, recordFilter={"runId": runId}) or []: + geDb.recordDelete(AutoStepLog, step.get("id")) + geDb.recordDelete(AutoRun, runId) + for task in geDb.getRecordset(AutoTask, recordFilter={"workflowId": wfId}) or []: + geDb.recordDelete(AutoTask, task.get("id")) + geDb.recordDelete(AutoWorkflow, wfId) + if workflows: + summary["removed"].append(f"{len(workflows)} AutoWorkflows in {mandateLabel}") + except Exception as e: + summary["errors"].append(f"GraphicalEditor cleanup for {mandateLabel}: {e}") + + def _removeTrusteeSeed(self, featureInstanceId: str, mandateLabel: str, summary: Dict): + try: + from modules.features.trustee.datamodelFeatureTrustee import ( + TrusteeAccountingConfig, + TrusteeDataAccount, + TrusteeDataContact, + TrusteeDataJournalEntry, + TrusteeDataJournalLine, + ) + trusteeDb = _openTrusteeDb() + for model in ( + TrusteeDataJournalLine, + TrusteeDataJournalEntry, + TrusteeDataContact, + TrusteeDataAccount, + TrusteeAccountingConfig, + ): + rows = trusteeDb.getRecordset(model, recordFilter={"featureInstanceId": featureInstanceId}) or [] + for row in rows: + trusteeDb.recordDelete(model, row.get("id")) + if rows: + summary["removed"].append(f"{len(rows)} {model.__name__} in {mandateLabel}") + except Exception as e: + summary["errors"].append(f"Trustee cleanup for {mandateLabel}: {e}") + + def _removeNeutralizationData(self, db, featureInstanceId: str, mandateLabel: str, summary: Dict): + try: + from modules.features.neutralization.datamodelFeatureNeutralizer import DataNeutraliserConfig + configs = db.getRecordset(DataNeutraliserConfig, recordFilter={"featureInstanceId": featureInstanceId}) or [] + for cfg in configs: + db.recordDelete(DataNeutraliserConfig, cfg.get("id")) + if configs: + summary["removed"].append(f"DataNeutraliserConfig in {mandateLabel}") + except Exception as e: + summary["errors"].append(f"Neutralization cleanup for {mandateLabel}: {e}") + + +# ---------------------------------------------------------------------- +# Module-level helpers (private) +# ---------------------------------------------------------------------- + + +def _demoDataDir() -> Path: + """Return absolute path to ``gateway/demoData`` regardless of CWD.""" + # __file__ = .../gateway/modules/demoConfigs/pwgDemo2026.py + return Path(__file__).resolve().parents[2] / "demoData" + + +def _openTrusteeDb(): + """Open a privileged DB connection to ``poweron_trustee`` (used by both + seed and remove paths so they work consistently).""" + from modules.connectors.connectorDbPostgre import DatabaseConnector + from modules.shared.configuration import APP_CONFIG + return DatabaseConnector( + dbHost=APP_CONFIG.get("DB_HOST", "localhost"), + dbDatabase="poweron_trustee", + dbUser=APP_CONFIG.get("DB_USER"), + dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD"), + dbPort=int(APP_CONFIG.get("DB_PORT", 5432)), + userId=None, + ) + + +def _openGraphicalEditorDb(): + """Open a privileged DB connection to ``poweron_graphicaleditor``.""" + from modules.connectors.connectorDbPostgre import DatabaseConnector + from modules.shared.configuration import APP_CONFIG + return DatabaseConnector( + dbHost=APP_CONFIG.get("DB_HOST", "localhost"), + dbDatabase="poweron_graphicaleditor", + dbUser=APP_CONFIG.get("DB_USER"), + dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD"), + dbPort=int(APP_CONFIG.get("DB_PORT", 5432)), + userId=None, + ) diff --git a/modules/features/graphicalEditor/_workflowFileSchema.py b/modules/features/graphicalEditor/_workflowFileSchema.py new file mode 100644 index 00000000..2ab5dfc9 --- /dev/null +++ b/modules/features/graphicalEditor/_workflowFileSchema.py @@ -0,0 +1,284 @@ +# Copyright (c) 2026 Patrick Motsch +# All rights reserved. +""" +Workflow File Schema (Versioned Envelope) for the GraphicalEditor. + +A *workflow file* is a portable JSON representation of an ``AutoWorkflow`` that +can be exchanged between mandates / instances / installations. It contains the +graph, the entry-points (invocations), and a small set of metadata under the +``$``-prefixed envelope keys. + +Persistence-bound fields (``id``, ``mandateId``, ``featureInstanceId``, +``currentVersionId``, ``eventId``, ``active``, ``sysCreated*``, +``sysModified*``) are NEVER part of the file — they are stripped on export and +re-derived on import. + +Reference: ``wiki/c-work/1-plan/2026-04-pwg-pilot-mietzinsbestaetigung-workflow.md`` +""" + +from typing import Any, Dict, List, Optional, Tuple +import logging +import time + +logger = logging.getLogger(__name__) + +WORKFLOW_FILE_SCHEMA_VERSION = "1.0" +WORKFLOW_FILE_KIND = "poweron.workflow" +WORKFLOW_FILE_EXTENSION = ".workflow.json" + +_PERSISTENCE_FIELDS = ( + "id", + "mandateId", + "featureInstanceId", + "currentVersionId", + "eventId", + "active", + "templateSourceId", + "sysCreatedBy", + "sysCreatedAt", + "sysModifiedBy", + "sysModifiedAt", +) + +_ENVELOPE_KEYS = ( + "$schemaVersion", + "$kind", + "$exportedAt", + "$gatewayVersion", +) + +_PORTABLE_WORKFLOW_FIELDS = ( + "label", + "description", + "tags", + "templateScope", + "sharedReadOnly", + "notifyOnFailure", + "isTemplate", + "graph", + "invocations", +) + + +class WorkflowFileSchemaError(ValueError): + """Raised when a workflow file does not conform to the expected schema.""" + + +def isWorkflowFileEnvelope(payload: Any) -> bool: + """Quick content-sniff used by the UDB to decide whether a file is a + workflow envelope (without raising on malformed input).""" + if not isinstance(payload, dict): + return False + if payload.get("$kind") == WORKFLOW_FILE_KIND: + return True + if "$schemaVersion" in payload and isinstance(payload.get("graph"), dict): + return True + return False + + +def _normalizeNodePosition(node: Dict[str, Any]) -> Dict[str, Any]: + """Canonicalize node coordinates to top-level ``x`` / ``y``. + + The canvas uses top-level ``x`` / ``y``; the agent ``addNode`` tool also + accepts ``position={x, y}``. Files may use either (or both) shape — pick + whatever is present and persist the canonical form. + """ + if not isinstance(node, dict): + return node + out = dict(node) + pos = out.pop("position", None) + x = out.get("x") + y = out.get("y") + if x is None and isinstance(pos, dict): + x = pos.get("x") + if y is None and isinstance(pos, dict): + y = pos.get("y") + if x is None: + x = 0 + if y is None: + y = 0 + out["x"] = x + out["y"] = y + return out + + +def normalizeGraph(graph: Any) -> Dict[str, Any]: + """Return a graph dict with ``nodes`` and ``connections`` lists, node + coordinates normalized to top-level ``x`` / ``y``.""" + if not isinstance(graph, dict): + return {"nodes": [], "connections": []} + nodes = graph.get("nodes") or [] + connections = graph.get("connections") or [] + if not isinstance(nodes, list): + nodes = [] + if not isinstance(connections, list): + connections = [] + return { + "nodes": [_normalizeNodePosition(n) for n in nodes if isinstance(n, dict)], + "connections": [c for c in connections if isinstance(c, dict)], + } + + +def _stripPersistenceFields(workflowDict: Dict[str, Any]) -> Dict[str, Any]: + """Return a copy of *workflowDict* with all persistence-bound fields removed.""" + out = {} + for k, v in workflowDict.items(): + if k in _PERSISTENCE_FIELDS: + continue + out[k] = v + return out + + +def buildFileFromWorkflow( + workflowDict: Dict[str, Any], + gatewayVersion: Optional[str] = None, +) -> Dict[str, Any]: + """Build a portable workflow-file envelope from an ``AutoWorkflow`` row. + + Strips persistence-bound fields, normalizes the graph, and prepends the + ``$``-envelope keys. + """ + if not isinstance(workflowDict, dict): + raise WorkflowFileSchemaError("workflowDict must be a dict") + + body: Dict[str, Any] = {} + body["$schemaVersion"] = WORKFLOW_FILE_SCHEMA_VERSION + body["$kind"] = WORKFLOW_FILE_KIND + body["$exportedAt"] = _isoTimestamp() + if gatewayVersion: + body["$gatewayVersion"] = str(gatewayVersion) + + stripped = _stripPersistenceFields(workflowDict) + for field in _PORTABLE_WORKFLOW_FIELDS: + if field in stripped: + value = stripped[field] + if field == "graph": + value = normalizeGraph(value) + body[field] = value + + return body + + +def validateFileEnvelope( + payload: Any, + knownNodeTypes: Optional[List[str]] = None, +) -> Tuple[Dict[str, Any], List[str]]: + """Validate a workflow-file envelope. + + Returns ``(normalizedEnvelope, warnings)``. Raises + ``WorkflowFileSchemaError`` on hard errors (unknown schema version, + missing graph, unknown node types). + """ + if not isinstance(payload, dict): + raise WorkflowFileSchemaError("Workflow file must be a JSON object") + + schemaVersion = payload.get("$schemaVersion") + if not schemaVersion: + raise WorkflowFileSchemaError( + "Missing $schemaVersion — file is not a recognized workflow file" + ) + if schemaVersion != WORKFLOW_FILE_SCHEMA_VERSION: + raise WorkflowFileSchemaError( + f"Unsupported $schemaVersion '{schemaVersion}' " + f"(this gateway supports '{WORKFLOW_FILE_SCHEMA_VERSION}')" + ) + + kind = payload.get("$kind") + if kind and kind != WORKFLOW_FILE_KIND: + raise WorkflowFileSchemaError( + f"Unexpected $kind '{kind}' (expected '{WORKFLOW_FILE_KIND}')" + ) + + label = payload.get("label") + if not isinstance(label, str) or not label.strip(): + raise WorkflowFileSchemaError("Field 'label' is required and must be a non-empty string") + + graph = payload.get("graph") + if not isinstance(graph, dict): + raise WorkflowFileSchemaError("Field 'graph' is required and must be an object") + + normalizedGraph = normalizeGraph(graph) + warnings: List[str] = [] + + if not normalizedGraph["nodes"]: + warnings.append("Workflow has no nodes") + + if knownNodeTypes is not None: + knownSet = set(knownNodeTypes) + unknownTypes = [] + for node in normalizedGraph["nodes"]: + nodeType = node.get("type") + if nodeType and nodeType not in knownSet: + unknownTypes.append(nodeType) + if unknownTypes: + uniqueUnknown = sorted(set(unknownTypes)) + raise WorkflowFileSchemaError( + "Workflow file references unknown node type(s) not registered in this gateway: " + + ", ".join(uniqueUnknown) + ) + + nodeIds = {n.get("id") for n in normalizedGraph["nodes"] if n.get("id")} + for c in normalizedGraph["connections"]: + src = c.get("source") + tgt = c.get("target") + if src and src not in nodeIds: + warnings.append(f"Connection source '{src}' is not a known node id") + if tgt and tgt not in nodeIds: + warnings.append(f"Connection target '{tgt}' is not a known node id") + + out: Dict[str, Any] = {} + for k in _ENVELOPE_KEYS: + if k in payload: + out[k] = payload[k] + for field in _PORTABLE_WORKFLOW_FIELDS: + if field in payload: + out[field] = payload[field] + out["graph"] = normalizedGraph + + return out, warnings + + +def envelopeToWorkflowData( + envelope: Dict[str, Any], + mandateId: str, + featureInstanceId: str, +) -> Dict[str, Any]: + """Convert a validated workflow-file envelope into a dict suitable for + ``GraphicalEditorObjects.createWorkflow`` / ``updateWorkflow``. + + Imports are always inactive — operators must explicitly activate them. + Persistence-bound fields are NEVER copied from the envelope. + """ + data: Dict[str, Any] = { + "mandateId": mandateId, + "featureInstanceId": featureInstanceId, + "active": False, + } + for field in _PORTABLE_WORKFLOW_FIELDS: + if field in envelope: + data[field] = envelope[field] + if "label" not in data or not data["label"]: + data["label"] = "Imported Workflow" + if "graph" in data: + data["graph"] = normalizeGraph(data["graph"]) + return data + + +def _isoTimestamp() -> str: + """UTC timestamp in ISO 8601 format (used for ``$exportedAt``).""" + return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + + +def buildFileName(label: str) -> str: + """Build a safe filename ``.workflow.json`` from a workflow label.""" + base = (label or "workflow").strip().lower() + safe_chars = [] + for ch in base: + if ch.isalnum() or ch in ("-", "_"): + safe_chars.append(ch) + elif ch in (" ", ":", "/", "\\", "."): + safe_chars.append("-") + slug = "".join(safe_chars).strip("-") or "workflow" + while "--" in slug: + slug = slug.replace("--", "-") + return f"{slug[:80]}{WORKFLOW_FILE_EXTENSION}" diff --git a/modules/features/graphicalEditor/interfaceFeatureGraphicalEditor.py b/modules/features/graphicalEditor/interfaceFeatureGraphicalEditor.py index 6622391a..8cdb18c6 100644 --- a/modules/features/graphicalEditor/interfaceFeatureGraphicalEditor.py +++ b/modules/features/graphicalEditor/interfaceFeatureGraphicalEditor.py @@ -653,6 +653,62 @@ class GraphicalEditorObjects: }) return dict(updated) + # ------------------------------------------------------------------------- + # Workflow File IO (versioned envelope export/import) + # ------------------------------------------------------------------------- + + def exportWorkflowToDict(self, workflowId: str) -> Optional[Dict[str, Any]]: + """Export an existing workflow as a portable file envelope (dict). + + The returned dict is the canonical workflow-file payload (versioned + envelope) and can be JSON-serialized as-is. Returns ``None`` if the + workflow does not exist for this mandate. + """ + from modules.features.graphicalEditor._workflowFileSchema import buildFileFromWorkflow + + wf = self.getWorkflow(workflowId) + if not wf: + return None + return buildFileFromWorkflow(wf) + + def importWorkflowFromDict( + self, + envelope: Dict[str, Any], + existingWorkflowId: Optional[str] = None, + ) -> Dict[str, Any]: + """Import a workflow-file envelope. + + Validates the envelope, then either creates a new workflow (default) + or replaces the graph + invocations of an existing workflow when + ``existingWorkflowId`` is given. Imports are always saved with + ``active=False`` so operators can review before scheduling. + """ + from modules.features.graphicalEditor._workflowFileSchema import ( + envelopeToWorkflowData, + validateFileEnvelope, + ) + from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES + + knownTypes = [n.get("id") for n in STATIC_NODE_TYPES if isinstance(n, dict) and n.get("id")] + normalizedEnvelope, warnings = validateFileEnvelope(envelope, knownNodeTypes=knownTypes) + data = envelopeToWorkflowData( + normalizedEnvelope, + mandateId=self.mandateId, + featureInstanceId=self.featureInstanceId, + ) + + if existingWorkflowId: + existing = self.getWorkflow(existingWorkflowId) + if not existing: + raise ValueError( + f"Cannot replace workflow {existingWorkflowId}: not found in this mandate" + ) + updated = self.updateWorkflow(existingWorkflowId, data) or {} + return {"workflow": updated, "warnings": warnings, "created": False} + + created = self.createWorkflow(data) + return {"workflow": created, "warnings": warnings, "created": True} + # Backward-compatible alias Automation2Objects = GraphicalEditorObjects diff --git a/modules/features/graphicalEditor/nodeDefinitions/clickup.py b/modules/features/graphicalEditor/nodeDefinitions/clickup.py index 51ddfada..210fe7f7 100644 --- a/modules/features/graphicalEditor/nodeDefinitions/clickup.py +++ b/modules/features/graphicalEditor/nodeDefinitions/clickup.py @@ -12,6 +12,7 @@ CLICKUP_NODES = [ "description": t("Aufgaben in einem Workspace suchen"), "parameters": [ {"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection", + "frontendOptions": {"authority": "clickup"}, "description": t("ClickUp-Verbindung")}, {"name": "teamId", "type": "string", "required": True, "frontendType": "text", "description": t("Team-/Workspace-ID")}, @@ -44,6 +45,7 @@ CLICKUP_NODES = [ "description": t("Aufgaben einer Liste auflisten"), "parameters": [ {"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection", + "frontendOptions": {"authority": "clickup"}, "description": t("ClickUp-Verbindung")}, {"name": "pathQuery", "type": "string", "required": True, "frontendType": "clickupList", "frontendOptions": {"dependsOn": "connectionReference"}, @@ -68,6 +70,7 @@ CLICKUP_NODES = [ "description": t("Eine Aufgabe abrufen"), "parameters": [ {"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection", + "frontendOptions": {"authority": "clickup"}, "description": t("ClickUp-Verbindung")}, {"name": "taskId", "type": "string", "required": False, "frontendType": "text", "description": t("Task-ID")}, @@ -89,6 +92,7 @@ CLICKUP_NODES = [ "description": t("Aufgabe erstellen"), "parameters": [ {"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection", + "frontendOptions": {"authority": "clickup"}, "description": t("ClickUp-Verbindung")}, {"name": "teamId", "type": "string", "required": False, "frontendType": "text", "description": t("Workspace")}, @@ -134,6 +138,7 @@ CLICKUP_NODES = [ "description": t("Felder der Aufgabe ändern"), "parameters": [ {"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection", + "frontendOptions": {"authority": "clickup"}, "description": t("ClickUp-Verbindung")}, {"name": "taskId", "type": "string", "required": False, "frontendType": "text", "description": t("Task-ID")}, @@ -159,6 +164,7 @@ CLICKUP_NODES = [ "description": t("Datei an Task anhängen"), "parameters": [ {"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection", + "frontendOptions": {"authority": "clickup"}, "description": t("ClickUp-Verbindung")}, {"name": "taskId", "type": "string", "required": False, "frontendType": "text", "description": t("Task-ID")}, diff --git a/modules/features/graphicalEditor/nodeDefinitions/email.py b/modules/features/graphicalEditor/nodeDefinitions/email.py index e2e852f0..30872815 100644 --- a/modules/features/graphicalEditor/nodeDefinitions/email.py +++ b/modules/features/graphicalEditor/nodeDefinitions/email.py @@ -11,6 +11,7 @@ EMAIL_NODES = [ "description": t("Neue E-Mails prüfen"), "parameters": [ {"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection", + "frontendOptions": {"authority": "msft"}, "description": t("E-Mail-Konto Verbindung")}, {"name": "folder", "type": "string", "required": False, "frontendType": "text", "description": t("Ordner"), "default": "Inbox"}, @@ -40,6 +41,7 @@ EMAIL_NODES = [ "description": t("E-Mails suchen"), "parameters": [ {"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection", + "frontendOptions": {"authority": "msft"}, "description": t("E-Mail-Konto Verbindung")}, {"name": "query", "type": "string", "required": False, "frontendType": "text", "description": t("Suchbegriff"), "default": ""}, @@ -75,6 +77,7 @@ EMAIL_NODES = [ "description": t("E-Mail-Entwurf erstellen"), "parameters": [ {"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection", + "frontendOptions": {"authority": "msft"}, "description": t("E-Mail-Konto")}, {"name": "subject", "type": "string", "required": True, "frontendType": "text", "description": t("Betreff")}, @@ -82,10 +85,15 @@ EMAIL_NODES = [ "description": t("Inhalt")}, {"name": "to", "type": "string", "required": False, "frontendType": "text", "description": t("Empfänger"), "default": ""}, + {"name": "attachments", "type": "json", "required": False, "frontendType": "attachmentBuilder", + "description": t( + "Anhänge: Liste von { contentRef | csvFromVariable | base64Content, name, mimeType }. " + "Per Wire befüllbar (z.B. CSV aus data.consolidate)."), + "default": []}, ], "inputs": 1, "outputs": 1, - "inputPorts": {0: {"accepts": ["EmailDraft", "AiResult", "Transit"]}}, + "inputPorts": {0: {"accepts": ["EmailDraft", "AiResult", "Transit", "ConsolidateResult", "DocumentList"]}}, "outputPorts": {0: {"schema": "ActionResult"}}, "meta": {"icon": "mdi-email-edit", "color": "#1976D2", "usesAi": False}, "_method": "outlook", diff --git a/modules/features/graphicalEditor/nodeDefinitions/sharepoint.py b/modules/features/graphicalEditor/nodeDefinitions/sharepoint.py index 4bb93256..1faa6bbb 100644 --- a/modules/features/graphicalEditor/nodeDefinitions/sharepoint.py +++ b/modules/features/graphicalEditor/nodeDefinitions/sharepoint.py @@ -11,6 +11,7 @@ SHAREPOINT_NODES = [ "description": t("Datei nach Pfad oder Suche finden"), "parameters": [ {"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection", + "frontendOptions": {"authority": "msft"}, "description": t("SharePoint-Verbindung")}, {"name": "searchQuery", "type": "string", "required": True, "frontendType": "text", "description": t("Suchanfrage oder Pfad")}, @@ -34,6 +35,7 @@ SHAREPOINT_NODES = [ "description": t("Inhalt aus Datei extrahieren"), "parameters": [ {"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection", + "frontendOptions": {"authority": "msft"}, "description": t("SharePoint-Verbindung")}, {"name": "pathQuery", "type": "string", "required": True, "frontendType": "sharepointFile", "frontendOptions": {"dependsOn": "connectionReference"}, @@ -54,6 +56,7 @@ SHAREPOINT_NODES = [ "description": t("Datei zu SharePoint hochladen"), "parameters": [ {"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection", + "frontendOptions": {"authority": "msft"}, "description": t("SharePoint-Verbindung")}, {"name": "pathQuery", "type": "string", "required": True, "frontendType": "sharepointFolder", "frontendOptions": {"dependsOn": "connectionReference"}, @@ -74,6 +77,7 @@ SHAREPOINT_NODES = [ "description": t("Dateien in Ordner auflisten"), "parameters": [ {"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection", + "frontendOptions": {"authority": "msft"}, "description": t("SharePoint-Verbindung")}, {"name": "pathQuery", "type": "string", "required": False, "frontendType": "sharepointFolder", "frontendOptions": {"dependsOn": "connectionReference"}, @@ -94,6 +98,7 @@ SHAREPOINT_NODES = [ "description": t("Datei vom Pfad herunterladen"), "parameters": [ {"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection", + "frontendOptions": {"authority": "msft"}, "description": t("SharePoint-Verbindung")}, {"name": "pathQuery", "type": "string", "required": True, "frontendType": "sharepointFile", "frontendOptions": {"dependsOn": "connectionReference"}, @@ -114,6 +119,7 @@ SHAREPOINT_NODES = [ "description": t("Datei an Ziel kopieren"), "parameters": [ {"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection", + "frontendOptions": {"authority": "msft"}, "description": t("SharePoint-Verbindung")}, {"name": "sourcePath", "type": "string", "required": True, "frontendType": "sharepointFile", "frontendOptions": {"dependsOn": "connectionReference"}, diff --git a/modules/features/graphicalEditor/nodeDefinitions/trustee.py b/modules/features/graphicalEditor/nodeDefinitions/trustee.py index 18874c40..0eb5e119 100644 --- a/modules/features/graphicalEditor/nodeDefinitions/trustee.py +++ b/modules/features/graphicalEditor/nodeDefinitions/trustee.py @@ -34,6 +34,7 @@ TRUSTEE_NODES = [ "description": t("Dokumenttyp und Daten aus PDF/JPG per AI extrahieren."), "parameters": [ {"name": "connectionReference", "type": "string", "required": False, "frontendType": "userConnection", + "frontendOptions": {"authority": "msft"}, "description": t("SharePoint-Verbindung"), "default": ""}, {"name": "sharepointFolder", "type": "string", "required": False, "frontendType": "sharepointFolder", "frontendOptions": {"dependsOn": "connectionReference"}, @@ -89,4 +90,42 @@ TRUSTEE_NODES = [ "_method": "trustee", "_action": "syncToAccounting", }, + { + "id": "trustee.queryData", + "category": "trustee", + "label": t("Treuhand-Daten abfragen"), + "description": t("Daten aus der Trustee-DB lesen (Lookup, Aggregation, Roh-Export). Pendant zu refreshAccountingData ohne externen Sync."), + "parameters": [ + {"name": "featureInstanceId", "type": "string", "required": True, "frontendType": "hidden", + "description": t("Trustee Feature-Instanz-ID")}, + {"name": "mode", "type": "string", "required": True, "frontendType": "select", + "frontendOptions": {"options": ["lookup", "raw", "aggregate"]}, + "description": t("Abfragemodus"), "default": "lookup"}, + {"name": "entity", "type": "string", "required": True, "frontendType": "select", + "frontendOptions": {"options": ["tenantWithRent", "contact", "journalLines", "accounts", "balances"]}, + "description": t("Entität, die gelesen werden soll"), "default": "tenantWithRent"}, + {"name": "tenantNameRef", "type": "string", "required": False, "frontendType": "text", + "frontendOptions": {"dependsOn": "entity", "showWhen": ["tenantWithRent", "contact"]}, + "description": t("Mietername (oder {{wire.feld}} aus Upstream)"), "default": ""}, + {"name": "tenantAddressRef", "type": "string", "required": False, "frontendType": "text", + "frontendOptions": {"dependsOn": "entity", "showWhen": ["tenantWithRent", "contact"]}, + "description": t("Mieteradresse (Toleranz für Tippfehler)"), "default": ""}, + {"name": "period", "type": "string", "required": False, "frontendType": "text", + "frontendOptions": {"dependsOn": "entity", "showWhen": ["tenantWithRent", "journalLines", "balances"]}, + "description": t("Zeitraum (YYYY oder YYYY-MM-DD/YYYY-MM-DD)"), "default": ""}, + {"name": "rentAccountPattern", "type": "string", "required": False, "frontendType": "text", + "frontendOptions": {"dependsOn": "entity", "showWhen": ["tenantWithRent"]}, + "description": t("Konto-Filter für Mietzins (z.B. '6000-6099' oder '6*')"), "default": ""}, + {"name": "filterJson", "type": "string", "required": False, "frontendType": "textarea", + "frontendOptions": {"dependsOn": "mode", "showWhen": ["raw", "aggregate"]}, + "description": t("Optionaler JSON-Filter für mode=raw/aggregate"), "default": ""}, + ], + "inputs": 1, + "outputs": 1, + "inputPorts": {0: {"accepts": ["Transit", "AiResult", "ConsolidateResult"]}}, + "outputPorts": {0: {"schema": "ActionResult"}}, + "meta": {"icon": "mdi-database-search", "color": "#4CAF50", "usesAi": False}, + "_method": "trustee", + "_action": "queryData", + }, ] diff --git a/modules/features/graphicalEditor/routeFeatureGraphicalEditor.py b/modules/features/graphicalEditor/routeFeatureGraphicalEditor.py index 494cebb9..11d9d3e9 100644 --- a/modules/features/graphicalEditor/routeFeatureGraphicalEditor.py +++ b/modules/features/graphicalEditor/routeFeatureGraphicalEditor.py @@ -11,7 +11,7 @@ import math from typing import Any, Dict, List, Optional from fastapi import APIRouter, Depends, Path, Query, Body, Request, HTTPException -from fastapi.responses import JSONResponse, StreamingResponse +from fastapi.responses import JSONResponse, StreamingResponse, Response from modules.auth import limiter, getRequestContext, RequestContext from modules.datamodels.datamodelPagination import PaginationParams, PaginationMetadata, normalize_pagination_dict from modules.routes.routeHelpers import _applyFiltersAndSort @@ -135,6 +135,58 @@ def get_node_types( return result +@router.get("/{instanceId}/options/user.connection") +@limiter.limit("60/minute") +def get_user_connection_options( + request: Request, + instanceId: str = Path(..., description="Feature instance ID"), + authority: Optional[str] = Query(None, description="Optional authority filter (e.g. 'msft', 'google', 'clickup', 'local')"), + activeOnly: bool = Query(True, description="If true (default), only ACTIVE connections are returned"), + context: RequestContext = Depends(getRequestContext), +) -> dict: + """Return current user's UserConnections as { options: [{ value, label }] }. + + Used by node parameters with frontendType='userConnection'. Optional + `authority` lets a node declare which provider it expects (e.g. SharePoint + nodes pass authority=msft so only Microsoft connections show up). + """ + _validateInstanceAccess(instanceId, context) + if not context.user: + raise HTTPException(status_code=401, detail=routeApiMsg("Authentication required")) + from modules.interfaces.interfaceDbApp import getRootInterface + rootInterface = getRootInterface() + try: + connections = rootInterface.getUserConnections(str(context.user.id)) or [] + except Exception as e: + logger.error("get_user_connection_options: failed to load connections: %s", e, exc_info=True) + return {"options": []} + wanted = (authority or "").strip().lower() or None + options: List[Dict[str, str]] = [] + for conn in connections: + connStatus = getattr(conn, "status", None) + statusVal = connStatus.value if hasattr(connStatus, "value") else str(connStatus or "") + if activeOnly and statusVal.lower() != "active": + continue + connAuthority = getattr(conn, "authority", None) + authorityVal = (connAuthority.value if hasattr(connAuthority, "value") else str(connAuthority or "")).lower() + if wanted and authorityVal != wanted: + continue + username = getattr(conn, "externalUsername", "") or "" + email = getattr(conn, "externalEmail", "") or "" + connId = str(getattr(conn, "id", "") or "") + labelParts = [p for p in [username, email] if p] + label = " — ".join(labelParts) if labelParts else connId + if authorityVal: + label = f"[{authorityVal}] {label}" + value = f"connection:{authorityVal}:{username}" if authorityVal and username else connId + options.append({"value": value, "label": label}) + logger.info( + "graphicalEditor user.connection options: instanceId=%s authority=%s -> %d options", + instanceId, wanted, len(options), + ) + return {"options": options} + + @router.post("/{instanceId}/execute") @limiter.limit("30/minute") async def post_execute( @@ -753,15 +805,32 @@ async def _runEditorAgent( systemPrompt = ( "You are a workflow EDITOR assistant for the GraphicalEditor. " - "Your ONLY job is to BUILD or MODIFY the workflow graph (nodes + connections) " - "for the user — you must NEVER execute the workflow or any of its actions. " - "Even when the user says 'create a workflow that sends an email', you build the " - "graph (e.g. add an email node, connect it) — you do NOT actually send an email. " - "\n\nGraph-mutating tools: readWorkflowGraph, listAvailableNodeTypes, " + "Your job is to MANAGE workflows for the user — create, rename, " + "import/export, edit the graph (nodes + connections) — but you must " + "NEVER execute a workflow or any of its actions. Even when the user " + "says 'create a workflow that sends an email', you build the graph " + "(add an email node, connect it) — you do NOT actually send an email." + "\n\nAvailable tools (all valid — use whichever the user's intent calls for):" + "\n Graph-mutating: readWorkflowGraph, listAvailableNodeTypes, " "describeNodeType, addNode, removeNode, connectNodes, setNodeParameter, " - "autoLayoutWorkflow, validateGraph. " - "Connection discovery (for parameters of frontendType='userConnection'): listConnections." - "\n\nMandatory build sequence:" + "autoLayoutWorkflow, validateGraph." + "\n Workflow lifecycle: createWorkflow (new empty workflow), " + "updateWorkflowMetadata (rename / change description / tags / activate), " + "createWorkflowFromFile (import .workflow.json from UDB), " + "exportWorkflowToFile (download envelope), deleteWorkflow (destructive — " + "ALWAYS confirm with the user before calling)." + "\n History: listWorkflowHistory, readWorkflowMessages." + "\n Connections (for parameters of frontendType='userConnection'): listConnections." + "\n\nIntent → tool mapping (do NOT improvise destructive paths):" + "\n • 'rename / umbenennen / call it X / nenne … um' → updateWorkflowMetadata({label: \"X\"})." + "\n • 'create empty workflow / new workflow / leeren Workflow' → createWorkflow({label: \"…\"})." + "\n • 'import / load from file' → createWorkflowFromFile({fileId: …})." + "\n • 'export / save to file / download' → exportWorkflowToFile()." + "\n • 'activate / deactivate' → updateWorkflowMetadata({active: true|false})." + "\n NEVER batch-call removeNode to 'rebuild' or 'rename' a workflow — that " + "destroys the user's work. removeNode is for removing ONE specific node the " + "user explicitly asked to delete." + "\n\nMandatory build sequence WHEN editing the graph:" "\n1. readWorkflowGraph — understand current state." "\n2. listAvailableNodeTypes — find candidate node ids." "\n3. For EACH node type you plan to add: call describeNodeType(nodeType=...) " @@ -781,7 +850,7 @@ async def _runEditorAgent( "\n\nIf a required parameter cannot be filled from the user's request and has " "no safe default, ask the user once for that specific value (e.g. recipient " "address, target language, prompt text) instead of leaving the field blank. " - "Respond concisely in the user's language and list what you changed in the graph." + "Respond concisely in the user's language and list what you changed." ) editorConfig = AgentConfig( @@ -1191,6 +1260,128 @@ def delete_workflow( return {"success": True} +# ------------------------------------------------------------------------- +# Workflow File IO (versioned envelope export/import) +# ------------------------------------------------------------------------- + + +@router.post("/{instanceId}/workflows/import") +@limiter.limit("30/minute") +def import_workflow( + request: Request, + instanceId: str = Path(..., description="Feature instance ID"), + body: dict = Body( + ..., + description=( + "{ envelope: , existingWorkflowId?: str, " + "fileId?: str } — supply EITHER the envelope inline OR a fileId of " + "a previously uploaded workflow file (.workflow.json)" + ), + ), + context: RequestContext = Depends(getRequestContext), +) -> dict: + """Import a workflow from a versioned-envelope file. + + Two input modes: + - ``envelope``: the parsed workflow-file payload (preferred for the agent) + - ``fileId``: the id of a previously uploaded ``.workflow.json`` in + Unified-Data-Bar (preferred for the UI "Import" modal) + + On success returns the created/updated workflow plus any non-fatal + warnings (e.g. dangling connection references). Imports are always + saved with ``active=False``. + """ + from modules.features.graphicalEditor._workflowFileSchema import WorkflowFileSchemaError + + mandateId = _validateInstanceAccess(instanceId, context) + iface = getGraphicalEditorInterface(context.user, mandateId, instanceId) + + envelope = body.get("envelope") if isinstance(body, dict) else None + fileId = body.get("fileId") if isinstance(body, dict) else None + existingWorkflowId = body.get("existingWorkflowId") if isinstance(body, dict) else None + + if not envelope and fileId: + envelope = _loadEnvelopeFromFile(str(fileId), context) + + if not envelope: + raise HTTPException( + status_code=400, + detail=routeApiMsg("Body must contain 'envelope' or 'fileId'"), + ) + + try: + result = iface.importWorkflowFromDict(envelope, existingWorkflowId=existingWorkflowId) + except WorkflowFileSchemaError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) + + return result + + +@router.get("/{instanceId}/workflows/{workflowId}/export") +@limiter.limit("60/minute") +def export_workflow( + request: Request, + instanceId: str = Path(..., description="Feature instance ID"), + workflowId: str = Path(..., description="Workflow ID"), + download: bool = Query(False, description="If true, return as file download"), + context: RequestContext = Depends(getRequestContext), +): + """Export a workflow as a versioned-envelope JSON file. + + With ``download=true`` returns a streaming response with the canonical + ``.workflow.json`` filename so the browser triggers a save dialog. + Without it returns the envelope inline as JSON (used by the agent and by + the editor's "Save to file" → upload-to-UDB flow). + """ + from modules.features.graphicalEditor._workflowFileSchema import buildFileName + + mandateId = _validateInstanceAccess(instanceId, context) + iface = getGraphicalEditorInterface(context.user, mandateId, instanceId) + envelope = iface.exportWorkflowToDict(workflowId) + if envelope is None: + raise HTTPException(status_code=404, detail=routeApiMsg("Workflow not found")) + + if not download: + return {"envelope": envelope, "fileName": buildFileName(envelope.get("label", "workflow"))} + + fileName = buildFileName(envelope.get("label", "workflow")) + payload = json.dumps(envelope, ensure_ascii=False, indent=2).encode("utf-8") + return Response( + content=payload, + media_type="application/json", + headers={"Content-Disposition": f'attachment; filename="{fileName}"'}, + ) + + +def _loadEnvelopeFromFile(fileId: str, context: RequestContext) -> Optional[Dict[str, Any]]: + """Load and parse a ``.workflow.json`` file from the Unified-Data-Bar + by file id. Returns the parsed envelope dict or raises HTTPException.""" + try: + import modules.interfaces.interfaceDbManagement as interfaceDbManagement + mgmt = interfaceDbManagement.getInterface(context.user) + rawBytes = mgmt.getFileData(fileId) + except Exception as exc: + logger.warning("Failed to load workflow file %s: %s", fileId, exc) + raise HTTPException(status_code=404, detail=routeApiMsg(f"File {fileId} not found")) + + if not rawBytes: + raise HTTPException(status_code=404, detail=routeApiMsg(f"File {fileId} is empty")) + + try: + if isinstance(rawBytes, bytes): + text = rawBytes.decode("utf-8") + else: + text = str(rawBytes) + return json.loads(text) + except Exception as exc: + raise HTTPException( + status_code=400, + detail=routeApiMsg(f"File {fileId} is not valid JSON: {exc}"), + ) + + # ------------------------------------------------------------------------- # Runs and Resume # ------------------------------------------------------------------------- diff --git a/modules/features/trustee/accounting/accountingConnectorBase.py b/modules/features/trustee/accounting/accountingConnectorBase.py index 355b6f34..c5124184 100644 --- a/modules/features/trustee/accounting/accountingConnectorBase.py +++ b/modules/features/trustee/accounting/accountingConnectorBase.py @@ -56,6 +56,7 @@ class ConnectorConfigField(BaseModel): secret: bool = False required: bool = True placeholder: Optional[str] = None + suggestions: Optional[List[str]] = None class BaseAccountingConnector(ABC): diff --git a/modules/features/trustee/accounting/accountingDataSync.py b/modules/features/trustee/accounting/accountingDataSync.py index e0584a02..e422566f 100644 --- a/modules/features/trustee/accounting/accountingDataSync.py +++ b/modules/features/trustee/accounting/accountingDataSync.py @@ -215,17 +215,34 @@ class AccountingDataSync: logger.error(f"Compute balances failed: {e}") summary["errors"].append(f"Balances: {e}") - # Update config with last import timestamp - try: - cfgId = cfgRecord.get("id") - if cfgId: - self._if.db.recordModify(TrusteeAccountingConfig, cfgId, { - "lastSyncAt": time.time(), - "lastSyncStatus": "success" if not summary["errors"] else "partial", - "lastSyncErrorMessage": "; ".join(summary["errors"])[:500] if summary["errors"] else None, - }) - except Exception: - pass + cfgId = cfgRecord.get("id") + if cfgId: + corePayload = { + "lastSyncAt": time.time(), + "lastSyncStatus": "success" if not summary["errors"] else "partial", + "lastSyncErrorMessage": "; ".join(summary["errors"])[:500] if summary["errors"] else None, + } + try: + self._if.db.recordModify(TrusteeAccountingConfig, cfgId, corePayload) + except Exception as coreErr: + logger.exception(f"AccountingDataSync: failed to write core lastSync* fields for cfg {cfgId}: {coreErr}") + summary["errors"].append(f"Persist lastSync core: {coreErr}") + extPayload = { + "lastSyncDateFrom": dateFrom, + "lastSyncDateTo": dateTo, + "lastSyncCounts": { + "accounts": int(summary.get("accounts", 0)), + "journalEntries": int(summary.get("journalEntries", 0)), + "journalLines": int(summary.get("journalLines", 0)), + "contacts": int(summary.get("contacts", 0)), + "accountBalances": int(summary.get("accountBalances", 0)), + }, + } + try: + self._if.db.recordModify(TrusteeAccountingConfig, cfgId, extPayload) + except Exception as extErr: + logger.exception(f"AccountingDataSync: failed to write extended lastSync* fields for cfg {cfgId}: {extErr}") + summary["errors"].append(f"Persist lastSync ext: {extErr}") summary["finishedAt"] = time.time() summary["durationSeconds"] = round(summary["finishedAt"] - summary["startedAt"], 1) diff --git a/modules/features/trustee/accounting/connectors/accountingConnectorRma.py b/modules/features/trustee/accounting/connectors/accountingConnectorRma.py index bcf52561..79a61d77 100644 --- a/modules/features/trustee/accounting/connectors/accountingConnectorRma.py +++ b/modules/features/trustee/accounting/connectors/accountingConnectorRma.py @@ -47,6 +47,10 @@ class AccountingConnectorRma(BaseAccountingConnector): fieldType="text", secret=False, placeholder="https://service.runmyaccounts.com/api/latest/clients/", + suggestions=[ + "https://service.runmyaccounts.com/api/latest/clients/", + "https://service.int.runmyaccounts.com/api/latest/clients/", + ], ), ConnectorConfigField( key="clientName", diff --git a/modules/features/trustee/datamodelFeatureTrustee.py b/modules/features/trustee/datamodelFeatureTrustee.py index 5d1b4263..fcf5c8b4 100644 --- a/modules/features/trustee/datamodelFeatureTrustee.py +++ b/modules/features/trustee/datamodelFeatureTrustee.py @@ -3,7 +3,7 @@ """Trustee models: TrusteeOrganisation, TrusteeRole, TrusteeAccess, TrusteeContract, TrusteeDocument, TrusteePosition.""" from enum import Enum -from typing import Optional +from typing import Optional, Dict from pydantic import BaseModel, Field from modules.datamodels.datamodelBase import PowerOnModel @@ -818,6 +818,9 @@ class TrusteeAccountingConfig(PowerOnModel): lastSyncAt: Optional[float] = Field(default=None, description="Timestamp of last sync attempt", json_schema_extra={"label": "Letzte Synchronisation"}) lastSyncStatus: Optional[str] = Field(default=None, description="Last sync result: success, error, partial", json_schema_extra={"label": "Status"}) lastSyncErrorMessage: Optional[str] = Field(default=None, description="Error message when lastSyncStatus is error", json_schema_extra={"label": "Fehlermeldung"}) + lastSyncDateFrom: Optional[str] = Field(default=None, description="dateFrom (ISO date) of the last data import window", json_schema_extra={"label": "Letztes Import-Fenster von"}) + lastSyncDateTo: Optional[str] = Field(default=None, description="dateTo (ISO date) of the last data import window", json_schema_extra={"label": "Letztes Import-Fenster bis"}) + lastSyncCounts: Optional[Dict[str, int]] = Field(default=None, description="Per-entity counts of the last import (accounts, journalEntries, journalLines, contacts, accountBalances)", json_schema_extra={"label": "Letzte Import-Zaehler"}) cachedChartOfAccounts: Optional[str] = Field(default=None, description="JSON-serialised chart of accounts cache (list of {accountNumber, label, accountType})", json_schema_extra={"label": "Cached Kontoplan"}) chartCachedAt: Optional[float] = Field(default=None, description="Timestamp when cachedChartOfAccounts was last refreshed", json_schema_extra={"label": "Kontoplan-Cache-Zeitpunkt"}) mandateId: Optional[str] = Field(default=None, json_schema_extra={"label": "Mandat", "fk_target": {"db": "poweron_app", "table": "Mandate"}}) diff --git a/modules/features/trustee/routeFeatureTrustee.py b/modules/features/trustee/routeFeatureTrustee.py index 73752788..7b80189e 100644 --- a/modules/features/trustee/routeFeatureTrustee.py +++ b/modules/features/trustee/routeFeatureTrustee.py @@ -1643,7 +1643,46 @@ def get_position_sync_status( # ===== Accounting Data Import ===== -@router.post("/{instanceId}/accounting/import-data") +TRUSTEE_ACCOUNTING_SYNC_JOB_TYPE = "trusteeAccountingSync" + + +async def _trusteeAccountingSyncJobHandler(job: Dict[str, Any], progressCb) -> Dict[str, Any]: + """BackgroundJob handler: imports accounting data from the external system. + + Reads inputs from `job["payload"]` (dateFrom, dateTo, userId) and runs + `AccountingDataSync.importData(...)` in the worker's event loop without + blocking the original HTTP request that submitted the job. + """ + from modules.security.rootAccess import getRootUser + from .accounting.accountingDataSync import AccountingDataSync + + instanceId = job["featureInstanceId"] + mandateId = job["mandateId"] + payload = job.get("payload") or {} + rootUser = getRootUser() + + progressCb(5, "Initialisiere Import...") + interface = getInterface(rootUser, mandateId=mandateId, featureInstanceId=instanceId) + sync = AccountingDataSync(interface) + progressCb(10, "Lese Daten vom Buchhaltungssystem...") + result = await sync.importData( + featureInstanceId=instanceId, + mandateId=mandateId, + dateFrom=payload.get("dateFrom"), + dateTo=payload.get("dateTo"), + ) + progressCb(100, "Import abgeschlossen") + return result + + +try: + from modules.serviceCenter.services.serviceBackgroundJobs import registerJobHandler + registerJobHandler(TRUSTEE_ACCOUNTING_SYNC_JOB_TYPE, _trusteeAccountingSyncJobHandler) +except Exception as _regErr: + logger.warning("Failed to register trusteeAccountingSync job handler: %s", _regErr) + + +@router.post("/{instanceId}/accounting/import-data", status_code=status.HTTP_202_ACCEPTED) @limiter.limit("3/minute") async def import_accounting_data( request: Request, @@ -1651,20 +1690,26 @@ async def import_accounting_data( data: Dict[str, Any] = Body(default={}), context: RequestContext = Depends(getRequestContext) ) -> Dict[str, Any]: - """Import accounting data (chart, journal entries, contacts) from the external system into TrusteeData* tables.""" + """Submit a background job to import accounting data. + + Returns immediately with `{ jobId }`; clients poll `GET /api/jobs/{jobId}` + until status is SUCCESS / ERROR. + """ + from modules.serviceCenter.services.serviceBackgroundJobs import startJob + mandateId = _validateInstanceAccess(instanceId, context) - interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) - from .accounting.accountingDataSync import AccountingDataSync - sync = AccountingDataSync(interface) - dateFrom = data.get("dateFrom") - dateTo = data.get("dateTo") - result = await sync.importData( - featureInstanceId=instanceId, + payload = { + "dateFrom": data.get("dateFrom"), + "dateTo": data.get("dateTo"), + } + jobId = await startJob( + TRUSTEE_ACCOUNTING_SYNC_JOB_TYPE, + payload, mandateId=mandateId, - dateFrom=dateFrom, - dateTo=dateTo, + featureInstanceId=instanceId, + triggeredBy=context.user.id if context.user else None, ) - return result + return {"jobId": jobId, "status": "pending"} @router.get("/{instanceId}/accounting/import-status") @@ -1695,6 +1740,9 @@ def get_import_status( counts["lastSyncAt"] = cfg.get("lastSyncAt") counts["lastSyncStatus"] = cfg.get("lastSyncStatus") counts["lastSyncErrorMessage"] = cfg.get("lastSyncErrorMessage") + counts["lastSyncDateFrom"] = cfg.get("lastSyncDateFrom") + counts["lastSyncDateTo"] = cfg.get("lastSyncDateTo") + counts["lastSyncCounts"] = cfg.get("lastSyncCounts") return counts diff --git a/modules/interfaces/interfaceDbApp.py b/modules/interfaces/interfaceDbApp.py index 2a88fecc..c754684f 100644 --- a/modules/interfaces/interfaceDbApp.py +++ b/modules/interfaces/interfaceDbApp.py @@ -19,6 +19,7 @@ from modules.connectors.connectorDbPostgre import DatabaseConnector, _get_cached from modules.shared.configuration import APP_CONFIG from modules.shared.dbRegistry import registerDatabase from modules.shared.timeUtils import getUtcTimestamp, parseTimestamp +from modules.shared.i18nRegistry import resolveText from modules.interfaces.interfaceRbac import getRecordsetWithRBAC from modules.security.rbac import RbacClass from modules.datamodels.datamodelUam import ( @@ -1639,7 +1640,7 @@ class AppObjects: if not featureDef.get("autoCreateInstance", False): continue featureCode = featureDef.get("code", featureName) - featureLabel = featureDef.get("label", {}).get("en", featureName) + featureLabel = resolveText(featureDef.get("label", featureName)) instance = featureInterface.createFeatureInstance( featureCode=featureCode, mandateId=mandateId, diff --git a/modules/routes/routeBilling.py b/modules/routes/routeBilling.py index 9b238df1..b0967259 100644 --- a/modules/routes/routeBilling.py +++ b/modules/routes/routeBilling.py @@ -935,11 +935,34 @@ async def stripeWebhook( return {"received": True} session_dict = session.to_dict_recursive() if hasattr(session, "to_dict_recursive") else dict(session) - result = _creditStripeSessionIfNeeded(billingInterface, session_dict, eventId=event_id) - logger.info( - f"Stripe webhook processed session {result.sessionId}: " - f"credited={result.credited}, alreadyCredited={result.alreadyCredited}" - ) + try: + result = _creditStripeSessionIfNeeded(billingInterface, session_dict, eventId=event_id) + logger.info( + f"Stripe webhook processed session {result.sessionId}: " + f"credited={result.credited}, alreadyCredited={result.alreadyCredited}" + ) + except HTTPException as he: + logger.error( + "Stripe webhook %s for session %s failed: status=%s detail=%s metadata=%s amount_total=%s", + event_id, + session_dict.get("id"), + he.status_code, + he.detail, + session_dict.get("metadata"), + session_dict.get("amount_total"), + ) + if 400 <= he.status_code < 500 and event_id: + if not billingInterface.getStripeWebhookEventByEventId(event_id): + try: + billingInterface.createStripeWebhookEvent(event_id) + logger.warning( + "Marked Stripe event %s as processed (permanent 4xx) to stop retries", + event_id, + ) + except Exception as markEx: + logger.error("Failed to mark Stripe event %s as processed: %s", event_id, markEx) + return {"received": True} + raise return {"received": True} @@ -1036,8 +1059,22 @@ def _handleSubscriptionCheckoutCompleted(session, eventId: str) -> None: operative = subInterface.getOperativeForMandate(mandateId) hasActivePredecessor = operative is not None and operative["id"] != subscriptionRecordId + predecessorIsTrial = ( + hasActivePredecessor + and operative.get("status") == SubscriptionStatusEnum.TRIALING.value + ) - if hasActivePredecessor: + if hasActivePredecessor and predecessorIsTrial: + try: + subInterface.forceExpire(operative["id"]) + logger.info( + "Trial subscription %s expired immediately for mandate %s due to paid upgrade %s", + operative["id"], mandateId, subscriptionRecordId, + ) + except Exception as e: + logger.error("Failed to expire trial predecessor %s: %s", operative["id"], e) + toStatus = SubscriptionStatusEnum.ACTIVE + elif hasActivePredecessor: toStatus = SubscriptionStatusEnum.SCHEDULED if operative.get("recurring", True): operativeStripeId = operative.get("stripeSubscriptionId") diff --git a/modules/routes/routeDataFiles.py b/modules/routes/routeDataFiles.py index f5b6f3d4..82cf1624 100644 --- a/modules/routes/routeDataFiles.py +++ b/modules/routes/routeDataFiles.py @@ -23,6 +23,55 @@ routeApiMsg = apiRouteContext("routeDataFiles") logger = logging.getLogger(__name__) +def _resolveFileWithScope(currentUser: User, context: RequestContext, fileId: str): + """Returns (managementInterface, fileItem) with RBAC scoped to the file's own mandate/instance. + + Files generated by workflows (e.g. AI report outputs) carry their own + mandateId/featureInstanceId. Direct download links via cannot send + custom scope headers, so we resolve the scope from the FileItem itself and + re-check RBAC in that scope. + + Returns (None, None) if the file does not exist or the user lacks access + in the file's actual scope. + """ + requestMandateId = str(context.mandateId) if context.mandateId else None + requestInstanceId = str(context.featureInstanceId) if context.featureInstanceId else None + + mgmt = interfaceDbManagement.getInterface( + currentUser, + mandateId=requestMandateId, + featureInstanceId=requestInstanceId, + ) + fileItem = mgmt.getFile(fileId) + if fileItem: + return mgmt, fileItem + + metas = mgmt.db.getRecordset(FileItem, recordFilter={"id": fileId}) + if not metas: + return None, None + + meta = metas[0] + fileMandateId = meta.get("mandateId") or None + fileInstanceId = meta.get("featureInstanceId") or None + + if not fileMandateId and not fileInstanceId: + return None, None + + if fileMandateId == requestMandateId and fileInstanceId == requestInstanceId: + return None, None + + scopedMgmt = interfaceDbManagement.getInterface( + currentUser, + mandateId=fileMandateId, + featureInstanceId=fileInstanceId, + ) + fileItem = scopedMgmt.getFile(fileId) + if not fileItem: + return None, None + + return scopedMgmt, fileItem + + async def _autoIndexFile(fileId: str, fileName: str, mimeType: str, user): """Background task: pre-scan + extraction + knowledge indexing. Step 1: Structure Pre-Scan (AI-free) -> FileContentIndex (persisted) @@ -975,20 +1024,18 @@ def updateFileNeutralize( def get_file( request: Request, fileId: str = Path(..., description="ID of the file"), - currentUser: User = Depends(getCurrentUser) + currentUser: User = Depends(getCurrentUser), + context: RequestContext = Depends(getRequestContext) ) -> FileItem: - """Get a file""" + """Get a file. Resolves the file's mandate/instance scope automatically.""" try: - managementInterface = interfaceDbManagement.getInterface(currentUser) - - # Get file via LucyDOM interface from the database - fileData = managementInterface.getFile(fileId) + _mgmt, fileData = _resolveFileWithScope(currentUser, context, fileId) if not fileData: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"File with ID {fileId} not found" ) - + return fileData except interfaceDbManagement.FileNotFoundError as e: @@ -1107,23 +1154,17 @@ def download_file( currentUser: User = Depends(getCurrentUser), context: RequestContext = Depends(getRequestContext) ) -> Response: - """Download a file. Uses mandate/instance context when present (e.g. from feature pages).""" + """Download a file. Resolves the file's mandate/instance scope automatically, + so direct links work even when X-Mandate-Id / X-Instance-Id headers + are not sent by the browser.""" try: - managementInterface = interfaceDbManagement.getInterface( - currentUser, - mandateId=str(context.mandateId) if context.mandateId else None, - featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None - ) - - # Get file data - fileData = managementInterface.getFile(fileId) + managementInterface, fileData = _resolveFileWithScope(currentUser, context, fileId) if not fileData: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"File with ID {fileId} not found" ) - - # Get file content + fileContent = managementInterface.getFileData(fileId) if not fileContent: raise HTTPException( @@ -1160,15 +1201,15 @@ def preview_file( currentUser: User = Depends(getCurrentUser), context: RequestContext = Depends(getRequestContext) ) -> FilePreview: - """Preview a file's content. Uses mandate/instance context when present.""" + """Preview a file's content. Resolves the file's mandate/instance scope automatically.""" try: - managementInterface = interfaceDbManagement.getInterface( - currentUser, - mandateId=str(context.mandateId) if context.mandateId else None, - featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None - ) - - # Get file preview using the correct method + managementInterface, fileMeta = _resolveFileWithScope(currentUser, context, fileId) + if not fileMeta: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"File with ID {fileId} not found" + ) + preview = managementInterface.getFileContent(fileId) if not preview: raise HTTPException( diff --git a/modules/routes/routeJobs.py b/modules/routes/routeJobs.py new file mode 100644 index 00000000..d2124a0b --- /dev/null +++ b/modules/routes/routeJobs.py @@ -0,0 +1,107 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +"""HTTP API for the generic background job service. + +Endpoints: +- GET /api/jobs/{jobId} -> single job status +- GET /api/jobs -> list (filter by jobType, instanceId) + +Access control: a caller may read a job iff they are a member of its mandate +(or PlatformAdmin). Jobs without a mandateId (system-wide) are restricted to +PlatformAdmin only. +""" + +import logging +from typing import Any, Dict, List, Optional + +from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request + +from modules.auth import getRequestContext, RequestContext, limiter +from modules.serviceCenter.services.serviceBackgroundJobs import ( + getJobStatus, + listJobs, +) +from modules.shared.i18nRegistry import apiRouteContext + +logger = logging.getLogger(__name__) +routeApiMsg = apiRouteContext("routeJobs") + +router = APIRouter( + prefix="/api/jobs", + tags=["BackgroundJobs"], + responses={404: {"description": "Not found"}}, +) + + +def _serialiseJob(job: Dict[str, Any]) -> Dict[str, Any]: + """Strip system audit fields and ensure JSON-safe types.""" + return {k: v for k, v in job.items() if not k.startswith("sys")} + + +def _userHasMandateAccess(context: RequestContext, mandateId: Optional[str]) -> bool: + """Return True if the current user can read jobs for the given mandate scope.""" + if context is None or context.user is None: + return False + if context.isPlatformAdmin: + return True + if mandateId is None: + return False + from modules.interfaces.interfaceDbApp import getRootInterface + from modules.datamodels.datamodelMembership import UserMandate + rootIf = getRootInterface() + try: + memberships = rootIf.db.getRecordset( + UserMandate, + recordFilter={"userId": context.user.id, "mandateId": mandateId}, + ) + return bool(memberships) + except Exception as ex: + logger.warning( + "Mandate access check failed for user=%s mandate=%s: %s", + context.user.id, mandateId, ex, + ) + return False + + +@router.get("/{jobId}") +@limiter.limit("60/minute") +def get_job( + request: Request, + jobId: str = Path(..., description="Background job ID"), + context: RequestContext = Depends(getRequestContext), +) -> Dict[str, Any]: + """Return the current state of one background job.""" + job = getJobStatus(jobId) + if not job: + raise HTTPException(status_code=404, detail=routeApiMsg("Job not found")) + if not _userHasMandateAccess(context, job.get("mandateId")): + raise HTTPException(status_code=403, detail=routeApiMsg("Access denied")) + return _serialiseJob(job) + + +@router.get("") +@limiter.limit("30/minute") +def list_jobs( + request: Request, + jobType: Optional[str] = Query(None), + mandateId: Optional[str] = Query(None), + instanceId: Optional[str] = Query(None, description="Feature instance scope"), + limit: int = Query(20, ge=1, le=100), + context: RequestContext = Depends(getRequestContext), +) -> Dict[str, List[Dict[str, Any]]]: + """List recent jobs filtered by scope. Newest first.""" + if mandateId is None: + if not context or not context.isPlatformAdmin: + raise HTTPException( + status_code=400, + detail=routeApiMsg("mandateId is required (only PlatformAdmin may list system-wide)"), + ) + elif not _userHasMandateAccess(context, mandateId): + raise HTTPException(status_code=403, detail=routeApiMsg("Access denied")) + jobs = listJobs( + mandateId=mandateId, + featureInstanceId=instanceId, + jobType=jobType, + limit=limit, + ) + return {"items": [_serialiseJob(j) for j in jobs]} diff --git a/modules/routes/routeSharepoint.py b/modules/routes/routeSharepoint.py index 4ae38112..e42611ac 100644 --- a/modules/routes/routeSharepoint.py +++ b/modules/routes/routeSharepoint.py @@ -86,6 +86,7 @@ async def getSharepointFolderOptionsByReference( connectionReference: str = Query(..., description="Connection reference string (e.g., 'connection:msft:user@email.com')"), siteId: Optional[str] = Query(None, description="Specific site ID to browse (if omitted, returns sites only)"), path: Optional[str] = Query(None, description="Folder path within site to browse"), + includeFiles: bool = Query(False, description="If true, also include files (not only folders) in the response"), currentUser: User = Depends(getCurrentUser) ) -> List[Dict[str, Any]]: """ @@ -156,10 +157,10 @@ async def getSharepointFolderOptionsByReference( folderOptions = [] for item in items: - if item.get("type") == "folder": + itemType = item.get("type") + if itemType == "folder": folderName = item.get("name", "") itemPath = f"{folderPath}/{folderName}" if folderPath else folderName - folderOptions.append({ "type": "folder", "value": itemPath, @@ -167,9 +168,21 @@ async def getSharepointFolderOptionsByReference( "siteId": siteId, "folderName": folderName, "path": itemPath, - "hasChildren": True # Assume folders may have children + "hasChildren": True + }) + elif includeFiles and itemType == "file": + fileName = item.get("name", "") + itemPath = f"{folderPath}/{fileName}" if folderPath else fileName + folderOptions.append({ + "type": "file", + "value": itemPath, + "label": fileName, + "siteId": siteId, + "fileName": fileName, + "path": itemPath, + "mimeType": item.get("mimeType"), + "size": item.get("size"), }) - return folderOptions except HTTPException: diff --git a/modules/serviceCenter/services/serviceAgent/toolboxRegistry.py b/modules/serviceCenter/services/serviceAgent/toolboxRegistry.py index 344d6d10..71c75eb5 100644 --- a/modules/serviceCenter/services/serviceAgent/toolboxRegistry.py +++ b/modules/serviceCenter/services/serviceAgent/toolboxRegistry.py @@ -200,8 +200,11 @@ def _registerDefaultToolboxes() -> None: isDefault=False, tools=[ "readWorkflowGraph", "addNode", "removeNode", "connectNodes", - "setNodeParameter", "listAvailableNodeTypes", "validateGraph", + "setNodeParameter", "listAvailableNodeTypes", "describeNodeType", + "autoLayoutWorkflow", "validateGraph", "listWorkflowHistory", "readWorkflowMessages", + "createWorkflow", "updateWorkflowMetadata", "createWorkflowFromFile", + "exportWorkflowToFile", "deleteWorkflow", ], ), ToolboxDefinition( diff --git a/modules/serviceCenter/services/serviceAgent/workflowTools.py b/modules/serviceCenter/services/serviceAgent/workflowTools.py index e0be2278..34ca5d46 100644 --- a/modules/serviceCenter/services/serviceAgent/workflowTools.py +++ b/modules/serviceCenter/services/serviceAgent/workflowTools.py @@ -650,6 +650,209 @@ async def _readWorkflowMessages(params: Dict[str, Any], context: Any) -> ToolRes return _err(name, str(e)) +# ------------------------------------------------------------------------- +# Full-CRUD tools — create / load-from-file / export-to-file / delete +# (Phase 3 of 2026-04-pwg-pilot-mietzinsbestaetigung-workflow.md) +# ------------------------------------------------------------------------- + + +async def _updateWorkflowMetadata(params: Dict[str, Any], context: Any) -> ToolResult: + """Update workflow metadata (label / description / tags / active flag). + + This is the **rename / re-tag / activate** tool. It does NOT touch the + graph or invocations. Use this whenever the user wants to: + - rename the workflow ("nenne den workflow um", "rename to X") + - change description + - add/remove tags + - toggle active + + NEVER use ``removeNode`` / ``deleteWorkflow`` for a rename — they are + destructive and irreversible. If only ``label`` is provided, only the + label is changed. + """ + name = "updateWorkflowMetadata" + try: + workflowId, instanceId = _resolveIds(params, context) + if not workflowId or not instanceId: + return _err(name, "workflowId and instanceId required") + patch: Dict[str, Any] = {} + for key in ("label", "description", "tags", "active"): + if key in params and params[key] is not None: + patch[key] = params[key] + if not patch: + return _err(name, "at least one of label/description/tags/active must be provided") + if "label" in patch: + label = (patch["label"] or "").strip() + if not label: + return _err(name, "label, if provided, must be a non-empty string") + patch["label"] = label + iface = _getInterface(context, instanceId) + updated = iface.updateWorkflow(workflowId, patch) + if updated is None: + return _err(name, f"Workflow {workflowId} not found") + changedFields = sorted(patch.keys()) + return _ok(name, { + "workflowId": updated.get("id"), + "label": updated.get("label"), + "active": updated.get("active"), + "changed": changedFields, + "message": f"Workflow metadata updated ({', '.join(changedFields)}).", + }) + except Exception as e: + logger.exception("updateWorkflowMetadata failed: %s", e) + return _err(name, str(e)) + + +async def _createWorkflow(params: Dict[str, Any], context: Any) -> ToolResult: + """Create a new (empty) workflow in the current feature instance. + + The newly created workflow is returned so subsequent ``addNode``/ + ``connectNodes`` calls can target it via ``workflowId`` (or via the + agent's auto-injected context once the editor switches to it). + """ + name = "createWorkflow" + try: + _, instanceId = _resolveIds(params, context) + if not instanceId: + return _err(name, "instanceId required (and not present in agent context)") + label = (params.get("label") or "").strip() + if not label: + return _err(name, "label required") + + iface = _getInterface(context, instanceId) + graph = params.get("graph") or {"nodes": [], "connections": []} + invocations = params.get("invocations") or [] + data = { + "label": label, + "description": params.get("description") or "", + "tags": params.get("tags") or [], + "graph": graph, + "invocations": invocations, + "active": False, + } + created = iface.createWorkflow(data) + return _ok(name, { + "workflowId": created.get("id"), + "label": created.get("label"), + "message": f"Workflow '{label}' created (active=false; activate via UI when ready).", + }) + except Exception as e: + logger.exception("createWorkflow failed: %s", e) + return _err(name, str(e)) + + +async def _createWorkflowFromFile(params: Dict[str, Any], context: Any) -> ToolResult: + """Import a workflow from a UDB-uploaded ``.workflow.json`` envelope. + + Accepts either ``fileId`` (preferred — re-uses uploaded file from the + Unified-Data-Bar) or ``envelope`` (inline dict, useful for tests). Always + creates a new workflow with ``active=false``. + """ + name = "createWorkflowFromFile" + try: + _, instanceId = _resolveIds(params, context) + if not instanceId: + return _err(name, "instanceId required") + fileId = params.get("fileId") + envelope = params.get("envelope") + if not fileId and not envelope: + return _err(name, "either fileId or envelope required") + + if not envelope and fileId: + envelope = _loadEnvelopeFromUdb(fileId, context) + if envelope is None: + return _err(name, f"Could not read workflow file {fileId}") + + iface = _getInterface(context, instanceId) + try: + result = iface.importWorkflowFromDict(envelope, existingWorkflowId=params.get("existingWorkflowId")) + except Exception as exc: + return _err(name, f"Import failed: {exc}") + wf = result.get("workflow") or {} + return _ok(name, { + "workflowId": wf.get("id"), + "label": wf.get("label"), + "created": result.get("created"), + "warnings": result.get("warnings") or [], + "message": f"Workflow '{wf.get('label')}' {'created' if result.get('created') else 'updated'} from file (active=false).", + }) + except Exception as e: + logger.exception("createWorkflowFromFile failed: %s", e) + return _err(name, str(e)) + + +async def _exportWorkflowToFile(params: Dict[str, Any], context: Any) -> ToolResult: + """Export a workflow as a versioned envelope. + + Returns the canonical envelope dict and a suggested filename so the + agent can offer the user a download link or re-upload to UDB. + """ + name = "exportWorkflowToFile" + try: + workflowId, instanceId = _resolveIds(params, context) + if not workflowId or not instanceId: + return _err(name, "workflowId and instanceId required") + iface = _getInterface(context, instanceId) + envelope = iface.exportWorkflowToDict(workflowId) + if envelope is None: + return _err(name, f"Workflow {workflowId} not found") + from modules.features.graphicalEditor._workflowFileSchema import buildFileName + return _ok(name, { + "fileName": buildFileName(envelope.get("label", "workflow")), + "envelope": envelope, + "schemaVersion": envelope.get("$schemaVersion"), + }) + except Exception as e: + logger.exception("exportWorkflowToFile failed: %s", e) + return _err(name, str(e)) + + +async def _deleteWorkflow(params: Dict[str, Any], context: Any) -> ToolResult: + """Delete a workflow. Requires explicit ``confirm=true`` to avoid + accidental deletion by an over-eager agent.""" + name = "deleteWorkflow" + try: + workflowId, instanceId = _resolveIds(params, context) + if not workflowId or not instanceId: + return _err(name, "workflowId and instanceId required") + if not params.get("confirm"): + return _err(name, "confirm=true required (deletion is permanent)") + iface = _getInterface(context, instanceId) + ok = iface.deleteWorkflow(workflowId) + if not ok: + return _err(name, f"Workflow {workflowId} not found") + return _ok(name, {"workflowId": workflowId, "message": "Workflow deleted."}) + except Exception as e: + logger.exception("deleteWorkflow failed: %s", e) + return _err(name, str(e)) + + +def _loadEnvelopeFromUdb(fileId: str, context: Any): + """Load and JSON-parse a workflow file from the Unified-Data-Bar. + + Returns ``None`` if the file cannot be read or is not valid JSON — the + caller turns that into a tool error message. + """ + import json + try: + import modules.interfaces.interfaceDbManagement as interfaceDbManagement + user = _resolveUser(context) + mandateId = _resolveMandateId(context) + mgmt = interfaceDbManagement.getInterface(user, mandateId) + rawBytes = mgmt.getFileData(fileId) + except Exception as exc: + logger.warning("workflowTools: cannot read UDB file %s: %s", fileId, exc) + return None + if not rawBytes: + return None + try: + text = rawBytes.decode("utf-8") if isinstance(rawBytes, bytes) else str(rawBytes) + return json.loads(text) + except Exception as exc: + logger.warning("workflowTools: file %s is not valid JSON: %s", fileId, exc) + return None + + def getWorkflowToolDefinitions() -> List[Dict[str, Any]]: """Return tool definitions for registration in the ToolRegistry. @@ -696,7 +899,14 @@ def getWorkflowToolDefinitions() -> List[Dict[str, Any]]: { "name": "removeNode", "handler": _removeNode, - "description": "Remove a node and its connections from the graph.", + "description": ( + "Remove a SINGLE node and its connections from the graph. " + "DESTRUCTIVE — only call when the user explicitly asks to " + "delete that specific node. NEVER use this to 'rename' or " + "'rebuild' a workflow — for renaming use updateWorkflowMetadata; " + "for replacing the whole graph use createWorkflowFromFile with " + "existingWorkflowId. NEVER batch-remove all nodes." + ), "parameters": { "type": "object", "properties": { @@ -829,4 +1039,107 @@ def getWorkflowToolDefinitions() -> List[Dict[str, Any]]: "readOnly": True, "toolSet": TOOLBOX_ID, }, + { + "name": "updateWorkflowMetadata", + "handler": _updateWorkflowMetadata, + "description": ( + "Rename / re-tag / (de)activate an EXISTING workflow. This is " + "the ONLY correct way to rename a workflow — DO NOT delete and " + "recreate, and NEVER call removeNode for a rename. Provide any " + "subset of label/description/tags/active; omitted fields stay " + "unchanged. Graph and invocations are not affected." + ), + "parameters": { + "type": "object", + "properties": { + **_idFields, + "label": {"type": "string", "description": "New workflow label (rename target)"}, + "description": {"type": "string", "description": "New description"}, + "tags": {"type": "array", "items": {"type": "string"}, "description": "New tag list (replaces existing)"}, + "active": {"type": "boolean", "description": "Activate (true) or deactivate (false) the workflow"}, + }, + "required": [], + }, + "toolSet": TOOLBOX_ID, + }, + { + "name": "createWorkflow", + "handler": _createWorkflow, + "description": ( + "Create a NEW empty workflow in the current feature instance. " + "The workflow is created with active=false; the user activates " + "it from the editor. Use this when the user wants to start " + "building a new automation from scratch." + ), + "parameters": { + "type": "object", + "properties": { + "instanceId": _idFields["instanceId"], + "label": {"type": "string", "description": "Workflow label (required, shown in the editor list)"}, + "description": {"type": "string", "description": "Optional description"}, + "tags": {"type": "array", "items": {"type": "string"}, "description": "Optional tags"}, + "graph": {"type": "object", "description": "Optional initial graph {nodes, connections} (defaults to empty)"}, + "invocations": {"type": "array", "description": "Optional invocation triggers"}, + }, + "required": ["label"], + }, + "toolSet": TOOLBOX_ID, + }, + { + "name": "createWorkflowFromFile", + "handler": _createWorkflowFromFile, + "description": ( + "Import a workflow from a previously uploaded .workflow.json " + "envelope (Unified-Data-Bar). Pass the UDB ``fileId``; the file " + "is parsed, validated against the envelopeVersioned schema and " + "saved as a new workflow with active=false. Use ``existingWorkflowId`` " + "to overwrite an existing workflow instead of creating a new one." + ), + "parameters": { + "type": "object", + "properties": { + "instanceId": _idFields["instanceId"], + "fileId": {"type": "string", "description": "FileItem.id of the uploaded .workflow.json (preferred)"}, + "envelope": {"type": "object", "description": "Inline envelope dict (alternative to fileId, mainly for tests)"}, + "existingWorkflowId": {"type": "string", "description": "Optional — overwrite this workflow instead of creating a new one"}, + }, + "required": [], + }, + "toolSet": TOOLBOX_ID, + }, + { + "name": "exportWorkflowToFile", + "handler": _exportWorkflowToFile, + "description": ( + "Export the current workflow as a portable envelopeVersioned " + "JSON dict and a suggested filename. The agent can then offer " + "the user a download or re-upload to UDB. Persistence-bound " + "fields (timestamps, mandate ids) are stripped automatically." + ), + "parameters": { + "type": "object", + "properties": {**_idFields}, + "required": [], + }, + "readOnly": True, + "toolSet": TOOLBOX_ID, + }, + { + "name": "deleteWorkflow", + "handler": _deleteWorkflow, + "description": ( + "Permanently delete a workflow. Requires ``confirm=true`` to " + "execute — this is a destructive operation. Always confirm " + "with the user in chat BEFORE calling this tool with confirm=true." + ), + "parameters": { + "type": "object", + "properties": { + **_idFields, + "confirm": {"type": "boolean", "description": "Must be true to actually delete"}, + }, + "required": ["confirm"], + }, + "toolSet": TOOLBOX_ID, + }, ] diff --git a/modules/serviceCenter/services/serviceBackgroundJobs/__init__.py b/modules/serviceCenter/services/serviceBackgroundJobs/__init__.py new file mode 100644 index 00000000..e9d4c94c --- /dev/null +++ b/modules/serviceCenter/services/serviceBackgroundJobs/__init__.py @@ -0,0 +1,19 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +"""Background job service: generic, reusable infrastructure for long-running tasks.""" + +from .mainBackgroundJobService import ( + registerJobHandler, + startJob, + getJobStatus, + listJobs, + JobProgressCallback, +) + +__all__ = [ + "registerJobHandler", + "startJob", + "getJobStatus", + "listJobs", + "JobProgressCallback", +] diff --git a/modules/serviceCenter/services/serviceBackgroundJobs/mainBackgroundJobService.py b/modules/serviceCenter/services/serviceBackgroundJobs/mainBackgroundJobService.py new file mode 100644 index 00000000..37830fd1 --- /dev/null +++ b/modules/serviceCenter/services/serviceBackgroundJobs/mainBackgroundJobService.py @@ -0,0 +1,245 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +"""Background job service. + +Generic infrastructure for fire-and-forget async tasks. Any caller (HTTP route, +AI tool, scheduler) can submit work and get a `jobId` back immediately; status +is polled via `GET /api/jobs/{jobId}`. + +Usage (registration, once at module load): + + from modules.serviceCenter.services.serviceBackgroundJobs import registerJobHandler + + async def _myHandler(job, progressCb): + progressCb(10, "starting...") + result = await doExpensiveWork(job["payload"]) + return result # stored as job.result + + registerJobHandler("myJobType", _myHandler) + +Usage (submission): + + from modules.serviceCenter.services.serviceBackgroundJobs import startJob + jobId = await startJob("myJobType", {"foo": "bar"}, mandateId=mid, triggeredBy=userId) + return {"jobId": jobId} + +Restart semantics: jobs are tracked in DB. If the worker process dies mid-job, +`_recoverInterruptedJobs()` (called at boot) flips RUNNING jobs to ERROR with a +clear message. No silent zombies. +""" + +import asyncio +import logging +from datetime import datetime, timezone +from typing import Any, Awaitable, Callable, Dict, List, Optional + +from modules.connectors.connectorDbPostgre import DatabaseConnector +from modules.shared.configuration import APP_CONFIG +from modules.shared.dbRegistry import registerDatabase +from modules.datamodels.datamodelBackgroundJob import ( + BackgroundJob, + BackgroundJobStatusEnum, + TERMINAL_JOB_STATUSES, +) + +logger = logging.getLogger(__name__) + + +JOBS_DATABASE = APP_CONFIG.get("DB_DATABASE", "poweron_app") +registerDatabase(JOBS_DATABASE) + + +JobProgressCallback = Callable[[int, Optional[str]], None] +JobHandler = Callable[[Dict[str, Any], JobProgressCallback], Awaitable[Optional[Dict[str, Any]]]] + + +_JOB_HANDLERS: Dict[str, JobHandler] = {} + + +def registerJobHandler(jobType: str, handler: JobHandler) -> None: + """Register a handler for a job type. Idempotent — last registration wins.""" + if jobType in _JOB_HANDLERS and _JOB_HANDLERS[jobType] is not handler: + logger.info("Re-registering background job handler for type %s", jobType) + _JOB_HANDLERS[jobType] = handler + + +def _getDb() -> DatabaseConnector: + return DatabaseConnector( + dbDatabase=JOBS_DATABASE, + dbHost=APP_CONFIG.get("DB_HOST", "localhost"), + dbPort=int(APP_CONFIG.get("DB_PORT", "5432")), + dbUser=APP_CONFIG.get("DB_USER"), + dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET"), + ) + + +def _serialiseDatetimes(data: Dict[str, Any]) -> Dict[str, Any]: + """Return copy of dict with datetime values converted to ISO 8601 strings.""" + out: Dict[str, Any] = {} + for k, v in data.items(): + if isinstance(v, datetime): + out[k] = v.isoformat() + else: + out[k] = v + return out + + +async def startJob( + jobType: str, + payload: Optional[Dict[str, Any]] = None, + *, + mandateId: Optional[str] = None, + featureInstanceId: Optional[str] = None, + triggeredBy: Optional[str] = None, +) -> str: + """Insert a new BackgroundJob, kick off its handler in the background, return jobId. + + Returns immediately; the handler runs via `asyncio.create_task`. + """ + if jobType not in _JOB_HANDLERS: + raise ValueError(f"No handler registered for jobType '{jobType}'") + + job = BackgroundJob( + jobType=jobType, + mandateId=mandateId, + featureInstanceId=featureInstanceId, + triggeredBy=triggeredBy, + payload=payload or {}, + ) + db = _getDb() + record = db.recordCreate(BackgroundJob, _serialiseDatetimes(job.model_dump())) + jobId = record["id"] + + asyncio.create_task(_runJob(jobId)) + logger.info( + "BackgroundJob %s submitted: type=%s mandate=%s instance=%s by=%s", + jobId, jobType, mandateId, featureInstanceId, triggeredBy, + ) + return jobId + + +def _loadJob(jobId: str) -> Optional[Dict[str, Any]]: + db = _getDb() + rows = db.getRecordset(BackgroundJob, recordFilter={"id": jobId}) + return dict(rows[0]) if rows else None + + +def _updateJob(jobId: str, fields: Dict[str, Any]) -> None: + db = _getDb() + db.recordModify(BackgroundJob, jobId, _serialiseDatetimes(fields)) + + +def _markStarted(jobId: str) -> None: + _updateJob(jobId, { + "status": BackgroundJobStatusEnum.RUNNING.value, + "startedAt": datetime.now(timezone.utc), + }) + + +def _markSuccess(jobId: str, result: Optional[Dict[str, Any]]) -> None: + _updateJob(jobId, { + "status": BackgroundJobStatusEnum.SUCCESS.value, + "result": result or {}, + "progress": 100, + "finishedAt": datetime.now(timezone.utc), + }) + + +def _markError(jobId: str, errorMessage: str) -> None: + truncated = (errorMessage or "")[:1000] + _updateJob(jobId, { + "status": BackgroundJobStatusEnum.ERROR.value, + "errorMessage": truncated, + "finishedAt": datetime.now(timezone.utc), + }) + + +def _makeProgressCallback(jobId: str) -> JobProgressCallback: + def _cb(progress: int, message: Optional[str] = None) -> None: + try: + clamped = max(0, min(100, int(progress))) + fields: Dict[str, Any] = {"progress": clamped} + if message is not None: + fields["progressMessage"] = message[:500] + _updateJob(jobId, fields) + except Exception as ex: + logger.warning("Progress update failed for job %s: %s", jobId, ex) + return _cb + + +async def _runJob(jobId: str) -> None: + job = _loadJob(jobId) + if not job: + logger.error("BackgroundJob %s vanished before runner started", jobId) + return + handler = _JOB_HANDLERS.get(job["jobType"]) + if not handler: + msg = f"No handler registered for jobType '{job['jobType']}'" + logger.error("BackgroundJob %s: %s", jobId, msg) + _markError(jobId, msg) + return + + _markStarted(jobId) + try: + result = await handler(job, _makeProgressCallback(jobId)) + _markSuccess(jobId, result if isinstance(result, dict) else None) + logger.info("BackgroundJob %s (%s) completed successfully", jobId, job["jobType"]) + except Exception as e: + logger.exception("BackgroundJob %s (%s) failed", jobId, job["jobType"]) + _markError(jobId, str(e)) + + +def getJobStatus(jobId: str) -> Optional[Dict[str, Any]]: + """Load current job state. Returns None if not found.""" + return _loadJob(jobId) + + +def listJobs( + *, + mandateId: Optional[str] = None, + featureInstanceId: Optional[str] = None, + jobType: Optional[str] = None, + limit: int = 20, +) -> List[Dict[str, Any]]: + """List recent jobs filtered by scope. Newest first.""" + db = _getDb() + rows = db.getRecordset(BackgroundJob) + out = [dict(r) for r in rows] + if mandateId is not None: + out = [r for r in out if r.get("mandateId") == mandateId] + if featureInstanceId is not None: + out = [r for r in out if r.get("featureInstanceId") == featureInstanceId] + if jobType is not None: + out = [r for r in out if r.get("jobType") == jobType] + out.sort(key=lambda r: r.get("createdAt") or "", reverse=True) + return out[:limit] + + +def isTerminalStatus(status: str) -> bool: + """True if the given status is one of {SUCCESS, ERROR, CANCELLED}.""" + return status in {s.value for s in TERMINAL_JOB_STATUSES} + + +def recoverInterruptedJobs() -> int: + """Flip any RUNNING jobs to ERROR (called at worker boot). + + A RUNNING job in the DB after process restart means the previous worker + died mid-execution; the asyncio task is gone and the job will never + finish on its own. + """ + db = _getDb() + try: + rows = db.getRecordset(BackgroundJob, recordFilter={"status": BackgroundJobStatusEnum.RUNNING.value}) + except Exception as ex: + logger.warning("recoverInterruptedJobs: failed to scan RUNNING jobs: %s", ex) + return 0 + count = 0 + for row in rows: + try: + _markError(row["id"], "Interrupted by worker restart") + count += 1 + except Exception as ex: + logger.warning("recoverInterruptedJobs: could not mark %s as ERROR: %s", row.get("id"), ex) + if count: + logger.warning("Recovered %d interrupted background job(s) after restart", count) + return count diff --git a/modules/serviceCenter/services/serviceSubscription/mainServiceSubscription.py b/modules/serviceCenter/services/serviceSubscription/mainServiceSubscription.py index 681070b0..8a2ff8d5 100644 --- a/modules/serviceCenter/services/serviceSubscription/mainServiceSubscription.py +++ b/modules/serviceCenter/services/serviceSubscription/mainServiceSubscription.py @@ -179,6 +179,10 @@ class SubscriptionService: checkoutUrl = self._createCheckoutSession(mid, plan, created, currentOperative, returnUrl) created["redirectUrl"] = checkoutUrl except Exception as e: + logger.exception( + "Checkout creation failed for mandate %s, plan %s — force-expiring PENDING %s", + mid, planKey, created["id"], + ) self._interface.forceExpire(created["id"]) self.invalidateCache(mid) raise ValueError(f"Subscription konnte nicht erstellt werden: {e}") from e @@ -276,7 +280,11 @@ class SubscriptionService: }, } - if currentOperative and currentOperative.get("currentPeriodEnd"): + isTrialPredecessor = ( + currentOperative is not None + and currentOperative.get("status") == SubscriptionStatusEnum.TRIALING.value + ) + if currentOperative and currentOperative.get("currentPeriodEnd") and not isTrialPredecessor: periodEnd = currentOperative["currentPeriodEnd"] if isinstance(periodEnd, str): periodEnd = datetime.fromisoformat(periodEnd) diff --git a/modules/workflows/methods/methodOutlook/actions/composeAndDraftEmailWithContext.py b/modules/workflows/methods/methodOutlook/actions/composeAndDraftEmailWithContext.py index 5c15173e..43c4dc41 100644 --- a/modules/workflows/methods/methodOutlook/actions/composeAndDraftEmailWithContext.py +++ b/modules/workflows/methods/methodOutlook/actions/composeAndDraftEmailWithContext.py @@ -17,6 +17,20 @@ async def composeAndDraftEmailWithContext(self, parameters: Dict[str, Any]) -> A context = parameters.get("context") documentList = parameters.get("documentList") or [] replySourceDocuments = parameters.get("replySourceDocuments") or [] # Original email(s) for reply attachment + # ``attachments`` (added in 2026-04 for the PWG pilot) is a list of + # explicit attachment specs that bypass the AI selection step. + # Supported shapes per item: + # { name, mimeType, base64Content } – inline bytes (already base64) + # { name, mimeType, contentRef } – upstream wire variable name + # from ``parameters[contentRef]`` + # (e.g. ``csv`` produced by + # ``data.consolidate``) + # { name, csvFromVariable } – shorthand for CSV ref + attachmentSpecs = parameters.get("attachments") or [] + if isinstance(attachmentSpecs, dict): + attachmentSpecs = [attachmentSpecs] + if not isinstance(attachmentSpecs, list): + attachmentSpecs = [] cc = parameters.get("cc") or [] bcc = parameters.get("bcc") or [] emailStyle = parameters.get("emailStyle") or "business" @@ -273,8 +287,14 @@ Return JSON: # Supports: 1) inline ActionDocuments (dict with documentData from e.g. sharepoint.downloadFile) # 2) docItem:... references (chat workflow documents) # 3) replySourceDocuments: original email(s) for reply – attach when use_direct_content + # 4) explicit attachment specs from the new ``attachments`` parameter # When use_direct_content: upstream AI doc IS the email body – do not attach it, BUT attach reply sources attachments_doc_list = (replySourceDocuments or []) if use_direct_content else (documentList or []) + # Materialize explicit attachment specs into inline ActionDocument-shaped dicts + for spec in attachmentSpecs: + resolved = _resolveAttachmentSpec(spec, parameters) + if resolved is not None: + attachments_doc_list = list(attachments_doc_list) + [resolved] if attachments_doc_list: message["attachments"] = [] for attachment_ref in attachments_doc_list: @@ -484,3 +504,49 @@ Return JSON: logger.error(f"Error in composeAndDraftEmailWithContext: {str(e)}") return ActionResult.isFailure(error=str(e)) + +def _resolveAttachmentSpec(spec: Any, parameters: Dict[str, Any]) -> Any: + """Resolve one attachment-spec dict into an inline-document shape that the + existing attachment loop already understands ({documentName, documentData, + mimeType}). + + Three input shapes are supported: + + - ``{name, mimeType, base64Content}``: inline bytes already encoded as + base64 — used by the agent when synthesising small text attachments. + - ``{name, mimeType, contentRef}``: pull the bytes from another + parameter on this node call (i.e. an upstream wire variable, e.g. + ``contentRef='csv'`` reads ``parameters['csv']``). + - ``{name, csvFromVariable}``: shorthand for the most common case — the + CSV produced by ``data.consolidate`` arriving via wire. + + Returns ``None`` for malformed specs (logged) so a single bad spec does + not abort the whole email draft. + """ + if not isinstance(spec, dict): + return None + name = spec.get("name") or spec.get("fileName") or "attachment.bin" + mimeType = spec.get("mimeType") or spec.get("contentType") + + raw = None + if "base64Content" in spec and spec.get("base64Content"): + raw = spec.get("base64Content") + elif spec.get("csvFromVariable"): + raw = parameters.get(spec["csvFromVariable"]) + if not mimeType: + mimeType = "text/csv" + if not name.lower().endswith(".csv"): + name = f"{name}.csv" + elif spec.get("contentRef"): + raw = parameters.get(spec["contentRef"]) + + if raw is None or raw == "": + logger.warning("email.draftEmail: attachment spec %r resolved to empty content, skipping", name) + return None + + return { + "documentName": name, + "documentData": raw, + "mimeType": mimeType or "application/octet-stream", + } + diff --git a/modules/workflows/methods/methodTrustee/actions/queryData.py b/modules/workflows/methods/methodTrustee/actions/queryData.py new file mode 100644 index 00000000..36cbbe89 --- /dev/null +++ b/modules/workflows/methods/methodTrustee/actions/queryData.py @@ -0,0 +1,390 @@ +# Copyright (c) 2026 Patrick Motsch +# All rights reserved. +""" +Query data from the Trustee feature DB. + +Three modes: +- ``lookup`` (default): tenant-aware lookup. For ``entity=tenantWithRent`` the + action joins ``TrusteeDataContact`` (identity match by tenantName + + tenantAddress with light tolerance) with derived rent amounts from + ``TrusteeDataJournalLine`` filtered by an account-pattern. Output is a + compact dict ready to feed into ``ai.prompt`` ``context``. +- ``raw``: return the recordset for the given entity (filtered by + ``filterJson``). Use for debugging or advanced workflows. +- ``aggregate``: count records per group (basic group-by helper). + +This action does NOT trigger an external sync — use +``trustee.refreshAccountingData`` first if data may be stale. +""" + +import json +import logging +import re +from typing import Any, Dict, List, Optional + +from modules.datamodels.datamodelChat import ActionResult + +logger = logging.getLogger(__name__) + + +_NAME_NORMALIZE_RE = re.compile(r"[^a-z0-9]+") +_ENTITY_TO_MODEL = { + "contact": "TrusteeDataContact", + "accounts": "TrusteeDataAccount", + "balances": "TrusteeDataAccountBalance", + "journalLines": "TrusteeDataJournalLine", +} + + +async def queryData(self, parameters: Dict[str, Any]) -> ActionResult: + """Query the Trustee feature DB. See module docstring for modes.""" + featureInstanceId = parameters.get("featureInstanceId") or ( + self.services.featureInstanceId if hasattr(self.services, "featureInstanceId") else None + ) + if not featureInstanceId: + return ActionResult.isFailure(error="featureInstanceId is required") + + mode = (parameters.get("mode") or "lookup").lower() + entity = (parameters.get("entity") or "tenantWithRent") + + try: + from modules.features.trustee.interfaceFeatureTrustee import getInterface as getTrusteeInterface + trusteeInterface = getTrusteeInterface( + self.services.user, + mandateId=self.services.mandateId, + featureInstanceId=featureInstanceId, + ) + except Exception as exc: + logger.exception("trustee.queryData: cannot open trustee interface") + return ActionResult.isFailure(error=f"Trustee interface unavailable: {exc}") + + if mode == "lookup" and entity == "tenantWithRent": + payload = _lookupTenantWithRent(trusteeInterface, featureInstanceId, parameters) + return ActionResult.isSuccess(data=payload) + + if mode == "lookup" and entity == "contact": + contact = _lookupContact( + trusteeInterface, + featureInstanceId, + parameters.get("tenantNameRef") or "", + parameters.get("tenantAddressRef") or "", + ) + return ActionResult.isSuccess(data={"contact": contact}) + + if mode in ("raw", "aggregate"): + modelName = _ENTITY_TO_MODEL.get(entity) + if not modelName: + return ActionResult.isFailure( + error=f"entity '{entity}' is not supported in mode '{mode}'" + ) + records = _readRecordset( + trusteeInterface, + featureInstanceId, + modelName, + _parseFilterJson(parameters.get("filterJson")), + ) + if mode == "raw": + return ActionResult.isSuccess(data={"entity": entity, "count": len(records), "records": records}) + return ActionResult.isSuccess(data={ + "entity": entity, + "count": len(records), + "summary": _summarizeAggregate(records), + }) + + return ActionResult.isFailure( + error=f"Unsupported combination mode='{mode}' entity='{entity}'" + ) + + +def _lookupTenantWithRent( + trusteeInterface, + featureInstanceId: str, + parameters: Dict[str, Any], +) -> Dict[str, Any]: + """Return ``{contact, expectedRent, rentLines}`` for one tenant. + + Identity match is intentionally tolerant (case-insensitive, punctuation + stripped) so OCR results with minor variations still hit. Rent amount is + derived from ``TrusteeDataJournalLine`` rows whose ``accountNumber`` + matches ``rentAccountPattern`` and whose booking date (via the journal + entry header) falls inside the requested period. + """ + tenantName = parameters.get("tenantNameRef") or "" + tenantAddress = parameters.get("tenantAddressRef") or "" + period = parameters.get("period") or "" + accountPattern = parameters.get("rentAccountPattern") or "" + + contact = _lookupContact(trusteeInterface, featureInstanceId, tenantName, tenantAddress) + if not contact: + return { + "matched": False, + "tenantNameRef": tenantName, + "tenantAddressRef": tenantAddress, + "contact": None, + "expectedRent": None, + "rentLines": [], + } + + rentLines, expectedRent = _deriveRentForContact( + trusteeInterface, + featureInstanceId, + contact, + period, + accountPattern, + ) + return { + "matched": True, + "tenantNameRef": tenantName, + "tenantAddressRef": tenantAddress, + "contact": contact, + "period": period, + "rentAccountPattern": accountPattern, + "rentLines": rentLines, + "expectedRent": expectedRent, + } + + +def _lookupContact( + trusteeInterface, + featureInstanceId: str, + tenantName: str, + tenantAddress: str, +) -> Optional[Dict[str, Any]]: + from modules.features.trustee.datamodelFeatureTrustee import TrusteeDataContact + + records = trusteeInterface.db.getRecordset( + TrusteeDataContact, + recordFilter={"featureInstanceId": featureInstanceId}, + ) or [] + if not records: + return None + + nameKey = _normalizeText(tenantName) + addressKey = _normalizeText(tenantAddress) + + if not nameKey and not addressKey: + return None + + bestScore = -1 + bestMatch: Optional[Dict[str, Any]] = None + for raw in records: + rec = dict(raw) + recName = _normalizeText(rec.get("name") or "") + recAddress = _normalizeText( + " ".join([rec.get("address") or "", rec.get("zip") or "", rec.get("city") or ""]).strip() + ) + score = 0 + if nameKey and recName: + if recName == nameKey: + score += 10 + elif nameKey in recName or recName in nameKey: + score += 6 + if addressKey and recAddress: + if recAddress == addressKey: + score += 5 + elif addressKey in recAddress or recAddress in addressKey: + score += 3 + if score > bestScore: + bestScore = score + bestMatch = rec + + if bestScore < 5: + return None + return _shrinkContact(bestMatch) + + +def _deriveRentForContact( + trusteeInterface, + featureInstanceId: str, + contact: Dict[str, Any], + period: str, + accountPattern: str, +) -> tuple: + """Derive expected annual rent from journal lines. + + The trustee DB does not store a ``Mietvertrag`` entity; the expected + annual rent is the sum of all credit amounts on rent-revenue accounts + referenced in journal entries whose description / reference contains + the contact name. This is intentionally a heuristic — when no match is + found we return ``(None, None)`` so the caller can flag ``unleserlich``. + """ + from modules.features.trustee.datamodelFeatureTrustee import ( + TrusteeDataJournalEntry, + TrusteeDataJournalLine, + ) + + entries = trusteeInterface.db.getRecordset( + TrusteeDataJournalEntry, + recordFilter={"featureInstanceId": featureInstanceId}, + ) or [] + lines = trusteeInterface.db.getRecordset( + TrusteeDataJournalLine, + recordFilter={"featureInstanceId": featureInstanceId}, + ) or [] + if not entries or not lines: + return [], None + + fromDate, toDate = _parsePeriod(period) + accountMatcher = _accountMatcher(accountPattern) + nameKey = _normalizeText(contact.get("name") or "") + contactNumber = (contact.get("contactNumber") or "").strip() + + relevantEntryIds = set() + entryById = {} + for raw in entries: + e = dict(raw) + eid = e.get("id") + if not eid: + continue + bDate = e.get("bookingDate") or "" + if fromDate and bDate and bDate < fromDate: + continue + if toDate and bDate and bDate > toDate: + continue + descKey = _normalizeText(" ".join([e.get("description") or "", e.get("reference") or ""])) + if (nameKey and nameKey in descKey) or (contactNumber and contactNumber in (e.get("reference") or "")): + relevantEntryIds.add(eid) + entryById[eid] = e + + rentLines = [] + total = 0.0 + for raw in lines: + ln = dict(raw) + if ln.get("journalEntryId") not in relevantEntryIds: + continue + accountNo = (ln.get("accountNumber") or "") + if not accountMatcher(accountNo): + continue + credit = float(ln.get("creditAmount") or 0.0) + debit = float(ln.get("debitAmount") or 0.0) + amount = credit - debit + e = entryById.get(ln.get("journalEntryId"), {}) + rentLines.append({ + "date": e.get("bookingDate"), + "ref": e.get("reference"), + "account": accountNo, + "amount": round(amount, 2), + "description": ln.get("description") or e.get("description"), + }) + total += amount + + expectedRent = round(total, 2) if rentLines else None + return rentLines, expectedRent + + +def _readRecordset( + trusteeInterface, + featureInstanceId: str, + modelName: str, + extraFilter: Optional[Dict[str, Any]], +) -> List[Dict[str, Any]]: + from modules.features.trustee.datamodelFeatureTrustee import ( + TrusteeDataAccount, + TrusteeDataAccountBalance, + TrusteeDataContact, + TrusteeDataJournalLine, + ) + + modelMap = { + "TrusteeDataAccount": TrusteeDataAccount, + "TrusteeDataAccountBalance": TrusteeDataAccountBalance, + "TrusteeDataContact": TrusteeDataContact, + "TrusteeDataJournalLine": TrusteeDataJournalLine, + } + model = modelMap.get(modelName) + if not model: + return [] + rf: Dict[str, Any] = {"featureInstanceId": featureInstanceId} + if isinstance(extraFilter, dict): + rf.update(extraFilter) + raw = trusteeInterface.db.getRecordset(model, recordFilter=rf) or [] + return [dict(r) for r in raw] + + +def _summarizeAggregate(records: List[Dict[str, Any]]) -> Dict[str, Any]: + """Quick counts by common fields. Avoids heavy SQL for the prototype.""" + summary: Dict[str, Any] = {"total": len(records)} + for field in ("contactType", "accountType", "currency"): + bucket: Dict[str, int] = {} + for r in records: + key = str(r.get(field) or "") + bucket[key] = bucket.get(key, 0) + 1 + if bucket: + summary[f"by_{field}"] = bucket + return summary + + +def _normalizeText(value: str) -> str: + return _NAME_NORMALIZE_RE.sub("", (value or "").lower()) + + +def _shrinkContact(rec: Dict[str, Any]) -> Dict[str, Any]: + return { + "id": rec.get("id"), + "externalId": rec.get("externalId"), + "contactType": rec.get("contactType"), + "contactNumber": rec.get("contactNumber"), + "name": rec.get("name"), + "address": rec.get("address"), + "zip": rec.get("zip"), + "city": rec.get("city"), + "email": rec.get("email"), + } + + +def _parseFilterJson(raw: Any) -> Dict[str, Any]: + if not raw: + return {} + if isinstance(raw, dict): + return raw + try: + parsed = json.loads(str(raw)) + return parsed if isinstance(parsed, dict) else {} + except Exception: + logger.warning("trustee.queryData: invalid filterJson, ignoring") + return {} + + +def _parsePeriod(period: str) -> tuple: + """Parse ``"YYYY"`` or ``"YYYY-MM-DD/YYYY-MM-DD"`` into ``(from, to)``. + + Empty string → ``(None, None)``. Invalid input is treated as no filter + rather than raising — workflows must not abort on malformed period text. + """ + if not period: + return None, None + period = period.strip() + if "/" in period: + parts = period.split("/", 1) + return parts[0].strip() or None, parts[1].strip() or None + if len(period) == 4 and period.isdigit(): + return f"{period}-01-01", f"{period}-12-31" + return period, period + + +def _accountMatcher(pattern: str): + """Return a predicate ``str -> bool`` that matches account numbers. + + Supports ``"6*"`` (prefix), ``"6000-6099"`` (numeric range), and exact + matches. Empty pattern matches everything (caller decides if that's wise). + """ + pattern = (pattern or "").strip() + if not pattern: + return lambda _x: True + if "-" in pattern and pattern.replace("-", "").isdigit(): + lo, hi = pattern.split("-", 1) + try: + lo_i = int(lo) + hi_i = int(hi) + def _rangeMatch(acc: str) -> bool: + try: + return lo_i <= int(acc) <= hi_i + except (TypeError, ValueError): + return False + return _rangeMatch + except ValueError: + pass + if pattern.endswith("*"): + prefix = pattern[:-1] + return lambda acc: (acc or "").startswith(prefix) + return lambda acc: acc == pattern diff --git a/modules/workflows/methods/methodTrustee/methodTrustee.py b/modules/workflows/methods/methodTrustee/methodTrustee.py index 5be232f8..ceb5849f 100644 --- a/modules/workflows/methods/methodTrustee/methodTrustee.py +++ b/modules/workflows/methods/methodTrustee/methodTrustee.py @@ -13,6 +13,7 @@ from .actions.extractFromFiles import extractFromFiles from .actions.processDocuments import processDocuments from .actions.syncToAccounting import syncToAccounting from .actions.refreshAccountingData import refreshAccountingData +from .actions.queryData import queryData logger = logging.getLogger(__name__) @@ -149,6 +150,70 @@ class MethodTrustee(MethodBase): }, execute=refreshAccountingData.__get__(self, self.__class__), ), + "queryData": WorkflowActionDefinition( + actionId="trustee.queryData", + description="Read data from the Trustee DB (lookup tenant+rent, raw recordset, or aggregate). Does NOT trigger an external sync.", + dynamicMode=False, + parameters={ + "featureInstanceId": WorkflowActionParameter( + name="featureInstanceId", + type="str", + frontendType=FrontendType.TEXT, + required=True, + description="Trustee feature instance ID", + ), + "mode": WorkflowActionParameter( + name="mode", + type="str", + frontendType=FrontendType.TEXT, + required=True, + description="Query mode: lookup | raw | aggregate", + ), + "entity": WorkflowActionParameter( + name="entity", + type="str", + frontendType=FrontendType.TEXT, + required=True, + description="Entity to query: tenantWithRent | contact | journalLines | accounts | balances", + ), + "tenantNameRef": WorkflowActionParameter( + name="tenantNameRef", + type="str", + frontendType=FrontendType.TEXT, + required=False, + description="Tenant name to match (or {{wire.field}} placeholder)", + ), + "tenantAddressRef": WorkflowActionParameter( + name="tenantAddressRef", + type="str", + frontendType=FrontendType.TEXT, + required=False, + description="Tenant address to match (tolerant)", + ), + "period": WorkflowActionParameter( + name="period", + type="str", + frontendType=FrontendType.TEXT, + required=False, + description="Period filter: YYYY or YYYY-MM-DD/YYYY-MM-DD", + ), + "rentAccountPattern": WorkflowActionParameter( + name="rentAccountPattern", + type="str", + frontendType=FrontendType.TEXT, + required=False, + description="Account-number pattern for rent revenue (e.g. '6000-6099' or '6*')", + ), + "filterJson": WorkflowActionParameter( + name="filterJson", + type="str", + frontendType=FrontendType.TEXTAREA, + required=False, + description="Optional JSON filter for mode=raw/aggregate", + ), + }, + execute=queryData.__get__(self, self.__class__), + ), } self._validateActions() @@ -156,3 +221,4 @@ class MethodTrustee(MethodBase): self.processDocuments = processDocuments.__get__(self, self.__class__) self.syncToAccounting = syncToAccounting.__get__(self, self.__class__) self.refreshAccountingData = refreshAccountingData.__get__(self, self.__class__) + self.queryData = queryData.__get__(self, self.__class__) diff --git a/tests/unit/workflow/test_trusteeQueryData.py b/tests/unit/workflow/test_trusteeQueryData.py new file mode 100644 index 00000000..b0bbae3b --- /dev/null +++ b/tests/unit/workflow/test_trusteeQueryData.py @@ -0,0 +1,88 @@ +# Copyright (c) 2026 Patrick Motsch +# All rights reserved. +"""Unit tests for trustee.queryData helpers (pure-logic, no DB required).""" + +import pytest + +from modules.workflows.methods.methodTrustee.actions.queryData import ( + _accountMatcher, + _normalizeText, + _parseFilterJson, + _parsePeriod, + _summarizeAggregate, +) + + +class TestNormalize: + def test_normalizeStripsCaseAndPunctuation(self): + assert _normalizeText("Müller AG") == "mllerag" + assert _normalizeText("Mueller, AG") == "muellerag" + + def test_normalizeHandlesEmpty(self): + assert _normalizeText("") == "" + assert _normalizeText(None) == "" + + +class TestParsePeriod: + def test_yearOnlyExpandsToFullYear(self): + assert _parsePeriod("2025") == ("2025-01-01", "2025-12-31") + + def test_rangeWithSlash(self): + assert _parsePeriod("2025-01-01/2025-06-30") == ("2025-01-01", "2025-06-30") + + def test_emptyReturnsNone(self): + assert _parsePeriod("") == (None, None) + assert _parsePeriod(None) == (None, None) + + +class TestAccountMatcher: + def test_exactMatch(self): + m = _accountMatcher("6000") + assert m("6000") is True + assert m("6001") is False + + def test_prefixMatch(self): + m = _accountMatcher("6*") + assert m("6000") is True + assert m("6999") is True + assert m("7000") is False + + def test_rangeMatch(self): + m = _accountMatcher("6000-6099") + assert m("6000") is True + assert m("6050") is True + assert m("6099") is True + assert m("6100") is False + assert m("not-a-number") is False + + def test_emptyMatchesAll(self): + m = _accountMatcher("") + assert m("anything") is True + + +class TestParseFilterJson: + def test_validJson(self): + assert _parseFilterJson('{"foo": "bar"}') == {"foo": "bar"} + + def test_dictPassThrough(self): + assert _parseFilterJson({"a": 1}) == {"a": 1} + + def test_emptyOrInvalidReturnsEmptyDict(self): + assert _parseFilterJson("") == {} + assert _parseFilterJson(None) == {} + assert _parseFilterJson("not json") == {} + + +class TestSummarizeAggregate: + def test_countsByContactType(self): + records = [ + {"contactType": "customer"}, + {"contactType": "customer"}, + {"contactType": "vendor"}, + ] + summary = _summarizeAggregate(records) + assert summary["total"] == 3 + assert summary["by_contactType"] == {"customer": 2, "vendor": 1} + + def test_emptyRecordsReturnsZero(self): + assert _summarizeAggregate([]) == {"total": 0} diff --git a/tests/unit/workflow/test_workflowFileSchema.py b/tests/unit/workflow/test_workflowFileSchema.py new file mode 100644 index 00000000..81849d06 --- /dev/null +++ b/tests/unit/workflow/test_workflowFileSchema.py @@ -0,0 +1,166 @@ +# Copyright (c) 2026 Patrick Motsch +# All rights reserved. +"""Unit tests for the workflow-file (versioned envelope) schema.""" + +import pytest + +from modules.features.graphicalEditor._workflowFileSchema import ( + WORKFLOW_FILE_KIND, + WORKFLOW_FILE_SCHEMA_VERSION, + WorkflowFileSchemaError, + buildFileFromWorkflow, + buildFileName, + envelopeToWorkflowData, + isWorkflowFileEnvelope, + normalizeGraph, + validateFileEnvelope, +) + + +def _sampleWorkflowRow() -> dict: + return { + "id": "wf-123", + "mandateId": "mand-1", + "featureInstanceId": "inst-1", + "currentVersionId": "ver-1", + "eventId": "evt-1", + "active": True, + "label": "Test Workflow", + "description": "Round-trip sample", + "tags": ["test"], + "templateScope": None, + "sharedReadOnly": False, + "notifyOnFailure": True, + "isTemplate": False, + "graph": { + "nodes": [ + {"id": "n1", "type": "trigger.manual", "x": 50, "y": 200, "parameters": {}}, + {"id": "n2", "type": "ai.prompt", "position": {"x": 300, "y": 200}, "parameters": {"aiPrompt": "Hi"}}, + ], + "connections": [ + {"source": "n1", "target": "n2", "sourceOutput": 0, "targetInput": 0}, + ], + }, + "invocations": [{"id": "inv-1", "kind": "manual", "enabled": True, "title": {"de": "Start"}}], + "sysCreatedAt": 1700000000.0, + "sysModifiedAt": 1700000100.0, + } + + +def _sampleNodeTypes() -> list: + return ["trigger.manual", "ai.prompt"] + + +class TestBuildFile: + def test_envelopeContainsKindAndSchemaVersion(self): + envelope = buildFileFromWorkflow(_sampleWorkflowRow()) + assert envelope["$kind"] == WORKFLOW_FILE_KIND + assert envelope["$schemaVersion"] == WORKFLOW_FILE_SCHEMA_VERSION + assert "$exportedAt" in envelope + + def test_persistenceFieldsAreStripped(self): + envelope = buildFileFromWorkflow(_sampleWorkflowRow()) + for forbidden in ("id", "mandateId", "featureInstanceId", "currentVersionId", "eventId", "active", "sysCreatedAt", "sysModifiedAt"): + assert forbidden not in envelope, f"{forbidden} must not appear in exported file" + + def test_portableFieldsAreCopied(self): + envelope = buildFileFromWorkflow(_sampleWorkflowRow()) + assert envelope["label"] == "Test Workflow" + assert envelope["description"] == "Round-trip sample" + assert envelope["tags"] == ["test"] + assert envelope["notifyOnFailure"] is True + + def test_graphPositionsAreNormalized(self): + envelope = buildFileFromWorkflow(_sampleWorkflowRow()) + nodes = envelope["graph"]["nodes"] + assert nodes[0]["x"] == 50 + assert nodes[0]["y"] == 200 + assert nodes[1]["x"] == 300 + assert nodes[1]["y"] == 200 + assert "position" not in nodes[1] + + +class TestValidate: + def test_validEnvelopeReturnsNoErrors(self): + envelope = buildFileFromWorkflow(_sampleWorkflowRow()) + normalized, warnings = validateFileEnvelope(envelope, knownNodeTypes=_sampleNodeTypes()) + assert normalized["label"] == "Test Workflow" + assert warnings == [] + + def test_missingSchemaVersionRaises(self): + with pytest.raises(WorkflowFileSchemaError): + validateFileEnvelope({"label": "x", "graph": {}}) + + def test_unsupportedSchemaVersionRaises(self): + with pytest.raises(WorkflowFileSchemaError): + validateFileEnvelope({"$schemaVersion": "99.0", "label": "x", "graph": {}}) + + def test_missingLabelRaises(self): + with pytest.raises(WorkflowFileSchemaError): + validateFileEnvelope({"$schemaVersion": WORKFLOW_FILE_SCHEMA_VERSION, "graph": {}}) + + def test_missingGraphRaises(self): + with pytest.raises(WorkflowFileSchemaError): + validateFileEnvelope({"$schemaVersion": WORKFLOW_FILE_SCHEMA_VERSION, "label": "x"}) + + def test_unknownNodeTypeRaises(self): + envelope = buildFileFromWorkflow(_sampleWorkflowRow()) + with pytest.raises(WorkflowFileSchemaError): + validateFileEnvelope(envelope, knownNodeTypes=["trigger.manual"]) + + def test_emptyNodesProducesWarning(self): + envelope = { + "$schemaVersion": WORKFLOW_FILE_SCHEMA_VERSION, + "label": "Empty", + "graph": {"nodes": [], "connections": []}, + } + _, warnings = validateFileEnvelope(envelope) + assert any("no nodes" in w.lower() for w in warnings) + + def test_danglingConnectionProducesWarning(self): + envelope = { + "$schemaVersion": WORKFLOW_FILE_SCHEMA_VERSION, + "label": "Bad", + "graph": { + "nodes": [{"id": "a", "type": "trigger.manual"}], + "connections": [{"source": "a", "target": "ghost"}], + }, + } + _, warnings = validateFileEnvelope(envelope, knownNodeTypes=["trigger.manual"]) + assert any("ghost" in w for w in warnings) + + +class TestRoundTrip: + def test_exportThenImportPreservesGraphStructure(self): + original = _sampleWorkflowRow() + envelope = buildFileFromWorkflow(original) + normalized, _ = validateFileEnvelope(envelope, knownNodeTypes=_sampleNodeTypes()) + data = envelopeToWorkflowData(normalized, mandateId="mand-2", featureInstanceId="inst-2") + + assert data["mandateId"] == "mand-2" + assert data["featureInstanceId"] == "inst-2" + assert data["active"] is False, "imports must be inactive by default" + assert data["label"] == original["label"] + assert data["description"] == original["description"] + assert len(data["graph"]["nodes"]) == len(original["graph"]["nodes"]) + assert len(data["graph"]["connections"]) == len(original["graph"]["connections"]) + for forbidden in ("id", "currentVersionId", "eventId"): + assert forbidden not in data + + +class TestHelpers: + def test_isWorkflowFileEnvelopeAcceptsValid(self): + assert isWorkflowFileEnvelope(buildFileFromWorkflow(_sampleWorkflowRow())) is True + + def test_isWorkflowFileEnvelopeRejectsRandom(self): + assert isWorkflowFileEnvelope({"foo": "bar"}) is False + assert isWorkflowFileEnvelope("not-a-dict") is False + assert isWorkflowFileEnvelope(None) is False + + def test_buildFileNameProducesSafeSlug(self): + assert buildFileName("PWG: Pilot Workflow!") == "pwg-pilot-workflow.workflow.json" + assert buildFileName("") == "workflow.workflow.json" + + def test_normalizeGraphHandlesMissingFields(self): + assert normalizeGraph(None) == {"nodes": [], "connections": []} + assert normalizeGraph({}) == {"nodes": [], "connections": []}