documents.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257
  1. """Shared, case-authorized document collection and mail drafts."""
  2. import hashlib
  3. import logging
  4. import mimetypes
  5. from email.message import EmailMessage
  6. from email.policy import SMTP
  7. from typing import Literal
  8. from urllib.parse import quote
  9. from fastapi import APIRouter, Depends, HTTPException, Request
  10. from fastapi.responses import HTMLResponse, Response
  11. from sqlalchemy import text
  12. from .db import get_session
  13. from .docuware_search import get_adapter
  14. from .file_storage import get_file, get_files
  15. from .jinja_templates import templates
  16. from .ldap import DirectoryUnavailable, lookup_directory_user
  17. from .permissions import current_principal, ensure_forderung_access
  18. router = APIRouter()
  19. logger = logging.getLogger(__name__)
  20. Scope = Literal["invoice", "open", "debtor", "vehicle"]
  21. BASE = "/app/forderungen/documents"
  22. def case_context(request, primary_key, db):
  23. from .routes import convert_primary_key
  24. key = convert_primary_key("forderungen", primary_key)
  25. ensure_forderung_access(db, current_principal(request), key["Client_DB"], key["Beleg_Nr"])
  26. row = (
  27. db.execute(
  28. text("SELECT TOP 1 * FROM dbo.Forderungen_Kopf WHERE Client_DB=:Client_DB AND Beleg_Nr=:Beleg_Nr"), key
  29. )
  30. .mappings()
  31. .first()
  32. )
  33. if not row:
  34. raise HTTPException(404, "Forderung nicht gefunden")
  35. return key, row
  36. def scoped_keys(db, key, row, scope):
  37. if scope == "invoice":
  38. return [key]
  39. column = "Fahrzeug_Nr" if scope == "vehicle" else "Kunde_Nr"
  40. value = row.get(column)
  41. if not value:
  42. return []
  43. condition = f"Client_DB=:tenant AND {column}=:value"
  44. sql = f"SELECT Client_DB, Beleg_Nr FROM dbo.Forderungen_Kopf WHERE {condition}"
  45. if scope == "open":
  46. sql += " AND Beleg_Offen <> 0"
  47. else:
  48. sql += f" UNION SELECT Client_DB, Beleg_Nr FROM dbo.Forderungen_Historie WHERE {condition}"
  49. return [
  50. dict(item) for item in db.execute(text(sql), {"tenant": key["Client_DB"], "value": value}).mappings().fetchall()
  51. ]
  52. def identity(source, *parts):
  53. return source + "-" + hashlib.sha256("\0".join(map(str, parts)).encode()).hexdigest()
  54. def collect(db, key, row, scope):
  55. documents, errors = {}, []
  56. keys = scoped_keys(db, key, row, scope)
  57. adapter = get_adapter()
  58. try:
  59. if scope in ("invoice", "open"):
  60. searches = [dict(document_no=k["Beleg_Nr"]) for k in keys]
  61. elif scope == "debtor":
  62. searches = [dict(customer_no=str(row["Kunde_Nr"]))] if row.get("Kunde_Nr") else []
  63. else:
  64. vin = None
  65. if row.get("Fahrzeug_Nr"):
  66. vehicle = (
  67. db.execute(
  68. text(
  69. "SELECT TOP 1 Fahrzeug_VIN FROM dbo.Fahrzeuge WHERE Client_DB=:tenant AND Fahrzeug_Nr=:vehicle"
  70. ),
  71. {"tenant": key["Client_DB"], "vehicle": row["Fahrzeug_Nr"]},
  72. )
  73. .mappings()
  74. .first()
  75. )
  76. vin = vehicle["Fahrzeug_VIN"] if vehicle else None
  77. searches = [dict(vehicle_no=str(vin))] if vin else []
  78. for search in searches:
  79. for item in adapter.search(key["Client_DB"], **search):
  80. doc = {**item, "source": "DocuWare", "pdf": True}
  81. doc["token"] = identity("dw", doc["archive"], doc["id"])
  82. documents[doc["token"]] = doc
  83. except Exception:
  84. logger.exception("DocuWare-Dokumentliste konnte nicht geladen werden")
  85. errors.append("Keine Verbindung zu DocuWare")
  86. if not errors and not documents:
  87. errors.append(
  88. f"Keine Dokumente für Beleg {key['Beleg_Nr']} gefunden"
  89. if scope == "invoice"
  90. else "Keine DocuWare-Dokumente für diesen Filter gefunden"
  91. )
  92. try:
  93. for item_key in keys:
  94. for file in get_files(item_key):
  95. token = identity("local", file.relative_path)
  96. documents[token] = dict(
  97. token=token,
  98. source="Lokal",
  99. number=item_key["Beleg_Nr"],
  100. date="",
  101. kind="",
  102. editor="",
  103. name=file.name,
  104. path=file.relative_path,
  105. pdf=file.name.lower().endswith(".pdf"),
  106. )
  107. except OSError:
  108. logger.exception("Lokale Dokumentliste konnte nicht geladen werden")
  109. errors.append("Die lokalen Dateien konnten nicht geladen werden.")
  110. docs = sorted(documents.values(), key=lambda d: (d.get("name", "").casefold(), d["token"]))
  111. docs.sort(key=lambda d: d.get("date", ""), reverse=True)
  112. return docs, errors
  113. def context(request, primary_key, db, scope):
  114. key, row = case_context(request, primary_key, db)
  115. docs, errors = collect(db, key, row, scope)
  116. return dict(
  117. documents=docs,
  118. document_errors=errors,
  119. document_scope=scope,
  120. document_base=BASE + "/" + quote(primary_key, safe=""),
  121. document_key=primary_key,
  122. customer_missing=not row.get("Kunde_Nr"),
  123. vehicle_missing=not row.get("Fahrzeug_Nr"),
  124. )
  125. def selected(request, primary_key, db, scope, token):
  126. ctx = context(request, primary_key, db, scope)
  127. doc = next((d for d in ctx["documents"] if d["token"] == token), None)
  128. if doc is None:
  129. if token.startswith("dw-") and "Keine Verbindung zu DocuWare" in ctx["document_errors"]:
  130. raise HTTPException(503, "Keine Verbindung zu DocuWare")
  131. raise HTTPException(404, "Dokument nicht gefunden oder nicht mehr verfügbar")
  132. return doc, ctx
  133. def content(primary_key, doc):
  134. try:
  135. if doc["source"] == "Lokal":
  136. path = get_file(doc["path"])
  137. return path.read_bytes(), mimetypes.guess_type(path.name)[0] or "application/octet-stream", path.name
  138. return get_adapter().download(primary_key.split("_", 1)[0], doc)
  139. except FileNotFoundError:
  140. raise HTTPException(404, "Dokumentdatei nicht gefunden")
  141. except Exception:
  142. logger.exception("Dokument konnte nicht geöffnet werden")
  143. raise HTTPException(503, "Dokument konnte nicht geladen werden. Bitte erneut versuchen.")
  144. @router.get(BASE + "/{primary_key}", response_class=HTMLResponse)
  145. def listing(
  146. request: Request, primary_key: str, scope: Scope = "invoice", detail: bool = False, db=Depends(get_session)
  147. ):
  148. ctx = context(request, primary_key, db, scope)
  149. ctx["document_detail"] = detail
  150. return templates.TemplateResponse(
  151. request, "forderungen/documents/list.html", ctx, headers={"Cache-Control": "no-store"}
  152. )
  153. @router.get(BASE + "/{primary_key}/content/{token}")
  154. def download(request: Request, primary_key: str, token: str, scope: Scope = "invoice", db=Depends(get_session)):
  155. doc, _ = selected(request, primary_key, db, scope, token)
  156. data, media, name = content(primary_key, doc)
  157. disposition = "inline" if doc["pdf"] else "attachment"
  158. return Response(
  159. data,
  160. media_type=media,
  161. headers={
  162. "Content-Disposition": f"{disposition}; filename*=UTF-8''{quote(name, safe='')}",
  163. "Cache-Control": "no-store",
  164. "X-Content-Type-Options": "nosniff",
  165. },
  166. )
  167. def recipients(request, primary_key, db):
  168. # Always use the opened case, regardless of the selected document's receipt.
  169. tenant, number = primary_key.split("_", 1)
  170. query = templates.env.get_template("forderungen/details/queries/forderungen_details.sql").render()
  171. row = db.execute(text(query), {"client_db": tenant, "beleg_nr": number}).mappings().first()
  172. colleague = ""
  173. if row and row.get("Verursacher_Aktuell_Benutzer_ID"):
  174. try:
  175. user = lookup_directory_user(row["Verursacher_Aktuell_Benutzer_ID"])
  176. if user and not user.disabled:
  177. colleague = user.email
  178. except (DirectoryUnavailable, ValueError):
  179. pass
  180. values = [
  181. ("self", "An mich", current_principal(request).email),
  182. ("colleague", "Verursacher", colleague),
  183. ("customer", "Kunde", row.get("Kunde_Email") if row else ""),
  184. ("insurance", "Versicherung", row.get("Vers_Email") if row else ""),
  185. ]
  186. return [
  187. dict(id=id_, label=label, email=address if valid_address(address) else "") for id_, label, address in values
  188. ]
  189. def valid_address(value):
  190. from email.headerregistry import Address
  191. if not value or any(c in value for c in "\r\n"):
  192. return False
  193. try:
  194. address = Address(addr_spec=value)
  195. return bool(address.username and address.domain)
  196. except ValueError:
  197. return False
  198. @router.get(BASE + "/{primary_key}/recipients/{token}", response_class=HTMLResponse)
  199. def recipient_list(request: Request, primary_key: str, token: str, scope: Scope = "invoice", db=Depends(get_session)):
  200. doc, ctx = selected(request, primary_key, db, scope, token)
  201. ctx.update(document=doc, recipients=recipients(request, primary_key, db))
  202. return templates.TemplateResponse(
  203. request, "forderungen/documents/recipients.html", ctx, headers={"Cache-Control": "no-store"}
  204. )
  205. @router.get(BASE + "/{primary_key}/draft/{token}")
  206. def draft(
  207. request: Request, primary_key: str, token: str, recipient: str, scope: Scope = "invoice", db=Depends(get_session)
  208. ):
  209. doc, _ = selected(request, primary_key, db, scope, token)
  210. target = next((r for r in recipients(request, primary_key, db) if r["id"] == recipient), None)
  211. if not target or not target["email"]:
  212. raise HTTPException(422, "Keine gültige hinterlegte E-Mail-Adresse vorhanden")
  213. data, media, name = content(primary_key, doc)
  214. message = EmailMessage(policy=SMTP)
  215. message["To"] = target["email"]
  216. message["Subject"] = "Dokument zu Beleg " + primary_key.split("_", 1)[1]
  217. message["X-Unsent"] = "1"
  218. message.set_content("Guten Tag,\n\nim Anhang finden Sie das ausgewählte Dokument.\n")
  219. major, minor = media.split(";", 1)[0].split("/", 1)
  220. message.add_attachment(data, maintype=major, subtype=minor, filename=name)
  221. return Response(
  222. message.as_bytes(),
  223. media_type="message/rfc822",
  224. headers={"Content-Disposition": "attachment; filename=Dokument.eml", "Cache-Control": "no-store"},
  225. )