From 184ec63372b227a036a2e9e3fbbad377f8ff379f Mon Sep 17 00:00:00 2001 From: Leopoldadmin Date: Thu, 4 Jun 2026 00:06:25 +0000 Subject: [PATCH] Upload app/services/activity_service.py --- app/services/activity_service.py | 184 +++++++++++++++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 app/services/activity_service.py diff --git a/app/services/activity_service.py b/app/services/activity_service.py new file mode 100644 index 0000000..bf13693 --- /dev/null +++ b/app/services/activity_service.py @@ -0,0 +1,184 @@ +"""Activity service: create, get, list, update, complete, soft-delete.""" + +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Optional + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.activity import Activity, ActivityType +from app.schemas.activity import ActivityCompleteRequest, ActivityCreate, ActivityUpdate +from app.services._base import OrgScopedQuery + + +class ActivityNotFound(Exception): + """Raised when an activity lookup fails.""" + + +class NoParentException(Exception): + """Raised when an activity is created without any parent (account/contact/deal).""" + + +async def _validate_parents( + db: AsyncSession, + *, + org_id: int, + account_id: Optional[int], + contact_id: Optional[int], + deal_id: Optional[int], +) -> None: + """Ensure at least one parent is set and each provided id exists in org.""" + if account_id is None and contact_id is None and deal_id is None: + raise NoParentException( + "Activity must reference at least one of: account_id, contact_id, deal_id" + ) + if account_id is not None: + from app.models.account import Account + if await OrgScopedQuery(Account, db, org_id=org_id).get(account_id) is None: + raise NoParentException(f"Account {account_id} not found in this org") + if contact_id is not None: + from app.models.contact import Contact + if await OrgScopedQuery(Contact, db, org_id=org_id).get(contact_id) is None: + raise NoParentException(f"Contact {contact_id} not found in this org") + if deal_id is not None: + from app.models.deal import Deal + if await OrgScopedQuery(Deal, db, org_id=org_id).get(deal_id) is None: + raise NoParentException(f"Deal {deal_id} not found in this org") + + +async def create_activity( + db: AsyncSession, + payload: ActivityCreate, + *, + org_id: int, + owner_id: int, +) -> Activity: + """Create a new activity. Validates parents (at least one required).""" + await _validate_parents( + db, + org_id=org_id, + account_id=payload.account_id, + contact_id=payload.contact_id, + deal_id=payload.deal_id, + ) + activity = Activity( + org_id=org_id, + type=payload.type, + subject=payload.subject, + body=payload.body, + due_date=payload.due_date, + account_id=payload.account_id, + contact_id=payload.contact_id, + deal_id=payload.deal_id, + owner_id=owner_id, + ) + db.add(activity) + await db.commit() + await db.refresh(activity) + return activity + + +async def get_activity( + db: AsyncSession, activity_id: int, *, org_id: int +) -> Optional[Activity]: + """Fetch a single activity by id.""" + q = OrgScopedQuery(Activity, db, org_id=org_id) + return await q.get(activity_id) + + +async def list_activities( + db: AsyncSession, + *, + org_id: int, + skip: int = 0, + limit: int = 20, + type: Optional[ActivityType] = None, + owner_id: Optional[int] = None, + overdue: Optional[bool] = None, + completed: Optional[bool] = None, +) -> list[Activity]: + """List activities with optional filters.""" + scoped = OrgScopedQuery(Activity, db, org_id=org_id) + base = await scoped.list( + skip=skip, + limit=limit, + order_by=Activity.id, + type=type.value if hasattr(type, "value") else type, + owner_id=owner_id, + ) + now = datetime.now(UTC).replace(tzinfo=None) # SQLite returns naive datetimes + if overdue is True: + base = [ + a for a in base + if a.due_date is not None and a.due_date < now and a.completed_at is None + ] + if completed is True: + base = [a for a in base if a.completed_at is not None] + elif completed is False: + base = [a for a in base if a.completed_at is None] + return base + + +async def update_activity( + db: AsyncSession, activity: Activity, payload: ActivityUpdate +) -> Activity: + """Apply partial updates to an activity.""" + data = payload.model_dump(exclude_unset=True) + # If parents change, re-validate + if any(k in data for k in ("account_id", "contact_id", "deal_id")): + new_acc = data.get("account_id", activity.account_id) + new_con = data.get("contact_id", activity.contact_id) + new_deal = data.get("deal_id", activity.deal_id) + await _validate_parents( + db, + org_id=activity.org_id, + account_id=new_acc, + contact_id=new_con, + deal_id=new_deal, + ) + for field, value in data.items(): + if value is not None: + setattr(activity, field, value) + await db.commit() + await db.refresh(activity) + return activity + + +async def complete_activity( + db: AsyncSession, + activity: Activity, + payload: ActivityCompleteRequest, +) -> Activity: + """Mark activity as completed; set completed_at and optionally outcome.""" + activity.completed_at = datetime.now(UTC) + if payload.outcome is not None: + # We store outcome in body (free-form text) for v1 + existing = activity.body or "" + outcome_line = f"\n[Outcome] {payload.outcome}" + activity.body = (existing + outcome_line).strip() + await db.commit() + await db.refresh(activity) + return activity + + +async def soft_delete_activity( + db: AsyncSession, activity: Activity +) -> Activity: + """Soft-delete an activity.""" + activity.deleted_at = datetime.now(UTC) + await db.commit() + await db.refresh(activity) + return activity + + +__all__ = [ + "ActivityNotFound", + "NoParentException", + "complete_activity", + "create_activity", + "get_activity", + "list_activities", + "soft_delete_activity", + "update_activity", +]