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:
@@ -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",
|
||||
]
|
||||
|
||||
590
app/modules/tenancy/schemas/admin.py
Normal file
590
app/modules/tenancy/schemas/admin.py
Normal 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
|
||||
216
app/modules/tenancy/schemas/company.py
Normal file
216
app/modules/tenancy/schemas/company.py
Normal 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
|
||||
293
app/modules/tenancy/schemas/team.py
Normal file
293
app/modules/tenancy/schemas/team.py
Normal 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
|
||||
351
app/modules/tenancy/schemas/vendor.py
Normal file
351
app/modules/tenancy/schemas/vendor.py
Normal 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}
|
||||
129
app/modules/tenancy/schemas/vendor_domain.py
Normal file
129
app/modules/tenancy/schemas/vendor_domain.py
Normal 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
|
||||
Reference in New Issue
Block a user