"""Business logic for tasks linked to deals.""" from __future__ import annotations from collections.abc import Mapping, Sequence from dataclasses import dataclass from datetime import datetime, timezone from typing import Any from app.models.activity import ActivityCreate, ActivityType from app.models.organization_member import OrganizationRole from app.models.task import Task, TaskCreate from app.repositories.activity_repo import ActivityOrganizationMismatchError, ActivityRepository from app.repositories.task_repo import ( TaskAccessError as RepoTaskAccessError, TaskOrganizationMismatchError as RepoTaskOrganizationMismatchError, TaskQueryParams, TaskRepository, ) from app.services.organization_service import OrganizationContext class TaskServiceError(Exception): """Base class for task service errors.""" class TaskDueDateError(TaskServiceError): """Raised when due_date violates temporal constraints.""" class TaskForbiddenError(TaskServiceError): """Raised when the user lacks permissions for an operation.""" class TaskOrganizationError(TaskServiceError): """Raised when a task/deal belongs to another organization.""" class TaskNotFoundError(TaskServiceError): """Raised when task cannot be located in the current organization.""" @dataclass(slots=True) class TaskListFilters: """Filters accepted by the task listing endpoint.""" deal_id: int | None = None only_open: bool = False due_before: datetime | None = None due_after: datetime | None = None @dataclass(slots=True) class TaskUpdateData: """Subset of fields allowed for partial updates.""" title: str | None = None description: str | None = None due_date: datetime | None = None is_done: bool | None = None class TaskService: """Encapsulates task workflows and policy validations.""" def __init__( self, task_repository: TaskRepository, activity_repository: ActivityRepository | None = None, ) -> None: self._task_repository = task_repository self._activity_repository = activity_repository async def list_tasks( self, *, filters: TaskListFilters, context: OrganizationContext, ) -> Sequence[Task]: params = TaskQueryParams( organization_id=context.organization_id, deal_id=filters.deal_id, only_open=filters.only_open, due_before=filters.due_before, due_after=filters.due_after, ) return await self._task_repository.list(params=params) async def get_task(self, task_id: int, *, context: OrganizationContext) -> Task: task = await self._task_repository.get(task_id, organization_id=context.organization_id) if task is None: raise TaskNotFoundError("Task not found") return task async def create_task( self, data: TaskCreate, *, context: OrganizationContext, ) -> Task: self._validate_due_date(data.due_date) try: task = await self._task_repository.create( data, organization_id=context.organization_id, role=context.role, user_id=context.user_id, ) except RepoTaskOrganizationMismatchError as exc: raise TaskOrganizationError("Deal belongs to another organization") from exc except RepoTaskAccessError as exc: raise TaskForbiddenError(str(exc)) from exc await self._log_task_created(task, context=context) return task async def update_task( self, task_id: int, updates: TaskUpdateData, *, context: OrganizationContext, ) -> Task: task = await self.get_task(task_id, context=context) if updates.due_date is not None: self._validate_due_date(updates.due_date) payload = self._build_update_mapping(updates) if not payload: return task try: return await self._task_repository.update( task, payload, role=context.role, user_id=context.user_id, ) except RepoTaskAccessError as exc: raise TaskForbiddenError(str(exc)) from exc async def delete_task(self, task_id: int, *, context: OrganizationContext) -> None: task = await self.get_task(task_id, context=context) self._ensure_member_owns_task(task, context) await self._task_repository.session.delete(task) await self._task_repository.session.flush() def _ensure_member_owns_task(self, task: Task, context: OrganizationContext) -> None: if context.role != OrganizationRole.MEMBER: return owner_id = task.deal.owner_id if task.deal is not None else None if owner_id is None or owner_id != context.user_id: raise TaskForbiddenError("Members can only modify their own tasks") def _validate_due_date(self, due_date: datetime | None) -> None: if due_date is None: return today = datetime.now(timezone.utc).date() value_date = (due_date.astimezone(timezone.utc) if due_date.tzinfo else due_date).date() if value_date < today: raise TaskDueDateError("Task due date cannot be in the past") def _build_update_mapping(self, updates: TaskUpdateData) -> Mapping[str, Any]: payload: dict[str, Any] = {} if updates.title is not None: payload["title"] = updates.title if updates.description is not None: payload["description"] = updates.description if updates.due_date is not None: payload["due_date"] = updates.due_date if updates.is_done is not None: payload["is_done"] = updates.is_done return payload async def _log_task_created(self, task: Task, *, context: OrganizationContext) -> None: if self._activity_repository is None: return data = ActivityCreate( deal_id=task.deal_id, author_id=context.user_id, type=ActivityType.TASK_CREATED, payload={"task_id": task.id, "title": task.title}, ) try: await self._activity_repository.create(data, organization_id=context.organization_id) except ActivityOrganizationMismatchError as exc: # pragma: no cover - defensive raise TaskOrganizationError("Activity target does not belong to organization") from exc