Explorar o código

Black Formatter

gc-server3 hai 1 semana
pai
achega
ed35ccade6

+ 8 - 2
app/auth.py

@@ -9,6 +9,7 @@ from starlette.middleware.base import BaseHTTPMiddleware
 from .config import settings
 from .db import SessionLocal
 from .ldap import DirectoryUnavailable, lookup_directory_user
+from .navigation import normalize_page_url
 from .oauth_token import (
     InvalidSessionToken,
     create_access_token,
@@ -16,7 +17,6 @@ from .oauth_token import (
     renew_access_token,
     token_needs_renewal,
 )
-from .navigation import normalize_page_url
 from .permissions import Principal, highest_role, load_entitlements
 
 logger = logging.getLogger(__name__)
@@ -131,7 +131,13 @@ class AuthenticationMiddleware(BaseHTTPMiddleware):
 
         principal = getattr(request.state, "principal", None)
         if not principal and not is_public:
-            logger.info('Anmeldung erforderlich', extra={'event': 'auth_login_required', 'reason': 'invalid_or_expired_session' if token else 'missing_session'})
+            logger.info(
+                "Anmeldung erforderlich",
+                extra={
+                    "event": "auth_login_required",
+                    "reason": "invalid_or_expired_session" if token else "missing_session",
+                },
+            )
             next_url = request.url.path
             if request.url.query:
                 next_url += "?" + request.url.query

+ 19 - 9
app/cleanup_comments.py

@@ -1,10 +1,11 @@
-import re
 import logging
+import re
 from datetime import date, timedelta
 
+from sqlalchemy import text
+
 from .db import SessionLocal
 from .logging_setup import run_cli
-from sqlalchemy import text
 
 logger = logging.getLogger(__name__)
 
@@ -20,10 +21,17 @@ def main():
                 r = r.replace("'", "").replace("?", "").replace(" :", "").replace("  ", " ").strip()
                 # d, u = get_date_and_user_from_comment(r)
                 db.execute(
-                    text("INSERT INTO dbo.Forderungen_Kommentar (Client_DB, Beleg_Nr, [Timestamp], Benutzer_ID, Kommentar) "
-                         "VALUES (:Client_DB, :Beleg_Nr, :Timestamp, :Benutzer_ID, :Kommentar)")
-                    .execution_options(query_name='cleanup_comments.import_comments'),
-                    dict(Client_DB=row.Client_DB, Beleg_Nr=row.Beleg_Nr, Timestamp=comment_date, Benutzer_ID=comment_user, Kommentar=r),
+                    text(
+                        "INSERT INTO dbo.Forderungen_Kommentar (Client_DB, Beleg_Nr, [Timestamp], Benutzer_ID, Kommentar) "
+                        "VALUES (:Client_DB, :Beleg_Nr, :Timestamp, :Benutzer_ID, :Kommentar)"
+                    ).execution_options(query_name="cleanup_comments.import_comments"),
+                    dict(
+                        Client_DB=row.Client_DB,
+                        Beleg_Nr=row.Beleg_Nr,
+                        Timestamp=comment_date,
+                        Benutzer_ID=comment_user,
+                        Kommentar=r,
+                    ),
                 )
 
         db.commit()
@@ -60,9 +68,11 @@ def get_details_from_comments():
 
             if data:
                 db.execute(
-                    text("UPDATE dbo.Forderungen_Details SET " + ", ".join(f"{key} = :{key}" for key in data)
-                         + " WHERE Client_DB = :Client_DB AND Beleg_Nr = :Beleg_Nr")
-                    .execution_options(query_name='cleanup_comments.parse_details'),
+                    text(
+                        "UPDATE dbo.Forderungen_Details SET "
+                        + ", ".join(f"{key} = :{key}" for key in data)
+                        + " WHERE Client_DB = :Client_DB AND Beleg_Nr = :Beleg_Nr"
+                    ).execution_options(query_name="cleanup_comments.parse_details"),
                     dict(data, Client_DB=row.Client_DB, Beleg_Nr=row.Beleg_Nr),
                 )
         db.commit()

+ 67 - 36
app/comment_submission.py

@@ -1,8 +1,9 @@
 """Independent file and comment results for the existing comment POST."""
-from datetime import datetime
+
 import json
 import logging
 import re
+from datetime import datetime
 
 from fastapi import HTTPException
 from sqlalchemy import text
@@ -22,76 +23,106 @@ def _rollback(db):
     try:
         db.rollback()
     except Exception:
-        logger.exception('Rollback nach fehlgeschlagenem Kommentar nicht möglich')
+        logger.exception("Rollback nach fehlgeschlagenem Kommentar nicht möglich")
 
 
 async def submit_comment(request, pkey, db, scope):
     results, messages = [], []
     comment_saved = False
     async with request.form() as form:
-        uploads = form.getlist('attachments')
-        comment = form.get('Kommentar', '')
+        uploads = form.getlist("attachments")
+        comment = form.get("Kommentar", "")
         try:
-            ids = json.loads(form.get('attachment_ids', '[]'))
+            ids = json.loads(form.get("attachment_ids", "[]"))
         except (ValueError, TypeError):
-            raise HTTPException(400, 'Die Dateiauswahl ist ungültig.')
-        if (not isinstance(comment, str) or not isinstance(ids, list) or len(ids) != len(uploads)
-                or any(not isinstance(item, UploadFile) for item in uploads)
-                or any(not isinstance(item, str) or not re.fullmatch(r'[A-Za-z0-9_-]{1,80}', item) for item in ids)
-                or len(set(ids)) != len(ids)):
-            raise HTTPException(400, 'Die Dateiauswahl ist ungültig.')
+            raise HTTPException(400, "Die Dateiauswahl ist ungültig.")
+        if (
+            not isinstance(comment, str)
+            or not isinstance(ids, list)
+            or len(ids) != len(uploads)
+            or any(not isinstance(item, UploadFile) for item in uploads)
+            or any(not isinstance(item, str) or not re.fullmatch(r"[A-Za-z0-9_-]{1,80}", item) for item in ids)
+            or len(set(ids)) != len(ids)
+        ):
+            raise HTTPException(400, "Die Dateiauswahl ist ungültig.")
 
         for identity, upload in zip(ids, uploads):
-            result = {'id': identity, 'name': upload.filename or '', 'success': False}
+            result = {"id": identity, "name": upload.filename or "", "success": False}
             try:
-                await run_in_threadpool(save_upload, pkey, upload.filename or '', upload.file,
-                                        settings.UPLOAD_MAX_FILE_SIZE_MB * 1024 * 1024)
-                result['success'] = True
+                await run_in_threadpool(
+                    save_upload,
+                    pkey,
+                    upload.filename or "",
+                    upload.file,
+                    settings.UPLOAD_MAX_FILE_SIZE_MB * 1024 * 1024,
+                )
+                result["success"] = True
             except UploadError as error:
-                result['error'] = str(error)
+                result["error"] = str(error)
             except OSError:
-                logger.exception('Datei konnte für %s nicht gespeichert werden', pkey)
-                result['error'] = 'Speichern fehlgeschlagen. Bitte Speicherplatz, Dateisperren und Zugriffsrechte prüfen.'
+                logger.exception("Datei konnte für %s nicht gespeichert werden", pkey)
+                result["error"] = (
+                    "Speichern fehlgeschlagen. Bitte Speicherplatz, Dateisperren und Zugriffsrechte prüfen."
+                )
             results.append(result)
 
         if comment.strip():
-            data = dict(pkey, Benutzer_ID=current_principal(request).username,
-                        Timestamp=datetime.now().strftime('%Y-%m-%dT%H:%M:%S'), Kommentar=comment)
+            data = dict(
+                pkey,
+                Benutzer_ID=current_principal(request).username,
+                Timestamp=datetime.now().strftime("%Y-%m-%dT%H:%M:%S"),
+                Kommentar=comment,
+            )
             try:
-                query = templates.env.get_template('forderungen/details/queries/post/post_comments.sql').render(data=data)
-                for statement in query.split(';'):
+                query = templates.env.get_template("forderungen/details/queries/post/post_comments.sql").render(
+                    data=data
+                )
+                for statement in query.split(";"):
                     if statement.strip():
-                        db.execute(text(statement).execution_options(query_name='forderungen/details/queries/post/post_comments.sql'), data)
+                        db.execute(
+                            text(statement).execution_options(
+                                query_name="forderungen/details/queries/post/post_comments.sql"
+                            ),
+                            data,
+                        )
                 db.commit()
                 comment_saved = True
             except Exception:
                 _rollback(db)
-                logger.exception('Kommentar konnte für %s nicht gespeichert werden', pkey)
-                messages.append('Der Kommentar konnte nicht gespeichert werden. Der Text bleibt im Eingabefeld.')
+                logger.exception("Kommentar konnte für %s nicht gespeichert werden", pkey)
+                messages.append("Der Kommentar konnte nicht gespeichert werden. Der Text bleibt im Eingabefeld.")
         elif not uploads:
-            messages.append('Bitte einen Kommentar oder Dateien hinzufügen.')
+            messages.append("Bitte einen Kommentar oder Dateien hinzufügen.")
 
     context = {}
     history_available = True
     try:
-        context.update(history_context(db, pkey, 'kommentare', scope))
+        context.update(history_context(db, pkey, "kommentare", scope))
     except Exception:
         history_available = False
         _rollback(db)
-        logger.exception('Kommentarhistorie konnte nicht aktualisiert werden')
-        messages.append('Die Kommentare konnten nicht aktualisiert werden. Bereits gespeicherte Ergebnisse bleiben erhalten.')
+        logger.exception("Kommentarhistorie konnte nicht aktualisiert werden")
+        messages.append(
+            "Die Kommentare konnten nicht aktualisiert werden. Bereits gespeicherte Ergebnisse bleiben erhalten."
+        )
     files_available = True
     try:
-        context['files'] = get_files(pkey)
+        context["files"] = get_files(pkey)
     except OSError:
         files_available = False
-        logger.exception('Dateiliste konnte nicht aktualisiert werden')
-        messages.append('Die Dateiliste konnte nicht aktualisiert werden. Bereits gespeicherte Dateien bleiben erhalten.')
+        logger.exception("Dateiliste konnte nicht aktualisiert werden")
+        messages.append(
+            "Die Dateiliste konnte nicht aktualisiert werden. Bereits gespeicherte Dateien bleiben erhalten."
+        )
     context.update(
-        history_available=history_available, files_available=files_available, files_oob=True,
-        send_result={'files': results, 'comment_saved': comment_saved, 'messages': messages},
+        history_available=history_available,
+        files_available=files_available,
+        files_oob=True,
+        send_result={"files": results, "comment_saved": comment_saved, "messages": messages},
     )
     return templates.TemplateResponse(
-        request, 'forderungen/details/comment_submission.html', context,
-        headers={} if history_available else {'HX-Reswap': 'none'},
+        request,
+        "forderungen/details/comment_submission.html",
+        context,
+        headers={} if history_available else {"HX-Reswap": "none"},
     )

+ 5 - 2
app/config.py

@@ -7,6 +7,7 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
 
 DEVELOPMENT_JWT_SECRET = "development-only-change-me-32-bytes"
 
+
 def _split_csv(value: object) -> list[str]:
     if isinstance(value, str):
         return [item.strip() for item in value.split(",") if item.strip()]
@@ -47,8 +48,10 @@ class Settings(BaseSettings):
     LDAP_START_TLS: bool = False
     LOCAL_STORAGE: str | None = None
     UPLOAD_MAX_FILE_SIZE_MB: int = Field(default=50, gt=0)
-    LOG_LEVEL: Annotated[Literal['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'], BeforeValidator(lambda value: str(value).upper())] = 'INFO'
-    LOG_DIR: str = 'logs'
+    LOG_LEVEL: Annotated[
+        Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], BeforeValidator(lambda value: str(value).upper())
+    ] = "INFO"
+    LOG_DIR: str = "logs"
     LOG_TO_FILE: bool = True
     LOG_TO_CONSOLE: bool = True
     LOG_RETENTION_DAYS: int = Field(default=30, ge=1)

+ 60 - 48
app/dashboard_segment.py

@@ -1,70 +1,80 @@
 """Decode legacy dashboard links into the regular editable list filters."""
-from datetime import date
+
 import json
+from datetime import date
+
 from .query_filter import EMPTY_FILTER_VALUE, encode_filter_values
 
-VIEWS = ('Altersstaffel', 'Standort', 'Verursacher', 'Wiedervorlage')
+VIEWS = ("Altersstaffel", "Standort", "Verursacher", "Wiedervorlage")
 DASHBOARD_VIEWS = VIEWS[:-1]  # Old Wiedervorlage list selections remain valid.
 
 
 def _date_key(key):
     if key is None:
         return
-    if not isinstance(key, dict) or set(key) != {'from', 'to'} or not any(key.values()):
-        raise ValueError('Ungültiger Datumsfilter')
+    if not isinstance(key, dict) or set(key) != {"from", "to"} or not any(key.values()):
+        raise ValueError("Ungültiger Datumsfilter")
     for value in key.values():
         if value is not None and (not isinstance(value, str) or date.fromisoformat(value).isoformat() != value):
-            raise ValueError('Ungültiges Datum')
-    if key['from'] and key['to'] and key['from'] > key['to']:
-        raise ValueError('Ungültiger Zeitraum')
-    if key['to'] == '9999-12-31':
-        raise ValueError('Zeitraum außerhalb des unterstützten Bereichs')
+            raise ValueError("Ungültiges Datum")
+    if key["from"] and key["to"] and key["from"] > key["to"]:
+        raise ValueError("Ungültiger Zeitraum")
+    if key["to"] == "9999-12-31":
+        raise ValueError("Zeitraum außerhalb des unterstützten Bereichs")
 
 
 def _value(value):
-    if value is not None and (not isinstance(value, str) or len(value) > 500
-                              or any(ord(char) < 32 or ord(char) == 127 or 0xd800 <= ord(char) <= 0xdfff for char in value)):
-        raise ValueError('Ungültiger Gruppenschlüssel')
+    if value is not None and (
+        not isinstance(value, str)
+        or len(value) > 500
+        or any(ord(char) < 32 or ord(char) == 127 or 0xD800 <= ord(char) <= 0xDFFF for char in value)
+    ):
+        raise ValueError("Ungültiger Gruppenschlüssel")
 
 
 def parse_segment(raw):
     if not raw:
         return None
     if not isinstance(raw, str) or len(raw) > 4096:
-        raise ValueError('Ungültiger Dashboardfilter')
+        raise ValueError("Ungültiger Dashboardfilter")
     try:
         segment = json.loads(raw)
     except (ValueError, TypeError, RecursionError) as error:
-        raise ValueError('Ungültiger Dashboardfilter') from error
-    if (not isinstance(segment, dict) or set(segment) - {'view', 'key', 'department', 'only_invoices', 'follow_up'}
-            or not {'view', 'key'} <= set(segment) or segment['view'] not in VIEWS):
-        raise ValueError('Ungültiger Dashboardfilter')
-    key, view = segment['key'], segment['view']
-    if 'only_invoices' in segment and type(segment['only_invoices']) is not bool:
-        raise ValueError('Ungültiger Rechnungsfilter')
-    if 'department' in segment:
-        _value(segment['department'])
-    if 'follow_up' in segment:
-        _date_key(segment['follow_up'])
-    if view == 'Altersstaffel':
+        raise ValueError("Ungültiger Dashboardfilter") from error
+    if (
+        not isinstance(segment, dict)
+        or set(segment) - {"view", "key", "department", "only_invoices", "follow_up"}
+        or not {"view", "key"} <= set(segment)
+        or segment["view"] not in VIEWS
+    ):
+        raise ValueError("Ungültiger Dashboardfilter")
+    key, view = segment["key"], segment["view"]
+    if "only_invoices" in segment and type(segment["only_invoices"]) is not bool:
+        raise ValueError("Ungültiger Rechnungsfilter")
+    if "department" in segment:
+        _value(segment["department"])
+    if "follow_up" in segment:
+        _date_key(segment["follow_up"])
+    if view == "Altersstaffel":
         _value(key)
-    elif view == 'Standort':
-        if not isinstance(key, dict) or set(key) != {'client', 'site'} or not key['client']:
-            raise ValueError('Ungültiger Standortfilter')
-        _value(key['client']); _value(key['site'])
-    elif view == 'Verursacher':
-        if not isinstance(key, dict) or set(key) not in ({'id'}, {'text'}):
-            raise ValueError('Ungültiger Verursacherfilter')
+    elif view == "Standort":
+        if not isinstance(key, dict) or set(key) != {"client", "site"} or not key["client"]:
+            raise ValueError("Ungültiger Standortfilter")
+        _value(key["client"])
+        _value(key["site"])
+    elif view == "Verursacher":
+        if not isinstance(key, dict) or set(key) not in ({"id"}, {"text"}):
+            raise ValueError("Ungültiger Verursacherfilter")
         _value(next(iter(key.values())))
-        if 'id' in key and not key['id']:
-            raise ValueError('Benutzer-ID fehlt')
+        if "id" in key and not key["id"]:
+            raise ValueError("Benutzer-ID fehlt")
     else:
         _date_key(key)
     return segment
 
 
 def pack_segment(segment):
-    return json.dumps(segment, ensure_ascii=False, sort_keys=True, separators=(',', ':'))
+    return json.dumps(segment, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
 
 
 def segment_filters(segment, person_values=None):
@@ -72,17 +82,19 @@ def segment_filters(segment, person_values=None):
     if not segment:
         return {}
     values = {}
-    key, view = segment['key'], segment['view']
-    if view == 'Standort':
-        values.update(Mandant=[key['client']], Filiale=[key['site']])
-    elif view == 'Altersstaffel':
-        values['Staffel'] = [key]
-    elif view == 'Verursacher':
-        values['Verursacher'] = person_values if person_values is not None else [key.get('text', key.get('id'))]
-    if 'department' in segment:
-        values['Abteilung'] = [segment['department']]
-    if segment.get('only_invoices'):
-        values['RechnungGutschrift'] = ['Rechnung']
+    key, view = segment["key"], segment["view"]
+    if view == "Standort":
+        values.update(Mandant=[key["client"]], Filiale=[key["site"]])
+    elif view == "Altersstaffel":
+        values["Staffel"] = [key]
+    elif view == "Verursacher":
+        values["Verursacher"] = person_values if person_values is not None else [key.get("text", key.get("id"))]
+    if "department" in segment:
+        values["Abteilung"] = [segment["department"]]
+    if segment.get("only_invoices"):
+        values["RechnungGutschrift"] = ["Rechnung"]
     # Both the old Wiedervorlage rubric and follow_up stacks are deliberately ignored.
-    return {name: encode_filter_values([EMPTY_FILTER_VALUE if value in (None, '') else value for value in items])
-            for name, items in values.items()}
+    return {
+        name: encode_filter_values([EMPTY_FILTER_VALUE if value in (None, "") else value for value in items])
+        for name, items in values.items()
+    }

+ 30 - 19
app/db.py

@@ -6,39 +6,46 @@ from .logging_setup import db_operation, db_query, log_query_error, query_origin
 
 
 def install_engine_logging(engine):
-    if getattr(engine, '_gc_logging', False): return
+    if getattr(engine, "_gc_logging", False):
+        return
     engine._gc_logging = True
 
-    @event.listens_for(engine, 'before_cursor_execute')
+    @event.listens_for(engine, "before_cursor_execute")
     def executing(conn, cursor, statement, parameters, context, executemany):
-        context._gc_operation = 'execute'
+        context._gc_operation = "execute"
 
-    @event.listens_for(engine, 'after_cursor_execute')
+    @event.listens_for(engine, "after_cursor_execute")
     def executed(conn, cursor, statement, parameters, context, executemany):
-        context._gc_operation = 'fetch'
+        context._gc_operation = "fetch"
 
-    @event.listens_for(engine, 'handle_error')
+    @event.listens_for(engine, "handle_error")
     def query_failed(context):
         # Observational only: never replace exceptions or affect connection invalidation.
         error = context.sqlalchemy_exception or context.original_exception
-        options = getattr(context.execution_context, 'execution_options', {}) or {}
+        options = getattr(context.execution_context, "execution_options", {}) or {}
         active = db_query.get() or {}
-        statement = context.statement if context.statement is not None else active.get('statement')
-        log_query_error(error, statement, options.get('query_name') or active.get('query_name'),
-                        db_operation.get() or getattr(context.execution_context, '_gc_operation', 'connect_or_transaction'),
-                        parameters=context.parameters)
+        statement = context.statement if context.statement is not None else active.get("statement")
+        log_query_error(
+            error,
+            statement,
+            options.get("query_name") or active.get("query_name"),
+            db_operation.get() or getattr(context.execution_context, "_gc_operation", "connect_or_transaction"),
+            parameters=context.parameters,
+        )
 
 
 class LoggingSession(Session):
     def execute(self, statement, params=None, *, execution_options=None, **kwargs):
         options = dict(execution_options or {})
-        origin = options.setdefault('query_name', getattr(statement, '_execution_options', {}).get('query_name') or query_origin())
-        token = db_operation.set('execute')
-        query_token = db_query.set({'statement': statement, 'query_name': origin})
+        origin = options.setdefault(
+            "query_name", getattr(statement, "_execution_options", {}).get("query_name") or query_origin()
+        )
+        token = db_operation.set("execute")
+        query_token = db_query.set({"statement": statement, "query_name": origin})
         try:
             return super().execute(statement, params, execution_options=options, **kwargs)
         except Exception as error:
-            log_query_error(error, statement, origin, 'execute', parameters=params)
+            log_query_error(error, statement, origin, "execute", parameters=params)
             raise
         finally:
             db_query.reset(query_token)
@@ -54,19 +61,23 @@ class LoggingSession(Session):
         finally:
             db_operation.reset(token)
 
-    def commit(self): return self._transaction_operation('commit')
-    def rollback(self): return self._transaction_operation('rollback')
+    def commit(self):
+        return self._transaction_operation("commit")
+
+    def rollback(self):
+        return self._transaction_operation("rollback")
 
     def flush(self, objects=None):
-        token = db_operation.set('flush')
+        token = db_operation.set("flush")
         try:
             return super().flush(objects)
         except Exception as error:
-            log_query_error(error, operation='flush')
+            log_query_error(error, operation="flush")
             raise
         finally:
             db_operation.reset(token)
 
+
 engine = create_engine(
     settings.DATABASE_URL,
     connect_args={"check_same_thread": False} if settings.DATABASE_URL.startswith("sqlite") else {},

+ 7 - 6
app/docuware_search.py

@@ -1,9 +1,9 @@
 import json
-import os
 import logging
 
 import docuware
 from dotenv import load_dotenv
+
 from .logging_setup import run_cli
 
 logger = logging.getLogger(__name__)
@@ -23,11 +23,11 @@ def main():
     dw = docuware.connect()
 
     for org in dw.organizations:
-        logger.debug('DocuWare-Organisation verfügbar')
+        logger.debug("DocuWare-Organisation verfügbar")
         for fc in org.file_cabinets:
-            logger.debug('DocuWare-Ablage verfügbar')
+            logger.debug("DocuWare-Ablage verfügbar")
         for b in org.baskets:
-            logger.debug('DocuWare-Briefkorb verfügbar')
+            logger.debug("DocuWare-Briefkorb verfügbar")
 
 
 def get_file_cabinet(client_db: str) -> docuware.FileCabinet:
@@ -110,6 +110,7 @@ if __name__ == "__main__":
     # main()
     # search_files(client_db="1", vehicle_no="WBA21FZ070FT19353")
     def download_sample():
-        data = get_invoice_pdf_by_document_no('1', 'WRG25117581')
-        logger.info('PDF abgerufen', extra={'event': 'docuware_pdf_downloaded', 'size_bytes': len(data)})
+        data = get_invoice_pdf_by_document_no("1", "WRG25117581")
+        logger.info("PDF abgerufen", extra={"event": "docuware_pdf_downloaded", "size_bytes": len(data)})
+
     run_cli(download_sample)

+ 59 - 41
app/file_storage.py

@@ -1,26 +1,27 @@
 """Document storage, including legacy folders and collision-safe uploads."""
-from contextlib import contextmanager
-from dataclasses import dataclass
-from datetime import datetime
+
 import hashlib
 import logging
 import os
-from pathlib import Path
 import re
 import tempfile
 import threading
 import time
+from contextlib import contextmanager
+from dataclasses import dataclass
+from datetime import datetime
+from pathlib import Path
 from weakref import WeakValueDictionary
 from zoneinfo import ZoneInfo
 
 from .config import settings
 
 logger = logging.getLogger(__name__)
-BERLIN = ZoneInfo('Europe/Berlin')
+BERLIN = ZoneInfo("Europe/Berlin")
 _THREAD_LOCKS = WeakValueDictionary()
 _LOCK_GUARD = threading.Lock()
-_RESERVED = {'CON', 'PRN', 'AUX', 'NUL', 'CONIN$', 'CONOUT$'} | {
-    f'{prefix}{digit}' for prefix in ('COM', 'LPT') for digit in '123456789¹²³'
+_RESERVED = {"CON", "PRN", "AUX", "NUL", "CONIN$", "CONOUT$"} | {
+    f"{prefix}{digit}" for prefix in ("COM", "LPT") for digit in "123456789¹²³"
 }
 
 
@@ -37,17 +38,21 @@ class StoredFile:
 
 
 def validate_filename(name: str) -> str:
-    if (not name or name in {'.', '..'} or name.endswith(('.', ' '))
-            or re.search(r'[<>:"/\\|?*\x00-\x1f\x7f]', name)
-            or name.split('.')[0].rstrip(' ').upper() in _RESERVED
-            or len(name.encode('utf-16-le', errors='surrogatepass')) // 2 > 255):
-        raise UploadError('Dieser Dateiname kann unter Windows nicht gespeichert werden.')
+    if (
+        not name
+        or name in {".", ".."}
+        or name.endswith((".", " "))
+        or re.search(r'[<>:"/\\|?*\x00-\x1f\x7f]', name)
+        or name.split(".")[0].rstrip(" ").upper() in _RESERVED
+        or len(name.encode("utf-16-le", errors="surrogatepass")) // 2 > 255
+    ):
+        raise UploadError("Dieser Dateiname kann unter Windows nicht gespeichert werden.")
     return name
 
 
 def _root() -> Path:
     if not settings.LOCAL_STORAGE:
-        raise UploadError('Die Dateiablage ist nicht konfiguriert.')
+        raise UploadError("Die Dateiablage ist nicht konfiguriert.")
     return Path(settings.LOCAL_STORAGE).resolve()
 
 
@@ -57,34 +62,34 @@ def _checked(root: Path, *parts: str) -> Path:
     candidate = root.joinpath(*parts)
     resolved = candidate.resolve()
     if root not in resolved.parents or resolved != candidate:
-        raise UploadError('Der Ablagepfad ist nicht zulässig.')
+        raise UploadError("Der Ablagepfad ist nicht zulässig.")
     return candidate
 
 
 def _folders(root: Path, key: dict[str, str]) -> tuple[Path, Path]:
-    tenant, number = str(key['Client_DB']), str(key['Beleg_Nr'])
+    tenant, number = str(key["Client_DB"]), str(key["Beleg_Nr"])
     validate_filename(tenant)
     validate_filename(number)
-    return _checked(root, f'{tenant}_{number}'), _checked(root, tenant, number)
+    return _checked(root, f"{tenant}_{number}"), _checked(root, tenant, number)
 
 
 def file_primary_key(name: str) -> dict[str, str]:
     # Query parameters are already URL-decoded by the framework. Do not decode twice.
-    parts = name.replace('\\', '/').split('/')
+    parts = name.replace("\\", "/").split("/")
     if len(parts) not in (2, 3):
         raise FileNotFoundError(name)
     try:
         for part in parts:
             validate_filename(part)
         if len(parts) == 2:
-            tenant, separator, number = parts[0].partition('_')
+            tenant, separator, number = parts[0].partition("_")
             if not separator or not tenant or not number:
                 raise FileNotFoundError(name)
         else:
             tenant, number = parts[:2]
     except UploadError as error:
         raise FileNotFoundError(name) from error
-    return {'Client_DB': tenant, 'Beleg_Nr': number}
+    return {"Client_DB": tenant, "Beleg_Nr": number}
 
 
 def get_files(primary_key: dict[str, str]) -> list[StoredFile]:
@@ -97,14 +102,20 @@ def get_files(primary_key: dict[str, str]) -> list[StoredFile]:
         return []
     files = []
     for folder in folders:
-        for entry in folder.glob('*'):
+        for entry in folder.glob("*"):
             try:
                 filename = _checked(root, *entry.relative_to(root).parts)
                 if not filename.is_file():
                     continue
                 stat = filename.stat()
-                files.append(StoredFile(filename.name, filename.relative_to(root).as_posix(),
-                                        datetime.fromtimestamp(stat.st_mtime, BERLIN), stat.st_size))
+                files.append(
+                    StoredFile(
+                        filename.name,
+                        filename.relative_to(root).as_posix(),
+                        datetime.fromtimestamp(stat.st_mtime, BERLIN),
+                        stat.st_size,
+                    )
+                )
             except (FileNotFoundError, UploadError):
                 continue
     return sorted(files, key=lambda item: (-item.modified_at.timestamp(), item.name.casefold(), item.relative_path))
@@ -115,7 +126,7 @@ def get_file(name: str) -> Path:
         raise FileNotFoundError(name)
     file_primary_key(name)
     try:
-        filename = _checked(_root(), *name.replace('\\', '/').split('/'))
+        filename = _checked(_root(), *name.replace("\\", "/").split("/"))
         if not filename.is_file():
             raise FileNotFoundError(name)
         return filename
@@ -125,56 +136,60 @@ def get_file(name: str) -> Path:
 
 @contextmanager
 def _beleg_lock(root: Path, folder: Path):
-    lock_dir = _checked(root, '.upload-locks')
+    lock_dir = _checked(root, ".upload-locks")
     lock_dir.mkdir(parents=True, exist_ok=True)
-    digest = hashlib.sha256(str(folder).casefold().encode('utf-8')).hexdigest()
-    lock_path = _checked(root, '.upload-locks', digest + '.lock')
+    digest = hashlib.sha256(str(folder).casefold().encode("utf-8")).hexdigest()
+    lock_path = _checked(root, ".upload-locks", digest + ".lock")
     with _LOCK_GUARD:
         lock = _THREAD_LOCKS.setdefault(str(lock_path), threading.Lock())
     deadline = time.monotonic() + 30
     if not lock.acquire(timeout=30):
-        raise UploadError('Die Dateiablage ist gerade belegt. Bitte erneut abschicken.')
+        raise UploadError("Die Dateiablage ist gerade belegt. Bitte erneut abschicken.")
     try:
-        with lock_path.open('a+b') as handle:
+        with lock_path.open("a+b") as handle:
             if handle.tell() == 0:
-                handle.write(b'0')
+                handle.write(b"0")
                 handle.flush()
             locked = False
             try:
                 while not locked:
                     try:
                         handle.seek(0)
-                        if os.name == 'nt':
+                        if os.name == "nt":
                             import msvcrt
+
                             msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
                         else:
                             import fcntl
+
                             fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
                         locked = True
                     except OSError:
                         if time.monotonic() >= deadline:
-                            raise UploadError('Die Dateiablage ist gerade belegt. Bitte erneut abschicken.')
-                        time.sleep(.05)
+                            raise UploadError("Die Dateiablage ist gerade belegt. Bitte erneut abschicken.")
+                        time.sleep(0.05)
                 yield
             finally:
                 if locked:
                     handle.seek(0)
-                    if os.name == 'nt':
+                    if os.name == "nt":
                         import msvcrt
+
                         msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
                     else:
                         import fcntl
+
                         fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
     finally:
         lock.release()
 
 
 def _archive_name(root: Path, original: Path) -> Path:
-    stamp = datetime.now(BERLIN).strftime('%Y%m%d_%H%M%S_%f')
+    stamp = datetime.now(BERLIN).strftime("%Y%m%d_%H%M%S_%f")
     counter = 0
     while True:
-        suffix = f'_{counter}' if counter else ''
-        name = f'{original.stem}_{stamp}{suffix}{original.suffix}'
+        suffix = f"_{counter}" if counter else ""
+        name = f"{original.stem}_{stamp}{suffix}{original.suffix}"
         archive = _checked(root, *original.parent.relative_to(root).parts, name)
         if not archive.exists():
             return archive
@@ -186,18 +201,18 @@ def save_upload(primary_key: dict[str, str], name: str, stream, max_bytes: int)
     validate_filename(name)
     root = _root()
     destination, legacy = _folders(root, primary_key)
-    staging = _checked(root, '.upload-staging')
+    staging = _checked(root, ".upload-staging")
     staging.mkdir(parents=True, exist_ok=True)
     temporary = None
     try:
-        with tempfile.NamedTemporaryFile(dir=staging, suffix='.tmp', delete=False) as output:
+        with tempfile.NamedTemporaryFile(dir=staging, suffix=".tmp", delete=False) as output:
             temporary = Path(output.name)
             stream.seek(0)
             size = 0
             while chunk := stream.read(1024 * 1024):
                 size += len(chunk)
                 if size > max_bytes:
-                    raise UploadError(f'Die Datei überschreitet die erlaubten {settings.UPLOAD_MAX_FILE_SIZE_MB} MB.')
+                    raise UploadError(f"Die Datei überschreitet die erlaubten {settings.UPLOAD_MAX_FILE_SIZE_MB} MB.")
                 output.write(chunk)
             output.flush()
             os.fsync(output.fileno())
@@ -210,7 +225,7 @@ def save_upload(primary_key: dict[str, str], name: str, stream, max_bytes: int)
                     existing = _checked(root, *folder.relative_to(root).parts, name)
                     if existing.exists():
                         if not existing.is_file():
-                            raise UploadError('Unter diesem Dateinamen besteht bereits ein Ordner.')
+                            raise UploadError("Unter diesem Dateinamen besteht bereits ein Ordner.")
                         archive = _archive_name(root, existing)
                         existing.rename(archive)
                         previous.append((existing, archive))
@@ -220,7 +235,10 @@ def save_upload(primary_key: dict[str, str], name: str, stream, max_bytes: int)
                     try:
                         archive.rename(existing)
                     except OSError:
-                        logger.exception('Archivierte Datei konnte nicht zurückbenannt werden', extra={'event': 'file_archive_rollback_failed'})
+                        logger.exception(
+                            "Archivierte Datei konnte nicht zurückbenannt werden",
+                            extra={"event": "file_archive_rollback_failed"},
+                        )
                 raise
         return target.relative_to(root).as_posix()
     finally:

+ 3 - 1
app/forderung_dashboard.py

@@ -65,8 +65,10 @@ def follow_up_bands(today):
 
 def _url(view, key, segment=..., only_invoices=False, stack="abteilung"):
     filters = {}
+
     def select(name, value):
-        filters[name] = encode_filter_values([EMPTY_FILTER_VALUE if value is None or value == '' else value])
+        filters[name] = encode_filter_values([EMPTY_FILTER_VALUE if value is None or value == "" else value])
+
     if view == "Standort":
         select("Mandant", key["client"])
         select("Filiale", key["site"])

+ 28 - 17
app/forderung_data.py

@@ -1,47 +1,58 @@
 """One authorized projection for list results and dashboard aggregates."""
+
 from sqlalchemy import text
+
 from .jinja_templates import templates
-from .query_filter import EMPTY_FILTER_VALUE, EMPTY_FILTER_LABEL
+from .query_filter import EMPTY_FILTER_LABEL, EMPTY_FILTER_VALUE
 
 
 def read_filter_suggestions(db, username, field, clauses, values, search="", *, selected=None):
     # Reuse the authorized projection, including local changes to its WHERE clause.
-    query = templates.env.get_template('forderungen/liste/queries/forderungen_liste.sql').render()
-    query = query.rsplit('ORDER BY', 1)[0]
+    query = templates.env.get_template("forderungen/liste/queries/forderungen_liste.sql").render()
+    query = query.rsplit("ORDER BY", 1)[0]
     raw_key, raw_label, sort = (f"F.[{column}]" for column in (field.key_column, field.value_column, field.sort_column))
     key = f"COALESCE(NULLIF(CAST({raw_key} AS nvarchar(4000)), ''), '{EMPTY_FILTER_VALUE}')"
-    label = (f"CASE WHEN NULLIF(CAST({raw_key} AS nvarchar(4000)), '') IS NULL THEN '{EMPTY_FILTER_LABEL}' "
-             f"ELSE CAST({raw_label} AS nvarchar(4000)) END")
-    limit = 'TOP 51 ' if selected is None else ''
-    query = query.replace('SELECT F.*', f'SELECT {limit}{key} AS value, MIN({label}) AS label, MIN({sort}) AS sort_value', 1)
+    label = (
+        f"CASE WHEN NULLIF(CAST({raw_key} AS nvarchar(4000)), '') IS NULL THEN '{EMPTY_FILTER_LABEL}' "
+        f"ELSE CAST({raw_label} AS nvarchar(4000)) END"
+    )
+    limit = "TOP 51 " if selected is None else ""
+    query = query.replace(
+        "SELECT F.*", f"SELECT {limit}{key} AS value, MIN({label}) AS label, MIN({sort}) AS sort_value", 1
+    )
     predicates = list(clauses)
     values = dict(values, auth_username=username)
     if selected is not None:
         placeholders = []
         for index, value in enumerate(selected):
-            parameter = f'selected_{index}'
-            placeholders.append(':' + parameter)
+            parameter = f"selected_{index}"
+            placeholders.append(":" + parameter)
             values[parameter] = value
         if not placeholders:
             return []
         predicates.append(f"{key} IN ({', '.join(placeholders)})")
     if search:
-        predicates.append(f"(CAST({key} AS nvarchar(max)) LIKE :suggest_search OR CAST({label} AS nvarchar(max)) LIKE :suggest_search)")
-        values['suggest_search'] = f'%{search}%'
+        predicates.append(
+            f"(CAST({key} AS nvarchar(max)) LIKE :suggest_search OR CAST({label} AS nvarchar(max)) LIKE :suggest_search)"
+        )
+        values["suggest_search"] = f"%{search}%"
     if predicates:
-        query = query.replace('AND 1 = 1', 'AND 1 = 1 AND ' + ' AND '.join(predicates))
-    query += f' GROUP BY {key} ORDER BY MIN({sort}), {key}'
+        query = query.replace("AND 1 = 1", "AND 1 = 1 AND " + " AND ".join(predicates))
+    query += f" GROUP BY {key} ORDER BY MIN({sort}), {key}"
     rows = db.execute(text(query), values).fetchall()
     return [{"value": str(row[0]), "label": str(row[1] if row[1] is not None else row[0])} for row in rows]
 
 
 def read_invoices(db, username, clauses=(), values=None):
-    query = templates.env.get_template('forderungen/liste/queries/forderungen_liste.sql').render()
+    query = templates.env.get_template("forderungen/liste/queries/forderungen_liste.sql").render()
     if clauses:
-        query = query.replace('AND 1 = 1', 'AND 1 = 1 AND ' + ' AND '.join(clauses))
-    result = db.execute(text(query).execution_options(query_name='forderungen/liste/queries/forderungen_liste.sql'), dict(values or {}, auth_username=username))
+        query = query.replace("AND 1 = 1", "AND 1 = 1 AND " + " AND ".join(clauses))
+    result = db.execute(
+        text(query).execution_options(query_name="forderungen/liste/queries/forderungen_liste.sql"),
+        dict(values or {}, auth_username=username),
+    )
     columns = list(result.keys())
-    tenant, number = columns.index('Client_DB'), columns.index('Beleg_Nr')
+    tenant, number = columns.index("Client_DB"), columns.index("Beleg_Nr")
     seen, rows = set(), []
     for row in result.fetchall():
         identity = (str(row[tenant]), str(row[number]))

+ 11 - 5
app/forderung_history.py

@@ -12,10 +12,14 @@ HistoryScope = Literal["invoice", "open", "vehicle", "debtor"]
 
 def history_context(db: Session, pkey: dict[str, str], section: str, scope: HistoryScope = "invoice") -> dict:
     values = {"client_db": pkey["Client_DB"], "beleg_nr": pkey["Beleg_Nr"], "scope": scope}
-    invoice = db.execute(
-        text("SELECT TOP 1 * FROM dbo.Forderungen_Kopf WHERE Client_DB = :client_db AND Beleg_Nr = :beleg_nr"),
-        values,
-    ).mappings().first()
+    invoice = (
+        db.execute(
+            text("SELECT TOP 1 * FROM dbo.Forderungen_Kopf WHERE Client_DB = :client_db AND Beleg_Nr = :beleg_nr"),
+            values,
+        )
+        .mappings()
+        .first()
+    )
     if invoice is None:
         raise HTTPException(status_code=404, detail="Forderung nicht gefunden")
     return section_context(db, pkey, invoice, section, scope)
@@ -29,7 +33,9 @@ def section_context(db: Session, pkey: dict[str, str], invoice, section: str, sc
     rows = []
     if scope != "vehicle" or not vehicle_missing:
         query = templates.env.get_template(f"forderungen/details/queries/forderungen_{section}.sql").render(scope=scope)
-        rows = db.execute(text(query).execution_options(query_name=f'forderungen/details/queries/forderungen_{section}.sql'), values).fetchall()
+        rows = db.execute(
+            text(query).execution_options(query_name=f"forderungen/details/queries/forderungen_{section}.sql"), values
+        ).fetchall()
     return {
         "forderungen_details": [invoice],
         f"forderungen_{section}": rows,

+ 54 - 23
app/http_logging.py

@@ -1,4 +1,5 @@
 """ASGI response logging outside authentication, without consuming request bodies."""
+
 import logging
 import time
 from uuid import uuid4
@@ -15,54 +16,84 @@ class RequestLoggingMiddleware:
         self.app = app
 
     async def __call__(self, scope, receive, send):
-        if scope['type'] != 'http':
+        if scope["type"] != "http":
             return await self.app(scope, receive, send)
         request_id = uuid4().hex
-        scope.setdefault('state', {})['request_id'] = request_id
-        token = request_context.set({'request_id': request_id, 'scope': scope})
+        scope.setdefault("state", {})["request_id"] = request_id
+        token = request_context.set({"request_id": request_id, "scope": scope})
         started = time.perf_counter()
         status, completed, failed = None, False, False
 
         def finish(outcome, level=None):
             nonlocal completed
-            if completed: return
+            if completed:
+                return
             completed = True
-            level = level or (logging.ERROR if status and status >= 500 else logging.WARNING if status and status >= 400 else
-                              logging.DEBUG if scope['path'] == '/health' or scope['path'].startswith('/static/') else logging.INFO)
-            logger.log(level, 'HTTP-Anfrage abgeschlossen', extra={
-                'event': 'http_completed', 'status': status,
-                'duration_ms': round((time.perf_counter()-started)*1000, 2), 'outcome': outcome,
-            })
+            level = level or (
+                logging.ERROR
+                if status and status >= 500
+                else (
+                    logging.WARNING
+                    if status and status >= 400
+                    else (
+                        logging.DEBUG
+                        if scope["path"] == "/health" or scope["path"].startswith("/static/")
+                        else logging.INFO
+                    )
+                )
+            )
+            logger.log(
+                level,
+                "HTTP-Anfrage abgeschlossen",
+                extra={
+                    "event": "http_completed",
+                    "status": status,
+                    "duration_ms": round((time.perf_counter() - started) * 1000, 2),
+                    "outcome": outcome,
+                },
+            )
 
         async def logged_send(message):
             nonlocal status
-            if message['type'] == 'http.response.start':
-                message = dict(message, headers=[(key, value) for key, value in message.get('headers', [])
-                                                if key.lower() != b'x-request-id'] + [(b'x-request-id', request_id.encode('ascii'))])
+            if message["type"] == "http.response.start":
+                message = dict(
+                    message,
+                    headers=[
+                        (key, value) for key, value in message.get("headers", []) if key.lower() != b"x-request-id"
+                    ]
+                    + [(b"x-request-id", request_id.encode("ascii"))],
+                )
             await send(message)
-            if message['type'] == 'http.response.start': status = message['status']
-            if message['type'] == 'http.response.body' and not message.get('more_body', False):
-                finish('completed')
+            if message["type"] == "http.response.start":
+                status = message["status"]
+            if message["type"] == "http.response.body" and not message.get("more_body", False):
+                finish("completed")
 
         try:
             await self.app(scope, receive, logged_send)
         except Exception:
             failed = True
             streaming = status is not None
-            logger.exception('HTTP-Verarbeitung fehlgeschlagen', extra={'event': 'response_stream_error' if streaming else 'unhandled_exception'})
-            if status is None: status = 500
+            logger.exception(
+                "HTTP-Verarbeitung fehlgeschlagen",
+                extra={"event": "response_stream_error" if streaming else "unhandled_exception"},
+            )
+            if status is None:
+                status = 500
             if not completed:
-                finish('stream_failed' if streaming else 'failed', logging.ERROR)
+                finish("stream_failed" if streaming else "failed", logging.ERROR)
             raise
         finally:
-            if not completed and not failed: finish('aborted', logging.WARNING)
+            if not completed and not failed:
+                finish("aborted", logging.WARNING)
             request_context.reset(token)
 
 
 async def unhandled_error_response(request, exc):
-    request_id = getattr(request.state, 'request_id', None)
-    return PlainTextResponse('Internal Server Error', status_code=500,
-                             headers={'X-Request-ID': request_id} if request_id else {})
+    request_id = getattr(request.state, "request_id", None)
+    return PlainTextResponse(
+        "Internal Server Error", status_code=500, headers={"X-Request-ID": request_id} if request_id else {}
+    )
 
 
 def install_request_logging(app):

+ 8 - 7
app/imap.py

@@ -1,15 +1,16 @@
+import logging
 import os
 import re
-import logging
 from dataclasses import dataclass
 from pathlib import Path
 
-from .db import SessionLocal
-from .logging_setup import run_cli
 from dotenv import load_dotenv
 from imap_tools import MailBox
 from sqlalchemy import text
 
+from .db import SessionLocal
+from .logging_setup import run_cli
+
 load_dotenv()
 logger = logging.getLogger(__name__)
 
@@ -42,20 +43,20 @@ def main():
         for msg in messages:
             match = re.findall(r"([WV]\w+\d+)", msg.subject)
             if not match:
-                logger.debug('E-Mail ohne Belegzuordnung übersprungen')
+                logger.debug("E-Mail ohne Belegzuordnung übersprungen")
                 continue
-            logger.debug('Belegkennung in E-Mail erkannt')
+            logger.debug("Belegkennung in E-Mail erkannt")
             invoice_no = match[0]
             if invoice_no not in lookup:
                 continue
             client_db = lookup[invoice_no]
-            logger.debug('E-Mail einem Beleg zugeordnet')
+            logger.debug("E-Mail einem Beleg zugeordnet")
             attachments = {att.filename: att.payload for att in msg.attachments if att.size > 0}
             for file, content in attachments.items():
                 filename: Path = fileserver / client_db / invoice_no / file
                 filename.parent.mkdir(exist_ok=True, parents=True)
                 filename.write_bytes(content)
-                logger.info('Mailanhang abgelegt', extra={'event': 'mail_attachment_saved', 'size_bytes': len(content)})
+                logger.info("Mailanhang abgelegt", extra={"event": "mail_attachment_saved", "size_bytes": len(content)})
 
 
 if __name__ == "__main__":

+ 8 - 1
app/ldap.py

@@ -4,7 +4,14 @@ import ssl
 from dataclasses import dataclass
 from urllib.parse import urlparse
 
-from ldap3 import AUTO_BIND_NO_TLS, AUTO_BIND_TLS_BEFORE_BIND, SUBTREE, Connection, Server, Tls
+from ldap3 import (
+    AUTO_BIND_NO_TLS,
+    AUTO_BIND_TLS_BEFORE_BIND,
+    SUBTREE,
+    Connection,
+    Server,
+    Tls,
+)
 from ldap3.core.exceptions import LDAPException
 from ldap3.utils.conv import escape_filter_chars
 

+ 269 - 124
app/logging_setup.py

@@ -1,35 +1,52 @@
 """Safe structured logging shared by HTTP, database and CLI entrypoints."""
-from contextvars import ContextVar
-from datetime import datetime, timedelta, timezone
+
 import hashlib
 import json
 import logging
 import os
-from pathlib import Path
 import re
 import sys
 import threading
 import time
 import traceback
+from contextvars import ContextVar
+from datetime import datetime, timedelta, timezone
+from pathlib import Path
 from uuid import uuid4
 from zoneinfo import ZoneInfo
 
 from concurrent_log_handler import ConcurrentTimedRotatingFileHandler
 
-request_context = ContextVar('request_logging_context', default=None)
-db_operation = ContextVar('database_logging_operation', default=None)
-db_query = ContextVar('database_logging_query', default=None)
-BERLIN = ZoneInfo('Europe/Berlin')
+request_context = ContextVar("request_logging_context", default=None)
+db_operation = ContextVar("database_logging_operation", default=None)
+db_query = ContextVar("database_logging_query", default=None)
+BERLIN = ZoneInfo("Europe/Berlin")
 PROJECT_ROOT = Path(__file__).resolve().parent.parent
 _lock = threading.RLock()
 _configuration = None
-_SAFE_FIELDS = ('event', 'operation', 'query_name', 'query_fingerprint', 'sql', 'sql_truncated',
-                'sqlstate', 'db_code', 'db_message', 'db_diagnostics', 'status', 'duration_ms', 'outcome', 'size_bytes', 'count',
-                'reason', 'error_type')
+_SAFE_FIELDS = (
+    "event",
+    "operation",
+    "query_name",
+    "query_fingerprint",
+    "sql",
+    "sql_truncated",
+    "sqlstate",
+    "db_code",
+    "db_message",
+    "db_diagnostics",
+    "status",
+    "duration_ms",
+    "outcome",
+    "size_bytes",
+    "count",
+    "reason",
+    "error_type",
+)
 
 
 def clean(value):
-    return str(value).replace('\r', '\\r').replace('\n', '\\n').replace('\x00', '\\0')[:8192]
+    return str(value).replace("\r", "\\r").replace("\n", "\\n").replace("\x00", "\\0")[:8192]
 
 
 def sanitize_sql(statement):
@@ -37,49 +54,75 @@ def sanitize_sql(statement):
     source, output, i = str(statement), [], 0
     while i < len(source):
         char = source[i]
-        if source.startswith('--', i):
-            end = source.find('\n', i + 2); i = len(source) if end < 0 else end
-            output.append(' ')
-        elif source.startswith('/*', i):
-            depth = 1; i += 2
+        if source.startswith("--", i):
+            end = source.find("\n", i + 2)
+            i = len(source) if end < 0 else end
+            output.append(" ")
+        elif source.startswith("/*", i):
+            depth = 1
+            i += 2
             while i < len(source) and depth:
-                if source.startswith('/*', i): depth += 1; i += 2
-                elif source.startswith('*/', i): depth -= 1; i += 2
-                else: i += 1
-            output.append(' ')
-        elif char == "'" or (char in 'nN' and source[i:i+2].endswith("'") and (i == 0 or not source[i-1].isalnum())):
-            if char != "'": i += 1
+                if source.startswith("/*", i):
+                    depth += 1
+                    i += 2
+                elif source.startswith("*/", i):
+                    depth -= 1
+                    i += 2
+                else:
+                    i += 1
+            output.append(" ")
+        elif char == "'" or (
+            char in "nN" and source[i : i + 2].endswith("'") and (i == 0 or not source[i - 1].isalnum())
+        ):
+            if char != "'":
+                i += 1
             i += 1
             while i < len(source):
-                if source[i:i+2] == "''": i += 2
-                elif source[i] == "'": i += 1; break
-                else: i += 1
-            output.append('?')
+                if source[i : i + 2] == "''":
+                    i += 2
+                elif source[i] == "'":
+                    i += 1
+                    break
+                else:
+                    i += 1
+            output.append("?")
         elif char in '["':
-            end_char = ']' if char == '[' else '"'
-            start = i; i += 1
+            end_char = "]" if char == "[" else '"'
+            start = i
+            i += 1
             while i < len(source):
-                if source[i:i+2] == end_char * 2: i += 2
-                elif source[i] == end_char: i += 1; break
-                else: i += 1
-            output.append(source[start:i] if char == '[' else '?')
-        elif (char.isdecimal() or char == '.' and i+1 < len(source) and source[i+1].isdecimal()) and (i == 0 or not (source[i-1].isalnum() or source[i-1] in '_:@$')):
-            match = re.match(r'(?:0[xX][0-9a-fA-F]+|(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?)', source[i:])
-            output.append('?'); i += len(match[0])
+                if source[i : i + 2] == end_char * 2:
+                    i += 2
+                elif source[i] == end_char:
+                    i += 1
+                    break
+                else:
+                    i += 1
+            output.append(source[start:i] if char == "[" else "?")
+        elif (char.isdecimal() or char == "." and i + 1 < len(source) and source[i + 1].isdecimal()) and (
+            i == 0 or not (source[i - 1].isalnum() or source[i - 1] in "_:@$")
+        ):
+            match = re.match(r"(?:0[xX][0-9a-fA-F]+|(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?)", source[i:])
+            output.append("?")
+            i += len(match[0])
         else:
-            output.append(char); i += 1
-    return ' '.join(''.join(output).split())
+            output.append(char)
+            i += 1
+    return " ".join("".join(output).split())
 
 
 def exception_roots(error):
     seen = set()
+
     def visit(exc):
-        if id(exc) in seen: return []
+        if id(exc) in seen:
+            return []
         seen.add(id(exc))
         if isinstance(exc, BaseExceptionGroup):
             return [root for child in exc.exceptions for root in visit(child)]
-        original = getattr(exc, 'orig', None) or exc.__cause__
+        original = getattr(exc, "orig", None) or exc.__cause__
         return visit(original) if isinstance(original, BaseException) else [exc]
+
     return visit(error)
 
 
@@ -90,51 +133,86 @@ def frames(tb=None):
 
 class ContextFilter(logging.Filter):
     def filter(self, record):
-        if record.name.startswith('uvicorn.') and record.levelno < logging.INFO: return False
-        if not record.name.startswith(('app.', 'uvicorn.', '__main__', 'filter_export')) and record.levelno < logging.WARNING: return False
-        if not hasattr(record, 'event_id'): record.event_id = uuid4().hex
+        if record.name.startswith("uvicorn.") and record.levelno < logging.INFO:
+            return False
+        if (
+            not record.name.startswith(("app.", "uvicorn.", "__main__", "filter_export"))
+            and record.levelno < logging.WARNING
+        ):
+            return False
+        if not hasattr(record, "event_id"):
+            record.event_id = uuid4().hex
         context = request_context.get()
-        request_id = context['request_id'] if context else None
+        request_id = context["request_id"] if context else None
         if record.exc_info and record.exc_info[1]:
             roots = exception_roots(record.exc_info[1])
-            fresh = [exc for exc in roots if getattr(exc, '_gc_log_event', None) in (None, record.event_id)
-                     or request_id is not None and getattr(exc, '_gc_log_request', None) != request_id]
-            if not fresh: return False
+            fresh = [
+                exc
+                for exc in roots
+                if getattr(exc, "_gc_log_event", None) in (None, record.event_id)
+                or request_id is not None
+                and getattr(exc, "_gc_log_request", None) != request_id
+            ]
+            if not fresh:
+                return False
             for exc in fresh:
                 exc._gc_log_event = record.event_id
                 exc._gc_log_request = request_id
-            record.safe_exceptions = [dict(type=type(exc).__module__ + '.' + type(exc).__name__,
-                                           frames=getattr(exc, '_gc_log_frames', None) or frames(exc.__traceback__)) for exc in fresh]
+            record.safe_exceptions = [
+                dict(
+                    type=type(exc).__module__ + "." + type(exc).__name__,
+                    frames=getattr(exc, "_gc_log_frames", None) or frames(exc.__traceback__),
+                )
+                for exc in fresh
+            ]
         record.request_fields = {}
         if context:
-            scope = context['scope']
-            principal = scope.get('state', {}).get('principal')
-            record.request_fields = dict(request_id=context['request_id'], user=getattr(principal, 'username', None),
-                                         method=scope['method'], path=scope.get('path', ''))
+            scope = context["scope"]
+            principal = scope.get("state", {}).get("principal")
+            record.request_fields = dict(
+                request_id=context["request_id"],
+                user=getattr(principal, "username", None),
+                method=scope["method"],
+                path=scope.get("path", ""),
+            )
         return True
 
 
 def record_data(record):
-    data = dict(timestamp=datetime.fromtimestamp(record.created, BERLIN).isoformat(timespec='milliseconds'),
-                level=record.levelname, logger=record.name, event_id=record.event_id,
-                event=getattr(record, 'event', 'application'),
-                message=clean(record.getMessage()) if record.name.startswith(('app.', 'uvicorn.', '__main__', 'filter_export')) else 'External logger event')
-    data.update({key: clean(value) if isinstance(value, str) else value for key, value in record.request_fields.items()})
+    data = dict(
+        timestamp=datetime.fromtimestamp(record.created, BERLIN).isoformat(timespec="milliseconds"),
+        level=record.levelname,
+        logger=record.name,
+        event_id=record.event_id,
+        event=getattr(record, "event", "application"),
+        message=(
+            clean(record.getMessage())
+            if record.name.startswith(("app.", "uvicorn.", "__main__", "filter_export"))
+            else "External logger event"
+        ),
+    )
+    data.update(
+        {key: clean(value) if isinstance(value, str) else value for key, value in record.request_fields.items()}
+    )
     data.update({key: getattr(record, key) for key in _SAFE_FIELDS if hasattr(record, key)})
-    if hasattr(record, 'safe_exceptions'): data['exceptions'] = record.safe_exceptions
+    if hasattr(record, "safe_exceptions"):
+        data["exceptions"] = record.safe_exceptions
     return data
 
 
 class JsonFormatter(logging.Formatter):
     def format(self, record):
-        return json.dumps(record_data(record), ensure_ascii=True, separators=(',', ':'), default=lambda _: '[unsupported]')
+        return json.dumps(
+            record_data(record), ensure_ascii=True, separators=(",", ":"), default=lambda _: "[unsupported]"
+        )
 
 
 class ConsoleFormatter(logging.Formatter):
     def format(self, record):
         data = record_data(record)
-        suffix = {key: value for key, value in data.items() if key not in ('timestamp', 'level', 'logger', 'message')}
-        return f"{data['timestamp']} {data['level']} {data['logger']}: {data['message']} {json.dumps(suffix, ensure_ascii=True, separators=(',', ':'))}"
+        suffix = {key: value for key, value in data.items() if key not in ("timestamp", "level", "logger", "message")}
+        suffix_text = json.dumps(suffix, ensure_ascii=True, separators=(",", ":")) if suffix else ""
+        return f"{data['timestamp']} {data['level']} {data['logger']}: {data['message']} {suffix_text}".rstrip()
 
 
 class SafeConsoleHandler(logging.StreamHandler):
@@ -149,39 +227,52 @@ class SafeConsoleHandler(logging.StreamHandler):
 class ApplicationFileHandler(ConcurrentTimedRotatingFileHandler):
     def __init__(self, filename, retention_days=30):
         self.retention_days = retention_days
-        self.last_fallback = float('-inf')
-        super().__init__(filename=filename, when='midnight', interval=1, utc=True,
-                         backupCount=retention_days, encoding='utf-8', maxBytes=0)
+        self.last_fallback = float("-inf")
+        super().__init__(
+            filename=filename,
+            when="midnight",
+            interval=1,
+            utc=True,
+            backupCount=retention_days,
+            encoding="utf-8",
+            maxBytes=0,
+        )
         self.cleanup_archives()
 
     def cleanup_archives(self):
         cutoff = datetime.now(timezone.utc).date() - timedelta(days=self.retention_days)
         path = Path(self.baseFilename)
-        for candidate in path.parent.glob(path.name + '.*'):
-            suffix = candidate.name[len(path.name)+1:]
-            if not re.fullmatch(r'\d{4}-\d{2}-\d{2}', suffix): continue
+        for candidate in path.parent.glob(path.name + ".*"):
+            suffix = candidate.name[len(path.name) + 1 :]
+            if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", suffix):
+                continue
             try:
-                if datetime.strptime(suffix, '%Y-%m-%d').date() < cutoff: candidate.unlink(missing_ok=True)
+                if datetime.strptime(suffix, "%Y-%m-%d").date() < cutoff:
+                    candidate.unlink(missing_ok=True)
             except (OSError, ValueError):
-                self.fallback(None, 'log_cleanup_failed')
+                self.fallback(None, "log_cleanup_failed")
 
     def doRollover(self):
         try:
             super().doRollover()
         except Exception:
-            self.fallback(None, 'log_rotation_failed')
+            self.fallback(None, "log_rotation_failed")
             raise
         self.cleanup_archives()
 
-    def fallback(self, record, event='log_file_unavailable'):
+    def fallback(self, record, event="log_file_unavailable"):
         handler = SafeConsoleHandler(sys.stderr)
         handler.setFormatter(ConsoleFormatter())
         if time.monotonic() - self.last_fallback >= 60:
             self.last_fallback = time.monotonic()
-            notice = logging.LogRecord('app.logging', logging.ERROR, __file__, 0, 'Logdatei nicht verfügbar; Konsolenfallback aktiv', (), None)
+            notice = logging.LogRecord(
+                "app.logging", logging.ERROR, __file__, 0, "Logdatei nicht verfügbar; Konsolenfallback aktiv", (), None
+            )
             notice.event = event
-            ContextFilter().filter(notice); handler.handle(notice)
-        if record is not None and not getattr(record, '_gc_console_emitted', False): handler.handle(record)
+            ContextFilter().filter(notice)
+            handler.handle(notice)
+        if record is not None and not getattr(record, "_gc_console_emitted", False):
+            handler.handle(record)
 
     def handleError(self, record):
         self.fallback(record)
@@ -192,58 +283,90 @@ def configure_logging(config=None, *, console_stream=None):
     global _configuration
     if config is None:
         from .config import settings
+
         config = settings
     directory = Path(config.LOG_DIR)
-    if not directory.is_absolute(): directory = PROJECT_ROOT / directory
-    signature = (os.getpid(), str(directory), config.LOG_LEVEL, config.LOG_TO_FILE, config.LOG_TO_CONSOLE,
-                 config.LOG_RETENTION_DAYS, config.LOG_SQL_STATEMENT, id(console_stream))
+    if not directory.is_absolute():
+        directory = PROJECT_ROOT / directory
+    signature = (
+        os.getpid(),
+        str(directory),
+        config.LOG_LEVEL,
+        config.LOG_TO_FILE,
+        config.LOG_TO_CONSOLE,
+        config.LOG_RETENTION_DAYS,
+        config.LOG_SQL_STATEMENT,
+        id(console_stream),
+    )
     with _lock:
         root = logging.getLogger()
-        if _configuration == signature and any(getattr(handler, '_gc_handler', False) for handler in root.handlers): return
+        if _configuration == signature and any(getattr(handler, "_gc_handler", False) for handler in root.handlers):
+            return
         for handler in list(root.handlers):
-            if getattr(handler, '_gc_handler', False): root.removeHandler(handler); handler.close()
+            if getattr(handler, "_gc_handler", False):
+                root.removeHandler(handler)
+                handler.close()
         handlers, file_failure = [], False
         if config.LOG_TO_CONSOLE:
-            console = SafeConsoleHandler(console_stream or sys.stderr); console.setFormatter(ConsoleFormatter()); handlers.append(console)
+            console = SafeConsoleHandler(console_stream or sys.stderr)
+            console.setFormatter(ConsoleFormatter())
+            handlers.append(console)
         if config.LOG_TO_FILE:
             try:
                 directory.mkdir(parents=True, exist_ok=True)
-                file_handler = ApplicationFileHandler(str(directory / 'application.log'), config.LOG_RETENTION_DAYS)
-                file_handler.setFormatter(JsonFormatter()); handlers.append(file_handler)
+                file_handler = ApplicationFileHandler(str(directory / "application.log"), config.LOG_RETENTION_DAYS)
+                file_handler.setFormatter(JsonFormatter())
+                handlers.append(file_handler)
             except Exception:
                 file_failure = True
                 if not handlers:
-                    console = SafeConsoleHandler(console_stream or sys.stderr); console.setFormatter(ConsoleFormatter()); handlers.append(console)
-        if not handlers: handlers.append(logging.NullHandler())
+                    console = SafeConsoleHandler(console_stream or sys.stderr)
+                    console.setFormatter(ConsoleFormatter())
+                    handlers.append(console)
+        if not handlers:
+            handlers.append(logging.NullHandler())
         for handler in handlers:
-            handler._gc_handler = True; handler.addFilter(ContextFilter()); root.addHandler(handler)
+            handler._gc_handler = True
+            handler.addFilter(ContextFilter())
+            root.addHandler(handler)
         root.setLevel(config.LOG_LEVEL)
-        for name in ('uvicorn', 'uvicorn.error', 'uvicorn.access'):
-            logger = logging.getLogger(name); logger.handlers.clear(); logger.propagate = True
+        for name in ("uvicorn", "uvicorn.error", "uvicorn.access"):
+            logger = logging.getLogger(name)
+            logger.handlers.clear()
+            logger.propagate = True
             logger.setLevel(logging.INFO)
-        logging.getLogger('uvicorn.access').disabled = True
-        for name in ('sqlalchemy', 'urllib3', 'httpx', 'httpcore', 'ldap3', 'docuware'):
+        logging.getLogger("uvicorn.access").disabled = True
+        for name in ("sqlalchemy", "urllib3", "httpx", "httpcore", "ldap3", "docuware"):
             logging.getLogger(name).setLevel(logging.WARNING)
         _configuration = signature
-        if file_failure: logging.getLogger('app.logging').error('Logverzeichnis nicht verfügbar; Konsolenfallback aktiv', extra={'event': 'log_file_unavailable'})
+        if file_failure:
+            logging.getLogger("app.logging").error(
+                "Logverzeichnis nicht verfügbar; Konsolenfallback aktiv", extra={"event": "log_file_unavailable"}
+            )
 
 
 def database_diagnostics(error, statement=None, parameters=None):
     """Read ODBC diagnostics from the driver, never from SQLAlchemy's formatted exception."""
-    if type(error).__module__ != 'pyodbc':
+    if type(error).__module__ != "pyodbc":
         return []
-    state = error.args[0] if error.args and isinstance(error.args[0], str) and re.fullmatch(r'[A-Z0-9]{5}', error.args[0]) else None
+    state = (
+        error.args[0]
+        if error.args and isinstance(error.args[0], str) and re.fullmatch(r"[A-Z0-9]{5}", error.args[0])
+        else None
+    )
     secrets = set()
 
     def collect(value):
         if isinstance(value, str) and value:
             secrets.add(value)
         elif isinstance(value, bytes):
-            collect(value.decode('utf-8', errors='replace'))
+            collect(value.decode("utf-8", errors="replace"))
         elif isinstance(value, dict):
-            for child in value.values(): collect(child)
+            for child in value.values():
+                collect(child)
         elif isinstance(value, (tuple, list)):
-            for child in value: collect(child)
+            for child in value:
+                collect(child)
 
     collect(parameters)
     if statement is not None:
@@ -252,24 +375,31 @@ def database_diagnostics(error, statement=None, parameters=None):
 
     diagnostics = []
     for raw in error.args[1:] if state else error.args:
-        if not isinstance(raw, str): continue
+        if not isinstance(raw, str):
+            continue
         # pyodbc concatenates SQLGetDiagRec records, appending native codes and the ODBC function.
-        parts = list(re.finditer(r'(.*?)\s+\((-?\d+)\)(?:\s+\(SQL\w+\))?(?=;\s*|$)', raw, re.DOTALL))
-        records = [(part[1].lstrip('; \t\r\n'), int(part[2])) for part in parts] or [(raw.strip(), None)]
+        parts = list(re.finditer(r"(.*?)\s+\((-?\d+)\)(?:\s+\(SQL\w+\))?(?=;\s*|$)", raw, re.DOTALL))
+        records = [(part[1].lstrip("; \t\r\n"), int(part[2])) for part in parts] or [(raw.strip(), None)]
         for description, code in records:
-            own_state = re.match(r'\[([A-Z0-9]{5})\]', description)
-            description = re.sub(r'^(?:\[[^\]\r\n]*\]\s*)+', '', description)
+            own_state = re.match(r"\[([A-Z0-9]{5})\]", description)
+            description = re.sub(r"^(?:\[[^\]\r\n]*\]\s*)+", "", description)
             # Error 207 identifies schema metadata, not a parameter value. Only exempt
             # the canonical English/German message, never arbitrary text with code 207.
-            column_message = description if code == 207 and re.fullmatch(
-                r'''(?:Invalid column name|Ungültiger Spaltenname)\s+(?:'(?:''|[^'])*'|"(?:""|[^"])*"|„[^“]*“|“[^”]*”|‚[^‘]*‘|‘[^’]*’|«[^»]*»)\s*\.?''',
-                description.strip(), re.IGNORECASE,
-            ) else None
+            column_message = (
+                description
+                if code == 207
+                and re.fullmatch(
+                    r"""(?:Invalid column name|Ungültiger Spaltenname)\s+(?:'(?:''|[^'])*'|"(?:""|[^"])*"|„[^“]*“|“[^”]*”|‚[^‘]*‘|‘[^’]*’|«[^»]*»)\s*\.?""",
+                    description.strip(),
+                    re.IGNORECASE,
+                )
+                else None
+            )
             # Values may also be echoed without quotes (notably duplicate key tuples).
             for secret in sorted(secrets, key=len, reverse=True):
-                description = description.replace(secret, '?').replace(secret.replace("'", "''"), '?')
-            description = re.sub(r'\([^)]*(?:\)|$)', '(?)', description)
-            description = re.sub(r'(?i)\b(password|pwd|token|secret)\s*[:=]\s*[^;\s]+', r'\1=?', description)
+                description = description.replace(secret, "?").replace(secret.replace("'", "''"), "?")
+            description = re.sub(r"\([^)]*(?:\)|$)", "(?)", description)
+            description = re.sub(r"(?i)\b(password|pwd|token|secret)\s*[:=]\s*[^;\s]+", r"\1=?", description)
             description = clean(column_message if column_message is not None else sanitize_sql(description))[:4096]
             diagnostics.append(dict(sqlstate=own_state[1] if own_state else state, code=code, message=description))
     return diagnostics[:20]
@@ -277,39 +407,54 @@ def database_diagnostics(error, statement=None, parameters=None):
 
 def log_query_error(error, statement=None, query_name=None, operation=None, parameters=None):
     from .config import settings
+
     try:
-        sql = sanitize_sql(statement) if statement is not None else ''
+        sql = sanitize_sql(statement) if statement is not None else ""
     except Exception:
-        sql = '[statement unavailable]'
-    metadata = dict(event='database_error', operation=operation or db_operation.get() or 'execute',
-                    query_name=query_name or query_origin(), query_fingerprint=hashlib.sha256(sql.encode()).hexdigest())
+        sql = "[statement unavailable]"
+    metadata = dict(
+        event="database_error",
+        operation=operation or db_operation.get() or "execute",
+        query_name=query_name or query_origin(),
+        query_fingerprint=hashlib.sha256(sql.encode()).hexdigest(),
+    )
     if settings.LOG_SQL_STATEMENT and sql:
         metadata.update(sql=sql[:8192], sql_truncated=len(sql) > 8192)
     roots = exception_roots(error)
     diagnostics = []
     for root in roots:
-        if not getattr(root, '_gc_log_frames', None): root._gc_log_frames = frames()
+        if not getattr(root, "_gc_log_frames", None):
+            root._gc_log_frames = frames()
         first = root.args[0] if root.args else None
-        if isinstance(first, str) and re.fullmatch(r'[A-Z0-9]{5}', first): metadata['sqlstate'] = first
-        code = getattr(root, 'sqlite_errorcode', None)
-        if isinstance(code, int): metadata['db_code'] = code
+        if isinstance(first, str) and re.fullmatch(r"[A-Z0-9]{5}", first):
+            metadata["sqlstate"] = first
+        code = getattr(root, "sqlite_errorcode", None)
+        if isinstance(code, int):
+            metadata["db_code"] = code
         try:
-            diagnostics.extend(database_diagnostics(root, statement, parameters if parameters is not None else getattr(error, 'params', None)))
+            diagnostics.extend(
+                database_diagnostics(
+                    root, statement, parameters if parameters is not None else getattr(error, "params", None)
+                )
+            )
         except Exception:
             pass  # A malformed driver diagnostic must not replace the original database error.
     if diagnostics:
-        metadata['db_diagnostics'] = diagnostics[:20]
-        metadata['db_message'] = diagnostics[0]['message']
-        if diagnostics[0]['code'] is not None: metadata['db_code'] = diagnostics[0]['code']
-    logging.getLogger('app.db').error('Datenbankoperation fehlgeschlagen', exc_info=(type(error), error, error.__traceback__), extra=metadata)
+        metadata["db_diagnostics"] = diagnostics[:20]
+        metadata["db_message"] = diagnostics[0]["message"]
+        if diagnostics[0]["code"] is not None:
+            metadata["db_code"] = diagnostics[0]["code"]
+    logging.getLogger("app.db").error(
+        "Datenbankoperation fehlgeschlagen", exc_info=(type(error), error, error.__traceback__), extra=metadata
+    )
 
 
 def query_origin():
     for item in reversed(traceback.extract_stack()[:-1]):
         path = Path(item.filename)
-        if PROJECT_ROOT / 'app' in path.parents and path.name not in ('logging_setup.py', 'db.py'):
-            return f'{path.name}:{item.name}'
-    return 'database'
+        if PROJECT_ROOT / "app" in path.parents and path.name not in ("logging_setup.py", "db.py"):
+            return f"{path.name}:{item.name}"
+    return "database"
 
 
 def run_cli(callback):
@@ -317,5 +462,5 @@ def run_cli(callback):
     try:
         callback()
     except Exception:
-        logging.getLogger('app.cli').exception('Hilfsprogramm fehlgeschlagen', extra={'event': 'cli_failed'})
+        logging.getLogger("app.cli").exception("Hilfsprogramm fehlgeschlagen", extra={"event": "cli_failed"})
         raise SystemExit(1) from None

+ 1 - 1
app/main.py

@@ -4,9 +4,9 @@ from fastapi import FastAPI
 from fastapi.staticfiles import StaticFiles
 
 from .auth import AuthenticationMiddleware
-from .routes import router
 from .http_logging import install_request_logging
 from .logging_setup import configure_logging
+from .routes import router
 
 
 @asynccontextmanager

+ 49 - 43
app/navigation.py

@@ -1,35 +1,36 @@
 """Canonical page URLs for return navigation and authentication recovery."""
+
 import re
 from urllib.parse import parse_qsl, quote, unquote, urlencode, urlsplit
 
-from .query_filter import import_filter_config
 from .dashboard_segment import parse_segment, segment_filters
+from .query_filter import import_filter_config
 
-LIST_PATH = '/app/forderungen/liste'
-DETAIL_PREFIX = '/app/forderungen/details/'
-DETAIL_PAGE = re.compile(r'^/app/forderungen/details/([^/]+)(?:/(?:kommentare|buchungen))?/?$')
-ASSIGNEE_MODAL = re.compile(r'^/app/forderungen/(liste|details)/([^/]+)/verursacher/?$')
-FOLLOW_UP_MODAL = re.compile(r'^/app/forderungen/liste/([^/]+)/wiedervorlage/?$')
+LIST_PATH = "/app/forderungen/liste"
+DETAIL_PREFIX = "/app/forderungen/details/"
+DETAIL_PAGE = re.compile(r"^/app/forderungen/details/([^/]+)(?:/(?:kommentare|buchungen))?/?$")
+ASSIGNEE_MODAL = re.compile(r"^/app/forderungen/(liste|details)/([^/]+)/verursacher/?$")
+FOLLOW_UP_MODAL = re.compile(r"^/app/forderungen/liste/([^/]+)/wiedervorlage/?$")
 
 
 def valid_focus(value: str | None) -> bool:
-    if not value or any(ord(c) < 32 or ord(c) == 127 or c in '/\\' for c in value):
+    if not value or any(ord(c) < 32 or ord(c) == 127 or c in "/\\" for c in value):
         return False
-    tenant, separator, number = value.partition('_')
+    tenant, separator, number = value.partition("_")
     return bool(separator and tenant and number and len(tenant) <= 20 and len(number) <= 100)
 
 
 def _local_parts(value):
-    if not isinstance(value, str) or not value.startswith('/') or value.startswith('//'):
+    if not isinstance(value, str) or not value.startswith("/") or value.startswith("//"):
         return None
-    if any(ord(c) < 32 or ord(c) == 127 or c == '\\' for c in value):
+    if any(ord(c) < 32 or ord(c) == 127 or c == "\\" for c in value):
         return None
     try:
         parts = urlsplit(value)
     except ValueError:
         return None
     decoded = unquote(parts.path)
-    if parts.scheme or parts.netloc or decoded.startswith('//') or '\\' in decoded:
+    if parts.scheme or parts.netloc or decoded.startswith("//") or "\\" in decoded:
         return None
     if any(ord(c) < 32 or ord(c) == 127 for c in decoded):
         return None
@@ -38,21 +39,21 @@ def _local_parts(value):
 
 def list_url(filters=None, focus: str | None = None) -> str:
     filters = filters or {}
-    config = import_filter_config('forderungen')
-    multi_fields = {f.name for f in config if f.filter_type in {'select', 'text'}}
-    params = {f.name: filters[f.name] for f in config
-              if f.name in filters and filters[f.name] not in ('', None)}
-    if filters.get('dashboard_segment'):
+    config = import_filter_config("forderungen")
+    multi_fields = {f.name for f in config if f.filter_type in {"select", "text"}}
+    params = {f.name: filters[f.name] for f in config if f.name in filters and filters[f.name] not in ("", None)}
+    if filters.get("dashboard_segment"):
         try:
-            params = {**segment_filters(parse_segment(filters['dashboard_segment'])), **params}
+            params = {**segment_filters(parse_segment(filters["dashboard_segment"])), **params}
         except ValueError:
             pass  # Untrusted return/login URLs must never carry malformed selections.
     if valid_focus(focus):
-        params['focus'] = focus
+        params["focus"] = focus
     # Only separators remain literal; encoded commas inside values stay %252C.
-    query = '&'.join(urlencode({name: value}, safe=',' if name in multi_fields else '')
-                     for name, value in params.items())
-    return LIST_PATH + ('?' + query if query else '')
+    query = "&".join(
+        urlencode({name: value}, safe="," if name in multi_fields else "") for name, value in params.items()
+    )
+    return LIST_PATH + ("?" + query if query else "")
 
 
 def list_return_url(value: str | None, primary_key: str) -> str:
@@ -69,30 +70,30 @@ def _current_parts(value):
         parts = urlsplit(value)
     except ValueError:
         return None
-    if parts.scheme and parts.scheme not in ('http', 'https'):
+    if parts.scheme and parts.scheme not in ("http", "https"):
         return None
-    return _local_parts(parts.path + ('?' + parts.query if parts.query else ''))
+    return _local_parts(parts.path + ("?" + parts.query if parts.query else ""))
 
 
 def return_url_for_request(request, primary_key: str, explicit: str | None = None) -> str:
-    value = explicit if explicit is not None else request.query_params.get('return_to')
+    value = explicit if explicit is not None else request.query_params.get("return_to")
     if value is None:
-        current = _current_parts(request.headers.get('HX-Current-URL'))
+        current = _current_parts(request.headers.get("HX-Current-URL"))
         match = DETAIL_PAGE.fullmatch(unquote(current.path)) if current else None
         if match and match[1] == primary_key:
-            value = dict(parse_qsl(current.query)).get('return_to')
+            value = dict(parse_qsl(current.query)).get("return_to")
     return list_return_url(value, primary_key)
 
 
 def detail_navigation_context(request, primary_key: str, explicit: str | None = None) -> dict:
     return_url = return_url_for_request(request, primary_key, explicit)
     return {
-        'return_url': return_url,
-        'detail_post_url': DETAIL_PREFIX + quote(primary_key, safe='') + '?' + urlencode({'return_to': return_url}),
+        "return_url": return_url,
+        "detail_post_url": DETAIL_PREFIX + quote(primary_key, safe="") + "?" + urlencode({"return_to": return_url}),
     }
 
 
-def normalize_page_url(value: str | None, default='/select', current_url: str | None = None) -> str:
+def normalize_page_url(value: str | None, default="/select", current_url: str | None = None) -> str:
     parts = _local_parts(value)
     if not parts:
         return default
@@ -100,38 +101,43 @@ def normalize_page_url(value: str | None, default='/select', current_url: str |
     current = _current_parts(current_url)
     follow_up = FOLLOW_UP_MODAL.fullmatch(unquote(parts.path))
     if follow_up and valid_focus(follow_up[1]):
-        value = params.get('return_to')
+        value = params.get("return_to")
         if value is None and current and current.path == LIST_PATH:
-            value = current.path + ('?' + current.query if current.query else '')
+            value = current.path + ("?" + current.query if current.query else "")
         return list_return_url(value, follow_up[1])
     modal = ASSIGNEE_MODAL.fullmatch(unquote(parts.path))
     if modal and valid_focus(modal[2]):
         primary_key = modal[2]
-        value = params.get('return_to')
-        if modal[1] == 'liste':
+        value = params.get("return_to")
+        if modal[1] == "liste":
             if value is None and current and current.path == LIST_PATH:
-                value = current.path + ('?' + current.query if current.query else '')
+                value = current.path + ("?" + current.query if current.query else "")
             return list_return_url(value, primary_key)
         if value is None and current:
             previous = DETAIL_PAGE.fullmatch(unquote(current.path))
             if previous and previous[1] == primary_key:
-                value = dict(parse_qsl(current.query)).get('return_to')
-        return DETAIL_PREFIX + quote(primary_key, safe='') + '?' + urlencode({'return_to': list_return_url(value, primary_key)})
+                value = dict(parse_qsl(current.query)).get("return_to")
+        return (
+            DETAIL_PREFIX
+            + quote(primary_key, safe="")
+            + "?"
+            + urlencode({"return_to": list_return_url(value, primary_key)})
+        )
     if parts.path == LIST_PATH:
-        focus = params.get('focus')
+        focus = params.get("focus")
         if not focus and current and current.path == LIST_PATH:
             previous = dict(parse_qsl(current.query))
             if list_url(params) == list_url(previous):
-                focus = previous.get('focus')
+                focus = previous.get("focus")
         return list_url(params, focus)
     match = DETAIL_PAGE.fullmatch(unquote(parts.path))
     if match and valid_focus(match[1]):
         primary_key = match[1]
-        value = params.get('return_to')
+        value = params.get("return_to")
         if value is None and current:
             previous = DETAIL_PAGE.fullmatch(unquote(current.path))
             if previous and previous[1] == primary_key:
-                value = dict(parse_qsl(current.query)).get('return_to')
-        detail = DETAIL_PREFIX + quote(primary_key, safe='')
-        return detail + ('?' + urlencode({'return_to': list_return_url(value, primary_key)}) if value else '')
-    return parts.path + ('?' + parts.query if parts.query else '') + ('#' + parts.fragment if parts.fragment else '')
+                value = dict(parse_qsl(current.query)).get("return_to")
+        detail = DETAIL_PREFIX + quote(primary_key, safe="")
+        return detail + ("?" + urlencode({"return_to": list_return_url(value, primary_key)}) if value else "")
+    return parts.path + ("?" + parts.query if parts.query else "") + ("#" + parts.fragment if parts.fragment else "")

+ 7 - 4
app/permissions.py

@@ -74,10 +74,13 @@ FORDERUNG_ACCESS_SQL = text("""
 
 
 def has_forderung_access(db: Session, principal: Principal, client_db: str, beleg_nr: str) -> bool:
-    return db.execute(
-        FORDERUNG_ACCESS_SQL,
-        {"client_db": client_db, "beleg_nr": beleg_nr, "auth_username": principal.username},
-    ).first() is not None
+    return (
+        db.execute(
+            FORDERUNG_ACCESS_SQL,
+            {"client_db": client_db, "beleg_nr": beleg_nr, "auth_username": principal.username},
+        ).first()
+        is not None
+    )
 
 
 def ensure_forderung_access(db: Session, principal: Principal, client_db: str, beleg_nr: str) -> None:

+ 2 - 4
app/query_filter.py

@@ -8,13 +8,11 @@ EMPTY_FILTER_LABEL = "Ohne Wert"
 
 def decode_filter_values(value: str | None) -> list[str]:
     """Split the GET-decoded parameter before decoding individual values."""
-    return list(dict.fromkeys(decoded for part in (value or "").split(",")
-                              if (decoded := unquote(part).strip())))
+    return list(dict.fromkeys(decoded for part in (value or "").split(",") if (decoded := unquote(part).strip())))
 
 
 def encode_filter_values(values) -> str:
-    return ",".join(quote(value, safe="") for value in dict.fromkeys(
-        str(value).strip() for value in values) if value)
+    return ",".join(quote(value, safe="") for value in dict.fromkeys(str(value).strip() for value in values) if value)
 
 
 def filter_clauses(config, params, exclude=None):

+ 6 - 5
app/routes.py

@@ -21,7 +21,7 @@ from sqlalchemy import text
 from sqlalchemy.orm import Session
 
 from app.file_storage import file_primary_key, get_file, get_files
-from app.query_filter import import_filter_config, filter_clauses
+from app.query_filter import filter_clauses, import_filter_config
 
 from .auth import clear_session_cookie, safe_next_url, set_session_cookie
 from .comment_submission import submit_comment
@@ -34,7 +34,7 @@ from .dashboard_segment import (
 from .db import get_session
 from .docuware_search import get_invoice_pdf_by_document_no
 from .forderung_dashboard import STACKS, build_dashboard
-from .forderung_data import read_invoices, read_filter_suggestions
+from .forderung_data import read_filter_suggestions, read_invoices
 from .forderung_history import HistoryScope, history_context, section_context
 from .forderung_summary import get_forderung_summary
 from .jinja_templates import templates
@@ -185,8 +185,9 @@ def _list_filter_params(request, config, db, username):
         raise HTTPException(400, "Ungültiger Dashboardlink") from error
     names = None
     if legacy and legacy["view"] == "Verursacher" and "id" in legacy["key"] and "Verursacher" not in params:
-        columns, rows = read_invoices(db, username, ["F.[Verursacher_Benutzer_ID] = :legacy_person"],
-                                     {"legacy_person": legacy["key"]["id"]})
+        columns, rows = read_invoices(
+            db, username, ["F.[Verursacher_Benutzer_ID] = :legacy_person"], {"legacy_person": legacy["key"]["id"]}
+        )
         names = list(dict.fromkeys(row[columns.index("Verursacher_Text")] for row in rows))
         if not names:
             names = [legacy["key"]["id"]]
@@ -217,7 +218,7 @@ def forderungen_liste(
         for f in forderung_filter_config:
             if f.filter_type == "select" and f.key_column != f.value_column and f.selected_values:
                 items = read_filter_suggestions(db, principal.username, f, [], {}, selected=f.selected_values)
-                selected_options[f.name] = {item['value']: item['label'] for item in items}
+                selected_options[f.name] = {item["value"]: item["label"] for item in items}
     col_names, q = read_invoices(db, principal.username, query_clauses, query_values)
 
     filters = {}

+ 87 - 49
app/verursacher.py

@@ -1,4 +1,5 @@
 """Change the manual invoice assignee independently of the workflow form."""
+
 import json
 import logging
 from urllib.parse import quote, urlencode
@@ -41,88 +42,125 @@ WHERE F.Client_DB = :Client_DB AND F.Beleg_Nr = :Beleg_Nr
 
 def _context(request, primary_key, db):
     if not valid_focus(primary_key):
-        raise HTTPException(400, 'Ungültiger Primärschlüssel')
-    tenant, number = primary_key.split('_', 1)
-    pkey = {'Client_DB': tenant, 'Beleg_Nr': number}
+        raise HTTPException(400, "Ungültiger Primärschlüssel")
+    tenant, number = primary_key.split("_", 1)
+    pkey = {"Client_DB": tenant, "Beleg_Nr": number}
     ensure_forderung_access(db, current_principal(request), tenant, number)
     state = db.execute(STATE_SQL, pkey).mappings().first()
     if state is None:
-        raise HTTPException(404, 'Forderung nicht gefunden')
+        raise HTTPException(404, "Forderung nicht gefunden")
     users = {}
     for row in db.execute(USERS_SQL).mappings().all():
-        identity = row['Benutzer_ID']
-        user = users.setdefault(identity, {'id': identity, 'name': row['Name'] or identity, 'roles': []})
-        if row['Rolle_Name'] and row['Rolle_Name'] not in user['roles']:
-            user['roles'].append(row['Rolle_Name'])
-    users = sorted(users.values(), key=lambda user: (user['name'].casefold(), user['id']))
-    roles = sorted({role for user in users for role in user['roles']}, key=str.casefold)
+        identity = row["Benutzer_ID"]
+        user = users.setdefault(identity, {"id": identity, "name": row["Name"] or identity, "roles": []})
+        if row["Rolle_Name"] and row["Rolle_Name"] not in user["roles"]:
+            user["roles"].append(row["Rolle_Name"])
+    users = sorted(users.values(), key=lambda user: (user["name"].casefold(), user["id"]))
+    roles = sorted({role for user in users for role in user["roles"]}, key=str.casefold)
     detail = request.url.path.startswith(DETAIL_PREFIX)
-    return_url = list_return_url(request.query_params.get('return_to'), primary_key)
-    page_url = DETAIL_PREFIX + quote(primary_key, safe='') + '?' + urlencode({'return_to': return_url}) if detail else return_url
-    selected = state['Override_Verursacher_Benutzer_ID'] or state['Original_Verursacher_Benutzer_ID'] or ''
-    return dict(pkey=pkey, state=state, users=users, roles=roles, selected=selected,
-                detail=detail, primary_key=primary_key, page_url=page_url, return_url=return_url,
-                modal_url=request.url.path + '?' + urlencode({'return_to': return_url}), role_filter='', error='')
+    return_url = list_return_url(request.query_params.get("return_to"), primary_key)
+    page_url = (
+        DETAIL_PREFIX + quote(primary_key, safe="") + "?" + urlencode({"return_to": return_url})
+        if detail
+        else return_url
+    )
+    selected = state["Override_Verursacher_Benutzer_ID"] or state["Original_Verursacher_Benutzer_ID"] or ""
+    return dict(
+        pkey=pkey,
+        state=state,
+        users=users,
+        roles=roles,
+        selected=selected,
+        detail=detail,
+        primary_key=primary_key,
+        page_url=page_url,
+        return_url=return_url,
+        modal_url=request.url.path + "?" + urlencode({"return_to": return_url}),
+        role_filter="",
+        error="",
+    )
 
 
 def _render(request, context, status_code=200):
-    return templates.TemplateResponse(request, 'forderungen/verursacher/modal.html', context,
-                                      status_code=status_code, headers={'Cache-Control': 'no-store'})
+    return templates.TemplateResponse(
+        request,
+        "forderungen/verursacher/modal.html",
+        context,
+        status_code=status_code,
+        headers={"Cache-Control": "no-store"},
+    )
 
 
-@router.get('/app/forderungen/liste/{primary_key}/verursacher', response_class=HTMLResponse)
-@router.get('/app/forderungen/details/{primary_key}/verursacher', response_class=HTMLResponse)
+@router.get("/app/forderungen/liste/{primary_key}/verursacher", response_class=HTMLResponse)
+@router.get("/app/forderungen/details/{primary_key}/verursacher", response_class=HTMLResponse)
 def edit_verursacher(request: Request, primary_key: str, db: Session = Depends(get_session)):
     context = _context(request, primary_key, db)
-    if request.headers.get('HX-Request', '').lower() != 'true':
-        return RedirectResponse(context['page_url'], status_code=303)
+    if request.headers.get("HX-Request", "").lower() != "true":
+        return RedirectResponse(context["page_url"], status_code=303)
     return _render(request, context)
 
 
-@router.post('/app/forderungen/liste/{primary_key}/verursacher', response_class=HTMLResponse)
-@router.post('/app/forderungen/details/{primary_key}/verursacher', response_class=HTMLResponse)
-def save_verursacher(request: Request, primary_key: str, action: str = Form('assign'),
-                     benutzer_id: str = Form(''), rolle: str = Form(''), db: Session = Depends(get_session)):
+@router.post("/app/forderungen/liste/{primary_key}/verursacher", response_class=HTMLResponse)
+@router.post("/app/forderungen/details/{primary_key}/verursacher", response_class=HTMLResponse)
+def save_verursacher(
+    request: Request,
+    primary_key: str,
+    action: str = Form("assign"),
+    benutzer_id: str = Form(""),
+    rolle: str = Form(""),
+    db: Session = Depends(get_session),
+):
     context = _context(request, primary_key, db)
-    context['selected'] = benutzer_id
-    context['role_filter'] = rolle if rolle in context['roles'] else ''
-    user = next((user for user in context['users'] if user['id'] == benutzer_id), None)
-    if action not in {'assign', 'reset'} or (action == 'assign' and user is None):
-        context['error'] = 'Bitte einen gültigen Benutzer auswählen.'
+    context["selected"] = benutzer_id
+    context["role_filter"] = rolle if rolle in context["roles"] else ""
+    user = next((user for user in context["users"] if user["id"] == benutzer_id), None)
+    if action not in {"assign", "reset"} or (action == "assign" and user is None):
+        context["error"] = "Bitte einen gültigen Benutzer auswählen."
         return _render(request, context, 422)
-    value = None if action == 'reset' else benutzer_id
-    values = dict(context['pkey'], Verursacher_Benutzer_ID=value)
+    value = None if action == "reset" else benutzer_id
+    values = dict(context["pkey"], Verursacher_Benutzer_ID=value)
     try:
         # Serializable key-range lock prevents concurrent creation of the same detail record.
-        existing = db.execute(text("""
+        existing = db.execute(
+            text("""
             SELECT Verursacher_Benutzer_ID FROM dbo.Forderungen_Details WITH (UPDLOCK, HOLDLOCK)
             WHERE Client_DB = :Client_DB AND Beleg_Nr = :Beleg_Nr
-        """), context['pkey']).first()
+        """),
+            context["pkey"],
+        ).first()
         if existing is None:
             if value is not None:
-                db.execute(text("""
+                db.execute(
+                    text("""
                     INSERT INTO dbo.Forderungen_Details (Client_DB, Beleg_Nr, Verursacher_Benutzer_ID)
                     VALUES (:Client_DB, :Beleg_Nr, :Verursacher_Benutzer_ID)
-                """), values)
+                """),
+                    values,
+                )
         else:
-            db.execute(text("""
+            db.execute(
+                text("""
                 UPDATE dbo.Forderungen_Details SET Verursacher_Benutzer_ID = :Verursacher_Benutzer_ID
                 WHERE Client_DB = :Client_DB AND Beleg_Nr = :Beleg_Nr
-            """), values)
+            """),
+                values,
+            )
         db.commit()
     except Exception:
         db.rollback()
-        logger.exception('Verursacher konnte für %s nicht gespeichert werden', primary_key)
-        context['error'] = 'Der Verursacher konnte nicht gespeichert werden. Bitte erneut versuchen.'
+        logger.exception("Verursacher konnte für %s nicht gespeichert werden", primary_key)
+        context["error"] = "Der Verursacher konnte nicht gespeichert werden. Bitte erneut versuchen."
         return _render(request, context, 503)
 
-    if request.headers.get('HX-Request', '').lower() != 'true':
-        return RedirectResponse(context['page_url'], status_code=303)
-    if not context['detail']:
-        return HTMLResponse('', headers={'HX-Redirect': context['return_url'], 'Cache-Control': 'no-store'})
-    context['effective_name'] = context['state']['Original_Verursacher_Text'] if value is None else user['name']
-    context['oob'] = True
+    if request.headers.get("HX-Request", "").lower() != "true":
+        return RedirectResponse(context["page_url"], status_code=303)
+    if not context["detail"]:
+        return HTMLResponse("", headers={"HX-Redirect": context["return_url"], "Cache-Control": "no-store"})
+    context["effective_name"] = context["state"]["Original_Verursacher_Text"] if value is None else user["name"]
+    context["oob"] = True
     return templates.TemplateResponse(
-        request, 'forderungen/verursacher/detail_field.html', context,
-        headers={'HX-Trigger-After-Swap': json.dumps({'gc:verursacherSaved': {}}), 'Cache-Control': 'no-store'},
+        request,
+        "forderungen/verursacher/detail_field.html",
+        context,
+        headers={"HX-Trigger-After-Swap": json.dumps({"gc:verursacherSaved": {}}), "Cache-Control": "no-store"},
     )

+ 85 - 44
app/wiedervorlage.py

@@ -1,7 +1,8 @@
 """List follow-up editing, with its audit comment committed atomically."""
-from datetime import date, datetime, timedelta
+
 import logging
 import re
+from datetime import date, datetime, timedelta
 from urllib.parse import urlencode
 from zoneinfo import ZoneInfo
 
@@ -17,7 +18,7 @@ from .permissions import current_principal, ensure_forderung_access
 
 router = APIRouter()
 logger = logging.getLogger(__name__)
-BERLIN = ZoneInfo('Europe/Berlin')
+BERLIN = ZoneInfo("Europe/Berlin")
 
 
 def _now_berlin():
@@ -36,88 +37,128 @@ def _as_date(value):
 
 def _context(request, primary_key, db):
     if not valid_focus(primary_key):
-        raise HTTPException(400, 'Ungültiger Primärschlüssel')
-    tenant, number = primary_key.split('_', 1)
-    pkey = {'Client_DB': tenant, 'Beleg_Nr': number}
+        raise HTTPException(400, "Ungültiger Primärschlüssel")
+    tenant, number = primary_key.split("_", 1)
+    pkey = {"Client_DB": tenant, "Beleg_Nr": number}
     ensure_forderung_access(db, current_principal(request), tenant, number)
-    state = db.execute(text("""
+    state = (
+        db.execute(
+            text("""
         SELECT F.Beleg_Nr, F.Beleg_Faellig_Datum,
                COALESCE(FD.Wiedervorlage, F.Beleg_Faellig_Datum) AS Wiedervorlage
         FROM dbo.Forderungen_Kopf F
         LEFT JOIN dbo.Forderungen_Details FD ON FD.Client_DB = F.Client_DB AND FD.Beleg_Nr = F.Beleg_Nr
         WHERE F.Client_DB = :Client_DB AND F.Beleg_Nr = :Beleg_Nr
-    """), pkey).mappings().first()
+    """),
+            pkey,
+        )
+        .mappings()
+        .first()
+    )
     if state is None:
-        raise HTTPException(404, 'Forderung nicht gefunden')
+        raise HTTPException(404, "Forderung nicht gefunden")
     today = _now_berlin().date()
-    current = _as_date(state['Wiedervorlage'])
-    return_url = list_return_url(request.query_params.get('return_to'), primary_key)
-    return dict(pkey=pkey, state=state, today=today.isoformat(),
-                next_week=(today + timedelta(days=7)).isoformat(),
-                selected=current.isoformat() if current else '', error='',
-                return_url=return_url,
-                modal_url=request.url.path + '?' + urlencode({'return_to': return_url}))
+    current = _as_date(state["Wiedervorlage"])
+    return_url = list_return_url(request.query_params.get("return_to"), primary_key)
+    return dict(
+        pkey=pkey,
+        state=state,
+        today=today.isoformat(),
+        next_week=(today + timedelta(days=7)).isoformat(),
+        selected=current.isoformat() if current else "",
+        error="",
+        return_url=return_url,
+        modal_url=request.url.path + "?" + urlencode({"return_to": return_url}),
+    )
 
 
 def _render(request, context, status_code=200):
-    return templates.TemplateResponse(request, 'forderungen/wiedervorlage/modal.html', context,
-                                      status_code=status_code, headers={'Cache-Control': 'no-store'})
+    return templates.TemplateResponse(
+        request,
+        "forderungen/wiedervorlage/modal.html",
+        context,
+        status_code=status_code,
+        headers={"Cache-Control": "no-store"},
+    )
 
 
-@router.get('/app/forderungen/liste/{primary_key}/wiedervorlage', response_class=HTMLResponse)
+@router.get("/app/forderungen/liste/{primary_key}/wiedervorlage", response_class=HTMLResponse)
 def edit_wiedervorlage(request: Request, primary_key: str, db: Session = Depends(get_session)):
     context = _context(request, primary_key, db)
-    if request.headers.get('HX-Request', '').lower() != 'true':
-        return RedirectResponse(context['return_url'], status_code=303)
+    if request.headers.get("HX-Request", "").lower() != "true":
+        return RedirectResponse(context["return_url"], status_code=303)
     return _render(request, context)
 
 
-@router.post('/app/forderungen/liste/{primary_key}/wiedervorlage', response_class=HTMLResponse)
-def save_wiedervorlage(request: Request, primary_key: str, wiedervorlage: str = Form(''),
-                      db: Session = Depends(get_session)):
+@router.post("/app/forderungen/liste/{primary_key}/wiedervorlage", response_class=HTMLResponse)
+def save_wiedervorlage(
+    request: Request, primary_key: str, wiedervorlage: str = Form(""), db: Session = Depends(get_session)
+):
     context = _context(request, primary_key, db)
-    context['selected'] = wiedervorlage
+    context["selected"] = wiedervorlage
     try:
-        if not re.fullmatch(r'\d{4}-\d{2}-\d{2}', wiedervorlage):
+        if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", wiedervorlage):
             raise ValueError
         selected = date.fromisoformat(wiedervorlage)
     except ValueError:
-        context['error'] = 'Bitte ein gültiges Wiedervorlagedatum auswählen.'
+        context["error"] = "Bitte ein gültiges Wiedervorlagedatum auswählen."
         return _render(request, context, 422)
 
     try:
-        existing = db.execute(text("""
+        existing = (
+            db.execute(
+                text("""
             SELECT Wiedervorlage FROM dbo.Forderungen_Details WITH (UPDLOCK, HOLDLOCK)
             WHERE Client_DB = :Client_DB AND Beleg_Nr = :Beleg_Nr
-        """), context['pkey']).mappings().first()
-        current = _as_date(existing['Wiedervorlage'] if existing and existing['Wiedervorlage'] is not None
-                           else context['state']['Beleg_Faellig_Datum'])
+        """),
+                context["pkey"],
+            )
+            .mappings()
+            .first()
+        )
+        current = _as_date(
+            existing["Wiedervorlage"]
+            if existing and existing["Wiedervorlage"] is not None
+            else context["state"]["Beleg_Faellig_Datum"]
+        )
         # Repeated submissions of an unchanged date must not duplicate the audit comment.
         if selected != current:
-            values = dict(context['pkey'], Wiedervorlage=selected)
+            values = dict(context["pkey"], Wiedervorlage=selected)
             if existing is None:
-                db.execute(text("""
+                db.execute(
+                    text("""
                     INSERT INTO dbo.Forderungen_Details (Client_DB, Beleg_Nr, Wiedervorlage)
                     VALUES (:Client_DB, :Beleg_Nr, :Wiedervorlage)
-                """), values)
+                """),
+                    values,
+                )
             else:
-                db.execute(text("""
+                db.execute(
+                    text("""
                     UPDATE dbo.Forderungen_Details SET Wiedervorlage = :Wiedervorlage
                     WHERE Client_DB = :Client_DB AND Beleg_Nr = :Beleg_Nr
-                """), values)
-            db.execute(text("""
+                """),
+                    values,
+                )
+            db.execute(
+                text("""
                 INSERT INTO dbo.Forderungen_Kommentar (Client_DB, Beleg_Nr, [Timestamp], Benutzer_ID, Kommentar)
                 VALUES (:Client_DB, :Beleg_Nr, :Timestamp, :Benutzer_ID, :Kommentar)
-            """), dict(context['pkey'], Timestamp=_now_berlin().replace(tzinfo=None),
-                      Benutzer_ID=current_principal(request).username,
-                      Kommentar=f'WV auf {selected.strftime("%d.%m.%Y")} angepasst'))
+            """),
+                dict(
+                    context["pkey"],
+                    Timestamp=_now_berlin().replace(tzinfo=None),
+                    Benutzer_ID=current_principal(request).username,
+                    Kommentar=f'WV auf {selected.strftime("%d.%m.%Y")} angepasst',
+                ),
+            )
         db.commit()
     except Exception:
         db.rollback()
-        logger.exception('Wiedervorlage konnte für %s nicht gespeichert werden', primary_key)
-        context['error'] = 'Datum und Kommentar konnten nicht gespeichert werden. Bitte erneut versuchen.'
+        logger.exception("Wiedervorlage konnte für %s nicht gespeichert werden", primary_key)
+        context["error"] = "Datum und Kommentar konnten nicht gespeichert werden. Bitte erneut versuchen."
         return _render(request, context, 503)
 
-    if request.headers.get('HX-Request', '').lower() == 'true':
-        return HTMLResponse('', headers={'HX-Redirect': context['return_url'], 'Cache-Control': 'no-store'})
-    return RedirectResponse(context['return_url'], status_code=303)
+    if request.headers.get("HX-Request", "").lower() == "true":
+        return HTMLResponse("", headers={"HX-Redirect": context["return_url"], "Cache-Control": "no-store"})
+    return RedirectResponse(context["return_url"], status_code=303)

+ 4 - 4
filter_export.py

@@ -2,14 +2,14 @@ import csv
 import logging
 from dataclasses import asdict
 
-from app.query_filter import import_filter_config
 from app.logging_setup import run_cli
+from app.query_filter import import_filter_config
 
 logger = logging.getLogger(__name__)
 
 
 def export_filter_config():
-    forderung_filter_config = import_filter_config('forderungen')
+    forderung_filter_config = import_filter_config("forderungen")
     with open("filter_config.csv", "w", newline="", encoding="latin-1") as csvfile:
         writer = csv.DictWriter(csvfile, fieldnames=asdict(forderung_filter_config[0]).keys(), delimiter=";")
         writer.writeheader()
@@ -20,8 +20,8 @@ def export_filter_config():
 
 
 def import_filter_config2():
-    configs = import_filter_config('forderungen')
-    logger.info('Filterkonfiguration geladen', extra={'event': 'filter_config_loaded', 'count': len(configs)})
+    configs = import_filter_config("forderungen")
+    logger.info("Filterkonfiguration geladen", extra={"event": "filter_config_loaded", "count": len(configs)})
 
 
 if __name__ == "__main__":