| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269 |
- """Shared, case-authorized document collection and mail drafts."""
- import hashlib
- import logging
- import mimetypes
- from email.message import EmailMessage
- from email.policy import SMTP
- from typing import Literal
- from urllib.parse import quote
- from fastapi import APIRouter, Depends, HTTPException, Request
- from fastapi.responses import HTMLResponse, Response
- from sqlalchemy import text
- from .db import get_session
- from .docuware_search import get_adapter
- from .file_storage import get_file, get_files
- from .jinja_templates import templates
- from .ldap import DirectoryUnavailable, lookup_directory_user
- from .permissions import current_principal, ensure_forderung_access
- router = APIRouter()
- logger = logging.getLogger(__name__)
- Scope = Literal["invoice", "open", "debtor", "vehicle"]
- BASE = "/app/forderungen/documents"
- def case_context(request, primary_key, db):
- from .routes import convert_primary_key
- key = convert_primary_key("forderungen", primary_key)
- ensure_forderung_access(db, current_principal(request), key["Client_DB"], key["Beleg_Nr"])
- row = (
- db.execute(
- text("SELECT TOP 1 * FROM dbo.Forderungen_Kopf WHERE Client_DB=:Client_DB AND Beleg_Nr=:Beleg_Nr"), key
- )
- .mappings()
- .first()
- )
- if not row:
- raise HTTPException(404, "Forderung nicht gefunden")
- return key, row
- def scoped_keys(db, key, row, scope):
- if scope == "invoice":
- return [key]
- column = "Fahrzeug_Nr" if scope == "vehicle" else "Kunde_Nr"
- value = row.get(column)
- if not value:
- return []
- condition = f"Client_DB=:tenant AND {column}=:value"
- sql = f"SELECT Client_DB, Beleg_Nr FROM dbo.Forderungen_Kopf WHERE {condition}"
- if scope == "open":
- sql += " AND Beleg_Offen <> 0"
- else:
- sql += f" UNION SELECT Client_DB, Beleg_Nr FROM dbo.Forderungen_Historie WHERE {condition}"
- return [
- dict(item) for item in db.execute(text(sql), {"tenant": key["Client_DB"], "value": value}).mappings().fetchall()
- ]
- def identity(source, *parts):
- return source + "-" + hashlib.sha256("\0".join(map(str, parts)).encode()).hexdigest()
- def collect(db, key, row, scope):
- documents, errors = {}, []
- keys = scoped_keys(db, key, row, scope)
- adapter = get_adapter()
- try:
- if scope in ("invoice", "open"):
- searches = [dict(document_no=k["Beleg_Nr"]) for k in keys]
- elif scope == "debtor":
- searches = [dict(customer_no=str(row["Kunde_Nr"]))] if row.get("Kunde_Nr") else []
- else:
- vin = None
- if row.get("Fahrzeug_Nr"):
- vehicle = (
- db.execute(
- text(
- "SELECT TOP 1 Fahrzeug_VIN FROM dbo.Fahrzeuge WHERE Client_DB=:tenant AND Fahrzeug_Nr=:vehicle"
- ),
- {"tenant": key["Client_DB"], "vehicle": row["Fahrzeug_Nr"]},
- )
- .mappings()
- .first()
- )
- vin = vehicle["Fahrzeug_VIN"] if vehicle else None
- searches = [dict(vehicle_no=str(vin))] if vin else []
- for search in searches:
- for item in adapter.search(key["Client_DB"], **search):
- doc = {**item, "source": "DocuWare", "pdf": True}
- doc["token"] = identity("dw", doc["archive"], doc["id"])
- documents[doc["token"]] = doc
- except Exception:
- logger.exception("DocuWare-Dokumentliste konnte nicht geladen werden")
- errors.append("Keine Verbindung zu DocuWare")
- if not errors and not documents:
- errors.append(
- f"Keine Dokumente für Beleg {key['Beleg_Nr']} gefunden"
- if scope == "invoice"
- else "Keine DocuWare-Dokumente für diesen Filter gefunden"
- )
- try:
- for item_key in keys:
- for file in get_files(item_key):
- token = identity("local", file.relative_path)
- documents[token] = dict(
- token=token,
- source="Lokal",
- number=item_key["Beleg_Nr"],
- date="",
- kind="",
- editor="",
- name=file.name,
- path=file.relative_path,
- pdf=file.name.lower().endswith(".pdf"),
- )
- except OSError:
- logger.exception("Lokale Dokumentliste konnte nicht geladen werden")
- errors.append("Die lokalen Dateien konnten nicht geladen werden.")
- docs = sorted(documents.values(), key=lambda d: (d.get("name", "").casefold(), d["token"]))
- docs.sort(key=lambda d: d.get("date", ""), reverse=True)
- return docs, errors
- def context(request, primary_key, db, scope):
- key, row = case_context(request, primary_key, db)
- docs, errors = collect(db, key, row, scope)
- return dict(
- documents=docs,
- document_errors=errors,
- document_scope=scope,
- document_base=BASE + "/" + quote(primary_key, safe=""),
- document_key=primary_key,
- customer_missing=not row.get("Kunde_Nr"),
- vehicle_missing=not row.get("Fahrzeug_Nr"),
- )
- def selected(request, primary_key, db, scope, token):
- ctx = context(request, primary_key, db, scope)
- doc = next((d for d in ctx["documents"] if d["token"] == token), None)
- if doc is None:
- if token.startswith("dw-") and "Keine Verbindung zu DocuWare" in ctx["document_errors"]:
- raise HTTPException(503, "Keine Verbindung zu DocuWare")
- raise HTTPException(404, "Dokument nicht gefunden oder nicht mehr verfügbar")
- return doc, ctx
- def content(primary_key, doc):
- try:
- if doc["source"] == "Lokal":
- path = get_file(doc["path"])
- return path.read_bytes(), mimetypes.guess_type(path.name)[0] or "application/octet-stream", path.name
- return get_adapter().download(primary_key.split("_", 1)[0], doc)
- except FileNotFoundError:
- raise HTTPException(404, "Dokumentdatei nicht gefunden")
- except Exception:
- logger.exception("Dokument konnte nicht geöffnet werden")
- raise HTTPException(503, "Dokument konnte nicht geladen werden. Bitte erneut versuchen.")
- @router.get(BASE + "/{primary_key}", response_class=HTMLResponse)
- def listing(
- request: Request, primary_key: str, scope: Scope = "invoice", detail: bool = False, db=Depends(get_session)
- ):
- ctx = context(request, primary_key, db, scope)
- ctx["document_detail"] = detail
- return templates.TemplateResponse(
- request, "forderungen/documents/list.html", ctx, headers={"Cache-Control": "no-store"}
- )
- @router.get(BASE + "/{primary_key}/content/{token}")
- def download(request: Request, primary_key: str, token: str, scope: Scope = "invoice", db=Depends(get_session)):
- doc, _ = selected(request, primary_key, db, scope, token)
- data, media, name = content(primary_key, doc)
- disposition = "inline" if doc["pdf"] else "attachment"
- return Response(
- data,
- media_type=media,
- headers={
- "Content-Disposition": f"{disposition}; filename*=UTF-8''{quote(name, safe='')}",
- "Cache-Control": "no-store",
- "X-Content-Type-Options": "nosniff",
- },
- )
- def recipients(request, primary_key, db):
- # Always use the opened case, regardless of the selected document's receipt.
- tenant, number = primary_key.split("_", 1)
- query = templates.env.get_template("forderungen/details/queries/forderungen_details.sql").render()
- row = db.execute(text(query), {"client_db": tenant, "beleg_nr": number}).mappings().first()
- principal = current_principal(request)
- user_rows = db.execute(text("""
- SELECT DISTINCT Benutzer_Email FROM dbo.Benutzer_Rolle
- WHERE Benutzer_ID = :username
- ORDER BY Benutzer_Email
- """), {"username": principal.username}).mappings().fetchall()
- own_address = next((address for item in user_rows
- if valid_address(address := (item["Benutzer_Email"] or "").strip())), principal.email)
- list_row = db.execute(text("""
- SELECT TOP 1 Verursacher_Email FROM dbo.Forderungen_Liste
- WHERE Client_DB = :client_db AND Beleg_Nr = :beleg_nr
- """), {"client_db": tenant, "beleg_nr": number}).mappings().first()
- colleague = (list_row.get("Verursacher_Email") or "").strip() if list_row else ""
- if not valid_address(colleague) and row and row.get("Verursacher_Aktuell_Benutzer_ID"):
- try:
- user = lookup_directory_user(row["Verursacher_Aktuell_Benutzer_ID"])
- if user and not user.disabled:
- colleague = user.email
- except (DirectoryUnavailable, ValueError):
- pass
- values = [
- ("self", "An mich", own_address),
- ("colleague", "Verursacher", colleague),
- ("customer", "Kunde", row.get("Kunde_Email") if row else ""),
- ("insurance", "Versicherung", row.get("Vers_Email") if row else ""),
- ]
- return [
- dict(id=id_, label=label, email=address if valid_address(address) else "") for id_, label, address in values
- ]
- def valid_address(value):
- from email.headerregistry import Address
- if not value or any(c in value for c in "\r\n"):
- return False
- try:
- address = Address(addr_spec=value)
- return bool(address.username and address.domain)
- except ValueError:
- return False
- @router.get(BASE + "/{primary_key}/recipients/{token}", response_class=HTMLResponse)
- def recipient_list(request: Request, primary_key: str, token: str, scope: Scope = "invoice", db=Depends(get_session)):
- doc, ctx = selected(request, primary_key, db, scope, token)
- ctx.update(document=doc, recipients=recipients(request, primary_key, db))
- return templates.TemplateResponse(
- request, "forderungen/documents/recipients.html", ctx, headers={"Cache-Control": "no-store"}
- )
- @router.get(BASE + "/{primary_key}/draft/{token}")
- def draft(
- request: Request, primary_key: str, token: str, recipient: str, scope: Scope = "invoice", db=Depends(get_session)
- ):
- doc, _ = selected(request, primary_key, db, scope, token)
- target = next((r for r in recipients(request, primary_key, db) if r["id"] == recipient), None)
- if not target or not target["email"]:
- raise HTTPException(422, "Keine gültige hinterlegte E-Mail-Adresse vorhanden")
- data, media, name = content(primary_key, doc)
- message = EmailMessage(policy=SMTP)
- message["To"] = target["email"]
- message["Subject"] = "Dokument zu Beleg " + primary_key.split("_", 1)[1]
- message["X-Unsent"] = "1"
- message.set_content("Guten Tag,\n\nim Anhang finden Sie das ausgewählte Dokument.\n")
- major, minor = media.split(";", 1)[0].split("/", 1)
- message.add_attachment(data, maintype=major, subtype=minor, filename=name)
- return Response(
- message.as_bytes(),
- media_type="message/rfc822",
- headers={"Content-Disposition": "attachment; filename=Dokument.eml", "Cache-Control": "no-store"},
- )
|