Upload app/services/activity_service.py
This commit is contained in:
@@ -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",
|
||||
]
|
||||
Reference in New Issue
Block a user