feat(phase-4b): backend business-logic, 8 entities, 32 endpoints, 58 tests
This commit is contained in:
+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"]
|
||||
Reference in New Issue
Block a user