bmc_hub/app/modules/remote/service.py
Christian 85e019783b Add comprehensive test coverage for case creation, assignment, and remote recovery
- 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.
2026-10-15 12:17:09 +02:00

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