105 lines
3.3 KiB
Python
105 lines
3.3 KiB
Python
"""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
|