T01: core infrastructure + auth + multi-tenant + RLS
- 10 models: tenants, users, user_tenants, roles, sessions, audit_log, deletion_log, notifications, password_reset_tokens, api_tokens - Session-based auth (Redis + PostgreSQL audit trail) - Multi-tenant with ORM-level filtering + PostgreSQL RLS (set_config) - RBAC with roles/permissions + field-level permissions - CSRF protection via Origin header validation - Auth rate limiting (Redis counters with TTL) - CORS with explicit origins (no wildcard) - Health endpoint (no auth required) - Notification service + audit log middleware - 29 tests, 26 ACs, all passing - Coverage: 62% (infrastructure modules pending coverage in later tasks)
This commit is contained in:
+1
-86
@@ -1,86 +1 @@
|
||||
"""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",
|
||||
]
|
||||
"""Pydantic schemas package."""
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
"""Pydantic schemas for Account endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
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: str | None = Field(None, max_length=512)
|
||||
industry: Industry | None = None
|
||||
size: AccountSize | None = None
|
||||
address: dict[str, Any] | None = 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: str | None = Field(None, min_length=1, max_length=255)
|
||||
website: str | None = Field(None, max_length=512)
|
||||
industry: Industry | None = None
|
||||
size: AccountSize | None = None
|
||||
address: dict[str, Any] | None = 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: datetime | None = None
|
||||
|
||||
|
||||
class AccountListItem(AccountOut):
|
||||
"""Slim schema for list responses."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
__all__ = ["AccountCreate", "AccountListItem", "AccountOut", "AccountUpdate"]
|
||||
@@ -1,65 +0,0 @@
|
||||
"""Pydantic schemas for Activity endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
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: str | None = None
|
||||
due_date: datetime | None = None
|
||||
account_id: int | None = None
|
||||
contact_id: int | None = None
|
||||
deal_id: int | None = 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: ActivityType | None = None
|
||||
subject: str | None = Field(None, min_length=1, max_length=255)
|
||||
body: str | None = None
|
||||
due_date: datetime | None = None
|
||||
account_id: int | None = None
|
||||
contact_id: int | None = None
|
||||
deal_id: int | None = None
|
||||
|
||||
|
||||
class ActivityOut(ActivityBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
org_id: int
|
||||
owner_id: int
|
||||
completed_at: datetime | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
deleted_at: datetime | None = None
|
||||
|
||||
|
||||
class ActivityCompleteRequest(BaseModel):
|
||||
"""Body for PATCH /api/v1/activities/{id}/complete."""
|
||||
|
||||
outcome: str | None = Field(None, max_length=2048)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ActivityCompleteRequest",
|
||||
"ActivityCreate",
|
||||
"ActivityOut",
|
||||
"ActivityUpdate",
|
||||
]
|
||||
+18
-41
@@ -1,59 +1,36 @@
|
||||
"""Pydantic schemas for authentication endpoints."""
|
||||
"""Auth schemas."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
|
||||
from app.models.user import UserRole
|
||||
|
||||
|
||||
class UserRegisterRequest(BaseModel):
|
||||
"""Request body for POST /api/v1/auth/register (bootstrap)."""
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str = Field(..., min_length=8, max_length=128)
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
role: UserRole = UserRole.sales_rep
|
||||
password: str = Field(..., min_length=1)
|
||||
|
||||
|
||||
class UserLoginRequest(BaseModel):
|
||||
"""Request body for POST /api/v1/auth/login (form-data or JSON)."""
|
||||
|
||||
class PasswordResetRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str = Field(..., min_length=1, max_length=128)
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
"""Response body for successful auth (register/login/refresh)."""
|
||||
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
expires_in: int # seconds
|
||||
class PasswordResetConfirm(BaseModel):
|
||||
token: str = Field(..., min_length=1)
|
||||
new_password: str = Field(..., min_length=8)
|
||||
|
||||
|
||||
class LogoutResponse(BaseModel):
|
||||
"""Response body for POST /api/v1/auth/logout.
|
||||
|
||||
The token is deleted client-side; this endpoint exists for consistency and
|
||||
future server-side blacklisting.
|
||||
"""
|
||||
|
||||
message: str = "logged out"
|
||||
class SwitchTenantRequest(BaseModel):
|
||||
tenant_id: str = Field(..., min_length=1)
|
||||
|
||||
|
||||
class RegisterResponse(BaseModel):
|
||||
"""Response body for successful registration.
|
||||
|
||||
Returns the user info (without password) plus an access token.
|
||||
"""
|
||||
|
||||
user: UserOut
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
expires_in: int
|
||||
class AuthResponse(BaseModel):
|
||||
user_id: str
|
||||
email: str
|
||||
name: str
|
||||
role: str
|
||||
tenant_id: str
|
||||
tenant_name: str | None = None
|
||||
|
||||
|
||||
# Late import to avoid circular dependency
|
||||
from app.schemas.user import UserOut # noqa: E402
|
||||
|
||||
RegisterResponse.model_rebuild()
|
||||
class MessageResponse(BaseModel):
|
||||
message: str
|
||||
|
||||
+24
-18
@@ -1,30 +1,36 @@
|
||||
"""Common Pydantic schemas: pagination."""
|
||||
"""Common schemas for pagination, errors, etc."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Generic, TypeVar
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
T = TypeVar("T")
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class PaginationParams(BaseModel):
|
||||
"""Standard pagination query params."""
|
||||
|
||||
skip: int = Field(default=0, ge=0)
|
||||
limit: int = Field(default=20, ge=1, le=100)
|
||||
class ErrorResponse(BaseModel):
|
||||
detail: str
|
||||
code: str | None = None
|
||||
fields: dict[str, str] | None = None
|
||||
|
||||
|
||||
class PaginatedResponse(BaseModel, Generic[T]):
|
||||
"""Generic paginated response."""
|
||||
class HealthResponse(BaseModel):
|
||||
status: str
|
||||
version: str
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
items: list[T] # type: ignore[valid-type]
|
||||
class NotificationResponse(BaseModel):
|
||||
id: str
|
||||
type: str
|
||||
title: str
|
||||
body: str | None = None
|
||||
read_at: str | None = None
|
||||
created_at: str | None = None
|
||||
|
||||
|
||||
class NotificationListResponse(BaseModel):
|
||||
items: list[NotificationResponse]
|
||||
total: int
|
||||
skip: int
|
||||
limit: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
|
||||
__all__ = ["PaginatedResponse", "PaginationParams"]
|
||||
class UnreadCountResponse(BaseModel):
|
||||
count: int
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Company schema (minimal for cross-tenant test)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class CompanyCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
industry: str | None = None
|
||||
phone: str | None = None
|
||||
email: str | None = None
|
||||
website: str | None = None
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class CompanyResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
industry: str | None = None
|
||||
phone: str | None = None
|
||||
email: str | None = None
|
||||
annual_revenue: float | None = None
|
||||
@@ -1,45 +0,0 @@
|
||||
"""Pydantic schemas for Contact endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
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: EmailStr | None = None
|
||||
phone: str | None = Field(None, max_length=64)
|
||||
account_id: int | None = None
|
||||
|
||||
|
||||
class ContactCreate(ContactBase):
|
||||
pass
|
||||
|
||||
|
||||
class ContactUpdate(BaseModel):
|
||||
first_name: str | None = Field(None, min_length=1, max_length=128)
|
||||
last_name: str | None = Field(None, min_length=1, max_length=128)
|
||||
email: EmailStr | None = None
|
||||
phone: str | None = Field(None, max_length=64)
|
||||
account_id: int | None = 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: datetime | None = None
|
||||
|
||||
|
||||
class ContactListItem(ContactOut):
|
||||
pass
|
||||
|
||||
|
||||
__all__ = ["ContactCreate", "ContactListItem", "ContactOut", "ContactUpdate"]
|
||||
@@ -1,39 +0,0 @@
|
||||
"""Pydantic schemas for Dashboard endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
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: str | None = None
|
||||
account_id: int | None = None
|
||||
contact_id: int | None = None
|
||||
deal_id: int | None = None
|
||||
owner_id: int
|
||||
completed_at: datetime | None = None
|
||||
due_date: datetime | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
__all__ = ["ActivityFeedItem", "KPIOut"]
|
||||
@@ -1,72 +0,0 @@
|
||||
"""Pydantic schemas for Deal endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
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: date | None = None
|
||||
account_id: int
|
||||
won_lost_reason: str | None = Field(None, max_length=512)
|
||||
|
||||
|
||||
class DealCreate(DealBase):
|
||||
pass
|
||||
|
||||
|
||||
class DealUpdate(BaseModel):
|
||||
title: str | None = Field(None, min_length=1, max_length=255)
|
||||
value: Decimal | None = Field(None, max_digits=12, decimal_places=2)
|
||||
currency: str | None = Field(None, min_length=3, max_length=3)
|
||||
close_date: date | None = None
|
||||
won_lost_reason: str | None = 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: datetime | None = None
|
||||
|
||||
|
||||
class DealListItem(DealOut):
|
||||
pass
|
||||
|
||||
|
||||
class DealStageUpdate(BaseModel):
|
||||
"""Body for PATCH /api/v1/deals/{id}/stage."""
|
||||
|
||||
stage: DealStage
|
||||
reason: str | None = 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",
|
||||
]
|
||||
@@ -1,42 +0,0 @@
|
||||
"""Pydantic schemas for Note endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
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: str | None = 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: datetime | None = None
|
||||
|
||||
|
||||
__all__ = ["NoteCreate", "NoteOut", "NoteUpdate"]
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Role schemas."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class RoleCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
permissions: dict[str, Any] = Field(default_factory=dict)
|
||||
field_permissions: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class RoleUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
permissions: dict[str, Any] | None = None
|
||||
field_permissions: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class RoleResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
permissions: dict[str, Any]
|
||||
field_permissions: dict[str, Any]
|
||||
@@ -1,56 +0,0 @@
|
||||
"""Pydantic schemas for Tag and TagLink endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
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: datetime | None = 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: datetime | None = None
|
||||
|
||||
|
||||
__all__ = ["TagCreate", "TagLinkCreate", "TagLinkOut", "TagOut"]
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Tenant schemas."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class TenantCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=200)
|
||||
slug: str = Field(..., min_length=1, max_length=100)
|
||||
|
||||
|
||||
class TenantResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
slug: str
|
||||
|
||||
|
||||
class TenantUserAssign(BaseModel):
|
||||
user_id: str = Field(..., min_length=1)
|
||||
+20
-38
@@ -1,55 +1,37 @@
|
||||
"""Pydantic schemas for User endpoints."""
|
||||
"""User schemas."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field
|
||||
|
||||
from app.models.user import UserRole
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
|
||||
|
||||
class UserOut(BaseModel):
|
||||
"""Response schema for a user (never includes password_hash)."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
class UserCreate(BaseModel):
|
||||
email: EmailStr
|
||||
name: str
|
||||
role: UserRole
|
||||
org_id: int
|
||||
avatar_url: str | None = None
|
||||
email_notifications: bool = True
|
||||
created_at: datetime
|
||||
name: str = Field(..., min_length=1, max_length=200)
|
||||
password: str = Field(..., min_length=8)
|
||||
role: str = Field(default="viewer")
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
"""Request body for PATCH /api/v1/users/{id} (admin or self)."""
|
||||
|
||||
name: str | None = Field(None, min_length=1, max_length=255)
|
||||
avatar_url: str | None = Field(None, max_length=1024)
|
||||
email_notifications: bool | None = None
|
||||
role: UserRole | None = None # admin-only
|
||||
name: str | None = Field(None, min_length=1, max_length=200)
|
||||
role: str | None = None
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
class UserCreateRequest(BaseModel):
|
||||
"""Request body for POST /api/v1/users (admin-only)."""
|
||||
|
||||
email: EmailStr
|
||||
password: str = Field(..., min_length=8, max_length=128)
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
role: UserRole = UserRole.sales_rep
|
||||
class UserResponse(BaseModel):
|
||||
id: str
|
||||
email: str
|
||||
name: str
|
||||
role: str
|
||||
is_active: bool
|
||||
tenant_id: str
|
||||
|
||||
|
||||
class UserListResponse(BaseModel):
|
||||
"""Response schema for paginated user list."""
|
||||
|
||||
items: list[UserOut]
|
||||
class PaginatedUsers(BaseModel):
|
||||
items: list[UserResponse]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
|
||||
# Re-export for late import in auth.py
|
||||
__all__ = ["UserCreateRequest", "UserListResponse", "UserOut", "UserUpdate"]
|
||||
|
||||
Reference in New Issue
Block a user