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