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:
leocrm-bot
2026-06-29 00:10:10 +02:00
parent 6520e88d53
commit 3ab4925783
137 changed files with 3866 additions and 10195 deletions
+1 -86
View File
@@ -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."""
-58
View File
@@ -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"]
-65
View File
@@ -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
View File
@@ -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
View File
@@ -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
+23
View File
@@ -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
-45
View File
@@ -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"]
-39
View File
@@ -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"]
-72
View File
@@ -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",
]
-42
View File
@@ -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"]
+26
View File
@@ -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]
-56
View File
@@ -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"]
+20
View File
@@ -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
View File
@@ -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"]