52 KiB
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
- Planning Phase
- Database Layer
- Schema Layer (Pydantic)
- Exception Layer
- Service Layer
- API Layer (Endpoints)
- Frontend Layer
- Testing
- Documentation
- 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:
# 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"<VendorDomain(id={self.id}, domain='{self.domain}', vendor_id={self.vendor_id})>"
# 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
TimestampMixinforcreated_at/updated_at - Add proper indexes for query performance
- Include foreign key constraints with
ondeletebehavior - 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)
# 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
# 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:
# 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:
# 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
# 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:
# 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:
# 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
# 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
# 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
{% extends "admin/base.html" %}
{% block title %}Vendor Domains - {{ vendor_code }}{% endblock %}
{% block content %}
<div class="container mx-auto px-6 py-8">
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-3xl font-semibold text-gray-800 dark:text-white">
Custom Domains
</h1>
<p class="text-gray-600 dark:text-gray-400 mt-2">
Manage custom domains for {{ vendor_code }}
</p>
</div>
<button
onclick="openAddDomainModal()"
class="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700">
Add Domain
</button>
</div>
<!-- Domains List -->
<div id="domainsList" class="space-y-4">
<!-- Dynamically loaded via JavaScript -->
</div>
</div>
<!-- Add Domain Modal -->
<div id="addDomainModal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50">
<div class="flex items-center justify-center min-h-screen px-4">
<div class="bg-white dark:bg-gray-800 rounded-lg max-w-md w-full p-6">
<h2 class="text-2xl font-semibold mb-4">Add Custom Domain</h2>
<form id="addDomainForm">
<div class="mb-4">
<label class="block text-sm font-medium mb-2">Domain</label>
<input
type="text"
name="domain"
placeholder="myshop.com"
class="w-full px-3 py-2 border rounded-lg"
required>
</div>
<div class="mb-4">
<label class="flex items-center">
<input type="checkbox" name="is_primary" class="mr-2">
<span class="text-sm">Set as primary domain</span>
</label>
</div>
<div class="flex gap-2">
<button
type="submit"
class="flex-1 bg-purple-600 text-white py-2 rounded-lg hover:bg-purple-700">
Add Domain
</button>
<button
type="button"
onclick="closeAddDomainModal()"
class="flex-1 bg-gray-300 text-gray-700 py-2 rounded-lg hover:bg-gray-400">
Cancel
</button>
</div>
</form>
</div>
</div>
</div>
<script>
// Get vendor ID from URL
const vendorCode = "{{ vendor_code }}";
let vendorId = null;
// Load domains on page load
document.addEventListener('DOMContentLoaded', async () => {
// First, get vendor ID from vendor code
await loadVendorId();
// Then load domains
await loadDomains();
});
async function loadVendorId() {
try {
const response = await fetch(`/api/v1/admin/vendors/${vendorCode}`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
});
if (response.ok) {
const vendor = await response.json();
vendorId = vendor.id;
}
} catch (error) {
console.error('Error loading vendor:', error);
}
}
async function loadDomains() {
if (!vendorId) return;
try {
const response = await fetch(`/api/v1/admin/vendors/${vendorId}/domains`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
});
if (response.ok) {
const data = await response.json();
renderDomains(data.domains);
}
} catch (error) {
console.error('Error loading domains:', error);
}
}
function renderDomains(domains) {
const container = document.getElementById('domainsList');
if (domains.length === 0) {
container.innerHTML = '<p class="text-gray-500">No domains added yet.</p>';
return;
}
container.innerHTML = domains.map(domain => `
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<div class="flex items-center justify-between">
<div class="flex-1">
<div class="flex items-center gap-2">
<h3 class="text-lg font-semibold">${domain.domain}</h3>
${domain.is_primary ? '<span class="px-2 py-1 bg-purple-100 text-purple-800 text-xs rounded">Primary</span>' : ''}
${domain.is_verified ? '<span class="px-2 py-1 bg-green-100 text-green-800 text-xs rounded">Verified</span>' : '<span class="px-2 py-1 bg-yellow-100 text-yellow-800 text-xs rounded">Unverified</span>'}
${domain.is_active ? '<span class="px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded">Active</span>' : '<span class="px-2 py-1 bg-gray-100 text-gray-800 text-xs rounded">Inactive</span>'}
</div>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
Added ${new Date(domain.created_at).toLocaleDateString()}
</p>
</div>
<div class="flex gap-2">
${!domain.is_verified ? `
<button
onclick="verifyDomain(${domain.id})"
class="px-3 py-1 bg-green-600 text-white text-sm rounded hover:bg-green-700">
Verify
</button>
` : ''}
<button
onclick="deleteDomain(${domain.id})"
class="px-3 py-1 bg-red-600 text-white text-sm rounded hover:bg-red-700">
Delete
</button>
</div>
</div>
</div>
`).join('');
}
function openAddDomainModal() {
document.getElementById('addDomainModal').classList.remove('hidden');
}
function closeAddDomainModal() {
document.getElementById('addDomainModal').classList.add('hidden');
document.getElementById('addDomainForm').reset();
}
document.getElementById('addDomainForm').addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const data = {
domain: formData.get('domain'),
is_primary: formData.get('is_primary') === 'on'
};
try {
const response = await fetch(`/api/v1/admin/vendors/${vendorId}/domains`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify(data)
});
if (response.ok) {
closeAddDomainModal();
await loadDomains();
alert('Domain added successfully!');
} else {
const error = await response.json();
alert(`Error: ${error.message}`);
}
} catch (error) {
console.error('Error adding domain:', error);
alert('Failed to add domain');
}
});
async function verifyDomain(domainId) {
try {
const response = await fetch(`/api/v1/admin/vendors/domains/${domainId}/verify`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
});
if (response.ok) {
await loadDomains();
alert('Domain verified successfully!');
} else {
const error = await response.json();
alert(`Verification failed: ${error.message}`);
}
} catch (error) {
console.error('Error verifying domain:', error);
alert('Failed to verify domain');
}
}
async function deleteDomain(domainId) {
if (!confirm('Are you sure you want to delete this domain?')) return;
try {
const response = await fetch(`/api/v1/admin/vendors/domains/${domainId}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
});
if (response.ok) {
await loadDomains();
alert('Domain deleted successfully!');
} else {
const error = await response.json();
alert(`Error: ${error.message}`);
}
} catch (error) {
console.error('Error deleting domain:', error);
alert('Failed to delete domain');
}
}
</script>
{% endblock %}
Testing
Step 1: Unit Tests (Service Layer)
Location: tests/unit/services/test_vendor_domain_service.py
# 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
# 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
# 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
## [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
-
Database Migration
alembic upgrade head -
Verify Migration
# Check table exists SELECT * FROM vendor_domains LIMIT 1; -
Deploy Code
git push origin main # Deploy via your CI/CD pipeline -
Verify Deployment
# Test API endpoint curl -X GET https://your-domain.com/api/v1/admin/vendors/1/domains \ -H "Authorization: Bearer TOKEN" -
Monitor Logs
# 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:
- Check this guide
- Review
exception-handling.md - Reference
6_complete_naming_convention.md - Review existing implementations (vendors, users)
Last Updated: 2025-01-15
Version: 1.0
Maintainer: Development Team