bmc_hub/app/modules/planner/backend/desk.py
Christian 1fb5b4a191 Add comprehensive tests for email and drift functionalities
- Introduced tests for drift operations UI and backend to ensure proper handling of ignore rules and UI elements.
- Added tests for email identity API to validate email linking and identity retrieval without updates.
- Implemented tests for email purchase link service to verify correct handling of purchase mail links and conflict scenarios.
- Created tests for email reply automation to ensure proper routing and processing of replies based on classification.
- Developed tests for email sender creation to validate suggestions and handling of existing contacts and companies.
- Added tests for email sender identity to ensure correct matching of contacts and domains.
- Implemented tests for work desk functionalities to validate task management, meeting registrations, and timer functionalities.
2026-11-03 01:16:12 +01:00

330 lines
19 KiB
Python

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