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
|