refactor: complete module-driven architecture migration

This commit completes the migration to a fully module-driven architecture:

## Models Migration
- Moved all domain models from models/database/ to their respective modules:
  - tenancy: User, Admin, Vendor, Company, Platform, VendorDomain, etc.
  - cms: MediaFile, VendorTheme
  - messaging: Email, VendorEmailSettings, VendorEmailTemplate
  - core: AdminMenuConfig
- models/database/ now only contains Base and TimestampMixin (infrastructure)

## Schemas Migration
- Moved all domain schemas from models/schema/ to their respective modules:
  - tenancy: company, vendor, admin, team, vendor_domain
  - cms: media, image, vendor_theme
  - messaging: email
- models/schema/ now only contains base.py and auth.py (infrastructure)

## Routes Migration
- Moved admin routes from app/api/v1/admin/ to modules:
  - menu_config.py -> core module
  - modules.py -> tenancy module
  - module_config.py -> tenancy module
- app/api/v1/admin/ now only aggregates auto-discovered module routes

## Menu System
- Implemented module-driven menu system with MenuDiscoveryService
- Extended FrontendType enum: PLATFORM, ADMIN, VENDOR, STOREFRONT
- Added MenuItemDefinition and MenuSectionDefinition dataclasses
- Each module now defines its own menu items in definition.py
- MenuService integrates with MenuDiscoveryService for template rendering

## Documentation
- Updated docs/architecture/models-structure.md
- Updated docs/architecture/menu-management.md
- Updated architecture validation rules for new exceptions

## Architecture Validation
- Updated MOD-019 rule to allow base.py in models/schema/
- Created core module exceptions.py and schemas/ directory
- All validation errors resolved (only warnings remain)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-01 21:02:56 +01:00
parent 09d7d282c6
commit d7a0ff8818
307 changed files with 5536 additions and 3826 deletions

View File

@@ -2,11 +2,207 @@
"""
Tenancy module Pydantic schemas.
Request/response schemas for platform, company, vendor, and admin user management.
Currently schemas remain in models/schema/ - this package is a placeholder
for future migration.
Request/response schemas for platform, company, vendor, admin user, and team management.
"""
# Schemas will be migrated here from models/schema/
# For now, import from legacy location if needed:
# from models.schema.vendor import VendorDetailResponse
# Company schemas
from app.modules.tenancy.schemas.company import (
CompanyBase,
CompanyCreate,
CompanyCreateResponse,
CompanyDetailResponse,
CompanyListResponse,
CompanyResponse,
CompanySummary,
CompanyTransferOwnership,
CompanyTransferOwnershipResponse,
CompanyUpdate,
)
# Vendor schemas
from app.modules.tenancy.schemas.vendor import (
LetzshopExportFileInfo,
LetzshopExportRequest,
LetzshopExportResponse,
VendorCreate,
VendorCreateResponse,
VendorDetailResponse,
VendorListResponse,
VendorResponse,
VendorSummary,
VendorUpdate,
)
# Admin schemas
from app.modules.tenancy.schemas.admin import (
AdminAuditLogFilters,
AdminAuditLogListResponse,
AdminAuditLogResponse,
AdminDashboardStats,
AdminNotificationCreate,
AdminNotificationListResponse,
AdminNotificationResponse,
AdminNotificationUpdate,
AdminSessionListResponse,
AdminSessionResponse,
AdminSettingCreate,
AdminSettingDefaultResponse,
AdminSettingListResponse,
AdminSettingResponse,
AdminSettingUpdate,
ApplicationLogFilters,
ApplicationLogListResponse,
ApplicationLogResponse,
BulkUserAction,
BulkUserActionResponse,
BulkVendorAction,
BulkVendorActionResponse,
ComponentHealthStatus,
FileLogResponse,
LogCleanupResponse,
LogDeleteResponse,
LogFileInfo,
LogFileListResponse,
LogSettingsResponse,
LogSettingsUpdate,
LogSettingsUpdateResponse,
LogStatistics,
PlatformAlertCreate,
PlatformAlertListResponse,
PlatformAlertResolve,
PlatformAlertResponse,
PublicDisplaySettingsResponse,
RowsPerPageResponse,
RowsPerPageUpdateResponse,
SystemHealthResponse,
)
# Team schemas
from app.modules.tenancy.schemas.team import (
BulkRemoveRequest,
BulkRemoveResponse,
InvitationAccept,
InvitationAcceptResponse,
InvitationResponse,
PermissionCheckRequest,
PermissionCheckResponse,
RoleBase,
RoleCreate,
RoleListResponse,
RoleResponse,
RoleUpdate,
TeamErrorResponse,
TeamMemberBase,
TeamMemberInvite,
TeamMemberListResponse,
TeamMemberResponse,
TeamMemberUpdate,
TeamStatistics,
UserPermissionsResponse,
)
# Vendor domain schemas
from app.modules.tenancy.schemas.vendor_domain import (
DomainDeletionResponse,
DomainVerificationInstructions,
DomainVerificationResponse,
VendorDomainCreate,
VendorDomainListResponse,
VendorDomainResponse,
VendorDomainUpdate,
)
__all__ = [
# Company
"CompanyBase",
"CompanyCreate",
"CompanyCreateResponse",
"CompanyDetailResponse",
"CompanyListResponse",
"CompanyResponse",
"CompanySummary",
"CompanyTransferOwnership",
"CompanyTransferOwnershipResponse",
"CompanyUpdate",
# Vendor
"LetzshopExportFileInfo",
"LetzshopExportRequest",
"LetzshopExportResponse",
"VendorCreate",
"VendorCreateResponse",
"VendorDetailResponse",
"VendorListResponse",
"VendorResponse",
"VendorSummary",
"VendorUpdate",
# Admin
"AdminAuditLogFilters",
"AdminAuditLogListResponse",
"AdminAuditLogResponse",
"AdminDashboardStats",
"AdminNotificationCreate",
"AdminNotificationListResponse",
"AdminNotificationResponse",
"AdminNotificationUpdate",
"AdminSessionListResponse",
"AdminSessionResponse",
"AdminSettingCreate",
"AdminSettingDefaultResponse",
"AdminSettingListResponse",
"AdminSettingResponse",
"AdminSettingUpdate",
"ApplicationLogFilters",
"ApplicationLogListResponse",
"ApplicationLogResponse",
"BulkUserAction",
"BulkUserActionResponse",
"BulkVendorAction",
"BulkVendorActionResponse",
"ComponentHealthStatus",
"FileLogResponse",
"LogCleanupResponse",
"LogDeleteResponse",
"LogFileInfo",
"LogFileListResponse",
"LogSettingsResponse",
"LogSettingsUpdate",
"LogSettingsUpdateResponse",
"LogStatistics",
"PlatformAlertCreate",
"PlatformAlertListResponse",
"PlatformAlertResolve",
"PlatformAlertResponse",
"PublicDisplaySettingsResponse",
"RowsPerPageResponse",
"RowsPerPageUpdateResponse",
"SystemHealthResponse",
# Team
"BulkRemoveRequest",
"BulkRemoveResponse",
"InvitationAccept",
"InvitationAcceptResponse",
"InvitationResponse",
"PermissionCheckRequest",
"PermissionCheckResponse",
"RoleBase",
"RoleCreate",
"RoleListResponse",
"RoleResponse",
"RoleUpdate",
"TeamErrorResponse",
"TeamMemberBase",
"TeamMemberInvite",
"TeamMemberListResponse",
"TeamMemberResponse",
"TeamMemberUpdate",
"TeamStatistics",
"UserPermissionsResponse",
# Vendor Domain
"DomainDeletionResponse",
"DomainVerificationInstructions",
"DomainVerificationResponse",
"VendorDomainCreate",
"VendorDomainListResponse",
"VendorDomainResponse",
"VendorDomainUpdate",
]

View File

@@ -0,0 +1,590 @@
# app/modules/tenancy/schemas/admin.py
"""
Admin-specific Pydantic schemas for API validation and responses.
This module provides schemas for:
- Admin audit logs
- Admin notifications
- Platform settings
- Platform alerts
- Bulk operations
- System health checks
"""
from datetime import datetime
from typing import Any
from pydantic import BaseModel, Field, field_validator
# ============================================================================
# ADMIN AUDIT LOG SCHEMAS
# ============================================================================
class AdminAuditLogResponse(BaseModel):
"""Response model for admin audit logs."""
id: int
admin_user_id: int
admin_username: str | None = None
action: str
target_type: str
target_id: str
details: dict[str, Any] | None = None
ip_address: str | None = None
user_agent: str | None = None
request_id: str | None = None
created_at: datetime
model_config = {"from_attributes": True}
class AdminAuditLogFilters(BaseModel):
"""Filters for querying audit logs."""
admin_user_id: int | None = None
action: str | None = None
target_type: str | None = None
date_from: datetime | None = None
date_to: datetime | None = None
skip: int = Field(0, ge=0)
limit: int = Field(100, ge=1, le=1000)
class AdminAuditLogListResponse(BaseModel):
"""Paginated list of audit logs."""
logs: list[AdminAuditLogResponse]
total: int
skip: int
limit: int
# ============================================================================
# ADMIN NOTIFICATION SCHEMAS
# ============================================================================
class AdminNotificationCreate(BaseModel):
"""Create admin notification."""
type: str = Field(..., max_length=50, description="Notification type")
priority: str = Field(default="normal", description="Priority level")
title: str = Field(..., max_length=200)
message: str = Field(..., description="Notification message")
action_required: bool = Field(default=False)
action_url: str | None = Field(None, max_length=500)
metadata: dict[str, Any] | None = None
@field_validator("priority")
@classmethod
def validate_priority(cls, v):
allowed = ["low", "normal", "high", "critical"]
if v not in allowed:
raise ValueError(f"Priority must be one of: {', '.join(allowed)}")
return v
class AdminNotificationResponse(BaseModel):
"""Admin notification response."""
id: int
type: str
priority: str
title: str
message: str
is_read: bool
read_at: datetime | None = None
read_by_user_id: int | None = None
action_required: bool
action_url: str | None = None
metadata: dict[str, Any] | None = None
created_at: datetime
model_config = {"from_attributes": True}
class AdminNotificationUpdate(BaseModel):
"""Mark notification as read."""
is_read: bool = True
class AdminNotificationListResponse(BaseModel):
"""Paginated list of notifications."""
notifications: list[AdminNotificationResponse]
total: int
unread_count: int
skip: int
limit: int
# ============================================================================
# ADMIN SETTINGS SCHEMAS
# ============================================================================
class AdminSettingCreate(BaseModel):
"""Create or update admin setting."""
key: str = Field(..., max_length=100, description="Unique setting key")
value: str = Field(..., description="Setting value")
value_type: str = Field(default="string", description="Data type")
category: str | None = Field(None, max_length=50)
description: str | None = None
is_encrypted: bool = Field(default=False)
is_public: bool = Field(default=False, description="Can be exposed to frontend")
@field_validator("value_type")
@classmethod
def validate_value_type(cls, v):
allowed = ["string", "integer", "boolean", "json", "float"]
if v not in allowed:
raise ValueError(f"Value type must be one of: {', '.join(allowed)}")
return v
@field_validator("key")
@classmethod
def validate_key_format(cls, v):
# Setting keys should be lowercase with underscores
if not v.replace("_", "").isalnum():
raise ValueError(
"Setting key must contain only letters, numbers, and underscores"
)
return v.lower()
class AdminSettingResponse(BaseModel):
"""Admin setting response."""
id: int
key: str
value: str
value_type: str
category: str | None = None
description: str | None = None
is_encrypted: bool
is_public: bool
last_modified_by_user_id: int | None = None
updated_at: datetime
model_config = {"from_attributes": True}
class AdminSettingDefaultResponse(BaseModel):
"""Response when returning a default value for non-existent setting."""
key: str
value: str
exists: bool = False
class AdminSettingUpdate(BaseModel):
"""Update admin setting value."""
value: str
description: str | None = None
class AdminSettingListResponse(BaseModel):
"""List of settings by category."""
settings: list[AdminSettingResponse]
total: int
category: str | None = None
# ============================================================================
# DISPLAY SETTINGS SCHEMAS
# ============================================================================
class RowsPerPageResponse(BaseModel):
"""Response for rows per page setting."""
rows_per_page: int
class RowsPerPageUpdateResponse(BaseModel):
"""Response after updating rows per page."""
rows_per_page: int
message: str
class PublicDisplaySettingsResponse(BaseModel):
"""Public display settings (no auth required)."""
rows_per_page: int
# ============================================================================
# PLATFORM ALERT SCHEMAS
# ============================================================================
class PlatformAlertCreate(BaseModel):
"""Create platform alert."""
alert_type: str = Field(..., max_length=50)
severity: str = Field(..., description="Alert severity")
title: str = Field(..., max_length=200)
description: str | None = None
affected_vendors: list[int] | None = None
affected_systems: list[str] | None = None
auto_generated: bool = Field(default=True)
@field_validator("severity")
@classmethod
def validate_severity(cls, v):
allowed = ["info", "warning", "error", "critical"]
if v not in allowed:
raise ValueError(f"Severity must be one of: {', '.join(allowed)}")
return v
@field_validator("alert_type")
@classmethod
def validate_alert_type(cls, v):
allowed = [
"security",
"performance",
"capacity",
"integration",
"database",
"system",
]
if v not in allowed:
raise ValueError(f"Alert type must be one of: {', '.join(allowed)}")
return v
class PlatformAlertResponse(BaseModel):
"""Platform alert response."""
id: int
alert_type: str
severity: str
title: str
description: str | None = None
affected_vendors: list[int] | None = None
affected_systems: list[str] | None = None
is_resolved: bool
resolved_at: datetime | None = None
resolved_by_user_id: int | None = None
resolution_notes: str | None = None
auto_generated: bool
occurrence_count: int
first_occurred_at: datetime
last_occurred_at: datetime
created_at: datetime
model_config = {"from_attributes": True}
class PlatformAlertResolve(BaseModel):
"""Resolve platform alert."""
is_resolved: bool = True
resolution_notes: str | None = None
class PlatformAlertListResponse(BaseModel):
"""Paginated list of platform alerts."""
alerts: list[PlatformAlertResponse]
total: int
active_count: int
critical_count: int
skip: int
limit: int
# ============================================================================
# BULK OPERATION SCHEMAS
# ============================================================================
class BulkVendorAction(BaseModel):
"""Bulk actions on vendors."""
vendor_ids: list[int] = Field(..., min_length=1, max_length=100)
action: str = Field(..., description="Action to perform")
confirm: bool = Field(default=False, description="Required for destructive actions")
reason: str | None = Field(None, description="Reason for bulk action")
@field_validator("action")
@classmethod
def validate_action(cls, v):
allowed = ["activate", "deactivate", "verify", "unverify", "delete"]
if v not in allowed:
raise ValueError(f"Action must be one of: {', '.join(allowed)}")
return v
class BulkVendorActionResponse(BaseModel):
"""Response for bulk vendor actions."""
successful: list[int]
failed: dict[int, str] # vendor_id -> error_message
total_processed: int
action_performed: str
message: str
class BulkUserAction(BaseModel):
"""Bulk actions on users."""
user_ids: list[int] = Field(..., min_length=1, max_length=100)
action: str = Field(..., description="Action to perform")
confirm: bool = Field(default=False)
reason: str | None = None
@field_validator("action")
@classmethod
def validate_action(cls, v):
allowed = ["activate", "deactivate", "delete"]
if v not in allowed:
raise ValueError(f"Action must be one of: {', '.join(allowed)}")
return v
class BulkUserActionResponse(BaseModel):
"""Response for bulk user actions."""
successful: list[int]
failed: dict[int, str]
total_processed: int
action_performed: str
message: str
# ============================================================================
# ADMIN DASHBOARD SCHEMAS
# ============================================================================
class AdminDashboardStats(BaseModel):
"""Comprehensive admin dashboard statistics."""
platform: dict[str, Any]
users: dict[str, Any]
vendors: dict[str, Any]
products: dict[str, Any]
orders: dict[str, Any]
imports: dict[str, Any]
recent_vendors: list[dict[str, Any]]
recent_imports: list[dict[str, Any]]
unread_notifications: int
active_alerts: int
critical_alerts: int
# ============================================================================
# SYSTEM HEALTH SCHEMAS
# ============================================================================
class ComponentHealthStatus(BaseModel):
"""Health status for a system component."""
status: str # healthy, degraded, unhealthy
response_time_ms: float | None = None
error_message: str | None = None
last_checked: datetime
details: dict[str, Any] | None = None
class SystemHealthResponse(BaseModel):
"""System health check response."""
overall_status: str # healthy, degraded, critical
database: ComponentHealthStatus
redis: ComponentHealthStatus
celery: ComponentHealthStatus
storage: ComponentHealthStatus
api_response_time_ms: float
uptime_seconds: int
timestamp: datetime
# ============================================================================
# ADMIN SESSION SCHEMAS
# ============================================================================
class AdminSessionResponse(BaseModel):
"""Admin session information."""
id: int
admin_user_id: int
admin_username: str | None = None
ip_address: str
user_agent: str | None = None
login_at: datetime
last_activity_at: datetime
logout_at: datetime | None = None
is_active: bool
logout_reason: str | None = None
model_config = {"from_attributes": True}
class AdminSessionListResponse(BaseModel):
"""List of admin sessions."""
sessions: list[AdminSessionResponse]
total: int
active_count: int
# ============================================================================
# APPLICATION LOGS SCHEMAS
# ============================================================================
class ApplicationLogResponse(BaseModel):
"""Application log entry response."""
id: int
timestamp: datetime
level: str
logger_name: str
module: str | None = None
function_name: str | None = None
line_number: int | None = None
message: str
exception_type: str | None = None
exception_message: str | None = None
stack_trace: str | None = None
request_id: str | None = None
user_id: int | None = None
vendor_id: int | None = None
context: dict[str, Any] | None = None
created_at: datetime
model_config = {"from_attributes": True}
class ApplicationLogFilters(BaseModel):
"""Filters for querying application logs."""
level: str | None = Field(None, description="Filter by log level")
logger_name: str | None = Field(None, description="Filter by logger name")
module: str | None = Field(None, description="Filter by module")
user_id: int | None = Field(None, description="Filter by user ID")
vendor_id: int | None = Field(None, description="Filter by vendor ID")
date_from: datetime | None = Field(None, description="Start date")
date_to: datetime | None = Field(None, description="End date")
search: str | None = Field(None, description="Search in message")
skip: int = Field(0, ge=0)
limit: int = Field(100, ge=1, le=1000)
class ApplicationLogListResponse(BaseModel):
"""Paginated list of application logs."""
logs: list[ApplicationLogResponse]
total: int
skip: int
limit: int
class LogStatistics(BaseModel):
"""Statistics about application logs."""
total_count: int
warning_count: int
error_count: int
critical_count: int
by_level: dict[str, int]
by_module: dict[str, int]
recent_errors: list[ApplicationLogResponse]
# ============================================================================
# LOG SETTINGS SCHEMAS
# ============================================================================
class LogSettingsResponse(BaseModel):
"""Log configuration settings."""
log_level: str
log_file_max_size_mb: int
log_file_backup_count: int
db_log_retention_days: int
file_logging_enabled: bool
db_logging_enabled: bool
class LogSettingsUpdate(BaseModel):
"""Update log settings."""
log_level: str | None = Field(
None, description="Log level: DEBUG, INFO, WARNING, ERROR, CRITICAL"
)
log_file_max_size_mb: int | None = Field(
None, ge=1, le=1000, description="Max log file size in MB"
)
log_file_backup_count: int | None = Field(
None, ge=0, le=50, description="Number of backup files to keep"
)
db_log_retention_days: int | None = Field(
None, ge=1, le=365, description="Days to retain logs in database"
)
@field_validator("log_level")
@classmethod
def validate_log_level(cls, v):
if v is not None:
allowed = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
if v.upper() not in allowed:
raise ValueError(f"Log level must be one of: {', '.join(allowed)}")
return v.upper()
return v
class FileLogResponse(BaseModel):
"""File log content response."""
filename: str
size_bytes: int
last_modified: datetime
lines: list[str]
total_lines: int
class LogFileInfo(BaseModel):
"""Log file info for listing."""
filename: str
size_bytes: int
last_modified: datetime
class LogFileListResponse(BaseModel):
"""Response for listing log files."""
files: list[LogFileInfo]
class LogDeleteResponse(BaseModel):
"""Response for log deletion."""
message: str
class LogCleanupResponse(BaseModel):
"""Response for log cleanup operation."""
message: str
deleted_count: int
class LogSettingsUpdateResponse(BaseModel):
"""Response for log settings update."""
message: str
updated_fields: list[str]
note: str | None = None

View File

@@ -0,0 +1,216 @@
# app/modules/tenancy/schemas/company.py
"""
Pydantic schemas for Company model.
These schemas are used for API request/response validation and serialization.
"""
from datetime import datetime
from typing import Any
from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator
class CompanyBase(BaseModel):
"""Base schema for company with common fields."""
name: str = Field(..., min_length=2, max_length=200, description="Company name")
description: str | None = Field(None, description="Company description")
contact_email: EmailStr = Field(..., description="Business contact email")
contact_phone: str | None = Field(None, description="Business phone number")
website: str | None = Field(None, description="Company website URL")
business_address: str | None = Field(None, description="Physical business address")
tax_number: str | None = Field(None, description="Tax/VAT registration number")
@field_validator("contact_email")
@classmethod
def normalize_email(cls, v):
"""Normalize email to lowercase."""
return v.lower() if v else v
class CompanyCreate(CompanyBase):
"""
Schema for creating a new company.
Requires owner_email to create the associated owner user account.
"""
owner_email: EmailStr = Field(
..., description="Email for the company owner account"
)
@field_validator("owner_email")
@classmethod
def normalize_owner_email(cls, v):
"""Normalize owner email to lowercase."""
return v.lower() if v else v
model_config = ConfigDict(from_attributes=True)
class CompanyUpdate(BaseModel):
"""
Schema for updating company information.
All fields are optional to support partial updates.
"""
name: str | None = Field(None, min_length=2, max_length=200)
description: str | None = None
contact_email: EmailStr | None = None
contact_phone: str | None = None
website: str | None = None
business_address: str | None = None
tax_number: str | None = None
# Status (Admin only)
is_active: bool | None = None
is_verified: bool | None = None
@field_validator("contact_email")
@classmethod
def normalize_email(cls, v):
"""Normalize email to lowercase."""
return v.lower() if v else v
model_config = ConfigDict(from_attributes=True)
class CompanyResponse(BaseModel):
"""Standard schema for company response data."""
model_config = ConfigDict(from_attributes=True)
id: int
name: str
description: str | None
# Owner information
owner_user_id: int
# Contact Information
contact_email: str
contact_phone: str | None
website: str | None
# Business Information
business_address: str | None
tax_number: str | None
# Status Flags
is_active: bool
is_verified: bool
# Timestamps
created_at: str
updated_at: str
class CompanyDetailResponse(CompanyResponse):
"""
Detailed company response including vendor count and owner details.
Used for company detail pages and admin views.
"""
# Owner details (from related User)
owner_email: str | None = Field(None, description="Owner's email address")
owner_username: str | None = Field(None, description="Owner's username")
# Vendor statistics
vendor_count: int = Field(0, description="Number of vendors under this company")
active_vendor_count: int = Field(
0, description="Number of active vendors under this company"
)
# Vendors list (optional, for detail view)
vendors: list | None = Field(None, description="List of vendors under this company")
class CompanyListResponse(BaseModel):
"""Schema for paginated company list."""
companies: list[CompanyResponse]
total: int
skip: int
limit: int
class CompanyCreateResponse(BaseModel):
"""
Response after creating a company with owner account.
Includes temporary password for the owner (shown only once).
"""
company: CompanyResponse
owner_user_id: int
owner_username: str
owner_email: str
temporary_password: str = Field(
..., description="Temporary password for owner (SHOWN ONLY ONCE)"
)
login_url: str | None = Field(None, description="URL for company owner to login")
class CompanySummary(BaseModel):
"""Lightweight company summary for dropdowns and quick references."""
model_config = ConfigDict(from_attributes=True)
id: int
name: str
is_active: bool
is_verified: bool
vendor_count: int = 0
class CompanyTransferOwnership(BaseModel):
"""
Schema for transferring company ownership to another user.
This is a critical operation that requires:
- Confirmation flag
- Reason for audit trail (optional)
"""
new_owner_user_id: int = Field(
..., description="ID of the user who will become the new owner", gt=0
)
confirm_transfer: bool = Field(
..., description="Must be true to confirm ownership transfer"
)
transfer_reason: str | None = Field(
None,
max_length=500,
description="Reason for ownership transfer (for audit logs)",
)
@field_validator("confirm_transfer")
@classmethod
def validate_confirmation(cls, v):
"""Ensure confirmation is explicitly true."""
if not v:
raise ValueError("Ownership transfer requires explicit confirmation")
return v
class CompanyTransferOwnershipResponse(BaseModel):
"""Response after successful ownership transfer."""
message: str
company_id: int
company_name: str
old_owner: dict[str, Any] = Field(
..., description="Information about the previous owner"
)
new_owner: dict[str, Any] = Field(
..., description="Information about the new owner"
)
transferred_at: datetime
transfer_reason: str | None

View File

@@ -0,0 +1,293 @@
# app/modules/tenancy/schemas/team.py
"""
Pydantic schemas for vendor team management.
This module defines request/response schemas for:
- Team member listing
- Team member invitation
- Team member updates
- Role management
"""
from datetime import datetime
from pydantic import BaseModel, EmailStr, Field, field_validator
# ============================================================================
# Role Schemas
# ============================================================================
class RoleBase(BaseModel):
"""Base role schema."""
name: str = Field(..., min_length=1, max_length=100, description="Role name")
permissions: list[str] = Field(
default_factory=list, description="List of permission strings"
)
class RoleCreate(RoleBase):
"""Schema for creating a role."""
class RoleUpdate(BaseModel):
"""Schema for updating a role."""
name: str | None = Field(None, min_length=1, max_length=100)
permissions: list[str] | None = None
class RoleResponse(RoleBase):
"""Schema for role response."""
id: int
vendor_id: int
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True # Pydantic v2 (use orm_mode = True for v1)
class RoleListResponse(BaseModel):
"""Schema for role list response."""
roles: list[RoleResponse]
total: int
# ============================================================================
# Team Member Schemas
# ============================================================================
class TeamMemberBase(BaseModel):
"""Base team member schema."""
email: EmailStr = Field(..., description="Team member email address")
first_name: str | None = Field(None, max_length=100)
last_name: str | None = Field(None, max_length=100)
class TeamMemberInvite(TeamMemberBase):
"""Schema for inviting a team member."""
role_id: int | None = Field(
None, description="Role ID to assign (for preset roles)"
)
role_name: str | None = Field(
None, description="Role name (manager, staff, support, etc.)"
)
custom_permissions: list[str] | None = Field(
None, description="Custom permissions (overrides role preset)"
)
@field_validator("role_name")
def validate_role_name(cls, v):
"""Validate role name is in allowed presets."""
if v is not None:
allowed_roles = ["manager", "staff", "support", "viewer", "marketing"]
if v.lower() not in allowed_roles:
raise ValueError(
f"Role name must be one of: {', '.join(allowed_roles)}"
)
return v.lower() if v else v
@field_validator("custom_permissions")
def validate_custom_permissions(cls, v, values):
"""Ensure either role_id/role_name OR custom_permissions is provided."""
if v is not None and len(v) > 0:
# If custom permissions provided, role_name should be provided too
if "role_name" not in values or not values["role_name"]:
raise ValueError(
"role_name is required when providing custom_permissions"
)
return v
class TeamMemberUpdate(BaseModel):
"""Schema for updating a team member."""
role_id: int | None = Field(None, description="New role ID")
is_active: bool | None = Field(None, description="Active status")
class TeamMemberResponse(BaseModel):
"""Schema for team member response."""
id: int = Field(..., description="User ID")
email: EmailStr
username: str
first_name: str | None
last_name: str | None
full_name: str
user_type: str = Field(..., description="'owner' or 'member'")
role_name: str = Field(..., description="Role name")
role_id: int | None
permissions: list[str] = Field(
default_factory=list, description="User's permissions"
)
is_active: bool
is_owner: bool
invitation_pending: bool = Field(
default=False, description="True if invitation not yet accepted"
)
invited_at: datetime | None = Field(None, description="When invitation was sent")
accepted_at: datetime | None = Field(
None, description="When invitation was accepted"
)
joined_at: datetime = Field(..., description="When user joined vendor")
class Config:
from_attributes = True
class TeamMemberListResponse(BaseModel):
"""Schema for team member list response."""
members: list[TeamMemberResponse]
total: int
active_count: int
pending_invitations: int
# ============================================================================
# Invitation Schemas
# ============================================================================
class InvitationAccept(BaseModel):
"""Schema for accepting a team invitation."""
invitation_token: str = Field(
..., min_length=32, description="Invitation token from email"
)
password: str = Field(
..., min_length=8, max_length=128, description="Password for new account"
)
first_name: str = Field(..., min_length=1, max_length=100)
last_name: str = Field(..., min_length=1, max_length=100)
@field_validator("password")
def validate_password_strength(cls, v):
"""Validate password meets minimum requirements."""
if len(v) < 8:
raise ValueError("Password must be at least 8 characters long")
has_upper = any(c.isupper() for c in v)
has_lower = any(c.islower() for c in v)
has_digit = any(c.isdigit() for c in v)
if not (has_upper and has_lower and has_digit):
raise ValueError(
"Password must contain at least one uppercase letter, "
"one lowercase letter, and one digit"
)
return v
class InvitationResponse(BaseModel):
"""Schema for invitation response."""
message: str
email: EmailStr
role: str
invitation_token: str | None = Field(
None, description="Token (only returned in dev/test environments)"
)
invitation_sent: bool = Field(default=True)
class InvitationAcceptResponse(BaseModel):
"""Schema for invitation acceptance response."""
message: str
vendor: dict = Field(..., description="Vendor information")
user: dict = Field(..., description="User information")
role: str
# ============================================================================
# Team Statistics Schema
# ============================================================================
class TeamStatistics(BaseModel):
"""Schema for team statistics."""
total_members: int
active_members: int
inactive_members: int
pending_invitations: int
owners: int
team_members: int
roles_breakdown: dict = Field(
default_factory=dict, description="Count of members per role"
)
# ============================================================================
# Bulk Operations Schemas
# ============================================================================
class BulkRemoveRequest(BaseModel):
"""Schema for bulk removing team members."""
user_ids: list[int] = Field(
..., min_items=1, description="List of user IDs to remove"
)
class BulkRemoveResponse(BaseModel):
"""Schema for bulk remove response."""
success_count: int
failed_count: int
errors: list[dict] = Field(default_factory=list)
# ============================================================================
# Permission Check Schemas
# ============================================================================
class PermissionCheckRequest(BaseModel):
"""Schema for checking permissions."""
permissions: list[str] = Field(..., min_items=1, description="Permissions to check")
class PermissionCheckResponse(BaseModel):
"""Schema for permission check response."""
has_all: bool = Field(..., description="True if user has all permissions")
has_any: bool = Field(..., description="True if user has any permission")
granted: list[str] = Field(default_factory=list, description="Permissions user has")
denied: list[str] = Field(
default_factory=list, description="Permissions user lacks"
)
class UserPermissionsResponse(BaseModel):
"""Schema for user's permissions response."""
permissions: list[str] = Field(default_factory=list)
permission_count: int
is_owner: bool
role_name: str | None = None
# ============================================================================
# Error Response Schema
# ============================================================================
class TeamErrorResponse(BaseModel):
"""Schema for team operation errors."""
error_code: str
message: str
details: dict | None = None

View File

@@ -0,0 +1,351 @@
# app/modules/tenancy/schemas/vendor.py
"""
Pydantic schemas for Vendor-related operations.
Schemas include:
- VendorCreate: For creating vendors under companies
- VendorUpdate: For updating vendor information (Admin only)
- VendorResponse: Standard vendor response
- VendorDetailResponse: Vendor response with company/owner details
- VendorCreateResponse: Response after vendor creation
- VendorListResponse: Paginated vendor list
- VendorSummary: Lightweight vendor info
Note: Ownership transfer is handled at the Company level.
See models/schema/company.py for CompanyTransferOwnership.
"""
import re
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field, field_validator
class VendorCreate(BaseModel):
"""
Schema for creating a new vendor (storefront/brand) under an existing company.
Contact info is inherited from the parent company by default.
Optionally, provide contact fields to override from the start.
"""
# Parent company
company_id: int = Field(..., description="ID of the parent company", gt=0)
# Basic Information
vendor_code: str = Field(
...,
description="Unique vendor identifier (e.g., TECHSTORE)",
min_length=2,
max_length=50,
)
subdomain: str = Field(
..., description="Unique subdomain for the vendor", min_length=2, max_length=100
)
name: str = Field(
...,
description="Display name of the vendor/brand",
min_length=2,
max_length=255,
)
description: str | None = Field(None, description="Vendor/brand description")
# Platform assignments (optional - vendor can be on multiple platforms)
platform_ids: list[int] | None = Field(
None, description="List of platform IDs to assign the vendor to"
)
# Marketplace URLs (brand-specific multi-language support)
letzshop_csv_url_fr: str | None = Field(None, description="French CSV URL")
letzshop_csv_url_en: str | None = Field(None, description="English CSV URL")
letzshop_csv_url_de: str | None = Field(None, description="German CSV URL")
# Contact Info (optional - if not provided, inherited from company)
contact_email: str | None = Field(
None, description="Override company contact email"
)
contact_phone: str | None = Field(
None, description="Override company contact phone"
)
website: str | None = Field(None, description="Override company website")
business_address: str | None = Field(
None, description="Override company business address"
)
tax_number: str | None = Field(None, description="Override company tax number")
# Language Settings
default_language: str | None = Field(
"fr", description="Default language for content (en, fr, de, lb)"
)
dashboard_language: str | None = Field(
"fr", description="Vendor dashboard UI language"
)
storefront_language: str | None = Field(
"fr", description="Default storefront language for customers"
)
storefront_languages: list[str] | None = Field(
default=["fr", "de", "en"], description="Enabled languages for storefront"
)
storefront_locale: str | None = Field(
None,
description="Locale for currency/number formatting (e.g., 'fr-LU', 'de-DE'). NULL = inherit from platform default",
max_length=10,
)
@field_validator("subdomain")
@classmethod
def validate_subdomain(cls, v):
"""Validate subdomain format: lowercase alphanumeric with hyphens."""
if v and not re.match(r"^[a-z0-9][a-z0-9-]*[a-z0-9]$", v):
raise ValueError(
"Subdomain must contain only lowercase letters, numbers, and hyphens"
)
return v.lower() if v else v
@field_validator("vendor_code")
@classmethod
def validate_vendor_code(cls, v):
"""Ensure vendor code is uppercase for consistency."""
return v.upper() if v else v
class VendorUpdate(BaseModel):
"""
Schema for updating vendor information (Admin only).
Contact fields can be overridden at the vendor level.
Set to null/empty to reset to company default (inherit).
"""
# Basic Information
name: str | None = Field(None, min_length=2, max_length=255)
description: str | None = None
subdomain: str | None = Field(None, min_length=2, max_length=100)
# Marketplace URLs (brand-specific)
letzshop_csv_url_fr: str | None = None
letzshop_csv_url_en: str | None = None
letzshop_csv_url_de: str | None = None
# Status (Admin only)
is_active: bool | None = None
is_verified: bool | None = None
# Contact Info (set value to override, set to empty string to reset to inherit)
contact_email: str | None = Field(
None, description="Override company contact email"
)
contact_phone: str | None = Field(
None, description="Override company contact phone"
)
website: str | None = Field(None, description="Override company website")
business_address: str | None = Field(
None, description="Override company business address"
)
tax_number: str | None = Field(None, description="Override company tax number")
# Special flag to reset contact fields to inherit from company
reset_contact_to_company: bool | None = Field(
None, description="If true, reset all contact fields to inherit from company"
)
# Language Settings
default_language: str | None = Field(
None, description="Default language for content (en, fr, de, lb)"
)
dashboard_language: str | None = Field(
None, description="Vendor dashboard UI language"
)
storefront_language: str | None = Field(
None, description="Default storefront language for customers"
)
storefront_languages: list[str] | None = Field(
None, description="Enabled languages for storefront"
)
storefront_locale: str | None = Field(
None,
description="Locale for currency/number formatting (e.g., 'fr-LU', 'de-DE'). NULL = inherit from platform default",
max_length=10,
)
@field_validator("subdomain")
@classmethod
def subdomain_lowercase(cls, v):
"""Normalize subdomain to lowercase."""
return v.lower().strip() if v else v
model_config = ConfigDict(from_attributes=True)
class VendorResponse(BaseModel):
"""
Standard schema for vendor response data.
Note: Business contact info (contact_email, contact_phone, website,
business_address, tax_number) is now at the Company level.
Use company_id to look up company details.
"""
model_config = ConfigDict(from_attributes=True)
id: int
vendor_code: str
subdomain: str
name: str
description: str | None
# Company relationship
company_id: int
# Marketplace URLs (brand-specific)
letzshop_csv_url_fr: str | None
letzshop_csv_url_en: str | None
letzshop_csv_url_de: str | None
# Status Flags
is_active: bool
is_verified: bool
# Language Settings (optional with defaults for backward compatibility)
default_language: str = "fr"
dashboard_language: str = "fr"
storefront_language: str = "fr"
storefront_languages: list[str] = ["fr", "de", "en"]
# Currency/number formatting locale (NULL = inherit from platform default)
storefront_locale: str | None = None
# Timestamps
created_at: datetime
updated_at: datetime
class VendorDetailResponse(VendorResponse):
"""
Extended vendor response including company information and resolved contact info.
Contact fields show the effective value (vendor override or company default)
with flags indicating if the value is inherited from the parent company.
"""
# Company info
company_name: str = Field(..., description="Name of the parent company")
# Owner info (at company level)
owner_email: str = Field(
..., description="Email of the company owner (for login/authentication)"
)
owner_username: str = Field(..., description="Username of the company owner")
# Resolved contact info (vendor override or company default)
contact_email: str | None = Field(None, description="Effective contact email")
contact_phone: str | None = Field(None, description="Effective contact phone")
website: str | None = Field(None, description="Effective website")
business_address: str | None = Field(None, description="Effective business address")
tax_number: str | None = Field(None, description="Effective tax number")
# Inheritance flags (True = value is inherited from company, not overridden)
contact_email_inherited: bool = Field(
False, description="True if contact_email is from company"
)
contact_phone_inherited: bool = Field(
False, description="True if contact_phone is from company"
)
website_inherited: bool = Field(
False, description="True if website is from company"
)
business_address_inherited: bool = Field(
False, description="True if business_address is from company"
)
tax_number_inherited: bool = Field(
False, description="True if tax_number is from company"
)
# Original company values (for reference in UI)
company_contact_email: str | None = Field(
None, description="Company's contact email"
)
company_contact_phone: str | None = Field(
None, description="Company's phone number"
)
company_website: str | None = Field(None, description="Company's website URL")
company_business_address: str | None = Field(
None, description="Company's business address"
)
company_tax_number: str | None = Field(None, description="Company's tax number")
class VendorCreateResponse(VendorDetailResponse):
"""
Response after creating vendor under an existing company.
The vendor is created under a company, so no new owner credentials are generated.
The company owner already has access to this vendor.
"""
login_url: str | None = Field(None, description="URL for vendor storefront")
class VendorListResponse(BaseModel):
"""Schema for paginated vendor list."""
vendors: list[VendorResponse]
total: int
skip: int
limit: int
class VendorSummary(BaseModel):
"""Lightweight vendor summary for dropdowns and quick references."""
model_config = ConfigDict(from_attributes=True)
id: int
vendor_code: str
subdomain: str
name: str
company_id: int
is_active: bool
# NOTE: Vendor ownership transfer schemas have been removed.
# Ownership transfer is now handled at the Company level.
# See models/schema/company.py for CompanyTransferOwnership and CompanyTransferOwnershipResponse.
# ============================================================================
# LETZSHOP EXPORT SCHEMAS
# ============================================================================
class LetzshopExportRequest(BaseModel):
"""Request body for Letzshop export to pickup folder."""
include_inactive: bool = Field(
default=False,
description="Include inactive products in export"
)
class LetzshopExportFileInfo(BaseModel):
"""Info about an exported file."""
language: str
filename: str | None = None
path: str | None = None
size_bytes: int | None = None
error: str | None = None
class LetzshopExportResponse(BaseModel):
"""Response from Letzshop export to folder."""
success: bool
message: str
vendor_code: str
export_directory: str
files: list[LetzshopExportFileInfo]
celery_task_id: str | None = None # Set when using Celery async export
is_async: bool = Field(default=False, serialization_alias="async") # True when queued via Celery
model_config = {"populate_by_name": True}

View File

@@ -0,0 +1,129 @@
# app/modules/tenancy/schemas/vendor_domain.py
"""
Pydantic schemas for Vendor Domain operations.
Schemas include:
- VendorDomainCreate: For adding custom domains
- VendorDomainUpdate: For updating domain settings
- VendorDomainResponse: Standard domain response
- VendorDomainListResponse: Paginated domain list
- DomainVerificationInstructions: DNS verification instructions
"""
import re
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field, field_validator
class VendorDomainCreate(BaseModel):
"""Schema for adding a custom domain to vendor."""
domain: str = Field(
...,
description="Custom domain (e.g., myshop.com or shop.mybrand.com)",
min_length=3,
max_length=255,
)
is_primary: bool = Field(
default=False, description="Set as primary domain for the vendor"
)
@field_validator("domain")
@classmethod
def validate_domain(cls, v: str) -> str:
"""Validate and normalize domain."""
# Remove protocol if present
domain = v.replace("https://", "").replace("http://", "") # noqa: SEC-034
# Remove trailing slash
domain = domain.rstrip("/")
# Convert to lowercase
domain = domain.lower().strip()
# Basic validation
if not domain or "/" in domain:
raise ValueError("Invalid domain format")
if "." not in domain:
raise ValueError("Domain must have at least one dot")
# Check for reserved subdomains
reserved = ["www", "admin", "api", "mail", "smtp", "ftp", "cpanel", "webmail"]
first_part = domain.split(".")[0]
if first_part in reserved:
raise ValueError(
f"Domain cannot start with reserved subdomain: {first_part}"
)
# Validate domain format (basic regex)
domain_pattern = r"^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$"
if not re.match(domain_pattern, domain):
raise ValueError("Invalid domain format")
return domain
class VendorDomainUpdate(BaseModel):
"""Schema for updating vendor domain settings."""
is_primary: bool | None = Field(None, description="Set as primary domain")
is_active: bool | None = Field(None, description="Activate or deactivate domain")
model_config = ConfigDict(from_attributes=True)
class VendorDomainResponse(BaseModel):
"""Standard schema for vendor domain response."""
model_config = ConfigDict(from_attributes=True)
id: int
vendor_id: int
domain: str
is_primary: bool
is_active: bool
is_verified: bool
ssl_status: str
verification_token: str | None = None
verified_at: datetime | None = None
ssl_verified_at: datetime | None = None
created_at: datetime
updated_at: datetime
class VendorDomainListResponse(BaseModel):
"""Schema for paginated vendor domain list."""
domains: list[VendorDomainResponse]
total: int
class DomainVerificationInstructions(BaseModel):
"""DNS verification instructions for domain ownership."""
domain: str
verification_token: str
instructions: dict[str, str]
txt_record: dict[str, str]
common_registrars: dict[str, str]
model_config = ConfigDict(from_attributes=True)
class DomainVerificationResponse(BaseModel):
"""Response after domain verification."""
message: str
domain: str
verified_at: datetime
is_verified: bool
class DomainDeletionResponse(BaseModel):
"""Response after domain deletion."""
message: str
domain: str
vendor_id: int