Updated all documentation to use correct authentication dependency names: - HTML pages: get_current_admin_from_cookie_or_header, get_current_vendor_from_cookie_or_header, get_current_customer_from_cookie_or_header - API endpoints: get_current_admin_api, get_current_vendor_api, get_current_customer_api Changes: - Updated authentication flow diagrams with correct dependency names for admin and vendor flows - Added comprehensive customer/shop authentication flow documentation - Updated cookie isolation architecture to show all three contexts (admin, vendor, shop) - Expanded security boundary enforcement diagram to include shop routes - Added customer cross-context prevention examples - Updated all code examples in frontend and backend documentation - Fixed import statements to use app.api.deps instead of app.core.auth Files updated: - docs/api/authentication-flow-diagrams.md (added customer flows) - docs/frontend/admin/page-templates.md - docs/frontend/admin/architecture.md - docs/frontend/shared/ui-components.md - docs/frontend/shared/sidebar.md - docs/development/exception-handling.md - docs/architecture/diagrams/vendor-domain-diagrams.md - docs/backend/admin-integration-guide.md - docs/backend/admin-feature-integration.md 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1800 lines
52 KiB
Markdown
1800 lines
52 KiB
Markdown
# 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"<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 `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_api, 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_api),
|
|
):
|
|
"""
|
|
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_api),
|
|
):
|
|
"""
|
|
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_api),
|
|
):
|
|
"""
|
|
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_api),
|
|
):
|
|
"""
|
|
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_from_cookie_or_header),
|
|
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 %}
|
|
<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`
|
|
|
|
```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
|