"""Tasks: Native PM Construction Platform Tasks & Activities. Supports: - Hierarchical tasks (Project -> Phase -> Activity -> Subtask) via self-referencing `parent_id`. - Dates, duration, status, priorities, progress. - Full dependency relations & scheduling. """ from __future__ import annotations import uuid from datetime import date, timedelta from typing import Any from fastapi import APIRouter, Depends, Query from pydantic import BaseModel, Field from sqlalchemy import func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.authz import AuthContext, Perm, require_permission from app.db import get_db from app.errors import NotFoundError, ValidationError from app.models import Project, ProjectMember, Task, TaskPriority, TaskProgressLog, TaskRelation, TaskStatus from app.qc_service import build_state, definition_dict, load_definitions, state_dict from app.routers.qc import _defect_dict, defects_for_task from app.serializers import _iso, native_task_dict, task_relation_dict from app.utils.date_service import date_service from app.scheduling_service import sync_project_lifecycle_dates router = APIRouter(prefix="/tasks", tags=["tasks"]) view_tasks = require_permission(Perm.tasks_view) manage_tasks = require_permission(Perm.tasks_create) update_tasks = require_permission(Perm.tasks_update) progress_tasks = require_permission(Perm.tasks_progress) class HumanResourceItem(BaseModel): role: str = Field(..., min_length=1) count: int = Field(1, ge=1) class MaterialItem(BaseModel): name: str = Field(..., min_length=1) unit: str = Field("", max_length=64) specifications: list[str] = Field(default_factory=list) count: float = Field(1.0, ge=0) class TaskCreateSchema(BaseModel): project_id: str parent_id: str | None = None title: str = Field(..., min_length=1, max_length=512) description: str = "" status: str = TaskStatus.TODO.value priority: str = TaskPriority.MEDIUM.value assignee_id: str | None = None planned_start: str | None = None planned_end: str | None = None duration: int | None = None human_resources: list[HumanResourceItem] = Field(default_factory=list) materials: list[MaterialItem] = Field(default_factory=list) class TaskUpdateSchema(BaseModel): title: str | None = None description: str | None = None status: str | None = None priority: str | None = None assignee: str | None = None assignee_id: str | None = None parent_id: str | None = None planned_start: str | None = None planned_end: str | None = None duration: int | None = None duration_days: int | None = None human_resources: list[HumanResourceItem] | None = None materials: list[MaterialItem] | None = None class HumanResourceConsumption(BaseModel): role: str = "" planned_count: int | None = None actual_count: int = 1 note: str = "" class MaterialConsumption(BaseModel): name: str = "" unit: str = "" amount: float = 0 allocated_amount: float | None = None previous_consumed: float | None = None total_consumed: float | None = None remaining_amount: float | None = None is_over_allocated: bool = False over_allocated_amount: float = 0.0 note: str = "" class TaskProgressSchema(BaseModel): percentage: int = Field(..., ge=0, le=100) human_resources_used: list[HumanResourceConsumption] = Field(default_factory=list) materials_used: list[MaterialConsumption] = Field(default_factory=list) description: str = "" class TaskDatesSchema(BaseModel): start_date: str | None = None due_date: str | None = None async def _resolve_task(db: AsyncSession, ctx: AuthContext, task_id_or_op: str | int | uuid.UUID) -> Task: """Find a native task by UUID, enforcing the caller's scope.""" query = ( select(Task) .options( selectinload(Task.project), selectinload(Task.assignee), selectinload(Task.children), selectinload(Task.parent), selectinload(Task.progress_logs), ) ) try: val_uuid = uuid.UUID(str(task_id_or_op)) stmt = query.where(Task.id == val_uuid) except ValueError: raise NotFoundError("تسک یافت نشد") task = (await db.execute(stmt)).scalar_one_or_none() if not task: raise NotFoundError("تسک یافت نشد") ctx.ensure_native_project(task.project_id) return task async def _validate_task_dates_and_hierarchy( db: AsyncSession, task_id: uuid.UUID | None, planned_start: date | None, planned_end: date | None, duration: int | None, parent_id: uuid.UUID | None, actual_start: date | None = None, actual_end: date | None = None, ) -> None: # 0. اجباری بودن تاریخ شروع و تاریخ پایان if not planned_start: raise ValidationError("تاریخ شروع وظیفه الزامی است", field="planned_start") if not planned_end: raise ValidationError("تاریخ پایان وظیفه الزامی است", field="planned_end") # 1. Start date must not be later than end date if planned_start and planned_end and planned_start > planned_end: raise ValidationError( f"تاریخ شروع یک وظیفه ({date_service.to_jalali(planned_start)}) نمی‌تواند بعد از تاریخ اتمام آن ({date_service.to_jalali(planned_end)}) باشد", field="planned_start", ) if actual_start and actual_end and actual_start > actual_end: raise ValidationError( f"تاریخ شروع واقعی ({date_service.to_jalali(actual_start)}) نمی‌تواند بعد از تاریخ پایان واقعی ({date_service.to_jalali(actual_end)}) باشد", field="actual_start", ) # 2. Parent vs Child constraint: Parent must envelop child if parent_id: parent = await db.scalar(select(Task).where(Task.id == parent_id)) if parent: if parent.planned_start and planned_start and planned_start < parent.planned_start: raise ValidationError( f"تاریخ شروع وظیفه فرزند ({date_service.to_jalali(planned_start)}) نمی‌تواند قبل از تاریخ شروع وظیفه والد ({date_service.to_jalali(parent.planned_start)}) باشد", field="planned_start", ) if parent.planned_end and planned_end and planned_end > parent.planned_end: raise ValidationError( f"تاریخ پایان وظیفه فرزند ({date_service.to_jalali(planned_end)}) نمی‌تواند بعد از تاریخ پایان وظیفه والد ({date_service.to_jalali(parent.planned_end)}) باشد", field="planned_end", ) if parent.duration and duration and duration > parent.duration: raise ValidationError( f"مدت زمان وظیفه والد ({parent.duration} روز) باید بیشتر یا مساوی مدت زمان وظیفه فرزند ({duration} روز) باشد", field="duration", ) # 3. If this task is a parent, its time span/duration must be >= all its children if task_id: children_stmt = select(Task).where(Task.parent_id == task_id) children = (await db.execute(children_stmt)).scalars().all() for c in children: if planned_start and c.planned_start and planned_start > c.planned_start: raise ValidationError( f"تاریخ شروع وظیفه والد ({date_service.to_jalali(planned_start)}) نمی‌تواند بعد از تاریخ شروع وظیفه فرزند «{c.title}» ({date_service.to_jalali(c.planned_start)}) باشد", field="planned_start", ) if planned_end and c.planned_end and planned_end < c.planned_end: raise ValidationError( f"تاریخ پایان وظیفه والد ({date_service.to_jalali(planned_end)}) نمی‌تواند قبل از تاریخ پایان وظیفه فرزند «{c.title}» ({date_service.to_jalali(c.planned_end)}) باشد", field="planned_end", ) if duration and c.duration and duration < c.duration: raise ValidationError( f"مدت زمان وظیفه والد ({duration} روز) باید بیشتر یا مساوی مدت زمان وظیفه فرزند «{c.title}» ({c.duration} روز) باشد", field="duration", ) @router.get("") async def list_tasks( project_id: str | None = None, parent_id: str | None = None, search: str | None = None, status: str | None = None, qc_status: str | None = None, page: int = Query(1, ge=1), page_size: int = Query(300, ge=1, le=1000), ctx: AuthContext = Depends(view_tasks), db: AsyncSession = Depends(get_db), ): """List native tasks with flexible filtering.""" stmt = ( select(Task) .options( selectinload(Task.project), selectinload(Task.assignee), selectinload(Task.children), ) .order_by(Task.created_at.asc()) ) # Filter by project if project_id is not None: try: p_uuid = uuid.UUID(project_id) stmt = stmt.where(Task.project_id == p_uuid) except ValueError: pass # Project scoping for non-managers — native projects are keyed by UUID stmt = ctx.scope_native(stmt, Task.project_id) if parent_id is not None: if parent_id.lower() in ("null", "root", "none"): stmt = stmt.where(Task.parent_id.is_(None)) else: stmt = stmt.where(Task.parent_id == uuid.UUID(parent_id)) if search: stmt = stmt.where(Task.title.ilike(f"%{search.strip()}%")) if status: stmt = stmt.where(Task.status == status.upper()) if qc_status: stmt = stmt.where(Task.qc_status == qc_status) total = (await db.scalar(select(func.count()).select_from(stmt.subquery()))) or 0 tasks = (await db.execute(stmt.offset((page - 1) * page_size).limit(page_size))).scalars().all() # Count children in memory from loaded tasks to prevent lazy-load I/O child_counts: dict[uuid.UUID, int] = {} for t in tasks: if t.parent_id: child_counts[t.parent_id] = child_counts.get(t.parent_id, 0) + 1 results = [] for t in tasks: p_name = t.project.name if t.project else None c_count = child_counts.get(t.id, 0) d = native_task_dict(t, project_name=p_name, children_count=c_count) d["subject"] = t.title d["percentage_done"] = t.progress d["start_date"] = _iso(t.planned_start) d["due_date"] = _iso(t.planned_end) d["is_closed"] = t.status == TaskStatus.COMPLETED.value results.append(d) return {"data": results, "meta": {"total": total}} @router.post("") async def create_task( payload: TaskCreateSchema, ctx: AuthContext = Depends(manage_tasks), db: AsyncSession = Depends(get_db), ): """Create a new native task / subtask.""" # Resolve project try: p_uuid = uuid.UUID(payload.project_id) project = await db.scalar(select(Project).where(Project.id == p_uuid)) except ValueError: project = None if not project: raise NotFoundError("پروژه یافت نشد") # Resolve parent task if specified parent_uuid = None if payload.parent_id: parent_task = await _resolve_task(db, ctx, payload.parent_id) parent_uuid = parent_task.id p_start = date_service.parse(payload.planned_start) p_end = date_service.parse(payload.planned_end) duration = payload.duration or 0 if p_start and p_end: duration = date_service.calculate_duration(p_start, p_end) elif p_start and duration: p_end = date_service.compute_end_date(p_start, duration) elif p_end and duration: p_start = p_end - timedelta(days=duration) await _validate_task_dates_and_hierarchy( db=db, task_id=None, planned_start=p_start, planned_end=p_end, duration=duration, parent_id=parent_uuid, ) assignee_uuid = None assignee_name = None if payload.assignee_id: try: assignee_uuid = uuid.UUID(payload.assignee_id) except ValueError: assignee_name = payload.assignee_id task = Task( project_id=project.id, parent_id=parent_uuid, title=payload.title.strip(), description=payload.description.strip(), status=payload.status.upper(), priority=payload.priority.lower(), assignee_id=assignee_uuid, assignee_name=assignee_name, planned_start=p_start, planned_end=p_end, duration=duration, progress=0, human_resources=[hr.model_dump() for hr in payload.human_resources], materials=[m.model_dump() for m in payload.materials], ) db.add(task) await db.flush() await sync_project_lifecycle_dates(db, project.id) await db.commit() await db.refresh(task) d = native_task_dict(task, project_name=project.name) d["subject"] = task.title d["percentage_done"] = task.progress return {"data": d} @router.get("/{id}") async def get_task_detail( id: str, ctx: AuthContext = Depends(view_tasks), db: AsyncSession = Depends(get_db), ): """Get full task details, including hierarchy, QC state, and defects.""" task = await _resolve_task(db, ctx, id) # QC definitions and execution state definitions = await load_definitions(db, task.id) state = build_state(definitions) # Defects defects = await defects_for_task(db, task.id) children_data = [] for c in task.children: c_dict = native_task_dict(c, project_name=task.project.name if task.project else None) c_dict["subject"] = c.title c_dict["percentage_done"] = c.progress children_data.append(c_dict) data = native_task_dict( task, project_name=task.project.name if task.project else None, children_count=len(task.children), ) data["subject"] = task.title data["percentage_done"] = task.progress data["start_date"] = _iso(task.planned_start) data["due_date"] = _iso(task.planned_end) data["start_date_jalali"] = date_service.to_jalali(task.planned_start) data["due_date_jalali"] = date_service.to_jalali(task.planned_end) data["is_closed"] = task.status == TaskStatus.COMPLETED.value # Load task relations (predecessors and successors) rel_stmt = ( select(TaskRelation) .options(selectinload(TaskRelation.from_task), selectinload(TaskRelation.to_task)) .where( or_( TaskRelation.from_task_id == task.id, TaskRelation.to_task_id == task.id, ) ) ) task_rels = (await db.execute(rel_stmt)).scalars().all() incoming = [] outgoing = [] for r in task_rels: rd = task_relation_dict(r) if r.to_task_id == task.id: # incoming: predecessor (from_task) -> current task incoming.append({ **rd, "id": str(r.id), "op_wp_id": str(r.from_task_id), "task_id": str(r.from_task_id), "subject": r.from_task.title if r.from_task else "", "relation_type": r.relation_type, "lag_days": r.lag_days, }) if r.from_task_id == task.id: # outgoing: current task -> successor (to_task) outgoing.append({ **rd, "id": str(r.id), "op_wp_id": str(r.to_task_id), "task_id": str(r.to_task_id), "subject": r.to_task.title if r.to_task else "", "relation_type": r.relation_type, "lag_days": r.lag_days, }) data["relations"] = {"blocks": outgoing, "blocked_by": incoming} data["all_relations"] = [task_relation_dict(r) for r in task_rels] data["children"] = children_data data["parent"] = ( {"id": str(task.parent.id), "title": task.parent.title} if task.parent else None ) data["qc"] = state_dict(state, progress=task.progress) data["qc_definitions"] = [definition_dict(d, state) for d in definitions] data["defects"] = [_defect_dict(d) for d in defects] data["open_defects"] = sum(1 for d in defects if d.status != "resolved") return {"data": data} @router.patch("/{id}") async def update_task( id: str, payload: TaskUpdateSchema, ctx: AuthContext = Depends(update_tasks), db: AsyncSession = Depends(get_db), ): """Update task title, description, status, priority, or dates.""" task = await _resolve_task(db, ctx, id) if payload.title is not None: task.title = payload.title.strip() if payload.description is not None: task.description = payload.description.strip() if payload.status is not None: task.status = payload.status.upper() if payload.priority is not None: task.priority = payload.priority.lower() if payload.assignee is not None: task.assignee_name = payload.assignee.strip() or None if payload.assignee_id is not None: try: task.assignee_id = uuid.UUID(payload.assignee_id) except ValueError: task.assignee_name = payload.assignee_id if payload.parent_id is not None: clean_p = payload.parent_id.strip().lower() if not clean_p or clean_p in ("none", "null", "root"): task.parent_id = None else: parent = await _resolve_task(db, ctx, payload.parent_id) if parent.id == task.id: raise ValidationError("تسک نمی‌تواند والد خودش باشد") # Prevent cycle: ensure task is not in ancestors of parent check = parent while check: if check.id == task.id: raise ValidationError("نمی‌توان یک زیرتسک را به عنوان والد انتخاب کرد (وابستگی چرخه‌ای)") if check.parent_id: check = await db.scalar(select(Task).where(Task.id == check.parent_id)) else: break task.parent_id = parent.id p_start = task.planned_start p_end = task.planned_end a_start = task.actual_start a_end = task.actual_end dur = task.duration if payload.planned_start is not None: p_start = date_service.parse(payload.planned_start) if payload.planned_end is not None: p_end = date_service.parse(payload.planned_end) dur_input = payload.duration if payload.duration is not None else payload.duration_days if dur_input is not None: dur = max(0, dur_input) if p_start: p_end = date_service.compute_end_date(p_start, dur) elif p_start and p_end: dur = date_service.calculate_duration(p_start, p_end) await _validate_task_dates_and_hierarchy( db=db, task_id=task.id, planned_start=p_start, planned_end=p_end, duration=dur, parent_id=task.parent_id, actual_start=a_start, actual_end=a_end, ) task.planned_start = p_start task.planned_end = p_end task.duration = dur if payload.human_resources is not None: task.human_resources = [hr.model_dump() for hr in payload.human_resources] if payload.materials is not None: task.materials = [m.model_dump() for m in payload.materials] await sync_project_lifecycle_dates(db, task.project_id) await db.commit() task = await _resolve_task(db, ctx, task.id) return {"data": native_task_dict(task, project_name=task.project.name if task.project else None)} @router.patch("/{id}/progress") async def update_task_progress( id: str, payload: TaskProgressSchema, ctx: AuthContext = Depends(progress_tasks), db: AsyncSession = Depends(get_db), ): """Update task progress (0-100%) with strict server-side QC validation rules.""" task = await _resolve_task(db, ctx, id) percentage = payload.percentage # 1. Evaluate QC business rules definitions = await load_definitions(db, task.id) state = build_state(definitions) # Server-side Invariant Rule 1: pre-QC must be satisfied if progress > 0 if percentage > 0 and not state.can_start: unresolved = state.pre.required_unresolved msg = ( f"بازرسی پیش از اجرا تأیید نشده است ({unresolved} سؤال باقی‌مانده)" if unresolved > 0 else "بازرسی پیش از اجرا مردود شده است و نیاز به رفع و بازرسی مجدد دارد" ) raise ValidationError(msg, field="percentage") # Server-side Invariant Rule 2: all required QC must be satisfied if progress >= 100 if percentage >= 100 and not state.can_complete: unresolved = state.all_.required_unresolved failed = state.all_.failed raise ValidationError( f"تا زمانی که همه بازرسی‌های الزامی تأیید نشوند پیشرفت ۱۰۰٪ مجاز نیست ({unresolved} سؤال باقی‌مانده، {failed} مورد مردود)", field="percentage", ) # 2. Update task progress and lifecycle dates progress_before = task.progress or 0 task.progress = percentage if percentage > 0: if not task.actual_start: task.actual_start = date_service.today() if task.status == TaskStatus.TODO.value: task.status = TaskStatus.IN_PROGRESS.value else: # percentage == 0: reset actual dates task.actual_start = None task.actual_end = None task.status = TaskStatus.TODO.value if percentage >= 100: task.actual_end = date_service.today() task.status = TaskStatus.COMPLETED.value elif 0 < percentage < 100: task.actual_end = None if task.status == TaskStatus.COMPLETED.value: task.status = TaskStatus.IN_PROGRESS.value # 3. Log consumption of human resources and materials hr_list = [h.model_dump() for h in payload.human_resources_used if h.role.strip()] # Build allocation lookup from task.materials alloc_map = {} for item in (task.materials or []): if isinstance(item, dict): m_name = (item.get("name") or "").strip().lower() if m_name: alloc_map[m_name] = item # Calculate previously consumed amounts from prior logs past_consumed_map: dict[str, float] = {} for plog in (task.progress_logs or []): for pmat in (plog.materials_used or []): if isinstance(pmat, dict): p_name = (pmat.get("name") or "").strip().lower() if p_name: past_consumed_map[p_name] = past_consumed_map.get(p_name, 0.0) + float(pmat.get("amount") or 0.0) mat_list = [] for m in payload.materials_used: name = m.name.strip() if not name: continue key = name.lower() matched = alloc_map.get(key) # Base allocation: explicitly passed or from task.materials if m.allocated_amount is not None: alloc_amt = float(m.allocated_amount) elif matched: alloc_amt = float(matched.get("count") or 0.0) else: alloc_amt = 0.0 unit = m.unit.strip() or (matched.get("unit", "") if matched else "") amount = float(m.amount or 0.0) prev_consumed = past_consumed_map.get(key, 0.0) tot_consumed = prev_consumed + amount rem_amount = max(0.0, alloc_amt - tot_consumed) if alloc_amt > 0 else 0.0 is_over = (tot_consumed > alloc_amt) if alloc_amt > 0 else (tot_consumed > 0) over_amt = max(0.0, tot_consumed - alloc_amt) if alloc_amt > 0 else tot_consumed mat_list.append({ "name": name, "unit": unit, "amount": amount, "allocated_amount": alloc_amt, "previous_consumed": prev_consumed, "total_consumed": tot_consumed, "remaining_amount": rem_amount, "is_over_allocated": is_over, "over_allocated_amount": over_amt, "note": m.note.strip() if m.note else "", }) log = TaskProgressLog( task_id=task.id, progress_before=progress_before, progress_after=percentage, human_resources_used=hr_list, materials_used=mat_list, description=payload.description.strip(), logged_by=ctx.user.display_name or ctx.user.username, ) db.add(log) await sync_project_lifecycle_dates(db, task.project_id) await db.commit() await db.refresh(task) return { "data": { "id": str(task.id), "progress": task.progress, "status": task.status, "qc_status": task.qc_status, "actual_start": _iso(task.actual_start), "actual_end": _iso(task.actual_end), "progress_log": { "id": str(log.id), "progress_before": log.progress_before, "progress_after": log.progress_after, "human_resources_used": log.human_resources_used, "materials_used": log.materials_used, "description": log.description, "logged_by": log.logged_by, "created_at": _iso(log.created_at), }, } } @router.get("/{id}/material-audit") async def get_task_material_audit( id: str, ctx: AuthContext = Depends(view_tasks), db: AsyncSession = Depends(get_db), ): """استعلام مقایسه‌ای مصالح تخصیص‌یافته در برابر کل مصارف واقعی و گزارش اضافه مصرف.""" task = await _resolve_task(db, ctx, id) alloc_map = {} for m in (task.materials or []): if isinstance(m, dict): name = (m.get("name") or "").strip() if name: alloc_map[name.lower()] = { "name": name, "unit": m.get("unit") or "", "allocated_amount": float(m.get("count") or 0.0), "specifications": m.get("specifications") or [], } consumption_map: dict[str, dict] = {} for log in (task.progress_logs or []): for mu in (log.materials_used or []): if isinstance(mu, dict): mname = (mu.get("name") or "").strip() if not mname: continue key = mname.lower() m_amt = float(mu.get("amount") or 0.0) m_unit = mu.get("unit") or "" if key not in consumption_map: consumption_map[key] = { "name": mname, "unit": m_unit, "total_consumed": 0.0, } consumption_map[key]["total_consumed"] += m_amt all_keys = set(alloc_map.keys()) | set(consumption_map.keys()) audit_items = [] total_over_allocated_count = 0 for key in sorted(all_keys): alloc = alloc_map.get(key) consumed = consumption_map.get(key) name = alloc["name"] if alloc else consumed["name"] unit = alloc["unit"] if alloc else consumed["unit"] alloc_amt = alloc["allocated_amount"] if alloc else 0.0 tot_cons = consumed["total_consumed"] if consumed else 0.0 rem_amt = max(0.0, alloc_amt - tot_cons) if alloc_amt > 0 else 0.0 is_over = (tot_cons > alloc_amt) if alloc_amt > 0 else (tot_cons > 0) over_amt = max(0.0, tot_cons - alloc_amt) if alloc_amt > 0 else tot_cons if is_over: total_over_allocated_count += 1 audit_items.append({ "name": name, "unit": unit, "allocated_amount": alloc_amt, "total_consumed": tot_cons, "remaining_amount": rem_amt, "is_over_allocated": is_over, "over_allocated_amount": over_amt, "specifications": alloc["specifications"] if alloc else [], }) return { "data": { "task_id": str(task.id), "task_title": task.title, "has_over_allocation": total_over_allocated_count > 0, "over_allocated_items_count": total_over_allocated_count, "items": audit_items, } } @router.patch("/{id}/dates") async def update_task_dates( id: str, payload: TaskDatesSchema, ctx: AuthContext = Depends(update_tasks), db: AsyncSession = Depends(get_db), ): """Update task dates (dual Jalali/ISO compatible).""" task = await _resolve_task(db, ctx, id) p_start = task.planned_start p_end = task.planned_end if payload.start_date is not None: p_start = date_service.parse(payload.start_date) if payload.due_date is not None: p_end = date_service.parse(payload.due_date) dur = task.duration if p_start and p_end: dur = date_service.calculate_duration(p_start, p_end) await _validate_task_dates_and_hierarchy( db=db, task_id=task.id, planned_start=p_start, planned_end=p_end, duration=dur, parent_id=task.parent_id, actual_start=task.actual_start, actual_end=task.actual_end, ) task.planned_start = p_start task.planned_end = p_end task.duration = dur await sync_project_lifecycle_dates(db, task.project_id) await db.commit() task = await _resolve_task(db, ctx, task.id) return { "data": { "id": str(task.id), "start_date": _iso(task.planned_start), "due_date": _iso(task.planned_end), "start_date_jalali": date_service.to_jalali(task.planned_start), "due_date_jalali": date_service.to_jalali(task.planned_end), "duration": task.duration, } } @router.delete("/{id}") async def delete_task( id: str, ctx: AuthContext = Depends(require_permission(Perm.tasks_delete)), db: AsyncSession = Depends(get_db), ): """Delete a task and its children.""" task = await _resolve_task(db, ctx, id) project_id = task.project_id await db.delete(task) await db.flush() await sync_project_lifecycle_dates(db, project_id) await db.commit() return {"data": {"deleted": True, "id": id}}