- 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.
113 lines
6.9 KiB
Python
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"})
|