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

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)