"""Personal work queues; planning never creates billable time implicitly.""" from datetime import date, datetime, time, timedelta, timezone from contextlib import nullcontext from decimal import Decimal from typing import Literal import re from fastapi import APIRouter, Depends, HTTPException, Query from fastapi.encoders import jsonable_encoder from psycopg2.extras import Json from pydantic import BaseModel, Field from app.core.auth_dependencies import get_current_user from app.core.database import atomic_transaction, execute_query, execute_query_single, _transaction_connection from app.core.case_visibility import require_case,hidden_case_ids,filter_data from .router import TZ, CLOSED_STATUSES, _aware, _can_edit, _can_view_target, _require_view def require_schema(): row = execute_query_single("SELECT to_regclass('planner_activity_types') IS NOT NULL AS ready") if not row or not row['ready']: raise HTTPException(503, 'Planlægning afventer databaseopdatering. Anvend migration 1070.') router = APIRouter(dependencies=[Depends(require_schema)]) FINISHED = CLOSED_STATUSES | {'løst','resolved','annulleret', 'cancelled', 'canceled'} WAITING = {'afventer', 'on_hold', 'waiting', 'blocked', 'blokeret'} def db_utc(value): """Existing case/time TIMESTAMP columns store UTC without an offset.""" return value.replace(tzinfo=timezone.utc) if value.tzinfo is None else value.astimezone(timezone.utc) def require_target(user, target, edit=False): _require_view(user) visible=_can_view_target(user,target) if not visible or (edit and not _can_edit(user,target)): raise HTTPException(403, 'Du har ikke adgang til denne medarbejders plan') if not (user.get('is_superadmin') or set(user.get('permissions') or []) & {'cases.view', 'tickets.view'}): raise HTTPException(403, 'Du har ikke adgang til sager') def audit(user, action, entity, entity_id, before=None, after=None, sag_id=None): entry = execute_query_single( """INSERT INTO planner_allocation_history (sag_id,action,changed_by,before_value,after_value,entity_type,entity_id) VALUES(%s,%s,%s,%s,%s,%s,%s) RETURNING id""", (sag_id, action, user['id'], Json(jsonable_encoder(before)), Json(jsonable_encoder(after)), entity, entity_id)) if sag_id and entity=='assignee' and action in {'assign','transfer','queue_change'} and entity_id!=user['id']: key=(f'queue:{entity_id}:{sag_id}:{int(datetime.now(timezone.utc).timestamp()//300)}' if action=='queue_change' else f'audit:{entry["id"]}') notify_case(user,sag_id,key, 'Din allokering eller arbejdsrækkefølge er ændret.',[entity_id]) return entry['id'] def notify_case(user,sag_id,key,message,recipients): ids=sorted(set(int(uid) for uid in recipients if int(uid)!=int(user['id']))) if not ids or not sag_id: return execute_query('''INSERT INTO sag_reminders(sag_id,title,message,event_type,trigger_type,trigger_config, recipient_user_ids,notify_mattermost,notify_email,notify_frontend,override_user_preferences, recurrence_type,scheduled_at,next_check_at,created_by_user_id) VALUES(%s,'Planlægning ændret',%s,'reminder','time_based',%s,%s,FALSE,FALSE,TRUE,TRUE,'once',NOW(),NOW(),%s) ON CONFLICT DO NOTHING''',(sag_id,message,Json({'planner_key':key}),ids,user['id']),fetch=False) def lock_queue(target): # The same employee lock is used for membership, state and order changes. execute_query('SELECT pg_advisory_xact_lock(%s,%s)', (252, target)) execute_query('INSERT INTO planner_queue_versions(user_id) VALUES(%s) ON CONFLICT DO NOTHING', (target,), fetch=False) return execute_query_single('SELECT version FROM planner_queue_versions WHERE user_id=%s FOR UPDATE', (target,))['version'] def bump_queue(target): execute_query('UPDATE planner_queue_versions SET version=version+1 WHERE user_id=%s', (target,), fetch=False) def ready(row, now): if str(row.get('status') or '').lower() in FINISHED | WAITING: return False if row.get('work_status') in {'waiting', 'paused', 'done'}: return False if row.get('snoozed_until') and _aware(row['snoozed_until']) > now: return False deferred=row.get('deferred_until') if deferred: if isinstance(deferred,datetime): if db_utc(deferred)>now: return False elif deferred>now.date(): return False if row.get('deferred_until_case_id'): targets = {s.strip().casefold() for s in re.split(r'[,;\n]',str(row.get('deferred_until_status') or '')) if s.strip()} if not targets or str(row.get('deferred_case_status') or '').casefold() not in targets: return False if row.get('start_date'): start = datetime.combine(row['start_date'], row.get('start_time') or time.min, TZ) if start > now: return False return True def remaining(row): if row.get('remaining_minutes') is not None: return int(row['remaining_minutes']) estimate = row.get('estimated_minutes') return None if estimate is None else max(0, int(estimate) - int(row.get('actual_minutes') or 0)) def queue_rows(target, limit=100, offset=0, user=None, q=''): from app.modules.sag.frontend.views import _fetch_closed_case_statuses closed = FINISHED | set(_fetch_closed_case_statuses()) rows = execute_query( """SELECT * FROM (SELECT a.*,s.titel,s.status,s.priority,s.deadline,s.start_date,s.start_time,s.updated_at AS case_updated_at, s.deferred_until,s.deferred_until_case_id,s.deferred_until_status,waiting.status AS deferred_case_status, s.type,s.estimated_minutes AS case_estimated_minutes, ARRAY(WITH RECURSIVE labels AS ( SELECT t.id,t.name,t.parent_id FROM tags t JOIN entity_tags et ON et.tag_id=t.id WHERE et.entity_type='case' AND et.entity_id=s.id UNION SELECT p.id,p.name,p.parent_id FROM tags p JOIN labels l ON p.id=l.parent_id) SELECT name::text FROM labels UNION SELECT tag_navn::text FROM sag_tags WHERE sag_id=s.id AND deleted_at IS NULL) AS tags, c.name AS customer_name,COALESCE(t.minutes,0) AS actual_minutes,COALESCE(t.active,FALSE) AS active_timer, COALESCE(p.minutes,0) AS future_planned_minutes,COALESCE(p.slots,'[]'::jsonb) AS planned_slots FROM sag_case_assignees a JOIN sag_sager s ON s.id=a.sag_id LEFT JOIN sag_sager waiting ON waiting.id=s.deferred_until_case_id LEFT JOIN customers c ON c.id=s.customer_id LEFT JOIN LATERAL (SELECT SUM(COALESCE(faktisk_tid_min, ROUND(COALESCE(approved_hours,original_hours,0)*60))) AS minutes, BOOL_OR(aktiv_timer AND slut_tid IS NULL) AS active FROM tmodule_times WHERE sag_id=a.sag_id AND medarbejder_id=a.user_id AND status<>'rejected') t ON TRUE LEFT JOIN LATERAL (SELECT SUM(EXTRACT(EPOCH FROM (ends_at-GREATEST(starts_at,NOW())))/60)::int AS minutes, jsonb_agg(jsonb_build_object('event_id',event_id,'starts_at',starts_at,'ends_at',ends_at) ORDER BY starts_at,id) AS slots FROM planner_allocations WHERE sag_id=a.sag_id AND user_id=a.user_id AND deleted_at IS NULL AND ends_at>NOW()) p ON TRUE WHERE a.user_id=%s AND s.deleted_at IS NULL AND LOWER(s.status)<>ALL(%s) AND NOT(s.id=ANY(%s)) ) entries WHERE (%s='' OR CONCAT_WS(' ',sag_id::text,titel,customer_name,type,ARRAY_TO_STRING(tags,' ')) ILIKE %s) ORDER BY pinned DESC,queue_position,sag_id LIMIT %s OFFSET %s""", (target, list(closed), list(hidden_case_ids(user)) if user else [], q, '%'+q+'%', limit, offset)) or [] for row in rows: for key in ('deadline','deferred_until','case_updated_at'): if isinstance(row.get(key),datetime): row[key]=db_utc(row[key]).astimezone(TZ) return rows @router.get('/queue') def get_queue(user_id: int | None = None, limit: int = Query(100, ge=1, le=500), offset: int = Query(0, ge=0), user: dict = Depends(get_current_user), q: str = ''): target = user_id or int(user['id']) require_target(user, target) # A single repeatable snapshot prevents a stale body being paired with a new version. with atomic_transaction(): execute_query('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ', fetch=False) version = execute_query_single('SELECT version FROM planner_queue_versions WHERE user_id=%s', (target,)) rows = queue_rows(target, limit, offset,user,q.strip()[:200]) now = datetime.now(TZ) for row in rows: row['ready'] = ready(row, now) row['remaining_work_minutes'] = remaining(row) row['queue_minutes'] = (None if remaining(row) is None else max(0, remaining(row) - row['future_planned_minutes'])) row['url'] = f"/sag/{row['sag_id']}" row['planning_status']='fast_planlagt' if row['future_planned_minutes'] else 'i_arbejdsko' row['has_queue_work']=row['queue_minutes'] is None or row['queue_minutes']>0 return {'user_id': target, 'version': version['version'] if version else 1, 'items': rows, 'limit': limit, 'offset': offset} class QueueChange(BaseModel): version: int = Field(ge=1) before_sag_id: int | None = None pinned: bool | None = None work_status: Literal['ready', 'working', 'paused', 'waiting', 'done'] | None = None estimated_minutes: int | None = Field(default=None, ge=0, le=525600) remaining_minutes: int | None = Field(default=None, ge=0, le=525600) snoozed_until: datetime | None = None @router.patch('/queue/{target}/{sag_id}') def change_queue(target: int, sag_id: int, payload: QueueChange, user: dict = Depends(get_current_user)): require_target(user, target, True) require_case(user,sag_id) with (nullcontext() if _transaction_connection.get() else atomic_transaction()): execute_query('SELECT id FROM sag_sager WHERE id=%s FOR KEY SHARE',(sag_id,)) version = lock_queue(target) if version != payload.version: raise HTTPException(409, 'Køen er ændret. Genindlæs og prøv igen.') old = execute_query_single('SELECT * FROM sag_case_assignees WHERE user_id=%s AND sag_id=%s FOR UPDATE', (target, sag_id)) if not old: raise HTTPException(404, 'Allokering findes ikke') values = {k: v for k, v in payload.model_dump().items() if k in payload.model_fields_set and k not in {'version', 'before_sag_id'}} if values.get('work_status') is None and 'work_status' in values: raise HTTPException(400, 'Arbejdsstatus skal angives') if values.get('pinned') is None and 'pinned' in values: raise HTTPException(400, 'Fastlåsning skal angives') if values.get('snoozed_until') is not None: values['snoozed_until'] = _aware(values['snoozed_until']) if 'before_sag_id' in payload.model_fields_set or 'pinned' in values: group = values.get('pinned', old['pinned']) siblings = execute_query('SELECT sag_id,queue_position FROM sag_case_assignees WHERE user_id=%s AND pinned=%s AND sag_id<>%s ORDER BY queue_position,sag_id', (target, group, sag_id)) or [] index = len(siblings) if payload.before_sag_id is not None: index = next((i for i, r in enumerate(siblings) if r['sag_id'] == payload.before_sag_id), -1) if index < 0: raise HTTPException(409, 'Målopgaven findes ikke i samme køgruppe') low = Decimal(siblings[index-1]['queue_position']) if index else Decimal(0) high = Decimal(siblings[index]['queue_position']) if index < len(siblings) else low + 2048 if high-low < Decimal('0.000001'): for i, row in enumerate(siblings): execute_query('UPDATE sag_case_assignees SET queue_position=%s WHERE user_id=%s AND sag_id=%s', ((i+1)*1024, target, row['sag_id']), fetch=False) low = Decimal(index*1024) high = low+1024 values['queue_position'] = (low+high)/2 if values: assignments = ','.join(f'{key}=%s' for key in values) execute_query(f'UPDATE sag_case_assignees SET {assignments},version=version+1 WHERE user_id=%s AND sag_id=%s', (*values.values(), target, sag_id), fetch=False) bump_queue(target) new = execute_query_single('SELECT * FROM sag_case_assignees WHERE user_id=%s AND sag_id=%s', (target, sag_id)) audit(user, 'queue_change', 'assignee', target, old, new, sag_id) return {'version': version+1, 'item': new} @router.get('/next-task') def next_task(user: dict = Depends(get_current_user)): require_target(user, int(user['id'])) now = datetime.now(TZ) # Scan all pages so a long waiting queue cannot hide a ready task. candidates = [] offset = 0 while True: rows = queue_rows(int(user['id']), 500, offset,user) candidates.extend(r for r in rows if ready(r, now)) if len(rows) < 500 or candidates: break offset += 500 task = candidates[0] if candidates else None appointment = execute_query_single("""SELECT id,sag_id,starts_at,ends_at FROM planner_allocations WHERE user_id=%s AND deleted_at IS NULL AND ends_at>%s ORDER BY starts_at LIMIT 1""", (user['id'], now)) settings = execute_query_single('SELECT appointment_warning_minutes FROM planner_settings WHERE id=1') or {} warning = False shorter = None if appointment and task: gap = max(0, int((_aware(appointment['starts_at'])-now).total_seconds()/60)) estimate = remaining(task) warning = gap <= settings.get('appointment_warning_minutes', 30) and (estimate is None or estimate > gap) shorter = next((r for r in candidates if remaining(r) is not None and remaining(r) <= gap), None) def result(row): return None if row is None else {'sag_id': row['sag_id'], 'title': row['titel'], 'remaining_minutes': remaining(row), 'url': f"/sag/{row['sag_id']}"} return filter_data({'task': result(task), 'appointment': appointment, 'warning': warning, 'shorter_task': result(shorter), 'message': None if task else 'Du har ingen opgaver klar.'},hidden_case_ids(user)) class TransferBookingVersion(BaseModel): id: int version: int event_version: int class TransferInput(BaseModel): source_user_id: int = Field(gt=0) target_user_id: int = Field(gt=0) source_version: int = Field(ge=1) target_version: int = Field(ge=1) future_bookings_action: Literal['transfer', 'remove'] | None = None future_bookings: list[TransferBookingVersion] = Field(default_factory=list) confirm_conflicts: bool = False @router.post('/cases/{sag_id}/transfer') def transfer(sag_id:int,payload:TransferInput,user:dict=Depends(get_current_user)): require_case(user,sag_id) source,target = payload.source_user_id,payload.target_user_id if source==target: raise HTTPException(400,'Vælg en anden medarbejder') for uid in (source,target): require_target(user,uid,True) if not (user.get('is_superadmin') or set(user.get('permissions') or []) & {'planner.allocate','planner.admin'}): raise HTTPException(403,'Du må ikke omfordele medarbejdere') from .calendar import employee_locks, check_event_conflicts with atomic_transaction(): # Lock shared parents before employee/queue locks, as the event editor does. execute_query('''SELECT e.id FROM planner_events e WHERE e.id IN (SELECT event_id FROM planner_allocations WHERE sag_id=%s AND user_id=%s AND deleted_at IS NULL AND ends_at>NOW()) ORDER BY e.id FOR UPDATE''',(sag_id,source)) employee_locks([source,target]) execute_query('SELECT id FROM sag_sager WHERE id=%s FOR KEY SHARE',(sag_id,)) versions = {uid:lock_queue(uid) for uid in sorted([source,target])} if versions[source]!=payload.source_version or versions[target]!=payload.target_version: raise HTTPException(409,'En af køerne er ændret. Genindlæs.') old = execute_query_single('SELECT * FROM sag_case_assignees WHERE sag_id=%s AND user_id=%s FOR UPDATE',(sag_id,source)) if not old: raise HTTPException(404,'Allokering findes ikke') if execute_query_single('SELECT 1 FROM sag_case_assignees WHERE sag_id=%s AND user_id=%s',(sag_id,target)): raise HTTPException(409,'Medarbejderen er allerede allokeret. Justér fordelingen i stedet.') if not execute_query_single('SELECT user_id FROM users WHERE user_id=%s AND is_active',(target,)): raise HTTPException(400,'Vælg en aktiv medarbejder') bookings=execute_query('''SELECT a.*,e.version AS event_version FROM planner_allocations a JOIN planner_events e ON e.id=a.event_id WHERE a.sag_id=%s AND a.user_id=%s AND a.deleted_at IS NULL AND a.ends_at>NOW() ORDER BY a.id FOR UPDATE OF a''',(sag_id,source)) or [] snapshot=[{k:r[k] for k in ('id','version','event_version')} for r in bookings] if bookings and payload.future_bookings_action is None: raise HTTPException(409,{'code':'future_bookings_choice', 'message':'Hvad skal der ske med de kommende reservationer?', 'booking_count':len(bookings),'bookings':snapshot}) if payload.future_bookings_action and [r.model_dump() for r in payload.future_bookings]!=snapshot: raise HTTPException(409,'Reservationerne er ændret. Genindlæs og vælg igen.') if any(r['locked'] for r in bookings): raise HTTPException(409,'Fjern tidslås før reservationerne ændres') if payload.future_bookings_action=='transfer': conflicts=[] for booking in bookings: conflicts.extend(check_event_conflicts([target],booking['starts_at'],booking['ends_at'],booking['event_id'])) if conflicts and not payload.confirm_conflicts: raise HTTPException(409,{'message':'De overdragne tider konflikter med medarbejderens kalender eller arbejdstid', 'conflicts':conflicts,'confirm_conflicts':True}) position = execute_query_single('SELECT COALESCE(MAX(queue_position),0)+1024 AS position FROM sag_case_assignees WHERE user_id=%s',(target,))['position'] # Preserve already performed work on the source employee; transfer the remainder. totals = execute_query_single('''SELECT SUM(COALESCE(faktisk_tid_min,ROUND(COALESCE(approved_hours,original_hours,0)*60))) AS minutes FROM tmodule_times WHERE sag_id=%s AND medarbejder_id=%s AND status<>'rejected' ''',(sag_id,source)) or {} rest = remaining(dict(old,actual_minutes=totals.get('minutes') or 0)) new = execute_query_single('''INSERT INTO sag_case_assignees(sag_id,user_id,assigned_by_user_id, estimated_minutes,remaining_minutes,queue_position,work_status) VALUES(%s,%s,%s,%s,%s,%s,'ready') RETURNING *''',(sag_id,target,user['id'],rest,rest,position)) for booking in bookings: if payload.future_bookings_action=='transfer': changed=execute_query_single('''UPDATE planner_allocations SET user_id=%s,version=version+1, updated_at=NOW(),updated_by=%s WHERE id=%s RETURNING *''',(target,user['id'],booking['id'])) else: changed=execute_query_single('''UPDATE planner_allocations SET deleted_at=NOW(),version=version+1, updated_at=NOW(),updated_by=%s WHERE id=%s RETURNING *''',(user['id'],booking['id'])) audit(user,'transfer' if payload.future_bookings_action=='transfer' else 'cancel', 'allocation',booking['id'],booking,changed,sag_id) for event_id in sorted({r['event_id'] for r in bookings}): # Cancellation of the last participant already versions/deletes the parent via its trigger. execute_query('''UPDATE planner_events SET version=version+1,updated_at=NOW(),updated_by=%s WHERE id=%s AND deleted_at IS NULL''',(user['id'],event_id),fetch=False) execute_query('DELETE FROM sag_case_assignees WHERE sag_id=%s AND user_id=%s',(sag_id,source),fetch=False) audit(user,'transfer','assignee',target,old,new,sag_id) return {'item':new,'responsible_unchanged':True} class RemoveInput(BaseModel): version:int=Field(ge=1) @router.delete('/cases/{sag_id}/assignees/{target}') def remove_assignee(sag_id:int,target:int,version:int,user:dict=Depends(get_current_user)): require_case(user,sag_id) require_target(user,target,True) if not (user.get('is_superadmin') or set(user.get('permissions') or []) & {'planner.allocate','planner.admin'}): raise HTTPException(403,'Du må ikke fjerne allokeringer') with atomic_transaction(): execute_query('SELECT id FROM sag_sager WHERE id=%s FOR KEY SHARE',(sag_id,)) if lock_queue(target)!=version: raise HTTPException(409,'Køen er ændret') old=execute_query_single('SELECT * FROM sag_case_assignees WHERE sag_id=%s AND user_id=%s',(sag_id,target)) if not old: raise HTTPException(404,'Allokering findes ikke') if execute_query_single('SELECT 1 FROM planner_allocations WHERE sag_id=%s AND user_id=%s AND deleted_at IS NULL AND ends_at>NOW()',(sag_id,target)): raise HTTPException(409,'Fjern eller flyt fremtidige aftaler først') execute_query('DELETE FROM sag_case_assignees WHERE sag_id=%s AND user_id=%s',(sag_id,target),fetch=False) audit(user,'unassign','assignee',target,old,None,sag_id) return {'removed':True} class AssignmentInput(BaseModel): user_id: int = Field(gt=0) estimated_minutes:int|None=Field(default=None,ge=0,le=525600) @router.post('/cases/{sag_id}/assignees', status_code=201) def add_assignee(sag_id: int, payload: AssignmentInput, user: dict = Depends(get_current_user)): require_case(user,sag_id) require_target(user, payload.user_id, True) if not (user.get('is_superadmin') or set(user.get('permissions') or []) & {'planner.admin', 'planner.allocate'}): raise HTTPException(403, 'Du må ikke allokere medarbejdere') with atomic_transaction(): execute_query('SELECT id FROM sag_sager WHERE id=%s FOR KEY SHARE',(sag_id,)) lock_queue(payload.user_id) if not execute_query_single('SELECT id FROM sag_sager WHERE id=%s AND deleted_at IS NULL', (sag_id,)): raise HTTPException(404, 'Sagen findes ikke') if not execute_query_single('SELECT user_id FROM users WHERE user_id=%s AND is_active', (payload.user_id,)): raise HTTPException(400, 'Vælg en aktiv medarbejder') row = execute_query_single('''INSERT INTO sag_case_assignees(sag_id,user_id,assigned_by_user_id,estimated_minutes,queue_position) SELECT %s,%s,%s,%s,COALESCE(MAX(queue_position),0)+1024 FROM sag_case_assignees WHERE user_id=%s ON CONFLICT DO NOTHING RETURNING *''', (sag_id,payload.user_id,user['id'],payload.estimated_minutes,payload.user_id)) if row: audit(user,'assign','assignee',payload.user_id,None,row,sag_id) return {'created': bool(row)} class WorkAction(BaseModel): version:int=Field(ge=1) timer_action:bool=False async def work_action(sag_id,payload,user,resume): from app.timetracking.backend.router import pause_live_timer_v1,resume_live_timer_v1 uid=int(user['id']) require_target(user,uid,True) require_case(user,sag_id) with atomic_transaction(): execute_query('SELECT user_id FROM users WHERE user_id=%s FOR NO KEY UPDATE',(uid,)) result=change_queue(uid,sag_id,QueueChange(version=payload.version,work_status='working' if resume else 'paused'),user) if payload.timer_action: timer=execute_query_single('''SELECT id,aktiv_timer FROM tmodule_times WHERE medarbejder_id=%s AND sag_id=%s AND slut_tid IS NULL AND (aktiv_timer=TRUE OR paused_at IS NOT NULL) ORDER BY id DESC LIMIT 1''',(uid,sag_id)) if timer and (bool(timer['aktiv_timer']) != resume): # This transaction already serializes against the existing timer row lock. resume_fn=getattr(resume_live_timer_v1,'__wrapped__',resume_live_timer_v1) pause_fn=getattr(pause_live_timer_v1,'__wrapped__',pause_live_timer_v1) result['timer']=await resume_fn({'time_id':timer['id']},user) if resume else await pause_fn(user) return result @router.post('/queue/{sag_id}/pause') async def pause_work(sag_id:int,payload:WorkAction,user:dict=Depends(get_current_user)): return await work_action(sag_id,payload,user,False) @router.post('/queue/{sag_id}/resume') async def resume_work(sag_id:int,payload:WorkAction,user:dict=Depends(get_current_user)): return await work_action(sag_id,payload,user,True) class AssigneeEstimateInput(BaseModel): user_id:int=Field(gt=0) queue_version:int=Field(ge=1) estimated_minutes:int|None=Field(default=None,ge=0,le=525600) remaining_minutes:int|None=Field(default=None,ge=0,le=525600) class CasePlanningInput(BaseModel): updated_at:datetime start_date:date|None=None start_time:time|None=None deadline:datetime|None=None deadline_date:date|None=None estimated_minutes:int|None=Field(default=None,ge=0,le=525600) assignee_estimates:list[AssigneeEstimateInput]=Field(default_factory=list,max_length=100) add_assignee:AssignmentInput|None=None @router.get('/cases/{sag_id}/planning') def case_planning(sag_id:int,user:dict=Depends(get_current_user)): require_target(user,int(user['id'])) require_case(user,sag_id) row=execute_query_single('SELECT id,titel,start_date,start_time,deadline,estimated_minutes,updated_at FROM sag_sager WHERE id=%s AND deleted_at IS NULL',(sag_id,)) if not row: raise HTTPException(404,'Sagen findes ikke') row['assignees']=execute_query('''SELECT a.*,COALESCE(u.full_name,u.username) AS name,COALESCE(v.version,1) AS queue_version FROM sag_case_assignees a JOIN users u ON u.user_id=a.user_id LEFT JOIN planner_queue_versions v ON v.user_id=a.user_id WHERE a.sag_id=%s ORDER BY name''',(sag_id,)) or [] row['updated_at']=db_utc(row['updated_at']) if row['deadline']: row['deadline']=db_utc(row['deadline']).astimezone(TZ) return row @router.patch('/cases/{sag_id}/planning') def update_case_planning(sag_id:int,payload:CasePlanningInput,user:dict=Depends(get_current_user)): require_case(user,sag_id) metadata_fields={'start_date','start_time','deadline','deadline_date','estimated_minutes'} if {'deadline','deadline_date'} <= payload.model_fields_set: raise HTTPException(400,'Angiv enten deadline eller deadlinedato') if payload.model_fields_set & metadata_fields and not (user.get('is_superadmin') or set(user.get('permissions') or []) & {'cases.edit','tickets.edit'}): raise HTTPException(403,'Du må ikke ændre sagsplanlægningen') values={k:v for k,v in payload.model_dump().items() if k in payload.model_fields_set and k not in {'updated_at','assignee_estimates','add_assignee','deadline_date'}} estimates=payload.assignee_estimates if len({r.user_id for r in estimates})!=len(estimates): raise HTTPException(400,'En medarbejder må kun angives én gang') for row in estimates: require_target(user,row.user_id,True) if payload.add_assignee: require_target(user,payload.add_assignee.user_id,True) if not (user.get('is_superadmin') or set(user.get('permissions') or []) & {'planner.allocate','planner.admin'}): raise HTTPException(403,'Du må ikke allokere medarbejdere') if values.get('deadline') is not None: values['deadline']=db_utc(values['deadline']).replace(tzinfo=None) with atomic_transaction(): old=execute_query_single('SELECT * FROM sag_sager WHERE id=%s AND deleted_at IS NULL FOR UPDATE',(sag_id,)) if not old: raise HTTPException(404,'Sagen findes ikke') if db_utc(old['updated_at'])!=db_utc(payload.updated_at): raise HTTPException(409,'Sagen er ændret. Genindlæs.') if 'deadline_date' in payload.model_fields_set: clock=db_utc(old['deadline']).astimezone(TZ).time() if old['deadline'] else time(17) values['deadline']=(datetime.combine(payload.deadline_date,clock,TZ).astimezone(timezone.utc).replace(tzinfo=None) if payload.deadline_date else None) ids={r.user_id for r in estimates} if payload.add_assignee: ids.add(payload.add_assignee.user_id) versions={uid:lock_queue(uid) for uid in sorted(ids)} for row in estimates: before=execute_query_single('SELECT * FROM sag_case_assignees WHERE sag_id=%s AND user_id=%s FOR UPDATE',(sag_id,row.user_id)) if not before: raise HTTPException(409,'En medarbejders allokering er ændret') fields={k:v for k,v in row.model_dump().items() if k in row.model_fields_set and k in {'estimated_minutes','remaining_minutes'} and before.get(k)!=v} if fields: if versions[row.user_id]!=row.queue_version: raise HTTPException(409,'En medarbejders kø er ændret. Genindlæs.') setters=','.join(f'{key}=%s' for key in fields) execute_query(f'UPDATE sag_case_assignees SET {setters},version=version+1 WHERE sag_id=%s AND user_id=%s',(*fields.values(),sag_id,row.user_id),fetch=False) bump_queue(row.user_id) audit(user,'queue_change','assignee',row.user_id,before,{**before,**fields},sag_id) if payload.add_assignee: new_assignee=payload.add_assignee if not execute_query_single('SELECT user_id FROM users WHERE user_id=%s AND is_active',(new_assignee.user_id,)): raise HTTPException(400,'Vælg en aktiv medarbejder') added=execute_query_single('''INSERT INTO sag_case_assignees(sag_id,user_id,assigned_by_user_id,estimated_minutes,queue_position) SELECT %s,%s,%s,%s,COALESCE(MAX(queue_position),0)+1024 FROM sag_case_assignees WHERE user_id=%s ON CONFLICT DO NOTHING RETURNING *''',(sag_id,new_assignee.user_id,user['id'],new_assignee.estimated_minutes,new_assignee.user_id)) if not added: raise HTTPException(409,'Medarbejderen er allerede allokeret. Genindlæs.') audit(user,'assign','assignee',new_assignee.user_id,None,added,sag_id) if values: setters=','.join(f'{key}=%s' for key in values) execute_query(f'UPDATE sag_sager SET {setters},updated_at=NOW() WHERE id=%s',(*values.values(),sag_id),fetch=False) elif estimates or payload.add_assignee: execute_query('UPDATE sag_sager SET updated_at=NOW() WHERE id=%s',(sag_id,),fetch=False) new=execute_query_single('SELECT start_date,start_time,deadline,estimated_minutes,updated_at FROM sag_sager WHERE id=%s',(sag_id,)) if new['start_time'] and not new['start_date']: raise HTTPException(400,'Startklokkeslæt kræver en startdato') audit(user,'case_plan_update','case',sag_id,old,new,sag_id) new['updated_at']=db_utc(new['updated_at']) if new['deadline']: new['deadline']=db_utc(new['deadline']).astimezone(TZ) return new