From 0727c4737bb7019f2ad50d633a26ae795b5d7559 Mon Sep 17 00:00:00 2001 From: Artem Kashaev Date: Thu, 27 Nov 2025 16:32:06 +0500 Subject: [PATCH] feat: add ActivityRepository and TaskRepository with CRUD operations for activities and tasks --- app/repositories/activity_repo.py | 68 +++++++++++++++++ app/repositories/task_repo.py | 123 ++++++++++++++++++++++++++++++ 2 files changed, 191 insertions(+) create mode 100644 app/repositories/activity_repo.py create mode 100644 app/repositories/task_repo.py diff --git a/app/repositories/activity_repo.py b/app/repositories/activity_repo.py new file mode 100644 index 0000000..4f4e0ef --- /dev/null +++ b/app/repositories/activity_repo.py @@ -0,0 +1,68 @@ +"""Repository helpers for deal activities.""" +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass + +from sqlalchemy import Select, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.activity import Activity, ActivityCreate +from app.models.deal import Deal + + +class ActivityOrganizationMismatchError(Exception): + """Raised when a deal/activity pair targets another organization.""" + + +@dataclass(slots=True) +class ActivityQueryParams: + """Filtering options for fetching activities.""" + + organization_id: int + deal_id: int + limit: int | None = None + offset: int = 0 + + +class ActivityRepository: + """Provides CRUD helpers for Activity model.""" + + def __init__(self, session: AsyncSession) -> None: + self._session = session + + @property + def session(self) -> AsyncSession: + return self._session + + async def list(self, *, params: ActivityQueryParams) -> Sequence[Activity]: + stmt = ( + select(Activity) + .join(Deal, Deal.id == Activity.deal_id) + .where(Activity.deal_id == params.deal_id, Deal.organization_id == params.organization_id) + .order_by(Activity.created_at) + ) + stmt = self._apply_window(stmt, params) + result = await self._session.scalars(stmt) + return result.all() + + async def create(self, data: ActivityCreate, *, organization_id: int) -> Activity: + deal = await self._session.get(Deal, data.deal_id) + if deal is None or deal.organization_id != organization_id: + raise ActivityOrganizationMismatchError("Deal belongs to another organization") + + activity = Activity(**data.model_dump()) + self._session.add(activity) + await self._session.flush() + return activity + + def _apply_window( + self, + stmt: Select[tuple[Activity]], + params: ActivityQueryParams, + ) -> Select[tuple[Activity]]: + if params.offset: + stmt = stmt.offset(params.offset) + if params.limit is not None: + stmt = stmt.limit(params.limit) + return stmt diff --git a/app/repositories/task_repo.py b/app/repositories/task_repo.py new file mode 100644 index 0000000..30fcd3f --- /dev/null +++ b/app/repositories/task_repo.py @@ -0,0 +1,123 @@ +"""Task repository providing role-aware CRUD helpers.""" +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from datetime import datetime +from typing import Any + +from sqlalchemy import Select, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.models.deal import Deal +from app.models.organization_member import OrganizationRole +from app.models.task import Task, TaskCreate + + +class TaskAccessError(Exception): + """Raised when a user attempts to modify a forbidden task.""" + + +class TaskOrganizationMismatchError(Exception): + """Raised when a task or deal belongs to another organization.""" + + +@dataclass(slots=True) +class TaskQueryParams: + """Filtering options supported by list queries.""" + + organization_id: int + deal_id: int | None = None + only_open: bool = False + due_before: datetime | None = None + due_after: datetime | None = None + + +class TaskRepository: + """Encapsulates database access for Task entities.""" + + def __init__(self, session: AsyncSession) -> None: + self._session = session + + @property + def session(self) -> AsyncSession: + return self._session + + async def list(self, *, params: TaskQueryParams) -> Sequence[Task]: + stmt = ( + select(Task) + .join(Deal, Deal.id == Task.deal_id) + .where(Deal.organization_id == params.organization_id) + .options(selectinload(Task.deal)) + .order_by(Task.due_date.is_(None), Task.due_date, Task.id) + ) + stmt = self._apply_filters(stmt, params) + result = await self._session.scalars(stmt) + return result.all() + + async def get(self, task_id: int, *, organization_id: int) -> Task | None: + stmt = ( + select(Task) + .join(Deal, Deal.id == Task.deal_id) + .where(Task.id == task_id, Deal.organization_id == organization_id) + .options(selectinload(Task.deal)) + ) + result = await self._session.scalars(stmt) + return result.first() + + async def create( + self, + data: TaskCreate, + *, + organization_id: int, + role: OrganizationRole, + user_id: int, + ) -> Task: + deal = await self._session.get(Deal, data.deal_id) + if deal is None or deal.organization_id != organization_id: + raise TaskOrganizationMismatchError("Deal belongs to another organization") + if role == OrganizationRole.MEMBER and deal.owner_id != user_id: + raise TaskAccessError("Members can only create tasks for their own deals") + + task = Task(**data.model_dump()) + self._session.add(task) + await self._session.flush() + return task + + async def update( + self, + task: Task, + updates: Mapping[str, Any], + *, + role: OrganizationRole, + user_id: int, + ) -> Task: + owner_id = await self._resolve_task_owner(task) + if owner_id is None: + raise TaskOrganizationMismatchError("Task is missing an owner context") + if role == OrganizationRole.MEMBER and owner_id != user_id: + raise TaskAccessError("Members can only modify their own tasks") + + for field, value in updates.items(): + if hasattr(task, field): + setattr(task, field, value) + await self._session.flush() + return task + + def _apply_filters(self, stmt: Select[tuple[Task]], params: TaskQueryParams) -> Select[tuple[Task]]: + if params.deal_id is not None: + stmt = stmt.where(Task.deal_id == params.deal_id) + if params.only_open: + stmt = stmt.where(Task.is_done.is_(False)) + if params.due_before is not None: + stmt = stmt.where(Task.due_date <= params.due_before) + if params.due_after is not None: + stmt = stmt.where(Task.due_date >= params.due_after) + return stmt + + async def _resolve_task_owner(self, task: Task) -> int | None: + if task.deal is not None: + return task.deal.owner_id + stmt = select(Deal.owner_id).where(Deal.id == task.deal_id) + return await self._session.scalar(stmt)