"""Personal work desk, reusing queue, planner and time-entry contracts.""" from datetime import date, datetime, time, timedelta, timezone from typing import Literal from uuid import UUID from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, Field, model_validator from fastapi.encoders import jsonable_encoder from psycopg2.extras import Json from app.core.auth_dependencies import get_current_user from app.core.case_visibility import require_case from app.core.database import atomic_transaction, execute_query, execute_query_single from . import work from .router import TZ, _aware, _can_edit def require_desk_schema(): work.require_schema() if not execute_query_single("SELECT 1 FROM information_schema.columns WHERE table_name='planner_queue_versions' AND column_name='desk_manual_order'"): raise HTTPException(503, 'Arbejdsbord afventer databaseopdatering 1073.') router = APIRouter(dependencies=[Depends(require_desk_schema)]) def daily_task(row, day, now): """Expose why work is due today; never infer an individual case estimate.""" start = datetime.combine(day, time.min, TZ) end = start + timedelta(days=1) deadline = work.db_utc(row['deadline']).astimezone(TZ) if row.get('deadline') else None slots = row.get('planned_slots') or [] today_slots = [s for s in slots if _aware(datetime.fromisoformat(str(s['starts_at']))) < end and _aware(datetime.fromisoformat(str(s['ends_at']))) > start] planned = round(sum(max(0, (min(_aware(datetime.fromisoformat(str(s['ends_at']))), end) - max(_aware(datetime.fromisoformat(str(s['starts_at']))), start)).total_seconds()) / 60 for s in today_slots)) blocked = None if row.get('work_status') in {'waiting', 'paused'} or str(row.get('status') or '').lower() in work.WAITING: blocked = 'Afventer' if row.get('work_status') != 'paused' else 'På pause' if row.get('deferred_until_case_id') and not work.ready({**row, 'start_date': None, 'work_status': 'ready'}, now): blocked = f"Afhænger af sag #{row['deferred_until_case_id']}" if row.get('snoozed_until') and _aware(row['snoozed_until']) > now: blocked = 'Udsat' if row.get('deferred_until') and work.db_utc(row['deferred_until']) > now: blocked = 'Udsat' remaining = work.remaining(row) missing = None if remaining is None else max(0, remaining - int(row.get('future_planned_minutes') or 0)) overdue = bool(deadline and deadline < now) due = bool(deadline and deadline < end) included = row.get('work_status') != 'done' and (bool(today_slots) or due or not row.get('start_date') or row['start_date'] <= day) if overdue: reason, rank = 'Deadline overskredet', 0 elif today_slots: reason, rank = 'Tid reserveret i dag', 1 elif due: reason, rank = 'Deadline i dag', 2 elif row.get('start_date') == day: reason, rank = 'Starter i dag', 3 else: reason, rank = 'Åben opgave · kan fortsættes i dag', 4 if blocked: reason = blocked + ' · ' + reason if row.get('start_date') and row['start_date'] > day and due: reason += ' · start ligger efter deadline' after_deadline = bool(deadline and any(_aware(datetime.fromisoformat(str(s['ends_at']))) > deadline for s in slots)) if after_deadline: reason += ' · tid reserveret efter deadline' next_slot = min((str(s['starts_at']) for s in today_slots), default='9999') priority = {'urgent': 0, 'akut': 0, 'high': 1, 'høj': 1, 'normal': 2, 'low': 3}.get(str(row.get('priority') or '').lower(), 2) return {**row, 'in_day': included, 'blocked_reason': blocked, 'priority_reason': reason, 'remaining_work_minutes': remaining, 'missing_minutes': missing, 'planned_today_minutes': planned, 'today_slots': today_slots, 'ready': work.ready(row, now), 'booking_after_deadline': after_deadline, '_rank': (bool(blocked), rank, deadline.isoformat() if deadline else '9999', next_slot, priority, row.get('start_date') or date.max, missing if missing is not None else 10**9, row['sag_id'])} @router.get('/desk') def desk(day: date | None = None, user: dict = Depends(get_current_user)): uid = int(user['id']) work.require_target(user, uid) now = datetime.now(TZ) day = day or now.date() with atomic_transaction(): execute_query('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ', fetch=False) version = execute_query_single('SELECT * FROM planner_queue_versions WHERE user_id=%s', (uid,)) or {} rows = work.queue_rows(uid, limit=10001, user=user) if len(rows) > 10000: raise HTTPException(422, 'Køen er for stor til Arbejdsbord. Brug opgavevisningen til at afgrænse den.') # Past slots on the chosen day must remain visible even after they end. bookings = execute_query('''SELECT sag_id,id,event_id,starts_at,ends_at FROM planner_allocations WHERE user_id=%s AND sag_id=ANY(%s) AND deleted_at IS NULL AND starts_at<%s AND ends_at>%s''', (uid, [r['sag_id'] for r in rows], datetime.combine(day+timedelta(days=1), time.min, TZ), datetime.combine(day,time.min,TZ))) or [] contacts = execute_query('''SELECT DISTINCT ON (sk.sag_id) sk.sag_id,c.id, TRIM(CONCAT_WS(' ',c.first_name,c.last_name)) AS name,c.email,COALESCE(NULLIF(c.mobile,''),c.phone) AS phone FROM sag_kontakter sk JOIN contacts c ON c.id=sk.contact_id WHERE sk.sag_id=ANY(%s) AND sk.deleted_at IS NULL ORDER BY sk.sag_id,sk.is_primary DESC,sk.id''', ([r['sag_id'] for r in rows],)) or [] by_case = {c['sag_id']: c for c in contacts} def slot_key(slot): # jsonb future slots use ISO strings; today's SQL rows use datetimes. # Normalize both before merging, preserving simultaneous events. return (slot.get('event_id'), _aware(datetime.fromisoformat(str(slot['starts_at']))).astimezone(timezone.utc).isoformat(), _aware(datetime.fromisoformat(str(slot['ends_at']))).astimezone(timezone.utc).isoformat()) result = [] for row in rows: merged = {slot_key(s): s for s in row.get('planned_slots') or []} merged.update({slot_key(b): b for b in bookings if b['sag_id'] == row['sag_id']}) row['planned_slots'] = list(merged.values()) task = daily_task(row, day, now) task['contact'] = by_case.get(row['sag_id']) task['is_new'] = not row.get('desk_seen_at') result.append(task) recommended = [r['sag_id'] for r in sorted(result, key=lambda r: r['_rank'])] manual = bool(version.get('desk_manual_order')) if not manual: result.sort(key=lambda r: r['_rank']) else: result.sort(key=lambda r: (r['queue_position'], r['sag_id'])) for row in result: row.pop('_rank') from .capacity import working_capacity horizon_start = min(datetime.combine(day+timedelta(days=1),time.min,TZ), max(datetime.combine(day,time.min,TZ), now)) horizon_end = datetime.combine(day+timedelta(days=1),time.min,TZ) schedules = execute_query('SELECT * FROM planner_work_schedules WHERE user_id=%s AND active', (uid,)) or [] occupied = execute_query('''SELECT starts_at,ends_at FROM planner_allocations WHERE user_id=%s AND deleted_at IS NULL AND starts_at<%s AND ends_at>%s UNION ALL SELECT starts_at,ends_at FROM planner_absences WHERE user_id=%s AND deleted_at IS NULL AND starts_at<%s AND ends_at>%s UNION ALL SELECT starts_at,ends_at FROM planner_external_busy WHERE user_id=%s AND starts_at<%s AND ends_at>%s UNION ALL SELECT starts_at,ends_at FROM quick_time_items WHERE user_id=%s AND state='planned' AND starts_at<%s AND ends_at>%s''', (uid,horizon_end,horizon_start)*4) or [] capacity = working_capacity(schedules, [(_aware(b['starts_at']), _aware(b['ends_at'])) for b in occupied], horizon_start, horizon_end) capacity['configured'] = bool(schedules) return {'items': result, 'recommended_ids': recommended, 'version': version.get('version', 1), 'stored_order_ids': [r['sag_id'] for r in sorted(result, key=lambda r: (r['queue_position'], r['sag_id']))], 'manual_order': manual, 'day': day, 'can_edit': _can_edit(user, uid), 'capacity': capacity} class DeskOrder(BaseModel): version: int = Field(ge=1) mode: Literal['manual', 'recommended'] = 'manual' ids: list[int] = Field(default_factory=list, max_length=10000) @router.put('/desk/order') def desk_order(payload: DeskOrder, user: dict = Depends(get_current_user)): uid = int(user['id']) work.require_target(user, uid, True) with atomic_transaction(): version = work.lock_queue(uid) if version != payload.version: raise HTTPException(409, 'Køen er ændret. Genindlæs og prøv igen.') if len(set(payload.ids)) != len(payload.ids): raise HTTPException(400, 'En opgave må kun forekomme én gang') if payload.mode == 'manual' or payload.ids: allowed = {r['sag_id'] for r in work.queue_rows(uid, limit=10001, user=user)} if set(payload.ids) != allowed: raise HTTPException(409, 'Opgaverne er ændret. Genindlæs før sortering.') for position, sid in enumerate(payload.ids, 1): execute_query('UPDATE sag_case_assignees SET queue_position=%s,version=version+1 WHERE user_id=%s AND sag_id=%s', (position*1024, uid, sid), fetch=False) execute_query('UPDATE planner_queue_versions SET desk_manual_order=%s WHERE user_id=%s', (payload.mode == 'manual', uid), fetch=False) work.bump_queue(uid) work.audit(user, 'desk_order', 'assignee', uid, None, payload.model_dump()) return {'version': version+1} @router.post('/desk/seen') def desk_seen(user: dict = Depends(get_current_user)): uid = int(user['id']) work.require_target(user, uid, True) # Only acknowledge the assignments this user is allowed to see. ids = [r['sag_id'] for r in work.queue_rows(uid, limit=10000, user=user)] execute_query('UPDATE sag_case_assignees SET desk_seen_at=NOW() WHERE user_id=%s AND sag_id=ANY(%s) AND desk_seen_at IS NULL', (uid, ids), fetch=False) return {'ok': True} @router.get('/desk/recent') def recent_work(user: dict = Depends(get_current_user)): work.require_target(user, int(user['id'])) hidden = list(work.hidden_case_ids(user)) entries = execute_query('''SELECT t.id,t.sag_id,t.description,t.aktiv_timer,t.paused_at,t.slut_tid,s.titel, COALESCE(t.slut_tid,t.paused_at,t.start_tid,t.created_at) AS last_activity FROM tmodule_times t LEFT JOIN sag_sager s ON s.id=t.sag_id WHERE t.medarbejder_id=%s AND t.status<>'rejected' AND (t.sag_id IS NULL OR (s.deleted_at IS NULL AND NOT(t.sag_id=ANY(%s)))) ORDER BY (t.aktiv_timer AND t.slut_tid IS NULL) DESC,last_activity DESC,t.id DESC LIMIT 20''', (user['id'], hidden)) or [] return {'entries': entries} class DeskTimer(BaseModel): description: str = Field(min_length=1, max_length=2000) sag_id: int | None = Field(default=None, gt=0) @router.post('/desk/timer') async def start_desk_timer(payload: DeskTimer, user: dict = Depends(get_current_user)): work.require_target(user, int(user['id'])) if not payload.description.strip(): raise HTTPException(400, 'Angiv hvad du arbejder på') if payload.sag_id: require_case(user, payload.sag_id) with atomic_transaction(): execute_query('SELECT user_id FROM users WHERE user_id=%s FOR UPDATE', (user['id'],)) if execute_query_single('SELECT id FROM tmodule_times WHERE medarbejder_id=%s AND aktiv_timer AND slut_tid IS NULL', (user['id'],)): raise HTTPException(409, 'Du har en aktiv timer. Pause eller stop den først.') if payload.sag_id: from app.timetracking.backend.router import start_live_timer_v1 return await start_live_timer_v1(payload={'sag_id': payload.sag_id, 'description': payload.description}, current_user=user) now = datetime.now(timezone.utc).replace(tzinfo=None) name = execute_query_single('SELECT COALESCE(full_name,username) AS name FROM users WHERE user_id=%s', (user['id'],))['name'] entry = execute_query_single('''INSERT INTO tmodule_times(description,original_hours,worked_date,user_name,status, billable,billing_method,start_tid,faktisk_tid_min,fakturerbar_tid_min,entry_type,kilde,entry_status, medarbejder_id,aktiv_timer,round_block_min,ikke_placeret,work_type) VALUES(%s,0.01,%s,%s,'pending',false,'draft',%s,NULL,0,'manuel','manuel','kladde',%s,true,1,false,'other') RETURNING id''', (payload.description.strip(), datetime.now(TZ).date(), name, now, user['id'])) return {'entry': entry} class DeskTime(BaseModel): request_id: UUID sag_id: int = Field(gt=0) description: str = Field(min_length=1, max_length=2000) starts_at: datetime minutes: int = Field(ge=1, le=1440) @model_validator(mode='after') def valid_time(self): if not self.starts_at.tzinfo or not self.description.strip(): raise ValueError('Angiv beskrivelse og tidspunkt med tidszone') return self @router.post('/desk/time') async def register_case_time(payload: DeskTime, user: dict = Depends(get_current_user)): work.require_target(user, int(user['id'])) require_case(user, payload.sag_id) from app.timetracking.backend.router import create_manual_time_v1 value = payload.model_dump(mode='json') with atomic_transaction(): execute_query('SELECT pg_advisory_xact_lock(hashtextextended(%s,0))', (str(payload.request_id),)) old = execute_query_single('SELECT * FROM quick_time_batches WHERE request_id=%s', (str(payload.request_id),)) if old: if old['actor_id'] != user['id'] or old['payload'] != value: raise HTTPException(409, 'Gemningen tilhører andre oplysninger') return old['result'] result = await create_manual_time_v1(payload={'sag_id': payload.sag_id, 'description': payload.description.strip(), 'start_tid': payload.starts_at.isoformat(), 'slut_tid': (payload.starts_at+timedelta(minutes=payload.minutes)).isoformat(), 'faktisk_tid_min': payload.minutes, 'worked_date': payload.starts_at.astimezone(TZ).date().isoformat()}, current_user=user) result = jsonable_encoder(result) execute_query('INSERT INTO quick_time_batches(request_id,actor_id,payload,result) VALUES(%s,%s,%s,%s)', (str(payload.request_id), user['id'], Json(value), Json(result)), fetch=False) return result class MeetingAttendance(BaseModel): user_id: int = Field(gt=0) starts_at: datetime ends_at: datetime @model_validator(mode='after') def valid_times(self): if not self.starts_at.tzinfo or not self.ends_at.tzinfo or not timedelta(0) < self.ends_at-self.starts_at <= timedelta(days=1): raise ValueError('Angiv start og slut med tidszone, højst 24 timer') return self class MeetingRegistration(BaseModel): version: int = Field(ge=1) attendees: list[MeetingAttendance] = Field(min_length=1, max_length=100) def meeting_access(event_id, user, lock=False): row = execute_query_single('SELECT * FROM planner_events WHERE id=%s AND deleted_at IS NULL' + (' FOR UPDATE' if lock else ''), (event_id,)) if not row or row['kind'] not in {'meeting', 'internal'} or row.get('sag_id'): raise HTTPException(404, 'Internt møde uden sag findes ikke') participants = execute_query('SELECT a.user_id,COALESCE(u.full_name,u.username) AS name FROM planner_allocations a JOIN users u ON u.user_id=a.user_id WHERE a.event_id=%s AND a.deleted_at IS NULL', (event_id,)) or [] if not participants or (int(user['id']) not in {p['user_id'] for p in participants} and not all(_can_edit(user,p['user_id']) for p in participants)): raise HTTPException(403, 'Du har ikke adgang til mødet') return row, participants @router.get('/desk/events/{event_id}/attendance') def meeting_attendance(event_id: int, user: dict = Depends(get_current_user)): from app.timetracking.backend.quick_time import can_register work.require_target(user, int(user['id'])) row, people = meeting_access(event_id, user) registered = execute_query('SELECT medarbejder_id FROM tmodule_times WHERE planner_event_id=%s', (event_id,)) or [] done = {p['medarbejder_id'] for p in registered} return {'event': row, 'participants': [{**p, 'registered': p['user_id'] in done, 'can_register': can_register(user, p['user_id'])} for p in people]} @router.post('/desk/events/{event_id}/register') def register_meeting(event_id: int, payload: MeetingRegistration, user: dict = Depends(get_current_user)): from app.timetracking.backend.quick_time import can_register work.require_target(user, int(user['id'])) if len({a.user_id for a in payload.attendees}) != len(payload.attendees): raise HTTPException(400, 'En deltager må kun forekomme én gang') with atomic_transaction(): row, people = meeting_access(event_id, user, lock=True) if row['version'] != payload.version: raise HTTPException(409, 'Mødet er ændret. Genindlæs før registrering.') names = {p['user_id']: p['name'] for p in people} for attendee in payload.attendees: if attendee.user_id not in names or not can_register(user, attendee.user_id): raise HTTPException(403, 'Du må ikke registrere tid for denne deltager') if attendee.ends_at > datetime.now(TZ): raise HTTPException(400, 'Mødetid kan først registreres, når den er afholdt') ids = [] for a in payload.attendees: existing = execute_query_single('SELECT id,start_tid,slut_tid FROM tmodule_times WHERE planner_event_id=%s AND medarbejder_id=%s', (event_id, a.user_id)) if existing: if work.db_utc(existing['start_tid']) != a.starts_at.astimezone(timezone.utc) or work.db_utc(existing['slut_tid']) != a.ends_at.astimezone(timezone.utc): raise HTTPException(409, 'Deltageren har allerede registreret en anden varighed. Ret den eksisterende tidsregistrering.') ids.append(existing['id']) continue minutes = max(1, round((a.ends_at-a.starts_at).total_seconds()/60)) 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_event_id) VALUES(%s,%s,%s,%s,'pending',false,'draft',%s,0,'manuel','manuel','kladde',%s,false,1,false,'other',%s,%s,%s) RETURNING id''', (row['title'],minutes/60,a.starts_at.astimezone(TZ).date(),names[a.user_id],minutes,a.user_id, work.db_utc(a.starts_at).replace(tzinfo=None),work.db_utc(a.ends_at).replace(tzinfo=None),event_id)) ids.append(entry['id']) work.audit(user, 'meeting_register', 'event', event_id, None, {'time_entry_ids': ids}) return {'time_entry_ids': ids}