bmc_hub/app/modules/sag/backend/file_browser.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

113 lines
6.9 KiB
Python

"""Read-only, case-scoped file browsing including linked mail attachments."""
from io import BytesIO
from pathlib import Path
from html import escape
from urllib.parse import quote
from fastapi import APIRouter, Depends, HTTPException, Response
from app.core.auth_dependencies import get_current_user
from app.core.database import execute_query
router=APIRouter(dependencies=[Depends(get_current_user)])
MAX_BYTES=25*1024*1024
def catalog(sag_id):
if not execute_query('SELECT id FROM sag_sager WHERE id=%s AND deleted_at IS NULL',(sag_id,)):raise HTTPException(404,detail='Sagen findes ikke')
files=execute_query("SELECT *, 'case' AS source FROM sag_files WHERE sag_id=%s",(sag_id,)) or []
mails=execute_query("SELECT a.id,a.email_id,a.filename,a.content_type,a.size_bytes,a.file_path, 'mail' AS source,e.subject AS mail_subject FROM email_attachments a JOIN email_messages e ON e.id=a.email_id JOIN sag_emails se ON se.email_id=e.id WHERE se.sag_id=%s AND e.deleted_at IS NULL",(sag_id,)) or []
return files+mails
def read_file(sag_id,source,file_id):
row=next((r for r in catalog(sag_id) if r['source']==source and r['id']==file_id),None)
if not row:raise HTTPException(404,detail='Filen er ikke knyttet til sagen')
if source=='case':
from .router import _resolve_attachment_path
path=_resolve_attachment_path(row['stored_name'])
else:path=Path(row['file_path']) if row.get('file_path') else None
if path and path.is_file():
if path.stat().st_size>MAX_BYTES:raise HTTPException(413,detail='Filen er for stor til forhåndsvisning (25 MB)')
content=path.read_bytes()
else:
if source=='mail' and 'content_data' not in row:
saved=execute_query('SELECT content_data FROM email_attachments WHERE id=%s',(file_id,))
row['content_data']=saved[0].get('content_data') if saved else None
content=bytes(row.get('content_data') or b'')
if not content:raise HTTPException(404,detail='Filens indhold mangler')
if len(content)>MAX_BYTES:raise HTTPException(413,detail='Filen er for stor til forhåndsvisning (25 MB)')
return row,content
def inspect_file(name,content):
kind=Path(name).suffix.lower()
if kind=='.txt':return {'pages':1,'text':content.decode('utf-8',errors='replace')}
if kind=='.pdf':
import pdfplumber
with pdfplumber.open(BytesIO(content)) as pdf:
if len(pdf.pages)>300:raise HTTPException(413,detail='PDF har over 300 sider')
return {'pages':len(pdf.pages),'text':'\n'.join(p.extract_text() or '' for p in pdf.pages)}
return {'pages':1,'text':''}
@router.get('/sag/{sag_id}/file-browser')
def list_files(sag_id:int):
return [{'id':r['id'],'source':r['source'],'filename':r['filename'],'type':Path(r['filename']).suffix.lower().lstrip('.') or 'andet','size':r.get('size_bytes') or 0,'email_id':r.get('email_id'),'mail_subject':r.get('mail_subject')} for r in catalog(sag_id)]
@router.get('/sag/{sag_id}/file-browser/{source}/{file_id}/text')
def text(sag_id:int,source:str,file_id:int):
row,content=read_file(sag_id,source,file_id)
try:return inspect_file(row['filename'],content)
except HTTPException:raise
except Exception:raise HTTPException(422,detail='Tekst kunne ikke læses; filen kan være beskyttet eller beskadiget')
@router.get('/sag/{sag_id}/file-browser/{source}/{file_id}/download')
def download(sag_id:int,source:str,file_id:int):
row,content=read_file(sag_id,source,file_id)
return Response(content,media_type='application/octet-stream',headers={'Content-Disposition':"attachment; filename*=UTF-8''"+quote(row['filename'],safe=''),'X-Content-Type-Options':'nosniff'})
@router.get('/sag/{sag_id}/file-browser/{source}/{file_id}/page/{page}')
def page_image(sag_id:int,source:str,file_id:int,page:int,thumbnail:bool=False):
row,content=read_file(sag_id,source,file_id)
if Path(row['filename']).suffix.lower()!='.pdf':raise HTTPException(400,detail='Sidevisning gælder PDF')
import pypdfium2 as pdfium
try:
with pdfium.PdfDocument(content) as pdf:
if page<1 or page>len(pdf):raise HTTPException(404,detail='Siden findes ikke')
p=pdf[page-1]
try:
scale=min(1.4,(220 if thumbnail else 1600)/max(p.get_size()))
bitmap=p.render(scale=scale)
try:
out=BytesIO();bitmap.to_pil().save(out,format='PNG');return Response(out.getvalue(),media_type='image/png')
finally:bitmap.close()
finally:p.close()
except HTTPException:raise
except Exception:raise HTTPException(422,detail='PDF kunne ikke vises')
@router.get('/sag/{sag_id}/file-browser/{source}/{file_id}/image')
def image(sag_id:int,source:str,file_id:int,thumbnail:bool=False):
row,content=read_file(sag_id,source,file_id)
from PIL import Image, ImageOps, UnidentifiedImageError
try:
with Image.open(BytesIO(content)) as original:
if original.width*original.height>40_000_000:
raise HTTPException(413,detail='Billedet er for stort til visning')
rendered=ImageOps.exif_transpose(original)
rendered.thumbnail((220,160) if thumbnail else (2000,2000))
if rendered.mode not in ('RGB','RGBA'):rendered=rendered.convert('RGBA')
out=BytesIO();rendered.save(out,format='PNG')
return Response(out.getvalue(),media_type='image/png',headers={'X-Content-Type-Options':'nosniff'})
except HTTPException:raise
except (UnidentifiedImageError, OSError, ValueError, Image.DecompressionBombError):
raise HTTPException(422,detail='Billedet kunne ikke vises')
@router.get('/sag/{sag_id}/file-browser/{source}/{file_id}/thumbnail')
def thumbnail(sag_id:int,source:str,file_id:int):
row=next((r for r in catalog(sag_id) if r['source']==source and r['id']==file_id),None)
if not row:raise HTTPException(404,detail='Filen er ikke knyttet til sagen')
kind=Path(row['filename']).suffix.lower().lstrip('.')
try:
if kind=='pdf':return page_image(sag_id,source,file_id,1,thumbnail=True)
if kind in {'jpg','jpeg','png','gif','webp','bmp','tif','tiff','ico'}:return image(sag_id,source,file_id,thumbnail=True)
except HTTPException:pass # Keep a visible file-type thumbnail for damaged files.
label=escape((kind or 'FIL')[:10].upper())
svg=f'<svg xmlns="http://www.w3.org/2000/svg" width="120" height="150" viewBox="0 0 120 150"><rect x="14" y="6" width="92" height="138" rx="9" fill="#edf3f6" stroke="#879cab"/><path d="M32 40h56M32 52h56M32 64h38" stroke="#a7bac7" stroke-width="4"/><rect x="22" y="88" width="76" height="34" rx="6" fill="#244e48"/><text x="60" y="109" text-anchor="middle" font-family="sans-serif" font-size="12" fill="white">{label}</text></svg>'
return Response(svg,media_type='image/svg+xml',headers={'X-Content-Type-Options':'nosniff','Content-Security-Policy':"default-src 'none'; style-src 'none'; sandbox"})