feat(phase-4b): backend business-logic, 8 entities, 32 endpoints, 58 tests

This commit is contained in:
CRM Bot
2026-06-03 21:40:26 +00:00
parent 955607f730
commit 53cbcde729
45 changed files with 3902 additions and 4 deletions
+86 -1
View File
@@ -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",
]
+59
View File
@@ -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"]
+68
View File
@@ -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",
]
+30
View File
@@ -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"]
+46
View File
@@ -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"]
+40
View File
@@ -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"]
+73
View File
@@ -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",
]
+43
View File
@@ -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"]
+57
View File
@@ -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"]