"""Atomic standalone time/planning with explicit conversion and no case creation.""" from datetime import datetime, timedelta from uuid import UUID from zoneinfo import ZoneInfo from typing import Literal from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, Field, field_validator from psycopg2.extras import RealDictCursor, Json from app.core.auth_dependencies import get_current_user from app.core.database import get_db_connection,release_db_connection router=APIRouter(prefix='/quick-time') class QuickTimeInput(BaseModel): request_id:UUID mode:Literal['registered','planned','convert'] description:str=Field(min_length=1,max_length=2000) minutes:int=Field(ge=1,le=1440) starts_at:datetime priority:Literal['low','normal','high','urgent']='normal' user_ids:list[int]=Field(min_length=1,max_length=50) customer_id:int|None=None contact_id:int|None=None plan_ids:list[int]=Field(default_factory=list,max_length=50) confirm_conflicts:bool=False @field_validator('starts_at') @classmethod def aware(cls,value): if value.tzinfo is None:raise ValueError('Tidspunkt skal have tidszone') return value @field_validator('description') @classmethod def description_present(cls,value): if not value.strip():raise ValueError('Angiv beskrivelse') return value.strip() def can_register(user,target): from .router import _can_view_all_team_timers return int(user['id'])==target or _can_view_all_team_timers(user) def can_plan(user,target): from app.modules.planner.backend.router import _can_edit return _can_edit(user,target) @router.get('/options') def options(user:dict=Depends(get_current_user)): conn=get_db_connection() try: with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute("SELECT user_id AS id,COALESCE(full_name,username) AS name FROM users WHERE is_active=true ORDER BY name") rows=list(cur.fetchall()) from .router import _can_view_all_team_timers team_register=_can_view_all_team_timers(user) users=[dict(r,can_register=(r['id']==user['id'] or team_register),can_plan=can_plan(user,r['id'])) for r in rows] ids=[r['id'] for r in users if r['can_register'] and r['can_plan']] cur.execute("SELECT q.*,COALESCE(u.full_name,u.username) AS user_name,c.name AS customer_name FROM quick_time_items q JOIN users u ON u.user_id=q.user_id LEFT JOIN customers c ON c.id=q.customer_id WHERE q.state='planned' AND q.user_id=ANY(%s) ORDER BY q.starts_at DESC LIMIT 200",(ids,)) return {'current_user_id':user['id'],'users':users,'plans':list(cur.fetchall())} finally:conn.rollback();release_db_connection(conn) @router.post('') def save(payload:QuickTimeInput,user:dict=Depends(get_current_user)): ids=sorted(set(payload.user_ids)) for uid in ids: if not can_register(user,uid) and payload.mode!='planned':raise HTTPException(403,detail='Du må ikke registrere tid for denne medarbejder') if payload.mode in {'planned','convert'} and not can_plan(user,uid):raise HTTPException(403,detail='Du må ikke ændre denne medarbejders plan') if payload.mode=='convert' and not payload.plan_ids:raise HTTPException(400,detail='Vælg en planlagt tid') if payload.mode!='convert' and payload.plan_ids:raise HTTPException(400,detail='Planvalg gælder kun konvertering') value=payload.model_dump(mode='json');conn=get_db_connection() try: with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute('SELECT pg_advisory_xact_lock(hashtextextended(%s,0))',(str(payload.request_id),)) cur.execute('SELECT * FROM quick_time_batches WHERE request_id=%s',(str(payload.request_id),));old=cur.fetchone() if old: if old['actor_id']!=user['id'] or old['payload']!=value:raise HTTPException(409,detail='Gemningen tilhører andre oplysninger') return old['result'] cur.execute('SELECT user_id FROM users WHERE user_id=ANY(%s) AND is_active=true',(ids,)) if len(cur.fetchall())!=len(ids):raise HTTPException(400,detail='Vælg aktive medarbejdere') if payload.customer_id: cur.execute('SELECT id FROM customers WHERE id=%s',(payload.customer_id,)) if not cur.fetchone():raise HTTPException(400,detail='Firma findes ikke') if payload.contact_id: cur.execute('SELECT id FROM contacts WHERE id=%s',(payload.contact_id,)) if not cur.fetchone():raise HTTPException(400,detail='Kontakt findes ikke') end=payload.starts_at+timedelta(minutes=payload.minutes) plans=[] if payload.mode=='convert': cur.execute('SELECT * FROM quick_time_items WHERE id=ANY(%s) ORDER BY id FOR UPDATE',(sorted(set(payload.plan_ids)),));plans=list(cur.fetchall()) if len(plans)!=len(set(payload.plan_ids)) or any(p['state']!='planned' or p['user_id'] not in ids for p in plans):raise HTTPException(409,detail='En valgt plan er allerede registreret eller ikke tilgængelig') else: if payload.mode=='planned': from app.modules.planner.backend.router import _conflicts conflicts=[] for uid in ids: # Serialize simultaneous standalone plans for each employee. cur.execute('SELECT pg_advisory_xact_lock(%s,%s)',(1060,uid)) state,details=_conflicts(uid,payload.starts_at,end) cur.execute("SELECT id FROM quick_time_items WHERE user_id=%s AND state='planned' AND starts_at<%s AND ends_at>%s",(uid,end,payload.starts_at)) if state!='none' or cur.fetchall():conflicts.append(uid) if conflicts and not payload.confirm_conflicts:raise HTTPException(409,detail={'message':'Tiden overlapper eller overbooker. Kontrollér før gemning.','user_ids':conflicts,'confirm_conflicts':True}) for uid in ids: cur.execute("INSERT INTO quick_time_items(user_id,customer_id,contact_id,description,priority,starts_at,ends_at,state,created_by) VALUES(%s,%s,%s,%s,%s,%s,%s,%s,%s) RETURNING *",(uid,payload.customer_id,payload.contact_id,payload.description,payload.priority,payload.starts_at,end,'planned',user['id'])) plans.append(cur.fetchone()) result={'ids':[],'time_entry_ids':[],'mode':payload.mode} for plan in plans: result['ids'].append(plan['id']) if payload.mode=='planned':continue # Conversion preserves the plan's customer, contact and employee. customer=plan['customer_id'];time_customer=None if customer: cur.execute('SELECT id FROM tmodule_customers WHERE hub_customer_id=%s',(customer,));mapped=cur.fetchone() if not mapped:raise HTTPException(409,detail='Firmaet mangler i tidsmodulet. Synkronisér kunder før registrering.') time_customer=mapped['id'] uid=plan['user_id'] cur.execute('SELECT COALESCE(full_name,username) AS name FROM users WHERE user_id=%s',(uid,));name=cur.fetchone()['name'] cur.execute("""INSERT INTO tmodule_times(customer_id,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) VALUES(%s,%s,%s,%s,%s,'pending',%s,'draft',%s,%s,'manuel','manuel','kladde',%s,false,30,false,'other',%s,%s) RETURNING id""",(time_customer,payload.description,payload.minutes/60,payload.starts_at.astimezone(ZoneInfo("Europe/Copenhagen")).date(),name,customer is not None,payload.minutes,((payload.minutes+29)//30)*30 if customer else 0,uid,payload.starts_at,end)) tid=cur.fetchone()['id'];result['time_entry_ids'].append(tid) cur.execute("UPDATE quick_time_items SET state='registered',time_entry_id=%s,priority=%s,description=%s WHERE id=%s",(tid,payload.priority,payload.description,plan['id'])) cur.execute('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))) conn.commit();return result except Exception:conn.rollback();raise finally:conn.rollback();release_db_connection(conn)