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:
@@ -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",
|
||||
|
||||
81
app/modules/tenancy/locales/de.json
Normal file
81
app/modules/tenancy/locales/de.json
Normal 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"
|
||||
}
|
||||
}
|
||||
81
app/modules/tenancy/locales/fr.json
Normal file
81
app/modules/tenancy/locales/fr.json
Normal 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"
|
||||
}
|
||||
}
|
||||
81
app/modules/tenancy/locales/lb.json
Normal file
81
app/modules/tenancy/locales/lb.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
205
app/modules/tenancy/models/admin.py
Normal file
205
app/modules/tenancy/models/admin.py
Normal 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",
|
||||
]
|
||||
164
app/modules/tenancy/models/admin_platform.py
Normal file
164
app/modules/tenancy/models/admin_platform.py
Normal 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"]
|
||||
109
app/modules/tenancy/models/company.py
Normal file
109
app/modules/tenancy/models/company.py
Normal 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"]
|
||||
242
app/modules/tenancy/models/platform.py
Normal file
242
app/modules/tenancy/models/platform.py
Normal 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"]
|
||||
165
app/modules/tenancy/models/platform_module.py
Normal file
165
app/modules/tenancy/models/platform_module.py
Normal 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"]
|
||||
202
app/modules/tenancy/models/user.py
Normal file
202
app/modules/tenancy/models/user.py
Normal 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"]
|
||||
571
app/modules/tenancy/models/vendor.py
Normal file
571
app/modules/tenancy/models/vendor.py
Normal 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"]
|
||||
99
app/modules/tenancy/models/vendor_domain.py
Normal file
99
app/modules/tenancy/models/vendor_domain.py
Normal 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"]
|
||||
192
app/modules/tenancy/models/vendor_platform.py
Normal file
192
app/modules/tenancy/models/vendor_platform.py
Normal 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"]
|
||||
@@ -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"])
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
418
app/modules/tenancy/routes/api/admin_module_config.py
Normal file
418
app/modules/tenancy/routes/api/admin_module_config.py
Normal 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,
|
||||
}
|
||||
399
app/modules/tenancy/routes/api/admin_modules.py
Normal file
399
app/modules/tenancy/routes/api/admin_modules.py
Normal 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,
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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__)
|
||||
|
||||
@@ -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__)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -2,11 +2,207 @@
|
||||
"""
|
||||
Tenancy module Pydantic schemas.
|
||||
|
||||
Request/response schemas for platform, company, vendor, and admin user management.
|
||||
Currently schemas remain in models/schema/ - this package is a placeholder
|
||||
for future migration.
|
||||
Request/response schemas for platform, company, vendor, admin user, and team management.
|
||||
"""
|
||||
|
||||
# Schemas will be migrated here from models/schema/
|
||||
# For now, import from legacy location if needed:
|
||||
# from models.schema.vendor import VendorDetailResponse
|
||||
# Company schemas
|
||||
from app.modules.tenancy.schemas.company import (
|
||||
CompanyBase,
|
||||
CompanyCreate,
|
||||
CompanyCreateResponse,
|
||||
CompanyDetailResponse,
|
||||
CompanyListResponse,
|
||||
CompanyResponse,
|
||||
CompanySummary,
|
||||
CompanyTransferOwnership,
|
||||
CompanyTransferOwnershipResponse,
|
||||
CompanyUpdate,
|
||||
)
|
||||
|
||||
# Vendor schemas
|
||||
from app.modules.tenancy.schemas.vendor import (
|
||||
LetzshopExportFileInfo,
|
||||
LetzshopExportRequest,
|
||||
LetzshopExportResponse,
|
||||
VendorCreate,
|
||||
VendorCreateResponse,
|
||||
VendorDetailResponse,
|
||||
VendorListResponse,
|
||||
VendorResponse,
|
||||
VendorSummary,
|
||||
VendorUpdate,
|
||||
)
|
||||
|
||||
# Admin schemas
|
||||
from app.modules.tenancy.schemas.admin import (
|
||||
AdminAuditLogFilters,
|
||||
AdminAuditLogListResponse,
|
||||
AdminAuditLogResponse,
|
||||
AdminDashboardStats,
|
||||
AdminNotificationCreate,
|
||||
AdminNotificationListResponse,
|
||||
AdminNotificationResponse,
|
||||
AdminNotificationUpdate,
|
||||
AdminSessionListResponse,
|
||||
AdminSessionResponse,
|
||||
AdminSettingCreate,
|
||||
AdminSettingDefaultResponse,
|
||||
AdminSettingListResponse,
|
||||
AdminSettingResponse,
|
||||
AdminSettingUpdate,
|
||||
ApplicationLogFilters,
|
||||
ApplicationLogListResponse,
|
||||
ApplicationLogResponse,
|
||||
BulkUserAction,
|
||||
BulkUserActionResponse,
|
||||
BulkVendorAction,
|
||||
BulkVendorActionResponse,
|
||||
ComponentHealthStatus,
|
||||
FileLogResponse,
|
||||
LogCleanupResponse,
|
||||
LogDeleteResponse,
|
||||
LogFileInfo,
|
||||
LogFileListResponse,
|
||||
LogSettingsResponse,
|
||||
LogSettingsUpdate,
|
||||
LogSettingsUpdateResponse,
|
||||
LogStatistics,
|
||||
PlatformAlertCreate,
|
||||
PlatformAlertListResponse,
|
||||
PlatformAlertResolve,
|
||||
PlatformAlertResponse,
|
||||
PublicDisplaySettingsResponse,
|
||||
RowsPerPageResponse,
|
||||
RowsPerPageUpdateResponse,
|
||||
SystemHealthResponse,
|
||||
)
|
||||
|
||||
# Team schemas
|
||||
from app.modules.tenancy.schemas.team import (
|
||||
BulkRemoveRequest,
|
||||
BulkRemoveResponse,
|
||||
InvitationAccept,
|
||||
InvitationAcceptResponse,
|
||||
InvitationResponse,
|
||||
PermissionCheckRequest,
|
||||
PermissionCheckResponse,
|
||||
RoleBase,
|
||||
RoleCreate,
|
||||
RoleListResponse,
|
||||
RoleResponse,
|
||||
RoleUpdate,
|
||||
TeamErrorResponse,
|
||||
TeamMemberBase,
|
||||
TeamMemberInvite,
|
||||
TeamMemberListResponse,
|
||||
TeamMemberResponse,
|
||||
TeamMemberUpdate,
|
||||
TeamStatistics,
|
||||
UserPermissionsResponse,
|
||||
)
|
||||
|
||||
# Vendor domain schemas
|
||||
from app.modules.tenancy.schemas.vendor_domain import (
|
||||
DomainDeletionResponse,
|
||||
DomainVerificationInstructions,
|
||||
DomainVerificationResponse,
|
||||
VendorDomainCreate,
|
||||
VendorDomainListResponse,
|
||||
VendorDomainResponse,
|
||||
VendorDomainUpdate,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Company
|
||||
"CompanyBase",
|
||||
"CompanyCreate",
|
||||
"CompanyCreateResponse",
|
||||
"CompanyDetailResponse",
|
||||
"CompanyListResponse",
|
||||
"CompanyResponse",
|
||||
"CompanySummary",
|
||||
"CompanyTransferOwnership",
|
||||
"CompanyTransferOwnershipResponse",
|
||||
"CompanyUpdate",
|
||||
# Vendor
|
||||
"LetzshopExportFileInfo",
|
||||
"LetzshopExportRequest",
|
||||
"LetzshopExportResponse",
|
||||
"VendorCreate",
|
||||
"VendorCreateResponse",
|
||||
"VendorDetailResponse",
|
||||
"VendorListResponse",
|
||||
"VendorResponse",
|
||||
"VendorSummary",
|
||||
"VendorUpdate",
|
||||
# Admin
|
||||
"AdminAuditLogFilters",
|
||||
"AdminAuditLogListResponse",
|
||||
"AdminAuditLogResponse",
|
||||
"AdminDashboardStats",
|
||||
"AdminNotificationCreate",
|
||||
"AdminNotificationListResponse",
|
||||
"AdminNotificationResponse",
|
||||
"AdminNotificationUpdate",
|
||||
"AdminSessionListResponse",
|
||||
"AdminSessionResponse",
|
||||
"AdminSettingCreate",
|
||||
"AdminSettingDefaultResponse",
|
||||
"AdminSettingListResponse",
|
||||
"AdminSettingResponse",
|
||||
"AdminSettingUpdate",
|
||||
"ApplicationLogFilters",
|
||||
"ApplicationLogListResponse",
|
||||
"ApplicationLogResponse",
|
||||
"BulkUserAction",
|
||||
"BulkUserActionResponse",
|
||||
"BulkVendorAction",
|
||||
"BulkVendorActionResponse",
|
||||
"ComponentHealthStatus",
|
||||
"FileLogResponse",
|
||||
"LogCleanupResponse",
|
||||
"LogDeleteResponse",
|
||||
"LogFileInfo",
|
||||
"LogFileListResponse",
|
||||
"LogSettingsResponse",
|
||||
"LogSettingsUpdate",
|
||||
"LogSettingsUpdateResponse",
|
||||
"LogStatistics",
|
||||
"PlatformAlertCreate",
|
||||
"PlatformAlertListResponse",
|
||||
"PlatformAlertResolve",
|
||||
"PlatformAlertResponse",
|
||||
"PublicDisplaySettingsResponse",
|
||||
"RowsPerPageResponse",
|
||||
"RowsPerPageUpdateResponse",
|
||||
"SystemHealthResponse",
|
||||
# Team
|
||||
"BulkRemoveRequest",
|
||||
"BulkRemoveResponse",
|
||||
"InvitationAccept",
|
||||
"InvitationAcceptResponse",
|
||||
"InvitationResponse",
|
||||
"PermissionCheckRequest",
|
||||
"PermissionCheckResponse",
|
||||
"RoleBase",
|
||||
"RoleCreate",
|
||||
"RoleListResponse",
|
||||
"RoleResponse",
|
||||
"RoleUpdate",
|
||||
"TeamErrorResponse",
|
||||
"TeamMemberBase",
|
||||
"TeamMemberInvite",
|
||||
"TeamMemberListResponse",
|
||||
"TeamMemberResponse",
|
||||
"TeamMemberUpdate",
|
||||
"TeamStatistics",
|
||||
"UserPermissionsResponse",
|
||||
# Vendor Domain
|
||||
"DomainDeletionResponse",
|
||||
"DomainVerificationInstructions",
|
||||
"DomainVerificationResponse",
|
||||
"VendorDomainCreate",
|
||||
"VendorDomainListResponse",
|
||||
"VendorDomainResponse",
|
||||
"VendorDomainUpdate",
|
||||
]
|
||||
|
||||
590
app/modules/tenancy/schemas/admin.py
Normal file
590
app/modules/tenancy/schemas/admin.py
Normal file
@@ -0,0 +1,590 @@
|
||||
# app/modules/tenancy/schemas/admin.py
|
||||
"""
|
||||
Admin-specific Pydantic schemas for API validation and responses.
|
||||
|
||||
This module provides schemas for:
|
||||
- Admin audit logs
|
||||
- Admin notifications
|
||||
- Platform settings
|
||||
- Platform alerts
|
||||
- Bulk operations
|
||||
- System health checks
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
# ============================================================================
|
||||
# ADMIN AUDIT LOG SCHEMAS
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class AdminAuditLogResponse(BaseModel):
|
||||
"""Response model for admin audit logs."""
|
||||
|
||||
id: int
|
||||
admin_user_id: int
|
||||
admin_username: str | None = None
|
||||
action: str
|
||||
target_type: str
|
||||
target_id: str
|
||||
details: dict[str, Any] | None = None
|
||||
ip_address: str | None = None
|
||||
user_agent: str | None = None
|
||||
request_id: str | None = None
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class AdminAuditLogFilters(BaseModel):
|
||||
"""Filters for querying audit logs."""
|
||||
|
||||
admin_user_id: int | None = None
|
||||
action: str | None = None
|
||||
target_type: str | None = None
|
||||
date_from: datetime | None = None
|
||||
date_to: datetime | None = None
|
||||
skip: int = Field(0, ge=0)
|
||||
limit: int = Field(100, ge=1, le=1000)
|
||||
|
||||
|
||||
class AdminAuditLogListResponse(BaseModel):
|
||||
"""Paginated list of audit logs."""
|
||||
|
||||
logs: list[AdminAuditLogResponse]
|
||||
total: int
|
||||
skip: int
|
||||
limit: int
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# ADMIN NOTIFICATION SCHEMAS
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class AdminNotificationCreate(BaseModel):
|
||||
"""Create admin notification."""
|
||||
|
||||
type: str = Field(..., max_length=50, description="Notification type")
|
||||
priority: str = Field(default="normal", description="Priority level")
|
||||
title: str = Field(..., max_length=200)
|
||||
message: str = Field(..., description="Notification message")
|
||||
action_required: bool = Field(default=False)
|
||||
action_url: str | None = Field(None, max_length=500)
|
||||
metadata: dict[str, Any] | None = None
|
||||
|
||||
@field_validator("priority")
|
||||
@classmethod
|
||||
def validate_priority(cls, v):
|
||||
allowed = ["low", "normal", "high", "critical"]
|
||||
if v not in allowed:
|
||||
raise ValueError(f"Priority must be one of: {', '.join(allowed)}")
|
||||
return v
|
||||
|
||||
|
||||
class AdminNotificationResponse(BaseModel):
|
||||
"""Admin notification response."""
|
||||
|
||||
id: int
|
||||
type: str
|
||||
priority: str
|
||||
title: str
|
||||
message: str
|
||||
is_read: bool
|
||||
read_at: datetime | None = None
|
||||
read_by_user_id: int | None = None
|
||||
action_required: bool
|
||||
action_url: str | None = None
|
||||
metadata: dict[str, Any] | None = None
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class AdminNotificationUpdate(BaseModel):
|
||||
"""Mark notification as read."""
|
||||
|
||||
is_read: bool = True
|
||||
|
||||
|
||||
class AdminNotificationListResponse(BaseModel):
|
||||
"""Paginated list of notifications."""
|
||||
|
||||
notifications: list[AdminNotificationResponse]
|
||||
total: int
|
||||
unread_count: int
|
||||
skip: int
|
||||
limit: int
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# ADMIN SETTINGS SCHEMAS
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class AdminSettingCreate(BaseModel):
|
||||
"""Create or update admin setting."""
|
||||
|
||||
key: str = Field(..., max_length=100, description="Unique setting key")
|
||||
value: str = Field(..., description="Setting value")
|
||||
value_type: str = Field(default="string", description="Data type")
|
||||
category: str | None = Field(None, max_length=50)
|
||||
description: str | None = None
|
||||
is_encrypted: bool = Field(default=False)
|
||||
is_public: bool = Field(default=False, description="Can be exposed to frontend")
|
||||
|
||||
@field_validator("value_type")
|
||||
@classmethod
|
||||
def validate_value_type(cls, v):
|
||||
allowed = ["string", "integer", "boolean", "json", "float"]
|
||||
if v not in allowed:
|
||||
raise ValueError(f"Value type must be one of: {', '.join(allowed)}")
|
||||
return v
|
||||
|
||||
@field_validator("key")
|
||||
@classmethod
|
||||
def validate_key_format(cls, v):
|
||||
# Setting keys should be lowercase with underscores
|
||||
if not v.replace("_", "").isalnum():
|
||||
raise ValueError(
|
||||
"Setting key must contain only letters, numbers, and underscores"
|
||||
)
|
||||
return v.lower()
|
||||
|
||||
|
||||
class AdminSettingResponse(BaseModel):
|
||||
"""Admin setting response."""
|
||||
|
||||
id: int
|
||||
key: str
|
||||
value: str
|
||||
value_type: str
|
||||
category: str | None = None
|
||||
description: str | None = None
|
||||
is_encrypted: bool
|
||||
is_public: bool
|
||||
last_modified_by_user_id: int | None = None
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class AdminSettingDefaultResponse(BaseModel):
|
||||
"""Response when returning a default value for non-existent setting."""
|
||||
|
||||
key: str
|
||||
value: str
|
||||
exists: bool = False
|
||||
|
||||
|
||||
class AdminSettingUpdate(BaseModel):
|
||||
"""Update admin setting value."""
|
||||
|
||||
value: str
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class AdminSettingListResponse(BaseModel):
|
||||
"""List of settings by category."""
|
||||
|
||||
settings: list[AdminSettingResponse]
|
||||
total: int
|
||||
category: str | None = None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# DISPLAY SETTINGS SCHEMAS
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class RowsPerPageResponse(BaseModel):
|
||||
"""Response for rows per page setting."""
|
||||
|
||||
rows_per_page: int
|
||||
|
||||
|
||||
class RowsPerPageUpdateResponse(BaseModel):
|
||||
"""Response after updating rows per page."""
|
||||
|
||||
rows_per_page: int
|
||||
message: str
|
||||
|
||||
|
||||
class PublicDisplaySettingsResponse(BaseModel):
|
||||
"""Public display settings (no auth required)."""
|
||||
|
||||
rows_per_page: int
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# PLATFORM ALERT SCHEMAS
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class PlatformAlertCreate(BaseModel):
|
||||
"""Create platform alert."""
|
||||
|
||||
alert_type: str = Field(..., max_length=50)
|
||||
severity: str = Field(..., description="Alert severity")
|
||||
title: str = Field(..., max_length=200)
|
||||
description: str | None = None
|
||||
affected_vendors: list[int] | None = None
|
||||
affected_systems: list[str] | None = None
|
||||
auto_generated: bool = Field(default=True)
|
||||
|
||||
@field_validator("severity")
|
||||
@classmethod
|
||||
def validate_severity(cls, v):
|
||||
allowed = ["info", "warning", "error", "critical"]
|
||||
if v not in allowed:
|
||||
raise ValueError(f"Severity must be one of: {', '.join(allowed)}")
|
||||
return v
|
||||
|
||||
@field_validator("alert_type")
|
||||
@classmethod
|
||||
def validate_alert_type(cls, v):
|
||||
allowed = [
|
||||
"security",
|
||||
"performance",
|
||||
"capacity",
|
||||
"integration",
|
||||
"database",
|
||||
"system",
|
||||
]
|
||||
if v not in allowed:
|
||||
raise ValueError(f"Alert type must be one of: {', '.join(allowed)}")
|
||||
return v
|
||||
|
||||
|
||||
class PlatformAlertResponse(BaseModel):
|
||||
"""Platform alert response."""
|
||||
|
||||
id: int
|
||||
alert_type: str
|
||||
severity: str
|
||||
title: str
|
||||
description: str | None = None
|
||||
affected_vendors: list[int] | None = None
|
||||
affected_systems: list[str] | None = None
|
||||
is_resolved: bool
|
||||
resolved_at: datetime | None = None
|
||||
resolved_by_user_id: int | None = None
|
||||
resolution_notes: str | None = None
|
||||
auto_generated: bool
|
||||
occurrence_count: int
|
||||
first_occurred_at: datetime
|
||||
last_occurred_at: datetime
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class PlatformAlertResolve(BaseModel):
|
||||
"""Resolve platform alert."""
|
||||
|
||||
is_resolved: bool = True
|
||||
resolution_notes: str | None = None
|
||||
|
||||
|
||||
class PlatformAlertListResponse(BaseModel):
|
||||
"""Paginated list of platform alerts."""
|
||||
|
||||
alerts: list[PlatformAlertResponse]
|
||||
total: int
|
||||
active_count: int
|
||||
critical_count: int
|
||||
skip: int
|
||||
limit: int
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# BULK OPERATION SCHEMAS
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class BulkVendorAction(BaseModel):
|
||||
"""Bulk actions on vendors."""
|
||||
|
||||
vendor_ids: list[int] = Field(..., min_length=1, max_length=100)
|
||||
action: str = Field(..., description="Action to perform")
|
||||
confirm: bool = Field(default=False, description="Required for destructive actions")
|
||||
reason: str | None = Field(None, description="Reason for bulk action")
|
||||
|
||||
@field_validator("action")
|
||||
@classmethod
|
||||
def validate_action(cls, v):
|
||||
allowed = ["activate", "deactivate", "verify", "unverify", "delete"]
|
||||
if v not in allowed:
|
||||
raise ValueError(f"Action must be one of: {', '.join(allowed)}")
|
||||
return v
|
||||
|
||||
|
||||
class BulkVendorActionResponse(BaseModel):
|
||||
"""Response for bulk vendor actions."""
|
||||
|
||||
successful: list[int]
|
||||
failed: dict[int, str] # vendor_id -> error_message
|
||||
total_processed: int
|
||||
action_performed: str
|
||||
message: str
|
||||
|
||||
|
||||
class BulkUserAction(BaseModel):
|
||||
"""Bulk actions on users."""
|
||||
|
||||
user_ids: list[int] = Field(..., min_length=1, max_length=100)
|
||||
action: str = Field(..., description="Action to perform")
|
||||
confirm: bool = Field(default=False)
|
||||
reason: str | None = None
|
||||
|
||||
@field_validator("action")
|
||||
@classmethod
|
||||
def validate_action(cls, v):
|
||||
allowed = ["activate", "deactivate", "delete"]
|
||||
if v not in allowed:
|
||||
raise ValueError(f"Action must be one of: {', '.join(allowed)}")
|
||||
return v
|
||||
|
||||
|
||||
class BulkUserActionResponse(BaseModel):
|
||||
"""Response for bulk user actions."""
|
||||
|
||||
successful: list[int]
|
||||
failed: dict[int, str]
|
||||
total_processed: int
|
||||
action_performed: str
|
||||
message: str
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# ADMIN DASHBOARD SCHEMAS
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class AdminDashboardStats(BaseModel):
|
||||
"""Comprehensive admin dashboard statistics."""
|
||||
|
||||
platform: dict[str, Any]
|
||||
users: dict[str, Any]
|
||||
vendors: dict[str, Any]
|
||||
products: dict[str, Any]
|
||||
orders: dict[str, Any]
|
||||
imports: dict[str, Any]
|
||||
recent_vendors: list[dict[str, Any]]
|
||||
recent_imports: list[dict[str, Any]]
|
||||
unread_notifications: int
|
||||
active_alerts: int
|
||||
critical_alerts: int
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# SYSTEM HEALTH SCHEMAS
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class ComponentHealthStatus(BaseModel):
|
||||
"""Health status for a system component."""
|
||||
|
||||
status: str # healthy, degraded, unhealthy
|
||||
response_time_ms: float | None = None
|
||||
error_message: str | None = None
|
||||
last_checked: datetime
|
||||
details: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class SystemHealthResponse(BaseModel):
|
||||
"""System health check response."""
|
||||
|
||||
overall_status: str # healthy, degraded, critical
|
||||
database: ComponentHealthStatus
|
||||
redis: ComponentHealthStatus
|
||||
celery: ComponentHealthStatus
|
||||
storage: ComponentHealthStatus
|
||||
api_response_time_ms: float
|
||||
uptime_seconds: int
|
||||
timestamp: datetime
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# ADMIN SESSION SCHEMAS
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class AdminSessionResponse(BaseModel):
|
||||
"""Admin session information."""
|
||||
|
||||
id: int
|
||||
admin_user_id: int
|
||||
admin_username: str | None = None
|
||||
ip_address: str
|
||||
user_agent: str | None = None
|
||||
login_at: datetime
|
||||
last_activity_at: datetime
|
||||
logout_at: datetime | None = None
|
||||
is_active: bool
|
||||
logout_reason: str | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class AdminSessionListResponse(BaseModel):
|
||||
"""List of admin sessions."""
|
||||
|
||||
sessions: list[AdminSessionResponse]
|
||||
total: int
|
||||
active_count: int
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# APPLICATION LOGS SCHEMAS
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class ApplicationLogResponse(BaseModel):
|
||||
"""Application log entry response."""
|
||||
|
||||
id: int
|
||||
timestamp: datetime
|
||||
level: str
|
||||
logger_name: str
|
||||
module: str | None = None
|
||||
function_name: str | None = None
|
||||
line_number: int | None = None
|
||||
message: str
|
||||
exception_type: str | None = None
|
||||
exception_message: str | None = None
|
||||
stack_trace: str | None = None
|
||||
request_id: str | None = None
|
||||
user_id: int | None = None
|
||||
vendor_id: int | None = None
|
||||
context: dict[str, Any] | None = None
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class ApplicationLogFilters(BaseModel):
|
||||
"""Filters for querying application logs."""
|
||||
|
||||
level: str | None = Field(None, description="Filter by log level")
|
||||
logger_name: str | None = Field(None, description="Filter by logger name")
|
||||
module: str | None = Field(None, description="Filter by module")
|
||||
user_id: int | None = Field(None, description="Filter by user ID")
|
||||
vendor_id: int | None = Field(None, description="Filter by vendor ID")
|
||||
date_from: datetime | None = Field(None, description="Start date")
|
||||
date_to: datetime | None = Field(None, description="End date")
|
||||
search: str | None = Field(None, description="Search in message")
|
||||
skip: int = Field(0, ge=0)
|
||||
limit: int = Field(100, ge=1, le=1000)
|
||||
|
||||
|
||||
class ApplicationLogListResponse(BaseModel):
|
||||
"""Paginated list of application logs."""
|
||||
|
||||
logs: list[ApplicationLogResponse]
|
||||
total: int
|
||||
skip: int
|
||||
limit: int
|
||||
|
||||
|
||||
class LogStatistics(BaseModel):
|
||||
"""Statistics about application logs."""
|
||||
|
||||
total_count: int
|
||||
warning_count: int
|
||||
error_count: int
|
||||
critical_count: int
|
||||
by_level: dict[str, int]
|
||||
by_module: dict[str, int]
|
||||
recent_errors: list[ApplicationLogResponse]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# LOG SETTINGS SCHEMAS
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class LogSettingsResponse(BaseModel):
|
||||
"""Log configuration settings."""
|
||||
|
||||
log_level: str
|
||||
log_file_max_size_mb: int
|
||||
log_file_backup_count: int
|
||||
db_log_retention_days: int
|
||||
file_logging_enabled: bool
|
||||
db_logging_enabled: bool
|
||||
|
||||
|
||||
class LogSettingsUpdate(BaseModel):
|
||||
"""Update log settings."""
|
||||
|
||||
log_level: str | None = Field(
|
||||
None, description="Log level: DEBUG, INFO, WARNING, ERROR, CRITICAL"
|
||||
)
|
||||
log_file_max_size_mb: int | None = Field(
|
||||
None, ge=1, le=1000, description="Max log file size in MB"
|
||||
)
|
||||
log_file_backup_count: int | None = Field(
|
||||
None, ge=0, le=50, description="Number of backup files to keep"
|
||||
)
|
||||
db_log_retention_days: int | None = Field(
|
||||
None, ge=1, le=365, description="Days to retain logs in database"
|
||||
)
|
||||
|
||||
@field_validator("log_level")
|
||||
@classmethod
|
||||
def validate_log_level(cls, v):
|
||||
if v is not None:
|
||||
allowed = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
|
||||
if v.upper() not in allowed:
|
||||
raise ValueError(f"Log level must be one of: {', '.join(allowed)}")
|
||||
return v.upper()
|
||||
return v
|
||||
|
||||
|
||||
class FileLogResponse(BaseModel):
|
||||
"""File log content response."""
|
||||
|
||||
filename: str
|
||||
size_bytes: int
|
||||
last_modified: datetime
|
||||
lines: list[str]
|
||||
total_lines: int
|
||||
|
||||
|
||||
class LogFileInfo(BaseModel):
|
||||
"""Log file info for listing."""
|
||||
|
||||
filename: str
|
||||
size_bytes: int
|
||||
last_modified: datetime
|
||||
|
||||
|
||||
class LogFileListResponse(BaseModel):
|
||||
"""Response for listing log files."""
|
||||
|
||||
files: list[LogFileInfo]
|
||||
|
||||
|
||||
class LogDeleteResponse(BaseModel):
|
||||
"""Response for log deletion."""
|
||||
|
||||
message: str
|
||||
|
||||
|
||||
class LogCleanupResponse(BaseModel):
|
||||
"""Response for log cleanup operation."""
|
||||
|
||||
message: str
|
||||
deleted_count: int
|
||||
|
||||
|
||||
class LogSettingsUpdateResponse(BaseModel):
|
||||
"""Response for log settings update."""
|
||||
|
||||
message: str
|
||||
updated_fields: list[str]
|
||||
note: str | None = None
|
||||
216
app/modules/tenancy/schemas/company.py
Normal file
216
app/modules/tenancy/schemas/company.py
Normal file
@@ -0,0 +1,216 @@
|
||||
# app/modules/tenancy/schemas/company.py
|
||||
"""
|
||||
Pydantic schemas for Company model.
|
||||
|
||||
These schemas are used for API request/response validation and serialization.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator
|
||||
|
||||
|
||||
class CompanyBase(BaseModel):
|
||||
"""Base schema for company with common fields."""
|
||||
|
||||
name: str = Field(..., min_length=2, max_length=200, description="Company name")
|
||||
description: str | None = Field(None, description="Company description")
|
||||
contact_email: EmailStr = Field(..., description="Business contact email")
|
||||
contact_phone: str | None = Field(None, description="Business phone number")
|
||||
website: str | None = Field(None, description="Company website URL")
|
||||
business_address: str | None = Field(None, description="Physical business address")
|
||||
tax_number: str | None = Field(None, description="Tax/VAT registration number")
|
||||
|
||||
@field_validator("contact_email")
|
||||
@classmethod
|
||||
def normalize_email(cls, v):
|
||||
"""Normalize email to lowercase."""
|
||||
return v.lower() if v else v
|
||||
|
||||
|
||||
class CompanyCreate(CompanyBase):
|
||||
"""
|
||||
Schema for creating a new company.
|
||||
|
||||
Requires owner_email to create the associated owner user account.
|
||||
"""
|
||||
|
||||
owner_email: EmailStr = Field(
|
||||
..., description="Email for the company owner account"
|
||||
)
|
||||
|
||||
@field_validator("owner_email")
|
||||
@classmethod
|
||||
def normalize_owner_email(cls, v):
|
||||
"""Normalize owner email to lowercase."""
|
||||
return v.lower() if v else v
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class CompanyUpdate(BaseModel):
|
||||
"""
|
||||
Schema for updating company information.
|
||||
|
||||
All fields are optional to support partial updates.
|
||||
"""
|
||||
|
||||
name: str | None = Field(None, min_length=2, max_length=200)
|
||||
description: str | None = None
|
||||
contact_email: EmailStr | None = None
|
||||
contact_phone: str | None = None
|
||||
website: str | None = None
|
||||
business_address: str | None = None
|
||||
tax_number: str | None = None
|
||||
|
||||
# Status (Admin only)
|
||||
is_active: bool | None = None
|
||||
is_verified: bool | None = None
|
||||
|
||||
@field_validator("contact_email")
|
||||
@classmethod
|
||||
def normalize_email(cls, v):
|
||||
"""Normalize email to lowercase."""
|
||||
return v.lower() if v else v
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class CompanyResponse(BaseModel):
|
||||
"""Standard schema for company response data."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
name: str
|
||||
description: str | None
|
||||
|
||||
# Owner information
|
||||
owner_user_id: int
|
||||
|
||||
# Contact Information
|
||||
contact_email: str
|
||||
contact_phone: str | None
|
||||
website: str | None
|
||||
|
||||
# Business Information
|
||||
business_address: str | None
|
||||
tax_number: str | None
|
||||
|
||||
# Status Flags
|
||||
is_active: bool
|
||||
is_verified: bool
|
||||
|
||||
# Timestamps
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
class CompanyDetailResponse(CompanyResponse):
|
||||
"""
|
||||
Detailed company response including vendor count and owner details.
|
||||
|
||||
Used for company detail pages and admin views.
|
||||
"""
|
||||
|
||||
# Owner details (from related User)
|
||||
owner_email: str | None = Field(None, description="Owner's email address")
|
||||
owner_username: str | None = Field(None, description="Owner's username")
|
||||
|
||||
# Vendor statistics
|
||||
vendor_count: int = Field(0, description="Number of vendors under this company")
|
||||
active_vendor_count: int = Field(
|
||||
0, description="Number of active vendors under this company"
|
||||
)
|
||||
|
||||
# Vendors list (optional, for detail view)
|
||||
vendors: list | None = Field(None, description="List of vendors under this company")
|
||||
|
||||
|
||||
class CompanyListResponse(BaseModel):
|
||||
"""Schema for paginated company list."""
|
||||
|
||||
companies: list[CompanyResponse]
|
||||
total: int
|
||||
skip: int
|
||||
limit: int
|
||||
|
||||
|
||||
class CompanyCreateResponse(BaseModel):
|
||||
"""
|
||||
Response after creating a company with owner account.
|
||||
|
||||
Includes temporary password for the owner (shown only once).
|
||||
"""
|
||||
|
||||
company: CompanyResponse
|
||||
owner_user_id: int
|
||||
owner_username: str
|
||||
owner_email: str
|
||||
temporary_password: str = Field(
|
||||
..., description="Temporary password for owner (SHOWN ONLY ONCE)"
|
||||
)
|
||||
login_url: str | None = Field(None, description="URL for company owner to login")
|
||||
|
||||
|
||||
class CompanySummary(BaseModel):
|
||||
"""Lightweight company summary for dropdowns and quick references."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
name: str
|
||||
is_active: bool
|
||||
is_verified: bool
|
||||
vendor_count: int = 0
|
||||
|
||||
|
||||
class CompanyTransferOwnership(BaseModel):
|
||||
"""
|
||||
Schema for transferring company ownership to another user.
|
||||
|
||||
This is a critical operation that requires:
|
||||
- Confirmation flag
|
||||
- Reason for audit trail (optional)
|
||||
"""
|
||||
|
||||
new_owner_user_id: int = Field(
|
||||
..., description="ID of the user who will become the new owner", gt=0
|
||||
)
|
||||
|
||||
confirm_transfer: bool = Field(
|
||||
..., description="Must be true to confirm ownership transfer"
|
||||
)
|
||||
|
||||
transfer_reason: str | None = Field(
|
||||
None,
|
||||
max_length=500,
|
||||
description="Reason for ownership transfer (for audit logs)",
|
||||
)
|
||||
|
||||
@field_validator("confirm_transfer")
|
||||
@classmethod
|
||||
def validate_confirmation(cls, v):
|
||||
"""Ensure confirmation is explicitly true."""
|
||||
if not v:
|
||||
raise ValueError("Ownership transfer requires explicit confirmation")
|
||||
return v
|
||||
|
||||
|
||||
class CompanyTransferOwnershipResponse(BaseModel):
|
||||
"""Response after successful ownership transfer."""
|
||||
|
||||
message: str
|
||||
company_id: int
|
||||
company_name: str
|
||||
|
||||
old_owner: dict[str, Any] = Field(
|
||||
..., description="Information about the previous owner"
|
||||
)
|
||||
new_owner: dict[str, Any] = Field(
|
||||
..., description="Information about the new owner"
|
||||
)
|
||||
|
||||
transferred_at: datetime
|
||||
transfer_reason: str | None
|
||||
293
app/modules/tenancy/schemas/team.py
Normal file
293
app/modules/tenancy/schemas/team.py
Normal file
@@ -0,0 +1,293 @@
|
||||
# app/modules/tenancy/schemas/team.py
|
||||
"""
|
||||
Pydantic schemas for vendor team management.
|
||||
|
||||
This module defines request/response schemas for:
|
||||
- Team member listing
|
||||
- Team member invitation
|
||||
- Team member updates
|
||||
- Role management
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, EmailStr, Field, field_validator
|
||||
|
||||
# ============================================================================
|
||||
# Role Schemas
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class RoleBase(BaseModel):
|
||||
"""Base role schema."""
|
||||
|
||||
name: str = Field(..., min_length=1, max_length=100, description="Role name")
|
||||
permissions: list[str] = Field(
|
||||
default_factory=list, description="List of permission strings"
|
||||
)
|
||||
|
||||
|
||||
class RoleCreate(RoleBase):
|
||||
"""Schema for creating a role."""
|
||||
|
||||
|
||||
class RoleUpdate(BaseModel):
|
||||
"""Schema for updating a role."""
|
||||
|
||||
name: str | None = Field(None, min_length=1, max_length=100)
|
||||
permissions: list[str] | None = None
|
||||
|
||||
|
||||
class RoleResponse(RoleBase):
|
||||
"""Schema for role response."""
|
||||
|
||||
id: int
|
||||
vendor_id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True # Pydantic v2 (use orm_mode = True for v1)
|
||||
|
||||
|
||||
class RoleListResponse(BaseModel):
|
||||
"""Schema for role list response."""
|
||||
|
||||
roles: list[RoleResponse]
|
||||
total: int
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Team Member Schemas
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class TeamMemberBase(BaseModel):
|
||||
"""Base team member schema."""
|
||||
|
||||
email: EmailStr = Field(..., description="Team member email address")
|
||||
first_name: str | None = Field(None, max_length=100)
|
||||
last_name: str | None = Field(None, max_length=100)
|
||||
|
||||
|
||||
class TeamMemberInvite(TeamMemberBase):
|
||||
"""Schema for inviting a team member."""
|
||||
|
||||
role_id: int | None = Field(
|
||||
None, description="Role ID to assign (for preset roles)"
|
||||
)
|
||||
role_name: str | None = Field(
|
||||
None, description="Role name (manager, staff, support, etc.)"
|
||||
)
|
||||
custom_permissions: list[str] | None = Field(
|
||||
None, description="Custom permissions (overrides role preset)"
|
||||
)
|
||||
|
||||
@field_validator("role_name")
|
||||
def validate_role_name(cls, v):
|
||||
"""Validate role name is in allowed presets."""
|
||||
if v is not None:
|
||||
allowed_roles = ["manager", "staff", "support", "viewer", "marketing"]
|
||||
if v.lower() not in allowed_roles:
|
||||
raise ValueError(
|
||||
f"Role name must be one of: {', '.join(allowed_roles)}"
|
||||
)
|
||||
return v.lower() if v else v
|
||||
|
||||
@field_validator("custom_permissions")
|
||||
def validate_custom_permissions(cls, v, values):
|
||||
"""Ensure either role_id/role_name OR custom_permissions is provided."""
|
||||
if v is not None and len(v) > 0:
|
||||
# If custom permissions provided, role_name should be provided too
|
||||
if "role_name" not in values or not values["role_name"]:
|
||||
raise ValueError(
|
||||
"role_name is required when providing custom_permissions"
|
||||
)
|
||||
return v
|
||||
|
||||
|
||||
class TeamMemberUpdate(BaseModel):
|
||||
"""Schema for updating a team member."""
|
||||
|
||||
role_id: int | None = Field(None, description="New role ID")
|
||||
is_active: bool | None = Field(None, description="Active status")
|
||||
|
||||
|
||||
class TeamMemberResponse(BaseModel):
|
||||
"""Schema for team member response."""
|
||||
|
||||
id: int = Field(..., description="User ID")
|
||||
email: EmailStr
|
||||
username: str
|
||||
first_name: str | None
|
||||
last_name: str | None
|
||||
full_name: str
|
||||
user_type: str = Field(..., description="'owner' or 'member'")
|
||||
role_name: str = Field(..., description="Role name")
|
||||
role_id: int | None
|
||||
permissions: list[str] = Field(
|
||||
default_factory=list, description="User's permissions"
|
||||
)
|
||||
is_active: bool
|
||||
is_owner: bool
|
||||
invitation_pending: bool = Field(
|
||||
default=False, description="True if invitation not yet accepted"
|
||||
)
|
||||
invited_at: datetime | None = Field(None, description="When invitation was sent")
|
||||
accepted_at: datetime | None = Field(
|
||||
None, description="When invitation was accepted"
|
||||
)
|
||||
joined_at: datetime = Field(..., description="When user joined vendor")
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class TeamMemberListResponse(BaseModel):
|
||||
"""Schema for team member list response."""
|
||||
|
||||
members: list[TeamMemberResponse]
|
||||
total: int
|
||||
active_count: int
|
||||
pending_invitations: int
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Invitation Schemas
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class InvitationAccept(BaseModel):
|
||||
"""Schema for accepting a team invitation."""
|
||||
|
||||
invitation_token: str = Field(
|
||||
..., min_length=32, description="Invitation token from email"
|
||||
)
|
||||
password: str = Field(
|
||||
..., min_length=8, max_length=128, description="Password for new account"
|
||||
)
|
||||
first_name: str = Field(..., min_length=1, max_length=100)
|
||||
last_name: str = Field(..., min_length=1, max_length=100)
|
||||
|
||||
@field_validator("password")
|
||||
def validate_password_strength(cls, v):
|
||||
"""Validate password meets minimum requirements."""
|
||||
if len(v) < 8:
|
||||
raise ValueError("Password must be at least 8 characters long")
|
||||
|
||||
has_upper = any(c.isupper() for c in v)
|
||||
has_lower = any(c.islower() for c in v)
|
||||
has_digit = any(c.isdigit() for c in v)
|
||||
|
||||
if not (has_upper and has_lower and has_digit):
|
||||
raise ValueError(
|
||||
"Password must contain at least one uppercase letter, "
|
||||
"one lowercase letter, and one digit"
|
||||
)
|
||||
|
||||
return v
|
||||
|
||||
|
||||
class InvitationResponse(BaseModel):
|
||||
"""Schema for invitation response."""
|
||||
|
||||
message: str
|
||||
email: EmailStr
|
||||
role: str
|
||||
invitation_token: str | None = Field(
|
||||
None, description="Token (only returned in dev/test environments)"
|
||||
)
|
||||
invitation_sent: bool = Field(default=True)
|
||||
|
||||
|
||||
class InvitationAcceptResponse(BaseModel):
|
||||
"""Schema for invitation acceptance response."""
|
||||
|
||||
message: str
|
||||
vendor: dict = Field(..., description="Vendor information")
|
||||
user: dict = Field(..., description="User information")
|
||||
role: str
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Team Statistics Schema
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class TeamStatistics(BaseModel):
|
||||
"""Schema for team statistics."""
|
||||
|
||||
total_members: int
|
||||
active_members: int
|
||||
inactive_members: int
|
||||
pending_invitations: int
|
||||
owners: int
|
||||
team_members: int
|
||||
roles_breakdown: dict = Field(
|
||||
default_factory=dict, description="Count of members per role"
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Bulk Operations Schemas
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class BulkRemoveRequest(BaseModel):
|
||||
"""Schema for bulk removing team members."""
|
||||
|
||||
user_ids: list[int] = Field(
|
||||
..., min_items=1, description="List of user IDs to remove"
|
||||
)
|
||||
|
||||
|
||||
class BulkRemoveResponse(BaseModel):
|
||||
"""Schema for bulk remove response."""
|
||||
|
||||
success_count: int
|
||||
failed_count: int
|
||||
errors: list[dict] = Field(default_factory=list)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Permission Check Schemas
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class PermissionCheckRequest(BaseModel):
|
||||
"""Schema for checking permissions."""
|
||||
|
||||
permissions: list[str] = Field(..., min_items=1, description="Permissions to check")
|
||||
|
||||
|
||||
class PermissionCheckResponse(BaseModel):
|
||||
"""Schema for permission check response."""
|
||||
|
||||
has_all: bool = Field(..., description="True if user has all permissions")
|
||||
has_any: bool = Field(..., description="True if user has any permission")
|
||||
granted: list[str] = Field(default_factory=list, description="Permissions user has")
|
||||
denied: list[str] = Field(
|
||||
default_factory=list, description="Permissions user lacks"
|
||||
)
|
||||
|
||||
|
||||
class UserPermissionsResponse(BaseModel):
|
||||
"""Schema for user's permissions response."""
|
||||
|
||||
permissions: list[str] = Field(default_factory=list)
|
||||
permission_count: int
|
||||
is_owner: bool
|
||||
role_name: str | None = None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Error Response Schema
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class TeamErrorResponse(BaseModel):
|
||||
"""Schema for team operation errors."""
|
||||
|
||||
error_code: str
|
||||
message: str
|
||||
details: dict | None = None
|
||||
351
app/modules/tenancy/schemas/vendor.py
Normal file
351
app/modules/tenancy/schemas/vendor.py
Normal file
@@ -0,0 +1,351 @@
|
||||
# app/modules/tenancy/schemas/vendor.py
|
||||
"""
|
||||
Pydantic schemas for Vendor-related operations.
|
||||
|
||||
Schemas include:
|
||||
- VendorCreate: For creating vendors under companies
|
||||
- VendorUpdate: For updating vendor information (Admin only)
|
||||
- VendorResponse: Standard vendor response
|
||||
- VendorDetailResponse: Vendor response with company/owner details
|
||||
- VendorCreateResponse: Response after vendor creation
|
||||
- VendorListResponse: Paginated vendor list
|
||||
- VendorSummary: Lightweight vendor info
|
||||
|
||||
Note: Ownership transfer is handled at the Company level.
|
||||
See models/schema/company.py for CompanyTransferOwnership.
|
||||
"""
|
||||
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
|
||||
class VendorCreate(BaseModel):
|
||||
"""
|
||||
Schema for creating a new vendor (storefront/brand) under an existing company.
|
||||
|
||||
Contact info is inherited from the parent company by default.
|
||||
Optionally, provide contact fields to override from the start.
|
||||
"""
|
||||
|
||||
# Parent company
|
||||
company_id: int = Field(..., description="ID of the parent company", gt=0)
|
||||
|
||||
# Basic Information
|
||||
vendor_code: str = Field(
|
||||
...,
|
||||
description="Unique vendor identifier (e.g., TECHSTORE)",
|
||||
min_length=2,
|
||||
max_length=50,
|
||||
)
|
||||
subdomain: str = Field(
|
||||
..., description="Unique subdomain for the vendor", min_length=2, max_length=100
|
||||
)
|
||||
name: str = Field(
|
||||
...,
|
||||
description="Display name of the vendor/brand",
|
||||
min_length=2,
|
||||
max_length=255,
|
||||
)
|
||||
description: str | None = Field(None, description="Vendor/brand description")
|
||||
|
||||
# Platform assignments (optional - vendor can be on multiple platforms)
|
||||
platform_ids: list[int] | None = Field(
|
||||
None, description="List of platform IDs to assign the vendor to"
|
||||
)
|
||||
|
||||
# Marketplace URLs (brand-specific multi-language support)
|
||||
letzshop_csv_url_fr: str | None = Field(None, description="French CSV URL")
|
||||
letzshop_csv_url_en: str | None = Field(None, description="English CSV URL")
|
||||
letzshop_csv_url_de: str | None = Field(None, description="German CSV URL")
|
||||
|
||||
# Contact Info (optional - if not provided, inherited from company)
|
||||
contact_email: str | None = Field(
|
||||
None, description="Override company contact email"
|
||||
)
|
||||
contact_phone: str | None = Field(
|
||||
None, description="Override company contact phone"
|
||||
)
|
||||
website: str | None = Field(None, description="Override company website")
|
||||
business_address: str | None = Field(
|
||||
None, description="Override company business address"
|
||||
)
|
||||
tax_number: str | None = Field(None, description="Override company tax number")
|
||||
|
||||
# Language Settings
|
||||
default_language: str | None = Field(
|
||||
"fr", description="Default language for content (en, fr, de, lb)"
|
||||
)
|
||||
dashboard_language: str | None = Field(
|
||||
"fr", description="Vendor dashboard UI language"
|
||||
)
|
||||
storefront_language: str | None = Field(
|
||||
"fr", description="Default storefront language for customers"
|
||||
)
|
||||
storefront_languages: list[str] | None = Field(
|
||||
default=["fr", "de", "en"], description="Enabled languages for storefront"
|
||||
)
|
||||
storefront_locale: str | None = Field(
|
||||
None,
|
||||
description="Locale for currency/number formatting (e.g., 'fr-LU', 'de-DE'). NULL = inherit from platform default",
|
||||
max_length=10,
|
||||
)
|
||||
|
||||
@field_validator("subdomain")
|
||||
@classmethod
|
||||
def validate_subdomain(cls, v):
|
||||
"""Validate subdomain format: lowercase alphanumeric with hyphens."""
|
||||
if v and not re.match(r"^[a-z0-9][a-z0-9-]*[a-z0-9]$", v):
|
||||
raise ValueError(
|
||||
"Subdomain must contain only lowercase letters, numbers, and hyphens"
|
||||
)
|
||||
return v.lower() if v else v
|
||||
|
||||
@field_validator("vendor_code")
|
||||
@classmethod
|
||||
def validate_vendor_code(cls, v):
|
||||
"""Ensure vendor code is uppercase for consistency."""
|
||||
return v.upper() if v else v
|
||||
|
||||
|
||||
class VendorUpdate(BaseModel):
|
||||
"""
|
||||
Schema for updating vendor information (Admin only).
|
||||
|
||||
Contact fields can be overridden at the vendor level.
|
||||
Set to null/empty to reset to company default (inherit).
|
||||
"""
|
||||
|
||||
# Basic Information
|
||||
name: str | None = Field(None, min_length=2, max_length=255)
|
||||
description: str | None = None
|
||||
subdomain: str | None = Field(None, min_length=2, max_length=100)
|
||||
|
||||
# Marketplace URLs (brand-specific)
|
||||
letzshop_csv_url_fr: str | None = None
|
||||
letzshop_csv_url_en: str | None = None
|
||||
letzshop_csv_url_de: str | None = None
|
||||
|
||||
# Status (Admin only)
|
||||
is_active: bool | None = None
|
||||
is_verified: bool | None = None
|
||||
|
||||
# Contact Info (set value to override, set to empty string to reset to inherit)
|
||||
contact_email: str | None = Field(
|
||||
None, description="Override company contact email"
|
||||
)
|
||||
contact_phone: str | None = Field(
|
||||
None, description="Override company contact phone"
|
||||
)
|
||||
website: str | None = Field(None, description="Override company website")
|
||||
business_address: str | None = Field(
|
||||
None, description="Override company business address"
|
||||
)
|
||||
tax_number: str | None = Field(None, description="Override company tax number")
|
||||
|
||||
# Special flag to reset contact fields to inherit from company
|
||||
reset_contact_to_company: bool | None = Field(
|
||||
None, description="If true, reset all contact fields to inherit from company"
|
||||
)
|
||||
|
||||
# Language Settings
|
||||
default_language: str | None = Field(
|
||||
None, description="Default language for content (en, fr, de, lb)"
|
||||
)
|
||||
dashboard_language: str | None = Field(
|
||||
None, description="Vendor dashboard UI language"
|
||||
)
|
||||
storefront_language: str | None = Field(
|
||||
None, description="Default storefront language for customers"
|
||||
)
|
||||
storefront_languages: list[str] | None = Field(
|
||||
None, description="Enabled languages for storefront"
|
||||
)
|
||||
storefront_locale: str | None = Field(
|
||||
None,
|
||||
description="Locale for currency/number formatting (e.g., 'fr-LU', 'de-DE'). NULL = inherit from platform default",
|
||||
max_length=10,
|
||||
)
|
||||
|
||||
@field_validator("subdomain")
|
||||
@classmethod
|
||||
def subdomain_lowercase(cls, v):
|
||||
"""Normalize subdomain to lowercase."""
|
||||
return v.lower().strip() if v else v
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class VendorResponse(BaseModel):
|
||||
"""
|
||||
Standard schema for vendor response data.
|
||||
|
||||
Note: Business contact info (contact_email, contact_phone, website,
|
||||
business_address, tax_number) is now at the Company level.
|
||||
Use company_id to look up company details.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
vendor_code: str
|
||||
subdomain: str
|
||||
name: str
|
||||
description: str | None
|
||||
|
||||
# Company relationship
|
||||
company_id: int
|
||||
|
||||
# Marketplace URLs (brand-specific)
|
||||
letzshop_csv_url_fr: str | None
|
||||
letzshop_csv_url_en: str | None
|
||||
letzshop_csv_url_de: str | None
|
||||
|
||||
# Status Flags
|
||||
is_active: bool
|
||||
is_verified: bool
|
||||
|
||||
# Language Settings (optional with defaults for backward compatibility)
|
||||
default_language: str = "fr"
|
||||
dashboard_language: str = "fr"
|
||||
storefront_language: str = "fr"
|
||||
storefront_languages: list[str] = ["fr", "de", "en"]
|
||||
|
||||
# Currency/number formatting locale (NULL = inherit from platform default)
|
||||
storefront_locale: str | None = None
|
||||
|
||||
# Timestamps
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class VendorDetailResponse(VendorResponse):
|
||||
"""
|
||||
Extended vendor response including company information and resolved contact info.
|
||||
|
||||
Contact fields show the effective value (vendor override or company default)
|
||||
with flags indicating if the value is inherited from the parent company.
|
||||
"""
|
||||
|
||||
# Company info
|
||||
company_name: str = Field(..., description="Name of the parent company")
|
||||
|
||||
# Owner info (at company level)
|
||||
owner_email: str = Field(
|
||||
..., description="Email of the company owner (for login/authentication)"
|
||||
)
|
||||
owner_username: str = Field(..., description="Username of the company owner")
|
||||
|
||||
# Resolved contact info (vendor override or company default)
|
||||
contact_email: str | None = Field(None, description="Effective contact email")
|
||||
contact_phone: str | None = Field(None, description="Effective contact phone")
|
||||
website: str | None = Field(None, description="Effective website")
|
||||
business_address: str | None = Field(None, description="Effective business address")
|
||||
tax_number: str | None = Field(None, description="Effective tax number")
|
||||
|
||||
# Inheritance flags (True = value is inherited from company, not overridden)
|
||||
contact_email_inherited: bool = Field(
|
||||
False, description="True if contact_email is from company"
|
||||
)
|
||||
contact_phone_inherited: bool = Field(
|
||||
False, description="True if contact_phone is from company"
|
||||
)
|
||||
website_inherited: bool = Field(
|
||||
False, description="True if website is from company"
|
||||
)
|
||||
business_address_inherited: bool = Field(
|
||||
False, description="True if business_address is from company"
|
||||
)
|
||||
tax_number_inherited: bool = Field(
|
||||
False, description="True if tax_number is from company"
|
||||
)
|
||||
|
||||
# Original company values (for reference in UI)
|
||||
company_contact_email: str | None = Field(
|
||||
None, description="Company's contact email"
|
||||
)
|
||||
company_contact_phone: str | None = Field(
|
||||
None, description="Company's phone number"
|
||||
)
|
||||
company_website: str | None = Field(None, description="Company's website URL")
|
||||
company_business_address: str | None = Field(
|
||||
None, description="Company's business address"
|
||||
)
|
||||
company_tax_number: str | None = Field(None, description="Company's tax number")
|
||||
|
||||
|
||||
class VendorCreateResponse(VendorDetailResponse):
|
||||
"""
|
||||
Response after creating vendor under an existing company.
|
||||
|
||||
The vendor is created under a company, so no new owner credentials are generated.
|
||||
The company owner already has access to this vendor.
|
||||
"""
|
||||
|
||||
login_url: str | None = Field(None, description="URL for vendor storefront")
|
||||
|
||||
|
||||
class VendorListResponse(BaseModel):
|
||||
"""Schema for paginated vendor list."""
|
||||
|
||||
vendors: list[VendorResponse]
|
||||
total: int
|
||||
skip: int
|
||||
limit: int
|
||||
|
||||
|
||||
class VendorSummary(BaseModel):
|
||||
"""Lightweight vendor summary for dropdowns and quick references."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
vendor_code: str
|
||||
subdomain: str
|
||||
name: str
|
||||
company_id: int
|
||||
is_active: bool
|
||||
|
||||
|
||||
# NOTE: Vendor ownership transfer schemas have been removed.
|
||||
# Ownership transfer is now handled at the Company level.
|
||||
# See models/schema/company.py for CompanyTransferOwnership and CompanyTransferOwnershipResponse.
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# LETZSHOP EXPORT SCHEMAS
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class LetzshopExportRequest(BaseModel):
|
||||
"""Request body for Letzshop export to pickup folder."""
|
||||
|
||||
include_inactive: bool = Field(
|
||||
default=False,
|
||||
description="Include inactive products in export"
|
||||
)
|
||||
|
||||
|
||||
class LetzshopExportFileInfo(BaseModel):
|
||||
"""Info about an exported file."""
|
||||
|
||||
language: str
|
||||
filename: str | None = None
|
||||
path: str | None = None
|
||||
size_bytes: int | None = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class LetzshopExportResponse(BaseModel):
|
||||
"""Response from Letzshop export to folder."""
|
||||
|
||||
success: bool
|
||||
message: str
|
||||
vendor_code: str
|
||||
export_directory: str
|
||||
files: list[LetzshopExportFileInfo]
|
||||
celery_task_id: str | None = None # Set when using Celery async export
|
||||
is_async: bool = Field(default=False, serialization_alias="async") # True when queued via Celery
|
||||
|
||||
model_config = {"populate_by_name": True}
|
||||
129
app/modules/tenancy/schemas/vendor_domain.py
Normal file
129
app/modules/tenancy/schemas/vendor_domain.py
Normal file
@@ -0,0 +1,129 @@
|
||||
# app/modules/tenancy/schemas/vendor_domain.py
|
||||
"""
|
||||
Pydantic schemas for Vendor Domain operations.
|
||||
|
||||
Schemas include:
|
||||
- VendorDomainCreate: For adding custom domains
|
||||
- VendorDomainUpdate: For updating domain settings
|
||||
- VendorDomainResponse: Standard domain response
|
||||
- VendorDomainListResponse: Paginated domain list
|
||||
- DomainVerificationInstructions: DNS verification instructions
|
||||
"""
|
||||
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
|
||||
class VendorDomainCreate(BaseModel):
|
||||
"""Schema for adding a custom domain to vendor."""
|
||||
|
||||
domain: str = Field(
|
||||
...,
|
||||
description="Custom domain (e.g., myshop.com or shop.mybrand.com)",
|
||||
min_length=3,
|
||||
max_length=255,
|
||||
)
|
||||
is_primary: bool = Field(
|
||||
default=False, description="Set as primary domain for the vendor"
|
||||
)
|
||||
|
||||
@field_validator("domain")
|
||||
@classmethod
|
||||
def validate_domain(cls, v: str) -> str:
|
||||
"""Validate and normalize domain."""
|
||||
# Remove protocol if present
|
||||
domain = v.replace("https://", "").replace("http://", "") # noqa: SEC-034
|
||||
|
||||
# Remove trailing slash
|
||||
domain = domain.rstrip("/")
|
||||
|
||||
# Convert to lowercase
|
||||
domain = domain.lower().strip()
|
||||
|
||||
# Basic validation
|
||||
if not domain or "/" in domain:
|
||||
raise ValueError("Invalid domain format")
|
||||
|
||||
if "." not in domain:
|
||||
raise ValueError("Domain must have at least one dot")
|
||||
|
||||
# Check for reserved subdomains
|
||||
reserved = ["www", "admin", "api", "mail", "smtp", "ftp", "cpanel", "webmail"]
|
||||
first_part = domain.split(".")[0]
|
||||
if first_part in reserved:
|
||||
raise ValueError(
|
||||
f"Domain cannot start with reserved subdomain: {first_part}"
|
||||
)
|
||||
|
||||
# Validate domain format (basic regex)
|
||||
domain_pattern = r"^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$"
|
||||
if not re.match(domain_pattern, domain):
|
||||
raise ValueError("Invalid domain format")
|
||||
|
||||
return domain
|
||||
|
||||
|
||||
class VendorDomainUpdate(BaseModel):
|
||||
"""Schema for updating vendor domain settings."""
|
||||
|
||||
is_primary: bool | None = Field(None, description="Set as primary domain")
|
||||
is_active: bool | None = Field(None, description="Activate or deactivate domain")
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class VendorDomainResponse(BaseModel):
|
||||
"""Standard schema for vendor domain response."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
vendor_id: int
|
||||
domain: str
|
||||
is_primary: bool
|
||||
is_active: bool
|
||||
is_verified: bool
|
||||
ssl_status: str
|
||||
verification_token: str | None = None
|
||||
verified_at: datetime | None = None
|
||||
ssl_verified_at: datetime | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class VendorDomainListResponse(BaseModel):
|
||||
"""Schema for paginated vendor domain list."""
|
||||
|
||||
domains: list[VendorDomainResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class DomainVerificationInstructions(BaseModel):
|
||||
"""DNS verification instructions for domain ownership."""
|
||||
|
||||
domain: str
|
||||
verification_token: str
|
||||
instructions: dict[str, str]
|
||||
txt_record: dict[str, str]
|
||||
common_registrars: dict[str, str]
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class DomainVerificationResponse(BaseModel):
|
||||
"""Response after domain verification."""
|
||||
|
||||
message: str
|
||||
domain: str
|
||||
verified_at: datetime
|
||||
is_verified: bool
|
||||
|
||||
|
||||
class DomainDeletionResponse(BaseModel):
|
||||
"""Response after domain deletion."""
|
||||
|
||||
message: str
|
||||
domain: str
|
||||
vendor_id: int
|
||||
@@ -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__)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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__)
|
||||
|
||||
|
||||
@@ -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__)
|
||||
|
||||
|
||||
@@ -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__)
|
||||
|
||||
|
||||
@@ -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__)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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__)
|
||||
|
||||
|
||||
@@ -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 ===');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 ===');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ===');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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 ===');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 ===');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
25
app/modules/tenancy/static/vendor/js/settings.js
vendored
25
app/modules/tenancy/static/vendor/js/settings.js
vendored
@@ -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 {
|
||||
|
||||
11
app/modules/tenancy/static/vendor/js/team.js
vendored
11
app/modules/tenancy/static/vendor/js/team.js
vendored
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user