refactor: migrate templates and static files to self-contained modules
Templates Migration: - Migrate admin templates to modules (tenancy, billing, monitoring, marketplace, etc.) - Migrate vendor templates to modules (tenancy, billing, orders, messaging, etc.) - Migrate storefront templates to modules (catalog, customers, orders, cart, checkout, cms) - Migrate public templates to modules (billing, marketplace, cms) - Keep shared templates in app/templates/ (base.html, errors/, partials/, macros/) - Migrate letzshop partials to marketplace module Static Files Migration: - Migrate admin JS to modules: tenancy (23 files), core (5 files), monitoring (1 file) - Migrate vendor JS to modules: tenancy (4 files), core (2 files) - Migrate shared JS: vendor-selector.js to core, media-picker.js to cms - Migrate storefront JS: storefront-layout.js to core - Keep framework JS in static/ (api-client, utils, money, icons, log-config, lib/) - Update all template references to use module_static paths Naming Consistency: - Rename static/platform/ to static/public/ - Rename app/templates/platform/ to app/templates/public/ - Update all extends and static references Documentation: - Update module-system.md with shared templates documentation - Update frontend-structure.md with new module JS organization Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -18,12 +18,9 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_admin_api, require_module_access
|
||||
from app.core.database import get_db
|
||||
from app.exceptions import (
|
||||
OrderHasUnresolvedExceptionsException,
|
||||
ResourceNotFoundException,
|
||||
ValidationException,
|
||||
)
|
||||
from app.services.order_item_exception_service import order_item_exception_service
|
||||
from app.exceptions import ResourceNotFoundException, ValidationException
|
||||
from app.modules.orders.exceptions import OrderHasUnresolvedExceptionsException
|
||||
from app.modules.orders.services.order_item_exception_service import order_item_exception_service
|
||||
from app.modules.marketplace.services.letzshop import (
|
||||
CredentialsNotFoundError,
|
||||
LetzshopClientError,
|
||||
@@ -33,7 +30,7 @@ from app.modules.marketplace.services.letzshop import (
|
||||
OrderNotFoundError,
|
||||
VendorNotFoundError,
|
||||
)
|
||||
from app.tasks.letzshop_tasks import process_historical_import
|
||||
from app.modules.marketplace.tasks import process_historical_import
|
||||
from models.schema.auth import UserContext
|
||||
from app.modules.marketplace.schemas import (
|
||||
FulfillmentOperationResponse,
|
||||
@@ -1311,7 +1308,7 @@ def trigger_vendor_directory_sync(
|
||||
the local cache. This is typically run daily via Celery beat, but
|
||||
can be triggered manually here.
|
||||
"""
|
||||
from app.tasks.celery_tasks.letzshop import sync_vendor_directory
|
||||
from app.modules.marketplace.tasks import sync_vendor_directory
|
||||
|
||||
# Try to dispatch via Celery first
|
||||
try:
|
||||
|
||||
@@ -13,8 +13,8 @@ from sqlalchemy.orm import Session
|
||||
from app.api.deps import get_current_admin_api, require_module_access
|
||||
from app.core.database import get_db
|
||||
from app.modules.marketplace.services.marketplace_import_job_service import marketplace_import_job_service
|
||||
from app.services.stats_service import stats_service
|
||||
from app.services.vendor_service import vendor_service
|
||||
from app.modules.analytics.services.stats_service import stats_service
|
||||
from app.modules.tenancy.services.vendor_service import vendor_service
|
||||
from models.schema.auth import UserContext
|
||||
from app.modules.marketplace.schemas import (
|
||||
AdminMarketplaceImportJobListResponse,
|
||||
|
||||
269
app/modules/marketplace/routes/api/public.py
Normal file
269
app/modules/marketplace/routes/api/public.py
Normal file
@@ -0,0 +1,269 @@
|
||||
# app/modules/marketplace/routes/api/public.py
|
||||
"""
|
||||
Public Letzshop vendor lookup API endpoints.
|
||||
|
||||
Allows potential vendors to find themselves in the Letzshop marketplace
|
||||
and claim their shop during signup.
|
||||
|
||||
All endpoints are public (no authentication required).
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.exceptions import ResourceNotFoundException
|
||||
from app.modules.marketplace.services.letzshop import LetzshopVendorSyncService
|
||||
from app.modules.marketplace.models import LetzshopVendorCache
|
||||
|
||||
router = APIRouter(prefix="/letzshop-vendors")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Response Schemas
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class LetzshopVendorInfo(BaseModel):
|
||||
"""Letzshop vendor information for display."""
|
||||
|
||||
letzshop_id: str | None = None
|
||||
slug: str
|
||||
name: str
|
||||
company_name: str | None = None
|
||||
description: str | None = None
|
||||
email: str | None = None
|
||||
phone: str | None = None
|
||||
website: str | None = None
|
||||
address: str | None = None
|
||||
city: str | None = None
|
||||
categories: list[str] = []
|
||||
background_image_url: str | None = None
|
||||
social_media_links: list[str] = []
|
||||
letzshop_url: str
|
||||
is_claimed: bool = False
|
||||
|
||||
@classmethod
|
||||
def from_cache(cls, cache: LetzshopVendorCache, lang: str = "en") -> "LetzshopVendorInfo":
|
||||
"""Create from cache entry."""
|
||||
return cls(
|
||||
letzshop_id=cache.letzshop_id,
|
||||
slug=cache.slug,
|
||||
name=cache.name,
|
||||
company_name=cache.company_name,
|
||||
description=cache.get_description(lang),
|
||||
email=cache.email,
|
||||
phone=cache.phone,
|
||||
website=cache.website,
|
||||
address=cache.get_full_address(),
|
||||
city=cache.city,
|
||||
categories=cache.categories or [],
|
||||
background_image_url=cache.background_image_url,
|
||||
social_media_links=cache.social_media_links or [],
|
||||
letzshop_url=cache.letzshop_url,
|
||||
is_claimed=cache.is_claimed,
|
||||
)
|
||||
|
||||
|
||||
class LetzshopVendorListResponse(BaseModel):
|
||||
"""Paginated list of Letzshop vendors."""
|
||||
|
||||
vendors: list[LetzshopVendorInfo]
|
||||
total: int
|
||||
page: int
|
||||
limit: int
|
||||
has_more: bool
|
||||
|
||||
|
||||
class LetzshopLookupRequest(BaseModel):
|
||||
"""Request to lookup a Letzshop vendor by URL."""
|
||||
|
||||
url: str # e.g., https://letzshop.lu/vendors/my-shop or just "my-shop"
|
||||
|
||||
|
||||
class LetzshopLookupResponse(BaseModel):
|
||||
"""Response from Letzshop vendor lookup."""
|
||||
|
||||
found: bool
|
||||
vendor: LetzshopVendorInfo | None = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Helper Functions
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def extract_slug_from_url(url_or_slug: str) -> str:
|
||||
"""
|
||||
Extract vendor slug from Letzshop URL or return as-is if already a slug.
|
||||
|
||||
Handles:
|
||||
- https://letzshop.lu/vendors/my-shop
|
||||
- https://letzshop.lu/en/vendors/my-shop
|
||||
- letzshop.lu/vendors/my-shop
|
||||
- my-shop
|
||||
"""
|
||||
# Clean up the input
|
||||
url_or_slug = url_or_slug.strip()
|
||||
|
||||
# If it looks like a URL, extract the slug
|
||||
if "letzshop" in url_or_slug.lower() or "/" in url_or_slug:
|
||||
# Remove protocol if present
|
||||
url_or_slug = re.sub(r"^https?://", "", url_or_slug)
|
||||
|
||||
# Match pattern like letzshop.lu/[lang/]vendors/SLUG[/...]
|
||||
match = re.search(r"letzshop\.lu/(?:[a-z]{2}/)?vendors?/([^/?#]+)", url_or_slug, re.IGNORECASE)
|
||||
if match:
|
||||
return match.group(1).lower()
|
||||
|
||||
# If just a path like vendors/my-shop
|
||||
match = re.search(r"vendors?/([^/?#]+)", url_or_slug)
|
||||
if match:
|
||||
return match.group(1).lower()
|
||||
|
||||
# Return as-is (assume it's already a slug)
|
||||
return url_or_slug.lower()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Endpoints
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@router.get("", response_model=LetzshopVendorListResponse) # public
|
||||
async def list_letzshop_vendors(
|
||||
search: Annotated[str | None, Query(description="Search by name")] = None,
|
||||
category: Annotated[str | None, Query(description="Filter by category")] = None,
|
||||
city: Annotated[str | None, Query(description="Filter by city")] = None,
|
||||
only_unclaimed: Annotated[bool, Query(description="Only show unclaimed vendors")] = False,
|
||||
lang: Annotated[str, Query(description="Language for descriptions")] = "en",
|
||||
page: Annotated[int, Query(ge=1)] = 1,
|
||||
limit: Annotated[int, Query(ge=1, le=50)] = 20,
|
||||
db: Session = Depends(get_db),
|
||||
) -> LetzshopVendorListResponse:
|
||||
"""
|
||||
List Letzshop vendors from cached directory.
|
||||
|
||||
The cache is periodically synced from Letzshop's public GraphQL API.
|
||||
Run the sync task manually or wait for scheduled sync if cache is empty.
|
||||
"""
|
||||
sync_service = LetzshopVendorSyncService(db)
|
||||
|
||||
vendors, total = sync_service.search_cached_vendors(
|
||||
search=search,
|
||||
city=city,
|
||||
category=category,
|
||||
only_unclaimed=only_unclaimed,
|
||||
page=page,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
return LetzshopVendorListResponse(
|
||||
vendors=[LetzshopVendorInfo.from_cache(v, lang) for v in vendors],
|
||||
total=total,
|
||||
page=page,
|
||||
limit=limit,
|
||||
has_more=(page * limit) < total,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/lookup", response_model=LetzshopLookupResponse) # public
|
||||
async def lookup_letzshop_vendor(
|
||||
request: LetzshopLookupRequest,
|
||||
lang: Annotated[str, Query(description="Language for descriptions")] = "en",
|
||||
db: Session = Depends(get_db),
|
||||
) -> LetzshopLookupResponse:
|
||||
"""
|
||||
Lookup a Letzshop vendor by URL or slug.
|
||||
|
||||
This endpoint:
|
||||
1. Extracts the slug from the provided URL
|
||||
2. Looks up vendor in local cache (or fetches from Letzshop if not cached)
|
||||
3. Checks if the vendor is already claimed on our platform
|
||||
4. Returns vendor info for signup pre-fill
|
||||
"""
|
||||
try:
|
||||
slug = extract_slug_from_url(request.url)
|
||||
|
||||
if not slug:
|
||||
return LetzshopLookupResponse(
|
||||
found=False,
|
||||
error="Could not extract vendor slug from URL",
|
||||
)
|
||||
|
||||
sync_service = LetzshopVendorSyncService(db)
|
||||
|
||||
# First try cache
|
||||
cache_entry = sync_service.get_cached_vendor(slug)
|
||||
|
||||
# If not in cache, try to fetch from Letzshop
|
||||
if not cache_entry:
|
||||
logger.info(f"Vendor {slug} not in cache, fetching from Letzshop...")
|
||||
cache_entry = sync_service.sync_single_vendor(slug)
|
||||
|
||||
if not cache_entry:
|
||||
return LetzshopLookupResponse(
|
||||
found=False,
|
||||
error="Vendor not found on Letzshop",
|
||||
)
|
||||
|
||||
return LetzshopLookupResponse(
|
||||
found=True,
|
||||
vendor=LetzshopVendorInfo.from_cache(cache_entry, lang),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error looking up Letzshop vendor: {e}")
|
||||
return LetzshopLookupResponse(
|
||||
found=False,
|
||||
error="Failed to lookup vendor",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/stats") # public
|
||||
async def get_letzshop_vendor_stats(
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""
|
||||
Get statistics about the Letzshop vendor cache.
|
||||
|
||||
Returns total, active, claimed, and unclaimed vendor counts.
|
||||
"""
|
||||
sync_service = LetzshopVendorSyncService(db)
|
||||
return sync_service.get_sync_stats()
|
||||
|
||||
|
||||
@router.get("/{slug}", response_model=LetzshopVendorInfo) # public
|
||||
async def get_letzshop_vendor(
|
||||
slug: str,
|
||||
lang: Annotated[str, Query(description="Language for descriptions")] = "en",
|
||||
db: Session = Depends(get_db),
|
||||
) -> LetzshopVendorInfo:
|
||||
"""
|
||||
Get a specific Letzshop vendor by slug.
|
||||
|
||||
Returns 404 if vendor not found in cache or on Letzshop.
|
||||
"""
|
||||
slug = slug.lower()
|
||||
|
||||
sync_service = LetzshopVendorSyncService(db)
|
||||
|
||||
# First try cache
|
||||
cache_entry = sync_service.get_cached_vendor(slug)
|
||||
|
||||
# If not in cache, try to fetch from Letzshop
|
||||
if not cache_entry:
|
||||
logger.info(f"Vendor {slug} not in cache, fetching from Letzshop...")
|
||||
cache_entry = sync_service.sync_single_vendor(slug)
|
||||
|
||||
if not cache_entry:
|
||||
raise ResourceNotFoundException("LetzshopVendor", slug)
|
||||
|
||||
return LetzshopVendorInfo.from_cache(cache_entry, lang)
|
||||
@@ -20,12 +20,9 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_vendor_api, require_module_access
|
||||
from app.core.database import get_db
|
||||
from app.exceptions import (
|
||||
OrderHasUnresolvedExceptionsException,
|
||||
ResourceNotFoundException,
|
||||
ValidationException,
|
||||
)
|
||||
from app.services.order_item_exception_service import order_item_exception_service
|
||||
from app.exceptions import ResourceNotFoundException, ValidationException
|
||||
from app.modules.orders.exceptions import OrderHasUnresolvedExceptionsException
|
||||
from app.modules.orders.services.order_item_exception_service import order_item_exception_service
|
||||
from app.modules.marketplace.services.letzshop import (
|
||||
CredentialsNotFoundError,
|
||||
LetzshopClientError,
|
||||
@@ -770,7 +767,7 @@ def export_products_letzshop(
|
||||
from fastapi.responses import Response
|
||||
|
||||
from app.modules.marketplace.services.letzshop_export_service import letzshop_export_service
|
||||
from app.services.vendor_service import vendor_service
|
||||
from app.modules.tenancy.services.vendor_service import vendor_service
|
||||
|
||||
vendor_id = current_user.token_vendor_id
|
||||
vendor = vendor_service.get_vendor_by_id(db, vendor_id)
|
||||
|
||||
@@ -16,7 +16,7 @@ from sqlalchemy.orm import Session
|
||||
from app.api.deps import get_current_vendor_api, require_module_access
|
||||
from app.core.database import get_db
|
||||
from app.modules.marketplace.services.marketplace_import_job_service import marketplace_import_job_service
|
||||
from app.services.vendor_service import vendor_service
|
||||
from app.modules.tenancy.services.vendor_service import vendor_service
|
||||
from middleware.decorators import rate_limit
|
||||
from models.schema.auth import UserContext
|
||||
from app.modules.marketplace.schemas import (
|
||||
|
||||
@@ -20,7 +20,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_vendor_api, require_module_access
|
||||
from app.core.database import get_db
|
||||
from app.services.onboarding_service import OnboardingService
|
||||
from app.modules.marketplace.services.onboarding_service import OnboardingService
|
||||
from models.schema.auth import UserContext
|
||||
from app.modules.marketplace.schemas import (
|
||||
CompanyProfileRequest,
|
||||
|
||||
Reference in New Issue
Block a user