refactor: migrate templates and static files to self-contained modules
Templates Migration: - Migrate admin templates to modules (tenancy, billing, monitoring, marketplace, etc.) - Migrate vendor templates to modules (tenancy, billing, orders, messaging, etc.) - Migrate storefront templates to modules (catalog, customers, orders, cart, checkout, cms) - Migrate public templates to modules (billing, marketplace, cms) - Keep shared templates in app/templates/ (base.html, errors/, partials/, macros/) - Migrate letzshop partials to marketplace module Static Files Migration: - Migrate admin JS to modules: tenancy (23 files), core (5 files), monitoring (1 file) - Migrate vendor JS to modules: tenancy (4 files), core (2 files) - Migrate shared JS: vendor-selector.js to core, media-picker.js to cms - Migrate storefront JS: storefront-layout.js to core - Keep framework JS in static/ (api-client, utils, money, icons, log-config, lib/) - Update all template references to use module_static paths Naming Consistency: - Rename static/platform/ to static/public/ - Rename app/templates/platform/ to app/templates/public/ - Update all extends and static references Documentation: - Update module-system.md with shared templates documentation - Update frontend-structure.md with new module JS organization Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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__)
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
# Platform monitoring and alerts
|
||||
@@ -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"])
|
||||
38
app/api/v1/public/__init__.py
Normal file
38
app/api/v1/public/__init__.py
Normal file
@@ -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,
|
||||
)
|
||||
@@ -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__)
|
||||
@@ -1 +0,0 @@
|
||||
# Health checks
|
||||
@@ -1 +0,0 @@
|
||||
# File upload handling
|
||||
@@ -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)
|
||||
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(
|
||||
cms_storefront_router, prefix="/content-pages", tags=["storefront-content-pages"]
|
||||
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"]
|
||||
|
||||
29
app/api/v1/webhooks/__init__.py
Normal file
29
app/api/v1/webhooks/__init__.py
Normal file
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -1 +0,0 @@
|
||||
# Backup/recovery exceptions
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
@@ -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"
|
||||
@@ -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},
|
||||
)
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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",
|
||||
)
|
||||
@@ -1 +0,0 @@
|
||||
# Import/marketplace exceptions
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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",
|
||||
)
|
||||
@@ -1 +0,0 @@
|
||||
# Monitoring exceptions
|
||||
@@ -1 +0,0 @@
|
||||
# Notification exceptions
|
||||
@@ -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},
|
||||
)
|
||||
@@ -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},
|
||||
)
|
||||
@@ -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},
|
||||
)
|
||||
@@ -1 +0,0 @@
|
||||
# Payment processing exceptions
|
||||
@@ -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},
|
||||
)
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
@@ -1 +0,0 @@
|
||||
# Search exceptions
|
||||
@@ -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"
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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},
|
||||
)
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
@@ -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."""
|
||||
|
||||
87
app/modules/analytics/routes/pages/admin.py
Normal file
87
app/modules/analytics/routes/pages/admin.py
Normal file
@@ -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),
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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).
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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."""
|
||||
|
||||
80
app/modules/billing/routes/pages/admin.py
Normal file
80
app/modules/billing/routes/pages/admin.py
Normal file
@@ -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),
|
||||
)
|
||||
121
app/modules/billing/routes/pages/public.py
Normal file
121
app/modules/billing/routes/pages/public.py
Normal file
@@ -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,
|
||||
)
|
||||
62
app/modules/billing/routes/pages/vendor.py
Normal file
62
app/modules/billing/routes/pages/vendor.py
Normal file
@@ -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),
|
||||
)
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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,
|
||||
@@ -1,4 +1,4 @@
|
||||
# app/services/platform_pricing_service.py
|
||||
# app/modules/billing/services/platform_pricing_service.py
|
||||
"""
|
||||
Platform pricing service.
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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({
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
2
app/modules/cart/routes/pages/__init__.py
Normal file
2
app/modules/cart/routes/pages/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# app/modules/cart/routes/pages/__init__.py
|
||||
"""Cart module page routes."""
|
||||
46
app/modules/cart/routes/pages/storefront.py
Normal file
46
app/modules/cart/routes/pages/storefront.py
Normal file
@@ -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)
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
2
app/modules/catalog/routes/pages/__init__.py
Normal file
2
app/modules/catalog/routes/pages/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# app/modules/catalog/routes/pages/__init__.py
|
||||
"""Catalog module page routes."""
|
||||
110
app/modules/catalog/routes/pages/admin.py
Normal file
110
app/modules/catalog/routes/pages/admin.py
Normal file
@@ -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),
|
||||
)
|
||||
171
app/modules/catalog/routes/pages/storefront.py
Normal file
171
app/modules/catalog/routes/pages/storefront.py
Normal file
@@ -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),
|
||||
)
|
||||
64
app/modules/catalog/routes/pages/vendor.py
Normal file
64
app/modules/catalog/routes/pages/vendor.py
Normal file
@@ -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),
|
||||
)
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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__)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -507,6 +507,6 @@
|
||||
document.head.appendChild(script);
|
||||
})();
|
||||
</script>
|
||||
<script src="{{ url_for('static', path='shared/js/media-picker.js') }}"></script>
|
||||
<script src="{{ url_for('cms_static', path='shared/js/media-picker.js') }}"></script>
|
||||
<script src="{{ url_for('catalog_static', path='admin/js/product-create.js') }}"></script>
|
||||
{% endblock %}
|
||||
@@ -498,6 +498,6 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script src="{{ url_for('static', path='shared/js/media-picker.js') }}"></script>
|
||||
<script src="{{ url_for('cms_static', path='shared/js/media-picker.js') }}"></script>
|
||||
<script src="{{ url_for('catalog_static', path='admin/js/product-edit.js') }}"></script>
|
||||
{% endblock %}
|
||||
281
app/modules/catalog/templates/catalog/storefront/category.html
Normal file
281
app/modules/catalog/templates/catalog/storefront/category.html
Normal file
@@ -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 %}
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
|
||||
{# Breadcrumbs #}
|
||||
<div class="breadcrumb mb-6">
|
||||
<a href="{{ base_url }}" class="hover:text-primary">Home</a>
|
||||
<span>/</span>
|
||||
<a href="{{ base_url }}shop/products" class="hover:text-primary">Products</a>
|
||||
<span>/</span>
|
||||
<span class="text-gray-900 dark:text-gray-200 font-medium" x-text="categoryName">{{ category_slug | replace('-', ' ') | title if category_slug else 'Category' }}</span>
|
||||
</div>
|
||||
|
||||
{# Page Header #}
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl md:text-4xl font-bold text-gray-800 dark:text-gray-200 mb-2" x-text="categoryName">
|
||||
{{ category_slug | replace('-', ' ') | title if category_slug else 'Category' }}
|
||||
</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400" x-show="!loading && total > 0">
|
||||
<span x-text="total" class="font-semibold"></span> product<span x-show="total !== 1">s</span> in this category
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{# Sort Bar #}
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-4 mb-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Showing <span x-text="products.length" class="font-semibold"></span> of <span x-text="total" class="font-semibold"></span> products
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-sm text-gray-600 dark:text-gray-400">Sort by:</label>
|
||||
<select
|
||||
x-model="sortBy"
|
||||
@change="loadProducts()"
|
||||
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white"
|
||||
>
|
||||
<option value="newest">Newest First</option>
|
||||
<option value="price-low">Price: Low to High</option>
|
||||
<option value="price-high">Price: High to Low</option>
|
||||
<option value="popular">Most Popular</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Products Grid #}
|
||||
<div>
|
||||
{# Loading State #}
|
||||
<div x-show="loading" class="flex justify-center items-center py-12">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
|
||||
{# Products Grid #}
|
||||
<div x-show="!loading && products.length > 0" class="product-grid">
|
||||
<template x-for="product in products" :key="product.id">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm hover:shadow-md transition-shadow overflow-hidden">
|
||||
<a :href="`{{ base_url }}shop/products/${product.id}`">
|
||||
<img :src="product.marketplace_product?.image_link || '/static/shop/img/placeholder.svg'"
|
||||
@error="$el.src = '/static/shop/img/placeholder.svg'"
|
||||
:alt="product.marketplace_product?.title"
|
||||
class="w-full h-48 object-cover">
|
||||
</a>
|
||||
<div class="p-4">
|
||||
<a :href="`{{ base_url }}shop/products/${product.id}`" class="block">
|
||||
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-2 line-clamp-2" x-text="product.marketplace_product?.title"></h3>
|
||||
</a>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3 line-clamp-2" x-text="product.marketplace_product?.description"></p>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="min-w-0">
|
||||
<span class="text-xl sm:text-2xl font-bold text-primary" x-text="formatPrice(product.price)"></span>
|
||||
<span x-show="product.sale_price" class="text-sm text-gray-500 line-through ml-2" x-text="formatPrice(product.sale_price)"></span>
|
||||
</div>
|
||||
<button @click.prevent="addToCart(product)"
|
||||
class="flex-shrink-0 p-2 sm:px-4 sm:py-2 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors flex items-center justify-center gap-2"
|
||||
style="background-color: var(--color-primary)"
|
||||
:title="'Add to Cart'">
|
||||
<span class="w-5 h-5" x-html="$icon('shopping-cart', 'w-5 h-5')"></span>
|
||||
<span class="hidden sm:inline">Add to Cart</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
{# No Products Message #}
|
||||
<div x-show="!loading && products.length === 0" class="col-span-full text-center py-12 bg-white dark:bg-gray-800 rounded-lg border-2 border-dashed border-gray-300 dark:border-gray-600">
|
||||
<div class="text-6xl mb-4">📦</div>
|
||||
<h3 class="text-2xl font-semibold text-gray-700 dark:text-gray-200 mb-2">
|
||||
No Products in This Category
|
||||
</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-4">
|
||||
Check back later or browse other categories.
|
||||
</p>
|
||||
<a href="{{ base_url }}shop/products" class="inline-block px-6 py-3 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors" style="background-color: var(--color-primary)">
|
||||
Browse All Products
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{# Pagination #}
|
||||
<div x-show="!loading && totalPages > 1" class="mt-8 flex justify-center">
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
@click="goToPage(currentPage - 1)"
|
||||
:disabled="currentPage === 1"
|
||||
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
|
||||
<template x-for="page in visiblePages" :key="page">
|
||||
<button
|
||||
@click="goToPage(page)"
|
||||
:class="page === currentPage ? 'bg-primary text-white' : 'border border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700'"
|
||||
class="px-4 py-2 rounded-lg"
|
||||
:style="page === currentPage ? 'background-color: var(--color-primary)' : ''"
|
||||
x-text="page"
|
||||
></button>
|
||||
</template>
|
||||
|
||||
<button
|
||||
@click="goToPage(currentPage + 1)"
|
||||
:disabled="currentPage === totalPages"
|
||||
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
// Pass category slug from template to JavaScript
|
||||
window.CATEGORY_SLUG = '{{ category_slug | default("") }}';
|
||||
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('shopCategory', () => ({
|
||||
...shopLayoutData(),
|
||||
|
||||
// Data
|
||||
categorySlug: window.CATEGORY_SLUG,
|
||||
categoryName: '',
|
||||
products: [],
|
||||
total: 0,
|
||||
loading: true,
|
||||
sortBy: 'newest',
|
||||
|
||||
// Pagination
|
||||
currentPage: 1,
|
||||
perPage: 12,
|
||||
|
||||
get totalPages() {
|
||||
return Math.ceil(this.total / this.perPage);
|
||||
},
|
||||
|
||||
get visiblePages() {
|
||||
const pages = [];
|
||||
const total = this.totalPages;
|
||||
const current = this.currentPage;
|
||||
|
||||
let start = Math.max(1, current - 2);
|
||||
let end = Math.min(total, current + 2);
|
||||
|
||||
if (end - start < 4) {
|
||||
if (start === 1) {
|
||||
end = Math.min(total, 5);
|
||||
} else {
|
||||
start = Math.max(1, total - 4);
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = start; i <= end; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
return pages;
|
||||
},
|
||||
|
||||
async init() {
|
||||
console.log('[SHOP] Category page initializing...');
|
||||
console.log('[SHOP] Category slug:', this.categorySlug);
|
||||
|
||||
// Convert slug to display name
|
||||
this.categoryName = this.categorySlug
|
||||
.split('-')
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
|
||||
await this.loadProducts();
|
||||
},
|
||||
|
||||
async loadProducts() {
|
||||
this.loading = true;
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
skip: (this.currentPage - 1) * this.perPage,
|
||||
limit: this.perPage,
|
||||
category: this.categorySlug
|
||||
});
|
||||
|
||||
if (this.sortBy) {
|
||||
params.append('sort', this.sortBy);
|
||||
}
|
||||
|
||||
console.log(`[SHOP] Loading category products from /api/v1/shop/products?${params}`);
|
||||
|
||||
const response = await fetch(`/api/v1/shop/products?${params}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
console.log(`[SHOP] Loaded ${data.products.length} products (total: ${data.total})`);
|
||||
|
||||
this.products = data.products;
|
||||
this.total = data.total;
|
||||
} catch (error) {
|
||||
console.error('[SHOP] Failed to load category products:', error);
|
||||
this.showToast('Failed to load products', 'error');
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async goToPage(page) {
|
||||
if (page < 1 || page > this.totalPages) return;
|
||||
this.currentPage = page;
|
||||
await this.loadProducts();
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
},
|
||||
|
||||
async addToCart(product) {
|
||||
console.log('[SHOP] Adding to cart:', product);
|
||||
|
||||
try {
|
||||
const url = `/api/v1/shop/cart/${this.sessionId}/items`;
|
||||
const payload = {
|
||||
product_id: product.id,
|
||||
quantity: 1
|
||||
};
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
console.log('[SHOP] Add to cart success:', result);
|
||||
this.cartCount += 1;
|
||||
this.showToast(`${product.marketplace_product.title} added to cart`, 'success');
|
||||
} else {
|
||||
const error = await response.json();
|
||||
console.error('[SHOP] Add to cart error:', error);
|
||||
this.showToast(error.message || 'Failed to add to cart', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[SHOP] Add to cart exception:', error);
|
||||
this.showToast('Failed to add to cart', 'error');
|
||||
}
|
||||
}
|
||||
}));
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,4 +1,4 @@
|
||||
{# app/templates/storefront/product.html #}
|
||||
{# app/modules/catalog/templates/catalog/storefront/product.html #}
|
||||
{% extends "storefront/base.html" %}
|
||||
|
||||
{% block title %}{{ product.name if product else 'Product' }}{% endblock %}
|
||||
@@ -1,4 +1,4 @@
|
||||
{# app/templates/storefront/products.html #}
|
||||
{# app/modules/catalog/templates/catalog/storefront/products.html #}
|
||||
{% extends "storefront/base.html" %}
|
||||
|
||||
{% block title %}Products{% endblock %}
|
||||
@@ -1,4 +1,4 @@
|
||||
{# app/templates/storefront/search.html #}
|
||||
{# app/modules/catalog/templates/catalog/storefront/search.html #}
|
||||
{# noqa: FE-001 - Shop uses custom pagination with vendor-themed styling (CSS variables) #}
|
||||
{% extends "storefront/base.html" %}
|
||||
|
||||
252
app/modules/catalog/templates/catalog/storefront/wishlist.html
Normal file
252
app/modules/catalog/templates/catalog/storefront/wishlist.html
Normal file
@@ -0,0 +1,252 @@
|
||||
{# app/modules/catalog/templates/catalog/storefront/wishlist.html #}
|
||||
{% extends "storefront/base.html" %}
|
||||
|
||||
{% block title %}My Wishlist{% endblock %}
|
||||
|
||||
{# Alpine.js component #}
|
||||
{% block alpine_data %}shopWishlist(){% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
|
||||
{# Breadcrumbs #}
|
||||
<div class="breadcrumb mb-6">
|
||||
<a href="{{ base_url }}" class="hover:text-primary">Home</a>
|
||||
<span>/</span>
|
||||
<a href="{{ base_url }}shop/account/dashboard" class="hover:text-primary">Account</a>
|
||||
<span>/</span>
|
||||
<span class="text-gray-900 dark:text-gray-200 font-medium">Wishlist</span>
|
||||
</div>
|
||||
|
||||
{# Page Header #}
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl md:text-4xl font-bold text-gray-800 dark:text-gray-200 mb-2">
|
||||
My Wishlist
|
||||
</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400" x-show="!loading && items.length > 0">
|
||||
<span x-text="items.length" class="font-semibold"></span> saved item<span x-show="items.length !== 1">s</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{# Wishlist Content #}
|
||||
<div>
|
||||
{# Loading State #}
|
||||
<div x-show="loading" class="flex justify-center items-center py-12">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
|
||||
{# Not Logged In Message #}
|
||||
<div x-show="!loading && !isLoggedIn" class="text-center py-12 bg-white dark:bg-gray-800 rounded-lg border-2 border-dashed border-gray-300 dark:border-gray-600">
|
||||
<div class="text-6xl mb-4">
|
||||
<span x-html="$icon('user', 'w-16 h-16 mx-auto text-gray-400')"></span>
|
||||
</div>
|
||||
<h3 class="text-2xl font-semibold text-gray-700 dark:text-gray-200 mb-2">
|
||||
Please Log In
|
||||
</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-6">
|
||||
Log in to your account to view and manage your wishlist.
|
||||
</p>
|
||||
<a href="{{ base_url }}shop/account/login" class="inline-block px-6 py-3 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors" style="background-color: var(--color-primary)">
|
||||
Log In
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{# Wishlist Items Grid #}
|
||||
<div x-show="!loading && isLoggedIn && items.length > 0" class="product-grid">
|
||||
<template x-for="item in items" :key="item.id">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm hover:shadow-md transition-shadow overflow-hidden relative">
|
||||
{# Remove from Wishlist Button #}
|
||||
<button
|
||||
@click="removeFromWishlist(item)"
|
||||
class="absolute top-2 right-2 z-10 p-2 bg-white dark:bg-gray-700 rounded-full shadow-md hover:bg-red-50 dark:hover:bg-red-900 transition-colors group"
|
||||
title="Remove from wishlist"
|
||||
>
|
||||
<span x-html="$icon('heart', 'w-5 h-5 text-red-500 fill-current')"></span>
|
||||
</button>
|
||||
|
||||
<a :href="`{{ base_url }}shop/products/${item.product.id}`">
|
||||
<img :src="item.product.marketplace_product?.image_link || '/static/shop/img/placeholder.svg'"
|
||||
@error="$el.src = '/static/shop/img/placeholder.svg'"
|
||||
:alt="item.product.marketplace_product?.title"
|
||||
class="w-full h-48 object-cover">
|
||||
</a>
|
||||
<div class="p-4">
|
||||
<a :href="`{{ base_url }}shop/products/${item.product.id}`" class="block">
|
||||
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-2 line-clamp-2" x-text="item.product.marketplace_product?.title"></h3>
|
||||
</a>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3 line-clamp-2" x-text="item.product.marketplace_product?.description"></p>
|
||||
|
||||
{# Availability #}
|
||||
<div class="mb-3">
|
||||
<span
|
||||
x-show="item.product.available_inventory > 0"
|
||||
class="text-sm text-green-600 dark:text-green-400"
|
||||
>
|
||||
In Stock
|
||||
</span>
|
||||
<span
|
||||
x-show="!item.product.available_inventory || item.product.available_inventory <= 0"
|
||||
class="text-sm text-red-600 dark:text-red-400"
|
||||
>
|
||||
Out of Stock
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="min-w-0">
|
||||
<span class="text-xl sm:text-2xl font-bold text-primary" x-text="formatPrice(item.product.price)"></span>
|
||||
<span x-show="item.product.sale_price" class="text-sm text-gray-500 line-through ml-2" x-text="formatPrice(item.product.sale_price)"></span>
|
||||
</div>
|
||||
<button @click.prevent="addToCart(item.product)"
|
||||
:disabled="!item.product.available_inventory || item.product.available_inventory <= 0"
|
||||
class="flex-shrink-0 p-2 sm:px-4 sm:py-2 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
style="background-color: var(--color-primary)"
|
||||
:title="'Add to Cart'">
|
||||
<span class="w-5 h-5" x-html="$icon('shopping-cart', 'w-5 h-5')"></span>
|
||||
<span class="hidden sm:inline">Add to Cart</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
{# Empty Wishlist Message #}
|
||||
<div x-show="!loading && isLoggedIn && items.length === 0" class="text-center py-12 bg-white dark:bg-gray-800 rounded-lg border-2 border-dashed border-gray-300 dark:border-gray-600">
|
||||
<div class="text-6xl mb-4">
|
||||
<span x-html="$icon('heart', 'w-16 h-16 mx-auto text-gray-400')"></span>
|
||||
</div>
|
||||
<h3 class="text-2xl font-semibold text-gray-700 dark:text-gray-200 mb-2">
|
||||
Your Wishlist is Empty
|
||||
</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-6">
|
||||
Save items you like by clicking the heart icon on product pages.
|
||||
</p>
|
||||
<a href="{{ base_url }}shop/products" class="inline-block px-6 py-3 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors" style="background-color: var(--color-primary)">
|
||||
Browse Products
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('shopWishlist', () => ({
|
||||
...shopLayoutData(),
|
||||
|
||||
// Data
|
||||
items: [],
|
||||
loading: true,
|
||||
isLoggedIn: false,
|
||||
|
||||
async init() {
|
||||
console.log('[SHOP] Wishlist page initializing...');
|
||||
|
||||
// Check if user is logged in
|
||||
this.isLoggedIn = await this.checkLoginStatus();
|
||||
|
||||
if (this.isLoggedIn) {
|
||||
await this.loadWishlist();
|
||||
} else {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async checkLoginStatus() {
|
||||
try {
|
||||
const response = await fetch('/api/v1/shop/customers/me');
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
async loadWishlist() {
|
||||
this.loading = true;
|
||||
|
||||
try {
|
||||
console.log('[SHOP] Loading wishlist...');
|
||||
|
||||
const response = await fetch('/api/v1/shop/wishlist');
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
this.isLoggedIn = false;
|
||||
return;
|
||||
}
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
console.log(`[SHOP] Loaded ${data.items?.length || 0} wishlist items`);
|
||||
|
||||
this.items = data.items || [];
|
||||
} catch (error) {
|
||||
console.error('[SHOP] Failed to load wishlist:', error);
|
||||
this.showToast('Failed to load wishlist', 'error');
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async removeFromWishlist(item) {
|
||||
try {
|
||||
console.log('[SHOP] Removing from wishlist:', item);
|
||||
|
||||
const response = await fetch(`/api/v1/shop/wishlist/${item.id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
this.items = this.items.filter(i => i.id !== item.id);
|
||||
this.showToast('Removed from wishlist', 'success');
|
||||
} else {
|
||||
throw new Error('Failed to remove from wishlist');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[SHOP] Failed to remove from wishlist:', error);
|
||||
this.showToast('Failed to remove from wishlist', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async addToCart(product) {
|
||||
console.log('[SHOP] Adding to cart:', product);
|
||||
|
||||
try {
|
||||
const url = `/api/v1/shop/cart/${this.sessionId}/items`;
|
||||
const payload = {
|
||||
product_id: product.id,
|
||||
quantity: 1
|
||||
};
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
console.log('[SHOP] Add to cart success:', result);
|
||||
this.cartCount += 1;
|
||||
this.showToast(`${product.marketplace_product?.title || 'Product'} added to cart`, 'success');
|
||||
} else {
|
||||
const error = await response.json();
|
||||
console.error('[SHOP] Add to cart error:', error);
|
||||
this.showToast(error.message || 'Failed to add to cart', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[SHOP] Add to cart exception:', error);
|
||||
this.showToast('Failed to add to cart', 'error');
|
||||
}
|
||||
}
|
||||
}));
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user