bmc_hub/app/modules/planner/backend/calendar.py

584 lines
35 KiB
Python

"""Shared bookings and absence on the existing planner/time models."""
from datetime import date, datetime, time, timedelta
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field, model_validator
from app.core.auth_dependencies import get_current_user
from app.core.database import atomic_transaction, execute_query, execute_query_single
from app.core.case_visibility import require_case,hidden_case_ids,filter_data
from .router import TZ, _aware,_merge_minutes
from .work import audit, require_target, require_schema,notify_case,db_utc,FINISHED
router = APIRouter(dependencies=[Depends(require_schema)])
class EventInput(BaseModel):
title: str = Field(min_length=1, max_length=255)
description: str | None = Field(default=None, max_length=10000)
sag_id: int | None = None
user_ids: list[int] = Field(min_length=1, max_length=100)
starts_at: datetime
ends_at: datetime
kind: Literal['work', 'meeting', 'internal'] = 'work'
confirm_conflicts: bool = False
version: int | None = Field(default=None, ge=1)
@model_validator(mode='after')
def validate_period(self):
if self.starts_at.tzinfo is None or self.ends_at.tzinfo is None:
raise ValueError('Tidspunkter skal indeholde tidszone')
if self.ends_at <= self.starts_at or self.ends_at-self.starts_at > timedelta(days=370):
raise ValueError('Ugyldig periode')
if not self.title.strip():
raise ValueError('Titel skal udfyldes')
return self
def employee_locks(ids):
for uid in sorted(set(ids)):
execute_query('SELECT pg_advisory_xact_lock(%s,%s)', (2521, uid))
def check_event_conflicts(ids, start, end, event_id=None):
rows=execute_query('''SELECT user_id FROM planner_allocations WHERE user_id=ANY(%s)
AND deleted_at IS NULL AND starts_at<%s AND ends_at>%s AND (%s IS NULL OR event_id IS DISTINCT FROM %s)
UNION SELECT user_id FROM planner_absences WHERE user_id=ANY(%s) AND deleted_at IS NULL AND starts_at<%s AND ends_at>%s
UNION SELECT user_id FROM quick_time_items WHERE user_id=ANY(%s) AND state='planned' AND starts_at<%s AND ends_at>%s
UNION SELECT user_id FROM planner_external_busy WHERE user_id=ANY(%s) AND starts_at<%s AND ends_at>%s''',
(ids,end,start,event_id,event_id,ids,end,start,ids,end,start,ids,end,start)) or []
result=[{'user_id':r['user_id'],'type':'time_conflict'} for r in rows]
schedules=execute_query('SELECT * FROM planner_work_schedules WHERE user_id=ANY(%s) AND active',(ids,)) or []
for uid in ids:
if outside_work_hours(uid,start,end,schedules):
result.append({'user_id':uid,'type':'outside_work_hours'})
return result
def outside_work_hours(uid,start,end,schedules):
from datetime import timezone
rows=[r for r in schedules if r['user_id']==uid]
if not rows:
return False # Unknown norm is shown in capacity, rather than inferred.
a,b=_aware(start),_aware(end)
day=a.astimezone(TZ).date()
windows=[]
while day<=b.astimezone(TZ).date():
for row in rows:
if row['weekday']!=day.weekday() or (row.get('valid_from') and day<row['valid_from']) or (row.get('valid_to') and day>row['valid_to']):
continue
windows.append((datetime.combine(day,row['start_time'],TZ).astimezone(timezone.utc),
datetime.combine(day,row['end_time'],TZ).astimezone(timezone.utc)))
day+=timedelta(days=1)
start_utc,end_utc=a.astimezone(timezone.utc),b.astimezone(timezone.utc)
return _merge_minutes(windows,start_utc,end_utc)<round((end_utc-start_utc).total_seconds()/60)
def mark_conflicts(bookings,others):
"""Half-open intervals: adjacent appointments are never overlaps."""
active={}
entries=[dict(r,_booking=True) for r in bookings]+[dict(r,_booking=False) for r in others]
flags=set()
for row in sorted(entries,key=lambda r:(r['user_id'],_aware(r['starts_at']))):
uid=row['user_id']
current=[r for r in active.get(uid,[]) if _aware(r['ends_at'])>_aware(row['starts_at'])]
for old in current:
if row['_booking']:
flags.add(row['id'])
if old['_booking']:
flags.add(old['id'])
current.append(row)
active[uid]=current
for row in bookings:
row['has_conflict']=row['id'] in flags
def save_event(payload, user, event_id=None):
if payload.sag_id:
require_case(user,payload.sag_id)
ids = sorted(set(payload.user_ids))
for uid in ids:
require_target(user, uid, True)
with atomic_transaction():
old = None
old_ids = []
if event_id:
old = execute_query_single('SELECT * FROM planner_events WHERE id=%s AND deleted_at IS NULL FOR UPDATE', (event_id,))
if not old:
raise HTTPException(404, 'Aftalen findes ikke')
if old['sag_id']:
require_case(user,old['sag_id'])
if old['sag_id']!=payload.sag_id:
private=execute_query_single('SELECT restricted FROM sag_case_visibility WHERE sag_id=%s',(old['sag_id'],))
if private and private['restricted'] and not (user.get('is_superadmin') or 'cases.manage_confidential' in set(user.get('permissions') or [])):
raise HTTPException(403,'Fortrolige aftalers sagstilknytning kræver rettighed til fortrolighed')
if payload.version != old['version']:
raise HTTPException(409, 'Aftalen er ændret. Genindlæs.')
old_rows = execute_query('SELECT * FROM planner_allocations WHERE event_id=%s AND deleted_at IS NULL', (event_id,)) or []
old_ids = [r['user_id'] for r in old_rows]
for uid in old_ids:
require_target(user, uid, True)
if any(r['locked'] for r in old_rows):
raise HTTPException(409, 'Fjern tidslås før aftalen ændres')
employee_locks(ids + old_ids)
active = execute_query('SELECT user_id FROM users WHERE user_id=ANY(%s) AND is_active', (ids,)) or []
if len(active) != len(ids):
raise HTTPException(400, 'Vælg aktive medarbejdere')
if payload.sag_id:
case=execute_query_single('SELECT id,status FROM sag_sager WHERE id=%s AND deleted_at IS NULL',(payload.sag_id,))
if not case:
raise HTTPException(404,'Sagen findes ikke')
from app.modules.sag.frontend.views import _fetch_closed_case_statuses
if str(case['status']).lower() in FINISHED|set(_fetch_closed_case_statuses()):
raise HTTPException(409,'Genåbn sagen før den planlægges')
conflicts = check_event_conflicts(ids,payload.starts_at,payload.ends_at,event_id)
if conflicts and not payload.confirm_conflicts:
message='Aftalen ligger uden for arbejdstiden' if all(r['type']=='outside_work_hours' for r in conflicts) else 'Aftalen overlapper med en anden reservation'
raise HTTPException(409, {'message': message, 'conflicts': conflicts, 'confirm_conflicts': True})
values = (payload.sag_id,payload.title.strip(),payload.description,payload.kind,payload.starts_at,payload.ends_at,user['id'])
if event_id:
event = execute_query_single('''UPDATE planner_events SET sag_id=%s,title=%s,description=%s,kind=%s,
starts_at=%s,ends_at=%s,updated_by=%s,updated_at=NOW(),version=version+1 WHERE id=%s RETURNING *''', (*values,event_id))
else:
event = execute_query_single('''INSERT INTO planner_events(sag_id,title,description,kind,starts_at,ends_at,updated_by,created_by)
VALUES(%s,%s,%s,%s,%s,%s,%s,%s) RETURNING *''', (*values,user['id']))
event_id = event['id']
execute_query('UPDATE planner_allocations SET deleted_at=NOW(),version=version+1,updated_by=%s WHERE event_id=%s AND deleted_at IS NULL AND NOT(user_id=ANY(%s))', (user['id'],event_id,ids),fetch=False)
for uid in ids:
existing = execute_query_single('SELECT id FROM planner_allocations WHERE event_id=%s AND user_id=%s AND deleted_at IS NULL', (event_id,uid))
if existing:
execute_query('''UPDATE planner_allocations SET sag_id=%s,starts_at=%s,ends_at=%s,
note=%s,updated_by=%s,updated_at=NOW(),version=version+1 WHERE id=%s''',
(payload.sag_id,payload.starts_at,payload.ends_at,payload.description,user['id'],existing['id']),fetch=False)
else:
execute_query('''INSERT INTO planner_allocations(event_id,sag_id,user_id,starts_at,ends_at,note,created_by,updated_by)
VALUES(%s,%s,%s,%s,%s,%s,%s,%s)''', (event_id,payload.sag_id,uid,payload.starts_at,payload.ends_at,payload.description,user['id'],user['id']),fetch=False)
audit(user,'event_update' if old else 'event_create','event',event_id,old,event,payload.sag_id)
notify_case(user,payload.sag_id,f'event:{event_id}:{event["version"]}',
'En fast aftale er oprettet eller ændret.',ids+old_ids)
return {'event': event, 'conflicts': conflicts}
@router.post('/events', status_code=201)
def create_event(payload: EventInput, user: dict = Depends(get_current_user)):
return save_event(payload,user)
@router.patch('/events/{event_id}')
def update_event(event_id: int, payload: EventInput, user: dict = Depends(get_current_user)):
return save_event(payload,user,event_id)
@router.delete('/events/{event_id}')
def cancel_event(event_id: int, version: int, user: dict = Depends(get_current_user)):
with atomic_transaction():
old = execute_query_single('SELECT * FROM planner_events WHERE id=%s AND deleted_at IS NULL FOR UPDATE', (event_id,))
if not old:
raise HTTPException(404, 'Aftalen findes ikke')
if old['sag_id']:
require_case(user,old['sag_id'])
if old['version'] != version:
raise HTTPException(409, 'Aftalen er ændret')
participants = execute_query('SELECT user_id,locked FROM planner_allocations WHERE event_id=%s AND deleted_at IS NULL', (event_id,)) or []
for row in participants:
require_target(user,row['user_id'],True)
if row['locked']:
raise HTTPException(409,'Aftalen er låst')
employee_locks([r['user_id'] for r in participants])
execute_query('UPDATE planner_events SET deleted_at=NOW(),version=version+1,updated_by=%s WHERE id=%s', (user['id'],event_id),fetch=False)
execute_query('UPDATE planner_allocations SET deleted_at=NOW(),version=version+1,updated_by=%s WHERE event_id=%s AND deleted_at IS NULL', (user['id'],event_id),fetch=False)
audit(user,'event_cancel','event',event_id,old,{'cancelled':True},old['sag_id'])
notify_case(user,old['sag_id'],f'event:{event_id}:cancel:{version}',
'En fast aftale er annulleret.',[r['user_id'] for r in participants])
return {'cancelled': True}
class AbsenceInput(BaseModel):
user_id: int = Field(gt=0)
starts_at: datetime
ends_at: datetime
kind: str | None = Field(default=None,max_length=40)
note: str | None = Field(default=None,max_length=2000)
version: int | None = Field(default=None,ge=1)
whole_day_start: date | None = None
whole_day_end: date | None = None
@model_validator(mode='after')
def validate_period(self):
if self.whole_day_start is not None or self.whole_day_end is not None:
if self.whole_day_start is None or self.whole_day_end is None or self.whole_day_end<self.whole_day_start:
raise ValueError('Angiv første og sidste fraværsdag')
self.starts_at=datetime.combine(self.whole_day_start,time.min,TZ)
self.ends_at=datetime.combine(self.whole_day_end+timedelta(days=1),time.min,TZ)
if self.starts_at.tzinfo is None or self.ends_at.tzinfo is None or self.ends_at <= self.starts_at:
raise ValueError('Angiv gyldige tidspunkter med tidszone')
if self.ends_at-self.starts_at > timedelta(days=370):
raise ValueError('Fravær må højst dække 370 dage')
return self
def require_absence(user, target):
require_target(user,target,True)
permission = 'planner.absence_own' if target == int(user['id']) else 'planner.absence_team'
if not (user.get('is_superadmin') or set(user.get('permissions') or []) & {permission,'planner.admin'}):
raise HTTPException(403,'Du må ikke registrere dette fravær')
def can_see_absence_reason(user,target):
return bool(user.get('is_superadmin') or target==int(user['id']) or set(user.get('permissions') or []) & {'planner.absence_reason','planner.admin'})
def visible_absence(row,user):
result=dict(row)
if not can_see_absence_reason(user,row['user_id']):
result['kind']='unavailable'
result['note']=None
start,end=(_aware(row[k]).astimezone(TZ) for k in ('starts_at','ends_at'))
result['whole_days']=start.time()==time.min and end.time()==time.min
result['all_day']=start.date()!=end.date() or result['whole_days']
result['display_start']=start.date().isoformat()
result['display_end']=(end.date()+timedelta(days=bool(end.time()!=time.min))).isoformat()
return result
def save_absence(payload,user,absence_id=None):
require_absence(user,payload.user_id)
with atomic_transaction():
employee_locks([payload.user_id])
if not execute_query_single('SELECT user_id FROM users WHERE user_id=%s',(payload.user_id,)):
raise HTTPException(400,'Medarbejderen findes ikke')
old = None
if absence_id:
old = execute_query_single('SELECT * FROM planner_absences WHERE id=%s AND deleted_at IS NULL FOR UPDATE',(absence_id,))
if not old:
raise HTTPException(404,'Fravær findes ikke')
require_absence(user,old['user_id'])
if old['user_id'] != payload.user_id:
raise HTTPException(400,'Fravær kan ikke overdrages')
if old['version'] != payload.version:
raise HTTPException(409,'Fravær er ændret')
if execute_query_single('SELECT id FROM tmodule_times WHERE planner_absence_id=%s',(absence_id,)):
raise HTTPException(409,'Fraværet er allerede tidsregistreret')
if not can_see_absence_reason(user,old['user_id']) and (payload.kind is not None or 'note' in payload.model_fields_set):
raise HTTPException(403,'Du må kun ændre perioden, ikke fraværsårsagen')
kind=payload.kind if payload.kind is not None else old['kind'] if old else None
note=payload.note if 'note' in payload.model_fields_set or not old else old['note']
if not execute_query_single('SELECT code FROM planner_activity_types WHERE code=%s AND is_absence AND (active OR %s)',(kind,bool(old and kind==old['kind']))):
raise HTTPException(400,'Ukendt fraværstype')
# The employee advisory lock serializes create/update checks, including concurrent requests.
# Legacy duplicate periods are retained; new/edited periods cannot introduce the same type twice.
if execute_query_single('''SELECT id FROM planner_absences WHERE user_id=%s AND kind=%s
AND deleted_at IS NULL AND id IS DISTINCT FROM %s AND starts_at<%s AND ends_at>%s LIMIT 1''',
(payload.user_id,kind,absence_id,payload.ends_at,payload.starts_at)):
raise HTTPException(409,{'code':'absence_overlap',
'message':'Der findes allerede fravær af denne type i perioden. Ret den eksisterende periode i stedet.'})
values = (payload.user_id,payload.starts_at,payload.ends_at,kind,note)
if absence_id:
row = execute_query_single('UPDATE planner_absences SET user_id=%s,starts_at=%s,ends_at=%s,kind=%s,note=%s,version=version+1 WHERE id=%s RETURNING *',(*values,absence_id))
else:
row = execute_query_single('INSERT INTO planner_absences(user_id,starts_at,ends_at,kind,note,created_by) VALUES(%s,%s,%s,%s,%s,%s) RETURNING *',(*values,user['id']))
affected = execute_query('SELECT id,sag_id FROM planner_allocations WHERE user_id=%s AND deleted_at IS NULL AND starts_at<%s AND ends_at>%s',(payload.user_id,payload.ends_at,payload.starts_at)) or []
audit(user,'absence_update' if old else 'absence_create','absence',row['id'],old,row)
for sag_id in {r['sag_id'] for r in affected if r['sag_id']}:
notify_case(user,sag_id,f'absence:{row["id"]}:{row["version"]}:{sag_id}',
'Fravær påvirker en planlagt aftale.',[payload.user_id])
return {'absence':visible_absence(row,user),'affected_allocations':affected}
@router.post('/absences',status_code=201)
def create_absence(payload:AbsenceInput,user:dict=Depends(get_current_user)):
return save_absence(payload,user)
@router.patch('/absences/{absence_id}')
def update_absence(absence_id:int,payload:AbsenceInput,user:dict=Depends(get_current_user)):
return save_absence(payload,user,absence_id)
@router.delete('/absences/{absence_id}')
def cancel_absence(absence_id:int,version:int,user:dict=Depends(get_current_user)):
with atomic_transaction():
old=execute_query_single('SELECT * FROM planner_absences WHERE id=%s AND deleted_at IS NULL',(absence_id,))
if not old:
raise HTTPException(404,'Fravær findes ikke')
require_absence(user,old['user_id'])
employee_locks([old['user_id']])
row=execute_query_single('SELECT * FROM planner_absences WHERE id=%s AND deleted_at IS NULL FOR UPDATE',(absence_id,))
if not row or row['version']!=version:
raise HTTPException(409,'Fraværet er ændret')
if execute_query_single('SELECT id FROM tmodule_times WHERE planner_absence_id=%s',(absence_id,)):
raise HTTPException(409,'Fraværet er allerede tidsregistreret og kan ikke annulleres her')
execute_query('UPDATE planner_absences SET deleted_at=NOW(),version=version+1 WHERE id=%s',(absence_id,),fetch=False)
audit(user,'absence_cancel','absence',absence_id,old,{'cancelled':True})
return {'cancelled':True}
@router.post('/absences/{absence_id}/register')
def register_absence(absence_id:int,version:int,user:dict=Depends(get_current_user)):
"""Explicit, replay-safe conversion to nonbillable actual time."""
with atomic_transaction():
row = execute_query_single('SELECT * FROM planner_absences WHERE id=%s AND deleted_at IS NULL',(absence_id,))
if not row:
raise HTTPException(404,'Fravær findes ikke')
require_absence(user,row['user_id'])
employee_locks([row['user_id']])
row=execute_query_single('SELECT * FROM planner_absences WHERE id=%s AND deleted_at IS NULL FOR UPDATE',(absence_id,))
if not row:
raise HTTPException(409,'Fraværet er ændret')
existing = execute_query('SELECT id FROM tmodule_times WHERE planner_absence_id=%s ORDER BY worked_date',(absence_id,)) or []
if existing:
return {'time_entry_ids':[r['id'] for r in existing],'already_registered':True}
if row['version'] != version:
raise HTTPException(409,'Fravær er ændret')
if _aware(row['ends_at']) > datetime.now(TZ):
raise HTTPException(400,'Fremtidigt fravær kan ikke registreres som afholdt')
if execute_query_single('''SELECT id FROM tmodule_times WHERE medarbejder_id=%s
AND planner_absence_id IS NOT NULL AND planner_absence_id<>%s
AND start_tid<%s AND slut_tid>%s AND status<>'rejected' LIMIT 1''',
(row['user_id'],absence_id,db_utc(row['ends_at']).replace(tzinfo=None),
db_utc(row['starts_at']).replace(tzinfo=None))):
raise HTTPException(409,'Der er allerede registreret fravær i en del af perioden')
# Only scheduled working minutes become actual absence, never nights/weekends.
schedules = execute_query('SELECT * FROM planner_work_schedules WHERE user_id=%s AND active',(row['user_id'],)) or []
days = absence_work_days(row,schedules)
if not days:
raise HTTPException(400,'Fraværet indeholder ingen planlagt arbejdstid')
name = execute_query_single('SELECT COALESCE(full_name,username) AS name FROM users WHERE user_id=%s',(row['user_id'],))['name']
entry_ids = []
for day,start,end,minutes in days:
entry = execute_query_single('''INSERT INTO tmodule_times(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,start_tid,slut_tid,planner_absence_id,planner_activity_code)
VALUES(%s,%s,%s,%s,'pending',false,'draft',%s,0,'manuel','manuel','kladde',%s,false,1,false,'other',%s,%s,%s,%s) RETURNING id''',
('Fravær',minutes/60,day,name,minutes,row['user_id'],
db_utc(start).replace(tzinfo=None),db_utc(end).replace(tzinfo=None),absence_id,row['kind']))
entry_ids.append(entry['id'])
audit(user,'absence_register','absence',absence_id,None,{'time_entry_ids':entry_ids})
return {'time_entry_ids':entry_ids,'already_registered':False}
def absence_work_minutes(absence,schedules):
return sum(row[3] for row in absence_work_days(absence,schedules))
def absence_work_days(absence,schedules):
start,end = _aware(absence['starts_at']).astimezone(TZ),_aware(absence['ends_at']).astimezone(TZ)
day = start.date()
last_day=end.date()
start,end=db_utc(start),db_utc(end)
result = []
while day <= last_day:
total = 0
intervals = []
for schedule in schedules:
if schedule['weekday'] != day.weekday():
continue
if schedule.get('valid_from') and day < schedule['valid_from'] or schedule.get('valid_to') and day > schedule['valid_to']:
continue
a,b = datetime.combine(day,schedule['start_time'],TZ),datetime.combine(day,schedule['end_time'],TZ)
a,b=db_utc(a),db_utc(b)
overlap = max(0,(min(end,b)-max(start,a)).total_seconds()/60)
gross = (b-a).total_seconds()/60
total += round(max(0,overlap-int(schedule['break_minutes'] or 0)) if overlap>=gross else overlap)
if overlap:
intervals.append((max(start,a),min(end,b)))
if total:
result.append((day,min(a for a,b in intervals),max(b for a,b in intervals),total))
day += timedelta(days=1)
return result
@router.get('/activity-types')
def activity_types(user:dict=Depends(get_current_user)):
require_target(user,int(user['id']))
return execute_query('SELECT * FROM planner_activity_types ORDER BY label') or []
@router.get('/schedule')
def schedule(starts_at:datetime,ends_at:datetime,user_id:list[int]=Query(default=[]),user:dict=Depends(get_current_user),include_unassigned:bool=False):
if ends_at<=starts_at or ends_at-starts_at>timedelta(days=370):
raise HTTPException(400,'Ugyldig periode')
ids = sorted(set(user_id or [int(user['id'])]))
for uid in ids:
require_target(user,uid)
bookings = execute_query('''SELECT a.id,a.event_id,a.user_id,a.sag_id,a.starts_at,a.ends_at,a.version,a.locked,
COALESCE(e.title,s.titel,'Optaget') AS title,e.kind,COALESCE(e.description,a.note) AS description,e.version AS event_version,
ARRAY(SELECT p.user_id FROM planner_allocations p WHERE p.event_id=a.event_id AND p.deleted_at IS NULL ORDER BY p.user_id) AS participants
FROM planner_allocations a LEFT JOIN planner_events e ON e.id=a.event_id
LEFT JOIN sag_sager s ON s.id=a.sag_id
WHERE a.deleted_at IS NULL AND a.user_id=ANY(%s) AND a.starts_at<%s AND a.ends_at>%s''',(ids,ends_at,starts_at)) or []
absences = execute_query('''SELECT a.*,EXISTS(SELECT 1 FROM tmodule_times t WHERE t.planner_absence_id=a.id) AS registered
FROM planner_absences a WHERE a.deleted_at IS NULL AND a.user_id=ANY(%s) AND a.starts_at<%s AND a.ends_at>%s''',(ids,ends_at,starts_at)) or []
absences=[visible_absence(row,user) for row in absences]
quick = execute_query("SELECT id,user_id,description AS title,starts_at,ends_at FROM quick_time_items WHERE state='planned' AND user_id=ANY(%s) AND starts_at<%s AND ends_at>%s",(ids,ends_at,starts_at)) or []
hidden=hidden_case_ids(user)
include_inbox=include_unassigned and bool(user.get('is_superadmin') or set(user.get('permissions') or []) & {'planner.admin','planner.allocate'})
cases=execute_query('''SELECT s.id,s.titel,s.start_date,s.start_time,s.deadline,s.deferred_until,s.updated_at FROM sag_sager s
WHERE s.deleted_at IS NULL AND NOT(s.id=ANY(%s)) AND
(s.ansvarlig_bruger_id=ANY(%s) OR EXISTS(SELECT 1 FROM sag_case_assignees a WHERE a.sag_id=s.id AND a.user_id=ANY(%s))
OR (%s AND NOT EXISTS(SELECT 1 FROM sag_case_assignees a WHERE a.sag_id=s.id)))
AND ((s.start_date BETWEEN %s AND %s) OR (s.deadline BETWEEN %s AND %s) OR (s.deferred_until BETWEEN %s AND %s))''',
(list(hidden),ids,ids,include_inbox,starts_at.date(),ends_at.date(),db_utc(_aware(starts_at)).replace(tzinfo=None),db_utc(_aware(ends_at)).replace(tzinfo=None),db_utc(_aware(starts_at)).replace(tzinfo=None),db_utc(_aware(ends_at)).replace(tzinfo=None))) or []
milestones=[]
for case in cases:
if case['start_date']:
value=datetime.combine(case['start_date'],case['start_time'],TZ).isoformat() if case['start_time'] else case['start_date'].isoformat()
milestones.append({'id':f'start-{case["id"]}','sag_id':case['id'],'title':'Start: '+case['titel'],'start':value,'allDay':not bool(case['start_time']),'kind':'case_start'})
if case['deadline']:
milestones.append({'id':f'deadline-{case["id"]}','sag_id':case['id'],'title':'Deadline: '+case['titel'],
'start':db_utc(case['deadline']).astimezone(TZ).isoformat(),'kind':'deadline'})
if case['deferred_until']:
milestones.append({'id':f'deferred-{case["id"]}','sag_id':case['id'],'title':'Udsat: '+case['titel'],
'start':db_utc(case['deferred_until']).astimezone(TZ).isoformat(),'kind':'deferred'})
versions={case['id']:db_utc(case['updated_at']) for case in cases}
for milestone in milestones:
milestone['case_updated_at']=versions[milestone['sag_id']]
reminders=execute_query('''SELECT r.id,r.sag_id,r.title,COALESCE(r.next_check_at,r.scheduled_at) AS moment
FROM sag_reminders r JOIN sag_sager s ON s.id=r.sag_id WHERE r.deleted_at IS NULL AND r.is_active
AND s.deleted_at IS NULL AND NOT(s.id=ANY(%s))
AND (r.recipient_user_ids && %s::integer[] OR r.created_by_user_id=ANY(%s))
AND COALESCE(r.next_check_at,r.scheduled_at)>=%s AND COALESCE(r.next_check_at,r.scheduled_at)<%s''',
(list(hidden),ids,ids,starts_at,ends_at)) or []
for reminder in reminders:
milestones.append({'id':f'reminder-{reminder["id"]}','sag_id':reminder['sag_id'],
'title':'Påmindelse: '+reminder['title'],'start':_aware(reminder['moment']).isoformat(),'kind':'reminder'})
registered=execute_query('''SELECT t.id,t.sag_id,t.medarbejder_id AS user_id,
COALESCE(s.titel,t.description,'Registreret tid') AS title,
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,1))) AS ends_at
FROM tmodule_times t LEFT JOIN sag_sager s ON s.id=t.sag_id
WHERE t.medarbejder_id=ANY(%s) AND t.status<>'rejected' AND t.planner_absence_id IS NULL
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''',
(ids,db_utc(_aware(starts_at)).replace(tzinfo=None),db_utc(_aware(ends_at)).replace(tzinfo=None))) or []
for entry in registered:
entry['starts_at']=db_utc(entry['starts_at'])
entry['ends_at']=db_utc(entry['ends_at'])
# Converted standalone work is already represented by its canonical time entry above.
external=execute_query('SELECT id,user_id,starts_at,ends_at FROM planner_external_busy WHERE user_id=ANY(%s) AND starts_at<%s AND ends_at>%s',(ids,ends_at,starts_at)) or []
mark_conflicts(bookings,absences+quick+external)
schedules=execute_query('SELECT * FROM planner_work_schedules WHERE user_id=ANY(%s) AND active',(ids,)) or []
for row in bookings:
row['outside_work_hours']=outside_work_hours(row['user_id'],row['starts_at'],row['ends_at'],schedules)
return filter_data({'bookings':bookings,'absences':absences,'standalone_plans':quick,
'milestones':milestones,'registered':registered,'external_busy':external},hidden)
def can_view_absence_report(user):
return bool(user.get('is_superadmin') or set(user.get('permissions') or []) & {'planner.admin','planner.absence_report'})
def require_absence_report(user):
from .router import _require_view
_require_view(user)
if not can_view_absence_report(user):
raise HTTPException(403,'Du har ikke adgang til fraværsrapporter')
def merged_absence_totals(rows,schedules,start,end):
"""Union overlapping absence before counting scheduled minutes and day fractions."""
spans=sorted((max(_aware(r['starts_at']),start),min(_aware(r['ends_at']),end)) for r in rows)
merged=[]
for a,b in spans:
if b<=a:
continue
if merged and a<=merged[-1][1]:
merged[-1]=(merged[-1][0],max(b,merged[-1][1]))
else:
merged.append((a,b))
minutes_by_day={}
for a,b in merged:
for day,_,_,minutes in absence_work_days({'starts_at':a,'ends_at':b},schedules):
minutes_by_day[day]=minutes_by_day.get(day,0)+minutes
days=0
for day,minutes in minutes_by_day.items():
norm=absence_work_minutes({'starts_at':datetime.combine(day,time.min,TZ),
'ends_at':datetime.combine(day+timedelta(days=1),time.min,TZ)},schedules)
minutes_by_day[day]=min(minutes,norm)
days+=min(1,minutes/norm) if norm else 0
return {'planned_minutes':sum(minutes_by_day.values()),'planned_days':round(days,2)}
@router.get('/absence-report')
def absence_report(start_date:date,end_date:date,user_id:int|None=None,
limit:int=Query(50,ge=1,le=500),offset:int=Query(0,ge=0),
user:dict=Depends(get_current_user),group_by:Literal['date','type']='date'):
require_absence_report(user)
if end_date<start_date or (end_date-start_date).days>366:
raise HTTPException(400,'Vælg en periode på højst 367 dage')
from .router import _can_view_target
employees=execute_query('SELECT user_id AS id,COALESCE(full_name,username) AS name FROM users ORDER BY name,user_id') or []
if user_id is not None:
require_target(user,user_id)
employees=[r for r in employees if r['id']==user_id]
else:
employees=[r for r in employees if _can_view_target(user,r['id'])]
# Reporting grants no additional employee scope or case access.
for employee in employees:
require_target(user,employee['id'])
ids=[r['id'] for r in employees]
names={r['id']:r['name'] for r in employees}
start=datetime.combine(start_date,time.min,TZ)
end=datetime.combine(end_date+timedelta(days=1),time.min,TZ)
rows=execute_query("""SELECT a.*,COALESCE(t.minutes,0)::int AS actual_minutes,
COALESCE(t.entries,0)::int AS actual_entries,COALESCE(k.label,a.kind) AS kind_label
FROM planner_absences a LEFT JOIN planner_activity_types k ON k.code=a.kind
LEFT JOIN (SELECT planner_absence_id,SUM(COALESCE(faktisk_tid_min,
ROUND(COALESCE(approved_hours,original_hours,0)*60))) AS minutes,COUNT(*) AS entries
FROM tmodule_times WHERE planner_absence_id IS NOT NULL AND status<>'rejected'
AND medarbejder_id=ANY(%s) AND worked_date BETWEEN %s AND %s GROUP BY planner_absence_id) t
ON t.planner_absence_id=a.id
WHERE a.deleted_at IS NULL AND a.user_id=ANY(%s) AND
((a.starts_at<%s AND a.ends_at>%s) OR t.entries>0)
ORDER BY a.starts_at,a.user_id,a.id""",(ids,start_date,end_date,ids,end,start)) or []
schedules=execute_query('SELECT * FROM planner_work_schedules WHERE user_id=ANY(%s) AND active',(ids,)) or []
summaries=[]
for employee in employees:
own=[r for r in rows if r['user_id']==employee['id']]
norm=[r for r in schedules if r['user_id']==employee['id']]
summaries.append(dict(user_id=employee['id'],name=employee['name'],norm_configured=bool(norm),
actual_minutes=sum(r['actual_minutes'] for r in own),periods=len(own),
**merged_absence_totals(own,norm,start,end)))
# Mask before grouping/sorting: hidden reasons must never form separate groups.
by_type={}
for row in rows:
kind=row['kind'] if can_see_absence_reason(user,row['user_id']) else 'unavailable'
label=row['kind_label'] if kind!='unavailable' else 'Fravær'
by_type.setdefault(kind,{'kind':kind,'kind_label':label,'rows':[]})['rows'].append(row)
type_summary=[]
for group in sorted(by_type.values(),key=lambda g:(g['kind_label'].casefold(),g['kind'])):
members=[]
for employee in employees:
own=[r for r in group['rows'] if r['user_id']==employee['id']]
if not own:
continue
norm=[r for r in schedules if r['user_id']==employee['id']]
members.append(dict(user_id=employee['id'],name=employee['name'],norm_configured=bool(norm),
actual_minutes=sum(r['actual_minutes'] for r in own),periods=len(own),
**merged_absence_totals(own,norm,start,end)))
type_summary.append(dict(kind=group['kind'],kind_label=group['kind_label'],employees=members,
periods=len(group['rows']),norm_configured=all(r['norm_configured'] for r in members),
planned_minutes=sum(r['planned_minutes'] for r in members),
planned_days=round(sum(r['planned_days'] for r in members),2),
actual_minutes=sum(r['actual_minutes'] for r in members)))
if group_by=='type':
rows=[row for group in sorted(by_type.values(),key=lambda g:(g['kind_label'].casefold(),g['kind']))
for row in sorted(group['rows'],key=lambda r:(names[r['user_id']].casefold(),r['user_id'],r['starts_at'],r['id']))]
items=[]
for row in rows[offset:offset+limit]:
safe=visible_absence(row,user)
norm=[r for r in schedules if r['user_id']==row['user_id']]
values=merged_absence_totals([row],norm,start,end)
# Private notes never belong in a report or its export.
items.append(dict(id=row['id'],user_id=row['user_id'],name=names[row['user_id']],
kind=safe['kind'],kind_label=row['kind_label'] if safe['kind']!='unavailable' else 'Fravær',
starts_at=row['starts_at'],ends_at=row['ends_at'],actual_minutes=row['actual_minutes'],
registered=bool(row['actual_entries']),norm_configured=bool(norm),**values))
return {'items':items,'total':len(rows),'limit':limit,'offset':offset,'summary':summaries,'type_summary':type_summary,
'employees':employees,'start_date':start_date,'end_date':end_date,
'totals':{'planned_minutes':sum(r['planned_minutes'] for r in summaries),
'planned_days':round(sum(r['planned_days'] for r in summaries),2),
'actual_minutes':sum(r['actual_minutes'] for r in summaries)}}