diff --git a/.env b/.env index 392a1780..708119f1 100644 --- a/.env +++ b/.env @@ -35,4 +35,19 @@ RATE_LIMIT_WINDOW=3600 # Logging LOG_LEVEL=INFO -LOG_FILE=log/app.log \ No newline at end of file +LOG_FILE=log/app.log + +# Platform domain configuration +PLATFORM_DOMAIN=platform.com # Your main platform domain + +# Custom domain features +ALLOW_CUSTOM_DOMAINS=True # Enable/disable custom domains +REQUIRE_DOMAIN_VERIFICATION=True # Require DNS verification + +# SSL/TLS configuration for custom domains +SSL_PROVIDER=letsencrypt # or "cloudflare", "manual" +AUTO_PROVISION_SSL=False # Set to True if using automated SSL + +# DNS verification +DNS_VERIFICATION_PREFIX=_letzshop-verify +DNS_VERIFICATION_TTL=3600 \ No newline at end of file diff --git a/.env.example b/.env.example index db98bb37..708119f1 100644 --- a/.env.example +++ b/.env.example @@ -1,12 +1,27 @@ # .env.example + +# Project information +PROJECT_NAME=Ecommerce Backend API with Marketplace Support +DESCRIPTION=Advanced product management system with JWT authentication +VERSION=0.0.1 + # Database Configuration -DATABASE_URL=postgresql://username:password@localhost:5432/ecommerce_db +# DATABASE_URL=postgresql://username:password@localhost:5432/ecommerce_db # For development, you can use SQLite: -# DATABASE_URL=sqlite:///./ecommerce.db +DATABASE_URL=sqlite:///./ecommerce.db + +# Documentation +# .env.development +DOCUMENTATION_URL=http://localhost:8001 +# .env.production +# DOCUMENTATION_URL=https://yourdomain.com/docs +# .env.staging +# DOCUMENTATION_URL=https://staging-docs.yourdomain.com # JWT Configuration JWT_SECRET_KEY=your-super-secret-jwt-key-change-in-production JWT_EXPIRE_HOURS=24 +JWT_EXPIRE_MINUTES=30 # API Configuration API_HOST=0.0.0.0 @@ -15,9 +30,24 @@ DEBUG=False # Rate Limiting RATE_LIMIT_ENABLED=True -DEFAULT_RATE_LIMIT=100 -DEFAULT_WINDOW_SECONDS=3600 +RATE_LIMIT_REQUESTS=100 +RATE_LIMIT_WINDOW=3600 # Logging LOG_LEVEL=INFO -LOG_FILE=app.log \ No newline at end of file +LOG_FILE=log/app.log + +# Platform domain configuration +PLATFORM_DOMAIN=platform.com # Your main platform domain + +# Custom domain features +ALLOW_CUSTOM_DOMAINS=True # Enable/disable custom domains +REQUIRE_DOMAIN_VERIFICATION=True # Require DNS verification + +# SSL/TLS configuration for custom domains +SSL_PROVIDER=letsencrypt # or "cloudflare", "manual" +AUTO_PROVISION_SSL=False # Set to True if using automated SSL + +# DNS verification +DNS_VERIFICATION_PREFIX=_letzshop-verify +DNS_VERIFICATION_TTL=3600 \ No newline at end of file diff --git a/app/api/v1/admin/__init__.py b/app/api/v1/admin/__init__.py index 01c143c3..0888da7c 100644 --- a/app/api/v1/admin/__init__.py +++ b/app/api/v1/admin/__init__.py @@ -5,6 +5,7 @@ Admin API router aggregation. This module combines all admin-related API endpoints: - Authentication (login/logout) - Vendor management (CRUD, bulk operations) +- Vendor domains management (custom domains, DNS verification) - User management (status, roles) - Dashboard and statistics - Marketplace monitoring @@ -20,6 +21,7 @@ from fastapi import APIRouter from . import ( auth, vendors, + vendor_domains, users, dashboard, marketplace, @@ -34,28 +36,56 @@ from . import ( router = APIRouter() +# ============================================================================ +# Authentication & Authorization +# ============================================================================ + # Include authentication endpoints router.include_router(auth.router, tags=["admin-auth"]) + +# ============================================================================ +# Vendor Management +# ============================================================================ + # 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"]) + + +# ============================================================================ +# User Management +# ============================================================================ + # Include user management endpoints router.include_router(users.router, tags=["admin-users"]) + +# ============================================================================ +# Dashboard & Statistics +# ============================================================================ + # Include dashboard and statistics endpoints router.include_router(dashboard.router, tags=["admin-dashboard"]) + +# ============================================================================ +# Marketplace & Imports +# ============================================================================ + # Include marketplace monitoring endpoints router.include_router(marketplace.router, tags=["admin-marketplace"]) -# Include monitoring endpoints (placeholder) -# router.include_router(monitoring.router, tags=["admin-monitoring"]) # ============================================================================ -# Admin Models Integration +# Platform Administration # ============================================================================ +# Include monitoring endpoints (placeholder for future implementation) +# router.include_router(monitoring.router, tags=["admin-monitoring"]) + # Include audit logging endpoints router.include_router(audit.router, tags=["admin-audit"]) @@ -65,6 +95,7 @@ router.include_router(settings.router, tags=["admin-settings"]) # Include notifications and alerts endpoints router.include_router(notifications.router, tags=["admin-notifications"]) + # ============================================================================ # HTML Page Routes (Jinja2 Templates) # ============================================================================ @@ -72,5 +103,6 @@ router.include_router(notifications.router, tags=["admin-notifications"]) # Include HTML page routes (these return rendered templates, not JSON) router.include_router(pages.router, tags=["admin-pages"]) + # Export the router __all__ = ["router"] diff --git a/app/api/v1/admin/pages.py b/app/api/v1/admin/pages.py index 642da71b..09e85da5 100644 --- a/app/api/v1/admin/pages.py +++ b/app/api/v1/admin/pages.py @@ -16,6 +16,7 @@ Routes: - GET /vendors/create → Create vendor form (auth required) - GET /vendors/{vendor_code} → Vendor details (auth required) - GET /vendors/{vendor_code}/edit → Edit vendor form (auth required) +- GET /vendors/{vendor_code}/domains → Vendor domains management (auth required) - GET /users → User management page (auth required) - GET /imports → Import history page (auth required) - GET /settings → Settings page (auth required) @@ -166,6 +167,55 @@ async def admin_vendor_edit_page( ) +# ============================================================================ +# VENDOR DOMAINS ROUTES +# ============================================================================ + +@router.get("/vendors/{vendor_code}/domains", response_class=HTMLResponse, include_in_schema=False) +async def admin_vendor_domains_page( + request: Request, + vendor_code: str = Path(..., description="Vendor code"), + current_user: User = Depends(get_current_admin_user), + db: Session = Depends(get_db) +): + """ + Render vendor domains management page. + Shows custom domains, verification status, and DNS configuration. + """ + return templates.TemplateResponse( + "admin/vendor-domains.html", + { + "request": request, + "user": current_user, + "vendor_code": vendor_code, + } + ) + + +# ============================================================================ +# VENDOR THEMES ROUTES +# ============================================================================ + +@router.get("/vendors/{vendor_code}/theme", response_class=HTMLResponse, include_in_schema=False) +async def admin_vendor_theme_page( + request: Request, + vendor_code: str = Path(..., description="Vendor code"), + current_user: User = Depends(get_current_admin_user), + db: Session = Depends(get_db) +): + """ + Render vendor theme customization page. + """ + return templates.TemplateResponse( + "admin/vendor-theme.html", + { + "request": request, + "user": current_user, + "vendor_code": vendor_code, + } + ) + + # ============================================================================ # USER MANAGEMENT ROUTES # ============================================================================ @@ -257,6 +307,7 @@ async def admin_components_page( } ) + @router.get("/icons", response_class=HTMLResponse, include_in_schema=False) async def admin_icons_page( request: Request, @@ -275,6 +326,7 @@ async def admin_icons_page( } ) + @router.get("/testing", response_class=HTMLResponse, include_in_schema=False) async def admin_testing_hub( request: Request, diff --git a/app/api/v1/admin/vendor_domains.py b/app/api/v1/admin/vendor_domains.py new file mode 100644 index 00000000..b9d470b2 --- /dev/null +++ b/app/api/v1/admin/vendor_domains.py @@ -0,0 +1,328 @@ +# app/api/v1/admin/vendor_domains.py +""" +Admin endpoints for managing vendor custom domains. + +Follows the architecture pattern: +- Endpoints only handle HTTP layer +- Business logic in service layer +- Proper exception handling +- Pydantic schemas for validation +""" + +import logging +from typing import List + +from fastapi import APIRouter, Depends, Path, Body, Query +from sqlalchemy.orm import Session + +from app.api.deps import get_current_admin_user +from app.core.database import get_db +from app.services.vendor_domain_service import vendor_domain_service +from app.exceptions import VendorNotFoundException +from models.schema.vendor_domain import ( + VendorDomainCreate, + VendorDomainUpdate, + VendorDomainResponse, + VendorDomainListResponse, + DomainVerificationInstructions, + DomainVerificationResponse, + DomainDeletionResponse, +) +from models.database.user import User +from models.database.vendor import Vendor + +router = APIRouter(prefix="/vendors") +logger = logging.getLogger(__name__) + + +def _get_vendor_by_id(db: Session, vendor_id: int) -> Vendor: + """ + Helper to get vendor by ID. + + Args: + db: Database session + vendor_id: Vendor ID + + Returns: + Vendor object + + Raises: + VendorNotFoundException: If vendor not found + """ + vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first() + if not vendor: + raise VendorNotFoundException(str(vendor_id), identifier_type="id") + return vendor + + +@router.post("/{vendor_id}/domains", response_model=VendorDomainResponse) +def add_vendor_domain( + vendor_id: int = Path(..., description="Vendor ID", gt=0), + domain_data: VendorDomainCreate = Body(...), + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin_user), +): + """ + Add a custom domain to vendor (Admin only). + + This endpoint: + 1. Validates the domain format + 2. Checks if domain is already registered + 3. Generates verification token + 4. Creates domain record (unverified, inactive) + 5. Returns domain with verification instructions + + **Domain Examples:** + - myshop.com + - shop.mybrand.com + - customstore.net + + **Next Steps:** + 1. Vendor adds DNS TXT record + 2. Admin clicks "Verify Domain" to confirm ownership + 3. Once verified, domain can be activated + + **Raises:** + - 404: Vendor not found + - 409: Domain already registered + - 422: Invalid domain format or reserved subdomain + """ + domain = vendor_domain_service.add_domain( + db=db, + vendor_id=vendor_id, + domain_data=domain_data + ) + + return VendorDomainResponse( + id=domain.id, + vendor_id=domain.vendor_id, + domain=domain.domain, + is_primary=domain.is_primary, + is_active=domain.is_active, + is_verified=domain.is_verified, + ssl_status=domain.ssl_status, + verification_token=domain.verification_token, + verified_at=domain.verified_at, + ssl_verified_at=domain.ssl_verified_at, + created_at=domain.created_at, + updated_at=domain.updated_at, + ) + + +@router.get("/{vendor_id}/domains", response_model=VendorDomainListResponse) +def list_vendor_domains( + vendor_id: int = Path(..., description="Vendor ID", gt=0), + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin_user), +): + """ + List all domains for a vendor (Admin only). + + Returns domains ordered by: + 1. Primary domains first + 2. Creation date (newest first) + + **Raises:** + - 404: Vendor not found + """ + # Verify vendor exists + _get_vendor_by_id(db, vendor_id) + + domains = vendor_domain_service.get_vendor_domains(db, vendor_id) + + return VendorDomainListResponse( + domains=[ + VendorDomainResponse( + id=d.id, + vendor_id=d.vendor_id, + domain=d.domain, + is_primary=d.is_primary, + is_active=d.is_active, + is_verified=d.is_verified, + ssl_status=d.ssl_status, + verification_token=d.verification_token if not d.is_verified else None, + verified_at=d.verified_at, + ssl_verified_at=d.ssl_verified_at, + created_at=d.created_at, + updated_at=d.updated_at, + ) + for d in domains + ], + total=len(domains) + ) + + +@router.get("/domains/{domain_id}", response_model=VendorDomainResponse) +def get_domain_details( + domain_id: int = Path(..., description="Domain ID", gt=0), + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin_user), +): + """ + Get detailed information about a specific domain (Admin only). + + **Raises:** + - 404: Domain not found + """ + domain = vendor_domain_service.get_domain_by_id(db, domain_id) + + return VendorDomainResponse( + id=domain.id, + vendor_id=domain.vendor_id, + domain=domain.domain, + is_primary=domain.is_primary, + is_active=domain.is_active, + is_verified=domain.is_verified, + ssl_status=domain.ssl_status, + verification_token=domain.verification_token if not domain.is_verified else None, + verified_at=domain.verified_at, + ssl_verified_at=domain.ssl_verified_at, + created_at=domain.created_at, + updated_at=domain.updated_at, + ) + + +@router.put("/domains/{domain_id}", response_model=VendorDomainResponse) +def update_vendor_domain( + domain_id: int = Path(..., description="Domain ID", gt=0), + domain_update: VendorDomainUpdate = Body(...), + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin_user), +): + """ + Update domain settings (Admin only). + + **Can update:** + - `is_primary`: Set as primary domain for vendor + - `is_active`: Activate or deactivate domain + + **Important:** + - Cannot activate unverified domains + - Setting a domain as primary will unset other primary domains + - Cannot modify domain name (delete and recreate instead) + + **Raises:** + - 404: Domain not found + - 400: Cannot activate unverified domain + """ + domain = vendor_domain_service.update_domain( + db=db, + domain_id=domain_id, + domain_update=domain_update + ) + + return VendorDomainResponse( + id=domain.id, + vendor_id=domain.vendor_id, + domain=domain.domain, + is_primary=domain.is_primary, + is_active=domain.is_active, + is_verified=domain.is_verified, + ssl_status=domain.ssl_status, + verification_token=None, # Don't expose token after updates + verified_at=domain.verified_at, + ssl_verified_at=domain.ssl_verified_at, + created_at=domain.created_at, + updated_at=domain.updated_at, + ) + + +@router.delete("/domains/{domain_id}", response_model=DomainDeletionResponse) +def delete_vendor_domain( + domain_id: int = Path(..., description="Domain ID", gt=0), + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin_user), +): + """ + Delete a custom domain (Admin only). + + **Warning:** This is permanent and cannot be undone. + + **Raises:** + - 404: Domain not found + """ + # Get domain details before deletion + domain = vendor_domain_service.get_domain_by_id(db, domain_id) + vendor_id = domain.vendor_id + domain_name = domain.domain + + # Delete domain + message = vendor_domain_service.delete_domain(db, domain_id) + + return DomainDeletionResponse( + message=message, + domain=domain_name, + vendor_id=vendor_id + ) + + +@router.post("/domains/{domain_id}/verify", response_model=DomainVerificationResponse) +def verify_domain_ownership( + domain_id: int = Path(..., description="Domain ID", gt=0), + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin_user), +): + """ + Verify domain ownership via DNS TXT record (Admin only). + + **Verification Process:** + 1. Queries DNS for TXT record: `_letzshop-verify.{domain}` + 2. Checks if verification token matches + 3. If found, marks domain as verified + + **Requirements:** + - Vendor must have added TXT record to their DNS + - DNS propagation may take 5-15 minutes + - Record format: `_letzshop-verify.domain.com` TXT `{token}` + + **After verification:** + - Domain can be activated + - Domain will be available for routing + + **Raises:** + - 404: Domain not found + - 400: Already verified, or verification failed + - 502: DNS query failed + """ + domain, message = vendor_domain_service.verify_domain(db, domain_id) + + return DomainVerificationResponse( + message=message, + domain=domain.domain, + verified_at=domain.verified_at, + is_verified=domain.is_verified + ) + + +@router.get("/domains/{domain_id}/verification-instructions", response_model=DomainVerificationInstructions) +def get_domain_verification_instructions( + domain_id: int = Path(..., description="Domain ID", gt=0), + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin_user), +): + """ + Get DNS verification instructions for domain (Admin only). + + Returns step-by-step instructions for: + 1. Where to add DNS records + 2. What TXT record to create + 3. Links to common registrars + 4. Verification token + + **Use this endpoint to:** + - Show vendors how to verify their domain + - Get the exact TXT record values + - Access registrar links + + **Raises:** + - 404: Domain not found + """ + instructions = vendor_domain_service.get_verification_instructions(db, domain_id) + + return DomainVerificationInstructions( + domain=instructions["domain"], + verification_token=instructions["verification_token"], + instructions=instructions["instructions"], + txt_record=instructions["txt_record"], + common_registrars=instructions["common_registrars"] + ) diff --git a/app/core/config.py b/app/core/config.py index adf418ba..df3cea86 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -61,6 +61,21 @@ class Settings(BaseSettings): log_level: str = "INFO" log_file: Optional[str] = None + # Platform domain configuration + platform_domain: str = "platform.com" # Your main platform domain + + # Custom domain features + allow_custom_domains: bool = True # Enable/disable custom domains + require_domain_verification: bool = True # Require DNS verification + + # SSL/TLS configuration for custom domains + ssl_provider: str = "letsencrypt" # or "cloudflare", "manual" + auto_provision_ssl: bool = False # Set to True if using automated SSL + + # DNS verification + dns_verification_prefix: str = "_letzshop-verify" + dns_verification_ttl: int = 3600 + model_config = {"env_file": ".env"} # Updated syntax for Pydantic v2 diff --git a/app/exceptions/__init__.py b/app/exceptions/__init__.py index a24b79a1..d9598049 100644 --- a/app/exceptions/__init__.py +++ b/app/exceptions/__init__.py @@ -6,6 +6,7 @@ This module provides frontend-friendly exceptions with consistent error codes, messages, and HTTP status mappings. """ +# Base exceptions from .base import ( LetzShopException, ValidationException, @@ -19,6 +20,7 @@ from .base import ( ServiceUnavailableException, ) +# Authentication exceptions from .auth import ( InvalidCredentialsException, TokenExpiredException, @@ -29,6 +31,34 @@ from .auth import ( UserAlreadyExistsException ) +# Admin exceptions +from .admin import ( + UserNotFoundException, + UserStatusChangeException, + VendorVerificationException, + AdminOperationException, + CannotModifyAdminException, + CannotModifySelfException, + InvalidAdminActionException, + BulkOperationException, +) + +# Marketplace import jon exceptions +from .marketplace_import_job import ( + MarketplaceImportException, + ImportJobNotFoundException, + ImportJobNotOwnedException, + InvalidImportDataException, + ImportJobCannotBeCancelledException, + ImportJobCannotBeDeletedException, + MarketplaceConnectionException, + MarketplaceDataParsingException, + ImportRateLimitException, + InvalidMarketplaceException, + ImportJobAlreadyProcessingException, +) + +# Marketplace product exceptions from .marketplace_product import ( MarketplaceProductNotFoundException, MarketplaceProductAlreadyExistsException, @@ -38,6 +68,7 @@ from .marketplace_product import ( MarketplaceProductCSVImportException, ) +# Inventory exceptions from .inventory import ( InventoryNotFoundException, InsufficientInventoryException, @@ -48,6 +79,7 @@ from .inventory import ( LocationNotFoundException ) +# Vendor exceptions from .vendor import ( VendorNotFoundException, VendorAlreadyExistsException, @@ -59,6 +91,22 @@ from .vendor import ( VendorValidationException, ) +# Vendor domain exceptions +from .vendor_domain import ( + VendorDomainNotFoundException, + VendorDomainAlreadyExistsException, + InvalidDomainFormatException, + ReservedDomainException, + DomainNotVerifiedException, + DomainVerificationFailedException, + DomainAlreadyVerifiedException, + MultiplePrimaryDomainsException, + DNSVerificationException, + MaxDomainsReachedException, + UnauthorizedDomainAccessException, +) + +# Customer exceptions from .customer import ( CustomerNotFoundException, CustomerAlreadyExistsException, @@ -69,6 +117,7 @@ from .customer import ( CustomerAuthorizationException, ) +# Team exceptions from .team import ( TeamMemberNotFoundException, TeamMemberAlreadyExistsException, @@ -86,6 +135,7 @@ from .team import ( InvalidInvitationDataException, ) +# Product exceptions from .product import ( ProductNotFoundException, ProductAlreadyExistsException, @@ -97,6 +147,7 @@ from .product import ( CannotDeleteProductWithOrdersException, ) +# Order exceptions from .order import ( OrderNotFoundException, OrderAlreadyExistsException, @@ -105,31 +156,6 @@ from .order import ( OrderCannotBeCancelledException, ) -from .marketplace_import_job import ( - MarketplaceImportException, - ImportJobNotFoundException, - ImportJobNotOwnedException, - InvalidImportDataException, - ImportJobCannotBeCancelledException, - ImportJobCannotBeDeletedException, - MarketplaceConnectionException, - MarketplaceDataParsingException, - ImportRateLimitException, - InvalidMarketplaceException, - ImportJobAlreadyProcessingException, -) - -from .admin import ( - UserNotFoundException, - UserStatusChangeException, - VendorVerificationException, - AdminOperationException, - CannotModifyAdminException, - CannotModifySelfException, - InvalidAdminActionException, - BulkOperationException, -) - __all__ = [ # Base exceptions "LetzShopException", @@ -196,6 +222,19 @@ __all__ = [ "MaxVendorsReachedException", "VendorValidationException", + # Vendor Domain + "VendorDomainNotFoundException", + "VendorDomainAlreadyExistsException", + "InvalidDomainFormatException", + "ReservedDomainException", + "DomainNotVerifiedException", + "DomainVerificationFailedException", + "DomainAlreadyVerifiedException", + "MultiplePrimaryDomainsException", + "DNSVerificationException", + "MaxDomainsReachedException", + "UnauthorizedDomainAccessException", + # Product exceptions "ProductNotFoundException", "ProductAlreadyExistsException", diff --git a/app/exceptions/vendor_domain.py b/app/exceptions/vendor_domain.py new file mode 100644 index 00000000..cb04d883 --- /dev/null +++ b/app/exceptions/vendor_domain.py @@ -0,0 +1,168 @@ +# app/exceptions/vendor_domain.py +""" +Vendor domain management specific exceptions. +""" + +from typing import Any, Dict, Optional +from .base import ( + ResourceNotFoundException, + ConflictException, + ValidationException, + BusinessLogicException, + ExternalServiceException +) + + +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: Optional[int] = 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=f"Vendor can only have one primary domain", + error_code="MULTIPLE_PRIMARY_DOMAINS", + details={"vendor_id": vendor_id}, + ) + + +class DNSVerificationException(ExternalServiceException): + """Raised when DNS verification service fails.""" + + def __init__(self, domain: str, reason: str): + super().__init__( + service_name="DNS", + message=f"DNS verification failed for '{domain}': {reason}", + error_code="DNS_VERIFICATION_ERROR", + details={ + "domain": domain, + "reason": reason + }, + ) + + +class MaxDomainsReachedException(BusinessLogicException): + """Raised when vendor tries to add more domains than allowed.""" + + def __init__(self, vendor_id: int, max_domains: int): + super().__init__( + message=f"Maximum number of domains reached ({max_domains})", + error_code="MAX_DOMAINS_REACHED", + details={ + "vendor_id": vendor_id, + "max_domains": max_domains + }, + ) + + +class UnauthorizedDomainAccessException(BusinessLogicException): + """Raised when trying to access domain that doesn't belong to vendor.""" + + def __init__(self, domain_id: int, vendor_id: int): + super().__init__( + message=f"Unauthorized access to domain {domain_id}", + error_code="UNAUTHORIZED_DOMAIN_ACCESS", + details={ + "domain_id": domain_id, + "vendor_id": vendor_id + }, + ) diff --git a/app/services/vendor_domain_service.py b/app/services/vendor_domain_service.py new file mode 100644 index 00000000..7716d0df --- /dev/null +++ b/app/services/vendor_domain_service.py @@ -0,0 +1,458 @@ +# app/services/vendor_domain_service.py +""" +Vendor domain service for managing custom domain operations. + +This module provides classes and functions for: +- Adding and removing custom domains +- Domain verification via DNS +- Domain activation and deactivation +- Setting primary domains +- Domain validation and normalization +""" + +import logging +import secrets +from typing import List, Tuple, Optional +from datetime import datetime, timezone + +from sqlalchemy.orm import Session +from sqlalchemy import and_ + +from app.exceptions import ( + VendorNotFoundException, + VendorDomainNotFoundException, + VendorDomainAlreadyExistsException, + InvalidDomainFormatException, + ReservedDomainException, + DomainNotVerifiedException, + DomainVerificationFailedException, + DomainAlreadyVerifiedException, + MultiplePrimaryDomainsException, + DNSVerificationException, + MaxDomainsReachedException, + UnauthorizedDomainAccessException, + ValidationException, +) +from models.schema.vendor_domain import VendorDomainCreate, VendorDomainUpdate +from models.database.vendor import Vendor +from models.database.vendor_domain import VendorDomain + +logger = logging.getLogger(__name__) + + +class VendorDomainService: + """Service class for vendor domain operations.""" + + def __init__(self): + self.max_domains_per_vendor = 10 # Configure as needed + self.reserved_subdomains = ['www', 'admin', 'api', 'mail', 'smtp', 'ftp', 'cpanel', 'webmail'] + + def add_domain( + self, + db: Session, + vendor_id: int, + domain_data: VendorDomainCreate + ) -> VendorDomain: + """ + Add a custom domain to vendor. + + Args: + db: Database session + vendor_id: Vendor ID to add domain to + domain_data: Domain creation data + + Returns: + Created VendorDomain object + + Raises: + VendorNotFoundException: If vendor not found + VendorDomainAlreadyExistsException: If domain already registered + MaxDomainsReachedException: If vendor has reached max domains + InvalidDomainFormatException: If domain format is invalid + """ + try: + # Verify vendor exists + vendor = self._get_vendor_by_id_or_raise(db, vendor_id) + + # Check domain limit + self._check_domain_limit(db, vendor_id) + + # Normalize domain + normalized_domain = VendorDomain.normalize_domain(domain_data.domain) + + # Validate domain format + self._validate_domain_format(normalized_domain) + + # Check if domain already exists + if self._domain_exists(db, normalized_domain): + existing_domain = db.query(VendorDomain).filter( + VendorDomain.domain == normalized_domain + ).first() + raise VendorDomainAlreadyExistsException( + normalized_domain, + existing_domain.vendor_id if existing_domain else None + ) + + # If setting as primary, unset other primary domains + if domain_data.is_primary: + self._unset_primary_domains(db, vendor_id) + + # Create domain record + new_domain = VendorDomain( + vendor_id=vendor_id, + domain=normalized_domain, + is_primary=domain_data.is_primary, + verification_token=secrets.token_urlsafe(32), + is_verified=False, # Requires DNS verification + is_active=False, # Cannot be active until verified + ssl_status="pending" + ) + + db.add(new_domain) + db.commit() + db.refresh(new_domain) + + logger.info(f"Domain {normalized_domain} added to vendor {vendor_id}") + return new_domain + + except ( + VendorNotFoundException, + VendorDomainAlreadyExistsException, + MaxDomainsReachedException, + InvalidDomainFormatException, + ReservedDomainException + ): + db.rollback() + raise + except Exception as e: + db.rollback() + logger.error(f"Error adding domain: {str(e)}") + raise ValidationException("Failed to add domain") + + def get_vendor_domains( + self, + db: Session, + vendor_id: int + ) -> List[VendorDomain]: + """ + Get all domains for a vendor. + + Args: + db: Database session + vendor_id: Vendor ID + + Returns: + List of VendorDomain objects + + Raises: + VendorNotFoundException: If vendor not found + """ + try: + # Verify vendor exists + self._get_vendor_by_id_or_raise(db, vendor_id) + + domains = db.query(VendorDomain).filter( + VendorDomain.vendor_id == vendor_id + ).order_by( + VendorDomain.is_primary.desc(), + VendorDomain.created_at.desc() + ).all() + + return domains + + except VendorNotFoundException: + raise + except Exception as e: + logger.error(f"Error getting vendor domains: {str(e)}") + raise ValidationException("Failed to retrieve domains") + + def get_domain_by_id( + self, + db: Session, + domain_id: int + ) -> VendorDomain: + """ + Get domain by ID. + + Args: + db: Database session + domain_id: Domain ID + + Returns: + VendorDomain object + + Raises: + VendorDomainNotFoundException: If domain not found + """ + domain = db.query(VendorDomain).filter(VendorDomain.id == domain_id).first() + if not domain: + raise VendorDomainNotFoundException(str(domain_id)) + return domain + + def update_domain( + self, + db: Session, + domain_id: int, + domain_update: VendorDomainUpdate + ) -> VendorDomain: + """ + Update domain settings. + + Args: + db: Database session + domain_id: Domain ID + domain_update: Update data + + Returns: + Updated VendorDomain object + + Raises: + VendorDomainNotFoundException: If domain not found + DomainNotVerifiedException: If trying to activate unverified domain + """ + try: + domain = self.get_domain_by_id(db, domain_id) + + # If setting as primary, unset other primary domains + if domain_update.is_primary: + self._unset_primary_domains(db, domain.vendor_id, exclude_domain_id=domain_id) + domain.is_primary = True + + # If activating, check verification + if domain_update.is_active is True and not domain.is_verified: + raise DomainNotVerifiedException(domain_id, domain.domain) + + # Update fields + if domain_update.is_active is not None: + domain.is_active = domain_update.is_active + + db.commit() + db.refresh(domain) + + logger.info(f"Domain {domain.domain} updated") + return domain + + except (VendorDomainNotFoundException, DomainNotVerifiedException): + db.rollback() + raise + except Exception as e: + db.rollback() + logger.error(f"Error updating domain: {str(e)}") + raise ValidationException("Failed to update domain") + + def delete_domain( + self, + db: Session, + domain_id: int + ) -> str: + """ + Delete a custom domain. + + Args: + db: Database session + domain_id: Domain ID + + Returns: + Success message + + Raises: + VendorDomainNotFoundException: If domain not found + """ + try: + domain = self.get_domain_by_id(db, domain_id) + domain_name = domain.domain + vendor_id = domain.vendor_id + + db.delete(domain) + db.commit() + + logger.info(f"Domain {domain_name} deleted from vendor {vendor_id}") + return f"Domain {domain_name} deleted successfully" + + except VendorDomainNotFoundException: + db.rollback() + raise + except Exception as e: + db.rollback() + logger.error(f"Error deleting domain: {str(e)}") + raise ValidationException("Failed to delete domain") + + def verify_domain( + self, + db: Session, + domain_id: int + ) -> Tuple[VendorDomain, str]: + """ + Verify domain ownership via DNS TXT record. + + The vendor must add a TXT record: + Name: _letzshop-verify.{domain} + Value: {verification_token} + + Args: + db: Database session + domain_id: Domain ID + + Returns: + Tuple of (verified_domain, message) + + Raises: + VendorDomainNotFoundException: If domain not found + DomainAlreadyVerifiedException: If already verified + DomainVerificationFailedException: If verification fails + """ + try: + import dns.resolver + + domain = self.get_domain_by_id(db, domain_id) + + # Check if already verified + if domain.is_verified: + raise DomainAlreadyVerifiedException(domain_id, domain.domain) + + # Query DNS TXT records + try: + txt_records = dns.resolver.resolve( + f"_letzshop-verify.{domain.domain}", + 'TXT' + ) + + # Check if verification token is present + for txt in txt_records: + txt_value = txt.to_text().strip('"') + if txt_value == domain.verification_token: + # Verification successful + domain.is_verified = True + domain.verified_at = datetime.now(timezone.utc) + db.commit() + db.refresh(domain) + + logger.info(f"Domain {domain.domain} verified successfully") + return domain, f"Domain {domain.domain} verified successfully" + + # Token not found + raise DomainVerificationFailedException( + domain.domain, + "Verification token not found in DNS records" + ) + + except dns.resolver.NXDOMAIN: + raise DomainVerificationFailedException( + domain.domain, + f"DNS record _letzshop-verify.{domain.domain} not found" + ) + except dns.resolver.NoAnswer: + raise DomainVerificationFailedException( + domain.domain, + "No TXT records found for verification" + ) + except Exception as dns_error: + raise DNSVerificationException( + domain.domain, + str(dns_error) + ) + + except ( + VendorDomainNotFoundException, + DomainAlreadyVerifiedException, + DomainVerificationFailedException, + DNSVerificationException + ): + raise + except Exception as e: + logger.error(f"Error verifying domain: {str(e)}") + raise ValidationException("Failed to verify domain") + + def get_verification_instructions( + self, + db: Session, + domain_id: int + ) -> dict: + """ + Get DNS verification instructions for domain. + + Args: + db: Database session + domain_id: Domain ID + + Returns: + Dict with verification instructions + + Raises: + VendorDomainNotFoundException: If domain not found + """ + domain = self.get_domain_by_id(db, domain_id) + + return { + "domain": domain.domain, + "verification_token": domain.verification_token, + "instructions": { + "step1": "Go to your domain's DNS settings (at your domain registrar)", + "step2": "Add a new TXT record with the following values:", + "step3": "Wait for DNS propagation (5-15 minutes)", + "step4": "Click 'Verify Domain' button in admin panel" + }, + "txt_record": { + "type": "TXT", + "name": "_letzshop-verify", + "value": domain.verification_token, + "ttl": 3600 + }, + "common_registrars": { + "Cloudflare": "https://dash.cloudflare.com", + "GoDaddy": "https://dcc.godaddy.com/manage/dns", + "Namecheap": "https://www.namecheap.com/myaccount/domain-list/", + "Google Domains": "https://domains.google.com" + } + } + + # Private helper methods + def _get_vendor_by_id_or_raise(self, db: Session, vendor_id: int) -> Vendor: + """Get vendor by ID or raise exception.""" + vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first() + if not vendor: + raise VendorNotFoundException(str(vendor_id), identifier_type="id") + return vendor + + def _check_domain_limit(self, db: Session, vendor_id: int) -> None: + """Check if vendor has reached maximum domain limit.""" + domain_count = db.query(VendorDomain).filter( + VendorDomain.vendor_id == vendor_id + ).count() + + if domain_count >= self.max_domains_per_vendor: + raise MaxDomainsReachedException(vendor_id, self.max_domains_per_vendor) + + def _domain_exists(self, db: Session, domain: str) -> bool: + """Check if domain already exists in system.""" + return db.query(VendorDomain).filter( + VendorDomain.domain == domain + ).first() is not None + + def _validate_domain_format(self, domain: str) -> None: + """Validate domain format and check for reserved subdomains.""" + # Check for reserved subdomains + first_part = domain.split('.')[0] + if first_part in self.reserved_subdomains: + raise ReservedDomainException(domain, first_part) + + def _unset_primary_domains( + self, + db: Session, + vendor_id: int, + exclude_domain_id: Optional[int] = None + ) -> None: + """Unset all primary domains for vendor.""" + query = db.query(VendorDomain).filter( + VendorDomain.vendor_id == vendor_id, + VendorDomain.is_primary == True + ) + + if exclude_domain_id: + query = query.filter(VendorDomain.id != exclude_domain_id) + + query.update({"is_primary": False}) + + +# Create service instance +vendor_domain_service = VendorDomainService() diff --git a/app/templates/admin/components.html b/app/templates/admin/components.html index b8353a73..d257dcb1 100644 --- a/app/templates/admin/components.html +++ b/app/templates/admin/components.html @@ -6,6 +6,23 @@ {# ✅ CRITICAL: Link to Alpine.js component #} {% block alpine_data %}adminComponents(){% endblock %} +{% block extra_head %} +{# Chart.js for charts section #} + + + +{% endblock %} + {% block content %}
@@ -32,71 +49,71 @@
-
+
- -
-
-
-

Sections

- -
+ +
+
+
+

Sections

+ +
- -
-
- -
-

Pro Tip

-

- Click any code block to copy it to your clipboard! -

-
+ +
+
+ +
+

Pro Tip

+

+ Click any code block to copy it to your clipboard! +

+
- -
+ +
- -
-
-

- - Forms -

+ +
+
+

+ + Forms +

- -
-

Basic Input

-
- -
- -
+ + Copy Code + +
- -
-

Required Field with Error

-
- -
- -
+ + Copy Code + +
- -
-

Textarea

-
- -
- -
+ + Copy Code + +
- -
-

Select Dropdown

-
- -
- -
- - -
-

Checkbox

-
- -
- -
- - -
-

Disabled/Read-Only Input

-
- -
- -
+ + Copy Code +
- +
+ - -
-
-

- - Buttons -

+ +
+
+

+ + Buttons +

- -
-

Primary Button

-
- - -
- +
- - -
-

Secondary Button

-
- - -
- `)" class="text-sm text-purple-600 hover:text-purple-700 dark:text-purple-400 flex items-center"> - - Copy Code + + Copy Code + +
+ + +
+

Secondary Button

+
+
+ +
- -
-

Danger Button

-
- - -
- +
+ -
- - -
-

Button States

-
- - -
- -
+ + Copy Code +
-
+
+ - -
-
-

- - Cards -

- - -
-

Stats Card

-
-
-
- -
-
-

Total Users

-

1,234

-
-
-
- -
- - -
-

Info Card

-
-
-

Card Title

-
-
-

Field Label

-

Field Value

-
-
-

Another Label

-

Another Value

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

- - Badges -

+ +
+
+

+ + Cards +

+ +
+

Basic Card

-
- - - - Active - - - - - Pending - - - - - Inactive - - - - - Info - +
+

Card Title

+

+ This is a basic card component with a title and description. +

-
`)" class="text-sm text-purple-600 hover:text-purple-700 dark:text-purple-400 flex items-center"> + + Copy Code + +
+
+
+ + +
+
+

+ + Badges +

+ + +
+

Status Badges

+
+ + Active + + + Pending + + + Inactive + + + Info + +
+
-
+
+
- -
-
-

- - Alerts & Toasts -

+ +
+
+

+ + Tables +

-
- - - - + +
+

Basic Table

+
+ + + + + + + + + + + + + + + + + + + + +
NameEmailStatus
John Doejohn@example.com + + Active + +
Jane Smithjane@example.com + + Pending + +
+ +
+
+
-
- + + +
+ +
+
+

+ Confirm Action +

+ +
+ +

+ Are you sure you want to perform this action? This cannot be undone. +

+ +
+ + +
+
+
-
-
- - -
-
-

Tables

-

- See your existing table components in the vendor and user list pages. -

-
-
- -
-
-

Modals

-

- Modal dialogs for confirmations and forms. -

-
-
+ +
+
+ + + +
+
+

+ + Alerts +

+ + +
+

Toast Notifications

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

+ + Charts +

+ +

+ Charts are provided by + + Chart.js + . Note that default legends are disabled and you should provide descriptions for your charts in HTML. +

+ + +
+

Doughnut/Pie Chart

+
+
+ +
+
+ + Shoes +
+
+ + Shirts +
+
+ + Bags +
+
+
+
+ +
+ + +
+

Line Chart

+
+
+ +
+
+ + Organic +
+
+ + Paid +
+
+
+
+ +
+ + +
+

Bar Chart

+
+
+ +
+
+ + Shoes +
+
+ + Bags +
+
+
+
+ +
+
+
+ +
+
{% endblock %} {% block extra_scripts %} diff --git a/app/templates/admin/icons.html b/app/templates/admin/icons.html index 50af40ca..c8fc04b9 100644 --- a/app/templates/admin/icons.html +++ b/app/templates/admin/icons.html @@ -12,22 +12,26 @@

Icons Browser

- + Back to Dashboard - -
+ +
- -
-

Icon Library

-

- Browse all available icons. Click any icon to copy its name or usage code. +

+ +
+
+

Icon Library

+

+ Browse all available icons. Click any icon to copy its name or + usage code.

-
+
Heroicons @@ -46,270 +50,279 @@
-
-
- -
- -
+
+
+ +
+ +
- -
-

- Found icon(s) -

+ />
+

+ Found icon(s) +

+
- -
- -
-
+
- -
-
- - - Show All Categories - -
- +
+
+
- -
-
- - + +
+
+ + Showing ( icons) -
- +
+ + +
+ +
+ +

No icons found

+

Try adjusting your search or filter

+
-
- -
- -

No icons found

-

Try adjusting your search or filter

- -
- - -
- +
+
+ + +
+

+ Selected Icon: +

+ +
+ +
+

Preview

+
+
+ +
+
+ +
+
+ +
+
- -
-

Common Sizes

-
-
-
-
- -
- w-4 h-4 -
-
-
- -
- w-5 h-5 -
-
-
- -
- w-6 h-6 -
-
-
- -
- w-8 h-8 -
-
-
- -
- w-12 h-12 -
+ +
+

Usage Code

+ + +
+ +
+
+ +
+
+ + +
+ +
+
+
- -
-

- - How to Use Icons -

-
-
-

In Alpine.js Templates

-

Use the x-html directive:

-
<span x-html="$icon('home', 'w-5 h-5')"></span>
-
-
-

Customizing Size & Color

-

Use Tailwind classes:

-
<span x-html="$icon('check', 'w-6 h-6 text-green-500')"></span>
+ +
+

Common Sizes

+
+
+
+
+ +
+ w-4 h-4 +
+
+
+ +
+ w-5 h-5 +
+
+
+ +
+ w-6 h-6 +
+
+
+ +
+ w-8 h-8 +
+
+
+ +
+ w-12 h-12 +
+
+ + +
+

+ + How to Use Icons +

+
+
+

In Alpine.js Templates

+

Use the x-html directive:

+
<span x-html="$icon('home', 'w-5 h-5')"></span>
+
+
+

Customizing Size & Color

+

Use Tailwind classes:

+
<span x-html="$icon('check', 'w-6 h-6 text-green-500')"></span>
+
+
+
{% endblock %} {% block extra_scripts %} diff --git a/static/admin/marketplace.html b/app/templates/admin/marketplace.html similarity index 100% rename from static/admin/marketplace.html rename to app/templates/admin/marketplace.html diff --git a/static/admin/monitoring.html b/app/templates/admin/monitoring.html similarity index 100% rename from static/admin/monitoring.html rename to app/templates/admin/monitoring.html diff --git a/app/templates/partials/header.html b/app/templates/admin/partials/header.html similarity index 100% rename from app/templates/partials/header.html rename to app/templates/admin/partials/header.html diff --git a/app/templates/partials/sidebar.html b/app/templates/admin/partials/sidebar.html similarity index 100% rename from app/templates/partials/sidebar.html rename to app/templates/admin/partials/sidebar.html diff --git a/app/templates/admin/testing-hub.html b/app/templates/admin/testing-hub.html index 849516b8..5526863b 100644 --- a/app/templates/admin/testing-hub.html +++ b/app/templates/admin/testing-hub.html @@ -12,17 +12,22 @@

Testing Hub

+ + + Back to Dashboard +
-
+
-

Testing & QA Tools

-

+

Testing & QA Tools

+

Comprehensive testing tools for manual QA, feature verification, and bug reproduction. These pages help you test specific flows without writing code.

@@ -32,171 +37,192 @@
-
-
- -
-
-

Test Suites

-

-
-
+
+
+ +
+
+

Test Suites

+

+
+
-
-
- -
-
-

Test Cases

-

-
-
+
+
+ +
+
+

Test Cases

+

+
+
-
-
- -
-
-

Features Covered

-

-
-
+
+
+ +
+
+

Features Covered

+

+
+
-
-
- -
-
-

Quick Tests

-

-
-
+
+
+ +
+
+

Quick Tests

+

+
+
+
+ + +
+