- Fix IPv6 host parsing with _strip_port() utility - Remove dangerous StorePlatform→Store.subdomain silent fallback - Close storefront gate bypass when frontend_type is None - Add custom subdomain management UI and API for stores - Add domain health diagnostic tool - Convert db.add() in loops to db.add_all() (24 PERF-006 fixes) - Add tests for all new functionality (18 subdomain service tests) - Add .github templates for validator compliance Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
171 lines
5.4 KiB
Python
171 lines
5.4 KiB
Python
# app/modules/tenancy/services/store_subdomain_service.py
|
|
"""
|
|
Service for managing StorePlatform custom subdomains.
|
|
|
|
Handles validation (format, uniqueness) and CRUD operations on
|
|
the custom_subdomain field of StorePlatform entries.
|
|
"""
|
|
|
|
import logging
|
|
import re
|
|
|
|
from sqlalchemy import func
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.exceptions.base import (
|
|
ConflictException,
|
|
ResourceNotFoundException,
|
|
ValidationException,
|
|
)
|
|
from app.modules.tenancy.models import Platform, Store
|
|
from app.modules.tenancy.models.store_platform import StorePlatform
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Subdomain rules: lowercase alphanumeric + hyphens, 3-63 chars, no leading/trailing hyphen
|
|
_SUBDOMAIN_RE = re.compile(r"^[a-z0-9]([a-z0-9-]{1,61}[a-z0-9])?$")
|
|
|
|
|
|
class StoreSubdomainService:
|
|
"""Manage custom subdomains on StorePlatform entries."""
|
|
|
|
def get_custom_subdomains(self, db: Session, store_id: int) -> list[dict]:
|
|
"""
|
|
List all platform memberships for a store with their custom subdomains.
|
|
|
|
Returns a list of dicts with platform info and custom_subdomain (may be None).
|
|
"""
|
|
store = db.query(Store).filter(Store.id == store_id).first()
|
|
if not store:
|
|
raise ResourceNotFoundException("Store", str(store_id))
|
|
|
|
memberships = (
|
|
db.query(StorePlatform)
|
|
.filter(
|
|
StorePlatform.store_id == store_id,
|
|
StorePlatform.is_active.is_(True),
|
|
)
|
|
.all()
|
|
)
|
|
|
|
results = []
|
|
for sp in memberships:
|
|
platform = db.query(Platform).filter(Platform.id == sp.platform_id).first()
|
|
if not platform:
|
|
continue
|
|
results.append({
|
|
"store_platform_id": sp.id,
|
|
"platform_id": sp.platform_id,
|
|
"platform_code": platform.code,
|
|
"platform_name": platform.name,
|
|
"platform_domain": platform.domain,
|
|
"custom_subdomain": sp.custom_subdomain,
|
|
"default_subdomain": store.subdomain,
|
|
"full_url": (
|
|
f"{sp.custom_subdomain}.{platform.domain}"
|
|
if sp.custom_subdomain and platform.domain
|
|
else None
|
|
),
|
|
"default_url": (
|
|
f"{store.subdomain}.{platform.domain}"
|
|
if store.subdomain and platform.domain
|
|
else None
|
|
),
|
|
})
|
|
|
|
return results
|
|
|
|
def set_custom_subdomain(
|
|
self, db: Session, store_id: int, platform_id: int, subdomain: str
|
|
) -> StorePlatform:
|
|
"""
|
|
Set or update the custom_subdomain for a store on a specific platform.
|
|
|
|
Validates:
|
|
- Subdomain format (lowercase, alphanumeric + hyphens)
|
|
- Uniqueness on the platform (no other store claims it)
|
|
- StorePlatform membership exists and is active
|
|
"""
|
|
subdomain = subdomain.strip().lower()
|
|
|
|
# Validate format
|
|
if not _SUBDOMAIN_RE.match(subdomain):
|
|
raise ValidationException(
|
|
"Must be 3-63 characters, lowercase alphanumeric and hyphens, "
|
|
"cannot start or end with a hyphen.",
|
|
field="custom_subdomain",
|
|
)
|
|
|
|
# Find the membership
|
|
sp = (
|
|
db.query(StorePlatform)
|
|
.filter(
|
|
StorePlatform.store_id == store_id,
|
|
StorePlatform.platform_id == platform_id,
|
|
StorePlatform.is_active.is_(True),
|
|
)
|
|
.first()
|
|
)
|
|
if not sp:
|
|
raise ResourceNotFoundException(
|
|
"StorePlatform",
|
|
f"store_id={store_id}, platform_id={platform_id}",
|
|
)
|
|
|
|
# Check uniqueness on this platform (exclude current entry)
|
|
existing = (
|
|
db.query(StorePlatform)
|
|
.filter(
|
|
func.lower(StorePlatform.custom_subdomain) == subdomain,
|
|
StorePlatform.platform_id == platform_id,
|
|
StorePlatform.id != sp.id,
|
|
)
|
|
.first()
|
|
)
|
|
if existing:
|
|
raise ConflictException(
|
|
f"Subdomain '{subdomain}' is already claimed by another store "
|
|
f"on this platform."
|
|
)
|
|
|
|
sp.custom_subdomain = subdomain
|
|
db.flush()
|
|
|
|
logger.info(
|
|
f"Set custom_subdomain='{subdomain}' for store_id={store_id} "
|
|
f"on platform_id={platform_id}"
|
|
)
|
|
return sp
|
|
|
|
def clear_custom_subdomain(
|
|
self, db: Session, store_id: int, platform_id: int
|
|
) -> StorePlatform:
|
|
"""Clear the custom_subdomain for a store on a specific platform."""
|
|
sp = (
|
|
db.query(StorePlatform)
|
|
.filter(
|
|
StorePlatform.store_id == store_id,
|
|
StorePlatform.platform_id == platform_id,
|
|
StorePlatform.is_active.is_(True),
|
|
)
|
|
.first()
|
|
)
|
|
if not sp:
|
|
raise ResourceNotFoundException(
|
|
"StorePlatform",
|
|
f"store_id={store_id}, platform_id={platform_id}",
|
|
)
|
|
|
|
old_value = sp.custom_subdomain
|
|
sp.custom_subdomain = None
|
|
db.flush()
|
|
|
|
logger.info(
|
|
f"Cleared custom_subdomain (was '{old_value}') for store_id={store_id} "
|
|
f"on platform_id={platform_id}"
|
|
)
|
|
return sp
|
|
|
|
|
|
store_subdomain_service = StoreSubdomainService()
|