- 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.
183 lines
8.4 KiB
Python
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}
|