Files
orion/app/modules/tenancy/services/store_domain_service.py
Samir Boulahtit e9253fbd84 refactor: rename Wizamart to Orion across entire codebase
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>
2026-02-14 16:46:56 +01:00

444 lines
15 KiB
Python

# app/modules/tenancy/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 datetime import UTC, datetime
import dns.resolver
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session
from app.modules.tenancy.exceptions import (
DNSVerificationException,
DomainAlreadyVerifiedException,
DomainNotVerifiedException,
DomainVerificationFailedException,
InvalidDomainFormatException,
MaxDomainsReachedException,
ReservedDomainException,
StoreDomainAlreadyExistsException,
StoreDomainNotFoundException,
StoreNotFoundException,
StoreValidationException,
)
from app.modules.tenancy.models import Store, StoreDomain
from app.modules.tenancy.schemas.store_domain import (
StoreDomainCreate,
StoreDomainUpdate,
)
logger = logging.getLogger(__name__)
class StoreDomainService:
"""Service class for store domain operations."""
def __init__(self):
self.max_domains_per_store = 10 # Configure as needed
self.reserved_subdomains = [
"www",
"admin",
"api",
"mail",
"smtp",
"ftp",
"cpanel",
"webmail",
]
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:
# Verify store exists
self._get_store_by_id_or_raise(db, store_id)
# Check domain limit
self._check_domain_limit(db, store_id)
# Normalize domain
normalized_domain = StoreDomain.normalize_domain(domain_data.domain)
# Validate domain format
self._validate_domain_format(normalized_domain)
# Check if domain already exists
if self._domain_exists(db, normalized_domain):
existing_domain = (
db.query(StoreDomain)
.filter(StoreDomain.domain == normalized_domain)
.first()
)
raise StoreDomainAlreadyExistsException(
normalized_domain,
existing_domain.store_id if existing_domain else None,
)
# If setting as primary, unset other primary domains
if domain_data.is_primary:
self._unset_primary_domains(db, store_id)
# Resolve platform_id: use provided value, or auto-resolve from primary StorePlatform
platform_id = domain_data.platform_id
if not platform_id:
from app.modules.tenancy.models import StorePlatform
primary_sp = (
db.query(StorePlatform)
.filter(StorePlatform.store_id == store_id, StorePlatform.is_primary.is_(True))
.first()
)
platform_id = primary_sp.platform_id if primary_sp else None
# Create domain record
new_domain = StoreDomain(
store_id=store_id,
domain=normalized_domain,
is_primary=domain_data.is_primary,
platform_id=platform_id,
verification_token=secrets.token_urlsafe(32),
is_verified=False, # Requires DNS verification
is_active=False, # Cannot be active until verified
ssl_status="pending",
)
db.add(new_domain)
db.flush()
db.refresh(new_domain)
logger.info(f"Domain {normalized_domain} added to store {store_id}")
return new_domain
except (
StoreNotFoundException,
StoreDomainAlreadyExistsException,
MaxDomainsReachedException,
InvalidDomainFormatException,
ReservedDomainException,
):
raise
except SQLAlchemyError as e:
logger.error(f"Error adding domain: {str(e)}")
raise StoreValidationException("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 SQLAlchemyError as e:
logger.error(f"Error getting store domains: {str(e)}")
raise StoreValidationException("Failed to retrieve domains")
def get_domain_by_id(self, db: Session, domain_id: int) -> StoreDomain:
"""
Get domain by ID.
Args:
db: Database session
domain_id: Domain ID
Returns:
StoreDomain object
Raises:
StoreDomainNotFoundException: If domain not found
"""
domain = db.query(StoreDomain).filter(StoreDomain.id == domain_id).first()
if not domain:
raise StoreDomainNotFoundException(str(domain_id))
return domain
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(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.flush()
db.refresh(domain)
logger.info(f"Domain {domain.domain} updated")
return domain
except (StoreDomainNotFoundException, DomainNotVerifiedException):
raise
except SQLAlchemyError as e:
logger.error(f"Error updating domain: {str(e)}")
raise StoreValidationException("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(db, domain_id)
domain_name = domain.domain
store_id = domain.store_id
db.delete(domain)
logger.info(f"Domain {domain_name} deleted from store {store_id}")
return f"Domain {domain_name} deleted successfully"
except StoreDomainNotFoundException:
raise
except SQLAlchemyError as e:
logger.error(f"Error deleting domain: {str(e)}")
raise StoreValidationException("Failed to delete domain")
def verify_domain(self, db: Session, domain_id: int) -> tuple[StoreDomain, str]:
"""
Verify domain ownership via DNS TXT record.
The store must add a TXT record:
Name: _orion-verify.{domain}
Value: {verification_token}
Args:
db: Database session
domain_id: Domain ID
Returns:
Tuple of (verified_domain, message)
Raises:
StoreDomainNotFoundException: If domain not found
DomainAlreadyVerifiedException: If already verified
DomainVerificationFailedException: If verification fails
"""
try:
domain = self.get_domain_by_id(db, domain_id)
# Check if already verified
if domain.is_verified:
raise DomainAlreadyVerifiedException(domain_id, domain.domain)
# Query DNS TXT records
try:
txt_records = dns.resolver.resolve(
f"_orion-verify.{domain.domain}", "TXT"
)
# Check if verification token is present
for txt in txt_records:
txt_value = txt.to_text().strip('"')
if txt_value == domain.verification_token:
# Verification successful
domain.is_verified = True
domain.verified_at = datetime.now(UTC)
db.flush()
db.refresh(domain)
logger.info(f"Domain {domain.domain} verified successfully")
return domain, f"Domain {domain.domain} verified successfully"
# Token not found
raise DomainVerificationFailedException(
domain.domain, "Verification token not found in DNS records"
)
except dns.resolver.NXDOMAIN:
raise DomainVerificationFailedException(
domain.domain,
f"DNS record _orion-verify.{domain.domain} not found",
)
except dns.resolver.NoAnswer:
raise DomainVerificationFailedException(
domain.domain, "No TXT records found for verification"
)
except DomainVerificationFailedException:
raise
except dns.resolver.DNSException as dns_error:
raise DNSVerificationException(domain.domain, str(dns_error))
except (
StoreDomainNotFoundException,
DomainAlreadyVerifiedException,
DomainVerificationFailedException,
DNSVerificationException,
):
raise
except SQLAlchemyError as e:
logger.error(f"Error verifying domain: {str(e)}")
raise StoreValidationException("Failed to verify domain")
def get_verification_instructions(self, db: Session, domain_id: int) -> dict:
"""
Get DNS verification instructions for domain.
Args:
db: Database session
domain_id: Domain ID
Returns:
Dict with verification instructions
Raises:
StoreDomainNotFoundException: If domain not found
"""
domain = self.get_domain_by_id(db, domain_id)
return {
"domain": domain.domain,
"verification_token": domain.verification_token,
"instructions": {
"step1": "Go to your domain's DNS settings (at your domain registrar)",
"step2": "Add a new TXT record with the following values:",
"step3": "Wait for DNS propagation (5-15 minutes)",
"step4": "Click 'Verify Domain' button in admin panel",
},
"txt_record": {
"type": "TXT",
"name": "_orion-verify",
"value": domain.verification_token,
"ttl": 3600,
},
"common_registrars": {
"Cloudflare": "https://dash.cloudflare.com",
"GoDaddy": "https://dcc.godaddy.com/manage/dns",
"Namecheap": "https://www.namecheap.com/myaccount/domain-list/",
"Google Domains": "https://domains.google.com",
},
}
# Private helper methods
def _get_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 _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."""
# Check for reserved subdomains
first_part = domain.split(".")[0]
if first_part in self.reserved_subdomains:
raise ReservedDomainException(domain, first_part)
def _unset_primary_domains(
self, db: Session, store_id: int, exclude_domain_id: int | None = 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()