bmc_hub/app/modules/sag/backend/mail_templates.py

116 lines
8.7 KiB
Python
Raw Normal View History

"""Case-scoped selection of the existing mail templates. No sending here."""
import json
import re
from uuid import UUID
from urllib.parse import quote
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, Response
from psycopg2.extras import RealDictCursor
from app.core.auth_dependencies import require_any_permission
from app.core.database import get_db_connection, release_db_connection
router=APIRouter(dependencies=[Depends(require_any_permission('cases.edit','tickets.edit'))])
MAX_FILE=20*1024*1024
MAX_TOTAL=25*1024*1024
def case_context(cursor,sag_id):
cursor.execute('SELECT s.id,s.titel,s.customer_id,c.name AS customer_name FROM sag_sager s LEFT JOIN customers c ON c.id=s.customer_id WHERE s.id=%s AND s.deleted_at IS NULL',(sag_id,))
case=cursor.fetchone()
if not case:raise HTTPException(404,detail='Sagen findes ikke')
return case
def available(template,case):
return template.get('customer_id') is None or template['customer_id']==case.get('customer_id')
def rank_templates(rows,tags):
case_tags={str(tag).strip().casefold() for tag in tags}
for row in rows:
row['matched_tags']=[tag for tag in row.get('match_tags') or [] if str(tag).strip().casefold() in case_tags]
row['recommended']=bool(row['matched_tags'])
return sorted(rows,key=lambda r:(-len(r['matched_tags']),r.get('customer_id') is None,str(r['name']).casefold()))
def render_text(text,case,contact):
values={'sag_id':str(case['id']),'sag_titel':case['titel'],'kunde_navn':case.get('customer_name') or '', 'kontakt_navn':contact.get('name') or ''}
values.update(customer_name=values['kunde_navn'],name=values['kontakt_navn'])
return re.sub(r'\{\{?\s*(sag_id|sag_titel|kunde_navn|kontakt_navn|customer_name|name)\s*\}?\}',lambda m:values[m[1]],text)
@router.get('/sag/{sag_id}/mail-templates')
async def list_templates(sag_id:int):
conn=get_db_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
case=case_context(cur,sag_id)
cur.execute("SELECT t.name FROM tags t JOIN entity_tags et ON et.tag_id=t.id WHERE et.entity_type='case' AND et.entity_id=%s",(sag_id,));tags=[r['name'] for r in cur.fetchall()]
cur.execute('SELECT t.*, (SELECT COUNT(*) FROM email_template_attachments a WHERE a.template_id=t.id) AS attachment_count FROM email_templates t WHERE t.customer_id IS NULL OR t.customer_id=%s',(case['customer_id'],))
return {'customer_id':case['customer_id'],'customer_name':case['customer_name'],'tags':tags,'templates':rank_templates([dict(r) for r in cur.fetchall()],tags)}
finally:conn.rollback();release_db_connection(conn)
@router.get('/sag/{sag_id}/mail-templates/{template_id}')
async def preview_template(sag_id:int,template_id:int):
conn=get_db_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
case=case_context(cur,sag_id)
cur.execute('SELECT * FROM email_templates WHERE id=%s',(template_id,));template=cur.fetchone()
if not template or not available(template,case):raise HTTPException(404,detail='Skabelonen er ikke tilgængelig for denne kunde')
cur.execute("SELECT CONCAT_WS(' ',c.first_name,c.last_name) AS name,c.email FROM sag_kontakter sk JOIN contacts c ON c.id=sk.contact_id WHERE sk.sag_id=%s AND sk.deleted_at IS NULL ORDER BY sk.is_primary DESC,sk.id LIMIT 1",(sag_id,));contact=cur.fetchone() or {}
cur.execute('SELECT id,filename,content_type,size_bytes FROM email_template_attachments WHERE template_id=%s ORDER BY id',(template_id,))
return {'id':template_id,'name':template['name'],'subject':render_text(template['subject'],case,contact),'body':render_text(template['body'],case,contact),'to':contact.get('email') or '', 'attachments':list(cur.fetchall())}
finally:conn.rollback();release_db_connection(conn)
@router.post('/sag/{sag_id}/mail-templates')
async def create_template(sag_id:int,data:str=Form(...),files:list[UploadFile]=File(default=[])):
try:payload=json.loads(data);request_id=str(UUID(payload['request_id']))
except (ValueError,KeyError,TypeError):raise HTTPException(400,detail='Ugyldige skabelonoplysninger')
if not isinstance(payload,dict):raise HTTPException(400,detail='Ugyldige skabelonoplysninger')
for key,limit in [('name',255),('subject',255),('body',50000)]:
value=payload.get(key)
if not isinstance(value,str) or not value.strip() or len(value)>limit:raise HTTPException(400,detail='Udfyld navn, emne og besked inden for feltets længde')
scope=payload.get('scope','customer')
if scope not in {'global','customer'}:raise HTTPException(400,detail='Vælg global eller kunde')
tags=payload.get('match_tags',[])
if not isinstance(tags,list) or len(tags)>30 or any(not isinstance(t,str) or not t.strip() or len(t)>100 for t in tags):raise HTTPException(400,detail='Ugyldige tags')
assets=[];total=0
if len(files)>10:raise HTTPException(400,detail='Vælg højst 10 filer')
for file in files:
content=await file.read(MAX_FILE+1);total+=len(content)
if len(content)>MAX_FILE or total>MAX_TOTAL:raise HTTPException(413,detail='Hver fil må fylde 20 MB og filerne samlet højst 25 MB')
name=(file.filename or 'vedhaeftning').replace('\\','/').split('/')[-1].replace('\r','').replace('\n','')[:255]
assets.append((name,file.content_type or 'application/octet-stream',content,len(content)))
conn=get_db_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
case=case_context(cur,sag_id)
customer=case['customer_id'] if scope=='customer' else None
if scope=='customer' and not customer:raise HTTPException(400,detail='Sagen skal have en kunde for at gemme en kundeskabelon')
cur.execute('SELECT pg_advisory_xact_lock(hashtextextended(%s,0))',(request_id,))
cur.execute('SELECT id,customer_id,name,subject,body,match_tags FROM email_templates WHERE case_request_id=%s',(request_id,));previous=cur.fetchone()
if previous:
if previous['customer_id']!=customer or any(previous[k]!=payload[k].strip() for k in ['name','subject','body']) or previous['match_tags']!=tags:raise HTTPException(409,detail='Gemningen tilhører en anden skabelon')
cur.execute('SELECT filename,content_type,content,size_bytes FROM email_template_attachments WHERE template_id=%s ORDER BY id',(previous['id'],))
saved=[(r['filename'],r['content_type'],bytes(r['content']),r['size_bytes']) for r in cur.fetchall()]
if saved!=assets:raise HTTPException(409,detail='Gemningen tilhører andre vedhæftninger')
return {'id':previous['id']}
cur.execute("INSERT INTO email_templates (name,slug,subject,body,category,customer_id,match_tags,case_request_id,is_system) VALUES (%s,%s,%s,%s,'case',%s,%s::jsonb,%s,false) RETURNING id",(payload['name'].strip(),'case-'+request_id,payload['subject'].strip(),payload['body'].strip(),customer,json.dumps(tags),request_id));tid=cur.fetchone()['id']
for asset in assets:cur.execute('INSERT INTO email_template_attachments (template_id,filename,content_type,content,size_bytes) VALUES (%s,%s,%s,%s,%s)',(tid,*asset))
conn.commit();return {'id':tid}
except Exception:conn.rollback();raise
finally:conn.rollback();release_db_connection(conn)
def send_attachments(sag_id,ids):
if not ids:return []
if len(ids)>10:raise HTTPException(400,detail='Vælg højst 10 skabelonfiler')
conn=get_db_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
case=case_context(cur,sag_id)
cur.execute('SELECT a.*,t.customer_id FROM email_template_attachments a JOIN email_templates t ON t.id=a.template_id WHERE a.id=ANY(%s)',(list(set(ids)),));rows=cur.fetchall()
if len(rows)!=len(set(ids)) or any(not available(row,case) for row in rows):raise HTTPException(400,detail='En vedhæftning er ikke tilgængelig for denne kunde')
if sum(r['size_bytes'] for r in rows)>MAX_TOTAL:raise HTTPException(413,detail='Skabelonfilerne må samlet fylde højst 25 MB')
return [{'filename':r['filename'],'content_type':r['content_type'],'content':bytes(r['content']),'size':r['size_bytes']} for r in rows]
finally:conn.rollback();release_db_connection(conn)
@router.get('/sag/{sag_id}/mail-template-files/{attachment_id}')
async def download_attachment(sag_id:int,attachment_id:int):
asset=send_attachments(sag_id,[attachment_id])[0]
return Response(asset['content'],media_type='application/octet-stream',headers={'Content-Disposition':f"attachment; filename*=UTF-8''{quote(asset['filename'],safe='')}",'X-Content-Type-Options':'nosniff'})