2026-09-09 10:34:49 +02:00
from __future__ import annotations
from datetime import date , datetime , time , timedelta , timezone
from typing import Literal
from zoneinfo import ZoneInfo
from fastapi import APIRouter , Depends , HTTPException , Query
from pydantic import BaseModel , Field , model_validator
from psycopg2 . extras import RealDictCursor , Json
from app . core . auth_dependencies import get_current_user
from app . core . database import execute_query , execute_query_single , get_db_connection , release_db_connection
from . providers import busy_time_provider
router = APIRouter ( prefix = " /planner " )
TZ = ZoneInfo ( " Europe/Copenhagen " )
CLOSED_STATUSES = { " afsluttet " , " lukket " , " closed " , " done " , " arkiveret " , " archived " }
class AllocationInput ( BaseModel ) :
sag_id : int
user_id : int
starts_at : datetime
ends_at : datetime
note : str | None = Field ( default = None , max_length = 2000 )
source : str = Field ( default = " manual " , max_length = 40 )
confirm_conflicts : bool = False
locked : bool = False
@model_validator ( mode = " after " )
def valid_period ( self ) :
if self . ends_at < = self . starts_at :
raise ValueError ( " Sluttid skal ligge efter starttid " )
return self
class AllocationUpdate ( BaseModel ) :
user_id : int | None = None
starts_at : datetime | None = None
ends_at : datetime | None = None
note : str | None = Field ( default = None , max_length = 2000 )
version : int
confirm_conflicts : bool = False
locked : bool | None = None
class CaseEstimateInput ( BaseModel ) :
estimated_minutes : int | None = Field ( default = None , ge = 0 , le = 525600 )
class PlannerSettingsInput ( BaseModel ) :
warning_percent : int = Field ( ge = 1 , le = 99 )
full_percent : int = Field ( ge = 100 , le = 300 )
default_allocation_minutes : int = Field ( ge = 15 , le = 1440 )
def _permissions ( user : dict ) - > set [ str ] :
return set ( user . get ( " permissions " ) or [ ] )
def _can_view ( user : dict ) - > bool :
return bool ( user . get ( " is_superadmin " ) or " planner.view " in _permissions ( user ) )
def _can_edit ( user : dict , target_id : int ) - > bool :
if user . get ( " is_superadmin " ) or " planner.admin " in _permissions ( user ) :
return True
if target_id == int ( user [ " id " ] ) and " planner.edit_own " in _permissions ( user ) :
return True
if " planner.edit_team " not in _permissions ( user ) :
return False
return bool ( execute_query_single (
" SELECT 1 FROM planner_team_scope WHERE editor_user_id= %s AND target_user_id= %s " ,
( user [ " id " ] , target_id ) ,
) )
def _require_view ( user : dict ) :
if not _can_view ( user ) :
raise HTTPException ( 403 , " Du har ikke adgang til Planlæggeren " )
def _aware ( value : datetime ) - > datetime :
return value . replace ( tzinfo = TZ ) if value . tzinfo is None else value
def _minutes ( start , end ) - > int :
return max ( 0 , round ( ( end - start ) . total_seconds ( ) / 60 ) )
def _merge_minutes ( intervals : list [ tuple [ datetime , datetime ] ] , start : datetime , end : datetime ) - > int :
clipped = sorted ( ( max ( a , start ) , min ( b , end ) ) for a , b in intervals if a < end and b > start )
merged : list [ list [ datetime ] ] = [ ]
for a , b in clipped :
if not merged or a > merged [ - 1 ] [ 1 ] :
merged . append ( [ a , b ] )
else :
merged [ - 1 ] [ 1 ] = max ( merged [ - 1 ] [ 1 ] , b )
return sum ( _minutes ( a , b ) for a , b in merged )
def _sum_minutes ( intervals : list [ tuple [ datetime , datetime ] ] , start : datetime , end : datetime ) - > int :
""" Count every allocation; overlapping jobs each consume assigned capacity. """
return sum ( _minutes ( max ( a , start ) , min ( b , end ) ) for a , b in intervals if a < end and b > start )
def _capacity ( user_ids : list [ int ] , starts_at : datetime , ends_at : datetime , allocations = None ) - > list [ dict ] :
settings = execute_query_single ( " SELECT * FROM planner_settings WHERE id=1 " ) or {
" warning_percent " : 80 , " full_percent " : 100
}
schedules = execute_query (
""" SELECT * FROM planner_work_schedules WHERE user_id=ANY( %s ) AND active
AND ( valid_from IS NULL OR valid_from < = % s ) AND ( valid_to IS NULL OR valid_to > = % s ) """ ,
( user_ids , ends_at . date ( ) , starts_at . date ( ) ) ,
) or [ ]
absences = execute_query (
" SELECT user_id, starts_at, ends_at FROM planner_absences WHERE user_id=ANY( %s ) AND starts_at< %s AND ends_at> %s " ,
( user_ids , ends_at , starts_at ) ,
) or [ ]
external = busy_time_provider . get_busy ( user_ids , starts_at , ends_at )
registered = execute_query (
""" SELECT t.medarbejder_id AS user_id,
COALESCE ( t . start_tid , t . worked_date : : timestamp , t . created_at ) AS registered_at ,
COALESCE ( t . faktisk_tid_min , ROUND ( COALESCE ( t . approved_hours , t . original_hours , 0 ) * 60 ) ) : : int AS minutes
FROM tmodule_times t JOIN sag_sager s ON s . id = t . sag_id
WHERE t . medarbejder_id = ANY ( % s )
AND COALESCE ( t . start_tid , t . worked_date : : timestamp , t . created_at ) > = % s
AND COALESCE ( t . start_tid , t . worked_date : : timestamp , t . created_at ) < % s
AND s . deleted_at IS NULL AND LOWER ( COALESCE ( s . status , ' ' ) ) < > ALL ( % s ) """ ,
( user_ids , starts_at , ends_at , list ( CLOSED_STATUSES ) ) ,
) or [ ]
if allocations is None :
allocations = execute_query (
" SELECT user_id, starts_at, ends_at FROM planner_allocations WHERE deleted_at IS NULL AND user_id=ANY( %s ) AND starts_at< %s AND ends_at> %s " ,
( user_ids , ends_at , starts_at ) ,
) or [ ]
result = [ ]
current = starts_at . astimezone ( TZ ) . date ( )
final = ( ends_at - timedelta ( microseconds = 1 ) ) . astimezone ( TZ ) . date ( )
schedule_map = { ( r [ " user_id " ] , r [ " weekday " ] ) : r for r in schedules }
while current < = final :
for uid in user_ids :
row = schedule_map . get ( ( uid , current . weekday ( ) ) )
if row :
day_start = datetime . combine ( current , row [ " start_time " ] , TZ )
day_end = datetime . combine ( current , row [ " end_time " ] , TZ )
gross = _minutes ( day_start , day_end )
unavailable = _merge_minutes (
[ ( _aware ( r [ " starts_at " ] ) , _aware ( r [ " ends_at " ] ) ) for r in absences + external if r [ " user_id " ] == uid ] ,
day_start , day_end ,
)
available = max ( 0 , gross - int ( row [ " break_minutes " ] or 0 ) - unavailable )
else :
day_start = datetime . combine ( current , time ( 0 ) , TZ )
day_end = day_start + timedelta ( days = 1 )
available = 0
allocated = _sum_minutes (
[ ( _aware ( r [ " starts_at " ] ) , _aware ( r [ " ends_at " ] ) ) for r in allocations if r [ " user_id " ] == uid ] ,
day_start , day_end ,
)
percent = round ( allocated * 100 / available ) if available else ( 100 if allocated else 0 )
registered_minutes = sum (
int ( r . get ( " minutes " ) or 0 ) for r in registered
if r [ " user_id " ] == uid and _aware ( r [ " registered_at " ] ) . astimezone ( TZ ) . date ( ) == current
)
state = " gray " if available == 0 else (
" red " if percent > = settings [ " full_percent " ] else
" yellow " if percent > = settings [ " warning_percent " ] else " green "
)
result . append ( { " user_id " : uid , " date " : current . isoformat ( ) , " available_minutes " : available ,
" allocated_minutes " : allocated , " percent " : percent , " state " : state ,
" registered_minutes " : registered_minutes ,
" label " : f " { allocated / 60 : g } af { available / 60 : g } timer planlagt " ,
" registered_label " : f " { registered_minutes / 60 : g } timer registreret " } )
current + = timedelta ( days = 1 )
return result
def _conflicts ( user_id , starts_at , ends_at , exclude_id = None ) - > tuple [ str , dict ] :
query = """ SELECT id FROM planner_allocations WHERE deleted_at IS NULL AND user_id= %s
AND starts_at < % s AND ends_at > % s """
params : list = [ user_id , ends_at , starts_at ]
if exclude_id :
query + = " AND id <> %s "
params . append ( exclude_id )
overlaps = execute_query ( query , tuple ( params ) ) or [ ]
prospective = ( execute_query (
" SELECT user_id, starts_at, ends_at FROM planner_allocations WHERE deleted_at IS NULL AND user_id= %s AND starts_at< %s AND ends_at> %s " + ( " AND id<> %s " if exclude_id else " " ) ,
tuple ( [ user_id , ends_at , starts_at ] + ( [ exclude_id ] if exclude_id else [ ] ) ) ,
) or [ ] ) + [ { " user_id " : user_id , " starts_at " : starts_at , " ends_at " : ends_at } ]
cap = _capacity ( [ user_id ] , starts_at , ends_at , prospective )
overbooked = any ( x [ " state " ] == " red " and x [ " allocated_minutes " ] > x [ " available_minutes " ] for x in cap )
status = " overlap_and_overbooked " if overlaps and overbooked else " overlap " if overlaps else " overbooked " if overbooked else " none "
return status , { " overlapping_ids " : [ r [ " id " ] for r in overlaps ] , " capacity " : cap }
def _serialize ( row : dict | None ) :
if not row : return None
return { k : ( v . isoformat ( ) if isinstance ( v , ( datetime , date , time ) ) else v ) for k , v in row . items ( ) }
@router.get ( " /metadata " )
def metadata ( current_user : dict = Depends ( get_current_user ) ) :
_require_view ( current_user )
users = execute_query ( " SELECT user_id AS id, COALESCE(full_name, username) AS name FROM users WHERE is_active ORDER BY name " ) or [ ]
settings = execute_query_single ( " SELECT warning_percent, full_percent, default_allocation_minutes FROM planner_settings WHERE id=1 " )
departments = execute_query ( " SELECT id, name FROM groups ORDER BY name " ) or [ ]
statuses = busy_time_provider . statuses ( [ r [ " id " ] for r in users ] )
editable_user_ids = [ r [ " id " ] for r in users if _can_edit ( current_user , r [ " id " ] ) ]
return { " users " : users , " departments " : departments , " settings " : settings , " integration_statuses " : statuses ,
" current_user_id " : current_user [ " id " ] , " permissions " : list ( _permissions ( current_user ) ) ,
" editable_user_ids " : editable_user_ids }
@router.put ( " /settings " )
def update_settings ( payload : PlannerSettingsInput , current_user : dict = Depends ( get_current_user ) ) :
if not ( current_user . get ( " is_superadmin " ) or " planner.admin " in _permissions ( current_user ) ) :
raise HTTPException ( 403 , " Kun administratorer kan ændre kapacitetsgrænser " )
if payload . warning_percent > = payload . full_percent :
raise HTTPException ( 422 , " Gul-grænsen skal ligge under rød-grænsen " )
return execute_query_single (
""" UPDATE planner_settings SET warning_percent= %s ,full_percent= %s ,default_allocation_minutes= %s ,
updated_by = % s , updated_at = NOW ( ) WHERE id = 1 RETURNING * """ ,
( payload . warning_percent , payload . full_percent , payload . default_allocation_minutes , current_user [ " id " ] ) ,
)
@router.get ( " /cases " )
def cases ( q : str = " " , status : list [ str ] = Query ( default = [ ] ) , priority : list [ str ] = Query ( default = [ ] ) ,
case_type : list [ str ] = Query ( default = [ ] ) , responsible_id : int | None = None ,
department_id : int | None = None ,
2026-09-20 03:33:10 +02:00
planning : Literal [ " all " , " unplanned " , " partial " , " planned " , " overdue " ] = " all " , mine : bool = False ,
2026-09-09 10:34:49 +02:00
urgent : bool = False , sort : Literal [ " priority " , " remaining " , " updated " ] = " priority " ,
limit : int = Query ( 100 , ge = 1 , le = 300 ) , current_user : dict = Depends ( get_current_user ) ) :
_require_view ( current_user )
sql = """ SELECT s.id, s.titel AS title, s.status, s.type, s.priority::text, s.ansvarlig_bruger_id AS responsible_id,
( LOWER ( COALESCE ( s . status , ' ' ) ) = ANY ( % s ) ) AS is_closed ,
COALESCE ( u . full_name , u . username ) AS responsible_name , c . name AS customer_name ,
COALESCE ( s . estimated_minutes , 0 ) AS estimated_minutes ,
COALESCE ( used . used_minutes , 0 ) AS used_minutes ,
used . latest_registration_at ,
2026-09-20 03:33:10 +02:00
COALESCE ( SUM ( EXTRACT ( EPOCH FROM ( a . ends_at - a . starts_at ) ) / 60 ) FILTER ( WHERE a . deleted_at IS NULL ) , 0 ) : : int AS planned_minutes ,
BOOL_OR ( a . deleted_at IS NULL AND a . ends_at < CURRENT_DATE ) AS overdue_planned ,
MAX ( a . ends_at ) FILTER ( WHERE a . deleted_at IS NULL AND a . ends_at < CURRENT_DATE ) AS overdue_planned_until
2026-09-09 10:34:49 +02:00
FROM sag_sager s JOIN customers c ON c . id = s . customer_id
LEFT JOIN users u ON u . user_id = s . ansvarlig_bruger_id LEFT JOIN planner_allocations a ON a . sag_id = s . id
LEFT JOIN groups department ON department . id = s . assigned_group_id
LEFT JOIN LATERAL (
SELECT COALESCE ( SUM ( COALESCE ( t . faktisk_tid_min ,
ROUND ( COALESCE ( t . approved_hours , t . original_hours , 0 ) * 60 ) ) ) , 0 ) : : int AS used_minutes ,
MAX ( COALESCE ( t . slut_tid , t . start_tid , t . worked_date : : timestamp , t . created_at ) ) AS latest_registration_at
FROM tmodule_times t WHERE t . sag_id = s . id
) used ON TRUE
WHERE s . deleted_at IS NULL
AND NOT ( LOWER ( COALESCE ( s . status , ' ' ) ) = ANY ( % s ) AND COALESCE ( used . used_minutes , 0 ) > 0 ) """
params : list = [ list ( CLOSED_STATUSES ) , list ( CLOSED_STATUSES ) ]
if q :
sql + = " AND (s.id::text ILIKE %s OR s.titel ILIKE %s OR c.name ILIKE %s OR COALESCE(u.full_name,u.username, ' ' ) ILIKE %s ) "
params + = [ f " % { q } % " ] * 4
if status : sql + = " AND s.status = ANY( %s ) " ; params . append ( status )
if priority : sql + = " AND s.priority::text = ANY( %s ) " ; params . append ( priority )
if case_type : sql + = " AND s.type = ANY( %s ) " ; params . append ( case_type )
if responsible_id : sql + = " AND s.ansvarlig_bruger_id= %s " ; params . append ( responsible_id )
if department_id : sql + = " AND s.assigned_group_id= %s " ; params . append ( department_id )
if mine : sql + = " AND s.ansvarlig_bruger_id= %s " ; params . append ( current_user [ " id " ] )
if urgent : sql + = " AND s.priority::text IN ( ' urgent ' , ' high ' ) "
sql + = " GROUP BY s.id,u.full_name,u.username,c.name,department.name,used.used_minutes,used.latest_registration_at "
if planning == " unplanned " : sql + = " HAVING COALESCE(SUM(EXTRACT(EPOCH FROM (a.ends_at-a.starts_at))/60) FILTER (WHERE a.deleted_at IS NULL),0)=0 "
elif planning == " partial " : sql + = " HAVING COALESCE(SUM(EXTRACT(EPOCH FROM (a.ends_at-a.starts_at))/60) FILTER (WHERE a.deleted_at IS NULL),0)>0 AND COALESCE(SUM(EXTRACT(EPOCH FROM (a.ends_at-a.starts_at))/60) FILTER (WHERE a.deleted_at IS NULL),0)<COALESCE(s.estimated_minutes,0) "
elif planning == " planned " : sql + = " HAVING COALESCE(s.estimated_minutes,0)>0 AND COALESCE(SUM(EXTRACT(EPOCH FROM (a.ends_at-a.starts_at))/60) FILTER (WHERE a.deleted_at IS NULL),0)>=s.estimated_minutes "
2026-09-20 03:33:10 +02:00
elif planning == " overdue " : sql + = " HAVING BOOL_OR(a.deleted_at IS NULL AND a.ends_at < CURRENT_DATE) "
2026-09-09 10:34:49 +02:00
order = { " priority " : " CASE s.priority::text WHEN ' urgent ' THEN 1 WHEN ' high ' THEN 2 WHEN ' normal ' THEN 3 ELSE 4 END, s.updated_at DESC " ,
" remaining " : " (COALESCE(s.estimated_minutes,0)-COALESCE(SUM(EXTRACT(EPOCH FROM (a.ends_at-a.starts_at))/60) FILTER (WHERE a.deleted_at IS NULL),0)) DESC " ,
" updated " : " s.updated_at DESC " } [ sort ]
rows = execute_query ( sql + f " ORDER BY { order } LIMIT %s " , tuple ( params + [ limit ] ) ) or [ ]
for row in rows : row [ " remaining_minutes " ] = max ( 0 , row [ " estimated_minutes " ] - row [ " planned_minutes " ] )
return { " items " : rows }
@router.get ( " /cases/ {sag_id} /time-entries " )
def case_time_entries ( sag_id : int , current_user : dict = Depends ( get_current_user ) ) :
""" Show actual registered time separately from planned allocations. """
_require_view ( current_user )
rows = execute_query (
""" SELECT t.id, t.worked_date, t.start_tid, t.slut_tid,
COALESCE ( t . faktisk_tid_min , ROUND ( COALESCE ( t . approved_hours , t . original_hours , 0 ) * 60 ) ) : : int AS minutes ,
t . description , t . entry_status , t . status ,
COALESCE ( u . full_name , u . username , t . user_name , ' Ukendt ' ) AS employee_name
FROM tmodule_times t LEFT JOIN users u ON u . user_id = t . medarbejder_id
WHERE t . sag_id = % s ORDER BY COALESCE ( t . slut_tid , t . start_tid , t . worked_date : : timestamp , t . created_at ) DESC , t . id DESC """ ,
( sag_id , ) ,
) or [ ]
return { " sag_id " : sag_id , " total_minutes " : sum ( int ( r . get ( " minutes " ) or 0 ) for r in rows ) , " items " : rows }
2026-09-20 03:33:10 +02:00
@router.get ( " /cases/ {sag_id} /allocations " )
def case_allocations ( sag_id : int , current_user : dict = Depends ( get_current_user ) ) :
""" Return the persisted plan for one case for the embedded case planner. """
_require_view ( current_user )
case_row = execute_query_single (
""" SELECT s.id, s.titel, s.estimated_minutes, s.deferred_until,
s . deferred_until_case_id , s . deferred_until_status ,
waiting . titel AS deferred_case_title , waiting . status AS deferred_case_current_status
FROM sag_sager s
LEFT JOIN sag_sager waiting ON waiting . id = s . deferred_until_case_id
WHERE s . id = % s AND s . deleted_at IS NULL """ ,
( sag_id , ) ,
)
if not case_row :
raise HTTPException ( 404 , " Sagen findes ikke " )
rows = execute_query (
""" SELECT a.id, a.sag_id, a.user_id, a.starts_at, a.ends_at, a.note,
a . conflict_status , a . locked , a . version ,
COALESCE ( u . full_name , u . username , ' Ukendt ' ) AS user_name
FROM planner_allocations a
LEFT JOIN users u ON u . user_id = a . user_id
WHERE a . sag_id = % s AND a . deleted_at IS NULL
ORDER BY a . starts_at , a . id """ ,
( sag_id , ) ,
) or [ ]
planned_minutes = sum ( _minutes ( _aware ( row [ " starts_at " ] ) , _aware ( row [ " ends_at " ] ) ) for row in rows )
subcases = execute_query (
""" SELECT child.id, child.titel, child.status, child.type,
child . ansvarlig_bruger_id , child . estimated_minutes ,
child . deferred_until , child . deferred_until_case_id , child . deferred_until_status ,
COALESCE ( owner . full_name , owner . username ) AS responsible_name ,
waiting . titel AS deferred_case_title ,
2026-10-03 14:06:51 +02:00
waiting . status AS deferred_case_current_status ,
COALESCE ( allocation_summary . planned_minutes , 0 ) : : int AS planned_minutes ,
COALESCE ( allocation_summary . items , ' [] ' : : json ) AS allocations
2026-09-20 03:33:10 +02:00
FROM sag_relationer relation
JOIN sag_sager child ON child . id = relation . målsag_id AND child . deleted_at IS NULL
LEFT JOIN users owner ON owner . user_id = child . ansvarlig_bruger_id
LEFT JOIN sag_sager waiting ON waiting . id = child . deferred_until_case_id
2026-10-03 14:06:51 +02:00
LEFT JOIN LATERAL (
SELECT
COALESCE ( SUM ( EXTRACT ( EPOCH FROM ( allocation . ends_at - allocation . starts_at ) ) / 60 ) , 0 ) AS planned_minutes ,
json_agg ( json_build_object (
' starts_at ' , allocation . starts_at ,
' ends_at ' , allocation . ends_at ,
' note ' , allocation . note ,
' user_name ' , COALESCE ( allocation_user . full_name , allocation_user . username , ' Ukendt ' )
) ORDER BY allocation . starts_at , allocation . id ) AS items
FROM planner_allocations allocation
LEFT JOIN users allocation_user ON allocation_user . user_id = allocation . user_id
WHERE allocation . sag_id = child . id AND allocation . deleted_at IS NULL
) allocation_summary ON TRUE
2026-09-20 03:33:10 +02:00
WHERE relation . kilde_sag_id = % s AND LOWER ( relation . relationstype ) = ' undersag '
AND relation . deleted_at IS NULL
ORDER BY child . created_at , child . id """ ,
( sag_id , ) ,
) or [ ]
return {
" sag_id " : sag_id ,
" estimated_minutes " : case_row . get ( " estimated_minutes " ) ,
" planned_minutes " : planned_minutes ,
" remaining_minutes " : max ( 0 , int ( case_row . get ( " estimated_minutes " ) or 0 ) - planned_minutes ) ,
" deferred " : {
" until " : case_row . get ( " deferred_until " ) ,
" case_id " : case_row . get ( " deferred_until_case_id " ) ,
" case_title " : case_row . get ( " deferred_case_title " ) ,
" case_status " : case_row . get ( " deferred_case_current_status " ) ,
" target_status " : case_row . get ( " deferred_until_status " ) ,
} ,
" subcases " : subcases ,
" items " : rows ,
}
2026-09-09 10:34:49 +02:00
@router.patch ( " /cases/ {sag_id} /estimate " )
def update_case_estimate ( sag_id : int , payload : CaseEstimateInput , current_user : dict = Depends ( get_current_user ) ) :
permissions = _permissions ( current_user )
allowed = { " planner.edit_own " , " planner.edit_team " , " planner.admin " , " cases.edit " }
if not ( current_user . get ( " is_superadmin " ) or permissions . intersection ( allowed ) ) :
raise HTTPException ( 403 , " Du må ikke ændre forventet tid " )
old = execute_query_single ( " SELECT id,titel,estimated_minutes FROM sag_sager WHERE id= %s AND deleted_at IS NULL " , ( sag_id , ) )
if not old :
raise HTTPException ( 404 , " Sagen findes ikke " )
row = execute_query_single (
" UPDATE sag_sager SET estimated_minutes= %s ,updated_at=NOW() WHERE id= %s RETURNING id,estimated_minutes " ,
( payload . estimated_minutes , sag_id ) ,
)
execute_query (
""" INSERT INTO audit_log(user_id,username,action,resource_type,resource_id,resource_name,before_value,after_value)
VALUES ( % s , % s , ' planner.estimate.update ' , ' case ' , % s , % s , % s , % s ) """ ,
( current_user [ " id " ] , current_user . get ( " username " ) , sag_id , old . get ( " titel " ) ,
str ( old . get ( " estimated_minutes " ) ) , str ( payload . estimated_minutes ) ) ,
)
return row
@router.get ( " /period " )
def period ( starts_at : datetime , ends_at : datetime , user_id : list [ int ] = Query ( default = [ ] ) , current_user : dict = Depends ( get_current_user ) ) :
_require_view ( current_user )
if ends_at < = starts_at or ends_at - starts_at > timedelta ( days = 370 ) : raise HTTPException ( 400 , " Ugyldig periode " )
ids = user_id or [ current_user [ " id " ] ]
allocations = execute_query (
""" SELECT a.*, s.titel AS case_title, s.priority::text AS priority, c.name AS customer_name,
COALESCE ( u . full_name , u . username ) AS user_name
FROM planner_allocations a JOIN sag_sager s ON s . id = a . sag_id JOIN customers c ON c . id = s . customer_id
JOIN users u ON u . user_id = a . user_id WHERE a . deleted_at IS NULL AND a . user_id = ANY ( % s )
AND a . starts_at < % s AND a . ends_at > % s ORDER BY a . starts_at """ , (ids, ends_at, starts_at)) or []
time_entries = execute_query (
""" SELECT t.id, t.sag_id, t.medarbejder_id AS user_id,
( t . start_tid IS NOT NULL ) AS has_time ,
COALESCE ( t . start_tid , t . worked_date : : timestamp , t . created_at ) AS starts_at ,
COALESCE ( t . slut_tid ,
COALESCE ( t . start_tid , t . worked_date : : timestamp , t . created_at )
+ make_interval ( mins = > GREATEST ( COALESCE ( t . faktisk_tid_min ,
ROUND ( COALESCE ( t . approved_hours , t . original_hours , 0 ) * 60 ) ) : : int , 15 ) ) ) AS ends_at ,
COALESCE ( t . faktisk_tid_min , ROUND ( COALESCE ( t . approved_hours , t . original_hours , 0 ) * 60 ) ) : : int AS minutes ,
t . description , s . titel AS case_title , c . name AS customer_name ,
COALESCE ( u . full_name , u . username , t . user_name , ' Ukendt ' ) AS user_name
FROM tmodule_times t JOIN sag_sager s ON s . id = t . sag_id
JOIN customers c ON c . id = s . customer_id LEFT JOIN users u ON u . user_id = t . medarbejder_id
WHERE t . medarbejder_id = ANY ( % s )
AND COALESCE ( t . start_tid , t . worked_date : : timestamp , t . created_at ) > = % s
AND COALESCE ( t . start_tid , t . worked_date : : timestamp , t . created_at ) < % s
AND s . deleted_at IS NULL AND LOWER ( COALESCE ( s . status , ' ' ) ) < > ALL ( % s )
ORDER BY starts_at """ ,
( ids , starts_at , ends_at , list ( CLOSED_STATUSES ) ) ,
) or [ ]
return { " allocations " : allocations , " capacity " : _capacity ( ids , _aware ( starts_at ) , _aware ( ends_at ) , allocations ) ,
" time_entries " : time_entries ,
" external_busy " : busy_time_provider . get_busy ( ids , starts_at , ends_at ) ,
" integration_statuses " : busy_time_provider . statuses ( ids ) }
@router.post ( " /allocations " , status_code = 201 )
def create_allocation ( payload : AllocationInput , current_user : dict = Depends ( get_current_user ) ) :
if not _can_edit ( current_user , payload . user_id ) : raise HTTPException ( 403 , " Du må ikke redigere denne medarbejders plan " )
case = execute_query_single ( " SELECT id,status,deleted_at FROM sag_sager WHERE id= %s " , ( payload . sag_id , ) )
if not case or case [ " deleted_at " ] or str ( case [ " status " ] ) . lower ( ) in CLOSED_STATUSES : raise HTTPException ( 409 , " Lukkede eller arkiverede sager skal genåbnes før planlægning " )
conflict , details = _conflicts ( payload . user_id , payload . starts_at , payload . ends_at )
if conflict != " none " and not payload . confirm_conflicts : raise HTTPException ( 409 , detail = { " message " : " Tiden overlapper eller overbooker " , " conflict_status " : conflict , * * details } )
conn = get_db_connection ( )
try :
with conn . cursor ( cursor_factory = RealDictCursor ) as cur :
cur . execute ( """ INSERT INTO planner_allocations(sag_id,user_id,starts_at,ends_at,note,source,conflict_status,locked,created_by,updated_by)
VALUES ( % s , % s , % s , % s , % s , % s , % s , % s , % s , % s ) RETURNING * """ ,
( payload . sag_id , payload . user_id , payload . starts_at , payload . ends_at , payload . note , payload . source , conflict , payload . locked , current_user [ " id " ] , current_user [ " id " ] ) )
row = cur . fetchone ( ) ; cur . execute ( " INSERT INTO planner_allocation_history(allocation_id,sag_id,action,changed_by,after_value) VALUES( %s , %s , ' created ' , %s , %s ) " , ( row [ " id " ] , row [ " sag_id " ] , current_user [ " id " ] , Json ( _serialize ( row ) ) ) )
conn . commit ( ) ; return row
except Exception : conn . rollback ( ) ; raise
finally : release_db_connection ( conn )
@router.patch ( " /allocations/ {allocation_id} " )
def update_allocation ( allocation_id : int , payload : AllocationUpdate , current_user : dict = Depends ( get_current_user ) ) :
conn = get_db_connection ( )
try :
with conn . cursor ( cursor_factory = RealDictCursor ) as cur :
cur . execute ( " SELECT * FROM planner_allocations WHERE id= %s AND deleted_at IS NULL FOR UPDATE " , ( allocation_id , ) ) ; old = cur . fetchone ( )
if not old : raise HTTPException ( 404 , " Allokeringen findes ikke " )
target = payload . user_id or old [ " user_id " ]
if not _can_edit ( current_user , target ) : raise HTTPException ( 403 , " Du må ikke redigere denne medarbejders plan " )
if old [ " version " ] != payload . version : raise HTTPException ( 409 , detail = { " message " : " Allokeringen er ændret af en anden bruger " , " current " : _serialize ( old ) } )
moving = bool ( { " user_id " , " starts_at " , " ends_at " } . intersection ( payload . model_fields_set ) )
if old . get ( " locked " ) and moving and payload . locked is not False :
raise HTTPException ( 409 , " Tidspunktet er låst. Fjern låsen før blokken flyttes eller ændres " )
start = payload . starts_at or old [ " starts_at " ] ; end = payload . ends_at or old [ " ends_at " ]
if end < = start : raise HTTPException ( 422 , " Sluttid skal ligge efter starttid " )
conflict , details = _conflicts ( target , start , end , allocation_id )
if conflict != " none " and not payload . confirm_conflicts : raise HTTPException ( 409 , detail = { " message " : " Tiden overlapper eller overbooker " , " conflict_status " : conflict , * * details } )
cur . execute ( """ UPDATE planner_allocations SET user_id= %s ,starts_at= %s ,ends_at= %s ,note= %s ,conflict_status= %s ,locked= %s ,
version = version + 1 , updated_by = % s , updated_at = NOW ( ) WHERE id = % s AND version = % s RETURNING * """ ,
( target , start , end , payload . note if " note " in payload . model_fields_set else old [ " note " ] , conflict , payload . locked if payload . locked is not None else old . get ( " locked " , False ) , current_user [ " id " ] , allocation_id , payload . version ) )
row = cur . fetchone ( )
if not row : raise HTTPException ( 409 , " Allokeringen blev ændret samtidigt " )
cur . execute ( " INSERT INTO planner_allocation_history(allocation_id,sag_id,action,changed_by,before_value,after_value) VALUES( %s , %s , ' updated ' , %s , %s , %s ) " , ( row [ " id " ] , row [ " sag_id " ] , current_user [ " id " ] , Json ( _serialize ( old ) ) , Json ( _serialize ( row ) ) ) )
conn . commit ( ) ; return row
except HTTPException : conn . rollback ( ) ; raise
except Exception : conn . rollback ( ) ; raise
finally : release_db_connection ( conn )
@router.delete ( " /allocations/ {allocation_id} " )
def delete_allocation ( allocation_id : int , version : int , current_user : dict = Depends ( get_current_user ) ) :
conn = get_db_connection ( )
try :
with conn . cursor ( cursor_factory = RealDictCursor ) as cur :
cur . execute ( " SELECT * FROM planner_allocations WHERE id= %s AND deleted_at IS NULL FOR UPDATE " , ( allocation_id , ) ) ; old = cur . fetchone ( )
if not old : raise HTTPException ( 404 , " Allokeringen findes ikke " )
if not _can_edit ( current_user , old [ " user_id " ] ) : raise HTTPException ( 403 , " Du må ikke slette denne allokering " )
if old [ " version " ] != version : raise HTTPException ( 409 , " Allokeringen er ændret af en anden bruger " )
if old . get ( " locked " ) : raise HTTPException ( 409 , " Tidspunktet er låst. Fjern låsen før blokken slettes " )
cur . execute ( " UPDATE planner_allocations SET deleted_at=NOW(),version=version+1,updated_by= %s WHERE id= %s AND version= %s RETURNING * " , ( current_user [ " id " ] , allocation_id , version ) ) ; row = cur . fetchone ( )
cur . execute ( " INSERT INTO planner_allocation_history(allocation_id,sag_id,action,changed_by,before_value,after_value) VALUES( %s , %s , ' deleted ' , %s , %s , %s ) " , ( row [ " id " ] , row [ " sag_id " ] , current_user [ " id " ] , Json ( _serialize ( old ) ) , Json ( _serialize ( row ) ) ) )
conn . commit ( ) ; return { " deleted " : True , " allocation " : row }
except HTTPException : conn . rollback ( ) ; raise
except Exception : conn . rollback ( ) ; raise
finally : release_db_connection ( conn )
@router.post ( " /allocations/ {allocation_id} /undo " )
def undo_allocation ( allocation_id : int , version : int , current_user : dict = Depends ( get_current_user ) ) :
""" Undo the latest persisted allocation mutation, guarded by its current version. """
conn = get_db_connection ( )
try :
with conn . cursor ( cursor_factory = RealDictCursor ) as cur :
cur . execute ( " SELECT * FROM planner_allocations WHERE id= %s FOR UPDATE " , ( allocation_id , ) )
current = cur . fetchone ( )
if not current : raise HTTPException ( 404 , " Allokeringen findes ikke " )
if not _can_edit ( current_user , current [ " user_id " ] ) : raise HTTPException ( 403 , " Du må ikke fortryde denne ændring " )
if current [ " version " ] != version : raise HTTPException ( 409 , " Kan ikke fortryde: allokeringen er ændret siden " )
cur . execute ( " SELECT * FROM planner_allocation_history WHERE allocation_id= %s ORDER BY id DESC LIMIT 1 " , ( allocation_id , ) )
history = cur . fetchone ( )
if not history : raise HTTPException ( 409 , " Der er ingen ændring at fortryde " )
before = history . get ( " before_value " )
if history [ " action " ] == " created " :
cur . execute ( " UPDATE planner_allocations SET deleted_at=NOW(),version=version+1,updated_by= %s ,updated_at=NOW() WHERE id= %s RETURNING * " , ( current_user [ " id " ] , allocation_id ) )
elif before :
cur . execute ( """ UPDATE planner_allocations SET user_id= %s ,starts_at= %s ,ends_at= %s ,note= %s ,
conflict_status = % s , locked = % s , deleted_at = % s , version = version + 1 , updated_by = % s , updated_at = NOW ( )
WHERE id = % s RETURNING * """ ,
( before [ " user_id " ] , before [ " starts_at " ] , before [ " ends_at " ] , before . get ( " note " ) , before . get ( " conflict_status " , " none " ) , before . get ( " locked " , False ) , before . get ( " deleted_at " ) , current_user [ " id " ] , allocation_id ) )
else : raise HTTPException ( 409 , " Ændringen kan ikke fortrydes " )
row = cur . fetchone ( )
cur . execute ( " INSERT INTO planner_allocation_history(allocation_id,sag_id,action,changed_by,before_value,after_value) VALUES( %s , %s , ' undo ' , %s , %s , %s ) " , ( row [ " id " ] , row [ " sag_id " ] , current_user [ " id " ] , Json ( _serialize ( current ) ) , Json ( _serialize ( row ) ) ) )
conn . commit ( ) ; return row
except HTTPException : conn . rollback ( ) ; raise
except Exception : conn . rollback ( ) ; raise
finally : release_db_connection ( conn )
@router.post ( " /allocations/ {allocation_id} /copy " , status_code = 201 )
def copy_allocation ( allocation_id : int , starts_at : datetime , user_id : int | None = None , confirm_conflicts : bool = False , current_user : dict = Depends ( get_current_user ) ) :
old = execute_query_single ( " SELECT * FROM planner_allocations WHERE id= %s AND deleted_at IS NULL " , ( allocation_id , ) )
if not old : raise HTTPException ( 404 , " Allokeringen findes ikke " )
duration = old [ " ends_at " ] - old [ " starts_at " ]
return create_allocation ( AllocationInput ( sag_id = old [ " sag_id " ] , user_id = user_id or old [ " user_id " ] , starts_at = starts_at , ends_at = starts_at + duration , note = old [ " note " ] , source = " copy " , confirm_conflicts = confirm_conflicts ) , current_user )
@router.get ( " /cases/ {sag_id} /history " )
def case_history ( sag_id : int , current_user : dict = Depends ( get_current_user ) ) :
_require_view ( current_user )
return { " items " : execute_query ( " SELECT * FROM planner_allocation_history WHERE sag_id= %s ORDER BY created_at DESC " , ( sag_id , ) ) or [ ] }