- 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.
261 lines
20 KiB
Python
261 lines
20 KiB
Python
"""Atomic, opt-in V5 command batches. Existing case APIs are unchanged."""
|
|
import hashlib
|
|
import html
|
|
import json
|
|
import logging
|
|
from datetime import date
|
|
from uuid import UUID
|
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
from fastapi.encoders import jsonable_encoder
|
|
from psycopg2.extras import RealDictCursor
|
|
from app.core.auth_dependencies import get_current_user, require_any_permission
|
|
from app.core.database import get_db_connection, release_db_connection
|
|
|
|
logger=logging.getLogger(__name__)
|
|
router = APIRouter()
|
|
SUPPORTED = {'time','assign','estimate','subcase','self','todo','waiting','solution','deadline','defer','relation','priority','group','note','mail','file','help','timer'}
|
|
|
|
def fail(message):
|
|
raise HTTPException(400, detail=message)
|
|
|
|
def text(command, key, required=True):
|
|
value = str(command.get(key) or '').strip()
|
|
if required and not value: fail(f'{key} skal udfyldes')
|
|
if len(value) > 10000: fail('Teksten er for lang')
|
|
return value
|
|
|
|
def resolve_user(cursor, token):
|
|
token = str(token or '').strip().casefold().lstrip('@')
|
|
cursor.execute('SELECT user_id, username, full_name FROM users WHERE is_active=true')
|
|
matches = []
|
|
for row in cursor.fetchall():
|
|
name = str(row.get('full_name') or '').strip()
|
|
initials = ''.join(p[0] for p in name.split()).casefold()
|
|
if token in {str(row['user_id']),str(row.get('username') or '').casefold(),name.casefold(),initials}:
|
|
matches.append(row)
|
|
if len(matches) != 1: fail('Medarbejderen findes ikke eller initialerne er tvetydige')
|
|
return matches[0]
|
|
|
|
def normalize(cursor, case, payload, actor):
|
|
from .router import _normalize_optional_timestamp, _validate_deferred_deadline
|
|
commands = payload.get('commands')
|
|
if not isinstance(commands, list) or len(commands) > 50: fail('Vælg højst 50 kommandoer')
|
|
if len(str(payload.get('comment') or '')) > 50000: fail('Kommentaren er for lang')
|
|
resolved, children, dates = [], set(), {'main':dict(case)}
|
|
for index, raw in enumerate(commands):
|
|
try:
|
|
if not isinstance(raw,dict): fail('Ugyldig kommando')
|
|
c = {key:value for key,value in raw.items() if key in {'type','scope','line','title','description','text','reason','assignee','minutes','category','settlement_choice','date','target','priority','group','case_id','caseId','target_id','action'}}; kind = c.get('type'); scope = c.get('scope','main')
|
|
if kind in {'mail','note','solution'}: c['description']=c.get('description') or c.get('text')
|
|
if kind=='waiting': c['description']=c.get('description') or c.get('reason')
|
|
if kind=='relation': c['case_id']=c.get('case_id') or c.get('caseId')
|
|
if kind not in SUPPORTED: fail('Ukendt kommando')
|
|
if kind != 'subcase' and scope != 'main' and (not isinstance(scope,int) or isinstance(scope,bool) or scope not in children): fail('// kræver en tidligere undersag')
|
|
if kind == 'subcase':
|
|
perms = set(actor.get('permissions') or [])
|
|
if not actor.get('is_superadmin') and not perms.intersection({'cases.create','tickets.create'}):
|
|
raise HTTPException(403, detail='Du må ikke oprette undersager')
|
|
if scope != 'main' and scope != len(children): fail('Ugyldig undersagsrækkefølge')
|
|
c['scope']='main'
|
|
title=text(c,'title')
|
|
if len(title)>255: fail('Undersagens titel må højst være 255 tegn')
|
|
child_index=len(children); children.add(child_index); c['child_index']=child_index
|
|
dates[child_index]={'deadline':None,'deferred_until':None,'assigned_group_id':dates['main'].get('assigned_group_id')}
|
|
if kind in {'assign','self'}:
|
|
if kind == 'assign': text(c,'reason')
|
|
else: c['reason']=str(c.get('reason') or 'Tildelt til mig via kommando')
|
|
user=resolve_user(cursor,actor['id'] if kind=='self' else text(c,'assignee'))
|
|
c['user_id']=user['user_id']; c['user_name']=user.get('full_name') or user['username']
|
|
cursor.execute('SELECT group_id FROM user_groups WHERE user_id=%s ORDER BY (group_id=%s) DESC, group_id', (c['user_id'],dates[scope].get('assigned_group_id')))
|
|
group=cursor.fetchone()
|
|
if not group: fail('Medarbejderen mangler en gruppe')
|
|
c['group_id']=group['group_id']; dates[scope]['assigned_group_id']=c['group_id']
|
|
if kind == 'group':
|
|
token=text(c,'group'); text(c,'reason')
|
|
cursor.execute('SELECT id,name FROM groups WHERE LOWER(name)=LOWER(%s)',(token,)); groups=cursor.fetchall()
|
|
if len(groups)!=1: fail('Gruppen findes ikke eller er tvetydig')
|
|
c['group_id']=groups[0]['id']; c['group_name']=groups[0]['name']; dates[scope]['assigned_group_id']=c['group_id']
|
|
if kind in {'time','estimate'}:
|
|
minutes=c.get('minutes')
|
|
if isinstance(minutes,bool) or not isinstance(minutes,int) or not (0 if kind=='estimate' else 1)<=minutes<=525600: fail('Ugyldigt minuttal')
|
|
if kind=='time':
|
|
text(c,'description'); text(c,'category',required=False); choice=str(c.get('settlement_choice') or 'draft')
|
|
from app.timetracking.backend.router import _case_billing_choice
|
|
c['billing']=_case_billing_choice(case['id'],choice)
|
|
c['settlement_choice']=choice
|
|
if kind=='todo':
|
|
text(c,'title')
|
|
if c.get('assignee'):
|
|
target=resolve_user(cursor,c['assignee']); c['user_name']=target.get('full_name') or target['username']; c['assigned_to_user_id']=target['user_id']
|
|
if c.get('date'):
|
|
try: date.fromisoformat(c['date'])
|
|
except (ValueError,TypeError): fail('Ugyldig tododato')
|
|
if kind in {'waiting','solution','note','mail'}: text(c,'description')
|
|
if kind=='waiting':
|
|
target=str(c.get('target') or 'kunde').lower()
|
|
if target not in {'kunde','leverandør','intern','customer','supplier'}: fail('Ukendt afventer-type')
|
|
c['target']=target
|
|
if kind=='priority':
|
|
value=str(c.get('priority') or '').lower()
|
|
mapping={'høj':'high','normal':'normal','lav':'low','kritisk':'urgent','high':'high','low':'low','critical':'urgent','urgent':'urgent'}
|
|
if value not in mapping: fail('Ukendt prioritet')
|
|
c['priority']=mapping[value]; text(c,'reason')
|
|
if kind in {'deadline','defer'}:
|
|
value=_normalize_optional_timestamp(text(c,'date'),'deadline' if kind=='deadline' else 'deferred_until')
|
|
dates[scope]['deadline' if kind=='deadline' else 'deferred_until']=value
|
|
|
|
if kind=='relation':
|
|
target=c.get('case_id') or c.get('target_id')
|
|
try: target=int(str(target).lstrip('#'))
|
|
except (ValueError,TypeError): fail('Ugyldigt sagsnummer')
|
|
if scope=='main' and target==case['id']: fail('Sagen kan ikke relateres til sig selv')
|
|
cursor.execute('SELECT id,titel,customer_id FROM sag_sager WHERE id=%s AND deleted_at IS NULL',(target,)); relation=cursor.fetchone()
|
|
if not relation: fail('Den relaterede sag findes ikke')
|
|
c['case_id']=target; c['case_title']=relation['titel']; c['customer_id']=relation['customer_id']
|
|
if kind=='timer':
|
|
if c.get('action') not in {'start','pause'}: fail('Timer kræver start eller pause')
|
|
if c['action']=='start': text(c,'description')
|
|
resolved.append(c)
|
|
except HTTPException as error:
|
|
line = raw.get('line', index + 1) if isinstance(raw, dict) else index + 1
|
|
if not isinstance(line, int) or isinstance(line, bool) or line < 1:
|
|
line = index + 1
|
|
raise HTTPException(error.status_code, detail=f"Linje {line}: {error.detail}") from error
|
|
for values in dates.values():
|
|
_validate_deferred_deadline(values.get('deferred_until'),values.get('deadline'))
|
|
return resolved
|
|
|
|
def comment(cursor,case_id,author,content,internal=True,system=False):
|
|
cursor.execute('INSERT INTO sag_kommentarer (sag_id,forfatter,indhold,er_intern,er_system_besked) VALUES (%s,%s,%s,%s,%s) RETURNING id',(case_id,author,html.escape(content),internal,system))
|
|
return cursor.fetchone()['id']
|
|
|
|
def apply(cursor,case,commands,payload,actor):
|
|
result=[]; children={}; author=actor.get('full_name') or actor.get('username') or 'Bruger'
|
|
if str(payload.get('comment') or '').strip():
|
|
result.append({'type':'comment','case_id':case['id'],'id':comment(cursor,case['id'],author,str(payload['comment']),payload.get('internal',True) is not False)})
|
|
for c in commands:
|
|
kind=c['type']; cid=case['id'] if kind=='subcase' or c.get('scope','main')=='main' else children[c['scope']]; item={'type':kind,'case_id':cid}
|
|
if kind in {'file','help'}:
|
|
item['client_action']=c; result.append(item); continue
|
|
if kind=='subcase':
|
|
cursor.execute('SELECT assigned_group_id FROM sag_sager WHERE id=%s',(case['id'],)); parent_group=cursor.fetchone()['assigned_group_id']
|
|
cursor.execute("INSERT INTO sag_sager (titel,beskrivelse,template_key,type,status,customer_id,ansvarlig_bruger_id,assigned_group_id,created_by_user_id) VALUES (%s,'','ticket','ticket','Ny',%s,%s,%s,%s) RETURNING id",(c['title'],case.get('customer_id'),actor['id'],parent_group,actor['id']))
|
|
child=cursor.fetchone()['id']; children[c['child_index']]=child; item['id']=child
|
|
cursor.execute('INSERT INTO sag_relationer (kilde_sag_id,målsag_id,relationstype) VALUES (%s,%s,%s)',(case['id'],child,'undersag'))
|
|
cursor.execute('INSERT INTO sag_kontakter (sag_id,contact_id,role,is_primary) SELECT %s,contact_id,role,is_primary FROM sag_kontakter WHERE sag_id=%s AND deleted_at IS NULL',(child,case['id']))
|
|
elif kind in {'assign','self','group'}:
|
|
gid=c['group_id']; uid=c.get('user_id') if kind!='group' else None
|
|
cursor.execute('DELETE FROM sag_case_assignees a WHERE sag_id=%s AND NOT EXISTS (SELECT 1 FROM user_groups ug WHERE ug.user_id=a.user_id AND ug.group_id=%s)',(cid,gid))
|
|
if uid: cursor.execute('INSERT INTO sag_case_assignees (sag_id,user_id,assigned_by_user_id) VALUES (%s,%s,%s) ON CONFLICT (sag_id,user_id) DO NOTHING',(cid,uid,actor['id']))
|
|
cursor.execute('UPDATE sag_sager SET assigned_group_id=%s,ansvarlig_bruger_id=%s WHERE id=%s',(gid,uid,cid))
|
|
comment(cursor,cid,author,f"Tildeling: {c.get('user_name') or c.get('group')}\nBegrundelse: {c['reason']}")
|
|
elif kind=='mail':
|
|
cursor.execute('INSERT INTO sag_command_mail_drafts (sag_id,actor_id,subject,body) VALUES (%s,%s,%s,%s) RETURNING id',(cid,actor['id'],case['titel'],c['description'])); item['id']=cursor.fetchone()['id']
|
|
elif kind=='timer':
|
|
cursor.execute('SELECT user_id FROM users WHERE user_id=%s FOR UPDATE',(actor['id'],))
|
|
if c['action']=='pause':
|
|
cursor.execute('UPDATE tmodule_times SET aktiv_timer=false,paused_at=CURRENT_TIMESTAMP WHERE medarbejder_id=%s AND sag_id=%s AND aktiv_timer=true AND slut_tid IS NULL RETURNING id',(actor['id'],cid))
|
|
paused=cursor.fetchone()
|
|
if not paused: fail('Ingen aktiv timer på denne sag')
|
|
item['id']=paused['id']
|
|
else:
|
|
cursor.execute('SELECT id FROM tmodule_times WHERE medarbejder_id=%s AND aktiv_timer=true AND slut_tid IS NULL',(actor['id'],))
|
|
if cursor.fetchone(): raise HTTPException(409,detail='Du har allerede en aktiv timer. Pause den først.')
|
|
cursor.execute("INSERT INTO tmodule_times (sag_id,customer_id,description,original_hours,worked_date,user_name,status,billable,billing_method,start_tid,entry_type,kilde,entry_status,medarbejder_id,aktiv_timer,round_block_min,ikke_placeret,work_type) SELECT id,customer_id,%s,0.01,CURRENT_DATE,%s,'pending',(customer_id IS NOT NULL),'draft',CURRENT_TIMESTAMP,'manuel','sag_v5','kladde',%s,true,30,false,'support' FROM sag_sager WHERE id=%s RETURNING id",(c['description'],author,actor['id'],cid)); item['id']=cursor.fetchone()['id']
|
|
elif kind=='estimate': cursor.execute('UPDATE sag_sager SET estimated_minutes=%s WHERE id=%s',(c['minutes'],cid))
|
|
elif kind in {'deadline','defer','priority'}:
|
|
column={'deadline':'deadline','defer':'deferred_until','priority':'priority'}[kind]; value=c.get('date') if kind!='priority' else c['priority']
|
|
cursor.execute(f'UPDATE sag_sager SET {column}=%s WHERE id=%s',(value,cid))
|
|
comment(cursor,cid,author,f"{kind}: {value}"+(f"\nBegrundelse: {c['reason']}" if kind=='priority' else ''),system=True)
|
|
elif kind in {'todo','waiting'}:
|
|
title=c['title'] if kind=='todo' else f"Afventer {c['target']}: {c['description']}"
|
|
cursor.execute('INSERT INTO sag_todo_steps (sag_id,title,description,due_date,created_by_user_id,assigned_to_user_id) VALUES (%s,%s,%s,%s,%s,%s) RETURNING id',(cid,title,(f"Ansvarlig: {c['user_name']}" if c.get('user_name') else c.get('description')),c.get('date'),actor['id'],c.get('assigned_to_user_id'))); item['id']=cursor.fetchone()['id']
|
|
if kind=='waiting':
|
|
cursor.execute("UPDATE sag_sager SET status='Afventer' WHERE id=%s",(cid,)); comment(cursor,cid,author,title,system=True)
|
|
elif kind=='note': item['id']=comment(cursor,cid,author,c['description'])
|
|
elif kind=='solution':
|
|
cursor.execute('SELECT id,deleted_at FROM sag_solutions WHERE sag_id=%s FOR UPDATE',(cid,)); existing=cursor.fetchone()
|
|
if existing: raise HTTPException(409,detail='Sagen har allerede en løsning. Ret den eksisterende løsning i løsningsmodulet.')
|
|
cursor.execute("INSERT INTO sag_solutions (sag_id,title,description,solution_type,result,approval_status,visibility,is_final,created_by_user_id,updated_by_user_id) VALUES (%s,%s,%s,'Support','Løst','draft','internal',true,%s,%s) RETURNING *",(cid,'Løsning',c['description'],actor['id'],actor['id'])); solution=dict(cursor.fetchone()); item['id']=solution['id']
|
|
cursor.execute('INSERT INTO sag_solution_versions (solution_id,version_number,snapshot,changed_by_user_id,change_note) VALUES (%s,1,%s::jsonb,%s,%s)',(solution['id'],json.dumps(jsonable_encoder(solution)),actor['id'],'Løsning oprettet via kommando'))
|
|
elif kind=='relation':
|
|
cursor.execute("INSERT INTO sag_relationer (kilde_sag_id,målsag_id,relationstype) SELECT %s,%s,'Relateret til' WHERE NOT EXISTS (SELECT 1 FROM sag_relationer WHERE kilde_sag_id=%s AND målsag_id=%s AND relationstype='Relateret til' AND deleted_at IS NULL)",(cid,c['case_id'],cid,c['case_id']))
|
|
elif kind=='time':
|
|
cursor.execute('SELECT customer_id FROM sag_sager WHERE id=%s',(cid,)); customer=cursor.fetchone()['customer_id']; minutes=c['minutes']; rounded=((minutes+29)//30)*30
|
|
cursor.execute("""INSERT INTO tmodule_times (sag_id,customer_id,description,original_hours,worked_date,user_name,status,billable,billing_method,faktisk_tid_min,fakturerbar_tid_min,entry_type,kilde,entry_status,medarbejder_id,aktiv_timer,round_block_min,ikke_placeret,work_type,prepaid_card_id,fixed_price_agreement_id)
|
|
VALUES (%s,%s,%s,%s,CURRENT_DATE,%s,'pending',%s,%s,%s,%s,'manuel','sag_v5',%s,%s,false,30,false,'other',%s,%s) RETURNING id""",(cid,customer,f"{c.get('category') or 'Arbejde'}: {c['description']}",minutes/60,author,customer is not None,c['billing']['method'],minutes,rounded,c['billing']['status'],actor['id'],c['billing']['card_id'],c['billing']['agreement_id']))
|
|
item['id']=cursor.fetchone()['id']
|
|
result.append(item)
|
|
return result
|
|
|
|
async def run(sag_id,payload,actor,execute=False):
|
|
conn=get_db_connection()
|
|
try:
|
|
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
|
|
cursor.execute('SELECT * FROM sag_sager WHERE id=%s AND deleted_at IS NULL FOR UPDATE',(sag_id,)); case=cursor.fetchone()
|
|
if not case: raise HTTPException(404,detail='Sagen findes ikke')
|
|
digest=hashlib.sha256(json.dumps({'comment':payload.get('comment'),'commands':payload.get('commands'),'internal':payload.get('internal',True)},sort_keys=True,ensure_ascii=False).encode()).hexdigest()
|
|
rid=None
|
|
if execute:
|
|
try: rid=str(UUID(str(payload.get('request_id'))))
|
|
except (ValueError,TypeError): fail('request_id skal være UUID')
|
|
cursor.execute('SELECT pg_advisory_xact_lock(hashtextextended(%s,0))',(rid,))
|
|
cursor.execute('SELECT * FROM sag_command_batches WHERE request_id=%s',(rid,)); previous=cursor.fetchone()
|
|
if previous:
|
|
if previous['sag_id']!=sag_id or previous['actor_id']!=actor['id'] or previous['payload_hash']!=digest: raise HTTPException(409,detail='Denne request_id er allerede brugt til andre handlinger')
|
|
conn.rollback()
|
|
return previous['result']
|
|
commands=normalize(cursor,case,payload,actor)
|
|
if not execute:
|
|
conn.rollback()
|
|
return {'commands':commands,'errors':[]}
|
|
result={'request_id':rid,'results':apply(cursor,case,commands,payload,actor),'errors':[]}
|
|
cursor.execute('INSERT INTO sag_command_batches (request_id,sag_id,actor_id,payload_hash,result) VALUES (%s,%s,%s,%s,%s::jsonb)',(rid,sag_id,actor['id'],digest,json.dumps(jsonable_encoder(result))))
|
|
conn.commit(); return result
|
|
except HTTPException:
|
|
conn.rollback(); raise
|
|
except Exception:
|
|
conn.rollback()
|
|
logger.exception('Case command batch failed for case %s',sag_id)
|
|
raise HTTPException(500,detail='Handlingerne blev ikke gemt. Prøv igen; ingen delvise ændringer er gemt.')
|
|
finally:
|
|
conn.rollback()
|
|
release_db_connection(conn)
|
|
|
|
@router.post('/sag/{sag_id}/command-preview', dependencies=[Depends(require_any_permission('cases.edit','tickets.edit'))])
|
|
async def preview_commands(sag_id:int,payload:dict,current_user:dict=Depends(get_current_user)):
|
|
return await run(sag_id,payload,current_user)
|
|
|
|
@router.post('/sag/{sag_id}/command-execute', dependencies=[Depends(require_any_permission('cases.edit','tickets.edit'))])
|
|
async def execute_commands(sag_id:int,payload:dict,current_user:dict=Depends(get_current_user)):
|
|
return await run(sag_id,payload,current_user,True)
|
|
|
|
@router.get('/sag/{sag_id}/command-mail-drafts', dependencies=[Depends(require_any_permission('cases.view','tickets.view'))])
|
|
async def command_mail_drafts(sag_id:int,current_user:dict=Depends(get_current_user)):
|
|
conn=get_db_connection()
|
|
try:
|
|
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
|
|
cursor.execute('SELECT * FROM sag_command_mail_drafts WHERE sag_id=%s AND actor_id=%s ORDER BY id DESC',(sag_id,current_user['id']))
|
|
return list(cursor.fetchall())
|
|
finally:
|
|
conn.rollback()
|
|
release_db_connection(conn)
|
|
|
|
|
|
@router.get('/sag/{sag_id}/command-options', dependencies=[Depends(require_any_permission('cases.edit','tickets.edit'))])
|
|
async def command_options(sag_id:int,current_user:dict=Depends(get_current_user)):
|
|
conn=get_db_connection()
|
|
try:
|
|
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
|
|
cursor.execute('SELECT id FROM sag_sager WHERE id=%s AND deleted_at IS NULL',(sag_id,))
|
|
if not cursor.fetchone(): raise HTTPException(404,detail='Sagen findes ikke')
|
|
cursor.execute('SELECT user_id,username,full_name FROM users WHERE is_active=true ORDER BY full_name NULLS LAST,username')
|
|
users=list(cursor.fetchall())
|
|
cursor.execute('SELECT id,name FROM groups ORDER BY name')
|
|
groups=list(cursor.fetchall())
|
|
cursor.execute('SELECT id,titel FROM sag_sager WHERE deleted_at IS NULL AND id<>%s ORDER BY id DESC LIMIT 100',(sag_id,))
|
|
return {'users':users,'groups':groups,'cases':list(cursor.fetchall())}
|
|
finally:
|
|
conn.rollback()
|
|
release_db_connection(conn)
|