584 lines
35 KiB
Python
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)}}
|