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 %} +
+ products in this category +
++ Check back later or browse other categories. +
+ + Browse All Products + ++ saved items +
++ Log in to your account to view and manage your wishlist. +
+ + Log In + ++ Save items you like by clicking the heart icon on product pages. +
+ + Browse Products + +{json.dumps(debug_info, indent=2)}
+
+ + 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('settings', tab_var='activeTab') }} - {% include 'admin/partials/letzshop-settings-tab.html' %} + {% include 'marketplace/admin/partials/letzshop-settings-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 @@ - +