bmc_hub/app/timetracking/backend/quick_time.py

125 lines
8.5 KiB
Python
Raw Normal View History

"""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)