Replace all ~1,086 occurrences of Wizamart/wizamart/WIZAMART/WizaMart with Orion/orion/ORION across 184 files. This includes database identifiers, email addresses, domain references, R2 bucket names, DNS prefixes, encryption salt, Celery app name, config defaults, Docker configs, CI configs, documentation, seed data, and templates. Renames homepage-wizamart.html template to homepage-orion.html. Fixes duplicate file_pattern key in api.yaml architecture rule. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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 "Store Domains" Feature
Feature Name: Store Domains
Purpose: Allow stores to use custom domains
Operations: Create, Read, Update, Delete, Verify
Relationships: Belongs to Store
Validation: Domain format, uniqueness, DNS verification
Permissions: Admin only
2. Review Naming Conventions
Consult: 6_complete_naming_convention.md
Key Rules:
- Collections/Endpoints: PLURAL (
stores.py,domains.py) - Entities/Models: SINGULAR (
store.py,domain.py) - Services: SINGULAR + "service" (
store_service.py,domain_service.py) - Exceptions: SINGULAR domain name (
store.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/store_domain.py - ❌
models/database/store_domains.py
Template:
# models/database/store_domain.py
"""
StoreDomain database model.
This model represents custom domains for stores.
"""
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 StoreDomain(Base, TimestampMixin):
"""
Custom domain mapping for stores.
Allows stores to use their own domains (e.g., myshop.com)
instead of subdomains (store1.platform.com).
"""
__tablename__ = "store_domains" # PLURAL table name
# Primary Key
id = Column(Integer, primary_key=True, index=True)
# Foreign Keys
store_id = Column(
Integer,
ForeignKey("stores.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
store = relationship("Store", back_populates="domains")
# Constraints and Indexes
__table_args__ = (
UniqueConstraint('store_id', 'domain', name='uq_store_domain'),
Index('idx_domain_active', 'domain', 'is_active'),
Index('idx_store_primary', 'store_id', 'is_primary'),
)
def __repr__(self):
return f"<StoreDomain(id={self.id}, domain='{self.domain}', store_id={self.store_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/store.py)
# models/database/store.py
class Store(Base, TimestampMixin):
__tablename__ = "stores"
# ... existing fields ...
# Add relationship
domains = relationship(
"StoreDomain",
back_populates="store",
cascade="all, delete-orphan",
order_by="StoreDomain.is_primary.desc()"
)
# Helper method
@property
def primary_domain(self):
"""Get the primary custom domain for this store."""
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 store_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/store_domain.py - ❌
models/schema/store_domains.py
Template:
# models/schema/store_domain.py
"""
Pydantic schemas for StoreDomain operations.
Schemas include:
- StoreDomainCreate: For adding custom domains
- StoreDomainUpdate: For updating domain settings
- StoreDomainResponse: Standard domain response
- StoreDomainListResponse: 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 StoreDomainCreate(BaseModel):
"""Schema for adding a custom domain to store."""
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 store"
)
@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 StoreDomainUpdate(BaseModel):
"""Schema for updating store 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 StoreDomainResponse(BaseModel):
"""Standard schema for store domain response."""
model_config = ConfigDict(from_attributes=True)
id: int
store_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 StoreDomainListResponse(BaseModel):
"""Schema for paginated store domain list."""
domains: List[StoreDomainResponse]
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/store_domain.py - ❌
app/exceptions/store_domains.py
Template:
# app/exceptions/store_domain.py
"""
Store 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 StoreDomainNotFoundException(ResourceNotFoundException):
"""Raised when a store 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="StoreDomain",
identifier=domain_identifier,
message=message,
error_code="STORE_DOMAIN_NOT_FOUND",
)
# ============================================================================
# Conflicts (409)
# ============================================================================
class StoreDomainAlreadyExistsException(ConflictException):
"""Raised when trying to add a domain that already exists."""
def __init__(self, domain: str, existing_store_id: Optional[int] = None):
details = {"domain": domain}
if existing_store_id:
details["existing_store_id"] = existing_store_id
super().__init__(
message=f"Domain '{domain}' is already registered",
error_code="STORE_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 store tries to add more domains than allowed."""
def __init__(self, store_id: int, max_domains: int):
super().__init__(
message=f"Maximum number of domains reached ({max_domains})",
error_code="MAX_DOMAINS_REACHED",
details={"store_id": store_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 | OrionException |
Generic errors |
| 502 | ExternalServiceException |
Third-party failures |
Step 2: Update Exception Exports
Location: app/exceptions/__init__.py
# app/exceptions/__init__.py
# ... existing imports ...
# Store domain exceptions
from .store_domain import (
StoreDomainNotFoundException,
StoreDomainAlreadyExistsException,
InvalidDomainFormatException,
ReservedDomainException,
DomainNotVerifiedException,
DomainVerificationFailedException,
MaxDomainsReachedException,
DNSVerificationException,
)
__all__ = [
# ... existing exports ...
# Store Domain
"StoreDomainNotFoundException",
"StoreDomainAlreadyExistsException",
"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/store_domain_service.py - ❌
app/services/store_domains_service.py
Template:
# app/services/store_domain_service.py
"""
Store 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 (
StoreNotFoundException,
StoreDomainNotFoundException,
StoreDomainAlreadyExistsException,
InvalidDomainFormatException,
DomainNotVerifiedException,
DomainVerificationFailedException,
MaxDomainsReachedException,
ValidationException,
)
from models.schema.store_domain import StoreDomainCreate, StoreDomainUpdate
from models.database.store import Store
from models.database.store_domain import StoreDomain
logger = logging.getLogger(__name__)
class StoreDomainService:
"""Service class for store domain operations."""
def __init__(self):
"""Initialize service with configuration."""
self.max_domains_per_store = 10
self.reserved_subdomains = ['www', 'admin', 'api', 'mail', 'smtp', 'ftp']
def add_domain(
self,
db: Session,
store_id: int,
domain_data: StoreDomainCreate
) -> StoreDomain:
"""
Add a custom domain to store.
Args:
db: Database session
store_id: Store ID to add domain to
domain_data: Domain creation data
Returns:
Created StoreDomain object
Raises:
StoreNotFoundException: If store not found
StoreDomainAlreadyExistsException: If domain already registered
MaxDomainsReachedException: If store has reached max domains
InvalidDomainFormatException: If domain format is invalid
"""
try:
# 1. Verify store exists
store = self._get_store_by_id_or_raise(db, store_id)
# 2. Check domain limit
self._check_domain_limit(db, store_id)
# 3. Normalize domain
normalized_domain = StoreDomain.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(StoreDomain).filter(
StoreDomain.domain == normalized_domain
).first()
raise StoreDomainAlreadyExistsException(
normalized_domain,
existing.store_id if existing else None
)
# 6. If setting as primary, unset other primary domains
if domain_data.is_primary:
self._unset_primary_domains(db, store_id)
# 7. Create domain record
new_domain = StoreDomain(
store_id=store_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 store {store_id}")
return new_domain
except (
StoreNotFoundException,
StoreDomainAlreadyExistsException,
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_store_domains(
self,
db: Session,
store_id: int
) -> List[StoreDomain]:
"""
Get all domains for a store.
Args:
db: Database session
store_id: Store ID
Returns:
List of StoreDomain objects
Raises:
StoreNotFoundException: If store not found
"""
try:
# Verify store exists
self._get_store_by_id_or_raise(db, store_id)
domains = db.query(StoreDomain).filter(
StoreDomain.store_id == store_id
).order_by(
StoreDomain.is_primary.desc(),
StoreDomain.created_at.desc()
).all()
return domains
except StoreNotFoundException:
raise
except Exception as e:
logger.error(f"Error getting store domains: {str(e)}")
raise ValidationException("Failed to retrieve domains")
def update_domain(
self,
db: Session,
domain_id: int,
domain_update: StoreDomainUpdate
) -> StoreDomain:
"""
Update domain settings.
Args:
db: Database session
domain_id: Domain ID
domain_update: Update data
Returns:
Updated StoreDomain object
Raises:
StoreDomainNotFoundException: 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.store_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 (StoreDomainNotFoundException, 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:
StoreDomainNotFoundException: If domain not found
"""
try:
domain = self._get_domain_by_id_or_raise(db, domain_id)
domain_name = domain.domain
store_id = domain.store_id
db.delete(domain)
db.commit()
logger.info(f"Domain {domain_name} deleted from store {store_id}")
return f"Domain {domain_name} deleted successfully"
except StoreDomainNotFoundException:
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_store_by_id_or_raise(self, db: Session, store_id: int) -> Store:
"""Get store by ID or raise exception."""
store = db.query(Store).filter(Store.id == store_id).first()
if not store:
raise StoreNotFoundException(str(store_id), identifier_type="id")
return store
def _get_domain_by_id_or_raise(self, db: Session, domain_id: int) -> StoreDomain:
"""Get domain by ID or raise exception."""
domain = db.query(StoreDomain).filter(StoreDomain.id == domain_id).first()
if not domain:
raise StoreDomainNotFoundException(str(domain_id))
return domain
def _check_domain_limit(self, db: Session, store_id: int) -> None:
"""Check if store has reached maximum domain limit."""
domain_count = db.query(StoreDomain).filter(
StoreDomain.store_id == store_id
).count()
if domain_count >= self.max_domains_per_store:
raise MaxDomainsReachedException(store_id, self.max_domains_per_store)
def _domain_exists(self, db: Session, domain: str) -> bool:
"""Check if domain already exists in system."""
return db.query(StoreDomain).filter(
StoreDomain.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,
store_id: int,
exclude_domain_id: Optional[int] = None
) -> None:
"""Unset all primary domains for store."""
query = db.query(StoreDomain).filter(
StoreDomain.store_id == store_id,
StoreDomain.is_primary == True
)
if exclude_domain_id:
query = query.filter(StoreDomain.id != exclude_domain_id)
query.update({"is_primary": False})
# Create service instance
store_domain_service = StoreDomainService()
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/store_domains.py - ❌
app/api/v1/admin/store_domain.py
Template:
# app/api/v1/admin/store_domains.py
"""
Admin endpoints for managing store 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_api, get_db
from app.services.store_domain_service import store_domain_service
from app.exceptions import StoreNotFoundException
from models.schema.store_domain import (
StoreDomainCreate,
StoreDomainUpdate,
StoreDomainResponse,
StoreDomainListResponse,
)
from models.database.user import User
from models.database.store import Store
router = APIRouter(prefix="/stores")
logger = logging.getLogger(__name__)
# ============================================================================
# Helper Functions
# ============================================================================
def _get_store_by_id(db: Session, store_id: int) -> Store:
"""
Helper to get store by ID.
Args:
db: Database session
store_id: Store ID
Returns:
Store object
Raises:
StoreNotFoundException: If store not found
"""
store = db.query(Store).filter(Store.id == store_id).first()
if not store:
raise StoreNotFoundException(str(store_id), identifier_type="id")
return store
# ============================================================================
# Endpoints
# ============================================================================
@router.post("/{store_id}/domains", response_model=StoreDomainResponse)
def add_store_domain(
store_id: int = Path(..., description="Store ID", gt=0),
domain_data: StoreDomainCreate = Body(...),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""
Add a custom domain to store (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: Store not found
- 409: Domain already registered
- 422: Invalid domain format or reserved subdomain
"""
domain = store_domain_service.add_domain(
db=db,
store_id=store_id,
domain_data=domain_data
)
return StoreDomainResponse(
id=domain.id,
store_id=domain.store_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("/{store_id}/domains", response_model=StoreDomainListResponse)
def list_store_domains(
store_id: int = Path(..., description="Store ID", gt=0),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""
List all domains for a store (Admin only).
Returns domains ordered by:
1. Primary domains first
2. Creation date (newest first)
**Raises:**
- 404: Store not found
"""
# Verify store exists
_get_store_by_id(db, store_id)
domains = store_domain_service.get_store_domains(db, store_id)
return StoreDomainListResponse(
domains=[
StoreDomainResponse(
id=d.id,
store_id=d.store_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=StoreDomainResponse)
def update_store_domain(
domain_id: int = Path(..., description="Domain ID", gt=0),
domain_update: StoreDomainUpdate = Body(...),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""
Update domain settings (Admin only).
**Can update:**
- `is_primary`: Set as primary domain for store
- `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 = store_domain_service.update_domain(
db=db,
domain_id=domain_id,
domain_update=domain_update
)
return StoreDomainResponse(
id=domain.id,
store_id=domain.store_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_store_domain(
domain_id: int = Path(..., description="Domain ID", gt=0),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""
Delete a custom domain (Admin only).
**Warning:** This is permanent and cannot be undone.
**Raises:**
- 404: Domain not found
"""
message = store_domain_service.delete_domain(db, domain_id)
return {"message": message}
Endpoint Best Practices:
- ✅ Use plural file names for collections
- ✅ Prefix for related resources (
/stores/{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,
stores,
store_domains, # NEW
users,
dashboard,
# ... other routers
)
router = APIRouter()
# Include routers
router.include_router(auth.router, tags=["admin-auth"])
router.include_router(stores.router, tags=["admin-stores"])
router.include_router(store_domains.router, tags=["admin-store-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("/stores/{store_code}/domains", response_class=HTMLResponse, include_in_schema=False)
async def admin_store_domains_page(
request: Request,
store_code: str = Path(..., description="Store code"),
current_user: User = Depends(get_current_admin_from_cookie_or_header),
db: Session = Depends(get_db)
):
"""
Render store domains management page.
Shows custom domains, verification status, and DNS configuration.
"""
return templates.TemplateResponse(
"admin/store-domains.html",
{
"request": request,
"user": current_user,
"store_code": store_code,
}
)
Step 2: Create HTML Template
Location: app/templates/admin/store-domains.html
{% extends "admin/base.html" %}
{% block title %}Store Domains - {{ store_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 {{ store_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 store ID from URL
const storeCode = "{{ store_code }}";
let storeId = null;
// Load domains on page load
document.addEventListener('DOMContentLoaded', async () => {
// First, get store ID from store code
await loadStoreId();
// Then load domains
await loadDomains();
});
async function loadStoreId() {
try {
const response = await fetch(`/api/v1/admin/stores/${storeCode}`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
});
if (response.ok) {
const store = await response.json();
storeId = store.id;
}
} catch (error) {
console.error('Error loading store:', error);
}
}
async function loadDomains() {
if (!storeId) return;
try {
const response = await fetch(`/api/v1/admin/stores/${storeId}/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/stores/${storeId}/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/stores/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/stores/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_store_domain_service.py
# tests/unit/services/test_store_domain_service.py
import pytest
from app.services.store_domain_service import StoreDomainService
from app.exceptions import (
StoreDomainAlreadyExistsException,
MaxDomainsReachedException,
InvalidDomainFormatException
)
from models.schema.store_domain import StoreDomainCreate
@pytest.fixture
def service():
return StoreDomainService()
def test_add_domain_success(db_session, test_store, service):
"""Test successful domain addition."""
domain_data = StoreDomainCreate(
domain="test.com",
is_primary=True
)
domain = service.add_domain(db_session, test_store.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_store, existing_domain, service):
"""Test adding duplicate domain raises exception."""
domain_data = StoreDomainCreate(domain=existing_domain.domain)
with pytest.raises(StoreDomainAlreadyExistsException):
service.add_domain(db_session, test_store.id, domain_data)
def test_add_domain_max_limit_reached(db_session, test_store, service):
"""Test max domains limit enforcement."""
# Add max_domains domains
for i in range(service.max_domains_per_store):
domain_data = StoreDomainCreate(domain=f"test{i}.com")
service.add_domain(db_session, test_store.id, domain_data)
# Try adding one more
domain_data = StoreDomainCreate(domain="overflow.com")
with pytest.raises(MaxDomainsReachedException):
service.add_domain(db_session, test_store.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_store_domains.py
# tests/integration/api/v1/admin/test_store_domains.py
import pytest
def test_add_domain_endpoint(client, admin_headers, test_store):
"""Test domain addition endpoint."""
response = client.post(
f"/api/v1/admin/stores/{test_store.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_store):
"""Test adding domain with invalid format."""
response = client.post(
f"/api/v1/admin/stores/{test_store.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_store, test_domain):
"""Test listing store domains."""
response = client.get(
f"/api/v1/admin/stores/{test_store.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/stores/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/stores/domains/99999/verify",
headers=admin_headers
)
assert response.status_code == 404
assert response.json()["error_code"] == "STORE_DOMAIN_NOT_FOUND"
Documentation
Step 1: Update API Documentation
Location: docs/api/admin_store_domains.md
# Store Domains API
## Overview
Custom domain management for stores.
## Endpoints
### Add Domain
\`\`\`
POST /api/v1/admin/stores/{store_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/stores/{store_id}/domains
\`\`\`
### Verify Domain
\`\`\`
POST /api/v1/admin/stores/domains/{domain_id}/verify
\`\`\`
### Delete Domain
\`\`\`
DELETE /api/v1/admin/stores/domains/{domain_id}
\`\`\`
Step 2: Update Changelog
Location: CHANGELOG.md
## [Unreleased]
### Added
- Custom domain support for stores
- DNS verification system
- Domain management UI in admin panel
### API Changes
- Added `/api/v1/admin/stores/{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 store_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/stores/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/store_domain.py # Database model
✅ models/schema/store_domain.py # Pydantic schemas
✅ app/exceptions/store_domain.py # Custom exceptions
✅ app/services/store_domain_service.py # Business logic
✅ app/api/v1/admin/store_domains.py # API endpoints
✅ app/api/v1/admin/pages.py # HTML routes
✅ app/templates/admin/store-domains.html # HTML template
✅ tests/unit/services/test_store_domain_service.py
✅ tests/integration/api/v1/admin/test_store_domains.py
Naming Convention Summary
| Type | Naming | Example |
|---|---|---|
| API File | PLURAL | store_domains.py |
| Model File | SINGULAR | store_domain.py |
| Schema File | SINGULAR | store_domain.py |
| Service File | SINGULAR + service | store_domain_service.py |
| Exception File | SINGULAR | store_domain.py |
| Class Name | SINGULAR | StoreDomain |
| Table Name | PLURAL | store_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 (stores, users)
Last Updated: 2025-01-15 Version: 1.0 Maintainer: Development Team