# Admin Feature Integration Guide ## Overview This guide provides a step-by-step process for adding new admin features to the multi-tenant e-commerce platform. It ensures consistency with the established architecture patterns, naming conventions, and best practices. ## Table of Contents 1. [Planning Phase](#planning-phase) 2. [Database Layer](#database-layer) 3. [Schema Layer (Pydantic)](#schema-layer-pydantic) 4. [Exception Layer](#exception-layer) 5. [Service Layer](#service-layer) 6. [API Layer (Endpoints)](#api-layer-endpoints) 7. [Frontend Layer](#frontend-layer) 8. [Testing](#testing) 9. [Documentation](#documentation) 10. [Deployment Checklist](#deployment-checklist) --- ## Planning Phase ### 1. Define the Feature **Questions to Answer:** - What is the feature name? (Use singular for entity, plural for collections) - What business problem does it solve? - What are the CRUD operations needed? - What relationships exist with other entities? - What validation rules apply? - What permissions are required? **Example: Adding "Vendor Domains" Feature** ``` Feature Name: Vendor Domains Purpose: Allow vendors to use custom domains Operations: Create, Read, Update, Delete, Verify Relationships: Belongs to Vendor Validation: Domain format, uniqueness, DNS verification Permissions: Admin only ``` ### 2. Review Naming Conventions **Consult:** `6_complete_naming_convention.md` **Key Rules:** - **Collections/Endpoints**: PLURAL (`vendors.py`, `domains.py`) - **Entities/Models**: SINGULAR (`vendor.py`, `domain.py`) - **Services**: SINGULAR + "service" (`vendor_service.py`, `domain_service.py`) - **Exceptions**: SINGULAR domain name (`vendor.py`, `domain.py`) ### 3. Identify Dependencies **Check existing modules:** - Related database models - Existing services to reuse - Exception types needed - Authentication requirements --- ## Database Layer ### Step 1: Create Database Model **Location:** `models/database/{entity}.py` (SINGULAR) **File Naming:** Use singular entity name - ✅ `models/database/vendor_domain.py` - ❌ `models/database/vendor_domains.py` **Template:** ```python # models/database/vendor_domain.py """ VendorDomain database model. This model represents custom domains for vendors. """ from datetime import datetime, timezone from sqlalchemy import ( Column, Integer, String, Boolean, DateTime, ForeignKey, UniqueConstraint, Index ) from sqlalchemy.orm import relationship from app.core.database import Base from models.database.base import TimestampMixin class VendorDomain(Base, TimestampMixin): """ Custom domain mapping for vendors. Allows vendors to use their own domains (e.g., myshop.com) instead of subdomains (vendor1.platform.com). """ __tablename__ = "vendor_domains" # PLURAL table name # Primary Key id = Column(Integer, primary_key=True, index=True) # Foreign Keys vendor_id = Column( Integer, ForeignKey("vendors.id", ondelete="CASCADE"), nullable=False, index=True ) # Domain Configuration domain = Column(String(255), nullable=False, unique=True, index=True) is_primary = Column(Boolean, default=False, nullable=False) is_active = Column(Boolean, default=True, nullable=False) # Verification is_verified = Column(Boolean, default=False, nullable=False) verification_token = Column(String(100), unique=True, nullable=True) verified_at = Column(DateTime(timezone=True), nullable=True) # SSL Status ssl_status = Column(String(50), default="pending") ssl_verified_at = Column(DateTime(timezone=True), nullable=True) # Relationships vendor = relationship("Vendor", back_populates="domains") # Constraints and Indexes __table_args__ = ( UniqueConstraint('vendor_id', 'domain', name='uq_vendor_domain'), Index('idx_domain_active', 'domain', 'is_active'), Index('idx_vendor_primary', 'vendor_id', 'is_primary'), ) def __repr__(self): return f"" # Helper Methods @classmethod def normalize_domain(cls, domain: str) -> str: """Normalize domain for consistent storage.""" domain = domain.replace("https://", "").replace("http://", "") domain = domain.rstrip("/") domain = domain.lower() return domain @property def full_url(self): """Return full URL with https.""" return f"https://{self.domain}" ``` **Key Points:** - Use `TimestampMixin` for `created_at`/`updated_at` - Add proper indexes for query performance - Include foreign key constraints with `ondelete` behavior - Use singular class name, plural table name - Add docstrings for class and complex methods - Include `__repr__` for debugging ### Step 2: Update Related Models **Location:** Update the parent model (e.g., `models/database/vendor.py`) ```python # models/database/vendor.py class Vendor(Base, TimestampMixin): __tablename__ = "vendors" # ... existing fields ... # Add relationship domains = relationship( "VendorDomain", back_populates="vendor", cascade="all, delete-orphan", order_by="VendorDomain.is_primary.desc()" ) # Helper method @property def primary_domain(self): """Get the primary custom domain for this vendor.""" for domain in self.domains: if domain.is_primary and domain.is_active: return domain.domain return None ``` ### Step 3: Create Database Migration ```bash # Create migration alembic revision --autogenerate -m "Add vendor_domains table" # Review the generated migration file # Edit if needed for data migrations or complex changes # Apply migration alembic upgrade head ``` **Migration Checklist:** - [ ] Table created with correct name - [ ] All columns present with correct types - [ ] Indexes created - [ ] Foreign keys configured - [ ] Unique constraints added - [ ] Default values set --- ## Schema Layer (Pydantic) ### Step 1: Create Pydantic Schemas **Location:** `models/schema/{entity}.py` (SINGULAR) **File Naming:** Use singular entity name - ✅ `models/schema/vendor_domain.py` - ❌ `models/schema/vendor_domains.py` **Template:** ```python # models/schema/vendor_domain.py """ Pydantic schemas for VendorDomain operations. Schemas include: - VendorDomainCreate: For adding custom domains - VendorDomainUpdate: For updating domain settings - VendorDomainResponse: Standard domain response - VendorDomainListResponse: Paginated domain list """ import re from datetime import datetime from typing import List, Optional, Dict from pydantic import BaseModel, ConfigDict, Field, field_validator # ============================================================================ # Request Schemas (Input) # ============================================================================ class VendorDomainCreate(BaseModel): """Schema for adding a custom domain to vendor.""" domain: str = Field( ..., description="Custom domain (e.g., myshop.com)", min_length=3, max_length=255, examples=["myshop.com", "shop.mybrand.com"] ) is_primary: bool = Field( default=False, description="Set as primary domain for the vendor" ) @field_validator('domain') @classmethod def validate_domain(cls, v: str) -> str: """Validate and normalize domain.""" # Remove protocol if present domain = v.replace("https://", "").replace("http://", "") # Remove trailing slash domain = domain.rstrip("/") # Convert to lowercase domain = domain.lower().strip() # Basic validation if not domain or '/' in domain: raise ValueError("Invalid domain format") if '.' not in domain: raise ValueError("Domain must have at least one dot") # Check for reserved subdomains reserved = ['www', 'admin', 'api', 'mail', 'smtp', 'ftp'] first_part = domain.split('.')[0] if first_part in reserved: raise ValueError(f"Domain cannot start with reserved subdomain: {first_part}") # Validate domain format (basic regex) pattern = r'^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$' if not re.match(pattern, domain): raise ValueError("Invalid domain format") return domain class VendorDomainUpdate(BaseModel): """Schema for updating vendor domain settings.""" is_primary: Optional[bool] = Field(None, description="Set as primary domain") is_active: Optional[bool] = Field(None, description="Activate or deactivate domain") model_config = ConfigDict(from_attributes=True) # ============================================================================ # Response Schemas (Output) # ============================================================================ class VendorDomainResponse(BaseModel): """Standard schema for vendor domain response.""" model_config = ConfigDict(from_attributes=True) id: int vendor_id: int domain: str is_primary: bool is_active: bool is_verified: bool ssl_status: str verification_token: Optional[str] = None verified_at: Optional[datetime] = None ssl_verified_at: Optional[datetime] = None created_at: datetime updated_at: datetime class VendorDomainListResponse(BaseModel): """Schema for paginated vendor domain list.""" domains: List[VendorDomainResponse] total: int # ============================================================================ # Specialized Schemas # ============================================================================ class DomainVerificationInstructions(BaseModel): """DNS verification instructions for domain ownership.""" domain: str verification_token: str instructions: Dict[str, str] txt_record: Dict[str, str] common_registrars: Dict[str, str] class DomainVerificationResponse(BaseModel): """Response after domain verification.""" message: str domain: str verified_at: datetime is_verified: bool ``` **Key Points:** - Separate request schemas (Create, Update) from response schemas - Use `Field()` for descriptions and examples - Add validators with `@field_validator` - Use `ConfigDict(from_attributes=True)` for ORM compatibility - Document all fields with descriptions - Use appropriate types (Optional, List, Dict) --- ## Exception Layer ### Step 1: Create Custom Exceptions **Location:** `app/exceptions/{domain}.py` (SINGULAR) **File Naming:** Use singular domain name - ✅ `app/exceptions/vendor_domain.py` - ❌ `app/exceptions/vendor_domains.py` **Template:** ```python # app/exceptions/vendor_domain.py """ Vendor domain management specific exceptions. All exceptions follow the pattern: - Inherit from appropriate base exception - Include relevant context in details dict - Use proper HTTP status codes - Provide clear, actionable error messages """ from typing import Any, Dict, Optional from .base import ( ResourceNotFoundException, ConflictException, ValidationException, BusinessLogicException, ExternalServiceException ) # ============================================================================ # Resource Not Found (404) # ============================================================================ 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", ) # ============================================================================ # Conflicts (409) # ============================================================================ 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, ) # ============================================================================ # Validation Errors (422) # ============================================================================ 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" # ============================================================================ # Business Logic Errors (400) # ============================================================================ 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 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}, ) # ============================================================================ # External Service Errors (502) # ============================================================================ 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}, ) ``` **Exception Categories:** | HTTP Status | Base Exception | Use Case | |-------------|----------------|----------| | 400 | `BusinessLogicException` | Business rule violations | | 401 | `AuthenticationException` | Authentication failures | | 403 | `AuthorizationException` | Permission denied | | 404 | `ResourceNotFoundException` | Resource not found | | 409 | `ConflictException` | Resource conflicts | | 422 | `ValidationException` | Input validation errors | | 429 | `RateLimitException` | Rate limiting | | 500 | `WizamartException` | Generic errors | | 502 | `ExternalServiceException` | Third-party failures | ### Step 2: Update Exception Exports **Location:** `app/exceptions/__init__.py` ```python # app/exceptions/__init__.py # ... existing imports ... # Vendor domain exceptions from .vendor_domain import ( VendorDomainNotFoundException, VendorDomainAlreadyExistsException, InvalidDomainFormatException, ReservedDomainException, DomainNotVerifiedException, DomainVerificationFailedException, MaxDomainsReachedException, DNSVerificationException, ) __all__ = [ # ... existing exports ... # Vendor Domain "VendorDomainNotFoundException", "VendorDomainAlreadyExistsException", "InvalidDomainFormatException", "ReservedDomainException", "DomainNotVerifiedException", "DomainVerificationFailedException", "MaxDomainsReachedException", "DNSVerificationException", ] ``` --- ## Service Layer ### Step 1: Create Service Class **Location:** `app/services/{entity}_service.py` (SINGULAR + service) **File Naming:** Use singular entity name + "_service" - ✅ `app/services/vendor_domain_service.py` - ❌ `app/services/vendor_domains_service.py` **Template:** ```python # 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 app.exceptions import ( VendorNotFoundException, VendorDomainNotFoundException, VendorDomainAlreadyExistsException, InvalidDomainFormatException, DomainNotVerifiedException, DomainVerificationFailedException, MaxDomainsReachedException, 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): """Initialize service with configuration.""" self.max_domains_per_vendor = 10 self.reserved_subdomains = ['www', 'admin', 'api', 'mail', 'smtp', 'ftp'] 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: # 1. Verify vendor exists vendor = self._get_vendor_by_id_or_raise(db, vendor_id) # 2. Check domain limit self._check_domain_limit(db, vendor_id) # 3. Normalize domain normalized_domain = VendorDomain.normalize_domain(domain_data.domain) # 4. Validate domain format self._validate_domain_format(normalized_domain) # 5. Check if domain already exists if self._domain_exists(db, normalized_domain): existing = db.query(VendorDomain).filter( VendorDomain.domain == normalized_domain ).first() raise VendorDomainAlreadyExistsException( normalized_domain, existing.vendor_id if existing else None ) # 6. If setting as primary, unset other primary domains if domain_data.is_primary: self._unset_primary_domains(db, vendor_id) # 7. 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, is_active=False, 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 ): 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 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_or_raise(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_or_raise(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") # ======================================================================== # Private Helper Methods (use _ prefix) # ======================================================================== 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 _get_domain_by_id_or_raise(self, db: Session, domain_id: int) -> VendorDomain: """Get domain by ID or raise exception.""" domain = db.query(VendorDomain).filter(VendorDomain.id == domain_id).first() if not domain: raise VendorDomainNotFoundException(str(domain_id)) return domain 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.""" first_part = domain.split('.')[0] if first_part in self.reserved_subdomains: from app.exceptions import ReservedDomainException 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() ``` **Service Layer Best Practices:** - ✅ All business logic in service layer - ✅ Raise custom exceptions (not HTTPException) - ✅ Transaction management (commit/rollback) - ✅ Comprehensive logging - ✅ Helper methods with `_` prefix - ✅ Detailed docstrings - ✅ Type hints for all methods - ✅ Create singleton instance at bottom --- ## API Layer (Endpoints) ### Step 1: Create API Endpoints **Location:** `app/api/v1/admin/{entities}.py` (PLURAL) **File Naming:** Use plural entity name - ✅ `app/api/v1/admin/vendor_domains.py` - ❌ `app/api/v1/admin/vendor_domain.py` **Template:** ```python # 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, 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, ) from models.database.user import User from models.database.vendor import Vendor router = APIRouter(prefix="/vendors") logger = logging.getLogger(__name__) # ============================================================================ # Helper Functions # ============================================================================ 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 # ============================================================================ # Endpoints # ============================================================================ @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 **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.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 **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, 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}") 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 """ message = vendor_domain_service.delete_domain(db, domain_id) return {"message": message} ``` **Endpoint Best Practices:** - ✅ Use plural file names for collections - ✅ Prefix for related resources (`/vendors/{id}/domains`) - ✅ Path parameters with validation (`gt=0`) - ✅ Comprehensive docstrings with examples - ✅ Delegate to service layer immediately - ✅ No business logic in endpoints - ✅ Proper dependency injection - ✅ Response models for consistency ### Step 2: Register Router **Location:** `app/api/v1/admin/__init__.py` ```python # app/api/v1/admin/__init__.py from fastapi import APIRouter from . import ( auth, vendors, vendor_domains, # NEW users, dashboard, # ... other routers ) router = APIRouter() # Include routers router.include_router(auth.router, tags=["admin-auth"]) router.include_router(vendors.router, tags=["admin-vendors"]) router.include_router(vendor_domains.router, tags=["admin-vendor-domains"]) # NEW router.include_router(users.router, tags=["admin-users"]) # ... other routers ``` --- ## Frontend Layer ### Step 1: Create HTML Page Route **Location:** `app/api/v1/admin/pages.py` ```python # app/api/v1/admin/pages.py @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, } ) ``` ### Step 2: Create HTML Template **Location:** `app/templates/admin/vendor-domains.html` ```html {% extends "admin/base.html" %} {% block title %}Vendor Domains - {{ vendor_code }}{% endblock %} {% block content %}

Custom Domains

Manage custom domains for {{ vendor_code }}

{% endblock %} ``` --- ## Testing ### Step 1: Unit Tests (Service Layer) **Location:** `tests/unit/services/test_vendor_domain_service.py` ```python # tests/unit/services/test_vendor_domain_service.py import pytest from app.services.vendor_domain_service import VendorDomainService from app.exceptions import ( VendorDomainAlreadyExistsException, MaxDomainsReachedException, InvalidDomainFormatException ) from models.schema.vendor_domain import VendorDomainCreate @pytest.fixture def service(): return VendorDomainService() def test_add_domain_success(db_session, test_vendor, service): """Test successful domain addition.""" domain_data = VendorDomainCreate( domain="test.com", is_primary=True ) domain = service.add_domain(db_session, test_vendor.id, domain_data) assert domain.domain == "test.com" assert domain.is_primary is True assert domain.is_verified is False assert domain.verification_token is not None def test_add_domain_already_exists(db_session, test_vendor, existing_domain, service): """Test adding duplicate domain raises exception.""" domain_data = VendorDomainCreate(domain=existing_domain.domain) with pytest.raises(VendorDomainAlreadyExistsException): service.add_domain(db_session, test_vendor.id, domain_data) def test_add_domain_max_limit_reached(db_session, test_vendor, service): """Test max domains limit enforcement.""" # Add max_domains domains for i in range(service.max_domains_per_vendor): domain_data = VendorDomainCreate(domain=f"test{i}.com") service.add_domain(db_session, test_vendor.id, domain_data) # Try adding one more domain_data = VendorDomainCreate(domain="overflow.com") with pytest.raises(MaxDomainsReachedException): service.add_domain(db_session, test_vendor.id, domain_data) def test_domain_normalization(service): """Test domain normalization.""" assert service._normalize_domain("HTTP://TEST.COM/") == "test.com" assert service._normalize_domain("WWW.Test.COM") == "www.test.com" ``` ### Step 2: Integration Tests (API Endpoints) **Location:** `tests/integration/api/v1/admin/test_vendor_domains.py` ```python # tests/integration/api/v1/admin/test_vendor_domains.py import pytest def test_add_domain_endpoint(client, admin_headers, test_vendor): """Test domain addition endpoint.""" response = client.post( f"/api/v1/admin/vendors/{test_vendor.id}/domains", json={"domain": "newshop.com", "is_primary": False}, headers=admin_headers ) assert response.status_code == 201 data = response.json() assert data["domain"] == "newshop.com" assert data["is_verified"] is False def test_add_domain_invalid_format(client, admin_headers, test_vendor): """Test adding domain with invalid format.""" response = client.post( f"/api/v1/admin/vendors/{test_vendor.id}/domains", json={"domain": "admin.example.com"}, # Reserved subdomain headers=admin_headers ) assert response.status_code == 422 assert "reserved" in response.json()["message"].lower() def test_list_domains_endpoint(client, admin_headers, test_vendor, test_domain): """Test listing vendor domains.""" response = client.get( f"/api/v1/admin/vendors/{test_vendor.id}/domains", headers=admin_headers ) assert response.status_code == 200 data = response.json() assert data["total"] >= 1 assert len(data["domains"]) >= 1 def test_delete_domain_endpoint(client, admin_headers, test_domain): """Test domain deletion.""" response = client.delete( f"/api/v1/admin/vendors/domains/{test_domain.id}", headers=admin_headers ) assert response.status_code == 200 assert "deleted successfully" in response.json()["message"] def test_verify_domain_not_found(client, admin_headers): """Test verification with non-existent domain.""" response = client.post( "/api/v1/admin/vendors/domains/99999/verify", headers=admin_headers ) assert response.status_code == 404 assert response.json()["error_code"] == "VENDOR_DOMAIN_NOT_FOUND" ``` --- ## Documentation ### Step 1: Update API Documentation **Location:** `docs/api/admin_vendor_domains.md` ```markdown # Vendor Domains API ## Overview Custom domain management for vendors. ## Endpoints ### Add Domain \`\`\` POST /api/v1/admin/vendors/{vendor_id}/domains \`\`\` **Request:** \`\`\`json { "domain": "myshop.com", "is_primary": true } \`\`\` **Response:** `201 Created` \`\`\`json { "id": 1, "domain": "myshop.com", "is_verified": false, "verification_token": "abc123..." } \`\`\` ### List Domains \`\`\` GET /api/v1/admin/vendors/{vendor_id}/domains \`\`\` ### Verify Domain \`\`\` POST /api/v1/admin/vendors/domains/{domain_id}/verify \`\`\` ### Delete Domain \`\`\` DELETE /api/v1/admin/vendors/domains/{domain_id} \`\`\` ``` ### Step 2: Update Changelog **Location:** `CHANGELOG.md` ```markdown ## [Unreleased] ### Added - Custom domain support for vendors - DNS verification system - Domain management UI in admin panel ### API Changes - Added `/api/v1/admin/vendors/{id}/domains` endpoints - Added domain verification endpoints ``` --- ## Deployment Checklist ### Pre-Deployment - [ ] All unit tests passing - [ ] All integration tests passing - [ ] Database migration created and tested - [ ] Exception handling tested - [ ] API documentation updated - [ ] Frontend UI tested - [ ] Code review completed - [ ] Changelog updated ### Deployment Steps 1. **Database Migration** ```bash alembic upgrade head ``` 2. **Verify Migration** ```bash # Check table exists SELECT * FROM vendor_domains LIMIT 1; ``` 3. **Deploy Code** ```bash git push origin main # Deploy via your CI/CD pipeline ``` 4. **Verify Deployment** ```bash # Test API endpoint curl -X GET https://your-domain.com/api/v1/admin/vendors/1/domains \ -H "Authorization: Bearer TOKEN" ``` 5. **Monitor Logs** ```bash # Check for errors tail -f /var/log/app.log | grep ERROR ``` ### Post-Deployment - [ ] API endpoints accessible - [ ] Frontend pages loading - [ ] Database queries performing well - [ ] No error logs - [ ] User acceptance testing completed - [ ] Documentation deployed --- ## Quick Reference ### File Locations Checklist ``` ✅ models/database/vendor_domain.py # Database model ✅ models/schema/vendor_domain.py # Pydantic schemas ✅ app/exceptions/vendor_domain.py # Custom exceptions ✅ app/services/vendor_domain_service.py # Business logic ✅ app/api/v1/admin/vendor_domains.py # API endpoints ✅ app/api/v1/admin/pages.py # HTML routes ✅ app/templates/admin/vendor-domains.html # HTML template ✅ tests/unit/services/test_vendor_domain_service.py ✅ tests/integration/api/v1/admin/test_vendor_domains.py ``` ### Naming Convention Summary | Type | Naming | Example | |------|--------|---------| | API File | PLURAL | `vendor_domains.py` | | Model File | SINGULAR | `vendor_domain.py` | | Schema File | SINGULAR | `vendor_domain.py` | | Service File | SINGULAR + service | `vendor_domain_service.py` | | Exception File | SINGULAR | `vendor_domain.py` | | Class Name | SINGULAR | `VendorDomain` | | Table Name | PLURAL | `vendor_domains` | ### Common Pitfalls to Avoid ❌ **Don't:** Put business logic in endpoints ✅ **Do:** Put business logic in service layer ❌ **Don't:** Raise HTTPException from services ✅ **Do:** Raise custom exceptions ❌ **Don't:** Access database directly in endpoints ✅ **Do:** Call service methods ❌ **Don't:** Use plural for model files ✅ **Do:** Use singular for model files ❌ **Don't:** Skip validation ✅ **Do:** Use Pydantic validators ❌ **Don't:** Forget transaction management ✅ **Do:** Use try/except with commit/rollback --- ## Support For questions or issues: 1. Check this guide 2. Review `exception-handling.md` 3. Reference `6_complete_naming_convention.md` 4. Review existing implementations (vendors, users) --- **Last Updated:** 2025-01-15 **Version:** 1.0 **Maintainer:** Development Team