187 lines
6.4 KiB
Python
187 lines
6.4 KiB
Python
"""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 ActivityRepository, ActivityOrganizationMismatchError
|
|
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: # pragma: no cover - defensive
|
|
raise TaskOrganizationError("Activity target does not belong to organization")
|