- 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.
330 lines
19 KiB
Python
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}
|