"""Activity service: create, get, list, update, complete, soft-delete.""" from __future__ import annotations from datetime import UTC, datetime 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: int | None, contact_id: int | None, deal_id: int | None, ) -> 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 ) -> Activity | None: """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: ActivityType | None = None, owner_id: int | None = None, overdue: bool | None = None, completed: bool | None = 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", ]