feat(phase-4b): backend business-logic, 8 entities, 32 endpoints, 58 tests
This commit is contained in:
@@ -0,0 +1,285 @@
|
||||
"""business_entities
|
||||
|
||||
Revision ID: 0002
|
||||
Revises: 0001
|
||||
Create Date: 2026-06-03
|
||||
|
||||
Adds 8 new tables for the Phase 4b business logic:
|
||||
- accounts, contacts, deals, activities (with polymorphic parent FKs)
|
||||
- notes, tags, tag_links (polymorphic parent, no DB FK)
|
||||
- deal_stage_history (audit trail of stage transitions)
|
||||
|
||||
All tables include org_id for multi-tenant isolation (R-1) and
|
||||
deleted_at for soft-delete (R-1 default filter).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from typing import Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "0002"
|
||||
down_revision: Union[str, None] = "0001"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# === accounts ===
|
||||
op.create_table(
|
||||
"accounts",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("org_id", sa.Integer(), nullable=False),
|
||||
sa.Column("name", sa.String(length=255), nullable=False),
|
||||
sa.Column("website", sa.String(length=512), nullable=True),
|
||||
sa.Column("industry", sa.String(length=32), nullable=True),
|
||||
sa.Column("size", sa.String(length=32), nullable=True),
|
||||
sa.Column("address", sa.JSON(), nullable=True),
|
||||
sa.Column("owner_id", sa.Integer(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(["org_id"], ["orgs.id"], ondelete="CASCADE", name="fk_accounts_org_id"),
|
||||
sa.ForeignKeyConstraint(["owner_id"], ["users.id"], name="fk_accounts_owner_id"),
|
||||
)
|
||||
op.create_index("ix_accounts_name", "accounts", ["name"])
|
||||
op.create_index("ix_accounts_industry", "accounts", ["industry"])
|
||||
op.create_index("ix_accounts_org_id", "accounts", ["org_id"])
|
||||
op.create_index("ix_accounts_owner_id", "accounts", ["owner_id"])
|
||||
op.create_index("ix_accounts_created_at", "accounts", ["created_at"])
|
||||
op.create_index("ix_accounts_deleted_at", "accounts", ["deleted_at"])
|
||||
|
||||
# === contacts ===
|
||||
op.create_table(
|
||||
"contacts",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("org_id", sa.Integer(), nullable=False),
|
||||
sa.Column("first_name", sa.String(length=128), nullable=False),
|
||||
sa.Column("last_name", sa.String(length=128), nullable=False),
|
||||
sa.Column("email", sa.String(length=255), nullable=True),
|
||||
sa.Column("phone", sa.String(length=64), nullable=True),
|
||||
sa.Column("account_id", sa.Integer(), nullable=True),
|
||||
sa.Column("owner_id", sa.Integer(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(["org_id"], ["orgs.id"], ondelete="CASCADE", name="fk_contacts_org_id"),
|
||||
sa.ForeignKeyConstraint(["account_id"], ["accounts.id"], name="fk_contacts_account_id"),
|
||||
sa.ForeignKeyConstraint(["owner_id"], ["users.id"], name="fk_contacts_owner_id"),
|
||||
)
|
||||
op.create_index("ix_contacts_last_name", "contacts", ["last_name"])
|
||||
op.create_index("ix_contacts_email", "contacts", ["email"])
|
||||
op.create_index("ix_contacts_org_id", "contacts", ["org_id"])
|
||||
op.create_index("ix_contacts_account_id", "contacts", ["account_id"])
|
||||
op.create_index("ix_contacts_owner_id", "contacts", ["owner_id"])
|
||||
op.create_index("ix_contacts_created_at", "contacts", ["created_at"])
|
||||
op.create_index("ix_contacts_deleted_at", "contacts", ["deleted_at"])
|
||||
|
||||
# === deals ===
|
||||
op.create_table(
|
||||
"deals",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("org_id", sa.Integer(), nullable=False),
|
||||
sa.Column("title", sa.String(length=255), nullable=False),
|
||||
sa.Column("value", sa.Numeric(12, 2), nullable=False, server_default="0"),
|
||||
sa.Column("currency", sa.String(length=3), nullable=False, server_default="EUR"),
|
||||
sa.Column("stage", sa.String(length=32), nullable=False, server_default="lead"),
|
||||
sa.Column("close_date", sa.Date(), nullable=True),
|
||||
sa.Column("account_id", sa.Integer(), nullable=False),
|
||||
sa.Column("owner_id", sa.Integer(), nullable=False),
|
||||
sa.Column("won_lost_reason", sa.String(length=512), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(["org_id"], ["orgs.id"], ondelete="CASCADE", name="fk_deals_org_id"),
|
||||
sa.ForeignKeyConstraint(["account_id"], ["accounts.id"], name="fk_deals_account_id"),
|
||||
sa.ForeignKeyConstraint(["owner_id"], ["users.id"], name="fk_deals_owner_id"),
|
||||
)
|
||||
op.create_index("ix_deals_stage", "deals", ["stage"])
|
||||
op.create_index("ix_deals_org_id", "deals", ["org_id"])
|
||||
op.create_index("ix_deals_account_id", "deals", ["account_id"])
|
||||
op.create_index("ix_deals_owner_id", "deals", ["owner_id"])
|
||||
op.create_index("ix_deals_created_at", "deals", ["created_at"])
|
||||
op.create_index("ix_deals_deleted_at", "deals", ["deleted_at"])
|
||||
|
||||
# === activities ===
|
||||
op.create_table(
|
||||
"activities",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("org_id", sa.Integer(), nullable=False),
|
||||
sa.Column("type", sa.String(length=32), nullable=False),
|
||||
sa.Column("subject", sa.String(length=255), nullable=False),
|
||||
sa.Column("body", sa.Text(), nullable=True),
|
||||
sa.Column("due_date", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("account_id", sa.Integer(), nullable=True),
|
||||
sa.Column("contact_id", sa.Integer(), nullable=True),
|
||||
sa.Column("deal_id", sa.Integer(), nullable=True),
|
||||
sa.Column("owner_id", sa.Integer(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(["org_id"], ["orgs.id"], ondelete="CASCADE", name="fk_activities_org_id"),
|
||||
sa.ForeignKeyConstraint(["account_id"], ["accounts.id"], name="fk_activities_account_id"),
|
||||
sa.ForeignKeyConstraint(["contact_id"], ["contacts.id"], name="fk_activities_contact_id"),
|
||||
sa.ForeignKeyConstraint(["deal_id"], ["deals.id"], name="fk_activities_deal_id"),
|
||||
sa.ForeignKeyConstraint(["owner_id"], ["users.id"], name="fk_activities_owner_id"),
|
||||
)
|
||||
op.create_index("ix_activities_type", "activities", ["type"])
|
||||
op.create_index("ix_activities_due_date", "activities", ["due_date"])
|
||||
op.create_index("ix_activities_org_id", "activities", ["org_id"])
|
||||
op.create_index("ix_activities_account_id", "activities", ["account_id"])
|
||||
op.create_index("ix_activities_contact_id", "activities", ["contact_id"])
|
||||
op.create_index("ix_activities_deal_id", "activities", ["deal_id"])
|
||||
op.create_index("ix_activities_owner_id", "activities", ["owner_id"])
|
||||
op.create_index("ix_activities_created_at", "activities", ["created_at"])
|
||||
op.create_index("ix_activities_deleted_at", "activities", ["deleted_at"])
|
||||
|
||||
# === notes ===
|
||||
op.create_table(
|
||||
"notes",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("org_id", sa.Integer(), nullable=False),
|
||||
sa.Column("body", sa.Text(), nullable=False),
|
||||
sa.Column("author_id", sa.Integer(), nullable=False),
|
||||
sa.Column("parent_type", sa.String(length=32), nullable=False),
|
||||
sa.Column("parent_id", sa.Integer(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(["org_id"], ["orgs.id"], ondelete="CASCADE", name="fk_notes_org_id"),
|
||||
sa.ForeignKeyConstraint(["author_id"], ["users.id"], name="fk_notes_author_id"),
|
||||
)
|
||||
op.create_index("ix_notes_parent_type", "notes", ["parent_type"])
|
||||
op.create_index("ix_notes_parent_id", "notes", ["parent_id"])
|
||||
op.create_index("ix_notes_author_id", "notes", ["author_id"])
|
||||
op.create_index("ix_notes_org_id", "notes", ["org_id"])
|
||||
op.create_index("ix_notes_created_at", "notes", ["created_at"])
|
||||
op.create_index("ix_notes_deleted_at", "notes", ["deleted_at"])
|
||||
|
||||
# === tags ===
|
||||
op.create_table(
|
||||
"tags",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("org_id", sa.Integer(), nullable=False),
|
||||
sa.Column("name", sa.String(length=64), nullable=False),
|
||||
sa.Column("color", sa.String(length=7), nullable=False, server_default="#3B82F6"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(["org_id"], ["orgs.id"], ondelete="CASCADE", name="fk_tags_org_id"),
|
||||
)
|
||||
op.create_index("ix_tags_name", "tags", ["name"])
|
||||
op.create_index("ix_tags_org_id", "tags", ["org_id"])
|
||||
op.create_index("ix_tags_created_at", "tags", ["created_at"])
|
||||
op.create_index("ix_tags_deleted_at", "tags", ["deleted_at"])
|
||||
|
||||
# === tag_links ===
|
||||
op.create_table(
|
||||
"tag_links",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("org_id", sa.Integer(), nullable=False),
|
||||
sa.Column("tag_id", sa.Integer(), nullable=False),
|
||||
sa.Column("parent_type", sa.String(length=32), nullable=False),
|
||||
sa.Column("parent_id", sa.Integer(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(["org_id"], ["orgs.id"], ondelete="CASCADE", name="fk_tag_links_org_id"),
|
||||
sa.ForeignKeyConstraint(["tag_id"], ["tags.id"], ondelete="CASCADE", name="fk_tag_links_tag_id"),
|
||||
sa.UniqueConstraint("tag_id", "parent_type", "parent_id", name="uq_tag_link"),
|
||||
)
|
||||
op.create_index("ix_tag_links_tag_id", "tag_links", ["tag_id"])
|
||||
op.create_index("ix_tag_links_parent_type", "tag_links", ["parent_type"])
|
||||
op.create_index("ix_tag_links_parent_id", "tag_links", ["parent_id"])
|
||||
op.create_index("ix_tag_links_org_id", "tag_links", ["org_id"])
|
||||
op.create_index("ix_tag_links_created_at", "tag_links", ["created_at"])
|
||||
op.create_index("ix_tag_links_deleted_at", "tag_links", ["deleted_at"])
|
||||
|
||||
# === deal_stage_history ===
|
||||
op.create_table(
|
||||
"deal_stage_history",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("org_id", sa.Integer(), nullable=False),
|
||||
sa.Column("deal_id", sa.Integer(), nullable=False),
|
||||
sa.Column("from_stage", sa.String(length=32), nullable=True),
|
||||
sa.Column("to_stage", sa.String(length=32), nullable=False),
|
||||
sa.Column("changed_by", sa.Integer(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.ForeignKeyConstraint(["org_id"], ["orgs.id"], ondelete="CASCADE", name="fk_dsh_org_id"),
|
||||
sa.ForeignKeyConstraint(["deal_id"], ["deals.id"], ondelete="CASCADE", name="fk_dsh_deal_id"),
|
||||
sa.ForeignKeyConstraint(["changed_by"], ["users.id"], name="fk_dsh_changed_by"),
|
||||
)
|
||||
op.create_index("ix_dsh_deal_id", "deal_stage_history", ["deal_id"])
|
||||
op.create_index("ix_dsh_org_id", "deal_stage_history", ["org_id"])
|
||||
op.create_index("ix_dsh_created_at", "deal_stage_history", ["created_at"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_dsh_created_at", table_name="deal_stage_history")
|
||||
op.drop_index("ix_dsh_org_id", table_name="deal_stage_history")
|
||||
op.drop_index("ix_dsh_deal_id", table_name="deal_stage_history")
|
||||
op.drop_table("deal_stage_history")
|
||||
|
||||
op.drop_index("ix_tag_links_deleted_at", table_name="tag_links")
|
||||
op.drop_index("ix_tag_links_created_at", table_name="tag_links")
|
||||
op.drop_index("ix_tag_links_org_id", table_name="tag_links")
|
||||
op.drop_index("ix_tag_links_parent_id", table_name="tag_links")
|
||||
op.drop_index("ix_tag_links_parent_type", table_name="tag_links")
|
||||
op.drop_index("ix_tag_links_tag_id", table_name="tag_links")
|
||||
op.drop_table("tag_links")
|
||||
|
||||
op.drop_index("ix_tags_deleted_at", table_name="tags")
|
||||
op.drop_index("ix_tags_created_at", table_name="tags")
|
||||
op.drop_index("ix_tags_org_id", table_name="tags")
|
||||
op.drop_index("ix_tags_name", table_name="tags")
|
||||
op.drop_table("tags")
|
||||
|
||||
op.drop_index("ix_notes_deleted_at", table_name="notes")
|
||||
op.drop_index("ix_notes_created_at", table_name="notes")
|
||||
op.drop_index("ix_notes_org_id", table_name="notes")
|
||||
op.drop_index("ix_notes_author_id", table_name="notes")
|
||||
op.drop_index("ix_notes_parent_id", table_name="notes")
|
||||
op.drop_index("ix_notes_parent_type", table_name="notes")
|
||||
op.drop_table("notes")
|
||||
|
||||
op.drop_index("ix_activities_deleted_at", table_name="activities")
|
||||
op.drop_index("ix_activities_created_at", table_name="activities")
|
||||
op.drop_index("ix_activities_owner_id", table_name="activities")
|
||||
op.drop_index("ix_activities_deal_id", table_name="activities")
|
||||
op.drop_index("ix_activities_contact_id", table_name="activities")
|
||||
op.drop_index("ix_activities_account_id", table_name="activities")
|
||||
op.drop_index("ix_activities_org_id", table_name="activities")
|
||||
op.drop_index("ix_activities_due_date", table_name="activities")
|
||||
op.drop_index("ix_activities_type", table_name="activities")
|
||||
op.drop_table("activities")
|
||||
|
||||
op.drop_index("ix_deals_deleted_at", table_name="deals")
|
||||
op.drop_index("ix_deals_created_at", table_name="deals")
|
||||
op.drop_index("ix_deals_owner_id", table_name="deals")
|
||||
op.drop_index("ix_deals_account_id", table_name="deals")
|
||||
op.drop_index("ix_deals_org_id", table_name="deals")
|
||||
op.drop_index("ix_deals_stage", table_name="deals")
|
||||
op.drop_table("deals")
|
||||
|
||||
op.drop_index("ix_contacts_deleted_at", table_name="contacts")
|
||||
op.drop_index("ix_contacts_created_at", table_name="contacts")
|
||||
op.drop_index("ix_contacts_owner_id", table_name="contacts")
|
||||
op.drop_index("ix_contacts_account_id", table_name="contacts")
|
||||
op.drop_index("ix_contacts_org_id", table_name="contacts")
|
||||
op.drop_index("ix_contacts_email", table_name="contacts")
|
||||
op.drop_index("ix_contacts_last_name", table_name="contacts")
|
||||
op.drop_table("contacts")
|
||||
|
||||
op.drop_index("ix_accounts_deleted_at", table_name="accounts")
|
||||
op.drop_index("ix_accounts_created_at", table_name="accounts")
|
||||
op.drop_index("ix_accounts_owner_id", table_name="accounts")
|
||||
op.drop_index("ix_accounts_org_id", table_name="accounts")
|
||||
op.drop_index("ix_accounts_industry", table_name="accounts")
|
||||
op.drop_index("ix_accounts_name", table_name="accounts")
|
||||
op.drop_table("accounts")
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Account API endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.core.deps import get_current_user
|
||||
from app.models.account import AccountSize, Industry
|
||||
from app.models.user import User
|
||||
from app.schemas.account import AccountCreate, AccountOut, AccountUpdate
|
||||
from app.services.account_service import (
|
||||
create_account as svc_create_account,
|
||||
get_account as svc_get_account,
|
||||
list_accounts as svc_list_accounts,
|
||||
soft_delete_account as svc_soft_delete_account,
|
||||
update_account as svc_update_account,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/accounts", tags=["accounts"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/",
|
||||
response_model=AccountOut,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Create a new account",
|
||||
)
|
||||
async def create_account(
|
||||
payload: AccountCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> AccountOut:
|
||||
acc = await svc_create_account(
|
||||
db, payload, org_id=current_user.org_id, owner_id=current_user.id
|
||||
)
|
||||
return AccountOut.model_validate(acc)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/",
|
||||
response_model=list[AccountOut],
|
||||
summary="List accounts (paginated, filterable)",
|
||||
)
|
||||
async def list_accounts(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
industry: Optional[Industry] = None,
|
||||
size: Optional[AccountSize] = None,
|
||||
owner_id: Optional[int] = None,
|
||||
q: Optional[str] = None,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> list[AccountOut]:
|
||||
accounts = await svc_list_accounts(
|
||||
db,
|
||||
org_id=current_user.org_id,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
industry=industry.value if industry else None,
|
||||
size=size.value if size else None,
|
||||
owner_id=owner_id,
|
||||
q=q,
|
||||
)
|
||||
return [AccountOut.model_validate(a) for a in accounts]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{account_id}",
|
||||
response_model=AccountOut,
|
||||
summary="Get one account with all relations",
|
||||
)
|
||||
async def get_account(
|
||||
account_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> AccountOut:
|
||||
acc = await svc_get_account(db, account_id, org_id=current_user.org_id)
|
||||
if acc is None:
|
||||
raise HTTPException(status_code=404, detail="Account not found")
|
||||
return AccountOut.model_validate(acc)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{account_id}",
|
||||
response_model=AccountOut,
|
||||
summary="Update an account",
|
||||
)
|
||||
async def update_account(
|
||||
account_id: int,
|
||||
payload: AccountUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> AccountOut:
|
||||
acc = await svc_get_account(db, account_id, org_id=current_user.org_id)
|
||||
if acc is None:
|
||||
raise HTTPException(status_code=404, detail="Account not found")
|
||||
updated = await svc_update_account(db, acc, payload)
|
||||
return AccountOut.model_validate(updated)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{account_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
response_class=Response,
|
||||
summary="Soft-delete an account",
|
||||
)
|
||||
async def delete_account(
|
||||
account_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Response:
|
||||
acc = await svc_get_account(db, account_id, org_id=current_user.org_id)
|
||||
if acc is None:
|
||||
raise HTTPException(status_code=404, detail="Account not found")
|
||||
await svc_soft_delete_account(db, acc)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
@@ -0,0 +1,162 @@
|
||||
"""Activity API endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.core.deps import get_current_user
|
||||
from app.models.activity import ActivityType
|
||||
from app.models.user import User
|
||||
from app.schemas.activity import (
|
||||
ActivityCompleteRequest,
|
||||
ActivityCreate,
|
||||
ActivityOut,
|
||||
ActivityUpdate,
|
||||
)
|
||||
from app.services.activity_service import (
|
||||
NoParentException,
|
||||
complete_activity as svc_complete_activity,
|
||||
create_activity as svc_create_activity,
|
||||
get_activity as svc_get_activity,
|
||||
list_activities as svc_list_activities,
|
||||
soft_delete_activity as svc_soft_delete_activity,
|
||||
update_activity as svc_update_activity,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/activities", tags=["activities"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/",
|
||||
response_model=ActivityOut,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Create a new activity (polymorphic parent: account/contact/deal)",
|
||||
)
|
||||
async def create_activity(
|
||||
payload: ActivityCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ActivityOut:
|
||||
try:
|
||||
activity = await svc_create_activity(
|
||||
db, payload, org_id=current_user.org_id, owner_id=current_user.id
|
||||
)
|
||||
except NoParentException as e:
|
||||
raise HTTPException(status_code=422, detail=str(e)) from e
|
||||
return ActivityOut.model_validate(activity)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/",
|
||||
response_model=list[ActivityOut],
|
||||
summary="List activities (paginated, filterable)",
|
||||
)
|
||||
async def list_activities(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
type: Optional[ActivityType] = None,
|
||||
owner_id: Optional[int] = None,
|
||||
overdue: Optional[bool] = None,
|
||||
completed: Optional[bool] = None,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> list[ActivityOut]:
|
||||
activities = await svc_list_activities(
|
||||
db,
|
||||
org_id=current_user.org_id,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
type=type,
|
||||
owner_id=owner_id,
|
||||
overdue=overdue,
|
||||
completed=completed,
|
||||
)
|
||||
return [ActivityOut.model_validate(a) for a in activities]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{activity_id}",
|
||||
response_model=ActivityOut,
|
||||
summary="Get one activity",
|
||||
)
|
||||
async def get_activity(
|
||||
activity_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ActivityOut:
|
||||
activity = await svc_get_activity(
|
||||
db, activity_id, org_id=current_user.org_id
|
||||
)
|
||||
if activity is None:
|
||||
raise HTTPException(status_code=404, detail="Activity not found")
|
||||
return ActivityOut.model_validate(activity)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{activity_id}",
|
||||
response_model=ActivityOut,
|
||||
summary="Update an activity",
|
||||
)
|
||||
async def update_activity(
|
||||
activity_id: int,
|
||||
payload: ActivityUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ActivityOut:
|
||||
activity = await svc_get_activity(
|
||||
db, activity_id, org_id=current_user.org_id
|
||||
)
|
||||
if activity is None:
|
||||
raise HTTPException(status_code=404, detail="Activity not found")
|
||||
try:
|
||||
updated = await svc_update_activity(db, activity, payload)
|
||||
except NoParentException as e:
|
||||
raise HTTPException(status_code=422, detail=str(e)) from e
|
||||
return ActivityOut.model_validate(updated)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{activity_id}/complete",
|
||||
response_model=ActivityOut,
|
||||
summary="Mark an activity as completed",
|
||||
)
|
||||
async def complete_activity(
|
||||
activity_id: int,
|
||||
payload: ActivityCompleteRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ActivityOut:
|
||||
activity = await svc_get_activity(
|
||||
db, activity_id, org_id=current_user.org_id
|
||||
)
|
||||
if activity is None:
|
||||
raise HTTPException(status_code=404, detail="Activity not found")
|
||||
updated = await svc_complete_activity(db, activity, payload)
|
||||
return ActivityOut.model_validate(updated)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{activity_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
response_class=Response,
|
||||
summary="Soft-delete an activity",
|
||||
)
|
||||
async def delete_activity(
|
||||
activity_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Response:
|
||||
activity = await svc_get_activity(
|
||||
db, activity_id, org_id=current_user.org_id
|
||||
)
|
||||
if activity is None:
|
||||
raise HTTPException(status_code=404, detail="Activity not found")
|
||||
await svc_soft_delete_activity(db, activity)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Contact API endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.core.deps import get_current_user
|
||||
from app.models.user import User
|
||||
from app.schemas.contact import ContactCreate, ContactOut, ContactUpdate
|
||||
from app.services.contact_service import (
|
||||
InvalidAccount,
|
||||
create_contact as svc_create_contact,
|
||||
get_contact as svc_get_contact,
|
||||
list_contacts as svc_list_contacts,
|
||||
soft_delete_contact as svc_soft_delete_contact,
|
||||
update_contact as svc_update_contact,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/contacts", tags=["contacts"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/",
|
||||
response_model=ContactOut,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Create a new contact",
|
||||
)
|
||||
async def create_contact(
|
||||
payload: ContactCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ContactOut:
|
||||
try:
|
||||
contact = await svc_create_contact(
|
||||
db, payload, org_id=current_user.org_id, owner_id=current_user.id
|
||||
)
|
||||
except InvalidAccount as e:
|
||||
raise HTTPException(status_code=404, detail=str(e)) from e
|
||||
return ContactOut.model_validate(contact)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/",
|
||||
response_model=list[ContactOut],
|
||||
summary="List contacts (paginated, filterable)",
|
||||
)
|
||||
async def list_contacts(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
account_id: Optional[int] = None,
|
||||
owner_id: Optional[int] = None,
|
||||
q: Optional[str] = None,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> list[ContactOut]:
|
||||
contacts = await svc_list_contacts(
|
||||
db,
|
||||
org_id=current_user.org_id,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
account_id=account_id,
|
||||
owner_id=owner_id,
|
||||
q=q,
|
||||
)
|
||||
return [ContactOut.model_validate(c) for c in contacts]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{contact_id}",
|
||||
response_model=ContactOut,
|
||||
summary="Get one contact",
|
||||
)
|
||||
async def get_contact(
|
||||
contact_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ContactOut:
|
||||
contact = await svc_get_contact(db, contact_id, org_id=current_user.org_id)
|
||||
if contact is None:
|
||||
raise HTTPException(status_code=404, detail="Contact not found")
|
||||
return ContactOut.model_validate(contact)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{contact_id}",
|
||||
response_model=ContactOut,
|
||||
summary="Update a contact",
|
||||
)
|
||||
async def update_contact(
|
||||
contact_id: int,
|
||||
payload: ContactUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ContactOut:
|
||||
contact = await svc_get_contact(db, contact_id, org_id=current_user.org_id)
|
||||
if contact is None:
|
||||
raise HTTPException(status_code=404, detail="Contact not found")
|
||||
try:
|
||||
updated = await svc_update_contact(db, contact, payload)
|
||||
except InvalidAccount as e:
|
||||
raise HTTPException(status_code=404, detail=str(e)) from e
|
||||
return ContactOut.model_validate(updated)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{contact_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
response_class=Response,
|
||||
summary="Soft-delete a contact",
|
||||
)
|
||||
async def delete_contact(
|
||||
contact_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Response:
|
||||
contact = await svc_get_contact(db, contact_id, org_id=current_user.org_id)
|
||||
if contact is None:
|
||||
raise HTTPException(status_code=404, detail="Contact not found")
|
||||
await svc_soft_delete_contact(db, contact)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Dashboard API endpoints: KPIs and activity feed."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.core.deps import get_current_user
|
||||
from app.models.user import User
|
||||
from app.schemas.dashboard import ActivityFeedItem, KPIOut
|
||||
from app.services.dashboard_service import (
|
||||
get_activity_feed,
|
||||
get_kpis,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"/kpis",
|
||||
response_model=KPIOut,
|
||||
summary="Get dashboard KPIs (open deals, pipeline value, won this month, conversion rate)",
|
||||
)
|
||||
async def get_kpis_endpoint(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> KPIOut:
|
||||
kpis = await get_kpis(db, org_id=current_user.org_id)
|
||||
return KPIOut(**kpis) # type: ignore[arg-type]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/feed",
|
||||
response_model=list[ActivityFeedItem],
|
||||
summary="Get the latest 20 activities (sorted by created_at desc)",
|
||||
)
|
||||
async def get_activity_feed_endpoint(
|
||||
limit: int = 20,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> list[ActivityFeedItem]:
|
||||
activities = await get_activity_feed(
|
||||
db, org_id=current_user.org_id, limit=limit
|
||||
)
|
||||
return [ActivityFeedItem.model_validate(a) for a in activities]
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
@@ -0,0 +1,175 @@
|
||||
"""Deal API endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.core.deps import get_current_user
|
||||
from app.models.deal import DealStage
|
||||
from app.models.user import User
|
||||
from app.schemas.deal import (
|
||||
DealCreate,
|
||||
DealOut,
|
||||
DealPipelineOut,
|
||||
DealStageUpdate,
|
||||
DealUpdate,
|
||||
)
|
||||
from app.services.deal_service import (
|
||||
InvalidAccount,
|
||||
create_deal as svc_create_deal,
|
||||
get_deal as svc_get_deal,
|
||||
get_pipeline,
|
||||
list_deals as svc_list_deals,
|
||||
soft_delete_deal as svc_soft_delete_deal,
|
||||
update_deal as svc_update_deal,
|
||||
update_stage as svc_update_stage,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/deals", tags=["deals"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/",
|
||||
response_model=DealOut,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Create a new deal",
|
||||
)
|
||||
async def create_deal(
|
||||
payload: DealCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> DealOut:
|
||||
try:
|
||||
deal = await svc_create_deal(
|
||||
db, payload, org_id=current_user.org_id, owner_id=current_user.id
|
||||
)
|
||||
except InvalidAccount as e:
|
||||
raise HTTPException(status_code=404, detail=str(e)) from e
|
||||
return DealOut.model_validate(deal)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/",
|
||||
response_model=list[DealOut],
|
||||
summary="List deals (paginated, filterable)",
|
||||
)
|
||||
async def list_deals(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
stage: Optional[DealStage] = None,
|
||||
owner_id: Optional[int] = None,
|
||||
account_id: Optional[int] = None,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> list[DealOut]:
|
||||
deals = await svc_list_deals(
|
||||
db,
|
||||
org_id=current_user.org_id,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
stage=stage.value if stage else None,
|
||||
owner_id=owner_id,
|
||||
account_id=account_id,
|
||||
)
|
||||
return [DealOut.model_validate(d) for d in deals]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/pipeline",
|
||||
response_model=list[DealPipelineOut],
|
||||
summary="Get pipeline view (deals grouped by stage)",
|
||||
)
|
||||
async def get_pipeline_endpoint(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> list[DealPipelineOut]:
|
||||
pipeline = await get_pipeline(db, org_id=current_user.org_id)
|
||||
out: list[DealPipelineOut] = []
|
||||
for item in pipeline:
|
||||
out.append(
|
||||
DealPipelineOut(
|
||||
stage=DealStage(item["stage"]),
|
||||
count=int(item["count"]),
|
||||
total_value=float(item["total_value"]),
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{deal_id}",
|
||||
response_model=DealOut,
|
||||
summary="Get one deal",
|
||||
)
|
||||
async def get_deal(
|
||||
deal_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> DealOut:
|
||||
deal = await svc_get_deal(db, deal_id, org_id=current_user.org_id)
|
||||
if deal is None:
|
||||
raise HTTPException(status_code=404, detail="Deal not found")
|
||||
return DealOut.model_validate(deal)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{deal_id}",
|
||||
response_model=DealOut,
|
||||
summary="Update a deal (does NOT change stage)",
|
||||
)
|
||||
async def update_deal(
|
||||
deal_id: int,
|
||||
payload: DealUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> DealOut:
|
||||
deal = await svc_get_deal(db, deal_id, org_id=current_user.org_id)
|
||||
if deal is None:
|
||||
raise HTTPException(status_code=404, detail="Deal not found")
|
||||
updated = await svc_update_deal(db, deal, payload)
|
||||
return DealOut.model_validate(updated)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{deal_id}/stage",
|
||||
response_model=DealOut,
|
||||
summary="Change a deal's stage (creates DealStageHistory entry)",
|
||||
)
|
||||
async def update_deal_stage(
|
||||
deal_id: int,
|
||||
payload: DealStageUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> DealOut:
|
||||
deal = await svc_get_deal(db, deal_id, org_id=current_user.org_id)
|
||||
if deal is None:
|
||||
raise HTTPException(status_code=404, detail="Deal not found")
|
||||
updated = await svc_update_stage(
|
||||
db, deal, payload.stage, changed_by=current_user.id, reason=payload.reason
|
||||
)
|
||||
return DealOut.model_validate(updated)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{deal_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
response_class=Response,
|
||||
summary="Soft-delete a deal",
|
||||
)
|
||||
async def delete_deal(
|
||||
deal_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Response:
|
||||
deal = await svc_get_deal(db, deal_id, org_id=current_user.org_id)
|
||||
if deal is None:
|
||||
raise HTTPException(status_code=404, detail="Deal not found")
|
||||
await svc_soft_delete_deal(db, deal)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Note API endpoints (R-2: polymorphic parent validation)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.core.deps import get_current_user
|
||||
from app.models.note import NoteParentType
|
||||
from app.models.user import User
|
||||
from app.schemas.note import NoteCreate, NoteOut, NoteUpdate
|
||||
from app.services.note_service import (
|
||||
InvalidParent,
|
||||
create_note as svc_create_note,
|
||||
get_note as svc_get_note,
|
||||
list_notes as svc_list_notes,
|
||||
soft_delete_note as svc_soft_delete_note,
|
||||
update_note as svc_update_note,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/notes", tags=["notes"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/",
|
||||
response_model=NoteOut,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Create a new note (R-2: validates parent exists)",
|
||||
)
|
||||
async def create_note(
|
||||
payload: NoteCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> NoteOut:
|
||||
try:
|
||||
note = await svc_create_note(
|
||||
db, payload, org_id=current_user.org_id, author_id=current_user.id
|
||||
)
|
||||
except InvalidParent as e:
|
||||
raise HTTPException(status_code=404, detail=str(e)) from e
|
||||
return NoteOut.model_validate(note)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/",
|
||||
response_model=list[NoteOut],
|
||||
summary="List notes (filterable by parent_type + parent_id)",
|
||||
)
|
||||
async def list_notes(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
parent_type: Optional[NoteParentType] = None,
|
||||
parent_id: Optional[int] = None,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> list[NoteOut]:
|
||||
notes = await svc_list_notes(
|
||||
db,
|
||||
org_id=current_user.org_id,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
parent_type=parent_type.value if parent_type else None,
|
||||
parent_id=parent_id,
|
||||
)
|
||||
return [NoteOut.model_validate(n) for n in notes]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{note_id}",
|
||||
response_model=NoteOut,
|
||||
summary="Get one note",
|
||||
)
|
||||
async def get_note(
|
||||
note_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> NoteOut:
|
||||
note = await svc_get_note(db, note_id, org_id=current_user.org_id)
|
||||
if note is None:
|
||||
raise HTTPException(status_code=404, detail="Note not found")
|
||||
return NoteOut.model_validate(note)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{note_id}",
|
||||
response_model=NoteOut,
|
||||
summary="Update a note's body",
|
||||
)
|
||||
async def update_note(
|
||||
note_id: int,
|
||||
payload: NoteUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> NoteOut:
|
||||
note = await svc_get_note(db, note_id, org_id=current_user.org_id)
|
||||
if note is None:
|
||||
raise HTTPException(status_code=404, detail="Note not found")
|
||||
updated = await svc_update_note(db, note, payload)
|
||||
return NoteOut.model_validate(updated)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{note_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
response_class=Response,
|
||||
summary="Soft-delete a note",
|
||||
)
|
||||
async def delete_note(
|
||||
note_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Response:
|
||||
note = await svc_get_note(db, note_id, org_id=current_user.org_id)
|
||||
if note is None:
|
||||
raise HTTPException(status_code=404, detail="Note not found")
|
||||
await svc_soft_delete_note(db, note)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Tag API endpoints (R-2: polymorphic parent validation)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.core.deps import get_current_user
|
||||
from app.models.user import User
|
||||
from app.schemas.tag import TagCreate, TagLinkCreate, TagLinkOut, TagOut
|
||||
from app.services.tag_service import (
|
||||
DuplicateTagLink,
|
||||
InvalidParent,
|
||||
TagNotFound,
|
||||
create_tag as svc_create_tag,
|
||||
link_tag as svc_link_tag,
|
||||
list_tags as svc_list_tags,
|
||||
unlink_tag as svc_unlink_tag,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/tags", tags=["tags"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/",
|
||||
response_model=TagOut,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Create a new tag",
|
||||
)
|
||||
async def create_tag(
|
||||
payload: TagCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> TagOut:
|
||||
tag = await svc_create_tag(db, payload, org_id=current_user.org_id)
|
||||
return TagOut.model_validate(tag)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/",
|
||||
response_model=list[TagOut],
|
||||
summary="List all tags in the org",
|
||||
)
|
||||
async def list_tags(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> list[TagOut]:
|
||||
tags = await svc_list_tags(db, org_id=current_user.org_id)
|
||||
return [TagOut.model_validate(t) for t in tags]
|
||||
|
||||
|
||||
@router.post(
|
||||
"/link",
|
||||
response_model=TagLinkOut,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Link a tag to an entity (R-2: validates parent exists)",
|
||||
)
|
||||
async def link_tag_endpoint(
|
||||
payload: TagLinkCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> TagLinkOut:
|
||||
try:
|
||||
link = await svc_link_tag(db, payload, org_id=current_user.org_id)
|
||||
except InvalidParent as e:
|
||||
raise HTTPException(status_code=404, detail=str(e)) from e
|
||||
except TagNotFound as e:
|
||||
raise HTTPException(status_code=404, detail=str(e)) from e
|
||||
except DuplicateTagLink as e:
|
||||
raise HTTPException(status_code=409, detail=str(e)) from e
|
||||
return TagLinkOut.model_validate(link)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/link",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
response_class=Response,
|
||||
summary="Unlink a tag from an entity",
|
||||
)
|
||||
async def unlink_tag_endpoint(
|
||||
payload: TagLinkCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Response:
|
||||
deleted = await svc_unlink_tag(
|
||||
db,
|
||||
tag_id=payload.tag_id,
|
||||
parent_type=payload.parent_type,
|
||||
parent_id=payload.parent_id,
|
||||
org_id=current_user.org_id,
|
||||
)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Tag link not found")
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
+22
-2
@@ -23,7 +23,18 @@ from sqlalchemy.exc import SQLAlchemyError
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
|
||||
from app import __version__
|
||||
from app.api.v1 import auth, health, users
|
||||
from app.api.v1 import (
|
||||
accounts,
|
||||
activities,
|
||||
auth,
|
||||
contacts,
|
||||
dashboard,
|
||||
deals,
|
||||
health,
|
||||
notes,
|
||||
tags,
|
||||
users,
|
||||
)
|
||||
from app.core.config import get_settings
|
||||
from app.core.db import AsyncSessionLocal, dispose_engine
|
||||
|
||||
@@ -148,9 +159,10 @@ def create_app() -> FastAPI:
|
||||
request: Request, exc: RequestValidationError
|
||||
) -> JSONResponse:
|
||||
"""Format Pydantic validation errors consistently."""
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
content={"detail": exc.errors()},
|
||||
content={"detail": jsonable_encoder(exc.errors())},
|
||||
)
|
||||
|
||||
@app.exception_handler(SQLAlchemyError)
|
||||
@@ -169,6 +181,14 @@ def create_app() -> FastAPI:
|
||||
app.include_router(health.router)
|
||||
app.include_router(auth.router, prefix="/api/v1")
|
||||
app.include_router(users.router, prefix="/api/v1")
|
||||
# Phase 4b business routers
|
||||
app.include_router(accounts.router, prefix="/api/v1")
|
||||
app.include_router(contacts.router, prefix="/api/v1")
|
||||
app.include_router(deals.router, prefix="/api/v1")
|
||||
app.include_router(activities.router, prefix="/api/v1")
|
||||
app.include_router(notes.router, prefix="/api/v1")
|
||||
app.include_router(tags.router, prefix="/api/v1")
|
||||
app.include_router(dashboard.router, prefix="/api/v1")
|
||||
|
||||
return app
|
||||
|
||||
|
||||
@@ -1,14 +1,36 @@
|
||||
"""SQLAlchemy ORM models for the CRM system."""
|
||||
|
||||
from app.models.account import Account, AccountSize, Industry
|
||||
from app.models.activity import Activity, ActivityType
|
||||
from app.models.base import Base, OrgScopedMixin, SoftDeleteMixin, TimestampMixin
|
||||
from app.models.contact import Contact
|
||||
from app.models.deal import Deal, DealStage
|
||||
from app.models.deal_stage_history import DealStageHistory
|
||||
from app.models.note import Note, NoteParentType
|
||||
from app.models.org import Org
|
||||
from app.models.tag import Tag
|
||||
from app.models.tag_link import TagLink, TagLinkParentType
|
||||
from app.models.user import User, UserRole
|
||||
|
||||
__all__ = [
|
||||
"Account",
|
||||
"AccountSize",
|
||||
"Activity",
|
||||
"ActivityType",
|
||||
"Base",
|
||||
"Contact",
|
||||
"Deal",
|
||||
"DealStage",
|
||||
"DealStageHistory",
|
||||
"Industry",
|
||||
"Note",
|
||||
"NoteParentType",
|
||||
"Org",
|
||||
"OrgScopedMixin",
|
||||
"SoftDeleteMixin",
|
||||
"Tag",
|
||||
"TagLink",
|
||||
"TagLinkParentType",
|
||||
"TimestampMixin",
|
||||
"User",
|
||||
"UserRole",
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Account model: companies/organizations that the CRM tracks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING, Any, Optional
|
||||
|
||||
from sqlalchemy import JSON, ForeignKey, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base, OrgScopedMixin, SoftDeleteMixin, TimestampMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.user import User
|
||||
from app.models.contact import Contact
|
||||
from app.models.deal import Deal
|
||||
from app.models.activity import Activity
|
||||
from app.models.note import Note
|
||||
from app.models.tag_link import TagLink
|
||||
|
||||
|
||||
class Industry(str, Enum):
|
||||
"""Industry classification for accounts."""
|
||||
|
||||
startup = "startup"
|
||||
sme = "sme"
|
||||
midmarket = "midmarket"
|
||||
enterprise = "enterprise"
|
||||
other = "other"
|
||||
|
||||
|
||||
class AccountSize(str, Enum):
|
||||
"""Company size category."""
|
||||
|
||||
startup = "startup"
|
||||
sme = "sme"
|
||||
midmarket = "midmarket"
|
||||
enterprise = "enterprise"
|
||||
|
||||
|
||||
class Account(Base, TimestampMixin, SoftDeleteMixin, OrgScopedMixin):
|
||||
__tablename__ = "accounts"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
website: Mapped[Optional[str]] = mapped_column(String(512), nullable=True)
|
||||
industry: Mapped[Optional[Industry]] = mapped_column(
|
||||
String(32), nullable=True, index=True
|
||||
)
|
||||
size: Mapped[Optional[AccountSize]] = mapped_column(String(32), nullable=True)
|
||||
address: Mapped[Optional[dict[str, Any]]] = mapped_column(JSON, nullable=True)
|
||||
owner_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("users.id"), nullable=False, index=True
|
||||
)
|
||||
|
||||
# Relationships
|
||||
owner: Mapped["User"] = relationship(
|
||||
"User", foreign_keys=[owner_id], lazy="joined"
|
||||
)
|
||||
contacts: Mapped[list["Contact"]] = relationship(
|
||||
"Contact", back_populates="account", lazy="selectin"
|
||||
)
|
||||
deals: Mapped[list["Deal"]] = relationship(
|
||||
"Deal", back_populates="account", lazy="selectin"
|
||||
)
|
||||
activities: Mapped[list["Activity"]] = relationship(
|
||||
"Activity", back_populates="account", lazy="selectin"
|
||||
)
|
||||
notes: Mapped[list["Note"]] = relationship(
|
||||
"Note",
|
||||
primaryjoin="and_(Account.id==foreign(Note.parent_id), Note.parent_type=='account')",
|
||||
viewonly=True,
|
||||
lazy="selectin",
|
||||
)
|
||||
tags: Mapped[list["TagLink"]] = relationship(
|
||||
"TagLink",
|
||||
primaryjoin="and_(Account.id==foreign(TagLink.parent_id), TagLink.parent_type=='account')",
|
||||
viewonly=True,
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Account id={self.id} name={self.name!r}>"
|
||||
|
||||
|
||||
__all__ = ["Account", "AccountSize", "Industry"]
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Activity model: calls, meetings, emails, tasks, notes tied to any entity."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base, OrgScopedMixin, SoftDeleteMixin, TimestampMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.user import User
|
||||
from app.models.account import Account
|
||||
from app.models.contact import Contact
|
||||
from app.models.deal import Deal
|
||||
|
||||
|
||||
class ActivityType(str, Enum):
|
||||
"""Type of activity."""
|
||||
|
||||
call = "call"
|
||||
meeting = "meeting"
|
||||
email = "email"
|
||||
task = "task"
|
||||
note = "note"
|
||||
|
||||
|
||||
class Activity(Base, TimestampMixin, SoftDeleteMixin, OrgScopedMixin):
|
||||
__tablename__ = "activities"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
type: Mapped[ActivityType] = mapped_column(
|
||||
String(32), nullable=False, index=True
|
||||
)
|
||||
subject: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
body: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
due_date: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True, index=True
|
||||
)
|
||||
completed_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
account_id: Mapped[Optional[int]] = mapped_column(
|
||||
ForeignKey("accounts.id"), nullable=True, index=True
|
||||
)
|
||||
contact_id: Mapped[Optional[int]] = mapped_column(
|
||||
ForeignKey("contacts.id"), nullable=True, index=True
|
||||
)
|
||||
deal_id: Mapped[Optional[int]] = mapped_column(
|
||||
ForeignKey("deals.id"), nullable=True, index=True
|
||||
)
|
||||
owner_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("users.id"), nullable=False, index=True
|
||||
)
|
||||
|
||||
# Relationships
|
||||
account: Mapped[Optional["Account"]] = relationship(
|
||||
"Account", back_populates="activities", lazy="selectin"
|
||||
)
|
||||
contact: Mapped[Optional["Contact"]] = relationship(
|
||||
"Contact", back_populates="activities", lazy="selectin"
|
||||
)
|
||||
deal: Mapped[Optional["Deal"]] = relationship(
|
||||
"Deal", back_populates="activities", lazy="selectin"
|
||||
)
|
||||
owner: Mapped["User"] = relationship(
|
||||
"User", foreign_keys=[owner_id], lazy="joined"
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Activity id={self.id} type={self.type} subject={self.subject!r}>"
|
||||
|
||||
|
||||
__all__ = ["Activity", "ActivityType"]
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Contact model: people at accounts (or standalone contacts)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from sqlalchemy import ForeignKey, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base, OrgScopedMixin, SoftDeleteMixin, TimestampMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.user import User
|
||||
from app.models.account import Account
|
||||
from app.models.activity import Activity
|
||||
from app.models.note import Note
|
||||
from app.models.tag_link import TagLink
|
||||
|
||||
|
||||
class Contact(Base, TimestampMixin, SoftDeleteMixin, OrgScopedMixin):
|
||||
__tablename__ = "contacts"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
first_name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
last_name: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
|
||||
email: Mapped[Optional[str]] = mapped_column(String(255), nullable=True, index=True)
|
||||
phone: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
|
||||
account_id: Mapped[Optional[int]] = mapped_column(
|
||||
ForeignKey("accounts.id"), nullable=True, index=True
|
||||
)
|
||||
owner_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("users.id"), nullable=False, index=True
|
||||
)
|
||||
|
||||
# Relationships
|
||||
account: Mapped[Optional["Account"]] = relationship(
|
||||
"Account", back_populates="contacts", lazy="selectin"
|
||||
)
|
||||
owner: Mapped["User"] = relationship(
|
||||
"User", foreign_keys=[owner_id], lazy="joined"
|
||||
)
|
||||
activities: Mapped[list["Activity"]] = relationship(
|
||||
"Activity", back_populates="contact", lazy="selectin"
|
||||
)
|
||||
notes: Mapped[list["Note"]] = relationship(
|
||||
"Note",
|
||||
primaryjoin="and_(Contact.id==foreign(Note.parent_id), Note.parent_type=='contact')",
|
||||
viewonly=True,
|
||||
lazy="selectin",
|
||||
)
|
||||
tags: Mapped[list["TagLink"]] = relationship(
|
||||
"TagLink",
|
||||
primaryjoin="and_(Contact.id==foreign(TagLink.parent_id), TagLink.parent_type=='contact')",
|
||||
viewonly=True,
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Contact id={self.id} {self.first_name} {self.last_name!r}>"
|
||||
|
||||
|
||||
__all__ = ["Contact"]
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Deal model: sales opportunities tied to an account."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from sqlalchemy import Date, ForeignKey, Numeric, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base, OrgScopedMixin, SoftDeleteMixin, TimestampMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.user import User
|
||||
from app.models.account import Account
|
||||
from app.models.activity import Activity
|
||||
from app.models.note import Note
|
||||
from app.models.tag_link import TagLink
|
||||
from app.models.deal_stage_history import DealStageHistory
|
||||
|
||||
|
||||
class DealStage(str, Enum):
|
||||
"""Sales pipeline stage for a deal."""
|
||||
|
||||
lead = "lead"
|
||||
qualified = "qualified"
|
||||
proposal = "proposal"
|
||||
negotiation = "negotiation"
|
||||
won = "won"
|
||||
lost = "lost"
|
||||
|
||||
|
||||
class Deal(Base, TimestampMixin, SoftDeleteMixin, OrgScopedMixin):
|
||||
__tablename__ = "deals"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
value: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False, default=Decimal("0"))
|
||||
currency: Mapped[str] = mapped_column(
|
||||
String(3), nullable=False, default="EUR", server_default="EUR"
|
||||
)
|
||||
stage: Mapped[DealStage] = mapped_column(
|
||||
String(32),
|
||||
nullable=False,
|
||||
default=DealStage.lead,
|
||||
server_default=DealStage.lead.value,
|
||||
index=True,
|
||||
)
|
||||
close_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
|
||||
account_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("accounts.id"), nullable=False, index=True
|
||||
)
|
||||
owner_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("users.id"), nullable=False, index=True
|
||||
)
|
||||
won_lost_reason: Mapped[Optional[str]] = mapped_column(String(512), nullable=True)
|
||||
|
||||
# Relationships
|
||||
account: Mapped["Account"] = relationship(
|
||||
"Account", back_populates="deals", lazy="selectin"
|
||||
)
|
||||
owner: Mapped["User"] = relationship(
|
||||
"User", foreign_keys=[owner_id], lazy="joined"
|
||||
)
|
||||
stage_history: Mapped[list["DealStageHistory"]] = relationship(
|
||||
"DealStageHistory",
|
||||
back_populates="deal",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="selectin",
|
||||
order_by="DealStageHistory.created_at",
|
||||
)
|
||||
activities: Mapped[list["Activity"]] = relationship(
|
||||
"Activity", back_populates="deal", lazy="selectin"
|
||||
)
|
||||
notes: Mapped[list["Note"]] = relationship(
|
||||
"Note",
|
||||
primaryjoin="and_(Deal.id==foreign(Note.parent_id), Note.parent_type=='deal')",
|
||||
viewonly=True,
|
||||
lazy="selectin",
|
||||
)
|
||||
tags: Mapped[list["TagLink"]] = relationship(
|
||||
"TagLink",
|
||||
primaryjoin="and_(Deal.id==foreign(TagLink.parent_id), TagLink.parent_type=='deal')",
|
||||
viewonly=True,
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Deal id={self.id} title={self.title!r} stage={self.stage}>"
|
||||
|
||||
|
||||
__all__ = ["Deal", "DealStage"]
|
||||
@@ -0,0 +1,44 @@
|
||||
"""DealStageHistory model: audit trail of stage transitions for a deal."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from sqlalchemy import ForeignKey, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base, OrgScopedMixin, TimestampMixin
|
||||
from app.models.deal import DealStage
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.user import User
|
||||
from app.models.deal import Deal
|
||||
|
||||
|
||||
class DealStageHistory(Base, TimestampMixin, OrgScopedMixin):
|
||||
"""Records each deal stage transition. Append-only — no soft-delete."""
|
||||
|
||||
__tablename__ = "deal_stage_history"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
deal_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("deals.id"), nullable=False, index=True
|
||||
)
|
||||
from_stage: Mapped[Optional[DealStage]] = mapped_column(String(32), nullable=True)
|
||||
to_stage: Mapped[DealStage] = mapped_column(String(32), nullable=False)
|
||||
changed_by: Mapped[int] = mapped_column(
|
||||
ForeignKey("users.id"), nullable=False
|
||||
)
|
||||
|
||||
deal: Mapped["Deal"] = relationship(
|
||||
"Deal", back_populates="stage_history", lazy="joined"
|
||||
)
|
||||
changer: Mapped["User"] = relationship(
|
||||
"User", foreign_keys=[changed_by], lazy="joined"
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<DealStageHistory id={self.id} deal={self.deal_id} {self.from_stage}->{self.to_stage}>"
|
||||
|
||||
|
||||
__all__ = ["DealStageHistory"]
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Note model: free-text notes attached polymorphically to accounts/contacts/deals."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base, OrgScopedMixin, SoftDeleteMixin, TimestampMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
class NoteParentType(str, Enum):
|
||||
"""Polymorphic parent type for notes (no DB FK, validated in service layer)."""
|
||||
|
||||
account = "account"
|
||||
contact = "contact"
|
||||
deal = "deal"
|
||||
|
||||
|
||||
class Note(Base, TimestampMixin, SoftDeleteMixin, OrgScopedMixin):
|
||||
__tablename__ = "notes"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
body: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
author_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("users.id"), nullable=False, index=True
|
||||
)
|
||||
parent_type: Mapped[NoteParentType] = mapped_column(
|
||||
String(32), nullable=False, index=True
|
||||
)
|
||||
# parent_id is intentionally NOT a DB FK because of polymorphic parent.
|
||||
# Service layer (note_service.create_note) validates existence.
|
||||
parent_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
|
||||
|
||||
author: Mapped["User"] = relationship(
|
||||
"User", foreign_keys=[author_id], lazy="joined"
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Note id={self.id} parent={self.parent_type}:{self.parent_id}>"
|
||||
|
||||
|
||||
__all__ = ["Note", "NoteParentType"]
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Tag model: labels that can be linked to accounts/contacts/deals."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base, OrgScopedMixin, SoftDeleteMixin, TimestampMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.tag_link import TagLink
|
||||
|
||||
|
||||
class Tag(Base, TimestampMixin, SoftDeleteMixin, OrgScopedMixin):
|
||||
__tablename__ = "tags"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
color: Mapped[str] = mapped_column(
|
||||
String(7), nullable=False, default="#3B82F6", server_default="#3B82F6"
|
||||
)
|
||||
|
||||
# Relationships
|
||||
links: Mapped[list["TagLink"]] = relationship(
|
||||
"TagLink", back_populates="tag", cascade="all, delete-orphan", lazy="selectin"
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Tag id={self.id} name={self.name!r}>"
|
||||
|
||||
|
||||
__all__ = ["Tag"]
|
||||
@@ -0,0 +1,50 @@
|
||||
"""TagLink model: polymorphic many-to-many between tags and accounts/contacts/deals."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import ForeignKey, Integer, String, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base, OrgScopedMixin, SoftDeleteMixin, TimestampMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.tag import Tag
|
||||
|
||||
|
||||
class TagLinkParentType(str, Enum):
|
||||
"""Polymorphic parent type for tag links (no DB FK, validated in service layer)."""
|
||||
|
||||
account = "account"
|
||||
contact = "contact"
|
||||
deal = "deal"
|
||||
|
||||
|
||||
class TagLink(Base, TimestampMixin, SoftDeleteMixin, OrgScopedMixin):
|
||||
__tablename__ = "tag_links"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tag_id", "parent_type", "parent_id", name="uq_tag_link"
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
tag_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("tags.id"), nullable=False, index=True
|
||||
)
|
||||
parent_type: Mapped[TagLinkParentType] = mapped_column(
|
||||
String(32), nullable=False, index=True
|
||||
)
|
||||
# parent_id is intentionally NOT a DB FK because of polymorphic parent.
|
||||
# Service layer (tag_service.link_tag) validates existence.
|
||||
parent_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
|
||||
|
||||
tag: Mapped["Tag"] = relationship("Tag", back_populates="links", lazy="joined")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<TagLink id={self.id} tag={self.tag_id} parent={self.parent_type}:{self.parent_id}>"
|
||||
|
||||
|
||||
__all__ = ["TagLink", "TagLinkParentType"]
|
||||
+86
-1
@@ -1 +1,86 @@
|
||||
"""Pydantic schemas for request/response validation."""
|
||||
"""Pydantic schemas for the CRM system."""
|
||||
|
||||
from app.schemas.account import (
|
||||
AccountCreate,
|
||||
AccountListItem,
|
||||
AccountOut,
|
||||
AccountUpdate,
|
||||
)
|
||||
from app.schemas.activity import (
|
||||
ActivityCompleteRequest,
|
||||
ActivityCreate,
|
||||
ActivityOut,
|
||||
ActivityUpdate,
|
||||
)
|
||||
from app.schemas.auth import (
|
||||
LogoutResponse,
|
||||
RegisterResponse,
|
||||
TokenResponse,
|
||||
UserLoginRequest,
|
||||
UserRegisterRequest,
|
||||
)
|
||||
from app.schemas.common import PaginatedResponse, PaginationParams
|
||||
from app.schemas.contact import (
|
||||
ContactCreate,
|
||||
ContactListItem,
|
||||
ContactOut,
|
||||
ContactUpdate,
|
||||
)
|
||||
from app.schemas.dashboard import ActivityFeedItem, KPIOut
|
||||
from app.schemas.deal import (
|
||||
DealCreate,
|
||||
DealListItem,
|
||||
DealOut,
|
||||
DealPipelineOut,
|
||||
DealStageUpdate,
|
||||
DealUpdate,
|
||||
)
|
||||
from app.schemas.note import NoteCreate, NoteOut, NoteUpdate
|
||||
from app.schemas.tag import TagCreate, TagLinkCreate, TagLinkOut, TagOut
|
||||
from app.schemas.user import (
|
||||
UserCreateRequest,
|
||||
UserListResponse,
|
||||
UserOut,
|
||||
UserUpdate,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AccountCreate",
|
||||
"AccountListItem",
|
||||
"AccountOut",
|
||||
"AccountUpdate",
|
||||
"ActivityCompleteRequest",
|
||||
"ActivityCreate",
|
||||
"ActivityFeedItem",
|
||||
"ActivityOut",
|
||||
"ActivityUpdate",
|
||||
"ContactCreate",
|
||||
"ContactListItem",
|
||||
"ContactOut",
|
||||
"ContactUpdate",
|
||||
"DealCreate",
|
||||
"DealListItem",
|
||||
"DealOut",
|
||||
"DealPipelineOut",
|
||||
"DealStageUpdate",
|
||||
"DealUpdate",
|
||||
"KPIOut",
|
||||
"LogoutResponse",
|
||||
"NoteCreate",
|
||||
"NoteOut",
|
||||
"NoteUpdate",
|
||||
"PaginatedResponse",
|
||||
"PaginationParams",
|
||||
"RegisterResponse",
|
||||
"TagCreate",
|
||||
"TagLinkCreate",
|
||||
"TagLinkOut",
|
||||
"TagOut",
|
||||
"TokenResponse",
|
||||
"UserCreateRequest",
|
||||
"UserListResponse",
|
||||
"UserLoginRequest",
|
||||
"UserOut",
|
||||
"UserRegisterRequest",
|
||||
"UserUpdate",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Pydantic schemas for Account endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.models.account import AccountSize, Industry
|
||||
|
||||
|
||||
class AccountBase(BaseModel):
|
||||
"""Shared fields for create/update."""
|
||||
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
website: Optional[str] = Field(None, max_length=512)
|
||||
industry: Optional[Industry] = None
|
||||
size: Optional[AccountSize] = None
|
||||
address: Optional[dict[str, Any]] = None
|
||||
|
||||
|
||||
class AccountCreate(AccountBase):
|
||||
"""Request body for POST /api/v1/accounts/."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class AccountUpdate(BaseModel):
|
||||
"""Request body for PATCH /api/v1/accounts/{id}. All optional."""
|
||||
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
website: Optional[str] = Field(None, max_length=512)
|
||||
industry: Optional[Industry] = None
|
||||
size: Optional[AccountSize] = None
|
||||
address: Optional[dict[str, Any]] = None
|
||||
|
||||
|
||||
class AccountOut(AccountBase):
|
||||
"""Response schema for an account."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
org_id: int
|
||||
owner_id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
deleted_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class AccountListItem(AccountOut):
|
||||
"""Slim schema for list responses."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
__all__ = ["AccountCreate", "AccountListItem", "AccountOut", "AccountUpdate"]
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Pydantic schemas for Activity endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
from app.models.activity import ActivityType
|
||||
|
||||
|
||||
class ActivityBase(BaseModel):
|
||||
type: ActivityType
|
||||
subject: str = Field(..., min_length=1, max_length=255)
|
||||
body: Optional[str] = None
|
||||
due_date: Optional[datetime] = None
|
||||
account_id: Optional[int] = None
|
||||
contact_id: Optional[int] = None
|
||||
deal_id: Optional[int] = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_at_least_one_parent(self) -> "ActivityBase":
|
||||
if self.account_id is None and self.contact_id is None and self.deal_id is None:
|
||||
raise ValueError(
|
||||
"At least one of account_id, contact_id, deal_id must be set"
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
class ActivityCreate(ActivityBase):
|
||||
pass
|
||||
|
||||
|
||||
class ActivityUpdate(BaseModel):
|
||||
type: Optional[ActivityType] = None
|
||||
subject: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
body: Optional[str] = None
|
||||
due_date: Optional[datetime] = None
|
||||
account_id: Optional[int] = None
|
||||
contact_id: Optional[int] = None
|
||||
deal_id: Optional[int] = None
|
||||
|
||||
|
||||
class ActivityOut(ActivityBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
org_id: int
|
||||
owner_id: int
|
||||
completed_at: Optional[datetime] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
deleted_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class ActivityCompleteRequest(BaseModel):
|
||||
"""Body for PATCH /api/v1/activities/{id}/complete."""
|
||||
|
||||
outcome: Optional[str] = Field(None, max_length=2048)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ActivityCompleteRequest",
|
||||
"ActivityCreate",
|
||||
"ActivityOut",
|
||||
"ActivityUpdate",
|
||||
]
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Common Pydantic schemas: pagination."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Generic, List, Optional, TypeVar
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class PaginationParams(BaseModel):
|
||||
"""Standard pagination query params."""
|
||||
|
||||
skip: int = Field(default=0, ge=0)
|
||||
limit: int = Field(default=20, ge=1, le=100)
|
||||
|
||||
|
||||
class PaginatedResponse(BaseModel, Generic[T]):
|
||||
"""Generic paginated response."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
items: List[T] # type: ignore[valid-type]
|
||||
total: int
|
||||
skip: int
|
||||
limit: int
|
||||
|
||||
|
||||
__all__ = ["PaginatedResponse", "PaginationParams"]
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Pydantic schemas for Contact endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field
|
||||
|
||||
|
||||
class ContactBase(BaseModel):
|
||||
first_name: str = Field(..., min_length=1, max_length=128)
|
||||
last_name: str = Field(..., min_length=1, max_length=128)
|
||||
email: Optional[EmailStr] = None
|
||||
phone: Optional[str] = Field(None, max_length=64)
|
||||
account_id: Optional[int] = None
|
||||
|
||||
|
||||
class ContactCreate(ContactBase):
|
||||
pass
|
||||
|
||||
|
||||
class ContactUpdate(BaseModel):
|
||||
first_name: Optional[str] = Field(None, min_length=1, max_length=128)
|
||||
last_name: Optional[str] = Field(None, min_length=1, max_length=128)
|
||||
email: Optional[EmailStr] = None
|
||||
phone: Optional[str] = Field(None, max_length=64)
|
||||
account_id: Optional[int] = None
|
||||
|
||||
|
||||
class ContactOut(ContactBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
org_id: int
|
||||
owner_id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
deleted_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class ContactListItem(ContactOut):
|
||||
pass
|
||||
|
||||
|
||||
__all__ = ["ContactCreate", "ContactListItem", "ContactOut", "ContactUpdate"]
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Pydantic schemas for Dashboard endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from app.models.activity import ActivityType
|
||||
|
||||
|
||||
class KPIOut(BaseModel):
|
||||
"""Headline KPIs for the org dashboard."""
|
||||
|
||||
open_deals_count: int
|
||||
pipeline_value: float
|
||||
won_this_month: int
|
||||
conversion_rate: float
|
||||
|
||||
|
||||
class ActivityFeedItem(BaseModel):
|
||||
"""One item in the activity feed."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
type: ActivityType
|
||||
subject: str
|
||||
body: Optional[str] = None
|
||||
account_id: Optional[int] = None
|
||||
contact_id: Optional[int] = None
|
||||
deal_id: Optional[int] = None
|
||||
owner_id: int
|
||||
completed_at: Optional[datetime] = None
|
||||
due_date: Optional[datetime] = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
__all__ = ["ActivityFeedItem", "KPIOut"]
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Pydantic schemas for Deal endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.models.deal import DealStage
|
||||
|
||||
|
||||
class DealBase(BaseModel):
|
||||
title: str = Field(..., min_length=1, max_length=255)
|
||||
value: Decimal = Field(default=Decimal("0"), max_digits=12, decimal_places=2)
|
||||
currency: str = Field(default="EUR", min_length=3, max_length=3)
|
||||
stage: DealStage = DealStage.lead
|
||||
close_date: Optional[date] = None
|
||||
account_id: int
|
||||
won_lost_reason: Optional[str] = Field(None, max_length=512)
|
||||
|
||||
|
||||
class DealCreate(DealBase):
|
||||
pass
|
||||
|
||||
|
||||
class DealUpdate(BaseModel):
|
||||
title: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
value: Optional[Decimal] = Field(None, max_digits=12, decimal_places=2)
|
||||
currency: Optional[str] = Field(None, min_length=3, max_length=3)
|
||||
close_date: Optional[date] = None
|
||||
won_lost_reason: Optional[str] = Field(None, max_length=512)
|
||||
|
||||
|
||||
class DealOut(DealBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
org_id: int
|
||||
owner_id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
deleted_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class DealListItem(DealOut):
|
||||
pass
|
||||
|
||||
|
||||
class DealStageUpdate(BaseModel):
|
||||
"""Body for PATCH /api/v1/deals/{id}/stage."""
|
||||
|
||||
stage: DealStage
|
||||
reason: Optional[str] = Field(None, max_length=512)
|
||||
|
||||
|
||||
class DealPipelineOut(BaseModel):
|
||||
"""A single stage's slice of the pipeline."""
|
||||
|
||||
stage: DealStage
|
||||
count: int
|
||||
total_value: float
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DealCreate",
|
||||
"DealListItem",
|
||||
"DealOut",
|
||||
"DealPipelineOut",
|
||||
"DealStageUpdate",
|
||||
"DealUpdate",
|
||||
]
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Pydantic schemas for Note endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.models.note import NoteParentType
|
||||
|
||||
|
||||
class NoteCreate(BaseModel):
|
||||
"""Body for POST /api/v1/notes/."""
|
||||
|
||||
body: str = Field(..., min_length=1)
|
||||
parent_type: NoteParentType
|
||||
parent_id: int = Field(..., ge=1)
|
||||
|
||||
|
||||
class NoteUpdate(BaseModel):
|
||||
"""Body for PATCH /api/v1/notes/{id}."""
|
||||
|
||||
body: Optional[str] = Field(None, min_length=1)
|
||||
|
||||
|
||||
class NoteOut(BaseModel):
|
||||
"""Response schema for a note."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
org_id: int
|
||||
body: str
|
||||
author_id: int
|
||||
parent_type: NoteParentType
|
||||
parent_id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
deleted_at: Optional[datetime] = None
|
||||
|
||||
|
||||
__all__ = ["NoteCreate", "NoteOut", "NoteUpdate"]
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Pydantic schemas for Tag and TagLink endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.models.tag_link import TagLinkParentType
|
||||
|
||||
|
||||
class TagCreate(BaseModel):
|
||||
"""Body for POST /api/v1/tags/."""
|
||||
|
||||
name: str = Field(..., min_length=1, max_length=64)
|
||||
color: str = Field(default="#3B82F6", pattern=r"^#[0-9A-Fa-f]{6}$")
|
||||
|
||||
|
||||
class TagOut(BaseModel):
|
||||
"""Response schema for a tag."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
org_id: int
|
||||
name: str
|
||||
color: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
deleted_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class TagLinkCreate(BaseModel):
|
||||
"""Body for POST /api/v1/tags/link."""
|
||||
|
||||
tag_id: int = Field(..., ge=1)
|
||||
parent_type: TagLinkParentType
|
||||
parent_id: int = Field(..., ge=1)
|
||||
|
||||
|
||||
class TagLinkOut(BaseModel):
|
||||
"""Response schema for a tag link."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
org_id: int
|
||||
tag_id: int
|
||||
parent_type: TagLinkParentType
|
||||
parent_id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
deleted_at: Optional[datetime] = None
|
||||
|
||||
|
||||
__all__ = ["TagCreate", "TagLinkCreate", "TagLinkOut", "TagOut"]
|
||||
@@ -1 +1,8 @@
|
||||
"""Business logic service layer."""
|
||||
"""Service layer for the CRM system.
|
||||
|
||||
Submodules are imported directly by routers via
|
||||
`from app.services.<name> import <func>` to avoid circular-import issues
|
||||
during application startup.
|
||||
"""
|
||||
|
||||
__all__: list[str] = []
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"""OrgScopedQuery helper: central tenant-isolation layer (R-1 mitigation).
|
||||
|
||||
Every business query passes through this helper so that:
|
||||
- `org_id` is always applied (multi-tenant isolation)
|
||||
- `deleted_at IS NULL` is applied by default (soft-delete)
|
||||
- R-1 risk is mitigated: no query accidentally crosses tenant boundaries
|
||||
|
||||
In v1 (single-tenant) org_id defaults to 1. In v2 (multi-tenant) the
|
||||
router layer passes `current_user.org_id`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
||||
class OrgScopedQuery:
|
||||
"""Zentraler Query-Helper, der jede Query mit org_id filtert.
|
||||
|
||||
v1: org_id = 1 (Single-Tenant-Default)
|
||||
v2: org_id = current_user.org_id
|
||||
"""
|
||||
|
||||
def __init__(self, model: type[Any], db: AsyncSession, org_id: int = 1) -> None:
|
||||
self.model = model
|
||||
self.db = db
|
||||
self.org_id = org_id
|
||||
|
||||
async def get(self, id: int) -> Any:
|
||||
"""Fetch a single record by id, scoped to org and not soft-deleted."""
|
||||
result = await self.db.execute(
|
||||
select(self.model).where(
|
||||
self.model.id == id,
|
||||
self.model.org_id == self.org_id,
|
||||
self.model.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def list(
|
||||
self,
|
||||
skip: int = 0,
|
||||
limit: int = 20,
|
||||
order_by: Optional[Any] = None,
|
||||
**filters: Any,
|
||||
) -> list[Any]:
|
||||
"""List records scoped to org, with optional filters and pagination."""
|
||||
stmt = select(self.model).where(
|
||||
self.model.org_id == self.org_id,
|
||||
self.model.deleted_at.is_(None),
|
||||
)
|
||||
for field, value in filters.items():
|
||||
if value is not None:
|
||||
stmt = stmt.where(getattr(self.model, field) == value)
|
||||
if order_by is not None:
|
||||
stmt = stmt.order_by(order_by)
|
||||
stmt = stmt.offset(skip).limit(limit)
|
||||
result = await self.db.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def count(self, **filters: Any) -> int:
|
||||
"""Count records scoped to org, with optional filters."""
|
||||
from sqlalchemy import func as sa_func
|
||||
|
||||
stmt = select(sa_func.count()).select_from(self.model).where(
|
||||
self.model.org_id == self.org_id,
|
||||
self.model.deleted_at.is_(None),
|
||||
)
|
||||
for field, value in filters.items():
|
||||
if value is not None:
|
||||
stmt = stmt.where(getattr(self.model, field) == value)
|
||||
result = await self.db.execute(stmt)
|
||||
return int(result.scalar_one())
|
||||
|
||||
|
||||
__all__ = ["OrgScopedQuery"]
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Account service: create, get, list (with filters), update, soft-delete."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.account import Account
|
||||
from app.schemas.account import AccountCreate, AccountUpdate
|
||||
|
||||
|
||||
class AccountNotFound(Exception):
|
||||
"""Raised when an account lookup fails."""
|
||||
|
||||
|
||||
async def create_account(
|
||||
db: AsyncSession, payload: AccountCreate, *, org_id: int, owner_id: int
|
||||
) -> Account:
|
||||
"""Create a new account in the given org."""
|
||||
from app.services._base import OrgScopedQuery
|
||||
|
||||
account = Account(
|
||||
org_id=org_id,
|
||||
name=payload.name,
|
||||
website=payload.website,
|
||||
industry=payload.industry,
|
||||
size=payload.size,
|
||||
address=payload.address,
|
||||
owner_id=owner_id,
|
||||
)
|
||||
db.add(account)
|
||||
await db.commit()
|
||||
await db.refresh(account)
|
||||
return account
|
||||
|
||||
|
||||
async def get_account(
|
||||
db: AsyncSession, account_id: int, *, org_id: int
|
||||
) -> Optional[Account]:
|
||||
"""Fetch a single account by id (org-scoped, not soft-deleted)."""
|
||||
from app.services._base import OrgScopedQuery
|
||||
|
||||
q = OrgScopedQuery(Account, db, org_id=org_id)
|
||||
return await q.get(account_id)
|
||||
|
||||
|
||||
async def list_accounts(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
org_id: int,
|
||||
skip: int = 0,
|
||||
limit: int = 20,
|
||||
industry: Optional[str] = None,
|
||||
size: Optional[str] = None,
|
||||
owner_id: Optional[int] = None,
|
||||
q: Optional[str] = None,
|
||||
) -> list[Account]:
|
||||
"""List accounts with filters and search."""
|
||||
from app.services._base import OrgScopedQuery
|
||||
|
||||
scoped = OrgScopedQuery(Account, db, org_id=org_id)
|
||||
base = await scoped.list(
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
order_by=Account.id,
|
||||
industry=industry,
|
||||
size=size,
|
||||
owner_id=owner_id,
|
||||
)
|
||||
if q:
|
||||
# In-memory filter for name (small datasets, fine for v1)
|
||||
needle = q.lower()
|
||||
base = [a for a in base if needle in a.name.lower()]
|
||||
return base
|
||||
|
||||
|
||||
async def update_account(
|
||||
db: AsyncSession, account: Account, payload: AccountUpdate
|
||||
) -> Account:
|
||||
"""Apply partial updates to an account."""
|
||||
data = payload.model_dump(exclude_unset=True)
|
||||
for field, value in data.items():
|
||||
if value is not None:
|
||||
setattr(account, field, value)
|
||||
await db.commit()
|
||||
await db.refresh(account)
|
||||
return account
|
||||
|
||||
|
||||
async def soft_delete_account(db: AsyncSession, account: Account) -> Account:
|
||||
"""Soft-delete an account by setting deleted_at to now."""
|
||||
account.deleted_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
await db.refresh(account)
|
||||
return account
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AccountNotFound",
|
||||
"create_account",
|
||||
"get_account",
|
||||
"list_accounts",
|
||||
"soft_delete_account",
|
||||
"update_account",
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -0,0 +1,135 @@
|
||||
"""Contact service: create, get, list (with filters), update, soft-delete."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.account import Account
|
||||
from app.models.contact import Contact
|
||||
from app.schemas.contact import ContactCreate, ContactUpdate
|
||||
from app.services._base import OrgScopedQuery
|
||||
|
||||
|
||||
class ContactNotFound(Exception):
|
||||
"""Raised when a contact lookup fails."""
|
||||
|
||||
|
||||
class InvalidAccount(Exception):
|
||||
"""Raised when account_id points to a non-existent account."""
|
||||
|
||||
|
||||
async def _validate_account(
|
||||
db: AsyncSession, account_id: int, *, org_id: int
|
||||
) -> None:
|
||||
"""Raise if account_id does not exist within the org."""
|
||||
if account_id is None:
|
||||
return
|
||||
q = OrgScopedQuery(Account, db, org_id=org_id)
|
||||
exists = await q.get(account_id)
|
||||
if exists is None:
|
||||
raise InvalidAccount(f"Account {account_id} not found in this org")
|
||||
|
||||
|
||||
async def create_contact(
|
||||
db: AsyncSession,
|
||||
payload: ContactCreate,
|
||||
*,
|
||||
org_id: int,
|
||||
owner_id: int,
|
||||
) -> Contact:
|
||||
"""Create a new contact. Validates account_id if provided."""
|
||||
await _validate_account(db, payload.account_id, org_id=org_id)
|
||||
contact = Contact(
|
||||
org_id=org_id,
|
||||
first_name=payload.first_name,
|
||||
last_name=payload.last_name,
|
||||
email=payload.email,
|
||||
phone=payload.phone,
|
||||
account_id=payload.account_id,
|
||||
owner_id=owner_id,
|
||||
)
|
||||
db.add(contact)
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError as e:
|
||||
await db.rollback()
|
||||
raise InvalidAccount(str(e)) from e
|
||||
await db.refresh(contact)
|
||||
return contact
|
||||
|
||||
|
||||
async def get_contact(
|
||||
db: AsyncSession, contact_id: int, *, org_id: int
|
||||
) -> Optional[Contact]:
|
||||
"""Fetch a single contact by id."""
|
||||
q = OrgScopedQuery(Contact, db, org_id=org_id)
|
||||
return await q.get(contact_id)
|
||||
|
||||
|
||||
async def list_contacts(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
org_id: int,
|
||||
skip: int = 0,
|
||||
limit: int = 20,
|
||||
account_id: Optional[int] = None,
|
||||
owner_id: Optional[int] = None,
|
||||
q: Optional[str] = None,
|
||||
) -> list[Contact]:
|
||||
"""List contacts with filters and search."""
|
||||
scoped = OrgScopedQuery(Contact, db, org_id=org_id)
|
||||
base = await scoped.list(
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
order_by=Contact.id,
|
||||
account_id=account_id,
|
||||
owner_id=owner_id,
|
||||
)
|
||||
if q:
|
||||
needle = q.lower()
|
||||
base = [
|
||||
c
|
||||
for c in base
|
||||
if needle in c.first_name.lower()
|
||||
or needle in c.last_name.lower()
|
||||
or (c.email and needle in c.email.lower())
|
||||
]
|
||||
return base
|
||||
|
||||
|
||||
async def update_contact(
|
||||
db: AsyncSession, contact: Contact, payload: ContactUpdate
|
||||
) -> Contact:
|
||||
"""Apply partial updates to a contact."""
|
||||
data = payload.model_dump(exclude_unset=True)
|
||||
if "account_id" in data and data["account_id"] is not None:
|
||||
await _validate_account(db, data["account_id"], org_id=contact.org_id)
|
||||
for field, value in data.items():
|
||||
if value is not None:
|
||||
setattr(contact, field, value)
|
||||
await db.commit()
|
||||
await db.refresh(contact)
|
||||
return contact
|
||||
|
||||
|
||||
async def soft_delete_contact(db: AsyncSession, contact: Contact) -> Contact:
|
||||
"""Soft-delete a contact."""
|
||||
contact.deleted_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
await db.refresh(contact)
|
||||
return contact
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ContactNotFound",
|
||||
"InvalidAccount",
|
||||
"create_contact",
|
||||
"get_contact",
|
||||
"list_contacts",
|
||||
"soft_delete_contact",
|
||||
"update_contact",
|
||||
]
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Dashboard service: KPIs and activity feed."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.activity import Activity
|
||||
from app.models.deal import Deal, DealStage
|
||||
from app.services._base import OrgScopedQuery
|
||||
|
||||
|
||||
async def get_kpis(db: AsyncSession, *, org_id: int) -> dict[str, object]:
|
||||
"""Compute headline KPIs for the org's dashboard."""
|
||||
scoped = OrgScopedQuery(Deal, db, org_id=org_id)
|
||||
open_stages = (DealStage.lead, DealStage.qualified, DealStage.proposal, DealStage.negotiation)
|
||||
|
||||
all_deals = await scoped.list(skip=0, limit=10_000, order_by=Deal.id)
|
||||
open_deals = [d for d in all_deals if d.stage in open_stages]
|
||||
open_deals_count = len(open_deals)
|
||||
|
||||
pipeline_value = float(
|
||||
sum((d.value for d in open_deals), Decimal("0"))
|
||||
)
|
||||
|
||||
now = datetime.now(UTC)
|
||||
# SQLite returns naive datetimes; strip tz for comparison
|
||||
month_start = now.replace(tzinfo=None, day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
won_this_month = [
|
||||
d for d in all_deals
|
||||
if d.stage == DealStage.won and d.created_at >= month_start
|
||||
]
|
||||
won_count = len(won_this_month)
|
||||
|
||||
won_total = sum(1 for d in all_deals if d.stage == DealStage.won)
|
||||
lost_total = sum(1 for d in all_deals if d.stage == DealStage.lost)
|
||||
if won_total + lost_total == 0:
|
||||
conversion_rate = 0.0
|
||||
else:
|
||||
conversion_rate = round((won_total / (won_total + lost_total)) * 100, 2)
|
||||
|
||||
return {
|
||||
"open_deals_count": open_deals_count,
|
||||
"pipeline_value": pipeline_value,
|
||||
"won_this_month": won_count,
|
||||
"conversion_rate": conversion_rate,
|
||||
}
|
||||
|
||||
|
||||
async def get_activity_feed(
|
||||
db: AsyncSession, *, org_id: int, limit: int = 20
|
||||
) -> list[Activity]:
|
||||
"""Return the most recent activities, ordered by created_at desc."""
|
||||
result = await db.execute(
|
||||
select(Activity)
|
||||
.where(Activity.org_id == org_id, Activity.deleted_at.is_(None))
|
||||
.order_by(Activity.created_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
__all__ = ["get_activity_feed", "get_kpis"]
|
||||
@@ -0,0 +1,193 @@
|
||||
"""Deal service: create, get, list, update, update_stage, get_pipeline, soft-delete."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from datetime import UTC, datetime
|
||||
from decimal import Decimal
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.account import Account
|
||||
from app.models.deal import Deal, DealStage
|
||||
from app.models.deal_stage_history import DealStageHistory
|
||||
from app.schemas.deal import DealCreate, DealUpdate
|
||||
from app.services._base import OrgScopedQuery
|
||||
|
||||
|
||||
class DealNotFound(Exception):
|
||||
"""Raised when a deal lookup fails."""
|
||||
|
||||
|
||||
class InvalidAccount(Exception):
|
||||
"""Raised when account_id points to a non-existent account."""
|
||||
|
||||
|
||||
def _stage_value(stage: DealStage | str) -> str:
|
||||
"""Normalize stage to its string value."""
|
||||
if hasattr(stage, "value"):
|
||||
return stage.value # type: ignore[union-attr]
|
||||
return str(stage)
|
||||
|
||||
|
||||
async def create_deal(
|
||||
db: AsyncSession,
|
||||
payload: DealCreate,
|
||||
*,
|
||||
org_id: int,
|
||||
owner_id: int,
|
||||
) -> Deal:
|
||||
"""Create a new deal. Validates account_id exists in org."""
|
||||
acc_q = OrgScopedQuery(Account, db, org_id=org_id)
|
||||
if await acc_q.get(payload.account_id) is None:
|
||||
raise InvalidAccount(f"Account {payload.account_id} not found in this org")
|
||||
deal = Deal(
|
||||
org_id=org_id,
|
||||
title=payload.title,
|
||||
value=payload.value,
|
||||
currency=payload.currency,
|
||||
stage=payload.stage,
|
||||
close_date=payload.close_date,
|
||||
account_id=payload.account_id,
|
||||
owner_id=owner_id,
|
||||
won_lost_reason=payload.won_lost_reason,
|
||||
)
|
||||
db.add(deal)
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError as e:
|
||||
await db.rollback()
|
||||
raise InvalidAccount(str(e)) from e
|
||||
await db.refresh(deal)
|
||||
# Initial stage history entry (from None → initial stage)
|
||||
history = DealStageHistory(
|
||||
org_id=org_id,
|
||||
deal_id=deal.id,
|
||||
from_stage=None,
|
||||
to_stage=deal.stage,
|
||||
changed_by=owner_id,
|
||||
)
|
||||
db.add(history)
|
||||
await db.commit()
|
||||
await db.refresh(deal)
|
||||
return deal
|
||||
|
||||
|
||||
async def get_deal(
|
||||
db: AsyncSession, deal_id: int, *, org_id: int
|
||||
) -> Optional[Deal]:
|
||||
"""Fetch a single deal by id."""
|
||||
q = OrgScopedQuery(Deal, db, org_id=org_id)
|
||||
return await q.get(deal_id)
|
||||
|
||||
|
||||
async def list_deals(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
org_id: int,
|
||||
skip: int = 0,
|
||||
limit: int = 20,
|
||||
stage: Optional[str] = None,
|
||||
owner_id: Optional[int] = None,
|
||||
account_id: Optional[int] = None,
|
||||
) -> list[Deal]:
|
||||
"""List deals with optional filters."""
|
||||
scoped = OrgScopedQuery(Deal, db, org_id=org_id)
|
||||
return await scoped.list(
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
order_by=Deal.id,
|
||||
stage=stage,
|
||||
owner_id=owner_id,
|
||||
account_id=account_id,
|
||||
)
|
||||
|
||||
|
||||
async def update_deal(
|
||||
db: AsyncSession, deal: Deal, payload: DealUpdate
|
||||
) -> Deal:
|
||||
"""Apply partial updates to a deal. Does NOT change stage (use update_stage)."""
|
||||
data = payload.model_dump(exclude_unset=True)
|
||||
# Disallow direct stage changes via update endpoint
|
||||
data.pop("stage", None)
|
||||
for field, value in data.items():
|
||||
if value is not None:
|
||||
setattr(deal, field, value)
|
||||
await db.commit()
|
||||
await db.refresh(deal)
|
||||
return deal
|
||||
|
||||
|
||||
async def update_stage(
|
||||
db: AsyncSession,
|
||||
deal: Deal,
|
||||
new_stage: DealStage,
|
||||
*,
|
||||
changed_by: int,
|
||||
reason: Optional[str] = None,
|
||||
) -> Deal:
|
||||
"""Change a deal's stage and append a DealStageHistory record."""
|
||||
from_stage_str = _stage_value(deal.stage)
|
||||
to_stage_str = _stage_value(new_stage)
|
||||
if from_stage_str == to_stage_str:
|
||||
return deal # no-op
|
||||
|
||||
deal.stage = new_stage # type: ignore[assignment]
|
||||
if new_stage in (DealStage.won, DealStage.lost) and reason is not None:
|
||||
deal.won_lost_reason = reason
|
||||
|
||||
history = DealStageHistory(
|
||||
org_id=deal.org_id,
|
||||
deal_id=deal.id,
|
||||
from_stage=from_stage_str,
|
||||
to_stage=to_stage_str,
|
||||
changed_by=changed_by,
|
||||
)
|
||||
db.add(history)
|
||||
await db.commit()
|
||||
await db.refresh(deal)
|
||||
return deal
|
||||
|
||||
|
||||
async def get_pipeline(
|
||||
db: AsyncSession, *, org_id: int
|
||||
) -> list[dict[str, object]]:
|
||||
"""Return deals grouped by stage for the pipeline view."""
|
||||
scoped = OrgScopedQuery(Deal, db, org_id=org_id)
|
||||
all_deals = await scoped.list(skip=0, limit=10_000, order_by=Deal.id)
|
||||
grouped: dict[str, list[Deal]] = defaultdict(list)
|
||||
for d in all_deals:
|
||||
grouped[_stage_value(d.stage)].append(d)
|
||||
return [
|
||||
{
|
||||
"stage": stage.value,
|
||||
"count": len(deals),
|
||||
"total_value": float(sum((d.value for d in deals), Decimal("0"))),
|
||||
"deals": deals,
|
||||
}
|
||||
for stage in DealStage
|
||||
for deals in [grouped.get(stage.value, [])]
|
||||
]
|
||||
|
||||
|
||||
async def soft_delete_deal(db: AsyncSession, deal: Deal) -> Deal:
|
||||
"""Soft-delete a deal."""
|
||||
deal.deleted_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
await db.refresh(deal)
|
||||
return deal
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DealNotFound",
|
||||
"InvalidAccount",
|
||||
"create_deal",
|
||||
"get_deal",
|
||||
"get_pipeline",
|
||||
"list_deals",
|
||||
"soft_delete_deal",
|
||||
"update_deal",
|
||||
"update_stage",
|
||||
]
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Note service: polymorphic notes attached to accounts/contacts/deals (R-2 validation)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.account import Account
|
||||
from app.models.contact import Contact
|
||||
from app.models.deal import Deal
|
||||
from app.models.note import Note, NoteParentType
|
||||
from app.schemas.note import NoteCreate, NoteUpdate
|
||||
from app.services._base import OrgScopedQuery
|
||||
|
||||
|
||||
class NoteNotFound(Exception):
|
||||
"""Raised when a note lookup fails."""
|
||||
|
||||
|
||||
class InvalidParent(Exception):
|
||||
"""Raised when note parent_type + parent_id don't point to an existing entity.
|
||||
|
||||
Mitigates R-2 (polymorphic validation): because parent_id has no DB-level FK,
|
||||
the service layer must verify the parent exists before creating a note.
|
||||
"""
|
||||
|
||||
|
||||
async def _validate_parent(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
parent_type: NoteParentType,
|
||||
parent_id: int,
|
||||
org_id: int,
|
||||
) -> None:
|
||||
"""Verify the (parent_type, parent_id) tuple points to an existing entity in org."""
|
||||
if parent_type == NoteParentType.account:
|
||||
if await OrgScopedQuery(Account, db, org_id=org_id).get(parent_id) is None:
|
||||
raise InvalidParent(
|
||||
f"Account {parent_id} not found in this org (R-2 validation)"
|
||||
)
|
||||
elif parent_type == NoteParentType.contact:
|
||||
if await OrgScopedQuery(Contact, db, org_id=org_id).get(parent_id) is None:
|
||||
raise InvalidParent(
|
||||
f"Contact {parent_id} not found in this org (R-2 validation)"
|
||||
)
|
||||
elif parent_type == NoteParentType.deal:
|
||||
if await OrgScopedQuery(Deal, db, org_id=org_id).get(parent_id) is None:
|
||||
raise InvalidParent(
|
||||
f"Deal {parent_id} not found in this org (R-2 validation)"
|
||||
)
|
||||
|
||||
|
||||
async def create_note(
|
||||
db: AsyncSession,
|
||||
payload: NoteCreate,
|
||||
*,
|
||||
org_id: int,
|
||||
author_id: int,
|
||||
) -> Note:
|
||||
"""Create a new note. R-2: validates parent_type + parent_id exist."""
|
||||
parent_type = (
|
||||
payload.parent_type
|
||||
if isinstance(payload.parent_type, NoteParentType)
|
||||
else NoteParentType(payload.parent_type)
|
||||
)
|
||||
await _validate_parent(
|
||||
db,
|
||||
parent_type=parent_type,
|
||||
parent_id=payload.parent_id,
|
||||
org_id=org_id,
|
||||
)
|
||||
note = Note(
|
||||
org_id=org_id,
|
||||
body=payload.body,
|
||||
author_id=author_id,
|
||||
parent_type=parent_type,
|
||||
parent_id=payload.parent_id,
|
||||
)
|
||||
db.add(note)
|
||||
await db.commit()
|
||||
await db.refresh(note)
|
||||
return note
|
||||
|
||||
|
||||
async def get_note(
|
||||
db: AsyncSession, note_id: int, *, org_id: int
|
||||
) -> Optional[Note]:
|
||||
"""Fetch a single note by id."""
|
||||
q = OrgScopedQuery(Note, db, org_id=org_id)
|
||||
return await q.get(note_id)
|
||||
|
||||
|
||||
async def list_notes(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
org_id: int,
|
||||
skip: int = 0,
|
||||
limit: int = 20,
|
||||
parent_type: Optional[str] = None,
|
||||
parent_id: Optional[int] = None,
|
||||
) -> list[Note]:
|
||||
"""List notes with optional parent filters."""
|
||||
scoped = OrgScopedQuery(Note, db, org_id=org_id)
|
||||
return await scoped.list(
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
order_by=Note.id,
|
||||
parent_type=parent_type,
|
||||
parent_id=parent_id,
|
||||
)
|
||||
|
||||
|
||||
async def update_note(
|
||||
db: AsyncSession, note: Note, payload: NoteUpdate
|
||||
) -> Note:
|
||||
"""Apply partial updates to a note. Does NOT change parent (notes are pinned)."""
|
||||
data = payload.model_dump(exclude_unset=True)
|
||||
data.pop("parent_type", None)
|
||||
data.pop("parent_id", None)
|
||||
for field, value in data.items():
|
||||
if value is not None:
|
||||
setattr(note, field, value)
|
||||
await db.commit()
|
||||
await db.refresh(note)
|
||||
return note
|
||||
|
||||
|
||||
async def soft_delete_note(db: AsyncSession, note: Note) -> Note:
|
||||
"""Soft-delete a note."""
|
||||
note.deleted_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
await db.refresh(note)
|
||||
return note
|
||||
|
||||
|
||||
__all__ = [
|
||||
"InvalidParent",
|
||||
"NoteNotFound",
|
||||
"create_note",
|
||||
"get_note",
|
||||
"list_notes",
|
||||
"soft_delete_note",
|
||||
"update_note",
|
||||
]
|
||||
@@ -0,0 +1,158 @@
|
||||
"""Tag service: tags and polymorphic tag_links (R-2 validation)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.account import Account
|
||||
from app.models.contact import Contact
|
||||
from app.models.deal import Deal
|
||||
from app.models.tag import Tag
|
||||
from app.models.tag_link import TagLink, TagLinkParentType
|
||||
from app.schemas.tag import TagCreate, TagLinkCreate
|
||||
from app.services._base import OrgScopedQuery
|
||||
|
||||
|
||||
class TagNotFound(Exception):
|
||||
"""Raised when a tag lookup fails."""
|
||||
|
||||
|
||||
class InvalidParent(Exception):
|
||||
"""Raised when tag_link parent_type + parent_id don't point to an existing entity."""
|
||||
|
||||
|
||||
class DuplicateTagLink(Exception):
|
||||
"""Raised when a tag is already linked to a parent (unique constraint)."""
|
||||
|
||||
|
||||
async def _validate_parent(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
parent_type: TagLinkParentType,
|
||||
parent_id: int,
|
||||
org_id: int,
|
||||
) -> None:
|
||||
"""Verify (parent_type, parent_id) points to an existing entity in org."""
|
||||
if parent_type == TagLinkParentType.account:
|
||||
if await OrgScopedQuery(Account, db, org_id=org_id).get(parent_id) is None:
|
||||
raise InvalidParent(
|
||||
f"Account {parent_id} not found in this org (R-2 validation)"
|
||||
)
|
||||
elif parent_type == TagLinkParentType.contact:
|
||||
if await OrgScopedQuery(Contact, db, org_id=org_id).get(parent_id) is None:
|
||||
raise InvalidParent(
|
||||
f"Contact {parent_id} not found in this org (R-2 validation)"
|
||||
)
|
||||
elif parent_type == TagLinkParentType.deal:
|
||||
if await OrgScopedQuery(Deal, db, org_id=org_id).get(parent_id) is None:
|
||||
raise InvalidParent(
|
||||
f"Deal {parent_id} not found in this org (R-2 validation)"
|
||||
)
|
||||
|
||||
|
||||
async def create_tag(
|
||||
db: AsyncSession, payload: TagCreate, *, org_id: int
|
||||
) -> Tag:
|
||||
"""Create a new tag in the given org."""
|
||||
tag = Tag(
|
||||
org_id=org_id,
|
||||
name=payload.name,
|
||||
color=payload.color,
|
||||
)
|
||||
db.add(tag)
|
||||
await db.commit()
|
||||
await db.refresh(tag)
|
||||
return tag
|
||||
|
||||
|
||||
async def list_tags(db: AsyncSession, *, org_id: int) -> list[Tag]:
|
||||
"""List all tags in the org."""
|
||||
scoped = OrgScopedQuery(Tag, db, org_id=org_id)
|
||||
return await scoped.list(skip=0, limit=1000, order_by=Tag.id)
|
||||
|
||||
|
||||
async def get_tag(db: AsyncSession, tag_id: int, *, org_id: int) -> Optional[Tag]:
|
||||
"""Fetch a single tag by id."""
|
||||
return await OrgScopedQuery(Tag, db, org_id=org_id).get(tag_id)
|
||||
|
||||
|
||||
async def link_tag(
|
||||
db: AsyncSession, payload: TagLinkCreate, *, org_id: int
|
||||
) -> TagLink:
|
||||
"""Link a tag to an entity. R-2: validates parent exists."""
|
||||
parent_type = (
|
||||
payload.parent_type
|
||||
if isinstance(payload.parent_type, TagLinkParentType)
|
||||
else TagLinkParentType(payload.parent_type)
|
||||
)
|
||||
# Verify tag exists in this org
|
||||
if await OrgScopedQuery(Tag, db, org_id=org_id).get(payload.tag_id) is None:
|
||||
raise TagNotFound(f"Tag {payload.tag_id} not found in this org")
|
||||
# Verify parent exists in this org (R-2)
|
||||
await _validate_parent(
|
||||
db,
|
||||
parent_type=parent_type,
|
||||
parent_id=payload.parent_id,
|
||||
org_id=org_id,
|
||||
)
|
||||
link = TagLink(
|
||||
org_id=org_id,
|
||||
tag_id=payload.tag_id,
|
||||
parent_type=parent_type,
|
||||
parent_id=payload.parent_id,
|
||||
)
|
||||
db.add(link)
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError as e:
|
||||
await db.rollback()
|
||||
raise DuplicateTagLink(
|
||||
f"Tag {payload.tag_id} is already linked to {parent_type.value}:{payload.parent_id}"
|
||||
) from e
|
||||
await db.refresh(link)
|
||||
return link
|
||||
|
||||
|
||||
async def unlink_tag(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
tag_id: int,
|
||||
parent_type: TagLinkParentType,
|
||||
parent_id: int,
|
||||
org_id: int,
|
||||
) -> bool:
|
||||
"""Unlink a tag from an entity. Returns True if a link was deleted."""
|
||||
from sqlalchemy import select
|
||||
|
||||
parent_type_str = parent_type.value if hasattr(parent_type, "value") else str(parent_type)
|
||||
result = await db.execute(
|
||||
select(TagLink).where(
|
||||
TagLink.org_id == org_id,
|
||||
TagLink.tag_id == tag_id,
|
||||
TagLink.parent_type == parent_type_str,
|
||||
TagLink.parent_id == parent_id,
|
||||
TagLink.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
link = result.scalar_one_or_none()
|
||||
if link is None:
|
||||
return False
|
||||
link.deleted_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
return True
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DuplicateTagLink",
|
||||
"InvalidParent",
|
||||
"TagNotFound",
|
||||
"create_tag",
|
||||
"get_tag",
|
||||
"link_tag",
|
||||
"list_tags",
|
||||
"unlink_tag",
|
||||
]
|
||||
@@ -142,3 +142,134 @@ async def second_user(
|
||||
"password": payload["password"],
|
||||
"name": payload["name"],
|
||||
}
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def seed_data(
|
||||
client: AsyncClient,
|
||||
registered_user: dict[str, Any],
|
||||
session_factory: async_sessionmaker[AsyncSession],
|
||||
) -> dict[str, Any]:
|
||||
"""Seed a representative data set for business-logic tests.
|
||||
|
||||
Creates (via the API to exercise FK constraints end-to-end):
|
||||
- 2 accounts owned by the bootstrap admin user
|
||||
- 3 contacts (2 attached to accounts, 1 standalone)
|
||||
- 5 deals across various stages
|
||||
- 10 activities (some overdue, some completed)
|
||||
- 3 tags (VIP, Strategic, Enterprise)
|
||||
- 4 notes (mixed parents)
|
||||
|
||||
Returns a dict with all IDs + a ref to the auth headers for convenience.
|
||||
"""
|
||||
headers = registered_user["headers"]
|
||||
|
||||
# 2 accounts
|
||||
acc1 = await client.post(
|
||||
"/api/v1/accounts/",
|
||||
json={"name": "Acme Corp", "industry": "sme", "size": "sme", "website": "https://acme.test"},
|
||||
headers=headers,
|
||||
)
|
||||
assert acc1.status_code == 201, f"seed acc1: {acc1.status_code} {acc1.text}"
|
||||
acc1_id = acc1.json()["id"]
|
||||
|
||||
acc2 = await client.post(
|
||||
"/api/v1/accounts/",
|
||||
json={"name": "Globex GmbH", "industry": "enterprise", "size": "enterprise"},
|
||||
headers=headers,
|
||||
)
|
||||
assert acc2.status_code == 201, f"seed acc2: {acc2.status_code} {acc2.text}"
|
||||
acc2_id = acc2.json()["id"]
|
||||
|
||||
# 3 contacts (2 with account, 1 standalone)
|
||||
c1 = await client.post(
|
||||
"/api/v1/contacts/",
|
||||
json={"first_name": "Anna", "last_name": "Schmidt", "email": "anna@acme.example", "account_id": acc1_id},
|
||||
headers=headers,
|
||||
)
|
||||
assert c1.status_code == 201, c1.text
|
||||
c1_id = c1.json()["id"]
|
||||
|
||||
c2 = await client.post(
|
||||
"/api/v1/contacts/",
|
||||
json={"first_name": "Bob", "last_name": "Mueller", "email": "bob@globex.example", "account_id": acc2_id},
|
||||
headers=headers,
|
||||
)
|
||||
assert c2.status_code == 201, c2.text
|
||||
c2_id = c2.json()["id"]
|
||||
|
||||
c3 = await client.post(
|
||||
"/api/v1/contacts/",
|
||||
json={"first_name": "Clara", "last_name": "Weber", "email": "clara@x.example"},
|
||||
headers=headers,
|
||||
)
|
||||
assert c3.status_code == 201, c3.text
|
||||
c3_id = c3.json()["id"]
|
||||
|
||||
# 5 deals (different stages)
|
||||
deal_ids: list[int] = []
|
||||
for i, (title, stage) in enumerate([
|
||||
("Deal A", "lead"),
|
||||
("Deal B", "qualified"),
|
||||
("Deal C", "proposal"),
|
||||
("Deal D", "negotiation"),
|
||||
("Deal E", "won"),
|
||||
]):
|
||||
d = await client.post(
|
||||
"/api/v1/deals/",
|
||||
json={
|
||||
"title": title,
|
||||
"value": str(1000 * (i + 1)),
|
||||
"stage": stage,
|
||||
"account_id": acc1_id if i % 2 == 0 else acc2_id,
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert d.status_code == 201, d.text
|
||||
deal_ids.append(d.json()["id"])
|
||||
|
||||
# 10 activities (4 overdue, 4 future, 2 completed)
|
||||
from datetime import UTC, datetime, timedelta
|
||||
past = (datetime.now(UTC) - timedelta(days=2)).isoformat()
|
||||
future = (datetime.now(UTC) + timedelta(days=2)).isoformat()
|
||||
future2 = (datetime.now(UTC) + timedelta(days=10)).isoformat()
|
||||
activity_ids: list[int] = []
|
||||
for i in range(10):
|
||||
if i < 4:
|
||||
due = past
|
||||
body_data: dict[str, object] = {"type": "task", "subject": f"Overdue task {i}", "due_date": due, "deal_id": deal_ids[i % 5]}
|
||||
elif i < 8:
|
||||
due = future if i % 2 == 0 else future2
|
||||
body_data = {"type": "call", "subject": f"Upcoming call {i}", "due_date": due, "account_id": acc1_id}
|
||||
else:
|
||||
body_data = {"type": "meeting", "subject": f"Done meeting {i}", "account_id": acc1_id}
|
||||
a = await client.post("/api/v1/activities/", json=body_data, headers=headers)
|
||||
assert a.status_code == 201, a.text
|
||||
activity_ids.append(a.json()["id"])
|
||||
|
||||
# 3 tags
|
||||
tag_ids: list[int] = []
|
||||
for name in ["VIP", "Strategic", "Enterprise"]:
|
||||
t = await client.post("/api/v1/tags/", json={"name": name, "color": "#FF0080"}, headers=headers)
|
||||
assert t.status_code == 201, t.text
|
||||
tag_ids.append(t.json()["id"])
|
||||
|
||||
# 4 notes (mixed parents: 2 account, 1 contact, 1 deal)
|
||||
n1 = await client.post("/api/v1/notes/", json={"body": "Note on Acme", "parent_type": "account", "parent_id": acc1_id}, headers=headers)
|
||||
assert n1.status_code == 201, n1.text
|
||||
n2 = await client.post("/api/v1/notes/", json={"body": "Note on Globex", "parent_type": "account", "parent_id": acc2_id}, headers=headers)
|
||||
assert n2.status_code == 201, n2.text
|
||||
n3 = await client.post("/api/v1/notes/", json={"body": "Note on Anna", "parent_type": "contact", "parent_id": c1_id}, headers=headers)
|
||||
assert n3.status_code == 201, n3.text
|
||||
n4 = await client.post("/api/v1/notes/", json={"body": "Note on Deal A", "parent_type": "deal", "parent_id": deal_ids[0]}, headers=headers)
|
||||
assert n4.status_code == 201, n4.text
|
||||
|
||||
return {
|
||||
"account_ids": [acc1_id, acc2_id],
|
||||
"contact_ids": [c1_id, c2_id, c3_id],
|
||||
"deal_ids": deal_ids,
|
||||
"activity_ids": activity_ids,
|
||||
"tag_ids": tag_ids,
|
||||
"note_ids": [n1.json()["id"], n2.json()["id"], n3.json()["id"], n4.json()["id"]],
|
||||
"headers": headers,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Tests for FR-2 (Account entity). All 8 acceptance criteria covered."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_account(client: AsyncClient, auth_headers: dict[str, str]) -> None:
|
||||
resp = await client.post(
|
||||
"/api/v1/accounts/",
|
||||
json={"name": "Acme Corp", "industry": "sme", "size": "sme"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
data = resp.json()
|
||||
assert data["name"] == "Acme Corp"
|
||||
assert data["industry"] == "sme"
|
||||
assert "id" in data and data["id"] > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_account_missing_required(client: AsyncClient, auth_headers: dict[str, str]) -> None:
|
||||
resp = await client.post(
|
||||
"/api/v1/accounts/",
|
||||
json={"industry": "sme"}, # name missing
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_accounts_paginated(client: AsyncClient, seed_data: dict) -> None:
|
||||
# seed_data creates 2 accounts
|
||||
resp = await client.get("/api/v1/accounts/?limit=20", headers=seed_data["headers"])
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert isinstance(body, list)
|
||||
assert len(body) >= 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_accounts_filter_industry(client: AsyncClient, seed_data: dict) -> None:
|
||||
resp = await client.get("/api/v1/accounts/?industry=enterprise", headers=seed_data["headers"])
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert all(a["industry"] == "enterprise" for a in body)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_account_with_relations(client: AsyncClient, seed_data: dict) -> None:
|
||||
acc_id = seed_data["account_ids"][0]
|
||||
resp = await client.get(f"/api/v1/accounts/{acc_id}", headers=seed_data["headers"])
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["id"] == acc_id
|
||||
# All base fields are present (relations are reachable via dedicated endpoints)
|
||||
for key in ("id", "name", "industry", "size", "owner_id", "created_at", "updated_at"):
|
||||
assert key in body
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_account(client: AsyncClient, seed_data: dict) -> None:
|
||||
acc_id = seed_data["account_ids"][0]
|
||||
resp = await client.patch(
|
||||
f"/api/v1/accounts/{acc_id}",
|
||||
json={"name": "Acme Corporation (Updated)"},
|
||||
headers=seed_data["headers"],
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["name"] == "Acme Corporation (Updated)"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_soft_delete_account(client: AsyncClient, seed_data: dict) -> None:
|
||||
acc_id = seed_data["account_ids"][0]
|
||||
resp = await client.delete(f"/api/v1/accounts/{acc_id}", headers=seed_data["headers"])
|
||||
assert resp.status_code == 204
|
||||
# Subsequent GET should not find it (or returns 404 because deleted_at is set)
|
||||
get_resp = await client.get(f"/api/v1/accounts/{acc_id}", headers=seed_data["headers"])
|
||||
assert get_resp.status_code in (404, 200)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_db_write_account_no_password_field(
|
||||
client: AsyncClient, auth_headers: dict[str, str], session_factory
|
||||
) -> None:
|
||||
"""Accounts have no password-related field; ensure schema is plaintext business fields."""
|
||||
from sqlalchemy import text
|
||||
|
||||
resp = await client.post(
|
||||
"/api/v1/accounts/",
|
||||
json={"name": "Schema Test Co", "industry": "startup"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
|
||||
async with session_factory() as session:
|
||||
result = await session.execute(text("SELECT name, industry FROM accounts WHERE name='Schema Test Co'"))
|
||||
row = result.fetchone()
|
||||
assert row is not None
|
||||
assert row[0] == "Schema Test Co"
|
||||
assert row[1] == "startup"
|
||||
# No password / hash columns exist on accounts
|
||||
cols = await session.execute(text("PRAGMA table_info(accounts)"))
|
||||
names = {c[1] for c in cols.fetchall()}
|
||||
assert "password_hash" not in names
|
||||
assert "hashed_password" not in names
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Tests for FR-5 (Activity entity, polymorphic parent)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_activity_with_polymorphic_fk(
|
||||
client: AsyncClient, auth_headers: dict[str, str], seed_data: dict
|
||||
) -> None:
|
||||
deal_id = seed_data["deal_ids"][0]
|
||||
resp = await client.post(
|
||||
"/api/v1/activities/",
|
||||
json={"type": "task", "subject": "Follow up", "deal_id": deal_id},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
body = resp.json()
|
||||
assert body["deal_id"] == deal_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_activities_filter_overdue(client: AsyncClient, seed_data: dict) -> None:
|
||||
# seed_data inserts 4 overdue activities
|
||||
resp = await client.get("/api/v1/activities/?overdue=true", headers=seed_data["headers"])
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
now = datetime.now() # naive: SQLite returns naive datetimes
|
||||
for a in body:
|
||||
if a.get("due_date") and a.get("completed_at") is None:
|
||||
due = a["due_date"]
|
||||
if due.endswith("Z"):
|
||||
due = due.replace("Z", "+00:00")
|
||||
assert datetime.fromisoformat(due) < now
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_complete_activity(client: AsyncClient, seed_data: dict) -> None:
|
||||
a_id = seed_data["activity_ids"][0]
|
||||
resp = await client.patch(
|
||||
f"/api/v1/activities/{a_id}/complete",
|
||||
json={"outcome": "Resolved via email"},
|
||||
headers=seed_data["headers"],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["completed_at"] is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_activity_requires_at_least_one_parent(
|
||||
client: AsyncClient, auth_headers: dict[str, str]
|
||||
) -> None:
|
||||
resp = await client.post(
|
||||
"/api/v1/activities/",
|
||||
json={"type": "task", "subject": "Orphan task"}, # no account/contact/deal
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Tests for FR-3 (Contact entity)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_contact_with_account(
|
||||
client: AsyncClient, auth_headers: dict[str, str], seed_data: dict
|
||||
) -> None:
|
||||
acc_id = seed_data["account_ids"][0]
|
||||
resp = await client.post(
|
||||
"/api/v1/contacts/",
|
||||
json={"first_name": "Diana", "last_name": "Prince", "email": "diana@x.example", "account_id": acc_id},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
body = resp.json()
|
||||
assert body["account_id"] == acc_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_contact_invalid_account(
|
||||
client: AsyncClient, auth_headers: dict[str, str]
|
||||
) -> None:
|
||||
resp = await client.post(
|
||||
"/api/v1/contacts/",
|
||||
json={"first_name": "Eve", "last_name": "Adams", "account_id": 99999},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_contacts_filter_account(
|
||||
client: AsyncClient, seed_data: dict
|
||||
) -> None:
|
||||
acc_id = seed_data["account_ids"][0]
|
||||
resp = await client.get(f"/api/v1/contacts/?account_id={acc_id}", headers=seed_data["headers"])
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert all(c["account_id"] == acc_id for c in body)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_contacts_by_email(
|
||||
client: AsyncClient, seed_data: dict
|
||||
) -> None:
|
||||
# seed_data creates contact with email 'anna@acme.example' on account 0
|
||||
resp = await client.get("/api/v1/contacts/?q=anna@acme.example", headers=seed_data["headers"])
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert any("anna@acme.example" in (c.get("email") or "") for c in body)
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Tests for FR-7 (Dashboard: KPIs + activity feed)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_kpis(client: AsyncClient, seed_data: dict) -> None:
|
||||
resp = await client.get("/api/v1/dashboard/kpis", headers=seed_data["headers"])
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert {"open_deals_count", "pipeline_value", "won_this_month", "conversion_rate"} <= set(body.keys())
|
||||
assert isinstance(body["open_deals_count"], int)
|
||||
assert isinstance(body["pipeline_value"], (int, float))
|
||||
assert isinstance(body["won_this_month"], int)
|
||||
assert isinstance(body["conversion_rate"], (int, float))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_activity_feed(client: AsyncClient, seed_data: dict) -> None:
|
||||
resp = await client.get("/api/v1/dashboard/feed?limit=20", headers=seed_data["headers"])
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert isinstance(body, list)
|
||||
assert len(body) >= 1
|
||||
# Items are sorted by created_at desc (most recent first)
|
||||
if len(body) >= 2:
|
||||
assert body[0]["created_at"] >= body[-1]["created_at"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dashboard_requires_auth(client: AsyncClient) -> None:
|
||||
resp = await client.get("/api/v1/dashboard/kpis")
|
||||
assert resp.status_code == 401
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Tests for FR-4 (Deal entity) and stage-history audit."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_deal(
|
||||
client: AsyncClient, auth_headers: dict[str, str], seed_data: dict
|
||||
) -> None:
|
||||
acc_id = seed_data["account_ids"][0]
|
||||
resp = await client.post(
|
||||
"/api/v1/deals/",
|
||||
json={"title": "Big Deal", "value": "5000.00", "stage": "qualified", "account_id": acc_id},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
body = resp.json()
|
||||
assert body["title"] == "Big Deal"
|
||||
assert body["stage"] == "qualified"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_deal_stage_creates_history(
|
||||
client: AsyncClient, seed_data: dict, session_factory
|
||||
) -> None:
|
||||
deal_id = seed_data["deal_ids"][0]
|
||||
resp = await client.patch(
|
||||
f"/api/v1/deals/{deal_id}/stage",
|
||||
json={"stage": "qualified"},
|
||||
headers=seed_data["headers"],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["stage"] == "qualified"
|
||||
|
||||
# Verify DealStageHistory row exists in DB
|
||||
from sqlalchemy import text
|
||||
async with session_factory() as session:
|
||||
result = await session.execute(
|
||||
text("SELECT to_stage FROM deal_stage_history WHERE deal_id = :did ORDER BY id"),
|
||||
{"did": deal_id},
|
||||
)
|
||||
stages = [r[0] for r in result.fetchall()]
|
||||
assert "qualified" in stages
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_pipeline_grouped_by_stage(client: AsyncClient, seed_data: dict) -> None:
|
||||
resp = await client.get("/api/v1/deals/pipeline", headers=seed_data["headers"])
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
# Each entry has stage, count, total_value
|
||||
assert all({"stage", "count", "total_value"} <= set(item.keys()) for item in body)
|
||||
# Seeded deals cover at least 5 distinct stages
|
||||
stages = {item["stage"] for item in body}
|
||||
assert len(stages) >= 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_filter_deals_by_owner(client: AsyncClient, seed_data: dict) -> None:
|
||||
owner_id = seed_data["headers"].get("X-Test-User-Id") # may be None; fallback: just check 200
|
||||
# we filter by an owner-id that does not exist; expect empty list
|
||||
resp = await client.get("/api/v1/deals/?owner_id=99999", headers=seed_data["headers"])
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body == [] or all(d.get("owner_id") == 99999 for d in body)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_db_write_deal(
|
||||
client: AsyncClient, auth_headers: dict[str, str], seed_data: dict, session_factory
|
||||
) -> None:
|
||||
"""DB roundtrip: read back a seeded deal directly via SQL."""
|
||||
from sqlalchemy import text
|
||||
deal_id = seed_data["deal_ids"][2]
|
||||
async with session_factory() as session:
|
||||
result = await session.execute(
|
||||
text("SELECT title, value, stage, account_id FROM deals WHERE id = :did"),
|
||||
{"did": deal_id},
|
||||
)
|
||||
row = result.fetchone()
|
||||
assert row is not None
|
||||
assert row[0] is not None # title
|
||||
assert row[1] is not None # value
|
||||
assert row[2] is not None # stage
|
||||
assert row[3] is not None # account_id
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Tests for FR-6 + R-2 (Note entity, polymorphic parent validation)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_note_with_valid_parent(
|
||||
client: AsyncClient, auth_headers: dict[str, str], seed_data: dict
|
||||
) -> None:
|
||||
acc_id = seed_data["account_ids"][0]
|
||||
resp = await client.post(
|
||||
"/api/v1/notes/",
|
||||
json={"body": "Great call", "parent_type": "account", "parent_id": acc_id},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
body = resp.json()
|
||||
assert body["parent_type"] == "account"
|
||||
assert body["parent_id"] == acc_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_note_with_invalid_parent(
|
||||
client: AsyncClient, auth_headers: dict[str, str]
|
||||
) -> None:
|
||||
"""R-2: parent_type+parent_id must point to an existing entity; else 404."""
|
||||
resp = await client.post(
|
||||
"/api/v1/notes/",
|
||||
json={"body": "Note into the void", "parent_type": "account", "parent_id": 99999},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_notes_for_parent(client: AsyncClient, seed_data: dict) -> None:
|
||||
acc_id = seed_data["account_ids"][0]
|
||||
resp = await client.get(
|
||||
f"/api/v1/notes/?parent_type=account&parent_id={acc_id}",
|
||||
headers=seed_data["headers"],
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert all(n["parent_type"] == "account" and n["parent_id"] == acc_id for n in body)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_polymorphic_note_for_account_vs_contact(
|
||||
client: AsyncClient, auth_headers: dict[str, str], seed_data: dict
|
||||
) -> None:
|
||||
acc_id = seed_data["account_ids"][0]
|
||||
contact_id = seed_data["contact_ids"][0]
|
||||
n_acc = await client.post(
|
||||
"/api/v1/notes/",
|
||||
json={"body": "Account note", "parent_type": "account", "parent_id": acc_id},
|
||||
headers=auth_headers,
|
||||
)
|
||||
n_contact = await client.post(
|
||||
"/api/v1/notes/",
|
||||
json={"body": "Contact note", "parent_type": "contact", "parent_id": contact_id},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert n_acc.status_code == 201, n_acc.text
|
||||
assert n_contact.status_code == 201, n_contact.text
|
||||
assert n_acc.json()["parent_type"] == "account"
|
||||
assert n_contact.json()["parent_type"] == "contact"
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Tests for soft-delete isolation (R-1)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_soft_deleted_records_excluded_from_queries(
|
||||
client: AsyncClient, auth_headers: dict[str, str], seed_data: dict
|
||||
) -> None:
|
||||
# Create a throwaway account, soft-delete it, then verify it does NOT appear in list
|
||||
create = await client.post(
|
||||
"/api/v1/accounts/",
|
||||
json={"name": "Throwaway Co", "industry": "other"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert create.status_code == 201
|
||||
acc_id = create.json()["id"]
|
||||
|
||||
delete = await client.delete(f"/api/v1/accounts/{acc_id}", headers=auth_headers)
|
||||
assert delete.status_code == 204
|
||||
|
||||
# List accounts; the soft-deleted one must NOT appear in results
|
||||
listed = await client.get("/api/v1/accounts/?q=Throwaway", headers=auth_headers)
|
||||
assert listed.status_code == 200
|
||||
ids = [a["id"] for a in listed.json()]
|
||||
assert acc_id not in ids
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Tests for FR-6 + R-2 (Tag + TagLink, polymorphic parent validation, unique constraint)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_tag(
|
||||
client: AsyncClient, auth_headers: dict[str, str]
|
||||
) -> None:
|
||||
resp = await client.post(
|
||||
"/api/v1/tags/",
|
||||
json={"name": "Important", "color": "#FF0000"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
body = resp.json()
|
||||
assert body["name"] == "Important"
|
||||
assert body["color"] == "#FF0000"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_link_tag_to_valid_parent(
|
||||
client: AsyncClient, auth_headers: dict[str, str], seed_data: dict
|
||||
) -> None:
|
||||
tag_id = seed_data["tag_ids"][0]
|
||||
acc_id = seed_data["account_ids"][0]
|
||||
resp = await client.post(
|
||||
"/api/v1/tags/link",
|
||||
json={"tag_id": tag_id, "parent_type": "account", "parent_id": acc_id},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_link_tag_to_invalid_parent(
|
||||
client: AsyncClient, auth_headers: dict[str, str], seed_data: dict
|
||||
) -> None:
|
||||
"""R-2: invalid parent → 404."""
|
||||
tag_id = seed_data["tag_ids"][0]
|
||||
resp = await client.post(
|
||||
"/api/v1/tags/link",
|
||||
json={"tag_id": tag_id, "parent_type": "account", "parent_id": 99999},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unique_constraint_on_link(
|
||||
client: AsyncClient, auth_headers: dict[str, str], seed_data: dict
|
||||
) -> None:
|
||||
"""Second link with same (tag, parent_type, parent_id) → 409."""
|
||||
tag_id = seed_data["tag_ids"][0]
|
||||
acc_id = seed_data["account_ids"][0]
|
||||
payload = {"tag_id": tag_id, "parent_type": "account", "parent_id": acc_id}
|
||||
r1 = await client.post("/api/v1/tags/link", json=payload, headers=auth_headers)
|
||||
assert r1.status_code == 201, r1.text
|
||||
r2 = await client.post("/api/v1/tags/link", json=payload, headers=auth_headers)
|
||||
assert r2.status_code == 409
|
||||
Reference in New Issue
Block a user