feat: implement ActivityService and TaskService with business logic for activities and tasks
This commit is contained in:
parent
0727c4737b
commit
0ecf1295d8
|
|
@ -10,10 +10,13 @@ from app.core.config import settings
|
|||
from app.core.database import get_session
|
||||
from app.core.security import jwt_service, password_hasher
|
||||
from app.models.user import User
|
||||
from app.repositories.activity_repo import ActivityRepository
|
||||
from app.repositories.deal_repo import DealRepository
|
||||
from app.repositories.org_repo import OrganizationRepository
|
||||
from app.repositories.task_repo import TaskRepository
|
||||
from app.repositories.user_repo import UserRepository
|
||||
from app.services.auth_service import AuthService
|
||||
from app.services.activity_service import ActivityService
|
||||
from app.services.deal_service import DealService
|
||||
from app.services.organization_service import (
|
||||
OrganizationAccessDeniedError,
|
||||
|
|
@ -21,6 +24,7 @@ from app.services.organization_service import (
|
|||
OrganizationContextMissingError,
|
||||
OrganizationService,
|
||||
)
|
||||
from app.services.task_service import TaskService
|
||||
from app.services.user_service import UserService
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"{settings.api_v1_prefix}/auth/token")
|
||||
|
|
@ -44,6 +48,14 @@ def get_deal_repository(session: AsyncSession = Depends(get_db_session)) -> Deal
|
|||
return DealRepository(session=session)
|
||||
|
||||
|
||||
def get_task_repository(session: AsyncSession = Depends(get_db_session)) -> TaskRepository:
|
||||
return TaskRepository(session=session)
|
||||
|
||||
|
||||
def get_activity_repository(session: AsyncSession = Depends(get_db_session)) -> ActivityRepository:
|
||||
return ActivityRepository(session=session)
|
||||
|
||||
|
||||
def get_deal_service(repo: DealRepository = Depends(get_deal_repository)) -> DealService:
|
||||
return DealService(repository=repo)
|
||||
|
||||
|
|
@ -68,6 +80,19 @@ def get_organization_service(
|
|||
return OrganizationService(repository=repo)
|
||||
|
||||
|
||||
def get_activity_service(
|
||||
repo: ActivityRepository = Depends(get_activity_repository),
|
||||
) -> ActivityService:
|
||||
return ActivityService(repository=repo)
|
||||
|
||||
|
||||
def get_task_service(
|
||||
task_repo: TaskRepository = Depends(get_task_repository),
|
||||
activity_repo: ActivityRepository = Depends(get_activity_repository),
|
||||
) -> TaskService:
|
||||
return TaskService(task_repository=task_repo, activity_repository=activity_repo)
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
token: str = Depends(oauth2_scheme),
|
||||
repo: UserRepository = Depends(get_user_repository),
|
||||
|
|
|
|||
|
|
@ -1,9 +1,26 @@
|
|||
"""Business logic services."""
|
||||
from .activity_service import ( # noqa: F401
|
||||
ActivityForbiddenError,
|
||||
ActivityListFilters,
|
||||
ActivityService,
|
||||
ActivityServiceError,
|
||||
ActivityValidationError,
|
||||
)
|
||||
from .auth_service import AuthService # noqa: F401
|
||||
from .organization_service import ( # noqa: F401
|
||||
OrganizationAccessDeniedError,
|
||||
OrganizationContext,
|
||||
OrganizationContextMissingError,
|
||||
OrganizationService,
|
||||
)
|
||||
from .user_service import UserService # noqa: F401
|
||||
from .auth_service import AuthService # noqa: F401
|
||||
from .task_service import ( # noqa: F401
|
||||
TaskDueDateError,
|
||||
TaskForbiddenError,
|
||||
TaskListFilters,
|
||||
TaskNotFoundError,
|
||||
TaskOrganizationError,
|
||||
TaskService,
|
||||
TaskServiceError,
|
||||
TaskUpdateData,
|
||||
)
|
||||
from .user_service import UserService # noqa: F401
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
"""Business logic for timeline activities."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from app.models.activity import Activity, ActivityCreate, ActivityType
|
||||
from app.models.deal import Deal
|
||||
from app.repositories.activity_repo import (
|
||||
ActivityOrganizationMismatchError,
|
||||
ActivityQueryParams,
|
||||
ActivityRepository,
|
||||
)
|
||||
from app.services.organization_service import OrganizationContext
|
||||
|
||||
|
||||
class ActivityServiceError(Exception):
|
||||
"""Base class for activity service errors."""
|
||||
|
||||
|
||||
class ActivityValidationError(ActivityServiceError):
|
||||
"""Raised when payload does not satisfy business constraints."""
|
||||
|
||||
|
||||
class ActivityForbiddenError(ActivityServiceError):
|
||||
"""Raised when a user accesses activities from another organization."""
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ActivityListFilters:
|
||||
"""Filtering helpers for listing activities."""
|
||||
|
||||
deal_id: int
|
||||
limit: int | None = None
|
||||
offset: int = 0
|
||||
|
||||
|
||||
class ActivityService:
|
||||
"""Encapsulates timeline-specific workflows."""
|
||||
|
||||
def __init__(self, repository: ActivityRepository) -> None:
|
||||
self._repository = repository
|
||||
|
||||
async def list_activities(
|
||||
self,
|
||||
*,
|
||||
filters: ActivityListFilters,
|
||||
context: OrganizationContext,
|
||||
) -> Sequence[Activity]:
|
||||
await self._ensure_deal_in_context(filters.deal_id, context)
|
||||
params = ActivityQueryParams(
|
||||
organization_id=context.organization_id,
|
||||
deal_id=filters.deal_id,
|
||||
limit=filters.limit,
|
||||
offset=max(filters.offset, 0),
|
||||
)
|
||||
return await self._repository.list(params=params)
|
||||
|
||||
async def add_comment(
|
||||
self,
|
||||
*,
|
||||
deal_id: int,
|
||||
author_id: int,
|
||||
text: str,
|
||||
context: OrganizationContext,
|
||||
) -> Activity:
|
||||
normalized = text.strip()
|
||||
if not normalized:
|
||||
raise ActivityValidationError("Comment text cannot be empty")
|
||||
return await self.record_activity(
|
||||
deal_id=deal_id,
|
||||
activity_type=ActivityType.COMMENT,
|
||||
payload={"text": normalized},
|
||||
author_id=author_id,
|
||||
context=context,
|
||||
)
|
||||
|
||||
async def record_activity(
|
||||
self,
|
||||
*,
|
||||
deal_id: int,
|
||||
activity_type: ActivityType,
|
||||
context: OrganizationContext,
|
||||
payload: dict[str, Any] | None = None,
|
||||
author_id: int | None = None,
|
||||
) -> Activity:
|
||||
await self._ensure_deal_in_context(deal_id, context)
|
||||
data = ActivityCreate(
|
||||
deal_id=deal_id,
|
||||
author_id=author_id,
|
||||
type=activity_type,
|
||||
payload=payload or {},
|
||||
)
|
||||
try:
|
||||
return await self._repository.create(data, organization_id=context.organization_id)
|
||||
except ActivityOrganizationMismatchError as exc: # pragma: no cover - defensive
|
||||
raise ActivityForbiddenError("Deal belongs to another organization") from exc
|
||||
|
||||
async def _ensure_deal_in_context(self, deal_id: int, context: OrganizationContext) -> Deal:
|
||||
deal = await self._repository.session.get(Deal, deal_id)
|
||||
if deal is None or deal.organization_id != context.organization_id:
|
||||
raise ActivityForbiddenError("Deal not found in current organization")
|
||||
return deal
|
||||
|
|
@ -0,0 +1,186 @@
|
|||
"""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")
|
||||
Loading…
Reference in New Issue