diff --git a/app/api/deps.py b/app/api/deps.py index 1c80521a..ce273dce 100644 --- a/app/api/deps.py +++ b/app/api/deps.py @@ -39,7 +39,7 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from sqlalchemy.orm import Session from app.core.database import get_db -from app.exceptions import ( +from app.modules.tenancy.exceptions import ( AdminRequiredException, InsufficientPermissionsException, InsufficientVendorPermissionsException, @@ -48,7 +48,7 @@ from app.exceptions import ( VendorNotFoundException, VendorOwnerOnlyException, ) -from app.services.vendor_service import vendor_service +from app.modules.tenancy.services.vendor_service import vendor_service from middleware.auth import AuthManager from middleware.rate_limiter import RateLimiter from models.database.user import User as UserModel @@ -552,7 +552,7 @@ def require_menu_access(menu_item_id: str, frontend_type: "FrontendType"): """ from app.modules.registry import get_menu_item_module from app.modules.service import module_service - from app.services.menu_service import menu_service + from app.modules.core.services.menu_service import menu_service from models.database.admin_menu_config import FrontendType as FT def _check_menu_access( diff --git a/app/api/main.py b/app/api/main.py index ec2e48af..438a066f 100644 --- a/app/api/main.py +++ b/app/api/main.py @@ -5,13 +5,12 @@ API router configuration for multi-tenant ecommerce platform. This module provides: - API version 1 route aggregation - Route organization by user type (admin, vendor, storefront) -- Proper route prefixing and tagging +- Auto-discovery of module routes """ from fastapi import APIRouter -from app.api.v1 import admin, platform, storefront, vendor -from app.api.v1.shared import language, webhooks +from app.api.v1 import admin, public, storefront, vendor, webhooks api_router = APIRouter() @@ -38,16 +37,17 @@ api_router.include_router(vendor.router, prefix="/v1/vendor", tags=["vendor"]) api_router.include_router(storefront.router, prefix="/v1/storefront", tags=["storefront"]) # ============================================================================ -# PLATFORM ROUTES (Public marketing and signup) -# Prefix: /api/v1/platform +# PUBLIC ROUTES (Unauthenticated endpoints) +# Prefix: /api/v1/public +# Includes: /signup, /pricing, /letzshop-vendors, /language # ============================================================================ -api_router.include_router(platform.router, prefix="/v1/platform", tags=["platform"]) +api_router.include_router(public.router, prefix="/v1/public", tags=["public"]) # ============================================================================ -# SHARED ROUTES (Cross-context utilities) -# Prefix: /api/v1 +# WEBHOOK ROUTES (External service callbacks via auto-discovery) +# Prefix: /api/v1/webhooks +# Includes: /stripe # ============================================================================ -api_router.include_router(language.router, prefix="/v1", tags=["language"]) -api_router.include_router(webhooks.router, prefix="/v1", tags=["webhooks"]) +api_router.include_router(webhooks.router, prefix="/v1/webhooks", tags=["webhooks"]) diff --git a/app/api/v1/admin/__init__.py b/app/api/v1/admin/__init__.py index 6d9c381a..98b7a19d 100644 --- a/app/api/v1/admin/__init__.py +++ b/app/api/v1/admin/__init__.py @@ -2,68 +2,45 @@ """ Admin API router aggregation. -This module combines all admin-related JSON API endpoints: -- Authentication (login/logout) -- Vendor management (CRUD, bulk operations) -- Vendor domains management (custom domains, DNS verification) -- Vendor themes management (theme editor, presets) -- User management (status, roles) -- Dashboard and statistics -- Marketplace monitoring -- Audit logging -- Platform settings -- Notifications and alerts -- Code quality and architecture validation +This module combines legacy admin routes with auto-discovered module routes. + +LEGACY ROUTES (defined in app/api/v1/admin/): +- /menu-config/* - Navigation configuration (super admin) +- /modules/* - Module management (super admin) +- /module-config/* - Module settings (super admin) + +AUTO-DISCOVERED MODULE ROUTES: +- tenancy: auth, admin_users, users, companies, platforms, vendors, vendor_domains +- core: dashboard, settings +- messaging: messages, notifications, email-templates +- monitoring: logs, tasks, tests, code_quality, audit, platform-health +- billing: subscriptions, invoices, payments +- inventory: stock management +- orders: order management, fulfillment, exceptions +- marketplace: letzshop integration, product sync +- catalog: vendor product catalog +- cms: content-pages, images, media, vendor-themes +- customers: customer management IMPORTANT: - This router is for JSON API endpoints only -- HTML page routes are mounted separately in main.py at /vendor/* -- Do NOT include pages.router here - it causes route conflicts - -MODULE SYSTEM: -Routes can be module-gated using require_module_access() dependency. -For multi-tenant apps, module enablement is checked at request time -based on platform context (not at route registration time). - -Self-contained modules (auto-discovered from app/modules/{module}/routes/api/admin.py): -- billing: Subscription tiers, vendor billing, invoices -- inventory: Stock management, inventory tracking -- orders: Order management, fulfillment, exceptions -- marketplace: Letzshop integration, product sync, marketplace products -- catalog: Vendor product catalog management -- cms: Content pages management -- customers: Customer management +- HTML page routes are mounted separately in main.py +- Module routes are auto-discovered from app/modules/{module}/routes/api/admin.py """ from fastapi import APIRouter # Import all admin routers (legacy routes that haven't been migrated to modules) +# NOTE: Migrated to modules (auto-discovered): +# - tenancy: auth, admin_users, users, companies, platforms, vendors, vendor_domains +# - core: dashboard, settings +# - messaging: messages, notifications, email_templates +# - monitoring: logs, tasks, tests, code_quality, audit, platform_health +# - cms: content_pages, images, media, vendor_themes from . import ( - admin_users, - audit, - auth, - background_tasks, - code_quality, - companies, - dashboard, - email_templates, - images, - logs, - media, menu_config, - messages, module_config, modules, - monitoring, - notifications, - platform_health, - platforms, - settings, - tests, - users, - vendor_domains, - vendor_themes, - vendors, ) # Create admin router @@ -71,32 +48,9 @@ router = APIRouter() # ============================================================================ -# Authentication & Authorization +# Framework Config (remain in legacy - super admin only) # ============================================================================ -# Include authentication endpoints -router.include_router(auth.router, tags=["admin-auth"]) - - -# ============================================================================ -# Company & Vendor Management -# ============================================================================ - -# Include company management endpoints -router.include_router(companies.router, tags=["admin-companies"]) - -# Include vendor management endpoints -router.include_router(vendors.router, tags=["admin-vendors"]) - -# Include vendor domains management endpoints -router.include_router(vendor_domains.router, tags=["admin-vendor-domains"]) - -# Include vendor themes management endpoints -router.include_router(vendor_themes.router, tags=["admin-vendor-themes"]) - -# Include platforms management endpoints (multi-platform CMS) -router.include_router(platforms.router, tags=["admin-platforms"]) - # Include menu configuration endpoints (super admin only) router.include_router(menu_config.router, tags=["admin-menu-config"]) @@ -107,82 +61,13 @@ router.include_router(modules.router, tags=["admin-modules"]) router.include_router(module_config.router, tags=["admin-module-config"]) -# ============================================================================ -# User Management -# ============================================================================ - -# Include user management endpoints -router.include_router(users.router, tags=["admin-users"]) - -# Include admin user management endpoints (super admin only) -router.include_router(admin_users.router, tags=["admin-admin-users"]) - - -# ============================================================================ -# Dashboard & Statistics -# ============================================================================ - -# Include dashboard and statistics endpoints -router.include_router(dashboard.router, tags=["admin-dashboard"]) - - -# ============================================================================ -# Platform Administration -# ============================================================================ - -# Include background tasks monitoring endpoints -router.include_router( - background_tasks.router, prefix="/background-tasks", tags=["admin-background-tasks"] -) - -# Include audit logging endpoints -router.include_router(audit.router, tags=["admin-audit"]) - -# Include platform settings endpoints -router.include_router(settings.router, tags=["admin-settings"]) - -# Include notifications and alerts endpoints -router.include_router(notifications.router, tags=["admin-notifications"]) - -# Include messaging endpoints -router.include_router(messages.router, tags=["admin-messages"]) - -# Include email templates management endpoints -router.include_router(email_templates.router, tags=["admin-email-templates"]) - -# Include log management endpoints -router.include_router(logs.router, tags=["admin-logs"]) - -# Include image management endpoints -router.include_router(images.router, tags=["admin-images"]) - -# Include media library management endpoints -router.include_router(media.router, tags=["admin-media"]) - -# Include platform health endpoints -router.include_router( - platform_health.router, prefix="/platform", tags=["admin-platform-health"] -) - - -# ============================================================================ -# Code Quality & Architecture -# ============================================================================ - -# Include code quality and architecture validation endpoints -router.include_router( - code_quality.router, prefix="/code-quality", tags=["admin-code-quality"] -) - -# Include test runner endpoints -router.include_router(tests.router, prefix="/tests", tags=["admin-tests"]) - - # ============================================================================ # Auto-discovered Module Routes # ============================================================================ # Routes from self-contained modules are auto-discovered and registered. -# Modules include: billing, inventory, orders, marketplace, cms, customers +# Modules include: billing, inventory, orders, marketplace, cms, customers, +# monitoring (logs, tasks, tests, code_quality, audit, platform_health), +# messaging (messages, notifications, email_templates) from app.modules.routes import get_admin_api_routes diff --git a/app/api/v1/admin/menu_config.py b/app/api/v1/admin/menu_config.py index 3f84c7f8..f7c48fa9 100644 --- a/app/api/v1/admin/menu_config.py +++ b/app/api/v1/admin/menu_config.py @@ -27,8 +27,8 @@ from app.api.deps import ( get_current_super_admin, get_db, ) -from app.services.menu_service import MenuItemConfig, menu_service -from app.services.platform_service import platform_service +from app.modules.core.services.menu_service import MenuItemConfig, menu_service +from app.modules.tenancy.services.platform_service import platform_service from models.database.admin_menu_config import FrontendType # noqa: API-007 - Enum for type safety from models.schema.auth import UserContext diff --git a/app/api/v1/admin/module_config.py b/app/api/v1/admin/module_config.py index 9cd5436c..bdc1d643 100644 --- a/app/api/v1/admin/module_config.py +++ b/app/api/v1/admin/module_config.py @@ -21,7 +21,7 @@ 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.services.platform_service import platform_service +from app.modules.tenancy.services.platform_service import platform_service from models.schema.auth import UserContext logger = logging.getLogger(__name__) diff --git a/app/api/v1/admin/modules.py b/app/api/v1/admin/modules.py index 2a580387..374c9581 100644 --- a/app/api/v1/admin/modules.py +++ b/app/api/v1/admin/modules.py @@ -21,7 +21,7 @@ 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.services.platform_service import platform_service +from app.modules.tenancy.services.platform_service import platform_service from models.schema.auth import UserContext logger = logging.getLogger(__name__) @@ -263,7 +263,7 @@ async def enable_module( # Validate module code if request.module_code not in MODULES: - from app.exceptions import BadRequestException + from app.modules.tenancy.exceptions import BadRequestException raise BadRequestException(f"Unknown module: {request.module_code}") @@ -307,13 +307,13 @@ async def disable_module( # Validate module code if request.module_code not in MODULES: - from app.exceptions import BadRequestException + 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.exceptions import BadRequestException + from app.modules.tenancy.exceptions import BadRequestException raise BadRequestException(f"Cannot disable core module: {request.module_code}") diff --git a/app/api/v1/admin/monitoring.py b/app/api/v1/admin/monitoring.py deleted file mode 100644 index 0b5699d2..00000000 --- a/app/api/v1/admin/monitoring.py +++ /dev/null @@ -1 +0,0 @@ -# Platform monitoring and alerts diff --git a/app/api/v1/platform/__init__.py b/app/api/v1/platform/__init__.py deleted file mode 100644 index a618039b..00000000 --- a/app/api/v1/platform/__init__.py +++ /dev/null @@ -1,22 +0,0 @@ -# app/api/v1/platform/__init__.py -""" -Platform public API endpoints. - -These endpoints are publicly accessible (no authentication required) -and serve the marketing homepage, pricing pages, and signup flows. -""" - -from fastapi import APIRouter - -from app.api.v1.platform import pricing, letzshop_vendors, signup - -router = APIRouter() - -# Public pricing and tier info -router.include_router(pricing.router, tags=["platform-pricing"]) - -# Letzshop vendor lookup -router.include_router(letzshop_vendors.router, tags=["platform-vendors"]) - -# Signup flow -router.include_router(signup.router, tags=["platform-signup"]) diff --git a/app/api/v1/public/__init__.py b/app/api/v1/public/__init__.py new file mode 100644 index 00000000..9a5850a8 --- /dev/null +++ b/app/api/v1/public/__init__.py @@ -0,0 +1,38 @@ +# app/api/v1/public/__init__.py +""" +Public API endpoints (no authentication required). + +Includes: +- signup: /signup/* (multi-step signup flow - cross-cutting) + +Auto-discovers and aggregates public routes from self-contained modules: +- billing: /pricing/* (subscription tiers and add-ons) +- marketplace: /letzshop-vendors/* (vendor lookup for signup) +- core: /language/* (language preferences) + +These endpoints serve the marketing homepage, pricing pages, and signup flows. +""" + +from fastapi import APIRouter + +from app.api.v1.public import signup +from app.modules.routes import get_public_api_routes + +router = APIRouter() + +# Cross-cutting signup flow (spans auth, vendors, billing, payments) +router.include_router(signup.router, tags=["public-signup"]) + +# Auto-discover public routes from modules +for route_info in get_public_api_routes(): + if route_info.custom_prefix: + router.include_router( + route_info.router, + prefix=route_info.custom_prefix, + tags=route_info.tags, + ) + else: + router.include_router( + route_info.router, + tags=route_info.tags, + ) diff --git a/app/api/v1/platform/signup.py b/app/api/v1/public/signup.py similarity index 98% rename from app/api/v1/platform/signup.py rename to app/api/v1/public/signup.py index 3f63534b..ea0385e6 100644 --- a/app/api/v1/platform/signup.py +++ b/app/api/v1/public/signup.py @@ -1,4 +1,4 @@ -# app/api/v1/platform/signup.py +# app/api/v1/public/signup.py """ Platform signup API endpoints. @@ -20,7 +20,7 @@ from sqlalchemy.orm import Session from app.core.database import get_db from app.core.environment import should_use_secure_cookies -from app.services.platform_signup_service import platform_signup_service +from app.modules.marketplace.services.platform_signup_service import platform_signup_service router = APIRouter() logger = logging.getLogger(__name__) diff --git a/app/api/v1/shared/health.py b/app/api/v1/shared/health.py deleted file mode 100644 index 0b438ce2..00000000 --- a/app/api/v1/shared/health.py +++ /dev/null @@ -1 +0,0 @@ -# Health checks diff --git a/app/api/v1/shared/uploads.py b/app/api/v1/shared/uploads.py deleted file mode 100644 index e573f17b..00000000 --- a/app/api/v1/shared/uploads.py +++ /dev/null @@ -1 +0,0 @@ -# File upload handling diff --git a/app/api/v1/storefront/__init__.py b/app/api/v1/storefront/__init__.py index e5836d3d..900227fa 100644 --- a/app/api/v1/storefront/__init__.py +++ b/app/api/v1/storefront/__init__.py @@ -5,63 +5,48 @@ Storefront API router aggregation. This module aggregates all storefront-related JSON API endpoints (public facing). Uses vendor context from middleware - no vendor_id in URLs. -Endpoints: -- Products: Browse catalog, search products (catalog module) -- Cart: Shopping cart operations (cart module) -- Orders: Order history viewing (orders module) -- Checkout: Order placement (checkout module) -- Auth: Customer login, registration, password reset (customers module) -- Profile/Addresses: Customer profile management (customers module) -- Messages: Customer messaging (messaging module) -- Content Pages: CMS pages (cms module) +AUTO-DISCOVERED MODULE ROUTES: +- cart: Shopping cart operations +- catalog: Browse catalog, search products +- checkout: Order placement +- cms: Content pages +- customers: Auth, profile, addresses +- messaging: Customer messaging +- orders: Order history viewing Authentication: - Products, Cart, Content Pages: No auth required - Orders, Profile, Messages: Requires customer authentication - Auth: Public (login, register) -Note: Routes are now served from their respective modules. +Note: Routes are auto-discovered from app/modules/{module}/routes/api/storefront.py """ from fastapi import APIRouter -# Import module routers -from app.modules.cart.routes.api import storefront_router as cart_router -from app.modules.catalog.routes.api import storefront_router as catalog_router -from app.modules.checkout.routes.api import storefront_router as checkout_router -from app.modules.cms.routes.api.storefront import router as cms_storefront_router -from app.modules.customers.routes.api import storefront_router as customers_router -from app.modules.messaging.routes.api import storefront_router as messaging_router -from app.modules.orders.routes.api import storefront_router as orders_router - # Create storefront router router = APIRouter() # ============================================================================ -# STOREFRONT API ROUTES (All vendor-context aware via middleware) +# Auto-discovered Module Routes # ============================================================================ +# Routes from self-contained modules are auto-discovered and registered. +# Modules include: cart, catalog, checkout, cms, customers, messaging, orders -# Customer authentication and account management (customers module) -router.include_router(customers_router, tags=["storefront-auth", "storefront-profile", "storefront-addresses"]) +from app.modules.routes import get_storefront_api_routes -# Product catalog browsing (catalog module) -router.include_router(catalog_router, tags=["storefront-products"]) - -# Shopping cart (cart module) -router.include_router(cart_router, tags=["storefront-cart"]) - -# Order placement (checkout module) -router.include_router(checkout_router, tags=["storefront-checkout"]) - -# Order history viewing (orders module) -router.include_router(orders_router, tags=["storefront-orders"]) - -# Customer messaging (messaging module) -router.include_router(messaging_router, tags=["storefront-messages"]) - -# CMS content pages (cms module) -router.include_router( - cms_storefront_router, prefix="/content-pages", tags=["storefront-content-pages"] -) +for route_info in get_storefront_api_routes(): + # Only pass prefix if custom_prefix is set (router already has internal prefix) + if route_info.custom_prefix: + router.include_router( + route_info.router, + prefix=route_info.custom_prefix, + tags=route_info.tags, + ) + else: + router.include_router( + route_info.router, + tags=route_info.tags, + ) __all__ = ["router"] diff --git a/app/api/v1/webhooks/__init__.py b/app/api/v1/webhooks/__init__.py new file mode 100644 index 00000000..3161954b --- /dev/null +++ b/app/api/v1/webhooks/__init__.py @@ -0,0 +1,29 @@ +# app/api/v1/webhooks/__init__.py +""" +Webhook API endpoints for external service callbacks. + +Auto-discovers and aggregates webhook routes from self-contained modules: +- payments: /stripe (Stripe payment webhooks) + +Webhooks use signature verification for security, not user authentication. +""" + +from fastapi import APIRouter + +from app.modules.routes import get_webhooks_api_routes + +router = APIRouter() + +# Auto-discover webhook routes from modules +for route_info in get_webhooks_api_routes(): + if route_info.custom_prefix: + router.include_router( + route_info.router, + prefix=route_info.custom_prefix, + tags=route_info.tags, + ) + else: + router.include_router( + route_info.router, + tags=route_info.tags, + ) diff --git a/app/core/feature_gate.py b/app/core/feature_gate.py index c678e3e8..ee27554d 100644 --- a/app/core/feature_gate.py +++ b/app/core/feature_gate.py @@ -37,7 +37,7 @@ from sqlalchemy.orm import Session from app.api.deps import get_current_vendor_api from app.core.database import get_db -from app.services.feature_service import feature_service +from app.modules.billing.services.feature_service import feature_service from app.modules.billing.models import FeatureCode from models.database.user import User diff --git a/app/core/logging.py b/app/core/logging.py index b28adfe7..4bc2f7d4 100644 --- a/app/core/logging.py +++ b/app/core/logging.py @@ -102,7 +102,7 @@ def get_log_level_from_db(): """ try: from app.core.database import SessionLocal - from app.services.admin_settings_service import admin_settings_service + from app.modules.core.services.admin_settings_service import admin_settings_service db = SessionLocal() if not db: @@ -127,7 +127,7 @@ def get_rotation_settings_from_db(): """ try: from app.core.database import SessionLocal - from app.services.admin_settings_service import admin_settings_service + from app.modules.core.services.admin_settings_service import admin_settings_service db = SessionLocal() if not db: diff --git a/app/exceptions/__init__.py b/app/exceptions/__init__.py index 8656b180..051a987f 100644 --- a/app/exceptions/__init__.py +++ b/app/exceptions/__init__.py @@ -1,45 +1,32 @@ # app/exceptions/__init__.py """ -Custom exception classes for the API. +Base exception classes for the application. -This module provides frontend-friendly exceptions with consistent error codes, -messages, and HTTP status mappings. +This module provides only framework-level exceptions. Domain-specific exceptions +have been moved to their respective modules: + +- tenancy: VendorNotFoundException, CompanyNotFoundException, etc. +- orders: OrderNotFoundException, InvoiceNotFoundException, etc. +- inventory: InventoryNotFoundException, InsufficientInventoryException, etc. +- billing: TierNotFoundException, SubscriptionNotFoundException, etc. +- marketplace: ImportJobNotFoundException, MarketplaceProductNotFoundException, etc. +- messaging: ConversationNotFoundException, MessageNotFoundException, etc. +- customers: CustomerNotFoundException, AddressNotFoundException, etc. +- cart: CartItemNotFoundException, EmptyCartException, etc. +- catalog: ProductNotFoundException, ProductValidationException, etc. +- cms: ContentPageNotFoundException, MediaNotFoundException, etc. +- monitoring: ScanNotFoundException, ViolationNotFoundException, etc. + +Import pattern: + # Base exceptions (framework-level) + from app.exceptions import ValidationException, ResourceNotFoundException + + # Domain exceptions (module-level) + from app.modules.orders.exceptions import OrderNotFoundException + from app.modules.tenancy.exceptions import VendorNotFoundException """ -# Address exceptions -from .address import ( - AddressLimitExceededException, - AddressNotFoundException, - InvalidAddressTypeException, -) - -# Admin exceptions -from .admin import ( - AdminOperationException, - BulkOperationException, - CannotModifyAdminException, - CannotModifySelfException, - ConfirmationRequiredException, - InvalidAdminActionException, - UserCannotBeDeletedException, - UserNotFoundException, - UserRoleChangeException, - UserStatusChangeException, - VendorVerificationException, -) - -# Authentication exceptions -from .auth import ( - AdminRequiredException, - InsufficientPermissionsException, - InvalidCredentialsException, - InvalidTokenException, - TokenExpiredException, - UserAlreadyExistsException, - UserNotActiveException, -) - -# Base exceptions +# Base exceptions - these are the only exports from root from .base import ( AuthenticationException, AuthorizationException, @@ -53,425 +40,21 @@ from .base import ( WizamartException, ) -# Billing exceptions -from .billing import ( - InvalidWebhookSignatureException, - NoActiveSubscriptionException, - PaymentSystemNotConfiguredException, - StripePriceNotConfiguredException, - SubscriptionAlreadyCancelledException, - SubscriptionNotCancelledException, - TierNotFoundException, - WebhookMissingSignatureException, -) - -# Cart exceptions -from .cart import ( - CartItemNotFoundException, - CartValidationException, - EmptyCartException, - InsufficientInventoryForCartException, - InvalidCartQuantityException, - ProductNotAvailableForCartException, -) - -# Code quality exceptions -from .code_quality import ( - InvalidViolationStatusException, - ScanExecutionException, - ScanNotFoundException, - ScanParseException, - ScanTimeoutException, - ViolationNotFoundException, - ViolationOperationException, -) - -# Company exceptions -from .company import ( - CompanyAlreadyExistsException, - CompanyHasVendorsException, - CompanyNotActiveException, - CompanyNotFoundException, - CompanyNotVerifiedException, - CompanyValidationException, - InvalidCompanyDataException, - UnauthorizedCompanyAccessException, -) - -# Customer exceptions -from .customer import ( - CustomerAlreadyExistsException, - CustomerAuthorizationException, - CustomerNotActiveException, - CustomerNotFoundException, - CustomerValidationException, - DuplicateCustomerEmailException, - InvalidCustomerCredentialsException, -) - -# Invoice exceptions -from .invoice import ( - InvoiceNotFoundException, - InvoicePDFGenerationException, - InvoicePDFNotFoundException, - InvoiceSettingsAlreadyExistException, - InvoiceSettingsNotFoundException, - InvoiceValidationException, - InvalidInvoiceStatusTransitionException, - OrderNotFoundException as OrderNotFoundForInvoiceException, -) - -# Inventory exceptions -from .inventory import ( - InsufficientInventoryException, - InvalidInventoryOperationException, - InvalidQuantityException, - InventoryNotFoundException, - InventoryValidationException, - LocationNotFoundException, - NegativeInventoryException, -) - -# Message exceptions -from .message import ( - AttachmentNotFoundException, - ConversationClosedException, - ConversationNotFoundException, - InvalidConversationTypeException, - InvalidRecipientTypeException, - MessageAttachmentException, - MessageNotFoundException, - UnauthorizedConversationAccessException, -) - -# Marketplace import job exceptions -from .marketplace_import_job import ( - ImportJobAlreadyProcessingException, - ImportJobCannotBeCancelledException, - ImportJobCannotBeDeletedException, - ImportJobNotFoundException, - ImportJobNotOwnedException, - ImportRateLimitException, - InvalidImportDataException, - InvalidMarketplaceException, - MarketplaceConnectionException, - MarketplaceDataParsingException, - MarketplaceImportException, -) - -# Marketplace product exceptions -from .marketplace_product import ( - InvalidGTINException, - InvalidMarketplaceProductDataException, - MarketplaceProductAlreadyExistsException, - MarketplaceProductCSVImportException, - MarketplaceProductNotFoundException, - MarketplaceProductValidationException, -) - -# Order exceptions -from .order import ( - InvalidOrderStatusException, - OrderAlreadyExistsException, - OrderCannotBeCancelledException, - OrderNotFoundException, - OrderValidationException, -) - -# Order item exception exceptions -from .order_item_exception import ( - ExceptionAlreadyResolvedException, - InvalidProductForExceptionException, - OrderHasUnresolvedExceptionsException, - OrderItemExceptionNotFoundException, -) - -# Product exceptions -from .product import ( - CannotDeleteProductWithInventoryException, - CannotDeleteProductWithOrdersException, - InvalidProductDataException, - ProductAlreadyExistsException, - ProductNotActiveException, - ProductNotFoundException, - ProductNotInCatalogException, - ProductValidationException, -) - -# Team exceptions -from .team import ( - CannotModifyOwnRoleException, - CannotRemoveOwnerException, - InsufficientTeamPermissionsException, - InvalidInvitationDataException, - InvalidInvitationTokenException, - InvalidRoleException, - MaxTeamMembersReachedException, - RoleNotFoundException, - TeamInvitationAlreadyAcceptedException, - TeamInvitationExpiredException, - TeamInvitationNotFoundException, - TeamMemberAlreadyExistsException, - TeamMemberNotFoundException, - TeamValidationException, - UnauthorizedTeamActionException, -) - -# Vendor exceptions -from .vendor import ( - InsufficientVendorPermissionsException, - InvalidVendorDataException, - MaxVendorsReachedException, - UnauthorizedVendorAccessException, - VendorAccessDeniedException, - VendorAlreadyExistsException, - VendorNotActiveException, - VendorNotFoundException, - VendorNotVerifiedException, - VendorOwnerOnlyException, - VendorValidationException, -) - -# Vendor domain exceptions -from .vendor_domain import ( - DNSVerificationException, - DomainAlreadyVerifiedException, - DomainNotVerifiedException, - DomainVerificationFailedException, - InvalidDomainFormatException, - MaxDomainsReachedException, - MultiplePrimaryDomainsException, - ReservedDomainException, - UnauthorizedDomainAccessException, - VendorDomainAlreadyExistsException, - VendorDomainNotFoundException, -) - -# Vendor theme exceptions -from .vendor_theme import ( - InvalidColorFormatException, - InvalidFontFamilyException, - InvalidThemeDataException, - ThemeOperationException, - ThemePresetAlreadyAppliedException, - ThemePresetNotFoundException, - ThemeValidationException, - VendorThemeNotFoundException, -) - -# Onboarding exceptions -from .onboarding import ( - LetzshopConnectionFailedException, - OnboardingAlreadyCompletedException, - OnboardingCsvUrlRequiredException, - OnboardingNotFoundException, - OnboardingStepOrderException, - OnboardingSyncJobNotFoundException, - OnboardingSyncNotCompleteException, -) - -# Feature management exceptions -from .feature import ( - FeatureNotFoundError, - InvalidFeatureCodesError, - TierNotFoundError, -) - __all__ = [ - # Base exceptions + # Base exception class "WizamartException", + # Validation and business logic "ValidationException", + "BusinessLogicException", + # Authentication and authorization "AuthenticationException", "AuthorizationException", + # Resource operations "ResourceNotFoundException", "ConflictException", - "BusinessLogicException", + # External services "ExternalServiceException", - "RateLimitException", "ServiceUnavailableException", - # Auth exceptions - "InvalidCredentialsException", - "TokenExpiredException", - "InvalidTokenException", - "InsufficientPermissionsException", - "UserNotActiveException", - "AdminRequiredException", - "UserAlreadyExistsException", - # Customer exceptions - "CustomerNotFoundException", - "CustomerAlreadyExistsException", - "DuplicateCustomerEmailException", - "CustomerNotActiveException", - "InvalidCustomerCredentialsException", - "CustomerValidationException", - "CustomerAuthorizationException", - # Team exceptions - "TeamMemberNotFoundException", - "TeamMemberAlreadyExistsException", - "TeamInvitationNotFoundException", - "TeamInvitationExpiredException", - "TeamInvitationAlreadyAcceptedException", - "UnauthorizedTeamActionException", - "CannotRemoveOwnerException", - "CannotModifyOwnRoleException", - "RoleNotFoundException", - "InvalidRoleException", - "InsufficientTeamPermissionsException", - "MaxTeamMembersReachedException", - "TeamValidationException", - "InvalidInvitationDataException", - "InvalidInvitationTokenException", - # Invoice exceptions - "InvoiceNotFoundException", - "InvoiceSettingsNotFoundException", - "InvoiceSettingsAlreadyExistException", - "InvoiceValidationException", - "InvoicePDFGenerationException", - "InvoicePDFNotFoundException", - "InvalidInvoiceStatusTransitionException", - "OrderNotFoundForInvoiceException", - # Inventory exceptions - "InventoryNotFoundException", - "InsufficientInventoryException", - "InvalidInventoryOperationException", - "InventoryValidationException", - "NegativeInventoryException", - "InvalidQuantityException", - "LocationNotFoundException", - # Vendor exceptions - "InsufficientVendorPermissionsException", - "InvalidVendorDataException", - "MaxVendorsReachedException", - "UnauthorizedVendorAccessException", - "VendorAccessDeniedException", - "VendorAlreadyExistsException", - "VendorNotActiveException", - "VendorNotFoundException", - "VendorNotVerifiedException", - "VendorOwnerOnlyException", - "VendorValidationException", - # Vendor Domain - "VendorDomainNotFoundException", - "VendorDomainAlreadyExistsException", - "InvalidDomainFormatException", - "ReservedDomainException", - "DomainNotVerifiedException", - "DomainVerificationFailedException", - "DomainAlreadyVerifiedException", - "MultiplePrimaryDomainsException", - "DNSVerificationException", - "MaxDomainsReachedException", - "UnauthorizedDomainAccessException", - # Vendor Theme - "VendorThemeNotFoundException", - "InvalidThemeDataException", - "ThemePresetNotFoundException", - "ThemeValidationException", - "ThemePresetAlreadyAppliedException", - "InvalidColorFormatException", - "InvalidFontFamilyException", - "ThemeOperationException", - # Product exceptions - "ProductNotFoundException", - "ProductAlreadyExistsException", - "ProductNotInCatalogException", - "ProductNotActiveException", - "InvalidProductDataException", - "ProductValidationException", - "CannotDeleteProductWithInventoryException", - "CannotDeleteProductWithOrdersException", - # Order exceptions - "OrderNotFoundException", - "OrderAlreadyExistsException", - "OrderValidationException", - "InvalidOrderStatusException", - "OrderCannotBeCancelledException", - # Order item exception exceptions - "OrderItemExceptionNotFoundException", - "OrderHasUnresolvedExceptionsException", - "ExceptionAlreadyResolvedException", - "InvalidProductForExceptionException", - # Cart exceptions - "CartItemNotFoundException", - "EmptyCartException", - "CartValidationException", - "InsufficientInventoryForCartException", - "InvalidCartQuantityException", - "ProductNotAvailableForCartException", - # Company exceptions - "CompanyNotFoundException", - "CompanyAlreadyExistsException", - "CompanyNotActiveException", - "CompanyNotVerifiedException", - "UnauthorizedCompanyAccessException", - "InvalidCompanyDataException", - "CompanyValidationException", - "CompanyHasVendorsException", - # MarketplaceProduct exceptions - "MarketplaceProductNotFoundException", - "MarketplaceProductAlreadyExistsException", - "InvalidMarketplaceProductDataException", - "MarketplaceProductValidationException", - "InvalidGTINException", - "MarketplaceProductCSVImportException", - # Marketplace import exceptions - "MarketplaceImportException", - "ImportJobNotFoundException", - "ImportJobNotOwnedException", - "InvalidImportDataException", - "ImportJobCannotBeCancelledException", - "ImportJobCannotBeDeletedException", - "MarketplaceConnectionException", - "MarketplaceDataParsingException", - "ImportRateLimitException", - "InvalidMarketplaceException", - "ImportJobAlreadyProcessingException", - # Admin exceptions - "UserNotFoundException", - "UserStatusChangeException", - "VendorVerificationException", - "AdminOperationException", - "CannotModifyAdminException", - "CannotModifySelfException", - "InvalidAdminActionException", - "BulkOperationException", - "ConfirmationRequiredException", - # Code quality exceptions - "ViolationNotFoundException", - "ScanNotFoundException", - "ScanExecutionException", - "ScanTimeoutException", - "ScanParseException", - "ViolationOperationException", - "InvalidViolationStatusException", - # Message exceptions - "ConversationNotFoundException", - "MessageNotFoundException", - "ConversationClosedException", - "MessageAttachmentException", - "UnauthorizedConversationAccessException", - "InvalidConversationTypeException", - "InvalidRecipientTypeException", - "AttachmentNotFoundException", - # Billing exceptions - "PaymentSystemNotConfiguredException", - "TierNotFoundException", - "StripePriceNotConfiguredException", - "NoActiveSubscriptionException", - "SubscriptionNotCancelledException", - "SubscriptionAlreadyCancelledException", - "InvalidWebhookSignatureException", - "WebhookMissingSignatureException", - # Onboarding exceptions - "OnboardingNotFoundException", - "OnboardingStepOrderException", - "OnboardingAlreadyCompletedException", - "LetzshopConnectionFailedException", - "OnboardingCsvUrlRequiredException", - "OnboardingSyncJobNotFoundException", - "OnboardingSyncNotCompleteException", - # Feature exceptions - "FeatureNotFoundError", - "TierNotFoundError", - "InvalidFeatureCodesError", + # Rate limiting + "RateLimitException", ] diff --git a/app/exceptions/address.py b/app/exceptions/address.py deleted file mode 100644 index 188f8ef1..00000000 --- a/app/exceptions/address.py +++ /dev/null @@ -1,38 +0,0 @@ -# app/exceptions/address.py -""" -Address-related custom exceptions. - -Used for customer address management operations. -""" - -from .base import BusinessLogicException, ResourceNotFoundException - - -class AddressNotFoundException(ResourceNotFoundException): - """Raised when a customer address is not found.""" - - def __init__(self, address_id: str | int): - super().__init__( - resource_type="Address", - identifier=str(address_id), - ) - - -class AddressLimitExceededException(BusinessLogicException): - """Raised when customer exceeds maximum number of addresses.""" - - def __init__(self, max_addresses: int = 10): - super().__init__( - message=f"Maximum number of addresses ({max_addresses}) reached", - error_code="ADDRESS_LIMIT_EXCEEDED", - ) - - -class InvalidAddressTypeException(BusinessLogicException): - """Raised when an invalid address type is provided.""" - - def __init__(self, address_type: str): - super().__init__( - message=f"Invalid address type '{address_type}'. Must be 'shipping' or 'billing'", - error_code="INVALID_ADDRESS_TYPE", - ) diff --git a/app/exceptions/admin.py b/app/exceptions/admin.py deleted file mode 100644 index ee26fb6f..00000000 --- a/app/exceptions/admin.py +++ /dev/null @@ -1,248 +0,0 @@ -# app/exceptions/admin.py -""" -Admin operations specific exceptions. -""" - -from typing import Any - -from .base import ( - AuthorizationException, - BusinessLogicException, - ResourceNotFoundException, - ValidationException, -) - - -class UserNotFoundException(ResourceNotFoundException): - """Raised when user is not found in admin operations.""" - - def __init__(self, user_identifier: str, identifier_type: str = "ID"): - if identifier_type.lower() == "username": - message = f"User with username '{user_identifier}' not found" - elif identifier_type.lower() == "email": - message = f"User with email '{user_identifier}' not found" - else: - message = f"User with ID '{user_identifier}' not found" - - super().__init__( - resource_type="User", - identifier=user_identifier, - message=message, - error_code="USER_NOT_FOUND", - ) - - -class UserStatusChangeException(BusinessLogicException): - """Raised when user status cannot be changed.""" - - def __init__( - self, - user_id: int, - current_status: str, - attempted_action: str, - reason: str | None = None, - ): - message = f"Cannot {attempted_action} user {user_id} (current status: {current_status})" - if reason: - message += f": {reason}" - - super().__init__( - message=message, - error_code="USER_STATUS_CHANGE_FAILED", - details={ - "user_id": user_id, - "current_status": current_status, - "attempted_action": attempted_action, - "reason": reason, - }, - ) - - -class AdminOperationException(BusinessLogicException): - """Raised when admin operation fails.""" - - def __init__( - self, - operation: str, - reason: str, - target_type: str | None = None, - target_id: str | None = None, - ): - message = f"Admin operation '{operation}' failed: {reason}" - - details = { - "operation": operation, - "reason": reason, - } - - if target_type: - details["target_type"] = target_type - if target_id: - details["target_id"] = target_id - - super().__init__( - message=message, - error_code="ADMIN_OPERATION_FAILED", - details=details, - ) - - -class CannotModifyAdminException(AuthorizationException): - """Raised when trying to modify another admin user.""" - - def __init__(self, target_user_id: int, admin_user_id: int): - super().__init__( - message=f"Cannot modify admin user {target_user_id}", - error_code="CANNOT_MODIFY_ADMIN", - details={ - "target_user_id": target_user_id, - "admin_user_id": admin_user_id, - }, - ) - - -class CannotModifySelfException(BusinessLogicException): - """Raised when admin tries to modify their own status.""" - - def __init__(self, user_id: int, operation: str): - super().__init__( - message=f"Cannot perform '{operation}' on your own account", - error_code="CANNOT_MODIFY_SELF", - details={ - "user_id": user_id, - "operation": operation, - }, - ) - - -class InvalidAdminActionException(ValidationException): - """Raised when admin action is invalid.""" - - def __init__( - self, - action: str, - reason: str, - valid_actions: list | None = None, - ): - details = { - "action": action, - "reason": reason, - } - - if valid_actions: - details["valid_actions"] = valid_actions - - super().__init__( - message=f"Invalid admin action '{action}': {reason}", - details=details, - ) - self.error_code = "INVALID_ADMIN_ACTION" - - -class BulkOperationException(BusinessLogicException): - """Raised when bulk admin operation fails.""" - - def __init__( - self, - operation: str, - total_items: int, - failed_items: int, - errors: dict[str, Any] | None = None, - ): - message = f"Bulk {operation} completed with errors: {failed_items}/{total_items} failed" - - details = { - "operation": operation, - "total_items": total_items, - "failed_items": failed_items, - "success_items": total_items - failed_items, - } - - if errors: - details["errors"] = errors - - super().__init__( - message=message, - error_code="BULK_OPERATION_PARTIAL_FAILURE", - details=details, - ) - - -class ConfirmationRequiredException(BusinessLogicException): - """Raised when a destructive operation requires explicit confirmation.""" - - def __init__( - self, - operation: str, - message: str | None = None, - confirmation_param: str = "confirm", - ): - if not message: - message = f"Operation '{operation}' requires confirmation parameter: {confirmation_param}=true" - - super().__init__( - message=message, - error_code="CONFIRMATION_REQUIRED", - details={ - "operation": operation, - "confirmation_param": confirmation_param, - }, - ) - - -class VendorVerificationException(BusinessLogicException): - """Raised when vendor verification fails.""" - - def __init__( - self, - vendor_id: int, - reason: str, - current_verification_status: bool | None = None, - ): - details = { - "vendor_id": vendor_id, - "reason": reason, - } - - if current_verification_status is not None: - details["current_verification_status"] = current_verification_status - - super().__init__( - message=f"Vendor verification failed for vendor {vendor_id}: {reason}", - error_code="VENDOR_VERIFICATION_FAILED", - details=details, - ) - - -class UserCannotBeDeletedException(BusinessLogicException): - """Raised when a user cannot be deleted due to ownership constraints.""" - - def __init__(self, user_id: int, reason: str, owned_count: int = 0): - details = { - "user_id": user_id, - "reason": reason, - } - if owned_count > 0: - details["owned_companies_count"] = owned_count - - super().__init__( - message=f"Cannot delete user {user_id}: {reason}", - error_code="USER_CANNOT_BE_DELETED", - details=details, - ) - - -class UserRoleChangeException(BusinessLogicException): - """Raised when user role cannot be changed.""" - - def __init__(self, user_id: int, current_role: str, target_role: str, reason: str): - super().__init__( - message=f"Cannot change user {user_id} role from {current_role} to {target_role}: {reason}", - error_code="USER_ROLE_CHANGE_FAILED", - details={ - "user_id": user_id, - "current_role": current_role, - "target_role": target_role, - "reason": reason, - }, - ) diff --git a/app/exceptions/auth.py b/app/exceptions/auth.py deleted file mode 100644 index f92ab3a5..00000000 --- a/app/exceptions/auth.py +++ /dev/null @@ -1,94 +0,0 @@ -# app/exceptions/auth.py -""" -Authentication and authorization specific exceptions. -""" - -from .base import AuthenticationException, AuthorizationException, ConflictException - - -class InvalidCredentialsException(AuthenticationException): - """Raised when login credentials are invalid.""" - - def __init__(self, message: str = "Invalid username or password"): - super().__init__( - message=message, - error_code="INVALID_CREDENTIALS", - ) - - -class TokenExpiredException(AuthenticationException): - """Raised when JWT token has expired.""" - - def __init__(self, message: str = "Token has expired"): - super().__init__( - message=message, - error_code="TOKEN_EXPIRED", - ) - - -class InvalidTokenException(AuthenticationException): - """Raised when JWT token is invalid or malformed.""" - - def __init__(self, message: str = "Invalid token"): - super().__init__( - message=message, - error_code="INVALID_TOKEN", - ) - - -class InsufficientPermissionsException(AuthorizationException): - """Raised when user lacks required permissions for an action.""" - - def __init__( - self, - message: str = "Insufficient permissions for this action", - required_permission: str | None = None, - ): - details = {} - if required_permission: - details["required_permission"] = required_permission - - super().__init__( - message=message, - error_code="INSUFFICIENT_PERMISSIONS", - details=details, - ) - - -class UserNotActiveException(AuthorizationException): - """Raised when user account is not active.""" - - def __init__(self, message: str = "User account is not active"): - super().__init__( - message=message, - error_code="USER_NOT_ACTIVE", - ) - - -class AdminRequiredException(AuthorizationException): - """Raised when admin privileges are required.""" - - def __init__(self, message: str = "Admin privileges required"): - super().__init__( - message=message, - error_code="ADMIN_REQUIRED", - ) - - -class UserAlreadyExistsException(ConflictException): - """Raised when trying to register with existing username/email.""" - - def __init__( - self, - message: str = "User already exists", - field: str | None = None, - ): - details = {} - if field: - details["field"] = field - - super().__init__( - message=message, - error_code="USER_ALREADY_EXISTS", - details=details, - ) diff --git a/app/exceptions/backup.py b/app/exceptions/backup.py deleted file mode 100644 index 5d0229cb..00000000 --- a/app/exceptions/backup.py +++ /dev/null @@ -1 +0,0 @@ -# Backup/recovery exceptions diff --git a/app/exceptions/billing.py b/app/exceptions/billing.py deleted file mode 100644 index 6a893bc6..00000000 --- a/app/exceptions/billing.py +++ /dev/null @@ -1,95 +0,0 @@ -# app/exceptions/billing.py -""" -Billing and subscription related exceptions. - -This module provides exceptions for: -- Payment system configuration issues -- Subscription management errors -- Tier-related errors -""" - -from typing import Any - -from .base import BusinessLogicException, ResourceNotFoundException, ServiceUnavailableException - - -class PaymentSystemNotConfiguredException(ServiceUnavailableException): - """Raised when the payment system (Stripe) is not configured.""" - - def __init__(self): - super().__init__(message="Payment system not configured") - - -class TierNotFoundException(ResourceNotFoundException): - """Raised when a subscription tier is not found.""" - - def __init__(self, tier_code: str): - super().__init__( - resource_type="SubscriptionTier", - identifier=tier_code, - message=f"Subscription tier '{tier_code}' not found", - error_code="TIER_NOT_FOUND", - ) - self.tier_code = tier_code - - -class StripePriceNotConfiguredException(BusinessLogicException): - """Raised when Stripe price is not configured for a tier.""" - - def __init__(self, tier_code: str): - super().__init__( - message=f"Stripe price not configured for tier '{tier_code}'", - error_code="STRIPE_PRICE_NOT_CONFIGURED", - details={"tier_code": tier_code}, - ) - self.tier_code = tier_code - - -class NoActiveSubscriptionException(BusinessLogicException): - """Raised when no active subscription exists for an operation that requires one.""" - - def __init__(self, message: str = "No active subscription found"): - super().__init__( - message=message, - error_code="NO_ACTIVE_SUBSCRIPTION", - ) - - -class SubscriptionNotCancelledException(BusinessLogicException): - """Raised when trying to reactivate a subscription that is not cancelled.""" - - def __init__(self): - super().__init__( - message="Subscription is not cancelled", - error_code="SUBSCRIPTION_NOT_CANCELLED", - ) - - -class SubscriptionAlreadyCancelledException(BusinessLogicException): - """Raised when trying to cancel an already cancelled subscription.""" - - def __init__(self): - super().__init__( - message="Subscription is already cancelled", - error_code="SUBSCRIPTION_ALREADY_CANCELLED", - ) - - -class InvalidWebhookSignatureException(BusinessLogicException): - """Raised when Stripe webhook signature verification fails.""" - - def __init__(self, message: str = "Invalid webhook signature"): - super().__init__( - message=message, - error_code="INVALID_WEBHOOK_SIGNATURE", - ) - - -class WebhookMissingSignatureException(BusinessLogicException): - """Raised when Stripe webhook is missing the signature header.""" - - def __init__(self): - super().__init__( - message="Missing Stripe-Signature header", - error_code="WEBHOOK_MISSING_SIGNATURE", - ) diff --git a/app/exceptions/cart.py b/app/exceptions/cart.py deleted file mode 100644 index 502a9e40..00000000 --- a/app/exceptions/cart.py +++ /dev/null @@ -1,107 +0,0 @@ -# app/exceptions/cart.py -""" -Shopping cart specific exceptions. -""" - -from .base import BusinessLogicException, ResourceNotFoundException, ValidationException - - -class CartItemNotFoundException(ResourceNotFoundException): - """Raised when a cart item is not found.""" - - def __init__(self, product_id: int, session_id: str): - super().__init__( - resource_type="CartItem", - identifier=str(product_id), - message=f"Product {product_id} not found in cart", - error_code="CART_ITEM_NOT_FOUND", - ) - self.details.update({"product_id": product_id, "session_id": session_id}) - - -class EmptyCartException(ValidationException): - """Raised when trying to perform operations on an empty cart.""" - - def __init__(self, session_id: str): - super().__init__(message="Cart is empty", details={"session_id": session_id}) - self.error_code = "CART_EMPTY" - - -class CartValidationException(ValidationException): - """Raised when cart data validation fails.""" - - def __init__( - self, - message: str = "Cart validation failed", - field: str | None = None, - details: dict | None = None, - ): - super().__init__( - message=message, - field=field, - details=details, - ) - self.error_code = "CART_VALIDATION_FAILED" - - -class InsufficientInventoryForCartException(BusinessLogicException): - """Raised when product doesn't have enough inventory for cart operation.""" - - def __init__( - self, - product_id: int, - product_name: str, - requested: int, - available: int, - ): - message = f"Insufficient inventory for product '{product_name}'. Requested: {requested}, Available: {available}" - - super().__init__( - message=message, - error_code="INSUFFICIENT_INVENTORY_FOR_CART", - details={ - "product_id": product_id, - "product_name": product_name, - "requested_quantity": requested, - "available_quantity": available, - }, - ) - - -class InvalidCartQuantityException(ValidationException): - """Raised when cart quantity is invalid.""" - - def __init__( - self, quantity: int, min_quantity: int = 1, max_quantity: int | None = None - ): - if quantity < min_quantity: - message = f"Quantity must be at least {min_quantity}" - elif max_quantity and quantity > max_quantity: - message = f"Quantity cannot exceed {max_quantity}" - else: - message = f"Invalid quantity: {quantity}" - - super().__init__( - message=message, - field="quantity", - details={ - "quantity": quantity, - "min_quantity": min_quantity, - "max_quantity": max_quantity, - }, - ) - self.error_code = "INVALID_CART_QUANTITY" - - -class ProductNotAvailableForCartException(BusinessLogicException): - """Raised when product is not available for adding to cart.""" - - def __init__(self, product_id: int, reason: str): - super().__init__( - message=f"Product {product_id} cannot be added to cart: {reason}", - error_code="PRODUCT_NOT_AVAILABLE_FOR_CART", - details={ - "product_id": product_id, - "reason": reason, - }, - ) diff --git a/app/exceptions/code_quality.py b/app/exceptions/code_quality.py deleted file mode 100644 index a11e5f02..00000000 --- a/app/exceptions/code_quality.py +++ /dev/null @@ -1,98 +0,0 @@ -# app/exceptions/code_quality.py -""" -Code Quality Domain Exceptions - -These exceptions are raised by the code quality service layer -and converted to HTTP responses by the global exception handler. - -Note: These exceptions are also re-exported from app.modules.dev_tools.exceptions -for module-based access. -""" - -from app.exceptions.base import ( - BusinessLogicException, - ExternalServiceException, - ResourceNotFoundException, - ValidationException, -) - - -class ViolationNotFoundException(ResourceNotFoundException): - """Raised when a violation is not found.""" - - def __init__(self, violation_id: int): - super().__init__( - resource_type="Violation", - identifier=str(violation_id), - error_code="VIOLATION_NOT_FOUND", - ) - - -class ScanNotFoundException(ResourceNotFoundException): - """Raised when a scan is not found.""" - - def __init__(self, scan_id: int): - super().__init__( - resource_type="Scan", - identifier=str(scan_id), - error_code="SCAN_NOT_FOUND", - ) - - -class ScanExecutionException(ExternalServiceException): - """Raised when architecture scan execution fails.""" - - def __init__(self, reason: str): - super().__init__( - service_name="ArchitectureValidator", - message=f"Scan execution failed: {reason}", - error_code="SCAN_EXECUTION_FAILED", - ) - - -class ScanTimeoutException(ExternalServiceException): - """Raised when architecture scan times out.""" - - def __init__(self, timeout_seconds: int = 300): - super().__init__( - service_name="ArchitectureValidator", - message=f"Scan timed out after {timeout_seconds} seconds", - error_code="SCAN_TIMEOUT", - ) - - -class ScanParseException(BusinessLogicException): - """Raised when scan results cannot be parsed.""" - - def __init__(self, reason: str): - super().__init__( - message=f"Failed to parse scan results: {reason}", - error_code="SCAN_PARSE_FAILED", - ) - - -class ViolationOperationException(BusinessLogicException): - """Raised when a violation operation fails.""" - - def __init__(self, operation: str, violation_id: int, reason: str): - super().__init__( - message=f"Failed to {operation} violation {violation_id}: {reason}", - error_code="VIOLATION_OPERATION_FAILED", - details={ - "operation": operation, - "violation_id": violation_id, - "reason": reason, - }, - ) - - -class InvalidViolationStatusException(ValidationException): - """Raised when a violation status transition is invalid.""" - - def __init__(self, violation_id: int, current_status: str, target_status: str): - super().__init__( - message=f"Cannot change violation {violation_id} from '{current_status}' to '{target_status}'", - field="status", - value=target_status, - ) - self.error_code = "INVALID_VIOLATION_STATUS" diff --git a/app/exceptions/company.py b/app/exceptions/company.py deleted file mode 100644 index 491e4e14..00000000 --- a/app/exceptions/company.py +++ /dev/null @@ -1,128 +0,0 @@ -# app/exceptions/company.py -""" -Company management specific exceptions. -""" - -from typing import Any - -from .base import ( - AuthorizationException, - BusinessLogicException, - ConflictException, - ResourceNotFoundException, - ValidationException, -) - - -class CompanyNotFoundException(ResourceNotFoundException): - """Raised when a company is not found.""" - - def __init__(self, company_identifier: str | int, identifier_type: str = "id"): - if identifier_type.lower() == "id": - message = f"Company with ID '{company_identifier}' not found" - else: - message = f"Company with name '{company_identifier}' not found" - - super().__init__( - resource_type="Company", - identifier=str(company_identifier), - message=message, - error_code="COMPANY_NOT_FOUND", - ) - - -class CompanyAlreadyExistsException(ConflictException): - """Raised when trying to create a company that already exists.""" - - def __init__(self, company_name: str): - super().__init__( - message=f"Company with name '{company_name}' already exists", - error_code="COMPANY_ALREADY_EXISTS", - details={"company_name": company_name}, - ) - - -class CompanyNotActiveException(BusinessLogicException): - """Raised when trying to perform operations on inactive company.""" - - def __init__(self, company_id: int): - super().__init__( - message=f"Company with ID '{company_id}' is not active", - error_code="COMPANY_NOT_ACTIVE", - details={"company_id": company_id}, - ) - - -class CompanyNotVerifiedException(BusinessLogicException): - """Raised when trying to perform operations requiring verified company.""" - - def __init__(self, company_id: int): - super().__init__( - message=f"Company with ID '{company_id}' is not verified", - error_code="COMPANY_NOT_VERIFIED", - details={"company_id": company_id}, - ) - - -class UnauthorizedCompanyAccessException(AuthorizationException): - """Raised when user tries to access company they don't own.""" - - def __init__(self, company_id: int, user_id: int | None = None): - details = {"company_id": company_id} - if user_id: - details["user_id"] = user_id - - super().__init__( - message=f"Unauthorized access to company with ID '{company_id}'", - error_code="UNAUTHORIZED_COMPANY_ACCESS", - details=details, - ) - - -class InvalidCompanyDataException(ValidationException): - """Raised when company data is invalid or incomplete.""" - - def __init__( - self, - message: str = "Invalid company data", - field: str | None = None, - details: dict[str, Any] | None = None, - ): - super().__init__( - message=message, - field=field, - details=details, - ) - self.error_code = "INVALID_COMPANY_DATA" - - -class CompanyValidationException(ValidationException): - """Raised when company validation fails.""" - - def __init__( - self, - message: str = "Company validation failed", - field: str | None = None, - validation_errors: dict[str, str] | None = None, - ): - details = {} - if validation_errors: - details["validation_errors"] = validation_errors - - super().__init__( - message=message, - field=field, - details=details, - ) - self.error_code = "COMPANY_VALIDATION_FAILED" - - -class CompanyHasVendorsException(BusinessLogicException): - """Raised when trying to delete a company that still has active vendors.""" - - def __init__(self, company_id: int, vendor_count: int): - super().__init__( - message=f"Cannot delete company with ID '{company_id}' because it has {vendor_count} associated vendor(s)", - error_code="COMPANY_HAS_VENDORS", - details={"company_id": company_id, "vendor_count": vendor_count}, - ) diff --git a/app/exceptions/customer.py b/app/exceptions/customer.py deleted file mode 100644 index 892d8d71..00000000 --- a/app/exceptions/customer.py +++ /dev/null @@ -1,116 +0,0 @@ -# app/exceptions/customer.py -""" -Customer management specific exceptions. -""" - -from typing import Any - -from .base import ( - AuthenticationException, - BusinessLogicException, - ConflictException, - ResourceNotFoundException, - ValidationException, -) - - -class CustomerNotFoundException(ResourceNotFoundException): - """Raised when a customer is not found.""" - - def __init__(self, customer_identifier: str): - super().__init__( - resource_type="Customer", - identifier=customer_identifier, - message=f"Customer '{customer_identifier}' not found", - error_code="CUSTOMER_NOT_FOUND", - ) - - -class CustomerAlreadyExistsException(ConflictException): - """Raised when trying to create a customer that already exists.""" - - def __init__(self, email: str): - super().__init__( - message=f"Customer with email '{email}' already exists", - error_code="CUSTOMER_ALREADY_EXISTS", - details={"email": email}, - ) - - -class DuplicateCustomerEmailException(ConflictException): - """Raised when email already exists for vendor.""" - - def __init__(self, email: str, vendor_code: str): - super().__init__( - message=f"Email '{email}' is already registered for this vendor", - error_code="DUPLICATE_CUSTOMER_EMAIL", - details={"email": email, "vendor_code": vendor_code}, - ) - - -class CustomerNotActiveException(BusinessLogicException): - """Raised when trying to perform operations on inactive customer.""" - - def __init__(self, email: str): - super().__init__( - message=f"Customer account '{email}' is not active", - error_code="CUSTOMER_NOT_ACTIVE", - details={"email": email}, - ) - - -class InvalidCustomerCredentialsException(AuthenticationException): - """Raised when customer credentials are invalid.""" - - def __init__(self): - super().__init__( - message="Invalid email or password", - error_code="INVALID_CUSTOMER_CREDENTIALS", - ) - - -class CustomerValidationException(ValidationException): - """Raised when customer data validation fails.""" - - def __init__( - self, - message: str = "Customer validation failed", - field: str | None = None, - details: dict[str, Any] | None = None, - ): - super().__init__(message=message, field=field, details=details) - self.error_code = "CUSTOMER_VALIDATION_FAILED" - - -class CustomerAuthorizationException(BusinessLogicException): - """Raised when customer is not authorized for operation.""" - - def __init__(self, customer_email: str, operation: str): - super().__init__( - message=f"Customer '{customer_email}' not authorized for: {operation}", - error_code="CUSTOMER_NOT_AUTHORIZED", - details={"customer_email": customer_email, "operation": operation}, - ) - - -class InvalidPasswordResetTokenException(ValidationException): - """Raised when password reset token is invalid or expired.""" - - def __init__(self): - super().__init__( - message="Invalid or expired password reset link. Please request a new one.", - field="reset_token", - ) - self.error_code = "INVALID_RESET_TOKEN" - - -class PasswordTooShortException(ValidationException): - """Raised when password doesn't meet minimum length requirement.""" - - def __init__(self, min_length: int = 8): - super().__init__( - message=f"Password must be at least {min_length} characters long", - field="password", - details={"min_length": min_length}, - ) - self.error_code = "PASSWORD_TOO_SHORT" diff --git a/app/exceptions/feature.py b/app/exceptions/feature.py deleted file mode 100644 index 3df4988e..00000000 --- a/app/exceptions/feature.py +++ /dev/null @@ -1,42 +0,0 @@ -# app/exceptions/feature.py -""" -Feature management exceptions. -""" - -from app.exceptions.base import ResourceNotFoundException, ValidationException - - -class FeatureNotFoundError(ResourceNotFoundException): - """Feature not found.""" - - def __init__(self, feature_code: str): - super().__init__( - resource_type="Feature", - identifier=feature_code, - message=f"Feature '{feature_code}' not found", - ) - self.feature_code = feature_code - - -class TierNotFoundError(ResourceNotFoundException): - """Subscription tier not found.""" - - def __init__(self, tier_code: str): - super().__init__( - resource_type="SubscriptionTier", - identifier=tier_code, - message=f"Tier '{tier_code}' not found", - ) - self.tier_code = tier_code - - -class InvalidFeatureCodesError(ValidationException): - """Invalid feature codes provided.""" - - def __init__(self, invalid_codes: set[str]): - codes_str = ", ".join(sorted(invalid_codes)) - super().__init__( - message=f"Invalid feature codes: {codes_str}", - details={"invalid_codes": list(invalid_codes)}, - ) - self.invalid_codes = invalid_codes diff --git a/app/exceptions/inventory.py b/app/exceptions/inventory.py deleted file mode 100644 index e62c73a8..00000000 --- a/app/exceptions/inventory.py +++ /dev/null @@ -1,135 +0,0 @@ -# app/exceptions/inventory.py -""" -Inventory management specific exceptions. -""" - -from typing import Any - -from .base import BusinessLogicException, ResourceNotFoundException, ValidationException - - -class InventoryNotFoundException(ResourceNotFoundException): - """Raised when inventory record is not found.""" - - def __init__(self, identifier: str, identifier_type: str = "ID"): - if identifier_type.lower() == "gtin": - message = f"No inventory found for GTIN '{identifier}'" - else: - message = ( - f"Inventory record with {identifier_type} '{identifier}' not found" - ) - - super().__init__( - resource_type="Inventory", - identifier=identifier, - message=message, - error_code="INVENTORY_NOT_FOUND", - ) - - -class InsufficientInventoryException(BusinessLogicException): - """Raised when trying to remove more inventory than available.""" - - def __init__( - self, - gtin: str, - location: str, - requested: int, - available: int, - ): - message = f"Insufficient inventory for GTIN '{gtin}' at '{location}'. Requested: {requested}, Available: {available}" - - super().__init__( - message=message, - error_code="INSUFFICIENT_INVENTORY", - details={ - "gtin": gtin, - "location": location, - "requested_quantity": requested, - "available_quantity": available, - }, - ) - - -class InvalidInventoryOperationException(ValidationException): - """Raised when inventory operation is invalid.""" - - def __init__( - self, - message: str, - operation: str | None = None, - details: dict[str, Any] | None = None, - ): - if not details: - details = {} - - if operation: - details["operation"] = operation - - super().__init__( - message=message, - details=details, - ) - self.error_code = "INVALID_INVENTORY_OPERATION" - - -class InventoryValidationException(ValidationException): - """Raised when inventory data validation fails.""" - - def __init__( - self, - message: str = "Inventory validation failed", - field: str | None = None, - validation_errors: dict[str, str] | None = None, - ): - details = {} - if validation_errors: - details["validation_errors"] = validation_errors - - super().__init__( - message=message, - field=field, - details=details, - ) - self.error_code = "INVENTORY_VALIDATION_FAILED" - - -class NegativeInventoryException(BusinessLogicException): - """Raised when inventory quantity would become negative.""" - - def __init__(self, gtin: str, location: str, resulting_quantity: int): - message = f"Inventory operation would result in negative quantity ({resulting_quantity}) for GTIN '{gtin}' at '{location}'" - - super().__init__( - message=message, - error_code="NEGATIVE_INVENTORY_NOT_ALLOWED", - details={ - "gtin": gtin, - "location": location, - "resulting_quantity": resulting_quantity, - }, - ) - - -class InvalidQuantityException(ValidationException): - """Raised when quantity value is invalid.""" - - def __init__(self, quantity: Any, message: str = "Invalid quantity"): - super().__init__( - message=f"{message}: {quantity}", - field="quantity", - details={"quantity": quantity}, - ) - self.error_code = "INVALID_QUANTITY" - - -class LocationNotFoundException(ResourceNotFoundException): - """Raised when inventory location is not found.""" - - def __init__(self, location: str): - super().__init__( - resource_type="Location", - identifier=location, - message=f"Inventory location '{location}' not found", - error_code="LOCATION_NOT_FOUND", - ) diff --git a/app/exceptions/invoice.py b/app/exceptions/invoice.py deleted file mode 100644 index 7ed3df1d..00000000 --- a/app/exceptions/invoice.py +++ /dev/null @@ -1,118 +0,0 @@ -# app/exceptions/invoice.py -""" -Invoice-related exceptions. - -This module provides exception classes for invoice operations including: -- Invoice not found errors -- Invoice settings validation -- PDF generation errors -- Invoice status transitions -""" - -from typing import Any - -from .base import BusinessLogicException, ResourceNotFoundException, WizamartException - - -class InvoiceNotFoundException(ResourceNotFoundException): - """Raised when an invoice is not found.""" - - def __init__(self, invoice_id: int | str): - super().__init__( - resource_type="Invoice", - identifier=str(invoice_id), - error_code="INVOICE_NOT_FOUND", - ) - - -class InvoiceSettingsNotFoundException(ResourceNotFoundException): - """Raised when invoice settings are not found for a vendor.""" - - def __init__(self, vendor_id: int): - super().__init__( - resource_type="InvoiceSettings", - identifier=str(vendor_id), - message="Invoice settings not found. Create settings first.", - error_code="INVOICE_SETTINGS_NOT_FOUND", - ) - - -class InvoiceSettingsAlreadyExistException(WizamartException): - """Raised when trying to create invoice settings that already exist.""" - - def __init__(self, vendor_id: int): - super().__init__( - message=f"Invoice settings already exist for vendor {vendor_id}", - error_code="INVOICE_SETTINGS_ALREADY_EXIST", - status_code=409, - details={"vendor_id": vendor_id}, - ) - - -class InvoiceValidationException(BusinessLogicException): - """Raised when invoice data validation fails.""" - - def __init__(self, message: str, details: dict[str, Any] | None = None): - super().__init__( - message=message, - error_code="INVOICE_VALIDATION_ERROR", - details=details, - ) - - -class InvoicePDFGenerationException(WizamartException): - """Raised when PDF generation fails.""" - - def __init__(self, invoice_id: int, reason: str): - super().__init__( - message=f"Failed to generate PDF for invoice {invoice_id}: {reason}", - error_code="INVOICE_PDF_GENERATION_FAILED", - status_code=500, - details={"invoice_id": invoice_id, "reason": reason}, - ) - - -class InvoicePDFNotFoundException(ResourceNotFoundException): - """Raised when invoice PDF file is not found.""" - - def __init__(self, invoice_id: int): - super().__init__( - resource_type="InvoicePDF", - identifier=str(invoice_id), - message="PDF file not found. Generate the PDF first.", - error_code="INVOICE_PDF_NOT_FOUND", - ) - - -class InvalidInvoiceStatusTransitionException(BusinessLogicException): - """Raised when an invalid invoice status transition is attempted.""" - - def __init__( - self, - current_status: str, - new_status: str, - reason: str | None = None, - ): - message = f"Cannot change invoice status from '{current_status}' to '{new_status}'" - if reason: - message += f": {reason}" - - super().__init__( - message=message, - error_code="INVALID_INVOICE_STATUS_TRANSITION", - details={ - "current_status": current_status, - "new_status": new_status, - }, - ) - - -class OrderNotFoundException(ResourceNotFoundException): - """Raised when an order for invoice creation is not found.""" - - def __init__(self, order_id: int): - super().__init__( - resource_type="Order", - identifier=str(order_id), - error_code="ORDER_NOT_FOUND_FOR_INVOICE", - ) diff --git a/app/exceptions/marketplace.py b/app/exceptions/marketplace.py deleted file mode 100644 index 28f96110..00000000 --- a/app/exceptions/marketplace.py +++ /dev/null @@ -1 +0,0 @@ -# Import/marketplace exceptions diff --git a/app/exceptions/marketplace_import_job.py b/app/exceptions/marketplace_import_job.py deleted file mode 100644 index abf5a9e3..00000000 --- a/app/exceptions/marketplace_import_job.py +++ /dev/null @@ -1,203 +0,0 @@ -# app/exceptions/marketplace_import_job.py -""" -Marketplace import specific exceptions. -""" - -from typing import Any - -from .base import ( - AuthorizationException, - BusinessLogicException, - ExternalServiceException, - ResourceNotFoundException, - ValidationException, -) - - -class MarketplaceImportException(BusinessLogicException): - """Base exception for marketplace import operations.""" - - def __init__( - self, - message: str, - error_code: str = "MARKETPLACE_IMPORT_ERROR", - marketplace: str | None = None, - details: dict[str, Any] | None = None, - ): - if not details: - details = {} - - if marketplace: - details["marketplace"] = marketplace - - super().__init__( - message=message, - error_code=error_code, - details=details, - ) - - -class ImportJobNotFoundException(ResourceNotFoundException): - """Raised when import job is not found.""" - - def __init__(self, job_id: int): - super().__init__( - resource_type="ImportJob", - identifier=str(job_id), - message=f"Import job with ID '{job_id}' not found", - error_code="IMPORT_JOB_NOT_FOUND", - ) - - -class ImportJobNotOwnedException(AuthorizationException): - """Raised when user tries to access import job they don't own.""" - - def __init__(self, job_id: int, user_id: int | None = None): - details = {"job_id": job_id} - if user_id: - details["user_id"] = user_id - - super().__init__( - message=f"Unauthorized access to import job '{job_id}'", - error_code="IMPORT_JOB_NOT_OWNED", - details=details, - ) - - -class InvalidImportDataException(ValidationException): - """Raised when import data is invalid.""" - - def __init__( - self, - message: str = "Invalid import data", - field: str | None = None, - row_number: int | None = None, - details: dict[str, Any] | None = None, - ): - if not details: - details = {} - - if row_number: - details["row_number"] = row_number - - super().__init__( - message=message, - field=field, - details=details, - ) - self.error_code = "INVALID_IMPORT_DATA" - - -class ImportJobCannotBeCancelledException(BusinessLogicException): - """Raised when trying to cancel job that cannot be cancelled.""" - - def __init__(self, job_id: int, current_status: str): - super().__init__( - message=f"Import job '{job_id}' cannot be cancelled (current status: {current_status})", - error_code="IMPORT_JOB_CANNOT_BE_CANCELLED", - details={ - "job_id": job_id, - "current_status": current_status, - }, - ) - - -class ImportJobCannotBeDeletedException(BusinessLogicException): - """Raised when trying to delete job that cannot be deleted.""" - - def __init__(self, job_id: int, current_status: str): - super().__init__( - message=f"Import job '{job_id}' cannot be deleted (current status: {current_status})", - error_code="IMPORT_JOB_CANNOT_BE_DELETED", - details={ - "job_id": job_id, - "current_status": current_status, - }, - ) - - -class MarketplaceConnectionException(ExternalServiceException): - """Raised when marketplace connection fails.""" - - def __init__( - self, marketplace: str, message: str = "Failed to connect to marketplace" - ): - super().__init__( - service=marketplace, - message=f"{message}: {marketplace}", - error_code="MARKETPLACE_CONNECTION_FAILED", - ) - - -class MarketplaceDataParsingException(ValidationException): - """Raised when marketplace data cannot be parsed.""" - - def __init__( - self, - marketplace: str, - message: str = "Failed to parse marketplace data", - details: dict[str, Any] | None = None, - ): - if not details: - details = {} - details["marketplace"] = marketplace - - super().__init__( - message=f"{message} from {marketplace}", - details=details, - ) - self.error_code = "MARKETPLACE_DATA_PARSING_FAILED" - - -class ImportRateLimitException(BusinessLogicException): - """Raised when import rate limit is exceeded.""" - - def __init__( - self, - max_imports: int, - time_window: str, - retry_after: int | None = None, - ): - details = { - "max_imports": max_imports, - "time_window": time_window, - } - - if retry_after: - details["retry_after"] = retry_after - - super().__init__( - message=f"Import rate limit exceeded: {max_imports} imports per {time_window}", - error_code="IMPORT_RATE_LIMIT_EXCEEDED", - details=details, - ) - - -class InvalidMarketplaceException(ValidationException): - """Raised when marketplace is not supported.""" - - def __init__(self, marketplace: str, supported_marketplaces: list | None = None): - details = {"marketplace": marketplace} - if supported_marketplaces: - details["supported_marketplaces"] = supported_marketplaces - - super().__init__( - message=f"Unsupported marketplace: {marketplace}", - field="marketplace", - details=details, - ) - self.error_code = "INVALID_MARKETPLACE" - - -class ImportJobAlreadyProcessingException(BusinessLogicException): - """Raised when trying to start import while another is already processing.""" - - def __init__(self, vendor_code: str, existing_job_id: int): - super().__init__( - message=f"Import already in progress for vendor '{vendor_code}'", - error_code="IMPORT_JOB_ALREADY_PROCESSING", - details={ - "vendor_code": vendor_code, - "existing_job_id": existing_job_id, - }, - ) diff --git a/app/exceptions/marketplace_product.py b/app/exceptions/marketplace_product.py deleted file mode 100644 index a5b47e32..00000000 --- a/app/exceptions/marketplace_product.py +++ /dev/null @@ -1,108 +0,0 @@ -# app/exceptions/marketplace_products.py -""" -MarketplaceProduct management specific exceptions. -""" - -from typing import Any - -from .base import ( - BusinessLogicException, - ConflictException, - ResourceNotFoundException, - ValidationException, -) - - -class MarketplaceProductNotFoundException(ResourceNotFoundException): - """Raised when a product is not found.""" - - def __init__(self, marketplace_product_id: str): - super().__init__( - resource_type="MarketplaceProduct", - identifier=marketplace_product_id, - message=f"MarketplaceProduct with ID '{marketplace_product_id}' not found", - error_code="PRODUCT_NOT_FOUND", - ) - - -class MarketplaceProductAlreadyExistsException(ConflictException): - """Raised when trying to create a product that already exists.""" - - def __init__(self, marketplace_product_id: str): - super().__init__( - message=f"MarketplaceProduct with ID '{marketplace_product_id}' already exists", - error_code="PRODUCT_ALREADY_EXISTS", - details={"marketplace_product_id": marketplace_product_id}, - ) - - -class InvalidMarketplaceProductDataException(ValidationException): - """Raised when product data is invalid.""" - - def __init__( - self, - message: str = "Invalid product data", - field: str | None = None, - details: dict[str, Any] | None = None, - ): - super().__init__( - message=message, - field=field, - details=details, - ) - self.error_code = "INVALID_PRODUCT_DATA" - - -class MarketplaceProductValidationException(ValidationException): - """Raised when product validation fails.""" - - def __init__( - self, - message: str, - field: str | None = None, - validation_errors: dict[str, str] | None = None, - ): - details = {} - if validation_errors: - details["validation_errors"] = validation_errors - - super().__init__( - message=message, - field=field, - details=details, - ) - self.error_code = "PRODUCT_VALIDATION_FAILED" - - -class InvalidGTINException(ValidationException): - """Raised when GTIN format is invalid.""" - - def __init__(self, gtin: str, message: str = "Invalid GTIN format"): - super().__init__( - message=f"{message}: {gtin}", - field="gtin", - details={"gtin": gtin}, - ) - self.error_code = "INVALID_GTIN" - - -class MarketplaceProductCSVImportException(BusinessLogicException): - """Raised when product CSV import fails.""" - - def __init__( - self, - message: str = "MarketplaceProduct CSV import failed", - row_number: int | None = None, - errors: dict[str, Any] | None = None, - ): - details = {} - if row_number: - details["row_number"] = row_number - if errors: - details["errors"] = errors - - super().__init__( - message=message, - error_code="PRODUCT_CSV_IMPORT_FAILED", - details=details, - ) diff --git a/app/exceptions/media.py b/app/exceptions/media.py deleted file mode 100644 index 55635187..00000000 --- a/app/exceptions/media.py +++ /dev/null @@ -1,101 +0,0 @@ -# app/exceptions/media.py -""" -Media/file management exceptions. -""" - -from typing import Any - -from .base import ( - BusinessLogicException, - ResourceNotFoundException, - ValidationException, -) - - -class MediaNotFoundException(ResourceNotFoundException): - """Raised when a media file is not found.""" - - def __init__(self, media_id: int | str): - super().__init__( - resource_type="MediaFile", - identifier=str(media_id), - message=f"Media file '{media_id}' not found", - error_code="MEDIA_NOT_FOUND", - ) - - -class MediaUploadException(BusinessLogicException): - """Raised when media upload fails.""" - - def __init__(self, message: str, details: dict[str, Any] | None = None): - super().__init__( - message=message, - error_code="MEDIA_UPLOAD_FAILED", - details=details, - ) - - -class MediaValidationException(ValidationException): - """Raised when media validation fails (file type, size, etc.).""" - - def __init__( - self, - message: str = "Media validation failed", - field: str | None = None, - details: dict[str, Any] | None = None, - ): - super().__init__(message=message, field=field, details=details) - self.error_code = "MEDIA_VALIDATION_FAILED" - - -class UnsupportedMediaTypeException(ValidationException): - """Raised when media file type is not supported.""" - - def __init__(self, file_type: str, allowed_types: list[str] | None = None): - details = {"file_type": file_type} - if allowed_types: - details["allowed_types"] = allowed_types - - super().__init__( - message=f"Unsupported media type: {file_type}", - field="file", - details=details, - ) - self.error_code = "UNSUPPORTED_MEDIA_TYPE" - - -class MediaFileTooLargeException(ValidationException): - """Raised when media file exceeds size limit.""" - - def __init__(self, file_size: int, max_size: int, media_type: str = "file"): - super().__init__( - message=f"File size ({file_size} bytes) exceeds maximum allowed ({max_size} bytes) for {media_type}", - field="file", - details={ - "file_size": file_size, - "max_size": max_size, - "media_type": media_type, - }, - ) - self.error_code = "MEDIA_FILE_TOO_LARGE" - - -class MediaOptimizationException(BusinessLogicException): - """Raised when media optimization fails.""" - - def __init__(self, message: str = "Only images can be optimized"): - super().__init__( - message=message, - error_code="MEDIA_OPTIMIZATION_FAILED", - ) - - -class MediaDeleteException(BusinessLogicException): - """Raised when media deletion fails.""" - - def __init__(self, message: str, details: dict[str, Any] | None = None): - super().__init__( - message=message, - error_code="MEDIA_DELETE_FAILED", - details=details, - ) diff --git a/app/exceptions/message.py b/app/exceptions/message.py deleted file mode 100644 index b1353bf6..00000000 --- a/app/exceptions/message.py +++ /dev/null @@ -1,100 +0,0 @@ -# app/exceptions/message.py -""" -Messaging specific exceptions. -""" - -from .base import BusinessLogicException, ResourceNotFoundException - - -class ConversationNotFoundException(ResourceNotFoundException): - """Raised when a conversation is not found.""" - - def __init__(self, conversation_identifier: str): - super().__init__( - resource_type="Conversation", - identifier=conversation_identifier, - message=f"Conversation '{conversation_identifier}' not found", - error_code="CONVERSATION_NOT_FOUND", - ) - - -class MessageNotFoundException(ResourceNotFoundException): - """Raised when a message is not found.""" - - def __init__(self, message_identifier: str): - super().__init__( - resource_type="Message", - identifier=message_identifier, - message=f"Message '{message_identifier}' not found", - error_code="MESSAGE_NOT_FOUND", - ) - - -class ConversationClosedException(BusinessLogicException): - """Raised when trying to send message to a closed conversation.""" - - def __init__(self, conversation_id: int): - super().__init__( - message=f"Cannot send message to closed conversation {conversation_id}", - error_code="CONVERSATION_CLOSED", - details={"conversation_id": conversation_id}, - ) - - -class MessageAttachmentException(BusinessLogicException): - """Raised when attachment validation fails.""" - - def __init__(self, message: str, details: dict | None = None): - super().__init__( - message=message, - error_code="MESSAGE_ATTACHMENT_INVALID", - details=details, - ) - - -class UnauthorizedConversationAccessException(BusinessLogicException): - """Raised when user tries to access a conversation they don't have access to.""" - - def __init__(self, conversation_id: int): - super().__init__( - message=f"You do not have access to conversation {conversation_id}", - error_code="CONVERSATION_ACCESS_DENIED", - details={"conversation_id": conversation_id}, - ) - - -class InvalidConversationTypeException(BusinessLogicException): - """Raised when conversation type is not valid for the operation.""" - - def __init__(self, message: str, allowed_types: list[str] | None = None): - super().__init__( - message=message, - error_code="INVALID_CONVERSATION_TYPE", - details={"allowed_types": allowed_types} if allowed_types else None, - ) - - -class InvalidRecipientTypeException(BusinessLogicException): - """Raised when recipient type doesn't match conversation type.""" - - def __init__(self, conversation_type: str, expected_recipient_type: str): - super().__init__( - message=f"{conversation_type} conversations require a {expected_recipient_type} recipient", - error_code="INVALID_RECIPIENT_TYPE", - details={ - "conversation_type": conversation_type, - "expected_recipient_type": expected_recipient_type, - }, - ) - - -class AttachmentNotFoundException(ResourceNotFoundException): - """Raised when an attachment is not found.""" - - def __init__(self, attachment_id: int | str): - super().__init__( - resource_type="Attachment", - identifier=str(attachment_id), - message=f"Attachment '{attachment_id}' not found", - error_code="ATTACHMENT_NOT_FOUND", - ) diff --git a/app/exceptions/monitoring.py b/app/exceptions/monitoring.py deleted file mode 100644 index d16c3949..00000000 --- a/app/exceptions/monitoring.py +++ /dev/null @@ -1 +0,0 @@ -# Monitoring exceptions diff --git a/app/exceptions/notification.py b/app/exceptions/notification.py deleted file mode 100644 index 8dfb9341..00000000 --- a/app/exceptions/notification.py +++ /dev/null @@ -1 +0,0 @@ -# Notification exceptions diff --git a/app/exceptions/onboarding.py b/app/exceptions/onboarding.py deleted file mode 100644 index 2b5c88e6..00000000 --- a/app/exceptions/onboarding.py +++ /dev/null @@ -1,87 +0,0 @@ -# app/exceptions/onboarding.py -""" -Onboarding-specific exceptions. - -Exceptions for the vendor onboarding wizard flow. -""" - -from typing import Any - -from .base import BusinessLogicException, ResourceNotFoundException, ValidationException - - -class OnboardingNotFoundException(ResourceNotFoundException): - """Raised when onboarding record is not found for a vendor.""" - - def __init__(self, vendor_id: int): - super().__init__( - resource_type="VendorOnboarding", - identifier=str(vendor_id), - ) - - -class OnboardingStepOrderException(ValidationException): - """Raised when trying to access a step out of order.""" - - def __init__(self, current_step: str, required_step: str): - super().__init__( - message=f"Please complete the {required_step} step first", - field="step", - details={ - "current_step": current_step, - "required_step": required_step, - }, - ) - - -class OnboardingAlreadyCompletedException(BusinessLogicException): - """Raised when trying to modify a completed onboarding.""" - - def __init__(self, vendor_id: int): - super().__init__( - message="Onboarding has already been completed", - error_code="ONBOARDING_ALREADY_COMPLETED", - details={"vendor_id": vendor_id}, - ) - - -class LetzshopConnectionFailedException(BusinessLogicException): - """Raised when Letzshop API connection test fails.""" - - def __init__(self, error_message: str): - super().__init__( - message=f"Letzshop connection failed: {error_message}", - error_code="LETZSHOP_CONNECTION_FAILED", - details={"error": error_message}, - ) - - -class OnboardingCsvUrlRequiredException(ValidationException): - """Raised when no CSV URL is provided in product import step.""" - - def __init__(self): - super().__init__( - message="At least one CSV URL must be provided", - field="csv_url", - ) - - -class OnboardingSyncJobNotFoundException(ResourceNotFoundException): - """Raised when sync job is not found.""" - - def __init__(self, job_id: int): - super().__init__( - resource_type="LetzshopHistoricalImportJob", - identifier=str(job_id), - ) - - -class OnboardingSyncNotCompleteException(BusinessLogicException): - """Raised when trying to complete onboarding before sync is done.""" - - def __init__(self, job_status: str): - super().__init__( - message=f"Import job is still {job_status}, please wait", - error_code="SYNC_NOT_COMPLETE", - details={"job_status": job_status}, - ) diff --git a/app/exceptions/order.py b/app/exceptions/order.py deleted file mode 100644 index 7d733829..00000000 --- a/app/exceptions/order.py +++ /dev/null @@ -1,60 +0,0 @@ -# app/exceptions/order.py -""" -Order management specific exceptions. -""" - -from .base import BusinessLogicException, ResourceNotFoundException, ValidationException - - -class OrderNotFoundException(ResourceNotFoundException): - """Raised when an order is not found.""" - - def __init__(self, order_identifier: str): - super().__init__( - resource_type="Order", - identifier=order_identifier, - message=f"Order '{order_identifier}' not found", - error_code="ORDER_NOT_FOUND", - ) - - -class OrderAlreadyExistsException(ValidationException): - """Raised when trying to create a duplicate order.""" - - def __init__(self, order_number: str): - super().__init__( - message=f"Order with number '{order_number}' already exists", - error_code="ORDER_ALREADY_EXISTS", - details={"order_number": order_number}, - ) - - -class OrderValidationException(ValidationException): - """Raised when order data validation fails.""" - - def __init__(self, message: str, details: dict | None = None): - super().__init__( - message=message, error_code="ORDER_VALIDATION_FAILED", details=details - ) - - -class InvalidOrderStatusException(BusinessLogicException): - """Raised when trying to set an invalid order status.""" - - def __init__(self, current_status: str, new_status: str): - super().__init__( - message=f"Cannot change order status from '{current_status}' to '{new_status}'", - error_code="INVALID_ORDER_STATUS_CHANGE", - details={"current_status": current_status, "new_status": new_status}, - ) - - -class OrderCannotBeCancelledException(BusinessLogicException): - """Raised when order cannot be cancelled.""" - - def __init__(self, order_number: str, reason: str): - super().__init__( - message=f"Order '{order_number}' cannot be cancelled: {reason}", - error_code="ORDER_CANNOT_BE_CANCELLED", - details={"order_number": order_number, "reason": reason}, - ) diff --git a/app/exceptions/order_item_exception.py b/app/exceptions/order_item_exception.py deleted file mode 100644 index 0d433c54..00000000 --- a/app/exceptions/order_item_exception.py +++ /dev/null @@ -1,54 +0,0 @@ -# app/exceptions/order_item_exception.py -"""Order item exception specific exceptions.""" - -from .base import BusinessLogicException, ResourceNotFoundException - - -class OrderItemExceptionNotFoundException(ResourceNotFoundException): - """Raised when an order item exception is not found.""" - - def __init__(self, exception_id: int | str): - super().__init__( - resource_type="OrderItemException", - identifier=str(exception_id), - error_code="ORDER_ITEM_EXCEPTION_NOT_FOUND", - ) - - -class OrderHasUnresolvedExceptionsException(BusinessLogicException): - """Raised when trying to confirm an order with unresolved exceptions.""" - - def __init__(self, order_id: int, unresolved_count: int): - super().__init__( - message=( - f"Order has {unresolved_count} unresolved product exception(s). " - f"Please resolve all exceptions before confirming the order." - ), - error_code="ORDER_HAS_UNRESOLVED_EXCEPTIONS", - details={ - "order_id": order_id, - "unresolved_count": unresolved_count, - }, - ) - - -class ExceptionAlreadyResolvedException(BusinessLogicException): - """Raised when trying to resolve an already resolved exception.""" - - def __init__(self, exception_id: int): - super().__init__( - message=f"Exception {exception_id} has already been resolved", - error_code="EXCEPTION_ALREADY_RESOLVED", - details={"exception_id": exception_id}, - ) - - -class InvalidProductForExceptionException(BusinessLogicException): - """Raised when the product provided for resolution is invalid.""" - - def __init__(self, product_id: int, reason: str): - super().__init__( - message=f"Cannot use product {product_id} for resolution: {reason}", - error_code="INVALID_PRODUCT_FOR_EXCEPTION", - details={"product_id": product_id, "reason": reason}, - ) diff --git a/app/exceptions/payment.py b/app/exceptions/payment.py deleted file mode 100644 index 2c7f0f89..00000000 --- a/app/exceptions/payment.py +++ /dev/null @@ -1 +0,0 @@ -# Payment processing exceptions diff --git a/app/exceptions/platform.py b/app/exceptions/platform.py deleted file mode 100644 index 9ed9d7a2..00000000 --- a/app/exceptions/platform.py +++ /dev/null @@ -1,44 +0,0 @@ -# app/exceptions/platform.py -""" -Platform-related exceptions. - -Custom exceptions for platform management operations. -""" - -from app.exceptions.base import WizamartException - - -class PlatformNotFoundException(WizamartException): - """Raised when a platform is not found.""" - - def __init__(self, code: str): - super().__init__( - message=f"Platform not found: {code}", - error_code="PLATFORM_NOT_FOUND", - status_code=404, - details={"platform_code": code}, - ) - - -class PlatformInactiveException(WizamartException): - """Raised when trying to access an inactive platform.""" - - def __init__(self, code: str): - super().__init__( - message=f"Platform is inactive: {code}", - error_code="PLATFORM_INACTIVE", - status_code=403, - details={"platform_code": code}, - ) - - -class PlatformUpdateException(WizamartException): - """Raised when platform update fails.""" - - def __init__(self, code: str, reason: str): - super().__init__( - message=f"Failed to update platform {code}: {reason}", - error_code="PLATFORM_UPDATE_FAILED", - status_code=400, - details={"platform_code": code, "reason": reason}, - ) diff --git a/app/exceptions/product.py b/app/exceptions/product.py deleted file mode 100644 index d80b7f1e..00000000 --- a/app/exceptions/product.py +++ /dev/null @@ -1,142 +0,0 @@ -# app/exceptions/product.py -""" -Product (vendor catalog) specific exceptions. -""" - -from .base import ( - BusinessLogicException, - ConflictException, - ResourceNotFoundException, - ValidationException, -) - - -class ProductNotFoundException(ResourceNotFoundException): - """Raised when a product is not found in vendor catalog.""" - - def __init__(self, product_id: int, vendor_id: int | None = None): - if vendor_id: - message = f"Product with ID '{product_id}' not found in vendor {vendor_id} catalog" - else: - message = f"Product with ID '{product_id}' not found" - - super().__init__( - resource_type="Product", - identifier=str(product_id), - message=message, - error_code="PRODUCT_NOT_FOUND", - ) - # Add extra details after init - self.details["product_id"] = product_id - if vendor_id: - self.details["vendor_id"] = vendor_id - - -class ProductAlreadyExistsException(ConflictException): - """Raised when trying to add a marketplace product that's already in vendor catalog.""" - - def __init__(self, vendor_id: int, marketplace_product_id: int): - super().__init__( - message=f"Marketplace product {marketplace_product_id} already exists in vendor {vendor_id} catalog", - error_code="PRODUCT_ALREADY_EXISTS", - details={ - "vendor_id": vendor_id, - "marketplace_product_id": marketplace_product_id, - }, - ) - - -class ProductNotInCatalogException(ResourceNotFoundException): - """Raised when trying to access a product that's not in vendor's catalog.""" - - def __init__(self, product_id: int, vendor_id: int): - super().__init__( - resource_type="Product", - identifier=str(product_id), - message=f"Product {product_id} is not in vendor {vendor_id} catalog", - error_code="PRODUCT_NOT_IN_CATALOG", - details={ - "product_id": product_id, - "vendor_id": vendor_id, - }, - ) - - -class ProductNotActiveException(BusinessLogicException): - """Raised when trying to perform operations on inactive product.""" - - def __init__(self, product_id: int, vendor_id: int): - super().__init__( - message=f"Product {product_id} in vendor {vendor_id} catalog is not active", - error_code="PRODUCT_NOT_ACTIVE", - details={ - "product_id": product_id, - "vendor_id": vendor_id, - }, - ) - - -class InvalidProductDataException(ValidationException): - """Raised when product data is invalid.""" - - def __init__( - self, - message: str = "Invalid product data", - field: str | None = None, - details: dict | None = None, - ): - super().__init__( - message=message, - field=field, - details=details, - ) - self.error_code = "INVALID_PRODUCT_DATA" - - -class ProductValidationException(ValidationException): - """Raised when product validation fails.""" - - def __init__( - self, - message: str = "Product validation failed", - field: str | None = None, - validation_errors: dict | None = None, - ): - details = {} - if validation_errors: - details["validation_errors"] = validation_errors - - super().__init__( - message=message, - field=field, - details=details, - ) - self.error_code = "PRODUCT_VALIDATION_FAILED" - - -class CannotDeleteProductWithInventoryException(BusinessLogicException): - """Raised when trying to delete a product that has inventory.""" - - def __init__(self, product_id: int, inventory_count: int): - super().__init__( - message=f"Cannot delete product {product_id} - it has {inventory_count} inventory entries", - error_code="CANNOT_DELETE_PRODUCT_WITH_INVENTORY", - details={ - "product_id": product_id, - "inventory_count": inventory_count, - }, - ) - - -class CannotDeleteProductWithOrdersException(BusinessLogicException): - """Raised when trying to delete a product that has been ordered.""" - - def __init__(self, product_id: int, order_count: int): - super().__init__( - message=f"Cannot delete product {product_id} - it has {order_count} associated orders", - error_code="CANNOT_DELETE_PRODUCT_WITH_ORDERS", - details={ - "product_id": product_id, - "order_count": order_count, - }, - ) diff --git a/app/exceptions/search.py b/app/exceptions/search.py deleted file mode 100644 index ed380099..00000000 --- a/app/exceptions/search.py +++ /dev/null @@ -1 +0,0 @@ -# Search exceptions diff --git a/app/exceptions/team.py b/app/exceptions/team.py deleted file mode 100644 index 70f229d2..00000000 --- a/app/exceptions/team.py +++ /dev/null @@ -1,273 +0,0 @@ -# app/exceptions/team.py -""" -Team management specific exceptions. -""" - -from typing import Any - -from .base import ( - AuthorizationException, - BusinessLogicException, - ConflictException, - ResourceNotFoundException, - ValidationException, -) - - -class TeamMemberNotFoundException(ResourceNotFoundException): - """Raised when a team member is not found.""" - - def __init__(self, user_id: int, vendor_id: int | None = None): - details = {"user_id": user_id} - if vendor_id: - details["vendor_id"] = vendor_id - message = ( - f"Team member with user ID '{user_id}' not found in vendor {vendor_id}" - ) - else: - message = f"Team member with user ID '{user_id}' not found" - - super().__init__( - resource_type="TeamMember", - identifier=str(user_id), - message=message, - error_code="TEAM_MEMBER_NOT_FOUND", - details=details, - ) - - -class TeamMemberAlreadyExistsException(ConflictException): - """Raised when trying to add a user who is already a team member.""" - - def __init__(self, user_id: int, vendor_id: int): - super().__init__( - message=f"User {user_id} is already a team member of vendor {vendor_id}", - error_code="TEAM_MEMBER_ALREADY_EXISTS", - details={ - "user_id": user_id, - "vendor_id": vendor_id, - }, - ) - - -class TeamInvitationNotFoundException(ResourceNotFoundException): - """Raised when a team invitation is not found.""" - - def __init__(self, invitation_token: str): - super().__init__( - resource_type="TeamInvitation", - identifier=invitation_token, - message=f"Team invitation with token '{invitation_token}' not found or expired", - error_code="TEAM_INVITATION_NOT_FOUND", - ) - - -class TeamInvitationExpiredException(BusinessLogicException): - """Raised when trying to accept an expired invitation.""" - - def __init__(self, invitation_token: str): - super().__init__( - message="Team invitation has expired", - error_code="TEAM_INVITATION_EXPIRED", - details={"invitation_token": invitation_token}, - ) - - -class TeamInvitationAlreadyAcceptedException(ConflictException): - """Raised when trying to accept an already accepted invitation.""" - - def __init__(self, invitation_token: str): - super().__init__( - message="Team invitation has already been accepted", - error_code="TEAM_INVITATION_ALREADY_ACCEPTED", - details={"invitation_token": invitation_token}, - ) - - -class UnauthorizedTeamActionException(AuthorizationException): - """Raised when user tries to perform team action without permission.""" - - def __init__( - self, - action: str, - user_id: int | None = None, - required_permission: str | None = None, - ): - details = {"action": action} - if user_id: - details["user_id"] = user_id - if required_permission: - details["required_permission"] = required_permission - - super().__init__( - message=f"Unauthorized to perform action: {action}", - error_code="UNAUTHORIZED_TEAM_ACTION", - details=details, - ) - - -class CannotRemoveOwnerException(BusinessLogicException): - """Raised when trying to remove the vendor owner from team.""" - - def __init__(self, user_id: int, vendor_id: int): - super().__init__( - message="Cannot remove vendor owner from team", - error_code="CANNOT_REMOVE_OWNER", - details={ - "user_id": user_id, - "vendor_id": vendor_id, - }, - ) - - -class CannotModifyOwnRoleException(BusinessLogicException): - """Raised when user tries to modify their own role.""" - - def __init__(self, user_id: int): - super().__init__( - message="Cannot modify your own role", - error_code="CANNOT_MODIFY_OWN_ROLE", - details={"user_id": user_id}, - ) - - -class RoleNotFoundException(ResourceNotFoundException): - """Raised when a role is not found.""" - - def __init__(self, role_id: int, vendor_id: int | None = None): - details = {"role_id": role_id} - if vendor_id: - details["vendor_id"] = vendor_id - message = f"Role with ID '{role_id}' not found in vendor {vendor_id}" - else: - message = f"Role with ID '{role_id}' not found" - - super().__init__( - resource_type="Role", - identifier=str(role_id), - message=message, - error_code="ROLE_NOT_FOUND", - details=details, - ) - - -class InvalidRoleException(ValidationException): - """Raised when role data is invalid.""" - - def __init__( - self, - message: str = "Invalid role data", - field: str | None = None, - details: dict[str, Any] | None = None, - ): - super().__init__( - message=message, - field=field, - details=details, - ) - self.error_code = "INVALID_ROLE_DATA" - - -class InsufficientTeamPermissionsException(AuthorizationException): - """Raised when user lacks required team permissions for an action.""" - - def __init__( - self, - required_permission: str, - user_id: int | None = None, - action: str | None = None, - ): - details = {"required_permission": required_permission} - if user_id: - details["user_id"] = user_id - if action: - details["action"] = action - - message = f"Insufficient team permissions. Required: {required_permission}" - - super().__init__( - message=message, - error_code="INSUFFICIENT_TEAM_PERMISSIONS", - details=details, - ) - - -class MaxTeamMembersReachedException(BusinessLogicException): - """Raised when vendor has reached maximum team members limit.""" - - def __init__(self, max_members: int, vendor_id: int): - super().__init__( - message=f"Maximum number of team members reached ({max_members})", - error_code="MAX_TEAM_MEMBERS_REACHED", - details={ - "max_members": max_members, - "vendor_id": vendor_id, - }, - ) - - -class TeamValidationException(ValidationException): - """Raised when team operation validation fails.""" - - def __init__( - self, - message: str = "Team operation validation failed", - field: str | None = None, - validation_errors: dict[str, str] | None = None, - ): - details = {} - if validation_errors: - details["validation_errors"] = validation_errors - - super().__init__( - message=message, - field=field, - details=details, - ) - self.error_code = "TEAM_VALIDATION_FAILED" - - -class InvalidInvitationDataException(ValidationException): - """Raised when team invitation data is invalid.""" - - def __init__( - self, - message: str = "Invalid invitation data", - field: str | None = None, - details: dict[str, Any] | None = None, - ): - super().__init__( - message=message, - field=field, - details=details, - ) - self.error_code = "INVALID_INVITATION_DATA" - - -# ============================================================================ -# NEW: Add InvalidInvitationTokenException -# ============================================================================ - - -class InvalidInvitationTokenException(ValidationException): - """Raised when invitation token is invalid, expired, or already used. - - This is a general exception for any invitation token validation failure. - Use this when checking invitation tokens during the acceptance flow. - """ - - def __init__( - self, - message: str = "Invalid or expired invitation token", - invitation_token: str | None = None, - ): - details = {} - if invitation_token: - details["invitation_token"] = invitation_token - - super().__init__( - message=message, - field="invitation_token", - details=details, - ) - self.error_code = "INVALID_INVITATION_TOKEN" diff --git a/app/exceptions/vendor.py b/app/exceptions/vendor.py deleted file mode 100644 index e33ce9b6..00000000 --- a/app/exceptions/vendor.py +++ /dev/null @@ -1,190 +0,0 @@ -# app/exceptions/vendor.py -""" -Vendor management specific exceptions. -""" - -from typing import Any - -from .base import ( - AuthorizationException, - BusinessLogicException, - ConflictException, - ResourceNotFoundException, - ValidationException, -) - - -class VendorNotFoundException(ResourceNotFoundException): - """Raised when a vendor is not found.""" - - def __init__(self, vendor_identifier: str, identifier_type: str = "code"): - if identifier_type.lower() == "id": - message = f"Vendor with ID '{vendor_identifier}' not found" - else: - message = f"Vendor with code '{vendor_identifier}' not found" - - super().__init__( - resource_type="Vendor", - identifier=vendor_identifier, - message=message, - error_code="VENDOR_NOT_FOUND", - ) - - -class VendorAlreadyExistsException(ConflictException): - """Raised when trying to create a vendor that already exists.""" - - def __init__(self, vendor_code: str): - super().__init__( - message=f"Vendor with code '{vendor_code}' already exists", - error_code="VENDOR_ALREADY_EXISTS", - details={"vendor_code": vendor_code}, - ) - - -class VendorNotActiveException(BusinessLogicException): - """Raised when trying to perform operations on inactive vendor.""" - - def __init__(self, vendor_code: str): - super().__init__( - message=f"Vendor '{vendor_code}' is not active", - error_code="VENDOR_NOT_ACTIVE", - details={"vendor_code": vendor_code}, - ) - - -class VendorNotVerifiedException(BusinessLogicException): - """Raised when trying to perform operations requiring verified vendor.""" - - def __init__(self, vendor_code: str): - super().__init__( - message=f"Vendor '{vendor_code}' is not verified", - error_code="VENDOR_NOT_VERIFIED", - details={"vendor_code": vendor_code}, - ) - - -class UnauthorizedVendorAccessException(AuthorizationException): - """Raised when user tries to access vendor they don't own.""" - - def __init__(self, vendor_code: str, user_id: int | None = None): - details = {"vendor_code": vendor_code} - if user_id: - details["user_id"] = user_id - - super().__init__( - message=f"Unauthorized access to vendor '{vendor_code}'", - error_code="UNAUTHORIZED_VENDOR_ACCESS", - details=details, - ) - - -class InvalidVendorDataException(ValidationException): - """Raised when vendor data is invalid or incomplete.""" - - def __init__( - self, - message: str = "Invalid vendor data", - field: str | None = None, - details: dict[str, Any] | None = None, - ): - super().__init__( - message=message, - field=field, - details=details, - ) - self.error_code = "INVALID_VENDOR_DATA" - - -class VendorValidationException(ValidationException): - """Raised when vendor validation fails.""" - - def __init__( - self, - message: str = "Vendor validation failed", - field: str | None = None, - validation_errors: dict[str, str] | None = None, - ): - details = {} - if validation_errors: - details["validation_errors"] = validation_errors - - super().__init__( - message=message, - field=field, - details=details, - ) - self.error_code = "VENDOR_VALIDATION_FAILED" - - -class IncompleteVendorDataException(ValidationException): - """Raised when vendor data is missing required fields.""" - - def __init__( - self, - vendor_code: str, - missing_fields: list, - ): - super().__init__( - message=f"Vendor '{vendor_code}' is missing required fields: {', '.join(missing_fields)}", - details={ - "vendor_code": vendor_code, - "missing_fields": missing_fields, - }, - ) - self.error_code = "INCOMPLETE_VENDOR_DATA" - - -class MaxVendorsReachedException(BusinessLogicException): - """Raised when user tries to create more vendors than allowed.""" - - def __init__(self, max_vendors: int, user_id: int | None = None): - details = {"max_vendors": max_vendors} - if user_id: - details["user_id"] = user_id - - super().__init__( - message=f"Maximum number of vendors reached ({max_vendors})", - error_code="MAX_VENDORS_REACHED", - details=details, - ) - - -class VendorAccessDeniedException(AuthorizationException): - """Raised when no vendor context is available for an authenticated endpoint.""" - - def __init__(self, message: str = "No vendor context available"): - super().__init__( - message=message, - error_code="VENDOR_ACCESS_DENIED", - ) - - -class VendorOwnerOnlyException(AuthorizationException): - """Raised when operation requires vendor owner role.""" - - def __init__(self, operation: str, vendor_code: str | None = None): - details = {"operation": operation} - if vendor_code: - details["vendor_code"] = vendor_code - - super().__init__( - message=f"Operation '{operation}' requires vendor owner role", - error_code="VENDOR_OWNER_ONLY", - details=details, - ) - - -class InsufficientVendorPermissionsException(AuthorizationException): - """Raised when user lacks required vendor permission.""" - - def __init__(self, required_permission: str, vendor_code: str | None = None): - details = {"required_permission": required_permission} - if vendor_code: - details["vendor_code"] = vendor_code - - super().__init__( - message=f"Permission required: {required_permission}", - error_code="INSUFFICIENT_VENDOR_PERMISSIONS", - details=details, - ) diff --git a/app/exceptions/vendor_domain.py b/app/exceptions/vendor_domain.py deleted file mode 100644 index 36f895bd..00000000 --- a/app/exceptions/vendor_domain.py +++ /dev/null @@ -1,146 +0,0 @@ -# app/exceptions/vendor_domain.py -""" -Vendor domain management specific exceptions. -""" - -from .base import ( - BusinessLogicException, - ConflictException, - ExternalServiceException, - ResourceNotFoundException, - ValidationException, -) - - -class VendorDomainNotFoundException(ResourceNotFoundException): - """Raised when a vendor domain is not found.""" - - def __init__(self, domain_identifier: str, identifier_type: str = "ID"): - if identifier_type.lower() == "domain": - message = f"Domain '{domain_identifier}' not found" - else: - message = f"Domain with ID '{domain_identifier}' not found" - - super().__init__( - resource_type="VendorDomain", - identifier=domain_identifier, - message=message, - error_code="VENDOR_DOMAIN_NOT_FOUND", - ) - - -class VendorDomainAlreadyExistsException(ConflictException): - """Raised when trying to add a domain that already exists.""" - - def __init__(self, domain: str, existing_vendor_id: int | None = None): - details = {"domain": domain} - if existing_vendor_id: - details["existing_vendor_id"] = existing_vendor_id - - super().__init__( - message=f"Domain '{domain}' is already registered", - error_code="VENDOR_DOMAIN_ALREADY_EXISTS", - details=details, - ) - - -class InvalidDomainFormatException(ValidationException): - """Raised when domain format is invalid.""" - - def __init__(self, domain: str, reason: str = "Invalid domain format"): - super().__init__( - message=f"{reason}: {domain}", - field="domain", - details={"domain": domain, "reason": reason}, - ) - self.error_code = "INVALID_DOMAIN_FORMAT" - - -class ReservedDomainException(ValidationException): - """Raised when trying to use a reserved domain.""" - - def __init__(self, domain: str, reserved_part: str): - super().__init__( - message=f"Domain cannot use reserved subdomain: {reserved_part}", - field="domain", - details={"domain": domain, "reserved_part": reserved_part}, - ) - self.error_code = "RESERVED_DOMAIN" - - -class DomainNotVerifiedException(BusinessLogicException): - """Raised when trying to activate an unverified domain.""" - - def __init__(self, domain_id: int, domain: str): - super().__init__( - message=f"Domain '{domain}' must be verified before activation", - error_code="DOMAIN_NOT_VERIFIED", - details={"domain_id": domain_id, "domain": domain}, - ) - - -class DomainVerificationFailedException(BusinessLogicException): - """Raised when domain verification fails.""" - - def __init__(self, domain: str, reason: str): - super().__init__( - message=f"Domain verification failed for '{domain}': {reason}", - error_code="DOMAIN_VERIFICATION_FAILED", - details={"domain": domain, "reason": reason}, - ) - - -class DomainAlreadyVerifiedException(BusinessLogicException): - """Raised when trying to verify an already verified domain.""" - - def __init__(self, domain_id: int, domain: str): - super().__init__( - message=f"Domain '{domain}' is already verified", - error_code="DOMAIN_ALREADY_VERIFIED", - details={"domain_id": domain_id, "domain": domain}, - ) - - -class MultiplePrimaryDomainsException(BusinessLogicException): - """Raised when trying to set multiple primary domains.""" - - def __init__(self, vendor_id: int): - super().__init__( - message="Vendor can only have one primary domain", - error_code="MULTIPLE_PRIMARY_DOMAINS", - details={"vendor_id": vendor_id}, - ) - - -class DNSVerificationException(ExternalServiceException): - """Raised when DNS verification service fails.""" - - def __init__(self, domain: str, reason: str): - super().__init__( - service_name="DNS", - message=f"DNS verification failed for '{domain}': {reason}", - error_code="DNS_VERIFICATION_ERROR", - details={"domain": domain, "reason": reason}, - ) - - -class MaxDomainsReachedException(BusinessLogicException): - """Raised when vendor tries to add more domains than allowed.""" - - def __init__(self, vendor_id: int, max_domains: int): - super().__init__( - message=f"Maximum number of domains reached ({max_domains})", - error_code="MAX_DOMAINS_REACHED", - details={"vendor_id": vendor_id, "max_domains": max_domains}, - ) - - -class UnauthorizedDomainAccessException(BusinessLogicException): - """Raised when trying to access domain that doesn't belong to vendor.""" - - def __init__(self, domain_id: int, vendor_id: int): - super().__init__( - message=f"Unauthorized access to domain {domain_id}", - error_code="UNAUTHORIZED_DOMAIN_ACCESS", - details={"domain_id": domain_id, "vendor_id": vendor_id}, - ) diff --git a/app/exceptions/vendor_theme.py b/app/exceptions/vendor_theme.py deleted file mode 100644 index 35b58222..00000000 --- a/app/exceptions/vendor_theme.py +++ /dev/null @@ -1,129 +0,0 @@ -# app/exceptions/vendor_theme.py -""" -Vendor theme management specific exceptions. -""" - -from typing import Any - -from .base import ( - BusinessLogicException, - ResourceNotFoundException, - ValidationException, -) - - -class VendorThemeNotFoundException(ResourceNotFoundException): - """Raised when a vendor theme is not found.""" - - def __init__(self, vendor_identifier: str): - super().__init__( - resource_type="VendorTheme", - identifier=vendor_identifier, - message=f"Theme for vendor '{vendor_identifier}' not found", - error_code="VENDOR_THEME_NOT_FOUND", - ) - - -class InvalidThemeDataException(ValidationException): - """Raised when theme data is invalid.""" - - def __init__( - self, - message: str = "Invalid theme data", - field: str | None = None, - details: dict[str, Any] | None = None, - ): - super().__init__( - message=message, - field=field, - details=details, - ) - self.error_code = "INVALID_THEME_DATA" - - -class ThemePresetNotFoundException(ResourceNotFoundException): - """Raised when a theme preset is not found.""" - - def __init__(self, preset_name: str, available_presets: list | None = None): - details = {"preset_name": preset_name} - if available_presets: - details["available_presets"] = available_presets - - super().__init__( - resource_type="ThemePreset", - identifier=preset_name, - message=f"Theme preset '{preset_name}' not found", - error_code="THEME_PRESET_NOT_FOUND", - ) - self.details = details - - -class ThemeValidationException(ValidationException): - """Raised when theme validation fails.""" - - def __init__( - self, - message: str = "Theme validation failed", - field: str | None = None, - validation_errors: dict[str, str] | None = None, - ): - details = {} - if validation_errors: - details["validation_errors"] = validation_errors - - super().__init__( - message=message, - field=field, - details=details, - ) - self.error_code = "THEME_VALIDATION_FAILED" - - -class ThemePresetAlreadyAppliedException(BusinessLogicException): - """Raised when trying to apply the same preset that's already active.""" - - def __init__(self, preset_name: str, vendor_code: str): - super().__init__( - message=f"Preset '{preset_name}' is already applied to vendor '{vendor_code}'", - error_code="THEME_PRESET_ALREADY_APPLIED", - details={"preset_name": preset_name, "vendor_code": vendor_code}, - ) - - -class InvalidColorFormatException(ValidationException): - """Raised when color format is invalid.""" - - def __init__(self, color_value: str, field: str): - super().__init__( - message=f"Invalid color format: {color_value}", - field=field, - details={"color_value": color_value}, - ) - self.error_code = "INVALID_COLOR_FORMAT" - - -class InvalidFontFamilyException(ValidationException): - """Raised when font family is invalid.""" - - def __init__(self, font_value: str, field: str): - super().__init__( - message=f"Invalid font family: {font_value}", - field=field, - details={"font_value": font_value}, - ) - self.error_code = "INVALID_FONT_FAMILY" - - -class ThemeOperationException(BusinessLogicException): - """Raised when theme operation fails.""" - - def __init__(self, operation: str, vendor_code: str, reason: str): - super().__init__( - message=f"Theme operation '{operation}' failed for vendor '{vendor_code}': {reason}", - error_code="THEME_OPERATION_FAILED", - details={ - "operation": operation, - "vendor_code": vendor_code, - "reason": reason, - }, - ) diff --git a/app/modules/analytics/routes/pages/__init__.py b/app/modules/analytics/routes/pages/__init__.py index d577a8c1..e0262def 100644 --- a/app/modules/analytics/routes/pages/__init__.py +++ b/app/modules/analytics/routes/pages/__init__.py @@ -1,13 +1,2 @@ # app/modules/analytics/routes/pages/__init__.py -""" -Analytics module page routes. - -Provides HTML page endpoints for analytics views: -- Vendor pages: Analytics dashboard for vendors -""" - -from app.modules.analytics.routes.pages.vendor import router as vendor_router - -# Note: Analytics has no admin pages - admin uses the main dashboard - -__all__ = ["vendor_router"] +"""Analytics module page routes.""" diff --git a/app/modules/analytics/routes/pages/admin.py b/app/modules/analytics/routes/pages/admin.py new file mode 100644 index 00000000..5b1ee752 --- /dev/null +++ b/app/modules/analytics/routes/pages/admin.py @@ -0,0 +1,87 @@ +# app/modules/analytics/routes/pages/admin.py +""" +Analytics Admin Page Routes (HTML rendering). + +Admin pages for code quality and analytics: +- Code quality dashboard +- Violations list +- Violation detail +""" + +from fastapi import APIRouter, Depends, Path, Request +from fastapi.responses import HTMLResponse +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 + +router = APIRouter() + + +# ============================================================================ +# CODE QUALITY & ARCHITECTURE ROUTES +# ============================================================================ + + +@router.get("/code-quality", response_class=HTMLResponse, include_in_schema=False) +async def admin_code_quality_dashboard( + request: Request, + current_user: User = Depends( + require_menu_access("code-quality", FrontendType.ADMIN) + ), + db: Session = Depends(get_db), +): + """ + Render code quality dashboard. + Shows architecture violations, trends, and technical debt score. + """ + return templates.TemplateResponse( + "dev_tools/admin/code-quality-dashboard.html", + get_admin_context(request, current_user), + ) + + +@router.get( + "/code-quality/violations", response_class=HTMLResponse, include_in_schema=False +) +async def admin_code_quality_violations( + request: Request, + current_user: User = Depends( + require_menu_access("code-quality", FrontendType.ADMIN) + ), + db: Session = Depends(get_db), +): + """ + Render violations list page. + Shows all violations with filtering and sorting options. + """ + return templates.TemplateResponse( + "dev_tools/admin/code-quality-violations.html", + get_admin_context(request, current_user), + ) + + +@router.get( + "/code-quality/violations/{violation_id}", + response_class=HTMLResponse, + include_in_schema=False, +) +async def admin_code_quality_violation_detail( + request: Request, + violation_id: int = Path(..., description="Violation ID"), + current_user: User = Depends( + require_menu_access("code-quality", FrontendType.ADMIN) + ), + db: Session = Depends(get_db), +): + """ + Render violation detail page. + Shows violation details, code context, assignments, and comments. + """ + return templates.TemplateResponse( + "dev_tools/admin/code-quality-violation-detail.html", + get_admin_context(request, current_user, violation_id=violation_id), + ) diff --git a/app/modules/analytics/routes/pages/vendor.py b/app/modules/analytics/routes/pages/vendor.py index c18410e9..7b59d7d0 100644 --- a/app/modules/analytics/routes/pages/vendor.py +++ b/app/modules/analytics/routes/pages/vendor.py @@ -12,7 +12,7 @@ from fastapi.responses import HTMLResponse from sqlalchemy.orm import Session from app.api.deps import get_current_vendor_from_cookie_or_header, get_db -from app.services.platform_settings_service import platform_settings_service # noqa: MOD-004 - shared platform service +from app.modules.core.services.platform_settings_service import platform_settings_service # noqa: MOD-004 - shared platform service from app.templates_config import templates from models.database.user import User from models.database.vendor import Vendor diff --git a/app/modules/analytics/services/stats_service.py b/app/modules/analytics/services/stats_service.py index cb9d26be..356f023d 100644 --- a/app/modules/analytics/services/stats_service.py +++ b/app/modules/analytics/services/stats_service.py @@ -18,7 +18,7 @@ from typing import Any from sqlalchemy import func from sqlalchemy.orm import Session -from app.exceptions import AdminOperationException, VendorNotFoundException +from app.modules.tenancy.exceptions import AdminOperationException, VendorNotFoundException from app.modules.customers.models.customer import Customer from app.modules.inventory.models import Inventory from app.modules.marketplace.models import MarketplaceImportJob, MarketplaceProduct diff --git a/app/modules/analytics/services/usage_service.py b/app/modules/analytics/services/usage_service.py index 02732a8c..6c75253d 100644 --- a/app/modules/analytics/services/usage_service.py +++ b/app/modules/analytics/services/usage_service.py @@ -93,7 +93,7 @@ class UsageService: Returns current usage, limits, and upgrade recommendations. """ - from app.services.subscription_service import subscription_service + from app.modules.billing.services.subscription_service import subscription_service # Get subscription subscription = subscription_service.get_or_create_subscription(db, vendor_id) @@ -151,7 +151,7 @@ class UsageService: Returns: LimitCheckData with proceed status and upgrade info """ - from app.services.subscription_service import subscription_service + from app.modules.billing.services.subscription_service import subscription_service if limit_type == "orders": can_proceed, message = subscription_service.can_create_order(db, vendor_id) diff --git a/app/modules/billing/exceptions.py b/app/modules/billing/exceptions.py index b81cd2c9..c1497b6b 100644 --- a/app/modules/billing/exceptions.py +++ b/app/modules/billing/exceptions.py @@ -2,82 +2,294 @@ """ Billing module exceptions. -Custom exceptions for subscription, billing, and payment operations. +This module provides exception classes for billing operations including: +- Subscription management +- Payment processing (Stripe) +- Feature management +- Tier management """ -from app.exceptions import BusinessLogicException, ResourceNotFoundException +from app.exceptions.base import ( + BusinessLogicException, + ResourceNotFoundException, + ServiceUnavailableException, + ValidationException, +) + +__all__ = [ + # Base billing exception + "BillingException", + # Subscription exceptions + "SubscriptionNotFoundException", + "NoActiveSubscriptionException", + "SubscriptionNotCancelledException", + "SubscriptionAlreadyCancelledException", + # Tier exceptions + "TierNotFoundException", + "TierNotFoundError", + "TierLimitExceededException", + # Payment exceptions + "PaymentSystemNotConfiguredException", + "StripeNotConfiguredException", + "StripePriceNotConfiguredException", + "PaymentFailedException", + # Webhook exceptions + "InvalidWebhookSignatureException", + "WebhookMissingSignatureException", + "WebhookVerificationException", + # Feature exceptions + "FeatureNotFoundException", + "FeatureNotFoundError", + "FeatureNotAvailableException", + "InvalidFeatureCodesError", +] + + +# ============================================================================= +# Base Billing Exception +# ============================================================================= class BillingException(BusinessLogicException): """Base exception for billing module errors.""" - pass + def __init__(self, message: str, error_code: str = "BILLING_ERROR", details: dict | None = None): + super().__init__(message=message, error_code=error_code, details=details) + + +# ============================================================================= +# Subscription Exceptions +# ============================================================================= class SubscriptionNotFoundException(ResourceNotFoundException): """Raised when a subscription is not found.""" def __init__(self, vendor_id: int): - super().__init__("Subscription", str(vendor_id)) + super().__init__( + resource_type="Subscription", + identifier=str(vendor_id), + error_code="SUBSCRIPTION_NOT_FOUND", + ) + + +class NoActiveSubscriptionException(BusinessLogicException): + """Raised when no active subscription exists for an operation that requires one.""" + + def __init__(self, message: str = "No active subscription found"): + super().__init__( + message=message, + error_code="NO_ACTIVE_SUBSCRIPTION", + ) + + +class SubscriptionNotCancelledException(BusinessLogicException): + """Raised when trying to reactivate a subscription that is not cancelled.""" + + def __init__(self): + super().__init__( + message="Subscription is not cancelled", + error_code="SUBSCRIPTION_NOT_CANCELLED", + ) + + +class SubscriptionAlreadyCancelledException(BusinessLogicException): + """Raised when trying to cancel an already cancelled subscription.""" + + def __init__(self): + super().__init__( + message="Subscription is already cancelled", + error_code="SUBSCRIPTION_ALREADY_CANCELLED", + ) + + +# ============================================================================= +# Tier Exceptions +# ============================================================================= class TierNotFoundException(ResourceNotFoundException): """Raised when a subscription tier is not found.""" def __init__(self, tier_code: str): - super().__init__("SubscriptionTier", tier_code) + super().__init__( + resource_type="SubscriptionTier", + identifier=tier_code, + message=f"Subscription tier '{tier_code}' not found", + error_code="TIER_NOT_FOUND", + ) + self.tier_code = tier_code + + +class TierNotFoundError(ResourceNotFoundException): + """Subscription tier not found (alternate naming).""" + + def __init__(self, tier_code: str): + super().__init__( + resource_type="SubscriptionTier", + identifier=tier_code, + message=f"Tier '{tier_code}' not found", + ) + self.tier_code = tier_code class TierLimitExceededException(BillingException): """Raised when a tier limit is exceeded.""" def __init__(self, message: str, limit_type: str, current: int, limit: int): - super().__init__(message) + super().__init__( + message=message, + error_code="TIER_LIMIT_EXCEEDED", + details={ + "limit_type": limit_type, + "current": current, + "limit": limit, + }, + ) self.limit_type = limit_type self.current = current self.limit = limit +# ============================================================================= +# Payment Exceptions +# ============================================================================= + + +class PaymentSystemNotConfiguredException(ServiceUnavailableException): + """Raised when the payment system (Stripe) is not configured.""" + + def __init__(self): + super().__init__(message="Payment system not configured") + + +class StripeNotConfiguredException(BillingException): + """Raised when Stripe is not configured.""" + + def __init__(self): + super().__init__( + message="Stripe is not configured", + error_code="STRIPE_NOT_CONFIGURED", + ) + + +class StripePriceNotConfiguredException(BusinessLogicException): + """Raised when Stripe price is not configured for a tier.""" + + def __init__(self, tier_code: str): + super().__init__( + message=f"Stripe price not configured for tier '{tier_code}'", + error_code="STRIPE_PRICE_NOT_CONFIGURED", + details={"tier_code": tier_code}, + ) + self.tier_code = tier_code + + +class PaymentFailedException(BillingException): + """Raised when a payment fails.""" + + def __init__(self, message: str, stripe_error: str | None = None): + details = {} + if stripe_error: + details["stripe_error"] = stripe_error + super().__init__( + message=message, + error_code="PAYMENT_FAILED", + details=details if details else None, + ) + self.stripe_error = stripe_error + + +# ============================================================================= +# Webhook Exceptions +# ============================================================================= + + +class InvalidWebhookSignatureException(BusinessLogicException): + """Raised when Stripe webhook signature verification fails.""" + + def __init__(self, message: str = "Invalid webhook signature"): + super().__init__( + message=message, + error_code="INVALID_WEBHOOK_SIGNATURE", + ) + + +class WebhookMissingSignatureException(BusinessLogicException): + """Raised when Stripe webhook is missing the signature header.""" + + def __init__(self): + super().__init__( + message="Missing Stripe-Signature header", + error_code="WEBHOOK_MISSING_SIGNATURE", + ) + + +class WebhookVerificationException(BillingException): + """Raised when webhook signature verification fails.""" + + def __init__(self, message: str = "Invalid webhook signature"): + super().__init__( + message=message, + error_code="WEBHOOK_VERIFICATION_FAILED", + ) + + +# ============================================================================= +# Feature Exceptions +# ============================================================================= + + +class FeatureNotFoundException(ResourceNotFoundException): + """Raised when a feature is not found.""" + + def __init__(self, feature_code: str): + super().__init__( + resource_type="Feature", + identifier=feature_code, + message=f"Feature '{feature_code}' not found", + ) + self.feature_code = feature_code + + +class FeatureNotFoundError(ResourceNotFoundException): + """Feature not found (alternate naming).""" + + def __init__(self, feature_code: str): + super().__init__( + resource_type="Feature", + identifier=feature_code, + message=f"Feature '{feature_code}' not found", + ) + self.feature_code = feature_code + + class FeatureNotAvailableException(BillingException): """Raised when a feature is not available in current tier.""" def __init__(self, feature: str, current_tier: str, required_tier: str): message = f"Feature '{feature}' requires {required_tier} tier (current: {current_tier})" - super().__init__(message) + super().__init__( + message=message, + error_code="FEATURE_NOT_AVAILABLE", + details={ + "feature": feature, + "current_tier": current_tier, + "required_tier": required_tier, + }, + ) self.feature = feature self.current_tier = current_tier self.required_tier = required_tier -class StripeNotConfiguredException(BillingException): - """Raised when Stripe is not configured.""" +class InvalidFeatureCodesError(ValidationException): + """Invalid feature codes provided.""" - def __init__(self): - super().__init__("Stripe is not configured") - - -class PaymentFailedException(BillingException): - """Raised when a payment fails.""" - - def __init__(self, message: str, stripe_error: str | None = None): - super().__init__(message) - self.stripe_error = stripe_error - - -class WebhookVerificationException(BillingException): - """Raised when webhook signature verification fails.""" - - def __init__(self, message: str = "Invalid webhook signature"): - super().__init__(message) - - -__all__ = [ - "BillingException", - "SubscriptionNotFoundException", - "TierNotFoundException", - "TierLimitExceededException", - "FeatureNotAvailableException", - "StripeNotConfiguredException", - "PaymentFailedException", - "WebhookVerificationException", -] + def __init__(self, invalid_codes: set[str]): + codes_str = ", ".join(sorted(invalid_codes)) + super().__init__( + message=f"Invalid feature codes: {codes_str}", + details={"invalid_codes": list(invalid_codes)}, + ) + self.invalid_codes = invalid_codes diff --git a/app/modules/billing/routes/api/admin_features.py b/app/modules/billing/routes/api/admin_features.py index cf92a101..31269928 100644 --- a/app/modules/billing/routes/api/admin_features.py +++ b/app/modules/billing/routes/api/admin_features.py @@ -19,7 +19,7 @@ from sqlalchemy.orm import Session from app.api.deps import get_current_admin_api, require_module_access from app.core.database import get_db -from app.services.feature_service import feature_service +from app.modules.billing.services.feature_service import feature_service from models.schema.auth import UserContext admin_features_router = APIRouter( @@ -211,7 +211,7 @@ def get_feature( feature = feature_service.get_feature_by_code(db, feature_code) if not feature: - from app.exceptions import FeatureNotFoundError + from app.modules.billing.exceptions import FeatureNotFoundError raise FeatureNotFoundError(feature_code) diff --git a/app/api/v1/platform/pricing.py b/app/modules/billing/routes/api/public.py similarity index 97% rename from app/api/v1/platform/pricing.py rename to app/modules/billing/routes/api/public.py index a49746fd..6f1370e0 100644 --- a/app/api/v1/platform/pricing.py +++ b/app/modules/billing/routes/api/public.py @@ -1,4 +1,4 @@ -# app/api/v1/platform/pricing.py +# app/modules/billing/routes/api/public.py """ Public pricing API endpoints. @@ -14,10 +14,10 @@ from sqlalchemy.orm import Session from app.core.database import get_db from app.exceptions import ResourceNotFoundException -from app.services.platform_pricing_service import platform_pricing_service +from app.modules.billing.services.platform_pricing_service import platform_pricing_service from app.modules.billing.models import TierCode -router = APIRouter() +router = APIRouter(prefix="/pricing") # ============================================================================= @@ -230,7 +230,7 @@ def get_addons(db: Session = Depends(get_db)) -> list[AddOnResponse]: return [_addon_to_response(addon) for addon in addons] -@router.get("/pricing", response_model=PricingResponse) # public +@router.get("", response_model=PricingResponse) # public def get_pricing(db: Session = Depends(get_db)) -> PricingResponse: """ Get complete pricing information (tiers + add-ons). diff --git a/app/modules/billing/routes/api/vendor_features.py b/app/modules/billing/routes/api/vendor_features.py index 5a0e7e43..6eb3d05b 100644 --- a/app/modules/billing/routes/api/vendor_features.py +++ b/app/modules/billing/routes/api/vendor_features.py @@ -24,8 +24,8 @@ from sqlalchemy.orm import Session from app.api.deps import get_current_vendor_api, require_module_access from app.core.database import get_db -from app.exceptions import FeatureNotFoundError -from app.services.feature_service import feature_service +from app.modules.billing.exceptions import FeatureNotFoundError +from app.modules.billing.services.feature_service import feature_service from models.schema.auth import UserContext vendor_features_router = APIRouter( @@ -134,7 +134,7 @@ def get_available_features( vendor_id = current_user.token_vendor_id # Get subscription for tier info - from app.services.subscription_service import subscription_service + from app.modules.billing.services.subscription_service import subscription_service subscription = subscription_service.get_or_create_subscription(db, vendor_id) tier = subscription.tier_obj @@ -175,7 +175,7 @@ def get_features( vendor_id = current_user.token_vendor_id # Get subscription for tier info - from app.services.subscription_service import subscription_service + from app.modules.billing.services.subscription_service import subscription_service subscription = subscription_service.get_or_create_subscription(db, vendor_id) tier = subscription.tier_obj diff --git a/app/modules/billing/routes/api/vendor_usage.py b/app/modules/billing/routes/api/vendor_usage.py index 7d6664f3..c09bdd99 100644 --- a/app/modules/billing/routes/api/vendor_usage.py +++ b/app/modules/billing/routes/api/vendor_usage.py @@ -18,7 +18,7 @@ from sqlalchemy.orm import Session from app.api.deps import get_current_vendor_api, require_module_access from app.core.database import get_db -from app.services.usage_service import usage_service +from app.modules.analytics.services.usage_service import usage_service from models.schema.auth import UserContext vendor_usage_router = APIRouter( diff --git a/app/modules/billing/routes/pages/__init__.py b/app/modules/billing/routes/pages/__init__.py index 56749c30..a827d945 100644 --- a/app/modules/billing/routes/pages/__init__.py +++ b/app/modules/billing/routes/pages/__init__.py @@ -1,18 +1,2 @@ # app/modules/billing/routes/pages/__init__.py -""" -Billing module page routes (HTML rendering). - -Provides Jinja2 template rendering for billing management: -- Admin pages: Subscription tiers, subscriptions list, billing history -- Vendor pages: Billing dashboard, invoices - -Note: These routes are placeholders. The actual page rendering -is currently handled by routes in app/api/v1/ and can be migrated here. -""" - -__all__ = [] - - -def __getattr__(name: str): - """Lazy import routers to avoid circular dependencies.""" - raise AttributeError(f"module {__name__!r} has no attribute {name!r}") +"""Billing module page routes.""" diff --git a/app/modules/billing/routes/pages/admin.py b/app/modules/billing/routes/pages/admin.py new file mode 100644 index 00000000..1897b949 --- /dev/null +++ b/app/modules/billing/routes/pages/admin.py @@ -0,0 +1,80 @@ +# app/modules/billing/routes/pages/admin.py +""" +Billing Admin Page Routes (HTML rendering). + +Admin pages for billing and subscription management: +- Subscription tiers +- Subscriptions list +- Billing history +""" + +from fastapi import APIRouter, Depends, Request +from fastapi.responses import HTMLResponse +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 + +router = APIRouter() + + +# ============================================================================ +# BILLING & SUBSCRIPTIONS ROUTES +# ============================================================================ + + +@router.get("/subscription-tiers", response_class=HTMLResponse, include_in_schema=False) +async def admin_subscription_tiers_page( + request: Request, + current_user: User = Depends( + require_menu_access("subscription-tiers", FrontendType.ADMIN) + ), + db: Session = Depends(get_db), +): + """ + Render subscription tiers management page. + Shows all subscription tiers with their limits and pricing. + """ + return templates.TemplateResponse( + "billing/admin/subscription-tiers.html", + get_admin_context(request, current_user), + ) + + +@router.get("/subscriptions", response_class=HTMLResponse, include_in_schema=False) +async def admin_subscriptions_page( + request: Request, + current_user: User = Depends( + require_menu_access("subscriptions", FrontendType.ADMIN) + ), + db: Session = Depends(get_db), +): + """ + Render vendor subscriptions management page. + Shows all vendor subscriptions with status and usage. + """ + return templates.TemplateResponse( + "billing/admin/subscriptions.html", + get_admin_context(request, current_user), + ) + + +@router.get("/billing-history", response_class=HTMLResponse, include_in_schema=False) +async def admin_billing_history_page( + request: Request, + current_user: User = Depends( + require_menu_access("billing-history", FrontendType.ADMIN) + ), + db: Session = Depends(get_db), +): + """ + Render billing history page. + Shows invoices and payments across all vendors. + """ + return templates.TemplateResponse( + "billing/admin/billing-history.html", + get_admin_context(request, current_user), + ) diff --git a/app/modules/billing/routes/pages/public.py b/app/modules/billing/routes/pages/public.py new file mode 100644 index 00000000..a6659df8 --- /dev/null +++ b/app/modules/billing/routes/pages/public.py @@ -0,0 +1,121 @@ +# app/modules/billing/routes/pages/public.py +""" +Billing Public Page Routes (HTML rendering). + +Public (unauthenticated) pages for pricing and signup: +- Pricing page +- Signup wizard +- Signup success +""" + +from fastapi import APIRouter, Depends, Request +from fastapi.responses import HTMLResponse +from sqlalchemy.orm import Session + +from app.core.database import get_db +from app.modules.billing.models import TIER_LIMITS, TierCode +from app.modules.core.utils.page_context import get_public_context +from app.templates_config import templates + +router = APIRouter() + + +def _get_tiers_data() -> list[dict]: + """Build tier data for display in templates.""" + tiers = [] + for tier_code, limits in TIER_LIMITS.items(): + tiers.append( + { + "code": tier_code.value, + "name": limits["name"], + "price_monthly": limits["price_monthly_cents"] / 100, + "price_annual": (limits["price_annual_cents"] / 100) + if limits.get("price_annual_cents") + else None, + "orders_per_month": limits.get("orders_per_month"), + "products_limit": limits.get("products_limit"), + "team_members": limits.get("team_members"), + "order_history_months": limits.get("order_history_months"), + "features": limits.get("features", []), + "is_popular": tier_code == TierCode.PROFESSIONAL, + "is_enterprise": tier_code == TierCode.ENTERPRISE, + } + ) + return tiers + + +# ============================================================================ +# PRICING PAGE +# ============================================================================ + + +@router.get("/pricing", response_class=HTMLResponse, name="platform_pricing") +async def pricing_page( + request: Request, + db: Session = Depends(get_db), +): + """ + Standalone pricing page with detailed tier comparison. + """ + context = get_public_context(request, db) + context["tiers"] = _get_tiers_data() + context["page_title"] = "Pricing" + + return templates.TemplateResponse( + "billing/public/pricing.html", + context, + ) + + +# ============================================================================ +# SIGNUP WIZARD +# ============================================================================ + + +@router.get("/signup", response_class=HTMLResponse, name="platform_signup") +async def signup_page( + request: Request, + tier: str | None = None, + annual: bool = False, + db: Session = Depends(get_db), +): + """ + Multi-step signup wizard. + + Query params: + - tier: Pre-selected tier code + - annual: Pre-select annual billing + """ + context = get_public_context(request, db) + context["page_title"] = "Start Your Free Trial" + context["selected_tier"] = tier + context["is_annual"] = annual + context["tiers"] = _get_tiers_data() + + return templates.TemplateResponse( + "billing/public/signup.html", + context, + ) + + +@router.get( + "/signup/success", response_class=HTMLResponse, name="platform_signup_success" +) +async def signup_success_page( + request: Request, + vendor_code: str | None = None, + db: Session = Depends(get_db), +): + """ + Signup success page. + + Shown after successful account creation. + """ + context = get_public_context(request, db) + context["page_title"] = "Welcome to Wizamart!" + context["vendor_code"] = vendor_code + + return templates.TemplateResponse( + "billing/public/signup-success.html", + context, + ) diff --git a/app/modules/billing/routes/pages/vendor.py b/app/modules/billing/routes/pages/vendor.py new file mode 100644 index 00000000..92f86e64 --- /dev/null +++ b/app/modules/billing/routes/pages/vendor.py @@ -0,0 +1,62 @@ +# app/modules/billing/routes/pages/vendor.py +""" +Billing Vendor Page Routes (HTML rendering). + +Vendor pages for billing management: +- Billing dashboard +- Invoices +""" + +from fastapi import APIRouter, Depends, Path, Request +from fastapi.responses import HTMLResponse +from sqlalchemy.orm import Session + +from app.api.deps import get_current_vendor_from_cookie_or_header, get_db +from app.modules.core.utils.page_context import get_vendor_context +from app.templates_config import templates +from models.database.user import User + +router = APIRouter() + + +# ============================================================================ +# BILLING ROUTES +# ============================================================================ + + +@router.get( + "/{vendor_code}/billing", response_class=HTMLResponse, include_in_schema=False +) +async def vendor_billing_page( + request: Request, + vendor_code: str = Path(..., description="Vendor code"), + current_user: User = Depends(get_current_vendor_from_cookie_or_header), + db: Session = Depends(get_db), +): + """ + Render billing and subscription management page. + JavaScript loads subscription status, tiers, and invoices via API. + """ + return templates.TemplateResponse( + "billing/vendor/billing.html", + get_vendor_context(request, db, current_user, vendor_code), + ) + + +@router.get( + "/{vendor_code}/invoices", response_class=HTMLResponse, include_in_schema=False +) +async def vendor_invoices_page( + request: Request, + vendor_code: str = Path(..., description="Vendor code"), + current_user: User = Depends(get_current_vendor_from_cookie_or_header), + db: Session = Depends(get_db), +): + """ + Render invoices management page. + JavaScript loads invoices via API. + """ + return templates.TemplateResponse( + "orders/vendor/invoices.html", + get_vendor_context(request, db, current_user, vendor_code), + ) diff --git a/app/modules/billing/services/__init__.py b/app/modules/billing/services/__init__.py index 2ae7b2e0..ac18af0e 100644 --- a/app/modules/billing/services/__init__.py +++ b/app/modules/billing/services/__init__.py @@ -27,6 +27,21 @@ from app.modules.billing.services.billing_service import ( NoActiveSubscriptionError, SubscriptionNotCancelledError, ) +from app.modules.billing.services.feature_service import ( + FeatureService, + feature_service, + FeatureInfo, + FeatureUpgradeInfo, + FeatureCode, +) +from app.modules.billing.services.capacity_forecast_service import ( + CapacityForecastService, + capacity_forecast_service, +) +from app.modules.billing.services.platform_pricing_service import ( + PlatformPricingService, + platform_pricing_service, +) __all__ = [ "SubscriptionService", @@ -43,4 +58,13 @@ __all__ = [ "StripePriceNotConfiguredError", "NoActiveSubscriptionError", "SubscriptionNotCancelledError", + "FeatureService", + "feature_service", + "FeatureInfo", + "FeatureUpgradeInfo", + "FeatureCode", + "CapacityForecastService", + "capacity_forecast_service", + "PlatformPricingService", + "platform_pricing_service", ] diff --git a/app/modules/billing/services/billing_service.py b/app/modules/billing/services/billing_service.py index 3dde95b2..6bd0b798 100644 --- a/app/modules/billing/services/billing_service.py +++ b/app/modules/billing/services/billing_service.py @@ -162,7 +162,7 @@ class BillingService: Raises: VendorNotFoundException from app.exceptions """ - from app.exceptions import VendorNotFoundException + from app.modules.tenancy.exceptions import VendorNotFoundException vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first() if not vendor: diff --git a/app/services/capacity_forecast_service.py b/app/modules/billing/services/capacity_forecast_service.py similarity index 97% rename from app/services/capacity_forecast_service.py rename to app/modules/billing/services/capacity_forecast_service.py index f68bb36b..ff18621e 100644 --- a/app/services/capacity_forecast_service.py +++ b/app/modules/billing/services/capacity_forecast_service.py @@ -1,4 +1,4 @@ -# app/services/capacity_forecast_service.py +# app/modules/billing/services/capacity_forecast_service.py """ Capacity forecasting service for growth trends and scaling recommendations. @@ -47,8 +47,8 @@ class CapacityForecastService: Should be called by a daily background job. """ - from app.services.image_service import image_service - from app.services.platform_health_service import platform_health_service + from app.modules.core.services.image_service import image_service + from app.modules.monitoring.services.platform_health_service import platform_health_service now = datetime.now(UTC) today = now.replace(hour=0, minute=0, second=0, microsecond=0) @@ -228,7 +228,7 @@ class CapacityForecastService: Returns prioritized list of recommendations. """ - from app.services.platform_health_service import platform_health_service + from app.modules.monitoring.services.platform_health_service import platform_health_service recommendations = [] diff --git a/app/services/feature_service.py b/app/modules/billing/services/feature_service.py similarity index 99% rename from app/services/feature_service.py rename to app/modules/billing/services/feature_service.py index f6434f7a..9a5c95d6 100644 --- a/app/services/feature_service.py +++ b/app/modules/billing/services/feature_service.py @@ -1,4 +1,4 @@ -# app/services/feature_service.py +# app/modules/billing/services/feature_service.py """ Feature service for tier-based access control. @@ -9,7 +9,7 @@ Provides: - Cache invalidation on subscription changes Usage: - from app.services.feature_service import feature_service + from app.modules.billing.services.feature_service import feature_service # Check if vendor has feature if feature_service.has_feature(db, vendor_id, FeatureCode.ANALYTICS_DASHBOARD): @@ -29,7 +29,7 @@ from functools import lru_cache from sqlalchemy.orm import Session, joinedload -from app.exceptions.feature import ( +from app.modules.billing.exceptions import ( FeatureNotFoundError, InvalidFeatureCodesError, TierNotFoundError, diff --git a/app/services/platform_pricing_service.py b/app/modules/billing/services/platform_pricing_service.py similarity index 97% rename from app/services/platform_pricing_service.py rename to app/modules/billing/services/platform_pricing_service.py index 9f26f001..64ad237a 100644 --- a/app/services/platform_pricing_service.py +++ b/app/modules/billing/services/platform_pricing_service.py @@ -1,4 +1,4 @@ -# app/services/platform_pricing_service.py +# app/modules/billing/services/platform_pricing_service.py """ Platform pricing service. diff --git a/app/templates/admin/billing-history.html b/app/modules/billing/templates/billing/admin/billing-history.html similarity index 100% rename from app/templates/admin/billing-history.html rename to app/modules/billing/templates/billing/admin/billing-history.html diff --git a/app/templates/admin/features.html b/app/modules/billing/templates/billing/admin/features.html similarity index 100% rename from app/templates/admin/features.html rename to app/modules/billing/templates/billing/admin/features.html diff --git a/app/templates/admin/subscription-tiers.html b/app/modules/billing/templates/billing/admin/subscription-tiers.html similarity index 100% rename from app/templates/admin/subscription-tiers.html rename to app/modules/billing/templates/billing/admin/subscription-tiers.html diff --git a/app/templates/admin/subscriptions.html b/app/modules/billing/templates/billing/admin/subscriptions.html similarity index 100% rename from app/templates/admin/subscriptions.html rename to app/modules/billing/templates/billing/admin/subscriptions.html diff --git a/app/templates/platform/pricing.html b/app/modules/billing/templates/billing/public/pricing.html similarity index 99% rename from app/templates/platform/pricing.html rename to app/modules/billing/templates/billing/public/pricing.html index d7b5e1f9..e12cd1e2 100644 --- a/app/templates/platform/pricing.html +++ b/app/modules/billing/templates/billing/public/pricing.html @@ -1,6 +1,6 @@ {# app/templates/platform/pricing.html #} {# Standalone Pricing Page #} -{% extends "platform/base.html" %} +{% extends "public/base.html" %} {% block title %}{{ _("platform.pricing.title") }} - Wizamart{% endblock %} diff --git a/app/templates/platform/signup-success.html b/app/modules/billing/templates/billing/public/signup-success.html similarity index 99% rename from app/templates/platform/signup-success.html rename to app/modules/billing/templates/billing/public/signup-success.html index a26f3e54..c13c8d7d 100644 --- a/app/templates/platform/signup-success.html +++ b/app/modules/billing/templates/billing/public/signup-success.html @@ -1,6 +1,6 @@ {# app/templates/platform/signup-success.html #} {# Signup Success Page #} -{% extends "platform/base.html" %} +{% extends "public/base.html" %} {% block title %}{{ _("platform.success.title") }}{% endblock %} diff --git a/app/templates/platform/signup.html b/app/modules/billing/templates/billing/public/signup.html similarity index 98% rename from app/templates/platform/signup.html rename to app/modules/billing/templates/billing/public/signup.html index e79de3e1..f7f47fff 100644 --- a/app/templates/platform/signup.html +++ b/app/modules/billing/templates/billing/public/signup.html @@ -1,6 +1,6 @@ {# app/templates/platform/signup.html #} {# Multi-step Signup Wizard #} -{% extends "platform/base.html" %} +{% extends "public/base.html" %} {% block title %}Start Your Free Trial - Wizamart{% endblock %} @@ -321,7 +321,7 @@ function signupWizard() { async startSignup() { this.loading = true; try { - const response = await fetch('/api/v1/platform/signup/start', { + const response = await fetch('/api/v1/public/signup/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -352,7 +352,7 @@ function signupWizard() { try { // First lookup the vendor - const lookupResponse = await fetch('/api/v1/platform/letzshop-vendors/lookup', { + const lookupResponse = await fetch('/api/v1/public/letzshop-vendors/lookup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: this.letzshopUrl }) @@ -364,7 +364,7 @@ function signupWizard() { this.letzshopVendor = lookupData.vendor; // Claim the vendor - const claimResponse = await fetch('/api/v1/platform/signup/claim-vendor', { + const claimResponse = await fetch('/api/v1/public/signup/claim-vendor', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -411,7 +411,7 @@ function signupWizard() { this.accountError = null; try { - const response = await fetch('/api/v1/platform/signup/create-account', { + const response = await fetch('/api/v1/public/signup/create-account', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -461,7 +461,7 @@ function signupWizard() { // Get SetupIntent try { - const response = await fetch('/api/v1/platform/signup/setup-payment', { + const response = await fetch('/api/v1/public/signup/setup-payment', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session_id: this.sessionId }) @@ -500,7 +500,7 @@ function signupWizard() { } // Complete signup - const response = await fetch('/api/v1/platform/signup/complete', { + const response = await fetch('/api/v1/public/signup/complete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ diff --git a/app/templates/vendor/billing.html b/app/modules/billing/templates/billing/vendor/billing.html similarity index 100% rename from app/templates/vendor/billing.html rename to app/modules/billing/templates/billing/vendor/billing.html diff --git a/app/modules/cart/exceptions.py b/app/modules/cart/exceptions.py index 5610822e..a9867ff8 100644 --- a/app/modules/cart/exceptions.py +++ b/app/modules/cart/exceptions.py @@ -2,7 +2,10 @@ """ Cart module exceptions. -Module-specific exceptions for shopping cart operations. +This module provides exception classes for cart operations including: +- Cart item management +- Quantity validation +- Inventory checks """ from app.exceptions.base import ( @@ -11,6 +14,15 @@ from app.exceptions.base import ( ValidationException, ) +__all__ = [ + "CartItemNotFoundException", + "EmptyCartException", + "CartValidationException", + "InsufficientInventoryForCartException", + "InvalidCartQuantityException", + "ProductNotAvailableForCartException", +] + class CartItemNotFoundException(ResourceNotFoundException): """Raised when a cart item is not found.""" @@ -115,11 +127,3 @@ class ProductNotAvailableForCartException(BusinessLogicException): ) -__all__ = [ - "CartItemNotFoundException", - "CartValidationException", - "EmptyCartException", - "InsufficientInventoryForCartException", - "InvalidCartQuantityException", - "ProductNotAvailableForCartException", -] diff --git a/app/modules/cart/routes/pages/__init__.py b/app/modules/cart/routes/pages/__init__.py new file mode 100644 index 00000000..cdbd1022 --- /dev/null +++ b/app/modules/cart/routes/pages/__init__.py @@ -0,0 +1,2 @@ +# app/modules/cart/routes/pages/__init__.py +"""Cart module page routes.""" diff --git a/app/modules/cart/routes/pages/storefront.py b/app/modules/cart/routes/pages/storefront.py new file mode 100644 index 00000000..b034e035 --- /dev/null +++ b/app/modules/cart/routes/pages/storefront.py @@ -0,0 +1,46 @@ +# app/modules/cart/routes/pages/storefront.py +""" +Cart Storefront Page Routes (HTML rendering). + +Storefront (customer shop) pages for shopping cart: +- Cart page +""" + +import logging + +from fastapi import APIRouter, Depends, Request +from fastapi.responses import HTMLResponse +from sqlalchemy.orm import Session + +from app.api.deps import get_db +from app.modules.core.utils.page_context import get_storefront_context +from app.templates_config import templates + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +# ============================================================================ +# SHOPPING CART +# ============================================================================ + + +@router.get("/cart", response_class=HTMLResponse, include_in_schema=False) +async def shop_cart_page(request: Request, db: Session = Depends(get_db)): + """ + Render shopping cart page. + Shows cart items and allows quantity updates. + """ + logger.debug( + "[STOREFRONT] shop_cart_page REACHED", + extra={ + "path": request.url.path, + "vendor": getattr(request.state, "vendor", "NOT SET"), + "context": getattr(request.state, "context_type", "NOT SET"), + }, + ) + + return templates.TemplateResponse( + "cart/storefront/cart.html", get_storefront_context(request, db=db) + ) diff --git a/app/modules/cart/services/cart_service.py b/app/modules/cart/services/cart_service.py index b6d826ea..b3ac629c 100644 --- a/app/modules/cart/services/cart_service.py +++ b/app/modules/cart/services/cart_service.py @@ -16,12 +16,12 @@ import logging from sqlalchemy import and_ from sqlalchemy.orm import Session -from app.exceptions import ( +from app.modules.cart.exceptions import ( CartItemNotFoundException, InsufficientInventoryForCartException, InvalidCartQuantityException, - ProductNotFoundException, ) +from app.modules.catalog.exceptions import ProductNotFoundException from app.utils.money import cents_to_euros from app.modules.cart.models.cart import CartItem from app.modules.catalog.models import Product diff --git a/app/templates/storefront/cart.html b/app/modules/cart/templates/cart/storefront/cart.html similarity index 100% rename from app/templates/storefront/cart.html rename to app/modules/cart/templates/cart/storefront/cart.html diff --git a/app/modules/catalog/exceptions.py b/app/modules/catalog/exceptions.py index 675f2447..730ab6f7 100644 --- a/app/modules/catalog/exceptions.py +++ b/app/modules/catalog/exceptions.py @@ -2,7 +2,10 @@ """ Catalog module exceptions. -Module-specific exceptions for product catalog operations. +This module provides exception classes for catalog operations including: +- Product management (CRUD) +- Product validation +- Product dependencies (inventory, orders) """ from app.exceptions.base import ( @@ -12,6 +15,19 @@ from app.exceptions.base import ( ValidationException, ) +__all__ = [ + "ProductNotFoundException", + "ProductAlreadyExistsException", + "ProductNotInCatalogException", + "ProductNotActiveException", + "InvalidProductDataException", + "ProductValidationException", + "CannotDeleteProductException", + "CannotDeleteProductWithInventoryException", + "CannotDeleteProductWithOrdersException", + "ProductMediaException", +] + class ProductNotFoundException(ResourceNotFoundException): """Raised when a product is not found in vendor catalog.""" @@ -56,11 +72,9 @@ class ProductNotInCatalogException(ResourceNotFoundException): identifier=str(product_id), message=f"Product {product_id} is not in vendor {vendor_id} catalog", error_code="PRODUCT_NOT_IN_CATALOG", - details={ - "product_id": product_id, - "vendor_id": vendor_id, - }, ) + self.details["product_id"] = product_id + self.details["vendor_id"] = vendor_id class ProductNotActiveException(BusinessLogicException): @@ -77,6 +91,23 @@ class ProductNotActiveException(BusinessLogicException): ) +class InvalidProductDataException(ValidationException): + """Raised when product data is invalid.""" + + def __init__( + self, + message: str = "Invalid product data", + field: str | None = None, + details: dict | None = None, + ): + super().__init__( + message=message, + field=field, + details=details, + ) + self.error_code = "INVALID_PRODUCT_DATA" + + class ProductValidationException(ValidationException): """Raised when product data validation fails.""" @@ -109,6 +140,34 @@ class CannotDeleteProductException(BusinessLogicException): ) +class CannotDeleteProductWithInventoryException(BusinessLogicException): + """Raised when trying to delete a product that has inventory.""" + + def __init__(self, product_id: int, inventory_count: int): + super().__init__( + message=f"Cannot delete product {product_id} - it has {inventory_count} inventory entries", + error_code="CANNOT_DELETE_PRODUCT_WITH_INVENTORY", + details={ + "product_id": product_id, + "inventory_count": inventory_count, + }, + ) + + +class CannotDeleteProductWithOrdersException(BusinessLogicException): + """Raised when trying to delete a product that has been ordered.""" + + def __init__(self, product_id: int, order_count: int): + super().__init__( + message=f"Cannot delete product {product_id} - it has {order_count} associated orders", + error_code="CANNOT_DELETE_PRODUCT_WITH_ORDERS", + details={ + "product_id": product_id, + "order_count": order_count, + }, + ) + + class ProductMediaException(BusinessLogicException): """Raised when there's an issue with product media.""" @@ -118,14 +177,3 @@ class ProductMediaException(BusinessLogicException): error_code="PRODUCT_MEDIA_ERROR", details={"product_id": product_id}, ) - - -__all__ = [ - "CannotDeleteProductException", - "ProductAlreadyExistsException", - "ProductMediaException", - "ProductNotActiveException", - "ProductNotFoundException", - "ProductNotInCatalogException", - "ProductValidationException", -] diff --git a/app/modules/catalog/routes/api/admin.py b/app/modules/catalog/routes/api/admin.py index 0894f273..3bc7792c 100644 --- a/app/modules/catalog/routes/api/admin.py +++ b/app/modules/catalog/routes/api/admin.py @@ -17,8 +17,8 @@ from sqlalchemy.orm import Session from app.api.deps import get_current_admin_api, require_module_access from app.core.database import get_db -from app.services.subscription_service import subscription_service -from app.services.vendor_product_service import vendor_product_service +from app.modules.billing.services.subscription_service import subscription_service +from app.modules.catalog.services.vendor_product_service import vendor_product_service from models.schema.auth import UserContext from app.modules.catalog.schemas import ( CatalogVendor, diff --git a/app/modules/catalog/routes/api/storefront.py b/app/modules/catalog/routes/api/storefront.py index cb72b00f..22f13586 100644 --- a/app/modules/catalog/routes/api/storefront.py +++ b/app/modules/catalog/routes/api/storefront.py @@ -109,7 +109,7 @@ def get_product_details( # Check if product is active if not product.is_active: - from app.exceptions import ProductNotActiveException + from app.modules.catalog.exceptions import ProductNotActiveException raise ProductNotActiveException(str(product_id)) diff --git a/app/modules/catalog/routes/api/vendor.py b/app/modules/catalog/routes/api/vendor.py index 4dd79ad6..ab7beefb 100644 --- a/app/modules/catalog/routes/api/vendor.py +++ b/app/modules/catalog/routes/api/vendor.py @@ -15,9 +15,9 @@ from sqlalchemy.orm import Session from app.api.deps import get_current_vendor_api, require_module_access from app.core.database import get_db -from app.services.product_service import product_service -from app.services.subscription_service import subscription_service -from app.services.vendor_product_service import vendor_product_service +from app.modules.catalog.services.product_service import product_service +from app.modules.billing.services.subscription_service import subscription_service +from app.modules.catalog.services.vendor_product_service import vendor_product_service from models.schema.auth import UserContext from app.modules.catalog.schemas import ( ProductCreate, diff --git a/app/modules/catalog/routes/pages/__init__.py b/app/modules/catalog/routes/pages/__init__.py new file mode 100644 index 00000000..6ef897e9 --- /dev/null +++ b/app/modules/catalog/routes/pages/__init__.py @@ -0,0 +1,2 @@ +# app/modules/catalog/routes/pages/__init__.py +"""Catalog module page routes.""" diff --git a/app/modules/catalog/routes/pages/admin.py b/app/modules/catalog/routes/pages/admin.py new file mode 100644 index 00000000..1d6a0ce5 --- /dev/null +++ b/app/modules/catalog/routes/pages/admin.py @@ -0,0 +1,110 @@ +# app/modules/catalog/routes/pages/admin.py +""" +Catalog Admin Page Routes (HTML rendering). + +Admin pages for vendor product catalog management: +- Vendor products list +- Vendor product create +- Vendor product detail/edit +""" + +from fastapi import APIRouter, Depends, Path, Request +from fastapi.responses import HTMLResponse +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 + +router = APIRouter() + + +# ============================================================================ +# VENDOR PRODUCT CATALOG ROUTES +# ============================================================================ + + +@router.get("/vendor-products", response_class=HTMLResponse, include_in_schema=False) +async def admin_vendor_products_page( + request: Request, + current_user: User = Depends( + require_menu_access("vendor-products", FrontendType.ADMIN) + ), + db: Session = Depends(get_db), +): + """ + Render vendor products catalog page. + Browse vendor-specific product catalogs with override capability. + """ + return templates.TemplateResponse( + "catalog/admin/vendor-products.html", + get_admin_context(request, current_user), + ) + + +@router.get( + "/vendor-products/create", response_class=HTMLResponse, include_in_schema=False +) +async def admin_vendor_product_create_page( + request: Request, + current_user: User = Depends( + require_menu_access("vendor-products", FrontendType.ADMIN) + ), + db: Session = Depends(get_db), +): + """ + Render vendor product create page. + Create a new vendor product entry. + """ + return templates.TemplateResponse( + "catalog/admin/vendor-product-create.html", + get_admin_context(request, current_user), + ) + + +@router.get( + "/vendor-products/{product_id}", + response_class=HTMLResponse, + include_in_schema=False, +) +async def admin_vendor_product_detail_page( + request: Request, + product_id: int = Path(..., description="Vendor Product ID"), + current_user: User = Depends( + require_menu_access("vendor-products", FrontendType.ADMIN) + ), + db: Session = Depends(get_db), +): + """ + Render vendor product detail page. + Shows full product information with vendor-specific overrides. + """ + return templates.TemplateResponse( + "catalog/admin/vendor-product-detail.html", + get_admin_context(request, current_user, product_id=product_id), + ) + + +@router.get( + "/vendor-products/{product_id}/edit", + response_class=HTMLResponse, + include_in_schema=False, +) +async def admin_vendor_product_edit_page( + request: Request, + product_id: int = Path(..., description="Vendor Product ID"), + current_user: User = Depends( + require_menu_access("vendor-products", FrontendType.ADMIN) + ), + db: Session = Depends(get_db), +): + """ + Render vendor product edit page. + Edit vendor product information and overrides. + """ + return templates.TemplateResponse( + "catalog/admin/vendor-product-edit.html", + get_admin_context(request, current_user, product_id=product_id), + ) diff --git a/app/modules/catalog/routes/pages/storefront.py b/app/modules/catalog/routes/pages/storefront.py new file mode 100644 index 00000000..c26ac56a --- /dev/null +++ b/app/modules/catalog/routes/pages/storefront.py @@ -0,0 +1,171 @@ +# app/modules/catalog/routes/pages/storefront.py +""" +Catalog Storefront Page Routes (HTML rendering). + +Storefront (customer shop) pages for catalog browsing: +- Shop homepage / product catalog +- Product list +- Product detail +- Category products +- Search results +""" + +import logging + +from fastapi import APIRouter, Depends, Path, Request +from fastapi.responses import HTMLResponse +from sqlalchemy.orm import Session + +from app.api.deps import get_db +from app.modules.core.utils.page_context import get_storefront_context +from app.templates_config import templates + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +# ============================================================================ +# PUBLIC SHOP ROUTES (No Authentication Required) +# ============================================================================ + + +@router.get("/", response_class=HTMLResponse, include_in_schema=False) +@router.get("/products", response_class=HTMLResponse, include_in_schema=False) +async def shop_products_page(request: Request, db: Session = Depends(get_db)): + """ + Render shop homepage / product catalog. + Shows featured products and categories. + """ + logger.debug( + "[STOREFRONT] shop_products_page REACHED", + extra={ + "path": request.url.path, + "vendor": getattr(request.state, "vendor", "NOT SET"), + "context": getattr(request.state, "context_type", "NOT SET"), + }, + ) + + return templates.TemplateResponse( + "catalog/storefront/products.html", get_storefront_context(request, db=db) + ) + + +@router.get( + "/products/{product_id}", response_class=HTMLResponse, include_in_schema=False +) +async def shop_product_detail_page( + request: Request, + product_id: int = Path(..., description="Product ID"), + db: Session = Depends(get_db), +): + """ + Render product detail page. + Shows product information, images, reviews, and buy options. + """ + logger.debug( + "[STOREFRONT] shop_product_detail_page REACHED", + extra={ + "path": request.url.path, + "product_id": product_id, + "vendor": getattr(request.state, "vendor", "NOT SET"), + "context": getattr(request.state, "context_type", "NOT SET"), + }, + ) + + return templates.TemplateResponse( + "catalog/storefront/product.html", + get_storefront_context(request, db=db, product_id=product_id), + ) + + +@router.get( + "/categories/{category_slug}", + response_class=HTMLResponse, + include_in_schema=False, +) +async def shop_category_page( + request: Request, + category_slug: str = Path(..., description="Category slug"), + db: Session = Depends(get_db), +): + """ + Render category products page. + Shows all products in a specific category. + """ + logger.debug( + "[STOREFRONT] shop_category_page REACHED", + extra={ + "path": request.url.path, + "category_slug": category_slug, + "vendor": getattr(request.state, "vendor", "NOT SET"), + "context": getattr(request.state, "context_type", "NOT SET"), + }, + ) + + return templates.TemplateResponse( + "catalog/storefront/category.html", + get_storefront_context(request, db=db, category_slug=category_slug), + ) + + +@router.get("/search", response_class=HTMLResponse, include_in_schema=False) +async def shop_search_page(request: Request, db: Session = Depends(get_db)): + """ + Render search results page. + Shows products matching search query. + """ + logger.debug( + "[STOREFRONT] shop_search_page REACHED", + extra={ + "path": request.url.path, + "vendor": getattr(request.state, "vendor", "NOT SET"), + "context": getattr(request.state, "context_type", "NOT SET"), + }, + ) + + return templates.TemplateResponse( + "catalog/storefront/search.html", get_storefront_context(request, db=db) + ) + + +# ============================================================================ +# CUSTOMER ACCOUNT - WISHLIST +# ============================================================================ + + +@router.get( + "/account/wishlist", response_class=HTMLResponse, include_in_schema=False +) +async def shop_wishlist_page( + request: Request, + db: Session = Depends(get_db), +): + """ + Render customer wishlist page. + View and manage saved products. + Requires customer authentication (handled by middleware/template). + """ + from app.api.deps import get_current_customer_from_cookie_or_header + + # Get customer if authenticated + try: + current_customer = await get_current_customer_from_cookie_or_header( + request=request, db=db + ) + except Exception: + current_customer = None + + logger.debug( + "[STOREFRONT] shop_wishlist_page REACHED", + extra={ + "path": request.url.path, + "vendor": getattr(request.state, "vendor", "NOT SET"), + "context": getattr(request.state, "context_type", "NOT SET"), + }, + ) + + return templates.TemplateResponse( + "catalog/storefront/wishlist.html", + get_storefront_context(request, db=db, user=current_customer), + ) diff --git a/app/modules/catalog/routes/pages/vendor.py b/app/modules/catalog/routes/pages/vendor.py new file mode 100644 index 00000000..a637fe82 --- /dev/null +++ b/app/modules/catalog/routes/pages/vendor.py @@ -0,0 +1,64 @@ +# app/modules/catalog/routes/pages/vendor.py +""" +Catalog Vendor Page Routes (HTML rendering). + +Vendor pages for product management: +- Products list +- Product create +""" + +from fastapi import APIRouter, Depends, Path, Request +from fastapi.responses import HTMLResponse +from sqlalchemy.orm import Session + +from app.api.deps import get_current_vendor_from_cookie_or_header, get_db +from app.modules.core.utils.page_context import get_vendor_context +from app.templates_config import templates +from models.database.user import User + +router = APIRouter() + + +# ============================================================================ +# PRODUCT MANAGEMENT +# ============================================================================ + + +@router.get( + "/{vendor_code}/products", response_class=HTMLResponse, include_in_schema=False +) +async def vendor_products_page( + request: Request, + vendor_code: str = Path(..., description="Vendor code"), + current_user: User = Depends(get_current_vendor_from_cookie_or_header), + db: Session = Depends(get_db), +): + """ + Render products management page. + JavaScript loads product list via API. + """ + return templates.TemplateResponse( + "catalog/vendor/products.html", + get_vendor_context(request, db, current_user, vendor_code), + ) + + +@router.get( + "/{vendor_code}/products/create", + response_class=HTMLResponse, + include_in_schema=False, +) +async def vendor_product_create_page( + request: Request, + vendor_code: str = Path(..., description="Vendor code"), + current_user: User = Depends(get_current_vendor_from_cookie_or_header), + db: Session = Depends(get_db), +): + """ + Render product creation page. + JavaScript handles form submission via API. + """ + return templates.TemplateResponse( + "catalog/vendor/product-create.html", + get_vendor_context(request, db, current_user, vendor_code), + ) diff --git a/app/modules/catalog/services/__init__.py b/app/modules/catalog/services/__init__.py index 5a220728..533c2273 100644 --- a/app/modules/catalog/services/__init__.py +++ b/app/modules/catalog/services/__init__.py @@ -2,5 +2,16 @@ """Catalog module services.""" from app.modules.catalog.services.catalog_service import catalog_service +from app.modules.catalog.services.product_service import ProductService, product_service +from app.modules.catalog.services.vendor_product_service import ( + VendorProductService, + vendor_product_service, +) -__all__ = ["catalog_service"] +__all__ = [ + "catalog_service", + "ProductService", + "product_service", + "VendorProductService", + "vendor_product_service", +] diff --git a/app/modules/catalog/services/catalog_service.py b/app/modules/catalog/services/catalog_service.py index afef5155..e43a53ad 100644 --- a/app/modules/catalog/services/catalog_service.py +++ b/app/modules/catalog/services/catalog_service.py @@ -17,7 +17,8 @@ import logging from sqlalchemy import or_ from sqlalchemy.orm import Session, joinedload -from app.exceptions import ProductNotFoundException, ValidationException +from app.exceptions import ValidationException +from app.modules.catalog.exceptions import ProductNotFoundException from app.modules.catalog.models import Product, ProductTranslation logger = logging.getLogger(__name__) diff --git a/app/services/product_service.py b/app/modules/catalog/services/product_service.py similarity index 98% rename from app/services/product_service.py rename to app/modules/catalog/services/product_service.py index 0ca17d00..99ec233a 100644 --- a/app/services/product_service.py +++ b/app/modules/catalog/services/product_service.py @@ -1,4 +1,4 @@ -# app/services/product_service.py +# app/modules/catalog/services/product_service.py """ Product service for vendor catalog management. @@ -13,10 +13,10 @@ from datetime import UTC, datetime from sqlalchemy.orm import Session -from app.exceptions import ( +from app.exceptions import ValidationException +from app.modules.catalog.exceptions import ( ProductAlreadyExistsException, ProductNotFoundException, - ValidationException, ) from app.modules.marketplace.models import MarketplaceProduct from app.modules.catalog.models import Product diff --git a/app/services/vendor_product_service.py b/app/modules/catalog/services/vendor_product_service.py similarity index 99% rename from app/services/vendor_product_service.py rename to app/modules/catalog/services/vendor_product_service.py index 539d85bc..87cfffd4 100644 --- a/app/services/vendor_product_service.py +++ b/app/modules/catalog/services/vendor_product_service.py @@ -1,4 +1,4 @@ -# app/services/vendor_product_service.py +# app/modules/catalog/services/vendor_product_service.py """ Vendor product service for managing vendor-specific product catalogs. @@ -14,7 +14,7 @@ import logging from sqlalchemy import func from sqlalchemy.orm import Session, joinedload -from app.exceptions import ProductNotFoundException +from app.modules.catalog.exceptions import ProductNotFoundException from app.modules.catalog.models import Product from models.database.vendor import Vendor diff --git a/app/templates/admin/vendor-product-create.html b/app/modules/catalog/templates/catalog/admin/vendor-product-create.html similarity index 99% rename from app/templates/admin/vendor-product-create.html rename to app/modules/catalog/templates/catalog/admin/vendor-product-create.html index ddb19bb4..138eb189 100644 --- a/app/templates/admin/vendor-product-create.html +++ b/app/modules/catalog/templates/catalog/admin/vendor-product-create.html @@ -507,6 +507,6 @@ document.head.appendChild(script); })(); - + {% endblock %} diff --git a/app/templates/admin/vendor-product-detail.html b/app/modules/catalog/templates/catalog/admin/vendor-product-detail.html similarity index 100% rename from app/templates/admin/vendor-product-detail.html rename to app/modules/catalog/templates/catalog/admin/vendor-product-detail.html diff --git a/app/templates/admin/vendor-product-edit.html b/app/modules/catalog/templates/catalog/admin/vendor-product-edit.html similarity index 99% rename from app/templates/admin/vendor-product-edit.html rename to app/modules/catalog/templates/catalog/admin/vendor-product-edit.html index 11750cca..5620f595 100644 --- a/app/templates/admin/vendor-product-edit.html +++ b/app/modules/catalog/templates/catalog/admin/vendor-product-edit.html @@ -498,6 +498,6 @@ {% endblock %} {% block extra_scripts %} - + {% endblock %} diff --git a/app/templates/admin/vendor-products.html b/app/modules/catalog/templates/catalog/admin/vendor-products.html similarity index 100% rename from app/templates/admin/vendor-products.html rename to app/modules/catalog/templates/catalog/admin/vendor-products.html diff --git a/app/modules/catalog/templates/catalog/storefront/category.html b/app/modules/catalog/templates/catalog/storefront/category.html new file mode 100644 index 00000000..2627c8c8 --- /dev/null +++ b/app/modules/catalog/templates/catalog/storefront/category.html @@ -0,0 +1,281 @@ +{# app/modules/catalog/templates/catalog/storefront/category.html #} +{% extends "storefront/base.html" %} + +{% block title %}{{ category_slug | replace('-', ' ') | title if category_slug else 'Category' }}{% endblock %} + +{# Alpine.js component #} +{% block alpine_data %}shopCategory(){% endblock %} + +{% block content %} +
+ + {# Breadcrumbs #} + + + {# Page Header #} +
+

+ {{ category_slug | replace('-', ' ') | title if category_slug else 'Category' }} +

+

+ products in this category +

+
+ + {# Sort Bar #} +
+
+
+ Showing of products +
+
+ + +
+
+
+ + {# Products Grid #} +
+ {# Loading State #} +
+
+
+ + {# Products Grid #} +
+ +
+ + {# No Products Message #} +
+
📦
+

+ No Products in This Category +

+

+ Check back later or browse other categories. +

+ + Browse All Products + +
+ + {# Pagination #} +
+
+ + + + + +
+
+
+ +
+{% endblock %} + +{% block extra_scripts %} + +{% endblock %} diff --git a/app/templates/storefront/product.html b/app/modules/catalog/templates/catalog/storefront/product.html similarity index 99% rename from app/templates/storefront/product.html rename to app/modules/catalog/templates/catalog/storefront/product.html index 1efb7260..e441c332 100644 --- a/app/templates/storefront/product.html +++ b/app/modules/catalog/templates/catalog/storefront/product.html @@ -1,4 +1,4 @@ -{# app/templates/storefront/product.html #} +{# app/modules/catalog/templates/catalog/storefront/product.html #} {% extends "storefront/base.html" %} {% block title %}{{ product.name if product else 'Product' }}{% endblock %} diff --git a/app/templates/storefront/products.html b/app/modules/catalog/templates/catalog/storefront/products.html similarity index 99% rename from app/templates/storefront/products.html rename to app/modules/catalog/templates/catalog/storefront/products.html index eaf3e24f..900e6a15 100644 --- a/app/templates/storefront/products.html +++ b/app/modules/catalog/templates/catalog/storefront/products.html @@ -1,4 +1,4 @@ -{# app/templates/storefront/products.html #} +{# app/modules/catalog/templates/catalog/storefront/products.html #} {% extends "storefront/base.html" %} {% block title %}Products{% endblock %} diff --git a/app/templates/storefront/search.html b/app/modules/catalog/templates/catalog/storefront/search.html similarity index 99% rename from app/templates/storefront/search.html rename to app/modules/catalog/templates/catalog/storefront/search.html index 3d50d294..7c4f72ad 100644 --- a/app/templates/storefront/search.html +++ b/app/modules/catalog/templates/catalog/storefront/search.html @@ -1,4 +1,4 @@ -{# app/templates/storefront/search.html #} +{# app/modules/catalog/templates/catalog/storefront/search.html #} {# noqa: FE-001 - Shop uses custom pagination with vendor-themed styling (CSS variables) #} {% extends "storefront/base.html" %} diff --git a/app/modules/catalog/templates/catalog/storefront/wishlist.html b/app/modules/catalog/templates/catalog/storefront/wishlist.html new file mode 100644 index 00000000..93d51f20 --- /dev/null +++ b/app/modules/catalog/templates/catalog/storefront/wishlist.html @@ -0,0 +1,252 @@ +{# app/modules/catalog/templates/catalog/storefront/wishlist.html #} +{% extends "storefront/base.html" %} + +{% block title %}My Wishlist{% endblock %} + +{# Alpine.js component #} +{% block alpine_data %}shopWishlist(){% endblock %} + +{% block content %} +
+ + {# Breadcrumbs #} + + + {# Page Header #} +
+

+ My Wishlist +

+

+ saved items +

+
+ + {# Wishlist Content #} +
+ {# Loading State #} +
+
+
+ + {# Not Logged In Message #} +
+
+ +
+

+ Please Log In +

+

+ Log in to your account to view and manage your wishlist. +

+ + Log In + +
+ + {# Wishlist Items Grid #} +
+ +
+ + {# Empty Wishlist Message #} +
+
+ +
+

+ Your Wishlist is Empty +

+

+ Save items you like by clicking the heart icon on product pages. +

+ + Browse Products + +
+
+ +
+{% endblock %} + +{% block extra_scripts %} + +{% endblock %} diff --git a/app/templates/vendor/product-create.html b/app/modules/catalog/templates/catalog/vendor/product-create.html similarity index 100% rename from app/templates/vendor/product-create.html rename to app/modules/catalog/templates/catalog/vendor/product-create.html diff --git a/app/templates/vendor/products.html b/app/modules/catalog/templates/catalog/vendor/products.html similarity index 100% rename from app/templates/vendor/products.html rename to app/modules/catalog/templates/catalog/vendor/products.html diff --git a/app/modules/checkout/routes/api/storefront.py b/app/modules/checkout/routes/api/storefront.py index 378c595a..87d020ea 100644 --- a/app/modules/checkout/routes/api/storefront.py +++ b/app/modules/checkout/routes/api/storefront.py @@ -18,7 +18,7 @@ from sqlalchemy.orm import Session from app.api.deps import get_current_customer_api from app.core.database import get_db -from app.exceptions import VendorNotFoundException +from app.modules.tenancy.exceptions import VendorNotFoundException from app.modules.cart.services import cart_service from app.modules.checkout.schemas import ( CheckoutRequest, @@ -28,7 +28,7 @@ from app.modules.checkout.schemas import ( from app.modules.checkout.services import checkout_service from app.modules.customers.schemas import CustomerContext from app.modules.orders.services import order_service -from app.services.email_service import EmailService # noqa: MOD-004 - Core email service +from app.modules.messaging.services.email_service import EmailService # noqa: MOD-004 - Core email service from middleware.vendor_context import require_vendor_context from models.database.vendor import Vendor from app.modules.orders.schemas import OrderCreate, OrderResponse diff --git a/app/modules/checkout/routes/pages/__init__.py b/app/modules/checkout/routes/pages/__init__.py new file mode 100644 index 00000000..1ca3dabf --- /dev/null +++ b/app/modules/checkout/routes/pages/__init__.py @@ -0,0 +1,2 @@ +# app/modules/checkout/routes/pages/__init__.py +"""Checkout module page routes.""" diff --git a/app/modules/checkout/routes/pages/storefront.py b/app/modules/checkout/routes/pages/storefront.py new file mode 100644 index 00000000..365af19b --- /dev/null +++ b/app/modules/checkout/routes/pages/storefront.py @@ -0,0 +1,46 @@ +# app/modules/checkout/routes/pages/storefront.py +""" +Checkout Storefront Page Routes (HTML rendering). + +Storefront (customer shop) pages for checkout: +- Checkout page +""" + +import logging + +from fastapi import APIRouter, Depends, Request +from fastapi.responses import HTMLResponse +from sqlalchemy.orm import Session + +from app.api.deps import get_db +from app.modules.core.utils.page_context import get_storefront_context +from app.templates_config import templates + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +# ============================================================================ +# CHECKOUT +# ============================================================================ + + +@router.get("/checkout", response_class=HTMLResponse, include_in_schema=False) +async def shop_checkout_page(request: Request, db: Session = Depends(get_db)): + """ + Render checkout page. + Handles shipping, payment, and order confirmation. + """ + logger.debug( + "[STOREFRONT] shop_checkout_page REACHED", + extra={ + "path": request.url.path, + "vendor": getattr(request.state, "vendor", "NOT SET"), + "context": getattr(request.state, "context_type", "NOT SET"), + }, + ) + + return templates.TemplateResponse( + "checkout/storefront/checkout.html", get_storefront_context(request, db=db) + ) diff --git a/app/templates/storefront/checkout.html b/app/modules/checkout/templates/checkout/storefront/checkout.html similarity index 100% rename from app/templates/storefront/checkout.html rename to app/modules/checkout/templates/checkout/storefront/checkout.html diff --git a/app/modules/cms/exceptions.py b/app/modules/cms/exceptions.py index e962f62a..527a7103 100644 --- a/app/modules/cms/exceptions.py +++ b/app/modules/cms/exceptions.py @@ -1,11 +1,15 @@ # app/modules/cms/exceptions.py """ -CMS Module Exceptions +CMS module exceptions. -These exceptions are raised by the CMS module service layer -and converted to HTTP responses by the global exception handler. +This module provides exception classes for CMS operations including: +- Content page management +- Media file handling +- Vendor theme customization """ +from typing import Any + from app.exceptions.base import ( AuthorizationException, BusinessLogicException, @@ -14,6 +18,39 @@ from app.exceptions.base import ( ValidationException, ) +__all__ = [ + # Content page exceptions + "ContentPageNotFoundException", + "ContentPageAlreadyExistsException", + "ContentPageSlugReservedException", + "ContentPageNotPublishedException", + "UnauthorizedContentPageAccessException", + "VendorNotAssociatedException", + "ContentPageValidationException", + # Media exceptions + "MediaNotFoundException", + "MediaUploadException", + "MediaValidationException", + "UnsupportedMediaTypeException", + "MediaFileTooLargeException", + "MediaOptimizationException", + "MediaDeleteException", + # Theme exceptions + "VendorThemeNotFoundException", + "InvalidThemeDataException", + "ThemePresetNotFoundException", + "ThemeValidationException", + "ThemePresetAlreadyAppliedException", + "InvalidColorFormatException", + "InvalidFontFamilyException", + "ThemeOperationException", +] + + +# ============================================================================= +# Content Page Exceptions +# ============================================================================= + class ContentPageNotFoundException(ResourceNotFoundException): """Raised when a content page is not found.""" @@ -25,8 +62,9 @@ class ContentPageNotFoundException(ResourceNotFoundException): message = "Content page not found" super().__init__( message=message, - resource_type="content_page", + resource_type="ContentPage", identifier=str(identifier) if identifier else "unknown", + error_code="CONTENT_PAGE_NOT_FOUND", ) @@ -38,7 +76,11 @@ class ContentPageAlreadyExistsException(ConflictException): message = f"Content page with slug '{slug}' already exists for this vendor" else: message = f"Platform content page with slug '{slug}' already exists" - super().__init__(message=message) + super().__init__( + message=message, + error_code="CONTENT_PAGE_ALREADY_EXISTS", + details={"slug": slug, "vendor_id": vendor_id} if vendor_id else {"slug": slug}, + ) class ContentPageSlugReservedException(ValidationException): @@ -48,15 +90,20 @@ class ContentPageSlugReservedException(ValidationException): super().__init__( message=f"Content page slug '{slug}' is reserved", field="slug", - value=slug, + details={"slug": slug}, ) + self.error_code = "CONTENT_PAGE_SLUG_RESERVED" class ContentPageNotPublishedException(BusinessLogicException): """Raised when trying to access an unpublished content page.""" def __init__(self, slug: str): - super().__init__(message=f"Content page '{slug}' is not published") + super().__init__( + message=f"Content page '{slug}' is not published", + error_code="CONTENT_PAGE_NOT_PUBLISHED", + details={"slug": slug}, + ) class UnauthorizedContentPageAccessException(AuthorizationException): @@ -85,26 +132,225 @@ class ContentPageValidationException(ValidationException): """Raised when content page data validation fails.""" def __init__(self, field: str, message: str, value: str | None = None): - super().__init__(message=message, field=field, value=value) + details = {} + if value: + details["value"] = value + super().__init__(message=message, field=field, details=details if details else None) + self.error_code = "CONTENT_PAGE_VALIDATION_FAILED" + + +# ============================================================================= +# Media Exceptions +# ============================================================================= class MediaNotFoundException(ResourceNotFoundException): - """Raised when a media item is not found.""" + """Raised when a media file is not found.""" - def __init__(self, identifier: str | int | None = None): - if identifier: - message = f"Media item not found: {identifier}" + def __init__(self, media_id: int | str | None = None): + if media_id: + message = f"Media file '{media_id}' not found" else: - message = "Media item not found" + message = "Media file not found" super().__init__( + resource_type="MediaFile", + identifier=str(media_id) if media_id else "unknown", message=message, - resource_type="media", - identifier=str(identifier) if identifier else "unknown", + error_code="MEDIA_NOT_FOUND", ) class MediaUploadException(BusinessLogicException): - """Raised when a media upload fails.""" + """Raised when media upload fails.""" - def __init__(self, reason: str): - super().__init__(message=f"Media upload failed: {reason}") + def __init__(self, message: str = "Media upload failed", details: dict[str, Any] | None = None): + super().__init__( + message=message, + error_code="MEDIA_UPLOAD_FAILED", + details=details, + ) + + +class MediaValidationException(ValidationException): + """Raised when media validation fails (file type, size, etc.).""" + + def __init__( + self, + message: str = "Media validation failed", + field: str | None = None, + details: dict[str, Any] | None = None, + ): + super().__init__(message=message, field=field, details=details) + self.error_code = "MEDIA_VALIDATION_FAILED" + + +class UnsupportedMediaTypeException(ValidationException): + """Raised when media file type is not supported.""" + + def __init__(self, file_type: str, allowed_types: list[str] | None = None): + details = {"file_type": file_type} + if allowed_types: + details["allowed_types"] = allowed_types + + super().__init__( + message=f"Unsupported media type: {file_type}", + field="file", + details=details, + ) + self.error_code = "UNSUPPORTED_MEDIA_TYPE" + + +class MediaFileTooLargeException(ValidationException): + """Raised when media file exceeds size limit.""" + + def __init__(self, file_size: int, max_size: int, media_type: str = "file"): + super().__init__( + message=f"File size ({file_size} bytes) exceeds maximum allowed ({max_size} bytes) for {media_type}", + field="file", + details={ + "file_size": file_size, + "max_size": max_size, + "media_type": media_type, + }, + ) + self.error_code = "MEDIA_FILE_TOO_LARGE" + + +class MediaOptimizationException(BusinessLogicException): + """Raised when media optimization fails.""" + + def __init__(self, message: str = "Only images can be optimized"): + super().__init__( + message=message, + error_code="MEDIA_OPTIMIZATION_FAILED", + ) + + +class MediaDeleteException(BusinessLogicException): + """Raised when media deletion fails.""" + + def __init__(self, message: str, details: dict[str, Any] | None = None): + super().__init__( + message=message, + error_code="MEDIA_DELETE_FAILED", + details=details, + ) + + +# ============================================================================= +# Theme Exceptions +# ============================================================================= + + +class VendorThemeNotFoundException(ResourceNotFoundException): + """Raised when a vendor theme is not found.""" + + def __init__(self, vendor_identifier: str): + super().__init__( + resource_type="VendorTheme", + identifier=vendor_identifier, + message=f"Theme for vendor '{vendor_identifier}' not found", + error_code="VENDOR_THEME_NOT_FOUND", + ) + + +class InvalidThemeDataException(ValidationException): + """Raised when theme data is invalid.""" + + def __init__( + self, + message: str = "Invalid theme data", + field: str | None = None, + details: dict[str, Any] | None = None, + ): + super().__init__( + message=message, + field=field, + details=details, + ) + self.error_code = "INVALID_THEME_DATA" + + +class ThemePresetNotFoundException(ResourceNotFoundException): + """Raised when a theme preset is not found.""" + + def __init__(self, preset_name: str, available_presets: list | None = None): + super().__init__( + resource_type="ThemePreset", + identifier=preset_name, + message=f"Theme preset '{preset_name}' not found", + error_code="THEME_PRESET_NOT_FOUND", + ) + if available_presets: + self.details["available_presets"] = available_presets + + +class ThemeValidationException(ValidationException): + """Raised when theme validation fails.""" + + def __init__( + self, + message: str = "Theme validation failed", + field: str | None = None, + validation_errors: dict[str, str] | None = None, + ): + details = {} + if validation_errors: + details["validation_errors"] = validation_errors + + super().__init__( + message=message, + field=field, + details=details, + ) + self.error_code = "THEME_VALIDATION_FAILED" + + +class ThemePresetAlreadyAppliedException(BusinessLogicException): + """Raised when trying to apply the same preset that's already active.""" + + def __init__(self, preset_name: str, vendor_code: str): + super().__init__( + message=f"Preset '{preset_name}' is already applied to vendor '{vendor_code}'", + error_code="THEME_PRESET_ALREADY_APPLIED", + details={"preset_name": preset_name, "vendor_code": vendor_code}, + ) + + +class InvalidColorFormatException(ValidationException): + """Raised when color format is invalid.""" + + def __init__(self, color_value: str, field: str): + super().__init__( + message=f"Invalid color format: {color_value}", + field=field, + details={"color_value": color_value}, + ) + self.error_code = "INVALID_COLOR_FORMAT" + + +class InvalidFontFamilyException(ValidationException): + """Raised when font family is invalid.""" + + def __init__(self, font_value: str, field: str): + super().__init__( + message=f"Invalid font family: {font_value}", + field=field, + details={"font_value": font_value}, + ) + self.error_code = "INVALID_FONT_FAMILY" + + +class ThemeOperationException(BusinessLogicException): + """Raised when theme operation fails.""" + + def __init__(self, operation: str, vendor_code: str, reason: str): + super().__init__( + message=f"Theme operation '{operation}' failed for vendor '{vendor_code}': {reason}", + error_code="THEME_OPERATION_FAILED", + details={ + "operation": operation, + "vendor_code": vendor_code, + "reason": reason, + }, + ) diff --git a/app/modules/cms/routes/admin.py b/app/modules/cms/routes/admin.py deleted file mode 100644 index 87a80614..00000000 --- a/app/modules/cms/routes/admin.py +++ /dev/null @@ -1,25 +0,0 @@ -# app/modules/cms/routes/admin.py -""" -CMS module admin routes. - -This module wraps the existing admin content pages routes and adds -module-based access control. Routes are re-exported from the -original location with the module access dependency. -""" - -from fastapi import APIRouter, Depends - -from app.api.deps import require_module_access - -# Import original router (direct import to avoid circular dependency) -from app.api.v1.admin.content_pages import router as original_router - -# Create module-aware router -admin_router = APIRouter( - prefix="/content-pages", - dependencies=[Depends(require_module_access("cms"))], -) - -# Re-export all routes from the original module with module access control -for route in original_router.routes: - admin_router.routes.append(route) diff --git a/app/modules/cms/routes/api/admin.py b/app/modules/cms/routes/api/admin.py index e0b9b36e..f1e44ff6 100644 --- a/app/modules/cms/routes/api/admin.py +++ b/app/modules/cms/routes/api/admin.py @@ -1,310 +1,32 @@ # app/modules/cms/routes/api/admin.py """ -Admin Content Pages API +CMS module admin API routes. -Platform administrators can: -- Create/edit/delete platform default content pages -- View all vendor content pages -- Override vendor content if needed +Aggregates all admin CMS routes: +- /content-pages/* - Content page management +- /images/* - Image upload and management +- /media/* - Vendor media libraries +- /vendor-themes/* - Vendor theme customization """ -import logging +from fastapi import APIRouter, Depends -from fastapi import APIRouter, Depends, Query -from sqlalchemy.orm import Session +from app.api.deps import require_module_access -from app.api.deps import get_current_admin_api, get_db -from app.exceptions import ValidationException -from app.modules.cms.schemas import ( - ContentPageCreate, - ContentPageUpdate, - ContentPageResponse, - HomepageSectionsResponse, - SectionUpdateResponse, +from .admin_content_pages import admin_content_pages_router +from .admin_images import admin_images_router +from .admin_media import admin_media_router +from .admin_vendor_themes import admin_vendor_themes_router + +admin_router = APIRouter( + dependencies=[Depends(require_module_access("cms"))], ) -from app.modules.cms.services import content_page_service -from models.database.user import User -# Route configuration for auto-discovery -ROUTE_CONFIG = { - "prefix": "/content-pages", - "tags": ["admin-content-pages"], - "priority": 100, # Register last (CMS has catch-all slug routes) -} +# For backwards compatibility with existing imports +router = admin_router -router = APIRouter() -admin_router = router # Alias for discovery compatibility -logger = logging.getLogger(__name__) - - -# ============================================================================ -# PLATFORM DEFAULT PAGES (vendor_id=NULL) -# ============================================================================ - - -@router.get("/platform", response_model=list[ContentPageResponse]) -def list_platform_pages( - include_unpublished: bool = Query(False, description="Include draft pages"), - current_user: User = Depends(get_current_admin_api), - db: Session = Depends(get_db), -): - """ - List all platform default content pages. - - These are used as fallbacks when vendors haven't created custom pages. - """ - pages = content_page_service.list_all_platform_pages( - db, include_unpublished=include_unpublished - ) - - return [page.to_dict() for page in pages] - - -@router.post("/platform", response_model=ContentPageResponse, status_code=201) -def create_platform_page( - page_data: ContentPageCreate, - current_user: User = Depends(get_current_admin_api), - db: Session = Depends(get_db), -): - """ - Create a new platform default content page. - - Platform defaults are shown to all vendors who haven't created their own version. - """ - # Force vendor_id to None for platform pages - page = content_page_service.create_page( - db, - slug=page_data.slug, - title=page_data.title, - content=page_data.content, - vendor_id=None, # Platform default - content_format=page_data.content_format, - template=page_data.template, - meta_description=page_data.meta_description, - meta_keywords=page_data.meta_keywords, - is_published=page_data.is_published, - show_in_footer=page_data.show_in_footer, - show_in_header=page_data.show_in_header, - show_in_legal=page_data.show_in_legal, - display_order=page_data.display_order, - created_by=current_user.id, - ) - db.commit() - - return page.to_dict() - - -# ============================================================================ -# VENDOR PAGES -# ============================================================================ - - -@router.post("/vendor", response_model=ContentPageResponse, status_code=201) -def create_vendor_page( - page_data: ContentPageCreate, - current_user: User = Depends(get_current_admin_api), - db: Session = Depends(get_db), -): - """ - Create a vendor-specific content page override. - - Vendor pages override platform defaults for a specific vendor. - """ - if not page_data.vendor_id: - raise ValidationException( - message="vendor_id is required for vendor pages. Use /platform for platform defaults.", - field="vendor_id", - ) - - page = content_page_service.create_page( - db, - slug=page_data.slug, - title=page_data.title, - content=page_data.content, - vendor_id=page_data.vendor_id, - content_format=page_data.content_format, - template=page_data.template, - meta_description=page_data.meta_description, - meta_keywords=page_data.meta_keywords, - is_published=page_data.is_published, - show_in_footer=page_data.show_in_footer, - show_in_header=page_data.show_in_header, - show_in_legal=page_data.show_in_legal, - display_order=page_data.display_order, - created_by=current_user.id, - ) - db.commit() - - return page.to_dict() - - -# ============================================================================ -# ALL CONTENT PAGES (Platform + Vendors) -# ============================================================================ - - -@router.get("/", response_model=list[ContentPageResponse]) -def list_all_pages( - vendor_id: int | None = Query(None, description="Filter by vendor ID"), - include_unpublished: bool = Query(False, description="Include draft pages"), - current_user: User = Depends(get_current_admin_api), - db: Session = Depends(get_db), -): - """ - List all content pages (platform defaults and vendor overrides). - - Filter by vendor_id to see specific vendor pages. - """ - pages = content_page_service.list_all_pages( - db, vendor_id=vendor_id, include_unpublished=include_unpublished - ) - - return [page.to_dict() for page in pages] - - -@router.get("/{page_id}", response_model=ContentPageResponse) -def get_page( - page_id: int, - current_user: User = Depends(get_current_admin_api), - db: Session = Depends(get_db), -): - """Get a specific content page by ID.""" - page = content_page_service.get_page_by_id_or_raise(db, page_id) - return page.to_dict() - - -@router.put("/{page_id}", response_model=ContentPageResponse) -def update_page( - page_id: int, - page_data: ContentPageUpdate, - current_user: User = Depends(get_current_admin_api), - db: Session = Depends(get_db), -): - """Update a content page (platform or vendor).""" - page = content_page_service.update_page_or_raise( - db, - page_id=page_id, - title=page_data.title, - content=page_data.content, - content_format=page_data.content_format, - template=page_data.template, - meta_description=page_data.meta_description, - meta_keywords=page_data.meta_keywords, - is_published=page_data.is_published, - show_in_footer=page_data.show_in_footer, - show_in_header=page_data.show_in_header, - show_in_legal=page_data.show_in_legal, - display_order=page_data.display_order, - updated_by=current_user.id, - ) - db.commit() - return page.to_dict() - - -@router.delete("/{page_id}", status_code=204) -def delete_page( - page_id: int, - current_user: User = Depends(get_current_admin_api), - db: Session = Depends(get_db), -): - """Delete a content page.""" - content_page_service.delete_page_or_raise(db, page_id) - db.commit() - - -# ============================================================================ -# HOMEPAGE SECTIONS MANAGEMENT -# ============================================================================ - - -@router.get("/{page_id}/sections", response_model=HomepageSectionsResponse) -def get_page_sections( - page_id: int, - current_user: User = Depends(get_current_admin_api), - db: Session = Depends(get_db), -): - """ - Get homepage sections for a content page. - - Returns sections along with platform language settings for the editor. - """ - page = content_page_service.get_page_by_id_or_raise(db, page_id) - - # Get platform languages - platform = page.platform - supported_languages = ( - platform.supported_languages if platform else ["fr", "de", "en"] - ) - default_language = platform.default_language if platform else "fr" - - return { - "sections": page.sections, - "supported_languages": supported_languages, - "default_language": default_language, - } - - -@router.put("/{page_id}/sections", response_model=SectionUpdateResponse) -def update_page_sections( - page_id: int, - sections: dict, - current_user: User = Depends(get_current_admin_api), - db: Session = Depends(get_db), -): - """ - Update all homepage sections at once. - - Expected structure: - { - "hero": { ... }, - "features": { ... }, - "pricing": { ... }, - "cta": { ... } - } - """ - page = content_page_service.update_homepage_sections( - db, - page_id=page_id, - sections=sections, - updated_by=current_user.id, - ) - db.commit() - - return { - "message": "Sections updated successfully", - "sections": page.sections, - } - - -@router.put("/{page_id}/sections/{section_name}", response_model=SectionUpdateResponse) -def update_single_section( - page_id: int, - section_name: str, - section_data: dict, - current_user: User = Depends(get_current_admin_api), - db: Session = Depends(get_db), -): - """ - Update a single section (hero, features, pricing, or cta). - - section_name must be one of: hero, features, pricing, cta - """ - if section_name not in ["hero", "features", "pricing", "cta"]: - raise ValidationException( - message=f"Invalid section name: {section_name}. Must be one of: hero, features, pricing, cta", - field="section_name", - ) - - page = content_page_service.update_single_section( - db, - page_id=page_id, - section_name=section_name, - section_data=section_data, - updated_by=current_user.id, - ) - db.commit() - - return { - "message": f"Section '{section_name}' updated successfully", - "sections": page.sections, - } +# Aggregate all CMS admin routes +admin_router.include_router(admin_content_pages_router, tags=["admin-content-pages"]) +admin_router.include_router(admin_images_router, tags=["admin-images"]) +admin_router.include_router(admin_media_router, tags=["admin-media"]) +admin_router.include_router(admin_vendor_themes_router, tags=["admin-vendor-themes"]) diff --git a/app/modules/cms/routes/api/admin_content_pages.py b/app/modules/cms/routes/api/admin_content_pages.py new file mode 100644 index 00000000..658940fb --- /dev/null +++ b/app/modules/cms/routes/api/admin_content_pages.py @@ -0,0 +1,302 @@ +# app/modules/cms/routes/api/admin_content_pages.py +""" +Admin Content Pages API + +Platform administrators can: +- Create/edit/delete platform default content pages +- View all vendor content pages +- Override vendor content if needed +""" + +import logging + +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session + +from app.api.deps import get_current_admin_api, get_db +from app.exceptions import ValidationException +from app.modules.cms.schemas import ( + ContentPageCreate, + ContentPageUpdate, + ContentPageResponse, + HomepageSectionsResponse, + SectionUpdateResponse, +) +from app.modules.cms.services import content_page_service +from models.database.user import User + +admin_content_pages_router = APIRouter(prefix="/content-pages") +logger = logging.getLogger(__name__) + + +# ============================================================================ +# PLATFORM DEFAULT PAGES (vendor_id=NULL) +# ============================================================================ + + +@admin_content_pages_router.get("/platform", response_model=list[ContentPageResponse]) +def list_platform_pages( + include_unpublished: bool = Query(False, description="Include draft pages"), + current_user: User = Depends(get_current_admin_api), + db: Session = Depends(get_db), +): + """ + List all platform default content pages. + + These are used as fallbacks when vendors haven't created custom pages. + """ + pages = content_page_service.list_all_platform_pages( + db, include_unpublished=include_unpublished + ) + + return [page.to_dict() for page in pages] + + +@admin_content_pages_router.post("/platform", response_model=ContentPageResponse, status_code=201) +def create_platform_page( + page_data: ContentPageCreate, + current_user: User = Depends(get_current_admin_api), + db: Session = Depends(get_db), +): + """ + Create a new platform default content page. + + Platform defaults are shown to all vendors who haven't created their own version. + """ + # Force vendor_id to None for platform pages + page = content_page_service.create_page( + db, + slug=page_data.slug, + title=page_data.title, + content=page_data.content, + vendor_id=None, # Platform default + content_format=page_data.content_format, + template=page_data.template, + meta_description=page_data.meta_description, + meta_keywords=page_data.meta_keywords, + is_published=page_data.is_published, + show_in_footer=page_data.show_in_footer, + show_in_header=page_data.show_in_header, + show_in_legal=page_data.show_in_legal, + display_order=page_data.display_order, + created_by=current_user.id, + ) + db.commit() + + return page.to_dict() + + +# ============================================================================ +# VENDOR PAGES +# ============================================================================ + + +@admin_content_pages_router.post("/vendor", response_model=ContentPageResponse, status_code=201) +def create_vendor_page( + page_data: ContentPageCreate, + current_user: User = Depends(get_current_admin_api), + db: Session = Depends(get_db), +): + """ + Create a vendor-specific content page override. + + Vendor pages override platform defaults for a specific vendor. + """ + if not page_data.vendor_id: + raise ValidationException( + message="vendor_id is required for vendor pages. Use /platform for platform defaults.", + field="vendor_id", + ) + + page = content_page_service.create_page( + db, + slug=page_data.slug, + title=page_data.title, + content=page_data.content, + vendor_id=page_data.vendor_id, + content_format=page_data.content_format, + template=page_data.template, + meta_description=page_data.meta_description, + meta_keywords=page_data.meta_keywords, + is_published=page_data.is_published, + show_in_footer=page_data.show_in_footer, + show_in_header=page_data.show_in_header, + show_in_legal=page_data.show_in_legal, + display_order=page_data.display_order, + created_by=current_user.id, + ) + db.commit() + + return page.to_dict() + + +# ============================================================================ +# ALL CONTENT PAGES (Platform + Vendors) +# ============================================================================ + + +@admin_content_pages_router.get("/", response_model=list[ContentPageResponse]) +def list_all_pages( + vendor_id: int | None = Query(None, description="Filter by vendor ID"), + include_unpublished: bool = Query(False, description="Include draft pages"), + current_user: User = Depends(get_current_admin_api), + db: Session = Depends(get_db), +): + """ + List all content pages (platform defaults and vendor overrides). + + Filter by vendor_id to see specific vendor pages. + """ + pages = content_page_service.list_all_pages( + db, vendor_id=vendor_id, include_unpublished=include_unpublished + ) + + return [page.to_dict() for page in pages] + + +@admin_content_pages_router.get("/{page_id}", response_model=ContentPageResponse) +def get_page( + page_id: int, + current_user: User = Depends(get_current_admin_api), + db: Session = Depends(get_db), +): + """Get a specific content page by ID.""" + page = content_page_service.get_page_by_id_or_raise(db, page_id) + return page.to_dict() + + +@admin_content_pages_router.put("/{page_id}", response_model=ContentPageResponse) +def update_page( + page_id: int, + page_data: ContentPageUpdate, + current_user: User = Depends(get_current_admin_api), + db: Session = Depends(get_db), +): + """Update a content page (platform or vendor).""" + page = content_page_service.update_page_or_raise( + db, + page_id=page_id, + title=page_data.title, + content=page_data.content, + content_format=page_data.content_format, + template=page_data.template, + meta_description=page_data.meta_description, + meta_keywords=page_data.meta_keywords, + is_published=page_data.is_published, + show_in_footer=page_data.show_in_footer, + show_in_header=page_data.show_in_header, + show_in_legal=page_data.show_in_legal, + display_order=page_data.display_order, + updated_by=current_user.id, + ) + db.commit() + return page.to_dict() + + +@admin_content_pages_router.delete("/{page_id}", status_code=204) +def delete_page( + page_id: int, + current_user: User = Depends(get_current_admin_api), + db: Session = Depends(get_db), +): + """Delete a content page.""" + content_page_service.delete_page_or_raise(db, page_id) + db.commit() + + +# ============================================================================ +# HOMEPAGE SECTIONS MANAGEMENT +# ============================================================================ + + +@admin_content_pages_router.get("/{page_id}/sections", response_model=HomepageSectionsResponse) +def get_page_sections( + page_id: int, + current_user: User = Depends(get_current_admin_api), + db: Session = Depends(get_db), +): + """ + Get homepage sections for a content page. + + Returns sections along with platform language settings for the editor. + """ + page = content_page_service.get_page_by_id_or_raise(db, page_id) + + # Get platform languages + platform = page.platform + supported_languages = ( + platform.supported_languages if platform else ["fr", "de", "en"] + ) + default_language = platform.default_language if platform else "fr" + + return { + "sections": page.sections, + "supported_languages": supported_languages, + "default_language": default_language, + } + + +@admin_content_pages_router.put("/{page_id}/sections", response_model=SectionUpdateResponse) +def update_page_sections( + page_id: int, + sections: dict, + current_user: User = Depends(get_current_admin_api), + db: Session = Depends(get_db), +): + """ + Update all homepage sections at once. + + Expected structure: + { + "hero": { ... }, + "features": { ... }, + "pricing": { ... }, + "cta": { ... } + } + """ + page = content_page_service.update_homepage_sections( + db, + page_id=page_id, + sections=sections, + updated_by=current_user.id, + ) + db.commit() + + return { + "message": "Sections updated successfully", + "sections": page.sections, + } + + +@admin_content_pages_router.put("/{page_id}/sections/{section_name}", response_model=SectionUpdateResponse) +def update_single_section( + page_id: int, + section_name: str, + section_data: dict, + current_user: User = Depends(get_current_admin_api), + db: Session = Depends(get_db), +): + """ + Update a single section (hero, features, pricing, or cta). + + section_name must be one of: hero, features, pricing, cta + """ + if section_name not in ["hero", "features", "pricing", "cta"]: + raise ValidationException( + message=f"Invalid section name: {section_name}. Must be one of: hero, features, pricing, cta", + field="section_name", + ) + + page = content_page_service.update_single_section( + db, + page_id=page_id, + section_name=section_name, + section_data=section_data, + updated_by=current_user.id, + ) + db.commit() + + return { + "message": f"Section '{section_name}' updated successfully", + "sections": page.sections, + } diff --git a/app/api/v1/admin/images.py b/app/modules/cms/routes/api/admin_images.py similarity index 86% rename from app/api/v1/admin/images.py rename to app/modules/cms/routes/api/admin_images.py index ed970ce9..a8f11f63 100644 --- a/app/api/v1/admin/images.py +++ b/app/modules/cms/routes/api/admin_images.py @@ -1,4 +1,4 @@ -# app/api/v1/admin/images.py +# app/modules/cms/routes/api/admin_images.py """ Admin image management endpoints. @@ -13,7 +13,7 @@ import logging from fastapi import APIRouter, Depends, File, Form, UploadFile from app.api.deps import get_current_admin_api -from app.services.image_service import image_service +from app.modules.core.services.image_service import image_service from models.schema.auth import UserContext from models.schema.image import ( ImageDeleteResponse, @@ -21,11 +21,11 @@ from models.schema.image import ( ImageUploadResponse, ) -router = APIRouter(prefix="/images") +admin_images_router = APIRouter(prefix="/images") logger = logging.getLogger(__name__) -@router.post("/upload", response_model=ImageUploadResponse) +@admin_images_router.post("/upload", response_model=ImageUploadResponse) async def upload_image( file: UploadFile = File(...), vendor_id: int = Form(...), @@ -64,7 +64,7 @@ async def upload_image( return ImageUploadResponse(success=True, image=result) -@router.delete("/{image_hash}", response_model=ImageDeleteResponse) +@admin_images_router.delete("/{image_hash}", response_model=ImageDeleteResponse) async def delete_image( image_hash: str, current_admin: UserContext = Depends(get_current_admin_api), @@ -86,7 +86,7 @@ async def delete_image( return ImageDeleteResponse(success=False, message="Image not found") -@router.get("/stats", response_model=ImageStorageStats) +@admin_images_router.get("/stats", response_model=ImageStorageStats) async def get_storage_stats( current_admin: UserContext = Depends(get_current_admin_api), ): diff --git a/app/api/v1/admin/media.py b/app/modules/cms/routes/api/admin_media.py similarity index 85% rename from app/api/v1/admin/media.py rename to app/modules/cms/routes/api/admin_media.py index 9fbac5ef..d3de5892 100644 --- a/app/api/v1/admin/media.py +++ b/app/modules/cms/routes/api/admin_media.py @@ -1,4 +1,4 @@ -# app/api/v1/admin/media.py +# app/modules/cms/routes/api/admin_media.py """ Admin media management endpoints for vendor media libraries. @@ -12,7 +12,7 @@ from sqlalchemy.orm import Session from app.api.deps import get_current_admin_api from app.core.database import get_db -from app.services.media_service import media_service +from app.modules.cms.services.media_service import media_service from models.schema.auth import UserContext from models.schema.media import ( MediaDetailResponse, @@ -21,11 +21,11 @@ from models.schema.media import ( MediaUploadResponse, ) -router = APIRouter(prefix="/media") +admin_media_router = APIRouter(prefix="/media") logger = logging.getLogger(__name__) -@router.get("/vendors/{vendor_id}", response_model=MediaListResponse) +@admin_media_router.get("/vendors/{vendor_id}", response_model=MediaListResponse) def get_vendor_media_library( vendor_id: int, skip: int = Query(0, ge=0), @@ -59,7 +59,7 @@ def get_vendor_media_library( ) -@router.post("/vendors/{vendor_id}/upload", response_model=MediaUploadResponse) +@admin_media_router.post("/vendors/{vendor_id}/upload", response_model=MediaUploadResponse) async def upload_vendor_media( vendor_id: int, file: UploadFile = File(...), @@ -94,7 +94,7 @@ async def upload_vendor_media( ) -@router.get("/vendors/{vendor_id}/{media_id}", response_model=MediaDetailResponse) +@admin_media_router.get("/vendors/{vendor_id}/{media_id}", response_model=MediaDetailResponse) def get_vendor_media_detail( vendor_id: int, media_id: int, @@ -108,13 +108,13 @@ def get_vendor_media_detail( # Verify media belongs to the specified vendor if media_file.vendor_id != vendor_id: - from app.exceptions.media import MediaNotFoundException + from app.modules.cms.exceptions import MediaNotFoundException raise MediaNotFoundException(media_id) return MediaDetailResponse.model_validate(media_file) -@router.delete("/vendors/{vendor_id}/{media_id}") +@admin_media_router.delete("/vendors/{vendor_id}/{media_id}") def delete_vendor_media( vendor_id: int, media_id: int, @@ -128,7 +128,7 @@ def delete_vendor_media( # Verify media belongs to the specified vendor if media_file.vendor_id != vendor_id: - from app.exceptions.media import MediaNotFoundException + from app.modules.cms.exceptions import MediaNotFoundException raise MediaNotFoundException(media_id) media_service.delete_media(db=db, media_id=media_id) diff --git a/app/api/v1/admin/vendor_themes.py b/app/modules/cms/routes/api/admin_vendor_themes.py similarity index 91% rename from app/api/v1/admin/vendor_themes.py rename to app/modules/cms/routes/api/admin_vendor_themes.py index 4e5f0110..9888f07e 100644 --- a/app/api/v1/admin/vendor_themes.py +++ b/app/modules/cms/routes/api/admin_vendor_themes.py @@ -1,4 +1,4 @@ -# app/api/v1/admin/vendor_themes.py +# app/modules/cms/routes/api/admin_vendor_themes.py """ Vendor theme management endpoints for admin. @@ -18,7 +18,7 @@ from fastapi import APIRouter, Depends, Path from sqlalchemy.orm import Session from app.api.deps import get_current_admin_api, get_db -from app.services.vendor_theme_service import vendor_theme_service +from app.modules.cms.services.vendor_theme_service import vendor_theme_service from models.schema.auth import UserContext from models.schema.vendor_theme import ( ThemeDeleteResponse, @@ -28,7 +28,7 @@ from models.schema.vendor_theme import ( VendorThemeUpdate, ) -router = APIRouter(prefix="/vendor-themes") +admin_vendor_themes_router = APIRouter(prefix="/vendor-themes") logger = logging.getLogger(__name__) @@ -37,7 +37,7 @@ logger = logging.getLogger(__name__) # ============================================================================ -@router.get("/presets", response_model=ThemePresetListResponse) +@admin_vendor_themes_router.get("/presets", response_model=ThemePresetListResponse) async def get_theme_presets(current_admin: UserContext = Depends(get_current_admin_api)): """ Get all available theme presets with preview information. @@ -61,7 +61,7 @@ async def get_theme_presets(current_admin: UserContext = Depends(get_current_adm # ============================================================================ -@router.get("/{vendor_code}", response_model=VendorThemeResponse) +@admin_vendor_themes_router.get("/{vendor_code}", response_model=VendorThemeResponse) async def get_vendor_theme( vendor_code: str = Path(..., description="Vendor code"), db: Session = Depends(get_db), @@ -96,7 +96,7 @@ async def get_vendor_theme( # ============================================================================ -@router.put("/{vendor_code}", response_model=VendorThemeResponse) +@admin_vendor_themes_router.put("/{vendor_code}", response_model=VendorThemeResponse) async def update_vendor_theme( vendor_code: str = Path(..., description="Vendor code"), theme_data: VendorThemeUpdate = None, @@ -145,7 +145,7 @@ async def update_vendor_theme( # ============================================================================ -@router.post("/{vendor_code}/preset/{preset_name}", response_model=ThemePresetResponse) +@admin_vendor_themes_router.post("/{vendor_code}/preset/{preset_name}", response_model=ThemePresetResponse) async def apply_theme_preset( vendor_code: str = Path(..., description="Vendor code"), preset_name: str = Path(..., description="Preset name"), @@ -199,7 +199,7 @@ async def apply_theme_preset( # ============================================================================ -@router.delete("/{vendor_code}", response_model=ThemeDeleteResponse) +@admin_vendor_themes_router.delete("/{vendor_code}", response_model=ThemeDeleteResponse) async def delete_vendor_theme( vendor_code: str = Path(..., description="Vendor code"), db: Session = Depends(get_db), diff --git a/app/modules/cms/routes/api/vendor_content_pages.py b/app/modules/cms/routes/api/vendor_content_pages.py index 92040efc..141e8984 100644 --- a/app/modules/cms/routes/api/vendor_content_pages.py +++ b/app/modules/cms/routes/api/vendor_content_pages.py @@ -25,7 +25,7 @@ from app.modules.cms.schemas import ( CMSUsageResponse, ) from app.modules.cms.services import content_page_service -from app.services.vendor_service import VendorService # noqa: MOD-004 - shared platform service +from app.modules.tenancy.services.vendor_service import VendorService # noqa: MOD-004 - shared platform service from models.database.user import User vendor_service = VendorService() diff --git a/app/modules/cms/routes/api/vendor_media.py b/app/modules/cms/routes/api/vendor_media.py index 377c6a9a..c0ba1d22 100644 --- a/app/modules/cms/routes/api/vendor_media.py +++ b/app/modules/cms/routes/api/vendor_media.py @@ -13,8 +13,8 @@ from sqlalchemy.orm import Session from app.api.deps import get_current_vendor_api from app.core.database import get_db -from app.exceptions.media import MediaOptimizationException -from app.services.media_service import media_service +from app.modules.cms.exceptions import MediaOptimizationException +from app.modules.cms.services.media_service import media_service from models.schema.auth import UserContext from models.schema.media import ( MediaDetailResponse, diff --git a/app/modules/cms/routes/pages/public.py b/app/modules/cms/routes/pages/public.py new file mode 100644 index 00000000..e6b5e079 --- /dev/null +++ b/app/modules/cms/routes/pages/public.py @@ -0,0 +1,241 @@ +# app/modules/cms/routes/pages/public.py +""" +CMS Public Page Routes (HTML rendering). + +Public (unauthenticated) pages for platform content: +- Homepage +- Generic content pages (/{slug} catch-all) +""" + +import logging + +from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi.responses import HTMLResponse, RedirectResponse +from sqlalchemy.orm import Session + +from app.core.database import get_db +from app.modules.billing.models import TIER_LIMITS, TierCode +from app.modules.cms.services import content_page_service +from app.modules.core.utils.page_context import get_public_context +from app.templates_config import templates + +logger = logging.getLogger(__name__) + +router = APIRouter() + +# Route configuration - high priority so catch-all is registered last +ROUTE_CONFIG = { + "priority": 100, +} + + +def _get_tiers_data() -> list[dict]: + """Build tier data for display in templates.""" + tiers = [] + for tier_code, limits in TIER_LIMITS.items(): + tiers.append( + { + "code": tier_code.value, + "name": limits["name"], + "price_monthly": limits["price_monthly_cents"] / 100, + "price_annual": (limits["price_annual_cents"] / 100) + if limits.get("price_annual_cents") + else None, + "orders_per_month": limits.get("orders_per_month"), + "products_limit": limits.get("products_limit"), + "team_members": limits.get("team_members"), + "features": limits.get("features", []), + "is_popular": tier_code == TierCode.PROFESSIONAL, + "is_enterprise": tier_code == TierCode.ENTERPRISE, + } + ) + return tiers + + +# ============================================================================ +# HOMEPAGE +# ============================================================================ + + +@router.get("/", response_class=HTMLResponse, name="platform_homepage") +async def homepage( + request: Request, + db: Session = Depends(get_db), +): + """ + Homepage handler. + + Handles two scenarios: + 1. Vendor on custom domain (vendor.com) -> Show vendor landing page or redirect to shop + 2. Platform marketing site -> Show platform homepage from CMS or default template + + URL routing: + - localhost:9999/ -> Main marketing site ('main' platform) + - localhost:9999/platforms/oms/ -> OMS platform (middleware rewrites to /) + - oms.lu/ -> OMS platform (domain-based) + - shop.mycompany.com/ -> Vendor landing page (custom domain) + """ + # Get platform and vendor from middleware + platform = getattr(request.state, "platform", None) + vendor = getattr(request.state, "vendor", None) + + # Scenario 1: Vendor detected (custom domain like vendor.com) + if vendor: + logger.debug(f"[HOMEPAGE] Vendor detected: {vendor.subdomain}") + + # Get platform_id (use platform from context or default to 1 for OMS) + platform_id = platform.id if platform else 1 + + # Try to find vendor landing page (slug='landing' or 'home') + landing_page = content_page_service.get_page_for_vendor( + db, + platform_id=platform_id, + slug="landing", + vendor_id=vendor.id, + include_unpublished=False, + ) + + if not landing_page: + landing_page = content_page_service.get_page_for_vendor( + db, + platform_id=platform_id, + slug="home", + vendor_id=vendor.id, + include_unpublished=False, + ) + + if landing_page: + # Render landing page with selected template + from app.modules.core.utils.page_context import get_storefront_context + + template_name = landing_page.template or "default" + template_path = f"cms/storefront/landing-{template_name}.html" + + logger.info(f"[HOMEPAGE] Rendering vendor landing page: {template_path}") + return templates.TemplateResponse( + template_path, + get_storefront_context(request, db=db, page=landing_page), + ) + + # No landing page - redirect to shop + vendor_context = getattr(request.state, "vendor_context", None) + access_method = ( + vendor_context.get("detection_method", "unknown") + if vendor_context + else "unknown" + ) + + if access_method == "path": + full_prefix = ( + vendor_context.get("full_prefix", "/vendor/") + if vendor_context + else "/vendor/" + ) + return RedirectResponse( + url=f"{full_prefix}{vendor.subdomain}/storefront/", status_code=302 + ) + # Domain/subdomain - redirect to /storefront/ + return RedirectResponse(url="/storefront/", status_code=302) + + # Scenario 2: Platform marketing site (no vendor) + # Load platform homepage from CMS (slug='home') + platform_id = platform.id if platform else 1 + + cms_homepage = content_page_service.get_platform_page( + db, platform_id=platform_id, slug="home", include_unpublished=False + ) + + if cms_homepage: + # Use CMS-based homepage with template selection + context = get_public_context(request, db) + context["page"] = cms_homepage + context["tiers"] = _get_tiers_data() + + template_name = cms_homepage.template or "default" + template_path = f"cms/public/homepage-{template_name}.html" + + logger.info(f"[HOMEPAGE] Rendering CMS homepage with template: {template_path}") + return templates.TemplateResponse(template_path, context) + + # Fallback: Default wizamart homepage (no CMS content) + logger.info("[HOMEPAGE] No CMS homepage found, using default wizamart template") + context = get_public_context(request, db) + context["tiers"] = _get_tiers_data() + + # Add-ons (hardcoded for now, will come from DB) + context["addons"] = [ + { + "code": "domain", + "name": "Custom Domain", + "description": "Use your own domain (mydomain.com)", + "price": 15, + "billing_period": "year", + "icon": "globe", + }, + { + "code": "ssl_premium", + "name": "Premium SSL", + "description": "EV certificate for trust badges", + "price": 49, + "billing_period": "year", + "icon": "shield-check", + }, + { + "code": "email", + "name": "Email Package", + "description": "Professional email addresses", + "price": 5, + "billing_period": "month", + "icon": "mail", + "options": [ + {"quantity": 5, "price": 5}, + {"quantity": 10, "price": 9}, + {"quantity": 25, "price": 19}, + ], + }, + ] + + return templates.TemplateResponse( + "cms/public/homepage-wizamart.html", + context, + ) + + +# ============================================================================ +# GENERIC CONTENT PAGES (CMS) +# ============================================================================ +# IMPORTANT: This route must be LAST as it catches all /{slug} URLs + + +@router.get("/{slug}", response_class=HTMLResponse, name="platform_content_page") +async def content_page( + request: Request, + slug: str, + db: Session = Depends(get_db), +): + """ + Serve CMS content pages (about, contact, faq, privacy, terms, etc.). + + This is a catch-all route for dynamic content pages managed via the admin CMS. + Platform pages have vendor_id=None and is_platform_page=True. + """ + # Get platform from middleware (default to OMS platform_id=1) + platform = getattr(request.state, "platform", None) + platform_id = platform.id if platform else 1 + + # Load platform marketing page from database + page = content_page_service.get_platform_page( + db, platform_id=platform_id, slug=slug, include_unpublished=False + ) + + if not page: + raise HTTPException(status_code=404, detail=f"Page not found: {slug}") + + context = get_public_context(request, db) + context["page"] = page + context["page_title"] = page.title + + return templates.TemplateResponse( + "cms/public/content-page.html", + context, + ) diff --git a/app/modules/cms/routes/pages/storefront.py b/app/modules/cms/routes/pages/storefront.py new file mode 100644 index 00000000..cfc04a86 --- /dev/null +++ b/app/modules/cms/routes/pages/storefront.py @@ -0,0 +1,175 @@ +# app/modules/cms/routes/pages/storefront.py +""" +CMS Storefront Page Routes (HTML rendering). + +Storefront (customer shop) pages for CMS content: +- Generic content pages (/{slug} catch-all) +- Debug context endpoint +""" + +import logging + +from fastapi import APIRouter, Depends, HTTPException, Path, Request +from fastapi.responses import HTMLResponse +from sqlalchemy.orm import Session + +from app.api.deps import get_db +from app.modules.cms.services import content_page_service +from app.modules.core.utils.page_context import get_storefront_context +from app.templates_config import templates + +logger = logging.getLogger(__name__) + +router = APIRouter() + +# Route configuration - high priority so catch-all is registered last +ROUTE_CONFIG = { + "priority": 100, +} + + +# ============================================================================ +# DYNAMIC CONTENT PAGES (CMS) +# ============================================================================ + + +@router.get("/{slug}", response_class=HTMLResponse, include_in_schema=False) +async def generic_content_page( + request: Request, + slug: str = Path(..., description="Content page slug"), + db: Session = Depends(get_db), +): + """ + Generic content page handler (CMS). + + Handles dynamic content pages like: + - /about, /faq, /contact, /shipping, /returns, /privacy, /terms, etc. + + Features: + - Two-tier system: Vendor overrides take priority, fallback to platform defaults + - Only shows published pages + - Returns 404 if page not found + + This route MUST be defined last in the router to avoid conflicts with + specific routes (like /products, /cart, /account, etc.) + """ + logger.debug( + "[CMS_STOREFRONT] generic_content_page REACHED", + extra={ + "path": request.url.path, + "slug": slug, + "vendor": getattr(request.state, "vendor", "NOT SET"), + "context": getattr(request.state, "context_type", "NOT SET"), + }, + ) + + vendor = getattr(request.state, "vendor", None) + platform = getattr(request.state, "platform", None) + vendor_id = vendor.id if vendor else None + platform_id = platform.id if platform else 1 # Default to OMS + + # Load content page from database (vendor override -> vendor default) + page = content_page_service.get_page_for_vendor( + db, + platform_id=platform_id, + slug=slug, + vendor_id=vendor_id, + include_unpublished=False, + ) + + if not page: + logger.warning( + "[CMS_STOREFRONT] Content page not found", + extra={ + "slug": slug, + "vendor_id": vendor_id, + "vendor_name": vendor.name if vendor else None, + }, + ) + raise HTTPException(status_code=404, detail=f"Page not found: {slug}") + + logger.info( + "[CMS_STOREFRONT] Content page found", + extra={ + "slug": slug, + "page_id": page.id, + "page_title": page.title, + "is_vendor_override": page.vendor_id is not None, + "vendor_id": vendor_id, + }, + ) + + return templates.TemplateResponse( + "cms/storefront/content-page.html", + get_storefront_context(request, db=db, page=page), + ) + + +# ============================================================================ +# DEBUG ENDPOINTS - For troubleshooting context issues +# ============================================================================ + + +@router.get("/debug/context", response_class=HTMLResponse, include_in_schema=False) +async def debug_context(request: Request): + """ + DEBUG ENDPOINT: Display request context. + + Shows what's available in request.state. + Useful for troubleshooting template variable issues. + + URL: /storefront/debug/context + """ + import json + + vendor = getattr(request.state, "vendor", None) + theme = getattr(request.state, "theme", None) + + debug_info = { + "path": request.url.path, + "host": request.headers.get("host", ""), + "vendor": { + "found": vendor is not None, + "id": vendor.id if vendor else None, + "name": vendor.name if vendor else None, + "subdomain": vendor.subdomain if vendor else None, + "is_active": vendor.is_active if vendor else None, + }, + "theme": { + "found": theme is not None, + "name": theme.get("theme_name") if theme else None, + }, + "clean_path": getattr(request.state, "clean_path", "NOT SET"), + "context_type": str(getattr(request.state, "context_type", "NOT SET")), + } + + html_content = f""" + + + + Debug Context + + + +

Request Context Debug

+
{json.dumps(debug_info, indent=2)}
+ +

Status

+

+ Vendor: {"Found" if vendor else "Not Found"} +

+

+ Theme: {"Found" if theme else "Not Found"} +

+

+ Context Type: {str(getattr(request.state, "context_type", "NOT SET"))} +

+ + + """ + return HTMLResponse(content=html_content) diff --git a/app/modules/cms/routes/pages/vendor.py b/app/modules/cms/routes/pages/vendor.py index 03ece3f6..325b7cbb 100644 --- a/app/modules/cms/routes/pages/vendor.py +++ b/app/modules/cms/routes/pages/vendor.py @@ -13,7 +13,7 @@ from sqlalchemy.orm import Session from app.api.deps import get_current_vendor_from_cookie_or_header, get_db from app.modules.cms.services import content_page_service -from app.services.platform_settings_service import platform_settings_service # noqa: MOD-004 - shared platform service +from app.modules.core.services.platform_settings_service import platform_settings_service # noqa: MOD-004 - shared platform service from app.templates_config import templates from models.database.user import User from models.database.vendor import Vendor diff --git a/app/modules/cms/services/__init__.py b/app/modules/cms/services/__init__.py index 1b2d2ab6..053e885d 100644 --- a/app/modules/cms/services/__init__.py +++ b/app/modules/cms/services/__init__.py @@ -9,8 +9,26 @@ from app.modules.cms.services.content_page_service import ( ContentPageService, content_page_service, ) +from app.modules.cms.services.media_service import ( + MediaService, + media_service, +) +from app.modules.cms.services.vendor_theme_service import ( + VendorThemeService, + vendor_theme_service, +) +from app.modules.cms.services.vendor_email_settings_service import ( + VendorEmailSettingsService, + get_vendor_email_settings_service, +) __all__ = [ "ContentPageService", "content_page_service", + "MediaService", + "media_service", + "VendorThemeService", + "vendor_theme_service", + "VendorEmailSettingsService", + "get_vendor_email_settings_service", ] diff --git a/app/services/media_service.py b/app/modules/cms/services/media_service.py similarity index 99% rename from app/services/media_service.py rename to app/modules/cms/services/media_service.py index 87644aff..6227e4b2 100644 --- a/app/services/media_service.py +++ b/app/modules/cms/services/media_service.py @@ -1,4 +1,4 @@ -# app/services/media_service.py +# app/modules/cms/services/media_service.py """ Media service for vendor media library management. @@ -20,7 +20,7 @@ from pathlib import Path from sqlalchemy import or_ from sqlalchemy.orm import Session -from app.exceptions.media import ( +from app.modules.cms.exceptions import ( MediaNotFoundException, MediaUploadException, MediaValidationException, diff --git a/app/services/vendor_email_settings_service.py b/app/modules/cms/services/vendor_email_settings_service.py similarity index 99% rename from app/services/vendor_email_settings_service.py rename to app/modules/cms/services/vendor_email_settings_service.py index 11ea7923..aa1a6c5b 100644 --- a/app/services/vendor_email_settings_service.py +++ b/app/modules/cms/services/vendor_email_settings_service.py @@ -1,4 +1,4 @@ -# app/services/vendor_email_settings_service.py +# app/modules/cms/services/vendor_email_settings_service.py """ Vendor Email Settings Service. diff --git a/app/services/vendor_theme_service.py b/app/modules/cms/services/vendor_theme_service.py similarity index 99% rename from app/services/vendor_theme_service.py rename to app/modules/cms/services/vendor_theme_service.py index 495706d1..e26fd171 100644 --- a/app/services/vendor_theme_service.py +++ b/app/modules/cms/services/vendor_theme_service.py @@ -1,4 +1,4 @@ -# app/services/vendor_theme_service.py +# app/modules/cms/services/vendor_theme_service.py """ Vendor Theme Service @@ -17,8 +17,8 @@ from app.core.theme_presets import ( get_available_presets, get_preset_preview, ) -from app.exceptions.vendor import VendorNotFoundException -from app.exceptions.vendor_theme import ( +from app.modules.tenancy.exceptions import VendorNotFoundException +from app.modules.cms.exceptions import ( InvalidColorFormatException, InvalidFontFamilyException, ThemeOperationException, diff --git a/static/shared/js/media-picker.js b/app/modules/cms/static/shared/js/media-picker.js similarity index 100% rename from static/shared/js/media-picker.js rename to app/modules/cms/static/shared/js/media-picker.js diff --git a/app/templates/platform/content-page.html b/app/modules/cms/templates/cms/public/content-page.html similarity index 99% rename from app/templates/platform/content-page.html rename to app/modules/cms/templates/cms/public/content-page.html index b4abe57b..6a785c03 100644 --- a/app/templates/platform/content-page.html +++ b/app/modules/cms/templates/cms/public/content-page.html @@ -1,6 +1,6 @@ {# app/templates/platform/content-page.html #} {# Generic template for platform content pages (About, FAQ, Terms, Contact, etc.) #} -{% extends "platform/base.html" %} +{% extends "public/base.html" %} {% block title %}{{ page.title }} - Marketplace{% endblock %} diff --git a/app/templates/platform/homepage-default.html b/app/modules/cms/templates/cms/public/homepage-default.html similarity index 99% rename from app/templates/platform/homepage-default.html rename to app/modules/cms/templates/cms/public/homepage-default.html index 7ed85a63..2a48cd21 100644 --- a/app/templates/platform/homepage-default.html +++ b/app/modules/cms/templates/cms/public/homepage-default.html @@ -1,6 +1,6 @@ {# app/templates/platform/homepage-default.html #} {# Default platform homepage template with section-based rendering #} -{% extends "platform/base.html" %} +{% extends "public/base.html" %} {# Import section partials #} {% from 'platform/sections/_hero.html' import render_hero %} diff --git a/app/templates/platform/homepage-minimal.html b/app/modules/cms/templates/cms/public/homepage-minimal.html similarity index 99% rename from app/templates/platform/homepage-minimal.html rename to app/modules/cms/templates/cms/public/homepage-minimal.html index a8e75d0e..5ded043f 100644 --- a/app/templates/platform/homepage-minimal.html +++ b/app/modules/cms/templates/cms/public/homepage-minimal.html @@ -1,6 +1,6 @@ {# app/templates/platform/homepage-minimal.html #} {# Minimal/clean platform homepage template #} -{% extends "platform/base.html" %} +{% extends "public/base.html" %} {% block title %} {% if page %}{{ page.title }}{% else %}Home{% endif %} - Marketplace diff --git a/app/templates/platform/homepage-modern.html b/app/modules/cms/templates/cms/public/homepage-modern.html similarity index 99% rename from app/templates/platform/homepage-modern.html rename to app/modules/cms/templates/cms/public/homepage-modern.html index f9804930..9e0bb9c9 100644 --- a/app/templates/platform/homepage-modern.html +++ b/app/modules/cms/templates/cms/public/homepage-modern.html @@ -1,6 +1,6 @@ {# app/templates/platform/homepage-modern.html #} {# Wizamart OMS - Luxembourg-focused homepage inspired by Veeqo #} -{% extends "platform/base.html" %} +{% extends "public/base.html" %} {% block title %} Wizamart - The Back-Office for Letzshop Sellers diff --git a/app/templates/platform/homepage-wizamart.html b/app/modules/cms/templates/cms/public/homepage-wizamart.html similarity index 99% rename from app/templates/platform/homepage-wizamart.html rename to app/modules/cms/templates/cms/public/homepage-wizamart.html index 503b310b..c1347618 100644 --- a/app/templates/platform/homepage-wizamart.html +++ b/app/modules/cms/templates/cms/public/homepage-wizamart.html @@ -1,6 +1,6 @@ {# app/templates/platform/homepage-wizamart.html #} {# Wizamart Marketing Homepage - Letzshop OMS Platform #} -{% extends "platform/base.html" %} +{% extends "public/base.html" %} {% from 'shared/macros/inputs.html' import toggle_switch %} {% block title %}Wizamart - Order Management for Letzshop Sellers{% endblock %} @@ -407,7 +407,7 @@ function homepageData() { this.vendorResult = null; try { - const response = await fetch('/api/v1/platform/letzshop-vendors/lookup', { + const response = await fetch('/api/v1/public/letzshop-vendors/lookup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: this.shopUrl }) diff --git a/app/templates/platform/sections/_cta.html b/app/modules/cms/templates/cms/public/sections/_cta.html similarity index 100% rename from app/templates/platform/sections/_cta.html rename to app/modules/cms/templates/cms/public/sections/_cta.html diff --git a/app/templates/platform/sections/_features.html b/app/modules/cms/templates/cms/public/sections/_features.html similarity index 100% rename from app/templates/platform/sections/_features.html rename to app/modules/cms/templates/cms/public/sections/_features.html diff --git a/app/templates/platform/sections/_hero.html b/app/modules/cms/templates/cms/public/sections/_hero.html similarity index 100% rename from app/templates/platform/sections/_hero.html rename to app/modules/cms/templates/cms/public/sections/_hero.html diff --git a/app/templates/platform/sections/_pricing.html b/app/modules/cms/templates/cms/public/sections/_pricing.html similarity index 100% rename from app/templates/platform/sections/_pricing.html rename to app/modules/cms/templates/cms/public/sections/_pricing.html diff --git a/app/templates/storefront/content-page.html b/app/modules/cms/templates/cms/storefront/content-page.html similarity index 100% rename from app/templates/storefront/content-page.html rename to app/modules/cms/templates/cms/storefront/content-page.html diff --git a/app/templates/vendor/landing-default.html b/app/modules/cms/templates/cms/storefront/landing-default.html similarity index 100% rename from app/templates/vendor/landing-default.html rename to app/modules/cms/templates/cms/storefront/landing-default.html diff --git a/app/templates/vendor/landing-full.html b/app/modules/cms/templates/cms/storefront/landing-full.html similarity index 100% rename from app/templates/vendor/landing-full.html rename to app/modules/cms/templates/cms/storefront/landing-full.html diff --git a/app/templates/vendor/landing-minimal.html b/app/modules/cms/templates/cms/storefront/landing-minimal.html similarity index 100% rename from app/templates/vendor/landing-minimal.html rename to app/modules/cms/templates/cms/storefront/landing-minimal.html diff --git a/app/templates/vendor/landing-modern.html b/app/modules/cms/templates/cms/storefront/landing-modern.html similarity index 100% rename from app/templates/vendor/landing-modern.html rename to app/modules/cms/templates/cms/storefront/landing-modern.html diff --git a/app/templates/vendor/media.html b/app/modules/cms/templates/cms/vendor/media.html similarity index 100% rename from app/templates/vendor/media.html rename to app/modules/cms/templates/cms/vendor/media.html diff --git a/app/modules/core/routes/api/__init__.py b/app/modules/core/routes/api/__init__.py index 2a00dde4..9fee569f 100644 --- a/app/modules/core/routes/api/__init__.py +++ b/app/modules/core/routes/api/__init__.py @@ -2,11 +2,16 @@ """ Core module API routes. +Admin routes: +- /dashboard/* - Admin dashboard and statistics +- /settings/* - Platform settings management + Vendor routes: - /dashboard/* - Dashboard statistics - /settings/* - Vendor settings management """ +from .admin import admin_router from .vendor import vendor_router -__all__ = ["vendor_router"] +__all__ = ["admin_router", "vendor_router"] diff --git a/app/modules/core/routes/api/admin.py b/app/modules/core/routes/api/admin.py new file mode 100644 index 00000000..ef774df0 --- /dev/null +++ b/app/modules/core/routes/api/admin.py @@ -0,0 +1,19 @@ +# app/modules/core/routes/api/admin.py +""" +Core module admin API routes. + +Aggregates all admin core routes: +- /dashboard/* - Admin dashboard and statistics +- /settings/* - Platform settings management +""" + +from fastapi import APIRouter + +from .admin_dashboard import admin_dashboard_router +from .admin_settings import admin_settings_router + +admin_router = APIRouter() + +# Aggregate all core admin routes +admin_router.include_router(admin_dashboard_router, tags=["admin-dashboard"]) +admin_router.include_router(admin_settings_router, tags=["admin-settings"]) diff --git a/app/api/v1/admin/dashboard.py b/app/modules/core/routes/api/admin_dashboard.py similarity index 88% rename from app/api/v1/admin/dashboard.py rename to app/modules/core/routes/api/admin_dashboard.py index 760414e1..1c02091f 100644 --- a/app/api/v1/admin/dashboard.py +++ b/app/modules/core/routes/api/admin_dashboard.py @@ -1,4 +1,4 @@ -# app/api/v1/admin/dashboard.py +# app/modules/core/routes/api/admin_dashboard.py """ Admin dashboard and statistics endpoints. """ @@ -10,8 +10,8 @@ from sqlalchemy.orm import Session from app.api.deps import get_current_admin_api from app.core.database import get_db -from app.services.admin_service import admin_service -from app.services.stats_service import stats_service +from app.modules.tenancy.services.admin_service import admin_service +from app.modules.analytics.services.stats_service import stats_service from models.schema.auth import UserContext from app.modules.analytics.schemas import ( AdminDashboardResponse, @@ -25,11 +25,11 @@ from app.modules.analytics.schemas import ( VendorStatsResponse, ) -router = APIRouter(prefix="/dashboard") +admin_dashboard_router = APIRouter(prefix="/dashboard") logger = logging.getLogger(__name__) -@router.get("", response_model=AdminDashboardResponse) +@admin_dashboard_router.get("", response_model=AdminDashboardResponse) def get_admin_dashboard( db: Session = Depends(get_db), current_admin: UserContext = Depends(get_current_admin_api), @@ -59,7 +59,7 @@ def get_admin_dashboard( ) -@router.get("/stats", response_model=StatsResponse) +@admin_dashboard_router.get("/stats", response_model=StatsResponse) def get_comprehensive_stats( db: Session = Depends(get_db), current_admin: UserContext = Depends(get_current_admin_api), @@ -78,7 +78,7 @@ def get_comprehensive_stats( ) -@router.get("/stats/marketplace", response_model=list[MarketplaceStatsResponse]) +@admin_dashboard_router.get("/stats/marketplace", response_model=list[MarketplaceStatsResponse]) def get_marketplace_stats( db: Session = Depends(get_db), current_admin: UserContext = Depends(get_current_admin_api), @@ -97,7 +97,7 @@ def get_marketplace_stats( ] -@router.get("/stats/platform", response_model=PlatformStatsResponse) +@admin_dashboard_router.get("/stats/platform", response_model=PlatformStatsResponse) def get_platform_statistics( db: Session = Depends(get_db), current_admin: UserContext = Depends(get_current_admin_api), diff --git a/app/api/v1/admin/settings.py b/app/modules/core/routes/api/admin_settings.py similarity index 94% rename from app/api/v1/admin/settings.py rename to app/modules/core/routes/api/admin_settings.py index 66840b99..a7a6b537 100644 --- a/app/api/v1/admin/settings.py +++ b/app/modules/core/routes/api/admin_settings.py @@ -1,4 +1,4 @@ -# app/api/v1/admin/settings.py +# app/modules/core/routes/api/admin_settings.py """ Platform settings management endpoints. @@ -18,9 +18,10 @@ from sqlalchemy.orm import Session from app.api.deps import get_current_admin_api from app.core.config import settings as app_settings from app.core.database import get_db -from app.exceptions import ConfirmationRequiredException, ResourceNotFoundException -from app.services.admin_audit_service import admin_audit_service -from app.services.admin_settings_service import admin_settings_service +from app.exceptions import ResourceNotFoundException +from app.modules.tenancy.exceptions import ConfirmationRequiredException +from app.modules.monitoring.services.admin_audit_service import admin_audit_service +from app.modules.core.services.admin_settings_service import admin_settings_service from models.schema.auth import UserContext from models.schema.admin import ( AdminSettingCreate, @@ -33,11 +34,11 @@ from models.schema.admin import ( RowsPerPageUpdateResponse, ) -router = APIRouter(prefix="/settings") +admin_settings_router = APIRouter(prefix="/settings") logger = logging.getLogger(__name__) -@router.get("", response_model=AdminSettingListResponse) +@admin_settings_router.get("", response_model=AdminSettingListResponse) def get_all_settings( category: str | None = Query(None, description="Filter by category"), is_public: bool | None = Query(None, description="Filter by public flag"), @@ -57,7 +58,7 @@ def get_all_settings( ) -@router.get("/categories") +@admin_settings_router.get("/categories") def get_setting_categories( db: Session = Depends(get_db), current_admin: UserContext = Depends(get_current_admin_api), @@ -76,7 +77,7 @@ def get_setting_categories( } -@router.get("/{key}", response_model=AdminSettingResponse | AdminSettingDefaultResponse) +@admin_settings_router.get("/{key}", response_model=AdminSettingResponse | AdminSettingDefaultResponse) def get_setting( key: str, default: str | None = Query(None, description="Default value if setting not found"), @@ -99,7 +100,7 @@ def get_setting( return AdminSettingResponse.model_validate(setting) -@router.post("", response_model=AdminSettingResponse) +@admin_settings_router.post("", response_model=AdminSettingResponse) def create_setting( setting_data: AdminSettingCreate, db: Session = Depends(get_db), @@ -131,7 +132,7 @@ def create_setting( return result -@router.put("/{key}", response_model=AdminSettingResponse) +@admin_settings_router.put("/{key}", response_model=AdminSettingResponse) def update_setting( key: str, update_data: AdminSettingUpdate, @@ -159,7 +160,7 @@ def update_setting( return result -@router.post("/upsert", response_model=AdminSettingResponse) +@admin_settings_router.post("/upsert", response_model=AdminSettingResponse) def upsert_setting( setting_data: AdminSettingCreate, db: Session = Depends(get_db), @@ -193,7 +194,7 @@ def upsert_setting( # ============================================================================ -@router.get("/display/rows-per-page", response_model=RowsPerPageResponse) +@admin_settings_router.get("/display/rows-per-page", response_model=RowsPerPageResponse) def get_rows_per_page( db: Session = Depends(get_db), current_admin: UserContext = Depends(get_current_admin_api), @@ -203,7 +204,7 @@ def get_rows_per_page( return RowsPerPageResponse(rows_per_page=int(value)) -@router.put("/display/rows-per-page", response_model=RowsPerPageUpdateResponse) +@admin_settings_router.put("/display/rows-per-page", response_model=RowsPerPageUpdateResponse) def set_rows_per_page( rows: int = Query(..., ge=10, le=100, description="Rows per page (10-100)"), db: Session = Depends(get_db), @@ -247,7 +248,7 @@ def set_rows_per_page( ) -@router.get("/display/public", response_model=PublicDisplaySettingsResponse) +@admin_settings_router.get("/display/public", response_model=PublicDisplaySettingsResponse) def get_public_display_settings( db: Session = Depends(get_db), ) -> PublicDisplaySettingsResponse: @@ -263,7 +264,7 @@ def get_public_display_settings( return PublicDisplaySettingsResponse(rows_per_page=int(rows_per_page)) -@router.delete("/{key}") +@admin_settings_router.delete("/{key}") def delete_setting( key: str, confirm: bool = Query(False, description="Must be true to confirm deletion"), @@ -470,7 +471,7 @@ class TestEmailResponse(BaseModel): message: str -@router.get("/email/status", response_model=EmailStatusResponse) +@admin_settings_router.get("/email/status", response_model=EmailStatusResponse) def get_email_status( db: Session = Depends(get_db), current_admin: UserContext = Depends(get_current_admin_api), @@ -515,7 +516,7 @@ def get_email_status( ) -@router.put("/email/settings") +@admin_settings_router.put("/email/settings") def update_email_settings( settings_update: EmailSettingsUpdate, db: Session = Depends(get_db), @@ -604,7 +605,7 @@ def update_email_settings( } -@router.delete("/email/settings") +@admin_settings_router.delete("/email/settings") def reset_email_settings( db: Session = Depends(get_db), current_admin: UserContext = Depends(get_current_admin_api), @@ -642,7 +643,7 @@ def reset_email_settings( } -@router.post("/email/test", response_model=TestEmailResponse) +@admin_settings_router.post("/email/test", response_model=TestEmailResponse) def send_test_email( request: TestEmailRequest, db: Session = Depends(get_db), @@ -653,7 +654,7 @@ def send_test_email( This tests the email provider configuration from environment variables. """ - from app.services.email_service import EmailService + from app.modules.messaging.services.email_service import EmailService try: email_service = EmailService(db) diff --git a/app/api/v1/shared/language.py b/app/modules/core/routes/api/public.py similarity index 84% rename from app/api/v1/shared/language.py rename to app/modules/core/routes/api/public.py index 4fc23d39..a7bb2a41 100644 --- a/app/api/v1/shared/language.py +++ b/app/modules/core/routes/api/public.py @@ -1,11 +1,13 @@ -# app/api/v1/shared/language.py +# app/modules/core/routes/api/public.py """ -Language API endpoints for setting user/customer language preferences. +Public language API endpoints. -These endpoints handle: +Handles: - Setting language preference via cookie - Getting current language info - Listing available languages + +All endpoints are public (no authentication required). """ import logging @@ -25,7 +27,12 @@ from middleware.language import LANGUAGE_COOKIE_NAME, set_language_cookie logger = logging.getLogger(__name__) -router = APIRouter(prefix="/language", tags=["language"]) +router = APIRouter(prefix="/language") + + +# ============================================================================= +# Schemas +# ============================================================================= class SetLanguageRequest(BaseModel): @@ -74,8 +81,12 @@ class CurrentLanguageResponse(BaseModel): source: str # Where the language was determined from (cookie, browser, default) -# public - Language preference can be set without authentication -@router.post("/set", response_model=SetLanguageResponse) +# ============================================================================= +# Endpoints +# ============================================================================= + + +@router.post("/set", response_model=SetLanguageResponse) # public async def set_language( request: Request, response: Response, @@ -108,7 +119,7 @@ async def set_language( ) -@router.get("/current", response_model=CurrentLanguageResponse) +@router.get("/current", response_model=CurrentLanguageResponse) # public async def get_current_language(request: Request) -> CurrentLanguageResponse: """ Get the current language for this request. @@ -137,7 +148,7 @@ async def get_current_language(request: Request) -> CurrentLanguageResponse: ) -@router.get("/list", response_model=LanguageListResponse) +@router.get("/list", response_model=LanguageListResponse) # public async def list_languages(request: Request) -> LanguageListResponse: """ List all available languages. @@ -163,8 +174,7 @@ async def list_languages(request: Request) -> LanguageListResponse: ) -# public - Language preference clearing doesn't require authentication -@router.delete("/clear") +@router.delete("/clear") # public async def clear_language(response: Response) -> SetLanguageResponse: """ Clear the language preference cookie. diff --git a/app/modules/core/routes/api/vendor_dashboard.py b/app/modules/core/routes/api/vendor_dashboard.py index e6a68bd9..a63f1a72 100644 --- a/app/modules/core/routes/api/vendor_dashboard.py +++ b/app/modules/core/routes/api/vendor_dashboard.py @@ -13,9 +13,9 @@ from sqlalchemy.orm import Session from app.api.deps import get_current_vendor_api from app.core.database import get_db -from app.exceptions import VendorNotActiveException -from app.services.stats_service import stats_service -from app.services.vendor_service import vendor_service +from app.modules.tenancy.exceptions import VendorNotActiveException +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 ( VendorCustomerStats, diff --git a/app/modules/core/routes/api/vendor_settings.py b/app/modules/core/routes/api/vendor_settings.py index 2491e5ba..77d74f1d 100644 --- a/app/modules/core/routes/api/vendor_settings.py +++ b/app/modules/core/routes/api/vendor_settings.py @@ -14,8 +14,8 @@ from sqlalchemy.orm import Session from app.api.deps import get_current_vendor_api from app.core.database import get_db -from app.services.platform_settings_service import platform_settings_service -from app.services.vendor_service import vendor_service +from app.modules.core.services.platform_settings_service import platform_settings_service +from app.modules.tenancy.services.vendor_service import vendor_service from models.schema.auth import UserContext vendor_settings_router = APIRouter(prefix="/settings") diff --git a/app/modules/core/routes/pages/__init__.py b/app/modules/core/routes/pages/__init__.py new file mode 100644 index 00000000..c1c6bace --- /dev/null +++ b/app/modules/core/routes/pages/__init__.py @@ -0,0 +1,2 @@ +# app/modules/core/routes/pages/__init__.py +"""Core module page routes.""" diff --git a/app/modules/core/routes/pages/admin.py b/app/modules/core/routes/pages/admin.py new file mode 100644 index 00000000..883e92ed --- /dev/null +++ b/app/modules/core/routes/pages/admin.py @@ -0,0 +1,159 @@ +# app/modules/core/routes/pages/admin.py +""" +Core Admin Page Routes (HTML rendering). + +Admin pages for core platform functionality: +- Login, logout, authentication +- Dashboard +- Settings +- Feature management +""" + +from fastapi import APIRouter, Depends, Request +from fastapi.responses import HTMLResponse, RedirectResponse +from sqlalchemy.orm import Session + +from app.api.deps import get_current_admin_optional, 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 + +router = APIRouter() + + +# ============================================================================ +# PUBLIC ROUTES (No Authentication Required) +# ============================================================================ + + +@router.get("/", response_class=RedirectResponse, include_in_schema=False) +async def admin_root( + current_user: User | None = Depends(get_current_admin_optional), +): + """ + Redirect /admin/ based on authentication status. + + - Authenticated admin users -> /admin/dashboard + - Unauthenticated users -> /admin/login + """ + if current_user: + return RedirectResponse(url="/admin/dashboard", status_code=302) + + return RedirectResponse(url="/admin/login", status_code=302) + + +@router.get("/login", response_class=HTMLResponse, include_in_schema=False) +async def admin_login_page( + request: Request, + current_user: User | None = Depends(get_current_admin_optional), +): + """ + Render admin login page. + + If user is already authenticated as admin, redirect to dashboard. + Otherwise, show login form. + """ + if current_user: + return RedirectResponse(url="/admin/dashboard", status_code=302) + + return templates.TemplateResponse("tenancy/admin/login.html", {"request": request}) + + +@router.get("/select-platform", response_class=HTMLResponse, include_in_schema=False) +async def admin_select_platform_page( + request: Request, + current_user: User | None = Depends(get_current_admin_optional), +): + """ + Render platform selection page for platform admins. + + Platform admins with access to multiple platforms must select + which platform they want to manage before accessing the dashboard. + Super admins are redirected to dashboard (they have global access). + """ + if not current_user: + return RedirectResponse(url="/admin/login", status_code=302) + + if current_user.is_super_admin: + return RedirectResponse(url="/admin/dashboard", status_code=302) + + return templates.TemplateResponse( + "tenancy/admin/select-platform.html", + {"request": request, "user": current_user}, + ) + + +# ============================================================================ +# AUTHENTICATED ROUTES (Admin Only) +# ============================================================================ + + +@router.get("/dashboard", response_class=HTMLResponse, include_in_schema=False) +async def admin_dashboard_page( + request: Request, + current_user: User = Depends(require_menu_access("dashboard", FrontendType.ADMIN)), + db: Session = Depends(get_db), +): + """ + Render admin dashboard page. + Shows platform statistics and recent activity. + """ + return templates.TemplateResponse( + "core/admin/dashboard.html", + get_admin_context(request, current_user), + ) + + +@router.get("/settings", response_class=HTMLResponse, include_in_schema=False) +async def admin_settings_page( + request: Request, + current_user: User = Depends(require_menu_access("settings", FrontendType.ADMIN)), + db: Session = Depends(get_db), +): + """ + Render admin settings page. + Platform configuration and preferences. + """ + return templates.TemplateResponse( + "core/admin/settings.html", + get_admin_context(request, current_user), + ) + + +@router.get("/my-menu", response_class=HTMLResponse, include_in_schema=False) +async def admin_my_menu_config( + request: Request, + current_user: User = Depends(require_menu_access("my-menu", FrontendType.ADMIN)), + db: Session = Depends(get_db), +): + """ + Render personal menu configuration page for super admins. + Allows super admins to customize their own sidebar menu. + """ + # Only super admins can configure their own menu + if not current_user.is_super_admin: + return RedirectResponse(url="/admin/settings", status_code=302) + + return templates.TemplateResponse( + "core/admin/my-menu-config.html", + get_admin_context(request, current_user), + ) + + +@router.get("/features", response_class=HTMLResponse, include_in_schema=False) +async def admin_features_page( + request: Request, + current_user: User = Depends( + require_menu_access("subscription-tiers", FrontendType.ADMIN) + ), + db: Session = Depends(get_db), +): + """ + Render feature management page. + Shows all features with tier assignments and allows editing. + """ + return templates.TemplateResponse( + "billing/admin/features.html", + get_admin_context(request, current_user), + ) diff --git a/app/modules/core/routes/pages/vendor.py b/app/modules/core/routes/pages/vendor.py new file mode 100644 index 00000000..e1865a1d --- /dev/null +++ b/app/modules/core/routes/pages/vendor.py @@ -0,0 +1,69 @@ +# app/modules/core/routes/pages/vendor.py +""" +Core Vendor Page Routes (HTML rendering). + +Vendor pages for core functionality: +- Media library +- Notifications +""" + +from fastapi import APIRouter, Depends, Path, Request +from fastapi.responses import HTMLResponse +from sqlalchemy.orm import Session + +from app.api.deps import get_current_vendor_from_cookie_or_header, get_db +from app.modules.core.utils.page_context import get_vendor_context +from app.templates_config import templates +from models.database.user import User + +router = APIRouter() + + +# ============================================================================ +# MEDIA LIBRARY +# ============================================================================ + + +@router.get( + "/{vendor_code}/media", response_class=HTMLResponse, include_in_schema=False +) +async def vendor_media_page( + request: Request, + vendor_code: str = Path(..., description="Vendor code"), + current_user: User = Depends(get_current_vendor_from_cookie_or_header), + db: Session = Depends(get_db), +): + """ + Render media library page. + JavaScript loads media files via API. + """ + return templates.TemplateResponse( + "cms/vendor/media.html", + get_vendor_context(request, db, current_user, vendor_code), + ) + + +# ============================================================================ +# NOTIFICATIONS +# ============================================================================ + + +@router.get( + "/{vendor_code}/notifications", + response_class=HTMLResponse, + include_in_schema=False, +) +async def vendor_notifications_page( + request: Request, + vendor_code: str = Path(..., description="Vendor code"), + current_user: User = Depends(get_current_vendor_from_cookie_or_header), + db: Session = Depends(get_db), +): + """ + Render notifications center page. + JavaScript loads notifications via API. + """ + return templates.TemplateResponse( + "messaging/vendor/notifications.html", + get_vendor_context(request, db, current_user, vendor_code), + ) diff --git a/app/modules/core/services/__init__.py b/app/modules/core/services/__init__.py new file mode 100644 index 00000000..3c9e45dc --- /dev/null +++ b/app/modules/core/services/__init__.py @@ -0,0 +1,56 @@ +# app/modules/core/services/__init__.py +""" +Core module services. + +Provides foundational services used across the platform: +- auth_service: Authentication and authorization +- menu_service: Menu visibility and configuration +- image_service: Image upload and management +- storage_service: Storage abstraction (local/R2) +- admin_settings_service: Platform-wide admin settings +- platform_settings_service: Platform settings with resolution chain +""" + +from app.modules.core.services.admin_settings_service import ( + AdminSettingsService, + admin_settings_service, +) +from app.modules.core.services.auth_service import AuthService, auth_service +from app.modules.core.services.image_service import ImageService, image_service +from app.modules.core.services.menu_service import MenuItemConfig, MenuService, menu_service +from app.modules.core.services.platform_settings_service import ( + PlatformSettingsService, + platform_settings_service, +) +from app.modules.core.services.storage_service import ( + LocalStorageBackend, + R2StorageBackend, + StorageBackend, + get_storage_backend, + reset_storage_backend, +) + +__all__ = [ + # Auth + "AuthService", + "auth_service", + # Menu + "MenuService", + "MenuItemConfig", + "menu_service", + # Image + "ImageService", + "image_service", + # Storage + "StorageBackend", + "LocalStorageBackend", + "R2StorageBackend", + "get_storage_backend", + "reset_storage_backend", + # Admin settings + "AdminSettingsService", + "admin_settings_service", + # Platform settings + "PlatformSettingsService", + "platform_settings_service", +] diff --git a/app/services/admin_settings_service.py b/app/modules/core/services/admin_settings_service.py similarity index 98% rename from app/services/admin_settings_service.py rename to app/modules/core/services/admin_settings_service.py index 168fc4de..69e10c9d 100644 --- a/app/services/admin_settings_service.py +++ b/app/modules/core/services/admin_settings_service.py @@ -1,4 +1,4 @@ -# app/services/admin_settings_service.py +# app/modules/core/services/admin_settings_service.py """ Admin settings service for platform-wide configuration. @@ -17,10 +17,10 @@ from sqlalchemy import func from sqlalchemy.orm import Session from app.exceptions import ( - AdminOperationException, ResourceNotFoundException, ValidationException, ) +from app.modules.tenancy.exceptions import AdminOperationException from models.database.admin import AdminSetting from models.schema.admin import ( AdminSettingCreate, diff --git a/app/services/auth_service.py b/app/modules/core/services/auth_service.py similarity index 97% rename from app/services/auth_service.py rename to app/modules/core/services/auth_service.py index 1ea729cd..ccb7004a 100644 --- a/app/services/auth_service.py +++ b/app/modules/core/services/auth_service.py @@ -1,4 +1,4 @@ -# app/services/auth_service.py +# app/modules/core/services/auth_service.py """ Authentication service for user login and vendor access control. @@ -17,10 +17,7 @@ from typing import Any from sqlalchemy.orm import Session -from app.exceptions import ( - InvalidCredentialsException, - UserNotActiveException, -) +from app.modules.tenancy.exceptions import InvalidCredentialsException, UserNotActiveException from middleware.auth import AuthManager from models.database.user import User from models.database.vendor import Vendor, VendorUser diff --git a/app/services/image_service.py b/app/modules/core/services/image_service.py similarity index 99% rename from app/services/image_service.py rename to app/modules/core/services/image_service.py index 36494a23..0263d7b1 100644 --- a/app/services/image_service.py +++ b/app/modules/core/services/image_service.py @@ -1,4 +1,4 @@ -# app/services/image_service.py +# app/modules/core/services/image_service.py """ Image upload and management service. diff --git a/app/services/menu_service.py b/app/modules/core/services/menu_service.py similarity index 99% rename from app/services/menu_service.py rename to app/modules/core/services/menu_service.py index d4412c71..98cd68dd 100644 --- a/app/services/menu_service.py +++ b/app/modules/core/services/menu_service.py @@ -1,4 +1,4 @@ -# app/services/menu_service.py +# app/modules/core/services/menu_service.py """ Menu service for platform-specific menu configuration. @@ -15,7 +15,7 @@ Menu Resolution Order: 3. Mandatory status: Is this item mandatory (always visible)? Usage: - from app.services.menu_service import menu_service + from app.modules.core.services import menu_service # Check if menu item is accessible if menu_service.can_access_menu_item(db, FrontendType.ADMIN, "inventory", platform_id=1): diff --git a/app/services/platform_settings_service.py b/app/modules/core/services/platform_settings_service.py similarity index 99% rename from app/services/platform_settings_service.py rename to app/modules/core/services/platform_settings_service.py index 8f8c189b..275bb550 100644 --- a/app/services/platform_settings_service.py +++ b/app/modules/core/services/platform_settings_service.py @@ -1,4 +1,4 @@ -# app/services/platform_settings_service.py +# app/modules/core/services/platform_settings_service.py """ Platform Settings Service diff --git a/app/services/storage_service.py b/app/modules/core/services/storage_service.py similarity index 98% rename from app/services/storage_service.py rename to app/modules/core/services/storage_service.py index a9f72ee3..b23c9240 100644 --- a/app/services/storage_service.py +++ b/app/modules/core/services/storage_service.py @@ -1,4 +1,4 @@ -# app/services/storage_service.py +# app/modules/core/services/storage_service.py """ Storage abstraction service for file uploads. @@ -7,7 +7,7 @@ Provides a unified interface for file storage with support for: - Cloudflare R2 (production, S3-compatible) Usage: - from app.services.storage_service import get_storage_backend + from app.modules.core.services import get_storage_backend storage = get_storage_backend() url = await storage.upload("path/to/file.jpg", file_bytes, "image/jpeg") diff --git a/static/admin/js/dashboard.js b/app/modules/core/static/admin/js/dashboard.js similarity index 100% rename from static/admin/js/dashboard.js rename to app/modules/core/static/admin/js/dashboard.js diff --git a/static/admin/js/init-alpine.js b/app/modules/core/static/admin/js/init-alpine.js similarity index 100% rename from static/admin/js/init-alpine.js rename to app/modules/core/static/admin/js/init-alpine.js diff --git a/static/admin/js/login.js b/app/modules/core/static/admin/js/login.js similarity index 100% rename from static/admin/js/login.js rename to app/modules/core/static/admin/js/login.js diff --git a/static/admin/js/my-menu-config.js b/app/modules/core/static/admin/js/my-menu-config.js similarity index 100% rename from static/admin/js/my-menu-config.js rename to app/modules/core/static/admin/js/my-menu-config.js diff --git a/static/admin/js/settings.js b/app/modules/core/static/admin/js/settings.js similarity index 100% rename from static/admin/js/settings.js rename to app/modules/core/static/admin/js/settings.js diff --git a/static/shared/js/vendor-selector.js b/app/modules/core/static/shared/js/vendor-selector.js similarity index 100% rename from static/shared/js/vendor-selector.js rename to app/modules/core/static/shared/js/vendor-selector.js diff --git a/static/storefront/js/storefront-layout.js b/app/modules/core/static/storefront/js/storefront-layout.js similarity index 100% rename from static/storefront/js/storefront-layout.js rename to app/modules/core/static/storefront/js/storefront-layout.js diff --git a/static/vendor/js/dashboard.js b/app/modules/core/static/vendor/js/dashboard.js similarity index 100% rename from static/vendor/js/dashboard.js rename to app/modules/core/static/vendor/js/dashboard.js diff --git a/static/vendor/js/init-alpine.js b/app/modules/core/static/vendor/js/init-alpine.js similarity index 100% rename from static/vendor/js/init-alpine.js rename to app/modules/core/static/vendor/js/init-alpine.js diff --git a/app/templates/admin/dashboard.html b/app/modules/core/templates/core/admin/dashboard.html similarity index 98% rename from app/templates/admin/dashboard.html rename to app/modules/core/templates/core/admin/dashboard.html index d1f0236f..15ebdca2 100644 --- a/app/templates/admin/dashboard.html +++ b/app/modules/core/templates/core/admin/dashboard.html @@ -137,5 +137,5 @@ {% endblock %} {% block extra_scripts %} - + {% endblock %} \ No newline at end of file diff --git a/app/templates/admin/my-menu-config.html b/app/modules/core/templates/core/admin/my-menu-config.html similarity index 99% rename from app/templates/admin/my-menu-config.html rename to app/modules/core/templates/core/admin/my-menu-config.html index a707936e..3e059fa3 100644 --- a/app/templates/admin/my-menu-config.html +++ b/app/modules/core/templates/core/admin/my-menu-config.html @@ -170,5 +170,5 @@ {% endblock %} {% block extra_scripts %} - + {% endblock %} diff --git a/app/templates/admin/settings.html b/app/modules/core/templates/core/admin/settings.html similarity index 99% rename from app/templates/admin/settings.html rename to app/modules/core/templates/core/admin/settings.html index f15c555f..7a1fb889 100644 --- a/app/templates/admin/settings.html +++ b/app/modules/core/templates/core/admin/settings.html @@ -778,5 +778,5 @@ {% endblock %} {% block extra_scripts %} - + {% endblock %} diff --git a/app/templates/vendor/dashboard.html b/app/modules/core/templates/core/vendor/dashboard.html similarity index 98% rename from app/templates/vendor/dashboard.html rename to app/modules/core/templates/core/vendor/dashboard.html index c829177e..0de06850 100644 --- a/app/templates/vendor/dashboard.html +++ b/app/modules/core/templates/core/vendor/dashboard.html @@ -177,5 +177,5 @@ {% endblock %} {% block extra_scripts %} - + {% endblock %} \ No newline at end of file diff --git a/app/templates/vendor/settings.html b/app/modules/core/templates/core/vendor/settings.html similarity index 99% rename from app/templates/vendor/settings.html rename to app/modules/core/templates/core/vendor/settings.html index 80fe5272..1db4d9bf 100644 --- a/app/templates/vendor/settings.html +++ b/app/modules/core/templates/core/vendor/settings.html @@ -1402,5 +1402,5 @@ {% endblock %} {% block extra_scripts %} - + {% endblock %} diff --git a/app/modules/core/utils/__init__.py b/app/modules/core/utils/__init__.py new file mode 100644 index 00000000..d27fae7e --- /dev/null +++ b/app/modules/core/utils/__init__.py @@ -0,0 +1,16 @@ +# app/modules/core/utils/__init__.py +"""Core module utilities.""" + +from .page_context import ( + get_admin_context, + get_vendor_context, + get_storefront_context, + get_public_context, +) + +__all__ = [ + "get_admin_context", + "get_vendor_context", + "get_storefront_context", + "get_public_context", +] diff --git a/app/modules/core/utils/page_context.py b/app/modules/core/utils/page_context.py new file mode 100644 index 00000000..90c6bcd8 --- /dev/null +++ b/app/modules/core/utils/page_context.py @@ -0,0 +1,328 @@ +# app/modules/core/utils/page_context.py +""" +Shared page context helpers for HTML page routes. + +These functions build template contexts that include common variables +needed across different frontends (admin, vendor, storefront, public). +""" + +import logging + +from fastapi import Request +from sqlalchemy.orm import Session + +from app.core.config import settings +from app.modules.core.services.platform_settings_service import platform_settings_service +from app.utils.i18n import get_jinja2_globals +from models.database.user import User +from models.database.vendor import Vendor + +logger = logging.getLogger(__name__) + + +def get_admin_context( + request: Request, + current_user: User, + db: Session | None = None, + **extra_context, +) -> dict: + """ + Build template context for admin dashboard pages. + + Args: + request: FastAPI request object + current_user: Authenticated admin user + db: Optional database session + **extra_context: Additional variables for template + + Returns: + Dictionary with request, user, and extra context + """ + context = { + "request": request, + "user": current_user, + } + + if extra_context: + context.update(extra_context) + + return context + + +def get_vendor_context( + request: Request, + db: Session, + current_user: User, + vendor_code: str, + **extra_context, +) -> dict: + """ + Build template context for vendor dashboard pages. + + Resolves locale/currency using the platform settings service with + vendor override support: + 1. Vendor's storefront_locale (if set) + 2. Platform's default from PlatformSettingsService + 3. Environment variable + 4. Hardcoded fallback + + Args: + request: FastAPI request object + db: Database session + current_user: Authenticated vendor user + vendor_code: Vendor subdomain/code + **extra_context: Additional variables for template + + Returns: + Dictionary with request, user, vendor, resolved locale/currency, and extra context + """ + # Load vendor from database + vendor = db.query(Vendor).filter(Vendor.subdomain == vendor_code).first() + + # Get platform defaults + platform_config = platform_settings_service.get_storefront_config(db) + + # Resolve with vendor override + storefront_locale = platform_config["locale"] + storefront_currency = platform_config["currency"] + + if vendor and vendor.storefront_locale: + storefront_locale = vendor.storefront_locale + + context = { + "request": request, + "user": current_user, + "vendor": vendor, + "vendor_code": vendor_code, + "storefront_locale": storefront_locale, + "storefront_currency": storefront_currency, + "dashboard_language": vendor.dashboard_language if vendor else "en", + } + + # Add any extra context + if extra_context: + context.update(extra_context) + + logger.debug( + "[VENDOR_CONTEXT] Context built", + extra={ + "vendor_id": vendor.id if vendor else None, + "vendor_code": vendor_code, + "storefront_locale": storefront_locale, + "storefront_currency": storefront_currency, + "extra_keys": list(extra_context.keys()) if extra_context else [], + }, + ) + + return context + + +def get_storefront_context( + request: Request, + db: Session | None = None, + **extra_context, +) -> dict: + """ + Build template context for storefront (customer shop) pages. + + Automatically includes vendor and theme from middleware request.state. + Additional context can be passed as keyword arguments. + + Args: + request: FastAPI request object with vendor/theme in state + db: Optional database session for loading navigation pages + **extra_context: Additional variables for template (user, product_id, etc.) + + Returns: + Dictionary with request, vendor, theme, navigation pages, and extra context + """ + # Import here to avoid circular imports + from app.modules.cms.services import content_page_service + + # Extract from middleware state + vendor = getattr(request.state, "vendor", None) + platform = getattr(request.state, "platform", None) + theme = getattr(request.state, "theme", None) + clean_path = getattr(request.state, "clean_path", request.url.path) + vendor_context = getattr(request.state, "vendor_context", None) + + # Get platform_id (default to 1 for OMS if not set) + platform_id = platform.id if platform else 1 + + # Get detection method from vendor_context + access_method = ( + vendor_context.get("detection_method", "unknown") + if vendor_context + else "unknown" + ) + + if vendor is None: + logger.warning( + "[STOREFRONT_CONTEXT] Vendor not found in request.state", + extra={ + "path": request.url.path, + "host": request.headers.get("host", ""), + "has_vendor": False, + }, + ) + + # Calculate base URL for links + # - Domain/subdomain access: base_url = "/" + # - Path-based access: base_url = "/vendor/{vendor_code}/" or "/vendors/{vendor_code}/" + base_url = "/" + if access_method == "path" and vendor: + # Use the full_prefix from vendor_context to determine which pattern was used + full_prefix = ( + vendor_context.get("full_prefix", "/vendor/") + if vendor_context + else "/vendor/" + ) + base_url = f"{full_prefix}{vendor.subdomain}/" + + # Load footer and header navigation pages from CMS if db session provided + footer_pages = [] + header_pages = [] + if db and vendor: + try: + vendor_id = vendor.id + # Get pages configured to show in footer + footer_pages = content_page_service.list_pages_for_vendor( + db, + platform_id=platform_id, + vendor_id=vendor_id, + footer_only=True, + include_unpublished=False, + ) + # Get pages configured to show in header + header_pages = content_page_service.list_pages_for_vendor( + db, + platform_id=platform_id, + vendor_id=vendor_id, + header_only=True, + include_unpublished=False, + ) + except Exception as e: + logger.error( + "[STOREFRONT_CONTEXT] Failed to load navigation pages", + extra={"error": str(e), "vendor_id": vendor.id if vendor else None}, + ) + + # Resolve storefront locale and currency + storefront_config = {"locale": "fr-LU", "currency": "EUR"} # defaults + if db and vendor: + platform_config = platform_settings_service.get_storefront_config(db) + storefront_config["locale"] = platform_config["locale"] + storefront_config["currency"] = platform_config["currency"] + if vendor.storefront_locale: + storefront_config["locale"] = vendor.storefront_locale + + context = { + "request": request, + "vendor": vendor, + "theme": theme, + "clean_path": clean_path, + "access_method": access_method, + "base_url": base_url, + "footer_pages": footer_pages, + "header_pages": header_pages, + "storefront_locale": storefront_config["locale"], + "storefront_currency": storefront_config["currency"], + } + + # Add any extra context (user, product_id, category_slug, etc.) + if extra_context: + context.update(extra_context) + + logger.debug( + "[STOREFRONT_CONTEXT] Context built", + extra={ + "vendor_id": vendor.id if vendor else None, + "vendor_name": vendor.name if vendor else None, + "vendor_subdomain": vendor.subdomain if vendor else None, + "has_theme": theme is not None, + "access_method": access_method, + "base_url": base_url, + "storefront_locale": storefront_config["locale"], + "storefront_currency": storefront_config["currency"], + "footer_pages_count": len(footer_pages), + "header_pages_count": len(header_pages), + "extra_keys": list(extra_context.keys()) if extra_context else [], + }, + ) + + return context + + +def get_public_context( + request: Request, + db: Session, + **extra_context, +) -> dict: + """ + Build context for public/marketing pages. + + Includes platform info, i18n globals, and CMS navigation pages. + + Args: + request: FastAPI request object + db: Database session + **extra_context: Additional variables for template + + Returns: + Dictionary with request, platform info, i18n globals, and extra context + """ + # Import here to avoid circular imports + from app.modules.cms.services import content_page_service + + # Get language from request state (set by middleware) + language = getattr(request.state, "language", "fr") + + # Get platform from middleware (default to OMS platform_id=1) + platform = getattr(request.state, "platform", None) + platform_id = platform.id if platform else 1 + + # Get translation function + i18n_globals = get_jinja2_globals(language) + + context = { + "request": request, + "platform": platform, + "platform_name": "Wizamart", + "platform_domain": settings.platform_domain, + "stripe_publishable_key": settings.stripe_publishable_key, + "trial_days": settings.stripe_trial_days, + } + + # Add i18n globals (_, t, current_language, SUPPORTED_LANGUAGES, etc.) + context.update(i18n_globals) + + # Load CMS pages for header, footer, and legal navigation + header_pages = [] + footer_pages = [] + legal_pages = [] + try: + # Platform marketing pages (is_platform_page=True) + header_pages = content_page_service.list_platform_pages( + db, platform_id=platform_id, header_only=True, include_unpublished=False + ) + footer_pages = content_page_service.list_platform_pages( + db, platform_id=platform_id, footer_only=True, include_unpublished=False + ) + # For legal pages, we need to add footer support or use a different approach + # For now, legal pages come from footer pages with show_in_legal flag + legal_pages = [] # Will be handled separately if needed + logger.debug( + f"Loaded CMS pages: {len(header_pages)} header, {len(footer_pages)} footer, {len(legal_pages)} legal" + ) + except Exception as e: + logger.error(f"Failed to load CMS navigation pages: {e}") + + context["header_pages"] = header_pages + context["footer_pages"] = footer_pages + context["legal_pages"] = legal_pages + + # Add any extra context + if extra_context: + context.update(extra_context) + + return context diff --git a/app/modules/customers/exceptions.py b/app/modules/customers/exceptions.py index 057e8585..91b7000b 100644 --- a/app/modules/customers/exceptions.py +++ b/app/modules/customers/exceptions.py @@ -2,23 +2,20 @@ """ Customers module exceptions. -Re-exports customer-related exceptions from their source locations. +This module provides exception classes for customer operations including: +- Customer management (create, update, authentication) +- Address management +- Password reset """ -from app.exceptions.customer import ( - CustomerNotFoundException, - CustomerAlreadyExistsException, - DuplicateCustomerEmailException, - CustomerNotActiveException, - InvalidCustomerCredentialsException, - CustomerValidationException, - CustomerAuthorizationException, -) +from typing import Any -from app.exceptions.address import ( - AddressNotFoundException, - AddressLimitExceededException, - InvalidAddressTypeException, +from app.exceptions.base import ( + AuthenticationException, + BusinessLogicException, + ConflictException, + ResourceNotFoundException, + ValidationException, ) __all__ = [ @@ -30,8 +27,155 @@ __all__ = [ "InvalidCustomerCredentialsException", "CustomerValidationException", "CustomerAuthorizationException", + "InvalidPasswordResetTokenException", + "PasswordTooShortException", # Address exceptions "AddressNotFoundException", "AddressLimitExceededException", "InvalidAddressTypeException", ] + + +# ============================================================================= +# Customer Exceptions +# ============================================================================= + + +class CustomerNotFoundException(ResourceNotFoundException): + """Raised when a customer is not found.""" + + def __init__(self, customer_identifier: str): + super().__init__( + resource_type="Customer", + identifier=customer_identifier, + message=f"Customer '{customer_identifier}' not found", + error_code="CUSTOMER_NOT_FOUND", + ) + + +class CustomerAlreadyExistsException(ConflictException): + """Raised when trying to create a customer that already exists.""" + + def __init__(self, email: str): + super().__init__( + message=f"Customer with email '{email}' already exists", + error_code="CUSTOMER_ALREADY_EXISTS", + details={"email": email}, + ) + + +class DuplicateCustomerEmailException(ConflictException): + """Raised when email already exists for vendor.""" + + def __init__(self, email: str, vendor_code: str): + super().__init__( + message=f"Email '{email}' is already registered for this vendor", + error_code="DUPLICATE_CUSTOMER_EMAIL", + details={"email": email, "vendor_code": vendor_code}, + ) + + +class CustomerNotActiveException(BusinessLogicException): + """Raised when trying to perform operations on inactive customer.""" + + def __init__(self, email: str): + super().__init__( + message=f"Customer account '{email}' is not active", + error_code="CUSTOMER_NOT_ACTIVE", + details={"email": email}, + ) + + +class InvalidCustomerCredentialsException(AuthenticationException): + """Raised when customer credentials are invalid.""" + + def __init__(self): + super().__init__( + message="Invalid email or password", + error_code="INVALID_CUSTOMER_CREDENTIALS", + ) + + +class CustomerValidationException(ValidationException): + """Raised when customer data validation fails.""" + + def __init__( + self, + message: str = "Customer validation failed", + field: str | None = None, + details: dict[str, Any] | None = None, + ): + super().__init__(message=message, field=field, details=details) + self.error_code = "CUSTOMER_VALIDATION_FAILED" + + +class CustomerAuthorizationException(BusinessLogicException): + """Raised when customer is not authorized for operation.""" + + def __init__(self, customer_email: str, operation: str): + super().__init__( + message=f"Customer '{customer_email}' not authorized for: {operation}", + error_code="CUSTOMER_NOT_AUTHORIZED", + details={"customer_email": customer_email, "operation": operation}, + ) + + +class InvalidPasswordResetTokenException(ValidationException): + """Raised when password reset token is invalid or expired.""" + + def __init__(self): + super().__init__( + message="Invalid or expired password reset link. Please request a new one.", + field="reset_token", + ) + self.error_code = "INVALID_RESET_TOKEN" + + +class PasswordTooShortException(ValidationException): + """Raised when password doesn't meet minimum length requirement.""" + + def __init__(self, min_length: int = 8): + super().__init__( + message=f"Password must be at least {min_length} characters long", + field="password", + details={"min_length": min_length}, + ) + self.error_code = "PASSWORD_TOO_SHORT" + + +# ============================================================================= +# Address Exceptions +# ============================================================================= + + +class AddressNotFoundException(ResourceNotFoundException): + """Raised when a customer address is not found.""" + + def __init__(self, address_id: str | int): + super().__init__( + resource_type="Address", + identifier=str(address_id), + error_code="ADDRESS_NOT_FOUND", + ) + + +class AddressLimitExceededException(BusinessLogicException): + """Raised when customer exceeds maximum number of addresses.""" + + def __init__(self, max_addresses: int = 10): + super().__init__( + message=f"Maximum number of addresses ({max_addresses}) reached", + error_code="ADDRESS_LIMIT_EXCEEDED", + details={"max_addresses": max_addresses}, + ) + + +class InvalidAddressTypeException(BusinessLogicException): + """Raised when an invalid address type is provided.""" + + def __init__(self, address_type: str): + super().__init__( + message=f"Invalid address type '{address_type}'. Must be 'shipping' or 'billing'", + error_code="INVALID_ADDRESS_TYPE", + details={"address_type": address_type}, + ) diff --git a/app/modules/customers/routes/api/storefront.py b/app/modules/customers/routes/api/storefront.py index 20cd29dc..fd349c0a 100644 --- a/app/modules/customers/routes/api/storefront.py +++ b/app/modules/customers/routes/api/storefront.py @@ -23,14 +23,15 @@ from sqlalchemy.orm import Session from app.api.deps import get_current_customer_api from app.core.database import get_db from app.core.environment import should_use_secure_cookies -from app.exceptions import ValidationException, VendorNotFoundException +from app.exceptions import ValidationException +from app.modules.tenancy.exceptions import VendorNotFoundException from app.modules.customers.schemas import CustomerContext from app.modules.customers.services import ( customer_address_service, customer_service, ) -from app.services.auth_service import AuthService # noqa: MOD-004 - Core auth service -from app.services.email_service import EmailService # noqa: MOD-004 - Core email service +from app.modules.core.services.auth_service import AuthService # noqa: MOD-004 - Core auth service +from app.modules.messaging.services.email_service import EmailService # noqa: MOD-004 - Core email service from app.modules.customers.models import PasswordResetToken from models.schema.auth import ( LogoutResponse, diff --git a/app/modules/customers/routes/pages/__init__.py b/app/modules/customers/routes/pages/__init__.py index 036b4041..78c2eb6f 100644 --- a/app/modules/customers/routes/pages/__init__.py +++ b/app/modules/customers/routes/pages/__init__.py @@ -1,4 +1,2 @@ -# Page routes will be added here -# TODO: Add HTML page routes for admin/vendor dashboards - -__all__ = [] +# app/modules/customers/routes/pages/__init__.py +"""Customers module page routes.""" diff --git a/app/modules/customers/routes/pages/admin.py b/app/modules/customers/routes/pages/admin.py new file mode 100644 index 00000000..5f33b8fc --- /dev/null +++ b/app/modules/customers/routes/pages/admin.py @@ -0,0 +1,40 @@ +# app/modules/customers/routes/pages/admin.py +""" +Customers Admin Page Routes (HTML rendering). + +Admin pages for customer management: +- Customers list +""" + +from fastapi import APIRouter, Depends, Request +from fastapi.responses import HTMLResponse +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 + +router = APIRouter() + + +# ============================================================================ +# CUSTOMER MANAGEMENT ROUTES +# ============================================================================ + + +@router.get("/customers", response_class=HTMLResponse, include_in_schema=False) +async def admin_customers_page( + request: Request, + current_user: User = Depends(require_menu_access("customers", FrontendType.ADMIN)), + db: Session = Depends(get_db), +): + """ + Render customers management page. + Shows list of all platform customers. + """ + return templates.TemplateResponse( + "customers/admin/customers.html", + get_admin_context(request, current_user), + ) diff --git a/app/modules/customers/routes/pages/storefront.py b/app/modules/customers/routes/pages/storefront.py new file mode 100644 index 00000000..88373190 --- /dev/null +++ b/app/modules/customers/routes/pages/storefront.py @@ -0,0 +1,276 @@ +# app/modules/customers/routes/pages/storefront.py +""" +Customers Storefront Page Routes (HTML rendering). + +Storefront (customer shop) pages for customer account: +- Registration +- Login +- Forgot password +- Reset password +- Account dashboard +- Profile management +- Addresses +- Settings +""" + +import logging + +from fastapi import APIRouter, Depends, Request +from fastapi.responses import HTMLResponse, RedirectResponse +from sqlalchemy.orm import Session + +from app.api.deps import get_current_customer_from_cookie_or_header, get_db +from app.modules.core.utils.page_context import get_storefront_context +from app.modules.customers.models import Customer +from app.templates_config import templates + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +# ============================================================================ +# CUSTOMER ACCOUNT - PUBLIC ROUTES (No Authentication) +# ============================================================================ + + +@router.get("/account/register", response_class=HTMLResponse, include_in_schema=False) +async def shop_register_page(request: Request, db: Session = Depends(get_db)): + """ + Render customer registration page. + No authentication required. + """ + logger.debug( + "[STOREFRONT] shop_register_page REACHED", + extra={ + "path": request.url.path, + "vendor": getattr(request.state, "vendor", "NOT SET"), + "context": getattr(request.state, "context_type", "NOT SET"), + }, + ) + + return templates.TemplateResponse( + "customers/storefront/register.html", get_storefront_context(request, db=db) + ) + + +@router.get("/account/login", response_class=HTMLResponse, include_in_schema=False) +async def shop_login_page(request: Request, db: Session = Depends(get_db)): + """ + Render customer login page. + No authentication required. + """ + logger.debug( + "[STOREFRONT] shop_login_page REACHED", + extra={ + "path": request.url.path, + "vendor": getattr(request.state, "vendor", "NOT SET"), + "context": getattr(request.state, "context_type", "NOT SET"), + }, + ) + + return templates.TemplateResponse( + "customers/storefront/login.html", get_storefront_context(request, db=db) + ) + + +@router.get( + "/account/forgot-password", response_class=HTMLResponse, include_in_schema=False +) +async def shop_forgot_password_page(request: Request, db: Session = Depends(get_db)): + """ + Render forgot password page. + Allows customers to reset their password. + """ + logger.debug( + "[STOREFRONT] shop_forgot_password_page REACHED", + extra={ + "path": request.url.path, + "vendor": getattr(request.state, "vendor", "NOT SET"), + "context": getattr(request.state, "context_type", "NOT SET"), + }, + ) + + return templates.TemplateResponse( + "customers/storefront/forgot-password.html", get_storefront_context(request, db=db) + ) + + +@router.get( + "/account/reset-password", response_class=HTMLResponse, include_in_schema=False +) +async def shop_reset_password_page( + request: Request, token: str = None, db: Session = Depends(get_db) +): + """ + Render reset password page. + User lands here after clicking the reset link in their email. + Token is passed as query parameter. + """ + logger.debug( + "[STOREFRONT] shop_reset_password_page REACHED", + extra={ + "path": request.url.path, + "vendor": getattr(request.state, "vendor", "NOT SET"), + "context": getattr(request.state, "context_type", "NOT SET"), + "has_token": bool(token), + }, + ) + + return templates.TemplateResponse( + "customers/storefront/reset-password.html", get_storefront_context(request, db=db) + ) + + +# ============================================================================ +# CUSTOMER ACCOUNT - ROOT REDIRECT +# ============================================================================ + + +@router.get("/account", response_class=RedirectResponse, include_in_schema=False) +@router.get("/account/", response_class=RedirectResponse, include_in_schema=False) +async def shop_account_root(request: Request): + """ + Redirect /storefront/account or /storefront/account/ to dashboard. + """ + logger.debug( + "[STOREFRONT] shop_account_root REACHED", + extra={ + "path": request.url.path, + "vendor": getattr(request.state, "vendor", "NOT SET"), + "context": getattr(request.state, "context_type", "NOT SET"), + }, + ) + + # Get base_url from context for proper redirect + vendor = getattr(request.state, "vendor", None) + vendor_context = getattr(request.state, "vendor_context", None) + access_method = ( + vendor_context.get("detection_method", "unknown") + if vendor_context + else "unknown" + ) + + base_url = "/" + if access_method == "path" and vendor: + full_prefix = ( + vendor_context.get("full_prefix", "/vendor/") + if vendor_context + else "/vendor/" + ) + base_url = f"{full_prefix}{vendor.subdomain}/" + + return RedirectResponse(url=f"{base_url}storefront/account/dashboard", status_code=302) + + +# ============================================================================ +# CUSTOMER ACCOUNT - AUTHENTICATED ROUTES +# ============================================================================ + + +@router.get( + "/account/dashboard", response_class=HTMLResponse, include_in_schema=False +) +async def shop_account_dashboard_page( + request: Request, + current_customer: Customer = Depends(get_current_customer_from_cookie_or_header), + db: Session = Depends(get_db), +): + """ + Render customer account dashboard. + Shows account overview, recent orders, and quick links. + Requires customer authentication. + """ + logger.debug( + "[STOREFRONT] shop_account_dashboard_page REACHED", + extra={ + "path": request.url.path, + "vendor": getattr(request.state, "vendor", "NOT SET"), + "context": getattr(request.state, "context_type", "NOT SET"), + }, + ) + + return templates.TemplateResponse( + "customers/storefront/dashboard.html", + get_storefront_context(request, db=db, user=current_customer), + ) + + +@router.get("/account/profile", response_class=HTMLResponse, include_in_schema=False) +async def shop_profile_page( + request: Request, + current_customer: Customer = Depends(get_current_customer_from_cookie_or_header), + db: Session = Depends(get_db), +): + """ + Render customer profile page. + Edit personal information and preferences. + Requires customer authentication. + """ + logger.debug( + "[STOREFRONT] shop_profile_page REACHED", + extra={ + "path": request.url.path, + "vendor": getattr(request.state, "vendor", "NOT SET"), + "context": getattr(request.state, "context_type", "NOT SET"), + }, + ) + + return templates.TemplateResponse( + "customers/storefront/profile.html", + get_storefront_context(request, db=db, user=current_customer), + ) + + +@router.get( + "/account/addresses", response_class=HTMLResponse, include_in_schema=False +) +async def shop_addresses_page( + request: Request, + current_customer: Customer = Depends(get_current_customer_from_cookie_or_header), + db: Session = Depends(get_db), +): + """ + Render customer addresses management page. + Manage shipping and billing addresses. + Requires customer authentication. + """ + logger.debug( + "[STOREFRONT] shop_addresses_page REACHED", + extra={ + "path": request.url.path, + "vendor": getattr(request.state, "vendor", "NOT SET"), + "context": getattr(request.state, "context_type", "NOT SET"), + }, + ) + + return templates.TemplateResponse( + "customers/storefront/addresses.html", + get_storefront_context(request, db=db, user=current_customer), + ) + + +@router.get("/account/settings", response_class=HTMLResponse, include_in_schema=False) +async def shop_settings_page( + request: Request, + current_customer: Customer = Depends(get_current_customer_from_cookie_or_header), + db: Session = Depends(get_db), +): + """ + Render customer account settings page. + Configure notifications, privacy, and preferences. + Requires customer authentication. + """ + logger.debug( + "[STOREFRONT] shop_settings_page REACHED", + extra={ + "path": request.url.path, + "vendor": getattr(request.state, "vendor", "NOT SET"), + "context": getattr(request.state, "context_type", "NOT SET"), + }, + ) + + return templates.TemplateResponse( + "customers/storefront/settings.html", + get_storefront_context(request, db=db, user=current_customer), + ) diff --git a/app/modules/customers/routes/pages/vendor.py b/app/modules/customers/routes/pages/vendor.py new file mode 100644 index 00000000..22190b5b --- /dev/null +++ b/app/modules/customers/routes/pages/vendor.py @@ -0,0 +1,42 @@ +# app/modules/customers/routes/pages/vendor.py +""" +Customers Vendor Page Routes (HTML rendering). + +Vendor pages for customer management: +- Customers list +""" + +from fastapi import APIRouter, Depends, Path, Request +from fastapi.responses import HTMLResponse +from sqlalchemy.orm import Session + +from app.api.deps import get_current_vendor_from_cookie_or_header, get_db +from app.modules.core.utils.page_context import get_vendor_context +from app.templates_config import templates +from models.database.user import User + +router = APIRouter() + + +# ============================================================================ +# CUSTOMER MANAGEMENT +# ============================================================================ + + +@router.get( + "/{vendor_code}/customers", response_class=HTMLResponse, include_in_schema=False +) +async def vendor_customers_page( + request: Request, + vendor_code: str = Path(..., description="Vendor code"), + current_user: User = Depends(get_current_vendor_from_cookie_or_header), + db: Session = Depends(get_db), +): + """ + Render customers management page. + JavaScript loads customer list via API. + """ + return templates.TemplateResponse( + "customers/vendor/customers.html", + get_vendor_context(request, db, current_user, vendor_code), + ) diff --git a/app/modules/customers/services/admin_customer_service.py b/app/modules/customers/services/admin_customer_service.py index 0b48f950..a6191817 100644 --- a/app/modules/customers/services/admin_customer_service.py +++ b/app/modules/customers/services/admin_customer_service.py @@ -11,7 +11,7 @@ from typing import Any from sqlalchemy import func from sqlalchemy.orm import Session -from app.exceptions.customer import CustomerNotFoundException +from app.modules.customers.exceptions import CustomerNotFoundException from app.modules.customers.models import Customer from models.database.vendor import Vendor diff --git a/app/modules/customers/services/customer_address_service.py b/app/modules/customers/services/customer_address_service.py index c9c51e85..933a71e9 100644 --- a/app/modules/customers/services/customer_address_service.py +++ b/app/modules/customers/services/customer_address_service.py @@ -9,7 +9,7 @@ import logging from sqlalchemy.orm import Session -from app.exceptions import ( +from app.modules.customers.exceptions import ( AddressLimitExceededException, AddressNotFoundException, ) diff --git a/app/modules/customers/services/customer_service.py b/app/modules/customers/services/customer_service.py index 33f4b683..6d73331d 100644 --- a/app/modules/customers/services/customer_service.py +++ b/app/modules/customers/services/customer_service.py @@ -13,7 +13,7 @@ from typing import Any from sqlalchemy import and_ from sqlalchemy.orm import Session -from app.exceptions.customer import ( +from app.modules.customers.exceptions import ( CustomerNotActiveException, CustomerNotFoundException, CustomerValidationException, @@ -22,8 +22,8 @@ from app.exceptions.customer import ( InvalidPasswordResetTokenException, PasswordTooShortException, ) -from app.exceptions.vendor import VendorNotActiveException, VendorNotFoundException -from app.services.auth_service import AuthService +from app.modules.tenancy.exceptions import VendorNotActiveException, VendorNotFoundException +from app.modules.core.services.auth_service import AuthService from app.modules.customers.models import Customer, PasswordResetToken from app.modules.customers.schemas import CustomerRegister, CustomerUpdate from models.database.vendor import Vendor diff --git a/app/templates/admin/customers.html b/app/modules/customers/templates/customers/admin/customers.html similarity index 100% rename from app/templates/admin/customers.html rename to app/modules/customers/templates/customers/admin/customers.html diff --git a/app/templates/storefront/account/addresses.html b/app/modules/customers/templates/customers/storefront/addresses.html similarity index 100% rename from app/templates/storefront/account/addresses.html rename to app/modules/customers/templates/customers/storefront/addresses.html diff --git a/app/templates/storefront/account/dashboard.html b/app/modules/customers/templates/customers/storefront/dashboard.html similarity index 100% rename from app/templates/storefront/account/dashboard.html rename to app/modules/customers/templates/customers/storefront/dashboard.html diff --git a/app/templates/storefront/account/forgot-password.html b/app/modules/customers/templates/customers/storefront/forgot-password.html similarity index 100% rename from app/templates/storefront/account/forgot-password.html rename to app/modules/customers/templates/customers/storefront/forgot-password.html diff --git a/app/templates/storefront/account/login.html b/app/modules/customers/templates/customers/storefront/login.html similarity index 100% rename from app/templates/storefront/account/login.html rename to app/modules/customers/templates/customers/storefront/login.html diff --git a/app/templates/storefront/account/profile.html b/app/modules/customers/templates/customers/storefront/profile.html similarity index 100% rename from app/templates/storefront/account/profile.html rename to app/modules/customers/templates/customers/storefront/profile.html diff --git a/app/templates/storefront/account/register.html b/app/modules/customers/templates/customers/storefront/register.html similarity index 100% rename from app/templates/storefront/account/register.html rename to app/modules/customers/templates/customers/storefront/register.html diff --git a/app/templates/storefront/account/reset-password.html b/app/modules/customers/templates/customers/storefront/reset-password.html similarity index 100% rename from app/templates/storefront/account/reset-password.html rename to app/modules/customers/templates/customers/storefront/reset-password.html diff --git a/app/templates/vendor/customers.html b/app/modules/customers/templates/customers/vendor/customers.html similarity index 100% rename from app/templates/vendor/customers.html rename to app/modules/customers/templates/customers/vendor/customers.html diff --git a/app/modules/dev_tools/routes/api/admin.py b/app/modules/dev_tools/routes/api/admin.py deleted file mode 100644 index c98ecb97..00000000 --- a/app/modules/dev_tools/routes/api/admin.py +++ /dev/null @@ -1,35 +0,0 @@ -# app/modules/dev_tools/routes/api/admin.py -""" -Dev-Tools Admin API Routes. - -Provides admin-only API endpoints for: -- Code quality scanning (architecture, security, performance) -- Violation management -- Test execution - -Note: This currently re-exports routes from legacy locations. -In future cleanup phases, the route implementations may be moved here. -""" - -from fastapi import APIRouter - -# Import the existing routers from legacy locations -from app.api.v1.admin.code_quality import router as code_quality_router -from app.api.v1.admin.tests import router as tests_router - -# Create a combined admin router for the dev-tools module -admin_router = APIRouter(prefix="/dev-tools", tags=["dev-tools"]) - -# Include sub-routers -admin_router.include_router( - code_quality_router, - prefix="/code-quality", - tags=["code-quality"], -) -admin_router.include_router( - tests_router, - prefix="/tests", - tags=["tests"], -) - -__all__ = ["admin_router"] diff --git a/app/modules/dev_tools/routes/pages/admin.py b/app/modules/dev_tools/routes/pages/admin.py new file mode 100644 index 00000000..222aa032 --- /dev/null +++ b/app/modules/dev_tools/routes/pages/admin.py @@ -0,0 +1,131 @@ +# app/modules/dev_tools/routes/pages/admin.py +""" +Dev Tools Admin Page Routes (HTML rendering). + +Admin pages for developer tools: +- Components library +- Icons browser +- Testing dashboard +- Testing hub +- Test auth flow +- Test vendors/users migration +""" + +from fastapi import APIRouter, Depends, Request +from fastapi.responses import HTMLResponse +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 + +router = APIRouter() + + +# ============================================================================ +# DEVELOPER TOOLS - COMPONENTS & TESTING +# ============================================================================ + + +@router.get("/components", response_class=HTMLResponse, include_in_schema=False) +async def admin_components_page( + request: Request, + current_user: User = Depends( + require_menu_access("components", FrontendType.ADMIN) + ), + db: Session = Depends(get_db), +): + """ + Render UI components library page. + Reference for all available UI components. + """ + return templates.TemplateResponse( + "dev_tools/admin/components.html", + get_admin_context(request, current_user), + ) + + +@router.get("/icons", response_class=HTMLResponse, include_in_schema=False) +async def admin_icons_page( + request: Request, + current_user: User = Depends(require_menu_access("icons", FrontendType.ADMIN)), + db: Session = Depends(get_db), +): + """ + Render icons browser page. + Browse and search all available icons. + """ + return templates.TemplateResponse( + "dev_tools/admin/icons.html", + get_admin_context(request, current_user), + ) + + +@router.get("/testing", response_class=HTMLResponse, include_in_schema=False) +async def admin_testing_dashboard( + request: Request, + current_user: User = Depends(require_menu_access("testing", FrontendType.ADMIN)), + db: Session = Depends(get_db), +): + """ + Render testing dashboard page. + pytest results and test coverage overview. + """ + return templates.TemplateResponse( + "dev_tools/admin/testing-dashboard.html", + get_admin_context(request, current_user), + ) + + +@router.get("/testing-hub", response_class=HTMLResponse, include_in_schema=False) +async def admin_testing_hub( + request: Request, + current_user: User = Depends(require_menu_access("testing", FrontendType.ADMIN)), + db: Session = Depends(get_db), +): + """ + Render manual testing hub page. + Central hub for all manual test suites and QA tools. + """ + return templates.TemplateResponse( + "dev_tools/admin/testing-hub.html", + get_admin_context(request, current_user), + ) + + +@router.get("/test/auth-flow", response_class=HTMLResponse, include_in_schema=False) +async def admin_test_auth_flow( + request: Request, + current_user: User = Depends(require_menu_access("testing", FrontendType.ADMIN)), + db: Session = Depends(get_db), +): + """ + Render authentication flow testing page. + Tests login, logout, token expiration, and protected routes. + """ + return templates.TemplateResponse( + "dev_tools/admin/test-auth-flow.html", + get_admin_context(request, current_user), + ) + + +@router.get( + "/test/vendors-users-migration", + response_class=HTMLResponse, + include_in_schema=False, +) +async def admin_test_vendors_users_migration( + request: Request, + current_user: User = Depends(require_menu_access("testing", FrontendType.ADMIN)), + db: Session = Depends(get_db), +): + """ + Render vendors and users migration testing page. + Tests CRUD operations, data migration, and form validation. + """ + return templates.TemplateResponse( + "dev_tools/admin/test-vendors-users-migration.html", + get_admin_context(request, current_user), + ) diff --git a/app/modules/dev_tools/tasks/code_quality.py b/app/modules/dev_tools/tasks/code_quality.py index cff745e2..2f835511 100644 --- a/app/modules/dev_tools/tasks/code_quality.py +++ b/app/modules/dev_tools/tasks/code_quality.py @@ -13,8 +13,8 @@ import subprocess from datetime import UTC, datetime from app.core.celery_config import celery_app -from app.services.admin_notification_service import admin_notification_service -from app.tasks.celery_tasks.base import DatabaseTask +from app.modules.messaging.services.admin_notification_service import admin_notification_service +from app.modules.task_base import ModuleTask from app.modules.dev_tools.models import ArchitectureScan, ArchitectureViolation logger = logging.getLogger(__name__) @@ -59,7 +59,7 @@ def _get_git_commit_hash() -> str | None: @celery_app.task( bind=True, - base=DatabaseTask, + base=ModuleTask, name="app.modules.dev_tools.tasks.code_quality.execute_code_quality_scan", max_retries=1, time_limit=700, # 11+ minutes hard limit diff --git a/app/modules/dev_tools/tasks/test_runner.py b/app/modules/dev_tools/tasks/test_runner.py index 4d6793a1..810dbbb0 100644 --- a/app/modules/dev_tools/tasks/test_runner.py +++ b/app/modules/dev_tools/tasks/test_runner.py @@ -10,8 +10,8 @@ for backward compatibility. import logging from app.core.celery_config import celery_app -from app.services.test_runner_service import test_runner_service -from app.tasks.celery_tasks.base import DatabaseTask +from app.modules.dev_tools.services.test_runner_service import test_runner_service +from app.modules.task_base import ModuleTask from app.modules.dev_tools.models import TestRun logger = logging.getLogger(__name__) @@ -19,7 +19,7 @@ logger = logging.getLogger(__name__) @celery_app.task( bind=True, - base=DatabaseTask, + base=ModuleTask, name="app.modules.dev_tools.tasks.test_runner.execute_test_run", max_retries=1, time_limit=3600, # 1 hour hard limit diff --git a/app/templates/admin/code-quality-dashboard.html b/app/modules/dev_tools/templates/dev_tools/admin/code-quality-dashboard.html similarity index 100% rename from app/templates/admin/code-quality-dashboard.html rename to app/modules/dev_tools/templates/dev_tools/admin/code-quality-dashboard.html diff --git a/app/templates/admin/code-quality-violation-detail.html b/app/modules/dev_tools/templates/dev_tools/admin/code-quality-violation-detail.html similarity index 100% rename from app/templates/admin/code-quality-violation-detail.html rename to app/modules/dev_tools/templates/dev_tools/admin/code-quality-violation-detail.html diff --git a/app/templates/admin/code-quality-violations.html b/app/modules/dev_tools/templates/dev_tools/admin/code-quality-violations.html similarity index 100% rename from app/templates/admin/code-quality-violations.html rename to app/modules/dev_tools/templates/dev_tools/admin/code-quality-violations.html diff --git a/app/templates/admin/components.html b/app/modules/dev_tools/templates/dev_tools/admin/components.html similarity index 100% rename from app/templates/admin/components.html rename to app/modules/dev_tools/templates/dev_tools/admin/components.html diff --git a/app/templates/admin/icons.html b/app/modules/dev_tools/templates/dev_tools/admin/icons.html similarity index 100% rename from app/templates/admin/icons.html rename to app/modules/dev_tools/templates/dev_tools/admin/icons.html diff --git a/app/templates/admin/test-auth-flow.html b/app/modules/dev_tools/templates/dev_tools/admin/test-auth-flow.html similarity index 100% rename from app/templates/admin/test-auth-flow.html rename to app/modules/dev_tools/templates/dev_tools/admin/test-auth-flow.html diff --git a/app/templates/admin/test-vendors-users-migration.html b/app/modules/dev_tools/templates/dev_tools/admin/test-vendors-users-migration.html similarity index 100% rename from app/templates/admin/test-vendors-users-migration.html rename to app/modules/dev_tools/templates/dev_tools/admin/test-vendors-users-migration.html diff --git a/app/templates/admin/testing-dashboard.html b/app/modules/dev_tools/templates/dev_tools/admin/testing-dashboard.html similarity index 100% rename from app/templates/admin/testing-dashboard.html rename to app/modules/dev_tools/templates/dev_tools/admin/testing-dashboard.html diff --git a/app/templates/admin/testing-hub.html b/app/modules/dev_tools/templates/dev_tools/admin/testing-hub.html similarity index 100% rename from app/templates/admin/testing-hub.html rename to app/modules/dev_tools/templates/dev_tools/admin/testing-hub.html diff --git a/app/modules/inventory/exceptions.py b/app/modules/inventory/exceptions.py index 1c0c5edb..a132be6f 100644 --- a/app/modules/inventory/exceptions.py +++ b/app/modules/inventory/exceptions.py @@ -2,17 +2,18 @@ """ Inventory module exceptions. -Re-exports inventory-related exceptions from their source locations. +This module provides exception classes for inventory operations including: +- Inventory record management +- Quantity validation +- Location management """ -from app.exceptions.inventory import ( - InventoryNotFoundException, - InsufficientInventoryException, - InvalidInventoryOperationException, - InventoryValidationException, - NegativeInventoryException, - InvalidQuantityException, - LocationNotFoundException, +from typing import Any + +from app.exceptions.base import ( + BusinessLogicException, + ResourceNotFoundException, + ValidationException, ) __all__ = [ @@ -24,3 +25,130 @@ __all__ = [ "InvalidQuantityException", "LocationNotFoundException", ] + + +class InventoryNotFoundException(ResourceNotFoundException): + """Raised when inventory record is not found.""" + + def __init__(self, identifier: str, identifier_type: str = "ID"): + if identifier_type.lower() == "gtin": + message = f"No inventory found for GTIN '{identifier}'" + else: + message = ( + f"Inventory record with {identifier_type} '{identifier}' not found" + ) + + super().__init__( + resource_type="Inventory", + identifier=identifier, + message=message, + error_code="INVENTORY_NOT_FOUND", + ) + + +class InsufficientInventoryException(BusinessLogicException): + """Raised when trying to remove more inventory than available.""" + + def __init__( + self, + gtin: str, + location: str, + requested: int, + available: int, + ): + message = f"Insufficient inventory for GTIN '{gtin}' at '{location}'. Requested: {requested}, Available: {available}" + + super().__init__( + message=message, + error_code="INSUFFICIENT_INVENTORY", + details={ + "gtin": gtin, + "location": location, + "requested_quantity": requested, + "available_quantity": available, + }, + ) + + +class InvalidInventoryOperationException(ValidationException): + """Raised when inventory operation is invalid.""" + + def __init__( + self, + message: str, + operation: str | None = None, + details: dict[str, Any] | None = None, + ): + if not details: + details = {} + + if operation: + details["operation"] = operation + + super().__init__( + message=message, + details=details, + ) + self.error_code = "INVALID_INVENTORY_OPERATION" + + +class InventoryValidationException(ValidationException): + """Raised when inventory data validation fails.""" + + def __init__( + self, + message: str = "Inventory validation failed", + field: str | None = None, + validation_errors: dict[str, str] | None = None, + ): + details = {} + if validation_errors: + details["validation_errors"] = validation_errors + + super().__init__( + message=message, + field=field, + details=details, + ) + self.error_code = "INVENTORY_VALIDATION_FAILED" + + +class NegativeInventoryException(BusinessLogicException): + """Raised when inventory quantity would become negative.""" + + def __init__(self, gtin: str, location: str, resulting_quantity: int): + message = f"Inventory operation would result in negative quantity ({resulting_quantity}) for GTIN '{gtin}' at '{location}'" + + super().__init__( + message=message, + error_code="NEGATIVE_INVENTORY_NOT_ALLOWED", + details={ + "gtin": gtin, + "location": location, + "resulting_quantity": resulting_quantity, + }, + ) + + +class InvalidQuantityException(ValidationException): + """Raised when quantity value is invalid.""" + + def __init__(self, quantity: Any, message: str = "Invalid quantity"): + super().__init__( + message=f"{message}: {quantity}", + field="quantity", + details={"quantity": quantity}, + ) + self.error_code = "INVALID_QUANTITY" + + +class LocationNotFoundException(ResourceNotFoundException): + """Raised when inventory location is not found.""" + + def __init__(self, location: str): + super().__init__( + resource_type="Location", + identifier=location, + message=f"Inventory location '{location}' not found", + error_code="LOCATION_NOT_FOUND", + ) diff --git a/app/modules/inventory/routes/api/admin.py b/app/modules/inventory/routes/api/admin.py index edbe9fc3..6d585ed3 100644 --- a/app/modules/inventory/routes/api/admin.py +++ b/app/modules/inventory/routes/api/admin.py @@ -20,9 +20,9 @@ from sqlalchemy.orm import Session from app.api.deps import get_current_admin_api, require_module_access from app.core.database import get_db -from app.services.inventory_import_service import inventory_import_service -from app.services.inventory_service import inventory_service -from app.services.inventory_transaction_service import inventory_transaction_service +from app.modules.inventory.services.inventory_import_service import inventory_import_service +from app.modules.inventory.services.inventory_service import inventory_service +from app.modules.inventory.services.inventory_transaction_service import inventory_transaction_service from models.schema.auth import UserContext from app.modules.inventory.schemas import ( AdminInventoryAdjust, diff --git a/app/modules/inventory/routes/api/vendor.py b/app/modules/inventory/routes/api/vendor.py index cb043c81..c9e9006a 100644 --- a/app/modules/inventory/routes/api/vendor.py +++ b/app/modules/inventory/routes/api/vendor.py @@ -13,8 +13,8 @@ from sqlalchemy.orm import Session from app.api.deps import get_current_vendor_api, require_module_access from app.core.database import get_db -from app.services.inventory_service import inventory_service -from app.services.inventory_transaction_service import inventory_transaction_service +from app.modules.inventory.services.inventory_service import inventory_service +from app.modules.inventory.services.inventory_transaction_service import inventory_transaction_service from models.schema.auth import UserContext from app.modules.inventory.schemas import ( InventoryAdjust, diff --git a/app/modules/inventory/routes/pages/__init__.py b/app/modules/inventory/routes/pages/__init__.py index 036b4041..6be609da 100644 --- a/app/modules/inventory/routes/pages/__init__.py +++ b/app/modules/inventory/routes/pages/__init__.py @@ -1,4 +1,2 @@ -# Page routes will be added here -# TODO: Add HTML page routes for admin/vendor dashboards - -__all__ = [] +# app/modules/inventory/routes/pages/__init__.py +"""Inventory module page routes.""" diff --git a/app/modules/inventory/routes/pages/admin.py b/app/modules/inventory/routes/pages/admin.py new file mode 100644 index 00000000..b4bea7da --- /dev/null +++ b/app/modules/inventory/routes/pages/admin.py @@ -0,0 +1,40 @@ +# app/modules/inventory/routes/pages/admin.py +""" +Inventory Admin Page Routes (HTML rendering). + +Admin pages for inventory management: +- Inventory list +""" + +from fastapi import APIRouter, Depends, Request +from fastapi.responses import HTMLResponse +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 + +router = APIRouter() + + +# ============================================================================ +# INVENTORY MANAGEMENT ROUTES +# ============================================================================ + + +@router.get("/inventory", response_class=HTMLResponse, include_in_schema=False) +async def admin_inventory_page( + request: Request, + current_user: User = Depends(require_menu_access("inventory", FrontendType.ADMIN)), + db: Session = Depends(get_db), +): + """ + Render inventory management page. + Shows stock levels across all vendors with filtering and adjustment capabilities. + """ + return templates.TemplateResponse( + "inventory/admin/inventory.html", + get_admin_context(request, current_user), + ) diff --git a/app/modules/inventory/routes/pages/vendor.py b/app/modules/inventory/routes/pages/vendor.py new file mode 100644 index 00000000..8a4741a3 --- /dev/null +++ b/app/modules/inventory/routes/pages/vendor.py @@ -0,0 +1,42 @@ +# app/modules/inventory/routes/pages/vendor.py +""" +Inventory Vendor Page Routes (HTML rendering). + +Vendor pages for inventory management: +- Inventory list +""" + +from fastapi import APIRouter, Depends, Path, Request +from fastapi.responses import HTMLResponse +from sqlalchemy.orm import Session + +from app.api.deps import get_current_vendor_from_cookie_or_header, get_db +from app.modules.core.utils.page_context import get_vendor_context +from app.templates_config import templates +from models.database.user import User + +router = APIRouter() + + +# ============================================================================ +# INVENTORY MANAGEMENT +# ============================================================================ + + +@router.get( + "/{vendor_code}/inventory", response_class=HTMLResponse, include_in_schema=False +) +async def vendor_inventory_page( + request: Request, + vendor_code: str = Path(..., description="Vendor code"), + current_user: User = Depends(get_current_vendor_from_cookie_or_header), + db: Session = Depends(get_db), +): + """ + Render inventory management page. + JavaScript loads inventory data via API. + """ + return templates.TemplateResponse( + "inventory/vendor/inventory.html", + get_vendor_context(request, db, current_user, vendor_code), + ) diff --git a/app/modules/inventory/services/inventory_service.py b/app/modules/inventory/services/inventory_service.py index 0d6c18b2..055982b0 100644 --- a/app/modules/inventory/services/inventory_service.py +++ b/app/modules/inventory/services/inventory_service.py @@ -5,15 +5,15 @@ from datetime import UTC, datetime from sqlalchemy import func from sqlalchemy.orm import Session -from app.exceptions import ( +from app.exceptions import ValidationException +from app.modules.inventory.exceptions import ( InsufficientInventoryException, InvalidQuantityException, InventoryNotFoundException, InventoryValidationException, - ProductNotFoundException, - ValidationException, - VendorNotFoundException, ) +from app.modules.catalog.exceptions import ProductNotFoundException +from app.modules.tenancy.exceptions import VendorNotFoundException from app.modules.inventory.models.inventory import Inventory from app.modules.inventory.schemas.inventory import ( AdminInventoryItem, diff --git a/app/modules/inventory/services/inventory_transaction_service.py b/app/modules/inventory/services/inventory_transaction_service.py index fbba7525..7366bad9 100644 --- a/app/modules/inventory/services/inventory_transaction_service.py +++ b/app/modules/inventory/services/inventory_transaction_service.py @@ -11,7 +11,8 @@ import logging from sqlalchemy import func from sqlalchemy.orm import Session -from app.exceptions import OrderNotFoundException, ProductNotFoundException +from app.modules.catalog.exceptions import ProductNotFoundException +from app.modules.orders.exceptions import OrderNotFoundException from app.modules.inventory.models.inventory import Inventory from app.modules.inventory.models.inventory_transaction import InventoryTransaction from app.modules.orders.models import Order diff --git a/app/templates/admin/inventory.html b/app/modules/inventory/templates/inventory/admin/inventory.html similarity index 100% rename from app/templates/admin/inventory.html rename to app/modules/inventory/templates/inventory/admin/inventory.html diff --git a/app/templates/vendor/inventory.html b/app/modules/inventory/templates/inventory/vendor/inventory.html similarity index 100% rename from app/templates/vendor/inventory.html rename to app/modules/inventory/templates/inventory/vendor/inventory.html diff --git a/app/modules/marketplace/exceptions.py b/app/modules/marketplace/exceptions.py index bebdba18..270b6b8f 100644 --- a/app/modules/marketplace/exceptions.py +++ b/app/modules/marketplace/exceptions.py @@ -2,23 +2,110 @@ """ Marketplace module exceptions. -Custom exceptions for Letzshop integration, product import/export, and sync operations. +This module provides exception classes for marketplace operations including: +- Letzshop integration (client, authentication, credentials) +- Product import/export +- Sync operations +- Onboarding wizard """ -from app.exceptions import BusinessLogicException, ResourceNotFoundException +from typing import Any + +from app.exceptions.base import ( + AuthorizationException, + BusinessLogicException, + ConflictException, + ExternalServiceException, + ResourceNotFoundException, + ValidationException, +) + +__all__ = [ + # Base exception + "MarketplaceException", + # Letzshop client exceptions + "LetzshopClientError", + "LetzshopAuthenticationError", + "LetzshopCredentialsNotFoundException", + "LetzshopConnectionFailedException", + # Import job exceptions + "ImportJobNotFoundException", + "HistoricalImportJobNotFoundException", + "ImportJobNotOwnedException", + "ImportJobCannotBeCancelledException", + "ImportJobCannotBeDeletedException", + "ImportJobAlreadyProcessingException", + "ImportValidationError", + "InvalidImportDataException", + "ImportRateLimitException", + # Marketplace exceptions + "MarketplaceImportException", + "MarketplaceConnectionException", + "MarketplaceDataParsingException", + "InvalidMarketplaceException", + # Product exceptions + "VendorNotFoundException", + "ProductNotFoundException", + "MarketplaceProductNotFoundException", + "MarketplaceProductAlreadyExistsException", + "InvalidMarketplaceProductDataException", + "MarketplaceProductValidationException", + "InvalidGTINException", + "MarketplaceProductCSVImportException", + # Export/Sync exceptions + "ExportError", + "SyncError", + # Onboarding exceptions + "OnboardingNotFoundException", + "OnboardingStepOrderException", + "OnboardingAlreadyCompletedException", + "OnboardingCsvUrlRequiredException", + "OnboardingSyncJobNotFoundException", + "OnboardingSyncNotCompleteException", +] + + +# ============================================================================= +# Base Marketplace Exception +# ============================================================================= class MarketplaceException(BusinessLogicException): """Base exception for marketplace module errors.""" - pass + def __init__( + self, + message: str, + error_code: str = "MARKETPLACE_ERROR", + details: dict | None = None, + ): + super().__init__(message=message, error_code=error_code, details=details) + + +# ============================================================================= +# Letzshop Client Exceptions +# ============================================================================= class LetzshopClientError(MarketplaceException): """Raised when Letzshop API call fails.""" - def __init__(self, message: str, status_code: int | None = None, response: str | None = None): - super().__init__(message) + def __init__( + self, + message: str, + status_code: int | None = None, + response: str | None = None, + ): + details = {} + if status_code: + details["http_status_code"] = status_code + if response: + details["response"] = response + super().__init__( + message=message, + error_code="LETZSHOP_CLIENT_ERROR", + details=details if details else None, + ) self.status_code = status_code self.response = response @@ -28,57 +115,393 @@ class LetzshopAuthenticationError(LetzshopClientError): def __init__(self, message: str = "Letzshop authentication failed"): super().__init__(message, status_code=401) + self.error_code = "LETZSHOP_AUTHENTICATION_FAILED" class LetzshopCredentialsNotFoundException(ResourceNotFoundException): """Raised when Letzshop credentials not found for vendor.""" def __init__(self, vendor_id: int): - super().__init__("LetzshopCredentials", str(vendor_id)) + super().__init__( + resource_type="LetzshopCredentials", + identifier=str(vendor_id), + error_code="LETZSHOP_CREDENTIALS_NOT_FOUND", + ) self.vendor_id = vendor_id +class LetzshopConnectionFailedException(BusinessLogicException): + """Raised when Letzshop API connection test fails.""" + + def __init__(self, error_message: str): + super().__init__( + message=f"Letzshop connection failed: {error_message}", + error_code="LETZSHOP_CONNECTION_FAILED", + details={"error": error_message}, + ) + + +# ============================================================================= +# Import Job Exceptions +# ============================================================================= + + class ImportJobNotFoundException(ResourceNotFoundException): """Raised when a marketplace import job is not found.""" def __init__(self, job_id: int): - super().__init__("MarketplaceImportJob", str(job_id)) + super().__init__( + resource_type="MarketplaceImportJob", + identifier=str(job_id), + message=f"Import job with ID '{job_id}' not found", + error_code="IMPORT_JOB_NOT_FOUND", + ) class HistoricalImportJobNotFoundException(ResourceNotFoundException): """Raised when a historical import job is not found.""" def __init__(self, job_id: int): - super().__init__("LetzshopHistoricalImportJob", str(job_id)) + super().__init__( + resource_type="LetzshopHistoricalImportJob", + identifier=str(job_id), + error_code="HISTORICAL_IMPORT_JOB_NOT_FOUND", + ) -class VendorNotFoundException(ResourceNotFoundException): - """Raised when a vendor is not found.""" +class ImportJobNotOwnedException(AuthorizationException): + """Raised when user tries to access import job they don't own.""" - def __init__(self, vendor_id: int): - super().__init__("Vendor", str(vendor_id)) + def __init__(self, job_id: int, user_id: int | None = None): + details = {"job_id": job_id} + if user_id: + details["user_id"] = user_id + + super().__init__( + message=f"Unauthorized access to import job '{job_id}'", + error_code="IMPORT_JOB_NOT_OWNED", + details=details, + ) -class ProductNotFoundException(ResourceNotFoundException): - """Raised when a marketplace product is not found.""" +class ImportJobCannotBeCancelledException(BusinessLogicException): + """Raised when trying to cancel job that cannot be cancelled.""" - def __init__(self, product_id: str | int): - super().__init__("MarketplaceProduct", str(product_id)) + def __init__(self, job_id: int, current_status: str): + super().__init__( + message=f"Import job '{job_id}' cannot be cancelled (current status: {current_status})", + error_code="IMPORT_JOB_CANNOT_BE_CANCELLED", + details={ + "job_id": job_id, + "current_status": current_status, + }, + ) + + +class ImportJobCannotBeDeletedException(BusinessLogicException): + """Raised when trying to delete job that cannot be deleted.""" + + def __init__(self, job_id: int, current_status: str): + super().__init__( + message=f"Import job '{job_id}' cannot be deleted (current status: {current_status})", + error_code="IMPORT_JOB_CANNOT_BE_DELETED", + details={ + "job_id": job_id, + "current_status": current_status, + }, + ) + + +class ImportJobAlreadyProcessingException(BusinessLogicException): + """Raised when trying to start import while another is already processing.""" + + def __init__(self, vendor_code: str, existing_job_id: int): + super().__init__( + message=f"Import already in progress for vendor '{vendor_code}'", + error_code="IMPORT_JOB_ALREADY_PROCESSING", + details={ + "vendor_code": vendor_code, + "existing_job_id": existing_job_id, + }, + ) class ImportValidationError(MarketplaceException): """Raised when import data validation fails.""" def __init__(self, message: str, errors: list[dict] | None = None): - super().__init__(message) + super().__init__( + message=message, + error_code="IMPORT_VALIDATION_ERROR", + details={"errors": errors} if errors else None, + ) self.errors = errors or [] +class InvalidImportDataException(ValidationException): + """Raised when import data is invalid.""" + + def __init__( + self, + message: str = "Invalid import data", + field: str | None = None, + row_number: int | None = None, + details: dict[str, Any] | None = None, + ): + if not details: + details = {} + + if row_number: + details["row_number"] = row_number + + super().__init__( + message=message, + field=field, + details=details, + ) + self.error_code = "INVALID_IMPORT_DATA" + + +class ImportRateLimitException(BusinessLogicException): + """Raised when import rate limit is exceeded.""" + + def __init__( + self, + max_imports: int, + time_window: str, + retry_after: int | None = None, + ): + details = { + "max_imports": max_imports, + "time_window": time_window, + } + + if retry_after: + details["retry_after"] = retry_after + + super().__init__( + message=f"Import rate limit exceeded: {max_imports} imports per {time_window}", + error_code="IMPORT_RATE_LIMIT_EXCEEDED", + details=details, + ) + + +# ============================================================================= +# Marketplace Exceptions +# ============================================================================= + + +class MarketplaceImportException(BusinessLogicException): + """Base exception for marketplace import operations.""" + + def __init__( + self, + message: str, + error_code: str = "MARKETPLACE_IMPORT_ERROR", + marketplace: str | None = None, + details: dict[str, Any] | None = None, + ): + if not details: + details = {} + + if marketplace: + details["marketplace"] = marketplace + + super().__init__( + message=message, + error_code=error_code, + details=details, + ) + + +class MarketplaceConnectionException(ExternalServiceException): + """Raised when marketplace connection fails.""" + + def __init__( + self, marketplace: str, message: str = "Failed to connect to marketplace" + ): + super().__init__( + service_name=marketplace, + message=f"{message}: {marketplace}", + error_code="MARKETPLACE_CONNECTION_FAILED", + ) + + +class MarketplaceDataParsingException(ValidationException): + """Raised when marketplace data cannot be parsed.""" + + def __init__( + self, + marketplace: str, + message: str = "Failed to parse marketplace data", + details: dict[str, Any] | None = None, + ): + if not details: + details = {} + details["marketplace"] = marketplace + + super().__init__( + message=f"{message} from {marketplace}", + details=details, + ) + self.error_code = "MARKETPLACE_DATA_PARSING_FAILED" + + +class InvalidMarketplaceException(ValidationException): + """Raised when marketplace is not supported.""" + + def __init__(self, marketplace: str, supported_marketplaces: list | None = None): + details = {"marketplace": marketplace} + if supported_marketplaces: + details["supported_marketplaces"] = supported_marketplaces + + super().__init__( + message=f"Unsupported marketplace: {marketplace}", + field="marketplace", + details=details, + ) + self.error_code = "INVALID_MARKETPLACE" + + +# ============================================================================= +# Product Exceptions +# ============================================================================= + + +class VendorNotFoundException(ResourceNotFoundException): + """Raised when a vendor is not found.""" + + def __init__(self, vendor_id: int): + super().__init__( + resource_type="Vendor", + identifier=str(vendor_id), + error_code="VENDOR_NOT_FOUND", + ) + + +class ProductNotFoundException(ResourceNotFoundException): + """Raised when a marketplace product is not found.""" + + def __init__(self, product_id: str | int): + super().__init__( + resource_type="MarketplaceProduct", + identifier=str(product_id), + error_code="MARKETPLACE_PRODUCT_NOT_FOUND", + ) + + +class MarketplaceProductNotFoundException(ResourceNotFoundException): + """Raised when a product is not found.""" + + def __init__(self, marketplace_product_id: str): + super().__init__( + resource_type="MarketplaceProduct", + identifier=marketplace_product_id, + message=f"MarketplaceProduct with ID '{marketplace_product_id}' not found", + error_code="PRODUCT_NOT_FOUND", + ) + + +class MarketplaceProductAlreadyExistsException(ConflictException): + """Raised when trying to create a product that already exists.""" + + def __init__(self, marketplace_product_id: str): + super().__init__( + message=f"MarketplaceProduct with ID '{marketplace_product_id}' already exists", + error_code="PRODUCT_ALREADY_EXISTS", + details={"marketplace_product_id": marketplace_product_id}, + ) + + +class InvalidMarketplaceProductDataException(ValidationException): + """Raised when product data is invalid.""" + + def __init__( + self, + message: str = "Invalid product data", + field: str | None = None, + details: dict[str, Any] | None = None, + ): + super().__init__( + message=message, + field=field, + details=details, + ) + self.error_code = "INVALID_PRODUCT_DATA" + + +class MarketplaceProductValidationException(ValidationException): + """Raised when product validation fails.""" + + def __init__( + self, + message: str, + field: str | None = None, + validation_errors: dict[str, str] | None = None, + ): + details = {} + if validation_errors: + details["validation_errors"] = validation_errors + + super().__init__( + message=message, + field=field, + details=details, + ) + self.error_code = "PRODUCT_VALIDATION_FAILED" + + +class InvalidGTINException(ValidationException): + """Raised when GTIN format is invalid.""" + + def __init__(self, gtin: str, message: str = "Invalid GTIN format"): + super().__init__( + message=f"{message}: {gtin}", + field="gtin", + details={"gtin": gtin}, + ) + self.error_code = "INVALID_GTIN" + + +class MarketplaceProductCSVImportException(BusinessLogicException): + """Raised when product CSV import fails.""" + + def __init__( + self, + message: str = "MarketplaceProduct CSV import failed", + row_number: int | None = None, + errors: dict[str, Any] | None = None, + ): + details = {} + if row_number: + details["row_number"] = row_number + if errors: + details["errors"] = errors + + super().__init__( + message=message, + error_code="PRODUCT_CSV_IMPORT_FAILED", + details=details, + ) + + +# ============================================================================= +# Export/Sync Exceptions +# ============================================================================= + + class ExportError(MarketplaceException): """Raised when product export fails.""" def __init__(self, message: str, language: str | None = None): - super().__init__(message) + details = {} + if language: + details["language"] = language + super().__init__( + message=message, + error_code="EXPORT_ERROR", + details=details if details else None, + ) self.language = language @@ -86,20 +509,87 @@ class SyncError(MarketplaceException): """Raised when vendor directory sync fails.""" def __init__(self, message: str, vendor_code: str | None = None): - super().__init__(message) + details = {} + if vendor_code: + details["vendor_code"] = vendor_code + super().__init__( + message=message, + error_code="SYNC_ERROR", + details=details if details else None, + ) self.vendor_code = vendor_code -__all__ = [ - "MarketplaceException", - "LetzshopClientError", - "LetzshopAuthenticationError", - "LetzshopCredentialsNotFoundException", - "ImportJobNotFoundException", - "HistoricalImportJobNotFoundException", - "VendorNotFoundException", - "ProductNotFoundException", - "ImportValidationError", - "ExportError", - "SyncError", -] +# ============================================================================= +# Onboarding Exceptions +# ============================================================================= + + +class OnboardingNotFoundException(ResourceNotFoundException): + """Raised when onboarding record is not found for a vendor.""" + + def __init__(self, vendor_id: int): + super().__init__( + resource_type="VendorOnboarding", + identifier=str(vendor_id), + error_code="ONBOARDING_NOT_FOUND", + ) + + +class OnboardingStepOrderException(ValidationException): + """Raised when trying to access a step out of order.""" + + def __init__(self, current_step: str, required_step: str): + super().__init__( + message=f"Please complete the {required_step} step first", + field="step", + details={ + "current_step": current_step, + "required_step": required_step, + }, + ) + self.error_code = "ONBOARDING_STEP_ORDER_ERROR" + + +class OnboardingAlreadyCompletedException(BusinessLogicException): + """Raised when trying to modify a completed onboarding.""" + + def __init__(self, vendor_id: int): + super().__init__( + message="Onboarding has already been completed", + error_code="ONBOARDING_ALREADY_COMPLETED", + details={"vendor_id": vendor_id}, + ) + + +class OnboardingCsvUrlRequiredException(ValidationException): + """Raised when no CSV URL is provided in product import step.""" + + def __init__(self): + super().__init__( + message="At least one CSV URL must be provided", + field="csv_url", + ) + self.error_code = "ONBOARDING_CSV_URL_REQUIRED" + + +class OnboardingSyncJobNotFoundException(ResourceNotFoundException): + """Raised when sync job is not found.""" + + def __init__(self, job_id: int): + super().__init__( + resource_type="LetzshopHistoricalImportJob", + identifier=str(job_id), + error_code="ONBOARDING_SYNC_JOB_NOT_FOUND", + ) + + +class OnboardingSyncNotCompleteException(BusinessLogicException): + """Raised when trying to complete onboarding before sync is done.""" + + def __init__(self, job_status: str): + super().__init__( + message=f"Import job is still {job_status}, please wait", + error_code="SYNC_NOT_COMPLETE", + details={"job_status": job_status}, + ) diff --git a/app/modules/marketplace/routes/api/admin_letzshop.py b/app/modules/marketplace/routes/api/admin_letzshop.py index 5fabbf1f..37f81b3f 100644 --- a/app/modules/marketplace/routes/api/admin_letzshop.py +++ b/app/modules/marketplace/routes/api/admin_letzshop.py @@ -18,12 +18,9 @@ from sqlalchemy.orm import Session from app.api.deps import get_current_admin_api, require_module_access from app.core.database import get_db -from app.exceptions import ( - OrderHasUnresolvedExceptionsException, - ResourceNotFoundException, - ValidationException, -) -from app.services.order_item_exception_service import order_item_exception_service +from app.exceptions import ResourceNotFoundException, ValidationException +from app.modules.orders.exceptions import OrderHasUnresolvedExceptionsException +from app.modules.orders.services.order_item_exception_service import order_item_exception_service from app.modules.marketplace.services.letzshop import ( CredentialsNotFoundError, LetzshopClientError, @@ -33,7 +30,7 @@ from app.modules.marketplace.services.letzshop import ( OrderNotFoundError, VendorNotFoundError, ) -from app.tasks.letzshop_tasks import process_historical_import +from app.modules.marketplace.tasks import process_historical_import from models.schema.auth import UserContext from app.modules.marketplace.schemas import ( FulfillmentOperationResponse, @@ -1311,7 +1308,7 @@ def trigger_vendor_directory_sync( the local cache. This is typically run daily via Celery beat, but can be triggered manually here. """ - from app.tasks.celery_tasks.letzshop import sync_vendor_directory + from app.modules.marketplace.tasks import sync_vendor_directory # Try to dispatch via Celery first try: diff --git a/app/modules/marketplace/routes/api/admin_marketplace.py b/app/modules/marketplace/routes/api/admin_marketplace.py index dbcd8baa..2653d6df 100644 --- a/app/modules/marketplace/routes/api/admin_marketplace.py +++ b/app/modules/marketplace/routes/api/admin_marketplace.py @@ -13,8 +13,8 @@ from sqlalchemy.orm import Session from app.api.deps import get_current_admin_api, require_module_access from app.core.database import get_db from app.modules.marketplace.services.marketplace_import_job_service import marketplace_import_job_service -from app.services.stats_service import stats_service -from app.services.vendor_service import vendor_service +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.marketplace.schemas import ( AdminMarketplaceImportJobListResponse, diff --git a/app/api/v1/platform/letzshop_vendors.py b/app/modules/marketplace/routes/api/public.py similarity index 94% rename from app/api/v1/platform/letzshop_vendors.py rename to app/modules/marketplace/routes/api/public.py index 4f2252b0..729b42a5 100644 --- a/app/api/v1/platform/letzshop_vendors.py +++ b/app/modules/marketplace/routes/api/public.py @@ -1,6 +1,6 @@ -# app/api/v1/platform/letzshop_vendors.py +# app/modules/marketplace/routes/api/public.py """ -Letzshop vendor lookup API endpoints. +Public Letzshop vendor lookup API endpoints. Allows potential vendors to find themselves in the Letzshop marketplace and claim their shop during signup. @@ -13,17 +13,15 @@ import re from typing import Annotated from fastapi import APIRouter, Depends, Query - -from app.exceptions import ResourceNotFoundException from pydantic import BaseModel from sqlalchemy.orm import Session from app.core.database import get_db +from app.exceptions import ResourceNotFoundException from app.modules.marketplace.services.letzshop import LetzshopVendorSyncService -from app.services.platform_signup_service import platform_signup_service from app.modules.marketplace.models import LetzshopVendorCache -router = APIRouter() +router = APIRouter(prefix="/letzshop-vendors") logger = logging.getLogger(__name__) @@ -139,7 +137,7 @@ def extract_slug_from_url(url_or_slug: str) -> str: # ============================================================================= -@router.get("/letzshop-vendors", response_model=LetzshopVendorListResponse) # public +@router.get("", response_model=LetzshopVendorListResponse) # public async def list_letzshop_vendors( search: Annotated[str | None, Query(description="Search by name")] = None, category: Annotated[str | None, Query(description="Filter by category")] = None, @@ -176,7 +174,7 @@ async def list_letzshop_vendors( ) -@router.post("/letzshop-vendors/lookup", response_model=LetzshopLookupResponse) # public +@router.post("/lookup", response_model=LetzshopLookupResponse) # public async def lookup_letzshop_vendor( request: LetzshopLookupRequest, lang: Annotated[str, Query(description="Language for descriptions")] = "en", @@ -229,7 +227,20 @@ async def lookup_letzshop_vendor( ) -@router.get("/letzshop-vendors/{slug}", response_model=LetzshopVendorInfo) # public +@router.get("/stats") # public +async def get_letzshop_vendor_stats( + db: Session = Depends(get_db), +) -> dict: + """ + Get statistics about the Letzshop vendor cache. + + Returns total, active, claimed, and unclaimed vendor counts. + """ + sync_service = LetzshopVendorSyncService(db) + return sync_service.get_sync_stats() + + +@router.get("/{slug}", response_model=LetzshopVendorInfo) # public async def get_letzshop_vendor( slug: str, lang: Annotated[str, Query(description="Language for descriptions")] = "en", @@ -256,16 +267,3 @@ async def get_letzshop_vendor( raise ResourceNotFoundException("LetzshopVendor", slug) return LetzshopVendorInfo.from_cache(cache_entry, lang) - - -@router.get("/letzshop-vendors-stats") # public -async def get_letzshop_vendor_stats( - db: Session = Depends(get_db), -) -> dict: - """ - Get statistics about the Letzshop vendor cache. - - Returns total, active, claimed, and unclaimed vendor counts. - """ - sync_service = LetzshopVendorSyncService(db) - return sync_service.get_sync_stats() diff --git a/app/modules/marketplace/routes/api/vendor_letzshop.py b/app/modules/marketplace/routes/api/vendor_letzshop.py index 217063e0..66e86f83 100644 --- a/app/modules/marketplace/routes/api/vendor_letzshop.py +++ b/app/modules/marketplace/routes/api/vendor_letzshop.py @@ -20,12 +20,9 @@ from sqlalchemy.orm import Session from app.api.deps import get_current_vendor_api, require_module_access from app.core.database import get_db -from app.exceptions import ( - OrderHasUnresolvedExceptionsException, - ResourceNotFoundException, - ValidationException, -) -from app.services.order_item_exception_service import order_item_exception_service +from app.exceptions import ResourceNotFoundException, ValidationException +from app.modules.orders.exceptions import OrderHasUnresolvedExceptionsException +from app.modules.orders.services.order_item_exception_service import order_item_exception_service from app.modules.marketplace.services.letzshop import ( CredentialsNotFoundError, LetzshopClientError, @@ -770,7 +767,7 @@ def export_products_letzshop( from fastapi.responses import Response from app.modules.marketplace.services.letzshop_export_service import letzshop_export_service - from app.services.vendor_service import vendor_service + from app.modules.tenancy.services.vendor_service import vendor_service vendor_id = current_user.token_vendor_id vendor = vendor_service.get_vendor_by_id(db, vendor_id) diff --git a/app/modules/marketplace/routes/api/vendor_marketplace.py b/app/modules/marketplace/routes/api/vendor_marketplace.py index fb18ac79..266d838a 100644 --- a/app/modules/marketplace/routes/api/vendor_marketplace.py +++ b/app/modules/marketplace/routes/api/vendor_marketplace.py @@ -16,7 +16,7 @@ from sqlalchemy.orm import Session from app.api.deps import get_current_vendor_api, require_module_access from app.core.database import get_db from app.modules.marketplace.services.marketplace_import_job_service import marketplace_import_job_service -from app.services.vendor_service import vendor_service +from app.modules.tenancy.services.vendor_service import vendor_service from middleware.decorators import rate_limit from models.schema.auth import UserContext from app.modules.marketplace.schemas import ( diff --git a/app/modules/marketplace/routes/api/vendor_onboarding.py b/app/modules/marketplace/routes/api/vendor_onboarding.py index 9bacbf49..1a90dcd1 100644 --- a/app/modules/marketplace/routes/api/vendor_onboarding.py +++ b/app/modules/marketplace/routes/api/vendor_onboarding.py @@ -20,7 +20,7 @@ from sqlalchemy.orm import Session from app.api.deps import get_current_vendor_api, require_module_access from app.core.database import get_db -from app.services.onboarding_service import OnboardingService +from app.modules.marketplace.services.onboarding_service import OnboardingService from models.schema.auth import UserContext from app.modules.marketplace.schemas import ( CompanyProfileRequest, diff --git a/app/modules/marketplace/routes/pages/__init__.py b/app/modules/marketplace/routes/pages/__init__.py index 1012df39..81787290 100644 --- a/app/modules/marketplace/routes/pages/__init__.py +++ b/app/modules/marketplace/routes/pages/__init__.py @@ -1,9 +1,2 @@ # app/modules/marketplace/routes/pages/__init__.py -""" -Marketplace module page routes (HTML rendering). - -Provides Jinja2 template rendering for marketplace management. -Note: Page routes can be added here as needed. -""" - -__all__ = [] +"""Marketplace module page routes.""" diff --git a/app/modules/marketplace/routes/pages/admin.py b/app/modules/marketplace/routes/pages/admin.py new file mode 100644 index 00000000..a9ab05c7 --- /dev/null +++ b/app/modules/marketplace/routes/pages/admin.py @@ -0,0 +1,243 @@ +# app/modules/marketplace/routes/pages/admin.py +""" +Marketplace Admin Page Routes (HTML rendering). + +Admin pages for marketplace management: +- Import history +- Background tasks +- Marketplace integration +- Letzshop management +- Marketplace products +""" + +from fastapi import APIRouter, Depends, Path, Request +from fastapi.responses import HTMLResponse +from sqlalchemy.orm import Session + +from app.api.deps import get_db, require_menu_access +from app.core.config import settings +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 + +router = APIRouter() + + +# ============================================================================ +# IMPORT MANAGEMENT ROUTES +# ============================================================================ + + +@router.get("/imports", response_class=HTMLResponse, include_in_schema=False) +async def admin_imports_page( + request: Request, + current_user: User = Depends(require_menu_access("imports", FrontendType.ADMIN)), + db: Session = Depends(get_db), +): + """ + Render imports management page. + Shows import history and status. + """ + return templates.TemplateResponse( + "marketplace/admin/imports.html", + get_admin_context(request, current_user), + ) + + +@router.get("/background-tasks", response_class=HTMLResponse, include_in_schema=False) +async def admin_background_tasks_page( + request: Request, + current_user: User = Depends( + require_menu_access("background-tasks", FrontendType.ADMIN) + ), + db: Session = Depends(get_db), +): + """ + Render background tasks monitoring page. + Shows running and completed background tasks across the system. + """ + return templates.TemplateResponse( + "marketplace/admin/background-tasks.html", + get_admin_context(request, current_user, flower_url=settings.flower_url), + ) + + +@router.get("/marketplace", response_class=HTMLResponse, include_in_schema=False) +async def admin_marketplace_page( + request: Request, + current_user: User = Depends( + require_menu_access("marketplace-letzshop", FrontendType.ADMIN) + ), + db: Session = Depends(get_db), +): + """ + Render marketplace import management page. + Allows admins to import products for any vendor and monitor all imports. + """ + return templates.TemplateResponse( + "marketplace/admin/marketplace.html", + get_admin_context(request, current_user), + ) + + +# ============================================================================ +# MARKETPLACE INTEGRATION ROUTES +# ============================================================================ + + +@router.get( + "/marketplace/letzshop", response_class=HTMLResponse, include_in_schema=False +) +async def admin_marketplace_letzshop_page( + request: Request, + current_user: User = Depends( + require_menu_access("marketplace-letzshop", FrontendType.ADMIN) + ), + db: Session = Depends(get_db), +): + """ + Render unified Letzshop management page. + Combines products (import/export), orders, and settings management. + Admin can select a vendor and manage their Letzshop integration. + """ + return templates.TemplateResponse( + "marketplace/admin/marketplace-letzshop.html", + get_admin_context(request, current_user), + ) + + +@router.get( + "/letzshop/orders/{order_id}", + response_class=HTMLResponse, + include_in_schema=False, +) +async def admin_letzshop_order_detail_page( + request: Request, + order_id: int = Path(..., description="Letzshop order ID"), + current_user: User = Depends( + require_menu_access("marketplace-letzshop", FrontendType.ADMIN) + ), + db: Session = Depends(get_db), +): + """ + Render detailed Letzshop order page. + Shows full order information with shipping address, billing address, + product details, and order history. + """ + return templates.TemplateResponse( + "marketplace/admin/letzshop-order-detail.html", + get_admin_context(request, current_user, order_id=order_id), + ) + + +@router.get( + "/letzshop/products/{product_id}", + response_class=HTMLResponse, + include_in_schema=False, +) +async def admin_letzshop_product_detail_page( + request: Request, + product_id: int = Path(..., description="Marketplace Product ID"), + current_user: User = Depends( + require_menu_access("marketplace-letzshop", FrontendType.ADMIN) + ), + db: Session = Depends(get_db), +): + """ + Render Letzshop product detail page. + Shows full product information from the marketplace. + """ + return templates.TemplateResponse( + "marketplace/admin/marketplace-product-detail.html", + get_admin_context( + request, + current_user, + product_id=product_id, + back_url="/admin/marketplace/letzshop", + ), + ) + + +# ============================================================================ +# LETZSHOP VENDOR DIRECTORY +# ============================================================================ + + +@router.get( + "/letzshop/vendor-directory", + response_class=HTMLResponse, + include_in_schema=False, +) +async def admin_letzshop_vendor_directory_page( + request: Request, + current_user: User = Depends( + require_menu_access("marketplace-letzshop", FrontendType.ADMIN) + ), + db: Session = Depends(get_db), +): + """ + Render Letzshop vendor directory management page. + + Allows admins to: + - View cached Letzshop vendors + - Trigger manual sync from Letzshop API + - Create platform vendors from cached Letzshop vendors + """ + return templates.TemplateResponse( + "marketplace/admin/letzshop-vendor-directory.html", + get_admin_context(request, current_user), + ) + + +# ============================================================================ +# MARKETPLACE PRODUCTS ROUTES +# ============================================================================ + + +@router.get( + "/marketplace-products", response_class=HTMLResponse, include_in_schema=False +) +async def admin_marketplace_products_page( + request: Request, + current_user: User = Depends( + require_menu_access("marketplace-letzshop", FrontendType.ADMIN) + ), + db: Session = Depends(get_db), +): + """ + Render marketplace products page. + Browse the master product repository imported from external sources. + """ + return templates.TemplateResponse( + "marketplace/admin/marketplace-products.html", + get_admin_context(request, current_user), + ) + + +@router.get( + "/marketplace-products/{product_id}", + response_class=HTMLResponse, + include_in_schema=False, +) +async def admin_marketplace_product_detail_page( + request: Request, + product_id: int = Path(..., description="Marketplace Product ID"), + current_user: User = Depends( + require_menu_access("marketplace-letzshop", FrontendType.ADMIN) + ), + db: Session = Depends(get_db), +): + """ + Render marketplace product detail page. + Shows full product information from the master repository. + """ + return templates.TemplateResponse( + "marketplace/admin/marketplace-product-detail.html", + get_admin_context( + request, + current_user, + product_id=product_id, + back_url="/admin/marketplace-products", + ), + ) diff --git a/app/modules/marketplace/routes/pages/public.py b/app/modules/marketplace/routes/pages/public.py new file mode 100644 index 00000000..4d18fcc6 --- /dev/null +++ b/app/modules/marketplace/routes/pages/public.py @@ -0,0 +1,41 @@ +# app/modules/marketplace/routes/pages/public.py +""" +Marketplace Public Page Routes (HTML rendering). + +Public (unauthenticated) pages: +- Find shop (Letzshop vendor browser) +""" + +from fastapi import APIRouter, Depends, Request +from fastapi.responses import HTMLResponse +from sqlalchemy.orm import Session + +from app.core.database import get_db +from app.modules.core.utils.page_context import get_public_context +from app.templates_config import templates + +router = APIRouter() + + +# ============================================================================ +# FIND YOUR SHOP (LETZSHOP VENDOR BROWSER) +# ============================================================================ + + +@router.get("/find-shop", response_class=HTMLResponse, name="platform_find_shop") +async def find_shop_page( + request: Request, + db: Session = Depends(get_db), +): + """ + Letzshop vendor browser page. + + Allows vendors to search for and claim their Letzshop shop. + """ + context = get_public_context(request, db) + context["page_title"] = "Find Your Letzshop Shop" + + return templates.TemplateResponse( + "marketplace/public/find-shop.html", + context, + ) diff --git a/app/modules/marketplace/routes/pages/vendor.py b/app/modules/marketplace/routes/pages/vendor.py new file mode 100644 index 00000000..5e2370bf --- /dev/null +++ b/app/modules/marketplace/routes/pages/vendor.py @@ -0,0 +1,146 @@ +# app/modules/marketplace/routes/pages/vendor.py +""" +Marketplace Vendor Page Routes (HTML rendering). + +Vendor pages for marketplace management: +- Onboarding wizard +- Dashboard +- Marketplace imports +- Letzshop integration +""" + +from fastapi import APIRouter, Depends, Path, Request +from fastapi.responses import HTMLResponse, RedirectResponse +from sqlalchemy.orm import Session + +from app.api.deps import get_current_vendor_from_cookie_or_header, get_db +from app.modules.core.utils.page_context import get_vendor_context +from app.modules.marketplace.services.onboarding_service import OnboardingService +from app.templates_config import templates +from models.database.user import User + +router = APIRouter() + + +# ============================================================================ +# ONBOARDING WIZARD +# ============================================================================ + + +@router.get( + "/{vendor_code}/onboarding", response_class=HTMLResponse, include_in_schema=False +) +async def vendor_onboarding_page( + request: Request, + vendor_code: str = Path(..., description="Vendor code"), + current_user: User = Depends(get_current_vendor_from_cookie_or_header), + db: Session = Depends(get_db), +): + """ + Render vendor onboarding wizard. + + Mandatory 4-step wizard that must be completed before accessing dashboard: + 1. Company Profile Setup + 2. Letzshop API Configuration + 3. Product & Order Import Configuration + 4. Order Sync (historical import) + + If onboarding is already completed, redirects to dashboard. + """ + onboarding_service = OnboardingService(db) + if onboarding_service.is_completed(current_user.token_vendor_id): + return RedirectResponse( + url=f"/vendor/{vendor_code}/dashboard", + status_code=302, + ) + + return templates.TemplateResponse( + "marketplace/vendor/onboarding.html", + get_vendor_context(request, db, current_user, vendor_code), + ) + + +# ============================================================================ +# VENDOR DASHBOARD +# ============================================================================ + + +@router.get( + "/{vendor_code}/dashboard", response_class=HTMLResponse, include_in_schema=False +) +async def vendor_dashboard_page( + request: Request, + vendor_code: str = Path(..., description="Vendor code"), + current_user: User = Depends(get_current_vendor_from_cookie_or_header), + db: Session = Depends(get_db), +): + """ + Render vendor dashboard. + + Redirects to onboarding if not completed. + + JavaScript will: + - Load vendor info via API + - Load dashboard stats via API + - Load recent orders via API + - Handle all interactivity + """ + onboarding_service = OnboardingService(db) + if not onboarding_service.is_completed(current_user.token_vendor_id): + return RedirectResponse( + url=f"/vendor/{vendor_code}/onboarding", + status_code=302, + ) + + return templates.TemplateResponse( + "core/vendor/dashboard.html", + get_vendor_context(request, db, current_user, vendor_code), + ) + + +# ============================================================================ +# MARKETPLACE IMPORTS +# ============================================================================ + + +@router.get( + "/{vendor_code}/marketplace", response_class=HTMLResponse, include_in_schema=False +) +async def vendor_marketplace_page( + request: Request, + vendor_code: str = Path(..., description="Vendor code"), + current_user: User = Depends(get_current_vendor_from_cookie_or_header), + db: Session = Depends(get_db), +): + """ + Render marketplace import page. + JavaScript loads import jobs and products via API. + """ + return templates.TemplateResponse( + "marketplace/vendor/marketplace.html", + get_vendor_context(request, db, current_user, vendor_code), + ) + + +# ============================================================================ +# LETZSHOP INTEGRATION +# ============================================================================ + + +@router.get( + "/{vendor_code}/letzshop", response_class=HTMLResponse, include_in_schema=False +) +async def vendor_letzshop_page( + request: Request, + vendor_code: str = Path(..., description="Vendor code"), + current_user: User = Depends(get_current_vendor_from_cookie_or_header), + db: Session = Depends(get_db), +): + """ + Render Letzshop integration page. + JavaScript loads orders, credentials status, and handles fulfillment operations. + """ + return templates.TemplateResponse( + "marketplace/vendor/letzshop.html", + get_vendor_context(request, db, current_user, vendor_code), + ) diff --git a/app/modules/marketplace/services/__init__.py b/app/modules/marketplace/services/__init__.py index e609fc70..0f149fec 100644 --- a/app/modules/marketplace/services/__init__.py +++ b/app/modules/marketplace/services/__init__.py @@ -18,6 +18,17 @@ from app.modules.marketplace.services.marketplace_product_service import ( MarketplaceProductService, marketplace_product_service, ) +from app.modules.marketplace.services.onboarding_service import ( + OnboardingService, + get_onboarding_service, +) +from app.modules.marketplace.services.platform_signup_service import ( + PlatformSignupService, + platform_signup_service, + SignupSessionData, + AccountCreationResult, + SignupCompletionResult, +) # Letzshop submodule services from app.modules.marketplace.services.letzshop import ( @@ -42,6 +53,15 @@ __all__ = [ # Product service "MarketplaceProductService", "marketplace_product_service", + # Onboarding service + "OnboardingService", + "get_onboarding_service", + # Platform signup service + "PlatformSignupService", + "platform_signup_service", + "SignupSessionData", + "AccountCreationResult", + "SignupCompletionResult", # Letzshop services "LetzshopClient", "LetzshopClientError", diff --git a/app/modules/marketplace/services/letzshop/order_service.py b/app/modules/marketplace/services/letzshop/order_service.py index dd88973d..31f8a506 100644 --- a/app/modules/marketplace/services/letzshop/order_service.py +++ b/app/modules/marketplace/services/letzshop/order_service.py @@ -14,8 +14,8 @@ from typing import Any, Callable from sqlalchemy import String, and_, func, or_ from sqlalchemy.orm import Session -from app.services.order_service import order_service as unified_order_service -from app.services.subscription_service import subscription_service +from app.modules.orders.services.order_service import order_service as unified_order_service +from app.modules.billing.services.subscription_service import subscription_service from app.modules.marketplace.models import ( LetzshopFulfillmentQueue, LetzshopHistoricalImportJob, diff --git a/app/modules/marketplace/services/letzshop/vendor_sync_service.py b/app/modules/marketplace/services/letzshop/vendor_sync_service.py index 5b4fc0b6..b9066b26 100644 --- a/app/modules/marketplace/services/letzshop/vendor_sync_service.py +++ b/app/modules/marketplace/services/letzshop/vendor_sync_service.py @@ -436,7 +436,7 @@ class LetzshopVendorSyncService: from sqlalchemy import func - from app.services.admin_service import admin_service + from app.modules.tenancy.services.admin_service import admin_service from models.database.company import Company from models.database.vendor import Vendor from models.schema.vendor import VendorCreate diff --git a/app/modules/marketplace/services/marketplace_import_job_service.py b/app/modules/marketplace/services/marketplace_import_job_service.py index 9f065d6f..9b99cd75 100644 --- a/app/modules/marketplace/services/marketplace_import_job_service.py +++ b/app/modules/marketplace/services/marketplace_import_job_service.py @@ -3,10 +3,10 @@ import logging from sqlalchemy.orm import Session -from app.exceptions import ( +from app.exceptions import ValidationException +from app.modules.marketplace.exceptions import ( ImportJobNotFoundException, ImportJobNotOwnedException, - ValidationException, ) from app.modules.marketplace.models import ( MarketplaceImportError, @@ -115,7 +115,7 @@ class MarketplaceImportJobService: ImportJobNotFoundException: If job not found UnauthorizedVendorAccessException: If job doesn't belong to vendor """ - from app.exceptions import UnauthorizedVendorAccessException + from app.modules.tenancy.exceptions import UnauthorizedVendorAccessException try: job = ( diff --git a/app/modules/marketplace/services/marketplace_product_service.py b/app/modules/marketplace/services/marketplace_product_service.py index 999f7645..c3e90ac9 100644 --- a/app/modules/marketplace/services/marketplace_product_service.py +++ b/app/modules/marketplace/services/marketplace_product_service.py @@ -22,12 +22,12 @@ from sqlalchemy import or_ from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session, joinedload -from app.exceptions import ( +from app.exceptions import ValidationException +from app.modules.marketplace.exceptions import ( InvalidMarketplaceProductDataException, MarketplaceProductAlreadyExistsException, MarketplaceProductNotFoundException, MarketplaceProductValidationException, - ValidationException, ) from app.utils.data_processing import GTINProcessor, PriceProcessor from app.modules.inventory.models import Inventory @@ -865,7 +865,7 @@ class MarketplaceProductService: vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first() if not vendor: - from app.exceptions import VendorNotFoundException + from app.modules.tenancy.exceptions import VendorNotFoundException raise VendorNotFoundException(str(vendor_id), identifier_type="id") @@ -880,7 +880,7 @@ class MarketplaceProductService: raise MarketplaceProductNotFoundException("No marketplace products found") # Check product limit from subscription - from app.services.subscription_service import subscription_service + from app.modules.billing.services.subscription_service import subscription_service from sqlalchemy import func current_products = ( @@ -998,7 +998,7 @@ class MarketplaceProductService: # Auto-match pending order item exceptions # Collect GTINs and their product IDs from newly copied products - from app.services.order_item_exception_service import ( + from app.modules.orders.services.order_item_exception_service import ( order_item_exception_service, ) diff --git a/app/services/onboarding_service.py b/app/modules/marketplace/services/onboarding_service.py similarity index 99% rename from app/services/onboarding_service.py rename to app/modules/marketplace/services/onboarding_service.py index aacb546c..834f093a 100644 --- a/app/services/onboarding_service.py +++ b/app/modules/marketplace/services/onboarding_service.py @@ -1,4 +1,4 @@ -# app/services/onboarding_service.py +# app/modules/marketplace/services/onboarding_service.py """ Vendor onboarding service. @@ -14,13 +14,13 @@ from datetime import UTC, datetime from sqlalchemy.orm import Session -from app.exceptions import ( +from app.modules.tenancy.exceptions import VendorNotFoundException +from app.modules.marketplace.exceptions import ( OnboardingCsvUrlRequiredException, OnboardingNotFoundException, OnboardingStepOrderException, OnboardingSyncJobNotFoundException, OnboardingSyncNotCompleteException, - VendorNotFoundException, ) from app.modules.marketplace.services.letzshop import ( LetzshopCredentialsService, diff --git a/app/services/platform_signup_service.py b/app/modules/marketplace/services/platform_signup_service.py similarity index 98% rename from app/services/platform_signup_service.py rename to app/modules/marketplace/services/platform_signup_service.py index def0225e..51011a38 100644 --- a/app/services/platform_signup_service.py +++ b/app/modules/marketplace/services/platform_signup_service.py @@ -1,4 +1,4 @@ -# app/services/platform_signup_service.py +# app/modules/marketplace/services/platform_signup_service.py """ Platform signup service. @@ -22,9 +22,9 @@ from app.exceptions import ( ResourceNotFoundException, ValidationException, ) -from app.services.email_service import EmailService -from app.services.onboarding_service import OnboardingService -from app.services.stripe_service import stripe_service +from app.modules.messaging.services.email_service import EmailService +from app.modules.marketplace.services.onboarding_service import OnboardingService +from app.modules.billing.services.stripe_service import stripe_service from middleware.auth import AuthManager from models.database.company import Company from app.modules.billing.models import ( diff --git a/app/modules/marketplace/tasks/import_tasks.py b/app/modules/marketplace/tasks/import_tasks.py index e9d9e1e6..2957b23c 100644 --- a/app/modules/marketplace/tasks/import_tasks.py +++ b/app/modules/marketplace/tasks/import_tasks.py @@ -14,7 +14,7 @@ from typing import Callable from app.core.celery_config import celery_app from app.modules.marketplace.models import MarketplaceImportJob, LetzshopHistoricalImportJob -from app.services.admin_notification_service import admin_notification_service +from app.modules.messaging.services.admin_notification_service import admin_notification_service from app.modules.marketplace.services.letzshop import ( LetzshopClientError, LetzshopCredentialsService, diff --git a/app/modules/marketplace/tasks/sync_tasks.py b/app/modules/marketplace/tasks/sync_tasks.py index 02c28d3b..1ff5d85c 100644 --- a/app/modules/marketplace/tasks/sync_tasks.py +++ b/app/modules/marketplace/tasks/sync_tasks.py @@ -10,7 +10,7 @@ from typing import Any from app.core.celery_config import celery_app from app.modules.task_base import ModuleTask -from app.services.admin_notification_service import admin_notification_service +from app.modules.messaging.services.admin_notification_service import admin_notification_service from app.modules.marketplace.services.letzshop import LetzshopVendorSyncService logger = logging.getLogger(__name__) diff --git a/app/templates/admin/background-tasks.html b/app/modules/marketplace/templates/marketplace/admin/background-tasks.html similarity index 100% rename from app/templates/admin/background-tasks.html rename to app/modules/marketplace/templates/marketplace/admin/background-tasks.html diff --git a/app/templates/admin/imports.html b/app/modules/marketplace/templates/marketplace/admin/imports.html similarity index 100% rename from app/templates/admin/imports.html rename to app/modules/marketplace/templates/marketplace/admin/imports.html diff --git a/app/templates/admin/letzshop-order-detail.html b/app/modules/marketplace/templates/marketplace/admin/letzshop-order-detail.html similarity index 100% rename from app/templates/admin/letzshop-order-detail.html rename to app/modules/marketplace/templates/marketplace/admin/letzshop-order-detail.html diff --git a/app/templates/admin/letzshop-vendor-directory.html b/app/modules/marketplace/templates/marketplace/admin/letzshop-vendor-directory.html similarity index 100% rename from app/templates/admin/letzshop-vendor-directory.html rename to app/modules/marketplace/templates/marketplace/admin/letzshop-vendor-directory.html diff --git a/app/templates/admin/letzshop.html b/app/modules/marketplace/templates/marketplace/admin/letzshop.html similarity index 100% rename from app/templates/admin/letzshop.html rename to app/modules/marketplace/templates/marketplace/admin/letzshop.html diff --git a/app/templates/admin/marketplace-letzshop.html b/app/modules/marketplace/templates/marketplace/admin/marketplace-letzshop.html similarity index 98% rename from app/templates/admin/marketplace-letzshop.html rename to app/modules/marketplace/templates/marketplace/admin/marketplace-letzshop.html index 7cc0e481..c58dfd7e 100644 --- a/app/templates/admin/marketplace-letzshop.html +++ b/app/modules/marketplace/templates/marketplace/admin/marketplace-letzshop.html @@ -133,29 +133,29 @@ {{ tab_panel('products', tab_var='activeTab') }} - {% include 'admin/partials/letzshop-products-tab.html' %} + {% include 'marketplace/admin/partials/letzshop-products-tab.html' %} {{ endtab_panel() }} {{ tab_panel('orders', tab_var='activeTab') }} - {% include 'admin/partials/letzshop-orders-tab.html' %} + {% include 'marketplace/admin/partials/letzshop-orders-tab.html' %} {{ endtab_panel() }} {{ tab_panel('exceptions', tab_var='activeTab') }} - {% include 'admin/partials/letzshop-exceptions-tab.html' %} + {% include 'marketplace/admin/partials/letzshop-exceptions-tab.html' %} {{ endtab_panel() }} {{ tab_panel('jobs', tab_var='activeTab') }} - {% include 'admin/partials/letzshop-jobs-table.html' %} + {% include 'marketplace/admin/partials/letzshop-jobs-table.html' %} {{ endtab_panel() }} diff --git a/app/templates/admin/marketplace-product-detail.html b/app/modules/marketplace/templates/marketplace/admin/marketplace-product-detail.html similarity index 100% rename from app/templates/admin/marketplace-product-detail.html rename to app/modules/marketplace/templates/marketplace/admin/marketplace-product-detail.html diff --git a/app/templates/admin/marketplace-products.html b/app/modules/marketplace/templates/marketplace/admin/marketplace-products.html similarity index 100% rename from app/templates/admin/marketplace-products.html rename to app/modules/marketplace/templates/marketplace/admin/marketplace-products.html diff --git a/app/templates/admin/marketplace.html b/app/modules/marketplace/templates/marketplace/admin/marketplace.html similarity index 100% rename from app/templates/admin/marketplace.html rename to app/modules/marketplace/templates/marketplace/admin/marketplace.html diff --git a/app/templates/admin/partials/letzshop-exceptions-tab.html b/app/modules/marketplace/templates/marketplace/admin/partials/letzshop-exceptions-tab.html similarity index 99% rename from app/templates/admin/partials/letzshop-exceptions-tab.html rename to app/modules/marketplace/templates/marketplace/admin/partials/letzshop-exceptions-tab.html index 5d4f4bdd..50baf8aa 100644 --- a/app/templates/admin/partials/letzshop-exceptions-tab.html +++ b/app/modules/marketplace/templates/marketplace/admin/partials/letzshop-exceptions-tab.html @@ -1,4 +1,4 @@ -{# app/templates/admin/partials/letzshop-exceptions-tab.html #} +{# app/modules/marketplace/templates/marketplace/admin/partials/letzshop-exceptions-tab.html #} {# Exceptions tab for admin Letzshop management - Order Item Exception Resolution #} {% from 'shared/macros/pagination.html' import pagination %} diff --git a/app/templates/admin/partials/letzshop-jobs-table.html b/app/modules/marketplace/templates/marketplace/admin/partials/letzshop-jobs-table.html similarity index 99% rename from app/templates/admin/partials/letzshop-jobs-table.html rename to app/modules/marketplace/templates/marketplace/admin/partials/letzshop-jobs-table.html index f9c44ff8..9ab838a1 100644 --- a/app/templates/admin/partials/letzshop-jobs-table.html +++ b/app/modules/marketplace/templates/marketplace/admin/partials/letzshop-jobs-table.html @@ -1,4 +1,4 @@ -{# app/templates/admin/partials/letzshop-jobs-table.html #} +{# app/modules/marketplace/templates/marketplace/admin/partials/letzshop-jobs-table.html #} {# Unified jobs table for admin Letzshop management - Import, Export, and Sync jobs #} {% from 'shared/macros/pagination.html' import pagination %} diff --git a/app/templates/admin/partials/letzshop-orders-tab.html b/app/modules/marketplace/templates/marketplace/admin/partials/letzshop-orders-tab.html similarity index 99% rename from app/templates/admin/partials/letzshop-orders-tab.html rename to app/modules/marketplace/templates/marketplace/admin/partials/letzshop-orders-tab.html index e416a1bf..9da98518 100644 --- a/app/templates/admin/partials/letzshop-orders-tab.html +++ b/app/modules/marketplace/templates/marketplace/admin/partials/letzshop-orders-tab.html @@ -1,4 +1,4 @@ -{# app/templates/admin/partials/letzshop-orders-tab.html #} +{# app/modules/marketplace/templates/marketplace/admin/partials/letzshop-orders-tab.html #} {# Orders tab for admin Letzshop management #} {% from 'shared/macros/pagination.html' import pagination %} diff --git a/app/templates/admin/partials/letzshop-products-tab.html b/app/modules/marketplace/templates/marketplace/admin/partials/letzshop-products-tab.html similarity index 99% rename from app/templates/admin/partials/letzshop-products-tab.html rename to app/modules/marketplace/templates/marketplace/admin/partials/letzshop-products-tab.html index 24055a9c..a00c5148 100644 --- a/app/templates/admin/partials/letzshop-products-tab.html +++ b/app/modules/marketplace/templates/marketplace/admin/partials/letzshop-products-tab.html @@ -1,4 +1,4 @@ -{# app/templates/admin/partials/letzshop-products-tab.html #} +{# app/modules/marketplace/templates/marketplace/admin/partials/letzshop-products-tab.html #} {# Products tab for admin Letzshop management - Product listing with Import/Export #} {% from 'shared/macros/pagination.html' import pagination %} {% from 'shared/macros/tables.html' import table_wrapper %} diff --git a/app/templates/admin/partials/letzshop-settings-tab.html b/app/modules/marketplace/templates/marketplace/admin/partials/letzshop-settings-tab.html similarity index 99% rename from app/templates/admin/partials/letzshop-settings-tab.html rename to app/modules/marketplace/templates/marketplace/admin/partials/letzshop-settings-tab.html index 1240a1b8..fd84762f 100644 --- a/app/templates/admin/partials/letzshop-settings-tab.html +++ b/app/modules/marketplace/templates/marketplace/admin/partials/letzshop-settings-tab.html @@ -1,4 +1,4 @@ -{# app/templates/admin/partials/letzshop-settings-tab.html #} +{# app/modules/marketplace/templates/marketplace/admin/partials/letzshop-settings-tab.html #} {# Settings tab for admin Letzshop management - API credentials, CSV URLs, Import/Export settings #} {% from 'shared/macros/inputs.html' import number_stepper %} diff --git a/app/templates/platform/find-shop.html b/app/modules/marketplace/templates/marketplace/public/find-shop.html similarity index 97% rename from app/templates/platform/find-shop.html rename to app/modules/marketplace/templates/marketplace/public/find-shop.html index 727bf35e..fa8ffcc1 100644 --- a/app/templates/platform/find-shop.html +++ b/app/modules/marketplace/templates/marketplace/public/find-shop.html @@ -1,6 +1,6 @@ -{# app/templates/platform/find-shop.html #} +{# app/modules/marketplace/templates/marketplace/public/find-shop.html #} {# Letzshop Vendor Finder Page #} -{% extends "platform/base.html" %} +{% extends "public/base.html" %} {% block title %}{{ _("platform.find_shop.title") }} - Wizamart{% endblock %} @@ -151,7 +151,7 @@ function vendorFinderData() { this.result = null; try { - const response = await fetch('/api/v1/platform/letzshop-vendors/lookup', { + const response = await fetch('/api/v1/public/letzshop-vendors/lookup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: this.searchQuery }) diff --git a/app/templates/vendor/letzshop.html b/app/modules/marketplace/templates/marketplace/vendor/letzshop.html similarity index 100% rename from app/templates/vendor/letzshop.html rename to app/modules/marketplace/templates/marketplace/vendor/letzshop.html diff --git a/app/templates/vendor/marketplace.html b/app/modules/marketplace/templates/marketplace/vendor/marketplace.html similarity index 100% rename from app/templates/vendor/marketplace.html rename to app/modules/marketplace/templates/marketplace/vendor/marketplace.html diff --git a/app/templates/vendor/onboarding.html b/app/modules/marketplace/templates/marketplace/vendor/onboarding.html similarity index 100% rename from app/templates/vendor/onboarding.html rename to app/modules/marketplace/templates/marketplace/vendor/onboarding.html diff --git a/app/modules/messaging/exceptions.py b/app/modules/messaging/exceptions.py index 401e0b26..f1cd4196 100644 --- a/app/modules/messaging/exceptions.py +++ b/app/modules/messaging/exceptions.py @@ -2,19 +2,13 @@ """ Messaging module exceptions. -Re-exports messaging-related exceptions from their source locations. +This module provides exception classes for messaging operations including: +- Conversation management +- Message handling +- Attachment processing """ -from app.exceptions.message import ( - ConversationNotFoundException, - MessageNotFoundException, - ConversationClosedException, - MessageAttachmentException, - UnauthorizedConversationAccessException, - InvalidConversationTypeException, - InvalidRecipientTypeException, - AttachmentNotFoundException, -) +from app.exceptions.base import BusinessLogicException, ResourceNotFoundException __all__ = [ "ConversationNotFoundException", @@ -26,3 +20,97 @@ __all__ = [ "InvalidRecipientTypeException", "AttachmentNotFoundException", ] + + +class ConversationNotFoundException(ResourceNotFoundException): + """Raised when a conversation is not found.""" + + def __init__(self, conversation_identifier: str): + super().__init__( + resource_type="Conversation", + identifier=conversation_identifier, + message=f"Conversation '{conversation_identifier}' not found", + error_code="CONVERSATION_NOT_FOUND", + ) + + +class MessageNotFoundException(ResourceNotFoundException): + """Raised when a message is not found.""" + + def __init__(self, message_identifier: str): + super().__init__( + resource_type="Message", + identifier=message_identifier, + message=f"Message '{message_identifier}' not found", + error_code="MESSAGE_NOT_FOUND", + ) + + +class ConversationClosedException(BusinessLogicException): + """Raised when trying to send message to a closed conversation.""" + + def __init__(self, conversation_id: int): + super().__init__( + message=f"Cannot send message to closed conversation {conversation_id}", + error_code="CONVERSATION_CLOSED", + details={"conversation_id": conversation_id}, + ) + + +class MessageAttachmentException(BusinessLogicException): + """Raised when attachment validation fails.""" + + def __init__(self, message: str, details: dict | None = None): + super().__init__( + message=message, + error_code="MESSAGE_ATTACHMENT_INVALID", + details=details, + ) + + +class UnauthorizedConversationAccessException(BusinessLogicException): + """Raised when user tries to access a conversation they don't have access to.""" + + def __init__(self, conversation_id: int): + super().__init__( + message=f"You do not have access to conversation {conversation_id}", + error_code="CONVERSATION_ACCESS_DENIED", + details={"conversation_id": conversation_id}, + ) + + +class InvalidConversationTypeException(BusinessLogicException): + """Raised when conversation type is not valid for the operation.""" + + def __init__(self, message: str, allowed_types: list[str] | None = None): + super().__init__( + message=message, + error_code="INVALID_CONVERSATION_TYPE", + details={"allowed_types": allowed_types} if allowed_types else None, + ) + + +class InvalidRecipientTypeException(BusinessLogicException): + """Raised when recipient type doesn't match conversation type.""" + + def __init__(self, conversation_type: str, expected_recipient_type: str): + super().__init__( + message=f"{conversation_type} conversations require a {expected_recipient_type} recipient", + error_code="INVALID_RECIPIENT_TYPE", + details={ + "conversation_type": conversation_type, + "expected_recipient_type": expected_recipient_type, + }, + ) + + +class AttachmentNotFoundException(ResourceNotFoundException): + """Raised when an attachment is not found.""" + + def __init__(self, attachment_id: int | str): + super().__init__( + resource_type="Attachment", + identifier=str(attachment_id), + message=f"Attachment '{attachment_id}' not found", + error_code="ATTACHMENT_NOT_FOUND", + ) diff --git a/app/modules/messaging/routes/admin.py b/app/modules/messaging/routes/admin.py deleted file mode 100644 index 8e4f15df..00000000 --- a/app/modules/messaging/routes/admin.py +++ /dev/null @@ -1,39 +0,0 @@ -# app/modules/messaging/routes/admin.py -""" -Messaging module admin routes. - -This module wraps the existing admin messages and notifications routes -and adds module-based access control. Routes are re-exported from the -original location with the module access dependency. - -Includes: -- /messages/* - Message management -- /notifications/* - Notification management -""" - -from fastapi import APIRouter, Depends - -from app.api.deps import require_module_access - -# Import original routers (direct import to avoid circular dependency) -from app.api.v1.admin.messages import router as messages_original_router -from app.api.v1.admin.notifications import router as notifications_original_router - -# Create module-aware router for messages -admin_router = APIRouter( - prefix="/messages", - dependencies=[Depends(require_module_access("messaging"))], -) - -# Re-export all routes from the original messages module -for route in messages_original_router.routes: - admin_router.routes.append(route) - -# Create separate router for notifications -admin_notifications_router = APIRouter( - prefix="/notifications", - dependencies=[Depends(require_module_access("messaging"))], -) - -for route in notifications_original_router.routes: - admin_notifications_router.routes.append(route) diff --git a/app/modules/messaging/routes/api/__init__.py b/app/modules/messaging/routes/api/__init__.py index c582707d..943fef8c 100644 --- a/app/modules/messaging/routes/api/__init__.py +++ b/app/modules/messaging/routes/api/__init__.py @@ -2,6 +2,10 @@ """ Messaging module API routes. +Admin routes: +- /messages/* - Conversation and message management +- /notifications/* - Admin notifications and platform alerts + Vendor routes: - /messages/* - Conversation and message management - /notifications/* - Vendor notifications @@ -12,10 +16,11 @@ Storefront routes: - Customer-facing messaging """ +from app.modules.messaging.routes.api.admin import admin_router from app.modules.messaging.routes.api.storefront import router as storefront_router from app.modules.messaging.routes.api.vendor import vendor_router # Tag for OpenAPI documentation STOREFRONT_TAG = "Messages (Storefront)" -__all__ = ["storefront_router", "vendor_router", "STOREFRONT_TAG"] +__all__ = ["admin_router", "storefront_router", "vendor_router", "STOREFRONT_TAG"] diff --git a/app/modules/messaging/routes/api/admin.py b/app/modules/messaging/routes/api/admin.py new file mode 100644 index 00000000..82fd10ce --- /dev/null +++ b/app/modules/messaging/routes/api/admin.py @@ -0,0 +1,26 @@ +# app/modules/messaging/routes/api/admin.py +""" +Messaging module admin API routes. + +Aggregates all admin messaging routes: +- /messages/* - Conversation and message management +- /notifications/* - Admin notifications and platform alerts +- /email-templates/* - Email template management +""" + +from fastapi import APIRouter, Depends + +from app.api.deps import require_module_access + +from .admin_messages import admin_messages_router +from .admin_notifications import admin_notifications_router +from .admin_email_templates import admin_email_templates_router + +admin_router = APIRouter( + dependencies=[Depends(require_module_access("messaging"))], +) + +# Aggregate all messaging admin routes +admin_router.include_router(admin_messages_router, tags=["admin-messages"]) +admin_router.include_router(admin_notifications_router, tags=["admin-notifications"]) +admin_router.include_router(admin_email_templates_router, tags=["admin-email-templates"]) diff --git a/app/api/v1/admin/email_templates.py b/app/modules/messaging/routes/api/admin_email_templates.py similarity index 92% rename from app/api/v1/admin/email_templates.py rename to app/modules/messaging/routes/api/admin_email_templates.py index 02b4973f..547185e3 100644 --- a/app/api/v1/admin/email_templates.py +++ b/app/modules/messaging/routes/api/admin_email_templates.py @@ -1,4 +1,4 @@ -# app/api/v1/admin/email_templates.py +# app/modules/messaging/routes/api/admin_email_templates.py """ Admin email template management endpoints. @@ -19,12 +19,11 @@ from sqlalchemy.orm import Session from app.api.deps import get_current_admin_api from app.core.database import get_db -from app.exceptions.base import ResourceNotFoundException, ValidationException -from app.services.email_service import EmailService -from app.services.email_template_service import EmailTemplateService +from app.modules.messaging.services.email_service import EmailService +from app.modules.messaging.services.email_template_service import EmailTemplateService from models.schema.auth import UserContext -router = APIRouter(prefix="/email-templates") +admin_email_templates_router = APIRouter(prefix="/email-templates") logger = logging.getLogger(__name__) @@ -90,7 +89,7 @@ class CategoriesResponse(BaseModel): # ============================================================================= -@router.get("", response_model=TemplateListResponse) +@admin_email_templates_router.get("", response_model=TemplateListResponse) def list_templates( current_user: UserContext = Depends(get_current_admin_api), db: Session = Depends(get_db), @@ -104,7 +103,7 @@ def list_templates( return TemplateListResponse(templates=service.list_platform_templates()) -@router.get("/categories", response_model=CategoriesResponse) +@admin_email_templates_router.get("/categories", response_model=CategoriesResponse) def get_categories( current_user: UserContext = Depends(get_current_admin_api), db: Session = Depends(get_db), @@ -114,7 +113,7 @@ def get_categories( return CategoriesResponse(categories=service.get_template_categories()) -@router.get("/{code}") +@admin_email_templates_router.get("/{code}") def get_template( code: str, current_user: UserContext = Depends(get_current_admin_api), @@ -129,7 +128,7 @@ def get_template( return service.get_platform_template(code) -@router.get("/{code}/{language}") +@admin_email_templates_router.get("/{code}/{language}") def get_template_language( code: str, language: str, @@ -159,7 +158,7 @@ def get_template_language( } -@router.put("/{code}/{language}") +@admin_email_templates_router.put("/{code}/{language}") def update_template( code: str, language: str, @@ -185,7 +184,7 @@ def update_template( return {"message": "Template updated successfully"} -@router.post("/{code}/preview") +@admin_email_templates_router.post("/{code}/preview") def preview_template( code: str, preview_data: PreviewRequest, @@ -208,7 +207,7 @@ def preview_template( return service.preview_template(code, preview_data.language, variables) -@router.post("/{code}/test") +@admin_email_templates_router.post("/{code}/test") def send_test_email( code: str, test_data: TestEmailRequest, @@ -253,7 +252,7 @@ def send_test_email( } -@router.get("/{code}/logs") +@admin_email_templates_router.get("/{code}/logs") def get_template_logs( code: str, limit: int = 50, diff --git a/app/api/v1/admin/messages.py b/app/modules/messaging/routes/api/admin_messages.py similarity index 94% rename from app/api/v1/admin/messages.py rename to app/modules/messaging/routes/api/admin_messages.py index 8fcea07e..9e227cea 100644 --- a/app/api/v1/admin/messages.py +++ b/app/modules/messaging/routes/api/admin_messages.py @@ -1,4 +1,4 @@ -# app/api/v1/admin/messages.py +# app/modules/messaging/routes/api/admin_messages.py """ Admin messaging endpoints. @@ -18,15 +18,15 @@ from sqlalchemy.orm import Session from app.api.deps import get_current_admin_api from app.core.database import get_db -from app.exceptions import ( +from app.modules.messaging.exceptions import ( ConversationClosedException, ConversationNotFoundException, InvalidConversationTypeException, InvalidRecipientTypeException, MessageAttachmentException, ) -from app.services.message_attachment_service import message_attachment_service -from app.services.messaging_service import messaging_service +from app.modules.messaging.services.message_attachment_service import message_attachment_service +from app.modules.messaging.services.messaging_service import messaging_service from app.modules.messaging.models import ConversationType, ParticipantType from app.modules.messaging.schemas import ( AdminConversationListResponse, @@ -48,7 +48,7 @@ from app.modules.messaging.schemas import ( ) from models.schema.auth import UserContext -router = APIRouter(prefix="/messages") +admin_messages_router = APIRouter(prefix="/messages") logger = logging.getLogger(__name__) @@ -177,7 +177,7 @@ def _enrich_conversation_summary( # ============================================================================ -@router.get("", response_model=AdminConversationListResponse) +@admin_messages_router.get("", response_model=AdminConversationListResponse) def list_conversations( conversation_type: ConversationType | None = Query(None, description="Filter by type"), is_closed: bool | None = Query(None, description="Filter by status"), @@ -208,7 +208,7 @@ def list_conversations( ) -@router.get("/unread-count", response_model=UnreadCountResponse) +@admin_messages_router.get("/unread-count", response_model=UnreadCountResponse) def get_unread_count( db: Session = Depends(get_db), current_admin: UserContext = Depends(get_current_admin_api), @@ -227,7 +227,7 @@ def get_unread_count( # ============================================================================ -@router.get("/recipients", response_model=RecipientListResponse) +@admin_messages_router.get("/recipients", response_model=RecipientListResponse) def get_recipients( recipient_type: ParticipantType = Query(..., description="Type of recipients to list"), search: str | None = Query(None, description="Search by name/email"), @@ -287,7 +287,7 @@ def get_recipients( # ============================================================================ -@router.post("", response_model=ConversationDetailResponse) +@admin_messages_router.post("", response_model=ConversationDetailResponse) def create_conversation( data: ConversationCreate, db: Session = Depends(get_db), @@ -423,7 +423,7 @@ def _build_conversation_detail( ) -@router.get("/{conversation_id}", response_model=ConversationDetailResponse) +@admin_messages_router.get("/{conversation_id}", response_model=ConversationDetailResponse) def get_conversation( conversation_id: int, mark_read: bool = Query(True, description="Automatically mark as read"), @@ -459,7 +459,7 @@ def get_conversation( # ============================================================================ -@router.post("/{conversation_id}/messages", response_model=MessageResponse) +@admin_messages_router.post("/{conversation_id}/messages", response_model=MessageResponse) async def send_message( conversation_id: int, content: str = Form(...), @@ -518,7 +518,7 @@ async def send_message( # ============================================================================ -@router.post("/{conversation_id}/close", response_model=CloseConversationResponse) +@admin_messages_router.post("/{conversation_id}/close", response_model=CloseConversationResponse) def close_conversation( conversation_id: int, db: Session = Depends(get_db), @@ -547,7 +547,7 @@ def close_conversation( ) -@router.post("/{conversation_id}/reopen", response_model=ReopenConversationResponse) +@admin_messages_router.post("/{conversation_id}/reopen", response_model=ReopenConversationResponse) def reopen_conversation( conversation_id: int, db: Session = Depends(get_db), @@ -576,7 +576,7 @@ def reopen_conversation( ) -@router.put("/{conversation_id}/read", response_model=MarkReadResponse) +@admin_messages_router.put("/{conversation_id}/read", response_model=MarkReadResponse) def mark_read( conversation_id: int, db: Session = Depends(get_db), @@ -603,7 +603,7 @@ class PreferencesUpdateResponse(BaseModel): success: bool -@router.put("/{conversation_id}/preferences", response_model=PreferencesUpdateResponse) +@admin_messages_router.put("/{conversation_id}/preferences", response_model=PreferencesUpdateResponse) def update_preferences( conversation_id: int, preferences: NotificationPreferencesUpdate, diff --git a/app/api/v1/admin/notifications.py b/app/modules/messaging/routes/api/admin_notifications.py similarity index 90% rename from app/api/v1/admin/notifications.py rename to app/modules/messaging/routes/api/admin_notifications.py index 484e1f26..8a447b64 100644 --- a/app/api/v1/admin/notifications.py +++ b/app/modules/messaging/routes/api/admin_notifications.py @@ -1,4 +1,4 @@ -# app/api/v1/admin/notifications.py +# app/modules/messaging/routes/api/admin_notifications.py """ Admin notifications and platform alerts endpoints. @@ -15,7 +15,7 @@ from sqlalchemy.orm import Session from app.api.deps import get_current_admin_api from app.core.database import get_db -from app.services.admin_notification_service import ( +from app.modules.messaging.services.admin_notification_service import ( admin_notification_service, platform_alert_service, ) @@ -35,7 +35,7 @@ from app.modules.messaging.schemas import ( UnreadCountResponse, ) -router = APIRouter(prefix="/notifications") +admin_notifications_router = APIRouter(prefix="/notifications") logger = logging.getLogger(__name__) @@ -44,7 +44,7 @@ logger = logging.getLogger(__name__) # ============================================================================ -@router.get("", response_model=AdminNotificationListResponse) +@admin_notifications_router.get("", response_model=AdminNotificationListResponse) def get_notifications( priority: str | None = Query(None, description="Filter by priority"), notification_type: str | None = Query(None, description="Filter by type"), @@ -89,7 +89,7 @@ def get_notifications( ) -@router.post("", response_model=AdminNotificationResponse) +@admin_notifications_router.post("", response_model=AdminNotificationResponse) def create_notification( notification_data: AdminNotificationCreate, db: Session = Depends(get_db), @@ -119,7 +119,7 @@ def create_notification( ) -@router.get("/recent") +@admin_notifications_router.get("/recent") def get_recent_notifications( limit: int = Query(5, ge=1, le=10), db: Session = Depends(get_db), @@ -148,7 +148,7 @@ def get_recent_notifications( } -@router.get("/unread-count", response_model=UnreadCountResponse) +@admin_notifications_router.get("/unread-count", response_model=UnreadCountResponse) def get_unread_count( db: Session = Depends(get_db), current_admin: UserContext = Depends(get_current_admin_api), @@ -158,7 +158,7 @@ def get_unread_count( return UnreadCountResponse(unread_count=count) -@router.put("/{notification_id}/read", response_model=MessageResponse) +@admin_notifications_router.put("/{notification_id}/read", response_model=MessageResponse) def mark_as_read( notification_id: int, db: Session = Depends(get_db), @@ -175,7 +175,7 @@ def mark_as_read( return MessageResponse(message="Notification not found") -@router.put("/mark-all-read", response_model=MessageResponse) +@admin_notifications_router.put("/mark-all-read", response_model=MessageResponse) def mark_all_as_read( db: Session = Depends(get_db), current_admin: UserContext = Depends(get_current_admin_api), @@ -189,7 +189,7 @@ def mark_all_as_read( return MessageResponse(message=f"Marked {count} notifications as read") -@router.delete("/{notification_id}", response_model=MessageResponse) +@admin_notifications_router.delete("/{notification_id}", response_model=MessageResponse) def delete_notification( notification_id: int, db: Session = Depends(get_db), @@ -212,7 +212,7 @@ def delete_notification( # ============================================================================ -@router.get("/alerts", response_model=PlatformAlertListResponse) +@admin_notifications_router.get("/alerts", response_model=PlatformAlertListResponse) def get_platform_alerts( severity: str | None = Query(None, description="Filter by severity"), alert_type: str | None = Query(None, description="Filter by alert type"), @@ -262,7 +262,7 @@ def get_platform_alerts( ) -@router.post("/alerts", response_model=PlatformAlertResponse) +@admin_notifications_router.post("/alerts", response_model=PlatformAlertResponse) def create_platform_alert( alert_data: PlatformAlertCreate, db: Session = Depends(get_db), @@ -294,7 +294,7 @@ def create_platform_alert( ) -@router.put("/alerts/{alert_id}/resolve", response_model=MessageResponse) +@admin_notifications_router.put("/alerts/{alert_id}/resolve", response_model=MessageResponse) def resolve_platform_alert( alert_id: int, resolve_data: PlatformAlertResolve, @@ -317,7 +317,7 @@ def resolve_platform_alert( return MessageResponse(message="Alert not found or already resolved") -@router.get("/alerts/stats", response_model=AlertStatisticsResponse) +@admin_notifications_router.get("/alerts/stats", response_model=AlertStatisticsResponse) def get_alert_statistics( db: Session = Depends(get_db), current_admin: UserContext = Depends(get_current_admin_api), diff --git a/app/modules/messaging/routes/api/storefront.py b/app/modules/messaging/routes/api/storefront.py index bc89cbc5..3bfbccb6 100644 --- a/app/modules/messaging/routes/api/storefront.py +++ b/app/modules/messaging/routes/api/storefront.py @@ -27,12 +27,12 @@ from sqlalchemy.orm import Session from app.api.deps import get_current_customer_api from app.core.database import get_db -from app.exceptions import ( +from app.modules.messaging.exceptions import ( AttachmentNotFoundException, ConversationClosedException, ConversationNotFoundException, - VendorNotFoundException, ) +from app.modules.tenancy.exceptions import VendorNotFoundException from app.modules.customers.schemas import CustomerContext from app.modules.messaging.models.message import ConversationType, ParticipantType from app.modules.messaging.schemas import ( diff --git a/app/modules/messaging/routes/api/vendor_email_settings.py b/app/modules/messaging/routes/api/vendor_email_settings.py index beec31bb..795015d6 100644 --- a/app/modules/messaging/routes/api/vendor_email_settings.py +++ b/app/modules/messaging/routes/api/vendor_email_settings.py @@ -20,8 +20,8 @@ from sqlalchemy.orm import Session from app.api.deps import get_current_vendor_api from app.core.database import get_db -from app.services.vendor_email_settings_service import VendorEmailSettingsService -from app.services.subscription_service import subscription_service +from app.modules.cms.services.vendor_email_settings_service import VendorEmailSettingsService +from app.modules.billing.services.subscription_service import subscription_service from models.schema.auth import UserContext vendor_email_settings_router = APIRouter(prefix="/email-settings") diff --git a/app/modules/messaging/routes/api/vendor_email_templates.py b/app/modules/messaging/routes/api/vendor_email_templates.py index 0af17d99..4acb3d28 100644 --- a/app/modules/messaging/routes/api/vendor_email_templates.py +++ b/app/modules/messaging/routes/api/vendor_email_templates.py @@ -17,9 +17,9 @@ from sqlalchemy.orm import Session from app.api.deps import get_current_vendor_api from app.core.database import get_db -from app.services.email_service import EmailService -from app.services.email_template_service import EmailTemplateService -from app.services.vendor_service import vendor_service +from app.modules.messaging.services.email_service import EmailService +from app.modules.messaging.services.email_template_service import EmailTemplateService +from app.modules.tenancy.services.vendor_service import vendor_service from models.schema.auth import UserContext vendor_email_templates_router = APIRouter(prefix="/email-templates") diff --git a/app/modules/messaging/routes/api/vendor_messages.py b/app/modules/messaging/routes/api/vendor_messages.py index 40c48c65..c741bcbd 100644 --- a/app/modules/messaging/routes/api/vendor_messages.py +++ b/app/modules/messaging/routes/api/vendor_messages.py @@ -20,15 +20,15 @@ from sqlalchemy.orm import Session from app.api.deps import get_current_vendor_api from app.core.database import get_db -from app.exceptions import ( +from app.modules.messaging.exceptions import ( ConversationClosedException, ConversationNotFoundException, InvalidConversationTypeException, InvalidRecipientTypeException, MessageAttachmentException, ) -from app.services.message_attachment_service import message_attachment_service -from app.services.messaging_service import messaging_service +from app.modules.messaging.services.message_attachment_service import message_attachment_service +from app.modules.messaging.services.messaging_service import messaging_service from app.modules.messaging.models import ConversationType, ParticipantType from app.modules.messaging.schemas import ( AttachmentResponse, diff --git a/app/modules/messaging/routes/api/vendor_notifications.py b/app/modules/messaging/routes/api/vendor_notifications.py index 9f12fa89..83b656a7 100644 --- a/app/modules/messaging/routes/api/vendor_notifications.py +++ b/app/modules/messaging/routes/api/vendor_notifications.py @@ -13,7 +13,7 @@ from sqlalchemy.orm import Session from app.api.deps import get_current_vendor_api from app.core.database import get_db -from app.services.vendor_service import vendor_service +from app.modules.tenancy.services.vendor_service import vendor_service from models.schema.auth import UserContext from app.modules.messaging.schemas import ( MessageResponse, diff --git a/app/modules/messaging/routes/pages/__init__.py b/app/modules/messaging/routes/pages/__init__.py index 036b4041..95b637d2 100644 --- a/app/modules/messaging/routes/pages/__init__.py +++ b/app/modules/messaging/routes/pages/__init__.py @@ -1,4 +1,2 @@ -# Page routes will be added here -# TODO: Add HTML page routes for admin/vendor dashboards - -__all__ = [] +# app/modules/messaging/routes/pages/__init__.py +"""Messaging module page routes.""" diff --git a/app/modules/messaging/routes/pages/admin.py b/app/modules/messaging/routes/pages/admin.py new file mode 100644 index 00000000..df86af0e --- /dev/null +++ b/app/modules/messaging/routes/pages/admin.py @@ -0,0 +1,110 @@ +# app/modules/messaging/routes/pages/admin.py +""" +Messaging Admin Page Routes (HTML rendering). + +Admin pages for messaging management: +- Notifications +- Messages list +- Conversation detail +- Email templates +""" + +from fastapi import APIRouter, Depends, Path, Request +from fastapi.responses import HTMLResponse +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 + +router = APIRouter() + + +# ============================================================================ +# NOTIFICATIONS ROUTES +# ============================================================================ + + +@router.get("/notifications", response_class=HTMLResponse, include_in_schema=False) +async def admin_notifications_page( + request: Request, + current_user: User = Depends( + require_menu_access("notifications", FrontendType.ADMIN) + ), + db: Session = Depends(get_db), +): + """ + Render notifications management page. + Shows all admin notifications and platform alerts. + """ + return templates.TemplateResponse( + "messaging/admin/notifications.html", + get_admin_context(request, current_user), + ) + + +# ============================================================================ +# MESSAGING ROUTES +# ============================================================================ + + +@router.get("/messages", response_class=HTMLResponse, include_in_schema=False) +async def admin_messages_page( + request: Request, + current_user: User = Depends(require_menu_access("messages", FrontendType.ADMIN)), + db: Session = Depends(get_db), +): + """ + Render messaging page. + Shows all conversations (admin_vendor and admin_customer channels). + """ + return templates.TemplateResponse( + "messaging/admin/messages.html", + get_admin_context(request, current_user), + ) + + +@router.get( + "/messages/{conversation_id}", + response_class=HTMLResponse, + include_in_schema=False, +) +async def admin_conversation_detail_page( + request: Request, + conversation_id: int = Path(..., description="Conversation ID"), + current_user: User = Depends(require_menu_access("messages", FrontendType.ADMIN)), + db: Session = Depends(get_db), +): + """ + Render conversation detail page. + Shows the full conversation thread with messages. + """ + return templates.TemplateResponse( + "messaging/admin/messages.html", + get_admin_context(request, current_user, conversation_id=conversation_id), + ) + + +# ============================================================================ +# EMAIL TEMPLATES ROUTES +# ============================================================================ + + +@router.get("/email-templates", response_class=HTMLResponse, include_in_schema=False) +async def admin_email_templates_page( + request: Request, + current_user: User = Depends( + require_menu_access("email-templates", FrontendType.ADMIN) + ), + db: Session = Depends(get_db), +): + """ + Render email templates management page. + Shows all platform email templates with edit capabilities. + """ + return templates.TemplateResponse( + "messaging/admin/email-templates.html", + get_admin_context(request, current_user), + ) diff --git a/app/modules/messaging/routes/pages/storefront.py b/app/modules/messaging/routes/pages/storefront.py new file mode 100644 index 00000000..860497f8 --- /dev/null +++ b/app/modules/messaging/routes/pages/storefront.py @@ -0,0 +1,90 @@ +# app/modules/messaging/routes/pages/storefront.py +""" +Messaging Storefront Page Routes (HTML rendering). + +Storefront (customer shop) pages for messaging: +- Messages list +- Conversation detail +""" + +import logging + +from fastapi import APIRouter, Depends, Path, Request +from fastapi.responses import HTMLResponse +from sqlalchemy.orm import Session + +from app.api.deps import get_current_customer_from_cookie_or_header, get_db +from app.modules.core.utils.page_context import get_storefront_context +from app.modules.customers.models import Customer +from app.templates_config import templates + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +# ============================================================================ +# CUSTOMER ACCOUNT - MESSAGES (Authenticated) +# ============================================================================ + + +@router.get( + "/account/messages", response_class=HTMLResponse, include_in_schema=False +) +async def shop_messages_page( + request: Request, + current_customer: Customer = Depends(get_current_customer_from_cookie_or_header), + db: Session = Depends(get_db), +): + """ + Render customer messages page. + View and reply to conversations with the vendor. + Requires customer authentication. + """ + logger.debug( + "[STOREFRONT] shop_messages_page REACHED", + extra={ + "path": request.url.path, + "vendor": getattr(request.state, "vendor", "NOT SET"), + "context": getattr(request.state, "context_type", "NOT SET"), + }, + ) + + return templates.TemplateResponse( + "messaging/storefront/messages.html", + get_storefront_context(request, db=db, user=current_customer), + ) + + +@router.get( + "/account/messages/{conversation_id}", + response_class=HTMLResponse, + include_in_schema=False, +) +async def shop_message_detail_page( + request: Request, + conversation_id: int = Path(..., description="Conversation ID"), + current_customer: Customer = Depends(get_current_customer_from_cookie_or_header), + db: Session = Depends(get_db), +): + """ + Render message conversation detail page. + Shows the full conversation thread. + Requires customer authentication. + """ + logger.debug( + "[STOREFRONT] shop_message_detail_page REACHED", + extra={ + "path": request.url.path, + "conversation_id": conversation_id, + "vendor": getattr(request.state, "vendor", "NOT SET"), + "context": getattr(request.state, "context_type", "NOT SET"), + }, + ) + + return templates.TemplateResponse( + "messaging/storefront/messages.html", + get_storefront_context( + request, db=db, user=current_customer, conversation_id=conversation_id + ), + ) diff --git a/app/modules/messaging/routes/pages/vendor.py b/app/modules/messaging/routes/pages/vendor.py new file mode 100644 index 00000000..459cefee --- /dev/null +++ b/app/modules/messaging/routes/pages/vendor.py @@ -0,0 +1,94 @@ +# app/modules/messaging/routes/pages/vendor.py +""" +Messaging Vendor Page Routes (HTML rendering). + +Vendor pages for messaging management: +- Messages list +- Conversation detail +- Email templates +""" + +from fastapi import APIRouter, Depends, Path, Request +from fastapi.responses import HTMLResponse +from sqlalchemy.orm import Session + +from app.api.deps import get_current_vendor_from_cookie_or_header, get_db +from app.modules.core.utils.page_context import get_vendor_context +from app.templates_config import templates +from models.database.user import User + +router = APIRouter() + + +# ============================================================================ +# MESSAGING +# ============================================================================ + + +@router.get( + "/{vendor_code}/messages", response_class=HTMLResponse, include_in_schema=False +) +async def vendor_messages_page( + request: Request, + vendor_code: str = Path(..., description="Vendor code"), + current_user: User = Depends(get_current_vendor_from_cookie_or_header), + db: Session = Depends(get_db), +): + """ + Render messages page. + JavaScript loads conversations and messages via API. + """ + return templates.TemplateResponse( + "messaging/vendor/messages.html", + get_vendor_context(request, db, current_user, vendor_code), + ) + + +@router.get( + "/{vendor_code}/messages/{conversation_id}", + response_class=HTMLResponse, + include_in_schema=False, +) +async def vendor_message_detail_page( + request: Request, + vendor_code: str = Path(..., description="Vendor code"), + conversation_id: int = Path(..., description="Conversation ID"), + current_user: User = Depends(get_current_vendor_from_cookie_or_header), + db: Session = Depends(get_db), +): + """ + Render message detail page. + Shows the full conversation thread. + """ + return templates.TemplateResponse( + "messaging/vendor/messages.html", + get_vendor_context( + request, db, current_user, vendor_code, conversation_id=conversation_id + ), + ) + + +# ============================================================================ +# EMAIL TEMPLATES +# ============================================================================ + + +@router.get( + "/{vendor_code}/email-templates", + response_class=HTMLResponse, + include_in_schema=False, +) +async def vendor_email_templates_page( + request: Request, + vendor_code: str = Path(..., description="Vendor code"), + current_user: User = Depends(get_current_vendor_from_cookie_or_header), + db: Session = Depends(get_db), +): + """ + Render vendor email templates customization page. + Allows vendors to override platform email templates. + """ + return templates.TemplateResponse( + "messaging/vendor/email-templates.html", + get_vendor_context(request, db, current_user, vendor_code), + ) diff --git a/app/modules/messaging/services/__init__.py b/app/modules/messaging/services/__init__.py index 0d528774..e6583231 100644 --- a/app/modules/messaging/services/__init__.py +++ b/app/modules/messaging/services/__init__.py @@ -24,6 +24,46 @@ from app.modules.messaging.services.admin_notification_service import ( AlertType, Severity, ) +from app.modules.messaging.services.email_service import ( + EmailService, + EmailProvider, + ResolvedTemplate, + BrandingContext, + send_email, + get_provider, + get_platform_provider, + get_vendor_provider, + get_platform_email_config, + # Provider classes + SMTPProvider, + SendGridProvider, + MailgunProvider, + SESProvider, + DebugProvider, + # Configurable provider classes + ConfigurableSMTPProvider, + ConfigurableSendGridProvider, + ConfigurableMailgunProvider, + ConfigurableSESProvider, + # Vendor provider classes + VendorSMTPProvider, + VendorSendGridProvider, + VendorMailgunProvider, + VendorSESProvider, + # Constants + PLATFORM_NAME, + PLATFORM_SUPPORT_EMAIL, + PLATFORM_DEFAULT_LANGUAGE, + SUPPORTED_LANGUAGES, + WHITELABEL_TIERS, + POWERED_BY_FOOTER_HTML, + POWERED_BY_FOOTER_TEXT, +) +from app.modules.messaging.services.email_template_service import ( + EmailTemplateService, + TemplateData, + VendorOverrideData, +) __all__ = [ "messaging_service", @@ -39,4 +79,42 @@ __all__ = [ "Priority", "AlertType", "Severity", + # Email service + "EmailService", + "EmailProvider", + "ResolvedTemplate", + "BrandingContext", + "send_email", + "get_provider", + "get_platform_provider", + "get_vendor_provider", + "get_platform_email_config", + # Provider classes + "SMTPProvider", + "SendGridProvider", + "MailgunProvider", + "SESProvider", + "DebugProvider", + # Configurable provider classes + "ConfigurableSMTPProvider", + "ConfigurableSendGridProvider", + "ConfigurableMailgunProvider", + "ConfigurableSESProvider", + # Vendor provider classes + "VendorSMTPProvider", + "VendorSendGridProvider", + "VendorMailgunProvider", + "VendorSESProvider", + # Email constants + "PLATFORM_NAME", + "PLATFORM_SUPPORT_EMAIL", + "PLATFORM_DEFAULT_LANGUAGE", + "SUPPORTED_LANGUAGES", + "WHITELABEL_TIERS", + "POWERED_BY_FOOTER_HTML", + "POWERED_BY_FOOTER_TEXT", + # Email template service + "EmailTemplateService", + "TemplateData", + "VendorOverrideData", ] diff --git a/app/services/email_service.py b/app/modules/messaging/services/email_service.py similarity index 99% rename from app/services/email_service.py rename to app/modules/messaging/services/email_service.py index 0afaade7..9ab74a28 100644 --- a/app/services/email_service.py +++ b/app/modules/messaging/services/email_service.py @@ -1,4 +1,4 @@ -# app/services/email_service.py +# app/modules/messaging/services/email_service.py """ Email service with multi-provider support. @@ -1012,7 +1012,7 @@ class EmailService: def _has_feature(self, vendor_id: int, feature_code: str) -> bool: """Check if vendor has a specific feature enabled.""" if vendor_id not in self._feature_cache: - from app.services.feature_service import feature_service + from app.modules.billing.services.feature_service import feature_service try: features = feature_service.get_vendor_features(self.db, vendor_id) @@ -1038,7 +1038,7 @@ class EmailService: def _get_vendor_tier(self, vendor_id: int) -> str | None: """Get vendor's subscription tier with caching.""" if vendor_id not in self._vendor_tier_cache: - from app.services.subscription_service import subscription_service + from app.modules.billing.services.subscription_service import subscription_service tier = subscription_service.get_current_tier(self.db, vendor_id) self._vendor_tier_cache[vendor_id] = tier.value if tier else None diff --git a/app/services/email_template_service.py b/app/modules/messaging/services/email_template_service.py similarity index 99% rename from app/services/email_template_service.py rename to app/modules/messaging/services/email_template_service.py index 5713e9c5..9ea2980c 100644 --- a/app/services/email_template_service.py +++ b/app/modules/messaging/services/email_template_service.py @@ -1,4 +1,4 @@ -# app/services/email_template_service.py +# app/modules/messaging/services/email_template_service.py """ Email Template Service diff --git a/app/modules/messaging/services/message_attachment_service.py b/app/modules/messaging/services/message_attachment_service.py index 0fd3e568..fad02d8d 100644 --- a/app/modules/messaging/services/message_attachment_service.py +++ b/app/modules/messaging/services/message_attachment_service.py @@ -14,7 +14,7 @@ from pathlib import Path from fastapi import UploadFile from sqlalchemy.orm import Session -from app.services.admin_settings_service import admin_settings_service +from app.modules.core.services.admin_settings_service import admin_settings_service logger = logging.getLogger(__name__) diff --git a/app/templates/admin/email-templates.html b/app/modules/messaging/templates/messaging/admin/email-templates.html similarity index 100% rename from app/templates/admin/email-templates.html rename to app/modules/messaging/templates/messaging/admin/email-templates.html diff --git a/app/templates/admin/messages.html b/app/modules/messaging/templates/messaging/admin/messages.html similarity index 100% rename from app/templates/admin/messages.html rename to app/modules/messaging/templates/messaging/admin/messages.html diff --git a/app/templates/admin/notifications.html b/app/modules/messaging/templates/messaging/admin/notifications.html similarity index 100% rename from app/templates/admin/notifications.html rename to app/modules/messaging/templates/messaging/admin/notifications.html diff --git a/app/templates/storefront/account/messages.html b/app/modules/messaging/templates/messaging/storefront/messages.html similarity index 99% rename from app/templates/storefront/account/messages.html rename to app/modules/messaging/templates/messaging/storefront/messages.html index 122ca5dc..9916af05 100644 --- a/app/templates/storefront/account/messages.html +++ b/app/modules/messaging/templates/messaging/storefront/messages.html @@ -1,4 +1,4 @@ -{# app/templates/storefront/account/messages.html #} +{# app/modules/messaging/templates/messaging/storefront/messages.html #} {% extends "storefront/base.html" %} {% block title %}Messages - {{ vendor.name }}{% endblock %} diff --git a/app/templates/vendor/email-templates.html b/app/modules/messaging/templates/messaging/vendor/email-templates.html similarity index 100% rename from app/templates/vendor/email-templates.html rename to app/modules/messaging/templates/messaging/vendor/email-templates.html diff --git a/app/templates/vendor/messages.html b/app/modules/messaging/templates/messaging/vendor/messages.html similarity index 100% rename from app/templates/vendor/messages.html rename to app/modules/messaging/templates/messaging/vendor/messages.html diff --git a/app/templates/vendor/notifications.html b/app/modules/messaging/templates/messaging/vendor/notifications.html similarity index 100% rename from app/templates/vendor/notifications.html rename to app/modules/messaging/templates/messaging/vendor/notifications.html diff --git a/app/modules/monitoring/exceptions.py b/app/modules/monitoring/exceptions.py index d9c4d10e..bfa7fa76 100644 --- a/app/modules/monitoring/exceptions.py +++ b/app/modules/monitoring/exceptions.py @@ -2,14 +2,41 @@ """ Monitoring module exceptions. -Module-specific exceptions for monitoring functionality. +This module provides exception classes for monitoring operations including: +- Background task tracking +- Capacity snapshot management +- Code quality/architecture scans """ from app.exceptions.base import ( BusinessLogicException, + ExternalServiceException, ResourceNotFoundException, + ValidationException, ) +__all__ = [ + # Task exceptions + "TaskNotFoundException", + # Capacity exceptions + "CapacitySnapshotNotFoundException", + # General monitoring exceptions + "MonitoringServiceException", + # Code quality exceptions + "ViolationNotFoundException", + "ScanNotFoundException", + "ScanExecutionException", + "ScanTimeoutException", + "ScanParseException", + "ViolationOperationException", + "InvalidViolationStatusException", +] + + +# ============================================================================= +# Task Exceptions +# ============================================================================= + class TaskNotFoundException(ResourceNotFoundException): """Raised when a background task is not found.""" @@ -22,6 +49,11 @@ class TaskNotFoundException(ResourceNotFoundException): ) +# ============================================================================= +# Capacity Exceptions +# ============================================================================= + + class CapacitySnapshotNotFoundException(ResourceNotFoundException): """Raised when a capacity snapshot is not found.""" @@ -33,6 +65,11 @@ class CapacitySnapshotNotFoundException(ResourceNotFoundException): ) +# ============================================================================= +# General Monitoring Exceptions +# ============================================================================= + + class MonitoringServiceException(BusinessLogicException): """Raised when a monitoring operation fails.""" @@ -40,11 +77,97 @@ class MonitoringServiceException(BusinessLogicException): super().__init__( message=f"Monitoring operation '{operation}' failed: {reason}", error_code="MONITORING_OPERATION_FAILED", + details={"operation": operation, "reason": reason}, ) -__all__ = [ - "TaskNotFoundException", - "CapacitySnapshotNotFoundException", - "MonitoringServiceException", -] +# ============================================================================= +# Code Quality Exceptions +# ============================================================================= + + +class ViolationNotFoundException(ResourceNotFoundException): + """Raised when a violation is not found.""" + + def __init__(self, violation_id: int): + super().__init__( + resource_type="Violation", + identifier=str(violation_id), + error_code="VIOLATION_NOT_FOUND", + ) + + +class ScanNotFoundException(ResourceNotFoundException): + """Raised when a scan is not found.""" + + def __init__(self, scan_id: int): + super().__init__( + resource_type="Scan", + identifier=str(scan_id), + error_code="SCAN_NOT_FOUND", + ) + + +class ScanExecutionException(ExternalServiceException): + """Raised when architecture scan execution fails.""" + + def __init__(self, reason: str): + super().__init__( + service_name="ArchitectureValidator", + message=f"Scan execution failed: {reason}", + error_code="SCAN_EXECUTION_FAILED", + ) + + +class ScanTimeoutException(ExternalServiceException): + """Raised when architecture scan times out.""" + + def __init__(self, timeout_seconds: int = 300): + super().__init__( + service_name="ArchitectureValidator", + message=f"Scan timed out after {timeout_seconds} seconds", + error_code="SCAN_TIMEOUT", + details={"timeout_seconds": timeout_seconds}, + ) + + +class ScanParseException(BusinessLogicException): + """Raised when scan results cannot be parsed.""" + + def __init__(self, reason: str): + super().__init__( + message=f"Failed to parse scan results: {reason}", + error_code="SCAN_PARSE_FAILED", + details={"reason": reason}, + ) + + +class ViolationOperationException(BusinessLogicException): + """Raised when a violation operation fails.""" + + def __init__(self, operation: str, violation_id: int, reason: str): + super().__init__( + message=f"Failed to {operation} violation {violation_id}: {reason}", + error_code="VIOLATION_OPERATION_FAILED", + details={ + "operation": operation, + "violation_id": violation_id, + "reason": reason, + }, + ) + + +class InvalidViolationStatusException(ValidationException): + """Raised when a violation status transition is invalid.""" + + def __init__(self, violation_id: int, current_status: str, target_status: str): + super().__init__( + message=f"Cannot change violation {violation_id} from '{current_status}' to '{target_status}'", + field="status", + details={ + "violation_id": violation_id, + "current_status": current_status, + "target_status": target_status, + }, + ) + self.error_code = "INVALID_VIOLATION_STATUS" diff --git a/app/modules/monitoring/routes/admin.py b/app/modules/monitoring/routes/admin.py deleted file mode 100644 index fadce965..00000000 --- a/app/modules/monitoring/routes/admin.py +++ /dev/null @@ -1,53 +0,0 @@ -# app/modules/monitoring/routes/admin.py -""" -Monitoring module admin routes. - -This module wraps the existing admin monitoring routes and adds -module-based access control. Routes are re-exported from the -original location with the module access dependency. - -Includes: -- /logs/* - Application logs -- /background-tasks/* - Background task monitoring -- /tests/* - Test runner -- /code-quality/* - Code quality tools -""" - -from fastapi import APIRouter, Depends - -from app.api.deps import require_module_access - -# Import original routers (direct import to avoid circular dependency) -from app.api.v1.admin.logs import router as logs_original_router -from app.api.v1.admin.background_tasks import router as tasks_original_router -from app.api.v1.admin.tests import router as tests_original_router -from app.api.v1.admin.code_quality import router as code_quality_original_router - -# Create module-aware router for logs -admin_router = APIRouter( - prefix="/monitoring", - dependencies=[Depends(require_module_access("monitoring"))], -) - -# Create sub-routers for each component -logs_router = APIRouter(prefix="/logs") -for route in logs_original_router.routes: - logs_router.routes.append(route) - -tasks_router = APIRouter(prefix="/background-tasks") -for route in tasks_original_router.routes: - tasks_router.routes.append(route) - -tests_router = APIRouter(prefix="/tests") -for route in tests_original_router.routes: - tests_router.routes.append(route) - -code_quality_router = APIRouter(prefix="/code-quality") -for route in code_quality_original_router.routes: - code_quality_router.routes.append(route) - -# Include all sub-routers -admin_router.include_router(logs_router) -admin_router.include_router(tasks_router) -admin_router.include_router(tests_router) -admin_router.include_router(code_quality_router) diff --git a/app/modules/monitoring/routes/api/__init__.py b/app/modules/monitoring/routes/api/__init__.py index bec0d558..dbaf2d43 100644 --- a/app/modules/monitoring/routes/api/__init__.py +++ b/app/modules/monitoring/routes/api/__init__.py @@ -1,4 +1,14 @@ -# Routes will be migrated here from legacy locations -# TODO: Move actual route implementations from app/api/v1/ +# app/modules/monitoring/routes/api/__init__.py +""" +Monitoring module API routes. -__all__ = [] +Admin routes: +- /logs/* - Application log management +- /tasks/* - Background tasks monitoring +- /tests/* - Test runner +- /code-quality/* - Code quality tools +""" + +from app.modules.monitoring.routes.api.admin import admin_router + +__all__ = ["admin_router"] diff --git a/app/modules/monitoring/routes/api/admin.py b/app/modules/monitoring/routes/api/admin.py new file mode 100644 index 00000000..c4e516b3 --- /dev/null +++ b/app/modules/monitoring/routes/api/admin.py @@ -0,0 +1,35 @@ +# app/modules/monitoring/routes/api/admin.py +""" +Monitoring module admin API routes. + +Aggregates all admin monitoring routes: +- /logs/* - Application log management +- /tasks/* - Background tasks monitoring +- /tests/* - Test runner +- /code-quality/* - Code quality tools +- /audit/* - Admin audit logging +- /platform/* - Platform health and capacity +""" + +from fastapi import APIRouter, Depends + +from app.api.deps import require_module_access + +from .admin_logs import admin_logs_router +from .admin_tasks import admin_tasks_router +from .admin_tests import admin_tests_router +from .admin_code_quality import admin_code_quality_router +from .admin_audit import admin_audit_router +from .admin_platform_health import admin_platform_health_router + +admin_router = APIRouter( + dependencies=[Depends(require_module_access("monitoring"))], +) + +# Aggregate all monitoring admin routes +admin_router.include_router(admin_logs_router, tags=["admin-logs"]) +admin_router.include_router(admin_tasks_router, tags=["admin-tasks"]) +admin_router.include_router(admin_tests_router, tags=["admin-tests"]) +admin_router.include_router(admin_code_quality_router, tags=["admin-code-quality"]) +admin_router.include_router(admin_audit_router, tags=["admin-audit"]) +admin_router.include_router(admin_platform_health_router, tags=["admin-platform-health"]) diff --git a/app/api/v1/admin/audit.py b/app/modules/monitoring/routes/api/admin_audit.py similarity index 86% rename from app/api/v1/admin/audit.py rename to app/modules/monitoring/routes/api/admin_audit.py index 91729b7a..47933032 100644 --- a/app/api/v1/admin/audit.py +++ b/app/modules/monitoring/routes/api/admin_audit.py @@ -1,4 +1,4 @@ -# app/api/v1/admin/audit.py +# app/modules/monitoring/routes/api/admin_audit.py """ Admin audit log endpoints. @@ -16,7 +16,7 @@ from sqlalchemy.orm import Session from app.api.deps import get_current_admin_api from app.core.database import get_db -from app.services.admin_audit_service import admin_audit_service +from app.modules.monitoring.services.admin_audit_service import admin_audit_service from models.schema.auth import UserContext from models.schema.admin import ( AdminAuditLogFilters, @@ -24,11 +24,11 @@ from models.schema.admin import ( AdminAuditLogResponse, ) -router = APIRouter(prefix="/audit") +admin_audit_router = APIRouter(prefix="/audit") logger = logging.getLogger(__name__) -@router.get("/logs", response_model=AdminAuditLogListResponse) +@admin_audit_router.get("/logs", response_model=AdminAuditLogListResponse) def get_audit_logs( admin_user_id: int | None = Query(None, description="Filter by admin user"), action: str | None = Query(None, description="Filter by action type"), @@ -64,7 +64,7 @@ def get_audit_logs( return AdminAuditLogListResponse(logs=logs, total=total, skip=skip, limit=limit) -@router.get("/logs/recent", response_model=list[AdminAuditLogResponse]) +@admin_audit_router.get("/logs/recent", response_model=list[AdminAuditLogResponse]) def get_recent_audit_logs( limit: int = Query(20, ge=1, le=100), db: Session = Depends(get_db), @@ -75,7 +75,7 @@ def get_recent_audit_logs( return admin_audit_service.get_audit_logs(db, filters) -@router.get("/logs/my-actions", response_model=list[AdminAuditLogResponse]) +@admin_audit_router.get("/logs/my-actions", response_model=list[AdminAuditLogResponse]) def get_my_actions( limit: int = Query(50, ge=1, le=100), db: Session = Depends(get_db), @@ -87,7 +87,7 @@ def get_my_actions( ) -@router.get("/logs/target/{target_type}/{target_id}") +@admin_audit_router.get("/logs/target/{target_type}/{target_id}") def get_actions_by_target( target_type: str, target_id: str, diff --git a/app/api/v1/admin/code_quality.py b/app/modules/monitoring/routes/api/admin_code_quality.py similarity index 93% rename from app/api/v1/admin/code_quality.py rename to app/modules/monitoring/routes/api/admin_code_quality.py index 903c8159..8f2a9a57 100644 --- a/app/api/v1/admin/code_quality.py +++ b/app/modules/monitoring/routes/api/admin_code_quality.py @@ -1,3 +1,4 @@ +# app/modules/monitoring/routes/api/admin_code_quality.py """ Code Quality API Endpoints RESTful API for code quality validation and violation management @@ -13,17 +14,16 @@ from sqlalchemy.orm import Session from app.api.deps import get_current_admin_api from app.core.database import get_db -from app.exceptions import ScanNotFoundException, ViolationNotFoundException -from app.services.code_quality_service import ( +from app.modules.monitoring.exceptions import ScanNotFoundException, ViolationNotFoundException +from app.modules.dev_tools.services.code_quality_service import ( VALID_VALIDATOR_TYPES, code_quality_service, ) -from app.tasks.code_quality_tasks import execute_code_quality_scan from app.modules.dev_tools.models import ArchitectureScan from models.schema.auth import UserContext from app.modules.analytics.schemas import CodeQualityDashboardStatsResponse -router = APIRouter() +admin_code_quality_router = APIRouter(prefix="/code-quality") # Enums and Constants @@ -192,7 +192,7 @@ def _scan_to_response(scan: ArchitectureScan) -> ScanResponse: ) -@router.post("/scan", response_model=MultiScanJobResponse, status_code=202) +@admin_code_quality_router.post("/scan", response_model=MultiScanJobResponse, status_code=202) async def trigger_scan( request: ScanRequest = None, background_tasks: BackgroundTasks = None, @@ -251,7 +251,7 @@ async def trigger_scan( ) -@router.get("/scans/{scan_id}/status", response_model=ScanResponse) +@admin_code_quality_router.get("/scans/{scan_id}/status", response_model=ScanResponse) async def get_scan_status( scan_id: int, db: Session = Depends(get_db), @@ -269,7 +269,7 @@ async def get_scan_status( return _scan_to_response(scan) -@router.get("/scans/running", response_model=list[ScanResponse]) +@admin_code_quality_router.get("/scans/running", response_model=list[ScanResponse]) async def get_running_scans( db: Session = Depends(get_db), current_user: UserContext = Depends(get_current_admin_api), @@ -283,7 +283,7 @@ async def get_running_scans( return [_scan_to_response(scan) for scan in scans] -@router.get("/scans", response_model=list[ScanResponse]) +@admin_code_quality_router.get("/scans", response_model=list[ScanResponse]) async def list_scans( limit: int = Query(30, ge=1, le=100, description="Number of scans to return"), validator_type: ValidatorType | None = Query( @@ -305,7 +305,7 @@ async def list_scans( return [_scan_to_response(scan) for scan in scans] -@router.get("/violations", response_model=ViolationListResponse) +@admin_code_quality_router.get("/violations", response_model=ViolationListResponse) async def list_violations( scan_id: int | None = Query( None, description="Filter by scan ID (defaults to latest)" @@ -380,7 +380,7 @@ async def list_violations( ) -@router.get("/violations/{violation_id}", response_model=ViolationDetailResponse) +@admin_code_quality_router.get("/violations/{violation_id}", response_model=ViolationDetailResponse) async def get_violation( violation_id: int, db: Session = Depends(get_db), @@ -445,7 +445,7 @@ async def get_violation( ) -@router.post("/violations/{violation_id}/assign") +@admin_code_quality_router.post("/violations/{violation_id}/assign") async def assign_violation( violation_id: int, request: AssignViolationRequest, @@ -478,7 +478,7 @@ async def assign_violation( } -@router.post("/violations/{violation_id}/resolve") +@admin_code_quality_router.post("/violations/{violation_id}/resolve") async def resolve_violation( violation_id: int, request: ResolveViolationRequest, @@ -510,7 +510,7 @@ async def resolve_violation( } -@router.post("/violations/{violation_id}/ignore") +@admin_code_quality_router.post("/violations/{violation_id}/ignore") async def ignore_violation( violation_id: int, request: IgnoreViolationRequest, @@ -542,7 +542,7 @@ async def ignore_violation( } -@router.post("/violations/{violation_id}/comments") +@admin_code_quality_router.post("/violations/{violation_id}/comments") async def add_comment( violation_id: int, request: AddCommentRequest, @@ -571,7 +571,7 @@ async def add_comment( } -@router.get("/stats", response_model=CodeQualityDashboardStatsResponse) +@admin_code_quality_router.get("/stats", response_model=CodeQualityDashboardStatsResponse) async def get_dashboard_stats( validator_type: ValidatorType | None = Query( None, description="Filter by validator type (returns combined stats if not specified)" @@ -600,7 +600,7 @@ async def get_dashboard_stats( return CodeQualityDashboardStatsResponse(**stats) -@router.get("/validator-types") +@admin_code_quality_router.get("/validator-types") async def get_validator_types( current_user: UserContext = Depends(get_current_admin_api), ): diff --git a/app/api/v1/admin/logs.py b/app/modules/monitoring/routes/api/admin_logs.py similarity index 89% rename from app/api/v1/admin/logs.py rename to app/modules/monitoring/routes/api/admin_logs.py index e706bb6d..0abccdf0 100644 --- a/app/api/v1/admin/logs.py +++ b/app/modules/monitoring/routes/api/admin_logs.py @@ -1,4 +1,4 @@ -# app/api/v1/admin/logs.py +# app/modules/monitoring/routes/api/admin_logs.py """ Log management endpoints for admin. @@ -18,10 +18,11 @@ from sqlalchemy.orm import Session from app.api.deps import get_current_admin_api from app.core.database import get_db from app.core.logging import reload_log_level -from app.exceptions import ConfirmationRequiredException, ResourceNotFoundException -from app.services.admin_audit_service import admin_audit_service -from app.services.admin_settings_service import admin_settings_service -from app.services.log_service import log_service +from app.exceptions import ResourceNotFoundException +from app.modules.tenancy.exceptions import ConfirmationRequiredException +from app.modules.monitoring.services.admin_audit_service import admin_audit_service +from app.modules.core.services.admin_settings_service import admin_settings_service +from app.modules.monitoring.services.log_service import log_service from models.schema.auth import UserContext from models.schema.admin import ( ApplicationLogFilters, @@ -36,7 +37,7 @@ from models.schema.admin import ( LogStatistics, ) -router = APIRouter(prefix="/logs") +admin_logs_router = APIRouter(prefix="/logs") logger = logging.getLogger(__name__) @@ -45,7 +46,7 @@ logger = logging.getLogger(__name__) # ============================================================================ -@router.get("/database", response_model=ApplicationLogListResponse) +@admin_logs_router.get("/database", response_model=ApplicationLogListResponse) def get_database_logs( level: str | None = Query(None, description="Filter by log level"), logger_name: str | None = Query(None, description="Filter by logger name"), @@ -78,7 +79,7 @@ def get_database_logs( return log_service.get_database_logs(db, filters) -@router.get("/statistics", response_model=LogStatistics) +@admin_logs_router.get("/statistics", response_model=LogStatistics) def get_log_statistics( days: int = Query(7, ge=1, le=90, description="Number of days to analyze"), db: Session = Depends(get_db), @@ -92,7 +93,7 @@ def get_log_statistics( return log_service.get_log_statistics(db, days) -@router.delete("/database/cleanup", response_model=LogCleanupResponse) +@admin_logs_router.delete("/database/cleanup", response_model=LogCleanupResponse) def cleanup_old_logs( retention_days: int = Query(30, ge=1, le=365), confirm: bool = Query(False, description="Must be true to confirm cleanup"), @@ -125,7 +126,7 @@ def cleanup_old_logs( ) -@router.delete("/database/{log_id}", response_model=LogDeleteResponse) +@admin_logs_router.delete("/database/{log_id}", response_model=LogDeleteResponse) def delete_log( log_id: int, db: Session = Depends(get_db), @@ -152,7 +153,7 @@ def delete_log( # ============================================================================ -@router.get("/files", response_model=LogFileListResponse) +@admin_logs_router.get("/files", response_model=LogFileListResponse) def list_log_files( current_admin: UserContext = Depends(get_current_admin_api), ): @@ -164,7 +165,7 @@ def list_log_files( return LogFileListResponse(files=log_service.list_log_files()) -@router.get("/files/{filename}", response_model=FileLogResponse) +@admin_logs_router.get("/files/{filename}", response_model=FileLogResponse) def get_file_log( filename: str, lines: int = Query(500, ge=1, le=10000, description="Number of lines to read"), @@ -178,7 +179,7 @@ def get_file_log( return log_service.get_file_logs(filename, lines) -@router.get("/files/{filename}/download") +@admin_logs_router.get("/files/{filename}/download") def download_log_file( filename: str, current_admin: UserContext = Depends(get_current_admin_api), @@ -234,7 +235,7 @@ def download_log_file( # ============================================================================ -@router.get("/settings", response_model=LogSettingsResponse) +@admin_logs_router.get("/settings", response_model=LogSettingsResponse) def get_log_settings( db: Session = Depends(get_db), current_admin: UserContext = Depends(get_current_admin_api), @@ -267,7 +268,7 @@ def get_log_settings( ) -@router.put("/settings", response_model=LogSettingsUpdateResponse) +@admin_logs_router.put("/settings", response_model=LogSettingsUpdateResponse) def update_log_settings( settings_update: LogSettingsUpdate, db: Session = Depends(get_db), diff --git a/app/api/v1/admin/platform_health.py b/app/modules/monitoring/routes/api/admin_platform_health.py similarity index 86% rename from app/api/v1/admin/platform_health.py rename to app/modules/monitoring/routes/api/admin_platform_health.py index fcd6585a..6725d48f 100644 --- a/app/api/v1/admin/platform_health.py +++ b/app/modules/monitoring/routes/api/admin_platform_health.py @@ -1,4 +1,4 @@ -# app/api/v1/admin/platform_health.py +# app/modules/monitoring/routes/api/admin_platform_health.py """ Platform health and capacity monitoring endpoints. @@ -16,10 +16,10 @@ from sqlalchemy.orm import Session from app.api.deps import get_current_admin_api from app.core.database import get_db -from app.services.platform_health_service import platform_health_service +from app.modules.monitoring.services.platform_health_service import platform_health_service from models.schema.auth import UserContext -router = APIRouter() +admin_platform_health_router = APIRouter(prefix="/platform") logger = logging.getLogger(__name__) @@ -112,7 +112,7 @@ class CapacityMetricsResponse(BaseModel): # ============================================================================ -@router.get("/health", response_model=PlatformHealthResponse) +@admin_platform_health_router.get("/health", response_model=PlatformHealthResponse) async def get_platform_health( db: Session = Depends(get_db), current_admin: UserContext = Depends(get_current_admin_api), @@ -136,7 +136,7 @@ async def get_platform_health( ) -@router.get("/capacity", response_model=CapacityMetricsResponse) +@admin_platform_health_router.get("/capacity", response_model=CapacityMetricsResponse) async def get_capacity_metrics( db: Session = Depends(get_db), current_admin: UserContext = Depends(get_current_admin_api), @@ -146,7 +146,7 @@ async def get_capacity_metrics( return CapacityMetricsResponse(**metrics) -@router.get("/subscription-capacity") +@admin_platform_health_router.get("/subscription-capacity") async def get_subscription_capacity( db: Session = Depends(get_db), current_admin: UserContext = Depends(get_current_admin_api), @@ -159,7 +159,7 @@ async def get_subscription_capacity( return platform_health_service.get_subscription_capacity(db) -@router.get("/trends") +@admin_platform_health_router.get("/trends") async def get_growth_trends( days: int = 30, db: Session = Depends(get_db), @@ -170,12 +170,12 @@ async def get_growth_trends( Returns growth rates and projections for key metrics. """ - from app.services.capacity_forecast_service import capacity_forecast_service + from app.modules.billing.services.capacity_forecast_service import capacity_forecast_service return capacity_forecast_service.get_growth_trends(db, days=days) -@router.get("/recommendations") +@admin_platform_health_router.get("/recommendations") async def get_scaling_recommendations( db: Session = Depends(get_db), current_admin: UserContext = Depends(get_current_admin_api), @@ -185,12 +185,12 @@ async def get_scaling_recommendations( Returns prioritized list of recommendations. """ - from app.services.capacity_forecast_service import capacity_forecast_service + from app.modules.billing.services.capacity_forecast_service import capacity_forecast_service return capacity_forecast_service.get_scaling_recommendations(db) -@router.post("/snapshot") +@admin_platform_health_router.post("/snapshot") async def capture_snapshot( db: Session = Depends(get_db), current_admin: UserContext = Depends(get_current_admin_api), @@ -200,7 +200,7 @@ async def capture_snapshot( Normally run automatically by daily background job. """ - from app.services.capacity_forecast_service import capacity_forecast_service + from app.modules.billing.services.capacity_forecast_service import capacity_forecast_service snapshot = capacity_forecast_service.capture_daily_snapshot(db) db.commit() diff --git a/app/api/v1/admin/background_tasks.py b/app/modules/monitoring/routes/api/admin_tasks.py similarity index 95% rename from app/api/v1/admin/background_tasks.py rename to app/modules/monitoring/routes/api/admin_tasks.py index a4b6c1c7..8ae6c637 100644 --- a/app/api/v1/admin/background_tasks.py +++ b/app/modules/monitoring/routes/api/admin_tasks.py @@ -1,4 +1,4 @@ -# app/api/v1/admin/background_tasks.py +# app/modules/monitoring/routes/api/admin_tasks.py """ Background Tasks Monitoring API Provides unified view of all background tasks across the system @@ -12,10 +12,10 @@ from sqlalchemy.orm import Session from app.api.deps import get_current_admin_api from app.core.database import get_db -from app.services.background_tasks_service import background_tasks_service +from app.modules.monitoring.services.background_tasks_service import background_tasks_service from models.schema.auth import UserContext -router = APIRouter() +admin_tasks_router = APIRouter(prefix="/tasks") class BackgroundTaskResponse(BaseModel): @@ -148,7 +148,7 @@ def _convert_scan_to_response(scan) -> BackgroundTaskResponse: ) -@router.get("/tasks", response_model=list[BackgroundTaskResponse]) +@admin_tasks_router.get("", response_model=list[BackgroundTaskResponse]) async def list_background_tasks( status: str | None = Query(None, description="Filter by status"), task_type: str | None = Query( @@ -195,7 +195,7 @@ async def list_background_tasks( return tasks[:limit] -@router.get("/tasks/stats", response_model=BackgroundTasksStatsResponse) +@admin_tasks_router.get("/stats", response_model=BackgroundTasksStatsResponse) async def get_background_tasks_stats( db: Session = Depends(get_db), current_user: UserContext = Depends(get_current_admin_api), @@ -233,7 +233,7 @@ async def get_background_tasks_stats( ) -@router.get("/tasks/running", response_model=list[BackgroundTaskResponse]) +@admin_tasks_router.get("/running", response_model=list[BackgroundTaskResponse]) async def list_running_tasks( db: Session = Depends(get_db), current_user: UserContext = Depends(get_current_admin_api), diff --git a/app/api/v1/admin/tests.py b/app/modules/monitoring/routes/api/admin_tests.py similarity index 92% rename from app/api/v1/admin/tests.py rename to app/modules/monitoring/routes/api/admin_tests.py index b11cdc69..1bd115e1 100644 --- a/app/api/v1/admin/tests.py +++ b/app/modules/monitoring/routes/api/admin_tests.py @@ -1,3 +1,4 @@ +# app/modules/monitoring/routes/api/admin_tests.py """ Test Runner API Endpoints RESTful API for running pytest and viewing test results @@ -9,11 +10,10 @@ from sqlalchemy.orm import Session from app.api.deps import get_current_admin_api from app.core.database import get_db -from app.services.test_runner_service import test_runner_service -from app.tasks.test_runner_tasks import execute_test_run +from app.modules.dev_tools.services.test_runner_service import test_runner_service from models.schema.auth import UserContext -router = APIRouter() +admin_tests_router = APIRouter(prefix="/tests") # Pydantic Models for API @@ -103,7 +103,7 @@ class TestDashboardStatsResponse(BaseModel): # API Endpoints -@router.post("/run", response_model=TestRunResponse) +@admin_tests_router.post("/run", response_model=TestRunResponse) async def run_tests( background_tasks: BackgroundTasks, request: RunTestsRequest | None = None, @@ -164,7 +164,7 @@ async def run_tests( ) -@router.get("/runs", response_model=list[TestRunResponse]) +@admin_tests_router.get("/runs", response_model=list[TestRunResponse]) async def list_runs( limit: int = Query(20, ge=1, le=100, description="Number of runs to return"), db: Session = Depends(get_db), @@ -201,7 +201,7 @@ async def list_runs( ] -@router.get("/runs/{run_id}", response_model=TestRunResponse) +@admin_tests_router.get("/runs/{run_id}", response_model=TestRunResponse) async def get_run( run_id: int, db: Session = Depends(get_db), @@ -238,7 +238,7 @@ async def get_run( ) -@router.get("/runs/{run_id}/results", response_model=list[TestResultResponse]) +@admin_tests_router.get("/runs/{run_id}/results", response_model=list[TestResultResponse]) async def get_run_results( run_id: int, outcome: str | None = Query( @@ -268,7 +268,7 @@ async def get_run_results( ] -@router.get("/runs/{run_id}/failures", response_model=list[TestResultResponse]) +@admin_tests_router.get("/runs/{run_id}/failures", response_model=list[TestResultResponse]) async def get_run_failures( run_id: int, db: Session = Depends(get_db), @@ -295,7 +295,7 @@ async def get_run_failures( ] -@router.get("/stats", response_model=TestDashboardStatsResponse) +@admin_tests_router.get("/stats", response_model=TestDashboardStatsResponse) async def get_dashboard_stats( db: Session = Depends(get_db), current_user: UserContext = Depends(get_current_admin_api), @@ -314,7 +314,7 @@ async def get_dashboard_stats( return TestDashboardStatsResponse(**stats) -@router.post("/collect") +@admin_tests_router.post("/collect") async def collect_tests( db: Session = Depends(get_db), current_user: UserContext = Depends(get_current_admin_api), diff --git a/app/modules/monitoring/routes/pages/__init__.py b/app/modules/monitoring/routes/pages/__init__.py index 036b4041..c8d7b7fd 100644 --- a/app/modules/monitoring/routes/pages/__init__.py +++ b/app/modules/monitoring/routes/pages/__init__.py @@ -1,4 +1,2 @@ -# Page routes will be added here -# TODO: Add HTML page routes for admin/vendor dashboards - -__all__ = [] +# app/modules/monitoring/routes/pages/__init__.py +"""Monitoring module page routes.""" diff --git a/app/modules/monitoring/routes/pages/admin.py b/app/modules/monitoring/routes/pages/admin.py new file mode 100644 index 00000000..427a91fb --- /dev/null +++ b/app/modules/monitoring/routes/pages/admin.py @@ -0,0 +1,59 @@ +# app/modules/monitoring/routes/pages/admin.py +""" +Monitoring Admin Page Routes (HTML rendering). + +Admin pages for platform monitoring: +- Logs viewer +- Platform health +""" + +from fastapi import APIRouter, Depends, Request +from fastapi.responses import HTMLResponse +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 + +router = APIRouter() + + +# ============================================================================ +# LOGS & MONITORING ROUTES +# ============================================================================ + + +@router.get("/logs", response_class=HTMLResponse, include_in_schema=False) +async def admin_logs_page( + request: Request, + current_user: User = Depends(require_menu_access("logs", FrontendType.ADMIN)), + db: Session = Depends(get_db), +): + """ + Render admin logs viewer page. + View database and file logs with filtering and search. + """ + return templates.TemplateResponse( + "monitoring/admin/logs.html", + get_admin_context(request, current_user), + ) + + +@router.get("/platform-health", response_class=HTMLResponse, include_in_schema=False) +async def admin_platform_health( + request: Request, + current_user: User = Depends( + require_menu_access("platform-health", FrontendType.ADMIN) + ), + db: Session = Depends(get_db), +): + """ + Render platform health monitoring page. + Shows system metrics, capacity thresholds, and scaling recommendations. + """ + return templates.TemplateResponse( + "monitoring/admin/platform-health.html", + get_admin_context(request, current_user), + ) diff --git a/app/modules/monitoring/services/__init__.py b/app/modules/monitoring/services/__init__.py index 5134b2b9..275e6914 100644 --- a/app/modules/monitoring/services/__init__.py +++ b/app/modules/monitoring/services/__init__.py @@ -5,12 +5,30 @@ Monitoring module services. This module contains the canonical implementations of monitoring-related services. """ +from app.modules.monitoring.services.admin_audit_service import ( + admin_audit_service, + AdminAuditService, +) from app.modules.monitoring.services.background_tasks_service import ( background_tasks_service, BackgroundTasksService, ) +from app.modules.monitoring.services.log_service import ( + log_service, + LogService, +) +from app.modules.monitoring.services.platform_health_service import ( + platform_health_service, + PlatformHealthService, +) __all__ = [ + "admin_audit_service", + "AdminAuditService", "background_tasks_service", "BackgroundTasksService", + "log_service", + "LogService", + "platform_health_service", + "PlatformHealthService", ] diff --git a/app/services/admin_audit_service.py b/app/modules/monitoring/services/admin_audit_service.py similarity index 97% rename from app/services/admin_audit_service.py rename to app/modules/monitoring/services/admin_audit_service.py index 8f9bf415..0e467727 100644 --- a/app/services/admin_audit_service.py +++ b/app/modules/monitoring/services/admin_audit_service.py @@ -1,4 +1,4 @@ -# app/services/admin_audit_service.py +# app/modules/monitoring/services/admin_audit_service.py """ Admin audit service for tracking admin actions. @@ -14,7 +14,7 @@ from typing import Any from sqlalchemy import and_ from sqlalchemy.orm import Session -from app.exceptions import AdminOperationException +from app.modules.tenancy.exceptions import AdminOperationException from models.database.admin import AdminAuditLog from models.database.user import User from models.schema.admin import AdminAuditLogFilters, AdminAuditLogResponse @@ -36,11 +36,12 @@ class AdminAuditService: ip_address: str | None = None, user_agent: str | None = None, request_id: str | None = None, - ) -> AdminAuditLog: + ) -> AdminAuditLog | None: """ Log an admin action to the audit trail. Args: + db: Database session admin_user_id: ID of the admin performing the action action: Action performed (e.g., 'create_vendor', 'delete_user') target_type: Type of target (e.g., 'vendor', 'user') @@ -88,6 +89,7 @@ class AdminAuditService: Get filtered admin audit logs with pagination. Args: + db: Database session filters: Filter criteria for audit logs Returns: diff --git a/app/services/log_service.py b/app/modules/monitoring/services/log_service.py similarity index 98% rename from app/services/log_service.py rename to app/modules/monitoring/services/log_service.py index ec93704e..7bc0f3f7 100644 --- a/app/services/log_service.py +++ b/app/modules/monitoring/services/log_service.py @@ -1,4 +1,4 @@ -# app/services/log_service.py +# app/modules/monitoring/services/log_service.py """ Log management service for viewing and managing application logs. @@ -18,7 +18,8 @@ from sqlalchemy import and_, func, or_ from sqlalchemy.orm import Session from app.core.config import settings -from app.exceptions import AdminOperationException, ResourceNotFoundException +from app.exceptions import ResourceNotFoundException +from app.modules.tenancy.exceptions import AdminOperationException from models.database.admin import ApplicationLog from models.schema.admin import ( ApplicationLogFilters, diff --git a/app/services/platform_health_service.py b/app/modules/monitoring/services/platform_health_service.py similarity index 99% rename from app/services/platform_health_service.py rename to app/modules/monitoring/services/platform_health_service.py index d26c2549..584454d5 100644 --- a/app/services/platform_health_service.py +++ b/app/modules/monitoring/services/platform_health_service.py @@ -1,4 +1,4 @@ -# app/services/platform_health_service.py +# app/modules/monitoring/services/platform_health_service.py """ Platform health and capacity monitoring service. @@ -16,7 +16,7 @@ import psutil from sqlalchemy import func, text from sqlalchemy.orm import Session -from app.services.image_service import image_service +from app.modules.core.services.image_service import image_service from app.modules.inventory.models import Inventory from app.modules.orders.models import Order from app.modules.catalog.models import Product diff --git a/static/admin/js/platform-health.js b/app/modules/monitoring/static/admin/js/platform-health.js similarity index 100% rename from static/admin/js/platform-health.js rename to app/modules/monitoring/static/admin/js/platform-health.js diff --git a/app/modules/monitoring/tasks/__init__.py b/app/modules/monitoring/tasks/__init__.py new file mode 100644 index 00000000..79dcba4a --- /dev/null +++ b/app/modules/monitoring/tasks/__init__.py @@ -0,0 +1,11 @@ +# app/modules/monitoring/tasks/__init__.py +""" +Monitoring module Celery tasks. + +Tasks for: +- Capacity snapshot capture for forecasting +""" + +from app.modules.monitoring.tasks.capacity import capture_capacity_snapshot + +__all__ = ["capture_capacity_snapshot"] diff --git a/app/modules/monitoring/tasks/capacity.py b/app/modules/monitoring/tasks/capacity.py new file mode 100644 index 00000000..1f4afd49 --- /dev/null +++ b/app/modules/monitoring/tasks/capacity.py @@ -0,0 +1,45 @@ +# app/modules/monitoring/tasks/capacity.py +""" +Celery tasks for capacity monitoring and forecasting. + +Captures daily snapshots of platform capacity metrics for trend analysis. +""" + +import logging + +from app.core.celery_config import celery_app +from app.modules.task_base import ModuleTask + +logger = logging.getLogger(__name__) + + +@celery_app.task( + bind=True, + base=ModuleTask, + name="app.modules.monitoring.tasks.capacity.capture_capacity_snapshot", +) +def capture_capacity_snapshot(self): + """ + Capture a daily snapshot of platform capacity metrics. + + Runs daily at midnight via Celery beat. + + Returns: + dict: Snapshot summary with vendor and product counts. + """ + from app.modules.billing.services.capacity_forecast_service import capacity_forecast_service + + with self.get_db() as db: + snapshot = capacity_forecast_service.capture_daily_snapshot(db) + + logger.info( + f"Captured capacity snapshot: {snapshot.total_vendors} vendors, " + f"{snapshot.total_products} products" + ) + + return { + "snapshot_id": snapshot.id, + "snapshot_date": snapshot.snapshot_date.isoformat(), + "total_vendors": snapshot.total_vendors, + "total_products": snapshot.total_products, + } diff --git a/app/templates/admin/logs.html b/app/modules/monitoring/templates/monitoring/admin/logs.html similarity index 100% rename from app/templates/admin/logs.html rename to app/modules/monitoring/templates/monitoring/admin/logs.html diff --git a/app/templates/admin/platform-health.html b/app/modules/monitoring/templates/monitoring/admin/platform-health.html similarity index 99% rename from app/templates/admin/platform-health.html rename to app/modules/monitoring/templates/monitoring/admin/platform-health.html index 25bd29f1..00c59120 100644 --- a/app/templates/admin/platform-health.html +++ b/app/modules/monitoring/templates/monitoring/admin/platform-health.html @@ -263,5 +263,5 @@ {% endblock %} {% block extra_scripts %} - + {% endblock %} diff --git a/app/modules/orders/exceptions.py b/app/modules/orders/exceptions.py index 2fc69f8a..0360073c 100644 --- a/app/modules/orders/exceptions.py +++ b/app/modules/orders/exceptions.py @@ -2,32 +2,19 @@ """ Orders module exceptions. -Re-exports order-related exceptions from their source locations. +This module provides exception classes for order operations including: +- Order management (not found, validation, status) +- Order item exceptions (unresolved, resolution) +- Invoice operations (PDF generation, status transitions) """ -from app.exceptions.order import ( - OrderNotFoundException, - OrderAlreadyExistsException, - OrderValidationException, - InvalidOrderStatusException, - OrderCannotBeCancelledException, -) +from typing import Any -from app.exceptions.order_item_exception import ( - OrderItemExceptionNotFoundException, - OrderHasUnresolvedExceptionsException, - ExceptionAlreadyResolvedException, - InvalidProductForExceptionException, -) - -from app.exceptions.invoice import ( - InvoiceNotFoundException, - InvoiceSettingsNotFoundException, - InvoiceSettingsAlreadyExistException, - InvoiceValidationException, - InvoicePDFGenerationException, - InvoicePDFNotFoundException, - InvalidInvoiceStatusTransitionException, +from app.exceptions.base import ( + BusinessLogicException, + ResourceNotFoundException, + ValidationException, + WizamartException, ) __all__ = [ @@ -50,4 +37,227 @@ __all__ = [ "InvoicePDFGenerationException", "InvoicePDFNotFoundException", "InvalidInvoiceStatusTransitionException", + "OrderNotFoundForInvoiceException", ] + + +# ============================================================================= +# Order Exceptions +# ============================================================================= + + +class OrderNotFoundException(ResourceNotFoundException): + """Raised when an order is not found.""" + + def __init__(self, order_identifier: str): + super().__init__( + resource_type="Order", + identifier=order_identifier, + message=f"Order '{order_identifier}' not found", + error_code="ORDER_NOT_FOUND", + ) + + +class OrderAlreadyExistsException(ValidationException): + """Raised when trying to create a duplicate order.""" + + def __init__(self, order_number: str): + super().__init__( + message=f"Order with number '{order_number}' already exists", + details={"order_number": order_number}, + ) + self.error_code = "ORDER_ALREADY_EXISTS" + + +class OrderValidationException(ValidationException): + """Raised when order data validation fails.""" + + def __init__(self, message: str, details: dict | None = None): + super().__init__(message=message, details=details) + self.error_code = "ORDER_VALIDATION_FAILED" + + +class InvalidOrderStatusException(BusinessLogicException): + """Raised when trying to set an invalid order status.""" + + def __init__(self, current_status: str, new_status: str): + super().__init__( + message=f"Cannot change order status from '{current_status}' to '{new_status}'", + error_code="INVALID_ORDER_STATUS_CHANGE", + details={"current_status": current_status, "new_status": new_status}, + ) + + +class OrderCannotBeCancelledException(BusinessLogicException): + """Raised when order cannot be cancelled.""" + + def __init__(self, order_number: str, reason: str): + super().__init__( + message=f"Order '{order_number}' cannot be cancelled: {reason}", + error_code="ORDER_CANNOT_BE_CANCELLED", + details={"order_number": order_number, "reason": reason}, + ) + + +# ============================================================================= +# Order Item Exception Exceptions +# ============================================================================= + + +class OrderItemExceptionNotFoundException(ResourceNotFoundException): + """Raised when an order item exception is not found.""" + + def __init__(self, exception_id: int | str): + super().__init__( + resource_type="OrderItemException", + identifier=str(exception_id), + error_code="ORDER_ITEM_EXCEPTION_NOT_FOUND", + ) + + +class OrderHasUnresolvedExceptionsException(BusinessLogicException): + """Raised when trying to confirm an order with unresolved exceptions.""" + + def __init__(self, order_id: int, unresolved_count: int): + super().__init__( + message=( + f"Order has {unresolved_count} unresolved product exception(s). " + f"Please resolve all exceptions before confirming the order." + ), + error_code="ORDER_HAS_UNRESOLVED_EXCEPTIONS", + details={ + "order_id": order_id, + "unresolved_count": unresolved_count, + }, + ) + + +class ExceptionAlreadyResolvedException(BusinessLogicException): + """Raised when trying to resolve an already resolved exception.""" + + def __init__(self, exception_id: int): + super().__init__( + message=f"Exception {exception_id} has already been resolved", + error_code="EXCEPTION_ALREADY_RESOLVED", + details={"exception_id": exception_id}, + ) + + +class InvalidProductForExceptionException(BusinessLogicException): + """Raised when the product provided for resolution is invalid.""" + + def __init__(self, product_id: int, reason: str): + super().__init__( + message=f"Cannot use product {product_id} for resolution: {reason}", + error_code="INVALID_PRODUCT_FOR_EXCEPTION", + details={"product_id": product_id, "reason": reason}, + ) + + +# ============================================================================= +# Invoice Exceptions +# ============================================================================= + + +class InvoiceNotFoundException(ResourceNotFoundException): + """Raised when an invoice is not found.""" + + def __init__(self, invoice_id: int | str): + super().__init__( + resource_type="Invoice", + identifier=str(invoice_id), + error_code="INVOICE_NOT_FOUND", + ) + + +class InvoiceSettingsNotFoundException(ResourceNotFoundException): + """Raised when invoice settings are not found for a vendor.""" + + def __init__(self, vendor_id: int): + super().__init__( + resource_type="InvoiceSettings", + identifier=str(vendor_id), + message="Invoice settings not found. Create settings first.", + error_code="INVOICE_SETTINGS_NOT_FOUND", + ) + + +class InvoiceSettingsAlreadyExistException(WizamartException): + """Raised when trying to create invoice settings that already exist.""" + + def __init__(self, vendor_id: int): + super().__init__( + message=f"Invoice settings already exist for vendor {vendor_id}", + error_code="INVOICE_SETTINGS_ALREADY_EXIST", + status_code=409, + details={"vendor_id": vendor_id}, + ) + + +class InvoiceValidationException(BusinessLogicException): + """Raised when invoice data validation fails.""" + + def __init__(self, message: str, details: dict[str, Any] | None = None): + super().__init__( + message=message, + error_code="INVOICE_VALIDATION_ERROR", + details=details, + ) + + +class InvoicePDFGenerationException(WizamartException): + """Raised when PDF generation fails.""" + + def __init__(self, invoice_id: int, reason: str): + super().__init__( + message=f"Failed to generate PDF for invoice {invoice_id}: {reason}", + error_code="INVOICE_PDF_GENERATION_FAILED", + status_code=500, + details={"invoice_id": invoice_id, "reason": reason}, + ) + + +class InvoicePDFNotFoundException(ResourceNotFoundException): + """Raised when invoice PDF file is not found.""" + + def __init__(self, invoice_id: int): + super().__init__( + resource_type="InvoicePDF", + identifier=str(invoice_id), + message="PDF file not found. Generate the PDF first.", + error_code="INVOICE_PDF_NOT_FOUND", + ) + + +class InvalidInvoiceStatusTransitionException(BusinessLogicException): + """Raised when an invalid invoice status transition is attempted.""" + + def __init__( + self, + current_status: str, + new_status: str, + reason: str | None = None, + ): + message = f"Cannot change invoice status from '{current_status}' to '{new_status}'" + if reason: + message += f": {reason}" + + super().__init__( + message=message, + error_code="INVALID_INVOICE_STATUS_TRANSITION", + details={ + "current_status": current_status, + "new_status": new_status, + }, + ) + + +class OrderNotFoundForInvoiceException(ResourceNotFoundException): + """Raised when an order for invoice creation is not found.""" + + def __init__(self, order_id: int): + super().__init__( + resource_type="Order", + identifier=str(order_id), + error_code="ORDER_NOT_FOUND_FOR_INVOICE", + ) diff --git a/app/modules/orders/routes/api/admin.py b/app/modules/orders/routes/api/admin.py index 22ef6805..3a8a9c80 100644 --- a/app/modules/orders/routes/api/admin.py +++ b/app/modules/orders/routes/api/admin.py @@ -21,7 +21,7 @@ from sqlalchemy.orm import Session from app.api.deps import get_current_admin_api, require_module_access from app.core.database import get_db -from app.services.order_service import order_service +from app.modules.orders.services.order_service import order_service from models.schema.auth import UserContext from app.modules.orders.schemas import ( AdminOrderItem, diff --git a/app/modules/orders/routes/api/admin_exceptions.py b/app/modules/orders/routes/api/admin_exceptions.py index 62fdc57a..b7d111ed 100644 --- a/app/modules/orders/routes/api/admin_exceptions.py +++ b/app/modules/orders/routes/api/admin_exceptions.py @@ -16,7 +16,7 @@ from sqlalchemy.orm import Session from app.api.deps import get_current_admin_api, require_module_access from app.core.database import get_db -from app.services.order_item_exception_service import order_item_exception_service +from app.modules.orders.services.order_item_exception_service import order_item_exception_service from models.schema.auth import UserContext from app.modules.orders.schemas import ( BulkResolveRequest, diff --git a/app/modules/orders/routes/api/storefront.py b/app/modules/orders/routes/api/storefront.py index 8c1227e0..564c059e 100644 --- a/app/modules/orders/routes/api/storefront.py +++ b/app/modules/orders/routes/api/storefront.py @@ -20,11 +20,12 @@ from sqlalchemy.orm import Session from app.api.deps import get_current_customer_api from app.core.database import get_db -from app.exceptions import OrderNotFoundException, VendorNotFoundException -from app.exceptions.invoice import InvoicePDFNotFoundException +from app.modules.orders.exceptions import OrderNotFoundException +from app.modules.tenancy.exceptions import VendorNotFoundException +from app.modules.orders.exceptions import InvoicePDFNotFoundException from app.modules.customers.schemas import CustomerContext from app.modules.orders.services import order_service -from app.services.invoice_service import invoice_service # noqa: MOD-004 - Core invoice service +from app.modules.orders.services.invoice_service import invoice_service # noqa: MOD-004 - Core invoice service from app.modules.orders.schemas import ( OrderDetailResponse, OrderListResponse, diff --git a/app/modules/orders/routes/api/vendor.py b/app/modules/orders/routes/api/vendor.py index c3fe5203..46a447e9 100644 --- a/app/modules/orders/routes/api/vendor.py +++ b/app/modules/orders/routes/api/vendor.py @@ -16,8 +16,8 @@ from sqlalchemy.orm import Session from app.api.deps import get_current_vendor_api, require_module_access from app.core.database import get_db -from app.services.order_inventory_service import order_inventory_service -from app.services.order_service import order_service +from app.modules.orders.services.order_inventory_service import order_inventory_service +from app.modules.orders.services.order_service import order_service from models.schema.auth import UserContext from app.modules.orders.schemas import ( OrderDetailResponse, diff --git a/app/modules/orders/routes/api/vendor_exceptions.py b/app/modules/orders/routes/api/vendor_exceptions.py index a95429fe..a3f7bb1b 100644 --- a/app/modules/orders/routes/api/vendor_exceptions.py +++ b/app/modules/orders/routes/api/vendor_exceptions.py @@ -15,7 +15,7 @@ from sqlalchemy.orm import Session from app.api.deps import get_current_vendor_api, require_module_access from app.core.database import get_db -from app.services.order_item_exception_service import order_item_exception_service +from app.modules.orders.services.order_item_exception_service import order_item_exception_service from models.schema.auth import UserContext from app.modules.orders.schemas import ( BulkResolveRequest, diff --git a/app/modules/orders/routes/api/vendor_invoices.py b/app/modules/orders/routes/api/vendor_invoices.py index 502e396d..2111ab32 100644 --- a/app/modules/orders/routes/api/vendor_invoices.py +++ b/app/modules/orders/routes/api/vendor_invoices.py @@ -34,10 +34,10 @@ from sqlalchemy.orm import Session from app.api.deps import get_current_vendor_api, require_module_access from app.core.database import get_db from app.core.feature_gate import RequireFeature -from app.exceptions.invoice import ( +from app.modules.orders.exceptions import ( InvoicePDFNotFoundException, ) -from app.services.invoice_service import invoice_service +from app.modules.orders.services.invoice_service import invoice_service from app.modules.billing.models import FeatureCode from models.schema.auth import UserContext from app.modules.orders.schemas import ( diff --git a/app/modules/orders/routes/pages/__init__.py b/app/modules/orders/routes/pages/__init__.py index 036b4041..faae4b0d 100644 --- a/app/modules/orders/routes/pages/__init__.py +++ b/app/modules/orders/routes/pages/__init__.py @@ -1,4 +1,2 @@ -# Page routes will be added here -# TODO: Add HTML page routes for admin/vendor dashboards - -__all__ = [] +# app/modules/orders/routes/pages/__init__.py +"""Orders module page routes.""" diff --git a/app/modules/orders/routes/pages/admin.py b/app/modules/orders/routes/pages/admin.py new file mode 100644 index 00000000..51c7846f --- /dev/null +++ b/app/modules/orders/routes/pages/admin.py @@ -0,0 +1,40 @@ +# app/modules/orders/routes/pages/admin.py +""" +Orders Admin Page Routes (HTML rendering). + +Admin pages for order management: +- Orders list +""" + +from fastapi import APIRouter, Depends, Request +from fastapi.responses import HTMLResponse +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 + +router = APIRouter() + + +# ============================================================================ +# ORDER MANAGEMENT ROUTES +# ============================================================================ + + +@router.get("/orders", response_class=HTMLResponse, include_in_schema=False) +async def admin_orders_page( + request: Request, + current_user: User = Depends(require_menu_access("orders", FrontendType.ADMIN)), + db: Session = Depends(get_db), +): + """ + Render orders management page. + Shows orders across all vendors with filtering and status management. + """ + return templates.TemplateResponse( + "orders/admin/orders.html", + get_admin_context(request, current_user), + ) diff --git a/app/modules/orders/routes/pages/storefront.py b/app/modules/orders/routes/pages/storefront.py new file mode 100644 index 00000000..d3d2165e --- /dev/null +++ b/app/modules/orders/routes/pages/storefront.py @@ -0,0 +1,86 @@ +# app/modules/orders/routes/pages/storefront.py +""" +Orders Storefront Page Routes (HTML rendering). + +Storefront (customer shop) pages for order history: +- Orders list +- Order detail +""" + +import logging + +from fastapi import APIRouter, Depends, Path, Request +from fastapi.responses import HTMLResponse +from sqlalchemy.orm import Session + +from app.api.deps import get_current_customer_from_cookie_or_header, get_db +from app.modules.core.utils.page_context import get_storefront_context +from app.modules.customers.models import Customer +from app.templates_config import templates + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +# ============================================================================ +# CUSTOMER ACCOUNT - ORDERS (Authenticated) +# ============================================================================ + + +@router.get("/account/orders", response_class=HTMLResponse, include_in_schema=False) +async def shop_orders_page( + request: Request, + current_customer: Customer = Depends(get_current_customer_from_cookie_or_header), + db: Session = Depends(get_db), +): + """ + Render customer orders history page. + Shows all past and current orders. + Requires customer authentication. + """ + logger.debug( + "[STOREFRONT] shop_orders_page REACHED", + extra={ + "path": request.url.path, + "vendor": getattr(request.state, "vendor", "NOT SET"), + "context": getattr(request.state, "context_type", "NOT SET"), + }, + ) + + return templates.TemplateResponse( + "orders/storefront/orders.html", + get_storefront_context(request, db=db, user=current_customer), + ) + + +@router.get( + "/account/orders/{order_id}", + response_class=HTMLResponse, + include_in_schema=False, +) +async def shop_order_detail_page( + request: Request, + order_id: int = Path(..., description="Order ID"), + current_customer: Customer = Depends(get_current_customer_from_cookie_or_header), + db: Session = Depends(get_db), +): + """ + Render customer order detail page. + Shows detailed order information and tracking. + Requires customer authentication. + """ + logger.debug( + "[STOREFRONT] shop_order_detail_page REACHED", + extra={ + "path": request.url.path, + "order_id": order_id, + "vendor": getattr(request.state, "vendor", "NOT SET"), + "context": getattr(request.state, "context_type", "NOT SET"), + }, + ) + + return templates.TemplateResponse( + "orders/storefront/order-detail.html", + get_storefront_context(request, db=db, user=current_customer, order_id=order_id), + ) diff --git a/app/modules/orders/routes/pages/vendor.py b/app/modules/orders/routes/pages/vendor.py new file mode 100644 index 00000000..f06eecb1 --- /dev/null +++ b/app/modules/orders/routes/pages/vendor.py @@ -0,0 +1,73 @@ +# app/modules/orders/routes/pages/vendor.py +""" +Orders Vendor Page Routes (HTML rendering). + +Vendor pages for order management: +- Orders list +- Order detail +""" + +from fastapi import APIRouter, Depends, Path, Request +from fastapi.responses import HTMLResponse +from sqlalchemy.orm import Session + +from app.api.deps import get_current_vendor_from_cookie_or_header, get_db +from app.modules.core.utils.page_context import get_vendor_context +from app.templates_config import templates +from models.database.user import User + +router = APIRouter() + + +# ============================================================================ +# ORDER MANAGEMENT +# ============================================================================ + + +@router.get( + "/{vendor_code}/orders", response_class=HTMLResponse, include_in_schema=False +) +async def vendor_orders_page( + request: Request, + vendor_code: str = Path(..., description="Vendor code"), + current_user: User = Depends(get_current_vendor_from_cookie_or_header), + db: Session = Depends(get_db), +): + """ + Render orders management page. + JavaScript loads order list via API. + """ + return templates.TemplateResponse( + "orders/vendor/orders.html", + get_vendor_context(request, db, current_user, vendor_code), + ) + + +@router.get( + "/{vendor_code}/orders/{order_id}", + response_class=HTMLResponse, + include_in_schema=False, +) +async def vendor_order_detail_page( + request: Request, + vendor_code: str = Path(..., description="Vendor code"), + order_id: int = Path(..., description="Order ID"), + current_user: User = Depends(get_current_vendor_from_cookie_or_header), + db: Session = Depends(get_db), +): + """ + Render order detail page. + + Shows comprehensive order information including: + - Order header and status + - Customer and shipping details + - Order items with shipment status + - Invoice creation/viewing + - Partial shipment controls + + JavaScript loads order details via API. + """ + return templates.TemplateResponse( + "orders/vendor/order-detail.html", + get_vendor_context(request, db, current_user, vendor_code, order_id=order_id), + ) diff --git a/app/modules/orders/services/invoice_service.py b/app/modules/orders/services/invoice_service.py index 2730a14d..43bccdc9 100644 --- a/app/modules/orders/services/invoice_service.py +++ b/app/modules/orders/services/invoice_service.py @@ -19,9 +19,9 @@ from sqlalchemy import and_, func from sqlalchemy.orm import Session from app.exceptions import ValidationException -from app.exceptions.invoice import ( - InvoiceNotFoundException, +from app.modules.orders.exceptions import ( InvoiceSettingsNotFoundException, + InvoiceNotFoundException, OrderNotFoundException, ) from app.modules.orders.models.invoice import ( diff --git a/app/modules/orders/services/order_inventory_service.py b/app/modules/orders/services/order_inventory_service.py index 2256582e..3e22ff9b 100644 --- a/app/modules/orders/services/order_inventory_service.py +++ b/app/modules/orders/services/order_inventory_service.py @@ -13,12 +13,12 @@ All operations are logged to the inventory_transactions table for audit trail. import logging from sqlalchemy.orm import Session -from app.exceptions import ( +from app.exceptions import ValidationException +from app.modules.inventory.exceptions import ( InsufficientInventoryException, InventoryNotFoundException, - OrderNotFoundException, - ValidationException, ) +from app.modules.orders.exceptions import OrderNotFoundException from app.modules.inventory.models.inventory import Inventory from app.modules.inventory.models.inventory_transaction import ( InventoryTransaction, diff --git a/app/modules/orders/services/order_item_exception_service.py b/app/modules/orders/services/order_item_exception_service.py index 29c5a6c4..c2c8e43d 100644 --- a/app/modules/orders/services/order_item_exception_service.py +++ b/app/modules/orders/services/order_item_exception_service.py @@ -15,12 +15,12 @@ from datetime import UTC, datetime from sqlalchemy import and_, func, or_ from sqlalchemy.orm import Session, joinedload -from app.exceptions import ( +from app.modules.orders.exceptions import ( ExceptionAlreadyResolvedException, InvalidProductForExceptionException, - OrderItemExceptionNotFoundException, - ProductNotFoundException, ) +from app.modules.catalog.exceptions import ProductNotFoundException +from app.modules.orders.exceptions import OrderItemExceptionNotFoundException from app.modules.orders.models.order import Order, OrderItem from app.modules.orders.models.order_item_exception import OrderItemException from app.modules.catalog.models import Product diff --git a/app/modules/orders/services/order_service.py b/app/modules/orders/services/order_service.py index 24b68dbf..8a87cbc5 100644 --- a/app/modules/orders/services/order_service.py +++ b/app/modules/orders/services/order_service.py @@ -24,12 +24,10 @@ from typing import Any from sqlalchemy import and_, func, or_ from sqlalchemy.orm import Session -from app.exceptions import ( - CustomerNotFoundException, - InsufficientInventoryException, - OrderNotFoundException, - ValidationException, -) +from app.exceptions import ValidationException +from app.modules.customers.exceptions import CustomerNotFoundException +from app.modules.inventory.exceptions import InsufficientInventoryException +from app.modules.orders.exceptions import OrderNotFoundException from app.modules.customers.models.customer import Customer from app.modules.orders.models.order import Order, OrderItem from app.modules.orders.schemas.order import ( @@ -39,7 +37,7 @@ from app.modules.orders.schemas.order import ( OrderItemCreate, OrderUpdate, ) -from app.services.subscription_service import ( +from app.modules.billing.services.subscription_service import ( subscription_service, TierLimitExceededException, ) @@ -1293,7 +1291,7 @@ class OrderService: order_id: int, ) -> dict[str, Any]: """Get shipping label information for an order (admin only).""" - from app.services.admin_settings_service import admin_settings_service # noqa: MOD-004 + from app.modules.core.services.admin_settings_service import admin_settings_service # noqa: MOD-004 order = db.query(Order).filter(Order.id == order_id).first() diff --git a/app/templates/admin/orders.html b/app/modules/orders/templates/orders/admin/orders.html similarity index 100% rename from app/templates/admin/orders.html rename to app/modules/orders/templates/orders/admin/orders.html diff --git a/app/templates/storefront/account/order-detail.html b/app/modules/orders/templates/orders/storefront/order-detail.html similarity index 100% rename from app/templates/storefront/account/order-detail.html rename to app/modules/orders/templates/orders/storefront/order-detail.html diff --git a/app/templates/storefront/account/orders.html b/app/modules/orders/templates/orders/storefront/orders.html similarity index 100% rename from app/templates/storefront/account/orders.html rename to app/modules/orders/templates/orders/storefront/orders.html diff --git a/app/templates/vendor/invoices.html b/app/modules/orders/templates/orders/vendor/invoices.html similarity index 100% rename from app/templates/vendor/invoices.html rename to app/modules/orders/templates/orders/vendor/invoices.html diff --git a/app/templates/vendor/order-detail.html b/app/modules/orders/templates/orders/vendor/order-detail.html similarity index 100% rename from app/templates/vendor/order-detail.html rename to app/modules/orders/templates/orders/vendor/order-detail.html diff --git a/app/templates/vendor/orders.html b/app/modules/orders/templates/orders/vendor/orders.html similarity index 100% rename from app/templates/vendor/orders.html rename to app/modules/orders/templates/orders/vendor/orders.html diff --git a/app/modules/payments/exceptions.py b/app/modules/payments/exceptions.py new file mode 100644 index 00000000..abd7bc97 --- /dev/null +++ b/app/modules/payments/exceptions.py @@ -0,0 +1,144 @@ +# app/modules/payments/exceptions.py +""" +Payment-related exceptions. + +Includes: +- Webhook verification exceptions +- Payment processing exceptions +""" + +from app.exceptions.base import ( + BusinessLogicException, + ExternalServiceException, + ResourceNotFoundException, + ValidationException, +) + + +# ============================================================================= +# Webhook Exceptions +# ============================================================================= + + +class InvalidWebhookSignatureException(BusinessLogicException): + """Raised when Stripe webhook signature verification fails.""" + + def __init__(self, message: str = "Invalid webhook signature"): + super().__init__( + message=message, + error_code="INVALID_WEBHOOK_SIGNATURE", + ) + + +class WebhookMissingSignatureException(BusinessLogicException): + """Raised when Stripe webhook is missing the signature header.""" + + def __init__(self): + super().__init__( + message="Missing Stripe-Signature header", + error_code="WEBHOOK_MISSING_SIGNATURE", + ) + + +class WebhookVerificationException(BusinessLogicException): + """Raised when webhook signature verification fails.""" + + def __init__(self, message: str = "Invalid webhook signature"): + super().__init__( + message=message, + error_code="WEBHOOK_VERIFICATION_FAILED", + ) + + +# ============================================================================= +# Payment Exceptions +# ============================================================================= + + +class PaymentException(BusinessLogicException): + """Base exception for payment-related errors.""" + + def __init__( + self, + message: str, + error_code: str = "PAYMENT_ERROR", + details: dict | None = None, + ): + super().__init__(message=message, error_code=error_code, details=details) + + +class PaymentNotFoundException(ResourceNotFoundException): + """Raised when a payment is not found.""" + + def __init__(self, payment_id: str): + super().__init__( + resource_type="Payment", + identifier=payment_id, + message=f"Payment with ID '{payment_id}' not found", + ) + self.payment_id = payment_id + + +class PaymentFailedException(PaymentException): + """Raised when payment processing fails.""" + + def __init__(self, message: str, stripe_error: str | None = None): + super().__init__( + message=message, + error_code="PAYMENT_FAILED", + details={"stripe_error": stripe_error} if stripe_error else None, + ) + self.stripe_error = stripe_error + + +class PaymentRefundException(PaymentException): + """Raised when a refund fails.""" + + def __init__(self, message: str, payment_id: str | None = None): + super().__init__( + message=message, + error_code="REFUND_FAILED", + details={"payment_id": payment_id} if payment_id else None, + ) + self.payment_id = payment_id + + +class InsufficientFundsException(PaymentException): + """Raised when there are insufficient funds for payment.""" + + def __init__(self, required_amount: float, available_amount: float | None = None): + message = f"Insufficient funds. Required: {required_amount}" + if available_amount is not None: + message += f", Available: {available_amount}" + super().__init__( + message=message, + error_code="INSUFFICIENT_FUNDS", + details={ + "required_amount": required_amount, + "available_amount": available_amount, + }, + ) + self.required_amount = required_amount + self.available_amount = available_amount + + +class PaymentGatewayException(ExternalServiceException): + """Raised when payment gateway fails.""" + + def __init__(self, gateway: str, message: str): + super().__init__( + service_name=gateway, + message=f"Payment gateway error: {message}", + ) + self.gateway = gateway + + +class InvalidPaymentMethodException(ValidationException): + """Raised when an invalid payment method is provided.""" + + def __init__(self, method: str): + super().__init__( + message=f"Invalid payment method: {method}", + details={"method": method}, + ) + self.method = method diff --git a/app/modules/payments/routes/api/vendor.py b/app/modules/payments/routes/api/vendor.py index a45e0f4e..d5b3366c 100644 --- a/app/modules/payments/routes/api/vendor.py +++ b/app/modules/payments/routes/api/vendor.py @@ -21,7 +21,7 @@ from sqlalchemy.orm import Session from app.api.deps import get_current_vendor_api, require_module_access from app.core.database import get_db -from app.services.vendor_service import vendor_service +from app.modules.tenancy.services.vendor_service import vendor_service from models.schema.auth import UserContext from app.modules.payments.schemas import ( PaymentBalanceResponse, diff --git a/app/api/v1/shared/webhooks.py b/app/modules/payments/routes/api/webhooks.py similarity index 80% rename from app/api/v1/shared/webhooks.py rename to app/modules/payments/routes/api/webhooks.py index 991b9da3..33148c35 100644 --- a/app/api/v1/shared/webhooks.py +++ b/app/modules/payments/routes/api/webhooks.py @@ -1,26 +1,27 @@ -# app/api/v1/shared/webhooks.py +# app/modules/payments/routes/api/webhooks.py """ External webhook endpoints. Handles webhooks from: - Stripe (payments and subscriptions) + +Webhooks use signature verification for security, not user authentication. """ import logging from fastapi import APIRouter, Header, Request -from sqlalchemy.orm import Session from app.core.database import get_db -from app.exceptions import InvalidWebhookSignatureException, WebhookMissingSignatureException -from app.services.stripe_service import stripe_service +from app.modules.payments.exceptions import InvalidWebhookSignatureException, WebhookMissingSignatureException +from app.modules.billing.services.stripe_service import stripe_service from app.handlers.stripe_webhook import stripe_webhook_handler -router = APIRouter(prefix="/webhooks") +router = APIRouter(prefix="/stripe") logger = logging.getLogger(__name__) -@router.post("/stripe") # public - Stripe webhooks use signature verification +@router.post("") # Uses signature verification, not user auth async def stripe_webhook( request: Request, stripe_signature: str = Header(None, alias="Stripe-Signature"), diff --git a/app/modules/routes.py b/app/modules/routes.py index 936aae6e..681d3e4f 100644 --- a/app/modules/routes.py +++ b/app/modules/routes.py @@ -10,17 +10,23 @@ This module bridges the gap between the module system and FastAPI, allowing routes to be defined within modules and automatically discovered and registered. -Usage: - # In main.py - from app.modules.routes import discover_module_routes +Route Discovery: + Routes are discovered from these file locations in each module: + - routes/api/admin.py -> /api/v1/admin/* + - routes/api/vendor.py -> /api/v1/vendor/* + - routes/api/storefront.py -> /api/v1/storefront/* + - routes/pages/admin.py -> /admin/* + - routes/pages/vendor.py -> /vendor/* - # Auto-discover and register routes - for route_info in discover_module_routes(): - app.include_router( - route_info["router"], - prefix=route_info["prefix"], - tags=route_info["tags"], - include_in_schema=route_info.get("include_in_schema", True), +Usage: + # In app/api/v1/{admin,vendor,storefront}/__init__.py + from app.modules.routes import get_admin_api_routes # or vendor/storefront + + for route_info in get_admin_api_routes(): + router.include_router( + route_info.router, + prefix=route_info.custom_prefix or "", + tags=route_info.tags, ) Route Configuration: @@ -160,7 +166,7 @@ def _discover_routes_in_dir( """ routes: list[RouteInfo] = [] - # Look for admin.py, vendor.py, shop.py + # Look for admin.py, vendor.py, storefront.py, public.py, webhooks.py frontends = { "admin": { "api_prefix": "/api/v1/admin", @@ -172,11 +178,21 @@ def _discover_routes_in_dir( "pages_prefix": "/vendor", "include_in_schema": True if route_type == "api" else False, }, - "shop": { - "api_prefix": "/api/v1/shop", - "pages_prefix": "/shop", + "storefront": { + "api_prefix": "/api/v1/storefront", + "pages_prefix": "/storefront", "include_in_schema": True if route_type == "api" else False, }, + "public": { + "api_prefix": "/api/v1/public", + "pages_prefix": "/public", + "include_in_schema": True, + }, + "webhooks": { + "api_prefix": "/api/v1/webhooks", + "pages_prefix": "/webhooks", + "include_in_schema": True, + }, } for frontend, config in frontends.items(): @@ -304,6 +320,90 @@ def get_vendor_api_routes() -> list[RouteInfo]: return sorted(routes, key=lambda r: r.priority) +def get_storefront_api_routes() -> list[RouteInfo]: + """ + Get storefront API routes from modules, sorted by priority. + + Returns routes sorted by priority (lower first, higher last). + This ensures catch-all routes (priority 100+) are registered after + specific routes. + """ + routes = [ + r for r in discover_module_routes() + if r.route_type == "api" and r.frontend == "storefront" + ] + return sorted(routes, key=lambda r: r.priority) + + +def get_public_api_routes() -> list[RouteInfo]: + """ + Get public API routes from modules, sorted by priority. + + Public routes are unauthenticated endpoints for marketing pages, + pricing info, and other public-facing features. + """ + routes = [ + r for r in discover_module_routes() + if r.route_type == "api" and r.frontend == "public" + ] + return sorted(routes, key=lambda r: r.priority) + + +def get_webhooks_api_routes() -> list[RouteInfo]: + """ + Get webhook API routes from modules, sorted by priority. + + Webhook routes handle callbacks from external services + (Stripe, payment providers, etc.). + """ + routes = [ + r for r in discover_module_routes() + if r.route_type == "api" and r.frontend == "webhooks" + ] + return sorted(routes, key=lambda r: r.priority) + + +def get_public_page_routes() -> list[RouteInfo]: + """ + Get public (marketing) page routes from modules, sorted by priority. + + Public pages are unauthenticated marketing pages like: + - Homepage (/) + - Pricing (/pricing) + - Signup (/signup) + - Find shop (/find-shop) + - CMS catch-all (/{slug}) + + Note: CMS routes should have priority=100 to be registered last + since they have catch-all patterns. + """ + routes = [ + r for r in discover_module_routes() + if r.route_type == "pages" and r.frontend == "public" + ] + return sorted(routes, key=lambda r: r.priority) + + +def get_storefront_page_routes() -> list[RouteInfo]: + """ + Get storefront (customer shop) page routes from modules, sorted by priority. + + Storefront pages include: + - Catalog pages (/, /products, /products/{id}, /categories/{slug}) + - Cart and checkout (/cart, /checkout) + - Account pages (/account/*) + - CMS content pages (/{slug}) + + Note: CMS routes should have priority=100 to be registered last + since they have catch-all patterns. + """ + routes = [ + r for r in discover_module_routes() + if r.route_type == "pages" and r.frontend == "storefront" + ] + return sorted(routes, key=lambda r: r.priority) + + __all__ = [ "RouteInfo", "discover_module_routes", @@ -313,4 +413,9 @@ __all__ = [ "get_admin_page_routes", "get_admin_api_routes", "get_vendor_api_routes", + "get_storefront_api_routes", + "get_public_api_routes", + "get_webhooks_api_routes", + "get_public_page_routes", + "get_storefront_page_routes", ] diff --git a/app/modules/tenancy/exceptions.py b/app/modules/tenancy/exceptions.py index 5006da58..80558aaa 100644 --- a/app/modules/tenancy/exceptions.py +++ b/app/modules/tenancy/exceptions.py @@ -2,37 +2,1143 @@ """ Tenancy module exceptions. -Exceptions for platform, company, vendor, and admin user management. +Exceptions for platform, company, vendor, admin user, team, and domain management. """ -from app.exceptions import WizamartException +from typing import Any + +from app.exceptions.base import ( + AuthenticationException, + AuthorizationException, + BusinessLogicException, + ConflictException, + ExternalServiceException, + ResourceNotFoundException, + ValidationException, + WizamartException, +) -class TenancyException(WizamartException): - """Base exception for tenancy module.""" - - pass +# ============================================================================= +# Authentication Exceptions +# ============================================================================= -class VendorNotFoundException(TenancyException): - """Vendor not found or inactive.""" +class InvalidCredentialsException(AuthenticationException): + """Raised when login credentials are invalid.""" + + def __init__(self, message: str = "Invalid username or password"): + super().__init__( + message=message, + error_code="INVALID_CREDENTIALS", + ) + + +class TokenExpiredException(AuthenticationException): + """Raised when JWT token has expired.""" + + def __init__(self, message: str = "Token has expired"): + super().__init__( + message=message, + error_code="TOKEN_EXPIRED", + ) + + +class InvalidTokenException(AuthenticationException): + """Raised when JWT token is invalid or malformed.""" + + def __init__(self, message: str = "Invalid token"): + super().__init__( + message=message, + error_code="INVALID_TOKEN", + ) + + +class InsufficientPermissionsException(AuthorizationException): + """Raised when user lacks required permissions for an action.""" + + def __init__( + self, + message: str = "Insufficient permissions for this action", + required_permission: str | None = None, + ): + details = {} + if required_permission: + details["required_permission"] = required_permission + + super().__init__( + message=message, + error_code="INSUFFICIENT_PERMISSIONS", + details=details, + ) + + +class UserNotActiveException(AuthorizationException): + """Raised when user account is not active.""" + + def __init__(self, message: str = "User account is not active"): + super().__init__( + message=message, + error_code="USER_NOT_ACTIVE", + ) + + +class AdminRequiredException(AuthorizationException): + """Raised when admin privileges are required.""" + + def __init__(self, message: str = "Admin privileges required"): + super().__init__( + message=message, + error_code="ADMIN_REQUIRED", + ) + + +class UserAlreadyExistsException(ConflictException): + """Raised when trying to register with existing username/email.""" + + def __init__( + self, + message: str = "User already exists", + field: str | None = None, + ): + details = {} + if field: + details["field"] = field + + super().__init__( + message=message, + error_code="USER_ALREADY_EXISTS", + details=details, + ) + + +# ============================================================================= +# Platform Exceptions +# ============================================================================= + + +class PlatformNotFoundException(WizamartException): + """Raised when a platform is not found.""" + + def __init__(self, code: str): + super().__init__( + message=f"Platform not found: {code}", + error_code="PLATFORM_NOT_FOUND", + status_code=404, + details={"platform_code": code}, + ) + + +class PlatformInactiveException(WizamartException): + """Raised when trying to access an inactive platform.""" + + def __init__(self, code: str): + super().__init__( + message=f"Platform is inactive: {code}", + error_code="PLATFORM_INACTIVE", + status_code=403, + details={"platform_code": code}, + ) + + +class PlatformUpdateException(WizamartException): + """Raised when platform update fails.""" + + def __init__(self, code: str, reason: str): + super().__init__( + message=f"Failed to update platform {code}: {reason}", + error_code="PLATFORM_UPDATE_FAILED", + status_code=400, + details={"platform_code": code, "reason": reason}, + ) + + +# ============================================================================= +# Vendor Exceptions +# ============================================================================= + + +class VendorNotFoundException(ResourceNotFoundException): + """Raised when a vendor is not found.""" + + def __init__(self, vendor_identifier: str, identifier_type: str = "code"): + if identifier_type.lower() == "id": + message = f"Vendor with ID '{vendor_identifier}' not found" + else: + message = f"Vendor with code '{vendor_identifier}' not found" + + super().__init__( + resource_type="Vendor", + identifier=vendor_identifier, + message=message, + error_code="VENDOR_NOT_FOUND", + ) + + +class VendorAlreadyExistsException(ConflictException): + """Raised when trying to create a vendor that already exists.""" def __init__(self, vendor_code: str): - super().__init__(f"Vendor '{vendor_code}' not found or inactive") - self.vendor_code = vendor_code + super().__init__( + message=f"Vendor with code '{vendor_code}' already exists", + error_code="VENDOR_ALREADY_EXISTS", + details={"vendor_code": vendor_code}, + ) -class CompanyNotFoundException(TenancyException): - """Company not found.""" +class VendorNotActiveException(BusinessLogicException): + """Raised when trying to perform operations on inactive vendor.""" + + def __init__(self, vendor_code: str): + super().__init__( + message=f"Vendor '{vendor_code}' is not active", + error_code="VENDOR_NOT_ACTIVE", + details={"vendor_code": vendor_code}, + ) + + +class VendorNotVerifiedException(BusinessLogicException): + """Raised when trying to perform operations requiring verified vendor.""" + + def __init__(self, vendor_code: str): + super().__init__( + message=f"Vendor '{vendor_code}' is not verified", + error_code="VENDOR_NOT_VERIFIED", + details={"vendor_code": vendor_code}, + ) + + +class UnauthorizedVendorAccessException(AuthorizationException): + """Raised when user tries to access vendor they don't own.""" + + def __init__(self, vendor_code: str, user_id: int | None = None): + details = {"vendor_code": vendor_code} + if user_id: + details["user_id"] = user_id + + super().__init__( + message=f"Unauthorized access to vendor '{vendor_code}'", + error_code="UNAUTHORIZED_VENDOR_ACCESS", + details=details, + ) + + +class InvalidVendorDataException(ValidationException): + """Raised when vendor data is invalid or incomplete.""" + + def __init__( + self, + message: str = "Invalid vendor data", + field: str | None = None, + details: dict[str, Any] | None = None, + ): + super().__init__( + message=message, + field=field, + details=details, + ) + self.error_code = "INVALID_VENDOR_DATA" + + +class VendorValidationException(ValidationException): + """Raised when vendor validation fails.""" + + def __init__( + self, + message: str = "Vendor validation failed", + field: str | None = None, + validation_errors: dict[str, str] | None = None, + ): + details = {} + if validation_errors: + details["validation_errors"] = validation_errors + + super().__init__( + message=message, + field=field, + details=details, + ) + self.error_code = "VENDOR_VALIDATION_FAILED" + + +class MaxVendorsReachedException(BusinessLogicException): + """Raised when user tries to create more vendors than allowed.""" + + def __init__(self, max_vendors: int, user_id: int | None = None): + details = {"max_vendors": max_vendors} + if user_id: + details["user_id"] = user_id + + super().__init__( + message=f"Maximum number of vendors reached ({max_vendors})", + error_code="MAX_VENDORS_REACHED", + details=details, + ) + + +class VendorAccessDeniedException(AuthorizationException): + """Raised when no vendor context is available for an authenticated endpoint.""" + + def __init__(self, message: str = "No vendor context available"): + super().__init__( + message=message, + error_code="VENDOR_ACCESS_DENIED", + ) + + +class VendorOwnerOnlyException(AuthorizationException): + """Raised when operation requires vendor owner role.""" + + def __init__(self, operation: str, vendor_code: str | None = None): + details = {"operation": operation} + if vendor_code: + details["vendor_code"] = vendor_code + + super().__init__( + message=f"Operation '{operation}' requires vendor owner role", + error_code="VENDOR_OWNER_ONLY", + details=details, + ) + + +class InsufficientVendorPermissionsException(AuthorizationException): + """Raised when user lacks required vendor permission.""" + + def __init__(self, required_permission: str, vendor_code: str | None = None): + details = {"required_permission": required_permission} + if vendor_code: + details["vendor_code"] = vendor_code + + super().__init__( + message=f"Permission required: {required_permission}", + error_code="INSUFFICIENT_VENDOR_PERMISSIONS", + details=details, + ) + + +# ============================================================================= +# Company Exceptions +# ============================================================================= + + +class CompanyNotFoundException(ResourceNotFoundException): + """Raised when a company is not found.""" + + def __init__(self, company_identifier: str | int, identifier_type: str = "id"): + if identifier_type.lower() == "id": + message = f"Company with ID '{company_identifier}' not found" + else: + message = f"Company with name '{company_identifier}' not found" + + super().__init__( + resource_type="Company", + identifier=str(company_identifier), + message=message, + error_code="COMPANY_NOT_FOUND", + ) + + +class CompanyAlreadyExistsException(ConflictException): + """Raised when trying to create a company that already exists.""" + + def __init__(self, company_name: str): + super().__init__( + message=f"Company with name '{company_name}' already exists", + error_code="COMPANY_ALREADY_EXISTS", + details={"company_name": company_name}, + ) + + +class CompanyNotActiveException(BusinessLogicException): + """Raised when trying to perform operations on inactive company.""" def __init__(self, company_id: int): - super().__init__(f"Company {company_id} not found") - self.company_id = company_id + super().__init__( + message=f"Company with ID '{company_id}' is not active", + error_code="COMPANY_NOT_ACTIVE", + details={"company_id": company_id}, + ) -class PlatformNotFoundException(TenancyException): - """Platform not found.""" +class CompanyNotVerifiedException(BusinessLogicException): + """Raised when trying to perform operations requiring verified company.""" - def __init__(self, platform_id: int): - super().__init__(f"Platform {platform_id} not found") - self.platform_id = platform_id + def __init__(self, company_id: int): + super().__init__( + message=f"Company with ID '{company_id}' is not verified", + error_code="COMPANY_NOT_VERIFIED", + details={"company_id": company_id}, + ) + + +class UnauthorizedCompanyAccessException(AuthorizationException): + """Raised when user tries to access company they don't own.""" + + def __init__(self, company_id: int, user_id: int | None = None): + details = {"company_id": company_id} + if user_id: + details["user_id"] = user_id + + super().__init__( + message=f"Unauthorized access to company with ID '{company_id}'", + error_code="UNAUTHORIZED_COMPANY_ACCESS", + details=details, + ) + + +class InvalidCompanyDataException(ValidationException): + """Raised when company data is invalid or incomplete.""" + + def __init__( + self, + message: str = "Invalid company data", + field: str | None = None, + details: dict[str, Any] | None = None, + ): + super().__init__( + message=message, + field=field, + details=details, + ) + self.error_code = "INVALID_COMPANY_DATA" + + +class CompanyValidationException(ValidationException): + """Raised when company validation fails.""" + + def __init__( + self, + message: str = "Company validation failed", + field: str | None = None, + validation_errors: dict[str, str] | None = None, + ): + details = {} + if validation_errors: + details["validation_errors"] = validation_errors + + super().__init__( + message=message, + field=field, + details=details, + ) + self.error_code = "COMPANY_VALIDATION_FAILED" + + +class CompanyHasVendorsException(BusinessLogicException): + """Raised when trying to delete a company that still has active vendors.""" + + def __init__(self, company_id: int, vendor_count: int): + super().__init__( + message=f"Cannot delete company with ID '{company_id}' because it has {vendor_count} associated vendor(s)", + error_code="COMPANY_HAS_VENDORS", + details={"company_id": company_id, "vendor_count": vendor_count}, + ) + + +# ============================================================================= +# Admin User Exceptions +# ============================================================================= + + +class UserNotFoundException(ResourceNotFoundException): + """Raised when user is not found in admin operations.""" + + def __init__(self, user_identifier: str, identifier_type: str = "ID"): + if identifier_type.lower() == "username": + message = f"User with username '{user_identifier}' not found" + elif identifier_type.lower() == "email": + message = f"User with email '{user_identifier}' not found" + else: + message = f"User with ID '{user_identifier}' not found" + + super().__init__( + resource_type="User", + identifier=user_identifier, + message=message, + error_code="USER_NOT_FOUND", + ) + + +class UserStatusChangeException(BusinessLogicException): + """Raised when user status cannot be changed.""" + + def __init__( + self, + user_id: int, + current_status: str, + attempted_action: str, + reason: str | None = None, + ): + message = f"Cannot {attempted_action} user {user_id} (current status: {current_status})" + if reason: + message += f": {reason}" + + super().__init__( + message=message, + error_code="USER_STATUS_CHANGE_FAILED", + details={ + "user_id": user_id, + "current_status": current_status, + "attempted_action": attempted_action, + "reason": reason, + }, + ) + + +class AdminOperationException(BusinessLogicException): + """Raised when admin operation fails.""" + + def __init__( + self, + operation: str, + reason: str, + target_type: str | None = None, + target_id: str | None = None, + ): + message = f"Admin operation '{operation}' failed: {reason}" + + details = { + "operation": operation, + "reason": reason, + } + + if target_type: + details["target_type"] = target_type + if target_id: + details["target_id"] = target_id + + super().__init__( + message=message, + error_code="ADMIN_OPERATION_FAILED", + details=details, + ) + + +class CannotModifyAdminException(AuthorizationException): + """Raised when trying to modify another admin user.""" + + def __init__(self, target_user_id: int, admin_user_id: int): + super().__init__( + message=f"Cannot modify admin user {target_user_id}", + error_code="CANNOT_MODIFY_ADMIN", + details={ + "target_user_id": target_user_id, + "admin_user_id": admin_user_id, + }, + ) + + +class CannotModifySelfException(BusinessLogicException): + """Raised when admin tries to modify their own status.""" + + def __init__(self, user_id: int, operation: str): + super().__init__( + message=f"Cannot perform '{operation}' on your own account", + error_code="CANNOT_MODIFY_SELF", + details={ + "user_id": user_id, + "operation": operation, + }, + ) + + +class InvalidAdminActionException(ValidationException): + """Raised when admin action is invalid.""" + + def __init__( + self, + action: str, + reason: str, + valid_actions: list | None = None, + ): + details = { + "action": action, + "reason": reason, + } + + if valid_actions: + details["valid_actions"] = valid_actions + + super().__init__( + message=f"Invalid admin action '{action}': {reason}", + details=details, + ) + self.error_code = "INVALID_ADMIN_ACTION" + + +class BulkOperationException(BusinessLogicException): + """Raised when bulk admin operation fails.""" + + def __init__( + self, + operation: str, + total_items: int, + failed_items: int, + errors: dict[str, Any] | None = None, + ): + message = f"Bulk {operation} completed with errors: {failed_items}/{total_items} failed" + + details = { + "operation": operation, + "total_items": total_items, + "failed_items": failed_items, + "success_items": total_items - failed_items, + } + + if errors: + details["errors"] = errors + + super().__init__( + message=message, + error_code="BULK_OPERATION_PARTIAL_FAILURE", + details=details, + ) + + +class ConfirmationRequiredException(BusinessLogicException): + """Raised when a destructive operation requires explicit confirmation.""" + + def __init__( + self, + operation: str, + message: str | None = None, + confirmation_param: str = "confirm", + ): + if not message: + message = f"Operation '{operation}' requires confirmation parameter: {confirmation_param}=true" + + super().__init__( + message=message, + error_code="CONFIRMATION_REQUIRED", + details={ + "operation": operation, + "confirmation_param": confirmation_param, + }, + ) + + +class VendorVerificationException(BusinessLogicException): + """Raised when vendor verification fails.""" + + def __init__( + self, + vendor_id: int, + reason: str, + current_verification_status: bool | None = None, + ): + details = { + "vendor_id": vendor_id, + "reason": reason, + } + + if current_verification_status is not None: + details["current_verification_status"] = current_verification_status + + super().__init__( + message=f"Vendor verification failed for vendor {vendor_id}: {reason}", + error_code="VENDOR_VERIFICATION_FAILED", + details=details, + ) + + +class UserCannotBeDeletedException(BusinessLogicException): + """Raised when a user cannot be deleted due to ownership constraints.""" + + def __init__(self, user_id: int, reason: str, owned_count: int = 0): + details = { + "user_id": user_id, + "reason": reason, + } + if owned_count > 0: + details["owned_companies_count"] = owned_count + + super().__init__( + message=f"Cannot delete user {user_id}: {reason}", + error_code="USER_CANNOT_BE_DELETED", + details=details, + ) + + +class UserRoleChangeException(BusinessLogicException): + """Raised when user role cannot be changed.""" + + def __init__(self, user_id: int, current_role: str, target_role: str, reason: str): + super().__init__( + message=f"Cannot change user {user_id} role from {current_role} to {target_role}: {reason}", + error_code="USER_ROLE_CHANGE_FAILED", + details={ + "user_id": user_id, + "current_role": current_role, + "target_role": target_role, + "reason": reason, + }, + ) + + +# ============================================================================= +# Team Exceptions +# ============================================================================= + + +class TeamMemberNotFoundException(ResourceNotFoundException): + """Raised when a team member is not found.""" + + def __init__(self, user_id: int, vendor_id: int | None = None): + details = {"user_id": user_id} + if vendor_id: + details["vendor_id"] = vendor_id + message = f"Team member with user ID '{user_id}' not found in vendor {vendor_id}" + else: + message = f"Team member with user ID '{user_id}' not found" + + super().__init__( + resource_type="TeamMember", + identifier=str(user_id), + message=message, + error_code="TEAM_MEMBER_NOT_FOUND", + ) + self.details.update(details) + + +class TeamMemberAlreadyExistsException(ConflictException): + """Raised when trying to add a user who is already a team member.""" + + def __init__(self, user_id: int, vendor_id: int): + super().__init__( + message=f"User {user_id} is already a team member of vendor {vendor_id}", + error_code="TEAM_MEMBER_ALREADY_EXISTS", + details={ + "user_id": user_id, + "vendor_id": vendor_id, + }, + ) + + +class TeamInvitationNotFoundException(ResourceNotFoundException): + """Raised when a team invitation is not found.""" + + def __init__(self, invitation_token: str): + super().__init__( + resource_type="TeamInvitation", + identifier=invitation_token, + message=f"Team invitation with token '{invitation_token}' not found or expired", + error_code="TEAM_INVITATION_NOT_FOUND", + ) + + +class TeamInvitationExpiredException(BusinessLogicException): + """Raised when trying to accept an expired invitation.""" + + def __init__(self, invitation_token: str): + super().__init__( + message="Team invitation has expired", + error_code="TEAM_INVITATION_EXPIRED", + details={"invitation_token": invitation_token}, + ) + + +class TeamInvitationAlreadyAcceptedException(ConflictException): + """Raised when trying to accept an already accepted invitation.""" + + def __init__(self, invitation_token: str): + super().__init__( + message="Team invitation has already been accepted", + error_code="TEAM_INVITATION_ALREADY_ACCEPTED", + details={"invitation_token": invitation_token}, + ) + + +class UnauthorizedTeamActionException(AuthorizationException): + """Raised when user tries to perform team action without permission.""" + + def __init__( + self, + action: str, + user_id: int | None = None, + required_permission: str | None = None, + ): + details = {"action": action} + if user_id: + details["user_id"] = user_id + if required_permission: + details["required_permission"] = required_permission + + super().__init__( + message=f"Unauthorized to perform action: {action}", + error_code="UNAUTHORIZED_TEAM_ACTION", + details=details, + ) + + +class CannotRemoveOwnerException(BusinessLogicException): + """Raised when trying to remove the vendor owner from team.""" + + def __init__(self, user_id: int, vendor_id: int): + super().__init__( + message="Cannot remove vendor owner from team", + error_code="CANNOT_REMOVE_OWNER", + details={ + "user_id": user_id, + "vendor_id": vendor_id, + }, + ) + + +class CannotModifyOwnRoleException(BusinessLogicException): + """Raised when user tries to modify their own role.""" + + def __init__(self, user_id: int): + super().__init__( + message="Cannot modify your own role", + error_code="CANNOT_MODIFY_OWN_ROLE", + details={"user_id": user_id}, + ) + + +class RoleNotFoundException(ResourceNotFoundException): + """Raised when a role is not found.""" + + def __init__(self, role_id: int, vendor_id: int | None = None): + details = {"role_id": role_id} + if vendor_id: + details["vendor_id"] = vendor_id + message = f"Role with ID '{role_id}' not found in vendor {vendor_id}" + else: + message = f"Role with ID '{role_id}' not found" + + super().__init__( + resource_type="Role", + identifier=str(role_id), + message=message, + error_code="ROLE_NOT_FOUND", + ) + self.details.update(details) + + +class InvalidRoleException(ValidationException): + """Raised when role data is invalid.""" + + def __init__( + self, + message: str = "Invalid role data", + field: str | None = None, + details: dict[str, Any] | None = None, + ): + super().__init__( + message=message, + field=field, + details=details, + ) + self.error_code = "INVALID_ROLE_DATA" + + +class InsufficientTeamPermissionsException(AuthorizationException): + """Raised when user lacks required team permissions for an action.""" + + def __init__( + self, + required_permission: str, + user_id: int | None = None, + action: str | None = None, + ): + details = {"required_permission": required_permission} + if user_id: + details["user_id"] = user_id + if action: + details["action"] = action + + message = f"Insufficient team permissions. Required: {required_permission}" + + super().__init__( + message=message, + error_code="INSUFFICIENT_TEAM_PERMISSIONS", + details=details, + ) + + +class MaxTeamMembersReachedException(BusinessLogicException): + """Raised when vendor has reached maximum team members limit.""" + + def __init__(self, max_members: int, vendor_id: int): + super().__init__( + message=f"Maximum number of team members reached ({max_members})", + error_code="MAX_TEAM_MEMBERS_REACHED", + details={ + "max_members": max_members, + "vendor_id": vendor_id, + }, + ) + + +class TeamValidationException(ValidationException): + """Raised when team operation validation fails.""" + + def __init__( + self, + message: str = "Team operation validation failed", + field: str | None = None, + validation_errors: dict[str, str] | None = None, + ): + details = {} + if validation_errors: + details["validation_errors"] = validation_errors + + super().__init__( + message=message, + field=field, + details=details, + ) + self.error_code = "TEAM_VALIDATION_FAILED" + + +class InvalidInvitationDataException(ValidationException): + """Raised when team invitation data is invalid.""" + + def __init__( + self, + message: str = "Invalid invitation data", + field: str | None = None, + details: dict[str, Any] | None = None, + ): + super().__init__( + message=message, + field=field, + details=details, + ) + self.error_code = "INVALID_INVITATION_DATA" + + +class InvalidInvitationTokenException(ValidationException): + """Raised when invitation token is invalid, expired, or already used.""" + + def __init__( + self, + message: str = "Invalid or expired invitation token", + invitation_token: str | None = None, + ): + details = {} + if invitation_token: + details["invitation_token"] = invitation_token + + super().__init__( + message=message, + field="invitation_token", + details=details, + ) + self.error_code = "INVALID_INVITATION_TOKEN" + + +# ============================================================================= +# Vendor Domain Exceptions +# ============================================================================= + + +class VendorDomainNotFoundException(ResourceNotFoundException): + """Raised when a vendor domain is not found.""" + + def __init__(self, domain_identifier: str, identifier_type: str = "ID"): + if identifier_type.lower() == "domain": + message = f"Domain '{domain_identifier}' not found" + else: + message = f"Domain with ID '{domain_identifier}' not found" + + super().__init__( + resource_type="VendorDomain", + identifier=domain_identifier, + message=message, + error_code="VENDOR_DOMAIN_NOT_FOUND", + ) + + +class VendorDomainAlreadyExistsException(ConflictException): + """Raised when trying to add a domain that already exists.""" + + def __init__(self, domain: str, existing_vendor_id: int | None = None): + details = {"domain": domain} + if existing_vendor_id: + details["existing_vendor_id"] = existing_vendor_id + + super().__init__( + message=f"Domain '{domain}' is already registered", + error_code="VENDOR_DOMAIN_ALREADY_EXISTS", + details=details, + ) + + +class InvalidDomainFormatException(ValidationException): + """Raised when domain format is invalid.""" + + def __init__(self, domain: str, reason: str = "Invalid domain format"): + super().__init__( + message=f"{reason}: {domain}", + field="domain", + details={"domain": domain, "reason": reason}, + ) + self.error_code = "INVALID_DOMAIN_FORMAT" + + +class ReservedDomainException(ValidationException): + """Raised when trying to use a reserved domain.""" + + def __init__(self, domain: str, reserved_part: str): + super().__init__( + message=f"Domain cannot use reserved subdomain: {reserved_part}", + field="domain", + details={"domain": domain, "reserved_part": reserved_part}, + ) + self.error_code = "RESERVED_DOMAIN" + + +class DomainNotVerifiedException(BusinessLogicException): + """Raised when trying to activate an unverified domain.""" + + def __init__(self, domain_id: int, domain: str): + super().__init__( + message=f"Domain '{domain}' must be verified before activation", + error_code="DOMAIN_NOT_VERIFIED", + details={"domain_id": domain_id, "domain": domain}, + ) + + +class DomainVerificationFailedException(BusinessLogicException): + """Raised when domain verification fails.""" + + def __init__(self, domain: str, reason: str): + super().__init__( + message=f"Domain verification failed for '{domain}': {reason}", + error_code="DOMAIN_VERIFICATION_FAILED", + details={"domain": domain, "reason": reason}, + ) + + +class DomainAlreadyVerifiedException(BusinessLogicException): + """Raised when trying to verify an already verified domain.""" + + def __init__(self, domain_id: int, domain: str): + super().__init__( + message=f"Domain '{domain}' is already verified", + error_code="DOMAIN_ALREADY_VERIFIED", + details={"domain_id": domain_id, "domain": domain}, + ) + + +class MultiplePrimaryDomainsException(BusinessLogicException): + """Raised when trying to set multiple primary domains.""" + + def __init__(self, vendor_id: int): + super().__init__( + message="Vendor can only have one primary domain", + error_code="MULTIPLE_PRIMARY_DOMAINS", + details={"vendor_id": vendor_id}, + ) + + +class DNSVerificationException(ExternalServiceException): + """Raised when DNS verification service fails.""" + + def __init__(self, domain: str, reason: str): + super().__init__( + service_name="DNS", + message=f"DNS verification failed for '{domain}': {reason}", + error_code="DNS_VERIFICATION_ERROR", + details={"domain": domain, "reason": reason}, + ) + + +class MaxDomainsReachedException(BusinessLogicException): + """Raised when vendor tries to add more domains than allowed.""" + + def __init__(self, vendor_id: int, max_domains: int): + super().__init__( + message=f"Maximum number of domains reached ({max_domains})", + error_code="MAX_DOMAINS_REACHED", + details={"vendor_id": vendor_id, "max_domains": max_domains}, + ) + + +class UnauthorizedDomainAccessException(BusinessLogicException): + """Raised when trying to access domain that doesn't belong to vendor.""" + + def __init__(self, domain_id: int, vendor_id: int): + super().__init__( + message=f"Unauthorized access to domain {domain_id}", + error_code="UNAUTHORIZED_DOMAIN_ACCESS", + details={"domain_id": domain_id, "vendor_id": vendor_id}, + ) + + +__all__ = [ + # Auth + "InvalidCredentialsException", + "TokenExpiredException", + "InvalidTokenException", + "InsufficientPermissionsException", + "UserNotActiveException", + "AdminRequiredException", + "UserAlreadyExistsException", + # Platform + "PlatformNotFoundException", + "PlatformInactiveException", + "PlatformUpdateException", + # Vendor + "VendorNotFoundException", + "VendorAlreadyExistsException", + "VendorNotActiveException", + "VendorNotVerifiedException", + "UnauthorizedVendorAccessException", + "InvalidVendorDataException", + "VendorValidationException", + "MaxVendorsReachedException", + "VendorAccessDeniedException", + "VendorOwnerOnlyException", + "InsufficientVendorPermissionsException", + # Company + "CompanyNotFoundException", + "CompanyAlreadyExistsException", + "CompanyNotActiveException", + "CompanyNotVerifiedException", + "UnauthorizedCompanyAccessException", + "InvalidCompanyDataException", + "CompanyValidationException", + "CompanyHasVendorsException", + # Admin User + "UserNotFoundException", + "UserStatusChangeException", + "AdminOperationException", + "CannotModifyAdminException", + "CannotModifySelfException", + "InvalidAdminActionException", + "BulkOperationException", + "ConfirmationRequiredException", + "VendorVerificationException", + "UserCannotBeDeletedException", + "UserRoleChangeException", + # Team + "TeamMemberNotFoundException", + "TeamMemberAlreadyExistsException", + "TeamInvitationNotFoundException", + "TeamInvitationExpiredException", + "TeamInvitationAlreadyAcceptedException", + "UnauthorizedTeamActionException", + "CannotRemoveOwnerException", + "CannotModifyOwnRoleException", + "RoleNotFoundException", + "InvalidRoleException", + "InsufficientTeamPermissionsException", + "MaxTeamMembersReachedException", + "TeamValidationException", + "InvalidInvitationDataException", + "InvalidInvitationTokenException", + # Vendor Domain + "VendorDomainNotFoundException", + "VendorDomainAlreadyExistsException", + "InvalidDomainFormatException", + "ReservedDomainException", + "DomainNotVerifiedException", + "DomainVerificationFailedException", + "DomainAlreadyVerifiedException", + "MultiplePrimaryDomainsException", + "DNSVerificationException", + "MaxDomainsReachedException", + "UnauthorizedDomainAccessException", +] diff --git a/app/modules/tenancy/routes/api/__init__.py b/app/modules/tenancy/routes/api/__init__.py index d7009d7a..8c305b62 100644 --- a/app/modules/tenancy/routes/api/__init__.py +++ b/app/modules/tenancy/routes/api/__init__.py @@ -2,6 +2,15 @@ """ Tenancy module API routes. +Admin routes: +- /auth/* - Admin authentication (login, logout, /me, platform selection) +- /admin-users/* - Admin user management +- /users/* - Platform user management +- /companies/* - Company management +- /platforms/* - Platform management +- /vendors/* - Vendor management +- /vendor-domains/* - Vendor domain configuration + Vendor routes: - /info/{vendor_code} - Public vendor info lookup - /auth/* - Vendor authentication (login, logout, /me) @@ -9,12 +18,14 @@ Vendor routes: - /team/* - Team member management, roles, permissions """ +from .admin import admin_router from .vendor import vendor_router from .vendor_auth import vendor_auth_router from .vendor_profile import vendor_profile_router from .vendor_team import vendor_team_router __all__ = [ + "admin_router", "vendor_router", "vendor_auth_router", "vendor_profile_router", diff --git a/app/modules/tenancy/routes/api/admin.py b/app/modules/tenancy/routes/api/admin.py new file mode 100644 index 00000000..e7d61bea --- /dev/null +++ b/app/modules/tenancy/routes/api/admin.py @@ -0,0 +1,36 @@ +# app/modules/tenancy/routes/api/admin.py +""" +Tenancy module admin API routes. + +Aggregates all admin tenancy routes: +- /auth/* - Admin authentication (login, logout, /me, platform selection) +- /admin-users/* - Admin user management (super admin only) +- /users/* - Platform user management +- /companies/* - Company management +- /platforms/* - Platform management (super admin only) +- /vendors/* - Vendor management +- /vendor-domains/* - Vendor domain configuration + +The tenancy module owns identity and organizational hierarchy. +""" + +from fastapi import APIRouter + +from .admin_auth import admin_auth_router +from .admin_users import admin_users_router +from .admin_platform_users import admin_platform_users_router +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 + +admin_router = APIRouter() + +# Aggregate all tenancy admin routes +admin_router.include_router(admin_auth_router, tags=["admin-auth"]) +admin_router.include_router(admin_users_router, tags=["admin-admin-users"]) +admin_router.include_router(admin_platform_users_router, tags=["admin-users"]) +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"]) diff --git a/app/api/v1/admin/auth.py b/app/modules/tenancy/routes/api/admin_auth.py similarity index 91% rename from app/api/v1/admin/auth.py rename to app/modules/tenancy/routes/api/admin_auth.py index 8e5616df..460056e8 100644 --- a/app/api/v1/admin/auth.py +++ b/app/modules/tenancy/routes/api/admin_auth.py @@ -1,4 +1,4 @@ -# app/api/v1/admin/auth.py +# app/modules/tenancy/routes/api/admin_auth.py """ Admin authentication endpoints. @@ -17,19 +17,19 @@ from sqlalchemy.orm import Session from app.api.deps import get_current_admin_api, get_current_admin_from_cookie_or_header from app.core.database import get_db from app.core.environment import should_use_secure_cookies -from app.exceptions import InsufficientPermissionsException, InvalidCredentialsException -from app.services.admin_platform_service import admin_platform_service -from app.services.auth_service import auth_service +from app.modules.tenancy.exceptions import InsufficientPermissionsException, InvalidCredentialsException +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 models.schema.auth import UserContext from models.schema.auth import LoginResponse, LogoutResponse, UserLogin, UserResponse -router = APIRouter(prefix="/auth") +admin_auth_router = APIRouter(prefix="/auth") logger = logging.getLogger(__name__) -@router.post("/login", response_model=LoginResponse) +@admin_auth_router.post("/login", response_model=LoginResponse) def admin_login( user_credentials: UserLogin, response: Response, db: Session = Depends(get_db) ): @@ -84,7 +84,7 @@ def admin_login( ) -@router.get("/me", response_model=UserResponse) +@admin_auth_router.get("/me", response_model=UserResponse) def get_current_admin(current_user: UserContext = Depends(get_current_admin_api)): """ Get current authenticated admin user. @@ -100,7 +100,7 @@ def get_current_admin(current_user: UserContext = Depends(get_current_admin_api) return current_user -@router.post("/logout", response_model=LogoutResponse) +@admin_auth_router.post("/logout", response_model=LogoutResponse) def admin_logout(response: Response): """ Admin logout endpoint. @@ -128,7 +128,7 @@ def admin_logout(response: Response): return LogoutResponse(message="Logged out successfully") -@router.get("/accessible-platforms") +@admin_auth_router.get("/accessible-platforms") def get_accessible_platforms( db: Session = Depends(get_db), current_user: UserContext = Depends(get_current_admin_from_cookie_or_header), @@ -160,7 +160,7 @@ def get_accessible_platforms( } -@router.post("/select-platform") +@admin_auth_router.post("/select-platform") def select_platform( platform_id: int, response: Response, diff --git a/app/api/v1/admin/companies.py b/app/modules/tenancy/routes/api/admin_companies.py similarity index 93% rename from app/api/v1/admin/companies.py rename to app/modules/tenancy/routes/api/admin_companies.py index 0ce1dc7c..f5c03249 100644 --- a/app/api/v1/admin/companies.py +++ b/app/modules/tenancy/routes/api/admin_companies.py @@ -1,4 +1,4 @@ -# app/api/v1/admin/companies.py +# app/modules/tenancy/routes/api/admin_companies.py """ Company management endpoints for admin. """ @@ -11,8 +11,8 @@ from sqlalchemy.orm import Session from app.api.deps import get_current_admin_api from app.core.database import get_db -from app.exceptions import CompanyHasVendorsException, ConfirmationRequiredException -from app.services.company_service import company_service +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 ( CompanyCreate, @@ -25,11 +25,11 @@ from models.schema.company import ( CompanyUpdate, ) -router = APIRouter(prefix="/companies") +admin_companies_router = APIRouter(prefix="/companies") logger = logging.getLogger(__name__) -@router.post("", response_model=CompanyCreateResponse) +@admin_companies_router.post("", response_model=CompanyCreateResponse) def create_company_with_owner( company_data: CompanyCreate, db: Session = Depends(get_db), @@ -79,7 +79,7 @@ def create_company_with_owner( ) -@router.get("", response_model=CompanyListResponse) +@admin_companies_router.get("", response_model=CompanyListResponse) def get_all_companies( skip: int = Query(0, ge=0), limit: int = Query(100, ge=1, le=1000), @@ -124,7 +124,7 @@ def get_all_companies( ) -@router.get("/{company_id}", response_model=CompanyDetailResponse) +@admin_companies_router.get("/{company_id}", response_model=CompanyDetailResponse) def get_company_details( company_id: int = Path(..., description="Company ID"), db: Session = Depends(get_db), @@ -174,7 +174,7 @@ def get_company_details( ) -@router.put("/{company_id}", response_model=CompanyResponse) +@admin_companies_router.put("/{company_id}", response_model=CompanyResponse) def update_company( company_id: int = Path(..., description="Company ID"), company_update: CompanyUpdate = Body(...), @@ -213,7 +213,7 @@ def update_company( ) -@router.put("/{company_id}/verification", response_model=CompanyResponse) +@admin_companies_router.put("/{company_id}/verification", response_model=CompanyResponse) def toggle_company_verification( company_id: int = Path(..., description="Company ID"), verification_data: dict = Body(..., example={"is_verified": True}), @@ -246,7 +246,7 @@ def toggle_company_verification( ) -@router.put("/{company_id}/status", response_model=CompanyResponse) +@admin_companies_router.put("/{company_id}/status", response_model=CompanyResponse) def toggle_company_status( company_id: int = Path(..., description="Company ID"), status_data: dict = Body(..., example={"is_active": True}), @@ -279,7 +279,7 @@ def toggle_company_status( ) -@router.post( +@admin_companies_router.post( "/{company_id}/transfer-ownership", response_model=CompanyTransferOwnershipResponse, ) @@ -328,7 +328,7 @@ def transfer_company_ownership( ) -@router.delete("/{company_id}") +@admin_companies_router.delete("/{company_id}") def delete_company( company_id: int = Path(..., description="Company ID"), confirm: bool = Query(False, description="Must be true to confirm deletion"), diff --git a/app/api/v1/admin/users.py b/app/modules/tenancy/routes/api/admin_platform_users.py similarity index 88% rename from app/api/v1/admin/users.py rename to app/modules/tenancy/routes/api/admin_platform_users.py index 222a1c48..c9a7c258 100644 --- a/app/api/v1/admin/users.py +++ b/app/modules/tenancy/routes/api/admin_platform_users.py @@ -1,4 +1,4 @@ -# app/api/v1/admin/users.py +# app/modules/tenancy/routes/api/admin_platform_users.py """ User management endpoints for admin. @@ -14,8 +14,8 @@ from sqlalchemy.orm import Session from app.api.deps import get_current_admin_api from app.core.database import get_db -from app.services.admin_service import admin_service -from app.services.stats_service import stats_service +from app.modules.tenancy.services.admin_service import admin_service +from app.modules.analytics.services.stats_service import stats_service from models.schema.auth import UserContext from models.schema.auth import ( UserCreate, @@ -28,11 +28,11 @@ from models.schema.auth import ( UserUpdate, ) -router = APIRouter(prefix="/users") +admin_platform_users_router = APIRouter(prefix="/users") logger = logging.getLogger(__name__) -@router.get("", response_model=UserListResponse) +@admin_platform_users_router.get("", response_model=UserListResponse) def get_all_users( page: int = Query(1, ge=1), per_page: int = Query(10, ge=1, le=100), @@ -66,7 +66,7 @@ def get_all_users( ) -@router.post("", response_model=UserDetailResponse) +@admin_platform_users_router.post("", response_model=UserDetailResponse) def create_user( user_data: UserCreate = Body(...), db: Session = Depends(get_db), @@ -105,7 +105,7 @@ def create_user( ) -@router.get("/stats") +@admin_platform_users_router.get("/stats") def get_user_statistics( db: Session = Depends(get_db), current_admin: UserContext = Depends(get_current_admin_api), @@ -114,7 +114,7 @@ def get_user_statistics( return stats_service.get_user_statistics(db) -@router.get("/search", response_model=UserSearchResponse) +@admin_platform_users_router.get("/search", response_model=UserSearchResponse) def search_users( q: str = Query(..., min_length=2, description="Search query (username or email)"), limit: int = Query(10, ge=1, le=50), @@ -130,7 +130,7 @@ def search_users( return UserSearchResponse(users=users) -@router.get("/{user_id}", response_model=UserDetailResponse) +@admin_platform_users_router.get("/{user_id}", response_model=UserDetailResponse) def get_user_details( user_id: int = Path(..., description="User ID"), db: Session = Depends(get_db), @@ -159,7 +159,7 @@ def get_user_details( ) -@router.put("/{user_id}", response_model=UserDetailResponse) +@admin_platform_users_router.put("/{user_id}", response_model=UserDetailResponse) def update_user( user_id: int = Path(..., description="User ID"), user_update: UserUpdate = Body(...), @@ -202,7 +202,7 @@ def update_user( ) -@router.put("/{user_id}/status", response_model=UserStatusToggleResponse) +@admin_platform_users_router.put("/{user_id}/status", response_model=UserStatusToggleResponse) def toggle_user_status( user_id: int = Path(..., description="User ID"), db: Session = Depends(get_db), @@ -219,7 +219,7 @@ def toggle_user_status( return UserStatusToggleResponse(message=message, is_active=user.is_active) -@router.delete("/{user_id}", response_model=UserDeleteResponse) +@admin_platform_users_router.delete("/{user_id}", response_model=UserDeleteResponse) def delete_user( user_id: int = Path(..., description="User ID"), db: Session = Depends(get_db), diff --git a/app/api/v1/admin/platforms.py b/app/modules/tenancy/routes/api/admin_platforms.py similarity index 93% rename from app/api/v1/admin/platforms.py rename to app/modules/tenancy/routes/api/admin_platforms.py index 1506dc44..26fc8c89 100644 --- a/app/api/v1/admin/platforms.py +++ b/app/modules/tenancy/routes/api/admin_platforms.py @@ -1,4 +1,4 @@ -# app/api/v1/admin/platforms.py +# app/modules/tenancy/routes/api/admin_platforms.py """ Admin API endpoints for Platform management (Multi-Platform CMS). @@ -22,11 +22,11 @@ from pydantic import BaseModel, Field from sqlalchemy.orm import Session from app.api.deps import get_current_admin_from_cookie_or_header, get_db -from app.services.platform_service import platform_service +from app.modules.tenancy.services.platform_service import platform_service from models.schema.auth import UserContext logger = logging.getLogger(__name__) -router = APIRouter(prefix="/platforms") +admin_platforms_router = APIRouter(prefix="/platforms") # ============================================================================= @@ -139,7 +139,7 @@ def _build_platform_response(db: Session, platform) -> PlatformResponse: # ============================================================================= -@router.get("", response_model=PlatformListResponse) +@admin_platforms_router.get("", response_model=PlatformListResponse) async def list_platforms( db: Session = Depends(get_db), current_user: UserContext = Depends(get_current_admin_from_cookie_or_header), @@ -159,7 +159,7 @@ async def list_platforms( return PlatformListResponse(platforms=result, total=len(result)) -@router.get("/{code}", response_model=PlatformResponse) +@admin_platforms_router.get("/{code}", response_model=PlatformResponse) async def get_platform( code: str = Path(..., description="Platform code (oms, loyalty, etc.)"), db: Session = Depends(get_db), @@ -174,7 +174,7 @@ async def get_platform( return _build_platform_response(db, platform) -@router.put("/{code}", response_model=PlatformResponse) +@admin_platforms_router.put("/{code}", response_model=PlatformResponse) async def update_platform( update_data: PlatformUpdateRequest, code: str = Path(..., description="Platform code"), @@ -197,7 +197,7 @@ async def update_platform( return _build_platform_response(db, platform) -@router.get("/{code}/stats", response_model=PlatformStatsResponse) +@admin_platforms_router.get("/{code}/stats", response_model=PlatformStatsResponse) async def get_platform_stats( code: str = Path(..., description="Platform code"), db: Session = Depends(get_db), diff --git a/app/api/v1/admin/admin_users.py b/app/modules/tenancy/routes/api/admin_users.py similarity index 93% rename from app/api/v1/admin/admin_users.py rename to app/modules/tenancy/routes/api/admin_users.py index 8963a06f..56b701b7 100644 --- a/app/api/v1/admin/admin_users.py +++ b/app/modules/tenancy/routes/api/admin_users.py @@ -1,4 +1,4 @@ -# app/api/v1/admin/admin_users.py +# app/modules/tenancy/routes/api/admin_users.py """ Admin user management endpoints (Super Admin only). @@ -22,11 +22,11 @@ from sqlalchemy.orm import Session 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.services.admin_platform_service import admin_platform_service +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 models.schema.auth import UserContext -router = APIRouter(prefix="/admin-users") +admin_users_router = APIRouter(prefix="/admin-users") logger = logging.getLogger(__name__) @@ -137,7 +137,7 @@ def _build_admin_response(admin: User) -> AdminUserResponse: # ============================================================================ -@router.get("", response_model=AdminUserListResponse) +@admin_users_router.get("", response_model=AdminUserListResponse) def list_admin_users( skip: int = Query(0, ge=0), limit: int = Query(100, ge=1, le=500), @@ -162,7 +162,7 @@ def list_admin_users( return AdminUserListResponse(admins=admin_responses, total=total) -@router.post("", response_model=AdminUserResponse) +@admin_users_router.post("", response_model=AdminUserResponse) def create_admin_user( request: CreateAdminUserRequest, db: Session = Depends(get_db), @@ -222,7 +222,7 @@ def create_admin_user( return _build_admin_response(user) -@router.get("/{user_id}", response_model=AdminUserResponse) +@admin_users_router.get("/{user_id}", response_model=AdminUserResponse) def get_admin_user( user_id: int = Path(...), db: Session = Depends(get_db), @@ -237,7 +237,7 @@ def get_admin_user( return _build_admin_response(admin) -@router.post("/{user_id}/platforms/{platform_id}") +@admin_users_router.post("/{user_id}/platforms/{platform_id}") def assign_admin_to_platform( user_id: int = Path(...), platform_id: int = Path(...), @@ -264,7 +264,7 @@ def assign_admin_to_platform( } -@router.delete("/{user_id}/platforms/{platform_id}") +@admin_users_router.delete("/{user_id}/platforms/{platform_id}") def remove_admin_from_platform( user_id: int = Path(...), platform_id: int = Path(...), @@ -291,7 +291,7 @@ def remove_admin_from_platform( } -@router.put("/{user_id}/super-admin") +@admin_users_router.put("/{user_id}/super-admin") def toggle_super_admin_status( user_id: int = Path(...), request: ToggleSuperAdminRequest = Body(...), @@ -320,7 +320,7 @@ def toggle_super_admin_status( } -@router.get("/{user_id}/platforms") +@admin_users_router.get("/{user_id}/platforms") def get_admin_platforms( user_id: int = Path(...), db: Session = Depends(get_db), @@ -346,7 +346,7 @@ def get_admin_platforms( } -@router.put("/{user_id}/status") +@admin_users_router.put("/{user_id}/status") def toggle_admin_status( user_id: int = Path(...), db: Session = Depends(get_db), @@ -373,7 +373,7 @@ def toggle_admin_status( } -@router.delete("/{user_id}") +@admin_users_router.delete("/{user_id}") def delete_admin_user( user_id: int = Path(...), db: Session = Depends(get_db), diff --git a/app/api/v1/admin/vendor_domains.py b/app/modules/tenancy/routes/api/admin_vendor_domains.py similarity index 90% rename from app/api/v1/admin/vendor_domains.py rename to app/modules/tenancy/routes/api/admin_vendor_domains.py index c866f1df..fed5515b 100644 --- a/app/api/v1/admin/vendor_domains.py +++ b/app/modules/tenancy/routes/api/admin_vendor_domains.py @@ -1,4 +1,4 @@ -# app/api/v1/admin/vendor_domains.py +# app/modules/tenancy/routes/api/admin_vendor_domains.py """ Admin endpoints for managing vendor custom domains. @@ -16,8 +16,8 @@ from sqlalchemy.orm import Session from app.api.deps import get_current_admin_api from app.core.database import get_db -from app.services.vendor_domain_service import vendor_domain_service -from app.services.vendor_service import vendor_service +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 ( DomainDeletionResponse, @@ -29,11 +29,11 @@ from models.schema.vendor_domain import ( VendorDomainUpdate, ) -router = APIRouter(prefix="/vendors") +admin_vendor_domains_router = APIRouter(prefix="/vendors") logger = logging.getLogger(__name__) -@router.post("/{vendor_id}/domains", response_model=VendorDomainResponse) +@admin_vendor_domains_router.post("/{vendor_id}/domains", response_model=VendorDomainResponse) def add_vendor_domain( vendor_id: int = Path(..., description="Vendor ID", gt=0), domain_data: VendorDomainCreate = Body(...), @@ -86,7 +86,7 @@ def add_vendor_domain( ) -@router.get("/{vendor_id}/domains", response_model=VendorDomainListResponse) +@admin_vendor_domains_router.get("/{vendor_id}/domains", response_model=VendorDomainListResponse) def list_vendor_domains( vendor_id: int = Path(..., description="Vendor ID", gt=0), db: Session = Depends(get_db), @@ -129,7 +129,7 @@ def list_vendor_domains( ) -@router.get("/domains/{domain_id}", response_model=VendorDomainResponse) +@admin_vendor_domains_router.get("/domains/{domain_id}", response_model=VendorDomainResponse) def get_domain_details( domain_id: int = Path(..., description="Domain ID", gt=0), db: Session = Depends(get_db), @@ -161,7 +161,7 @@ def get_domain_details( ) -@router.put("/domains/{domain_id}", response_model=VendorDomainResponse) +@admin_vendor_domains_router.put("/domains/{domain_id}", response_model=VendorDomainResponse) def update_vendor_domain( domain_id: int = Path(..., description="Domain ID", gt=0), domain_update: VendorDomainUpdate = Body(...), @@ -205,7 +205,7 @@ def update_vendor_domain( ) -@router.delete("/domains/{domain_id}", response_model=DomainDeletionResponse) +@admin_vendor_domains_router.delete("/domains/{domain_id}", response_model=DomainDeletionResponse) def delete_vendor_domain( domain_id: int = Path(..., description="Domain ID", gt=0), db: Session = Depends(get_db), @@ -233,7 +233,7 @@ def delete_vendor_domain( ) -@router.post("/domains/{domain_id}/verify", response_model=DomainVerificationResponse) +@admin_vendor_domains_router.post("/domains/{domain_id}/verify", response_model=DomainVerificationResponse) def verify_domain_ownership( domain_id: int = Path(..., description="Domain ID", gt=0), db: Session = Depends(get_db), @@ -272,7 +272,7 @@ def verify_domain_ownership( ) -@router.get( +@admin_vendor_domains_router.get( "/domains/{domain_id}/verification-instructions", response_model=DomainVerificationInstructions, ) diff --git a/app/api/v1/admin/vendors.py b/app/modules/tenancy/routes/api/admin_vendors.py similarity index 93% rename from app/api/v1/admin/vendors.py rename to app/modules/tenancy/routes/api/admin_vendors.py index 2d229977..78920f3f 100644 --- a/app/api/v1/admin/vendors.py +++ b/app/modules/tenancy/routes/api/admin_vendors.py @@ -1,4 +1,4 @@ -# app/api/v1/admin/vendors.py +# app/modules/tenancy/routes/api/admin_vendors.py """ Vendor management endpoints for admin. @@ -15,10 +15,10 @@ from sqlalchemy.orm import Session from app.api.deps import get_current_admin_api from app.core.database import get_db -from app.exceptions import ConfirmationRequiredException -from app.services.admin_service import admin_service -from app.services.stats_service import stats_service -from app.services.vendor_service import vendor_service +from app.modules.tenancy.exceptions import ConfirmationRequiredException +from app.modules.tenancy.services.admin_service import admin_service +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 ( @@ -31,11 +31,11 @@ from models.schema.vendor import ( VendorUpdate, ) -router = APIRouter(prefix="/vendors") +admin_vendors_router = APIRouter(prefix="/vendors") logger = logging.getLogger(__name__) -@router.post("", response_model=VendorCreateResponse) +@admin_vendors_router.post("", response_model=VendorCreateResponse) def create_vendor( vendor_data: VendorCreate, db: Session = Depends(get_db), @@ -81,7 +81,7 @@ def create_vendor( ) -@router.get("", response_model=VendorListResponse) +@admin_vendors_router.get("", response_model=VendorListResponse) def get_all_vendors_admin( skip: int = Query(0, ge=0), limit: int = Query(100, ge=1, le=1000), @@ -103,7 +103,7 @@ def get_all_vendors_admin( return VendorListResponse(vendors=vendors, total=total, skip=skip, limit=limit) -@router.get("/stats", response_model=VendorStatsResponse) +@admin_vendors_router.get("/stats", response_model=VendorStatsResponse) def get_vendor_statistics_endpoint( db: Session = Depends(get_db), current_admin: UserContext = Depends(get_current_admin_api), @@ -160,7 +160,7 @@ def _build_vendor_detail_response(vendor) -> VendorDetailResponse: ) -@router.get("/{vendor_identifier}", response_model=VendorDetailResponse) +@admin_vendors_router.get("/{vendor_identifier}", response_model=VendorDetailResponse) def get_vendor_details( vendor_identifier: str = Path(..., description="Vendor ID or vendor_code"), db: Session = Depends(get_db), @@ -181,7 +181,7 @@ def get_vendor_details( return _build_vendor_detail_response(vendor) -@router.put("/{vendor_identifier}", response_model=VendorDetailResponse) +@admin_vendors_router.put("/{vendor_identifier}", response_model=VendorDetailResponse) def update_vendor( vendor_identifier: str = Path(..., description="Vendor ID or vendor_code"), vendor_update: VendorUpdate = Body(...), @@ -217,7 +217,7 @@ def update_vendor( # This endpoint is kept for backwards compatibility but may be removed in future versions. -@router.put("/{vendor_identifier}/verification", response_model=VendorDetailResponse) +@admin_vendors_router.put("/{vendor_identifier}/verification", response_model=VendorDetailResponse) def toggle_vendor_verification( vendor_identifier: str = Path(..., description="Vendor ID or vendor_code"), verification_data: dict = Body(..., example={"is_verified": True}), @@ -246,7 +246,7 @@ def toggle_vendor_verification( return _build_vendor_detail_response(vendor) -@router.put("/{vendor_identifier}/status", response_model=VendorDetailResponse) +@admin_vendors_router.put("/{vendor_identifier}/status", response_model=VendorDetailResponse) def toggle_vendor_status( vendor_identifier: str = Path(..., description="Vendor ID or vendor_code"), status_data: dict = Body(..., example={"is_active": True}), @@ -275,7 +275,7 @@ def toggle_vendor_status( return _build_vendor_detail_response(vendor) -@router.delete("/{vendor_identifier}") +@admin_vendors_router.delete("/{vendor_identifier}") def delete_vendor( vendor_identifier: str = Path(..., description="Vendor ID or vendor_code"), confirm: bool = Query(False, description="Must be true to confirm deletion"), @@ -317,7 +317,7 @@ def delete_vendor( # ============================================================================ -@router.get("/{vendor_identifier}/export/letzshop") +@admin_vendors_router.get("/{vendor_identifier}/export/letzshop") def export_vendor_products_letzshop( vendor_identifier: str = Path(..., description="Vendor ID or vendor_code"), language: str = Query( @@ -367,7 +367,7 @@ def export_vendor_products_letzshop( ) -@router.post("/{vendor_identifier}/export/letzshop", response_model=LetzshopExportResponse) +@admin_vendors_router.post("/{vendor_identifier}/export/letzshop", response_model=LetzshopExportResponse) def export_vendor_products_letzshop_to_folder( vendor_identifier: str = Path(..., description="Vendor ID or vendor_code"), request: LetzshopExportRequest = None, diff --git a/app/modules/tenancy/routes/api/vendor.py b/app/modules/tenancy/routes/api/vendor.py index 38e06b8b..87876531 100644 --- a/app/modules/tenancy/routes/api/vendor.py +++ b/app/modules/tenancy/routes/api/vendor.py @@ -17,7 +17,7 @@ from fastapi import APIRouter, Depends, Path from sqlalchemy.orm import Session from app.core.database import get_db -from app.services.vendor_service import vendor_service # noqa: mod-004 +from app.modules.tenancy.services.vendor_service import vendor_service # noqa: mod-004 from models.schema.vendor import VendorDetailResponse vendor_router = APIRouter() diff --git a/app/modules/tenancy/routes/api/vendor_auth.py b/app/modules/tenancy/routes/api/vendor_auth.py index 0ec9f9f8..9c10ab3c 100644 --- a/app/modules/tenancy/routes/api/vendor_auth.py +++ b/app/modules/tenancy/routes/api/vendor_auth.py @@ -21,8 +21,8 @@ from sqlalchemy.orm import Session from app.api.deps import get_current_vendor_api from app.core.database import get_db from app.core.environment import should_use_secure_cookies -from app.exceptions import InvalidCredentialsException -from app.services.auth_service import auth_service +from app.modules.tenancy.exceptions import InvalidCredentialsException +from app.modules.core.services.auth_service import auth_service from middleware.vendor_context import get_current_vendor from models.schema.auth import UserContext from models.schema.auth import LogoutResponse, UserLogin, VendorUserResponse diff --git a/app/modules/tenancy/routes/api/vendor_profile.py b/app/modules/tenancy/routes/api/vendor_profile.py index 14ff9344..142113a7 100644 --- a/app/modules/tenancy/routes/api/vendor_profile.py +++ b/app/modules/tenancy/routes/api/vendor_profile.py @@ -13,7 +13,7 @@ from sqlalchemy.orm import Session from app.api.deps import get_current_vendor_api from app.core.database import get_db -from app.services.vendor_service import vendor_service +from app.modules.tenancy.services.vendor_service import vendor_service from models.schema.auth import UserContext from models.schema.vendor import VendorResponse, VendorUpdate diff --git a/app/modules/tenancy/routes/api/vendor_team.py b/app/modules/tenancy/routes/api/vendor_team.py index bf9eddae..456d6085 100644 --- a/app/modules/tenancy/routes/api/vendor_team.py +++ b/app/modules/tenancy/routes/api/vendor_team.py @@ -23,7 +23,7 @@ from app.api.deps import ( ) from app.core.database import get_db from app.core.permissions import VendorPermissions -from app.services.vendor_team_service import vendor_team_service +from app.modules.tenancy.services.vendor_team_service import vendor_team_service from models.schema.auth import UserContext from models.schema.team import ( BulkRemoveRequest, @@ -237,7 +237,7 @@ def get_team_member( member = next((m for m in members if m["id"] == user_id), None) if not member: - from app.exceptions import UserNotFoundException + from app.modules.tenancy.exceptions import UserNotFoundException raise UserNotFoundException(str(user_id)) diff --git a/app/modules/tenancy/routes/pages/__init__.py b/app/modules/tenancy/routes/pages/__init__.py new file mode 100644 index 00000000..1cddd587 --- /dev/null +++ b/app/modules/tenancy/routes/pages/__init__.py @@ -0,0 +1,2 @@ +# app/modules/tenancy/routes/pages/__init__.py +"""Tenancy module page routes.""" diff --git a/app/modules/tenancy/routes/pages/admin.py b/app/modules/tenancy/routes/pages/admin.py new file mode 100644 index 00000000..e4fb74ef --- /dev/null +++ b/app/modules/tenancy/routes/pages/admin.py @@ -0,0 +1,548 @@ +# app/modules/tenancy/routes/pages/admin.py +""" +Tenancy Admin Page Routes (HTML rendering). + +Admin pages for multi-tenant management: +- Companies +- Vendors +- Vendor domains +- Vendor themes +- Admin users +- Platforms +""" + +from fastapi import APIRouter, Depends, Path, Request +from fastapi.responses import HTMLResponse, RedirectResponse +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 + +router = APIRouter() + + +# ============================================================================ +# COMPANY MANAGEMENT ROUTES +# ============================================================================ + + +@router.get("/companies", response_class=HTMLResponse, include_in_schema=False) +async def admin_companies_list_page( + request: Request, + current_user: User = Depends(require_menu_access("companies", FrontendType.ADMIN)), + db: Session = Depends(get_db), +): + """ + Render companies management page. + Shows list of all companies with stats. + """ + return templates.TemplateResponse( + "tenancy/admin/companies.html", + get_admin_context(request, current_user), + ) + + +@router.get("/companies/create", response_class=HTMLResponse, include_in_schema=False) +async def admin_company_create_page( + request: Request, + current_user: User = Depends(require_menu_access("companies", FrontendType.ADMIN)), + db: Session = Depends(get_db), +): + """ + Render company creation form. + """ + return templates.TemplateResponse( + "tenancy/admin/company-create.html", + get_admin_context(request, current_user), + ) + + +@router.get( + "/companies/{company_id}", response_class=HTMLResponse, include_in_schema=False +) +async def admin_company_detail_page( + request: Request, + company_id: int = Path(..., description="Company ID"), + current_user: User = Depends(require_menu_access("companies", FrontendType.ADMIN)), + db: Session = Depends(get_db), +): + """ + Render company detail view. + """ + return templates.TemplateResponse( + "tenancy/admin/company-detail.html", + get_admin_context(request, current_user, company_id=company_id), + ) + + +@router.get( + "/companies/{company_id}/edit", + response_class=HTMLResponse, + include_in_schema=False, +) +async def admin_company_edit_page( + request: Request, + company_id: int = Path(..., description="Company ID"), + current_user: User = Depends(require_menu_access("companies", FrontendType.ADMIN)), + db: Session = Depends(get_db), +): + """ + Render company edit form. + """ + return templates.TemplateResponse( + "tenancy/admin/company-edit.html", + get_admin_context(request, current_user, company_id=company_id), + ) + + +# ============================================================================ +# VENDOR MANAGEMENT ROUTES +# ============================================================================ + + +@router.get("/vendors", response_class=HTMLResponse, include_in_schema=False) +async def admin_vendors_list_page( + request: Request, + current_user: User = Depends(require_menu_access("vendors", FrontendType.ADMIN)), + db: Session = Depends(get_db), +): + """ + Render vendors management page. + Shows list of all vendors with stats. + """ + return templates.TemplateResponse( + "tenancy/admin/vendors.html", + get_admin_context(request, current_user), + ) + + +@router.get("/vendors/create", response_class=HTMLResponse, include_in_schema=False) +async def admin_vendor_create_page( + request: Request, + current_user: User = Depends(require_menu_access("vendors", FrontendType.ADMIN)), + db: Session = Depends(get_db), +): + """ + Render vendor creation form. + """ + return templates.TemplateResponse( + "tenancy/admin/vendor-create.html", + get_admin_context(request, current_user), + ) + + +@router.get( + "/vendors/{vendor_code}", response_class=HTMLResponse, include_in_schema=False +) +async def admin_vendor_detail_page( + request: Request, + vendor_code: str = Path(..., description="Vendor code"), + current_user: User = Depends(require_menu_access("vendors", FrontendType.ADMIN)), + db: Session = Depends(get_db), +): + """ + Render vendor detail page. + Shows full vendor information. + """ + return templates.TemplateResponse( + "tenancy/admin/vendor-detail.html", + get_admin_context(request, current_user, vendor_code=vendor_code), + ) + + +@router.get( + "/vendors/{vendor_code}/edit", response_class=HTMLResponse, include_in_schema=False +) +async def admin_vendor_edit_page( + request: Request, + vendor_code: str = Path(..., description="Vendor code"), + current_user: User = Depends(require_menu_access("vendors", FrontendType.ADMIN)), + db: Session = Depends(get_db), +): + """ + Render vendor edit form. + """ + return templates.TemplateResponse( + "tenancy/admin/vendor-edit.html", + get_admin_context(request, current_user, vendor_code=vendor_code), + ) + + +# ============================================================================ +# VENDOR DOMAINS ROUTES +# ============================================================================ + + +@router.get( + "/vendors/{vendor_code}/domains", + response_class=HTMLResponse, + include_in_schema=False, +) +async def admin_vendor_domains_page( + request: Request, + vendor_code: str = Path(..., description="Vendor code"), + current_user: User = Depends(require_menu_access("vendors", FrontendType.ADMIN)), + db: Session = Depends(get_db), +): + """ + Render vendor domains management page. + Shows custom domains, verification status, and DNS configuration. + """ + return templates.TemplateResponse( + "tenancy/admin/vendor-domains.html", + get_admin_context(request, current_user, vendor_code=vendor_code), + ) + + +# ============================================================================ +# VENDOR THEMES ROUTES +# ============================================================================ + + +@router.get("/vendor-themes", response_class=HTMLResponse, include_in_schema=False) +async def admin_vendor_themes_page( + request: Request, + current_user: User = Depends( + require_menu_access("vendor-themes", FrontendType.ADMIN) + ), + db: Session = Depends(get_db), +): + """ + Render vendor themes selection page. + Allows admins to select a vendor to customize their theme. + """ + return templates.TemplateResponse( + "tenancy/admin/vendor-themes.html", + get_admin_context(request, current_user), + ) + + +@router.get( + "/vendors/{vendor_code}/theme", + response_class=HTMLResponse, + include_in_schema=False, +) +async def admin_vendor_theme_page( + request: Request, + vendor_code: str = Path(..., description="Vendor code"), + current_user: User = Depends( + require_menu_access("vendor-themes", FrontendType.ADMIN) + ), + db: Session = Depends(get_db), +): + """ + Render vendor theme customization page. + Allows admins to customize colors, fonts, layout, and branding. + """ + return templates.TemplateResponse( + "tenancy/admin/vendor-theme.html", + get_admin_context(request, current_user, vendor_code=vendor_code), + ) + + +# ============================================================================ +# ADMIN USER MANAGEMENT ROUTES (Super Admin Only) +# ============================================================================ + + +@router.get("/admin-users", response_class=HTMLResponse, include_in_schema=False) +async def admin_users_list_page( + request: Request, + current_user: User = Depends( + require_menu_access("admin-users", FrontendType.ADMIN) + ), + db: Session = Depends(get_db), +): + """ + Render admin users management page. + Shows list of all admin users (super admins and platform admins). + Super admin only (menu is in super_admin_only section). + """ + return templates.TemplateResponse( + "tenancy/admin/admin-users.html", + get_admin_context(request, current_user), + ) + + +@router.get( + "/admin-users/create", response_class=HTMLResponse, include_in_schema=False +) +async def admin_user_create_page( + request: Request, + current_user: User = Depends( + require_menu_access("admin-users", FrontendType.ADMIN) + ), + db: Session = Depends(get_db), +): + """ + Render admin user creation form. + Super admin only (menu is in super_admin_only section). + """ + return templates.TemplateResponse( + "tenancy/admin/user-create.html", + get_admin_context(request, current_user), + ) + + +@router.get( + "/admin-users/{user_id}", response_class=HTMLResponse, include_in_schema=False +) +async def admin_user_detail_page( + request: Request, + user_id: int = Path(..., description="User ID"), + current_user: User = Depends( + require_menu_access("admin-users", FrontendType.ADMIN) + ), + db: Session = Depends(get_db), +): + """ + Render admin user detail view. + Super admin only (menu is in super_admin_only section). + """ + return templates.TemplateResponse( + "tenancy/admin/admin-user-detail.html", + get_admin_context(request, current_user, user_id=user_id), + ) + + +@router.get( + "/admin-users/{user_id}/edit", response_class=HTMLResponse, include_in_schema=False +) +async def admin_user_edit_page( + request: Request, + user_id: int = Path(..., description="User ID"), + current_user: User = Depends( + require_menu_access("admin-users", FrontendType.ADMIN) + ), + db: Session = Depends(get_db), +): + """ + Render admin user edit form. + Super admin only (menu is in super_admin_only section). + """ + return templates.TemplateResponse( + "tenancy/admin/admin-user-edit.html", + get_admin_context(request, current_user, user_id=user_id), + ) + + +# ============================================================================ +# USER MANAGEMENT ROUTES (Legacy - Redirects) +# ============================================================================ + + +@router.get("/users", response_class=RedirectResponse, include_in_schema=False) +async def admin_users_page_redirect(): + """ + Redirect old /admin/users to /admin/admin-users. + """ + return RedirectResponse(url="/admin/admin-users", status_code=302) + + +@router.get("/users/create", response_class=RedirectResponse, include_in_schema=False) +async def admin_user_create_page_redirect(): + """ + Redirect old /admin/users/create to /admin/admin-users/create. + """ + return RedirectResponse(url="/admin/admin-users/create", status_code=302) + + +@router.get( + "/users/{user_id}", response_class=RedirectResponse, include_in_schema=False +) +async def admin_user_detail_page_redirect( + user_id: int = Path(..., description="User ID"), +): + """ + Redirect old /admin/users/{id} to /admin/admin-users/{id}. + """ + return RedirectResponse(url=f"/admin/admin-users/{user_id}", status_code=302) + + +@router.get( + "/users/{user_id}/edit", response_class=RedirectResponse, include_in_schema=False +) +async def admin_user_edit_page_redirect( + user_id: int = Path(..., description="User ID"), +): + """ + Redirect old /admin/users/{id}/edit to /admin/admin-users/{id}/edit. + """ + return RedirectResponse(url=f"/admin/admin-users/{user_id}/edit", status_code=302) + + +# ============================================================================ +# PLATFORM MANAGEMENT ROUTES (Multi-Platform Support) +# ============================================================================ + + +@router.get("/platforms", response_class=HTMLResponse, include_in_schema=False) +async def admin_platforms_list( + request: Request, + current_user: User = Depends(require_menu_access("platforms", FrontendType.ADMIN)), + db: Session = Depends(get_db), +): + """ + Render platforms management page. + Shows all platforms (OMS, Loyalty, etc.) with their configuration. + """ + return templates.TemplateResponse( + "tenancy/admin/platforms.html", + get_admin_context(request, current_user), + ) + + +@router.get( + "/platforms/{platform_code}", response_class=HTMLResponse, include_in_schema=False +) +async def admin_platform_detail( + request: Request, + platform_code: str = Path(..., description="Platform code (oms, loyalty, etc.)"), + current_user: User = Depends(require_menu_access("platforms", FrontendType.ADMIN)), + db: Session = Depends(get_db), +): + """ + Render platform detail page. + Shows platform configuration, marketing pages, and vendor defaults. + """ + return templates.TemplateResponse( + "tenancy/admin/platform-detail.html", + get_admin_context(request, current_user, platform_code=platform_code), + ) + + +@router.get( + "/platforms/{platform_code}/edit", + response_class=HTMLResponse, + include_in_schema=False, +) +async def admin_platform_edit( + request: Request, + platform_code: str = Path(..., description="Platform code"), + current_user: User = Depends(require_menu_access("platforms", FrontendType.ADMIN)), + db: Session = Depends(get_db), +): + """ + Render platform edit form. + Allows editing platform settings, branding, and configuration. + """ + return templates.TemplateResponse( + "tenancy/admin/platform-edit.html", + get_admin_context(request, current_user, platform_code=platform_code), + ) + + +@router.get( + "/platforms/{platform_code}/menu-config", + response_class=HTMLResponse, + include_in_schema=False, +) +async def admin_platform_menu_config( + request: Request, + platform_code: str = Path(..., description="Platform code"), + current_user: User = Depends(require_menu_access("platforms", FrontendType.ADMIN)), + db: Session = Depends(get_db), +): + """ + Render platform menu configuration page. + Super admin only - allows configuring which menu items are visible + for the platform's admin and vendor frontends. + """ + if not current_user.is_super_admin: + return RedirectResponse( + url=f"/admin/platforms/{platform_code}", status_code=302 + ) + + return templates.TemplateResponse( + "tenancy/admin/platform-menu-config.html", + get_admin_context(request, current_user, platform_code=platform_code), + ) + + +@router.get( + "/platforms/{platform_code}/modules", + response_class=HTMLResponse, + include_in_schema=False, +) +async def admin_platform_modules( + request: Request, + platform_code: str = Path(..., description="Platform code"), + current_user: User = Depends(require_menu_access("platforms", FrontendType.ADMIN)), + db: Session = Depends(get_db), +): + """ + Render platform module configuration page. + Super admin only - allows enabling/disabling feature modules + for the platform. + """ + if not current_user.is_super_admin: + return RedirectResponse( + url=f"/admin/platforms/{platform_code}", status_code=302 + ) + + return templates.TemplateResponse( + "tenancy/admin/platform-modules.html", + get_admin_context(request, current_user, platform_code=platform_code), + ) + + +@router.get( + "/platforms/{platform_code}/modules/{module_code}", + response_class=HTMLResponse, + include_in_schema=False, +) +async def admin_module_info( + request: Request, + platform_code: str = Path(..., description="Platform code"), + module_code: str = Path(..., description="Module code"), + current_user: User = Depends(require_menu_access("platforms", FrontendType.ADMIN)), + db: Session = Depends(get_db), +): + """ + Render module info/detail page. + Shows module details including features, menu items, dependencies, + and self-contained module information. + """ + if not current_user.is_super_admin: + return RedirectResponse( + url=f"/admin/platforms/{platform_code}", status_code=302 + ) + + return templates.TemplateResponse( + "tenancy/admin/module-info.html", + get_admin_context( + request, current_user, platform_code=platform_code, module_code=module_code + ), + ) + + +@router.get( + "/platforms/{platform_code}/modules/{module_code}/config", + response_class=HTMLResponse, + include_in_schema=False, +) +async def admin_module_config( + request: Request, + platform_code: str = Path(..., description="Platform code"), + module_code: str = Path(..., description="Module code"), + current_user: User = Depends(require_menu_access("platforms", FrontendType.ADMIN)), + db: Session = Depends(get_db), +): + """ + Render module configuration page. + Allows configuring module-specific settings. + """ + if not current_user.is_super_admin: + return RedirectResponse( + url=f"/admin/platforms/{platform_code}", status_code=302 + ) + + return templates.TemplateResponse( + "tenancy/admin/module-config.html", + get_admin_context( + request, current_user, platform_code=platform_code, module_code=module_code + ), + ) diff --git a/app/modules/tenancy/routes/pages/vendor.py b/app/modules/tenancy/routes/pages/vendor.py new file mode 100644 index 00000000..348d14c0 --- /dev/null +++ b/app/modules/tenancy/routes/pages/vendor.py @@ -0,0 +1,156 @@ +# app/modules/tenancy/routes/pages/vendor.py +""" +Tenancy Vendor Page Routes (HTML rendering). + +Vendor pages for authentication and account management: +- Root redirect +- Login +- Team management +- Profile +- Settings +""" + +from fastapi import APIRouter, Depends, Path, Request +from fastapi.responses import HTMLResponse, RedirectResponse +from sqlalchemy.orm import Session + +from app.api.deps import ( + get_current_vendor_from_cookie_or_header, + get_current_vendor_optional, + get_db, +) +from app.modules.core.utils.page_context import get_vendor_context +from app.templates_config import templates +from models.database.user import User + +router = APIRouter() + + +# ============================================================================ +# PUBLIC ROUTES (No Authentication Required) +# ============================================================================ + + +@router.get("/{vendor_code}", response_class=RedirectResponse, include_in_schema=False) +async def vendor_root_no_slash(vendor_code: str = Path(..., description="Vendor code")): + """ + Redirect /vendor/{code} (no trailing slash) to login page. + Handles requests without trailing slash. + """ + return RedirectResponse(url=f"/vendor/{vendor_code}/login", status_code=302) + + +@router.get( + "/{vendor_code}/", response_class=RedirectResponse, include_in_schema=False +) +async def vendor_root( + vendor_code: str = Path(..., description="Vendor code"), + current_user: User | None = Depends(get_current_vendor_optional), +): + """ + Redirect /vendor/{code}/ based on authentication status. + + - Authenticated vendor users -> /vendor/{code}/dashboard + - Unauthenticated users -> /vendor/{code}/login + """ + if current_user: + return RedirectResponse( + url=f"/vendor/{vendor_code}/dashboard", status_code=302 + ) + + return RedirectResponse(url=f"/vendor/{vendor_code}/login", status_code=302) + + +@router.get( + "/{vendor_code}/login", response_class=HTMLResponse, include_in_schema=False +) +async def vendor_login_page( + request: Request, + vendor_code: str = Path(..., description="Vendor code"), + current_user: User | None = Depends(get_current_vendor_optional), +): + """ + Render vendor login page. + + If user is already authenticated as vendor, redirect to dashboard. + Otherwise, show login form. + + JavaScript will: + - Load vendor info via API + - Handle login form submission + - Redirect to dashboard on success + """ + if current_user: + return RedirectResponse( + url=f"/vendor/{vendor_code}/dashboard", status_code=302 + ) + + return templates.TemplateResponse( + "tenancy/vendor/login.html", + { + "request": request, + "vendor_code": vendor_code, + }, + ) + + +# ============================================================================ +# AUTHENTICATED ROUTES (Vendor Users Only) +# ============================================================================ + + +@router.get( + "/{vendor_code}/team", response_class=HTMLResponse, include_in_schema=False +) +async def vendor_team_page( + request: Request, + vendor_code: str = Path(..., description="Vendor code"), + current_user: User = Depends(get_current_vendor_from_cookie_or_header), + db: Session = Depends(get_db), +): + """ + Render team management page. + JavaScript loads team members via API. + """ + return templates.TemplateResponse( + "tenancy/vendor/team.html", + get_vendor_context(request, db, current_user, vendor_code), + ) + + +@router.get( + "/{vendor_code}/profile", response_class=HTMLResponse, include_in_schema=False +) +async def vendor_profile_page( + request: Request, + vendor_code: str = Path(..., description="Vendor code"), + current_user: User = Depends(get_current_vendor_from_cookie_or_header), + db: Session = Depends(get_db), +): + """ + Render vendor profile page. + User can manage their personal profile information. + """ + return templates.TemplateResponse( + "tenancy/vendor/profile.html", + get_vendor_context(request, db, current_user, vendor_code), + ) + + +@router.get( + "/{vendor_code}/settings", response_class=HTMLResponse, include_in_schema=False +) +async def vendor_settings_page( + request: Request, + vendor_code: str = Path(..., description="Vendor code"), + current_user: User = Depends(get_current_vendor_from_cookie_or_header), + db: Session = Depends(get_db), +): + """ + Render vendor settings page. + JavaScript loads settings via API. + """ + return templates.TemplateResponse( + "tenancy/vendor/settings.html", + get_vendor_context(request, db, current_user, vendor_code), + ) diff --git a/app/modules/tenancy/services/__init__.py b/app/modules/tenancy/services/__init__.py index d62c6625..8e7b913a 100644 --- a/app/modules/tenancy/services/__init__.py +++ b/app/modules/tenancy/services/__init__.py @@ -3,11 +3,64 @@ Tenancy module services. Business logic for platform, company, vendor, and admin user management. -Currently services remain in app/services/ - this package is a placeholder -for future migration. + +Services: +- vendor_service: Vendor operations and product catalog +- admin_service: Admin user and vendor management +- admin_platform_service: Admin-platform assignments +- vendor_team_service: Team member management +- vendor_domain_service: Custom domain management +- company_service: Company CRUD operations +- platform_service: Platform operations +- team_service: Team operations """ -# Services will be migrated here from app/services/ -# For now, import from legacy location if needed: -# from app.services.vendor_service import vendor_service -# from app.services.company_service import company_service +from app.modules.tenancy.services.admin_platform_service import ( + AdminPlatformService, + admin_platform_service, +) +from app.modules.tenancy.services.admin_service import AdminService, admin_service +from app.modules.tenancy.services.company_service import CompanyService, company_service +from app.modules.tenancy.services.platform_service import ( + PlatformService, + PlatformStats, + platform_service, +) +from app.modules.tenancy.services.team_service import TeamService, team_service +from app.modules.tenancy.services.vendor_domain_service import ( + VendorDomainService, + vendor_domain_service, +) +from app.modules.tenancy.services.vendor_service import VendorService, vendor_service +from app.modules.tenancy.services.vendor_team_service import ( + VendorTeamService, + vendor_team_service, +) + +__all__ = [ + # Vendor + "VendorService", + "vendor_service", + # Admin + "AdminService", + "admin_service", + # Admin Platform + "AdminPlatformService", + "admin_platform_service", + # Vendor Team + "VendorTeamService", + "vendor_team_service", + # Vendor Domain + "VendorDomainService", + "vendor_domain_service", + # Company + "CompanyService", + "company_service", + # Platform + "PlatformService", + "PlatformStats", + "platform_service", + # Team + "TeamService", + "team_service", +] diff --git a/app/services/admin_platform_service.py b/app/modules/tenancy/services/admin_platform_service.py similarity index 98% rename from app/services/admin_platform_service.py rename to app/modules/tenancy/services/admin_platform_service.py index dfc70eea..1c2a664e 100644 --- a/app/services/admin_platform_service.py +++ b/app/modules/tenancy/services/admin_platform_service.py @@ -1,4 +1,4 @@ -# app/services/admin_platform_service.py +# app/modules/tenancy/services/admin_platform_service.py """ Admin Platform service for managing admin-platform assignments. @@ -15,10 +15,10 @@ from datetime import UTC, datetime from sqlalchemy.orm import Session, joinedload -from app.exceptions import ( +from app.exceptions import ValidationException +from app.modules.tenancy.exceptions import ( AdminOperationException, CannotModifySelfException, - ValidationException, ) from models.database.admin_platform import AdminPlatform from models.database.platform import Platform @@ -236,7 +236,7 @@ class AdminPlatformService: Raises: InsufficientPermissionsException: If user doesn't have access """ - from app.exceptions import InsufficientPermissionsException + from app.modules.tenancy.exceptions import InsufficientPermissionsException if not user.can_access_platform(platform_id): raise InsufficientPermissionsException( diff --git a/app/services/admin_service.py b/app/modules/tenancy/services/admin_service.py similarity index 99% rename from app/services/admin_service.py rename to app/modules/tenancy/services/admin_service.py index d852c0b0..c1b8244a 100644 --- a/app/services/admin_service.py +++ b/app/modules/tenancy/services/admin_service.py @@ -1,4 +1,4 @@ -# app/services/admin_service.py +# app/modules/tenancy/services/admin_service.py """ Admin service for managing users, vendors, and import jobs. @@ -18,19 +18,19 @@ from datetime import UTC, datetime from sqlalchemy import func, or_ from sqlalchemy.orm import Session, joinedload -from app.exceptions import ( +from app.exceptions import ValidationException +from app.modules.tenancy.exceptions import ( AdminOperationException, CannotModifySelfException, + UserAlreadyExistsException, UserCannotBeDeletedException, UserNotFoundException, UserRoleChangeException, UserStatusChangeException, - ValidationException, VendorAlreadyExistsException, VendorNotFoundException, VendorVerificationException, ) -from app.exceptions.auth import UserAlreadyExistsException from middleware.auth import AuthManager from models.database.company import Company from app.modules.marketplace.models import MarketplaceImportJob diff --git a/app/services/company_service.py b/app/modules/tenancy/services/company_service.py similarity index 98% rename from app/services/company_service.py rename to app/modules/tenancy/services/company_service.py index 054653b9..8b9d6aa7 100644 --- a/app/services/company_service.py +++ b/app/modules/tenancy/services/company_service.py @@ -1,4 +1,4 @@ -# app/services/company_service.py +# app/modules/tenancy/services/company_service.py """ Company service for managing company operations. @@ -12,7 +12,7 @@ import string from sqlalchemy import func, select from sqlalchemy.orm import Session, joinedload -from app.exceptions import CompanyNotFoundException, UserNotFoundException +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 diff --git a/app/services/platform_service.py b/app/modules/tenancy/services/platform_service.py similarity index 98% rename from app/services/platform_service.py rename to app/modules/tenancy/services/platform_service.py index ff8ccbb9..51412e80 100644 --- a/app/services/platform_service.py +++ b/app/modules/tenancy/services/platform_service.py @@ -1,4 +1,4 @@ -# app/services/platform_service.py +# app/modules/tenancy/services/platform_service.py """ Platform Service @@ -17,7 +17,7 @@ from dataclasses import dataclass from sqlalchemy import func from sqlalchemy.orm import Session -from app.exceptions.platform import ( +from app.modules.tenancy.exceptions import ( PlatformNotFoundException, ) from app.modules.cms.models import ContentPage diff --git a/app/services/team_service.py b/app/modules/tenancy/services/team_service.py similarity index 99% rename from app/services/team_service.py rename to app/modules/tenancy/services/team_service.py index 77582f41..c80e69fc 100644 --- a/app/services/team_service.py +++ b/app/modules/tenancy/services/team_service.py @@ -1,4 +1,4 @@ -# app/services/team_service.py +# app/modules/tenancy/services/team_service.py """ Team service for vendor team management. diff --git a/app/services/vendor_domain_service.py b/app/modules/tenancy/services/vendor_domain_service.py similarity index 98% rename from app/services/vendor_domain_service.py rename to app/modules/tenancy/services/vendor_domain_service.py index 05c6018f..723f0612 100644 --- a/app/services/vendor_domain_service.py +++ b/app/modules/tenancy/services/vendor_domain_service.py @@ -1,4 +1,4 @@ -# app/services/vendor_domain_service.py +# app/modules/tenancy/services/vendor_domain_service.py """ Vendor domain service for managing custom domain operations. @@ -16,7 +16,8 @@ from datetime import UTC, datetime from sqlalchemy.orm import Session -from app.exceptions import ( +from app.exceptions import ValidationException +from app.modules.tenancy.exceptions import ( DNSVerificationException, DomainAlreadyVerifiedException, DomainNotVerifiedException, @@ -24,7 +25,6 @@ from app.exceptions import ( InvalidDomainFormatException, MaxDomainsReachedException, ReservedDomainException, - ValidationException, VendorDomainAlreadyExistsException, VendorDomainNotFoundException, VendorNotFoundException, diff --git a/app/services/vendor_service.py b/app/modules/tenancy/services/vendor_service.py similarity index 98% rename from app/services/vendor_service.py rename to app/modules/tenancy/services/vendor_service.py index 02ce0f1f..554e2995 100644 --- a/app/services/vendor_service.py +++ b/app/modules/tenancy/services/vendor_service.py @@ -1,4 +1,4 @@ -# app/services/vendor_service.py +# app/modules/tenancy/services/vendor_service.py """ Vendor service for managing vendor operations and product catalog. @@ -14,12 +14,12 @@ import logging from sqlalchemy import func from sqlalchemy.orm import Session -from app.exceptions import ( +from app.exceptions import ValidationException +from app.modules.catalog.exceptions import ProductAlreadyExistsException +from app.modules.marketplace.exceptions import MarketplaceProductNotFoundException +from app.modules.tenancy.exceptions import ( InvalidVendorDataException, - MarketplaceProductNotFoundException, - ProductAlreadyExistsException, UnauthorizedVendorAccessException, - ValidationException, VendorAlreadyExistsException, VendorNotFoundException, ) @@ -639,7 +639,7 @@ class VendorService: VendorNotFoundException: If vendor not found InsufficientPermissionsException: If user lacks permission """ - from app.exceptions import InsufficientPermissionsException + from app.modules.tenancy.exceptions import InsufficientPermissionsException vendor = self.get_vendor_by_id(db, vendor_id) @@ -674,7 +674,7 @@ class VendorService: VendorNotFoundException: If vendor not found InsufficientPermissionsException: If user lacks permission """ - from app.exceptions import InsufficientPermissionsException + from app.modules.tenancy.exceptions import InsufficientPermissionsException vendor = self.get_vendor_by_id(db, vendor_id) diff --git a/app/services/vendor_team_service.py b/app/modules/tenancy/services/vendor_team_service.py similarity index 98% rename from app/services/vendor_team_service.py rename to app/modules/tenancy/services/vendor_team_service.py index b77e8d1b..b869683c 100644 --- a/app/services/vendor_team_service.py +++ b/app/modules/tenancy/services/vendor_team_service.py @@ -1,4 +1,4 @@ -# app/services/vendor_team_service.py +# app/modules/tenancy/services/vendor_team_service.py """ Vendor team management service. @@ -17,14 +17,14 @@ from typing import Any from sqlalchemy.orm import Session from app.core.permissions import get_preset_permissions -from app.exceptions import ( +from app.modules.tenancy.exceptions import ( CannotRemoveOwnerException, InvalidInvitationTokenException, TeamInvitationAlreadyAcceptedException, TeamMemberAlreadyExistsException, UserNotFoundException, ) -from app.services.subscription_service import TierLimitExceededException +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 @@ -68,7 +68,7 @@ class VendorTeamService: """ try: # Check team size limit from subscription - from app.services.subscription_service import subscription_service + from app.modules.billing.services import subscription_service subscription_service.check_team_limit(db, vendor.id) diff --git a/static/admin/js/admin-user-detail.js b/app/modules/tenancy/static/admin/js/admin-user-detail.js similarity index 100% rename from static/admin/js/admin-user-detail.js rename to app/modules/tenancy/static/admin/js/admin-user-detail.js diff --git a/static/admin/js/admin-user-edit.js b/app/modules/tenancy/static/admin/js/admin-user-edit.js similarity index 100% rename from static/admin/js/admin-user-edit.js rename to app/modules/tenancy/static/admin/js/admin-user-edit.js diff --git a/static/admin/js/admin-users.js b/app/modules/tenancy/static/admin/js/admin-users.js similarity index 100% rename from static/admin/js/admin-users.js rename to app/modules/tenancy/static/admin/js/admin-users.js diff --git a/static/admin/js/companies.js b/app/modules/tenancy/static/admin/js/companies.js similarity index 100% rename from static/admin/js/companies.js rename to app/modules/tenancy/static/admin/js/companies.js diff --git a/static/admin/js/company-detail.js b/app/modules/tenancy/static/admin/js/company-detail.js similarity index 100% rename from static/admin/js/company-detail.js rename to app/modules/tenancy/static/admin/js/company-detail.js diff --git a/static/admin/js/company-edit.js b/app/modules/tenancy/static/admin/js/company-edit.js similarity index 100% rename from static/admin/js/company-edit.js rename to app/modules/tenancy/static/admin/js/company-edit.js diff --git a/static/admin/js/platform-detail.js b/app/modules/tenancy/static/admin/js/platform-detail.js similarity index 100% rename from static/admin/js/platform-detail.js rename to app/modules/tenancy/static/admin/js/platform-detail.js diff --git a/static/admin/js/platform-edit.js b/app/modules/tenancy/static/admin/js/platform-edit.js similarity index 100% rename from static/admin/js/platform-edit.js rename to app/modules/tenancy/static/admin/js/platform-edit.js diff --git a/static/admin/js/platform-homepage.js b/app/modules/tenancy/static/admin/js/platform-homepage.js similarity index 100% rename from static/admin/js/platform-homepage.js rename to app/modules/tenancy/static/admin/js/platform-homepage.js diff --git a/static/admin/js/platform-menu-config.js b/app/modules/tenancy/static/admin/js/platform-menu-config.js similarity index 100% rename from static/admin/js/platform-menu-config.js rename to app/modules/tenancy/static/admin/js/platform-menu-config.js diff --git a/static/admin/js/platform-modules.js b/app/modules/tenancy/static/admin/js/platform-modules.js similarity index 100% rename from static/admin/js/platform-modules.js rename to app/modules/tenancy/static/admin/js/platform-modules.js diff --git a/static/admin/js/platforms.js b/app/modules/tenancy/static/admin/js/platforms.js similarity index 100% rename from static/admin/js/platforms.js rename to app/modules/tenancy/static/admin/js/platforms.js diff --git a/static/admin/js/select-platform.js b/app/modules/tenancy/static/admin/js/select-platform.js similarity index 100% rename from static/admin/js/select-platform.js rename to app/modules/tenancy/static/admin/js/select-platform.js diff --git a/static/admin/js/user-create.js b/app/modules/tenancy/static/admin/js/user-create.js similarity index 100% rename from static/admin/js/user-create.js rename to app/modules/tenancy/static/admin/js/user-create.js diff --git a/static/admin/js/user-detail.js b/app/modules/tenancy/static/admin/js/user-detail.js similarity index 100% rename from static/admin/js/user-detail.js rename to app/modules/tenancy/static/admin/js/user-detail.js diff --git a/static/admin/js/user-edit.js b/app/modules/tenancy/static/admin/js/user-edit.js similarity index 100% rename from static/admin/js/user-edit.js rename to app/modules/tenancy/static/admin/js/user-edit.js diff --git a/static/admin/js/users.js b/app/modules/tenancy/static/admin/js/users.js similarity index 100% rename from static/admin/js/users.js rename to app/modules/tenancy/static/admin/js/users.js diff --git a/static/admin/js/vendor-create.js b/app/modules/tenancy/static/admin/js/vendor-create.js similarity index 100% rename from static/admin/js/vendor-create.js rename to app/modules/tenancy/static/admin/js/vendor-create.js diff --git a/static/admin/js/vendor-detail.js b/app/modules/tenancy/static/admin/js/vendor-detail.js similarity index 100% rename from static/admin/js/vendor-detail.js rename to app/modules/tenancy/static/admin/js/vendor-detail.js diff --git a/static/admin/js/vendor-edit.js b/app/modules/tenancy/static/admin/js/vendor-edit.js similarity index 100% rename from static/admin/js/vendor-edit.js rename to app/modules/tenancy/static/admin/js/vendor-edit.js diff --git a/static/admin/js/vendor-theme.js b/app/modules/tenancy/static/admin/js/vendor-theme.js similarity index 100% rename from static/admin/js/vendor-theme.js rename to app/modules/tenancy/static/admin/js/vendor-theme.js diff --git a/static/admin/js/vendor-themes.js b/app/modules/tenancy/static/admin/js/vendor-themes.js similarity index 100% rename from static/admin/js/vendor-themes.js rename to app/modules/tenancy/static/admin/js/vendor-themes.js diff --git a/static/admin/js/vendors.js b/app/modules/tenancy/static/admin/js/vendors.js similarity index 100% rename from static/admin/js/vendors.js rename to app/modules/tenancy/static/admin/js/vendors.js diff --git a/static/vendor/js/login.js b/app/modules/tenancy/static/vendor/js/login.js similarity index 100% rename from static/vendor/js/login.js rename to app/modules/tenancy/static/vendor/js/login.js diff --git a/static/vendor/js/profile.js b/app/modules/tenancy/static/vendor/js/profile.js similarity index 100% rename from static/vendor/js/profile.js rename to app/modules/tenancy/static/vendor/js/profile.js diff --git a/static/vendor/js/settings.js b/app/modules/tenancy/static/vendor/js/settings.js similarity index 100% rename from static/vendor/js/settings.js rename to app/modules/tenancy/static/vendor/js/settings.js diff --git a/static/vendor/js/team.js b/app/modules/tenancy/static/vendor/js/team.js similarity index 100% rename from static/vendor/js/team.js rename to app/modules/tenancy/static/vendor/js/team.js diff --git a/app/templates/admin/admin-user-detail.html b/app/modules/tenancy/templates/tenancy/admin/admin-user-detail.html similarity index 99% rename from app/templates/admin/admin-user-detail.html rename to app/modules/tenancy/templates/tenancy/admin/admin-user-detail.html index 5f9dc7e1..6a7e1776 100644 --- a/app/templates/admin/admin-user-detail.html +++ b/app/modules/tenancy/templates/tenancy/admin/admin-user-detail.html @@ -234,5 +234,5 @@ {% endblock %} {% block extra_scripts %} - + {% endblock %} diff --git a/app/templates/admin/admin-user-edit.html b/app/modules/tenancy/templates/tenancy/admin/admin-user-edit.html similarity index 99% rename from app/templates/admin/admin-user-edit.html rename to app/modules/tenancy/templates/tenancy/admin/admin-user-edit.html index abe9f790..650c99c1 100644 --- a/app/templates/admin/admin-user-edit.html +++ b/app/modules/tenancy/templates/tenancy/admin/admin-user-edit.html @@ -268,5 +268,5 @@ {% endblock %} {% block extra_scripts %} - + {% endblock %} diff --git a/app/templates/admin/admin-users.html b/app/modules/tenancy/templates/tenancy/admin/admin-users.html similarity index 99% rename from app/templates/admin/admin-users.html rename to app/modules/tenancy/templates/tenancy/admin/admin-users.html index f9e86712..faf6ee65 100644 --- a/app/templates/admin/admin-users.html +++ b/app/modules/tenancy/templates/tenancy/admin/admin-users.html @@ -258,5 +258,5 @@ {% endblock %} {% block extra_scripts %} - + {% endblock %} diff --git a/app/templates/admin/companies.html b/app/modules/tenancy/templates/tenancy/admin/companies.html similarity index 99% rename from app/templates/admin/companies.html rename to app/modules/tenancy/templates/tenancy/admin/companies.html index 5e2a9ba3..262ff809 100644 --- a/app/templates/admin/companies.html +++ b/app/modules/tenancy/templates/tenancy/admin/companies.html @@ -245,5 +245,5 @@ {% endblock %} {% block extra_scripts %} - + {% endblock %} diff --git a/app/templates/admin/company-create.html b/app/modules/tenancy/templates/tenancy/admin/company-create.html similarity index 100% rename from app/templates/admin/company-create.html rename to app/modules/tenancy/templates/tenancy/admin/company-create.html diff --git a/app/templates/admin/company-detail.html b/app/modules/tenancy/templates/tenancy/admin/company-detail.html similarity index 99% rename from app/templates/admin/company-detail.html rename to app/modules/tenancy/templates/tenancy/admin/company-detail.html index ebdd5fd4..1f99ed08 100644 --- a/app/templates/admin/company-detail.html +++ b/app/modules/tenancy/templates/tenancy/admin/company-detail.html @@ -265,5 +265,5 @@ {% endblock %} {% block extra_scripts %} - + {% endblock %} diff --git a/app/templates/admin/company-edit.html b/app/modules/tenancy/templates/tenancy/admin/company-edit.html similarity index 99% rename from app/templates/admin/company-edit.html rename to app/modules/tenancy/templates/tenancy/admin/company-edit.html index 9cbc668b..a249cc6b 100644 --- a/app/templates/admin/company-edit.html +++ b/app/modules/tenancy/templates/tenancy/admin/company-edit.html @@ -445,5 +445,5 @@ {% endblock %} {% block extra_scripts %} - + {% endblock %} diff --git a/app/templates/admin/login.html b/app/modules/tenancy/templates/tenancy/admin/login.html similarity index 98% rename from app/templates/admin/login.html rename to app/modules/tenancy/templates/tenancy/admin/login.html index 295c41e1..146d8f99 100644 --- a/app/templates/admin/login.html +++ b/app/modules/tenancy/templates/tenancy/admin/login.html @@ -135,6 +135,6 @@ - + \ No newline at end of file diff --git a/app/templates/admin/module-config.html b/app/modules/tenancy/templates/tenancy/admin/module-config.html similarity index 100% rename from app/templates/admin/module-config.html rename to app/modules/tenancy/templates/tenancy/admin/module-config.html diff --git a/app/templates/admin/module-info.html b/app/modules/tenancy/templates/tenancy/admin/module-info.html similarity index 100% rename from app/templates/admin/module-info.html rename to app/modules/tenancy/templates/tenancy/admin/module-info.html diff --git a/app/templates/admin/platform-detail.html b/app/modules/tenancy/templates/tenancy/admin/platform-detail.html similarity index 100% rename from app/templates/admin/platform-detail.html rename to app/modules/tenancy/templates/tenancy/admin/platform-detail.html diff --git a/app/templates/admin/platform-edit.html b/app/modules/tenancy/templates/tenancy/admin/platform-edit.html similarity index 100% rename from app/templates/admin/platform-edit.html rename to app/modules/tenancy/templates/tenancy/admin/platform-edit.html diff --git a/app/templates/admin/platform-menu-config.html b/app/modules/tenancy/templates/tenancy/admin/platform-menu-config.html similarity index 99% rename from app/templates/admin/platform-menu-config.html rename to app/modules/tenancy/templates/tenancy/admin/platform-menu-config.html index 0be972a2..478369b3 100644 --- a/app/templates/admin/platform-menu-config.html +++ b/app/modules/tenancy/templates/tenancy/admin/platform-menu-config.html @@ -196,5 +196,5 @@ {% endblock %} {% block extra_scripts %} - + {% endblock %} diff --git a/app/templates/admin/platform-modules.html b/app/modules/tenancy/templates/tenancy/admin/platform-modules.html similarity index 99% rename from app/templates/admin/platform-modules.html rename to app/modules/tenancy/templates/tenancy/admin/platform-modules.html index cd52707d..466a104c 100644 --- a/app/templates/admin/platform-modules.html +++ b/app/modules/tenancy/templates/tenancy/admin/platform-modules.html @@ -278,5 +278,5 @@ {% endblock %} {% block extra_scripts %} - + {% endblock %} diff --git a/app/templates/admin/platforms.html b/app/modules/tenancy/templates/tenancy/admin/platforms.html similarity index 99% rename from app/templates/admin/platforms.html rename to app/modules/tenancy/templates/tenancy/admin/platforms.html index d8ff1bee..346fbf03 100644 --- a/app/templates/admin/platforms.html +++ b/app/modules/tenancy/templates/tenancy/admin/platforms.html @@ -158,5 +158,5 @@ {% endblock %} {% block extra_scripts %} - + {% endblock %} diff --git a/app/templates/admin/select-platform.html b/app/modules/tenancy/templates/tenancy/admin/select-platform.html similarity index 98% rename from app/templates/admin/select-platform.html rename to app/modules/tenancy/templates/tenancy/admin/select-platform.html index ffdc3124..6d4479c2 100644 --- a/app/templates/admin/select-platform.html +++ b/app/modules/tenancy/templates/tenancy/admin/select-platform.html @@ -115,7 +115,7 @@ - + diff --git a/app/templates/admin/user-create.html b/app/modules/tenancy/templates/tenancy/admin/user-create.html similarity index 99% rename from app/templates/admin/user-create.html rename to app/modules/tenancy/templates/tenancy/admin/user-create.html index 3e417830..a512899c 100644 --- a/app/templates/admin/user-create.html +++ b/app/modules/tenancy/templates/tenancy/admin/user-create.html @@ -189,5 +189,5 @@ {% endblock %} {% block extra_scripts %} - + {% endblock %} diff --git a/app/templates/admin/vendor-create.html b/app/modules/tenancy/templates/tenancy/admin/vendor-create.html similarity index 100% rename from app/templates/admin/vendor-create.html rename to app/modules/tenancy/templates/tenancy/admin/vendor-create.html diff --git a/app/templates/admin/vendor-detail.html b/app/modules/tenancy/templates/tenancy/admin/vendor-detail.html similarity index 99% rename from app/templates/admin/vendor-detail.html rename to app/modules/tenancy/templates/tenancy/admin/vendor-detail.html index 884cf0e9..8c00427b 100644 --- a/app/templates/admin/vendor-detail.html +++ b/app/modules/tenancy/templates/tenancy/admin/vendor-detail.html @@ -416,5 +416,5 @@ {% endblock %} {% block extra_scripts %} - + {% endblock %} \ No newline at end of file diff --git a/app/templates/admin/vendor-edit.html b/app/modules/tenancy/templates/tenancy/admin/vendor-edit.html similarity index 99% rename from app/templates/admin/vendor-edit.html rename to app/modules/tenancy/templates/tenancy/admin/vendor-edit.html index 5cc46b2b..192b11e5 100644 --- a/app/templates/admin/vendor-edit.html +++ b/app/modules/tenancy/templates/tenancy/admin/vendor-edit.html @@ -428,5 +428,5 @@ {% endblock %} {% block extra_scripts %} - + {% endblock %} \ No newline at end of file diff --git a/app/templates/admin/vendor-theme.html b/app/modules/tenancy/templates/tenancy/admin/vendor-theme.html similarity index 99% rename from app/templates/admin/vendor-theme.html rename to app/modules/tenancy/templates/tenancy/admin/vendor-theme.html index a90a2e51..fcc02ebd 100644 --- a/app/templates/admin/vendor-theme.html +++ b/app/modules/tenancy/templates/tenancy/admin/vendor-theme.html @@ -446,5 +446,5 @@ {% endblock %} {% block extra_scripts %} - + {% endblock %} \ No newline at end of file diff --git a/app/templates/admin/vendor-themes.html b/app/modules/tenancy/templates/tenancy/admin/vendor-themes.html similarity index 98% rename from app/templates/admin/vendor-themes.html rename to app/modules/tenancy/templates/tenancy/admin/vendor-themes.html index 0a2ef942..76594977 100644 --- a/app/templates/admin/vendor-themes.html +++ b/app/modules/tenancy/templates/tenancy/admin/vendor-themes.html @@ -125,5 +125,5 @@ {% block extra_scripts %} - + {% endblock %} diff --git a/app/templates/admin/vendors.html b/app/modules/tenancy/templates/tenancy/admin/vendors.html similarity index 99% rename from app/templates/admin/vendors.html rename to app/modules/tenancy/templates/tenancy/admin/vendors.html index 46f5a32b..666df3ff 100644 --- a/app/templates/admin/vendors.html +++ b/app/modules/tenancy/templates/tenancy/admin/vendors.html @@ -228,5 +228,5 @@ {% endblock %} {% block extra_scripts %} - + {% endblock %} \ No newline at end of file diff --git a/app/templates/vendor/login.html b/app/modules/tenancy/templates/tenancy/vendor/login.html similarity index 99% rename from app/templates/vendor/login.html rename to app/modules/tenancy/templates/tenancy/vendor/login.html index c0bfd470..da8d99d2 100644 --- a/app/templates/vendor/login.html +++ b/app/modules/tenancy/templates/tenancy/vendor/login.html @@ -155,6 +155,6 @@ - + \ No newline at end of file diff --git a/app/templates/vendor/profile.html b/app/modules/tenancy/templates/tenancy/vendor/profile.html similarity index 99% rename from app/templates/vendor/profile.html rename to app/modules/tenancy/templates/tenancy/vendor/profile.html index 5d0f538f..aef44662 100644 --- a/app/templates/vendor/profile.html +++ b/app/modules/tenancy/templates/tenancy/vendor/profile.html @@ -202,5 +202,5 @@ {% endblock %} {% block extra_scripts %} - + {% endblock %} diff --git a/app/modules/tenancy/templates/tenancy/vendor/settings.html b/app/modules/tenancy/templates/tenancy/vendor/settings.html new file mode 100644 index 00000000..a51319b4 --- /dev/null +++ b/app/modules/tenancy/templates/tenancy/vendor/settings.html @@ -0,0 +1,1406 @@ +{# app/templates/vendor/settings.html #} +{% extends "vendor/base.html" %} +{% from 'shared/macros/headers.html' import page_header_flex %} +{% from 'shared/macros/alerts.html' import loading_state, error_state %} +{% from 'shared/macros/tabs.html' import tabs_nav, tab_button %} + +{% block title %}Settings{% endblock %} + +{% block alpine_data %}vendorSettings(){% endblock %} + +{% block content %} + +{% call page_header_flex(title='Settings', subtitle='Configure your vendor preferences') %} +{% endcall %} + +{{ loading_state('Loading settings...') }} + +{{ error_state('Error loading settings') }} + + +
+ + {% call tabs_nav(tab_var='activeSection') %} + {{ tab_button('general', 'General', tab_var='activeSection', icon='cog') }} + {{ tab_button('business', 'Business', tab_var='activeSection', icon='office-building') }} + {{ tab_button('localization', 'Localization', tab_var='activeSection', icon='globe') }} + {{ tab_button('marketplace', 'Marketplace', tab_var='activeSection', icon='shopping-cart') }} + {{ tab_button('invoices', 'Invoices', tab_var='activeSection', icon='document-text') }} + {{ tab_button('branding', 'Branding', tab_var='activeSection', icon='color-swatch') }} + {{ tab_button('email', 'Email', tab_var='activeSection', icon='envelope') }} + {{ tab_button('domains', 'Domains', tab_var='activeSection', icon='globe-alt') }} + {{ tab_button('api', 'API', tab_var='activeSection', icon='key') }} + {{ tab_button('notifications', 'Notifications', tab_var='activeSection', icon='bell') }} + {% endcall %} + + +
+ +
+
+

General Settings

+

Basic vendor configuration

+
+
+
+ +
+ +
+ + + .letzshop.lu + +
+

Contact support to change your subdomain

+
+ + +
+
+

Store Status

+

Your store is currently visible to customers

+
+ +
+ + +
+
+

Verification Status

+

Verified vendors get a badge on their store

+
+ +
+
+
+
+ + +
+
+

Business Information

+

+ Store details and contact information + +

+
+
+
+ +
+ + +
+ + +
+ + +
+ + +
+ +
+ + +
+

+ Leave empty to use company default +

+
+ + +
+ +
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+ +
+
+
+
+ + +
+
+

Localization

+

Configure language and regional settings

+
+
+
+ +
+ +
+ + + Platform-wide currency (contact admin to change) + +
+
+ + +
+ + +

+ Controls how prices and numbers are displayed (e.g., "29,99 EUR" vs "EUR29.99") +

+
+ + +
+ + +

+ Language for the vendor dashboard interface +

+
+ + +
+ + +

+ Primary language for products, emails, and other content +

+
+ + +
+ + +

+ Default language shown to customers visiting your shop +

+
+ + +
+ +
+ +
+

+ Languages available in the storefront language selector +

+
+ + +
+ +
+
+
+
+ + +
+
+

Marketplace Integration

+

Configure Letzshop marketplace feed settings

+
+
+
+ + + + +
+

Letzshop CSV Feed URLs

+

+ Enter the URLs for your Letzshop product feeds in different languages. +

+ + +
+ +
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+ +
+ + +
+
+
+ + +
+

Feed Options

+ + +
+ + +

+ Default VAT rate for products without explicit rate +

+
+ + +
+ + +
+ + + {# noqa: FE-008 - Decimal input with 0.1 step and custom @input handler, not suited for number_stepper #} +
+ + +

+ Higher values boost product visibility (0.0 - 10.0) +

+
+ + +
+ + +
+
+ + +
+ +
+
+
+
+ + +
+
+

Invoice Settings

+

Configure invoice generation and billing details

+
+
+ + +
+
+ + +
+
+

Branding & Theme

+

Customize your storefront appearance

+
+
+ + +
+
+ + +
+
+

Email Settings

+

Configure your email sending settings for customer communications

+
+
+ +
+
+
+ +
+ + + + + + + + +
+

Sender Identity

+
+ +
+ + +

+ Email address that customers will see in their inbox +

+
+ + +
+ + +

+ Name that appears as the sender (e.g., "Your Store Name") +

+
+ + +
+ + +

+ Optional: Where replies should go (defaults to From Email) +

+
+
+
+ + +
+

Email Provider

+
+ +
+ +
+ +
+
+ + + + + + + + + + + + +
+
+ + +
+

Email Signature (Optional)

+
+
+ + +
+
+
+ + +
+ +
+ + +
+ + + +
+
+
+
+ + +
+
+

Domains

+

Manage your storefront domains

+
+
+
+ +
+
+

Default Subdomain

+

+
+ + Active + +
+ + + + +
+
+ +
+

+ Need a custom domain? Contact support to set up your own domain with SSL. +

+
+
+
+
+
+
+ + +
+
+

API & Payments

+

Payment integrations and API access

+
+
+
+ +
+
+
+ +
+
+

Stripe

+

Payment processing

+
+
+ + + +
+ + + + +
+
+ +
+

+ API keys and payment credentials are managed securely. Contact support for changes. +

+
+
+
+
+
+
+ + +
+
+

Notification Preferences

+

Control how you receive notifications

+
+
+
+ +
+
+

Email Notifications

+

Receive important updates via email

+
+ +
+ + +
+
+

Order Notifications

+

Get notified when you receive new orders

+
+ +
+ + +
+
+

Marketing Emails

+

Receive tips, updates, and promotional content

+
+ +
+ +

+ Note: Notification settings are currently display-only. Full notification management coming soon. +

+
+
+
+
+
+{% endblock %} + +{% block extra_scripts %} + +{% endblock %} diff --git a/app/templates/vendor/team.html b/app/modules/tenancy/templates/tenancy/vendor/team.html similarity index 99% rename from app/templates/vendor/team.html rename to app/modules/tenancy/templates/tenancy/vendor/team.html index 32e7b0de..2754a341 100644 --- a/app/templates/vendor/team.html +++ b/app/modules/tenancy/templates/tenancy/vendor/team.html @@ -289,5 +289,5 @@ {% endblock %} {% block extra_scripts %} - + {% endblock %} diff --git a/app/platforms/__init__.py b/app/platforms/__init__.py deleted file mode 100644 index 0f7a8a1a..00000000 --- a/app/platforms/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -# app/platforms/__init__.py -""" -Platform-specific code and configurations. - -Each platform (OMS, Loyalty, etc.) has its own: -- routes/: Platform-specific routes -- templates/: Platform-specific templates -- config.py: Platform configuration - -Shared code that applies to all platforms lives in shared/. -""" - -from .shared.base_platform import BasePlatformConfig - -__all__ = ["BasePlatformConfig"] diff --git a/app/platforms/loyalty/__init__.py b/app/platforms/loyalty/__init__.py deleted file mode 100644 index c10694eb..00000000 --- a/app/platforms/loyalty/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -# app/platforms/loyalty/__init__.py -""" -Loyalty Platform - -Platform for customer loyalty programs and rewards. -""" - -from .config import LoyaltyPlatformConfig - -__all__ = ["LoyaltyPlatformConfig"] diff --git a/app/platforms/loyalty/config.py b/app/platforms/loyalty/config.py deleted file mode 100644 index 09c1b7d6..00000000 --- a/app/platforms/loyalty/config.py +++ /dev/null @@ -1,83 +0,0 @@ -# app/platforms/loyalty/config.py -""" -Loyalty Platform Configuration - -Configuration for the Loyalty/Rewards platform. - -Loyalty is a focused customer rewards platform with: -- Customer management and segmentation -- Analytics and reporting -- Content management (for rewards pages) -- Messaging and notifications - -It does NOT include: -- Inventory management (no physical products) -- Order processing (rewards are claimed, not purchased) -- Marketplace integration (internal program only) -- Billing (typically internal/free programs) -""" - -from app.platforms.shared.base_platform import BasePlatformConfig - - -class LoyaltyPlatformConfig(BasePlatformConfig): - """Configuration for the Loyalty platform.""" - - @property - def code(self) -> str: - return "loyalty" - - @property - def name(self) -> str: - return "Loyalty+" - - @property - def description(self) -> str: - return "Customer loyalty and rewards platform" - - @property - def features(self) -> list[str]: - """Loyalty-specific features.""" - return [ - "loyalty_points", - "rewards_catalog", - "customer_tiers", - "referral_program", - ] - - @property - def enabled_modules(self) -> list[str]: - """ - Loyalty platform has a focused module set. - - Core modules (core, platform-admin) are always included. - Does not include: billing, inventory, orders, marketplace - """ - return [ - # Core modules (always enabled, listed for clarity) - "core", - "platform-admin", - # Customer-focused modules - "customers", - "analytics", - "messaging", - # Content for rewards pages - "cms", - # Internal tools (reduced set) - "monitoring", - ] - - @property - def vendor_default_page_slugs(self) -> list[str]: - """Default pages for Loyalty vendor storefronts.""" - return [ - "about", - "how-it-works", - "rewards", - "terms-of-service", - "privacy-policy", - ] - - -# Singleton instance -loyalty_config = LoyaltyPlatformConfig() diff --git a/app/platforms/loyalty/routes/__init__.py b/app/platforms/loyalty/routes/__init__.py deleted file mode 100644 index c13b43e5..00000000 --- a/app/platforms/loyalty/routes/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# app/platforms/loyalty/routes/__init__.py -"""Loyalty platform routes.""" diff --git a/app/platforms/loyalty/templates/__init__.py b/app/platforms/loyalty/templates/__init__.py deleted file mode 100644 index f777e5ab..00000000 --- a/app/platforms/loyalty/templates/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# app/platforms/loyalty/templates/__init__.py -"""Loyalty platform templates.""" diff --git a/app/platforms/oms/__init__.py b/app/platforms/oms/__init__.py deleted file mode 100644 index adf8a81a..00000000 --- a/app/platforms/oms/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -# app/platforms/oms/__init__.py -""" -OMS (Order Management System) Platform - -The primary platform for managing orders, products, and vendor storefronts. -""" - -from .config import OMSPlatformConfig - -__all__ = ["OMSPlatformConfig"] diff --git a/app/platforms/oms/config.py b/app/platforms/oms/config.py deleted file mode 100644 index 77a76de5..00000000 --- a/app/platforms/oms/config.py +++ /dev/null @@ -1,87 +0,0 @@ -# app/platforms/oms/config.py -""" -OMS Platform Configuration - -Configuration for the Order Management System platform. - -OMS is a full-featured order management system with: -- Inventory and product management -- Order processing and fulfillment -- Letzshop marketplace integration -- Customer management -- Billing and subscriptions -- Content management -- Analytics and reporting -""" - -from app.platforms.shared.base_platform import BasePlatformConfig - - -class OMSPlatformConfig(BasePlatformConfig): - """Configuration for the OMS platform.""" - - @property - def code(self) -> str: - return "oms" - - @property - def name(self) -> str: - return "Wizamart OMS" - - @property - def description(self) -> str: - return "Order Management System for Luxembourg merchants" - - @property - def features(self) -> list[str]: - """OMS-specific features.""" - return [ - "order_management", - "inventory_basic", - "invoice_lu", - "letzshop_sync", - "customer_view", - ] - - @property - def enabled_modules(self) -> list[str]: - """ - OMS enables all major commerce modules. - - Core modules (core, platform-admin) are always included. - """ - return [ - # Core modules (always enabled, listed for clarity) - "core", - "platform-admin", - # Commerce modules - "billing", - "inventory", - "orders", - "marketplace", - "customers", - # Content & communication - "cms", - "analytics", - "messaging", - # Internal tools - "dev-tools", - "monitoring", - ] - - @property - def vendor_default_page_slugs(self) -> list[str]: - """Default pages for OMS vendor storefronts.""" - return [ - "about", - "shipping", - "returns", - "privacy-policy", - "terms-of-service", - "contact", - "faq", - ] - - -# Singleton instance -oms_config = OMSPlatformConfig() diff --git a/app/platforms/oms/routes/__init__.py b/app/platforms/oms/routes/__init__.py deleted file mode 100644 index b8bac78a..00000000 --- a/app/platforms/oms/routes/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# app/platforms/oms/routes/__init__.py -"""OMS platform routes.""" diff --git a/app/platforms/oms/templates/__init__.py b/app/platforms/oms/templates/__init__.py deleted file mode 100644 index 750cab5f..00000000 --- a/app/platforms/oms/templates/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# app/platforms/oms/templates/__init__.py -"""OMS platform templates.""" diff --git a/app/platforms/shared/__init__.py b/app/platforms/shared/__init__.py deleted file mode 100644 index 37a8164f..00000000 --- a/app/platforms/shared/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -# app/platforms/shared/__init__.py -"""Shared platform code and base classes.""" - -from .base_platform import BasePlatformConfig - -__all__ = ["BasePlatformConfig"] diff --git a/app/platforms/shared/base_platform.py b/app/platforms/shared/base_platform.py deleted file mode 100644 index a44f4b8f..00000000 --- a/app/platforms/shared/base_platform.py +++ /dev/null @@ -1,128 +0,0 @@ -# app/platforms/shared/base_platform.py -""" -Base Platform Configuration - -Provides a base class for platform-specific configurations. -Each platform (OMS, Loyalty, etc.) should extend this class. -""" - -from abc import ABC, abstractmethod -from typing import Any - - -class BasePlatformConfig(ABC): - """ - Base configuration class for platforms. - - Each platform should create a config.py that extends this class - and provides platform-specific settings. - - Module Configuration: - - enabled_modules: List of module codes to enable for this platform - - Core modules (core, platform-admin) are always enabled - - If not specified, all modules are enabled (backwards compatibility) - """ - - @property - @abstractmethod - def code(self) -> str: - """Unique platform code (e.g., 'oms', 'loyalty').""" - pass - - @property - @abstractmethod - def name(self) -> str: - """Display name (e.g., 'Wizamart OMS').""" - pass - - @property - def description(self) -> str: - """Platform description.""" - return "" - - @property - def default_language(self) -> str: - """Default language code.""" - return "fr" - - @property - def supported_languages(self) -> list[str]: - """List of supported language codes.""" - return ["fr", "de", "en"] - - @property - def theme_defaults(self) -> dict[str, Any]: - """Default theme configuration.""" - return { - "primary_color": "#6366f1", - "secondary_color": "#8b5cf6", - "font_family": "Inter, sans-serif", - } - - @property - def features(self) -> list[str]: - """List of feature codes enabled for this platform.""" - return [] - - @property - def enabled_modules(self) -> list[str]: - """ - List of module codes enabled for this platform. - - Core modules (core, platform-admin) are always enabled regardless. - Override in subclass to customize which modules are available. - - Available modules: - - core (always enabled): Dashboard, settings, profile - - platform-admin (always enabled): Companies, vendors, admin users - - billing: Subscription tiers, billing history - - inventory: Stock management, products - - orders: Order processing, fulfillment - - marketplace: Letzshop integration - - customers: Customer management - - cms: Content pages, media library - - analytics: Reports, dashboard analytics - - messaging: Messages, notifications - - dev-tools: Component library (internal) - - monitoring: Logs, background tasks (internal) - - Returns: - List of module codes. Empty list means all modules enabled. - """ - return [] # Empty = all modules enabled (backwards compatibility) - - @property - def marketing_page_slugs(self) -> list[str]: - """ - Slugs that should be treated as platform marketing pages. - - These pages describe the platform itself (pricing, features, etc.) - rather than being vendor storefront content. - """ - return [ - "home", - "pricing", - "about", - "contact", - "faq", - "terms", - "privacy", - "features", - "integrations", - ] - - @property - def vendor_default_page_slugs(self) -> list[str]: - """ - Slugs for default vendor storefront pages. - - These pages provide fallback content for vendors who haven't - customized their storefront. - """ - return [ - "about", - "shipping", - "returns", - "privacy-policy", - "terms-of-service", - ] diff --git a/app/platforms/shared/routes/__init__.py b/app/platforms/shared/routes/__init__.py deleted file mode 100644 index 8b12e953..00000000 --- a/app/platforms/shared/routes/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# app/platforms/shared/routes/__init__.py -"""Shared platform routes.""" diff --git a/app/platforms/shared/templates/__init__.py b/app/platforms/shared/templates/__init__.py deleted file mode 100644 index 4fc3e147..00000000 --- a/app/platforms/shared/templates/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# app/platforms/shared/templates/__init__.py -"""Shared platform templates.""" diff --git a/app/services/__init__.py b/app/services/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/app/services/admin_customer_service.py b/app/services/admin_customer_service.py deleted file mode 100644 index b0ed0474..00000000 --- a/app/services/admin_customer_service.py +++ /dev/null @@ -1,23 +0,0 @@ -# app/services/admin_customer_service.py -""" -LEGACY LOCATION - Re-exports from module for backwards compatibility. - -The canonical implementation is now in: - app/modules/customers/services/admin_customer_service.py - -This file exists to maintain backwards compatibility with code that -imports from the old location. All new code should import directly -from the module: - - from app.modules.customers.services import admin_customer_service -""" - -from app.modules.customers.services.admin_customer_service import ( - admin_customer_service, - AdminCustomerService, -) - -__all__ = [ - "admin_customer_service", - "AdminCustomerService", -] diff --git a/app/services/admin_notification_service.py b/app/services/admin_notification_service.py deleted file mode 100644 index 4c84a183..00000000 --- a/app/services/admin_notification_service.py +++ /dev/null @@ -1,37 +0,0 @@ -# app/services/admin_notification_service.py -""" -LEGACY LOCATION - Re-exports from module for backwards compatibility. - -The canonical implementation is now in: - app/modules/messaging/services/admin_notification_service.py - -This file exists to maintain backwards compatibility with code that -imports from the old location. All new code should import directly -from the module: - - from app.modules.messaging.services import admin_notification_service -""" - -from app.modules.messaging.services.admin_notification_service import ( - admin_notification_service, - AdminNotificationService, - platform_alert_service, - PlatformAlertService, - # Constants - NotificationType, - Priority, - AlertType, - Severity, -) - -__all__ = [ - "admin_notification_service", - "AdminNotificationService", - "platform_alert_service", - "PlatformAlertService", - # Constants - "NotificationType", - "Priority", - "AlertType", - "Severity", -] diff --git a/app/services/admin_subscription_service.py b/app/services/admin_subscription_service.py deleted file mode 100644 index 8dc32c31..00000000 --- a/app/services/admin_subscription_service.py +++ /dev/null @@ -1,22 +0,0 @@ -# app/services/admin_subscription_service.py -""" -Admin Subscription Service. - -DEPRECATED: This file is maintained for backward compatibility. -Import from app.modules.billing.services instead: - - from app.modules.billing.services import admin_subscription_service - -This file re-exports the service from its new location in the billing module. -""" - -# Re-export from new location for backward compatibility -from app.modules.billing.services.admin_subscription_service import ( - AdminSubscriptionService, - admin_subscription_service, -) - -__all__ = [ - "AdminSubscriptionService", - "admin_subscription_service", -] diff --git a/app/services/audit_service.py b/app/services/audit_service.py deleted file mode 100644 index 51cb387c..00000000 --- a/app/services/audit_service.py +++ /dev/null @@ -1 +0,0 @@ -# Audit logging services diff --git a/app/services/background_tasks_service.py b/app/services/background_tasks_service.py deleted file mode 100644 index 51538849..00000000 --- a/app/services/background_tasks_service.py +++ /dev/null @@ -1,23 +0,0 @@ -# app/services/background_tasks_service.py -""" -LEGACY LOCATION - Re-exports from module for backwards compatibility. - -The canonical implementation is now in: - app/modules/monitoring/services/background_tasks_service.py - -This file exists to maintain backwards compatibility with code that -imports from the old location. All new code should import directly -from the module: - - from app.modules.monitoring.services import background_tasks_service -""" - -from app.modules.monitoring.services.background_tasks_service import ( - background_tasks_service, - BackgroundTasksService, -) - -__all__ = [ - "background_tasks_service", - "BackgroundTasksService", -] diff --git a/app/services/backup_service.py b/app/services/backup_service.py deleted file mode 100644 index 0759bea8..00000000 --- a/app/services/backup_service.py +++ /dev/null @@ -1 +0,0 @@ -# Backup and recovery services diff --git a/app/services/billing_service.py b/app/services/billing_service.py deleted file mode 100644 index b3fd6ace..00000000 --- a/app/services/billing_service.py +++ /dev/null @@ -1,35 +0,0 @@ -# app/services/billing_service.py -""" -LEGACY LOCATION - Re-exports from module for backwards compatibility. - -The canonical implementation is now in: - app/modules/billing/services/billing_service.py - -This file exists to maintain backwards compatibility with code that -imports from the old location. All new code should import directly -from the module: - - from app.modules.billing.services import billing_service -""" - -from app.modules.billing.services.billing_service import ( - BillingService, - billing_service, - BillingServiceError, - PaymentSystemNotConfiguredError, - TierNotFoundError, - StripePriceNotConfiguredError, - NoActiveSubscriptionError, - SubscriptionNotCancelledError, -) - -__all__ = [ - "BillingService", - "billing_service", - "BillingServiceError", - "PaymentSystemNotConfiguredError", - "TierNotFoundError", - "StripePriceNotConfiguredError", - "NoActiveSubscriptionError", - "SubscriptionNotCancelledError", -] diff --git a/app/services/cache_service.py b/app/services/cache_service.py deleted file mode 100644 index 63e64747..00000000 --- a/app/services/cache_service.py +++ /dev/null @@ -1 +0,0 @@ -# Caching services diff --git a/app/services/code_quality_service.py b/app/services/code_quality_service.py deleted file mode 100644 index 77d6bd91..00000000 --- a/app/services/code_quality_service.py +++ /dev/null @@ -1,35 +0,0 @@ -# app/services/code_quality_service.py -""" -LEGACY LOCATION - Re-exports from module for backwards compatibility. - -The canonical implementation is now in: - app/modules/dev_tools/services/code_quality_service.py - -This file exists to maintain backwards compatibility with code that -imports from the old location. All new code should import directly -from the module: - - from app.modules.dev_tools.services import code_quality_service -""" - -from app.modules.dev_tools.services.code_quality_service import ( - code_quality_service, - CodeQualityService, - VALIDATOR_ARCHITECTURE, - VALIDATOR_SECURITY, - VALIDATOR_PERFORMANCE, - VALID_VALIDATOR_TYPES, - VALIDATOR_SCRIPTS, - VALIDATOR_NAMES, -) - -__all__ = [ - "code_quality_service", - "CodeQualityService", - "VALIDATOR_ARCHITECTURE", - "VALIDATOR_SECURITY", - "VALIDATOR_PERFORMANCE", - "VALID_VALIDATOR_TYPES", - "VALIDATOR_SCRIPTS", - "VALIDATOR_NAMES", -] diff --git a/app/services/configuration_service.py b/app/services/configuration_service.py deleted file mode 100644 index 54f872ab..00000000 --- a/app/services/configuration_service.py +++ /dev/null @@ -1 +0,0 @@ -# Configuration management services diff --git a/app/services/customer_address_service.py b/app/services/customer_address_service.py deleted file mode 100644 index 8af43d33..00000000 --- a/app/services/customer_address_service.py +++ /dev/null @@ -1,23 +0,0 @@ -# app/services/customer_address_service.py -""" -LEGACY LOCATION - Re-exports from module for backwards compatibility. - -The canonical implementation is now in: - app/modules/customers/services/customer_address_service.py - -This file exists to maintain backwards compatibility with code that -imports from the old location. All new code should import directly -from the module: - - from app.modules.customers.services import customer_address_service -""" - -from app.modules.customers.services.customer_address_service import ( - customer_address_service, - CustomerAddressService, -) - -__all__ = [ - "customer_address_service", - "CustomerAddressService", -] diff --git a/app/services/customer_service.py b/app/services/customer_service.py deleted file mode 100644 index 85b02a56..00000000 --- a/app/services/customer_service.py +++ /dev/null @@ -1,23 +0,0 @@ -# app/services/customer_service.py -""" -LEGACY LOCATION - Re-exports from module for backwards compatibility. - -The canonical implementation is now in: - app/modules/customers/services/customer_service.py - -This file exists to maintain backwards compatibility with code that -imports from the old location. All new code should import directly -from the module: - - from app.modules.customers.services import customer_service -""" - -from app.modules.customers.services.customer_service import ( - customer_service, - CustomerService, -) - -__all__ = [ - "customer_service", - "CustomerService", -] diff --git a/app/services/inventory_import_service.py b/app/services/inventory_import_service.py deleted file mode 100644 index 2e3ef6e3..00000000 --- a/app/services/inventory_import_service.py +++ /dev/null @@ -1,25 +0,0 @@ -# app/services/inventory_import_service.py -""" -LEGACY LOCATION - Re-exports from module for backwards compatibility. - -The canonical implementation is now in: - app/modules/inventory/services/inventory_import_service.py - -This file exists to maintain backwards compatibility with code that -imports from the old location. All new code should import directly -from the module: - - from app.modules.inventory.services import inventory_import_service -""" - -from app.modules.inventory.services.inventory_import_service import ( - inventory_import_service, - InventoryImportService, - ImportResult, -) - -__all__ = [ - "inventory_import_service", - "InventoryImportService", - "ImportResult", -] diff --git a/app/services/inventory_service.py b/app/services/inventory_service.py deleted file mode 100644 index ee59cf38..00000000 --- a/app/services/inventory_service.py +++ /dev/null @@ -1,23 +0,0 @@ -# app/services/inventory_service.py -""" -LEGACY LOCATION - Re-exports from module for backwards compatibility. - -The canonical implementation is now in: - app/modules/inventory/services/inventory_service.py - -This file exists to maintain backwards compatibility with code that -imports from the old location. All new code should import directly -from the module: - - from app.modules.inventory.services import inventory_service -""" - -from app.modules.inventory.services.inventory_service import ( - inventory_service, - InventoryService, -) - -__all__ = [ - "inventory_service", - "InventoryService", -] diff --git a/app/services/inventory_transaction_service.py b/app/services/inventory_transaction_service.py deleted file mode 100644 index e95a9faa..00000000 --- a/app/services/inventory_transaction_service.py +++ /dev/null @@ -1,23 +0,0 @@ -# app/services/inventory_transaction_service.py -""" -LEGACY LOCATION - Re-exports from module for backwards compatibility. - -The canonical implementation is now in: - app/modules/inventory/services/inventory_transaction_service.py - -This file exists to maintain backwards compatibility with code that -imports from the old location. All new code should import directly -from the module: - - from app.modules.inventory.services import inventory_transaction_service -""" - -from app.modules.inventory.services.inventory_transaction_service import ( - inventory_transaction_service, - InventoryTransactionService, -) - -__all__ = [ - "inventory_transaction_service", - "InventoryTransactionService", -] diff --git a/app/services/invoice_pdf_service.py b/app/services/invoice_pdf_service.py deleted file mode 100644 index c259eb0d..00000000 --- a/app/services/invoice_pdf_service.py +++ /dev/null @@ -1,23 +0,0 @@ -# app/services/invoice_pdf_service.py -""" -LEGACY LOCATION - Re-exports from module for backwards compatibility. - -The canonical implementation is now in: - app/modules/orders/services/invoice_pdf_service.py - -This file exists to maintain backwards compatibility with code that -imports from the old location. All new code should import directly -from the module: - - from app.modules.orders.services import invoice_pdf_service -""" - -from app.modules.orders.services.invoice_pdf_service import ( - invoice_pdf_service, - InvoicePDFService, -) - -__all__ = [ - "invoice_pdf_service", - "InvoicePDFService", -] diff --git a/app/services/invoice_service.py b/app/services/invoice_service.py deleted file mode 100644 index ec5840f1..00000000 --- a/app/services/invoice_service.py +++ /dev/null @@ -1,23 +0,0 @@ -# app/services/invoice_service.py -""" -LEGACY LOCATION - Re-exports from module for backwards compatibility. - -The canonical implementation is now in: - app/modules/orders/services/invoice_service.py - -This file exists to maintain backwards compatibility with code that -imports from the old location. All new code should import directly -from the module: - - from app.modules.orders.services import invoice_service -""" - -from app.modules.orders.services.invoice_service import ( - invoice_service, - InvoiceService, -) - -__all__ = [ - "invoice_service", - "InvoiceService", -] diff --git a/app/services/message_attachment_service.py b/app/services/message_attachment_service.py deleted file mode 100644 index 78bff889..00000000 --- a/app/services/message_attachment_service.py +++ /dev/null @@ -1,23 +0,0 @@ -# app/services/message_attachment_service.py -""" -LEGACY LOCATION - Re-exports from module for backwards compatibility. - -The canonical implementation is now in: - app/modules/messaging/services/message_attachment_service.py - -This file exists to maintain backwards compatibility with code that -imports from the old location. All new code should import directly -from the module: - - from app.modules.messaging.services import message_attachment_service -""" - -from app.modules.messaging.services.message_attachment_service import ( - message_attachment_service, - MessageAttachmentService, -) - -__all__ = [ - "message_attachment_service", - "MessageAttachmentService", -] diff --git a/app/services/messaging_service.py b/app/services/messaging_service.py deleted file mode 100644 index e46010c9..00000000 --- a/app/services/messaging_service.py +++ /dev/null @@ -1,23 +0,0 @@ -# app/services/messaging_service.py -""" -LEGACY LOCATION - Re-exports from module for backwards compatibility. - -The canonical implementation is now in: - app/modules/messaging/services/messaging_service.py - -This file exists to maintain backwards compatibility with code that -imports from the old location. All new code should import directly -from the module: - - from app.modules.messaging.services import messaging_service -""" - -from app.modules.messaging.services.messaging_service import ( - messaging_service, - MessagingService, -) - -__all__ = [ - "messaging_service", - "MessagingService", -] diff --git a/app/services/monitoring_service.py b/app/services/monitoring_service.py deleted file mode 100644 index 9d5ec783..00000000 --- a/app/services/monitoring_service.py +++ /dev/null @@ -1 +0,0 @@ -# Application monitoring services diff --git a/app/services/notification_service.py b/app/services/notification_service.py deleted file mode 100644 index 4eaa423b..00000000 --- a/app/services/notification_service.py +++ /dev/null @@ -1 +0,0 @@ -# Email/notification services diff --git a/app/services/order_inventory_service.py b/app/services/order_inventory_service.py deleted file mode 100644 index d979453b..00000000 --- a/app/services/order_inventory_service.py +++ /dev/null @@ -1,23 +0,0 @@ -# app/services/order_inventory_service.py -""" -LEGACY LOCATION - Re-exports from module for backwards compatibility. - -The canonical implementation is now in: - app/modules/orders/services/order_inventory_service.py - -This file exists to maintain backwards compatibility with code that -imports from the old location. All new code should import directly -from the module: - - from app.modules.orders.services import order_inventory_service -""" - -from app.modules.orders.services.order_inventory_service import ( - order_inventory_service, - OrderInventoryService, -) - -__all__ = [ - "order_inventory_service", - "OrderInventoryService", -] diff --git a/app/services/order_item_exception_service.py b/app/services/order_item_exception_service.py deleted file mode 100644 index d75ba061..00000000 --- a/app/services/order_item_exception_service.py +++ /dev/null @@ -1,23 +0,0 @@ -# app/services/order_item_exception_service.py -""" -LEGACY LOCATION - Re-exports from module for backwards compatibility. - -The canonical implementation is now in: - app/modules/orders/services/order_item_exception_service.py - -This file exists to maintain backwards compatibility with code that -imports from the old location. All new code should import directly -from the module: - - from app.modules.orders.services import order_item_exception_service -""" - -from app.modules.orders.services.order_item_exception_service import ( - order_item_exception_service, - OrderItemExceptionService, -) - -__all__ = [ - "order_item_exception_service", - "OrderItemExceptionService", -] diff --git a/app/services/order_service.py b/app/services/order_service.py deleted file mode 100644 index a26c5f9c..00000000 --- a/app/services/order_service.py +++ /dev/null @@ -1,23 +0,0 @@ -# app/services/order_service.py -""" -LEGACY LOCATION - Re-exports from module for backwards compatibility. - -The canonical implementation is now in: - app/modules/orders/services/order_service.py - -This file exists to maintain backwards compatibility with code that -imports from the old location. All new code should import directly -from the module: - - from app.modules.orders.services import order_service -""" - -from app.modules.orders.services.order_service import ( - order_service, - OrderService, -) - -__all__ = [ - "order_service", - "OrderService", -] diff --git a/app/services/payment_service.py b/app/services/payment_service.py deleted file mode 100644 index 5aef72d7..00000000 --- a/app/services/payment_service.py +++ /dev/null @@ -1 +0,0 @@ -# Payment processing services diff --git a/app/services/search_service.py b/app/services/search_service.py deleted file mode 100644 index 58830348..00000000 --- a/app/services/search_service.py +++ /dev/null @@ -1 +0,0 @@ -# Search and indexing services diff --git a/app/services/stats_service.py b/app/services/stats_service.py deleted file mode 100644 index 27f47574..00000000 --- a/app/services/stats_service.py +++ /dev/null @@ -1,18 +0,0 @@ -# app/services/stats_service.py -""" -Statistics service - LEGACY LOCATION - -This file exists for backward compatibility. -The canonical location is now: app/modules/analytics/services/stats_service.py - -All imports should use the new location: - from app.modules.analytics.services import stats_service, StatsService -""" - -# Re-export from canonical location for backward compatibility -from app.modules.analytics.services.stats_service import ( - stats_service, - StatsService, -) - -__all__ = ["stats_service", "StatsService"] diff --git a/app/services/stripe_service.py b/app/services/stripe_service.py deleted file mode 100644 index 81e13f0f..00000000 --- a/app/services/stripe_service.py +++ /dev/null @@ -1,22 +0,0 @@ -# app/services/stripe_service.py -""" -Stripe payment integration service. - -DEPRECATED: This file is maintained for backward compatibility. -Import from app.modules.billing.services instead: - - from app.modules.billing.services import stripe_service - -This file re-exports the service from its new location in the billing module. -""" - -# Re-export from new location for backward compatibility -from app.modules.billing.services.stripe_service import ( - StripeService, - stripe_service, -) - -__all__ = [ - "StripeService", - "stripe_service", -] diff --git a/app/services/subscription_service.py b/app/services/subscription_service.py deleted file mode 100644 index c31e8547..00000000 --- a/app/services/subscription_service.py +++ /dev/null @@ -1,30 +0,0 @@ -# app/services/subscription_service.py -""" -Subscription service for tier-based access control. - -DEPRECATED: This file is maintained for backward compatibility. -Import from app.modules.billing.services instead: - - from app.modules.billing.services import subscription_service - -This file re-exports the service from its new location in the billing module. -""" - -# Re-export from new location for backward compatibility -from app.modules.billing.services.subscription_service import ( - SubscriptionService, - subscription_service, -) -from app.modules.billing.exceptions import ( - SubscriptionNotFoundException, - TierLimitExceededException, - FeatureNotAvailableException, -) - -__all__ = [ - "SubscriptionService", - "subscription_service", - "SubscriptionNotFoundException", - "TierLimitExceededException", - "FeatureNotAvailableException", -] diff --git a/app/services/test_runner_service.py b/app/services/test_runner_service.py deleted file mode 100644 index bafc0d6d..00000000 --- a/app/services/test_runner_service.py +++ /dev/null @@ -1,23 +0,0 @@ -# app/services/test_runner_service.py -""" -LEGACY LOCATION - Re-exports from module for backwards compatibility. - -The canonical implementation is now in: - app/modules/dev_tools/services/test_runner_service.py - -This file exists to maintain backwards compatibility with code that -imports from the old location. All new code should import directly -from the module: - - from app.modules.dev_tools.services import test_runner_service -""" - -from app.modules.dev_tools.services.test_runner_service import ( - test_runner_service, - TestRunnerService, -) - -__all__ = [ - "test_runner_service", - "TestRunnerService", -] diff --git a/app/services/usage_service.py b/app/services/usage_service.py deleted file mode 100644 index 9cc42567..00000000 --- a/app/services/usage_service.py +++ /dev/null @@ -1,31 +0,0 @@ -# app/services/usage_service.py -""" -Usage and limits service - LEGACY LOCATION - -This file exists for backward compatibility. -The canonical location is now: app/modules/analytics/services/usage_service.py - -All imports should use the new location: - from app.modules.analytics.services import usage_service, UsageService -""" - -# Re-export from canonical location for backward compatibility -from app.modules.analytics.services.usage_service import ( - usage_service, - UsageService, - UsageData, - UsageMetricData, - TierInfoData, - UpgradeTierData, - LimitCheckData, -) - -__all__ = [ - "usage_service", - "UsageService", - "UsageData", - "UsageMetricData", - "TierInfoData", - "UpgradeTierData", - "LimitCheckData", -] diff --git a/app/tasks/__init__.py b/app/tasks/__init__.py index e69de29b..16986004 100644 --- a/app/tasks/__init__.py +++ b/app/tasks/__init__.py @@ -0,0 +1,19 @@ +# app/tasks/__init__.py +""" +Task dispatcher for Celery background tasks. + +All Celery tasks are defined in their respective modules: +- app.modules.marketplace.tasks: Import/export/sync tasks +- app.modules.billing.tasks: Subscription management tasks +- app.modules.dev_tools.tasks: Code quality and test runner tasks +- app.modules.monitoring.tasks: Capacity monitoring tasks + +Use the task_dispatcher for dispatching tasks from API routes: + from app.tasks import task_dispatcher + + task_id = task_dispatcher.dispatch_marketplace_import(...) +""" + +from app.tasks.dispatcher import task_dispatcher, TaskDispatcher + +__all__ = ["task_dispatcher", "TaskDispatcher"] diff --git a/app/tasks/background_tasks.py b/app/tasks/background_tasks.py deleted file mode 100644 index d5cc4aa0..00000000 --- a/app/tasks/background_tasks.py +++ /dev/null @@ -1,136 +0,0 @@ -# app/tasks/background_tasks.py -"""Background tasks for marketplace imports.""" - -import logging -from datetime import UTC, datetime - -from app.core.database import SessionLocal -from app.services.admin_notification_service import admin_notification_service -from app.utils.csv_processor import CSVProcessor -from app.modules.marketplace.models import MarketplaceImportJob -from models.database.vendor import Vendor - -logger = logging.getLogger(__name__) - - -async def process_marketplace_import( - job_id: int, - url: str, - marketplace: str, - vendor_id: int, - batch_size: int = 1000, - language: str = "en", -): - """Background task to process marketplace CSV import. - - Args: - job_id: ID of the MarketplaceImportJob record - url: URL to the CSV file - marketplace: Name of the marketplace (e.g., 'Letzshop') - vendor_id: ID of the vendor - batch_size: Number of rows to process per batch - language: Language code for translations (default: 'en') - """ - db = SessionLocal() - csv_processor = CSVProcessor() - job = None - - try: - # Get the import job - job = ( - db.query(MarketplaceImportJob) - .filter(MarketplaceImportJob.id == job_id) - .first() - ) - if not job: - logger.error(f"Import job {job_id} not found") - return - - # Get vendor information - vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first() - if not vendor: - logger.error(f"Vendor {vendor_id} not found for import job {job_id}") - job.status = "failed" - job.error_message = f"Vendor {vendor_id} not found" - job.completed_at = datetime.now(UTC) - db.commit() - return - - # Update job status - job.status = "processing" - job.started_at = datetime.now(UTC) - db.commit() - - logger.info( - f"Processing import: Job {job_id}, Marketplace: {marketplace}, " - f"Vendor: {vendor.name} ({vendor.vendor_code}), Language: {language}" - ) - - # Process CSV with vendor name and language - result = await csv_processor.process_marketplace_csv_from_url( - url=url, - marketplace=marketplace, - vendor_name=vendor.name, # Pass vendor name to CSV processor - batch_size=batch_size, - db=db, - language=language, # Pass language for translations - import_job_id=job_id, # Pass job ID for error tracking - ) - - # Update job with results - job.status = "completed" - job.completed_at = datetime.now(UTC) - job.imported_count = result["imported"] - job.updated_count = result["updated"] - job.error_count = result.get("errors", 0) - job.total_processed = result["total_processed"] - - if result.get("errors", 0) > 0: - job.status = "completed_with_errors" - job.error_message = f"{result['errors']} rows had errors" - - # Notify admin if error count is significant - if result.get("errors", 0) >= 5: - admin_notification_service.notify_import_failure( - db=db, - vendor_name=vendor.name, - job_id=job_id, - error_message=f"Import completed with {result['errors']} errors out of {result['total_processed']} rows", - vendor_id=vendor_id, - ) - - db.commit() - logger.info( - f"Import job {job_id} completed: " - f"imported={result['imported']}, updated={result['updated']}, " - f"errors={result.get('errors', 0)}" - ) - - except Exception as e: - logger.error(f"Import job {job_id} failed: {e}", exc_info=True) - if job is not None: - try: - job.status = "failed" - job.error_message = str(e) - job.completed_at = datetime.now(UTC) - - # Create admin notification for import failure - vendor_name = vendor.name if vendor else f"Vendor {vendor_id}" - admin_notification_service.notify_import_failure( - db=db, - vendor_name=vendor_name, - job_id=job_id, - error_message=str(e)[:200], # Truncate long errors - vendor_id=vendor_id, - ) - - db.commit() - except Exception as commit_error: - logger.error(f"Failed to update job status: {commit_error}") - db.rollback() - finally: - if hasattr(db, "close") and callable(db.close): - try: - db.close() - except Exception as close_error: - logger.error(f"Error closing database session: {close_error}") diff --git a/app/tasks/celery_tasks/__init__.py b/app/tasks/celery_tasks/__init__.py deleted file mode 100644 index 1f48a18d..00000000 --- a/app/tasks/celery_tasks/__init__.py +++ /dev/null @@ -1,16 +0,0 @@ -# app/tasks/celery_tasks/__init__.py -""" -Celery task modules for Wizamart. - -This package contains Celery task wrappers for background processing: -- marketplace: Product import tasks -- letzshop: Historical import tasks -- subscription: Scheduled subscription management -- export: Product export tasks -- code_quality: Code quality scan tasks -- test_runner: Test execution tasks -""" - -from app.tasks.celery_tasks.base import DatabaseTask - -__all__ = ["DatabaseTask"] diff --git a/app/tasks/celery_tasks/base.py b/app/tasks/celery_tasks/base.py deleted file mode 100644 index f8a16c44..00000000 --- a/app/tasks/celery_tasks/base.py +++ /dev/null @@ -1,91 +0,0 @@ -# app/tasks/celery_tasks/base.py -""" -Base Celery task class with database session management. - -Provides a DatabaseTask base class that handles: -- Database session lifecycle (create/close) -- Context manager pattern for session usage -- Proper cleanup on task completion or failure -""" - -import logging -from contextlib import contextmanager - -from celery import Task - -from app.core.database import SessionLocal - -logger = logging.getLogger(__name__) - - -class DatabaseTask(Task): - """ - Base task with database session management. - - Usage: - @celery_app.task(bind=True, base=DatabaseTask) - def my_task(self, arg1, arg2): - with self.get_db() as db: - # Use db session - result = db.query(Model).all() - return result - """ - - abstract = True - - @contextmanager - def get_db(self): - """ - Context manager for database session. - - Yields a database session and ensures proper cleanup - on both success and failure. - - Yields: - Session: SQLAlchemy database session - - Example: - with self.get_db() as db: - vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first() - """ - db = SessionLocal() - try: - yield db - except Exception as e: - logger.error(f"Database error in task {self.name}: {e}") - db.rollback() - raise - finally: - db.close() - - def on_failure(self, exc, task_id, args, kwargs, einfo): - """ - Called when task fails. - - Logs the failure with task details for debugging. - """ - logger.error( - f"Task {self.name}[{task_id}] failed: {exc}\n" - f"Args: {args}\n" - f"Kwargs: {kwargs}\n" - f"Traceback: {einfo}" - ) - - def on_success(self, retval, task_id, args, kwargs): - """ - Called when task succeeds. - - Logs successful completion with task ID. - """ - logger.info(f"Task {self.name}[{task_id}] completed successfully") - - def on_retry(self, exc, task_id, args, kwargs, einfo): - """ - Called when task is being retried. - - Logs retry attempt with reason. - """ - logger.warning( - f"Task {self.name}[{task_id}] retrying due to: {exc}\n" - f"Retry count: {self.request.retries}" - ) diff --git a/app/tasks/celery_tasks/code_quality.py b/app/tasks/celery_tasks/code_quality.py deleted file mode 100644 index 8ade35e0..00000000 --- a/app/tasks/celery_tasks/code_quality.py +++ /dev/null @@ -1,31 +0,0 @@ -# app/tasks/celery_tasks/code_quality.py -""" -Celery tasks for code quality scans - LEGACY LOCATION - -This file exists for backward compatibility. -The canonical location is now: app/modules/dev_tools/tasks/code_quality.py - -All imports should use the new location: - from app.modules.dev_tools.tasks import execute_code_quality_scan -""" - -# Re-export from canonical location for backward compatibility -from app.modules.dev_tools.tasks.code_quality import ( - execute_code_quality_scan, - VALIDATOR_ARCHITECTURE, - VALIDATOR_SECURITY, - VALIDATOR_PERFORMANCE, - VALID_VALIDATOR_TYPES, - VALIDATOR_SCRIPTS, - VALIDATOR_NAMES, -) - -__all__ = [ - "execute_code_quality_scan", - "VALIDATOR_ARCHITECTURE", - "VALIDATOR_SECURITY", - "VALIDATOR_PERFORMANCE", - "VALID_VALIDATOR_TYPES", - "VALIDATOR_SCRIPTS", - "VALIDATOR_NAMES", -] diff --git a/app/tasks/celery_tasks/export.py b/app/tasks/celery_tasks/export.py deleted file mode 100644 index 3ab68b8c..00000000 --- a/app/tasks/celery_tasks/export.py +++ /dev/null @@ -1,24 +0,0 @@ -# app/tasks/celery_tasks/export.py -""" -Legacy export tasks. - -MIGRATED: All tasks have been migrated to app.modules.marketplace.tasks. - -New locations: -- export_vendor_products_to_folder -> app.modules.marketplace.tasks.export_tasks -- export_marketplace_products -> app.modules.marketplace.tasks.export_tasks - -Import from the new location: - from app.modules.marketplace.tasks import ( - export_vendor_products_to_folder, - export_marketplace_products, - ) -""" - -# Re-export from new location for backward compatibility -from app.modules.marketplace.tasks.export_tasks import ( - export_vendor_products_to_folder, - export_marketplace_products, -) - -__all__ = ["export_vendor_products_to_folder", "export_marketplace_products"] diff --git a/app/tasks/celery_tasks/letzshop.py b/app/tasks/celery_tasks/letzshop.py deleted file mode 100644 index 79f5124f..00000000 --- a/app/tasks/celery_tasks/letzshop.py +++ /dev/null @@ -1,22 +0,0 @@ -# app/tasks/celery_tasks/letzshop.py -""" -Legacy Letzshop tasks. - -MIGRATED: All tasks have been migrated to app.modules.marketplace.tasks. - -New locations: -- process_historical_import -> app.modules.marketplace.tasks.import_tasks -- sync_vendor_directory -> app.modules.marketplace.tasks.sync_tasks - -Import from the new location: - from app.modules.marketplace.tasks import ( - process_historical_import, - sync_vendor_directory, - ) -""" - -# Re-export from new location for backward compatibility -from app.modules.marketplace.tasks.import_tasks import process_historical_import -from app.modules.marketplace.tasks.sync_tasks import sync_vendor_directory - -__all__ = ["process_historical_import", "sync_vendor_directory"] diff --git a/app/tasks/celery_tasks/marketplace.py b/app/tasks/celery_tasks/marketplace.py deleted file mode 100644 index c8723428..00000000 --- a/app/tasks/celery_tasks/marketplace.py +++ /dev/null @@ -1,17 +0,0 @@ -# app/tasks/celery_tasks/marketplace.py -""" -Legacy marketplace tasks. - -MIGRATED: All tasks have been migrated to app.modules.marketplace.tasks. - -New locations: -- process_marketplace_import -> app.modules.marketplace.tasks.import_tasks - -Import from the new location: - from app.modules.marketplace.tasks import process_marketplace_import -""" - -# Re-export from new location for backward compatibility -from app.modules.marketplace.tasks.import_tasks import process_marketplace_import - -__all__ = ["process_marketplace_import"] diff --git a/app/tasks/celery_tasks/subscription.py b/app/tasks/celery_tasks/subscription.py deleted file mode 100644 index ed231cbf..00000000 --- a/app/tasks/celery_tasks/subscription.py +++ /dev/null @@ -1,54 +0,0 @@ -# app/tasks/celery_tasks/subscription.py -""" -Legacy subscription tasks. - -MOSTLY MIGRATED: Most tasks have been migrated to app.modules.billing.tasks. - -The following tasks now live in the billing module: -- reset_period_counters -> app.modules.billing.tasks.subscription -- check_trial_expirations -> app.modules.billing.tasks.subscription -- sync_stripe_status -> app.modules.billing.tasks.subscription -- cleanup_stale_subscriptions -> app.modules.billing.tasks.subscription - -Remaining task (to be migrated to monitoring module): -- capture_capacity_snapshot -""" - -import logging - -from app.core.celery_config import celery_app -from app.tasks.celery_tasks.base import DatabaseTask - -logger = logging.getLogger(__name__) - - -@celery_app.task( - bind=True, - base=DatabaseTask, - name="app.tasks.celery_tasks.subscription.capture_capacity_snapshot", -) -def capture_capacity_snapshot(self): - """ - Capture a daily snapshot of platform capacity metrics. - - Runs daily at midnight. - - TODO: Migrate to app.modules.monitoring.tasks - """ - from app.services.capacity_forecast_service import capacity_forecast_service - - with self.get_db() as db: - snapshot = capacity_forecast_service.capture_daily_snapshot(db) - db.commit() - - logger.info( - f"Captured capacity snapshot: {snapshot.total_vendors} vendors, " - f"{snapshot.total_products} products" - ) - - return { - "snapshot_id": snapshot.id, - "snapshot_date": snapshot.snapshot_date.isoformat(), - "total_vendors": snapshot.total_vendors, - "total_products": snapshot.total_products, - } diff --git a/app/tasks/celery_tasks/test_runner.py b/app/tasks/celery_tasks/test_runner.py deleted file mode 100644 index e6fa44c9..00000000 --- a/app/tasks/celery_tasks/test_runner.py +++ /dev/null @@ -1,15 +0,0 @@ -# app/tasks/celery_tasks/test_runner.py -""" -Celery tasks for test execution - LEGACY LOCATION - -This file exists for backward compatibility. -The canonical location is now: app/modules/dev_tools/tasks/test_runner.py - -All imports should use the new location: - from app.modules.dev_tools.tasks import execute_test_run -""" - -# Re-export from canonical location for backward compatibility -from app.modules.dev_tools.tasks.test_runner import execute_test_run - -__all__ = ["execute_test_run"] diff --git a/app/tasks/code_quality_tasks.py b/app/tasks/code_quality_tasks.py deleted file mode 100644 index d8271283..00000000 --- a/app/tasks/code_quality_tasks.py +++ /dev/null @@ -1,217 +0,0 @@ -# app/tasks/code_quality_tasks.py -"""Background tasks for code quality scans.""" - -import json -import logging -import subprocess -from datetime import UTC, datetime - -from app.core.database import SessionLocal -from app.services.admin_notification_service import admin_notification_service -from app.modules.dev_tools.models import ArchitectureScan, ArchitectureViolation - -logger = logging.getLogger(__name__) - -# Validator type constants -VALIDATOR_ARCHITECTURE = "architecture" -VALIDATOR_SECURITY = "security" -VALIDATOR_PERFORMANCE = "performance" - -VALID_VALIDATOR_TYPES = [VALIDATOR_ARCHITECTURE, VALIDATOR_SECURITY, VALIDATOR_PERFORMANCE] - -# Map validator types to their scripts -VALIDATOR_SCRIPTS = { - VALIDATOR_ARCHITECTURE: "scripts/validate_architecture.py", - VALIDATOR_SECURITY: "scripts/validate_security.py", - VALIDATOR_PERFORMANCE: "scripts/validate_performance.py", -} - -# Human-readable names -VALIDATOR_NAMES = { - VALIDATOR_ARCHITECTURE: "Architecture", - VALIDATOR_SECURITY: "Security", - VALIDATOR_PERFORMANCE: "Performance", -} - - -def _get_git_commit_hash() -> str | None: - """Get current git commit hash""" - try: - result = subprocess.run( - ["git", "rev-parse", "HEAD"], - capture_output=True, - text=True, - timeout=5, - ) - if result.returncode == 0: - return result.stdout.strip()[:40] - except Exception: - pass - return None - - -async def execute_code_quality_scan(scan_id: int): - """ - Background task to execute a code quality scan. - - This task: - 1. Gets the scan record from DB - 2. Updates status to 'running' - 3. Runs the validator script - 4. Parses JSON output and creates violation records - 5. Updates scan with results and status 'completed' or 'failed' - - Args: - scan_id: ID of the ArchitectureScan record - """ - db = SessionLocal() - scan = None - - try: - # Get the scan record - scan = db.query(ArchitectureScan).filter(ArchitectureScan.id == scan_id).first() - if not scan: - logger.error(f"Code quality scan {scan_id} not found") - return - - validator_type = scan.validator_type - if validator_type not in VALID_VALIDATOR_TYPES: - raise ValueError(f"Invalid validator type: {validator_type}") - - script_path = VALIDATOR_SCRIPTS[validator_type] - validator_name = VALIDATOR_NAMES[validator_type] - - # Update status to running - scan.status = "running" - scan.started_at = datetime.now(UTC) - scan.progress_message = f"Running {validator_name} validator..." - scan.git_commit_hash = _get_git_commit_hash() - db.commit() - - logger.info(f"Starting {validator_name} scan (scan_id={scan_id})") - - # Run validator with JSON output - start_time = datetime.now(UTC) - try: - result = subprocess.run( - ["python", script_path, "--json"], - capture_output=True, - text=True, - timeout=600, # 10 minute timeout - ) - except subprocess.TimeoutExpired: - logger.error(f"{validator_name} scan {scan_id} timed out after 10 minutes") - scan.status = "failed" - scan.error_message = "Scan timed out after 10 minutes" - scan.completed_at = datetime.now(UTC) - db.commit() - return - - duration = (datetime.now(UTC) - start_time).total_seconds() - - # Update progress - scan.progress_message = "Parsing results..." - db.commit() - - # Parse JSON output (get only the JSON part, skip progress messages) - try: - lines = result.stdout.strip().split("\n") - json_start = -1 - for i, line in enumerate(lines): - if line.strip().startswith("{"): - json_start = i - break - - if json_start == -1: - raise ValueError("No JSON output found in validator output") - - json_output = "\n".join(lines[json_start:]) - data = json.loads(json_output) - except (json.JSONDecodeError, ValueError) as e: - logger.error(f"Failed to parse {validator_name} validator output: {e}") - logger.error(f"Stdout: {result.stdout[:1000]}") - logger.error(f"Stderr: {result.stderr[:1000]}") - scan.status = "failed" - scan.error_message = f"Failed to parse validator output: {e}" - scan.completed_at = datetime.now(UTC) - scan.duration_seconds = duration - db.commit() - return - - # Update progress - scan.progress_message = "Storing violations..." - db.commit() - - # Create violation records - violations_data = data.get("violations", []) - logger.info(f"Creating {len(violations_data)} {validator_name} violation records") - - for v in violations_data: - violation = ArchitectureViolation( - scan_id=scan.id, - validator_type=validator_type, - rule_id=v.get("rule_id", "UNKNOWN"), - rule_name=v.get("rule_name", "Unknown Rule"), - severity=v.get("severity", "warning"), - file_path=v.get("file_path", ""), - line_number=v.get("line_number", 0), - message=v.get("message", ""), - context=v.get("context", ""), - suggestion=v.get("suggestion", ""), - status="open", - ) - db.add(violation) - - # Update scan with results - scan.total_files = data.get("files_checked", 0) - scan.total_violations = data.get("total_violations", len(violations_data)) - scan.errors = data.get("errors", 0) - scan.warnings = data.get("warnings", 0) - scan.duration_seconds = duration - scan.completed_at = datetime.now(UTC) - scan.progress_message = None - - # Set final status based on results - if scan.errors > 0: - scan.status = "completed_with_warnings" - else: - scan.status = "completed" - - db.commit() - - logger.info( - f"{validator_name} scan {scan_id} completed: " - f"files={scan.total_files}, violations={scan.total_violations}, " - f"errors={scan.errors}, warnings={scan.warnings}, " - f"duration={duration:.1f}s" - ) - - except Exception as e: - logger.error(f"Code quality scan {scan_id} failed: {e}", exc_info=True) - if scan is not None: - try: - scan.status = "failed" - scan.error_message = str(e)[:500] # Truncate long errors - scan.completed_at = datetime.now(UTC) - scan.progress_message = None - - # Create admin notification for scan failure - admin_notification_service.create_notification( - db=db, - title="Code Quality Scan Failed", - message=f"{VALIDATOR_NAMES.get(scan.validator_type, 'Unknown')} scan failed: {str(e)[:200]}", - notification_type="error", - category="code_quality", - action_url="/admin/code-quality", - ) - - db.commit() - except Exception as commit_error: - logger.error(f"Failed to update scan status: {commit_error}") - db.rollback() - finally: - if hasattr(db, "close") and callable(db.close): - try: - db.close() - except Exception as close_error: - logger.error(f"Error closing database session: {close_error}") diff --git a/app/tasks/dispatcher.py b/app/tasks/dispatcher.py index 6416ef95..63ade961 100644 --- a/app/tasks/dispatcher.py +++ b/app/tasks/dispatcher.py @@ -1,20 +1,21 @@ # app/tasks/dispatcher.py """ -Task dispatcher with feature flag for gradual Celery migration. +Task dispatcher for Celery background tasks. -This module provides a unified interface for dispatching background tasks. -Based on the USE_CELERY setting, tasks are either: -- Sent to Celery for persistent, reliable execution -- Run via FastAPI BackgroundTasks (fire-and-forget) +This module provides a unified interface for dispatching background tasks +to Celery workers. All tasks are dispatched to their canonical locations +in the respective modules. -This allows for gradual rollout and instant rollback. +Module task locations: +- Marketplace: app.modules.marketplace.tasks +- Billing: app.modules.billing.tasks +- Dev-Tools: app.modules.dev_tools.tasks +- Monitoring: app.modules.monitoring.tasks """ import logging from typing import Any -from fastapi import BackgroundTasks - from app.core.config import settings logger = logging.getLogger(__name__) @@ -22,14 +23,13 @@ logger = logging.getLogger(__name__) class TaskDispatcher: """ - Dispatches tasks to either Celery or FastAPI BackgroundTasks. + Dispatches tasks to Celery workers. Usage: from app.tasks.dispatcher import task_dispatcher # In an API endpoint: task_id = task_dispatcher.dispatch_marketplace_import( - background_tasks=background_tasks, job_id=job.id, url=url, marketplace=marketplace, @@ -42,21 +42,27 @@ class TaskDispatcher: """Check if Celery is enabled.""" return settings.use_celery + def _require_celery(self, task_name: str) -> None: + """Raise error if Celery is not enabled.""" + if not self.use_celery: + raise RuntimeError( + f"Celery is required for {task_name}. " + f"Set USE_CELERY=true in environment." + ) + def dispatch_marketplace_import( self, - background_tasks: BackgroundTasks, job_id: int, url: str, marketplace: str, vendor_id: int, batch_size: int = 1000, language: str = "en", - ) -> str | None: + ) -> str: """ Dispatch marketplace import task. Args: - background_tasks: FastAPI BackgroundTasks instance job_id: ID of the MarketplaceImportJob record url: URL to the CSV file marketplace: Name of the marketplace @@ -65,151 +71,100 @@ class TaskDispatcher: language: Language code for translations Returns: - str | None: Celery task ID if using Celery, None otherwise + str: Celery task ID """ - if self.use_celery: - from app.tasks.celery_tasks.marketplace import process_marketplace_import + self._require_celery("marketplace import") + from app.modules.marketplace.tasks import process_marketplace_import - task = process_marketplace_import.delay( - job_id=job_id, - url=url, - marketplace=marketplace, - vendor_id=vendor_id, - batch_size=batch_size, - language=language, - ) - logger.info(f"Dispatched marketplace import to Celery: task_id={task.id}") - return task.id - else: - from app.tasks.background_tasks import process_marketplace_import - - background_tasks.add_task( - process_marketplace_import, - job_id=job_id, - url=url, - marketplace=marketplace, - vendor_id=vendor_id, - batch_size=batch_size, - language=language, - ) - logger.info("Dispatched marketplace import to BackgroundTasks") - return None + task = process_marketplace_import.delay( + job_id=job_id, + url=url, + marketplace=marketplace, + vendor_id=vendor_id, + batch_size=batch_size, + language=language, + ) + logger.info(f"Dispatched marketplace import to Celery: task_id={task.id}") + return task.id def dispatch_historical_import( self, - background_tasks: BackgroundTasks, job_id: int, vendor_id: int, - ) -> str | None: + ) -> str: """ Dispatch Letzshop historical import task. Args: - background_tasks: FastAPI BackgroundTasks instance job_id: ID of the LetzshopHistoricalImportJob record vendor_id: ID of the vendor Returns: - str | None: Celery task ID if using Celery, None otherwise + str: Celery task ID """ - if self.use_celery: - from app.tasks.celery_tasks.letzshop import process_historical_import + self._require_celery("historical import") + from app.modules.marketplace.tasks import process_historical_import - task = process_historical_import.delay(job_id=job_id, vendor_id=vendor_id) - logger.info(f"Dispatched historical import to Celery: task_id={task.id}") - return task.id - else: - from app.tasks.letzshop_tasks import process_historical_import - - background_tasks.add_task( - process_historical_import, - job_id=job_id, - vendor_id=vendor_id, - ) - logger.info("Dispatched historical import to BackgroundTasks") - return None + task = process_historical_import.delay(job_id=job_id, vendor_id=vendor_id) + logger.info(f"Dispatched historical import to Celery: task_id={task.id}") + return task.id def dispatch_code_quality_scan( self, - background_tasks: BackgroundTasks, scan_id: int, - ) -> str | None: + ) -> str: """ Dispatch code quality scan task. Args: - background_tasks: FastAPI BackgroundTasks instance scan_id: ID of the ArchitectureScan record Returns: - str | None: Celery task ID if using Celery, None otherwise + str: Celery task ID """ - if self.use_celery: - from app.tasks.celery_tasks.code_quality import execute_code_quality_scan + self._require_celery("code quality scan") + from app.modules.dev_tools.tasks import execute_code_quality_scan - task = execute_code_quality_scan.delay(scan_id=scan_id) - logger.info(f"Dispatched code quality scan to Celery: task_id={task.id}") - return task.id - else: - from app.tasks.code_quality_tasks import execute_code_quality_scan - - background_tasks.add_task(execute_code_quality_scan, scan_id=scan_id) - logger.info("Dispatched code quality scan to BackgroundTasks") - return None + task = execute_code_quality_scan.delay(scan_id=scan_id) + logger.info(f"Dispatched code quality scan to Celery: task_id={task.id}") + return task.id def dispatch_test_run( self, - background_tasks: BackgroundTasks, run_id: int, test_path: str = "tests", extra_args: list[str] | None = None, - ) -> str | None: + ) -> str: """ Dispatch test run task. Args: - background_tasks: FastAPI BackgroundTasks instance run_id: ID of the TestRun record test_path: Path to tests extra_args: Additional pytest arguments Returns: - str | None: Celery task ID if using Celery, None otherwise + str: Celery task ID """ - if self.use_celery: - from app.tasks.celery_tasks.test_runner import execute_test_run + self._require_celery("test run") + from app.modules.dev_tools.tasks import execute_test_run - task = execute_test_run.delay( - run_id=run_id, - test_path=test_path, - extra_args=extra_args, - ) - logger.info(f"Dispatched test run to Celery: task_id={task.id}") - return task.id - else: - from app.tasks.test_runner_tasks import execute_test_run - - background_tasks.add_task( - execute_test_run, - run_id=run_id, - test_path=test_path, - extra_args=extra_args, - ) - logger.info("Dispatched test run to BackgroundTasks") - return None + task = execute_test_run.delay( + run_id=run_id, + test_path=test_path, + extra_args=extra_args, + ) + logger.info(f"Dispatched test run to Celery: task_id={task.id}") + return task.id def dispatch_product_export( self, vendor_id: int, triggered_by: str, include_inactive: bool = False, - ) -> str | None: + ) -> str: """ - Dispatch product export task (Celery only). - - This task is only available via Celery as it's designed for - asynchronous batch exports. For synchronous exports, use - the export service directly. + Dispatch product export task. Args: vendor_id: ID of the vendor to export @@ -217,24 +172,32 @@ class TaskDispatcher: include_inactive: Whether to include inactive products Returns: - str | None: Celery task ID if using Celery, None otherwise + str: Celery task ID """ - if self.use_celery: - from app.tasks.celery_tasks.export import export_vendor_products_to_folder + self._require_celery("product export") + from app.modules.marketplace.tasks import export_vendor_products_to_folder - task = export_vendor_products_to_folder.delay( - vendor_id=vendor_id, - triggered_by=triggered_by, - include_inactive=include_inactive, - ) - logger.info(f"Dispatched product export to Celery: task_id={task.id}") - return task.id - else: - logger.warning( - "Product export task requires Celery. " - "Use letzshop_export_service directly for synchronous export." - ) - return None + task = export_vendor_products_to_folder.delay( + vendor_id=vendor_id, + triggered_by=triggered_by, + include_inactive=include_inactive, + ) + logger.info(f"Dispatched product export to Celery: task_id={task.id}") + return task.id + + def dispatch_capacity_snapshot(self) -> str: + """ + Dispatch capacity snapshot capture task. + + Returns: + str: Celery task ID + """ + self._require_celery("capacity snapshot") + from app.modules.monitoring.tasks import capture_capacity_snapshot + + task = capture_capacity_snapshot.delay() + logger.info(f"Dispatched capacity snapshot to Celery: task_id={task.id}") + return task.id def get_task_status(self, task_id: str) -> dict[str, Any]: """ diff --git a/app/tasks/letzshop_tasks.py b/app/tasks/letzshop_tasks.py deleted file mode 100644 index d97627cc..00000000 --- a/app/tasks/letzshop_tasks.py +++ /dev/null @@ -1,344 +0,0 @@ -# app/tasks/letzshop_tasks.py -"""Background tasks for Letzshop integration.""" - -import logging -from datetime import UTC, datetime -from typing import Any, Callable - -from app.core.database import SessionLocal -from app.services.admin_notification_service import admin_notification_service -from app.modules.marketplace.services.letzshop import ( - LetzshopClientError, - LetzshopCredentialsService, - LetzshopOrderService, - LetzshopVendorSyncService, -) -from app.modules.marketplace.models import LetzshopHistoricalImportJob - -logger = logging.getLogger(__name__) - - -def _get_credentials_service(db) -> LetzshopCredentialsService: - """Create a credentials service instance.""" - return LetzshopCredentialsService(db) - - -def _get_order_service(db) -> LetzshopOrderService: - """Create an order service instance.""" - return LetzshopOrderService(db) - - -def process_historical_import(job_id: int, vendor_id: int): - """ - Background task for historical order import with progress tracking. - - Imports both confirmed and declined orders from Letzshop API, - updating job progress in the database for frontend polling. - - Args: - job_id: ID of the LetzshopHistoricalImportJob record - vendor_id: ID of the vendor to import orders for - """ - db = SessionLocal() - job = None - - try: - # Get the import job - job = ( - db.query(LetzshopHistoricalImportJob) - .filter(LetzshopHistoricalImportJob.id == job_id) - .first() - ) - if not job: - logger.error(f"Historical import job {job_id} not found") - return - - # Mark as started - job.status = "fetching" - job.started_at = datetime.now(UTC) - db.commit() - - creds_service = _get_credentials_service(db) - order_service = _get_order_service(db) - - # Create progress callback for fetching - def fetch_progress_callback(page: int, total_fetched: int): - """Update fetch progress in database.""" - job.current_page = page - job.shipments_fetched = total_fetched - db.commit() - - # Create progress callback for processing - def create_processing_callback( - phase: str, - ) -> Callable[[int, int, int, int], None]: - """Create a processing progress callback for a phase.""" - - def callback(processed: int, imported: int, updated: int, skipped: int): - job.orders_processed = processed - job.orders_imported = imported - job.orders_updated = updated - job.orders_skipped = skipped - db.commit() - - return callback - - with creds_service.create_client(vendor_id) as client: - # ================================================================ - # Phase 1: Import confirmed orders - # ================================================================ - job.current_phase = "confirmed" - job.current_page = 0 - job.shipments_fetched = 0 - db.commit() - - logger.info(f"Job {job_id}: Fetching confirmed shipments for vendor {vendor_id}") - - confirmed_shipments = client.get_all_shipments_paginated( - state="confirmed", - page_size=50, - progress_callback=fetch_progress_callback, - ) - - logger.info(f"Job {job_id}: Fetched {len(confirmed_shipments)} confirmed shipments") - - # Process confirmed shipments - job.status = "processing" - job.orders_processed = 0 - job.orders_imported = 0 - job.orders_updated = 0 - job.orders_skipped = 0 - db.commit() - - confirmed_stats = order_service.import_historical_shipments( - vendor_id=vendor_id, - shipments=confirmed_shipments, - match_products=True, - progress_callback=create_processing_callback("confirmed"), - ) - - # Store confirmed stats - job.confirmed_stats = { - "total": confirmed_stats["total"], - "imported": confirmed_stats["imported"], - "updated": confirmed_stats["updated"], - "skipped": confirmed_stats["skipped"], - "products_matched": confirmed_stats["products_matched"], - "products_not_found": confirmed_stats["products_not_found"], - } - job.products_matched = confirmed_stats["products_matched"] - job.products_not_found = confirmed_stats["products_not_found"] - db.commit() - - logger.info( - f"Job {job_id}: Confirmed phase complete - " - f"imported={confirmed_stats['imported']}, " - f"updated={confirmed_stats['updated']}, " - f"skipped={confirmed_stats['skipped']}" - ) - - # ================================================================ - # Phase 2: Import unconfirmed (pending) orders - # Note: Letzshop API has no "declined" state. Declined items - # are tracked at the inventory unit level, not shipment level. - # Valid states: unconfirmed, confirmed, completed, accepted - # ================================================================ - job.current_phase = "unconfirmed" - job.status = "fetching" - job.current_page = 0 - job.shipments_fetched = 0 - db.commit() - - logger.info(f"Job {job_id}: Fetching unconfirmed shipments for vendor {vendor_id}") - - unconfirmed_shipments = client.get_all_shipments_paginated( - state="unconfirmed", - page_size=50, - progress_callback=fetch_progress_callback, - ) - - logger.info(f"Job {job_id}: Fetched {len(unconfirmed_shipments)} unconfirmed shipments") - - # Process unconfirmed shipments - job.status = "processing" - job.orders_processed = 0 - db.commit() - - unconfirmed_stats = order_service.import_historical_shipments( - vendor_id=vendor_id, - shipments=unconfirmed_shipments, - match_products=True, - progress_callback=create_processing_callback("unconfirmed"), - ) - - # Store unconfirmed stats (in declined_stats field for compatibility) - job.declined_stats = { - "total": unconfirmed_stats["total"], - "imported": unconfirmed_stats["imported"], - "updated": unconfirmed_stats["updated"], - "skipped": unconfirmed_stats["skipped"], - "products_matched": unconfirmed_stats["products_matched"], - "products_not_found": unconfirmed_stats["products_not_found"], - } - - # Add to cumulative product matching stats - job.products_matched += unconfirmed_stats["products_matched"] - job.products_not_found += unconfirmed_stats["products_not_found"] - - logger.info( - f"Job {job_id}: Unconfirmed phase complete - " - f"imported={unconfirmed_stats['imported']}, " - f"updated={unconfirmed_stats['updated']}, " - f"skipped={unconfirmed_stats['skipped']}" - ) - - # ================================================================ - # Complete - # ================================================================ - job.status = "completed" - job.completed_at = datetime.now(UTC) - db.commit() - - # Update credentials sync status - creds_service.update_sync_status(vendor_id, "success", None) - - logger.info(f"Job {job_id}: Historical import completed successfully") - - except LetzshopClientError as e: - logger.error(f"Job {job_id}: Letzshop API error: {e}") - if job is not None: - try: - job.status = "failed" - job.error_message = f"Letzshop API error: {e}" - job.completed_at = datetime.now(UTC) - - # Get vendor name for notification - order_service = _get_order_service(db) - vendor = order_service.get_vendor(vendor_id) - vendor_name = vendor.name if vendor else f"Vendor {vendor_id}" - - # Create admin notification for sync failure - admin_notification_service.notify_order_sync_failure( - db=db, - vendor_name=vendor_name, - error_message=f"Historical import failed: {str(e)[:150]}", - vendor_id=vendor_id, - ) - - db.commit() - - creds_service = _get_credentials_service(db) - creds_service.update_sync_status(vendor_id, "failed", str(e)) - except Exception as commit_error: - logger.error(f"Job {job_id}: Failed to update job status: {commit_error}") - db.rollback() - - except Exception as e: - logger.error(f"Job {job_id}: Unexpected error: {e}", exc_info=True) - if job is not None: - try: - job.status = "failed" - job.error_message = str(e) - job.completed_at = datetime.now(UTC) - - # Get vendor name for notification - order_service = _get_order_service(db) - vendor = order_service.get_vendor(vendor_id) - vendor_name = vendor.name if vendor else f"Vendor {vendor_id}" - - # Create admin notification for critical error - admin_notification_service.notify_critical_error( - db=db, - error_type="Historical Import", - error_message=f"Import job {job_id} failed for {vendor_name}: {str(e)[:150]}", - details={"job_id": job_id, "vendor_id": vendor_id, "vendor_name": vendor_name}, - ) - - db.commit() - except Exception as commit_error: - logger.error(f"Job {job_id}: Failed to update job status: {commit_error}") - db.rollback() - - finally: - if hasattr(db, "close") and callable(db.close): - try: - db.close() - except Exception as close_error: - logger.error(f"Job {job_id}: Error closing database session: {close_error}") - - -# ============================================================================= -# Vendor Directory Sync -# ============================================================================= - - -def sync_letzshop_vendor_directory() -> dict[str, Any]: - """ - Sync Letzshop vendor directory to local cache. - - This task fetches all vendors from Letzshop's public GraphQL API - and updates the local letzshop_vendor_cache table. - - Should be run periodically (e.g., daily) via Celery beat. - - Returns: - Dictionary with sync statistics. - """ - db = SessionLocal() - stats = {} - - try: - logger.info("Starting Letzshop vendor directory sync task...") - - sync_service = LetzshopVendorSyncService(db) - - def progress_callback(page: int, fetched: int, total: int): - """Log progress during sync.""" - logger.info(f"Vendor sync progress: page {page}, {fetched}/{total} vendors") - - stats = sync_service.sync_all_vendors(progress_callback=progress_callback) - - logger.info( - f"Vendor directory sync completed: " - f"{stats.get('created', 0)} created, " - f"{stats.get('updated', 0)} updated, " - f"{stats.get('errors', 0)} errors" - ) - - # Send admin notification if there were errors - if stats.get("errors", 0) > 0: - admin_notification_service.notify_system_info( - db=db, - title="Letzshop Vendor Sync Completed with Errors", - message=( - f"Synced {stats.get('total_fetched', 0)} vendors. " - f"Errors: {stats.get('errors', 0)}" - ), - details=stats, - ) - - return stats - - except Exception as e: - logger.error(f"Vendor directory sync failed: {e}", exc_info=True) - - # Notify admins of failure - try: - admin_notification_service.notify_critical_error( - db=db, - error_type="Vendor Directory Sync", - error_message=f"Failed to sync Letzshop vendor directory: {str(e)[:200]}", - details={"error": str(e)}, - ) - db.commit() - except Exception: - pass - - raise - - finally: - if hasattr(db, "close") and callable(db.close): - try: - db.close() - except Exception as close_error: - logger.error(f"Error closing database session: {close_error}") diff --git a/app/tasks/subscription_tasks.py b/app/tasks/subscription_tasks.py deleted file mode 100644 index 63067bd0..00000000 --- a/app/tasks/subscription_tasks.py +++ /dev/null @@ -1,318 +0,0 @@ -# app/tasks/subscription_tasks.py -""" -Background tasks for subscription management. - -Provides scheduled tasks for: -- Resetting period counters at billing period end -- Expiring trials without payment methods -- Syncing subscription status with Stripe -- Capturing daily capacity snapshots -""" - -import logging -from datetime import UTC, datetime, timedelta - -from app.core.database import SessionLocal -from app.services.stripe_service import stripe_service -from app.modules.billing.models import SubscriptionStatus, VendorSubscription - -logger = logging.getLogger(__name__) - - -async def reset_period_counters(): - """ - Reset order counters for subscriptions whose billing period has ended. - - Should run daily. Resets orders_this_period to 0 and updates period dates. - """ - db = SessionLocal() - now = datetime.now(UTC) - reset_count = 0 - - try: - # Find subscriptions where period has ended - expired_periods = ( - db.query(VendorSubscription) - .filter( - VendorSubscription.period_end <= now, - VendorSubscription.status.in_(["active", "trial"]), - ) - .all() - ) - - for subscription in expired_periods: - old_period_end = subscription.period_end - - # Reset counters - subscription.orders_this_period = 0 - subscription.orders_limit_reached_at = None - - # Set new period dates - if subscription.is_annual: - subscription.period_start = now - subscription.period_end = now + timedelta(days=365) - else: - subscription.period_start = now - subscription.period_end = now + timedelta(days=30) - - subscription.updated_at = now - reset_count += 1 - - logger.info( - f"Reset period counters for vendor {subscription.vendor_id}: " - f"old_period_end={old_period_end}, new_period_end={subscription.period_end}" - ) - - db.commit() - logger.info(f"Reset period counters for {reset_count} subscriptions") - - except Exception as e: - logger.error(f"Error resetting period counters: {e}") - db.rollback() - raise - finally: - db.close() - - return {"reset_count": reset_count} - - -async def check_trial_expirations(): - """ - Check for expired trials and update their status. - - Trials without a payment method are marked as expired. - Trials with a payment method transition to active. - - Should run daily. - """ - db = SessionLocal() - now = datetime.now(UTC) - expired_count = 0 - activated_count = 0 - - try: - # Find expired trials - expired_trials = ( - db.query(VendorSubscription) - .filter( - VendorSubscription.status == SubscriptionStatus.TRIAL.value, - VendorSubscription.trial_ends_at <= now, - ) - .all() - ) - - for subscription in expired_trials: - if subscription.stripe_payment_method_id: - # Has payment method - activate - subscription.status = SubscriptionStatus.ACTIVE.value - activated_count += 1 - logger.info( - f"Activated subscription for vendor {subscription.vendor_id} " - f"(trial ended with payment method)" - ) - else: - # No payment method - expire - subscription.status = SubscriptionStatus.EXPIRED.value - expired_count += 1 - logger.info( - f"Expired trial for vendor {subscription.vendor_id} " - f"(no payment method)" - ) - - subscription.updated_at = now - - db.commit() - logger.info( - f"Trial expiration check: {expired_count} expired, {activated_count} activated" - ) - - except Exception as e: - logger.error(f"Error checking trial expirations: {e}") - db.rollback() - raise - finally: - db.close() - - return {"expired_count": expired_count, "activated_count": activated_count} - - -async def sync_stripe_status(): - """ - Sync subscription status with Stripe. - - Fetches current status from Stripe and updates local records. - Handles cases where Stripe status differs from local status. - - Should run hourly. - """ - if not stripe_service.is_configured: - logger.warning("Stripe not configured, skipping sync") - return {"synced": 0, "skipped": True} - - db = SessionLocal() - synced_count = 0 - error_count = 0 - - try: - # Find subscriptions with Stripe IDs - subscriptions = ( - db.query(VendorSubscription) - .filter(VendorSubscription.stripe_subscription_id.isnot(None)) - .all() - ) - - for subscription in subscriptions: - try: - # Fetch from Stripe - stripe_sub = stripe_service.get_subscription( - subscription.stripe_subscription_id - ) - - if not stripe_sub: - logger.warning( - f"Stripe subscription {subscription.stripe_subscription_id} " - f"not found for vendor {subscription.vendor_id}" - ) - continue - - # Map Stripe status to local status - status_map = { - "active": SubscriptionStatus.ACTIVE.value, - "trialing": SubscriptionStatus.TRIAL.value, - "past_due": SubscriptionStatus.PAST_DUE.value, - "canceled": SubscriptionStatus.CANCELLED.value, - "unpaid": SubscriptionStatus.PAST_DUE.value, - "incomplete": SubscriptionStatus.TRIAL.value, - "incomplete_expired": SubscriptionStatus.EXPIRED.value, - } - - new_status = status_map.get(stripe_sub.status) - if new_status and new_status != subscription.status: - old_status = subscription.status - subscription.status = new_status - subscription.updated_at = datetime.now(UTC) - logger.info( - f"Updated vendor {subscription.vendor_id} status: " - f"{old_status} -> {new_status} (from Stripe)" - ) - - # Update period dates from Stripe - if stripe_sub.current_period_start: - subscription.period_start = datetime.fromtimestamp( - stripe_sub.current_period_start, tz=UTC - ) - if stripe_sub.current_period_end: - subscription.period_end = datetime.fromtimestamp( - stripe_sub.current_period_end, tz=UTC - ) - - # Update payment method - if stripe_sub.default_payment_method: - subscription.stripe_payment_method_id = ( - stripe_sub.default_payment_method - if isinstance(stripe_sub.default_payment_method, str) - else stripe_sub.default_payment_method.id - ) - - synced_count += 1 - - except Exception as e: - logger.error( - f"Error syncing subscription {subscription.stripe_subscription_id}: {e}" - ) - error_count += 1 - - db.commit() - logger.info(f"Stripe sync complete: {synced_count} synced, {error_count} errors") - - except Exception as e: - logger.error(f"Error in Stripe sync task: {e}") - db.rollback() - raise - finally: - db.close() - - return {"synced_count": synced_count, "error_count": error_count} - - -async def cleanup_stale_subscriptions(): - """ - Clean up subscriptions in inconsistent states. - - Handles edge cases like: - - Subscriptions stuck in processing - - Old cancelled subscriptions past their period end - - Should run weekly. - """ - db = SessionLocal() - now = datetime.now(UTC) - cleaned_count = 0 - - try: - # Find cancelled subscriptions past their period end - stale_cancelled = ( - db.query(VendorSubscription) - .filter( - VendorSubscription.status == SubscriptionStatus.CANCELLED.value, - VendorSubscription.period_end < now - timedelta(days=30), - ) - .all() - ) - - for subscription in stale_cancelled: - # Mark as expired (fully terminated) - subscription.status = SubscriptionStatus.EXPIRED.value - subscription.updated_at = now - cleaned_count += 1 - logger.info( - f"Marked stale cancelled subscription as expired: " - f"vendor {subscription.vendor_id}" - ) - - db.commit() - logger.info(f"Cleaned up {cleaned_count} stale subscriptions") - - except Exception as e: - logger.error(f"Error cleaning up stale subscriptions: {e}") - db.rollback() - raise - finally: - db.close() - - return {"cleaned_count": cleaned_count} - - -async def capture_capacity_snapshot(): - """ - Capture a daily snapshot of platform capacity metrics. - - Used for growth trending and capacity forecasting. - Should run daily (e.g., at midnight). - """ - from app.services.capacity_forecast_service import capacity_forecast_service - - db = SessionLocal() - - try: - snapshot = capacity_forecast_service.capture_daily_snapshot(db) - db.commit() - - logger.info( - f"Captured capacity snapshot: {snapshot.total_vendors} vendors, " - f"{snapshot.total_products} products" - ) - - return { - "snapshot_id": snapshot.id, - "snapshot_date": snapshot.snapshot_date.isoformat(), - "total_vendors": snapshot.total_vendors, - "total_products": snapshot.total_products, - } - - except Exception as e: - logger.error(f"Error capturing capacity snapshot: {e}") - db.rollback() - raise - finally: - db.close() diff --git a/app/tasks/test_runner_tasks.py b/app/tasks/test_runner_tasks.py deleted file mode 100644 index 116ad2c1..00000000 --- a/app/tasks/test_runner_tasks.py +++ /dev/null @@ -1,61 +0,0 @@ -# app/tasks/test_runner_tasks.py -"""Background tasks for test runner.""" - -import logging - -from app.core.database import SessionLocal -from app.services.test_runner_service import test_runner_service -from app.modules.dev_tools.models import TestRun - -logger = logging.getLogger(__name__) - - -async def execute_test_run( - run_id: int, - test_path: str = "tests", - extra_args: list[str] | None = None, -): - """Background task to execute pytest tests. - - Args: - run_id: ID of the TestRun record - test_path: Path to tests (relative to project root) - extra_args: Additional pytest arguments - """ - db = SessionLocal() - test_run = None - - try: - # Get the test run record - test_run = db.query(TestRun).filter(TestRun.id == run_id).first() - if not test_run: - logger.error(f"Test run {run_id} not found") - return - - logger.info(f"Starting test execution: Run {run_id}, Path: {test_path}") - - # Execute the tests - test_runner_service._execute_tests(db, test_run, test_path, extra_args) - db.commit() - - logger.info( - f"Test run {run_id} completed: " - f"status={test_run.status}, passed={test_run.passed}, " - f"failed={test_run.failed}, duration={test_run.duration_seconds:.1f}s" - ) - - except Exception as e: - logger.error(f"Test run {run_id} failed: {e}", exc_info=True) - if test_run is not None: - try: - test_run.status = "error" - db.commit() - except Exception as commit_error: - logger.error(f"Failed to update test run status: {commit_error}") - db.rollback() - finally: - if hasattr(db, "close") and callable(db.close): - try: - db.close() - except Exception as close_error: - logger.error(f"Error closing database session: {close_error}") diff --git a/app/templates/admin/base.html b/app/templates/admin/base.html index d7e4bba8..52f362eb 100644 --- a/app/templates/admin/base.html +++ b/app/templates/admin/base.html @@ -104,7 +104,7 @@ - + @@ -128,7 +128,7 @@ - + diff --git a/app/templates/admin/monitoring.html b/app/templates/admin/monitoring.html deleted file mode 100644 index ed52e166..00000000 --- a/app/templates/admin/monitoring.html +++ /dev/null @@ -1,12 +0,0 @@ -{# standalone - Minimal monitoring page without admin chrome #} - - - - - - System monitoring - - - <-- System monitoring --> - - diff --git a/app/templates/admin/platform-homepage.html b/app/templates/admin/platform-homepage.html deleted file mode 100644 index 44c3130c..00000000 --- a/app/templates/admin/platform-homepage.html +++ /dev/null @@ -1,228 +0,0 @@ -{# app/templates/admin/platform-homepage.html #} -{% extends "admin/base.html" %} -{% from 'shared/macros/alerts.html' import loading_state, error_state, alert_dynamic %} -{% from 'shared/macros/headers.html' import page_header_flex, action_button %} -{% from 'shared/macros/richtext.html' import quill_css, quill_js, quill_editor %} - -{% block title %}Platform Homepage Manager{% endblock %} - -{% block alpine_data %}platformHomepageManager(){% endblock %} - -{% block quill_css %} -{{ quill_css() }} -{% endblock %} - -{% block quill_script %} -{{ quill_js() }} -{% endblock %} - -{% block content %} -{# Note: Subtitle has inline HTML link, so using page_header_flex with custom structure #} -
-
-

Platform Homepage

-

- Manage your platform's main landing page at localhost:8000 -

-
-
- - - Preview - - {{ action_button('Save Changes', 'Saving...', 'saving', 'savePage()') }} -
-
- -{{ loading_state('Loading homepage...') }} - -{{ error_state('Error loading homepage') }} - -{{ alert_dynamic(type='success', message_var='successMessage', show_condition='successMessage') }} - - - -{% endblock %} - -{% block extra_scripts %} - -{% endblock %} diff --git a/app/templates/admin/user-detail.html b/app/templates/admin/user-detail.html deleted file mode 100644 index cd08f985..00000000 --- a/app/templates/admin/user-detail.html +++ /dev/null @@ -1,225 +0,0 @@ -{# app/templates/admin/user-detail.html #} -{% extends "admin/base.html" %} -{% from 'shared/macros/alerts.html' import loading_state, error_state %} -{% from 'shared/macros/headers.html' import detail_page_header %} - -{% block title %}User Details{% endblock %} - -{% block alpine_data %}adminUserDetail(){% endblock %} - -{% block content %} -{% call detail_page_header("user?.full_name || user?.username || 'User Details'", '/admin/users', subtitle_show='user') %} - @ - | - -{% endcall %} - -{{ loading_state('Loading user details...') }} - -{{ error_state('Error loading user') }} - - -
- -
-

- Quick Actions -

-
- - - Edit User - - - -
-
- - -
- -
-
- -
-
-

- Role -

-

- - -

-
-
- - -
-
- -
-
-

- Status -

-

- - -

-
-
- - -
-
- -
-
-

- Companies -

-

- 0 -

-
-
- - -
-
- -
-
-

- Registered -

-

- - -

-
-
-
- - -
- -
-

- Account Information -

-
-
-

Username

-

@

-
-
-

Email

-

-

-
-
-

Email Verified

- - -
-
-
- - -
-

- Personal Information -

-
-
-

Full Name

-

-

-
-
-

First Name

-

-

-
-
-

Last Name

-

-

-
-
-
-
- - -
-

- Activity Information -

-
-
-

Last Login

-

-

-
-
-

Created At

-

-

-
-
-

Last Updated

-

-

-
-
-
- - -
-

- Ownership & Memberships -

-
-
-
- -

Companies Owned

-
-

-

- Companies where this user is the owner -

-
-
-
- -

Vendor Memberships

-
-

-

- Vendors where this user is a team member -

-
-
-
-
-{% endblock %} - -{% block extra_scripts %} - -{% endblock %} diff --git a/app/templates/admin/user-edit.html b/app/templates/admin/user-edit.html deleted file mode 100644 index 01878d9e..00000000 --- a/app/templates/admin/user-edit.html +++ /dev/null @@ -1,273 +0,0 @@ -{# app/templates/admin/user-edit.html #} -{% extends "admin/base.html" %} -{% from 'shared/macros/alerts.html' import loading_state %} -{% from 'shared/macros/headers.html' import edit_page_header %} - -{% block title %}Edit User{% endblock %} - -{% block alpine_data %}adminUserEdit(){% endblock %} - -{% block content %} -{% call edit_page_header('Edit User', '/admin/users', subtitle_show='user', back_label='Back to Users') %} - @ -{% endcall %} - -{{ loading_state('Loading user...', show_condition='loadingUser') }} - - -
- -
-

- Quick Actions -

-
- - - -
- - - - Active - - - Inactive - -
-
-
- - -
-
- -
-

- Account Information -

- - - - - - - - - - - - -
- - -
-

- Personal Information -

- - - - - - - - - -
-
- - - - - -
- - Cancel - - -
-
- - -
-

- More Actions -

-
- - - - View User - - - - -
-

- - - User cannot be deleted while they own companies ( companies). - - - User can be deleted. - -

-
-
-{% endblock %} - -{% block extra_scripts %} - -{% endblock %} diff --git a/app/templates/admin/users.html b/app/templates/admin/users.html deleted file mode 100644 index 948fc6e3..00000000 --- a/app/templates/admin/users.html +++ /dev/null @@ -1,244 +0,0 @@ -{# app/templates/admin/users.html #} -{% extends "admin/base.html" %} -{% from 'shared/macros/pagination.html' import pagination %} -{% from 'shared/macros/headers.html' import page_header %} -{% from 'shared/macros/alerts.html' import loading_state, error_state %} -{% from 'shared/macros/tables.html' import table_wrapper, table_header %} - -{% block title %}Users{% endblock %} - -{% block alpine_data %}adminUsers(){% endblock %} - -{% block content %} -{{ page_header('User Management', action_label='Create User', action_url='/admin/users/create', action_icon='user-plus') }} - -{{ loading_state('Loading users...') }} - -{{ error_state('Error loading users') }} - - -
- -
-
- -
-
-

- Total Users -

-

- 0 -

-
-
- - -
-
- -
-
-

- Active -

-

- 0 -

-
-
- - -
-
- -
-
-

- Inactive -

-

- 0 -

-
-
- - -
-
- -
-
-

- Admins -

-

- 0 -

-
-
-
- - -
-
- -
-
- -
- -
-
-
- - -
- - - - - - - - -
-
-
- - -
- {% call table_wrapper() %} - {{ table_header(['User', 'Email', 'Role', 'Status', 'Registered', 'Last Login', 'Actions']) }} - - - - - - - - {% endcall %} - - {{ pagination() }} -
-{% endblock %} - -{% block extra_scripts %} - -{% endblock %} diff --git a/app/templates/platform/base.html b/app/templates/public/base.html similarity index 98% rename from app/templates/platform/base.html rename to app/templates/public/base.html index e9b01e54..07b54811 100644 --- a/app/templates/platform/base.html +++ b/app/templates/public/base.html @@ -1,5 +1,5 @@ -{# app/templates/platform/base.html #} -{# Base template for platform public pages (homepage, about, faq, etc.) #} +{# app/templates/public/base.html #} +{# Base template for public pages (homepage, about, faq, pricing, signup, etc.) #} @@ -42,7 +42,7 @@ {# Tailwind CSS v4 (built locally via standalone CLI) #} - + {# Flag icons for language selector #} diff --git a/app/templates/storefront/home.html b/app/templates/storefront/home.html deleted file mode 100644 index 784fc8a9..00000000 --- a/app/templates/storefront/home.html +++ /dev/null @@ -1,153 +0,0 @@ -{# app/templates/storefront/home.html #} -{% extends "storefront/base.html" %} - -{% block title %}Home{% endblock %} - -{# Alpine.js component - uses shopLayoutData() from shop-layout.js #} -{% block alpine_data %}shopLayoutData(){% endblock %} - -{% block content %} -
- - {# Hero Section #} -
-

- Welcome to {{ vendor.name }} -

- {% if vendor.tagline %} -

- {{ vendor.tagline }} -

- {% endif %} - {% if vendor.description %} -

- {{ vendor.description }} -

- {% endif %} - - Shop Now - -
- - {# Featured Categories (if you have categories) #} -
-

- Shop by Category -

-
- {# Placeholder categories - will be loaded via API in future #} -
-
🏠
-

Home & Living

-
-
-
👔
-

Fashion

-
-
-
📱
-

Electronics

-
-
-
🎨
-

Arts & Crafts

-
-
-
- - {# Featured Products Section #} -
-
-

- Featured Products -

- - View All → - -
- - {# Loading State #} -
-
-
- - {# Products Grid #} -
- {# Coming Soon Notice #} -
-
🛍️
-

- Products Coming Soon -

-

- We're setting up our shop. Check back soon for amazing products! -

-

- For Developers: Products will be loaded dynamically from the API once you add them to the database. -

-
-
-
- - {# Why Shop With Us Section #} -
-

- Why Shop With Us -

-
-
-
🚚
-

Fast Shipping

-

- Quick and reliable delivery to your doorstep -

-
-
-
🔒
-

Secure Payment

-

- Your transactions are safe and encrypted -

-
-
-
💝
-

Quality Guarantee

-

- 100% satisfaction guaranteed on all products -

-
-
-
- -
-{% endblock %} - -{% block extra_scripts %} - -{% endblock %} diff --git a/app/templates/vendor/base.html b/app/templates/vendor/base.html index c39c42ef..03800483 100644 --- a/app/templates/vendor/base.html +++ b/app/templates/vendor/base.html @@ -63,7 +63,7 @@ - + diff --git a/docs/architecture/frontend-structure.md b/docs/architecture/frontend-structure.md index 398921ae..a2c6a4cf 100644 --- a/docs/architecture/frontend-structure.md +++ b/docs/architecture/frontend-structure.md @@ -50,8 +50,8 @@ app/ **Purpose:** Public-facing platform pages (marketing, info pages) **Location:** -- Templates: `app/templates/platform/` -- Static: `static/platform/` +- Templates: `app/templates/public/` +- Static: `static/public/` **Pages:** - Homepage (multiple layouts: default, minimal, modern) @@ -163,7 +163,7 @@ app/ Each frontend has its own static directory for frontend-specific assets. Use the appropriate directory based on which frontend the asset belongs to. -### Platform Static Assets (`static/platform/`) +### Platform Static Assets (`static/public/`) **JavaScript Files:** ```html @@ -270,7 +270,7 @@ Each frontend has its own static directory for frontend-specific assets. Use the Icon system (used by all 4 frontends) → static/shared/js/icons.js Admin dashboard chart → static/admin/js/charts.js Vendor product form → static/vendor/js/product-form.js -Platform hero image → static/platform/img/hero.jpg +Platform hero image → static/public/img/hero.jpg Storefront product carousel → static/storefront/js/carousel.js ``` @@ -338,31 +338,18 @@ app.mount("/static/modules/orders", StaticFiles(directory="app/modules/orders/st | **marketplace** | marketplace*.js, letzshop*.js | letzshop.js, marketplace.js, onboarding.js | - | | **monitoring** | monitoring.js, background-tasks.js, imports.js, logs.js | - | - | | **dev_tools** | testing-*.js, code-quality-*.js, icons-page.js, components.js | - | - | -| **cms** | content-pages.js, content-page-edit.js | content-pages.js, content-page-edit.js, media.js | - | +| **cms** | content-pages.js, content-page-edit.js | content-pages.js, content-page-edit.js, media.js | media-picker.js | | **analytics** | - | analytics.js | - | +| **tenancy** | companies*.js, vendors*.js, platforms*.js, admin-users*.js, users*.js | login.js, team.js, profile.js, settings.js | - | +| **core** | dashboard.js, settings.js, my-menu-config.js, login.js, init-alpine.js | dashboard.js, init-alpine.js | vendor-selector.js | ### Platform Static Files (Not in Modules) -These files remain in `static/` because they're platform-level, not module-specific: +Only framework-level files remain in `static/`: -**Admin Core (`static/admin/js/`):** -- `init-alpine.js` - Admin layout initialization -- `dashboard.js` - Main admin dashboard -- `login.js` - Admin authentication -- `platforms.js`, `platform-*.js` - Platform management (6 files) -- `vendors.js`, `vendor-*.js` - Vendor management at platform level (6 files) -- `companies.js`, `company-*.js` - Company management (3 files) -- `admin-users.js`, `admin-user-*.js` - Admin user management (3 files) -- `users.js`, `user-*.js` - Platform user management (4 files) -- `settings.js` - Platform settings - -**Vendor Core (`static/vendor/js/`):** -- `init-alpine.js` - Vendor layout initialization -- `dashboard.js` - Vendor main dashboard -- `login.js` - Vendor authentication -- `profile.js` - Vendor account settings -- `settings.js` - Vendor configuration -- `team.js` - Team member management +**Admin Framework (`static/admin/js/`):** +- `module-config.js` - Module system configuration UI +- `module-info.js` - Module information display **Shared Utilities (`static/shared/js/`):** - `api-client.js` - Core HTTP client with auth @@ -370,8 +357,7 @@ These files remain in `static/` because they're platform-level, not module-speci - `money.js` - Money/currency handling - `icons.js` - Heroicons SVG definitions - `log-config.js` - Centralized logging -- `vendor-selector.js` - Vendor autocomplete component -- `media-picker.js` - Media picker component +- `lib/` - Third-party libraries (Alpine.js, Chart.js, etc.) ### User Type Distinction @@ -379,13 +365,13 @@ The codebase distinguishes between three types of users: | Type | Management JS | Location | Description | |------|---------------|----------|-------------| -| **Admin Users** | admin-users.js | `static/admin/js/` | Platform administrators (super admins, platform admins) | -| **Platform Users** | users.js | `static/admin/js/` | Vendor/company users who log into the platform | +| **Admin Users** | admin-users.js | `app/modules/tenancy/static/admin/js/` | Platform administrators (super admins, platform admins) | +| **Platform Users** | users.js | `app/modules/tenancy/static/admin/js/` | Vendor/company users who log into the platform | | **Shop Customers** | customers.js | `app/modules/customers/static/` | End customers who buy from vendors | -This distinction is important: -- `admin-users.js` and `users.js` manage **internal platform users** → Stay in `static/admin/js/` -- `customers.js` manages **storefront customers** → Lives in the customers module +All user management JS is now in self-contained modules: +- `admin-users.js` and `users.js` are in the **tenancy** module (manages platform users) +- `customers.js` is in the **customers** module (manages storefront customers) --- @@ -466,7 +452,7 @@ All frontends communicate with backend via APIs: - `/api/v1/admin/*` - Admin APIs - `/api/v1/vendor/*` - Vendor APIs - `/api/v1/storefront/*` - Storefront APIs -- `/api/v1/platform/*` - Platform APIs +- `/api/v1/public/*` - Platform APIs **Benefits:** - Clear backend contracts diff --git a/docs/architecture/module-system.md b/docs/architecture/module-system.md index c1a78142..70a69114 100644 --- a/docs/architecture/module-system.md +++ b/docs/architecture/module-system.md @@ -509,11 +509,11 @@ Routes define API and page endpoints. They are auto-discovered from module direc | Type | Location | Discovery | Router Name | |------|----------|-----------|-------------| -| Vendor API | `routes/api/vendor.py` | `app/modules/routes.py` | `vendor_router` | | Admin API | `routes/api/admin.py` | `app/modules/routes.py` | `admin_router` | -| Shop API | `routes/api/shop.py` | `app/modules/routes.py` | `shop_router` | -| Vendor Pages | `routes/pages/vendor.py` | `app/modules/routes.py` | `vendor_router` | +| Vendor API | `routes/api/vendor.py` | `app/modules/routes.py` | `vendor_router` | +| Storefront API | `routes/api/storefront.py` | `app/modules/routes.py` | `router` | | Admin Pages | `routes/pages/admin.py` | `app/modules/routes.py` | `admin_router` | +| Vendor Pages | `routes/pages/vendor.py` | `app/modules/routes.py` | `vendor_router` | **Structure:** ``` @@ -521,9 +521,10 @@ app/modules/{module}/routes/ ├── __init__.py ├── api/ │ ├── __init__.py -│ ├── vendor.py # Must export vendor_router │ ├── admin.py # Must export admin_router -│ └── vendor_{feature}.py # Sub-routers aggregated in vendor.py +│ ├── vendor.py # Must export vendor_router +│ ├── storefront.py # Must export router (public storefront) +│ └── admin_{feature}.py # Sub-routers aggregated in admin.py └── pages/ ├── __init__.py └── vendor.py # Must export vendor_router @@ -754,22 +755,30 @@ class OrderAlreadyFulfilledError(OrderException): ### Templates -Jinja2 templates are auto-discovered from module `templates/` directories. +Jinja2 templates are auto-discovered from module `templates/` directories. The template loader searches `app/templates/` first (for shared templates), then each module's `templates/` directory. | Location | URL Pattern | Discovery | |----------|-------------|-----------| | `templates/{module}/vendor/*.html` | `/vendor/{vendor}/...` | Jinja2 loader | | `templates/{module}/admin/*.html` | `/admin/...` | Jinja2 loader | +| `templates/{module}/storefront/*.html` | `/storefront/...` | Jinja2 loader | +| `templates/{module}/public/*.html` | `/...` (platform pages) | Jinja2 loader | -**Structure:** +**Module Template Structure:** ``` app/modules/{module}/templates/ └── {module}/ + ├── admin/ + │ ├── list.html + │ └── partials/ # Module-specific partials + │ └── my-partial.html ├── vendor/ │ ├── index.html │ └── detail.html - └── admin/ - └── list.html + ├── storefront/ # Customer-facing shop pages + │ └── products.html + └── public/ # Platform marketing pages + └── pricing.html ``` **Template Reference:** @@ -782,6 +791,27 @@ return templates.TemplateResponse( ) ``` +**Shared Templates (in `app/templates/`):** + +Some templates remain in `app/templates/` because they are used across all modules: + +| Directory | Contents | Purpose | +|-----------|----------|---------| +| `admin/base.html` | Admin layout | Parent template all admin pages extend | +| `vendor/base.html` | Vendor layout | Parent template all vendor pages extend | +| `storefront/base.html` | Shop layout | Parent template all storefront pages extend | +| `platform/base.html` | Public layout | Parent template all public pages extend | +| `admin/errors/` | Error pages | HTTP error templates (404, 500, etc.) | +| `vendor/errors/` | Error pages | HTTP error templates for vendor | +| `storefront/errors/` | Error pages | HTTP error templates for storefront | +| `admin/partials/` | Shared partials | Header, sidebar used across admin | +| `vendor/partials/` | Shared partials | Header, sidebar used across vendor | +| `shared/macros/` | Jinja2 macros | Reusable UI components (buttons, forms, tables) | +| `shared/includes/` | Includes | Common HTML snippets | +| `invoices/` | PDF templates | Invoice PDF generation | + +These shared templates provide the "framework" that module templates build upon. Module templates extend base layouts and import shared macros. + --- ### Static Files diff --git a/docs/proposals/PLAN_storefront-module-restructure.md b/docs/archive/PLAN_storefront-module-restructure.md similarity index 100% rename from docs/proposals/PLAN_storefront-module-restructure.md rename to docs/archive/PLAN_storefront-module-restructure.md diff --git a/docs/proposals/SESSION_NOTE_2026-01-25_modular-platform-architecture.md b/docs/archive/SESSION_NOTE_2026-01-25_modular-platform-architecture.md similarity index 100% rename from docs/proposals/SESSION_NOTE_2026-01-25_modular-platform-architecture.md rename to docs/archive/SESSION_NOTE_2026-01-25_modular-platform-architecture.md diff --git a/docs/proposals/SESSION_NOTE_2026-01-26_self-contained-modules.md b/docs/archive/SESSION_NOTE_2026-01-26_self-contained-modules.md similarity index 100% rename from docs/proposals/SESSION_NOTE_2026-01-26_self-contained-modules.md rename to docs/archive/SESSION_NOTE_2026-01-26_self-contained-modules.md diff --git a/docs/proposals/SESSION_NOTE_2026-01-27_module-reclassification.md b/docs/archive/SESSION_NOTE_2026-01-27_module-reclassification.md similarity index 100% rename from docs/proposals/SESSION_NOTE_2026-01-27_module-reclassification.md rename to docs/archive/SESSION_NOTE_2026-01-27_module-reclassification.md diff --git a/docs/proposals/SESSION_NOTE_2026-01-28_module-config-migrations.md b/docs/archive/SESSION_NOTE_2026-01-28_module-config-migrations.md similarity index 100% rename from docs/proposals/SESSION_NOTE_2026-01-28_module-config-migrations.md rename to docs/archive/SESSION_NOTE_2026-01-28_module-config-migrations.md diff --git a/docs/proposals/SESSION_NOTE_2026-01-30_self-contained-module-routes.md b/docs/archive/SESSION_NOTE_2026-01-30_self-contained-module-routes.md similarity index 100% rename from docs/proposals/SESSION_NOTE_2026-01-30_self-contained-module-routes.md rename to docs/archive/SESSION_NOTE_2026-01-30_self-contained-module-routes.md diff --git a/docs/proposals/SESSION_NOTE_2026-01-31_tenancy-module-consolidation.md b/docs/archive/SESSION_NOTE_2026-01-31_tenancy-module-consolidation.md similarity index 100% rename from docs/proposals/SESSION_NOTE_2026-01-31_tenancy-module-consolidation.md rename to docs/archive/SESSION_NOTE_2026-01-31_tenancy-module-consolidation.md diff --git a/docs/proposals/humble-orbiting-otter.md b/docs/archive/humble-orbiting-otter.md similarity index 100% rename from docs/proposals/humble-orbiting-otter.md rename to docs/archive/humble-orbiting-otter.md diff --git a/docs/proposals/module-migration-plan.md b/docs/archive/module-migration-plan.md similarity index 100% rename from docs/proposals/module-migration-plan.md rename to docs/archive/module-migration-plan.md diff --git a/docs/proposals/multi-platform-cms-architecture-implementation-plan.md b/docs/archive/multi-platform-cms-architecture-implementation-plan.md similarity index 100% rename from docs/proposals/multi-platform-cms-architecture-implementation-plan.md rename to docs/archive/multi-platform-cms-architecture-implementation-plan.md diff --git a/docs/proposals/multi-platform-cms-architecture.md b/docs/archive/multi-platform-cms-architecture.md similarity index 100% rename from docs/proposals/multi-platform-cms-architecture.md rename to docs/archive/multi-platform-cms-architecture.md diff --git a/docs/proposals/section-based-homepage-plan.md b/docs/archive/section-based-homepage-plan.md similarity index 100% rename from docs/proposals/section-based-homepage-plan.md rename to docs/archive/section-based-homepage-plan.md diff --git a/docs/deployment/docker.md b/docs/deployment/docker.md index 36b6b907..126b4240 100644 --- a/docs/deployment/docker.md +++ b/docs/deployment/docker.md @@ -207,7 +207,7 @@ COPY . . RUN tailwindcss -i ./static/admin/css/tailwind.css -o ./static/admin/css/tailwind.output.css --minify \ && tailwindcss -i ./static/vendor/css/tailwind.css -o ./static/vendor/css/tailwind.output.css --minify \ && tailwindcss -i ./static/shop/css/tailwind.css -o ./static/shop/css/tailwind.output.css --minify \ - && tailwindcss -i ./static/platform/css/tailwind.css -o ./static/platform/css/tailwind.output.css --minify + && tailwindcss -i ./static/public/css/tailwind.css -o ./static/public/css/tailwind.output.css --minify # Create non-root user RUN useradd -m -u 1000 wizamart && chown -R wizamart:wizamart /app diff --git a/docs/development/migration/module-autodiscovery-migration.md b/docs/development/migration/module-autodiscovery-migration.md index de968d08..9d789339 100644 --- a/docs/development/migration/module-autodiscovery-migration.md +++ b/docs/development/migration/module-autodiscovery-migration.md @@ -78,56 +78,99 @@ The Wizamart platform has been migrating from a monolithic structure with code i - Created `app/modules/billing/routes/api/vendor_addons.py` - Deleted legacy billing routes from `app/api/v1/vendor/` -### Phase 6: Remaining Vendor Routes (2026-01-31) +### Phase 6: Vendor Routes Migration (2026-01-31) -#### Current Changes - Migrate analytics, usage, onboarding - **Deleted**: `app/api/v1/vendor/analytics.py` (duplicate - analytics module already auto-discovered) - **Created**: `app/modules/billing/routes/api/vendor_usage.py` (usage limits/upgrades) - **Created**: `app/modules/marketplace/routes/api/vendor_onboarding.py` (onboarding wizard) - **Deleted**: `app/api/v1/vendor/usage.py` (migrated to billing) - **Deleted**: `app/api/v1/vendor/onboarding.py` (migrated to marketplace) +- Migrated remaining vendor routes to respective modules + +### Phase 7: Admin Routes Migration (2026-01-31) + +Major admin route migration to modules. + +### Phase 8: Storefront Routes Auto-Discovery (2026-01-31) + +- Updated `app/modules/routes.py` to discover `storefront.py` files +- Added `get_storefront_api_routes()` function +- Updated `app/api/v1/storefront/__init__.py` to use auto-discovery +- 37 storefront routes now auto-discovered from 7 modules: + - cart, catalog, checkout, cms, customers, messaging, orders + +**Admin routes migrated to modules:** + +**Tenancy Module** (auth, users, companies, platforms, vendors): +- `admin_auth.py`, `admin_users.py`, `admin_admin_users.py` +- `admin_companies.py`, `admin_platforms.py`, `admin_vendors.py` +- `admin_vendor_domains.py` + +**Core Module** (dashboard, settings): +- `admin_dashboard.py`, `admin_settings.py` + +**Messaging Module** (messages, notifications, email templates): +- `admin_messages.py`, `admin_notifications.py`, `admin_email_templates.py` + +**Monitoring Module** (logs, tasks, tests, code quality, audit, platform health): +- `admin_logs.py`, `admin_tasks.py`, `admin_tests.py` +- `admin_code_quality.py`, `admin_audit.py`, `admin_platform_health.py` + +**CMS Module** (content pages, images, media, vendor themes): +- `admin_content_pages.py`, `admin_images.py` +- `admin_media.py`, `admin_vendor_themes.py` + +**Billing Module** (subscriptions, invoices, payments, features): +- `admin_subscriptions.py`, `admin_invoices.py`, `admin_features.py` + +**Inventory Module** (stock management): +- `admin.py` (inventory admin routes) + +**Orders Module** (order management, exceptions): +- `admin_orders.py`, `admin_order_item_exceptions.py` + +**Marketplace Module** (letzshop integration): +- `admin_letzshop.py`, `admin_marketplace.py` ## Current State -### Migrated to Modules (Auto-Discovered) +### ✅ Fully Migrated to Modules (Auto-Discovered) -| Module | Routes | Services | Models | Schemas | Tasks | -|--------|--------|----------|--------|---------|-------| -| analytics | API | Stats | Report | Stats | - | -| billing | API | Billing, Subscription | Tier, Subscription, Invoice | Billing | Subscription | -| catalog | API | Product | Product, Category | Product | - | -| cart | API | Cart | Cart, CartItem | Cart | Cleanup | -| checkout | API | Checkout | - | Checkout | - | -| cms | API, Pages | ContentPage | ContentPage, Section | CMS | - | -| customers | API | Customer | Customer | Customer | - | -| inventory | API | Inventory | Stock, Location | Inventory | - | -| marketplace | API | Import, Export, Sync | ImportJob | Marketplace | Import, Export | -| messaging | API | Message | Message | Message | - | -| orders | API | Order | Order, OrderItem | Order | - | -| payments | API | Payment, Stripe | Payment | Payment | - | +| Module | Admin Routes | Vendor Routes | Services | Models | Schemas | Tasks | +|--------|--------------|---------------|----------|--------|---------|-------| +| analytics | - | ✅ API | Stats | Report | Stats | - | +| billing | ✅ subscriptions, invoices, features | ✅ checkout, addons, usage | Billing, Subscription | Tier, Subscription, Invoice | Billing | Subscription | +| catalog | ✅ products | ✅ products | Product | Product, Category | Product | - | +| cart | - | ✅ API | Cart | Cart, CartItem | Cart | Cleanup | +| checkout | - | ✅ API | Checkout | - | Checkout | - | +| cms | ✅ content-pages, images, media, vendor-themes | ✅ content-pages, media | ContentPage | ContentPage, Section | CMS | - | +| core | ✅ dashboard, settings | ✅ dashboard, settings | - | - | - | - | +| customers | - | ✅ API | Customer | Customer | Customer | - | +| inventory | ✅ stock | ✅ stock | Inventory | Stock, Location | Inventory | - | +| marketplace | ✅ letzshop, marketplace | ✅ onboarding, letzshop | Import, Export, Sync | ImportJob | Marketplace | Import, Export | +| messaging | ✅ messages, notifications, email-templates | ✅ messages, notifications, email | Message | Message | Message | - | +| monitoring | ✅ logs, tasks, tests, code-quality, audit, platform-health | - | - | TestRun, CodeQuality | - | - | +| orders | ✅ orders, exceptions | ✅ orders | Order | Order, OrderItem | Order | - | +| payments | - | ✅ API | Payment, Stripe | Payment | Payment | - | +| tenancy | ✅ auth, users, admin-users, companies, platforms, vendors | ✅ auth, profile, team, info | - | - | - | - | + +### 🔒 Legacy Routes (Super Admin Only - Intentionally Kept) + +These files remain in `app/api/v1/admin/` as they are super-admin framework configuration: + +| File | Purpose | Why Legacy | +|------|---------|------------| +| `menu_config.py` | Navigation configuration | Framework-level config | +| `module_config.py` | Module settings | Framework-level config | +| `modules.py` | Module management | Framework-level config | + +**Note:** These are intentionally kept in legacy location as they configure the module system itself. ### Still in Legacy Locations (Need Migration) -#### Vendor Routes (`app/api/v1/vendor/`) -- `auth.py` - Authentication (belongs in core/tenancy) -- `dashboard.py` - Dashboard (belongs in core) -- `email_settings.py` - Email settings (belongs in messaging) -- `email_templates.py` - Email templates (belongs in messaging) -- `info.py` - Vendor info (belongs in tenancy) -- `media.py` - Media library (belongs in cms) -- `messages.py` - Messages (belongs in messaging) -- `notifications.py` - Notifications (belongs in messaging) -- `profile.py` - Profile (belongs in core/tenancy) -- `settings.py` - Settings (belongs in core) -- `team.py` - Team management (belongs in tenancy) - -#### Admin Routes (`app/api/v1/admin/`) -- Most files still in legacy location -- Target: Move to respective modules or tenancy module - #### Services (`app/services/`) -- 61 files still in legacy location -- Many are re-exports from modules +- Many files still in legacy location +- Some are re-exports from modules - Target: Move actual code to modules, delete re-exports #### Tasks (`app/tasks/`) @@ -146,10 +189,50 @@ The following rules enforce the module-first architecture: | MOD-018 | ERROR | Tasks must be in modules, not `app/tasks/` | | MOD-019 | WARNING | Schemas should be in modules, not `models/schema/` | +## Route Auto-Discovery Pattern + +### Admin Routes Structure + +Each module aggregates its admin routes in `routes/api/admin.py`: + +```python +# app/modules/{module}/routes/api/admin.py +from fastapi import APIRouter, Depends +from app.api.deps import require_module_access + +from .admin_feature1 import admin_feature1_router +from .admin_feature2 import admin_feature2_router + +admin_router = APIRouter( + dependencies=[Depends(require_module_access("{module}"))], +) + +admin_router.include_router(admin_feature1_router, tags=["admin-feature1"]) +admin_router.include_router(admin_feature2_router, tags=["admin-feature2"]) +``` + +### Vendor Routes Structure + +Similar pattern for vendor routes in `routes/api/vendor.py`: + +```python +# app/modules/{module}/routes/api/vendor.py +from fastapi import APIRouter, Depends +from app.api.deps import require_module_access + +from .vendor_feature1 import vendor_feature1_router + +vendor_router = APIRouter( + dependencies=[Depends(require_module_access("{module}"))], +) + +vendor_router.include_router(vendor_feature1_router, tags=["vendor-feature1"]) +``` + ## Next Steps -1. **Migrate remaining vendor routes** to appropriate modules -2. **Migrate admin routes** to modules +1. ✅ ~~Migrate remaining vendor routes~~ - COMPLETE +2. ✅ ~~Migrate admin routes~~ - COMPLETE (except super-admin framework config) 3. **Move services** from `app/services/` to module `services/` 4. **Move tasks** from `app/tasks/` to module `tasks/` 5. **Clean up re-exports** once all code is in modules @@ -168,3 +251,17 @@ Check for legacy location violations: python scripts/validate_architecture.py -d app/api/v1/vendor python scripts/validate_architecture.py -d app/services ``` + +Verify route count: + +```bash +python -c " +from main import app +routes = [r for r in app.routes if hasattr(r, 'path')] +print(f'Total routes: {len(routes)}') +admin = [r for r in routes if '/admin/' in r.path] +vendor = [r for r in routes if '/vendor/' in r.path] +print(f'Admin routes: {len(admin)}') +print(f'Vendor routes: {len(vendor)}') +" +``` diff --git a/docs/frontend/tailwind-css.md b/docs/frontend/tailwind-css.md index 1003274d..92026d89 100644 --- a/docs/frontend/tailwind-css.md +++ b/docs/frontend/tailwind-css.md @@ -22,7 +22,7 @@ Tailwind Standalone CLI (single binary, no npm) ├── static/admin/css/tailwind.css → tailwind.output.css (Admin) ├── static/vendor/css/tailwind.css → tailwind.output.css (Vendor) ├── static/shop/css/tailwind.css → tailwind.output.css (Shop) - └── static/platform/css/tailwind.css → tailwind.output.css (Platform) + └── static/public/css/tailwind.css → tailwind.output.css (Platform) ``` ### Key Files diff --git a/docs/implementation/platform-marketing-homepage.md b/docs/implementation/platform-marketing-homepage.md index b622699f..05f5935e 100644 --- a/docs/implementation/platform-marketing-homepage.md +++ b/docs/implementation/platform-marketing-homepage.md @@ -53,7 +53,7 @@ Based on `docs/marketing/pricing.md`: ### 1. Marketing Homepage (`/`) -**Template:** `app/templates/platform/homepage-wizamart.html` +**Template:** `app/templates/public/homepage-wizamart.html` **Sections:** @@ -84,7 +84,7 @@ Based on `docs/marketing/pricing.md`: ### 2. Pricing Page (`/pricing`) -**Template:** `app/templates/platform/pricing.html` +**Template:** `app/templates/public/pricing.html` Standalone page with: - Large tier cards @@ -94,7 +94,7 @@ Standalone page with: ### 3. Find Your Shop (`/find-shop`) -**Template:** `app/templates/platform/find-shop.html` +**Template:** `app/templates/public/find-shop.html` - URL input with examples - Real-time Letzshop vendor lookup @@ -103,7 +103,7 @@ Standalone page with: ### 4. Signup Wizard (`/signup`) -**Template:** `app/templates/platform/signup.html` +**Template:** `app/templates/public/signup.html` **4-Step Flow:** @@ -121,7 +121,7 @@ Standalone page with: ### 5. Signup Success (`/signup/success`) -**Template:** `app/templates/platform/signup-success.html` +**Template:** `app/templates/public/signup-success.html` - Success confirmation - Next steps checklist @@ -132,24 +132,24 @@ Standalone page with: ## API Endpoints -All endpoints under `/api/v1/platform/`: +All endpoints under `/api/v1/public/`: ### Pricing Endpoints ``` -GET /api/v1/platform/tiers +GET /api/v1/public/tiers Returns all public subscription tiers Response: TierResponse[] -GET /api/v1/platform/tiers/{tier_code} +GET /api/v1/public/tiers/{tier_code} Returns specific tier by code Response: TierResponse -GET /api/v1/platform/addons +GET /api/v1/public/addons Returns all active add-on products Response: AddOnResponse[] -GET /api/v1/platform/pricing +GET /api/v1/public/pricing Returns complete pricing info (tiers + addons + trial_days) Response: PricingResponse ``` @@ -157,17 +157,17 @@ GET /api/v1/platform/pricing ### Letzshop Vendor Endpoints ``` -GET /api/v1/platform/letzshop-vendors +GET /api/v1/public/letzshop-vendors Query params: ?search=&category=&city=&page=1&limit=20 Returns paginated vendor list (placeholder for future) Response: LetzshopVendorListResponse -POST /api/v1/platform/letzshop-vendors/lookup +POST /api/v1/public/letzshop-vendors/lookup Body: { "url": "letzshop.lu/vendors/my-shop" } Returns vendor info from URL lookup Response: LetzshopLookupResponse -GET /api/v1/platform/letzshop-vendors/{slug} +GET /api/v1/public/letzshop-vendors/{slug} Returns vendor info by slug Response: LetzshopVendorInfo ``` @@ -175,17 +175,17 @@ GET /api/v1/platform/letzshop-vendors/{slug} ### Signup Endpoints ``` -POST /api/v1/platform/signup/start +POST /api/v1/public/signup/start Body: { "tier_code": "professional", "is_annual": false } Creates signup session Response: { "session_id": "...", "tier_code": "...", "is_annual": false } -POST /api/v1/platform/signup/claim-vendor +POST /api/v1/public/signup/claim-vendor Body: { "session_id": "...", "letzshop_slug": "my-shop" } Claims Letzshop vendor for session Response: { "session_id": "...", "letzshop_slug": "...", "vendor_name": "..." } -POST /api/v1/platform/signup/create-account +POST /api/v1/public/signup/create-account Body: { "session_id": "...", "email": "user@example.com", @@ -197,17 +197,17 @@ POST /api/v1/platform/signup/create-account Creates User, Company, Vendor, Stripe Customer Response: { "session_id": "...", "user_id": 1, "vendor_id": 1, "stripe_customer_id": "cus_..." } -POST /api/v1/platform/signup/setup-payment +POST /api/v1/public/signup/setup-payment Body: { "session_id": "..." } Creates Stripe SetupIntent Response: { "session_id": "...", "client_secret": "seti_...", "stripe_customer_id": "cus_..." } -POST /api/v1/platform/signup/complete +POST /api/v1/public/signup/complete Body: { "session_id": "...", "setup_intent_id": "seti_..." } Completes signup, attaches payment method Response: { "success": true, "vendor_code": "...", "vendor_id": 1, "redirect_url": "...", "trial_ends_at": "..." } -GET /api/v1/platform/signup/session/{session_id} +GET /api/v1/public/signup/session/{session_id} Returns session status for resuming signup Response: { "session_id": "...", "step": "...", ... } ``` @@ -430,7 +430,7 @@ STRIPE_TRIAL_DAYS=30 ### Automated Tests -Test files located in `tests/integration/api/v1/platform/`: +Test files located in `tests/integration/api/v1/public/`: | File | Tests | Description | |------|-------|-------------| @@ -440,7 +440,7 @@ Test files located in `tests/integration/api/v1/platform/`: **Run tests:** ```bash -pytest tests/integration/api/v1/platform/ -v +pytest tests/integration/api/v1/public/ -v ``` **Test categories:** @@ -479,15 +479,15 @@ pytest tests/integration/api/v1/platform/ -v ```bash # Get pricing -curl http://localhost:8000/api/v1/platform/pricing +curl http://localhost:8000/api/v1/public/pricing # Lookup vendor -curl -X POST http://localhost:8000/api/v1/platform/letzshop-vendors/lookup \ +curl -X POST http://localhost:8000/api/v1/public/letzshop-vendors/lookup \ -H "Content-Type: application/json" \ -d '{"url": "letzshop.lu/vendors/test-shop"}' # Start signup -curl -X POST http://localhost:8000/api/v1/platform/signup/start \ +curl -X POST http://localhost:8000/api/v1/public/signup/start \ -H "Content-Type: application/json" \ -d '{"tier_code": "professional", "is_annual": false}' ``` diff --git a/docs/proposals/PLAN_documentation-consolidation.md b/docs/proposals/PLAN_documentation-consolidation.md new file mode 100644 index 00000000..1741e8b9 --- /dev/null +++ b/docs/proposals/PLAN_documentation-consolidation.md @@ -0,0 +1,129 @@ +# Documentation Consolidation Plan + +## Overview + +Many working documents (session notes, migration plans, proposals) now represent **implemented features**. This plan consolidates them into proper platform documentation. + +## Document Categories + +### 1. Implemented → Merge into Architecture/Feature Docs + +These documents describe features that are now implemented and should be merged into proper documentation. + +| Working Document | Target Location | Action | +|------------------|-----------------|--------| +| `proposals/SESSION_NOTE_2026-01-25_modular-platform-architecture.md` | `architecture/module-system.md` | Merge relevant content, archive original | +| `proposals/SESSION_NOTE_2026-01-26_self-contained-modules.md` | `architecture/module-system.md` | Merge relevant content, archive original | +| `proposals/SESSION_NOTE_2026-01-27_module-reclassification.md` | `architecture/module-system.md` | Merge relevant content, archive original | +| `proposals/SESSION_NOTE_2026-01-28_module-config-migrations.md` | `architecture/module-system.md` | Merge relevant content, archive original | +| `proposals/SESSION_NOTE_2026-01-30_self-contained-module-routes.md` | `architecture/module-system.md` | Merge relevant content, archive original | +| `proposals/SESSION_NOTE_2026-01-31_tenancy-module-consolidation.md` | `architecture/tenancy-module-migration.md` | Merge, archive original | +| `proposals/multi-platform-cms-architecture.md` | `features/content-management-system.md` | Merge as "How CMS Works" section | +| `proposals/multi-platform-cms-architecture-implementation-plan.md` | Archive | Implementation complete | +| `proposals/section-based-homepage-plan.md` | `features/platform-homepage.md` | Merge as technical details | +| `proposals/module-migration-plan.md` | Archive | Migration complete | +| `migration/language-i18n-implementation.md` | `architecture/language-i18n.md` | Merge (may already exist) | +| `migration/multi-marketplace-product-architecture.md` | `architecture/marketplace-integration.md` | Merge product sync details | +| `migration/vendor-operations-expansion.md` | `architecture/company-vendor-management.md` | Merge operations info | +| `migration/vendor-contact-inheritance.md` | `architecture/company-vendor-management.md` | Merge contact inheritance | +| `migration/product-migration-database-changes.md` | Archive | Migration complete | +| `migration/makefile-refactoring-complete.md` | Archive | Refactoring complete | +| `migration/tailwind-migration-plan.md` | Archive or `development/tailwind-css.md` | Verify if complete | + +### 2. Keep as Development Guides + +These should remain as development reference but may need cleanup. + +| Document | Location | Action | +|----------|----------|--------| +| `migration/database-migrations.md` | Keep | Update if needed | +| `migration/module-autodiscovery-migration.md` | Keep | Already updated, serves as history | +| `migration/svc-006-migration-plan.md` | `development/coding-standards.md` | Merge db.commit pattern | + +### 3. Future/Unimplemented → Keep in Proposals + +| Document | Status | Action | +|----------|--------|--------| +| `proposals/loyalty-program-analysis.md` | Future feature | Keep as proposal | +| `proposals/loyalty-phase2-interfaces-plan.md` | Future feature | Keep as proposal | +| `proposals/PLAN_storefront-module-restructure.md` | Evaluate status | Keep or archive | +| `proposals/humble-orbiting-otter.md` | Claude plan file | Archive | + +## Target Documentation Structure + +After consolidation, the docs should have: + +``` +docs/ +├── architecture/ +│ ├── module-system.md ← Comprehensive module docs (merged SESSION_NOTEs) +│ ├── multi-tenant.md ← Platform/Company/Vendor architecture +│ ├── language-i18n.md ← i18n implementation +│ ├── marketplace-integration.md ← Letzshop sync, product architecture +│ ├── company-vendor-management.md ← Vendor operations, contact inheritance +│ └── ... +├── features/ +│ ├── content-management-system.md ← CMS with multi-platform details +│ ├── platform-homepage.md ← Section-based homepage +│ ├── subscription-billing.md ← Billing features +│ └── ... +├── development/ +│ ├── creating-modules.md ← How to create new modules +│ ├── coding-standards.md ← db.commit pattern, etc. +│ ├── database-migrations.md ← Migration guide +│ └── migration/ ← Historical migration docs (reference) +│ └── module-autodiscovery-migration.md +├── proposals/ ← Future features only +│ ├── loyalty-program-analysis.md +│ └── loyalty-phase2-interfaces-plan.md +└── archive/ ← Completed plans (optional) + ├── SESSION_NOTE_*.md + └── *-implementation-plan.md +``` + +## Consolidation Steps + +### Phase 1: Module System Documentation +1. Review all SESSION_NOTE files for module system +2. Extract key decisions and final architecture +3. Update `architecture/module-system.md` with: + - Clear module classification (core/optional/internal) + - Route auto-discovery pattern + - Entity locations (routes, services, models, etc.) +4. Archive SESSION_NOTE files + +### Phase 2: CMS & Homepage Documentation +1. Merge CMS architecture into `features/content-management-system.md` +2. Merge homepage sections into `features/platform-homepage.md` +3. Archive implementation plans + +### Phase 3: Vendor & Marketplace Documentation +1. Update `architecture/company-vendor-management.md` with: + - Contact inheritance + - Vendor operations +2. Update `architecture/marketplace-integration.md` with: + - Multi-marketplace product architecture + - Sync patterns + +### Phase 4: Development Guides +1. Create/update `development/coding-standards.md` with db.commit pattern +2. Verify Tailwind migration status, archive if complete +3. Clean up migration folder + +### Phase 5: Archive +1. Create `docs/archive/` folder (or just delete completed plans) +2. Move completed implementation plans +3. Move SESSION_NOTE files + +## Priority Order + +1. **High**: Module system (most referenced, core architecture) +2. **High**: CMS/Homepage (user-facing features) +3. **Medium**: Vendor/Marketplace (operational) +4. **Low**: Archive cleanup + +## Notes + +- Keep `migration/module-autodiscovery-migration.md` as historical reference +- SESSION_NOTEs contain valuable context but are verbose for reference docs +- Focus on "what is" not "what was planned" in final docs diff --git a/docs/proposals/temp-loyalty b/docs/proposals/temp-loyalty deleted file mode 100644 index c4406440..00000000 --- a/docs/proposals/temp-loyalty +++ /dev/null @@ -1,229 +0,0 @@ -│ Loyalty Platform & Module Implementation Plan │ -│ │ -│ Overview │ -│ │ -│ Create a Loyalty Module for Wizamart that provides stamp-based and points-based loyalty programs with Google Wallet and Apple Wallet integration. │ -│ │ -│ Entity Mapping │ -│ ┌────────────────────┬────────────────────┬──────────────────────────────────────────────────────────┐ │ -│ │ Loyalty Concept │ Wizamart Entity │ Notes │ │ -│ ├────────────────────┼────────────────────┼──────────────────────────────────────────────────────────┤ │ -│ │ Merchant │ Company │ Existing - legal business entity │ │ -│ ├────────────────────┼────────────────────┼──────────────────────────────────────────────────────────┤ │ -│ │ Store │ Vendor │ Existing - brand/location │ │ -│ ├────────────────────┼────────────────────┼──────────────────────────────────────────────────────────┤ │ -│ │ Customer │ Customer │ Existing - has vendor_id, total_spent, marketing_consent │ │ -│ ├────────────────────┼────────────────────┼──────────────────────────────────────────────────────────┤ │ -│ │ Pass Object │ LoyaltyCard │ NEW - links customer to vendor's program │ │ -│ ├────────────────────┼────────────────────┼──────────────────────────────────────────────────────────┤ │ -│ │ Stamp/Points Event │ LoyaltyTransaction │ NEW - records all operations │ │ -│ ├────────────────────┼────────────────────┼──────────────────────────────────────────────────────────┤ │ -│ │ Staff PIN │ StaffPin │ NEW - fraud prevention │ │ -│ └────────────────────┴────────────────────┴──────────────────────────────────────────────────────────┘ │ -│ Database Models │ -│ │ -│ Core Models (5 tables) │ -│ │ -│ 1. loyalty_programs - Vendor's program configuration │ -│ - vendor_id (unique FK) │ -│ - loyalty_type: stamps | points | hybrid │ -│ - Stamps config: stamps_target, stamps_reward_description │ -│ - Points config: points_per_euro, points_rewards (JSON) │ -│ - Anti-fraud: cooldown_minutes, max_daily_stamps, require_staff_pin │ -│ - Branding: card_name, card_color, logo_url │ -│ - Wallet IDs: google_issuer_id, apple_pass_type_id │ -│ 2. loyalty_cards - Customer's card (PassObject) │ -│ - customer_id, program_id, vendor_id │ -│ - card_number (unique), qr_code_data │ -│ - Stamps: stamp_count, total_stamps_earned, stamps_redeemed │ -│ - Points: points_balance, total_points_earned, points_redeemed │ -│ - Wallet: google_object_id, apple_serial_number, apple_auth_token │ -│ - Timestamps: last_stamp_at, last_points_at │ -│ 3. loyalty_transactions - All loyalty events │ -│ - card_id, vendor_id, staff_pin_id │ -│ - transaction_type: stamp_earned | stamp_redeemed | points_earned | points_redeemed │ -│ - stamps_delta, points_delta, purchase_amount_cents │ -│ - Metadata: ip_address, user_agent, notes │ -│ 4. staff_pins - Fraud prevention │ -│ - program_id, vendor_id │ -│ - name, pin_hash (bcrypt) │ -│ - failed_attempts, locked_until │ -│ 5. apple_device_registrations - Apple Wallet push │ -│ - card_id, device_library_identifier, push_token │ -│ │ -│ Module Structure │ -│ │ -│ app/modules/loyalty/ │ -│ ├── __init__.py │ -│ ├── definition.py # ModuleDefinition (requires: customers) │ -│ ├── config.py # LOYALTY_ env vars │ -│ ├── exceptions.py │ -│ ├── models/ │ -│ │ ├── loyalty_program.py │ -│ │ ├── loyalty_card.py │ -│ │ ├── loyalty_transaction.py │ -│ │ ├── staff_pin.py │ -│ │ └── apple_device.py │ -│ ├── schemas/ │ -│ │ ├── program.py │ -│ │ ├── card.py │ -│ │ ├── stamp.py │ -│ │ ├── points.py │ -│ │ └── pin.py │ -│ ├── services/ │ -│ │ ├── program_service.py # Program CRUD │ -│ │ ├── card_service.py # Card enrollment, lookup │ -│ │ ├── stamp_service.py # Stamp logic + anti-fraud │ -│ │ ├── points_service.py # Points logic │ -│ │ ├── pin_service.py # PIN validation │ -│ │ ├── wallet_service.py # Unified wallet abstraction │ -│ │ ├── google_wallet_service.py │ -│ │ └── apple_wallet_service.py │ -│ ├── routes/ │ -│ │ ├── api/ │ -│ │ │ ├── admin.py # Platform admin │ -│ │ │ ├── vendor.py # Vendor dashboard │ -│ │ │ └── public.py # Enrollment, Apple web service │ -│ │ └── pages/ │ -│ ├── tasks/ │ -│ │ ├── point_expiration.py │ -│ │ └── wallet_sync.py │ -│ ├── migrations/versions/ │ -│ ├── locales/ │ -│ └── templates/ │ -│ │ -│ Key API Endpoints │ -│ │ -│ Public (Customer) │ -│ │ -│ - POST /api/v1/loyalty/enroll/{vendor_code} - Enroll in program │ -│ - GET /api/v1/loyalty/passes/apple/{serial}.pkpass - Download Apple pass │ -│ - Apple Web Service endpoints for device registration/updates │ -│ │ -│ Vendor (Staff) │ -│ │ -│ - POST /api/v1/vendor/loyalty/stamp - Add stamp (requires PIN) │ -│ - POST /api/v1/vendor/loyalty/points - Add points from purchase │ -│ - POST /api/v1/vendor/loyalty/*/redeem - Redeem for reward │ -│ - GET /api/v1/vendor/loyalty/cards - List customer cards │ -│ - GET /api/v1/vendor/loyalty/pins - Manage staff PINs │ -│ - GET /api/v1/vendor/loyalty/stats - Dashboard analytics │ -│ │ -│ Admin │ -│ │ -│ - GET /api/v1/admin/loyalty/programs - List all programs │ -│ - GET /api/v1/admin/loyalty/stats - Platform-wide stats │ -│ │ -│ Anti-Fraud System │ -│ │ -│ 1. Staff PIN - Required for all stamp/points operations │ -│ 2. Cooldown - Configurable minutes between stamps (default: 15) │ -│ 3. Daily Limit - Max stamps per card per day (default: 5) │ -│ 4. PIN Lockout - Lock after 5 failed attempts for 30 minutes │ -│ 5. Audit Trail - All transactions logged with IP/user agent │ -│ │ -│ Wallet Integration │ -│ │ -│ Google Wallet │ -│ │ -│ - Create LoyaltyClass when program created │ -│ - Create LoyaltyObject when customer enrolls │ -│ - PATCH object on stamp/points change │ -│ - Generate JWT-based "Add to Wallet" URL │ -│ │ -│ Apple Wallet │ -│ │ -│ - Generate .pkpass file (pass.json + images + signature) │ -│ - Implement Apple Web Service for device registration │ -│ - Send push notification on updates → device fetches new pass │ -│ │ -│ Implementation Phases │ -│ │ -│ Phase 1: MVP (Target) │ -│ │ -│ 1. Core Infrastructure │ -│ - Module structure, definition, exceptions │ -│ - Database models and migrations │ -│ - Program service (CRUD) │ -│ - Card service (enrollment, lookup) │ -│ 2. Stamp Loyalty │ -│ - Staff PIN service with lockout │ -│ - Stamp service with anti-fraud │ -│ - Transaction logging │ -│ - Vendor API routes │ -│ 3. Points Loyalty │ -│ - Points service │ -│ - Purchase-to-points calculation │ -│ - Redemption flow │ -│ 4. Wallet Integration │ -│ - Google Wallet service │ -│ - Apple .pkpass generation │ -│ - Apple Web Service endpoints │ -│ 5. Dashboard │ -│ - Vendor stats endpoint │ -│ - Transaction history │ -│ - QR code generation │ -│ │ -│ Phase 2: Future Enhancements │ -│ │ -│ - Rewards catalog with configurable tiers │ -│ - Customer tiers (Bronze/Silver/Gold) │ -│ - Referral program │ -│ - Gamification (spin wheel, scratch cards) │ -│ - POS integration │ -│ │ -│ Files to Create │ -│ │ -│ app/modules/loyalty/ │ -│ ├── __init__.py │ -│ ├── definition.py │ -│ ├── config.py │ -│ ├── exceptions.py │ -│ ├── models/__init__.py │ -│ ├── models/loyalty_program.py │ -│ ├── models/loyalty_card.py │ -│ ├── models/loyalty_transaction.py │ -│ ├── models/staff_pin.py │ -│ ├── models/apple_device.py │ -│ ├── schemas/__init__.py │ -│ ├── schemas/program.py │ -│ ├── schemas/card.py │ -│ ├── schemas/stamp.py │ -│ ├── schemas/points.py │ -│ ├── schemas/pin.py │ -│ ├── services/__init__.py │ -│ ├── services/program_service.py │ -│ ├── services/card_service.py │ -│ ├── services/stamp_service.py │ -│ ├── services/points_service.py │ -│ ├── services/pin_service.py │ -│ ├── services/wallet_service.py │ -│ ├── services/google_wallet_service.py │ -│ ├── services/apple_wallet_service.py │ -│ ├── routes/__init__.py │ -│ ├── routes/api/__init__.py │ -│ ├── routes/api/admin.py │ -│ ├── routes/api/vendor.py │ -│ ├── routes/api/public.py │ -│ ├── routes/pages/__init__.py │ -│ ├── tasks/__init__.py │ -│ ├── tasks/point_expiration.py │ -│ ├── migrations/__init__.py │ -│ ├── migrations/versions/__init__.py │ -│ └── locales/{en,fr,de,lu}.json │ -│ │ -│ Reference Patterns │ -│ │ -│ - Module definition: app/modules/billing/definition.py │ -│ - Models: app/modules/billing/models/subscription.py │ -│ - Services: app/modules/billing/services/subscription_service.py │ -│ - Customer integration: models/database/customer.py │ -│ │ -│ Verification │ -│ │ -│ 1. Run architecture validator: python scripts/validate_architecture.py │ -│ 2. Run migrations: alembic upgrade head │ -│ 3. Test enrollment flow via API │ -│ 4. Test stamp/points operations with PIN │ -│ 5. Verify wallet pass generation │ -│ 6. Check anti-fraud (cooldown, limits, lockout) \ No newline at end of file diff --git a/main.py b/main.py index ea0d4e7d..c0b47e4b 100644 --- a/main.py +++ b/main.py @@ -61,14 +61,13 @@ from app.core.lifespan import lifespan from app.exceptions import ServiceUnavailableException from app.exceptions.handler import setup_exception_handlers -# Import page routers (legacy routes - will be migrated to modules) -from app.routes import admin_pages, platform_pages, storefront_pages, vendor_pages - -# Import CMS module admin pages -from app.modules.cms.routes.pages.admin import router as cms_admin_pages - -# Module route auto-discovery -from app.modules.routes import discover_module_routes, get_vendor_page_routes +# Module route auto-discovery - all page routes now come from modules +from app.modules.routes import ( + get_admin_page_routes, + get_public_page_routes, + get_storefront_page_routes, + get_vendor_page_routes, +) from app.utils.i18n import get_jinja2_globals from middleware.context import ContextMiddleware from middleware.language import LanguageMiddleware @@ -304,87 +303,94 @@ def health_check(db: Session = Depends(get_db)): # ============================================================================ -# HTML PAGE ROUTES (Jinja2 Templates) +# HTML PAGE ROUTES (Jinja2 Templates) - AUTO-DISCOVERED FROM MODULES # ============================================================================ -# Include HTML page routes (these return rendered templates, not JSON) +# All page routes are now auto-discovered from self-contained modules. +# Routes are discovered from app/modules/*/routes/pages/{admin,vendor,public,storefront}.py logger.info("=" * 80) -logger.info("ROUTE REGISTRATION") +logger.info("ROUTE REGISTRATION (AUTO-DISCOVERY)") logger.info("=" * 80) -# Platform marketing pages (homepage, pricing, signup) -logger.info("Registering platform page routes: /*, /pricing, /find-shop, /signup") -app.include_router( - platform_pages.router, prefix="", tags=["platform-pages"], include_in_schema=False -) - -# Admin pages -logger.info("Registering admin page routes: /admin/*") -app.include_router( - admin_pages.router, prefix="/admin", tags=["admin-pages"], include_in_schema=False -) - -# CMS module admin pages (self-contained module) -# NOTE: These routes are specific (/content-pages/*) so they won't conflict -logger.info("Registering CMS admin page routes: /admin/content-pages/*") -app.include_router( - cms_admin_pages, prefix="/admin", tags=["cms-admin-pages"], include_in_schema=False -) - -# Vendor management pages (dashboard, products, orders, etc.) -# NOTE: Legacy routes - modules with their own routes will override these -logger.info("Registering vendor page routes: /vendor/{code}/*") -app.include_router( - vendor_pages.router, - prefix="/vendor", - tags=["vendor-pages"], - include_in_schema=False, -) - # ============================================================================= -# AUTO-DISCOVERED MODULE ROUTES +# PUBLIC PAGES (Marketing pages - homepage, pricing, signup, etc.) # ============================================================================= -# Self-contained modules register their routes automatically. -# Routes are discovered from app/modules/*/routes/pages/ and routes/api/ -# NOTE: CMS has catch-all route, so it's registered last via priority sorting +# Public pages are served at root level (/) for platform marketing +logger.info("Auto-discovering public (marketing) page routes...") +public_page_routes = get_public_page_routes() +logger.info(f" Found {len(public_page_routes)} public page route modules") -logger.info("Auto-discovering module page routes...") -vendor_page_routes = get_vendor_page_routes() - -# Sort routes: CMS last (has catch-all), others alphabetically -def route_priority(route): - if route.module_code == "cms": - return (1, route.module_code) # CMS last - return (0, route.module_code) - -vendor_page_routes.sort(key=route_priority) - -for route_info in vendor_page_routes: - logger.info(f" Registering {route_info.module_code} vendor pages: {route_info.prefix}") +for route_info in public_page_routes: + logger.info(f" Registering {route_info.module_code} public pages (priority={route_info.priority})") app.include_router( route_info.router, - prefix=route_info.prefix, + prefix="", # Public pages at root tags=route_info.tags, include_in_schema=route_info.include_in_schema, ) +# ============================================================================= +# ADMIN PAGES +# ============================================================================= +logger.info("Auto-discovering admin page routes...") +admin_page_routes = get_admin_page_routes() +logger.info(f" Found {len(admin_page_routes)} admin page route modules") + +for route_info in admin_page_routes: + logger.info(f" Registering {route_info.module_code} admin pages") + app.include_router( + route_info.router, + prefix="/admin", + tags=route_info.tags, + include_in_schema=route_info.include_in_schema, + ) + +# ============================================================================= +# VENDOR PAGES +# ============================================================================= +logger.info("Auto-discovering vendor page routes...") +vendor_page_routes = get_vendor_page_routes() +logger.info(f" Found {len(vendor_page_routes)} vendor page route modules") + +for route_info in vendor_page_routes: + logger.info(f" Registering {route_info.module_code} vendor pages (priority={route_info.priority})") + app.include_router( + route_info.router, + prefix="/vendor", + tags=route_info.tags, + include_in_schema=route_info.include_in_schema, + ) + +# ============================================================================= +# STOREFRONT PAGES (Customer Shop) +# ============================================================================= # Customer shop pages - Register at TWO prefixes: # 1. /storefront/* (for subdomain/custom domain modes) # 2. /vendors/{code}/storefront/* (for path-based development mode) -logger.info("Registering storefront page routes:") -logger.info(" - /storefront/* (subdomain/custom domain mode)") -logger.info(" - /vendors/{code}/storefront/* (path-based development mode)") +logger.info("Auto-discovering storefront page routes...") +storefront_page_routes = get_storefront_page_routes() +logger.info(f" Found {len(storefront_page_routes)} storefront page route modules") -app.include_router( - storefront_pages.router, prefix="/storefront", tags=["storefront-pages"], include_in_schema=False -) +# Register at /storefront/* (direct access) +logger.info(" Registering storefront routes at /storefront/*") +for route_info in storefront_page_routes: + logger.info(f" - {route_info.module_code} (priority={route_info.priority})") + app.include_router( + route_info.router, + prefix="/storefront", + tags=["storefront-pages"], + include_in_schema=False, + ) -app.include_router( - storefront_pages.router, - prefix="/vendors/{vendor_code}/storefront", - tags=["storefront-pages"], - include_in_schema=False, -) +# Register at /vendors/{code}/storefront/* (path-based development mode) +logger.info(" Registering storefront routes at /vendors/{code}/storefront/*") +for route_info in storefront_page_routes: + app.include_router( + route_info.router, + prefix="/vendors/{vendor_code}/storefront", + tags=["storefront-pages"], + include_in_schema=False, + ) # Add handler for /vendors/{vendor_code}/ root path @@ -402,7 +408,7 @@ async def vendor_root_path( if not vendor: raise HTTPException(status_code=404, detail=f"Vendor '{vendor_code}' not found") - from app.routes.storefront_pages import get_storefront_context + from app.modules.core.utils.page_context import get_storefront_context from app.modules.cms.services import content_page_service # Get platform_id (use platform from context or default to 1 for OMS) diff --git a/middleware/auth.py b/middleware/auth.py index 77349443..5d1487f7 100644 --- a/middleware/auth.py +++ b/middleware/auth.py @@ -27,7 +27,7 @@ from jose import jwt from passlib.context import CryptContext from sqlalchemy.orm import Session -from app.exceptions import ( +from app.modules.tenancy.exceptions import ( AdminRequiredException, InsufficientPermissionsException, InvalidCredentialsException, diff --git a/middleware/vendor_context.py b/middleware/vendor_context.py index 87e5a70e..700b9da6 100644 --- a/middleware/vendor_context.py +++ b/middleware/vendor_context.py @@ -565,7 +565,7 @@ def require_vendor_context(): def dependency(request: Request): vendor = get_current_vendor(request) if not vendor: - from app.exceptions import VendorNotFoundException + from app.modules.tenancy.exceptions import VendorNotFoundException raise VendorNotFoundException("unknown", identifier_type="context") return vendor diff --git a/scripts/debug_historical_import.py b/scripts/debug_historical_import.py index 078a6cbf..70a2310e 100644 --- a/scripts/debug_historical_import.py +++ b/scripts/debug_historical_import.py @@ -5,7 +5,7 @@ import sys sys.path.insert(0, ".") from app.core.database import SessionLocal -from app.services.letzshop.credentials_service import LetzshopCredentialsService +from app.modules.marketplace.services.letzshop.credentials_service import LetzshopCredentialsService from app.modules.marketplace.models import LetzshopHistoricalImportJob diff --git a/scripts/test_historical_import.py b/scripts/test_historical_import.py index ef1d0343..d95c38b3 100644 --- a/scripts/test_historical_import.py +++ b/scripts/test_historical_import.py @@ -597,7 +597,7 @@ def main(): print(f" Page {page}: {total} shipments fetched so far") # Import here to avoid issues if just doing debug - from app.services.letzshop.client_service import LetzshopClient + from app.modules.marketplace.services.letzshop.client_service import LetzshopClient # Create client and fetch with LetzshopClient(api_key=args.api_key) as client: diff --git a/scripts/test_logging_system.py b/scripts/test_logging_system.py index 46a96c00..287a2ebb 100644 --- a/scripts/test_logging_system.py +++ b/scripts/test_logging_system.py @@ -90,7 +90,7 @@ def test_logging_endpoints(): print("\n[4] Testing log settings...") try: from app.core.database import SessionLocal - from app.services.admin_settings_service import admin_settings_service + from app.modules.core.services.admin_settings_service import admin_settings_service db = SessionLocal() try: diff --git a/static/platform/css/.gitkeep b/static/public/css/.gitkeep similarity index 100% rename from static/platform/css/.gitkeep rename to static/public/css/.gitkeep diff --git a/static/platform/css/tailwind.css b/static/public/css/tailwind.css similarity index 100% rename from static/platform/css/tailwind.css rename to static/public/css/tailwind.css diff --git a/static/platform/css/tailwind.output.css b/static/public/css/tailwind.output.css similarity index 100% rename from static/platform/css/tailwind.output.css rename to static/public/css/tailwind.output.css diff --git a/static/platform/img/.gitkeep b/static/public/img/.gitkeep similarity index 100% rename from static/platform/img/.gitkeep rename to static/public/img/.gitkeep diff --git a/static/platform/js/.gitkeep b/static/public/js/.gitkeep similarity index 100% rename from static/platform/js/.gitkeep rename to static/public/js/.gitkeep diff --git a/tests/integration/api/v1/admin/test_letzshop.py b/tests/integration/api/v1/admin/test_letzshop.py index 805354ad..90be493f 100644 --- a/tests/integration/api/v1/admin/test_letzshop.py +++ b/tests/integration/api/v1/admin/test_letzshop.py @@ -178,7 +178,7 @@ class TestAdminLetzshopCredentialsAPI: class TestAdminLetzshopConnectionAPI: """Test admin Letzshop connection testing endpoints.""" - @patch("app.services.letzshop.client_service.requests.Session.post") + @patch("app.modules.marketplace.services.letzshop.client_service.requests.Session.post") def test_test_vendor_connection( self, mock_post, client, admin_headers, test_vendor ): @@ -206,7 +206,7 @@ class TestAdminLetzshopConnectionAPI: data = response.json() assert data["success"] is True - @patch("app.services.letzshop.client_service.requests.Session.post") + @patch("app.modules.marketplace.services.letzshop.client_service.requests.Session.post") def test_test_api_key_directly(self, mock_post, client, admin_headers): """Test any API key without associating with vendor.""" mock_response = MagicMock() @@ -290,7 +290,7 @@ class TestAdminLetzshopOrdersAPI: assert data["total"] == 1 assert data["orders"][0]["customer_email"] == "admin-test@example.com" - @patch("app.services.letzshop.client_service.requests.Session.post") + @patch("app.modules.marketplace.services.letzshop.client_service.requests.Session.post") def test_trigger_vendor_sync(self, mock_post, client, admin_headers, test_vendor): """Test triggering sync for a vendor.""" # Mock response diff --git a/tests/integration/api/v1/platform/__init__.py b/tests/integration/api/v1/platform/__init__.py deleted file mode 100644 index f19fba2a..00000000 --- a/tests/integration/api/v1/platform/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# tests/integration/api/v1/platform/__init__.py -"""Platform API integration tests.""" diff --git a/tests/integration/api/v1/public/__init__.py b/tests/integration/api/v1/public/__init__.py index e69de29b..8a3ad401 100644 --- a/tests/integration/api/v1/public/__init__.py +++ b/tests/integration/api/v1/public/__init__.py @@ -0,0 +1,8 @@ +# tests/integration/api/v1/public/__init__.py +"""Public API integration tests. + +Tests for unauthenticated public endpoints: +- /api/v1/public/signup/* - Multi-step signup flow +- /api/v1/public/pricing/* - Subscription tiers and pricing +- /api/v1/public/letzshop-vendors/* - Vendor lookup for signup +""" diff --git a/tests/integration/api/v1/platform/test_letzshop_vendors.py b/tests/integration/api/v1/public/test_letzshop_vendors.py similarity index 83% rename from tests/integration/api/v1/platform/test_letzshop_vendors.py rename to tests/integration/api/v1/public/test_letzshop_vendors.py index 3620d0f2..70c31287 100644 --- a/tests/integration/api/v1/platform/test_letzshop_vendors.py +++ b/tests/integration/api/v1/public/test_letzshop_vendors.py @@ -1,7 +1,7 @@ -# tests/integration/api/v1/platform/test_letzshop_vendors.py +# tests/integration/api/v1/public/test_letzshop_vendors.py """Integration tests for platform Letzshop vendor lookup API endpoints. -Tests the /api/v1/platform/letzshop-vendors/* endpoints. +Tests the /api/v1/public/letzshop-vendors/* endpoints. """ import pytest @@ -62,15 +62,15 @@ def claimed_vendor(db, test_company): @pytest.mark.api @pytest.mark.platform class TestLetzshopVendorLookupAPI: - """Test Letzshop vendor lookup endpoints at /api/v1/platform/letzshop-vendors/*.""" + """Test Letzshop vendor lookup endpoints at /api/v1/public/letzshop-vendors/*.""" # ========================================================================= - # GET /api/v1/platform/letzshop-vendors + # GET /api/v1/public/letzshop-vendors # ========================================================================= def test_list_vendors_returns_empty_list(self, client): """Test listing vendors returns empty list (placeholder).""" - response = client.get("/api/v1/platform/letzshop-vendors") + response = client.get("/api/v1/public/letzshop-vendors") assert response.status_code == 200 data = response.json() @@ -83,7 +83,7 @@ class TestLetzshopVendorLookupAPI: def test_list_vendors_with_pagination(self, client): """Test listing vendors with pagination parameters.""" - response = client.get("/api/v1/platform/letzshop-vendors?page=2&limit=10") + response = client.get("/api/v1/public/letzshop-vendors?page=2&limit=10") assert response.status_code == 200 data = response.json() @@ -92,7 +92,7 @@ class TestLetzshopVendorLookupAPI: def test_list_vendors_with_search(self, client): """Test listing vendors with search parameter.""" - response = client.get("/api/v1/platform/letzshop-vendors?search=my-shop") + response = client.get("/api/v1/public/letzshop-vendors?search=my-shop") assert response.status_code == 200 data = response.json() @@ -101,7 +101,7 @@ class TestLetzshopVendorLookupAPI: def test_list_vendors_with_filters(self, client): """Test listing vendors with category and city filters.""" response = client.get( - "/api/v1/platform/letzshop-vendors?category=fashion&city=luxembourg" + "/api/v1/public/letzshop-vendors?category=fashion&city=luxembourg" ) assert response.status_code == 200 @@ -111,17 +111,17 @@ class TestLetzshopVendorLookupAPI: def test_list_vendors_limit_validation(self, client): """Test that limit parameter is validated.""" # Maximum limit is 50 - response = client.get("/api/v1/platform/letzshop-vendors?limit=100") + response = client.get("/api/v1/public/letzshop-vendors?limit=100") assert response.status_code == 422 # ========================================================================= - # POST /api/v1/platform/letzshop-vendors/lookup + # POST /api/v1/public/letzshop-vendors/lookup # ========================================================================= def test_lookup_vendor_by_full_url(self, client): """Test looking up vendor by full Letzshop URL.""" response = client.post( - "/api/v1/platform/letzshop-vendors/lookup", + "/api/v1/public/letzshop-vendors/lookup", json={"url": "https://letzshop.lu/vendors/my-test-shop"}, ) @@ -134,7 +134,7 @@ class TestLetzshopVendorLookupAPI: def test_lookup_vendor_by_url_with_language(self, client): """Test looking up vendor by URL with language prefix.""" response = client.post( - "/api/v1/platform/letzshop-vendors/lookup", + "/api/v1/public/letzshop-vendors/lookup", json={"url": "https://letzshop.lu/en/vendors/my-shop"}, ) @@ -146,7 +146,7 @@ class TestLetzshopVendorLookupAPI: def test_lookup_vendor_by_url_without_protocol(self, client): """Test looking up vendor by URL without https://.""" response = client.post( - "/api/v1/platform/letzshop-vendors/lookup", + "/api/v1/public/letzshop-vendors/lookup", json={"url": "letzshop.lu/vendors/test-shop"}, ) @@ -158,7 +158,7 @@ class TestLetzshopVendorLookupAPI: def test_lookup_vendor_by_slug_only(self, client): """Test looking up vendor by slug alone.""" response = client.post( - "/api/v1/platform/letzshop-vendors/lookup", + "/api/v1/public/letzshop-vendors/lookup", json={"url": "my-shop-name"}, ) @@ -170,7 +170,7 @@ class TestLetzshopVendorLookupAPI: def test_lookup_vendor_normalizes_slug(self, client): """Test that slug is normalized to lowercase.""" response = client.post( - "/api/v1/platform/letzshop-vendors/lookup", + "/api/v1/public/letzshop-vendors/lookup", json={"url": "https://letzshop.lu/vendors/MY-SHOP-NAME"}, ) @@ -181,7 +181,7 @@ class TestLetzshopVendorLookupAPI: def test_lookup_vendor_shows_claimed_status(self, client, claimed_vendor): """Test that lookup shows if vendor is already claimed.""" response = client.post( - "/api/v1/platform/letzshop-vendors/lookup", + "/api/v1/public/letzshop-vendors/lookup", json={"url": "claimed-shop"}, ) @@ -193,7 +193,7 @@ class TestLetzshopVendorLookupAPI: def test_lookup_vendor_shows_unclaimed_status(self, client): """Test that lookup shows if vendor is not claimed.""" response = client.post( - "/api/v1/platform/letzshop-vendors/lookup", + "/api/v1/public/letzshop-vendors/lookup", json={"url": "unclaimed-new-shop"}, ) @@ -205,7 +205,7 @@ class TestLetzshopVendorLookupAPI: def test_lookup_vendor_empty_url(self, client): """Test lookup with empty URL.""" response = client.post( - "/api/v1/platform/letzshop-vendors/lookup", + "/api/v1/public/letzshop-vendors/lookup", json={"url": ""}, ) @@ -217,7 +217,7 @@ class TestLetzshopVendorLookupAPI: def test_lookup_vendor_response_has_expected_fields(self, client): """Test that vendor lookup response has all expected fields.""" response = client.post( - "/api/v1/platform/letzshop-vendors/lookup", + "/api/v1/public/letzshop-vendors/lookup", json={"url": "test-vendor"}, ) @@ -230,12 +230,12 @@ class TestLetzshopVendorLookupAPI: assert "is_claimed" in vendor # ========================================================================= - # GET /api/v1/platform/letzshop-vendors/{slug} + # GET /api/v1/public/letzshop-vendors/{slug} # ========================================================================= def test_get_vendor_by_slug(self, client): """Test getting vendor by slug.""" - response = client.get("/api/v1/platform/letzshop-vendors/my-shop") + response = client.get("/api/v1/public/letzshop-vendors/my-shop") assert response.status_code == 200 data = response.json() @@ -246,7 +246,7 @@ class TestLetzshopVendorLookupAPI: def test_get_vendor_normalizes_slug(self, client): """Test that get vendor normalizes slug to lowercase.""" - response = client.get("/api/v1/platform/letzshop-vendors/MY-SHOP") + response = client.get("/api/v1/public/letzshop-vendors/MY-SHOP") assert response.status_code == 200 data = response.json() @@ -254,7 +254,7 @@ class TestLetzshopVendorLookupAPI: def test_get_claimed_vendor_shows_status(self, client, claimed_vendor): """Test that get vendor shows claimed status correctly.""" - response = client.get("/api/v1/platform/letzshop-vendors/claimed-shop") + response = client.get("/api/v1/public/letzshop-vendors/claimed-shop") assert response.status_code == 200 data = response.json() @@ -262,7 +262,7 @@ class TestLetzshopVendorLookupAPI: def test_get_unclaimed_vendor_shows_status(self, client): """Test that get vendor shows unclaimed status correctly.""" - response = client.get("/api/v1/platform/letzshop-vendors/new-unclaimed-shop") + response = client.get("/api/v1/public/letzshop-vendors/new-unclaimed-shop") assert response.status_code == 200 data = response.json() @@ -278,7 +278,7 @@ class TestLetzshopSlugExtraction: def test_extract_from_full_https_url(self, client): """Test extraction from https://letzshop.lu/vendors/slug.""" response = client.post( - "/api/v1/platform/letzshop-vendors/lookup", + "/api/v1/public/letzshop-vendors/lookup", json={"url": "https://letzshop.lu/vendors/cafe-luxembourg"}, ) assert response.json()["vendor"]["slug"] == "cafe-luxembourg" @@ -286,7 +286,7 @@ class TestLetzshopSlugExtraction: def test_extract_from_http_url(self, client): """Test extraction from http://letzshop.lu/vendors/slug.""" response = client.post( - "/api/v1/platform/letzshop-vendors/lookup", + "/api/v1/public/letzshop-vendors/lookup", json={"url": "http://letzshop.lu/vendors/my-shop"}, ) assert response.json()["vendor"]["slug"] == "my-shop" @@ -294,7 +294,7 @@ class TestLetzshopSlugExtraction: def test_extract_from_url_with_trailing_slash(self, client): """Test extraction from URL with trailing slash.""" response = client.post( - "/api/v1/platform/letzshop-vendors/lookup", + "/api/v1/public/letzshop-vendors/lookup", json={"url": "https://letzshop.lu/vendors/my-shop/"}, ) assert response.json()["vendor"]["slug"] == "my-shop" @@ -302,7 +302,7 @@ class TestLetzshopSlugExtraction: def test_extract_from_url_with_query_params(self, client): """Test extraction from URL with query parameters.""" response = client.post( - "/api/v1/platform/letzshop-vendors/lookup", + "/api/v1/public/letzshop-vendors/lookup", json={"url": "https://letzshop.lu/vendors/my-shop?ref=google"}, ) assert response.json()["vendor"]["slug"] == "my-shop" @@ -310,7 +310,7 @@ class TestLetzshopSlugExtraction: def test_extract_from_french_url(self, client): """Test extraction from French language URL.""" response = client.post( - "/api/v1/platform/letzshop-vendors/lookup", + "/api/v1/public/letzshop-vendors/lookup", json={"url": "https://letzshop.lu/fr/vendors/boulangerie-paul"}, ) assert response.json()["vendor"]["slug"] == "boulangerie-paul" @@ -318,7 +318,7 @@ class TestLetzshopSlugExtraction: def test_extract_from_german_url(self, client): """Test extraction from German language URL.""" response = client.post( - "/api/v1/platform/letzshop-vendors/lookup", + "/api/v1/public/letzshop-vendors/lookup", json={"url": "https://letzshop.lu/de/vendors/backerei-muller"}, ) assert response.json()["vendor"]["slug"] == "backerei-muller" diff --git a/tests/integration/api/v1/platform/test_pricing.py b/tests/integration/api/v1/public/test_pricing.py similarity index 86% rename from tests/integration/api/v1/platform/test_pricing.py rename to tests/integration/api/v1/public/test_pricing.py index 0cd789dd..082177c1 100644 --- a/tests/integration/api/v1/platform/test_pricing.py +++ b/tests/integration/api/v1/public/test_pricing.py @@ -1,7 +1,7 @@ -# tests/integration/api/v1/platform/test_pricing.py +# tests/integration/api/v1/public/test_pricing.py """Integration tests for platform pricing API endpoints. -Tests the /api/v1/platform/pricing/* endpoints. +Tests the /api/v1/public/pricing/* endpoints. """ import pytest @@ -18,15 +18,15 @@ from app.modules.billing.models import ( @pytest.mark.api @pytest.mark.platform class TestPlatformPricingAPI: - """Test platform pricing endpoints at /api/v1/platform/*.""" + """Test platform pricing endpoints at /api/v1/public/*.""" # ========================================================================= - # GET /api/v1/platform/tiers + # GET /api/v1/public/tiers # ========================================================================= def test_get_tiers_returns_all_public_tiers(self, client): """Test getting all subscription tiers.""" - response = client.get("/api/v1/platform/tiers") + response = client.get("/api/v1/public/tiers") assert response.status_code == 200 data = response.json() @@ -35,7 +35,7 @@ class TestPlatformPricingAPI: def test_get_tiers_has_expected_fields(self, client): """Test that tier response has all expected fields.""" - response = client.get("/api/v1/platform/tiers") + response = client.get("/api/v1/public/tiers") assert response.status_code == 200 data = response.json() @@ -55,7 +55,7 @@ class TestPlatformPricingAPI: def test_get_tiers_includes_essential(self, client): """Test that Essential tier is included.""" - response = client.get("/api/v1/platform/tiers") + response = client.get("/api/v1/public/tiers") assert response.status_code == 200 data = response.json() @@ -64,7 +64,7 @@ class TestPlatformPricingAPI: def test_get_tiers_includes_professional(self, client): """Test that Professional tier is included and marked as popular.""" - response = client.get("/api/v1/platform/tiers") + response = client.get("/api/v1/public/tiers") assert response.status_code == 200 data = response.json() @@ -77,7 +77,7 @@ class TestPlatformPricingAPI: def test_get_tiers_includes_enterprise(self, client): """Test that Enterprise tier is included and marked appropriately.""" - response = client.get("/api/v1/platform/tiers") + response = client.get("/api/v1/public/tiers") assert response.status_code == 200 data = response.json() @@ -108,7 +108,7 @@ class TestPlatformPricingAPI: db.add(tier) db.commit() - response = client.get("/api/v1/platform/tiers") + response = client.get("/api/v1/public/tiers") assert response.status_code == 200 data = response.json() @@ -116,12 +116,12 @@ class TestPlatformPricingAPI: assert "test_tier" in tier_codes # ========================================================================= - # GET /api/v1/platform/tiers/{tier_code} + # GET /api/v1/public/tiers/{tier_code} # ========================================================================= def test_get_tier_by_code_success(self, client): """Test getting a specific tier by code.""" - response = client.get(f"/api/v1/platform/tiers/{TierCode.PROFESSIONAL.value}") + response = client.get(f"/api/v1/public/tiers/{TierCode.PROFESSIONAL.value}") assert response.status_code == 200 data = response.json() @@ -130,7 +130,7 @@ class TestPlatformPricingAPI: def test_get_tier_by_code_essential(self, client): """Test getting Essential tier details.""" - response = client.get(f"/api/v1/platform/tiers/{TierCode.ESSENTIAL.value}") + response = client.get(f"/api/v1/public/tiers/{TierCode.ESSENTIAL.value}") assert response.status_code == 200 data = response.json() @@ -139,19 +139,19 @@ class TestPlatformPricingAPI: def test_get_tier_by_code_not_found(self, client): """Test getting a non-existent tier returns 404.""" - response = client.get("/api/v1/platform/tiers/nonexistent_tier") + response = client.get("/api/v1/public/tiers/nonexistent_tier") assert response.status_code == 404 data = response.json() assert "not found" in data["message"].lower() # ========================================================================= - # GET /api/v1/platform/addons + # GET /api/v1/public/addons # ========================================================================= def test_get_addons_empty_when_none_configured(self, client): """Test getting add-ons when none are configured.""" - response = client.get("/api/v1/platform/addons") + response = client.get("/api/v1/public/addons") assert response.status_code == 200 data = response.json() @@ -173,7 +173,7 @@ class TestPlatformPricingAPI: db.add(addon) db.commit() - response = client.get("/api/v1/platform/addons") + response = client.get("/api/v1/public/addons") assert response.status_code == 200 data = response.json() @@ -197,7 +197,7 @@ class TestPlatformPricingAPI: db.add(addon) db.commit() - response = client.get("/api/v1/platform/addons") + response = client.get("/api/v1/public/addons") assert response.status_code == 200 data = response.json() @@ -236,7 +236,7 @@ class TestPlatformPricingAPI: db.add_all([active_addon, inactive_addon]) db.commit() - response = client.get("/api/v1/platform/addons") + response = client.get("/api/v1/public/addons") assert response.status_code == 200 data = response.json() @@ -245,12 +245,12 @@ class TestPlatformPricingAPI: assert "inactive_addon" not in addon_codes # ========================================================================= - # GET /api/v1/platform/pricing + # GET /api/v1/public/pricing # ========================================================================= def test_get_pricing_returns_complete_info(self, client): """Test getting complete pricing information.""" - response = client.get("/api/v1/platform/pricing") + response = client.get("/api/v1/public/pricing") assert response.status_code == 200 data = response.json() @@ -262,7 +262,7 @@ class TestPlatformPricingAPI: def test_get_pricing_includes_trial_days(self, client): """Test that pricing includes correct trial period.""" - response = client.get("/api/v1/platform/pricing") + response = client.get("/api/v1/public/pricing") assert response.status_code == 200 data = response.json() @@ -270,7 +270,7 @@ class TestPlatformPricingAPI: def test_get_pricing_includes_annual_discount(self, client): """Test that pricing includes annual discount info.""" - response = client.get("/api/v1/platform/pricing") + response = client.get("/api/v1/public/pricing") assert response.status_code == 200 data = response.json() @@ -278,7 +278,7 @@ class TestPlatformPricingAPI: def test_get_pricing_tiers_not_empty(self, client): """Test that pricing always includes tiers.""" - response = client.get("/api/v1/platform/pricing") + response = client.get("/api/v1/public/pricing") assert response.status_code == 200 data = response.json() diff --git a/tests/integration/api/v1/platform/test_signup.py b/tests/integration/api/v1/public/test_signup.py similarity index 87% rename from tests/integration/api/v1/platform/test_signup.py rename to tests/integration/api/v1/public/test_signup.py index 3f5e7b0d..76105c36 100644 --- a/tests/integration/api/v1/platform/test_signup.py +++ b/tests/integration/api/v1/public/test_signup.py @@ -1,7 +1,7 @@ -# tests/integration/api/v1/platform/test_signup.py +# tests/integration/api/v1/public/test_signup.py """Integration tests for platform signup API endpoints. -Tests the /api/v1/platform/signup/* endpoints. +Tests the /api/v1/public/signup/* endpoints. """ from unittest.mock import MagicMock, patch @@ -17,7 +17,7 @@ from models.database.vendor import Vendor @pytest.fixture def mock_stripe_service(): """Mock the Stripe service for tests.""" - with patch("app.services.platform_signup_service.stripe_service") as mock: + with patch("app.modules.marketplace.services.platform_signup_service.stripe_service") as mock: mock.create_customer.return_value = "cus_test_123" mock.create_setup_intent.return_value = MagicMock( id="seti_test_123", @@ -37,7 +37,7 @@ def mock_stripe_service(): def signup_session(client): """Create a signup session for testing.""" response = client.post( - "/api/v1/platform/signup/start", + "/api/v1/public/signup/start", json={"tier_code": TierCode.PROFESSIONAL.value, "is_annual": False}, ) return response.json()["session_id"] @@ -104,12 +104,12 @@ def claimed_letzshop_vendor(db, claimed_owner_user): @pytest.mark.api @pytest.mark.platform class TestSignupStartAPI: - """Test signup start endpoint at /api/v1/platform/signup/start.""" + """Test signup start endpoint at /api/v1/public/signup/start.""" def test_start_signup_success(self, client): """Test starting a signup session.""" response = client.post( - "/api/v1/platform/signup/start", + "/api/v1/public/signup/start", json={"tier_code": TierCode.ESSENTIAL.value, "is_annual": False}, ) @@ -122,7 +122,7 @@ class TestSignupStartAPI: def test_start_signup_with_annual_billing(self, client): """Test starting signup with annual billing.""" response = client.post( - "/api/v1/platform/signup/start", + "/api/v1/public/signup/start", json={"tier_code": TierCode.PROFESSIONAL.value, "is_annual": True}, ) @@ -134,7 +134,7 @@ class TestSignupStartAPI: """Test starting signup for all valid tiers.""" for tier in [TierCode.ESSENTIAL, TierCode.PROFESSIONAL, TierCode.BUSINESS, TierCode.ENTERPRISE]: response = client.post( - "/api/v1/platform/signup/start", + "/api/v1/public/signup/start", json={"tier_code": tier.value, "is_annual": False}, ) assert response.status_code == 200 @@ -143,7 +143,7 @@ class TestSignupStartAPI: def test_start_signup_invalid_tier(self, client): """Test starting signup with invalid tier code.""" response = client.post( - "/api/v1/platform/signup/start", + "/api/v1/public/signup/start", json={"tier_code": "invalid_tier", "is_annual": False}, ) @@ -154,11 +154,11 @@ class TestSignupStartAPI: def test_start_signup_session_id_is_unique(self, client): """Test that each signup session gets a unique ID.""" response1 = client.post( - "/api/v1/platform/signup/start", + "/api/v1/public/signup/start", json={"tier_code": TierCode.ESSENTIAL.value, "is_annual": False}, ) response2 = client.post( - "/api/v1/platform/signup/start", + "/api/v1/public/signup/start", json={"tier_code": TierCode.ESSENTIAL.value, "is_annual": False}, ) @@ -169,12 +169,12 @@ class TestSignupStartAPI: @pytest.mark.api @pytest.mark.platform class TestClaimVendorAPI: - """Test claim vendor endpoint at /api/v1/platform/signup/claim-vendor.""" + """Test claim vendor endpoint at /api/v1/public/signup/claim-vendor.""" def test_claim_vendor_success(self, client, signup_session): """Test claiming a Letzshop vendor.""" response = client.post( - "/api/v1/platform/signup/claim-vendor", + "/api/v1/public/signup/claim-vendor", json={ "session_id": signup_session, "letzshop_slug": "my-new-shop", @@ -190,7 +190,7 @@ class TestClaimVendorAPI: def test_claim_vendor_with_vendor_id(self, client, signup_session): """Test claiming vendor with Letzshop vendor ID.""" response = client.post( - "/api/v1/platform/signup/claim-vendor", + "/api/v1/public/signup/claim-vendor", json={ "session_id": signup_session, "letzshop_slug": "my-shop", @@ -205,7 +205,7 @@ class TestClaimVendorAPI: def test_claim_vendor_invalid_session(self, client): """Test claiming vendor with invalid session.""" response = client.post( - "/api/v1/platform/signup/claim-vendor", + "/api/v1/public/signup/claim-vendor", json={ "session_id": "invalid_session_id", "letzshop_slug": "my-shop", @@ -219,7 +219,7 @@ class TestClaimVendorAPI: def test_claim_vendor_already_claimed(self, client, signup_session, claimed_letzshop_vendor): """Test claiming a vendor that's already claimed.""" response = client.post( - "/api/v1/platform/signup/claim-vendor", + "/api/v1/public/signup/claim-vendor", json={ "session_id": signup_session, "letzshop_slug": "already-claimed-shop", @@ -235,12 +235,12 @@ class TestClaimVendorAPI: @pytest.mark.api @pytest.mark.platform class TestCreateAccountAPI: - """Test create account endpoint at /api/v1/platform/signup/create-account.""" + """Test create account endpoint at /api/v1/public/signup/create-account.""" def test_create_account_success(self, client, signup_session, mock_stripe_service): """Test creating an account.""" response = client.post( - "/api/v1/platform/signup/create-account", + "/api/v1/public/signup/create-account", json={ "session_id": signup_session, "email": "newuser@example.com", @@ -261,7 +261,7 @@ class TestCreateAccountAPI: def test_create_account_with_phone(self, client, signup_session, mock_stripe_service): """Test creating an account with phone number.""" response = client.post( - "/api/v1/platform/signup/create-account", + "/api/v1/public/signup/create-account", json={ "session_id": signup_session, "email": "user2@example.com", @@ -280,7 +280,7 @@ class TestCreateAccountAPI: def test_create_account_invalid_session(self, client, mock_stripe_service): """Test creating account with invalid session.""" response = client.post( - "/api/v1/platform/signup/create-account", + "/api/v1/public/signup/create-account", json={ "session_id": "invalid_session", "email": "test@example.com", @@ -298,7 +298,7 @@ class TestCreateAccountAPI: ): """Test creating account with existing email.""" response = client.post( - "/api/v1/platform/signup/create-account", + "/api/v1/public/signup/create-account", json={ "session_id": signup_session, "email": "existing@example.com", @@ -316,7 +316,7 @@ class TestCreateAccountAPI: def test_create_account_invalid_email(self, client, signup_session): """Test creating account with invalid email format.""" response = client.post( - "/api/v1/platform/signup/create-account", + "/api/v1/public/signup/create-account", json={ "session_id": signup_session, "email": "not-an-email", @@ -333,14 +333,14 @@ class TestCreateAccountAPI: """Test creating account after claiming Letzshop vendor.""" # Start signup start_response = client.post( - "/api/v1/platform/signup/start", + "/api/v1/public/signup/start", json={"tier_code": TierCode.PROFESSIONAL.value, "is_annual": False}, ) session_id = start_response.json()["session_id"] # Claim vendor client.post( - "/api/v1/platform/signup/claim-vendor", + "/api/v1/public/signup/claim-vendor", json={ "session_id": session_id, "letzshop_slug": "my-shop-claim", @@ -349,7 +349,7 @@ class TestCreateAccountAPI: # Create account response = client.post( - "/api/v1/platform/signup/create-account", + "/api/v1/public/signup/create-account", json={ "session_id": session_id, "email": "shop@example.com", @@ -369,13 +369,13 @@ class TestCreateAccountAPI: @pytest.mark.api @pytest.mark.platform class TestSetupPaymentAPI: - """Test setup payment endpoint at /api/v1/platform/signup/setup-payment.""" + """Test setup payment endpoint at /api/v1/public/signup/setup-payment.""" def test_setup_payment_success(self, client, signup_session, mock_stripe_service): """Test setting up payment after account creation.""" # Create account first client.post( - "/api/v1/platform/signup/create-account", + "/api/v1/public/signup/create-account", json={ "session_id": signup_session, "email": "payment@example.com", @@ -388,7 +388,7 @@ class TestSetupPaymentAPI: # Setup payment response = client.post( - "/api/v1/platform/signup/setup-payment", + "/api/v1/public/signup/setup-payment", json={"session_id": signup_session}, ) @@ -401,7 +401,7 @@ class TestSetupPaymentAPI: def test_setup_payment_invalid_session(self, client, mock_stripe_service): """Test setup payment with invalid session.""" response = client.post( - "/api/v1/platform/signup/setup-payment", + "/api/v1/public/signup/setup-payment", json={"session_id": "invalid_session"}, ) @@ -410,7 +410,7 @@ class TestSetupPaymentAPI: def test_setup_payment_without_account(self, client, signup_session, mock_stripe_service): """Test setup payment without creating account first.""" response = client.post( - "/api/v1/platform/signup/setup-payment", + "/api/v1/public/signup/setup-payment", json={"session_id": signup_session}, ) @@ -423,13 +423,13 @@ class TestSetupPaymentAPI: @pytest.mark.api @pytest.mark.platform class TestCompleteSignupAPI: - """Test complete signup endpoint at /api/v1/platform/signup/complete.""" + """Test complete signup endpoint at /api/v1/public/signup/complete.""" def test_complete_signup_success(self, client, signup_session, mock_stripe_service, db): """Test completing signup after payment setup.""" # Create account client.post( - "/api/v1/platform/signup/create-account", + "/api/v1/public/signup/create-account", json={ "session_id": signup_session, "email": "complete@example.com", @@ -442,13 +442,13 @@ class TestCompleteSignupAPI: # Setup payment client.post( - "/api/v1/platform/signup/setup-payment", + "/api/v1/public/signup/setup-payment", json={"session_id": signup_session}, ) # Complete signup response = client.post( - "/api/v1/platform/signup/complete", + "/api/v1/public/signup/complete", json={ "session_id": signup_session, "setup_intent_id": "seti_test_123", @@ -469,7 +469,7 @@ class TestCompleteSignupAPI: """Test that completing signup returns a valid JWT access token for auto-login.""" # Create account client.post( - "/api/v1/platform/signup/create-account", + "/api/v1/public/signup/create-account", json={ "session_id": signup_session, "email": "token_test@example.com", @@ -482,13 +482,13 @@ class TestCompleteSignupAPI: # Setup payment client.post( - "/api/v1/platform/signup/setup-payment", + "/api/v1/public/signup/setup-payment", json={"session_id": signup_session}, ) # Complete signup response = client.post( - "/api/v1/platform/signup/complete", + "/api/v1/public/signup/complete", json={ "session_id": signup_session, "setup_intent_id": "seti_test_123", @@ -509,7 +509,7 @@ class TestCompleteSignupAPI: """Test that the returned access token can be used to authenticate API calls.""" # Create account client.post( - "/api/v1/platform/signup/create-account", + "/api/v1/public/signup/create-account", json={ "session_id": signup_session, "email": "auth_test@example.com", @@ -522,13 +522,13 @@ class TestCompleteSignupAPI: # Setup payment client.post( - "/api/v1/platform/signup/setup-payment", + "/api/v1/public/signup/setup-payment", json={"session_id": signup_session}, ) # Complete signup complete_response = client.post( - "/api/v1/platform/signup/complete", + "/api/v1/public/signup/complete", json={ "session_id": signup_session, "setup_intent_id": "seti_test_123", @@ -553,7 +553,7 @@ class TestCompleteSignupAPI: """Test that completing signup sets the vendor_token HTTP-only cookie.""" # Create account client.post( - "/api/v1/platform/signup/create-account", + "/api/v1/public/signup/create-account", json={ "session_id": signup_session, "email": "cookie_test@example.com", @@ -566,13 +566,13 @@ class TestCompleteSignupAPI: # Setup payment client.post( - "/api/v1/platform/signup/setup-payment", + "/api/v1/public/signup/setup-payment", json={"session_id": signup_session}, ) # Complete signup response = client.post( - "/api/v1/platform/signup/complete", + "/api/v1/public/signup/complete", json={ "session_id": signup_session, "setup_intent_id": "seti_test_123", @@ -588,7 +588,7 @@ class TestCompleteSignupAPI: def test_complete_signup_invalid_session(self, client, mock_stripe_service): """Test completing signup with invalid session.""" response = client.post( - "/api/v1/platform/signup/complete", + "/api/v1/public/signup/complete", json={ "session_id": "invalid_session", "setup_intent_id": "seti_test_123", @@ -603,7 +603,7 @@ class TestCompleteSignupAPI: """Test completing signup when payment setup failed.""" # Create account client.post( - "/api/v1/platform/signup/create-account", + "/api/v1/public/signup/create-account", json={ "session_id": signup_session, "email": "fail@example.com", @@ -616,7 +616,7 @@ class TestCompleteSignupAPI: # Setup payment client.post( - "/api/v1/platform/signup/setup-payment", + "/api/v1/public/signup/setup-payment", json={"session_id": signup_session}, ) @@ -629,7 +629,7 @@ class TestCompleteSignupAPI: # Complete signup response = client.post( - "/api/v1/platform/signup/complete", + "/api/v1/public/signup/complete", json={ "session_id": signup_session, "setup_intent_id": "seti_failed", @@ -645,11 +645,11 @@ class TestCompleteSignupAPI: @pytest.mark.api @pytest.mark.platform class TestGetSignupSessionAPI: - """Test get signup session endpoint at /api/v1/platform/signup/session/{session_id}.""" + """Test get signup session endpoint at /api/v1/public/signup/session/{session_id}.""" def test_get_session_after_start(self, client, signup_session): """Test getting session after starting signup.""" - response = client.get(f"/api/v1/platform/signup/session/{signup_session}") + response = client.get(f"/api/v1/public/signup/session/{signup_session}") assert response.status_code == 200 data = response.json() @@ -661,14 +661,14 @@ class TestGetSignupSessionAPI: def test_get_session_after_claim(self, client, signup_session): """Test getting session after claiming vendor.""" client.post( - "/api/v1/platform/signup/claim-vendor", + "/api/v1/public/signup/claim-vendor", json={ "session_id": signup_session, "letzshop_slug": "my-session-shop", }, ) - response = client.get(f"/api/v1/platform/signup/session/{signup_session}") + response = client.get(f"/api/v1/public/signup/session/{signup_session}") assert response.status_code == 200 data = response.json() @@ -677,7 +677,7 @@ class TestGetSignupSessionAPI: def test_get_session_invalid_id(self, client): """Test getting non-existent session.""" - response = client.get("/api/v1/platform/signup/session/invalid_id") + response = client.get("/api/v1/public/signup/session/invalid_id") assert response.status_code == 404 @@ -692,7 +692,7 @@ class TestSignupFullFlow: """Test the complete signup flow from start to finish.""" # Step 1: Start signup start_response = client.post( - "/api/v1/platform/signup/start", + "/api/v1/public/signup/start", json={"tier_code": TierCode.BUSINESS.value, "is_annual": True}, ) assert start_response.status_code == 200 @@ -700,7 +700,7 @@ class TestSignupFullFlow: # Step 2: Claim Letzshop vendor (optional) claim_response = client.post( - "/api/v1/platform/signup/claim-vendor", + "/api/v1/public/signup/claim-vendor", json={ "session_id": session_id, "letzshop_slug": "full-flow-shop", @@ -710,7 +710,7 @@ class TestSignupFullFlow: # Step 3: Create account account_response = client.post( - "/api/v1/platform/signup/create-account", + "/api/v1/public/signup/create-account", json={ "session_id": session_id, "email": "fullflow@example.com", @@ -726,7 +726,7 @@ class TestSignupFullFlow: # Step 4: Setup payment payment_response = client.post( - "/api/v1/platform/signup/setup-payment", + "/api/v1/public/signup/setup-payment", json={"session_id": session_id}, ) assert payment_response.status_code == 200 @@ -734,7 +734,7 @@ class TestSignupFullFlow: # Step 5: Complete signup complete_response = client.post( - "/api/v1/platform/signup/complete", + "/api/v1/public/signup/complete", json={ "session_id": session_id, "setup_intent_id": "seti_test_123", @@ -753,14 +753,14 @@ class TestSignupFullFlow: """Test signup flow skipping Letzshop claim step.""" # Step 1: Start signup start_response = client.post( - "/api/v1/platform/signup/start", + "/api/v1/public/signup/start", json={"tier_code": TierCode.ESSENTIAL.value, "is_annual": False}, ) session_id = start_response.json()["session_id"] # Skip Step 2, go directly to Step 3 account_response = client.post( - "/api/v1/platform/signup/create-account", + "/api/v1/public/signup/create-account", json={ "session_id": session_id, "email": "noletzshop@example.com", @@ -775,12 +775,12 @@ class TestSignupFullFlow: # Step 4 & 5: Payment and complete client.post( - "/api/v1/platform/signup/setup-payment", + "/api/v1/public/signup/setup-payment", json={"session_id": session_id}, ) complete_response = client.post( - "/api/v1/platform/signup/complete", + "/api/v1/public/signup/complete", json={ "session_id": session_id, "setup_intent_id": "seti_test_123", diff --git a/tests/integration/api/v1/vendor/test_letzshop.py b/tests/integration/api/v1/vendor/test_letzshop.py index 4ee4855a..9f87cd49 100644 --- a/tests/integration/api/v1/vendor/test_letzshop.py +++ b/tests/integration/api/v1/vendor/test_letzshop.py @@ -165,7 +165,7 @@ class TestVendorLetzshopConnectionAPI: assert data["success"] is False assert "not configured" in data["error_details"] - @patch("app.services.letzshop.client_service.requests.Session.post") + @patch("app.modules.marketplace.services.letzshop.client_service.requests.Session.post") def test_test_connection_success( self, mock_post, client, vendor_user_headers, test_vendor_with_vendor_user ): @@ -193,7 +193,7 @@ class TestVendorLetzshopConnectionAPI: assert data["success"] is True assert data["response_time_ms"] is not None - @patch("app.services.letzshop.client_service.requests.Session.post") + @patch("app.modules.marketplace.services.letzshop.client_service.requests.Session.post") def test_test_api_key_without_saving( self, mock_post, client, vendor_user_headers, test_vendor_with_vendor_user ): @@ -382,7 +382,7 @@ class TestVendorLetzshopOrdersAPI: assert response.status_code == 422 # Validation error - @patch("app.services.letzshop.client_service.requests.Session.post") + @patch("app.modules.marketplace.services.letzshop.client_service.requests.Session.post") def test_import_orders_success( self, mock_post, @@ -445,7 +445,7 @@ class TestVendorLetzshopOrdersAPI: class TestVendorLetzshopFulfillmentAPI: """Test vendor Letzshop fulfillment endpoints.""" - @patch("app.services.letzshop.client_service.requests.Session.post") + @patch("app.modules.marketplace.services.letzshop.client_service.requests.Session.post") def test_confirm_order( self, mock_post, @@ -534,7 +534,7 @@ class TestVendorLetzshopFulfillmentAPI: data = response.json() assert data["success"] is True - @patch("app.services.letzshop.client_service.requests.Session.post") + @patch("app.modules.marketplace.services.letzshop.client_service.requests.Session.post") def test_set_tracking( self, mock_post, diff --git a/tests/integration/tasks/test_background_tasks.py b/tests/integration/tasks/test_background_tasks.py index 90b8b07e..9ff9dcdc 100644 --- a/tests/integration/tasks/test_background_tasks.py +++ b/tests/integration/tasks/test_background_tasks.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest -from app.tasks.background_tasks import process_marketplace_import +from app.modules.marketplace.tasks import process_marketplace_import from app.modules.marketplace.models import MarketplaceImportJob diff --git a/tests/integration/tasks/test_letzshop_tasks.py b/tests/integration/tasks/test_letzshop_tasks.py index f87fdbb8..7b3893ca 100644 --- a/tests/integration/tasks/test_letzshop_tasks.py +++ b/tests/integration/tasks/test_letzshop_tasks.py @@ -6,8 +6,8 @@ from unittest.mock import MagicMock, patch import pytest -from app.services.letzshop import LetzshopClientError -from app.tasks.letzshop_tasks import process_historical_import +from app.modules.marketplace.services.letzshop import LetzshopClientError +from app.modules.marketplace.tasks import process_historical_import from app.modules.marketplace.models import LetzshopHistoricalImportJob diff --git a/tests/integration/tasks/test_subscription_tasks.py b/tests/integration/tasks/test_subscription_tasks.py index b27f4776..72bcd5ad 100644 --- a/tests/integration/tasks/test_subscription_tasks.py +++ b/tests/integration/tasks/test_subscription_tasks.py @@ -6,7 +6,7 @@ from unittest.mock import MagicMock, PropertyMock, patch import pytest -from app.tasks.subscription_tasks import ( +from app.modules.billing.tasks import ( check_trial_expirations, cleanup_stale_subscriptions, reset_period_counters, diff --git a/tests/unit/middleware/test_auth.py b/tests/unit/middleware/test_auth.py index d8ce3d3f..bb7e0227 100644 --- a/tests/unit/middleware/test_auth.py +++ b/tests/unit/middleware/test_auth.py @@ -19,7 +19,7 @@ import pytest from fastapi import HTTPException from jose import jwt -from app.exceptions import ( +from app.modules.tenancy.exceptions import ( AdminRequiredException, InsufficientPermissionsException, InvalidCredentialsException, diff --git a/tests/unit/middleware/test_vendor_context.py b/tests/unit/middleware/test_vendor_context.py index 43803e39..db5173a2 100644 --- a/tests/unit/middleware/test_vendor_context.py +++ b/tests/unit/middleware/test_vendor_context.py @@ -17,7 +17,7 @@ import pytest from fastapi import Request from sqlalchemy.orm import Session -from app.exceptions.vendor import VendorNotFoundException +from app.modules.tenancy.exceptions import VendorNotFoundException from middleware.vendor_context import ( VendorContextManager, VendorContextMiddleware, diff --git a/tests/unit/services/test_admin_customer_service.py b/tests/unit/services/test_admin_customer_service.py index cf9f113a..a2c8b119 100644 --- a/tests/unit/services/test_admin_customer_service.py +++ b/tests/unit/services/test_admin_customer_service.py @@ -7,8 +7,8 @@ from decimal import Decimal import pytest -from app.exceptions.customer import CustomerNotFoundException -from app.services.admin_customer_service import AdminCustomerService +from app.modules.customers.exceptions import CustomerNotFoundException +from app.modules.customers.services.admin_customer_service import AdminCustomerService from app.modules.customers.models.customer import Customer diff --git a/tests/unit/services/test_admin_notification_service.py b/tests/unit/services/test_admin_notification_service.py index 19a71daa..012fb79b 100644 --- a/tests/unit/services/test_admin_notification_service.py +++ b/tests/unit/services/test_admin_notification_service.py @@ -7,7 +7,7 @@ from datetime import datetime, timedelta import pytest -from app.services.admin_notification_service import ( +from app.modules.messaging.services.admin_notification_service import ( AdminNotificationService, AlertType, NotificationType, diff --git a/tests/unit/services/test_admin_platform_service.py b/tests/unit/services/test_admin_platform_service.py index 63fe473b..910b1f80 100644 --- a/tests/unit/services/test_admin_platform_service.py +++ b/tests/unit/services/test_admin_platform_service.py @@ -7,8 +7,9 @@ Tests the admin platform assignment service operations. import pytest -from app.exceptions import AdminOperationException, CannotModifySelfException, ValidationException -from app.services.admin_platform_service import AdminPlatformService +from app.exceptions import ValidationException +from app.modules.tenancy.exceptions import AdminOperationException, CannotModifySelfException +from app.modules.tenancy.services.admin_platform_service import AdminPlatformService @pytest.mark.unit diff --git a/tests/unit/services/test_admin_service.py b/tests/unit/services/test_admin_service.py index c946c785..a0437202 100644 --- a/tests/unit/services/test_admin_service.py +++ b/tests/unit/services/test_admin_service.py @@ -1,17 +1,17 @@ # tests/unit/services/test_admin_service.py import pytest -from app.exceptions import ( +from app.exceptions import ValidationException +from app.modules.tenancy.exceptions import ( AdminOperationException, CannotModifySelfException, UserNotFoundException, UserStatusChangeException, - ValidationException, VendorAlreadyExistsException, VendorNotFoundException, ) -from app.services.admin_service import AdminService -from app.services.stats_service import stats_service +from app.modules.tenancy.services.admin_service import AdminService +from app.modules.analytics.services.stats_service import stats_service from models.schema.vendor import VendorCreate diff --git a/tests/unit/services/test_auth_service.py b/tests/unit/services/test_auth_service.py index f8a9b8ed..b18a52e8 100644 --- a/tests/unit/services/test_auth_service.py +++ b/tests/unit/services/test_auth_service.py @@ -3,11 +3,11 @@ import pytest -from app.exceptions.auth import ( +from app.modules.tenancy.exceptions import ( InvalidCredentialsException, UserNotActiveException, ) -from app.services.auth_service import AuthService +from app.modules.core.services.auth_service import AuthService from models.schema.auth import UserLogin diff --git a/tests/unit/services/test_billing_service.py b/tests/unit/services/test_billing_service.py index d51e6e07..1c09c710 100644 --- a/tests/unit/services/test_billing_service.py +++ b/tests/unit/services/test_billing_service.py @@ -6,8 +6,8 @@ from unittest.mock import MagicMock, patch import pytest -from app.exceptions import VendorNotFoundException -from app.services.billing_service import ( +from app.modules.tenancy.exceptions import VendorNotFoundException +from app.modules.billing.services.billing_service import ( BillingService, NoActiveSubscriptionError, PaymentSystemNotConfiguredError, @@ -107,7 +107,7 @@ class TestBillingServiceCheckout: """Initialize service instance before each test.""" self.service = BillingService() - @patch("app.services.billing_service.stripe_service") + @patch("app.modules.billing.services.billing_service.stripe_service") def test_create_checkout_session_stripe_not_configured( self, mock_stripe, db, test_vendor, test_subscription_tier ): @@ -124,7 +124,7 @@ class TestBillingServiceCheckout: cancel_url="https://example.com/cancel", ) - @patch("app.services.billing_service.stripe_service") + @patch("app.modules.billing.services.billing_service.stripe_service") def test_create_checkout_session_success( self, mock_stripe, db, test_vendor, test_subscription_tier_with_stripe ): @@ -147,7 +147,7 @@ class TestBillingServiceCheckout: assert result["checkout_url"] == "https://checkout.stripe.com/test" assert result["session_id"] == "cs_test_123" - @patch("app.services.billing_service.stripe_service") + @patch("app.modules.billing.services.billing_service.stripe_service") def test_create_checkout_session_tier_not_found( self, mock_stripe, db, test_vendor ): @@ -164,7 +164,7 @@ class TestBillingServiceCheckout: cancel_url="https://example.com/cancel", ) - @patch("app.services.billing_service.stripe_service") + @patch("app.modules.billing.services.billing_service.stripe_service") def test_create_checkout_session_no_price( self, mock_stripe, db, test_vendor, test_subscription_tier ): @@ -191,7 +191,7 @@ class TestBillingServicePortal: """Initialize service instance before each test.""" self.service = BillingService() - @patch("app.services.billing_service.stripe_service") + @patch("app.modules.billing.services.billing_service.stripe_service") def test_create_portal_session_stripe_not_configured(self, mock_stripe, db, test_vendor): """Test portal fails when Stripe not configured.""" mock_stripe.is_configured = False @@ -203,7 +203,7 @@ class TestBillingServicePortal: return_url="https://example.com/billing", ) - @patch("app.services.billing_service.stripe_service") + @patch("app.modules.billing.services.billing_service.stripe_service") def test_create_portal_session_no_subscription(self, mock_stripe, db, test_vendor): """Test portal fails when no subscription exists.""" mock_stripe.is_configured = True @@ -215,7 +215,7 @@ class TestBillingServicePortal: return_url="https://example.com/billing", ) - @patch("app.services.billing_service.stripe_service") + @patch("app.modules.billing.services.billing_service.stripe_service") def test_create_portal_session_success( self, mock_stripe, db, test_vendor, test_active_subscription ): @@ -313,7 +313,7 @@ class TestBillingServiceCancellation: """Initialize service instance before each test.""" self.service = BillingService() - @patch("app.services.billing_service.stripe_service") + @patch("app.modules.billing.services.billing_service.stripe_service") def test_cancel_subscription_no_subscription( self, mock_stripe, db, test_vendor ): @@ -328,7 +328,7 @@ class TestBillingServiceCancellation: immediately=False, ) - @patch("app.services.billing_service.stripe_service") + @patch("app.modules.billing.services.billing_service.stripe_service") def test_cancel_subscription_success( self, mock_stripe, db, test_vendor, test_active_subscription ): @@ -346,7 +346,7 @@ class TestBillingServiceCancellation: assert test_active_subscription.cancelled_at is not None assert test_active_subscription.cancellation_reason == "Too expensive" - @patch("app.services.billing_service.stripe_service") + @patch("app.modules.billing.services.billing_service.stripe_service") def test_reactivate_subscription_not_cancelled( self, mock_stripe, db, test_vendor, test_active_subscription ): @@ -356,7 +356,7 @@ class TestBillingServiceCancellation: with pytest.raises(SubscriptionNotCancelledError): self.service.reactivate_subscription(db, test_vendor.id) - @patch("app.services.billing_service.stripe_service") + @patch("app.modules.billing.services.billing_service.stripe_service") def test_reactivate_subscription_success( self, mock_stripe, db, test_vendor, test_cancelled_subscription ): diff --git a/tests/unit/services/test_capacity_forecast_service.py b/tests/unit/services/test_capacity_forecast_service.py index 25eba4bd..95b11641 100644 --- a/tests/unit/services/test_capacity_forecast_service.py +++ b/tests/unit/services/test_capacity_forecast_service.py @@ -15,7 +15,7 @@ from unittest.mock import MagicMock, patch import pytest -from app.services.capacity_forecast_service import ( +from app.modules.billing.services.capacity_forecast_service import ( INFRASTRUCTURE_SCALING, CapacityForecastService, capacity_forecast_service, diff --git a/tests/unit/services/test_customer_address_service.py b/tests/unit/services/test_customer_address_service.py index 32a89ef6..6102816a 100644 --- a/tests/unit/services/test_customer_address_service.py +++ b/tests/unit/services/test_customer_address_service.py @@ -5,8 +5,8 @@ Unit tests for CustomerAddressService. import pytest -from app.exceptions import AddressLimitExceededException, AddressNotFoundException -from app.services.customer_address_service import CustomerAddressService +from app.modules.customers.exceptions import AddressLimitExceededException, AddressNotFoundException +from app.modules.customers.services.customer_address_service import CustomerAddressService from app.modules.customers.models.customer import CustomerAddress from app.modules.customers.schemas import CustomerAddressCreate, CustomerAddressUpdate diff --git a/tests/unit/services/test_email_service.py b/tests/unit/services/test_email_service.py index f4c5cade..86433d06 100644 --- a/tests/unit/services/test_email_service.py +++ b/tests/unit/services/test_email_service.py @@ -6,7 +6,7 @@ from unittest.mock import MagicMock, patch import pytest -from app.services.email_service import ( +from app.modules.messaging.services.email_service import ( DebugProvider, EmailProvider, EmailService, @@ -56,7 +56,7 @@ class TestEmailProviders: assert success is True - @patch("app.services.email_service.settings") + @patch("app.modules.messaging.services.email_service.settings") def test_get_provider_debug_mode(self, mock_settings): """Test get_provider returns DebugProvider in debug mode.""" mock_settings.email_debug = True @@ -65,7 +65,7 @@ class TestEmailProviders: assert isinstance(provider, DebugProvider) - @patch("app.services.email_service.settings") + @patch("app.modules.messaging.services.email_service.settings") def test_get_provider_smtp(self, mock_settings): """Test get_provider returns SMTPProvider for smtp config.""" mock_settings.email_debug = False @@ -75,7 +75,7 @@ class TestEmailProviders: assert isinstance(provider, SMTPProvider) - @patch("app.services.email_service.settings") + @patch("app.modules.messaging.services.email_service.settings") def test_get_provider_unknown_defaults_to_smtp(self, mock_settings): """Test get_provider defaults to SMTP for unknown providers.""" mock_settings.email_debug = False @@ -211,8 +211,8 @@ class TestEmailService: class TestEmailSending: """Test suite for email sending functionality.""" - @patch("app.services.email_service.get_platform_provider") - @patch("app.services.email_service.get_platform_email_config") + @patch("app.modules.messaging.services.email_service.get_platform_provider") + @patch("app.modules.messaging.services.email_service.get_platform_email_config") def test_send_raw_success(self, mock_get_config, mock_get_platform_provider, db): """Test successful raw email sending.""" # Setup mocks @@ -243,8 +243,8 @@ class TestEmailSending: assert log.subject == "Test Subject" assert log.provider_message_id == "msg-123" - @patch("app.services.email_service.get_platform_provider") - @patch("app.services.email_service.get_platform_email_config") + @patch("app.modules.messaging.services.email_service.get_platform_provider") + @patch("app.modules.messaging.services.email_service.get_platform_email_config") def test_send_raw_failure(self, mock_get_config, mock_get_platform_provider, db): """Test failed raw email sending.""" # Setup mocks @@ -272,7 +272,7 @@ class TestEmailSending: assert log.status == EmailStatus.FAILED.value assert log.error_message == "Connection refused" - @patch("app.services.email_service.settings") + @patch("app.modules.messaging.services.email_service.settings") def test_send_raw_email_disabled(self, mock_settings, db): """Test email sending when disabled.""" mock_settings.email_enabled = False @@ -293,8 +293,8 @@ class TestEmailSending: assert log.status == EmailStatus.FAILED.value assert "disabled" in log.error_message.lower() - @patch("app.services.email_service.get_platform_provider") - @patch("app.services.email_service.get_platform_email_config") + @patch("app.modules.messaging.services.email_service.get_platform_provider") + @patch("app.modules.messaging.services.email_service.get_platform_email_config") def test_send_template_success(self, mock_get_config, mock_get_platform_provider, db): """Test successful template email sending.""" # Create test template @@ -586,8 +586,8 @@ class TestSignupWelcomeEmail: for var in required_vars: assert var in template.variables_list, f"Missing variable: {var}" - @patch("app.services.email_service.get_platform_provider") - @patch("app.services.email_service.get_platform_email_config") + @patch("app.modules.messaging.services.email_service.get_platform_provider") + @patch("app.modules.messaging.services.email_service.get_platform_email_config") def test_welcome_email_send(self, mock_get_config, mock_get_platform_provider, db, welcome_template, test_vendor, test_user): """Test sending welcome email.""" # Setup mocks diff --git a/tests/unit/services/test_feature_service.py b/tests/unit/services/test_feature_service.py index 008ca77b..6b6ff54e 100644 --- a/tests/unit/services/test_feature_service.py +++ b/tests/unit/services/test_feature_service.py @@ -3,8 +3,8 @@ import pytest -from app.exceptions import FeatureNotFoundError, InvalidFeatureCodesError, TierNotFoundError -from app.services.feature_service import FeatureService, feature_service +from app.modules.billing.exceptions import FeatureNotFoundError, InvalidFeatureCodesError, TierNotFoundError +from app.modules.billing.services.feature_service import FeatureService, feature_service from app.modules.billing.models import Feature from app.modules.billing.models import SubscriptionTier, VendorSubscription diff --git a/tests/unit/services/test_inventory_service.py b/tests/unit/services/test_inventory_service.py index a6669168..56244fc1 100644 --- a/tests/unit/services/test_inventory_service.py +++ b/tests/unit/services/test_inventory_service.py @@ -5,14 +5,14 @@ import uuid import pytest -from app.exceptions import ( +from app.modules.inventory.exceptions import ( InsufficientInventoryException, InvalidQuantityException, InventoryNotFoundException, InventoryValidationException, - ProductNotFoundException, ) -from app.services.inventory_service import InventoryService +from app.modules.catalog.exceptions import ProductNotFoundException +from app.modules.inventory.services.inventory_service import InventoryService from app.modules.inventory.models import Inventory from app.modules.inventory.schemas import ( InventoryAdjust, @@ -126,7 +126,7 @@ class TestInventoryService: def test_set_inventory_product_not_found(self, db, test_vendor): """Test setting inventory for non-existent product raises exception.""" - from app.exceptions.base import ValidationException + from app.exceptions import ValidationException unique_id = str(uuid.uuid4())[:8].upper() inventory_data = InventoryCreate( @@ -203,7 +203,7 @@ class TestInventoryService: self, db, test_inventory, test_product, test_vendor ): """Test removing more than available raises exception.""" - from app.exceptions.base import ValidationException + from app.exceptions import ValidationException inventory_data = InventoryAdjust( product_id=test_product.id, @@ -251,7 +251,7 @@ class TestInventoryService: self, db, test_inventory, test_product, test_vendor ): """Test reserving more than available raises exception.""" - from app.exceptions.base import ValidationException + from app.exceptions import ValidationException available = test_inventory.quantity - test_inventory.reserved_quantity @@ -335,7 +335,7 @@ class TestInventoryService: self, db, test_inventory, test_product, test_vendor ): """Test fulfilling more than quantity raises exception.""" - from app.exceptions.base import ValidationException + from app.exceptions import ValidationException reserve_data = InventoryReserve( product_id=test_product.id, @@ -401,7 +401,7 @@ class TestInventoryService: def test_get_product_inventory_not_found(self, db, test_vendor): """Test getting inventory for non-existent product raises exception.""" - from app.exceptions.base import ValidationException + from app.exceptions import ValidationException # Service wraps ProductNotFoundException in ValidationException with pytest.raises((ProductNotFoundException, ValidationException)): @@ -675,7 +675,7 @@ class TestInventoryService: def test_get_vendor_inventory_admin_vendor_not_found(self, db): """Test get_vendor_inventory_admin raises for non-existent vendor.""" - from app.exceptions import VendorNotFoundException + from app.modules.tenancy.exceptions import VendorNotFoundException with pytest.raises(VendorNotFoundException): self.service.get_vendor_inventory_admin(db, vendor_id=99999) @@ -700,7 +700,7 @@ class TestInventoryService: def test_verify_vendor_exists_not_found(self, db): """Test verify_vendor_exists raises for non-existent vendor.""" - from app.exceptions import VendorNotFoundException + from app.modules.tenancy.exceptions import VendorNotFoundException with pytest.raises(VendorNotFoundException): self.service.verify_vendor_exists(db, 99999) diff --git a/tests/unit/services/test_invoice_service.py b/tests/unit/services/test_invoice_service.py index eed47998..25fcde6a 100644 --- a/tests/unit/services/test_invoice_service.py +++ b/tests/unit/services/test_invoice_service.py @@ -7,11 +7,11 @@ from decimal import Decimal import pytest from app.exceptions import ValidationException -from app.exceptions.invoice import ( +from app.modules.orders.exceptions import ( InvoiceNotFoundException, InvoiceSettingsNotFoundException, ) -from app.services.invoice_service import ( +from app.modules.orders.services.invoice_service import ( EU_VAT_RATES, InvoiceService, LU_VAT_RATES, diff --git a/tests/unit/services/test_letzshop_service.py b/tests/unit/services/test_letzshop_service.py index 26c0d331..cad0bd82 100644 --- a/tests/unit/services/test_letzshop_service.py +++ b/tests/unit/services/test_letzshop_service.py @@ -12,7 +12,7 @@ from unittest.mock import MagicMock, patch import pytest -from app.services.letzshop import ( +from app.modules.marketplace.services.letzshop import ( CredentialsNotFoundError, LetzshopAPIError, LetzshopClient, @@ -570,7 +570,7 @@ class TestLetzshopOrderService: def test_create_order_extracts_locale(self, db, test_vendor): """Test that create_order extracts customer locale.""" - from app.services.letzshop.order_service import LetzshopOrderService + from app.modules.marketplace.services.letzshop.order_service import LetzshopOrderService service = LetzshopOrderService(db) @@ -603,7 +603,7 @@ class TestLetzshopOrderService: def test_create_order_extracts_ean(self, db, test_vendor): """Test that create_order extracts EAN from tradeId.""" - from app.services.letzshop.order_service import LetzshopOrderService + from app.modules.marketplace.services.letzshop.order_service import LetzshopOrderService service = LetzshopOrderService(db) @@ -652,7 +652,7 @@ class TestLetzshopOrderService: def test_import_historical_shipments_deduplication(self, db, test_vendor): """Test that historical import deduplicates existing orders.""" - from app.services.letzshop.order_service import LetzshopOrderService + from app.modules.marketplace.services.letzshop.order_service import LetzshopOrderService service = LetzshopOrderService(db) @@ -686,7 +686,7 @@ class TestLetzshopOrderService: def test_import_historical_shipments_new_orders(self, db, test_vendor): """Test that historical import creates new orders.""" - from app.services.letzshop.order_service import LetzshopOrderService + from app.modules.marketplace.services.letzshop.order_service import LetzshopOrderService service = LetzshopOrderService(db) @@ -718,7 +718,7 @@ class TestLetzshopOrderService: def test_get_historical_import_summary(self, db, test_vendor): """Test historical import summary statistics.""" - from app.services.letzshop.order_service import LetzshopOrderService + from app.modules.marketplace.services.letzshop.order_service import LetzshopOrderService service = LetzshopOrderService(db) diff --git a/tests/unit/services/test_marketplace_product_service.py b/tests/unit/services/test_marketplace_product_service.py index 1ad958f4..8b2f3109 100644 --- a/tests/unit/services/test_marketplace_product_service.py +++ b/tests/unit/services/test_marketplace_product_service.py @@ -16,13 +16,13 @@ import uuid import pytest -from app.exceptions import ( +from app.exceptions import ValidationException +from app.modules.marketplace.exceptions import ( InvalidMarketplaceProductDataException, MarketplaceProductNotFoundException, MarketplaceProductValidationException, - ValidationException, ) -from app.services.marketplace_product_service import ( +from app.modules.marketplace.services.marketplace_product_service import ( MarketplaceProductService, marketplace_product_service, ) @@ -502,7 +502,7 @@ class TestMarketplaceProductServiceCopyToCatalog: mock_subscription.products_limit = 100 with patch( - "app.services.subscription_service.subscription_service" + "app.modules.billing.services.subscription_service.subscription_service" ) as mock_sub: mock_sub.get_or_create_subscription.return_value = mock_subscription @@ -519,7 +519,7 @@ class TestMarketplaceProductServiceCopyToCatalog: def test_copy_to_vendor_catalog_vendor_not_found(self, db, test_marketplace_product): """Test copy fails for non-existent vendor""" - from app.exceptions import VendorNotFoundException + from app.modules.tenancy.exceptions import VendorNotFoundException with pytest.raises(VendorNotFoundException): self.service.copy_to_vendor_catalog( diff --git a/tests/unit/services/test_marketplace_service.py b/tests/unit/services/test_marketplace_service.py index 7bafa9d4..c69e6551 100644 --- a/tests/unit/services/test_marketplace_service.py +++ b/tests/unit/services/test_marketplace_service.py @@ -5,13 +5,13 @@ import uuid import pytest -from app.exceptions.base import ValidationException -from app.exceptions.marketplace_import_job import ( +from app.exceptions import ValidationException +from app.modules.marketplace.exceptions import ( ImportJobNotFoundException, ImportJobNotOwnedException, ) -from app.exceptions.vendor import UnauthorizedVendorAccessException -from app.services.marketplace_import_job_service import MarketplaceImportJobService +from app.modules.tenancy.exceptions import UnauthorizedVendorAccessException +from app.modules.marketplace.services.marketplace_import_job_service import MarketplaceImportJobService from app.modules.marketplace.models import MarketplaceImportJob from app.modules.marketplace.schemas import MarketplaceImportJobRequest diff --git a/tests/unit/services/test_message_attachment_service.py b/tests/unit/services/test_message_attachment_service.py index a6d3c52f..d3575a31 100644 --- a/tests/unit/services/test_message_attachment_service.py +++ b/tests/unit/services/test_message_attachment_service.py @@ -10,7 +10,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest from fastapi import UploadFile -from app.services.message_attachment_service import ( +from app.modules.messaging.services.message_attachment_service import ( ALLOWED_MIME_TYPES, DEFAULT_MAX_FILE_SIZE_MB, IMAGE_MIME_TYPES, @@ -101,7 +101,7 @@ class TestMessageAttachmentServiceMaxFileSize: def test_get_max_file_size_from_settings(self, db, attachment_service): """Test retrieving max file size from platform settings.""" with patch( - "app.services.message_attachment_service.admin_settings_service" + "app.modules.messaging.services.message_attachment_service.admin_settings_service" ) as mock_settings: mock_settings.get_setting_value.return_value = 15 max_size = attachment_service.get_max_file_size_bytes(db) @@ -110,7 +110,7 @@ class TestMessageAttachmentServiceMaxFileSize: def test_get_max_file_size_default(self, db, attachment_service): """Test default max file size when setting not found.""" with patch( - "app.services.message_attachment_service.admin_settings_service" + "app.modules.messaging.services.message_attachment_service.admin_settings_service" ) as mock_settings: mock_settings.get_setting_value.return_value = DEFAULT_MAX_FILE_SIZE_MB max_size = attachment_service.get_max_file_size_bytes(db) @@ -119,7 +119,7 @@ class TestMessageAttachmentServiceMaxFileSize: def test_get_max_file_size_invalid_value(self, db, attachment_service): """Test handling of invalid setting value.""" with patch( - "app.services.message_attachment_service.admin_settings_service" + "app.modules.messaging.services.message_attachment_service.admin_settings_service" ) as mock_settings: mock_settings.get_setting_value.return_value = "invalid" max_size = attachment_service.get_max_file_size_bytes(db) @@ -142,7 +142,7 @@ class TestMessageAttachmentServiceValidateAndStore: ) with patch( - "app.services.message_attachment_service.admin_settings_service" + "app.modules.messaging.services.message_attachment_service.admin_settings_service" ) as mock_settings: mock_settings.get_setting_value.return_value = 10 @@ -181,7 +181,7 @@ class TestMessageAttachmentServiceValidateAndStore: ) with patch( - "app.services.message_attachment_service.admin_settings_service" + "app.modules.messaging.services.message_attachment_service.admin_settings_service" ) as mock_settings: mock_settings.get_setting_value.return_value = 10 @@ -208,7 +208,7 @@ class TestMessageAttachmentServiceValidateAndStore: ) with patch( - "app.services.message_attachment_service.admin_settings_service" + "app.modules.messaging.services.message_attachment_service.admin_settings_service" ) as mock_settings: mock_settings.get_setting_value.return_value = 10 @@ -233,7 +233,7 @@ class TestMessageAttachmentServiceValidateAndStore: ) with patch( - "app.services.message_attachment_service.admin_settings_service" + "app.modules.messaging.services.message_attachment_service.admin_settings_service" ) as mock_settings: mock_settings.get_setting_value.return_value = 10 # 10 MB limit @@ -257,7 +257,7 @@ class TestMessageAttachmentServiceValidateAndStore: file.filename = None # Ensure it's None with patch( - "app.services.message_attachment_service.admin_settings_service" + "app.modules.messaging.services.message_attachment_service.admin_settings_service" ) as mock_settings: mock_settings.get_setting_value.return_value = 10 @@ -282,7 +282,7 @@ class TestMessageAttachmentServiceValidateAndStore: file.content_type = None with patch( - "app.services.message_attachment_service.admin_settings_service" + "app.modules.messaging.services.message_attachment_service.admin_settings_service" ) as mock_settings: mock_settings.get_setting_value.return_value = 10 diff --git a/tests/unit/services/test_messaging_service.py b/tests/unit/services/test_messaging_service.py index 7ddd2661..8445e8c5 100644 --- a/tests/unit/services/test_messaging_service.py +++ b/tests/unit/services/test_messaging_service.py @@ -3,7 +3,7 @@ import pytest -from app.services.messaging_service import MessagingService +from app.modules.messaging.services.messaging_service import MessagingService from app.modules.messaging.models import ( Conversation, ConversationParticipant, diff --git a/tests/unit/services/test_onboarding_service.py b/tests/unit/services/test_onboarding_service.py index 8dc05502..2b741192 100644 --- a/tests/unit/services/test_onboarding_service.py +++ b/tests/unit/services/test_onboarding_service.py @@ -15,7 +15,7 @@ from unittest.mock import MagicMock, patch import pytest -from app.services.onboarding_service import OnboardingService +from app.modules.marketplace.services.onboarding_service import OnboardingService from app.modules.marketplace.models import OnboardingStatus, OnboardingStep, VendorOnboarding @@ -50,7 +50,7 @@ class TestOnboardingServiceCRUD: def test_get_onboarding_or_raise_raises_exception(self, db): """Test get_onboarding_or_raise raises OnboardingNotFoundException""" - from app.exceptions import OnboardingNotFoundException + from app.modules.marketplace.exceptions import OnboardingNotFoundException service = OnboardingService(db) with pytest.raises(OnboardingNotFoundException): @@ -214,7 +214,7 @@ class TestOnboardingServiceStep1: def test_complete_company_profile_raises_for_missing_vendor(self, db): """Test complete_company_profile raises for non-existent vendor""" - from app.exceptions import VendorNotFoundException + from app.modules.tenancy.exceptions import VendorNotFoundException service = OnboardingService(db) @@ -238,7 +238,7 @@ class TestOnboardingServiceStep2: def test_test_letzshop_api_returns_result(self, db, test_vendor): """Test test_letzshop_api returns connection test result""" with patch( - "app.services.onboarding_service.LetzshopCredentialsService" + "app.modules.marketplace.services.onboarding_service.LetzshopCredentialsService" ) as mock_service: mock_instance = MagicMock() mock_instance.test_api_key.return_value = (True, 150.0, None) @@ -256,7 +256,7 @@ class TestOnboardingServiceStep2: def test_test_letzshop_api_returns_error(self, db, test_vendor): """Test test_letzshop_api returns error on failure""" with patch( - "app.services.onboarding_service.LetzshopCredentialsService" + "app.modules.marketplace.services.onboarding_service.LetzshopCredentialsService" ) as mock_service: mock_instance = MagicMock() mock_instance.test_api_key.return_value = (False, None, "Invalid API key") @@ -273,7 +273,7 @@ class TestOnboardingServiceStep2: def test_complete_letzshop_api_requires_step1(self, db, test_vendor): """Test complete_letzshop_api requires step 1 complete""" - from app.exceptions import OnboardingStepOrderException + from app.modules.marketplace.exceptions import OnboardingStepOrderException # Create onboarding with step 1 not complete onboarding = VendorOnboarding( @@ -319,7 +319,7 @@ class TestOnboardingServiceStep3: def test_complete_product_import_requires_csv_url(self, db, test_vendor): """Test complete_product_import requires at least one CSV URL""" - from app.exceptions import OnboardingCsvUrlRequiredException + from app.modules.marketplace.exceptions import OnboardingCsvUrlRequiredException # Create onboarding with steps 1 and 2 complete onboarding = VendorOnboarding( @@ -391,7 +391,7 @@ class TestOnboardingServiceStep4: db.commit() with patch( - "app.services.onboarding_service.LetzshopOrderService" + "app.modules.marketplace.services.onboarding_service.LetzshopOrderService" ) as mock_service: mock_instance = MagicMock() mock_instance.get_running_historical_import_job.return_value = None @@ -425,7 +425,7 @@ class TestOnboardingServiceStep4: db.commit() with patch( - "app.services.onboarding_service.LetzshopOrderService" + "app.modules.marketplace.services.onboarding_service.LetzshopOrderService" ) as mock_service: mock_instance = MagicMock() existing_job = MagicMock() @@ -446,7 +446,7 @@ class TestOnboardingServiceStep4: def test_get_order_sync_progress_not_found(self, db, test_vendor): """Test get_order_sync_progress for non-existent job""" with patch( - "app.services.onboarding_service.LetzshopOrderService" + "app.modules.marketplace.services.onboarding_service.LetzshopOrderService" ) as mock_service: mock_instance = MagicMock() mock_instance.get_historical_import_job_by_id.return_value = None @@ -464,7 +464,7 @@ class TestOnboardingServiceStep4: def test_get_order_sync_progress_completed(self, db, test_vendor): """Test get_order_sync_progress for completed job""" with patch( - "app.services.onboarding_service.LetzshopOrderService" + "app.modules.marketplace.services.onboarding_service.LetzshopOrderService" ) as mock_service: mock_instance = MagicMock() mock_job = MagicMock() @@ -494,7 +494,7 @@ class TestOnboardingServiceStep4: def test_get_order_sync_progress_processing(self, db, test_vendor): """Test get_order_sync_progress for processing job""" with patch( - "app.services.onboarding_service.LetzshopOrderService" + "app.modules.marketplace.services.onboarding_service.LetzshopOrderService" ) as mock_service: mock_instance = MagicMock() mock_job = MagicMock() @@ -525,7 +525,7 @@ class TestOnboardingServiceStep4: def test_complete_order_sync_raises_for_missing_job(self, db, test_vendor): """Test complete_order_sync raises for non-existent job""" - from app.exceptions import OnboardingSyncJobNotFoundException + from app.modules.marketplace.exceptions import OnboardingSyncJobNotFoundException onboarding = VendorOnboarding( vendor_id=test_vendor.id, @@ -535,7 +535,7 @@ class TestOnboardingServiceStep4: db.commit() with patch( - "app.services.onboarding_service.LetzshopOrderService" + "app.modules.marketplace.services.onboarding_service.LetzshopOrderService" ) as mock_service: mock_instance = MagicMock() mock_instance.get_historical_import_job_by_id.return_value = None @@ -550,7 +550,7 @@ class TestOnboardingServiceStep4: def test_complete_order_sync_raises_if_not_complete(self, db, test_vendor): """Test complete_order_sync raises if job still running""" - from app.exceptions import OnboardingSyncNotCompleteException + from app.modules.marketplace.exceptions import OnboardingSyncNotCompleteException onboarding = VendorOnboarding( vendor_id=test_vendor.id, @@ -560,7 +560,7 @@ class TestOnboardingServiceStep4: db.commit() with patch( - "app.services.onboarding_service.LetzshopOrderService" + "app.modules.marketplace.services.onboarding_service.LetzshopOrderService" ) as mock_service: mock_instance = MagicMock() mock_job = MagicMock() diff --git a/tests/unit/services/test_order_item_exception_service.py b/tests/unit/services/test_order_item_exception_service.py index 3cb30a4e..4993f27c 100644 --- a/tests/unit/services/test_order_item_exception_service.py +++ b/tests/unit/services/test_order_item_exception_service.py @@ -3,13 +3,13 @@ import pytest -from app.exceptions import ( +from app.modules.orders.exceptions import ( ExceptionAlreadyResolvedException, InvalidProductForExceptionException, OrderItemExceptionNotFoundException, - ProductNotFoundException, ) -from app.services.order_item_exception_service import OrderItemExceptionService +from app.modules.catalog.exceptions import ProductNotFoundException +from app.modules.orders.services.order_item_exception_service import OrderItemExceptionService from app.modules.orders.models import OrderItem from app.modules.orders.models import OrderItemException diff --git a/tests/unit/services/test_order_service.py b/tests/unit/services/test_order_service.py index 97422819..af8ba3b6 100644 --- a/tests/unit/services/test_order_service.py +++ b/tests/unit/services/test_order_service.py @@ -16,12 +16,10 @@ from unittest.mock import MagicMock, patch import pytest -from app.exceptions import ( - CustomerNotFoundException, - OrderNotFoundException, - ValidationException, -) -from app.services.order_service import ( +from app.exceptions import ValidationException +from app.modules.customers.exceptions import CustomerNotFoundException +from app.modules.orders.exceptions import OrderNotFoundException +from app.modules.orders.services.order_service import ( PLACEHOLDER_GTIN, PLACEHOLDER_MARKETPLACE_ID, OrderService, @@ -518,7 +516,7 @@ class TestOrderServiceLetzshop: } with patch( - "app.services.order_service.subscription_service" + "app.modules.orders.services.order_service.subscription_service" ) as mock_sub: mock_sub.can_create_order.return_value = (True, None) mock_sub.increment_order_count.return_value = None diff --git a/tests/unit/services/test_product_service.py b/tests/unit/services/test_product_service.py index bd6d8ad3..22372b65 100644 --- a/tests/unit/services/test_product_service.py +++ b/tests/unit/services/test_product_service.py @@ -1,13 +1,13 @@ # tests/test_product_service.py import pytest -from app.exceptions import ( +from app.modules.marketplace.exceptions import ( InvalidMarketplaceProductDataException, MarketplaceProductAlreadyExistsException, MarketplaceProductNotFoundException, MarketplaceProductValidationException, ) -from app.services.marketplace_product_service import MarketplaceProductService +from app.modules.marketplace.services.marketplace_product_service import MarketplaceProductService from app.modules.marketplace.schemas import ( MarketplaceProductCreate, MarketplaceProductUpdate, @@ -488,7 +488,7 @@ class TestMarketplaceProductServiceAdmin: def test_copy_to_vendor_catalog_invalid_vendor(self, db, test_marketplace_product): """Test copying to non-existent vendor raises exception.""" - from app.exceptions import VendorNotFoundException + from app.modules.tenancy.exceptions import VendorNotFoundException with pytest.raises(VendorNotFoundException): self.service.copy_to_vendor_catalog( diff --git a/tests/unit/services/test_stats_service.py b/tests/unit/services/test_stats_service.py index 881f82cf..7969396a 100644 --- a/tests/unit/services/test_stats_service.py +++ b/tests/unit/services/test_stats_service.py @@ -7,8 +7,8 @@ from unittest.mock import patch import pytest from sqlalchemy.exc import SQLAlchemyError -from app.exceptions import AdminOperationException, VendorNotFoundException -from app.services.stats_service import StatsService +from app.modules.tenancy.exceptions import AdminOperationException, VendorNotFoundException +from app.modules.analytics.services.stats_service import StatsService from app.modules.inventory.models import Inventory from app.modules.marketplace.models import ( MarketplaceProduct, diff --git a/tests/unit/services/test_team_service.py b/tests/unit/services/test_team_service.py index edd62314..342eae82 100644 --- a/tests/unit/services/test_team_service.py +++ b/tests/unit/services/test_team_service.py @@ -16,7 +16,7 @@ from unittest.mock import MagicMock import pytest from app.exceptions import ValidationException -from app.services.team_service import TeamService, team_service +from app.modules.tenancy.services.team_service import TeamService, team_service from models.database.vendor import Role, VendorUser diff --git a/tests/unit/services/test_usage_service.py b/tests/unit/services/test_usage_service.py index 50def26b..e32d6918 100644 --- a/tests/unit/services/test_usage_service.py +++ b/tests/unit/services/test_usage_service.py @@ -3,7 +3,7 @@ import pytest -from app.services.usage_service import UsageService, usage_service +from app.modules.analytics.services.usage_service import UsageService, usage_service from app.modules.catalog.models import Product from app.modules.billing.models import SubscriptionTier, VendorSubscription from models.database.vendor import VendorUser diff --git a/tests/unit/services/test_vendor_email_settings_service.py b/tests/unit/services/test_vendor_email_settings_service.py index ed21ae6a..96f69b0c 100644 --- a/tests/unit/services/test_vendor_email_settings_service.py +++ b/tests/unit/services/test_vendor_email_settings_service.py @@ -10,7 +10,7 @@ from app.exceptions import ( ResourceNotFoundException, ValidationException, ) -from app.services.vendor_email_settings_service import VendorEmailSettingsService +from app.modules.cms.services.vendor_email_settings_service import VendorEmailSettingsService from models.database import VendorEmailSettings, TierCode diff --git a/tests/unit/services/test_vendor_product_service.py b/tests/unit/services/test_vendor_product_service.py index c8aa758d..26d48397 100644 --- a/tests/unit/services/test_vendor_product_service.py +++ b/tests/unit/services/test_vendor_product_service.py @@ -7,8 +7,8 @@ Tests the vendor product catalog service operations. import pytest -from app.exceptions import ProductNotFoundException -from app.services.vendor_product_service import VendorProductService +from app.modules.catalog.exceptions import ProductNotFoundException +from app.modules.catalog.services.vendor_product_service import VendorProductService @pytest.mark.unit diff --git a/tests/unit/services/test_vendor_service.py b/tests/unit/services/test_vendor_service.py index 21b72a5c..1bcd42d9 100644 --- a/tests/unit/services/test_vendor_service.py +++ b/tests/unit/services/test_vendor_service.py @@ -5,16 +5,16 @@ import uuid import pytest -from app.exceptions import ( +from app.exceptions import ValidationException +from app.modules.tenancy.exceptions import ( InvalidVendorDataException, - MarketplaceProductNotFoundException, - ProductAlreadyExistsException, UnauthorizedVendorAccessException, - ValidationException, VendorAlreadyExistsException, VendorNotFoundException, ) -from app.services.vendor_service import VendorService +from app.modules.marketplace.exceptions import MarketplaceProductNotFoundException +from app.modules.catalog.exceptions import ProductAlreadyExistsException +from app.modules.tenancy.services.vendor_service import VendorService from models.database.company import Company from models.database.vendor import Vendor from app.modules.catalog.schemas import ProductCreate @@ -642,7 +642,7 @@ class TestVendorServiceUpdate: """Test update fails for unauthorized user.""" from pydantic import BaseModel - from app.exceptions import InsufficientPermissionsException + from app.modules.tenancy.exceptions import InsufficientPermissionsException from models.database.user import User class VendorUpdate(BaseModel): @@ -694,7 +694,7 @@ class TestVendorServiceUpdate: self, db, other_company, test_vendor ): """Test marketplace settings update fails for unauthorized user.""" - from app.exceptions import InsufficientPermissionsException + from app.modules.tenancy.exceptions import InsufficientPermissionsException from models.database.user import User other_user = db.query(User).filter(User.id == other_company.owner_user_id).first() @@ -722,7 +722,7 @@ class TestVendorServiceSingleton: def test_singleton_exists(self): """Test vendor_service singleton exists.""" - from app.services.vendor_service import vendor_service + from app.modules.tenancy.services.vendor_service import vendor_service assert vendor_service is not None assert isinstance(vendor_service, VendorService)