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

@@ -6,8 +6,8 @@ Platform, company, vendor, and admin user management.
Required for multi-tenant operation - cannot be disabled.
"""
from app.modules.base import ModuleDefinition
from models.database.admin_menu_config import FrontendType
from app.modules.base import MenuItemDefinition, MenuSectionDefinition, ModuleDefinition
from app.modules.enums import FrontendType
tenancy_module = ModuleDefinition(
code="tenancy",
@@ -22,6 +22,7 @@ tenancy_module = ModuleDefinition(
"vendor_management",
"admin_user_management",
],
# Legacy menu_items
menu_items={
FrontendType.ADMIN: [
"platforms",
@@ -33,6 +34,84 @@ tenancy_module = ModuleDefinition(
"team",
],
},
# New module-driven menu definitions
menus={
FrontendType.ADMIN: [
MenuSectionDefinition(
id="superAdmin",
label_key="tenancy.menu.super_admin",
icon="shield",
order=10,
is_super_admin_only=True,
items=[
MenuItemDefinition(
id="admin-users",
label_key="tenancy.menu.admin_users",
icon="shield",
route="/admin/admin-users",
order=10,
is_mandatory=True,
),
],
),
MenuSectionDefinition(
id="platformAdmin",
label_key="tenancy.menu.platform_admin",
icon="office-building",
order=20,
items=[
MenuItemDefinition(
id="companies",
label_key="tenancy.menu.companies",
icon="office-building",
route="/admin/companies",
order=10,
is_mandatory=True,
),
MenuItemDefinition(
id="vendors",
label_key="tenancy.menu.vendors",
icon="shopping-bag",
route="/admin/vendors",
order=20,
is_mandatory=True,
),
],
),
MenuSectionDefinition(
id="contentMgmt",
label_key="tenancy.menu.content_management",
icon="globe-alt",
order=70,
items=[
MenuItemDefinition(
id="platforms",
label_key="tenancy.menu.platforms",
icon="globe-alt",
route="/admin/platforms",
order=10,
),
],
),
],
FrontendType.VENDOR: [
MenuSectionDefinition(
id="account",
label_key="tenancy.menu.account_settings",
icon="user-group",
order=900,
items=[
MenuItemDefinition(
id="team",
label_key="tenancy.menu.team",
icon="user-group",
route="/vendor/{vendor_code}/team",
order=5,
),
],
),
],
},
services_path="app.modules.tenancy.services",
models_path="app.modules.tenancy.models",
schemas_path="app.modules.tenancy.schemas",

View File

@@ -0,0 +1,81 @@
{
"team": {
"title": "Team",
"members": "Mitglieder",
"add_member": "Mitglied hinzufügen",
"invite_member": "Mitglied einladen",
"remove_member": "Mitglied entfernen",
"role": "Rolle",
"owner": "Inhaber",
"manager": "Manager",
"editor": "Bearbeiter",
"viewer": "Betrachter",
"permissions": "Berechtigungen",
"pending_invitations": "Ausstehende Einladungen",
"invitation_sent": "Einladung gesendet",
"invitation_accepted": "Einladung angenommen"
},
"messages": {
"business_info_saved": "Business info saved",
"marketplace_settings_saved": "Marketplace settings saved",
"please_enter_a_url_first": "Please enter a URL first",
"could_not_validate_url_it_may_still_work": "Could not validate URL - it may still work",
"localization_settings_saved": "Localization settings saved",
"failed_to_load_email_settings": "Failed to load email settings",
"from_email_and_from_name_are_required": "From Email and From Name are required",
"email_settings_saved": "Email settings saved",
"please_enter_a_test_email_address": "Please enter a test email address",
"please_save_your_email_settings_first": "Please save your email settings first",
"test_email_sent_check_your_inbox": "Test email sent! Check your inbox.",
"please_fix_the_errors_before_saving": "Please fix the errors before saving",
"profile_updated_successfully": "Profile updated successfully",
"email_is_required": "Email is required",
"invitation_sent_successfully": "Invitation sent successfully",
"team_member_updated": "Team member updated",
"team_member_removed": "Team member removed",
"invalid_company_url": "Invalid company URL",
"failed_to_load_company_details": "Failed to load company details",
"company_deleted_successfully": "Company deleted successfully",
"company_details_refreshed": "Company details refreshed",
"invalid_admin_user_url": "Invalid admin user URL",
"failed_to_load_admin_user_details": "Failed to load admin user details",
"you_cannot_deactivate_your_own_account": "You cannot deactivate your own account",
"you_cannot_delete_your_own_account": "You cannot delete your own account",
"admin_user_deleted_successfully": "Admin user deleted successfully",
"admin_user_details_refreshed": "Admin user details refreshed",
"failed_to_initialize_page": "Failed to initialize page",
"failed_to_load_company": "Failed to load company",
"company_updated_successfully": "Company updated successfully",
"ownership_transferred_successfully": "Ownership transferred successfully",
"theme_saved_successfully": "Theme saved successfully",
"failed_to_apply_preset": "Failed to apply preset",
"theme_reset_to_default": "Theme reset to default",
"failed_to_reset_theme": "Failed to reset theme",
"failed_to_load_vendors": "Failed to load vendors",
"vendor_deleted_successfully": "Vendor deleted successfully",
"vendors_list_refreshed": "Vendors list refreshed",
"invalid_user_url": "Invalid user URL",
"failed_to_load_user_details": "Failed to load user details",
"user_deleted_successfully": "User deleted successfully",
"user_details_refreshed": "User details refreshed",
"invalid_vendor_url": "Invalid vendor URL",
"failed_to_load_vendor_details": "Failed to load vendor details",
"no_vendor_loaded": "No vendor loaded",
"subscription_created_successfully": "Subscription created successfully",
"vendor_details_refreshed": "Vendor details refreshed",
"failed_to_load_users": "Failed to load users",
"failed_to_delete_user": "Failed to delete user",
"failed_to_load_admin_users": "Failed to load admin users",
"failed_to_load_admin_user": "Failed to load admin user",
"you_cannot_demote_yourself_from_super_ad": "You cannot demote yourself from super admin",
"platform_assigned_successfully": "Platform assigned successfully",
"platform_admin_must_be_assigned_to_at_le": "Platform admin must be assigned to at least one platform",
"platform_removed_successfully": "Platform removed successfully",
"please_fix_the_errors_before_submitting": "Please fix the errors before submitting",
"failed_to_load_vendor": "Failed to load vendor",
"vendor_updated_successfully": "Vendor updated successfully",
"all_contact_fields_reset_to_company_defa": "All contact fields reset to company defaults",
"failed_to_load_user": "Failed to load user",
"user_updated_successfully": "User updated successfully"
}
}

View File

@@ -0,0 +1,81 @@
{
"team": {
"title": "Équipe",
"members": "Membres",
"add_member": "Ajouter un membre",
"invite_member": "Inviter un membre",
"remove_member": "Retirer un membre",
"role": "Rôle",
"owner": "Propriétaire",
"manager": "Gestionnaire",
"editor": "Éditeur",
"viewer": "Lecteur",
"permissions": "Permissions",
"pending_invitations": "Invitations en attente",
"invitation_sent": "Invitation envoyée",
"invitation_accepted": "Invitation acceptée"
},
"messages": {
"business_info_saved": "Business info saved",
"marketplace_settings_saved": "Marketplace settings saved",
"please_enter_a_url_first": "Please enter a URL first",
"could_not_validate_url_it_may_still_work": "Could not validate URL - it may still work",
"localization_settings_saved": "Localization settings saved",
"failed_to_load_email_settings": "Failed to load email settings",
"from_email_and_from_name_are_required": "From Email and From Name are required",
"email_settings_saved": "Email settings saved",
"please_enter_a_test_email_address": "Please enter a test email address",
"please_save_your_email_settings_first": "Please save your email settings first",
"test_email_sent_check_your_inbox": "Test email sent! Check your inbox.",
"please_fix_the_errors_before_saving": "Please fix the errors before saving",
"profile_updated_successfully": "Profile updated successfully",
"email_is_required": "Email is required",
"invitation_sent_successfully": "Invitation sent successfully",
"team_member_updated": "Team member updated",
"team_member_removed": "Team member removed",
"invalid_company_url": "Invalid company URL",
"failed_to_load_company_details": "Failed to load company details",
"company_deleted_successfully": "Company deleted successfully",
"company_details_refreshed": "Company details refreshed",
"invalid_admin_user_url": "Invalid admin user URL",
"failed_to_load_admin_user_details": "Failed to load admin user details",
"you_cannot_deactivate_your_own_account": "You cannot deactivate your own account",
"you_cannot_delete_your_own_account": "You cannot delete your own account",
"admin_user_deleted_successfully": "Admin user deleted successfully",
"admin_user_details_refreshed": "Admin user details refreshed",
"failed_to_initialize_page": "Failed to initialize page",
"failed_to_load_company": "Failed to load company",
"company_updated_successfully": "Company updated successfully",
"ownership_transferred_successfully": "Ownership transferred successfully",
"theme_saved_successfully": "Theme saved successfully",
"failed_to_apply_preset": "Failed to apply preset",
"theme_reset_to_default": "Theme reset to default",
"failed_to_reset_theme": "Failed to reset theme",
"failed_to_load_vendors": "Failed to load vendors",
"vendor_deleted_successfully": "Vendor deleted successfully",
"vendors_list_refreshed": "Vendors list refreshed",
"invalid_user_url": "Invalid user URL",
"failed_to_load_user_details": "Failed to load user details",
"user_deleted_successfully": "User deleted successfully",
"user_details_refreshed": "User details refreshed",
"invalid_vendor_url": "Invalid vendor URL",
"failed_to_load_vendor_details": "Failed to load vendor details",
"no_vendor_loaded": "No vendor loaded",
"subscription_created_successfully": "Subscription created successfully",
"vendor_details_refreshed": "Vendor details refreshed",
"failed_to_load_users": "Failed to load users",
"failed_to_delete_user": "Failed to delete user",
"failed_to_load_admin_users": "Failed to load admin users",
"failed_to_load_admin_user": "Failed to load admin user",
"you_cannot_demote_yourself_from_super_ad": "You cannot demote yourself from super admin",
"platform_assigned_successfully": "Platform assigned successfully",
"platform_admin_must_be_assigned_to_at_le": "Platform admin must be assigned to at least one platform",
"platform_removed_successfully": "Platform removed successfully",
"please_fix_the_errors_before_submitting": "Please fix the errors before submitting",
"failed_to_load_vendor": "Failed to load vendor",
"vendor_updated_successfully": "Vendor updated successfully",
"all_contact_fields_reset_to_company_defa": "All contact fields reset to company defaults",
"failed_to_load_user": "Failed to load user",
"user_updated_successfully": "User updated successfully"
}
}

View File

@@ -0,0 +1,81 @@
{
"team": {
"title": "Team",
"members": "Memberen",
"add_member": "Member derbäisetzen",
"invite_member": "Member invitéieren",
"remove_member": "Member ewechhuelen",
"role": "Roll",
"owner": "Proprietär",
"manager": "Manager",
"editor": "Editeur",
"viewer": "Betruechter",
"permissions": "Rechter",
"pending_invitations": "Aussteesend Invitatiounen",
"invitation_sent": "Invitatioun geschéckt",
"invitation_accepted": "Invitatioun ugeholl"
},
"messages": {
"business_info_saved": "Business info saved",
"marketplace_settings_saved": "Marketplace settings saved",
"please_enter_a_url_first": "Please enter a URL first",
"could_not_validate_url_it_may_still_work": "Could not validate URL - it may still work",
"localization_settings_saved": "Localization settings saved",
"failed_to_load_email_settings": "Failed to load email settings",
"from_email_and_from_name_are_required": "From Email and From Name are required",
"email_settings_saved": "Email settings saved",
"please_enter_a_test_email_address": "Please enter a test email address",
"please_save_your_email_settings_first": "Please save your email settings first",
"test_email_sent_check_your_inbox": "Test email sent! Check your inbox.",
"please_fix_the_errors_before_saving": "Please fix the errors before saving",
"profile_updated_successfully": "Profile updated successfully",
"email_is_required": "Email is required",
"invitation_sent_successfully": "Invitation sent successfully",
"team_member_updated": "Team member updated",
"team_member_removed": "Team member removed",
"invalid_company_url": "Invalid company URL",
"failed_to_load_company_details": "Failed to load company details",
"company_deleted_successfully": "Company deleted successfully",
"company_details_refreshed": "Company details refreshed",
"invalid_admin_user_url": "Invalid admin user URL",
"failed_to_load_admin_user_details": "Failed to load admin user details",
"you_cannot_deactivate_your_own_account": "You cannot deactivate your own account",
"you_cannot_delete_your_own_account": "You cannot delete your own account",
"admin_user_deleted_successfully": "Admin user deleted successfully",
"admin_user_details_refreshed": "Admin user details refreshed",
"failed_to_initialize_page": "Failed to initialize page",
"failed_to_load_company": "Failed to load company",
"company_updated_successfully": "Company updated successfully",
"ownership_transferred_successfully": "Ownership transferred successfully",
"theme_saved_successfully": "Theme saved successfully",
"failed_to_apply_preset": "Failed to apply preset",
"theme_reset_to_default": "Theme reset to default",
"failed_to_reset_theme": "Failed to reset theme",
"failed_to_load_vendors": "Failed to load vendors",
"vendor_deleted_successfully": "Vendor deleted successfully",
"vendors_list_refreshed": "Vendors list refreshed",
"invalid_user_url": "Invalid user URL",
"failed_to_load_user_details": "Failed to load user details",
"user_deleted_successfully": "User deleted successfully",
"user_details_refreshed": "User details refreshed",
"invalid_vendor_url": "Invalid vendor URL",
"failed_to_load_vendor_details": "Failed to load vendor details",
"no_vendor_loaded": "No vendor loaded",
"subscription_created_successfully": "Subscription created successfully",
"vendor_details_refreshed": "Vendor details refreshed",
"failed_to_load_users": "Failed to load users",
"failed_to_delete_user": "Failed to delete user",
"failed_to_load_admin_users": "Failed to load admin users",
"failed_to_load_admin_user": "Failed to load admin user",
"you_cannot_demote_yourself_from_super_ad": "You cannot demote yourself from super admin",
"platform_assigned_successfully": "Platform assigned successfully",
"platform_admin_must_be_assigned_to_at_le": "Platform admin must be assigned to at least one platform",
"platform_removed_successfully": "Platform removed successfully",
"please_fix_the_errors_before_submitting": "Please fix the errors before submitting",
"failed_to_load_vendor": "Failed to load vendor",
"vendor_updated_successfully": "Vendor updated successfully",
"all_contact_fields_reset_to_company_defa": "All contact fields reset to company defaults",
"failed_to_load_user": "Failed to load user",
"user_updated_successfully": "User updated successfully"
}
}

View File

@@ -2,13 +2,53 @@
"""
Tenancy module database models.
Models for platform, company, vendor, and admin user management.
Currently models remain in models/database/ - this package is a placeholder
for future migration.
This is the canonical location for tenancy module models including:
- Platform, Company, Vendor, User management
- Admin platform assignments
- Vendor platform memberships
- Platform module configuration
- Vendor domains
"""
# Models will be migrated here from models/database/
# For now, import from legacy location if needed:
# from models.database.vendor import Vendor
# from models.database.company import Company
# from models.database.platform import Platform
from app.modules.tenancy.models.admin import (
AdminAuditLog,
AdminSession,
AdminSetting,
ApplicationLog,
PlatformAlert,
)
from app.modules.tenancy.models.admin_platform import AdminPlatform
from app.modules.tenancy.models.company import Company
from app.modules.tenancy.models.platform import Platform
from app.modules.tenancy.models.platform_module import PlatformModule
from app.modules.tenancy.models.user import User, UserRole
from app.modules.tenancy.models.vendor import Role, Vendor, VendorUser, VendorUserType
from app.modules.tenancy.models.vendor_domain import VendorDomain
from app.modules.tenancy.models.vendor_platform import VendorPlatform
__all__ = [
# Admin models
"AdminAuditLog",
"AdminSession",
"AdminSetting",
"ApplicationLog",
"PlatformAlert",
# Admin-Platform junction
"AdminPlatform",
# Company
"Company",
# Platform
"Platform",
"PlatformModule",
# User
"User",
"UserRole",
# Vendor
"Vendor",
"VendorUser",
"VendorUserType",
"Role",
# Vendor configuration
"VendorDomain",
"VendorPlatform",
]

View File

@@ -0,0 +1,205 @@
# app/modules/tenancy/models/admin.py
"""
Admin-specific database models.
This module provides models for:
- Admin audit logging (compliance and security tracking)
- Admin notifications (system alerts and warnings)
- Platform settings (global configuration)
- Platform alerts (system-wide issues)
- Application logs (critical events logging)
"""
from sqlalchemy import (
JSON,
Boolean,
Column,
DateTime,
ForeignKey,
Integer,
String,
Text,
)
from sqlalchemy.orm import relationship
from app.core.database import Base
from models.database.base import TimestampMixin
class AdminAuditLog(Base, TimestampMixin):
"""
Track all admin actions for compliance and security.
Separate from regular audit logs - focuses on admin-specific operations
like vendor creation, user management, and system configuration changes.
"""
__tablename__ = "admin_audit_logs"
id = Column(Integer, primary_key=True, index=True)
admin_user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
action = Column(
String(100), nullable=False, index=True
) # create_vendor, delete_vendor, etc.
target_type = Column(
String(50), nullable=False, index=True
) # vendor, user, import_job, setting
target_id = Column(String(100), nullable=False, index=True)
details = Column(JSON) # Additional context about the action
ip_address = Column(String(45)) # IPv4 or IPv6
user_agent = Column(Text)
request_id = Column(String(100)) # For correlating with application logs
# Relationships
admin_user = relationship("User", foreign_keys=[admin_user_id])
def __repr__(self):
return f"<AdminAuditLog(id={self.id}, action='{self.action}', target={self.target_type}:{self.target_id})>"
# AdminNotification has been moved to app/modules/messaging/models/admin_notification.py
# It's re-exported via models/database/__init__.py for backwards compatibility
class AdminSetting(Base, TimestampMixin):
"""
Platform-wide admin settings and configuration.
Stores global settings that affect the entire platform, different from
vendor-specific settings. Supports encryption for sensitive values.
Examples:
- max_vendors_allowed
- maintenance_mode
- default_vendor_trial_days
- smtp_settings
- stripe_api_keys (encrypted)
"""
__tablename__ = "admin_settings"
id = Column(Integer, primary_key=True, index=True)
key = Column(String(100), unique=True, nullable=False, index=True)
value = Column(Text, nullable=False)
value_type = Column(String(20), default="string") # string, integer, boolean, json
category = Column(
String(50), index=True
) # system, security, marketplace, notifications
description = Column(Text)
is_encrypted = Column(Boolean, default=False)
is_public = Column(Boolean, default=False) # Can be exposed to frontend?
last_modified_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
# Relationships
last_modified_by = relationship("User", foreign_keys=[last_modified_by_user_id])
def __repr__(self):
return f"<AdminSetting(key='{self.key}', category='{self.category}')>"
class PlatformAlert(Base, TimestampMixin):
"""
System-wide alerts that admins need to be aware of.
Tracks platform issues, performance problems, security incidents,
and other system-level concerns that require admin attention.
"""
__tablename__ = "platform_alerts"
id = Column(Integer, primary_key=True, index=True)
alert_type = Column(
String(50), nullable=False, index=True
) # security, performance, capacity, integration
severity = Column(
String(20), nullable=False, index=True
) # info, warning, error, critical
title = Column(String(200), nullable=False)
description = Column(Text)
affected_vendors = Column(JSON) # List of affected vendor IDs
affected_systems = Column(JSON) # List of affected system components
is_resolved = Column(Boolean, default=False, index=True)
resolved_at = Column(DateTime, nullable=True)
resolved_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
resolution_notes = Column(Text)
auto_generated = Column(Boolean, default=True) # System-generated vs manual
occurrence_count = Column(Integer, default=1) # Track repeated occurrences
first_occurred_at = Column(DateTime, nullable=False)
last_occurred_at = Column(DateTime, nullable=False)
# Relationships
resolved_by = relationship("User", foreign_keys=[resolved_by_user_id])
def __repr__(self):
return f"<PlatformAlert(id={self.id}, type='{self.alert_type}', severity='{self.severity}')>"
class AdminSession(Base, TimestampMixin):
"""
Track admin login sessions for security monitoring.
Helps identify suspicious login patterns, track concurrent sessions,
and enforce session policies for admin users.
"""
__tablename__ = "admin_sessions"
id = Column(Integer, primary_key=True, index=True)
admin_user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
session_token = Column(String(255), unique=True, nullable=False, index=True)
ip_address = Column(String(45), nullable=False)
user_agent = Column(Text)
login_at = Column(DateTime, nullable=False, index=True)
last_activity_at = Column(DateTime, nullable=False)
logout_at = Column(DateTime, nullable=True)
is_active = Column(Boolean, default=True, index=True)
logout_reason = Column(String(50)) # manual, timeout, forced, suspicious
# Relationships
admin_user = relationship("User", foreign_keys=[admin_user_id])
def __repr__(self):
return f"<AdminSession(id={self.id}, admin_user_id={self.admin_user_id}, is_active={self.is_active})>"
class ApplicationLog(Base, TimestampMixin):
"""
Application-level logs stored in database for critical events.
Stores WARNING, ERROR, and CRITICAL level logs for easy searching,
filtering, and compliance. INFO and DEBUG logs are kept in files only.
"""
__tablename__ = "application_logs"
id = Column(Integer, primary_key=True, index=True)
timestamp = Column(DateTime, nullable=False, index=True)
level = Column(String(20), nullable=False, index=True) # WARNING, ERROR, CRITICAL
logger_name = Column(String(200), nullable=False, index=True)
module = Column(String(200))
function_name = Column(String(100))
line_number = Column(Integer)
message = Column(Text, nullable=False)
exception_type = Column(String(200))
exception_message = Column(Text)
stack_trace = Column(Text)
request_id = Column(String(100), index=True) # For correlating logs
user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True)
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=True, index=True)
context = Column(JSON) # Additional context data
# Relationships
user = relationship("User", foreign_keys=[user_id])
vendor = relationship("Vendor", foreign_keys=[vendor_id])
def __repr__(self):
return f"<ApplicationLog(id={self.id}, level='{self.level}', logger='{self.logger_name}')>"
__all__ = [
"AdminAuditLog",
"AdminSetting",
"PlatformAlert",
"AdminSession",
"ApplicationLog",
]

View File

@@ -0,0 +1,164 @@
# app/modules/tenancy/models/admin_platform.py
"""
AdminPlatform junction table for many-to-many relationship between Admin Users and Platforms.
This enables platform-scoped admin access:
- Super Admins: Have is_super_admin=True on User model, bypass this table
- Platform Admins: Assigned to specific platforms via this junction table
A platform admin CAN be assigned to multiple platforms (e.g., both OMS and Loyalty).
"""
from datetime import UTC, datetime
from sqlalchemy import (
Boolean,
Column,
DateTime,
ForeignKey,
Index,
Integer,
UniqueConstraint,
)
from sqlalchemy.orm import relationship
from app.core.database import Base
from models.database.base import TimestampMixin
class AdminPlatform(Base, TimestampMixin):
"""
Junction table linking admin users to platforms they can manage.
Allows a platform admin to:
- Manage specific platforms only (not all)
- Be assigned to multiple platforms
- Have assignment tracked for audit purposes
Example:
- User "john@example.com" (admin) can manage OMS platform only
- User "jane@example.com" (admin) can manage both OMS and Loyalty platforms
"""
__tablename__ = "admin_platforms"
id = Column(Integer, primary_key=True, index=True)
# ========================================================================
# Foreign Keys
# ========================================================================
user_id = Column(
Integer,
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
index=True,
comment="Reference to the admin user",
)
platform_id = Column(
Integer,
ForeignKey("platforms.id", ondelete="CASCADE"),
nullable=False,
index=True,
comment="Reference to the platform",
)
# ========================================================================
# Assignment Status
# ========================================================================
is_active = Column(
Boolean,
default=True,
nullable=False,
comment="Whether the admin assignment is active",
)
# ========================================================================
# Audit Fields
# ========================================================================
assigned_at = Column(
DateTime(timezone=True),
default=lambda: datetime.now(UTC),
nullable=False,
comment="When the admin was assigned to this platform",
)
assigned_by_user_id = Column(
Integer,
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
comment="Super admin who made this assignment",
)
# ========================================================================
# Relationships
# ========================================================================
user = relationship(
"User",
foreign_keys=[user_id],
back_populates="admin_platforms",
)
platform = relationship(
"Platform",
back_populates="admin_platforms",
)
assigned_by = relationship(
"User",
foreign_keys=[assigned_by_user_id],
)
# ========================================================================
# Constraints & Indexes
# ========================================================================
__table_args__ = (
# Each admin can only be assigned to a platform once
UniqueConstraint(
"user_id",
"platform_id",
name="uq_admin_platform",
),
# Performance indexes
Index(
"idx_admin_platform_active",
"user_id",
"platform_id",
"is_active",
),
Index(
"idx_admin_platform_user_active",
"user_id",
"is_active",
),
)
# ========================================================================
# Properties
# ========================================================================
@property
def platform_code(self) -> str | None:
"""Get the platform code for this assignment."""
return self.platform.code if self.platform else None
@property
def platform_name(self) -> str | None:
"""Get the platform name for this assignment."""
return self.platform.name if self.platform else None
def __repr__(self) -> str:
return (
f"<AdminPlatform("
f"user_id={self.user_id}, "
f"platform_id={self.platform_id}, "
f"is_active={self.is_active})>"
)
__all__ = ["AdminPlatform"]

View File

@@ -0,0 +1,109 @@
# app/modules/tenancy/models/company.py
"""
Company model representing the business entity that owns one or more vendor brands.
A Company represents the legal/business entity with contact information,
while Vendors represent the individual brands/storefronts operated by that company.
"""
from sqlalchemy import Boolean, Column, ForeignKey, Integer, String, Text
from sqlalchemy.orm import relationship
from app.core.database import Base
from models.database.base import TimestampMixin
class Company(Base, TimestampMixin):
"""
Represents a company (business entity) in the system.
A company owns one or more vendor brands. All business/contact information
is stored at the company level to avoid duplication.
"""
__tablename__ = "companies"
# ========================================================================
# Basic Information
# ========================================================================
id = Column(Integer, primary_key=True, index=True)
"""Unique identifier for the company."""
name = Column(String, nullable=False, index=True)
"""Company legal/business name."""
description = Column(Text)
"""Optional description of the company."""
# ========================================================================
# Ownership
# ========================================================================
owner_user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
"""Foreign key to the user who owns this company."""
# ========================================================================
# Contact Information
# ========================================================================
contact_email = Column(String, nullable=False)
"""Primary business contact email."""
contact_phone = Column(String)
"""Business phone number."""
website = Column(String)
"""Company website URL."""
# ========================================================================
# Business Details
# ========================================================================
business_address = Column(Text)
"""Physical business address."""
tax_number = Column(String)
"""Tax/VAT registration number."""
# ========================================================================
# Status Flags
# ========================================================================
is_active = Column(Boolean, default=True, nullable=False)
"""Whether the company is active. Affects all associated vendors."""
is_verified = Column(Boolean, default=False, nullable=False)
"""Whether the company has been verified by platform admins."""
# ========================================================================
# Relationships
# ========================================================================
owner = relationship("User", back_populates="owned_companies")
"""The user who owns this company."""
vendors = relationship(
"Vendor",
back_populates="company",
cascade="all, delete-orphan",
order_by="Vendor.name",
)
"""All vendor brands operated by this company."""
def __repr__(self):
"""String representation of the Company object."""
return f"<Company(id={self.id}, name='{self.name}', vendors={len(self.vendors) if self.vendors else 0})>"
# ========================================================================
# Helper Properties
# ========================================================================
@property
def vendor_count(self) -> int:
"""Get the number of vendors belonging to this company."""
return len(self.vendors) if self.vendors else 0
@property
def active_vendor_count(self) -> int:
"""Get the number of active vendors belonging to this company."""
if not self.vendors:
return 0
return sum(1 for v in self.vendors if v.is_active)
__all__ = ["Company"]

View File

@@ -0,0 +1,242 @@
# app/modules/tenancy/models/platform.py
"""
Platform model representing a business offering/product line.
Platforms are independent business products (e.g., OMS, Loyalty Program, Site Builder)
that can have their own:
- Marketing pages (homepage, pricing, about)
- Vendor default pages (fallback storefront pages)
- Subscription tiers with platform-specific features
- Branding and configuration
Each vendor can belong to multiple platforms via the VendorPlatform junction table.
"""
from sqlalchemy import (
JSON,
Boolean,
Column,
Index,
Integer,
String,
Text,
)
from sqlalchemy.orm import relationship
from app.core.database import Base
from models.database.base import TimestampMixin
class Platform(Base, TimestampMixin):
"""
Represents a business offering/product line.
Examples:
- Wizamart OMS (Order Management System)
- Loyalty+ (Loyalty Program Platform)
- Site Builder (Website Builder for Local Businesses)
Each platform has:
- Its own domain (production) or path prefix (development)
- Independent CMS pages (marketing pages + vendor defaults)
- Platform-specific subscription tiers
- Custom branding and theme
"""
__tablename__ = "platforms"
id = Column(Integer, primary_key=True, index=True)
# ========================================================================
# Identity
# ========================================================================
code = Column(
String(50),
unique=True,
nullable=False,
index=True,
comment="Unique platform identifier (e.g., 'oms', 'loyalty', 'sites')",
)
name = Column(
String(100),
nullable=False,
comment="Display name (e.g., 'Wizamart OMS')",
)
description = Column(
Text,
nullable=True,
comment="Platform description for admin/marketing purposes",
)
# ========================================================================
# Domain Routing
# ========================================================================
domain = Column(
String(255),
unique=True,
nullable=True,
index=True,
comment="Production domain (e.g., 'oms.lu', 'loyalty.lu')",
)
path_prefix = Column(
String(50),
unique=True,
nullable=True,
index=True,
comment="Development path prefix (e.g., 'oms' for localhost:9999/oms/*)",
)
# ========================================================================
# Branding
# ========================================================================
logo = Column(
String(500),
nullable=True,
comment="Logo URL for light mode",
)
logo_dark = Column(
String(500),
nullable=True,
comment="Logo URL for dark mode",
)
favicon = Column(
String(500),
nullable=True,
comment="Favicon URL",
)
theme_config = Column(
JSON,
nullable=True,
default=dict,
comment="Theme configuration (colors, fonts, etc.)",
)
# ========================================================================
# Localization
# ========================================================================
default_language = Column(
String(5),
default="fr",
nullable=False,
comment="Default language code (e.g., 'fr', 'en', 'de')",
)
supported_languages = Column(
JSON,
default=["fr", "de", "en"],
nullable=False,
comment="List of supported language codes",
)
# ========================================================================
# Status
# ========================================================================
is_active = Column(
Boolean,
default=True,
nullable=False,
comment="Whether the platform is active and accessible",
)
is_public = Column(
Boolean,
default=True,
nullable=False,
comment="Whether the platform is visible in public listings",
)
# ========================================================================
# Configuration
# ========================================================================
settings = Column(
JSON,
nullable=True,
default=dict,
comment="Platform-specific settings and feature flags",
)
# ========================================================================
# Relationships
# ========================================================================
# Content pages belonging to this platform
content_pages = relationship(
"ContentPage",
back_populates="platform",
cascade="all, delete-orphan",
)
# Vendors on this platform (via junction table)
vendor_platforms = relationship(
"VendorPlatform",
back_populates="platform",
cascade="all, delete-orphan",
)
# Subscription tiers for this platform
subscription_tiers = relationship(
"SubscriptionTier",
back_populates="platform",
foreign_keys="SubscriptionTier.platform_id",
)
# Admin assignments for this platform
admin_platforms = relationship(
"AdminPlatform",
back_populates="platform",
cascade="all, delete-orphan",
)
# Menu visibility configuration for platform admins
menu_configs = relationship(
"AdminMenuConfig",
back_populates="platform",
cascade="all, delete-orphan",
)
# Module enablement configuration
modules = relationship(
"PlatformModule",
back_populates="platform",
cascade="all, delete-orphan",
)
# ========================================================================
# Indexes
# ========================================================================
__table_args__ = (
Index("idx_platform_active", "is_active"),
Index("idx_platform_public", "is_public", "is_active"),
)
# ========================================================================
# Properties
# ========================================================================
@property
def base_url(self) -> str:
"""Get the base URL for this platform (for link generation)."""
if self.domain:
return f"https://{self.domain}"
if self.path_prefix:
return f"/{self.path_prefix}"
return "/"
def __repr__(self) -> str:
return f"<Platform(code='{self.code}', name='{self.name}')>"
__all__ = ["Platform"]

View File

@@ -0,0 +1,165 @@
# app/modules/tenancy/models/platform_module.py
"""
PlatformModule model for tracking module enablement per platform.
This junction table provides:
- Auditability: Track when modules were enabled/disabled and by whom
- Configuration: Per-module settings specific to each platform
- State tracking: Explicit enabled/disabled states with timestamps
Replaces the simpler Platform.settings["enabled_modules"] JSON approach
for better auditability and query capabilities.
"""
from sqlalchemy import (
JSON,
Boolean,
Column,
DateTime,
ForeignKey,
Index,
Integer,
String,
UniqueConstraint,
)
from sqlalchemy.orm import relationship
from app.core.database import Base
from models.database.base import TimestampMixin
class PlatformModule(Base, TimestampMixin):
"""
Junction table tracking module enablement per platform.
This provides a normalized, auditable way to track which modules
are enabled for each platform, with configuration options.
Example:
PlatformModule(
platform_id=1,
module_code="billing",
is_enabled=True,
enabled_at=datetime.now(),
enabled_by_user_id=42,
config={"stripe_mode": "live", "default_trial_days": 14}
)
"""
__tablename__ = "platform_modules"
id = Column(Integer, primary_key=True, index=True)
# ========================================================================
# Identity
# ========================================================================
platform_id = Column(
Integer,
ForeignKey("platforms.id", ondelete="CASCADE"),
nullable=False,
comment="Platform this module configuration belongs to",
)
module_code = Column(
String(50),
nullable=False,
comment="Module code (e.g., 'billing', 'inventory', 'orders')",
)
# ========================================================================
# State
# ========================================================================
is_enabled = Column(
Boolean,
nullable=False,
default=True,
comment="Whether this module is currently enabled for the platform",
)
# ========================================================================
# Audit Trail - Enable
# ========================================================================
enabled_at = Column(
DateTime(timezone=True),
nullable=True,
comment="When the module was last enabled",
)
enabled_by_user_id = Column(
Integer,
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
comment="User who enabled the module",
)
# ========================================================================
# Audit Trail - Disable
# ========================================================================
disabled_at = Column(
DateTime(timezone=True),
nullable=True,
comment="When the module was last disabled",
)
disabled_by_user_id = Column(
Integer,
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
comment="User who disabled the module",
)
# ========================================================================
# Configuration
# ========================================================================
config = Column(
JSON,
nullable=False,
default=dict,
comment="Module-specific configuration for this platform",
)
# ========================================================================
# Relationships
# ========================================================================
platform = relationship(
"Platform",
back_populates="modules",
)
enabled_by = relationship(
"User",
foreign_keys=[enabled_by_user_id],
)
disabled_by = relationship(
"User",
foreign_keys=[disabled_by_user_id],
)
# ========================================================================
# Constraints & Indexes
# ========================================================================
__table_args__ = (
# Each platform can only have one configuration per module
UniqueConstraint("platform_id", "module_code", name="uq_platform_module"),
# Index for querying by platform
Index("idx_platform_module_platform_id", "platform_id"),
# Index for querying by module code
Index("idx_platform_module_code", "module_code"),
# Index for querying enabled modules
Index("idx_platform_module_enabled", "platform_id", "is_enabled"),
)
def __repr__(self) -> str:
status = "enabled" if self.is_enabled else "disabled"
return f"<PlatformModule(platform_id={self.platform_id}, module='{self.module_code}', {status})>"
__all__ = ["PlatformModule"]

View File

@@ -0,0 +1,202 @@
# app/modules/tenancy/models/user.py
"""
User model with authentication support.
ROLE CLARIFICATION:
- User.role should ONLY contain platform-level roles:
* "admin" - Platform administrator (full system access)
* "vendor" - Any user who owns or is part of a vendor team
- Vendor-specific roles (manager, staff, etc.) are stored in VendorUser.role
- Customers are NOT in the User table - they use the Customer model
"""
import enum
from sqlalchemy import Boolean, Column, DateTime, Integer, String
from sqlalchemy.orm import relationship
from app.core.database import Base
from models.database.base import TimestampMixin
class UserRole(str, enum.Enum):
"""Platform-level user roles."""
ADMIN = "admin" # Platform administrator
VENDOR = "vendor" # Vendor owner or team member
class User(Base, TimestampMixin):
"""Represents a platform user (admins and vendors only)."""
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
email = Column(String, unique=True, index=True, nullable=False)
username = Column(String, unique=True, index=True, nullable=False)
first_name = Column(String)
last_name = Column(String)
hashed_password = Column(String, nullable=False)
# Platform-level role only (admin or vendor)
role = Column(String, nullable=False, default=UserRole.VENDOR.value)
is_active = Column(Boolean, default=True, nullable=False)
is_email_verified = Column(Boolean, default=False, nullable=False)
last_login = Column(DateTime, nullable=True)
# Super admin flag (only meaningful when role='admin')
# Super admins have access to ALL platforms and global settings
# Platform admins (is_super_admin=False) are assigned to specific platforms
is_super_admin = Column(Boolean, default=False, nullable=False)
# Language preference (NULL = use context default: vendor dashboard_language or system default)
# Supported: en, fr, de, lb
preferred_language = Column(String(5), nullable=True)
# Relationships
marketplace_import_jobs = relationship(
"MarketplaceImportJob", back_populates="user"
)
owned_companies = relationship("Company", back_populates="owner")
vendor_memberships = relationship(
"VendorUser", foreign_keys="[VendorUser.user_id]", back_populates="user"
)
# Admin-platform assignments (for platform admins only)
# Super admins don't need assignments - they have access to all platforms
admin_platforms = relationship(
"AdminPlatform",
foreign_keys="AdminPlatform.user_id",
back_populates="user",
cascade="all, delete-orphan",
)
# Menu visibility configuration (for super admins only)
# Platform admins get menu config from their platform, not user-level
menu_configs = relationship(
"AdminMenuConfig",
foreign_keys="AdminMenuConfig.user_id",
back_populates="user",
cascade="all, delete-orphan",
)
def __repr__(self):
"""String representation of the User object."""
return f"<User(id={self.id}, username='{self.username}', email='{self.email}', role='{self.role}')>"
@property
def full_name(self):
"""Returns the full name of the user, combining first and last names if available."""
if self.first_name and self.last_name:
return f"{self.first_name} {self.last_name}"
return self.username
@property
def is_admin(self) -> bool:
"""Check if user is a platform admin."""
return self.role == UserRole.ADMIN.value
@property
def is_vendor(self) -> bool:
"""Check if user is a vendor (owner or team member)."""
return self.role == UserRole.VENDOR.value
def is_owner_of(self, vendor_id: int) -> bool:
"""
Check if user is the owner of a specific vendor.
Ownership is determined via company ownership:
User owns Company -> Company has Vendor -> User owns Vendor
"""
for company in self.owned_companies:
if any(v.id == vendor_id for v in company.vendors):
return True
return False
def is_member_of(self, vendor_id: int) -> bool:
"""Check if user is a member of a specific vendor (owner or team)."""
# Check if owner (via company)
if self.is_owner_of(vendor_id):
return True
# Check if team member
return any(
vm.vendor_id == vendor_id and vm.is_active for vm in self.vendor_memberships
)
def get_vendor_role(self, vendor_id: int) -> str:
"""Get user's role within a specific vendor."""
# Check if owner (via company)
if self.is_owner_of(vendor_id):
return "owner"
# Check team membership
for vm in self.vendor_memberships:
if vm.vendor_id == vendor_id and vm.is_active:
return vm.role.name if vm.role else "member"
return None
def has_vendor_permission(self, vendor_id: int, permission: str) -> bool:
"""Check if user has a specific permission in a vendor."""
# Owners have all permissions
if self.is_owner_of(vendor_id):
return True
# Check team member permissions
for vm in self.vendor_memberships:
if vm.vendor_id == vendor_id and vm.is_active:
if vm.role and permission in vm.role.permissions:
return True
return False
# =========================================================================
# Admin Platform Access Methods
# =========================================================================
@property
def is_super_admin_user(self) -> bool:
"""Check if user is a super admin (can access all platforms)."""
return self.role == UserRole.ADMIN.value and self.is_super_admin
@property
def is_platform_admin(self) -> bool:
"""Check if user is a platform admin (access to assigned platforms only)."""
return self.role == UserRole.ADMIN.value and not self.is_super_admin
def can_access_platform(self, platform_id: int) -> bool:
"""
Check if admin can access a specific platform.
- Super admins can access all platforms
- Platform admins can only access assigned platforms
- Non-admins return False
"""
if not self.is_admin:
return False
if self.is_super_admin:
return True
return any(
ap.platform_id == platform_id and ap.is_active
for ap in self.admin_platforms
)
def get_accessible_platform_ids(self) -> list[int] | None:
"""
Get list of platform IDs this admin can access.
Returns:
- None for super admins (means ALL platforms)
- List of platform IDs for platform admins
- Empty list for non-admins
"""
if not self.is_admin:
return []
if self.is_super_admin:
return None # None means ALL platforms
return [ap.platform_id for ap in self.admin_platforms if ap.is_active]
__all__ = ["User", "UserRole"]

View File

@@ -0,0 +1,571 @@
# app/modules/tenancy/models/vendor.py
"""
Vendor model representing entities that sell products or services.
This module defines the Vendor model along with its relationships to
other models such as User (owner), Product, Customer, Order, and MarketplaceImportJob.
"""
import enum
from sqlalchemy import (
JSON,
Boolean,
Column,
DateTime,
ForeignKey,
Integer,
String,
Text,
)
from sqlalchemy.orm import relationship
from app.core.config import settings
# Import Base from the central database module instead of creating a new one
from app.core.database import Base
from models.database.base import TimestampMixin
class Vendor(Base, TimestampMixin):
"""Represents a vendor in the system."""
__tablename__ = "vendors" # Name of the table in the database
id = Column(
Integer, primary_key=True, index=True
) # Primary key and indexed column for vendor ID
# Company relationship
company_id = Column(
Integer, ForeignKey("companies.id"), nullable=False, index=True
) # Foreign key to the parent company
vendor_code = Column(
String, unique=True, index=True, nullable=False
) # Unique, indexed, non-nullable vendor code column
subdomain = Column(
String(100), unique=True, nullable=False, index=True
) # Unique, non-nullable subdomain column with indexing
name = Column(
String, nullable=False
) # Non-nullable name column for the vendor (brand name)
description = Column(Text) # Optional text description column for the vendor
# Letzshop URLs - multi-language support (brand-specific marketplace feeds)
letzshop_csv_url_fr = Column(String) # URL for French CSV in Letzshop
letzshop_csv_url_en = Column(String) # URL for English CSV in Letzshop
letzshop_csv_url_de = Column(String) # URL for German CSV in Letzshop
# Letzshop Vendor Identity (for linking to Letzshop marketplace profile)
letzshop_vendor_id = Column(
String(100), unique=True, nullable=True, index=True
) # Letzshop's vendor identifier
letzshop_vendor_slug = Column(
String(200), nullable=True, index=True
) # Letzshop shop URL slug (e.g., "my-shop" from letzshop.lu/vendors/my-shop)
# ========================================================================
# Letzshop Feed Settings (atalanda namespace)
# ========================================================================
# These are default values applied to all products in the Letzshop feed
# See https://letzshop.lu/en/dev#google_csv for documentation
# Default VAT rate for new products: 0 (exempt), 3 (super-reduced), 8 (reduced), 14 (intermediate), 17 (standard)
letzshop_default_tax_rate = Column(Integer, default=17, nullable=False)
# Product sort priority on Letzshop (0.0-10.0, higher = displayed first)
# Note: Having all products rated above 7 is not permitted by Letzshop
letzshop_boost_sort = Column(String(10), default="5.0") # Stored as string for precision
# Delivery method: 'nationwide', 'package_delivery', 'self_collect' (comma-separated for multiple)
# 'nationwide' automatically includes package_delivery and self_collect
letzshop_delivery_method = Column(String(100), default="package_delivery")
# Pre-order days: number of days before item ships (default 1 day)
letzshop_preorder_days = Column(Integer, default=1)
# Status (vendor-specific, can differ from company status)
is_active = Column(
Boolean, default=True
) # Boolean to indicate if the vendor brand is active
is_verified = Column(
Boolean, default=False
) # Boolean to indicate if the vendor brand is verified
# ========================================================================
# Contact Information (nullable = inherit from company)
# ========================================================================
# These fields allow vendor-specific branding/identity.
# If null, the value is inherited from the parent company.
contact_email = Column(String(255), nullable=True) # Override company contact email
contact_phone = Column(String(50), nullable=True) # Override company contact phone
website = Column(String(255), nullable=True) # Override company website
business_address = Column(Text, nullable=True) # Override company business address
tax_number = Column(String(100), nullable=True) # Override company tax number
# ========================================================================
# Language Settings
# ========================================================================
# Supported languages: en, fr, de, lb (Luxembourgish)
default_language = Column(
String(5), nullable=False, default="fr"
) # Default language for vendor content (products, emails, etc.)
dashboard_language = Column(
String(5), nullable=False, default="fr"
) # Language for vendor team dashboard UI
storefront_language = Column(
String(5), nullable=False, default="fr"
) # Default language for customer-facing storefront
storefront_languages = Column(
JSON, nullable=False, default=["fr", "de", "en"]
) # Array of enabled languages for storefront language selector
# Currency/number formatting locale (e.g., 'fr-LU' = "29,99 EUR", 'en-GB' = "EUR29.99")
# NULL means inherit from platform default (AdminSetting 'default_storefront_locale')
storefront_locale = Column(String(10), nullable=True)
# ========================================================================
# Relationships
# ========================================================================
company = relationship(
"Company", back_populates="vendors"
) # Relationship with Company model for the parent company
vendor_users = relationship(
"VendorUser", back_populates="vendor"
) # Relationship with VendorUser model for users in this vendor
products = relationship(
"Product", back_populates="vendor"
) # Relationship with Product model for products of this vendor
customers = relationship(
"Customer", back_populates="vendor"
) # Relationship with Customer model for customers of this vendor
orders = relationship(
"Order", back_populates="vendor"
) # Relationship with Order model for orders placed by this vendor
marketplace_import_jobs = relationship(
"MarketplaceImportJob", back_populates="vendor"
) # Relationship with MarketplaceImportJob model for import jobs related to this vendor
# Letzshop integration credentials (one-to-one)
letzshop_credentials = relationship(
"VendorLetzshopCredentials",
back_populates="vendor",
uselist=False,
cascade="all, delete-orphan",
)
# Invoice settings (one-to-one)
invoice_settings = relationship(
"VendorInvoiceSettings",
back_populates="vendor",
uselist=False,
cascade="all, delete-orphan",
)
# Invoices (one-to-many)
invoices = relationship(
"Invoice",
back_populates="vendor",
cascade="all, delete-orphan",
)
# Email template overrides (one-to-many)
email_templates = relationship(
"VendorEmailTemplate",
back_populates="vendor",
cascade="all, delete-orphan",
)
# Email settings (one-to-one) - vendor SMTP/provider configuration
email_settings = relationship(
"VendorEmailSettings",
back_populates="vendor",
uselist=False,
cascade="all, delete-orphan",
)
# Subscription (one-to-one)
subscription = relationship(
"VendorSubscription",
back_populates="vendor",
uselist=False,
cascade="all, delete-orphan",
)
# Add-ons purchased by vendor (one-to-many)
addons = relationship(
"VendorAddOn",
back_populates="vendor",
cascade="all, delete-orphan",
)
# Billing/invoice history (one-to-many)
billing_history = relationship(
"BillingHistory",
back_populates="vendor",
cascade="all, delete-orphan",
order_by="BillingHistory.invoice_date.desc()",
)
domains = relationship(
"VendorDomain",
back_populates="vendor",
cascade="all, delete-orphan",
order_by="VendorDomain.is_primary.desc()",
) # Relationship with VendorDomain model for custom domains of the vendor
# Single theme relationship (ONE vendor = ONE theme)
# A vendor has ONE active theme stored in the vendor_themes table.
# Theme presets available: default, modern, classic, minimal, vibrant
vendor_theme = relationship(
"VendorTheme",
back_populates="vendor",
uselist=False,
cascade="all, delete-orphan",
) # Relationship with VendorTheme model for the active theme of the vendor
# Content pages relationship (vendor can override platform default pages)
content_pages = relationship(
"ContentPage", back_populates="vendor", cascade="all, delete-orphan"
) # Relationship with ContentPage model for vendor-specific content pages
# Onboarding progress (one-to-one)
onboarding = relationship(
"VendorOnboarding",
back_populates="vendor",
uselist=False,
cascade="all, delete-orphan",
)
# Media library (one-to-many)
media_files = relationship(
"MediaFile",
back_populates="vendor",
cascade="all, delete-orphan",
)
# Platform memberships (many-to-many via junction table)
vendor_platforms = relationship(
"VendorPlatform",
back_populates="vendor",
cascade="all, delete-orphan",
)
# Loyalty program (one-to-one)
loyalty_program = relationship(
"LoyaltyProgram",
back_populates="vendor",
uselist=False,
cascade="all, delete-orphan",
)
def __repr__(self):
"""String representation of the Vendor object."""
return f"<Vendor(id={self.id}, vendor_code='{self.vendor_code}', name='{self.name}', subdomain='{self.subdomain}')>"
# ========================================================================
# Theme Helper Methods to get active theme and other related information
# ========================================================================
def get_effective_theme(self) -> dict:
"""
Get active theme for this vendor.
Returns theme from vendor_themes table, or default theme if not set.
Returns:
dict: Theme configuration with colors, fonts, layout, etc.
"""
# Check vendor_themes table
if self.vendor_theme and self.vendor_theme.is_active:
return self.vendor_theme.to_dict()
# Return default theme
return self._get_default_theme()
def _get_default_theme(self) -> dict:
"""Return the default theme configuration."""
return {
"theme_name": "default",
"colors": {
"primary": "#6366f1",
"secondary": "#8b5cf6",
"accent": "#ec4899",
"background": "#ffffff",
"text": "#1f2937",
"border": "#e5e7eb",
},
"fonts": {"heading": "Inter, sans-serif", "body": "Inter, sans-serif"},
"branding": {
"logo": None,
"logo_dark": None,
"favicon": None,
"banner": None,
},
"layout": {"style": "grid", "header": "fixed", "product_card": "modern"},
"social_links": {},
"custom_css": None,
"css_variables": {
"--color-primary": "#6366f1",
"--color-secondary": "#8b5cf6",
"--color-accent": "#ec4899",
"--color-background": "#ffffff",
"--color-text": "#1f2937",
"--color-border": "#e5e7eb",
"--font-heading": "Inter, sans-serif",
"--font-body": "Inter, sans-serif",
},
}
def get_primary_color(self) -> str:
"""Get primary color from active theme."""
theme = self.get_effective_theme()
return theme.get("colors", {}).get(
"primary", "#6366f1"
) # Default to default theme if not found
def get_logo_url(self) -> str:
"""Get logo URL from active theme."""
theme = self.get_effective_theme()
return theme.get("branding", {}).get(
"logo"
) # Return None or the logo URL if found
# ========================================================================
# Domain Helper Methods
# ========================================================================
@property
def primary_domain(self):
"""Get the primary custom domain for this vendor."""
for domain in self.domains:
if domain.is_primary and domain.is_active:
return domain.domain # Return the domain if it's primary and active
return None
@property
def all_domains(self):
"""Get all active domains (subdomain + custom domains)."""
domains = [
f"{self.subdomain}.{settings.platform_domain}"
] # Start with the main subdomain
for domain in self.domains:
if domain.is_active:
domains.append(domain.domain) # Add other active custom domains
return domains
# ========================================================================
# Contact Resolution Helper Properties
# ========================================================================
# These properties return the effective value (vendor override or company fallback)
@property
def effective_contact_email(self) -> str | None:
"""Get contact email (vendor override or company fallback)."""
if self.contact_email is not None:
return self.contact_email
return self.company.contact_email if self.company else None
@property
def effective_contact_phone(self) -> str | None:
"""Get contact phone (vendor override or company fallback)."""
if self.contact_phone is not None:
return self.contact_phone
return self.company.contact_phone if self.company else None
@property
def effective_website(self) -> str | None:
"""Get website (vendor override or company fallback)."""
if self.website is not None:
return self.website
return self.company.website if self.company else None
@property
def effective_business_address(self) -> str | None:
"""Get business address (vendor override or company fallback)."""
if self.business_address is not None:
return self.business_address
return self.company.business_address if self.company else None
@property
def effective_tax_number(self) -> str | None:
"""Get tax number (vendor override or company fallback)."""
if self.tax_number is not None:
return self.tax_number
return self.company.tax_number if self.company else None
def get_contact_info_with_inheritance(self) -> dict:
"""
Get all contact info with inheritance flags.
Returns dict with resolved values and flags indicating if inherited from company.
"""
company = self.company
return {
"contact_email": self.effective_contact_email,
"contact_email_inherited": self.contact_email is None
and company is not None,
"contact_phone": self.effective_contact_phone,
"contact_phone_inherited": self.contact_phone is None
and company is not None,
"website": self.effective_website,
"website_inherited": self.website is None and company is not None,
"business_address": self.effective_business_address,
"business_address_inherited": self.business_address is None
and company is not None,
"tax_number": self.effective_tax_number,
"tax_number_inherited": self.tax_number is None and company is not None,
}
class VendorUserType(str, enum.Enum):
"""Types of vendor users."""
OWNER = "owner" # Vendor owner (full access to vendor area)
TEAM_MEMBER = "member" # Team member (role-based access to vendor area)
class VendorUser(Base, TimestampMixin):
"""
Represents a user's membership in a vendor.
- Owner: Created automatically when vendor is created
- Team Member: Invited by owner via email
"""
__tablename__ = "vendor_users"
id = Column(Integer, primary_key=True, index=True)
"""Unique identifier for each VendorUser entry."""
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False)
"""Foreign key linking to the associated Vendor."""
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
"""Foreign key linking to the associated User."""
# Distinguish between owner and team member
user_type = Column(String, nullable=False, default=VendorUserType.TEAM_MEMBER.value)
# Role for team members (NULL for owners - they have all permissions)
role_id = Column(Integer, ForeignKey("roles.id"), nullable=True)
"""Foreign key linking to the associated Role."""
invited_by = Column(Integer, ForeignKey("users.id"))
"""Foreign key linking to the user who invited this VendorUser."""
invitation_token = Column(String, nullable=True, index=True) # For email activation
invitation_sent_at = Column(DateTime, nullable=True)
invitation_accepted_at = Column(DateTime, nullable=True)
is_active = Column(
Boolean, default=False, nullable=False
) # False until invitation accepted
"""Indicates whether the VendorUser role is active."""
# Relationships
vendor = relationship("Vendor", back_populates="vendor_users")
"""Relationship to the Vendor model, representing the associated vendor."""
user = relationship(
"User", foreign_keys=[user_id], back_populates="vendor_memberships"
)
"""Relationship to the User model, representing the user who holds this role within the vendor."""
inviter = relationship("User", foreign_keys=[invited_by])
"""Optional relationship to the User model, representing the user who invited this VendorUser."""
role = relationship("Role", back_populates="vendor_users")
"""Relationship to the Role model, representing the role held by the vendor user."""
def __repr__(self) -> str:
"""Return a string representation of the VendorUser instance.
Returns:
str: A string that includes the vendor_id, the user_id and the user_type of the VendorUser instance.
"""
return f"<VendorUser(vendor_id={self.vendor_id}, user_id={self.user_id}, type={self.user_type})>"
@property
def is_owner(self) -> bool:
"""Check if this is an owner membership."""
return self.user_type == VendorUserType.OWNER.value
@property
def is_team_member(self) -> bool:
"""Check if this is a team member (not owner)."""
return self.user_type == VendorUserType.TEAM_MEMBER.value
@property
def is_invitation_pending(self) -> bool:
"""Check if invitation is still pending."""
return self.invitation_token is not None and self.invitation_accepted_at is None
def has_permission(self, permission: str) -> bool:
"""
Check if user has a specific permission.
Owners always have all permissions.
Team members check their role's permissions.
"""
# Owners have all permissions
if self.is_owner:
return True
# Inactive users have no permissions
if not self.is_active:
return False
# Check role permissions
if self.role and self.role.permissions:
return permission in self.role.permissions
return False
def get_all_permissions(self) -> list:
"""Get all permissions this user has."""
if self.is_owner:
# Return all possible permissions
from app.core.permissions import VendorPermissions
return list(VendorPermissions.__members__.values())
if self.role and self.role.permissions:
return self.role.permissions
return []
class Role(Base, TimestampMixin):
"""Represents a role within a vendor's system."""
__tablename__ = "roles" # Name of the table in the database
id = Column(Integer, primary_key=True, index=True)
"""Unique identifier for each Role entry."""
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False)
"""Foreign key linking to the associated Vendor."""
name = Column(String(100), nullable=False)
"""Name of the role, with a maximum length of 100 characters."""
permissions = Column(JSON, default=list)
"""Permissions assigned to this role, stored as a JSON array."""
vendor = relationship("Vendor")
"""Relationship to the Vendor model, representing the associated vendor."""
vendor_users = relationship("VendorUser", back_populates="role")
"""Back-relationship to the VendorUser model, representing users with this role."""
def __repr__(self) -> str:
"""Return a string representation of the Role instance.
Returns:
str: A string that includes the id and name of the Role instance.
"""
return f"<Role(id={self.id}, name='{self.name}', vendor_id={self.vendor_id})>"
__all__ = ["Vendor", "VendorUser", "VendorUserType", "Role"]

View File

@@ -0,0 +1,99 @@
# app/modules/tenancy/models/vendor_domain.py
"""
Vendor Domain Model - Maps custom domains to vendors
"""
from sqlalchemy import (
Boolean,
Column,
DateTime,
ForeignKey,
Index,
Integer,
String,
UniqueConstraint,
)
from sqlalchemy.orm import relationship
from app.core.database import Base
from models.database.base import TimestampMixin
class VendorDomain(Base, TimestampMixin):
"""
Maps custom domains to vendors for multi-domain routing.
Examples:
- customdomain1.com -> Vendor 1
- shop.mybusiness.com -> Vendor 2
- www.customdomain1.com -> Vendor 1 (www is stripped)
"""
__tablename__ = "vendor_domains"
id = Column(Integer, primary_key=True, index=True)
vendor_id = Column(
Integer, ForeignKey("vendors.id", ondelete="CASCADE"), nullable=False
)
# Domain configuration
domain = Column(String(255), nullable=False, unique=True, index=True)
is_primary = Column(Boolean, default=False, nullable=False)
is_active = Column(Boolean, default=True, nullable=False)
# SSL/TLS status (for monitoring)
ssl_status = Column(
String(50), default="pending"
) # pending, active, expired, error
ssl_verified_at = Column(DateTime(timezone=True), nullable=True)
# DNS verification (to confirm domain ownership)
verification_token = Column(String(100), unique=True, nullable=True)
is_verified = Column(Boolean, default=False, nullable=False)
verified_at = Column(DateTime(timezone=True), nullable=True)
# Relationships
vendor = relationship("Vendor", back_populates="domains")
# Constraints
__table_args__ = (
UniqueConstraint("vendor_id", "domain", name="uq_vendor_domain"),
Index("idx_domain_active", "domain", "is_active"),
Index("idx_vendor_primary", "vendor_id", "is_primary"),
)
def __repr__(self):
return f"<VendorDomain(domain='{self.domain}', vendor_id={self.vendor_id})>"
@property
def full_url(self):
"""Return full URL with https"""
return f"https://{self.domain}"
@classmethod
def normalize_domain(cls, domain: str) -> str:
"""
Normalize domain for consistent storage.
Examples:
- https://example.com -> example.com
- www.example.com -> example.com
- EXAMPLE.COM -> example.com
"""
# Remove protocol
domain = domain.replace("https://", "").replace("http://", "") # noqa: SEC-034
# Remove trailing slash
domain = domain.rstrip("/")
# Remove www prefix (optional - depends on your preference)
# if domain.startswith("www."):
# domain = domain[4:]
# Convert to lowercase
domain = domain.lower()
return domain
__all__ = ["VendorDomain"]

View File

@@ -0,0 +1,192 @@
# app/modules/tenancy/models/vendor_platform.py
"""
VendorPlatform junction table for many-to-many relationship between Vendor and Platform.
A vendor CAN belong to multiple platforms (e.g., both OMS and Loyalty Program).
Each membership can have:
- Platform-specific subscription tier
- Custom subdomain for that platform
- Platform-specific settings
- Active/inactive status
"""
from datetime import UTC, datetime
from sqlalchemy import (
JSON,
Boolean,
Column,
DateTime,
ForeignKey,
Index,
Integer,
String,
UniqueConstraint,
)
from sqlalchemy.orm import relationship
from app.core.database import Base
from models.database.base import TimestampMixin
class VendorPlatform(Base, TimestampMixin):
"""
Junction table linking vendors to platforms.
Allows a vendor to:
- Subscribe to multiple platforms (OMS + Loyalty)
- Have different tiers per platform
- Have platform-specific subdomains
- Store platform-specific settings
Example:
- Vendor "WizaMart" is on OMS platform (Professional tier)
- Vendor "WizaMart" is also on Loyalty platform (Basic tier)
"""
__tablename__ = "vendor_platforms"
id = Column(Integer, primary_key=True, index=True)
# ========================================================================
# Foreign Keys
# ========================================================================
vendor_id = Column(
Integer,
ForeignKey("vendors.id", ondelete="CASCADE"),
nullable=False,
index=True,
comment="Reference to the vendor",
)
platform_id = Column(
Integer,
ForeignKey("platforms.id", ondelete="CASCADE"),
nullable=False,
index=True,
comment="Reference to the platform",
)
tier_id = Column(
Integer,
ForeignKey("subscription_tiers.id", ondelete="SET NULL"),
nullable=True,
index=True,
comment="Platform-specific subscription tier",
)
# ========================================================================
# Membership Status
# ========================================================================
is_active = Column(
Boolean,
default=True,
nullable=False,
comment="Whether the vendor is active on this platform",
)
is_primary = Column(
Boolean,
default=False,
nullable=False,
comment="Whether this is the vendor's primary platform",
)
# ========================================================================
# Platform-Specific Configuration
# ========================================================================
custom_subdomain = Column(
String(100),
nullable=True,
comment="Platform-specific subdomain (if different from main subdomain)",
)
settings = Column(
JSON,
nullable=True,
default=dict,
comment="Platform-specific vendor settings",
)
# ========================================================================
# Timestamps
# ========================================================================
joined_at = Column(
DateTime(timezone=True),
default=lambda: datetime.now(UTC),
nullable=False,
comment="When the vendor joined this platform",
)
# ========================================================================
# Relationships
# ========================================================================
vendor = relationship(
"Vendor",
back_populates="vendor_platforms",
)
platform = relationship(
"Platform",
back_populates="vendor_platforms",
)
tier = relationship(
"SubscriptionTier",
foreign_keys=[tier_id],
)
# ========================================================================
# Constraints & Indexes
# ========================================================================
__table_args__ = (
# Each vendor can only be on a platform once
UniqueConstraint(
"vendor_id",
"platform_id",
name="uq_vendor_platform",
),
# Performance indexes
Index(
"idx_vendor_platform_active",
"vendor_id",
"platform_id",
"is_active",
),
Index(
"idx_vendor_platform_primary",
"vendor_id",
"is_primary",
),
)
# ========================================================================
# Properties
# ========================================================================
@property
def tier_code(self) -> str | None:
"""Get the tier code for this platform membership."""
return self.tier.code if self.tier else None
@property
def tier_name(self) -> str | None:
"""Get the tier name for this platform membership."""
return self.tier.name if self.tier else None
def __repr__(self) -> str:
return (
f"<VendorPlatform("
f"vendor_id={self.vendor_id}, "
f"platform_id={self.platform_id}, "
f"is_active={self.is_active})>"
)
__all__ = ["VendorPlatform"]

View File

@@ -10,6 +10,8 @@ Aggregates all admin tenancy routes:
- /platforms/* - Platform management (super admin only)
- /vendors/* - Vendor management
- /vendor-domains/* - Vendor domain configuration
- /modules/* - Platform module management
- /module-config/* - Module configuration management
The tenancy module owns identity and organizational hierarchy.
"""
@@ -23,6 +25,8 @@ from .admin_companies import admin_companies_router
from .admin_platforms import admin_platforms_router
from .admin_vendors import admin_vendors_router
from .admin_vendor_domains import admin_vendor_domains_router
from .admin_modules import router as admin_modules_router
from .admin_module_config import router as admin_module_config_router
admin_router = APIRouter()
@@ -34,3 +38,5 @@ admin_router.include_router(admin_companies_router, tags=["admin-companies"])
admin_router.include_router(admin_platforms_router, tags=["admin-platforms"])
admin_router.include_router(admin_vendors_router, tags=["admin-vendors"])
admin_router.include_router(admin_vendor_domains_router, tags=["admin-vendor-domains"])
admin_router.include_router(admin_modules_router, tags=["admin-modules"])
admin_router.include_router(admin_module_config_router, tags=["admin-module-config"])

View File

@@ -21,7 +21,7 @@ from app.modules.tenancy.exceptions import InsufficientPermissionsException, Inv
from app.modules.tenancy.services.admin_platform_service import admin_platform_service
from app.modules.core.services.auth_service import auth_service
from middleware.auth import AuthManager
from models.database.platform import Platform # noqa: API-007 - Admin needs to query platforms
from app.modules.tenancy.models import Platform # noqa: API-007 - Admin needs to query platforms
from models.schema.auth import UserContext
from models.schema.auth import LoginResponse, LogoutResponse, UserLogin, UserResponse

View File

@@ -14,7 +14,7 @@ from app.core.database import get_db
from app.modules.tenancy.exceptions import CompanyHasVendorsException, ConfirmationRequiredException
from app.modules.tenancy.services.company_service import company_service
from models.schema.auth import UserContext
from models.schema.company import (
from app.modules.tenancy.schemas.company import (
CompanyCreate,
CompanyCreateResponse,
CompanyDetailResponse,

View File

@@ -0,0 +1,418 @@
# app/modules/tenancy/routes/api/admin_module_config.py
"""
Admin API endpoints for Module Configuration Management.
Provides per-module configuration for platforms:
- GET /module-config/platforms/{platform_id}/modules/{module_code}/config - Get module config
- PUT /module-config/platforms/{platform_id}/modules/{module_code}/config - Update module config
- GET /module-config/defaults/{module_code} - Get config defaults for a module
All endpoints require super admin access.
"""
import logging
from typing import Any
from fastapi import APIRouter, Depends, Path
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.api.deps import get_current_super_admin, get_db
from app.exceptions import ValidationException
from app.modules.registry import MODULES
from app.modules.service import module_service
from app.modules.tenancy.services.platform_service import platform_service
from models.schema.auth import UserContext
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/module-config")
# =============================================================================
# Config Defaults per Module
# =============================================================================
# Default configuration options per module
MODULE_CONFIG_DEFAULTS: dict[str, dict[str, Any]] = {
"billing": {
"stripe_mode": "test",
"default_trial_days": 14,
"allow_free_tier": True,
},
"inventory": {
"low_stock_threshold": 10,
"enable_locations": False,
},
"orders": {
"require_payment": True,
"auto_archive_days": 90,
},
"marketplace": {
"sync_frequency_hours": 24,
"auto_import_products": False,
},
"customers": {
"enable_segmentation": True,
"marketing_consent_default": False,
},
"cms": {
"max_pages": 50,
"enable_seo": True,
},
"analytics": {
"data_retention_days": 365,
"enable_export": True,
},
"messaging": {
"enable_attachments": True,
"max_attachment_size_mb": 10,
},
"monitoring": {
"log_retention_days": 30,
"alert_email": "",
},
}
# Config option metadata for UI rendering
MODULE_CONFIG_SCHEMA: dict[str, list[dict[str, Any]]] = {
"billing": [
{
"key": "stripe_mode",
"label": "Stripe Mode",
"type": "select",
"options": ["test", "live"],
"description": "Use test or live Stripe API keys",
},
{
"key": "default_trial_days",
"label": "Default Trial Days",
"type": "number",
"min": 0,
"max": 90,
"description": "Number of trial days for new subscriptions",
},
{
"key": "allow_free_tier",
"label": "Allow Free Tier",
"type": "boolean",
"description": "Allow vendors to use free tier indefinitely",
},
],
"inventory": [
{
"key": "low_stock_threshold",
"label": "Low Stock Threshold",
"type": "number",
"min": 0,
"max": 1000,
"description": "Stock level below which low stock alerts trigger",
},
{
"key": "enable_locations",
"label": "Enable Locations",
"type": "boolean",
"description": "Enable multiple inventory locations",
},
],
"orders": [
{
"key": "require_payment",
"label": "Require Payment",
"type": "boolean",
"description": "Require payment before order confirmation",
},
{
"key": "auto_archive_days",
"label": "Auto Archive Days",
"type": "number",
"min": 30,
"max": 365,
"description": "Days after which completed orders are archived",
},
],
"marketplace": [
{
"key": "sync_frequency_hours",
"label": "Sync Frequency (hours)",
"type": "number",
"min": 1,
"max": 168,
"description": "How often to sync with external marketplaces",
},
{
"key": "auto_import_products",
"label": "Auto Import Products",
"type": "boolean",
"description": "Automatically import new products from marketplace",
},
],
"customers": [
{
"key": "enable_segmentation",
"label": "Enable Segmentation",
"type": "boolean",
"description": "Enable customer segmentation and tagging",
},
{
"key": "marketing_consent_default",
"label": "Marketing Consent Default",
"type": "boolean",
"description": "Default value for marketing consent checkbox",
},
],
"cms": [
{
"key": "max_pages",
"label": "Max Pages",
"type": "number",
"min": 1,
"max": 500,
"description": "Maximum number of content pages allowed",
},
{
"key": "enable_seo",
"label": "Enable SEO",
"type": "boolean",
"description": "Enable SEO fields for content pages",
},
],
"analytics": [
{
"key": "data_retention_days",
"label": "Data Retention (days)",
"type": "number",
"min": 30,
"max": 730,
"description": "How long to keep analytics data",
},
{
"key": "enable_export",
"label": "Enable Export",
"type": "boolean",
"description": "Allow exporting analytics data",
},
],
"messaging": [
{
"key": "enable_attachments",
"label": "Enable Attachments",
"type": "boolean",
"description": "Allow file attachments in messages",
},
{
"key": "max_attachment_size_mb",
"label": "Max Attachment Size (MB)",
"type": "number",
"min": 1,
"max": 50,
"description": "Maximum attachment file size in megabytes",
},
],
"monitoring": [
{
"key": "log_retention_days",
"label": "Log Retention (days)",
"type": "number",
"min": 7,
"max": 365,
"description": "How long to keep log files",
},
{
"key": "alert_email",
"label": "Alert Email",
"type": "string",
"description": "Email address for system alerts (blank to disable)",
},
],
}
# =============================================================================
# Pydantic Schemas
# =============================================================================
class ModuleConfigResponse(BaseModel):
"""Module configuration response."""
module_code: str
module_name: str
config: dict[str, Any]
schema_info: list[dict[str, Any]] = Field(default_factory=list)
defaults: dict[str, Any] = Field(default_factory=dict)
class UpdateConfigRequest(BaseModel):
"""Request to update module configuration."""
config: dict[str, Any] = Field(..., description="Configuration key-value pairs")
class ConfigDefaultsResponse(BaseModel):
"""Response for module config defaults."""
module_code: str
module_name: str
defaults: dict[str, Any]
schema_info: list[dict[str, Any]]
# =============================================================================
# API Endpoints
# =============================================================================
@router.get("/platforms/{platform_id}/modules/{module_code}/config", response_model=ModuleConfigResponse)
async def get_module_config(
platform_id: int = Path(..., description="Platform ID"),
module_code: str = Path(..., description="Module code"),
db: Session = Depends(get_db),
current_user: UserContext = Depends(get_current_super_admin),
):
"""
Get configuration for a specific module on a platform.
Returns current config values merged with defaults.
Super admin only.
"""
# Verify platform exists
platform = platform_service.get_platform_by_id(db, platform_id)
# Validate module code
if module_code not in MODULES:
raise ValidationException(f"Unknown module: {module_code}")
module = MODULES[module_code]
# Get current config
current_config = module_service.get_module_config(db, platform_id, module_code)
# Merge with defaults
defaults = MODULE_CONFIG_DEFAULTS.get(module_code, {})
merged_config = {**defaults, **current_config}
logger.info(
f"[MODULE_CONFIG] Super admin {current_user.email} fetched config "
f"for module '{module_code}' on platform {platform.code}"
)
return ModuleConfigResponse(
module_code=module_code,
module_name=module.name,
config=merged_config,
schema_info=MODULE_CONFIG_SCHEMA.get(module_code, []),
defaults=defaults,
)
@router.put("/platforms/{platform_id}/modules/{module_code}/config", response_model=ModuleConfigResponse)
async def update_module_config(
update_data: UpdateConfigRequest,
platform_id: int = Path(..., description="Platform ID"),
module_code: str = Path(..., description="Module code"),
db: Session = Depends(get_db),
current_user: UserContext = Depends(get_current_super_admin),
):
"""
Update configuration for a specific module on a platform.
Super admin only.
"""
# Verify platform exists
platform = platform_service.get_platform_by_id(db, platform_id)
# Validate module code
if module_code not in MODULES:
raise ValidationException(f"Unknown module: {module_code}")
module = MODULES[module_code]
# Update config
success = module_service.set_module_config(db, platform_id, module_code, update_data.config)
if success:
db.commit()
# Get updated config
current_config = module_service.get_module_config(db, platform_id, module_code)
defaults = MODULE_CONFIG_DEFAULTS.get(module_code, {})
merged_config = {**defaults, **current_config}
logger.info(
f"[MODULE_CONFIG] Super admin {current_user.email} updated config "
f"for module '{module_code}' on platform {platform.code}: {update_data.config}"
)
return ModuleConfigResponse(
module_code=module_code,
module_name=module.name,
config=merged_config,
schema_info=MODULE_CONFIG_SCHEMA.get(module_code, []),
defaults=defaults,
)
@router.get("/defaults/{module_code}", response_model=ConfigDefaultsResponse)
async def get_config_defaults(
module_code: str = Path(..., description="Module code"),
current_user: UserContext = Depends(get_current_super_admin),
):
"""
Get default configuration for a module.
Returns the default config values and schema for a module.
Super admin only.
"""
# Validate module code
if module_code not in MODULES:
raise ValidationException(f"Unknown module: {module_code}")
module = MODULES[module_code]
logger.info(
f"[MODULE_CONFIG] Super admin {current_user.email} fetched defaults "
f"for module '{module_code}'"
)
return ConfigDefaultsResponse(
module_code=module_code,
module_name=module.name,
defaults=MODULE_CONFIG_DEFAULTS.get(module_code, {}),
schema_info=MODULE_CONFIG_SCHEMA.get(module_code, []),
)
@router.post("/platforms/{platform_id}/modules/{module_code}/reset")
async def reset_module_config(
platform_id: int = Path(..., description="Platform ID"),
module_code: str = Path(..., description="Module code"),
db: Session = Depends(get_db),
current_user: UserContext = Depends(get_current_super_admin),
):
"""
Reset module configuration to defaults.
Super admin only.
"""
# Verify platform exists
platform = platform_service.get_platform_by_id(db, platform_id)
# Validate module code
if module_code not in MODULES:
raise ValidationException(f"Unknown module: {module_code}")
# Reset to defaults
defaults = MODULE_CONFIG_DEFAULTS.get(module_code, {})
success = module_service.set_module_config(db, platform_id, module_code, defaults)
if success:
db.commit()
logger.info(
f"[MODULE_CONFIG] Super admin {current_user.email} reset config "
f"for module '{module_code}' on platform {platform.code} to defaults"
)
return {
"success": success,
"message": f"Module '{module_code}' config reset to defaults",
"config": defaults,
}

View File

@@ -0,0 +1,399 @@
# app/modules/tenancy/routes/api/admin_modules.py
"""
Admin API endpoints for Platform Module Management.
Provides module enablement/disablement for platforms:
- GET /modules - List all available modules
- GET /modules/platforms/{platform_id} - Get modules for a platform
- PUT /modules/platforms/{platform_id} - Update enabled modules
- POST /modules/platforms/{platform_id}/enable - Enable a module
- POST /modules/platforms/{platform_id}/disable - Disable a module
All endpoints require super admin access.
"""
import logging
from fastapi import APIRouter, Depends, Path
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.api.deps import get_current_super_admin, get_db
from app.modules.registry import MODULES, get_core_module_codes
from app.modules.service import module_service
from app.modules.tenancy.services.platform_service import platform_service
from models.schema.auth import UserContext
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/modules")
# =============================================================================
# Pydantic Schemas
# =============================================================================
class ModuleResponse(BaseModel):
"""Module definition response."""
code: str
name: str
description: str
is_core: bool
is_enabled: bool
requires: list[str] = Field(default_factory=list)
features: list[str] = Field(default_factory=list)
menu_items_admin: list[str] = Field(default_factory=list)
menu_items_vendor: list[str] = Field(default_factory=list)
dependent_modules: list[str] = Field(default_factory=list)
class ModuleListResponse(BaseModel):
"""Response for module list."""
modules: list[ModuleResponse]
total: int
enabled: int
disabled: int
class PlatformModulesResponse(BaseModel):
"""Response for platform module configuration."""
platform_id: int
platform_code: str
platform_name: str
modules: list[ModuleResponse]
total: int
enabled: int
disabled: int
class EnableModulesRequest(BaseModel):
"""Request to set enabled modules."""
module_codes: list[str] = Field(..., description="List of module codes to enable")
class ToggleModuleRequest(BaseModel):
"""Request to enable/disable a single module."""
module_code: str = Field(..., description="Module code to toggle")
# =============================================================================
# Helper Functions
# =============================================================================
def _get_dependent_modules(module_code: str) -> list[str]:
"""Get modules that depend on a given module."""
dependents = []
for code, module in MODULES.items():
if module_code in module.requires:
dependents.append(code)
return dependents
def _build_module_response(
code: str,
is_enabled: bool,
) -> ModuleResponse:
"""Build ModuleResponse from module code."""
from app.modules.enums import FrontendType # noqa: API-007 - Enum for type safety
module = MODULES.get(code)
if not module:
raise ValueError(f"Unknown module: {code}")
return ModuleResponse(
code=module.code,
name=module.name,
description=module.description,
is_core=module.is_core,
is_enabled=is_enabled,
requires=module.requires,
features=module.features,
menu_items_admin=module.get_menu_items(FrontendType.ADMIN),
menu_items_vendor=module.get_menu_items(FrontendType.VENDOR),
dependent_modules=_get_dependent_modules(module.code),
)
# =============================================================================
# API Endpoints
# =============================================================================
@router.get("", response_model=ModuleListResponse)
async def list_all_modules(
current_user: UserContext = Depends(get_current_super_admin),
):
"""
List all available modules.
Returns all module definitions with their metadata.
Super admin only.
"""
modules = []
for code in MODULES.keys():
# All modules shown as enabled in the global list
modules.append(_build_module_response(code, is_enabled=True))
# Sort: core first, then alphabetically
modules.sort(key=lambda m: (not m.is_core, m.name))
logger.info(f"[MODULES] Super admin {current_user.email} listed all modules")
return ModuleListResponse(
modules=modules,
total=len(modules),
enabled=len(modules),
disabled=0,
)
@router.get("/platforms/{platform_id}", response_model=PlatformModulesResponse)
async def get_platform_modules(
platform_id: int = Path(..., description="Platform ID"),
db: Session = Depends(get_db),
current_user: UserContext = Depends(get_current_super_admin),
):
"""
Get module configuration for a platform.
Returns all modules with their enablement status for the platform.
Super admin only.
"""
# Verify platform exists
platform = platform_service.get_platform_by_id(db, platform_id)
# Get enabled module codes for this platform
enabled_codes = module_service.get_enabled_module_codes(db, platform_id)
modules = []
for code in MODULES.keys():
is_enabled = code in enabled_codes
modules.append(_build_module_response(code, is_enabled))
# Sort: core first, then alphabetically
modules.sort(key=lambda m: (not m.is_core, m.name))
enabled_count = sum(1 for m in modules if m.is_enabled)
logger.info(
f"[MODULES] Super admin {current_user.email} fetched modules "
f"for platform {platform.code} ({enabled_count}/{len(modules)} enabled)"
)
return PlatformModulesResponse(
platform_id=platform.id,
platform_code=platform.code,
platform_name=platform.name,
modules=modules,
total=len(modules),
enabled=enabled_count,
disabled=len(modules) - enabled_count,
)
@router.put("/platforms/{platform_id}", response_model=PlatformModulesResponse)
async def update_platform_modules(
update_data: EnableModulesRequest,
platform_id: int = Path(..., description="Platform ID"),
db: Session = Depends(get_db),
current_user: UserContext = Depends(get_current_super_admin),
):
"""
Update enabled modules for a platform.
Sets the list of enabled modules. Core modules are automatically included.
Dependencies are automatically resolved.
Super admin only.
"""
# Verify platform exists
platform = platform_service.get_platform_by_id(db, platform_id)
# Update enabled modules
module_service.set_enabled_modules(db, platform_id, update_data.module_codes)
db.commit()
# Get updated module list
enabled_codes = module_service.get_enabled_module_codes(db, platform_id)
modules = []
for code in MODULES.keys():
is_enabled = code in enabled_codes
modules.append(_build_module_response(code, is_enabled))
modules.sort(key=lambda m: (not m.is_core, m.name))
enabled_count = sum(1 for m in modules if m.is_enabled)
logger.info(
f"[MODULES] Super admin {current_user.email} updated modules "
f"for platform {platform.code}: {sorted(update_data.module_codes)}"
)
return PlatformModulesResponse(
platform_id=platform.id,
platform_code=platform.code,
platform_name=platform.name,
modules=modules,
total=len(modules),
enabled=enabled_count,
disabled=len(modules) - enabled_count,
)
@router.post("/platforms/{platform_id}/enable")
async def enable_module(
request: ToggleModuleRequest,
platform_id: int = Path(..., description="Platform ID"),
db: Session = Depends(get_db),
current_user: UserContext = Depends(get_current_super_admin),
):
"""
Enable a single module for a platform.
Also enables required dependencies.
Super admin only.
"""
# Verify platform exists
platform = platform_service.get_platform_by_id(db, platform_id)
# Validate module code
if request.module_code not in MODULES:
from app.modules.tenancy.exceptions import BadRequestException
raise BadRequestException(f"Unknown module: {request.module_code}")
# Enable module
success = module_service.enable_module(db, platform_id, request.module_code)
if success:
db.commit()
# Check what dependencies were also enabled
module = MODULES[request.module_code]
enabled_deps = module.requires if module.requires else []
logger.info(
f"[MODULES] Super admin {current_user.email} enabled module "
f"'{request.module_code}' for platform {platform.code}"
)
return {
"success": success,
"message": f"Module '{request.module_code}' enabled",
"also_enabled": enabled_deps,
}
@router.post("/platforms/{platform_id}/disable")
async def disable_module(
request: ToggleModuleRequest,
platform_id: int = Path(..., description="Platform ID"),
db: Session = Depends(get_db),
current_user: UserContext = Depends(get_current_super_admin),
):
"""
Disable a single module for a platform.
Core modules cannot be disabled.
Also disables modules that depend on this one.
Super admin only.
"""
# Verify platform exists
platform = platform_service.get_platform_by_id(db, platform_id)
# Validate module code
if request.module_code not in MODULES:
from app.modules.tenancy.exceptions import BadRequestException
raise BadRequestException(f"Unknown module: {request.module_code}")
# Check if core module
if request.module_code in get_core_module_codes():
from app.modules.tenancy.exceptions import BadRequestException
raise BadRequestException(f"Cannot disable core module: {request.module_code}")
# Get dependent modules before disabling
dependents = _get_dependent_modules(request.module_code)
# Disable module
success = module_service.disable_module(db, platform_id, request.module_code)
if success:
db.commit()
logger.info(
f"[MODULES] Super admin {current_user.email} disabled module "
f"'{request.module_code}' for platform {platform.code}"
)
return {
"success": success,
"message": f"Module '{request.module_code}' disabled",
"also_disabled": dependents if dependents else [],
}
@router.post("/platforms/{platform_id}/enable-all")
async def enable_all_modules(
platform_id: int = Path(..., description="Platform ID"),
db: Session = Depends(get_db),
current_user: UserContext = Depends(get_current_super_admin),
):
"""
Enable all modules for a platform.
Super admin only.
"""
# Verify platform exists
platform = platform_service.get_platform_by_id(db, platform_id)
# Enable all modules
all_codes = list(MODULES.keys())
module_service.set_enabled_modules(db, platform_id, all_codes)
db.commit()
logger.info(
f"[MODULES] Super admin {current_user.email} enabled all modules "
f"for platform {platform.code}"
)
return {
"success": True,
"message": "All modules enabled",
"enabled_count": len(all_codes),
}
@router.post("/platforms/{platform_id}/disable-optional")
async def disable_optional_modules(
platform_id: int = Path(..., description="Platform ID"),
db: Session = Depends(get_db),
current_user: UserContext = Depends(get_current_super_admin),
):
"""
Disable all optional modules for a platform, keeping only core modules.
Super admin only.
"""
# Verify platform exists
platform = platform_service.get_platform_by_id(db, platform_id)
# Enable only core modules
core_codes = list(get_core_module_codes())
module_service.set_enabled_modules(db, platform_id, core_codes)
db.commit()
logger.info(
f"[MODULES] Super admin {current_user.email} disabled optional modules "
f"for platform {platform.code} (kept {len(core_codes)} core modules)"
)
return {
"success": True,
"message": "Optional modules disabled, core modules kept",
"core_modules": core_codes,
}

View File

@@ -23,7 +23,7 @@ from app.api.deps import get_current_super_admin, get_current_super_admin_api
from app.core.database import get_db
from app.exceptions import ValidationException
from app.modules.tenancy.services.admin_platform_service import admin_platform_service
from models.database.user import User # noqa: API-007 - Internal helper uses User model
from app.modules.tenancy.models import User # noqa: API-007 - Internal helper uses User model
from models.schema.auth import UserContext
admin_users_router = APIRouter(prefix="/admin-users")

View File

@@ -19,7 +19,7 @@ from app.core.database import get_db
from app.modules.tenancy.services.vendor_domain_service import vendor_domain_service
from app.modules.tenancy.services.vendor_service import vendor_service
from models.schema.auth import UserContext
from models.schema.vendor_domain import (
from app.modules.tenancy.schemas.vendor_domain import (
DomainDeletionResponse,
DomainVerificationInstructions,
DomainVerificationResponse,

View File

@@ -21,7 +21,7 @@ from app.modules.analytics.services.stats_service import stats_service
from app.modules.tenancy.services.vendor_service import vendor_service
from models.schema.auth import UserContext
from app.modules.analytics.schemas import VendorStatsResponse
from models.schema.vendor import (
from app.modules.tenancy.schemas.vendor import (
LetzshopExportRequest,
LetzshopExportResponse,
VendorCreate,

View File

@@ -18,7 +18,7 @@ from sqlalchemy.orm import Session
from app.core.database import get_db
from app.modules.tenancy.services.vendor_service import vendor_service # noqa: mod-004
from models.schema.vendor import VendorDetailResponse
from app.modules.tenancy.schemas.vendor import VendorDetailResponse
vendor_router = APIRouter()
logger = logging.getLogger(__name__)

View File

@@ -15,7 +15,7 @@ from app.api.deps import get_current_vendor_api
from app.core.database import get_db
from app.modules.tenancy.services.vendor_service import vendor_service
from models.schema.auth import UserContext
from models.schema.vendor import VendorResponse, VendorUpdate
from app.modules.tenancy.schemas.vendor import VendorResponse, VendorUpdate
vendor_profile_router = APIRouter(prefix="/profile")
logger = logging.getLogger(__name__)

View File

@@ -25,7 +25,7 @@ from app.core.database import get_db
from app.core.permissions import VendorPermissions
from app.modules.tenancy.services.vendor_team_service import vendor_team_service
from models.schema.auth import UserContext
from models.schema.team import (
from app.modules.tenancy.schemas.team import (
BulkRemoveRequest,
BulkRemoveResponse,
InvitationAccept,

View File

@@ -18,8 +18,8 @@ from sqlalchemy.orm import Session
from app.api.deps import get_db, require_menu_access
from app.modules.core.utils.page_context import get_admin_context
from app.templates_config import templates
from models.database.admin_menu_config import FrontendType
from models.database.user import User
from app.modules.enums import FrontendType
from app.modules.tenancy.models import User
router = APIRouter()

View File

@@ -21,7 +21,7 @@ from app.api.deps import (
)
from app.modules.core.utils.page_context import get_vendor_context
from app.templates_config import templates
from models.database.user import User
from app.modules.tenancy.models import User
router = APIRouter()

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

View File

@@ -20,9 +20,9 @@ from app.modules.tenancy.exceptions import (
AdminOperationException,
CannotModifySelfException,
)
from models.database.admin_platform import AdminPlatform
from models.database.platform import Platform
from models.database.user import User
from app.modules.tenancy.models import AdminPlatform
from app.modules.tenancy.models import Platform
from app.modules.tenancy.models import User
logger = logging.getLogger(__name__)

View File

@@ -32,13 +32,13 @@ from app.modules.tenancy.exceptions import (
VendorVerificationException,
)
from middleware.auth import AuthManager
from models.database.company import Company
from app.modules.tenancy.models import Company
from app.modules.marketplace.models import MarketplaceImportJob
from models.database.platform import Platform
from models.database.user import User
from models.database.vendor import Role, Vendor
from app.modules.tenancy.models import Platform
from app.modules.tenancy.models import User
from app.modules.tenancy.models import Role, Vendor
from app.modules.marketplace.schemas import MarketplaceImportJobResponse
from models.schema.vendor import VendorCreate
from app.modules.tenancy.schemas.vendor import VendorCreate
logger = logging.getLogger(__name__)
@@ -422,7 +422,7 @@ class AdminService:
# Assign vendor to platforms if provided
if vendor_data.platform_ids:
from models.database.vendor_platform import VendorPlatform
from app.modules.tenancy.models import VendorPlatform
for platform_id in vendor_data.platform_ids:
# Verify platform exists

View File

@@ -13,9 +13,9 @@ from sqlalchemy import func, select
from sqlalchemy.orm import Session, joinedload
from app.modules.tenancy.exceptions import CompanyNotFoundException, UserNotFoundException
from models.database.company import Company
from models.database.user import User
from models.schema.company import CompanyCreate, CompanyTransferOwnership, CompanyUpdate
from app.modules.tenancy.models import Company
from app.modules.tenancy.models import User
from app.modules.tenancy.schemas.company import CompanyCreate, CompanyTransferOwnership, CompanyUpdate
logger = logging.getLogger(__name__)

View File

@@ -21,8 +21,8 @@ from app.modules.tenancy.exceptions import (
PlatformNotFoundException,
)
from app.modules.cms.models import ContentPage
from models.database.platform import Platform
from models.database.vendor_platform import VendorPlatform
from app.modules.tenancy.models import Platform
from app.modules.tenancy.models import VendorPlatform
logger = logging.getLogger(__name__)

View File

@@ -15,8 +15,8 @@ from typing import Any
from sqlalchemy.orm import Session
from app.exceptions import ValidationException
from models.database.user import User
from models.database.vendor import Role, VendorUser
from app.modules.tenancy.models import User
from app.modules.tenancy.models import Role, VendorUser
logger = logging.getLogger(__name__)

View File

@@ -29,9 +29,9 @@ from app.modules.tenancy.exceptions import (
VendorDomainNotFoundException,
VendorNotFoundException,
)
from models.database.vendor import Vendor
from models.database.vendor_domain import VendorDomain
from models.schema.vendor_domain import VendorDomainCreate, VendorDomainUpdate
from app.modules.tenancy.models import Vendor
from app.modules.tenancy.models import VendorDomain
from app.modules.tenancy.schemas.vendor_domain import VendorDomainCreate, VendorDomainUpdate
logger = logging.getLogger(__name__)

View File

@@ -25,10 +25,10 @@ from app.modules.tenancy.exceptions import (
)
from app.modules.marketplace.models import MarketplaceProduct
from app.modules.catalog.models import Product
from models.database.user import User
from models.database.vendor import Vendor
from app.modules.tenancy.models import User
from app.modules.tenancy.models import Vendor
from app.modules.catalog.schemas import ProductCreate
from models.schema.vendor import VendorCreate
from app.modules.tenancy.schemas.vendor import VendorCreate
logger = logging.getLogger(__name__)
@@ -63,7 +63,7 @@ class VendorService:
UnauthorizedVendorAccessException: If user is not company owner
InvalidVendorDataException: If vendor data is invalid
"""
from models.database.company import Company
from app.modules.tenancy.models import Company
try:
# Validate company_id is provided
@@ -159,7 +159,7 @@ class VendorService:
# Non-admin users can only see active and verified vendors, plus their own
if current_user.role != "admin":
# Get vendor IDs the user owns through companies
from models.database.company import Company
from app.modules.tenancy.models import Company
owned_vendor_ids = (
db.query(Vendor.id)
@@ -243,7 +243,7 @@ class VendorService:
"""
from sqlalchemy.orm import joinedload
from models.database.company import Company
from app.modules.tenancy.models import Company
vendor = (
db.query(Vendor)
@@ -291,7 +291,7 @@ class VendorService:
"""
from sqlalchemy.orm import joinedload
from models.database.company import Company
from app.modules.tenancy.models import Company
vendor = (
db.query(Vendor)
@@ -325,7 +325,7 @@ class VendorService:
"""
from sqlalchemy.orm import joinedload
from models.database.company import Company
from app.modules.tenancy.models import Company
# Try as integer ID first
try:

View File

@@ -26,8 +26,8 @@ from app.modules.tenancy.exceptions import (
)
from app.modules.billing.exceptions import TierLimitExceededException
from middleware.auth import AuthManager
from models.database.user import User
from models.database.vendor import Role, Vendor, VendorUser, VendorUserType
from app.modules.tenancy.models import User
from app.modules.tenancy.models import Role, Vendor, VendorUser, VendorUserType
logger = logging.getLogger(__name__)

View File

@@ -20,6 +20,9 @@ function adminUserDetailPage() {
// Initialize
async init() {
// Load i18n translations
await I18n.loadModule('tenancy');
adminUserDetailLog.info('=== ADMIN USER DETAIL PAGE INITIALIZING ===');
// Prevent multiple initializations
@@ -43,7 +46,7 @@ function adminUserDetailPage() {
} else {
adminUserDetailLog.error('No user ID in URL');
this.error = 'Invalid admin user URL';
Utils.showToast('Invalid admin user URL', 'error');
Utils.showToast(I18n.t('tenancy.messages.invalid_admin_user_url'), 'error');
}
adminUserDetailLog.info('=== ADMIN USER DETAIL PAGE INITIALIZATION COMPLETE ===');
@@ -88,7 +91,7 @@ function adminUserDetailPage() {
} catch (error) {
window.LogConfig.logError(error, 'Load Admin User Details');
this.error = error.message || 'Failed to load admin user details';
Utils.showToast('Failed to load admin user details', 'error');
Utils.showToast(I18n.t('tenancy.messages.failed_to_load_admin_user_details'), 'error');
} finally {
this.loading = false;
}
@@ -109,7 +112,7 @@ function adminUserDetailPage() {
// Prevent self-deactivation
if (this.adminUser.id === this.currentUserId) {
Utils.showToast('You cannot deactivate your own account', 'error');
Utils.showToast(I18n.t('tenancy.messages.you_cannot_deactivate_your_own_account'), 'error');
return;
}
@@ -145,7 +148,7 @@ function adminUserDetailPage() {
// Prevent self-deletion
if (this.adminUser.id === this.currentUserId) {
Utils.showToast('You cannot delete your own account', 'error');
Utils.showToast(I18n.t('tenancy.messages.you_cannot_delete_your_own_account'), 'error');
return;
}
@@ -169,7 +172,7 @@ function adminUserDetailPage() {
window.LogConfig.logApiCall('DELETE', url, null, 'response');
Utils.showToast('Admin user deleted successfully', 'success');
Utils.showToast(I18n.t('tenancy.messages.admin_user_deleted_successfully'), 'success');
adminUserDetailLog.info('Admin user deleted successfully');
// Redirect to admin users list
@@ -187,7 +190,7 @@ function adminUserDetailPage() {
async refresh() {
adminUserDetailLog.info('=== ADMIN USER REFRESH TRIGGERED ===');
await this.loadAdminUser();
Utils.showToast('Admin user details refreshed', 'success');
Utils.showToast(I18n.t('tenancy.messages.admin_user_details_refreshed'), 'success');
adminUserDetailLog.info('=== ADMIN USER REFRESH COMPLETE ===');
}
};

View File

@@ -29,6 +29,9 @@ function adminUserEditPage() {
// Initialize
async init() {
// Load i18n translations
await I18n.loadModule('tenancy');
adminUserEditLog.info('=== ADMIN USER EDIT PAGE INITIALIZING ===');
// Prevent multiple initializations
@@ -52,7 +55,7 @@ function adminUserEditPage() {
await this.loadAllPlatforms();
} else {
adminUserEditLog.error('No user ID in URL');
Utils.showToast('Invalid admin user URL', 'error');
Utils.showToast(I18n.t('tenancy.messages.invalid_admin_user_url'), 'error');
setTimeout(() => window.location.href = '/admin/admin-users', 2000);
}
@@ -94,7 +97,7 @@ function adminUserEditPage() {
} catch (error) {
window.LogConfig.logError(error, 'Load Admin User');
Utils.showToast('Failed to load admin user', 'error');
Utils.showToast(I18n.t('tenancy.messages.failed_to_load_admin_user'), 'error');
setTimeout(() => window.location.href = '/admin/admin-users', 2000);
} finally {
this.loading = false;
@@ -137,7 +140,7 @@ function adminUserEditPage() {
// Prevent self-demotion
if (this.adminUser.id === this.currentUserId && !newStatus) {
Utils.showToast('You cannot demote yourself from super admin', 'error');
Utils.showToast(I18n.t('tenancy.messages.you_cannot_demote_yourself_from_super_ad'), 'error');
return;
}
@@ -180,7 +183,7 @@ function adminUserEditPage() {
// Prevent self-deactivation
if (this.adminUser.id === this.currentUserId) {
Utils.showToast('You cannot deactivate your own account', 'error');
Utils.showToast(I18n.t('tenancy.messages.you_cannot_deactivate_your_own_account'), 'error');
return;
}
@@ -228,7 +231,7 @@ function adminUserEditPage() {
// Reload admin user to get updated platforms
await this.loadAdminUser();
Utils.showToast('Platform assigned successfully', 'success');
Utils.showToast(I18n.t('tenancy.messages.platform_assigned_successfully'), 'success');
adminUserEditLog.info('Platform assigned successfully');
this.showPlatformModal = false;
this.selectedPlatformId = null;
@@ -245,7 +248,7 @@ function adminUserEditPage() {
removePlatform(platformId) {
// Validate: platform admin must have at least one platform
if (this.adminUser.platforms.length <= 1) {
Utils.showToast('Platform admin must be assigned to at least one platform', 'error');
Utils.showToast(I18n.t('tenancy.messages.platform_admin_must_be_assigned_to_at_le'), 'error');
return;
}
@@ -272,7 +275,7 @@ function adminUserEditPage() {
// Reload admin user to get updated platforms
await this.loadAdminUser();
Utils.showToast('Platform removed successfully', 'success');
Utils.showToast(I18n.t('tenancy.messages.platform_removed_successfully'), 'success');
adminUserEditLog.info('Platform removed successfully');
} catch (error) {
@@ -296,7 +299,7 @@ function adminUserEditPage() {
// Prevent self-deletion
if (this.adminUser.id === this.currentUserId) {
Utils.showToast('You cannot delete your own account', 'error');
Utils.showToast(I18n.t('tenancy.messages.you_cannot_delete_your_own_account'), 'error');
return;
}
@@ -320,7 +323,7 @@ function adminUserEditPage() {
window.LogConfig.logApiCall('DELETE', url, null, 'response');
Utils.showToast('Admin user deleted successfully', 'success');
Utils.showToast(I18n.t('tenancy.messages.admin_user_deleted_successfully'), 'success');
adminUserEditLog.info('Admin user deleted successfully');
// Redirect to admin users list

View File

@@ -37,6 +37,9 @@ function adminUsersPage() {
// Initialization
async init() {
// Load i18n translations
await I18n.loadModule('tenancy');
adminUsersLog.info('=== ADMIN USERS PAGE INITIALIZING ===');
// Prevent multiple initializations
@@ -197,7 +200,7 @@ function adminUsersPage() {
} catch (error) {
window.LogConfig.logError(error, 'Load Admin Users');
this.error = error.message || 'Failed to load admin users';
Utils.showToast('Failed to load admin users', 'error');
Utils.showToast(I18n.t('tenancy.messages.failed_to_load_admin_users'), 'error');
} finally {
this.loading = false;
}
@@ -288,7 +291,7 @@ function adminUsersPage() {
// Prevent self-deletion
if (admin.id === this.currentUserId) {
Utils.showToast('You cannot delete your own account', 'error');
Utils.showToast(I18n.t('tenancy.messages.you_cannot_delete_your_own_account'), 'error');
return;
}
@@ -309,7 +312,7 @@ function adminUsersPage() {
await apiClient.delete(url);
Utils.showToast('Admin user deleted successfully', 'success');
Utils.showToast(I18n.t('tenancy.messages.admin_user_deleted_successfully'), 'success');
adminUsersLog.info('Admin user deleted successfully');
await this.loadAdminUsers();

View File

@@ -18,6 +18,9 @@ function adminCompanyDetail() {
// Initialize
async init() {
// Load i18n translations
await I18n.loadModule('tenancy');
companyDetailLog.info('=== COMPANY DETAIL PAGE INITIALIZING ===');
// Prevent multiple initializations
@@ -38,7 +41,7 @@ function adminCompanyDetail() {
} else {
companyDetailLog.error('No company ID in URL');
this.error = 'Invalid company URL';
Utils.showToast('Invalid company URL', 'error');
Utils.showToast(I18n.t('tenancy.messages.invalid_company_url'), 'error');
}
companyDetailLog.info('=== COMPANY DETAIL PAGE INITIALIZATION COMPLETE ===');
@@ -75,7 +78,7 @@ function adminCompanyDetail() {
} catch (error) {
window.LogConfig.logError(error, 'Load Company Details');
this.error = error.message || 'Failed to load company details';
Utils.showToast('Failed to load company details', 'error');
Utils.showToast(I18n.t('tenancy.messages.failed_to_load_company_details'), 'error');
} finally {
this.loading = false;
}
@@ -121,7 +124,7 @@ function adminCompanyDetail() {
window.LogConfig.logApiCall('DELETE', url, null, 'response');
Utils.showToast('Company deleted successfully', 'success');
Utils.showToast(I18n.t('tenancy.messages.company_deleted_successfully'), 'success');
companyDetailLog.info('Company deleted successfully');
// Redirect to companies list
@@ -137,7 +140,7 @@ function adminCompanyDetail() {
async refresh() {
companyDetailLog.info('=== COMPANY REFRESH TRIGGERED ===');
await this.loadCompany();
Utils.showToast('Company details refreshed', 'success');
Utils.showToast(I18n.t('tenancy.messages.company_details_refreshed'), 'success');
companyDetailLog.info('=== COMPANY REFRESH COMPLETE ===');
}
};

View File

@@ -39,6 +39,9 @@ function adminCompanyEdit() {
// Initialize
async init() {
// Load i18n translations
await I18n.loadModule('tenancy');
companyEditLog.info('=== COMPANY EDIT PAGE INITIALIZING ===');
// Prevent multiple initializations
@@ -59,14 +62,14 @@ function adminCompanyEdit() {
await this.loadCompany();
} else {
companyEditLog.error('No company ID in URL');
Utils.showToast('Invalid company URL', 'error');
Utils.showToast(I18n.t('tenancy.messages.invalid_company_url'), 'error');
setTimeout(() => window.location.href = '/admin/companies', 2000);
}
companyEditLog.info('=== COMPANY EDIT PAGE INITIALIZATION COMPLETE ===');
} catch (error) {
window.LogConfig.logError(error, 'Company Edit Init');
Utils.showToast('Failed to initialize page', 'error');
Utils.showToast(I18n.t('tenancy.messages.failed_to_initialize_page'), 'error');
}
},
@@ -107,7 +110,7 @@ function adminCompanyEdit() {
} catch (error) {
window.LogConfig.logError(error, 'Load Company');
Utils.showToast('Failed to load company', 'error');
Utils.showToast(I18n.t('tenancy.messages.failed_to_load_company'), 'error');
setTimeout(() => window.location.href = '/admin/companies', 2000);
} finally {
this.loadingCompany = false;
@@ -134,7 +137,7 @@ function adminCompanyEdit() {
window.LogConfig.logPerformance('Update Company', duration);
this.company = response;
Utils.showToast('Company updated successfully', 'success');
Utils.showToast(I18n.t('tenancy.messages.company_updated_successfully'), 'success');
companyEditLog.info(`Company updated successfully in ${duration}ms`, response);
} catch (error) {
@@ -258,7 +261,7 @@ function adminCompanyEdit() {
window.LogConfig.logApiCall('POST', url, response, 'response');
Utils.showToast('Ownership transferred successfully', 'success');
Utils.showToast(I18n.t('tenancy.messages.ownership_transferred_successfully'), 'success');
companyEditLog.info('Ownership transferred successfully', response);
// Close modal and reload company data
@@ -370,7 +373,7 @@ function adminCompanyEdit() {
window.LogConfig.logApiCall('DELETE', url, response, 'response');
Utils.showToast('Company deleted successfully', 'success');
Utils.showToast(I18n.t('tenancy.messages.company_deleted_successfully'), 'success');
companyEditLog.info('Company deleted successfully');
// Redirect to companies list

View File

@@ -26,6 +26,9 @@ function adminUserCreate() {
// Initialize
async init() {
// Load i18n translations
await I18n.loadModule('tenancy');
userCreateLog.info('=== ADMIN USER CREATE PAGE INITIALIZING ===');
// Prevent multiple initializations
@@ -85,7 +88,7 @@ function adminUserCreate() {
if (!this.validateForm()) {
userCreateLog.warn('Validation failed:', this.errors);
Utils.showToast('Please fix the errors before submitting', 'error');
Utils.showToast(I18n.t('tenancy.messages.please_fix_the_errors_before_submitting'), 'error');
return;
}

View File

@@ -19,6 +19,9 @@ function adminUserDetail() {
// Initialize
async init() {
// Load i18n translations
await I18n.loadModule('tenancy');
userDetailLog.info('=== USER DETAIL PAGE INITIALIZING ===');
// Prevent multiple initializations
@@ -39,7 +42,7 @@ function adminUserDetail() {
} else {
userDetailLog.error('No user ID in URL');
this.error = 'Invalid user URL';
Utils.showToast('Invalid user URL', 'error');
Utils.showToast(I18n.t('tenancy.messages.invalid_user_url'), 'error');
}
userDetailLog.info('=== USER DETAIL PAGE INITIALIZATION COMPLETE ===');
@@ -75,7 +78,7 @@ function adminUserDetail() {
} catch (error) {
window.LogConfig.logError(error, 'Load User Details');
this.error = error.message || 'Failed to load user details';
Utils.showToast('Failed to load user details', 'error');
Utils.showToast(I18n.t('tenancy.messages.failed_to_load_user_details'), 'error');
} finally {
this.loading = false;
}
@@ -149,7 +152,7 @@ function adminUserDetail() {
window.LogConfig.logApiCall('DELETE', url, null, 'response');
Utils.showToast('User deleted successfully', 'success');
Utils.showToast(I18n.t('tenancy.messages.user_deleted_successfully'), 'success');
userDetailLog.info('User deleted successfully');
// Redirect to users list
@@ -167,7 +170,7 @@ function adminUserDetail() {
async refresh() {
userDetailLog.info('=== USER REFRESH TRIGGERED ===');
await this.loadUser();
Utils.showToast('User details refreshed', 'success');
Utils.showToast(I18n.t('tenancy.messages.user_details_refreshed'), 'success');
userDetailLog.info('=== USER REFRESH COMPLETE ===');
}
};

View File

@@ -20,6 +20,9 @@ function adminUserEdit() {
// Initialize
async init() {
// Load i18n translations
await I18n.loadModule('tenancy');
userEditLog.info('=== USER EDIT PAGE INITIALIZING ===');
// Prevent multiple initializations
@@ -40,14 +43,14 @@ function adminUserEdit() {
await this.loadUser();
} else {
userEditLog.error('No user ID in URL');
Utils.showToast('Invalid user URL', 'error');
Utils.showToast(I18n.t('tenancy.messages.invalid_user_url'), 'error');
setTimeout(() => window.location.href = '/admin/users', 2000);
}
userEditLog.info('=== USER EDIT PAGE INITIALIZATION COMPLETE ===');
} catch (error) {
window.LogConfig.logError(error, 'User Edit Init');
Utils.showToast('Failed to initialize page', 'error');
Utils.showToast(I18n.t('tenancy.messages.failed_to_initialize_page'), 'error');
}
},
@@ -87,7 +90,7 @@ function adminUserEdit() {
} catch (error) {
window.LogConfig.logError(error, 'Load User');
Utils.showToast('Failed to load user', 'error');
Utils.showToast(I18n.t('tenancy.messages.failed_to_load_user'), 'error');
setTimeout(() => window.location.href = '/admin/users', 2000);
} finally {
this.loadingUser = false;
@@ -122,7 +125,7 @@ function adminUserEdit() {
window.LogConfig.logPerformance('Update User', duration);
this.user = response;
Utils.showToast('User updated successfully', 'success');
Utils.showToast(I18n.t('tenancy.messages.user_updated_successfully'), 'success');
userEditLog.info(`User updated successfully in ${duration}ms`, response);
} catch (error) {
@@ -207,7 +210,7 @@ function adminUserEdit() {
window.LogConfig.logApiCall('DELETE', url, null, 'response');
Utils.showToast('User deleted successfully', 'success');
Utils.showToast(I18n.t('tenancy.messages.user_deleted_successfully'), 'success');
userEditLog.info('User deleted successfully');
// Redirect to users list

View File

@@ -36,6 +36,9 @@ function adminUsers() {
// Initialization
async init() {
// Load i18n translations
await I18n.loadModule('tenancy');
usersLog.info('=== USERS PAGE INITIALIZING ===');
// Prevent multiple initializations
@@ -159,7 +162,7 @@ function adminUsers() {
} catch (error) {
window.LogConfig.logError(error, 'Load Users');
this.error = error.message || 'Failed to load users';
Utils.showToast('Failed to load users', 'error');
Utils.showToast(I18n.t('tenancy.messages.failed_to_load_users'), 'error');
} finally {
this.loading = false;
}
@@ -278,14 +281,14 @@ function adminUsers() {
await apiClient.delete(url); // ✅ Fixed: lowercase apiClient
Utils.showToast('User deleted successfully', 'success');
Utils.showToast(I18n.t('tenancy.messages.user_deleted_successfully'), 'success');
usersLog.info('User deleted successfully');
await this.loadUsers();
await this.loadStats();
} catch (error) {
window.LogConfig.logError(error, 'Delete User');
Utils.showToast('Failed to delete user', 'error');
Utils.showToast(I18n.t('tenancy.messages.failed_to_delete_user'), 'error');
}
},

View File

@@ -21,6 +21,9 @@ function adminVendorDetail() {
// Initialize
async init() {
// Load i18n translations
await I18n.loadModule('tenancy');
detailLog.info('=== VENDOR DETAIL PAGE INITIALIZING ===');
// Prevent multiple initializations
@@ -45,7 +48,7 @@ function adminVendorDetail() {
} else {
detailLog.error('No vendor code in URL');
this.error = 'Invalid vendor URL';
Utils.showToast('Invalid vendor URL', 'error');
Utils.showToast(I18n.t('tenancy.messages.invalid_vendor_url'), 'error');
}
detailLog.info('=== VENDOR DETAIL PAGE INITIALIZATION COMPLETE ===');
@@ -81,7 +84,7 @@ function adminVendorDetail() {
} catch (error) {
window.LogConfig.logError(error, 'Load Vendor Details');
this.error = error.message || 'Failed to load vendor details';
Utils.showToast('Failed to load vendor details', 'error');
Utils.showToast(I18n.t('tenancy.messages.failed_to_load_vendor_details'), 'error');
} finally {
this.loading = false;
}
@@ -144,7 +147,7 @@ function adminVendorDetail() {
// Create a new subscription for this vendor
async createSubscription() {
if (!this.vendor?.id) {
Utils.showToast('No vendor loaded', 'error');
Utils.showToast(I18n.t('tenancy.messages.no_vendor_loaded'), 'error');
return;
}
@@ -165,7 +168,7 @@ function adminVendorDetail() {
window.LogConfig.logApiCall('POST', url, response, 'response');
this.subscription = response;
Utils.showToast('Subscription created successfully', 'success');
Utils.showToast(I18n.t('tenancy.messages.subscription_created_successfully'), 'success');
detailLog.info('Subscription created:', this.subscription);
} catch (error) {
@@ -198,7 +201,7 @@ function adminVendorDetail() {
window.LogConfig.logApiCall('DELETE', url, null, 'response');
Utils.showToast('Vendor deleted successfully', 'success');
Utils.showToast(I18n.t('tenancy.messages.vendor_deleted_successfully'), 'success');
detailLog.info('Vendor deleted successfully');
// Redirect to vendors list
@@ -214,7 +217,7 @@ function adminVendorDetail() {
async refresh() {
detailLog.info('=== VENDOR REFRESH TRIGGERED ===');
await this.loadVendor();
Utils.showToast('Vendor details refreshed', 'success');
Utils.showToast(I18n.t('tenancy.messages.vendor_details_refreshed'), 'success');
detailLog.info('=== VENDOR REFRESH COMPLETE ===');
}
};

View File

@@ -21,6 +21,9 @@ function adminVendorEdit() {
// Initialize
async init() {
// Load i18n translations
await I18n.loadModule('tenancy');
editLog.info('=== VENDOR EDIT PAGE INITIALIZING ===');
// Prevent multiple initializations
@@ -41,14 +44,14 @@ function adminVendorEdit() {
await this.loadVendor();
} else {
editLog.error('No vendor code in URL');
Utils.showToast('Invalid vendor URL', 'error');
Utils.showToast(I18n.t('tenancy.messages.invalid_vendor_url'), 'error');
setTimeout(() => window.location.href = '/admin/vendors', 2000);
}
editLog.info('=== VENDOR EDIT PAGE INITIALIZATION COMPLETE ===');
} catch (error) {
window.LogConfig.logError(error, 'Vendor Edit Init');
Utils.showToast('Failed to initialize page', 'error');
Utils.showToast(I18n.t('tenancy.messages.failed_to_initialize_page'), 'error');
}
},
@@ -96,7 +99,7 @@ function adminVendorEdit() {
} catch (error) {
window.LogConfig.logError(error, 'Load Vendor');
Utils.showToast('Failed to load vendor', 'error');
Utils.showToast(I18n.t('tenancy.messages.failed_to_load_vendor'), 'error');
setTimeout(() => window.location.href = '/admin/vendors', 2000);
} finally {
this.loadingVendor = false;
@@ -131,7 +134,7 @@ function adminVendorEdit() {
window.LogConfig.logPerformance('Update Vendor', duration);
this.vendor = response;
Utils.showToast('Vendor updated successfully', 'success');
Utils.showToast(I18n.t('tenancy.messages.vendor_updated_successfully'), 'success');
editLog.info(`Vendor updated successfully in ${duration}ms`, response);
// Optionally redirect back to list
@@ -249,7 +252,7 @@ function adminVendorEdit() {
window.LogConfig.logApiCall('DELETE', url, response, 'response');
Utils.showToast('Vendor deleted successfully', 'success');
Utils.showToast(I18n.t('tenancy.messages.vendor_deleted_successfully'), 'success');
editLog.info('Vendor deleted successfully');
// Redirect to vendors list
@@ -294,7 +297,7 @@ function adminVendorEdit() {
this.formData[field] = '';
});
Utils.showToast('All contact fields reset to company defaults', 'info');
Utils.showToast(I18n.t('tenancy.messages.all_contact_fields_reset_to_company_defa'), 'info');
},
/**

View File

@@ -43,6 +43,9 @@ function adminVendors() {
// Initialize
async init() {
// Load i18n translations
await I18n.loadModule('tenancy');
vendorsLog.info('=== VENDORS PAGE INITIALIZING ===');
// Prevent multiple initializations
@@ -195,7 +198,7 @@ function adminVendors() {
} catch (error) {
window.LogConfig.logError(error, 'Load Vendors');
this.error = error.message || 'Failed to load vendors';
Utils.showToast('Failed to load vendors', 'error');
Utils.showToast(I18n.t('tenancy.messages.failed_to_load_vendors'), 'error');
} finally {
this.loading = false;
}
@@ -298,7 +301,7 @@ function adminVendors() {
window.LogConfig.logApiCall('DELETE', url, null, 'response');
Utils.showToast('Vendor deleted successfully', 'success');
Utils.showToast(I18n.t('tenancy.messages.vendor_deleted_successfully'), 'success');
vendorsLog.info('Vendor deleted successfully');
// Reload data
@@ -320,7 +323,7 @@ function adminVendors() {
await this.loadStats();
vendorsLog.groupEnd();
Utils.showToast('Vendors list refreshed', 'success');
Utils.showToast(I18n.t('tenancy.messages.vendors_list_refreshed'), 'success');
vendorsLog.info('=== VENDORS REFRESH COMPLETE ===');
}
};

View File

@@ -45,6 +45,9 @@ function vendorProfile() {
hasChanges: false,
async init() {
// Load i18n translations
await I18n.loadModule('tenancy');
vendorProfileLog.info('Profile init() called');
// Guard against multiple initialization
@@ -153,7 +156,7 @@ function vendorProfile() {
*/
async saveProfile() {
if (!this.validateForm()) {
Utils.showToast('Please fix the errors before saving', 'error');
Utils.showToast(I18n.t('tenancy.messages.please_fix_the_errors_before_saving'), 'error');
return;
}
@@ -161,7 +164,7 @@ function vendorProfile() {
try {
await apiClient.put(`/vendor/profile`, this.form);
Utils.showToast('Profile updated successfully', 'success');
Utils.showToast(I18n.t('tenancy.messages.profile_updated_successfully'), 'success');
vendorProfileLog.info('Profile updated');
this.hasChanges = false;

View File

@@ -135,6 +135,9 @@ function vendorSettings() {
hasMarketplaceChanges: false,
async init() {
// Load i18n translations
await I18n.loadModule('tenancy');
vendorSettingsLog.info('Settings init() called');
// Guard against multiple initialization
@@ -315,7 +318,7 @@ function vendorSettings() {
await apiClient.put(`/vendor/settings/business-info`, payload);
Utils.showToast('Business info saved', 'success');
Utils.showToast(I18n.t('tenancy.messages.business_info_saved'), 'success');
vendorSettingsLog.info('Business info updated');
// Reload to get updated inheritance flags
@@ -337,7 +340,7 @@ function vendorSettings() {
try {
await apiClient.put(`/vendor/settings/letzshop`, this.marketplaceForm);
Utils.showToast('Marketplace settings saved', 'success');
Utils.showToast(I18n.t('tenancy.messages.marketplace_settings_saved'), 'success');
vendorSettingsLog.info('Marketplace settings updated');
this.hasMarketplaceChanges = false;
@@ -355,7 +358,7 @@ function vendorSettings() {
async testLetzshopUrl(lang) {
const url = this.marketplaceForm[`letzshop_csv_url_${lang}`];
if (!url) {
Utils.showToast('Please enter a URL first', 'error');
Utils.showToast(I18n.t('tenancy.messages.please_enter_a_url_first'), 'error');
return;
}
@@ -365,7 +368,7 @@ function vendorSettings() {
const response = await fetch(url, { method: 'HEAD', mode: 'no-cors' });
Utils.showToast(`URL appears to be valid`, 'success');
} catch (error) {
Utils.showToast('Could not validate URL - it may still work', 'warning');
Utils.showToast(I18n.t('tenancy.messages.could_not_validate_url_it_may_still_work'), 'warning');
} finally {
this.saving = false;
}
@@ -406,7 +409,7 @@ function vendorSettings() {
try {
await apiClient.put(`/vendor/settings/localization`, this.localizationForm);
Utils.showToast('Localization settings saved', 'success');
Utils.showToast(I18n.t('tenancy.messages.localization_settings_saved'), 'success');
vendorSettingsLog.info('Localization settings updated');
this.hasLocalizationChanges = false;
@@ -450,7 +453,7 @@ function vendorSettings() {
vendorSettingsLog.info('Loaded email settings');
} catch (error) {
vendorSettingsLog.error('Failed to load email settings:', error);
Utils.showToast('Failed to load email settings', 'error');
Utils.showToast(I18n.t('tenancy.messages.failed_to_load_email_settings'), 'error');
} finally {
this.emailSettingsLoading = false;
}
@@ -500,7 +503,7 @@ function vendorSettings() {
async saveEmailSettings() {
// Validate required fields
if (!this.emailForm.from_email || !this.emailForm.from_name) {
Utils.showToast('From Email and From Name are required', 'error');
Utils.showToast(I18n.t('tenancy.messages.from_email_and_from_name_are_required'), 'error');
return;
}
@@ -509,7 +512,7 @@ function vendorSettings() {
const response = await apiClient.put('/vendor/email-settings', this.emailForm);
if (response.success) {
Utils.showToast('Email settings saved', 'success');
Utils.showToast(I18n.t('tenancy.messages.email_settings_saved'), 'success');
vendorSettingsLog.info('Email settings updated');
// Update local state
@@ -531,12 +534,12 @@ function vendorSettings() {
*/
async sendTestEmail() {
if (!this.testEmailAddress) {
Utils.showToast('Please enter a test email address', 'error');
Utils.showToast(I18n.t('tenancy.messages.please_enter_a_test_email_address'), 'error');
return;
}
if (!this.emailSettings?.is_configured) {
Utils.showToast('Please save your email settings first', 'error');
Utils.showToast(I18n.t('tenancy.messages.please_save_your_email_settings_first'), 'error');
return;
}
@@ -547,7 +550,7 @@ function vendorSettings() {
});
if (response.success) {
Utils.showToast('Test email sent! Check your inbox.', 'success');
Utils.showToast(I18n.t('tenancy.messages.test_email_sent_check_your_inbox'), 'success');
// Update verification status
this.emailSettings.is_verified = true;
} else {

View File

@@ -64,6 +64,9 @@ function vendorTeam() {
],
async init() {
// Load i18n translations
await I18n.loadModule('tenancy');
vendorTeamLog.info('Team init() called');
// Guard against multiple initialization
@@ -149,7 +152,7 @@ function vendorTeam() {
*/
async sendInvitation() {
if (!this.inviteForm.email) {
Utils.showToast('Email is required', 'error');
Utils.showToast(I18n.t('tenancy.messages.email_is_required'), 'error');
return;
}
@@ -157,7 +160,7 @@ function vendorTeam() {
try {
await apiClient.post(`/vendor/team/invite`, this.inviteForm);
Utils.showToast('Invitation sent successfully', 'success');
Utils.showToast(I18n.t('tenancy.messages.invitation_sent_successfully'), 'success');
vendorTeamLog.info('Invitation sent to:', this.inviteForm.email);
this.showInviteModal = false;
@@ -195,7 +198,7 @@ function vendorTeam() {
this.editForm
);
Utils.showToast('Team member updated', 'success');
Utils.showToast(I18n.t('tenancy.messages.team_member_updated'), 'success');
vendorTeamLog.info('Updated team member:', this.selectedMember.user_id);
this.showEditModal = false;
@@ -227,7 +230,7 @@ function vendorTeam() {
try {
await apiClient.delete(`/vendor/team/members/${this.selectedMember.user_id}`);
Utils.showToast('Team member removed', 'success');
Utils.showToast(I18n.t('tenancy.messages.team_member_removed'), 'success');
vendorTeamLog.info('Removed team member:', this.selectedMember.user_id);
this.showRemoveModal = false;