feat: implement ActivityService and TaskService with business logic for activities and tasks
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user