bmc_hub/app/vendors/backend/resources.py
Christian 4871dd2d9e Add comprehensive test coverage for various modules
- Introduced tests for mail templates including ranking, rendering, and attachment handling.
- Added tests for time billing selection and approval processes, ensuring proper validation and state management.
- Implemented tests for case types in JavaScript, focusing on loading and editing functionality.
- Created tests for contact mail AI integration, ensuring proper parsing and error handling.
- Developed tests for dark theme CSS contrast and loading order.
- Added tests for invoice fee service, covering various scenarios including exemptions and invalid inputs.
- Implemented vendor resource tests, ensuring proper file handling and query validation.
2026-10-08 08:58:02 +02:00

183 lines
8.4 KiB
Python

"""Supplier resources; explicit supplier links only, never inferred by domain."""
from pathlib import Path
from urllib.parse import quote
from fastapi import APIRouter, File, HTTPException, Query, Request, UploadFile
from fastapi.responses import Response
from pydantic import BaseModel, Field
from psycopg2 import Binary
from app.core.database import execute_query, execute_query_single, execute_update
from app.core.config import settings
from app.utils.safe_html import sanitize_safe_html
router = APIRouter()
MAX_FILE_BYTES = 20 * 1024 * 1024
def require_vendor(vendor_id):
if not execute_query_single("SELECT id FROM vendors WHERE id = %s", (vendor_id,)):
raise HTTPException(404, "Leverandør ikke fundet")
def download(content, filename, inline=False):
content = bytes(content)
media_type = "application/octet-stream"
if inline:
if content.startswith(b"%PDF-"):
media_type = "application/pdf"
elif content.startswith(b"\x89PNG\r\n\x1a\n"):
media_type = "image/png"
elif content.startswith(b"\xff\xd8\xff"):
media_type = "image/jpeg"
elif content.startswith((b"GIF87a", b"GIF89a")):
media_type = "image/gif"
elif Path(filename).suffix.lower() in {".txt", ".csv", ".log"}:
media_type = "text/plain"
disposition = "inline" if inline and media_type != "application/octet-stream" else "attachment"
return Response(content, media_type=media_type, headers={
"Content-Disposition": disposition + "; filename*=UTF-8''" + quote(filename, safe=""),
"X-Content-Type-Options": "nosniff",
"Content-Security-Policy": "sandbox; default-src 'none'",
})
@router.get("/vendors/{vendor_id}/files")
async def files(vendor_id: int):
require_vendor(vendor_id)
return execute_query("""
SELECT id, filename, content_type, size_bytes, created_at, 'upload' AS source,
NULL::integer AS email_id FROM vendor_files WHERE vendor_id = %s
UNION ALL
SELECT a.id, a.filename, a.content_type, a.size_bytes, a.created_at,
'email' AS source, e.id AS email_id
FROM email_attachments a JOIN email_messages e ON e.id = a.email_id
WHERE e.supplier_id = %s AND e.deleted_at IS NULL
ORDER BY created_at DESC, id DESC
""", (vendor_id, vendor_id)) or []
@router.post("/vendors/{vendor_id}/files", status_code=201)
async def upload_file(vendor_id: int, request: Request, file: UploadFile = File(...)):
require_vendor(vendor_id)
content = await file.read(MAX_FILE_BYTES + 1)
if len(content) > MAX_FILE_BYTES:
raise HTTPException(413, "Filen må højst være 20 MB")
if not content:
raise HTTPException(422, "Filen er tom")
filename = Path(str(file.filename or "fil").replace("\\", "/")).name
return execute_query_single("""
INSERT INTO vendor_files (vendor_id, filename, content_type, size_bytes,
content_data, uploaded_by_user_id)
VALUES (%s,%s,%s,%s,%s,%s)
RETURNING id, filename, size_bytes, created_at
""", (vendor_id, filename, file.content_type or "application/octet-stream",
len(content), Binary(content), getattr(request.state, "user_id", None)))
@router.get("/vendors/{vendor_id}/files/{source}/{file_id}")
async def download_file(vendor_id: int, source: str, file_id: int, inline: bool = False):
require_vendor(vendor_id)
if source == "upload":
row = execute_query_single("SELECT filename, content_data FROM vendor_files WHERE vendor_id=%s AND id=%s", (vendor_id, file_id))
elif source == "email":
row = execute_query_single("""SELECT a.filename, a.content_data, a.file_path
FROM email_attachments a JOIN email_messages e ON e.id=a.email_id
WHERE e.supplier_id=%s AND e.deleted_at IS NULL AND a.id=%s""", (vendor_id, file_id))
else:
raise HTTPException(404, "Fil ikke fundet")
if not row:
raise HTTPException(404, "Fil ikke fundet")
content = row.get("content_data")
if content is None and row.get("file_path"):
path = Path(row["file_path"]).resolve()
root = Path(settings.UPLOAD_DIR).resolve()
if path.is_relative_to(root) and path.is_file():
content = path.read_bytes()
if content is None:
raise HTTPException(404, "Filindhold mangler. Genhent vedhæftningen fra mailmodulet.")
return download(content, row["filename"], inline=inline)
@router.get("/vendors/{vendor_id}/emails")
async def emails(vendor_id: int, limit: int = Query(50, ge=1, le=100), offset: int = Query(0, ge=0)):
require_vendor(vendor_id)
return execute_query("""SELECT id, subject, sender_email, recipient_email, received_date,
is_read, status, folder, thread_key, has_attachments, linked_case_id
FROM email_messages WHERE supplier_id=%s AND deleted_at IS NULL
ORDER BY received_date DESC NULLS LAST, id DESC LIMIT %s OFFSET %s""",
(vendor_id, limit, offset)) or []
@router.get("/vendors/{vendor_id}/emails/{email_id}")
async def email_detail(vendor_id: int, email_id: int):
require_vendor(vendor_id)
row = execute_query_single("""SELECT id, subject, sender_email, recipient_email, cc,
body_text, body_html, received_date, is_read, linked_case_id
FROM email_messages WHERE supplier_id=%s AND id=%s AND deleted_at IS NULL""", (vendor_id, email_id))
if not row:
raise HTTPException(404, "Mail ikke fundet")
# Preview deliberately does not alter global read-state.
row["body_html"] = sanitize_safe_html(row.get("body_html") or "")
return row
@router.get("/vendors/{vendor_id}/cases")
async def cases(vendor_id: int):
require_vendor(vendor_id)
return execute_query("""SELECT s.id, s.titel, s.status, s.type, s.updated_at,
c.name AS customer_name,
EXISTS(SELECT 1 FROM vendor_case_links v WHERE v.vendor_id=%s AND v.sag_id=s.id) AS explicit_link
FROM sag_sager s LEFT JOIN customers c ON c.id=s.customer_id
WHERE s.deleted_at IS NULL AND (
EXISTS(SELECT 1 FROM vendor_case_links v WHERE v.vendor_id=%s AND v.sag_id=s.id)
OR EXISTS(SELECT 1 FROM email_messages e WHERE e.supplier_id=%s AND e.deleted_at IS NULL
AND (e.linked_case_id=s.id OR EXISTS(SELECT 1 FROM sag_emails se WHERE se.email_id=e.id AND se.sag_id=s.id))))
ORDER BY s.updated_at DESC, s.id DESC""", (vendor_id, vendor_id, vendor_id)) or []
class CaseLink(BaseModel):
sag_id: int = Field(gt=0)
@router.post("/vendors/{vendor_id}/cases")
async def link_case(vendor_id: int, payload: CaseLink):
require_vendor(vendor_id)
if not execute_query_single("SELECT id FROM sag_sager WHERE id=%s AND deleted_at IS NULL", (payload.sag_id,)):
raise HTTPException(404, "Sag ikke fundet")
execute_query("INSERT INTO vendor_case_links(vendor_id,sag_id) VALUES (%s,%s) ON CONFLICT DO NOTHING", (vendor_id, payload.sag_id))
return {"success": True}
@router.get("/vendors/{vendor_id}/contacts")
async def contacts(vendor_id: int):
require_vendor(vendor_id)
return execute_query("""SELECT c.id, c.first_name, c.last_name, c.email, c.phone,
c.mobile, c.title, v.role, v.is_primary FROM vendor_contacts v JOIN contacts c ON c.id=v.contact_id
WHERE v.vendor_id=%s ORDER BY c.first_name, c.last_name, c.id""", (vendor_id,)) or []
class ContactLink(BaseModel):
contact_id: int = Field(gt=0)
role: str = Field(default="", max_length=100)
is_primary: bool | None = None
@router.post("/vendors/{vendor_id}/contacts")
async def link_contact(vendor_id: int, payload: ContactLink):
require_vendor(vendor_id)
if not execute_query_single("SELECT id FROM contacts WHERE id=%s", (payload.contact_id,)):
raise HTTPException(404, "Kontakt ikke fundet")
execute_query("""INSERT INTO vendor_contacts(vendor_id,contact_id,role,is_primary) VALUES (%s,%s,%s,COALESCE(%s,FALSE))
ON CONFLICT(vendor_id,contact_id) DO UPDATE SET role=EXCLUDED.role,
is_primary=COALESCE(%s,vendor_contacts.is_primary)""",
(vendor_id, payload.contact_id, payload.role.strip(), payload.is_primary, payload.is_primary))
return {"success": True}
@router.delete("/vendors/{vendor_id}/contacts/{contact_id}")
async def unlink_contact(vendor_id: int, contact_id: int):
require_vendor(vendor_id)
execute_update("DELETE FROM vendor_contacts WHERE vendor_id=%s AND contact_id=%s", (vendor_id, contact_id))
return {"success": True}