125 lines
8.5 KiB
Python
125 lines
8.5 KiB
Python
|
|
"""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)
|