All checks were successful
Deploy Development / deploy (push) Successful in 49s
Test Suite / pytest-backend (push) Successful in 4m57s
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 18s
Test Suite / playwright-smoke (push) Successful in 12s
Bestehende Recurring-Pfade bleiben kompatibel (interval_days-Fallback, Migration backfillt schedules), damit der aktuelle Betrieb nicht bricht und der Stand auf einem zweiten Rechner weitergebaut werden kann. Co-authored-by: Cursor <cursoragent@cursor.com>
263 lines
8.0 KiB
Python
263 lines
8.0 KiB
Python
"""Action ↔ Recurring bridge for unified schedules (ADP v0.1 Slice 2)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any, Optional
|
|
|
|
from psycopg2.extras import RealDictCursor
|
|
|
|
from db import get_connection
|
|
from services.audit import log_audit
|
|
from services.cadence_instance import ensure_open_instance
|
|
from services.recurring import (
|
|
create_recurring_element,
|
|
get_recurring_element,
|
|
update_recurring_element,
|
|
)
|
|
from services.schedule import (
|
|
create_schedule,
|
|
get_schedule,
|
|
initial_due_at,
|
|
update_schedule,
|
|
)
|
|
|
|
|
|
def _get_action_row(*, tenant_id: str, action_id: str) -> dict[str, Any]:
|
|
conn = get_connection()
|
|
try:
|
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT id, tenant_id, initiative_id, project_id, roadmap_item_id, schedule_id,
|
|
title, description
|
|
FROM actions
|
|
WHERE id = %s AND tenant_id = %s
|
|
""",
|
|
(action_id, tenant_id),
|
|
)
|
|
row = cur.fetchone()
|
|
if not row:
|
|
raise ValueError("Arbeitspaket nicht gefunden")
|
|
result = dict(row)
|
|
for key in (
|
|
"id",
|
|
"tenant_id",
|
|
"initiative_id",
|
|
"project_id",
|
|
"roadmap_item_id",
|
|
"schedule_id",
|
|
):
|
|
if result.get(key):
|
|
result[key] = str(result[key])
|
|
return result
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def _find_recurring_for_action(*, tenant_id: str, action_id: str) -> Optional[dict[str, Any]]:
|
|
conn = get_connection()
|
|
try:
|
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT id
|
|
FROM recurring_elements
|
|
WHERE tenant_id = %s AND action_id = %s
|
|
LIMIT 1
|
|
""",
|
|
(tenant_id, action_id),
|
|
)
|
|
row = cur.fetchone()
|
|
if not row:
|
|
return None
|
|
return get_recurring_element(tenant_id=tenant_id, recurring_id=str(row["id"]))
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def attach_action_schedule_fields(
|
|
action: dict[str, Any], *, tenant_id: str
|
|
) -> dict[str, Any]:
|
|
schedule = None
|
|
if action.get("schedule_id"):
|
|
schedule = get_schedule(tenant_id=tenant_id, schedule_id=str(action["schedule_id"]))
|
|
bridge = _find_recurring_for_action(tenant_id=tenant_id, action_id=str(action["id"]))
|
|
copy = dict(action)
|
|
copy["schedule"] = schedule
|
|
copy["recurring_id"] = bridge["id"] if bridge else None
|
|
return copy
|
|
|
|
|
|
def get_action_schedule_bundle(*, tenant_id: str, action_id: str) -> dict[str, Any]:
|
|
action = _get_action_row(tenant_id=tenant_id, action_id=action_id)
|
|
enriched = attach_action_schedule_fields(action, tenant_id=tenant_id)
|
|
bridge = _find_recurring_for_action(tenant_id=tenant_id, action_id=action_id)
|
|
return {
|
|
"action_id": action_id,
|
|
"schedule": enriched.get("schedule"),
|
|
"recurring_id": enriched.get("recurring_id"),
|
|
"recurring": bridge,
|
|
}
|
|
|
|
|
|
def upsert_action_schedule(
|
|
*,
|
|
tenant_id: str,
|
|
action_id: str,
|
|
user_id: Optional[str] = None,
|
|
schedule_kind: str = "interval",
|
|
interval_days: Optional[int] = 1,
|
|
weekday_mask: int = 0,
|
|
pause_until=None,
|
|
) -> dict[str, Any]:
|
|
action = _get_action_row(tenant_id=tenant_id, action_id=action_id)
|
|
existing_schedule_id = action.get("schedule_id")
|
|
if existing_schedule_id:
|
|
schedule = update_schedule(
|
|
tenant_id=tenant_id,
|
|
schedule_id=str(existing_schedule_id),
|
|
schedule_kind=schedule_kind, # type: ignore[arg-type]
|
|
interval_days=interval_days,
|
|
weekday_mask=weekday_mask,
|
|
pause_until=pause_until,
|
|
clear_pause_until=pause_until is None,
|
|
)
|
|
else:
|
|
schedule = create_schedule(
|
|
tenant_id=tenant_id,
|
|
schedule_kind=schedule_kind, # type: ignore[arg-type]
|
|
interval_days=interval_days,
|
|
weekday_mask=weekday_mask,
|
|
pause_until=pause_until,
|
|
)
|
|
|
|
if not schedule:
|
|
raise ValueError("Schedule konnte nicht gespeichert werden")
|
|
|
|
next_due = initial_due_at(schedule=schedule)
|
|
interval = schedule.get("interval_days") or 1
|
|
|
|
conn = get_connection()
|
|
try:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"""
|
|
UPDATE actions
|
|
SET schedule_id = %s, updated_at = NOW()
|
|
WHERE id = %s AND tenant_id = %s
|
|
""",
|
|
(schedule["id"], action_id, tenant_id),
|
|
)
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
|
|
bridge = _find_recurring_for_action(tenant_id=tenant_id, action_id=action_id)
|
|
if bridge:
|
|
update_recurring_element(
|
|
tenant_id=tenant_id,
|
|
recurring_id=bridge["id"],
|
|
user_id=user_id,
|
|
status="active",
|
|
interval_days=int(interval),
|
|
next_due_at=next_due,
|
|
)
|
|
conn = get_connection()
|
|
try:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"""
|
|
UPDATE recurring_elements
|
|
SET schedule_id = %s, updated_at = NOW()
|
|
WHERE id = %s AND tenant_id = %s
|
|
""",
|
|
(schedule["id"], bridge["id"], tenant_id),
|
|
)
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
recurring = get_recurring_element(tenant_id=tenant_id, recurring_id=bridge["id"])
|
|
else:
|
|
recurring = create_recurring_element(
|
|
tenant_id=tenant_id,
|
|
initiative_id=str(action["initiative_id"]),
|
|
title=action["title"],
|
|
description=action.get("description") or "",
|
|
status="active",
|
|
interval_days=int(interval),
|
|
next_due_at=next_due,
|
|
roadmap_item_id=action.get("roadmap_item_id"),
|
|
project_id=action.get("project_id"),
|
|
schedule_id=str(schedule["id"]),
|
|
user_id=user_id,
|
|
)
|
|
conn = get_connection()
|
|
try:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"""
|
|
UPDATE recurring_elements
|
|
SET action_id = %s
|
|
WHERE id = %s AND tenant_id = %s
|
|
""",
|
|
(action_id, recurring["id"], tenant_id),
|
|
)
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
|
|
ensure_open_instance(
|
|
tenant_id=tenant_id,
|
|
recurring_element_id=recurring["id"],
|
|
due_at=next_due,
|
|
)
|
|
|
|
log_audit(
|
|
"action.schedule_set",
|
|
user_id=user_id,
|
|
tenant_id=tenant_id,
|
|
details={"action_id": action_id, "schedule_id": schedule["id"]},
|
|
)
|
|
|
|
return get_action_schedule_bundle(tenant_id=tenant_id, action_id=action_id)
|
|
|
|
|
|
def clear_action_schedule(
|
|
*,
|
|
tenant_id: str,
|
|
action_id: str,
|
|
user_id: Optional[str] = None,
|
|
) -> dict[str, Any]:
|
|
bundle = get_action_schedule_bundle(tenant_id=tenant_id, action_id=action_id)
|
|
bridge = bundle.get("recurring")
|
|
if bridge:
|
|
update_recurring_element(
|
|
tenant_id=tenant_id,
|
|
recurring_id=bridge["id"],
|
|
user_id=user_id,
|
|
status="ended",
|
|
)
|
|
|
|
conn = get_connection()
|
|
try:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"""
|
|
UPDATE actions
|
|
SET schedule_id = NULL, updated_at = NOW()
|
|
WHERE id = %s AND tenant_id = %s
|
|
""",
|
|
(action_id, tenant_id),
|
|
)
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
|
|
log_audit(
|
|
"action.schedule_cleared",
|
|
user_id=user_id,
|
|
tenant_id=tenant_id,
|
|
details={"action_id": action_id},
|
|
)
|
|
return {"action_id": action_id, "schedule": None, "recurring_id": None}
|