330 lines
19 KiB
Python
330 lines
19 KiB
Python
|
|
"""Personal work desk, reusing queue, planner and time-entry contracts."""
|
||
|
|
from datetime import date, datetime, time, timedelta, timezone
|
||
|
|
from typing import Literal
|
||
|
|
from uuid import UUID
|
||
|
|
|
||
|
|
from fastapi import APIRouter, Depends, HTTPException
|
||
|
|
from pydantic import BaseModel, Field, model_validator
|
||
|
|
from fastapi.encoders import jsonable_encoder
|
||
|
|
from psycopg2.extras import Json
|
||
|
|
|
||
|
|
from app.core.auth_dependencies import get_current_user
|
||
|
|
from app.core.case_visibility import require_case
|
||
|
|
from app.core.database import atomic_transaction, execute_query, execute_query_single
|
||
|
|
from . import work
|
||
|
|
from .router import TZ, _aware, _can_edit
|
||
|
|
|
||
|
|
|
||
|
|
def require_desk_schema():
|
||
|
|
work.require_schema()
|
||
|
|
if not execute_query_single("SELECT 1 FROM information_schema.columns WHERE table_name='planner_queue_versions' AND column_name='desk_manual_order'"):
|
||
|
|
raise HTTPException(503, 'Arbejdsbord afventer databaseopdatering 1073.')
|
||
|
|
|
||
|
|
|
||
|
|
router = APIRouter(dependencies=[Depends(require_desk_schema)])
|
||
|
|
|
||
|
|
|
||
|
|
def daily_task(row, day, now):
|
||
|
|
"""Expose why work is due today; never infer an individual case estimate."""
|
||
|
|
start = datetime.combine(day, time.min, TZ)
|
||
|
|
end = start + timedelta(days=1)
|
||
|
|
deadline = work.db_utc(row['deadline']).astimezone(TZ) if row.get('deadline') else None
|
||
|
|
slots = row.get('planned_slots') or []
|
||
|
|
today_slots = [s for s in slots if _aware(datetime.fromisoformat(str(s['starts_at']))) < end
|
||
|
|
and _aware(datetime.fromisoformat(str(s['ends_at']))) > start]
|
||
|
|
planned = round(sum(max(0, (min(_aware(datetime.fromisoformat(str(s['ends_at']))), end) -
|
||
|
|
max(_aware(datetime.fromisoformat(str(s['starts_at']))), start)).total_seconds()) / 60
|
||
|
|
for s in today_slots))
|
||
|
|
blocked = None
|
||
|
|
if row.get('work_status') in {'waiting', 'paused'} or str(row.get('status') or '').lower() in work.WAITING:
|
||
|
|
blocked = 'Afventer' if row.get('work_status') != 'paused' else 'På pause'
|
||
|
|
if row.get('deferred_until_case_id') and not work.ready({**row, 'start_date': None, 'work_status': 'ready'}, now):
|
||
|
|
blocked = f"Afhænger af sag #{row['deferred_until_case_id']}"
|
||
|
|
if row.get('snoozed_until') and _aware(row['snoozed_until']) > now:
|
||
|
|
blocked = 'Udsat'
|
||
|
|
if row.get('deferred_until') and work.db_utc(row['deferred_until']) > now:
|
||
|
|
blocked = 'Udsat'
|
||
|
|
remaining = work.remaining(row)
|
||
|
|
missing = None if remaining is None else max(0, remaining - int(row.get('future_planned_minutes') or 0))
|
||
|
|
overdue = bool(deadline and deadline < now)
|
||
|
|
due = bool(deadline and deadline < end)
|
||
|
|
included = row.get('work_status') != 'done' and (bool(today_slots) or due or not row.get('start_date') or row['start_date'] <= day)
|
||
|
|
if overdue:
|
||
|
|
reason, rank = 'Deadline overskredet', 0
|
||
|
|
elif today_slots:
|
||
|
|
reason, rank = 'Tid reserveret i dag', 1
|
||
|
|
elif due:
|
||
|
|
reason, rank = 'Deadline i dag', 2
|
||
|
|
elif row.get('start_date') == day:
|
||
|
|
reason, rank = 'Starter i dag', 3
|
||
|
|
else:
|
||
|
|
reason, rank = 'Åben opgave · kan fortsættes i dag', 4
|
||
|
|
if blocked:
|
||
|
|
reason = blocked + ' · ' + reason
|
||
|
|
if row.get('start_date') and row['start_date'] > day and due:
|
||
|
|
reason += ' · start ligger efter deadline'
|
||
|
|
after_deadline = bool(deadline and any(_aware(datetime.fromisoformat(str(s['ends_at']))) > deadline for s in slots))
|
||
|
|
if after_deadline:
|
||
|
|
reason += ' · tid reserveret efter deadline'
|
||
|
|
next_slot = min((str(s['starts_at']) for s in today_slots), default='9999')
|
||
|
|
priority = {'urgent': 0, 'akut': 0, 'high': 1, 'høj': 1, 'normal': 2, 'low': 3}.get(str(row.get('priority') or '').lower(), 2)
|
||
|
|
return {**row, 'in_day': included, 'blocked_reason': blocked, 'priority_reason': reason,
|
||
|
|
'remaining_work_minutes': remaining, 'missing_minutes': missing, 'planned_today_minutes': planned,
|
||
|
|
'today_slots': today_slots, 'ready': work.ready(row, now),
|
||
|
|
'booking_after_deadline': after_deadline,
|
||
|
|
'_rank': (bool(blocked), rank, deadline.isoformat() if deadline else '9999', next_slot,
|
||
|
|
priority, row.get('start_date') or date.max, missing if missing is not None else 10**9, row['sag_id'])}
|
||
|
|
|
||
|
|
|
||
|
|
@router.get('/desk')
|
||
|
|
def desk(day: date | None = None, user: dict = Depends(get_current_user)):
|
||
|
|
uid = int(user['id'])
|
||
|
|
work.require_target(user, uid)
|
||
|
|
now = datetime.now(TZ)
|
||
|
|
day = day or now.date()
|
||
|
|
with atomic_transaction():
|
||
|
|
execute_query('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ', fetch=False)
|
||
|
|
version = execute_query_single('SELECT * FROM planner_queue_versions WHERE user_id=%s', (uid,)) or {}
|
||
|
|
rows = work.queue_rows(uid, limit=10001, user=user)
|
||
|
|
if len(rows) > 10000:
|
||
|
|
raise HTTPException(422, 'Køen er for stor til Arbejdsbord. Brug opgavevisningen til at afgrænse den.')
|
||
|
|
# Past slots on the chosen day must remain visible even after they end.
|
||
|
|
bookings = execute_query('''SELECT sag_id,id,event_id,starts_at,ends_at FROM planner_allocations
|
||
|
|
WHERE user_id=%s AND sag_id=ANY(%s) AND deleted_at IS NULL AND starts_at<%s AND ends_at>%s''',
|
||
|
|
(uid, [r['sag_id'] for r in rows], datetime.combine(day+timedelta(days=1), time.min, TZ), datetime.combine(day,time.min,TZ))) or []
|
||
|
|
contacts = execute_query('''SELECT DISTINCT ON (sk.sag_id) sk.sag_id,c.id,
|
||
|
|
TRIM(CONCAT_WS(' ',c.first_name,c.last_name)) AS name,c.email,COALESCE(NULLIF(c.mobile,''),c.phone) AS phone
|
||
|
|
FROM sag_kontakter sk JOIN contacts c ON c.id=sk.contact_id
|
||
|
|
WHERE sk.sag_id=ANY(%s) AND sk.deleted_at IS NULL ORDER BY sk.sag_id,sk.is_primary DESC,sk.id''',
|
||
|
|
([r['sag_id'] for r in rows],)) or []
|
||
|
|
by_case = {c['sag_id']: c for c in contacts}
|
||
|
|
def slot_key(slot):
|
||
|
|
# jsonb future slots use ISO strings; today's SQL rows use datetimes.
|
||
|
|
# Normalize both before merging, preserving simultaneous events.
|
||
|
|
return (slot.get('event_id'),
|
||
|
|
_aware(datetime.fromisoformat(str(slot['starts_at']))).astimezone(timezone.utc).isoformat(),
|
||
|
|
_aware(datetime.fromisoformat(str(slot['ends_at']))).astimezone(timezone.utc).isoformat())
|
||
|
|
result = []
|
||
|
|
for row in rows:
|
||
|
|
merged = {slot_key(s): s for s in row.get('planned_slots') or []}
|
||
|
|
merged.update({slot_key(b): b for b in bookings if b['sag_id'] == row['sag_id']})
|
||
|
|
row['planned_slots'] = list(merged.values())
|
||
|
|
task = daily_task(row, day, now)
|
||
|
|
task['contact'] = by_case.get(row['sag_id'])
|
||
|
|
task['is_new'] = not row.get('desk_seen_at')
|
||
|
|
result.append(task)
|
||
|
|
recommended = [r['sag_id'] for r in sorted(result, key=lambda r: r['_rank'])]
|
||
|
|
manual = bool(version.get('desk_manual_order'))
|
||
|
|
if not manual:
|
||
|
|
result.sort(key=lambda r: r['_rank'])
|
||
|
|
else:
|
||
|
|
result.sort(key=lambda r: (r['queue_position'], r['sag_id']))
|
||
|
|
for row in result:
|
||
|
|
row.pop('_rank')
|
||
|
|
from .capacity import working_capacity
|
||
|
|
horizon_start = min(datetime.combine(day+timedelta(days=1),time.min,TZ), max(datetime.combine(day,time.min,TZ), now))
|
||
|
|
horizon_end = datetime.combine(day+timedelta(days=1),time.min,TZ)
|
||
|
|
schedules = execute_query('SELECT * FROM planner_work_schedules WHERE user_id=%s AND active', (uid,)) or []
|
||
|
|
occupied = execute_query('''SELECT starts_at,ends_at FROM planner_allocations WHERE user_id=%s AND deleted_at IS NULL AND starts_at<%s AND ends_at>%s
|
||
|
|
UNION ALL SELECT starts_at,ends_at FROM planner_absences WHERE user_id=%s AND deleted_at IS NULL AND starts_at<%s AND ends_at>%s
|
||
|
|
UNION ALL SELECT starts_at,ends_at FROM planner_external_busy WHERE user_id=%s AND starts_at<%s AND ends_at>%s
|
||
|
|
UNION ALL SELECT starts_at,ends_at FROM quick_time_items WHERE user_id=%s AND state='planned' AND starts_at<%s AND ends_at>%s''',
|
||
|
|
(uid,horizon_end,horizon_start)*4) or []
|
||
|
|
capacity = working_capacity(schedules, [(_aware(b['starts_at']), _aware(b['ends_at'])) for b in occupied], horizon_start, horizon_end)
|
||
|
|
capacity['configured'] = bool(schedules)
|
||
|
|
return {'items': result, 'recommended_ids': recommended, 'version': version.get('version', 1),
|
||
|
|
'stored_order_ids': [r['sag_id'] for r in sorted(result, key=lambda r: (r['queue_position'], r['sag_id']))],
|
||
|
|
'manual_order': manual, 'day': day, 'can_edit': _can_edit(user, uid), 'capacity': capacity}
|
||
|
|
|
||
|
|
|
||
|
|
class DeskOrder(BaseModel):
|
||
|
|
version: int = Field(ge=1)
|
||
|
|
mode: Literal['manual', 'recommended'] = 'manual'
|
||
|
|
ids: list[int] = Field(default_factory=list, max_length=10000)
|
||
|
|
|
||
|
|
|
||
|
|
@router.put('/desk/order')
|
||
|
|
def desk_order(payload: DeskOrder, user: dict = Depends(get_current_user)):
|
||
|
|
uid = int(user['id'])
|
||
|
|
work.require_target(user, uid, True)
|
||
|
|
with atomic_transaction():
|
||
|
|
version = work.lock_queue(uid)
|
||
|
|
if version != payload.version:
|
||
|
|
raise HTTPException(409, 'Køen er ændret. Genindlæs og prøv igen.')
|
||
|
|
if len(set(payload.ids)) != len(payload.ids):
|
||
|
|
raise HTTPException(400, 'En opgave må kun forekomme én gang')
|
||
|
|
if payload.mode == 'manual' or payload.ids:
|
||
|
|
allowed = {r['sag_id'] for r in work.queue_rows(uid, limit=10001, user=user)}
|
||
|
|
if set(payload.ids) != allowed:
|
||
|
|
raise HTTPException(409, 'Opgaverne er ændret. Genindlæs før sortering.')
|
||
|
|
for position, sid in enumerate(payload.ids, 1):
|
||
|
|
execute_query('UPDATE sag_case_assignees SET queue_position=%s,version=version+1 WHERE user_id=%s AND sag_id=%s',
|
||
|
|
(position*1024, uid, sid), fetch=False)
|
||
|
|
execute_query('UPDATE planner_queue_versions SET desk_manual_order=%s WHERE user_id=%s', (payload.mode == 'manual', uid), fetch=False)
|
||
|
|
work.bump_queue(uid)
|
||
|
|
work.audit(user, 'desk_order', 'assignee', uid, None, payload.model_dump())
|
||
|
|
return {'version': version+1}
|
||
|
|
|
||
|
|
|
||
|
|
@router.post('/desk/seen')
|
||
|
|
def desk_seen(user: dict = Depends(get_current_user)):
|
||
|
|
uid = int(user['id'])
|
||
|
|
work.require_target(user, uid, True)
|
||
|
|
# Only acknowledge the assignments this user is allowed to see.
|
||
|
|
ids = [r['sag_id'] for r in work.queue_rows(uid, limit=10000, user=user)]
|
||
|
|
execute_query('UPDATE sag_case_assignees SET desk_seen_at=NOW() WHERE user_id=%s AND sag_id=ANY(%s) AND desk_seen_at IS NULL', (uid, ids), fetch=False)
|
||
|
|
return {'ok': True}
|
||
|
|
|
||
|
|
|
||
|
|
@router.get('/desk/recent')
|
||
|
|
def recent_work(user: dict = Depends(get_current_user)):
|
||
|
|
work.require_target(user, int(user['id']))
|
||
|
|
hidden = list(work.hidden_case_ids(user))
|
||
|
|
entries = execute_query('''SELECT t.id,t.sag_id,t.description,t.aktiv_timer,t.paused_at,t.slut_tid,s.titel,
|
||
|
|
COALESCE(t.slut_tid,t.paused_at,t.start_tid,t.created_at) AS last_activity
|
||
|
|
FROM tmodule_times t LEFT JOIN sag_sager s ON s.id=t.sag_id
|
||
|
|
WHERE t.medarbejder_id=%s AND t.status<>'rejected'
|
||
|
|
AND (t.sag_id IS NULL OR (s.deleted_at IS NULL AND NOT(t.sag_id=ANY(%s))))
|
||
|
|
ORDER BY (t.aktiv_timer AND t.slut_tid IS NULL) DESC,last_activity DESC,t.id DESC LIMIT 20''',
|
||
|
|
(user['id'], hidden)) or []
|
||
|
|
return {'entries': entries}
|
||
|
|
|
||
|
|
|
||
|
|
class DeskTimer(BaseModel):
|
||
|
|
description: str = Field(min_length=1, max_length=2000)
|
||
|
|
sag_id: int | None = Field(default=None, gt=0)
|
||
|
|
|
||
|
|
|
||
|
|
@router.post('/desk/timer')
|
||
|
|
async def start_desk_timer(payload: DeskTimer, user: dict = Depends(get_current_user)):
|
||
|
|
work.require_target(user, int(user['id']))
|
||
|
|
if not payload.description.strip():
|
||
|
|
raise HTTPException(400, 'Angiv hvad du arbejder på')
|
||
|
|
if payload.sag_id:
|
||
|
|
require_case(user, payload.sag_id)
|
||
|
|
with atomic_transaction():
|
||
|
|
execute_query('SELECT user_id FROM users WHERE user_id=%s FOR UPDATE', (user['id'],))
|
||
|
|
if execute_query_single('SELECT id FROM tmodule_times WHERE medarbejder_id=%s AND aktiv_timer AND slut_tid IS NULL', (user['id'],)):
|
||
|
|
raise HTTPException(409, 'Du har en aktiv timer. Pause eller stop den først.')
|
||
|
|
if payload.sag_id:
|
||
|
|
from app.timetracking.backend.router import start_live_timer_v1
|
||
|
|
return await start_live_timer_v1(payload={'sag_id': payload.sag_id, 'description': payload.description}, current_user=user)
|
||
|
|
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||
|
|
name = execute_query_single('SELECT COALESCE(full_name,username) AS name FROM users WHERE user_id=%s', (user['id'],))['name']
|
||
|
|
entry = execute_query_single('''INSERT INTO tmodule_times(description,original_hours,worked_date,user_name,status,
|
||
|
|
billable,billing_method,start_tid,faktisk_tid_min,fakturerbar_tid_min,entry_type,kilde,entry_status,
|
||
|
|
medarbejder_id,aktiv_timer,round_block_min,ikke_placeret,work_type)
|
||
|
|
VALUES(%s,0.01,%s,%s,'pending',false,'draft',%s,NULL,0,'manuel','manuel','kladde',%s,true,1,false,'other') RETURNING id''',
|
||
|
|
(payload.description.strip(), datetime.now(TZ).date(), name, now, user['id']))
|
||
|
|
return {'entry': entry}
|
||
|
|
|
||
|
|
|
||
|
|
class DeskTime(BaseModel):
|
||
|
|
request_id: UUID
|
||
|
|
sag_id: int = Field(gt=0)
|
||
|
|
description: str = Field(min_length=1, max_length=2000)
|
||
|
|
starts_at: datetime
|
||
|
|
minutes: int = Field(ge=1, le=1440)
|
||
|
|
|
||
|
|
@model_validator(mode='after')
|
||
|
|
def valid_time(self):
|
||
|
|
if not self.starts_at.tzinfo or not self.description.strip():
|
||
|
|
raise ValueError('Angiv beskrivelse og tidspunkt med tidszone')
|
||
|
|
return self
|
||
|
|
|
||
|
|
|
||
|
|
@router.post('/desk/time')
|
||
|
|
async def register_case_time(payload: DeskTime, user: dict = Depends(get_current_user)):
|
||
|
|
work.require_target(user, int(user['id']))
|
||
|
|
require_case(user, payload.sag_id)
|
||
|
|
from app.timetracking.backend.router import create_manual_time_v1
|
||
|
|
value = payload.model_dump(mode='json')
|
||
|
|
with atomic_transaction():
|
||
|
|
execute_query('SELECT pg_advisory_xact_lock(hashtextextended(%s,0))', (str(payload.request_id),))
|
||
|
|
old = execute_query_single('SELECT * FROM quick_time_batches WHERE request_id=%s', (str(payload.request_id),))
|
||
|
|
if old:
|
||
|
|
if old['actor_id'] != user['id'] or old['payload'] != value:
|
||
|
|
raise HTTPException(409, 'Gemningen tilhører andre oplysninger')
|
||
|
|
return old['result']
|
||
|
|
result = await create_manual_time_v1(payload={'sag_id': payload.sag_id, 'description': payload.description.strip(),
|
||
|
|
'start_tid': payload.starts_at.isoformat(), 'slut_tid': (payload.starts_at+timedelta(minutes=payload.minutes)).isoformat(),
|
||
|
|
'faktisk_tid_min': payload.minutes, 'worked_date': payload.starts_at.astimezone(TZ).date().isoformat()}, current_user=user)
|
||
|
|
result = jsonable_encoder(result)
|
||
|
|
execute_query('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)), fetch=False)
|
||
|
|
return result
|
||
|
|
|
||
|
|
|
||
|
|
class MeetingAttendance(BaseModel):
|
||
|
|
user_id: int = Field(gt=0)
|
||
|
|
starts_at: datetime
|
||
|
|
ends_at: datetime
|
||
|
|
|
||
|
|
@model_validator(mode='after')
|
||
|
|
def valid_times(self):
|
||
|
|
if not self.starts_at.tzinfo or not self.ends_at.tzinfo or not timedelta(0) < self.ends_at-self.starts_at <= timedelta(days=1):
|
||
|
|
raise ValueError('Angiv start og slut med tidszone, højst 24 timer')
|
||
|
|
return self
|
||
|
|
|
||
|
|
|
||
|
|
class MeetingRegistration(BaseModel):
|
||
|
|
version: int = Field(ge=1)
|
||
|
|
attendees: list[MeetingAttendance] = Field(min_length=1, max_length=100)
|
||
|
|
|
||
|
|
|
||
|
|
def meeting_access(event_id, user, lock=False):
|
||
|
|
row = execute_query_single('SELECT * FROM planner_events WHERE id=%s AND deleted_at IS NULL' + (' FOR UPDATE' if lock else ''), (event_id,))
|
||
|
|
if not row or row['kind'] not in {'meeting', 'internal'} or row.get('sag_id'):
|
||
|
|
raise HTTPException(404, 'Internt møde uden sag findes ikke')
|
||
|
|
participants = execute_query('SELECT a.user_id,COALESCE(u.full_name,u.username) AS name FROM planner_allocations a JOIN users u ON u.user_id=a.user_id WHERE a.event_id=%s AND a.deleted_at IS NULL', (event_id,)) or []
|
||
|
|
if not participants or (int(user['id']) not in {p['user_id'] for p in participants} and not all(_can_edit(user,p['user_id']) for p in participants)):
|
||
|
|
raise HTTPException(403, 'Du har ikke adgang til mødet')
|
||
|
|
return row, participants
|
||
|
|
|
||
|
|
|
||
|
|
@router.get('/desk/events/{event_id}/attendance')
|
||
|
|
def meeting_attendance(event_id: int, user: dict = Depends(get_current_user)):
|
||
|
|
from app.timetracking.backend.quick_time import can_register
|
||
|
|
work.require_target(user, int(user['id']))
|
||
|
|
row, people = meeting_access(event_id, user)
|
||
|
|
registered = execute_query('SELECT medarbejder_id FROM tmodule_times WHERE planner_event_id=%s', (event_id,)) or []
|
||
|
|
done = {p['medarbejder_id'] for p in registered}
|
||
|
|
return {'event': row, 'participants': [{**p, 'registered': p['user_id'] in done,
|
||
|
|
'can_register': can_register(user, p['user_id'])} for p in people]}
|
||
|
|
|
||
|
|
|
||
|
|
@router.post('/desk/events/{event_id}/register')
|
||
|
|
def register_meeting(event_id: int, payload: MeetingRegistration, user: dict = Depends(get_current_user)):
|
||
|
|
from app.timetracking.backend.quick_time import can_register
|
||
|
|
work.require_target(user, int(user['id']))
|
||
|
|
if len({a.user_id for a in payload.attendees}) != len(payload.attendees):
|
||
|
|
raise HTTPException(400, 'En deltager må kun forekomme én gang')
|
||
|
|
with atomic_transaction():
|
||
|
|
row, people = meeting_access(event_id, user, lock=True)
|
||
|
|
if row['version'] != payload.version:
|
||
|
|
raise HTTPException(409, 'Mødet er ændret. Genindlæs før registrering.')
|
||
|
|
names = {p['user_id']: p['name'] for p in people}
|
||
|
|
for attendee in payload.attendees:
|
||
|
|
if attendee.user_id not in names or not can_register(user, attendee.user_id):
|
||
|
|
raise HTTPException(403, 'Du må ikke registrere tid for denne deltager')
|
||
|
|
if attendee.ends_at > datetime.now(TZ):
|
||
|
|
raise HTTPException(400, 'Mødetid kan først registreres, når den er afholdt')
|
||
|
|
ids = []
|
||
|
|
for a in payload.attendees:
|
||
|
|
existing = execute_query_single('SELECT id,start_tid,slut_tid FROM tmodule_times WHERE planner_event_id=%s AND medarbejder_id=%s', (event_id, a.user_id))
|
||
|
|
if existing:
|
||
|
|
if work.db_utc(existing['start_tid']) != a.starts_at.astimezone(timezone.utc) or work.db_utc(existing['slut_tid']) != a.ends_at.astimezone(timezone.utc):
|
||
|
|
raise HTTPException(409, 'Deltageren har allerede registreret en anden varighed. Ret den eksisterende tidsregistrering.')
|
||
|
|
ids.append(existing['id'])
|
||
|
|
continue
|
||
|
|
minutes = max(1, round((a.ends_at-a.starts_at).total_seconds()/60))
|
||
|
|
entry = execute_query_single('''INSERT INTO tmodule_times(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,planner_event_id)
|
||
|
|
VALUES(%s,%s,%s,%s,'pending',false,'draft',%s,0,'manuel','manuel','kladde',%s,false,1,false,'other',%s,%s,%s) RETURNING id''',
|
||
|
|
(row['title'],minutes/60,a.starts_at.astimezone(TZ).date(),names[a.user_id],minutes,a.user_id,
|
||
|
|
work.db_utc(a.starts_at).replace(tzinfo=None),work.db_utc(a.ends_at).replace(tzinfo=None),event_id))
|
||
|
|
ids.append(entry['id'])
|
||
|
|
work.audit(user, 'meeting_register', 'event', event_id, None, {'time_entry_ids': ids})
|
||
|
|
return {'time_entry_ids': ids}
|