diff --git a/app/api/v1/activities/models.py b/app/api/v1/activities/models.py index 498b177..4b6abaf 100644 --- a/app/api/v1/activities/models.py +++ b/app/api/v1/activities/models.py @@ -1,11 +1,18 @@ """Pydantic schemas for activity endpoints.""" from __future__ import annotations -from typing import Any, Literal +from typing import Literal -from pydantic import BaseModel +from pydantic import BaseModel, Field + + +class ActivityCommentBody(BaseModel): + text: str = Field(..., min_length=1, max_length=2000) class ActivityCommentPayload(BaseModel): - type: Literal["comment"] - payload: dict[str, Any] + type: Literal["comment"] = "comment" + payload: ActivityCommentBody + + def extract_text(self) -> str: + return self.payload.text.strip() diff --git a/app/api/v1/activities/views.py b/app/api/v1/activities/views.py index 03c73de..cbd77c6 100644 --- a/app/api/v1/activities/views.py +++ b/app/api/v1/activities/views.py @@ -1,9 +1,16 @@ -"""Activity timeline API stubs.""" +"""Activity timeline endpoints backed by ActivityService.""" from __future__ import annotations -from fastapi import APIRouter, Depends, status +from fastapi import APIRouter, Depends, HTTPException, Query, status -from app.api.deps import get_organization_context +from app.api.deps import get_activity_service, get_organization_context +from app.models.activity import ActivityRead +from app.services.activity_service import ( + ActivityForbiddenError, + ActivityListFilters, + ActivityService, + ActivityValidationError, +) from app.services.organization_service import OrganizationContext from .models import ActivityCommentPayload @@ -11,26 +18,44 @@ from .models import ActivityCommentPayload router = APIRouter(prefix="/deals/{deal_id}/activities", tags=["activities"]) -def _stub(endpoint: str) -> dict[str, str]: - return {"detail": f"{endpoint} is not implemented yet"} - - -@router.get("/", status_code=status.HTTP_501_NOT_IMPLEMENTED) +@router.get("/", response_model=list[ActivityRead]) async def list_activities( deal_id: int, + limit: int = Query(50, ge=1, le=200), + offset: int = Query(0, ge=0), context: OrganizationContext = Depends(get_organization_context), -) -> dict[str, str]: - """Placeholder for listing deal activities.""" - _ = (deal_id, context) - return _stub("GET /deals/{deal_id}/activities") + service: ActivityService = Depends(get_activity_service), +) -> list[ActivityRead]: + """Fetch paginated activities for the deal within the current organization.""" + + filters = ActivityListFilters(deal_id=deal_id, limit=limit, offset=offset) + try: + activities = await service.list_activities(filters=filters, context=context) + except ActivityForbiddenError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + + return [ActivityRead.model_validate(activity) for activity in activities] -@router.post("/", status_code=status.HTTP_501_NOT_IMPLEMENTED) +@router.post("/", response_model=ActivityRead, status_code=status.HTTP_201_CREATED) async def create_activity_comment( deal_id: int, payload: ActivityCommentPayload, context: OrganizationContext = Depends(get_organization_context), -) -> dict[str, str]: - """Placeholder for adding a comment activity to a deal.""" - _ = (deal_id, payload, context) - return _stub("POST /deals/{deal_id}/activities") + service: ActivityService = Depends(get_activity_service), +) -> ActivityRead: + """Add a comment to the deal timeline.""" + + try: + activity = await service.add_comment( + deal_id=deal_id, + author_id=context.user_id, + text=payload.extract_text(), + context=context, + ) + except ActivityValidationError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + except ActivityForbiddenError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + + return ActivityRead.model_validate(activity) diff --git a/app/api/v1/tasks/models.py b/app/api/v1/tasks/models.py index f677e6a..3e4d71e 100644 --- a/app/api/v1/tasks/models.py +++ b/app/api/v1/tasks/models.py @@ -1,13 +1,36 @@ -"""Task API schemas.""" +"""Task API schemas and helpers.""" from __future__ import annotations -from datetime import date +from datetime import date, datetime, time, timezone from pydantic import BaseModel +from app.models.task import TaskCreate + class TaskCreatePayload(BaseModel): deal_id: int title: str description: str | None = None - due_date: date + due_date: date | None = None + + def to_domain(self) -> TaskCreate: + return TaskCreate( + deal_id=self.deal_id, + title=self.title, + description=self.description, + due_date=_date_to_datetime(self.due_date) if self.due_date else None, + ) + + +def to_range_boundary(value: date | None, *, end_of_day: bool) -> datetime | None: + """Convert a date query param to an inclusive datetime boundary.""" + + if value is None: + return None + boundary_time = time(23, 59, 59, 999999) if end_of_day else time(0, 0, 0) + return datetime.combine(value, boundary_time, tzinfo=timezone.utc) + + +def _date_to_datetime(value: date) -> datetime: + return datetime.combine(value, time(0, 0, 0), tzinfo=timezone.utc) diff --git a/app/api/v1/tasks/views.py b/app/api/v1/tasks/views.py index 9ed6f92..ea32ff0 100644 --- a/app/api/v1/tasks/views.py +++ b/app/api/v1/tasks/views.py @@ -1,40 +1,62 @@ -"""Task API stubs supporting list/create operations.""" +"""Task API endpoints backed by TaskService.""" from __future__ import annotations from datetime import date -from fastapi import APIRouter, Depends, Query, status +from fastapi import APIRouter, Depends, HTTPException, Query, status -from app.api.deps import get_organization_context +from app.api.deps import get_organization_context, get_task_service +from app.models.task import TaskRead from app.services.organization_service import OrganizationContext +from app.services.task_service import ( + TaskDueDateError, + TaskForbiddenError, + TaskListFilters, + TaskOrganizationError, + TaskService, +) -from .models import TaskCreatePayload +from .models import TaskCreatePayload, to_range_boundary router = APIRouter(prefix="/tasks", tags=["tasks"]) -def _stub(endpoint: str) -> dict[str, str]: - return {"detail": f"{endpoint} is not implemented yet"} - - -@router.get("/", status_code=status.HTTP_501_NOT_IMPLEMENTED) +@router.get("/", response_model=list[TaskRead]) async def list_tasks( deal_id: int | None = None, only_open: bool = False, due_before: date | None = Query(default=None), due_after: date | None = Query(default=None), context: OrganizationContext = Depends(get_organization_context), -) -> dict[str, str]: - """Placeholder for task filtering endpoint.""" - _ = context - return _stub("GET /tasks") + service: TaskService = Depends(get_task_service), +) -> list[TaskRead]: + """Filter tasks by deal, state, or due date range.""" + + filters = TaskListFilters( + deal_id=deal_id, + only_open=only_open, + due_before=to_range_boundary(due_before, end_of_day=True), + due_after=to_range_boundary(due_after, end_of_day=False), + ) + tasks = await service.list_tasks(filters=filters, context=context) + return [TaskRead.model_validate(task) for task in tasks] -@router.post("/", status_code=status.HTTP_501_NOT_IMPLEMENTED) +@router.post("/", response_model=TaskRead, status_code=status.HTTP_201_CREATED) async def create_task( payload: TaskCreatePayload, context: OrganizationContext = Depends(get_organization_context), -) -> dict[str, str]: - """Placeholder for creating a task linked to a deal.""" - _ = (payload, context) - return _stub("POST /tasks") + service: TaskService = Depends(get_task_service), +) -> TaskRead: + """Create a task ensuring due-date and ownership constraints.""" + + try: + task = await service.create_task(payload.to_domain(), context=context) + except TaskDueDateError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + except TaskForbiddenError as exc: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc + except TaskOrganizationError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + + return TaskRead.model_validate(task)