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 from app.core.case_visibility import require_case 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 if '_planner_team_scope_ids' in user: return target_id in user['_planner_team_scope_ids'] 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 _can_view_target(user:dict,target_id:int)->bool: if int(user['id'])==target_id or user.get('is_superadmin') or 'planner.admin' in _permissions(user): return True if not (_permissions(user) & {'planner.edit_team','planner.view_team'}): return False if '_planner_team_scope_ids' not in user: user['_planner_team_scope_ids']={r['target_user_id'] for r in execute_query( 'SELECT target_user_id FROM planner_team_scope WHERE editor_user_id=%s',(user['id'],)) or []} return target_id in user['_planner_team_scope_ids'] 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: if start.tzinfo is not None and end.tzinfo is not None: start,end=start.astimezone(timezone.utc),end.astimezone(timezone.utc) 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 deleted_at IS NULL 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 LEFT 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 (t.sag_id IS NULL OR (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 [] standalone = execute_query("SELECT user_id,starts_at,ends_at FROM quick_time_items WHERE state='planned' AND user_id=ANY(%s) AND starts_at<%s AND ends_at>%s",(user_ids,ends_at,starts_at)) or [] allocations = list(allocations) + standalone 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) from .capacity import working_capacity blocked=[(_aware(r['starts_at']),_aware(r['ends_at'])) for r in absences+external if r['user_id']==uid] periods=[(_aware(r['starts_at']),_aware(r['ends_at'])) for r in allocations if r['user_id']==uid] period_start,period_end=max(day_start,starts_at),min(day_end,ends_at) blocked_capacity=working_capacity([row],blocked,period_start,period_end) combined_capacity=working_capacity([row],blocked+periods,period_start,period_end) available=blocked_capacity['free_minutes'] allocated=combined_capacity['busy_minutes']-blocked_capacity['busy_minutes'] else: day_start = datetime.combine(current, time(0), TZ) day_end = day_start + timedelta(days=1) available = 0 allocated = _merge_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 (r['registered_at'].replace(tzinfo=timezone.utc) if r['registered_at'].tzinfo is None else 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 [] overlaps += execute_query("SELECT ('quick:' || id::text) AS id FROM quick_time_items WHERE state='planned' AND user_id=%s AND starts_at<%s AND ends_at>%s",(user_id,ends_at,starts_at)) 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 [] users=[row for row in users if _can_view_target(current_user,row['id'])] 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"])] from .calendar import can_view_absence_report return {"can_view_absence_report":can_view_absence_report(current_user),"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, planning: Literal["all", "unplanned", "partial", "planned", "overdue"] = "all", mine: bool = False, 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, 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 FROM sag_sager s LEFT 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)] from app.core.case_visibility import hidden_case_ids sql += ' AND NOT(s.id=ANY(%s))' params.append(list(hidden_case_ids(current_user))) 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) timedelta(days=370): raise HTTPException(400, "Ugyldig periode") ids = user_id or [current_user["id"]] if any(not _can_view_target(current_user,int(uid)) for uid in ids): raise HTTPException(403,'Du har ikke adgang til disse medarbejderes planer') allocations = execute_query( """SELECT a.*, COALESCE(e.title,s.titel,'Aftale') AS case_title, s.priority::text AS priority, COALESCE(c.name,'Intern aftale') AS customer_name, COALESCE(u.full_name,u.username) AS user_name FROM planner_allocations a LEFT JOIN sag_sager s ON s.id=a.sag_id LEFT JOIN customers c ON c.id=s.customer_id LEFT JOIN planner_events e ON e.id=a.event_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, COALESCE(s.titel,t.description) 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 LEFT JOIN sag_sager s ON s.id=t.sag_id LEFT JOIN tmodule_customers tc ON tc.id=t.customer_id LEFT JOIN customers c ON c.id=COALESCE(s.customer_id,tc.hub_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 (t.sag_id IS NULL OR (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 [] standalone_plans = execute_query("""SELECT q.*, q.description AS case_title, COALESCE(c.name,'Intern tid') AS customer_name, COALESCE(u.full_name,u.username) AS user_name FROM quick_time_items q JOIN users u ON u.user_id=q.user_id LEFT JOIN customers c ON c.id=q.customer_id WHERE q.state='planned' AND q.user_id=ANY(%s) AND q.starts_at<%s AND q.ends_at>%s""",(ids,ends_at,starts_at)) or [] for entry in time_entries: for key in ('starts_at','ends_at'): if entry.get(key) and entry[key].tzinfo is None: entry[key]=entry[key].replace(tzinfo=timezone.utc) # Capacity already subtracts absence; return the same periods for visible calendar blocks. # Send no private notes, and mask reasons before exposing labels to the team view. from .calendar import can_see_absence_reason absences=execute_query("""SELECT a.id,a.user_id,a.starts_at,a.ends_at,a.kind, COALESCE(k.label,'Fravær') AS kind_label FROM planner_absences a LEFT JOIN planner_activity_types k ON k.code=a.kind 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,a.id""",(ids,ends_at,starts_at)) or [] for absence in absences: if not can_see_absence_reason(current_user,absence['user_id']): absence['kind']='unavailable' absence['kind_label']='Fravær' return {"absences":absences,"allocations": allocations, "standalone_plans": standalone_plans, "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") require_case(current_user,payload.sag_id) 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) def _lock_booking_parent(cursor,allocation_id): """Use the same parent-before-participant lock order as the shared event API.""" cursor.execute('SELECT event_id FROM planner_allocations WHERE id=%s',(allocation_id,)) row=cursor.fetchone() if row and row.get('event_id'): cursor.execute('SELECT id FROM planner_events WHERE id=%s FOR UPDATE',(row['event_id'],)) @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: _lock_booking_parent(cur,allocation_id) 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 old.get('sag_id'): require_case(current_user,old['sag_id']) if not _can_edit(current_user,old['user_id']): raise HTTPException(403,'Du må ikke redigere denne medarbejders plan') 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('event_id') and (moving or 'note' in payload.model_fields_set): cur.execute('SELECT COUNT(*) AS count FROM planner_allocations WHERE event_id=%s AND deleted_at IS NULL',(old['event_id'],)) if cur.fetchone()['count']>1: raise HTTPException(409,'Fælles aftaler skal ændres fra den samlede kalender') 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: _lock_booking_parent(cur,allocation_id) 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 old.get('sag_id'): require_case(current_user,old['sag_id']) 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") if old.get('event_id'): cur.execute('SELECT COUNT(*) AS count FROM planner_allocations WHERE event_id=%s AND deleted_at IS NULL',(old['event_id'],)) if cur.fetchone()['count']>1: raise HTTPException(409,'Fælles aftaler skal annulleres fra den samlede kalender') 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: _lock_booking_parent(cur,allocation_id) 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 current.get('sag_id'): require_case(current_user,current['sag_id']) 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") if current.get('event_id'): cur.execute('SELECT COUNT(*) AS count FROM planner_allocations WHERE event_id=%s',(current['event_id'],)) if cur.fetchone()['count']>1: raise HTTPException(409,'Fælles aftaler skal ændres fra den samlede kalender') 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") if old.get('sag_id'): require_case(current_user,old['sag_id']) 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 []}