- Introduced tests for case creation commands, ensuring proper handling of UUIDs and payloads. - Added tests for v5 assignment functionality, validating employee identification and group assignment. - Implemented tests for remote recovery scenarios, including handling network failures and response validation. - Created tests for hardware label generation and validation, ensuring proper URL formatting and QR code generation. - Developed extensive tests for remote control functionality, covering connection isolation, command execution, and user permissions. - Added tests for workflow timer registration, ensuring correct handling of timer states and ownership validation.
196 lines
11 KiB
Python
196 lines
11 KiB
Python
"""Restricted remote actions reuse existing time and messaging business logic."""
|
|
from datetime import datetime, timezone
|
|
from uuid import UUID
|
|
from fastapi import HTTPException
|
|
from fastapi.encoders import jsonable_encoder
|
|
from psycopg2.extras import Json
|
|
from app.core.database import atomic_transaction, execute_query, execute_query_single
|
|
from app.modules.bottom_bar.backend.service import get_own_timer_snapshot, get_user_messages_summary, mark_user_messages_read, acknowledge_message
|
|
from app.modules.sag.backend.create_support import closed_statuses
|
|
from .security import case_permission, today
|
|
|
|
|
|
def user_state(user_id):
|
|
execute_query('INSERT INTO remote_user_state(user_id) VALUES(%s) ON CONFLICT DO NOTHING', (user_id,))
|
|
return execute_query_single('SELECT *,follower_seen_at>NOW()-INTERVAL \'10 seconds\' AS connected FROM remote_user_state WHERE user_id=%s', (user_id,))
|
|
|
|
|
|
def case(case_id, user):
|
|
case_permission(user)
|
|
row = execute_query_single('SELECT id,titel,status,deadline,customer_id FROM sag_sager WHERE id=%s AND deleted_at IS NULL', (case_id,))
|
|
if not row:
|
|
raise HTTPException(404, 'Sagen findes ikke længere')
|
|
return case_display(row)
|
|
|
|
|
|
def case_display(row, now=None):
|
|
row = dict(row)
|
|
deadline = row.get('deadline')
|
|
if deadline:
|
|
# Existing case timestamps without offset use Hub's local Danish timezone.
|
|
from zoneinfo import ZoneInfo
|
|
if deadline.tzinfo is None:
|
|
deadline = deadline.replace(tzinfo=ZoneInfo('Europe/Copenhagen'))
|
|
seconds = int((deadline - (now or datetime.now(timezone.utc))).total_seconds())
|
|
row.update(deadline=deadline.isoformat(), deadline_seconds=seconds,
|
|
deadline_state='overdue' if seconds < 0 else 'warning' if seconds <= 3600 else 'normal')
|
|
else:
|
|
row.update(deadline_seconds=None, deadline_state='none')
|
|
return row
|
|
|
|
|
|
def cases(user, kind='search', q='', offset=0):
|
|
case_permission(user)
|
|
where = 's.deleted_at IS NULL'
|
|
params = []
|
|
if kind == 'new':
|
|
where += " AND LOWER(TRIM(s.status))='ny'"
|
|
elif kind == 'own':
|
|
where += ' AND s.ansvarlig_bruger_id=%s AND NOT (LOWER(TRIM(s.status))=ANY(%s))'
|
|
params.extend([user['id'], closed_statuses()])
|
|
if q:
|
|
where += ' AND (s.titel ILIKE %s OR s.id::text=%s OR c.name ILIKE %s)'
|
|
params.extend(['%' + q + '%', q, '%' + q + '%'])
|
|
rows = execute_query(f'''SELECT s.id,s.titel,s.status,s.deadline,c.name AS customer_name FROM sag_sager s
|
|
LEFT JOIN customers c ON c.id=s.customer_id WHERE {where}
|
|
ORDER BY s.deadline ASC NULLS LAST,s.created_at DESC,s.id DESC LIMIT 51 OFFSET %s''', tuple(params + [offset]))
|
|
return {'items': [case_display(row) for row in rows[:50]], 'has_more': len(rows) > 50}
|
|
|
|
|
|
def timer_version(user_id):
|
|
"""Fingerprint changes made by desktop as well as remote, excluding elapsed seconds."""
|
|
from .security import digest
|
|
rows = execute_query('''SELECT id,sag_id,aktiv_timer,start_tid,slut_tid,paused_at,pause_total_seconds,updated_at
|
|
FROM tmodule_times WHERE medarbejder_id=%s AND slut_tid IS NULL
|
|
AND (aktiv_timer=TRUE OR paused_at IS NOT NULL) ORDER BY id''', (user_id,))
|
|
latest = execute_query_single('''SELECT id,updated_at FROM tmodule_times WHERE medarbejder_id=%s
|
|
ORDER BY updated_at DESC NULLS LAST,id DESC LIMIT 1''', (user_id,))
|
|
import json
|
|
return digest(json.dumps(jsonable_encoder([rows, latest]), sort_keys=True))
|
|
|
|
|
|
def planned_day(user, day=None):
|
|
from datetime import timedelta
|
|
from zoneinfo import ZoneInfo
|
|
case_permission(user)
|
|
day = day or today()
|
|
zone = ZoneInfo('Europe/Copenhagen')
|
|
start = datetime.combine(day, datetime.min.time(), tzinfo=zone)
|
|
end = datetime.combine(day + timedelta(days=1), datetime.min.time(), tzinfo=zone)
|
|
rows = execute_query("""SELECT a.id, a.sag_id AS case_id, a.starts_at, a.ends_at,
|
|
s.titel AS title, c.name AS customer_name, 'case' AS kind
|
|
FROM planner_allocations a JOIN sag_sager s ON s.id=a.sag_id
|
|
LEFT JOIN customers c ON c.id=s.customer_id
|
|
WHERE a.user_id=%s AND a.deleted_at IS NULL AND s.deleted_at IS NULL
|
|
AND a.starts_at<%s AND a.ends_at>%s
|
|
UNION ALL
|
|
SELECT q.id, NULL AS case_id, q.starts_at, q.ends_at, q.description AS title,
|
|
COALESCE(c.name,'Intern tid') AS customer_name, 'standalone' AS kind
|
|
FROM quick_time_items q LEFT JOIN customers c ON c.id=q.customer_id
|
|
WHERE q.user_id=%s AND q.state='planned' AND q.starts_at<%s AND q.ends_at>%s
|
|
ORDER BY starts_at""", (user['id'], end, start, user['id'], end, start)) or []
|
|
return {'date': day.isoformat(), 'items': rows}
|
|
|
|
|
|
def state(user):
|
|
s = user_state(user['id'])
|
|
selected = None
|
|
if s['active_case_id']:
|
|
try:
|
|
selected = case(s['active_case_id'], user)
|
|
except HTTPException as exc:
|
|
if exc.status_code != 404:
|
|
raise
|
|
total = execute_query_single('''SELECT COALESCE(SUM(faktisk_tid_min),0)::int AS minutes
|
|
FROM tmodule_times WHERE medarbejder_id=%s AND worked_date=%s
|
|
AND NOT aktiv_timer AND paused_at IS NULL''', (user['id'], today()))
|
|
return {'version': s['version'], 'timer_version': timer_version(user['id']), 'active_case': selected,
|
|
'timer': get_own_timer_snapshot(user['id']), 'today_minutes': total['minutes'],
|
|
'desktop_connected': bool(s['connected']), 'messages': get_user_messages_summary(user['id']),
|
|
'server_time': datetime.now(timezone.utc).isoformat(), 'user_id': user['id'],
|
|
'user_name': user['full_name'] or user['username']}
|
|
|
|
|
|
async def command(payload, user, request=None):
|
|
"""Atomic idempotency + remote version + user lock shared with desktop timer writes."""
|
|
import importlib
|
|
timers = importlib.import_module('app.timetracking.backend.router')
|
|
uid = user['id']
|
|
value = payload.model_dump(mode='json')
|
|
with atomic_transaction():
|
|
execute_query('SELECT user_id FROM users WHERE user_id=%s FOR UPDATE', (uid,))
|
|
if request is not None:
|
|
from .security import remote_device, actor
|
|
user = actor(remote_device(request))
|
|
s = user_state(uid)
|
|
old = execute_query_single('SELECT payload,result FROM remote_commands WHERE user_id=%s AND request_id=%s', (uid, str(payload.request_id)))
|
|
if old:
|
|
if old['payload'] != value:
|
|
raise HTTPException(409, 'Handlingen har allerede andre oplysninger')
|
|
return old['result']
|
|
if payload.version != s['version'] or payload.timer_version != timer_version(uid):
|
|
raise HTTPException(409, 'Hub er ændret. Opdatér og prøv igen')
|
|
action = payload.action
|
|
result = {'ok': True}
|
|
if action in {'select', 'start', 'manual'}:
|
|
if not payload.case_id:
|
|
raise HTTPException(400, 'Vælg en sag')
|
|
case(payload.case_id, user)
|
|
timer_data = {'sag_id': payload.case_id, 'beskrivelse': payload.description,
|
|
'billing_mode': 'automatic'}
|
|
if action == 'select':
|
|
active = get_own_timer_snapshot(uid)['active']
|
|
if active.get('active') and active.get('sag_id') != payload.case_id:
|
|
if payload.switch_timer is None:
|
|
raise HTTPException(409, 'Vælg om timeren også skal skifte')
|
|
if payload.switch_timer:
|
|
await timers.pause_live_timer_v1(current_user=user)
|
|
paused = execute_query_single('''SELECT id FROM tmodule_times WHERE medarbejder_id=%s
|
|
AND sag_id=%s AND NOT aktiv_timer AND paused_at IS NOT NULL AND slut_tid IS NULL
|
|
ORDER BY paused_at DESC LIMIT 1''', (uid, payload.case_id))
|
|
if paused:
|
|
await timers.resume_live_timer_v1(payload={'time_id': paused['id']}, current_user=user)
|
|
else:
|
|
await timers.start_live_timer_v1(payload=timer_data, current_user=user)
|
|
execute_query('''UPDATE remote_user_state SET active_case_id=%s,selection_seq=selection_seq+1,
|
|
selection_target=CASE WHEN follower_seen_at>NOW()-INTERVAL '10 seconds' THEN follower_id ELSE NULL END,
|
|
selection_expires_at=NOW()+INTERVAL '10 seconds' WHERE user_id=%s''', (payload.case_id, uid))
|
|
elif action == 'start':
|
|
active = get_own_timer_snapshot(uid)['active']
|
|
if active.get('active'):
|
|
raise HTTPException(409, 'En timer kører allerede. Brug sagsvalg for at skifte')
|
|
result = await timers.start_live_timer_v1(payload=timer_data, current_user=user)
|
|
elif action in {'pause', 'resume', 'stop'}:
|
|
if payload.time_id:
|
|
owned = execute_query_single('''SELECT id FROM tmodule_times WHERE id=%s AND medarbejder_id=%s
|
|
AND slut_tid IS NULL AND start_tid IS NOT NULL AND (aktiv_timer OR paused_at IS NOT NULL)''', (payload.time_id, uid))
|
|
if not owned:
|
|
raise HTTPException(404, 'Timeren findes ikke eller er afsluttet')
|
|
if action == 'pause':
|
|
result = await timers.pause_live_timer_v1(current_user=user)
|
|
elif action == 'resume':
|
|
result = await timers.resume_live_timer_v1(payload={'time_id': payload.time_id}, current_user=user)
|
|
else:
|
|
if not payload.description.strip():
|
|
raise HTTPException(400, 'Angiv beskrivelse før stop')
|
|
active = get_own_timer_snapshot(uid)['active']
|
|
tid = payload.time_id or active.get('time_entry_id')
|
|
if not tid:
|
|
raise HTTPException(404, 'Ingen timer valgt')
|
|
execute_query('UPDATE tmodule_times SET description=%s WHERE id=%s AND medarbejder_id=%s AND slut_tid IS NULL', (payload.description, tid, uid))
|
|
result = await timers.stop_live_timer_v1(payload={'time_id': tid}, current_user=user)
|
|
elif action == 'manual':
|
|
if not payload.description.strip() or not payload.minutes:
|
|
raise HTTPException(400, 'Angiv minutter og beskrivelse')
|
|
timer_data.update(faktisk_tid_min=payload.minutes, worked_date=today().isoformat())
|
|
result = await timers.create_manual_time_v1(payload=timer_data, current_user=user)
|
|
elif action == 'read':
|
|
result = {'updated': mark_user_messages_read(uid, partner_user_id=payload.partner_user_id)}
|
|
elif action == 'ack':
|
|
if not payload.message_id or not acknowledge_message(uid, payload.message_id):
|
|
raise HTTPException(404, 'Beskeden findes ikke eller er allerede bekræftet')
|
|
execute_query('UPDATE remote_user_state SET version=version+1 WHERE user_id=%s', (uid,))
|
|
result = jsonable_encoder({'ok': True, 'result': result, 'version': s['version'] + 1})
|
|
execute_query('INSERT INTO remote_commands(user_id,request_id,payload,result) VALUES(%s,%s,%s,%s)', (uid, str(payload.request_id), Json(value), Json(result)))
|
|
return result
|