535 lines
31 KiB
Python
535 lines
31 KiB
Python
|
|
"""Personal work queues; planning never creates billable time implicitly."""
|
||
|
|
from datetime import date, datetime, time, timedelta, timezone
|
||
|
|
from contextlib import nullcontext
|
||
|
|
from decimal import Decimal
|
||
|
|
from typing import Literal
|
||
|
|
import re
|
||
|
|
|
||
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||
|
|
from fastapi.encoders import jsonable_encoder
|
||
|
|
from psycopg2.extras import Json
|
||
|
|
from pydantic import BaseModel, Field
|
||
|
|
|
||
|
|
from app.core.auth_dependencies import get_current_user
|
||
|
|
from app.core.database import atomic_transaction, execute_query, execute_query_single, _transaction_connection
|
||
|
|
from app.core.case_visibility import require_case,hidden_case_ids,filter_data
|
||
|
|
from .router import TZ, CLOSED_STATUSES, _aware, _can_edit, _can_view_target, _require_view
|
||
|
|
|
||
|
|
def require_schema():
|
||
|
|
row = execute_query_single("SELECT to_regclass('planner_activity_types') IS NOT NULL AS ready")
|
||
|
|
if not row or not row['ready']:
|
||
|
|
raise HTTPException(503, 'Planlægning afventer databaseopdatering. Anvend migration 1070.')
|
||
|
|
|
||
|
|
|
||
|
|
router = APIRouter(dependencies=[Depends(require_schema)])
|
||
|
|
FINISHED = CLOSED_STATUSES | {'løst','resolved','annulleret', 'cancelled', 'canceled'}
|
||
|
|
WAITING = {'afventer', 'on_hold', 'waiting', 'blocked', 'blokeret'}
|
||
|
|
|
||
|
|
|
||
|
|
def db_utc(value):
|
||
|
|
"""Existing case/time TIMESTAMP columns store UTC without an offset."""
|
||
|
|
return value.replace(tzinfo=timezone.utc) if value.tzinfo is None else value.astimezone(timezone.utc)
|
||
|
|
|
||
|
|
|
||
|
|
def require_target(user, target, edit=False):
|
||
|
|
_require_view(user)
|
||
|
|
visible=_can_view_target(user,target)
|
||
|
|
if not visible or (edit and not _can_edit(user,target)):
|
||
|
|
raise HTTPException(403, 'Du har ikke adgang til denne medarbejders plan')
|
||
|
|
if not (user.get('is_superadmin') or set(user.get('permissions') or []) & {'cases.view', 'tickets.view'}):
|
||
|
|
raise HTTPException(403, 'Du har ikke adgang til sager')
|
||
|
|
|
||
|
|
|
||
|
|
def audit(user, action, entity, entity_id, before=None, after=None, sag_id=None):
|
||
|
|
entry = execute_query_single(
|
||
|
|
"""INSERT INTO planner_allocation_history
|
||
|
|
(sag_id,action,changed_by,before_value,after_value,entity_type,entity_id)
|
||
|
|
VALUES(%s,%s,%s,%s,%s,%s,%s) RETURNING id""",
|
||
|
|
(sag_id, action, user['id'], Json(jsonable_encoder(before)),
|
||
|
|
Json(jsonable_encoder(after)), entity, entity_id))
|
||
|
|
if sag_id and entity=='assignee' and action in {'assign','transfer','queue_change'} and entity_id!=user['id']:
|
||
|
|
key=(f'queue:{entity_id}:{sag_id}:{int(datetime.now(timezone.utc).timestamp()//300)}'
|
||
|
|
if action=='queue_change' else f'audit:{entry["id"]}')
|
||
|
|
notify_case(user,sag_id,key,
|
||
|
|
'Din allokering eller arbejdsrækkefølge er ændret.',[entity_id])
|
||
|
|
return entry['id']
|
||
|
|
|
||
|
|
|
||
|
|
def notify_case(user,sag_id,key,message,recipients):
|
||
|
|
ids=sorted(set(int(uid) for uid in recipients if int(uid)!=int(user['id'])))
|
||
|
|
if not ids or not sag_id:
|
||
|
|
return
|
||
|
|
execute_query('''INSERT INTO sag_reminders(sag_id,title,message,event_type,trigger_type,trigger_config,
|
||
|
|
recipient_user_ids,notify_mattermost,notify_email,notify_frontend,override_user_preferences,
|
||
|
|
recurrence_type,scheduled_at,next_check_at,created_by_user_id)
|
||
|
|
VALUES(%s,'Planlægning ændret',%s,'reminder','time_based',%s,%s,FALSE,FALSE,TRUE,TRUE,'once',NOW(),NOW(),%s)
|
||
|
|
ON CONFLICT DO NOTHING''',(sag_id,message,Json({'planner_key':key}),ids,user['id']),fetch=False)
|
||
|
|
|
||
|
|
|
||
|
|
def lock_queue(target):
|
||
|
|
# The same employee lock is used for membership, state and order changes.
|
||
|
|
execute_query('SELECT pg_advisory_xact_lock(%s,%s)', (252, target))
|
||
|
|
execute_query('INSERT INTO planner_queue_versions(user_id) VALUES(%s) ON CONFLICT DO NOTHING', (target,), fetch=False)
|
||
|
|
return execute_query_single('SELECT version FROM planner_queue_versions WHERE user_id=%s FOR UPDATE', (target,))['version']
|
||
|
|
|
||
|
|
|
||
|
|
def bump_queue(target):
|
||
|
|
execute_query('UPDATE planner_queue_versions SET version=version+1 WHERE user_id=%s', (target,), fetch=False)
|
||
|
|
|
||
|
|
|
||
|
|
def ready(row, now):
|
||
|
|
if str(row.get('status') or '').lower() in FINISHED | WAITING:
|
||
|
|
return False
|
||
|
|
if row.get('work_status') in {'waiting', 'paused', 'done'}:
|
||
|
|
return False
|
||
|
|
if row.get('snoozed_until') and _aware(row['snoozed_until']) > now:
|
||
|
|
return False
|
||
|
|
deferred=row.get('deferred_until')
|
||
|
|
if deferred:
|
||
|
|
if isinstance(deferred,datetime):
|
||
|
|
if db_utc(deferred)>now:
|
||
|
|
return False
|
||
|
|
elif deferred>now.date():
|
||
|
|
return False
|
||
|
|
if row.get('deferred_until_case_id'):
|
||
|
|
targets = {s.strip().casefold() for s in re.split(r'[,;\n]',str(row.get('deferred_until_status') or '')) if s.strip()}
|
||
|
|
if not targets or str(row.get('deferred_case_status') or '').casefold() not in targets:
|
||
|
|
return False
|
||
|
|
if row.get('start_date'):
|
||
|
|
start = datetime.combine(row['start_date'], row.get('start_time') or time.min, TZ)
|
||
|
|
if start > now:
|
||
|
|
return False
|
||
|
|
return True
|
||
|
|
|
||
|
|
|
||
|
|
def remaining(row):
|
||
|
|
if row.get('remaining_minutes') is not None:
|
||
|
|
return int(row['remaining_minutes'])
|
||
|
|
estimate = row.get('estimated_minutes')
|
||
|
|
return None if estimate is None else max(0, int(estimate) - int(row.get('actual_minutes') or 0))
|
||
|
|
|
||
|
|
|
||
|
|
def queue_rows(target, limit=100, offset=0, user=None, q=''):
|
||
|
|
from app.modules.sag.frontend.views import _fetch_closed_case_statuses
|
||
|
|
closed = FINISHED | set(_fetch_closed_case_statuses())
|
||
|
|
rows = execute_query(
|
||
|
|
"""SELECT * FROM (SELECT a.*,s.titel,s.status,s.priority,s.deadline,s.start_date,s.start_time,s.updated_at AS case_updated_at,
|
||
|
|
s.deferred_until,s.deferred_until_case_id,s.deferred_until_status,waiting.status AS deferred_case_status,
|
||
|
|
s.type,s.estimated_minutes AS case_estimated_minutes,
|
||
|
|
ARRAY(WITH RECURSIVE labels AS (
|
||
|
|
SELECT t.id,t.name,t.parent_id FROM tags t JOIN entity_tags et ON et.tag_id=t.id
|
||
|
|
WHERE et.entity_type='case' AND et.entity_id=s.id
|
||
|
|
UNION SELECT p.id,p.name,p.parent_id FROM tags p JOIN labels l ON p.id=l.parent_id)
|
||
|
|
SELECT name::text FROM labels UNION SELECT tag_navn::text FROM sag_tags WHERE sag_id=s.id AND deleted_at IS NULL) AS tags,
|
||
|
|
c.name AS customer_name,COALESCE(t.minutes,0) AS actual_minutes,COALESCE(t.active,FALSE) AS active_timer,
|
||
|
|
COALESCE(p.minutes,0) AS future_planned_minutes,COALESCE(p.slots,'[]'::jsonb) AS planned_slots
|
||
|
|
FROM sag_case_assignees a JOIN sag_sager s ON s.id=a.sag_id
|
||
|
|
LEFT JOIN sag_sager waiting ON waiting.id=s.deferred_until_case_id
|
||
|
|
LEFT JOIN customers c ON c.id=s.customer_id
|
||
|
|
LEFT JOIN LATERAL (SELECT SUM(COALESCE(faktisk_tid_min,
|
||
|
|
ROUND(COALESCE(approved_hours,original_hours,0)*60))) AS minutes,
|
||
|
|
BOOL_OR(aktiv_timer AND slut_tid IS NULL) AS active
|
||
|
|
FROM tmodule_times WHERE sag_id=a.sag_id AND medarbejder_id=a.user_id
|
||
|
|
AND status<>'rejected') t ON TRUE
|
||
|
|
LEFT JOIN LATERAL (SELECT SUM(EXTRACT(EPOCH FROM
|
||
|
|
(ends_at-GREATEST(starts_at,NOW())))/60)::int AS minutes,
|
||
|
|
jsonb_agg(jsonb_build_object('event_id',event_id,'starts_at',starts_at,'ends_at',ends_at)
|
||
|
|
ORDER BY starts_at,id) AS slots
|
||
|
|
FROM planner_allocations WHERE sag_id=a.sag_id AND user_id=a.user_id
|
||
|
|
AND deleted_at IS NULL AND ends_at>NOW()) p ON TRUE
|
||
|
|
WHERE a.user_id=%s AND s.deleted_at IS NULL AND LOWER(s.status)<>ALL(%s) AND NOT(s.id=ANY(%s))
|
||
|
|
) entries WHERE (%s='' OR CONCAT_WS(' ',sag_id::text,titel,customer_name,type,ARRAY_TO_STRING(tags,' ')) ILIKE %s)
|
||
|
|
ORDER BY pinned DESC,queue_position,sag_id LIMIT %s OFFSET %s""",
|
||
|
|
(target, list(closed), list(hidden_case_ids(user)) if user else [], q, '%'+q+'%', limit, offset)) or []
|
||
|
|
for row in rows:
|
||
|
|
for key in ('deadline','deferred_until','case_updated_at'):
|
||
|
|
if isinstance(row.get(key),datetime):
|
||
|
|
row[key]=db_utc(row[key]).astimezone(TZ)
|
||
|
|
return rows
|
||
|
|
|
||
|
|
|
||
|
|
@router.get('/queue')
|
||
|
|
def get_queue(user_id: int | None = None, limit: int = Query(100, ge=1, le=500),
|
||
|
|
offset: int = Query(0, ge=0), user: dict = Depends(get_current_user), q: str = ''):
|
||
|
|
target = user_id or int(user['id'])
|
||
|
|
require_target(user, target)
|
||
|
|
# A single repeatable snapshot prevents a stale body being paired with a new version.
|
||
|
|
with atomic_transaction():
|
||
|
|
execute_query('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ', fetch=False)
|
||
|
|
version = execute_query_single('SELECT version FROM planner_queue_versions WHERE user_id=%s', (target,))
|
||
|
|
rows = queue_rows(target, limit, offset,user,q.strip()[:200])
|
||
|
|
now = datetime.now(TZ)
|
||
|
|
for row in rows:
|
||
|
|
row['ready'] = ready(row, now)
|
||
|
|
row['remaining_work_minutes'] = remaining(row)
|
||
|
|
row['queue_minutes'] = (None if remaining(row) is None else
|
||
|
|
max(0, remaining(row) - row['future_planned_minutes']))
|
||
|
|
row['url'] = f"/sag/{row['sag_id']}"
|
||
|
|
row['planning_status']='fast_planlagt' if row['future_planned_minutes'] else 'i_arbejdsko'
|
||
|
|
row['has_queue_work']=row['queue_minutes'] is None or row['queue_minutes']>0
|
||
|
|
return {'user_id': target, 'version': version['version'] if version else 1,
|
||
|
|
'items': rows, 'limit': limit, 'offset': offset}
|
||
|
|
|
||
|
|
|
||
|
|
class QueueChange(BaseModel):
|
||
|
|
version: int = Field(ge=1)
|
||
|
|
before_sag_id: int | None = None
|
||
|
|
pinned: bool | None = None
|
||
|
|
work_status: Literal['ready', 'working', 'paused', 'waiting', 'done'] | None = None
|
||
|
|
estimated_minutes: int | None = Field(default=None, ge=0, le=525600)
|
||
|
|
remaining_minutes: int | None = Field(default=None, ge=0, le=525600)
|
||
|
|
snoozed_until: datetime | None = None
|
||
|
|
|
||
|
|
|
||
|
|
@router.patch('/queue/{target}/{sag_id}')
|
||
|
|
def change_queue(target: int, sag_id: int, payload: QueueChange, user: dict = Depends(get_current_user)):
|
||
|
|
require_target(user, target, True)
|
||
|
|
require_case(user,sag_id)
|
||
|
|
with (nullcontext() if _transaction_connection.get() else atomic_transaction()):
|
||
|
|
execute_query('SELECT id FROM sag_sager WHERE id=%s FOR KEY SHARE',(sag_id,))
|
||
|
|
version = lock_queue(target)
|
||
|
|
if version != payload.version:
|
||
|
|
raise HTTPException(409, 'Køen er ændret. Genindlæs og prøv igen.')
|
||
|
|
old = execute_query_single('SELECT * FROM sag_case_assignees WHERE user_id=%s AND sag_id=%s FOR UPDATE', (target, sag_id))
|
||
|
|
if not old:
|
||
|
|
raise HTTPException(404, 'Allokering findes ikke')
|
||
|
|
values = {k: v for k, v in payload.model_dump().items() if k in payload.model_fields_set and k not in {'version', 'before_sag_id'}}
|
||
|
|
if values.get('work_status') is None and 'work_status' in values:
|
||
|
|
raise HTTPException(400, 'Arbejdsstatus skal angives')
|
||
|
|
if values.get('pinned') is None and 'pinned' in values:
|
||
|
|
raise HTTPException(400, 'Fastlåsning skal angives')
|
||
|
|
if values.get('snoozed_until') is not None:
|
||
|
|
values['snoozed_until'] = _aware(values['snoozed_until'])
|
||
|
|
if 'before_sag_id' in payload.model_fields_set or 'pinned' in values:
|
||
|
|
group = values.get('pinned', old['pinned'])
|
||
|
|
siblings = execute_query('SELECT sag_id,queue_position FROM sag_case_assignees WHERE user_id=%s AND pinned=%s AND sag_id<>%s ORDER BY queue_position,sag_id', (target, group, sag_id)) or []
|
||
|
|
index = len(siblings)
|
||
|
|
if payload.before_sag_id is not None:
|
||
|
|
index = next((i for i, r in enumerate(siblings) if r['sag_id'] == payload.before_sag_id), -1)
|
||
|
|
if index < 0:
|
||
|
|
raise HTTPException(409, 'Målopgaven findes ikke i samme køgruppe')
|
||
|
|
low = Decimal(siblings[index-1]['queue_position']) if index else Decimal(0)
|
||
|
|
high = Decimal(siblings[index]['queue_position']) if index < len(siblings) else low + 2048
|
||
|
|
if high-low < Decimal('0.000001'):
|
||
|
|
for i, row in enumerate(siblings):
|
||
|
|
execute_query('UPDATE sag_case_assignees SET queue_position=%s WHERE user_id=%s AND sag_id=%s', ((i+1)*1024, target, row['sag_id']), fetch=False)
|
||
|
|
low = Decimal(index*1024)
|
||
|
|
high = low+1024
|
||
|
|
values['queue_position'] = (low+high)/2
|
||
|
|
if values:
|
||
|
|
assignments = ','.join(f'{key}=%s' for key in values)
|
||
|
|
execute_query(f'UPDATE sag_case_assignees SET {assignments},version=version+1 WHERE user_id=%s AND sag_id=%s', (*values.values(), target, sag_id), fetch=False)
|
||
|
|
bump_queue(target)
|
||
|
|
new = execute_query_single('SELECT * FROM sag_case_assignees WHERE user_id=%s AND sag_id=%s', (target, sag_id))
|
||
|
|
audit(user, 'queue_change', 'assignee', target, old, new, sag_id)
|
||
|
|
return {'version': version+1, 'item': new}
|
||
|
|
|
||
|
|
|
||
|
|
@router.get('/next-task')
|
||
|
|
def next_task(user: dict = Depends(get_current_user)):
|
||
|
|
require_target(user, int(user['id']))
|
||
|
|
now = datetime.now(TZ)
|
||
|
|
# Scan all pages so a long waiting queue cannot hide a ready task.
|
||
|
|
candidates = []
|
||
|
|
offset = 0
|
||
|
|
while True:
|
||
|
|
rows = queue_rows(int(user['id']), 500, offset,user)
|
||
|
|
candidates.extend(r for r in rows if ready(r, now))
|
||
|
|
if len(rows) < 500 or candidates:
|
||
|
|
break
|
||
|
|
offset += 500
|
||
|
|
task = candidates[0] if candidates else None
|
||
|
|
appointment = execute_query_single("""SELECT id,sag_id,starts_at,ends_at FROM planner_allocations
|
||
|
|
WHERE user_id=%s AND deleted_at IS NULL AND ends_at>%s ORDER BY starts_at LIMIT 1""", (user['id'], now))
|
||
|
|
settings = execute_query_single('SELECT appointment_warning_minutes FROM planner_settings WHERE id=1') or {}
|
||
|
|
warning = False
|
||
|
|
shorter = None
|
||
|
|
if appointment and task:
|
||
|
|
gap = max(0, int((_aware(appointment['starts_at'])-now).total_seconds()/60))
|
||
|
|
estimate = remaining(task)
|
||
|
|
warning = gap <= settings.get('appointment_warning_minutes', 30) and (estimate is None or estimate > gap)
|
||
|
|
shorter = next((r for r in candidates if remaining(r) is not None and remaining(r) <= gap), None)
|
||
|
|
def result(row):
|
||
|
|
return None if row is None else {'sag_id': row['sag_id'], 'title': row['titel'],
|
||
|
|
'remaining_minutes': remaining(row), 'url': f"/sag/{row['sag_id']}"}
|
||
|
|
return filter_data({'task': result(task), 'appointment': appointment, 'warning': warning,
|
||
|
|
'shorter_task': result(shorter), 'message': None if task else 'Du har ingen opgaver klar.'},hidden_case_ids(user))
|
||
|
|
|
||
|
|
|
||
|
|
class TransferBookingVersion(BaseModel):
|
||
|
|
id: int
|
||
|
|
version: int
|
||
|
|
event_version: int
|
||
|
|
|
||
|
|
|
||
|
|
class TransferInput(BaseModel):
|
||
|
|
source_user_id: int = Field(gt=0)
|
||
|
|
target_user_id: int = Field(gt=0)
|
||
|
|
source_version: int = Field(ge=1)
|
||
|
|
target_version: int = Field(ge=1)
|
||
|
|
future_bookings_action: Literal['transfer', 'remove'] | None = None
|
||
|
|
future_bookings: list[TransferBookingVersion] = Field(default_factory=list)
|
||
|
|
confirm_conflicts: bool = False
|
||
|
|
|
||
|
|
|
||
|
|
@router.post('/cases/{sag_id}/transfer')
|
||
|
|
def transfer(sag_id:int,payload:TransferInput,user:dict=Depends(get_current_user)):
|
||
|
|
require_case(user,sag_id)
|
||
|
|
source,target = payload.source_user_id,payload.target_user_id
|
||
|
|
if source==target:
|
||
|
|
raise HTTPException(400,'Vælg en anden medarbejder')
|
||
|
|
for uid in (source,target):
|
||
|
|
require_target(user,uid,True)
|
||
|
|
if not (user.get('is_superadmin') or set(user.get('permissions') or []) & {'planner.allocate','planner.admin'}):
|
||
|
|
raise HTTPException(403,'Du må ikke omfordele medarbejdere')
|
||
|
|
from .calendar import employee_locks, check_event_conflicts
|
||
|
|
with atomic_transaction():
|
||
|
|
# Lock shared parents before employee/queue locks, as the event editor does.
|
||
|
|
execute_query('''SELECT e.id FROM planner_events e WHERE e.id IN
|
||
|
|
(SELECT event_id FROM planner_allocations WHERE sag_id=%s AND user_id=%s
|
||
|
|
AND deleted_at IS NULL AND ends_at>NOW()) ORDER BY e.id FOR UPDATE''',(sag_id,source))
|
||
|
|
employee_locks([source,target])
|
||
|
|
execute_query('SELECT id FROM sag_sager WHERE id=%s FOR KEY SHARE',(sag_id,))
|
||
|
|
versions = {uid:lock_queue(uid) for uid in sorted([source,target])}
|
||
|
|
if versions[source]!=payload.source_version or versions[target]!=payload.target_version:
|
||
|
|
raise HTTPException(409,'En af køerne er ændret. Genindlæs.')
|
||
|
|
old = execute_query_single('SELECT * FROM sag_case_assignees WHERE sag_id=%s AND user_id=%s FOR UPDATE',(sag_id,source))
|
||
|
|
if not old:
|
||
|
|
raise HTTPException(404,'Allokering findes ikke')
|
||
|
|
if execute_query_single('SELECT 1 FROM sag_case_assignees WHERE sag_id=%s AND user_id=%s',(sag_id,target)):
|
||
|
|
raise HTTPException(409,'Medarbejderen er allerede allokeret. Justér fordelingen i stedet.')
|
||
|
|
if not execute_query_single('SELECT user_id FROM users WHERE user_id=%s AND is_active',(target,)):
|
||
|
|
raise HTTPException(400,'Vælg en aktiv medarbejder')
|
||
|
|
bookings=execute_query('''SELECT a.*,e.version AS event_version FROM planner_allocations a
|
||
|
|
JOIN planner_events e ON e.id=a.event_id WHERE a.sag_id=%s AND a.user_id=%s
|
||
|
|
AND a.deleted_at IS NULL AND a.ends_at>NOW() ORDER BY a.id FOR UPDATE OF a''',(sag_id,source)) or []
|
||
|
|
snapshot=[{k:r[k] for k in ('id','version','event_version')} for r in bookings]
|
||
|
|
if bookings and payload.future_bookings_action is None:
|
||
|
|
raise HTTPException(409,{'code':'future_bookings_choice',
|
||
|
|
'message':'Hvad skal der ske med de kommende reservationer?',
|
||
|
|
'booking_count':len(bookings),'bookings':snapshot})
|
||
|
|
if payload.future_bookings_action and [r.model_dump() for r in payload.future_bookings]!=snapshot:
|
||
|
|
raise HTTPException(409,'Reservationerne er ændret. Genindlæs og vælg igen.')
|
||
|
|
if any(r['locked'] for r in bookings):
|
||
|
|
raise HTTPException(409,'Fjern tidslås før reservationerne ændres')
|
||
|
|
if payload.future_bookings_action=='transfer':
|
||
|
|
conflicts=[]
|
||
|
|
for booking in bookings:
|
||
|
|
conflicts.extend(check_event_conflicts([target],booking['starts_at'],booking['ends_at'],booking['event_id']))
|
||
|
|
if conflicts and not payload.confirm_conflicts:
|
||
|
|
raise HTTPException(409,{'message':'De overdragne tider konflikter med medarbejderens kalender eller arbejdstid',
|
||
|
|
'conflicts':conflicts,'confirm_conflicts':True})
|
||
|
|
position = execute_query_single('SELECT COALESCE(MAX(queue_position),0)+1024 AS position FROM sag_case_assignees WHERE user_id=%s',(target,))['position']
|
||
|
|
# Preserve already performed work on the source employee; transfer the remainder.
|
||
|
|
totals = execute_query_single('''SELECT SUM(COALESCE(faktisk_tid_min,ROUND(COALESCE(approved_hours,original_hours,0)*60))) AS minutes
|
||
|
|
FROM tmodule_times WHERE sag_id=%s AND medarbejder_id=%s AND status<>'rejected' ''',(sag_id,source)) or {}
|
||
|
|
rest = remaining(dict(old,actual_minutes=totals.get('minutes') or 0))
|
||
|
|
new = execute_query_single('''INSERT INTO sag_case_assignees(sag_id,user_id,assigned_by_user_id,
|
||
|
|
estimated_minutes,remaining_minutes,queue_position,work_status)
|
||
|
|
VALUES(%s,%s,%s,%s,%s,%s,'ready') RETURNING *''',(sag_id,target,user['id'],rest,rest,position))
|
||
|
|
for booking in bookings:
|
||
|
|
if payload.future_bookings_action=='transfer':
|
||
|
|
changed=execute_query_single('''UPDATE planner_allocations SET user_id=%s,version=version+1,
|
||
|
|
updated_at=NOW(),updated_by=%s WHERE id=%s RETURNING *''',(target,user['id'],booking['id']))
|
||
|
|
else:
|
||
|
|
changed=execute_query_single('''UPDATE planner_allocations SET deleted_at=NOW(),version=version+1,
|
||
|
|
updated_at=NOW(),updated_by=%s WHERE id=%s RETURNING *''',(user['id'],booking['id']))
|
||
|
|
audit(user,'transfer' if payload.future_bookings_action=='transfer' else 'cancel',
|
||
|
|
'allocation',booking['id'],booking,changed,sag_id)
|
||
|
|
for event_id in sorted({r['event_id'] for r in bookings}):
|
||
|
|
# Cancellation of the last participant already versions/deletes the parent via its trigger.
|
||
|
|
execute_query('''UPDATE planner_events SET version=version+1,updated_at=NOW(),updated_by=%s
|
||
|
|
WHERE id=%s AND deleted_at IS NULL''',(user['id'],event_id),fetch=False)
|
||
|
|
execute_query('DELETE FROM sag_case_assignees WHERE sag_id=%s AND user_id=%s',(sag_id,source),fetch=False)
|
||
|
|
audit(user,'transfer','assignee',target,old,new,sag_id)
|
||
|
|
return {'item':new,'responsible_unchanged':True}
|
||
|
|
|
||
|
|
|
||
|
|
class RemoveInput(BaseModel):
|
||
|
|
version:int=Field(ge=1)
|
||
|
|
|
||
|
|
|
||
|
|
@router.delete('/cases/{sag_id}/assignees/{target}')
|
||
|
|
def remove_assignee(sag_id:int,target:int,version:int,user:dict=Depends(get_current_user)):
|
||
|
|
require_case(user,sag_id)
|
||
|
|
require_target(user,target,True)
|
||
|
|
if not (user.get('is_superadmin') or set(user.get('permissions') or []) & {'planner.allocate','planner.admin'}):
|
||
|
|
raise HTTPException(403,'Du må ikke fjerne allokeringer')
|
||
|
|
with atomic_transaction():
|
||
|
|
execute_query('SELECT id FROM sag_sager WHERE id=%s FOR KEY SHARE',(sag_id,))
|
||
|
|
if lock_queue(target)!=version:
|
||
|
|
raise HTTPException(409,'Køen er ændret')
|
||
|
|
old=execute_query_single('SELECT * FROM sag_case_assignees WHERE sag_id=%s AND user_id=%s',(sag_id,target))
|
||
|
|
if not old:
|
||
|
|
raise HTTPException(404,'Allokering findes ikke')
|
||
|
|
if execute_query_single('SELECT 1 FROM planner_allocations WHERE sag_id=%s AND user_id=%s AND deleted_at IS NULL AND ends_at>NOW()',(sag_id,target)):
|
||
|
|
raise HTTPException(409,'Fjern eller flyt fremtidige aftaler først')
|
||
|
|
execute_query('DELETE FROM sag_case_assignees WHERE sag_id=%s AND user_id=%s',(sag_id,target),fetch=False)
|
||
|
|
audit(user,'unassign','assignee',target,old,None,sag_id)
|
||
|
|
return {'removed':True}
|
||
|
|
|
||
|
|
|
||
|
|
class AssignmentInput(BaseModel):
|
||
|
|
user_id: int = Field(gt=0)
|
||
|
|
estimated_minutes:int|None=Field(default=None,ge=0,le=525600)
|
||
|
|
|
||
|
|
|
||
|
|
@router.post('/cases/{sag_id}/assignees', status_code=201)
|
||
|
|
def add_assignee(sag_id: int, payload: AssignmentInput, user: dict = Depends(get_current_user)):
|
||
|
|
require_case(user,sag_id)
|
||
|
|
require_target(user, payload.user_id, True)
|
||
|
|
if not (user.get('is_superadmin') or set(user.get('permissions') or []) & {'planner.admin', 'planner.allocate'}):
|
||
|
|
raise HTTPException(403, 'Du må ikke allokere medarbejdere')
|
||
|
|
with atomic_transaction():
|
||
|
|
execute_query('SELECT id FROM sag_sager WHERE id=%s FOR KEY SHARE',(sag_id,))
|
||
|
|
lock_queue(payload.user_id)
|
||
|
|
if not execute_query_single('SELECT id FROM sag_sager WHERE id=%s AND deleted_at IS NULL', (sag_id,)):
|
||
|
|
raise HTTPException(404, 'Sagen findes ikke')
|
||
|
|
if not execute_query_single('SELECT user_id FROM users WHERE user_id=%s AND is_active', (payload.user_id,)):
|
||
|
|
raise HTTPException(400, 'Vælg en aktiv medarbejder')
|
||
|
|
row = execute_query_single('''INSERT INTO sag_case_assignees(sag_id,user_id,assigned_by_user_id,estimated_minutes,queue_position)
|
||
|
|
SELECT %s,%s,%s,%s,COALESCE(MAX(queue_position),0)+1024 FROM sag_case_assignees WHERE user_id=%s
|
||
|
|
ON CONFLICT DO NOTHING RETURNING *''', (sag_id,payload.user_id,user['id'],payload.estimated_minutes,payload.user_id))
|
||
|
|
if row:
|
||
|
|
audit(user,'assign','assignee',payload.user_id,None,row,sag_id)
|
||
|
|
return {'created': bool(row)}
|
||
|
|
|
||
|
|
|
||
|
|
class WorkAction(BaseModel):
|
||
|
|
version:int=Field(ge=1)
|
||
|
|
timer_action:bool=False
|
||
|
|
|
||
|
|
|
||
|
|
async def work_action(sag_id,payload,user,resume):
|
||
|
|
from app.timetracking.backend.router import pause_live_timer_v1,resume_live_timer_v1
|
||
|
|
uid=int(user['id'])
|
||
|
|
require_target(user,uid,True)
|
||
|
|
require_case(user,sag_id)
|
||
|
|
with atomic_transaction():
|
||
|
|
execute_query('SELECT user_id FROM users WHERE user_id=%s FOR NO KEY UPDATE',(uid,))
|
||
|
|
result=change_queue(uid,sag_id,QueueChange(version=payload.version,work_status='working' if resume else 'paused'),user)
|
||
|
|
if payload.timer_action:
|
||
|
|
timer=execute_query_single('''SELECT id,aktiv_timer FROM tmodule_times WHERE medarbejder_id=%s AND sag_id=%s
|
||
|
|
AND slut_tid IS NULL AND (aktiv_timer=TRUE OR paused_at IS NOT NULL) ORDER BY id DESC LIMIT 1''',(uid,sag_id))
|
||
|
|
if timer and (bool(timer['aktiv_timer']) != resume):
|
||
|
|
# This transaction already serializes against the existing timer row lock.
|
||
|
|
resume_fn=getattr(resume_live_timer_v1,'__wrapped__',resume_live_timer_v1)
|
||
|
|
pause_fn=getattr(pause_live_timer_v1,'__wrapped__',pause_live_timer_v1)
|
||
|
|
result['timer']=await resume_fn({'time_id':timer['id']},user) if resume else await pause_fn(user)
|
||
|
|
return result
|
||
|
|
|
||
|
|
|
||
|
|
@router.post('/queue/{sag_id}/pause')
|
||
|
|
async def pause_work(sag_id:int,payload:WorkAction,user:dict=Depends(get_current_user)):
|
||
|
|
return await work_action(sag_id,payload,user,False)
|
||
|
|
|
||
|
|
|
||
|
|
@router.post('/queue/{sag_id}/resume')
|
||
|
|
async def resume_work(sag_id:int,payload:WorkAction,user:dict=Depends(get_current_user)):
|
||
|
|
return await work_action(sag_id,payload,user,True)
|
||
|
|
|
||
|
|
|
||
|
|
class AssigneeEstimateInput(BaseModel):
|
||
|
|
user_id:int=Field(gt=0)
|
||
|
|
queue_version:int=Field(ge=1)
|
||
|
|
estimated_minutes:int|None=Field(default=None,ge=0,le=525600)
|
||
|
|
remaining_minutes:int|None=Field(default=None,ge=0,le=525600)
|
||
|
|
|
||
|
|
|
||
|
|
class CasePlanningInput(BaseModel):
|
||
|
|
updated_at:datetime
|
||
|
|
start_date:date|None=None
|
||
|
|
start_time:time|None=None
|
||
|
|
deadline:datetime|None=None
|
||
|
|
deadline_date:date|None=None
|
||
|
|
estimated_minutes:int|None=Field(default=None,ge=0,le=525600)
|
||
|
|
assignee_estimates:list[AssigneeEstimateInput]=Field(default_factory=list,max_length=100)
|
||
|
|
add_assignee:AssignmentInput|None=None
|
||
|
|
|
||
|
|
|
||
|
|
@router.get('/cases/{sag_id}/planning')
|
||
|
|
def case_planning(sag_id:int,user:dict=Depends(get_current_user)):
|
||
|
|
require_target(user,int(user['id']))
|
||
|
|
require_case(user,sag_id)
|
||
|
|
row=execute_query_single('SELECT id,titel,start_date,start_time,deadline,estimated_minutes,updated_at FROM sag_sager WHERE id=%s AND deleted_at IS NULL',(sag_id,))
|
||
|
|
if not row:
|
||
|
|
raise HTTPException(404,'Sagen findes ikke')
|
||
|
|
row['assignees']=execute_query('''SELECT a.*,COALESCE(u.full_name,u.username) AS name,COALESCE(v.version,1) AS queue_version
|
||
|
|
FROM sag_case_assignees a JOIN users u ON u.user_id=a.user_id
|
||
|
|
LEFT JOIN planner_queue_versions v ON v.user_id=a.user_id WHERE a.sag_id=%s ORDER BY name''',(sag_id,)) or []
|
||
|
|
row['updated_at']=db_utc(row['updated_at'])
|
||
|
|
if row['deadline']:
|
||
|
|
row['deadline']=db_utc(row['deadline']).astimezone(TZ)
|
||
|
|
return row
|
||
|
|
|
||
|
|
|
||
|
|
@router.patch('/cases/{sag_id}/planning')
|
||
|
|
def update_case_planning(sag_id:int,payload:CasePlanningInput,user:dict=Depends(get_current_user)):
|
||
|
|
require_case(user,sag_id)
|
||
|
|
metadata_fields={'start_date','start_time','deadline','deadline_date','estimated_minutes'}
|
||
|
|
if {'deadline','deadline_date'} <= payload.model_fields_set:
|
||
|
|
raise HTTPException(400,'Angiv enten deadline eller deadlinedato')
|
||
|
|
if payload.model_fields_set & metadata_fields and not (user.get('is_superadmin') or set(user.get('permissions') or []) & {'cases.edit','tickets.edit'}):
|
||
|
|
raise HTTPException(403,'Du må ikke ændre sagsplanlægningen')
|
||
|
|
values={k:v for k,v in payload.model_dump().items() if k in payload.model_fields_set and k not in {'updated_at','assignee_estimates','add_assignee','deadline_date'}}
|
||
|
|
estimates=payload.assignee_estimates
|
||
|
|
if len({r.user_id for r in estimates})!=len(estimates):
|
||
|
|
raise HTTPException(400,'En medarbejder må kun angives én gang')
|
||
|
|
for row in estimates:
|
||
|
|
require_target(user,row.user_id,True)
|
||
|
|
if payload.add_assignee:
|
||
|
|
require_target(user,payload.add_assignee.user_id,True)
|
||
|
|
if not (user.get('is_superadmin') or set(user.get('permissions') or []) & {'planner.allocate','planner.admin'}):
|
||
|
|
raise HTTPException(403,'Du må ikke allokere medarbejdere')
|
||
|
|
if values.get('deadline') is not None:
|
||
|
|
values['deadline']=db_utc(values['deadline']).replace(tzinfo=None)
|
||
|
|
with atomic_transaction():
|
||
|
|
old=execute_query_single('SELECT * FROM sag_sager WHERE id=%s AND deleted_at IS NULL FOR UPDATE',(sag_id,))
|
||
|
|
if not old:
|
||
|
|
raise HTTPException(404,'Sagen findes ikke')
|
||
|
|
if db_utc(old['updated_at'])!=db_utc(payload.updated_at):
|
||
|
|
raise HTTPException(409,'Sagen er ændret. Genindlæs.')
|
||
|
|
if 'deadline_date' in payload.model_fields_set:
|
||
|
|
clock=db_utc(old['deadline']).astimezone(TZ).time() if old['deadline'] else time(17)
|
||
|
|
values['deadline']=(datetime.combine(payload.deadline_date,clock,TZ).astimezone(timezone.utc).replace(tzinfo=None)
|
||
|
|
if payload.deadline_date else None)
|
||
|
|
ids={r.user_id for r in estimates}
|
||
|
|
if payload.add_assignee:
|
||
|
|
ids.add(payload.add_assignee.user_id)
|
||
|
|
versions={uid:lock_queue(uid) for uid in sorted(ids)}
|
||
|
|
for row in estimates:
|
||
|
|
before=execute_query_single('SELECT * FROM sag_case_assignees WHERE sag_id=%s AND user_id=%s FOR UPDATE',(sag_id,row.user_id))
|
||
|
|
if not before:
|
||
|
|
raise HTTPException(409,'En medarbejders allokering er ændret')
|
||
|
|
fields={k:v for k,v in row.model_dump().items() if k in row.model_fields_set and k in {'estimated_minutes','remaining_minutes'} and before.get(k)!=v}
|
||
|
|
if fields:
|
||
|
|
if versions[row.user_id]!=row.queue_version:
|
||
|
|
raise HTTPException(409,'En medarbejders kø er ændret. Genindlæs.')
|
||
|
|
setters=','.join(f'{key}=%s' for key in fields)
|
||
|
|
execute_query(f'UPDATE sag_case_assignees SET {setters},version=version+1 WHERE sag_id=%s AND user_id=%s',(*fields.values(),sag_id,row.user_id),fetch=False)
|
||
|
|
bump_queue(row.user_id)
|
||
|
|
audit(user,'queue_change','assignee',row.user_id,before,{**before,**fields},sag_id)
|
||
|
|
if payload.add_assignee:
|
||
|
|
new_assignee=payload.add_assignee
|
||
|
|
if not execute_query_single('SELECT user_id FROM users WHERE user_id=%s AND is_active',(new_assignee.user_id,)):
|
||
|
|
raise HTTPException(400,'Vælg en aktiv medarbejder')
|
||
|
|
added=execute_query_single('''INSERT INTO sag_case_assignees(sag_id,user_id,assigned_by_user_id,estimated_minutes,queue_position)
|
||
|
|
SELECT %s,%s,%s,%s,COALESCE(MAX(queue_position),0)+1024 FROM sag_case_assignees WHERE user_id=%s
|
||
|
|
ON CONFLICT DO NOTHING RETURNING *''',(sag_id,new_assignee.user_id,user['id'],new_assignee.estimated_minutes,new_assignee.user_id))
|
||
|
|
if not added:
|
||
|
|
raise HTTPException(409,'Medarbejderen er allerede allokeret. Genindlæs.')
|
||
|
|
audit(user,'assign','assignee',new_assignee.user_id,None,added,sag_id)
|
||
|
|
if values:
|
||
|
|
setters=','.join(f'{key}=%s' for key in values)
|
||
|
|
execute_query(f'UPDATE sag_sager SET {setters},updated_at=NOW() WHERE id=%s',(*values.values(),sag_id),fetch=False)
|
||
|
|
elif estimates or payload.add_assignee:
|
||
|
|
execute_query('UPDATE sag_sager SET updated_at=NOW() WHERE id=%s',(sag_id,),fetch=False)
|
||
|
|
new=execute_query_single('SELECT start_date,start_time,deadline,estimated_minutes,updated_at FROM sag_sager WHERE id=%s',(sag_id,))
|
||
|
|
if new['start_time'] and not new['start_date']:
|
||
|
|
raise HTTPException(400,'Startklokkeslæt kræver en startdato')
|
||
|
|
audit(user,'case_plan_update','case',sag_id,old,new,sag_id)
|
||
|
|
new['updated_at']=db_utc(new['updated_at'])
|
||
|
|
if new['deadline']:
|
||
|
|
new['deadline']=db_utc(new['deadline']).astimezone(TZ)
|
||
|
|
return new
|