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

612 lines
38 KiB
Python

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)<COALESCE(s.estimated_minutes,0)"
elif planning == "planned": sql += " HAVING COALESCE(s.estimated_minutes,0)>0 AND COALESCE(SUM(EXTRACT(EPOCH FROM (a.ends_at-a.starts_at))/60) FILTER (WHERE a.deleted_at IS NULL),0)>=s.estimated_minutes"
elif planning == "overdue": sql += " HAVING BOOL_OR(a.deleted_at IS NULL AND a.ends_at < CURRENT_DATE)"
order = {"priority": "CASE s.priority::text WHEN 'urgent' THEN 1 WHEN 'high' THEN 2 WHEN 'normal' THEN 3 ELSE 4 END, s.updated_at DESC",
"remaining": "(COALESCE(s.estimated_minutes,0)-COALESCE(SUM(EXTRACT(EPOCH FROM (a.ends_at-a.starts_at))/60) FILTER (WHERE a.deleted_at IS NULL),0)) DESC",
"updated": "s.updated_at DESC"}[sort]
rows = execute_query(sql + f" ORDER BY {order} LIMIT %s", tuple(params + [limit])) or []
for row in rows: row["remaining_minutes"] = max(0, row["estimated_minutes"] - row["planned_minutes"])
return {"items": rows}
@router.get("/cases/{sag_id}/time-entries")
def case_time_entries(sag_id: int, current_user: dict = Depends(get_current_user)):
"""Show actual registered time separately from planned allocations."""
_require_view(current_user)
rows = execute_query(
"""SELECT t.id, t.worked_date, t.start_tid, t.slut_tid,
COALESCE(t.faktisk_tid_min, ROUND(COALESCE(t.approved_hours,t.original_hours,0)*60))::int AS minutes,
t.description, t.entry_status, t.status,
COALESCE(u.full_name,u.username,t.user_name,'Ukendt') AS employee_name
FROM tmodule_times t LEFT JOIN users u ON u.user_id=t.medarbejder_id
WHERE t.sag_id=%s ORDER BY COALESCE(t.slut_tid,t.start_tid,t.worked_date::timestamp,t.created_at) DESC, t.id DESC""",
(sag_id,),
) or []
return {"sag_id": sag_id, "total_minutes": sum(int(r.get("minutes") or 0) for r in rows), "items": rows}
@router.get("/cases/{sag_id}/allocations")
def case_allocations(sag_id: int, current_user: dict = Depends(get_current_user)):
"""Return the persisted plan for one case for the embedded case planner."""
_require_view(current_user)
case_row = execute_query_single(
"""SELECT s.id, s.titel, s.estimated_minutes, s.deferred_until,
s.deferred_until_case_id, s.deferred_until_status,
waiting.titel AS deferred_case_title, waiting.status AS deferred_case_current_status
FROM sag_sager s
LEFT JOIN sag_sager waiting ON waiting.id=s.deferred_until_case_id
WHERE s.id=%s AND s.deleted_at IS NULL""",
(sag_id,),
)
if not case_row:
raise HTTPException(404, "Sagen findes ikke")
rows = execute_query(
"""SELECT a.id, a.sag_id, a.user_id, a.starts_at, a.ends_at, a.note,
a.conflict_status, a.locked, a.version,
COALESCE(u.full_name,u.username,'Ukendt') AS user_name
FROM planner_allocations a
LEFT JOIN users u ON u.user_id=a.user_id
WHERE a.sag_id=%s AND a.deleted_at IS NULL
ORDER BY a.starts_at, a.id""",
(sag_id,),
) or []
planned_minutes = sum(_minutes(_aware(row["starts_at"]), _aware(row["ends_at"])) for row in rows)
subcases = execute_query(
"""SELECT child.id, child.titel, child.status, child.type,
child.ansvarlig_bruger_id, child.estimated_minutes,
child.deferred_until, child.deferred_until_case_id, child.deferred_until_status,
COALESCE(owner.full_name,owner.username) AS responsible_name,
waiting.titel AS deferred_case_title,
waiting.status AS deferred_case_current_status,
COALESCE(allocation_summary.planned_minutes, 0)::int AS planned_minutes,
COALESCE(allocation_summary.items, '[]'::json) AS allocations
FROM sag_relationer relation
JOIN sag_sager child ON child.id=relation.målsag_id AND child.deleted_at IS NULL
LEFT JOIN users owner ON owner.user_id=child.ansvarlig_bruger_id
LEFT JOIN sag_sager waiting ON waiting.id=child.deferred_until_case_id
LEFT JOIN LATERAL (
SELECT
COALESCE(SUM(EXTRACT(EPOCH FROM (allocation.ends_at-allocation.starts_at))/60), 0) AS planned_minutes,
json_agg(json_build_object(
'starts_at', allocation.starts_at,
'ends_at', allocation.ends_at,
'note', allocation.note,
'user_name', COALESCE(allocation_user.full_name, allocation_user.username, 'Ukendt')
) ORDER BY allocation.starts_at, allocation.id) AS items
FROM planner_allocations allocation
LEFT JOIN users allocation_user ON allocation_user.user_id=allocation.user_id
WHERE allocation.sag_id=child.id AND allocation.deleted_at IS NULL
) allocation_summary ON TRUE
WHERE relation.kilde_sag_id=%s AND LOWER(relation.relationstype)='undersag'
AND relation.deleted_at IS NULL
ORDER BY child.created_at, child.id""",
(sag_id,),
) or []
return {
"sag_id": sag_id,
"estimated_minutes": case_row.get("estimated_minutes"),
"planned_minutes": planned_minutes,
"remaining_minutes": max(0, int(case_row.get("estimated_minutes") or 0) - planned_minutes),
"deferred": {
"until": case_row.get("deferred_until"),
"case_id": case_row.get("deferred_until_case_id"),
"case_title": case_row.get("deferred_case_title"),
"case_status": case_row.get("deferred_case_current_status"),
"target_status": case_row.get("deferred_until_status"),
},
"subcases": subcases,
"items": rows,
}
@router.patch("/cases/{sag_id}/estimate")
def update_case_estimate(sag_id: int, payload: CaseEstimateInput, current_user: dict = Depends(get_current_user)):
permissions = _permissions(current_user)
allowed = {"planner.edit_own", "planner.edit_team", "planner.admin", "cases.edit"}
if not (current_user.get("is_superadmin") or permissions.intersection(allowed)):
raise HTTPException(403, "Du må ikke ændre forventet tid")
old = execute_query_single("SELECT id,titel,estimated_minutes FROM sag_sager WHERE id=%s AND deleted_at IS NULL", (sag_id,))
if not old:
raise HTTPException(404, "Sagen findes ikke")
row = execute_query_single(
"UPDATE sag_sager SET estimated_minutes=%s,updated_at=NOW() WHERE id=%s RETURNING id,estimated_minutes",
(payload.estimated_minutes, sag_id),
)
execute_query(
"""INSERT INTO audit_log(user_id,username,action,resource_type,resource_id,resource_name,before_value,after_value)
VALUES(%s,%s,'planner.estimate.update','case',%s,%s,%s,%s)""",
(current_user["id"], current_user.get("username"), sag_id, old.get("titel"),
str(old.get("estimated_minutes")), str(payload.estimated_minutes)),
)
return row
@router.get("/period")
def period(starts_at: datetime, ends_at: datetime, user_id: list[int] = Query(default=[]), current_user: dict = Depends(get_current_user)):
_require_view(current_user)
if ends_at <= starts_at or ends_at - starts_at > 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 []}