feat: production routing support for subdomain and custom domain modes
Some checks failed
Some checks failed
Double-mount store routes at /store/* and /store/{store_code}/* so the
same handlers work in dev path-based, prod path-based, prod subdomain,
and prod custom-domain modes. Wire StorePlatform.custom_subdomain into
StoreContextMiddleware for per-platform subdomain overrides. Add admin
custom-domain management UI, fix stale /shop/ reset link, add
/merchants/ to reserved paths, and server-render window.STORE_CODE for
JS that previously parsed the URL.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -39,7 +39,7 @@ The cookie path restrictions prevent cross-context cookie leakage:
|
|||||||
import logging
|
import logging
|
||||||
from datetime import UTC
|
from datetime import UTC
|
||||||
|
|
||||||
from fastapi import Cookie, Depends, Request
|
from fastapi import Cookie, Depends, HTTPException, Request
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -73,6 +73,19 @@ logger = logging.getLogger(__name__)
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
async def get_resolved_store_code(request: Request) -> str:
|
||||||
|
"""Get store code from path parameter (path-based) or middleware (subdomain/custom domain)."""
|
||||||
|
# Path parameter from double-mount prefix (/store/{store_code}/...)
|
||||||
|
store_code = request.path_params.get("store_code")
|
||||||
|
if store_code:
|
||||||
|
return store_code
|
||||||
|
# Middleware-resolved store (subdomain or custom domain)
|
||||||
|
store = getattr(request.state, "store", None)
|
||||||
|
if store:
|
||||||
|
return store.store_code
|
||||||
|
raise HTTPException(status_code=404, detail="Store not found")
|
||||||
|
|
||||||
|
|
||||||
def _get_token_from_request(
|
def _get_token_from_request(
|
||||||
credentials: HTTPAuthorizationCredentials | None,
|
credentials: HTTPAuthorizationCredentials | None,
|
||||||
cookie_value: str | None,
|
cookie_value: str | None,
|
||||||
|
|||||||
@@ -7,11 +7,15 @@ Store pages for analytics dashboard.
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Path, Request
|
from fastapi import APIRouter, Depends, Request
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.deps import get_current_store_from_cookie_or_header, get_db
|
from app.api.deps import (
|
||||||
|
get_current_store_from_cookie_or_header,
|
||||||
|
get_db,
|
||||||
|
get_resolved_store_code,
|
||||||
|
)
|
||||||
from app.modules.core.services.platform_settings_service import (
|
from app.modules.core.services.platform_settings_service import (
|
||||||
platform_settings_service, # MOD-004 - shared platform service
|
platform_settings_service, # MOD-004 - shared platform service
|
||||||
)
|
)
|
||||||
@@ -73,11 +77,11 @@ def get_store_context(
|
|||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{store_code}/analytics", response_class=HTMLResponse, include_in_schema=False
|
"/analytics", response_class=HTMLResponse, include_in_schema=False
|
||||||
)
|
)
|
||||||
async def store_analytics_page(
|
async def store_analytics_page(
|
||||||
request: Request,
|
request: Request,
|
||||||
store_code: str = Path(..., description="Store code"),
|
store_code: str = Depends(get_resolved_store_code),
|
||||||
current_user: User = Depends(get_current_store_from_cookie_or_header),
|
current_user: User = Depends(get_current_store_from_cookie_or_header),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -7,11 +7,15 @@ Store pages for billing management:
|
|||||||
- Invoices
|
- Invoices
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Path, Request
|
from fastapi import APIRouter, Depends, Request
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.deps import get_current_store_from_cookie_or_header, get_db
|
from app.api.deps import (
|
||||||
|
get_current_store_from_cookie_or_header,
|
||||||
|
get_db,
|
||||||
|
get_resolved_store_code,
|
||||||
|
)
|
||||||
from app.modules.core.utils.page_context import get_store_context
|
from app.modules.core.utils.page_context import get_store_context
|
||||||
from app.modules.tenancy.models import User
|
from app.modules.tenancy.models import User
|
||||||
from app.templates_config import templates
|
from app.templates_config import templates
|
||||||
@@ -25,11 +29,11 @@ router = APIRouter()
|
|||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{store_code}/billing", response_class=HTMLResponse, include_in_schema=False
|
"/billing", response_class=HTMLResponse, include_in_schema=False
|
||||||
)
|
)
|
||||||
async def store_billing_page(
|
async def store_billing_page(
|
||||||
request: Request,
|
request: Request,
|
||||||
store_code: str = Path(..., description="Store code"),
|
store_code: str = Depends(get_resolved_store_code),
|
||||||
current_user: User = Depends(get_current_store_from_cookie_or_header),
|
current_user: User = Depends(get_current_store_from_cookie_or_header),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
@@ -44,11 +48,11 @@ async def store_billing_page(
|
|||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{store_code}/invoices", response_class=HTMLResponse, include_in_schema=False
|
"/invoices", response_class=HTMLResponse, include_in_schema=False
|
||||||
)
|
)
|
||||||
async def store_invoices_page(
|
async def store_invoices_page(
|
||||||
request: Request,
|
request: Request,
|
||||||
store_code: str = Path(..., description="Store code"),
|
store_code: str = Depends(get_resolved_store_code),
|
||||||
current_user: User = Depends(get_current_store_from_cookie_or_header),
|
current_user: User = Depends(get_current_store_from_cookie_or_header),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -178,10 +178,11 @@
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get store code from URL
|
* Get store code from server-rendered value or URL fallback
|
||||||
* @returns {string|null}
|
* @returns {string|null}
|
||||||
*/
|
*/
|
||||||
getStoreCode() {
|
getStoreCode() {
|
||||||
|
if (window.STORE_CODE) return window.STORE_CODE;
|
||||||
const path = window.location.pathname;
|
const path = window.location.pathname;
|
||||||
const segments = path.split('/').filter(Boolean);
|
const segments = path.split('/').filter(Boolean);
|
||||||
// Direct: /store/{code}/...
|
// Direct: /store/{code}/...
|
||||||
|
|||||||
@@ -139,9 +139,10 @@
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get store code from URL
|
* Get store code from server-rendered value or URL fallback
|
||||||
*/
|
*/
|
||||||
getStoreCode() {
|
getStoreCode() {
|
||||||
|
if (window.STORE_CODE) return window.STORE_CODE;
|
||||||
const path = window.location.pathname;
|
const path = window.location.pathname;
|
||||||
const segments = path.split('/').filter(Boolean);
|
const segments = path.split('/').filter(Boolean);
|
||||||
if (segments[0] === 'store' && segments[1]) {
|
if (segments[0] === 'store' && segments[1]) {
|
||||||
|
|||||||
@@ -7,11 +7,15 @@ Store pages for product management:
|
|||||||
- Product create
|
- Product create
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Path, Request
|
from fastapi import APIRouter, Depends, Request
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.deps import get_current_store_from_cookie_or_header, get_db
|
from app.api.deps import (
|
||||||
|
get_current_store_from_cookie_or_header,
|
||||||
|
get_db,
|
||||||
|
get_resolved_store_code,
|
||||||
|
)
|
||||||
from app.modules.core.utils.page_context import get_store_context
|
from app.modules.core.utils.page_context import get_store_context
|
||||||
from app.modules.tenancy.models import User
|
from app.modules.tenancy.models import User
|
||||||
from app.templates_config import templates
|
from app.templates_config import templates
|
||||||
@@ -25,11 +29,11 @@ router = APIRouter()
|
|||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{store_code}/products", response_class=HTMLResponse, include_in_schema=False
|
"/products", response_class=HTMLResponse, include_in_schema=False
|
||||||
)
|
)
|
||||||
async def store_products_page(
|
async def store_products_page(
|
||||||
request: Request,
|
request: Request,
|
||||||
store_code: str = Path(..., description="Store code"),
|
store_code: str = Depends(get_resolved_store_code),
|
||||||
current_user: User = Depends(get_current_store_from_cookie_or_header),
|
current_user: User = Depends(get_current_store_from_cookie_or_header),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
@@ -44,13 +48,13 @@ async def store_products_page(
|
|||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{store_code}/products/create",
|
"/products/create",
|
||||||
response_class=HTMLResponse,
|
response_class=HTMLResponse,
|
||||||
include_in_schema=False,
|
include_in_schema=False,
|
||||||
)
|
)
|
||||||
async def store_product_create_page(
|
async def store_product_create_page(
|
||||||
request: Request,
|
request: Request,
|
||||||
store_code: str = Path(..., description="Store code"),
|
store_code: str = Depends(get_resolved_store_code),
|
||||||
current_user: User = Depends(get_current_store_from_cookie_or_header),
|
current_user: User = Depends(get_current_store_from_cookie_or_header),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -11,7 +11,11 @@ from fastapi import APIRouter, Depends, HTTPException, Path, Request
|
|||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.deps import get_current_store_from_cookie_or_header, get_db
|
from app.api.deps import (
|
||||||
|
get_current_store_from_cookie_or_header,
|
||||||
|
get_db,
|
||||||
|
get_resolved_store_code,
|
||||||
|
)
|
||||||
from app.modules.cms.services import content_page_service
|
from app.modules.cms.services import content_page_service
|
||||||
from app.modules.core.services.platform_settings_service import (
|
from app.modules.core.services.platform_settings_service import (
|
||||||
platform_settings_service, # MOD-004 - shared platform service
|
platform_settings_service, # MOD-004 - shared platform service
|
||||||
@@ -83,11 +87,11 @@ def get_store_context(
|
|||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{store_code}/content-pages", response_class=HTMLResponse, include_in_schema=False
|
"/content-pages", response_class=HTMLResponse, include_in_schema=False
|
||||||
)
|
)
|
||||||
async def store_content_pages_list(
|
async def store_content_pages_list(
|
||||||
request: Request,
|
request: Request,
|
||||||
store_code: str = Path(..., description="Store code"),
|
store_code: str = Depends(get_resolved_store_code),
|
||||||
current_user: User = Depends(get_current_store_from_cookie_or_header),
|
current_user: User = Depends(get_current_store_from_cookie_or_header),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
@@ -102,13 +106,13 @@ async def store_content_pages_list(
|
|||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{store_code}/content-pages/create",
|
"/content-pages/create",
|
||||||
response_class=HTMLResponse,
|
response_class=HTMLResponse,
|
||||||
include_in_schema=False,
|
include_in_schema=False,
|
||||||
)
|
)
|
||||||
async def store_content_page_create(
|
async def store_content_page_create(
|
||||||
request: Request,
|
request: Request,
|
||||||
store_code: str = Path(..., description="Store code"),
|
store_code: str = Depends(get_resolved_store_code),
|
||||||
current_user: User = Depends(get_current_store_from_cookie_or_header),
|
current_user: User = Depends(get_current_store_from_cookie_or_header),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
@@ -122,13 +126,13 @@ async def store_content_page_create(
|
|||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{store_code}/content-pages/{page_id}/edit",
|
"/content-pages/{page_id}/edit",
|
||||||
response_class=HTMLResponse,
|
response_class=HTMLResponse,
|
||||||
include_in_schema=False,
|
include_in_schema=False,
|
||||||
)
|
)
|
||||||
async def store_content_page_edit(
|
async def store_content_page_edit(
|
||||||
request: Request,
|
request: Request,
|
||||||
store_code: str = Path(..., description="Store code"),
|
store_code: str = Depends(get_resolved_store_code),
|
||||||
page_id: int = Path(..., description="Content page ID"),
|
page_id: int = Path(..., description="Content page ID"),
|
||||||
current_user: User = Depends(get_current_store_from_cookie_or_header),
|
current_user: User = Depends(get_current_store_from_cookie_or_header),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
@@ -148,11 +152,11 @@ async def store_content_page_edit(
|
|||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{store_code}/{slug}", response_class=HTMLResponse, include_in_schema=False
|
"/{slug}", response_class=HTMLResponse, include_in_schema=False
|
||||||
)
|
)
|
||||||
async def store_content_page(
|
async def store_content_page(
|
||||||
request: Request,
|
request: Request,
|
||||||
store_code: str = Path(..., description="Store code"),
|
store_code: str = Depends(get_resolved_store_code),
|
||||||
slug: str = Path(..., description="Content page slug"),
|
slug: str = Path(..., description="Content page slug"),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -8,11 +8,15 @@ Store pages for core functionality:
|
|||||||
- Notifications
|
- Notifications
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Path, Request
|
from fastapi import APIRouter, Depends, Request
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.deps import get_current_store_from_cookie_or_header, get_db
|
from app.api.deps import (
|
||||||
|
get_current_store_from_cookie_or_header,
|
||||||
|
get_db,
|
||||||
|
get_resolved_store_code,
|
||||||
|
)
|
||||||
from app.modules.core.utils.page_context import get_store_context
|
from app.modules.core.utils.page_context import get_store_context
|
||||||
from app.modules.tenancy.models import User
|
from app.modules.tenancy.models import User
|
||||||
from app.templates_config import templates
|
from app.templates_config import templates
|
||||||
@@ -26,11 +30,11 @@ router = APIRouter()
|
|||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{store_code}/dashboard", response_class=HTMLResponse, include_in_schema=False
|
"/dashboard", response_class=HTMLResponse, include_in_schema=False
|
||||||
)
|
)
|
||||||
async def store_dashboard_page(
|
async def store_dashboard_page(
|
||||||
request: Request,
|
request: Request,
|
||||||
store_code: str = Path(..., description="Store code"),
|
store_code: str = Depends(get_resolved_store_code),
|
||||||
current_user: User = Depends(get_current_store_from_cookie_or_header),
|
current_user: User = Depends(get_current_store_from_cookie_or_header),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
@@ -55,11 +59,11 @@ async def store_dashboard_page(
|
|||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{store_code}/media", response_class=HTMLResponse, include_in_schema=False
|
"/media", response_class=HTMLResponse, include_in_schema=False
|
||||||
)
|
)
|
||||||
async def store_media_page(
|
async def store_media_page(
|
||||||
request: Request,
|
request: Request,
|
||||||
store_code: str = Path(..., description="Store code"),
|
store_code: str = Depends(get_resolved_store_code),
|
||||||
current_user: User = Depends(get_current_store_from_cookie_or_header),
|
current_user: User = Depends(get_current_store_from_cookie_or_header),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
@@ -79,13 +83,13 @@ async def store_media_page(
|
|||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{store_code}/notifications",
|
"/notifications",
|
||||||
response_class=HTMLResponse,
|
response_class=HTMLResponse,
|
||||||
include_in_schema=False,
|
include_in_schema=False,
|
||||||
)
|
)
|
||||||
async def store_notifications_page(
|
async def store_notifications_page(
|
||||||
request: Request,
|
request: Request,
|
||||||
store_code: str = Path(..., description="Store code"),
|
store_code: str = Depends(get_resolved_store_code),
|
||||||
current_user: User = Depends(get_current_store_from_cookie_or_header),
|
current_user: User = Depends(get_current_store_from_cookie_or_header),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -74,8 +74,9 @@ function data() {
|
|||||||
? segments[segments.length - 2]
|
? segments[segments.length - 2]
|
||||||
: last;
|
: last;
|
||||||
|
|
||||||
// Get store code from URL
|
// Get store code from server-rendered value or URL fallback
|
||||||
if (segments[0] === 'store' && segments[1]) {
|
this.storeCode = window.STORE_CODE || null;
|
||||||
|
if (!this.storeCode && segments[0] === 'store' && segments[1]) {
|
||||||
this.storeCode = segments[1];
|
this.storeCode = segments[1];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,14 +282,18 @@ function emailSettingsWarning() {
|
|||||||
storeCode: null,
|
storeCode: null,
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
// Get store code from URL
|
// Get store code from server-rendered value or URL fallback
|
||||||
const path = window.location.pathname;
|
this.storeCode = window.STORE_CODE || null;
|
||||||
const segments = path.split('/').filter(Boolean);
|
if (!this.storeCode) {
|
||||||
if (segments[0] === 'store' && segments[1]) {
|
const path = window.location.pathname;
|
||||||
this.storeCode = segments[1];
|
const segments = path.split('/').filter(Boolean);
|
||||||
|
if (segments[0] === 'store' && segments[1]) {
|
||||||
|
this.storeCode = segments[1];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip if we're on the settings page (to avoid showing banner on config page)
|
// Skip if we're on the settings page (to avoid showing banner on config page)
|
||||||
|
const path = window.location.pathname;
|
||||||
if (path.includes('/settings')) {
|
if (path.includes('/settings')) {
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -262,7 +262,7 @@ def forgot_password(request: Request, email: str, db: Session = Depends(get_db))
|
|||||||
|
|
||||||
scheme = "https" if should_use_secure_cookies() else "http"
|
scheme = "https" if should_use_secure_cookies() else "http"
|
||||||
host = request.headers.get("host", "localhost")
|
host = request.headers.get("host", "localhost")
|
||||||
reset_link = f"{scheme}://{host}/shop/account/reset-password?token={plaintext_token}"
|
reset_link = f"{scheme}://{host}/account/reset-password?token={plaintext_token}"
|
||||||
|
|
||||||
email_service = EmailService(db)
|
email_service = EmailService(db)
|
||||||
email_service.send_template(
|
email_service.send_template(
|
||||||
|
|||||||
@@ -6,11 +6,15 @@ Store pages for customer management:
|
|||||||
- Customers list
|
- Customers list
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Path, Request
|
from fastapi import APIRouter, Depends, Request
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.deps import get_current_store_from_cookie_or_header, get_db
|
from app.api.deps import (
|
||||||
|
get_current_store_from_cookie_or_header,
|
||||||
|
get_db,
|
||||||
|
get_resolved_store_code,
|
||||||
|
)
|
||||||
from app.modules.core.utils.page_context import get_store_context
|
from app.modules.core.utils.page_context import get_store_context
|
||||||
from app.modules.tenancy.models import User
|
from app.modules.tenancy.models import User
|
||||||
from app.templates_config import templates
|
from app.templates_config import templates
|
||||||
@@ -24,11 +28,11 @@ router = APIRouter()
|
|||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{store_code}/customers", response_class=HTMLResponse, include_in_schema=False
|
"/customers", response_class=HTMLResponse, include_in_schema=False
|
||||||
)
|
)
|
||||||
async def store_customers_page(
|
async def store_customers_page(
|
||||||
request: Request,
|
request: Request,
|
||||||
store_code: str = Path(..., description="Store code"),
|
store_code: str = Depends(get_resolved_store_code),
|
||||||
current_user: User = Depends(get_current_store_from_cookie_or_header),
|
current_user: User = Depends(get_current_store_from_cookie_or_header),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -6,11 +6,15 @@ Store pages for inventory management:
|
|||||||
- Inventory list
|
- Inventory list
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Path, Request
|
from fastapi import APIRouter, Depends, Request
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.deps import get_current_store_from_cookie_or_header, get_db
|
from app.api.deps import (
|
||||||
|
get_current_store_from_cookie_or_header,
|
||||||
|
get_db,
|
||||||
|
get_resolved_store_code,
|
||||||
|
)
|
||||||
from app.modules.core.utils.page_context import get_store_context
|
from app.modules.core.utils.page_context import get_store_context
|
||||||
from app.modules.tenancy.models import User
|
from app.modules.tenancy.models import User
|
||||||
from app.templates_config import templates
|
from app.templates_config import templates
|
||||||
@@ -24,11 +28,11 @@ router = APIRouter()
|
|||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{store_code}/inventory", response_class=HTMLResponse, include_in_schema=False
|
"/inventory", response_class=HTMLResponse, include_in_schema=False
|
||||||
)
|
)
|
||||||
async def store_inventory_page(
|
async def store_inventory_page(
|
||||||
request: Request,
|
request: Request,
|
||||||
store_code: str = Path(..., description="Store code"),
|
store_code: str = Depends(get_resolved_store_code),
|
||||||
current_user: User = Depends(get_current_store_from_cookie_or_header),
|
current_user: User = Depends(get_current_store_from_cookie_or_header),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ Store pages for:
|
|||||||
- Program settings
|
- Program settings
|
||||||
- Stats dashboard
|
- Stats dashboard
|
||||||
|
|
||||||
Routes follow the standard store convention: /{store_code}/loyalty/...
|
Routes follow the standard store convention: /loyalty/...
|
||||||
so they match the menu URLs in definition.py.
|
so they match the menu URLs in definition.py.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -18,7 +18,11 @@ from fastapi import APIRouter, Depends, Path, Request
|
|||||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.deps import get_current_store_from_cookie_or_header, get_db
|
from app.api.deps import (
|
||||||
|
get_current_store_from_cookie_or_header,
|
||||||
|
get_db,
|
||||||
|
get_resolved_store_code,
|
||||||
|
)
|
||||||
from app.modules.core.services.platform_settings_service import (
|
from app.modules.core.services.platform_settings_service import (
|
||||||
platform_settings_service,
|
platform_settings_service,
|
||||||
)
|
)
|
||||||
@@ -86,12 +90,12 @@ def get_store_context(
|
|||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{store_code}/loyalty",
|
"/loyalty",
|
||||||
response_class=RedirectResponse,
|
response_class=RedirectResponse,
|
||||||
include_in_schema=False,
|
include_in_schema=False,
|
||||||
)
|
)
|
||||||
async def store_loyalty_root(
|
async def store_loyalty_root(
|
||||||
store_code: str = Path(..., description="Store code"),
|
store_code: str = Depends(get_resolved_store_code),
|
||||||
current_user: User = Depends(get_current_store_from_cookie_or_header),
|
current_user: User = Depends(get_current_store_from_cookie_or_header),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
@@ -110,13 +114,13 @@ async def store_loyalty_root(
|
|||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{store_code}/loyalty/terminal",
|
"/loyalty/terminal",
|
||||||
response_class=HTMLResponse,
|
response_class=HTMLResponse,
|
||||||
include_in_schema=False,
|
include_in_schema=False,
|
||||||
)
|
)
|
||||||
async def store_loyalty_terminal(
|
async def store_loyalty_terminal(
|
||||||
request: Request,
|
request: Request,
|
||||||
store_code: str = Path(..., description="Store code"),
|
store_code: str = Depends(get_resolved_store_code),
|
||||||
current_user: User = Depends(get_current_store_from_cookie_or_header),
|
current_user: User = Depends(get_current_store_from_cookie_or_header),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
@@ -136,13 +140,13 @@ async def store_loyalty_terminal(
|
|||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{store_code}/loyalty/cards",
|
"/loyalty/cards",
|
||||||
response_class=HTMLResponse,
|
response_class=HTMLResponse,
|
||||||
include_in_schema=False,
|
include_in_schema=False,
|
||||||
)
|
)
|
||||||
async def store_loyalty_cards(
|
async def store_loyalty_cards(
|
||||||
request: Request,
|
request: Request,
|
||||||
store_code: str = Path(..., description="Store code"),
|
store_code: str = Depends(get_resolved_store_code),
|
||||||
current_user: User = Depends(get_current_store_from_cookie_or_header),
|
current_user: User = Depends(get_current_store_from_cookie_or_header),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
@@ -157,13 +161,13 @@ async def store_loyalty_cards(
|
|||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{store_code}/loyalty/cards/{card_id}",
|
"/loyalty/cards/{card_id}",
|
||||||
response_class=HTMLResponse,
|
response_class=HTMLResponse,
|
||||||
include_in_schema=False,
|
include_in_schema=False,
|
||||||
)
|
)
|
||||||
async def store_loyalty_card_detail(
|
async def store_loyalty_card_detail(
|
||||||
request: Request,
|
request: Request,
|
||||||
store_code: str = Path(..., description="Store code"),
|
store_code: str = Depends(get_resolved_store_code),
|
||||||
card_id: int = Path(..., description="Loyalty card ID"),
|
card_id: int = Path(..., description="Loyalty card ID"),
|
||||||
current_user: User = Depends(get_current_store_from_cookie_or_header),
|
current_user: User = Depends(get_current_store_from_cookie_or_header),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
@@ -184,13 +188,13 @@ async def store_loyalty_card_detail(
|
|||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{store_code}/loyalty/stats",
|
"/loyalty/stats",
|
||||||
response_class=HTMLResponse,
|
response_class=HTMLResponse,
|
||||||
include_in_schema=False,
|
include_in_schema=False,
|
||||||
)
|
)
|
||||||
async def store_loyalty_stats(
|
async def store_loyalty_stats(
|
||||||
request: Request,
|
request: Request,
|
||||||
store_code: str = Path(..., description="Store code"),
|
store_code: str = Depends(get_resolved_store_code),
|
||||||
current_user: User = Depends(get_current_store_from_cookie_or_header),
|
current_user: User = Depends(get_current_store_from_cookie_or_header),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
@@ -210,13 +214,13 @@ async def store_loyalty_stats(
|
|||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{store_code}/loyalty/enroll",
|
"/loyalty/enroll",
|
||||||
response_class=HTMLResponse,
|
response_class=HTMLResponse,
|
||||||
include_in_schema=False,
|
include_in_schema=False,
|
||||||
)
|
)
|
||||||
async def store_loyalty_enroll(
|
async def store_loyalty_enroll(
|
||||||
request: Request,
|
request: Request,
|
||||||
store_code: str = Path(..., description="Store code"),
|
store_code: str = Depends(get_resolved_store_code),
|
||||||
current_user: User = Depends(get_current_store_from_cookie_or_header),
|
current_user: User = Depends(get_current_store_from_cookie_or_header),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -8,11 +8,15 @@ Store pages for marketplace management:
|
|||||||
- Letzshop integration
|
- Letzshop integration
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Path, Request
|
from fastapi import APIRouter, Depends, Request
|
||||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.deps import get_current_store_from_cookie_or_header, get_db
|
from app.api.deps import (
|
||||||
|
get_current_store_from_cookie_or_header,
|
||||||
|
get_db,
|
||||||
|
get_resolved_store_code,
|
||||||
|
)
|
||||||
from app.modules.core.utils.page_context import get_store_context
|
from app.modules.core.utils.page_context import get_store_context
|
||||||
from app.modules.marketplace.services.onboarding_service import OnboardingService
|
from app.modules.marketplace.services.onboarding_service import OnboardingService
|
||||||
from app.modules.service import module_service
|
from app.modules.service import module_service
|
||||||
@@ -29,11 +33,11 @@ router = APIRouter()
|
|||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{store_code}/onboarding", response_class=HTMLResponse, include_in_schema=False
|
"/onboarding", response_class=HTMLResponse, include_in_schema=False
|
||||||
)
|
)
|
||||||
async def store_onboarding_page(
|
async def store_onboarding_page(
|
||||||
request: Request,
|
request: Request,
|
||||||
store_code: str = Path(..., description="Store code"),
|
store_code: str = Depends(get_resolved_store_code),
|
||||||
current_user: User = Depends(get_current_store_from_cookie_or_header),
|
current_user: User = Depends(get_current_store_from_cookie_or_header),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
@@ -80,11 +84,11 @@ async def store_onboarding_page(
|
|||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{store_code}/marketplace", response_class=HTMLResponse, include_in_schema=False
|
"/marketplace", response_class=HTMLResponse, include_in_schema=False
|
||||||
)
|
)
|
||||||
async def store_marketplace_page(
|
async def store_marketplace_page(
|
||||||
request: Request,
|
request: Request,
|
||||||
store_code: str = Path(..., description="Store code"),
|
store_code: str = Depends(get_resolved_store_code),
|
||||||
current_user: User = Depends(get_current_store_from_cookie_or_header),
|
current_user: User = Depends(get_current_store_from_cookie_or_header),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
@@ -112,11 +116,11 @@ async def store_marketplace_page(
|
|||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{store_code}/letzshop", response_class=HTMLResponse, include_in_schema=False
|
"/letzshop", response_class=HTMLResponse, include_in_schema=False
|
||||||
)
|
)
|
||||||
async def store_letzshop_page(
|
async def store_letzshop_page(
|
||||||
request: Request,
|
request: Request,
|
||||||
store_code: str = Path(..., description="Store code"),
|
store_code: str = Depends(get_resolved_store_code),
|
||||||
current_user: User = Depends(get_current_store_from_cookie_or_header),
|
current_user: User = Depends(get_current_store_from_cookie_or_header),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -615,10 +615,10 @@ function storeOnboarding(initialLang = 'en') {
|
|||||||
async handleLogout() {
|
async handleLogout() {
|
||||||
onboardingLog.info('Logging out from onboarding...');
|
onboardingLog.info('Logging out from onboarding...');
|
||||||
|
|
||||||
// Get store code from URL
|
// Get store code from server-rendered value or URL fallback
|
||||||
const path = window.location.pathname;
|
const path = window.location.pathname;
|
||||||
const segments = path.split('/').filter(Boolean);
|
const segments = path.split('/').filter(Boolean);
|
||||||
const storeCode = segments[0] === 'store' && segments[1] ? segments[1] : '';
|
const storeCode = window.STORE_CODE || (segments[0] === 'store' && segments[1] ? segments[1] : '');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Call logout API
|
// Call logout API
|
||||||
|
|||||||
@@ -12,7 +12,11 @@ from fastapi import APIRouter, Depends, Path, Request
|
|||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.deps import get_current_store_from_cookie_or_header, get_db
|
from app.api.deps import (
|
||||||
|
get_current_store_from_cookie_or_header,
|
||||||
|
get_db,
|
||||||
|
get_resolved_store_code,
|
||||||
|
)
|
||||||
from app.modules.core.utils.page_context import get_store_context
|
from app.modules.core.utils.page_context import get_store_context
|
||||||
from app.modules.tenancy.models import User
|
from app.modules.tenancy.models import User
|
||||||
from app.templates_config import templates
|
from app.templates_config import templates
|
||||||
@@ -26,11 +30,11 @@ router = APIRouter()
|
|||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{store_code}/messages", response_class=HTMLResponse, include_in_schema=False
|
"/messages", response_class=HTMLResponse, include_in_schema=False
|
||||||
)
|
)
|
||||||
async def store_messages_page(
|
async def store_messages_page(
|
||||||
request: Request,
|
request: Request,
|
||||||
store_code: str = Path(..., description="Store code"),
|
store_code: str = Depends(get_resolved_store_code),
|
||||||
current_user: User = Depends(get_current_store_from_cookie_or_header),
|
current_user: User = Depends(get_current_store_from_cookie_or_header),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
@@ -45,13 +49,13 @@ async def store_messages_page(
|
|||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{store_code}/messages/{conversation_id}",
|
"/messages/{conversation_id}",
|
||||||
response_class=HTMLResponse,
|
response_class=HTMLResponse,
|
||||||
include_in_schema=False,
|
include_in_schema=False,
|
||||||
)
|
)
|
||||||
async def store_message_detail_page(
|
async def store_message_detail_page(
|
||||||
request: Request,
|
request: Request,
|
||||||
store_code: str = Path(..., description="Store code"),
|
store_code: str = Depends(get_resolved_store_code),
|
||||||
conversation_id: int = Path(..., description="Conversation ID"),
|
conversation_id: int = Path(..., description="Conversation ID"),
|
||||||
current_user: User = Depends(get_current_store_from_cookie_or_header),
|
current_user: User = Depends(get_current_store_from_cookie_or_header),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
@@ -74,13 +78,13 @@ async def store_message_detail_page(
|
|||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{store_code}/email-templates",
|
"/email-templates",
|
||||||
response_class=HTMLResponse,
|
response_class=HTMLResponse,
|
||||||
include_in_schema=False,
|
include_in_schema=False,
|
||||||
)
|
)
|
||||||
async def store_email_templates_page(
|
async def store_email_templates_page(
|
||||||
request: Request,
|
request: Request,
|
||||||
store_code: str = Path(..., description="Store code"),
|
store_code: str = Depends(get_resolved_store_code),
|
||||||
current_user: User = Depends(get_current_store_from_cookie_or_header),
|
current_user: User = Depends(get_current_store_from_cookie_or_header),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -11,7 +11,11 @@ from fastapi import APIRouter, Depends, Path, Request
|
|||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.deps import get_current_store_from_cookie_or_header, get_db
|
from app.api.deps import (
|
||||||
|
get_current_store_from_cookie_or_header,
|
||||||
|
get_db,
|
||||||
|
get_resolved_store_code,
|
||||||
|
)
|
||||||
from app.modules.core.utils.page_context import get_store_context
|
from app.modules.core.utils.page_context import get_store_context
|
||||||
from app.modules.tenancy.models import User
|
from app.modules.tenancy.models import User
|
||||||
from app.templates_config import templates
|
from app.templates_config import templates
|
||||||
@@ -25,11 +29,11 @@ router = APIRouter()
|
|||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{store_code}/orders", response_class=HTMLResponse, include_in_schema=False
|
"/orders", response_class=HTMLResponse, include_in_schema=False
|
||||||
)
|
)
|
||||||
async def store_orders_page(
|
async def store_orders_page(
|
||||||
request: Request,
|
request: Request,
|
||||||
store_code: str = Path(..., description="Store code"),
|
store_code: str = Depends(get_resolved_store_code),
|
||||||
current_user: User = Depends(get_current_store_from_cookie_or_header),
|
current_user: User = Depends(get_current_store_from_cookie_or_header),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
@@ -44,13 +48,13 @@ async def store_orders_page(
|
|||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{store_code}/orders/{order_id}",
|
"/orders/{order_id}",
|
||||||
response_class=HTMLResponse,
|
response_class=HTMLResponse,
|
||||||
include_in_schema=False,
|
include_in_schema=False,
|
||||||
)
|
)
|
||||||
async def store_order_detail_page(
|
async def store_order_detail_page(
|
||||||
request: Request,
|
request: Request,
|
||||||
store_code: str = Path(..., description="Store code"),
|
store_code: str = Depends(get_resolved_store_code),
|
||||||
order_id: int = Path(..., description="Order ID"),
|
order_id: int = Path(..., description="Order ID"),
|
||||||
current_user: User = Depends(get_current_store_from_cookie_or_header),
|
current_user: User = Depends(get_current_store_from_cookie_or_header),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ Store pages for authentication and account management:
|
|||||||
- Settings
|
- Settings
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Path, Request
|
from fastapi import APIRouter, Depends, Request
|
||||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -18,6 +18,7 @@ from app.api.deps import (
|
|||||||
get_current_store_from_cookie_or_header,
|
get_current_store_from_cookie_or_header,
|
||||||
get_current_store_optional,
|
get_current_store_optional,
|
||||||
get_db,
|
get_db,
|
||||||
|
get_resolved_store_code,
|
||||||
)
|
)
|
||||||
from app.modules.core.utils.page_context import get_store_context
|
from app.modules.core.utils.page_context import get_store_context
|
||||||
from app.modules.tenancy.models import User
|
from app.modules.tenancy.models import User
|
||||||
@@ -31,24 +32,13 @@ router = APIRouter()
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{store_code}", response_class=RedirectResponse, include_in_schema=False)
|
@router.get("/", response_class=RedirectResponse, include_in_schema=False)
|
||||||
async def store_root_no_slash(store_code: str = Path(..., description="Store code")):
|
|
||||||
"""
|
|
||||||
Redirect /store/{code} (no trailing slash) to login page.
|
|
||||||
Handles requests without trailing slash.
|
|
||||||
"""
|
|
||||||
return RedirectResponse(url=f"/store/{store_code}/login", status_code=302)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/{store_code}/", response_class=RedirectResponse, include_in_schema=False
|
|
||||||
)
|
|
||||||
async def store_root(
|
async def store_root(
|
||||||
store_code: str = Path(..., description="Store code"),
|
store_code: str = Depends(get_resolved_store_code),
|
||||||
current_user: User | None = Depends(get_current_store_optional),
|
current_user: User | None = Depends(get_current_store_optional),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Redirect /store/{code}/ based on authentication status.
|
Redirect /store/ or /store/{code}/ based on authentication status.
|
||||||
|
|
||||||
- Authenticated store users -> /store/{code}/dashboard
|
- Authenticated store users -> /store/{code}/dashboard
|
||||||
- Unauthenticated users -> /store/{code}/login
|
- Unauthenticated users -> /store/{code}/login
|
||||||
@@ -62,11 +52,11 @@ async def store_root(
|
|||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{store_code}/login", response_class=HTMLResponse, include_in_schema=False
|
"/login", response_class=HTMLResponse, include_in_schema=False
|
||||||
)
|
)
|
||||||
async def store_login_page(
|
async def store_login_page(
|
||||||
request: Request,
|
request: Request,
|
||||||
store_code: str = Path(..., description="Store code"),
|
store_code: str = Depends(get_resolved_store_code),
|
||||||
current_user: User | None = Depends(get_current_store_optional),
|
current_user: User | None = Depends(get_current_store_optional),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
@@ -74,11 +64,6 @@ async def store_login_page(
|
|||||||
|
|
||||||
If user is already authenticated as store, redirect to dashboard.
|
If user is already authenticated as store, redirect to dashboard.
|
||||||
Otherwise, show login form.
|
Otherwise, show login form.
|
||||||
|
|
||||||
JavaScript will:
|
|
||||||
- Load store info via API
|
|
||||||
- Handle login form submission
|
|
||||||
- Redirect to dashboard on success
|
|
||||||
"""
|
"""
|
||||||
if current_user:
|
if current_user:
|
||||||
return RedirectResponse(
|
return RedirectResponse(
|
||||||
@@ -100,11 +85,11 @@ async def store_login_page(
|
|||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{store_code}/team", response_class=HTMLResponse, include_in_schema=False
|
"/team", response_class=HTMLResponse, include_in_schema=False
|
||||||
)
|
)
|
||||||
async def store_team_page(
|
async def store_team_page(
|
||||||
request: Request,
|
request: Request,
|
||||||
store_code: str = Path(..., description="Store code"),
|
store_code: str = Depends(get_resolved_store_code),
|
||||||
current_user: User = Depends(get_current_store_from_cookie_or_header),
|
current_user: User = Depends(get_current_store_from_cookie_or_header),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
@@ -119,11 +104,11 @@ async def store_team_page(
|
|||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{store_code}/profile", response_class=HTMLResponse, include_in_schema=False
|
"/profile", response_class=HTMLResponse, include_in_schema=False
|
||||||
)
|
)
|
||||||
async def store_profile_page(
|
async def store_profile_page(
|
||||||
request: Request,
|
request: Request,
|
||||||
store_code: str = Path(..., description="Store code"),
|
store_code: str = Depends(get_resolved_store_code),
|
||||||
current_user: User = Depends(get_current_store_from_cookie_or_header),
|
current_user: User = Depends(get_current_store_from_cookie_or_header),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
@@ -138,11 +123,11 @@ async def store_profile_page(
|
|||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{store_code}/settings", response_class=HTMLResponse, include_in_schema=False
|
"/settings", response_class=HTMLResponse, include_in_schema=False
|
||||||
)
|
)
|
||||||
async def store_settings_page(
|
async def store_settings_page(
|
||||||
request: Request,
|
request: Request,
|
||||||
store_code: str = Path(..., description="Store code"),
|
store_code: str = Depends(get_resolved_store_code),
|
||||||
current_user: User = Depends(get_current_store_from_cookie_or_header),
|
current_user: User = Depends(get_current_store_from_cookie_or_header),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -20,6 +20,13 @@ function adminStoreDetail() {
|
|||||||
showDeleteStoreModal: false,
|
showDeleteStoreModal: false,
|
||||||
showDeleteStoreFinalModal: false,
|
showDeleteStoreFinalModal: false,
|
||||||
|
|
||||||
|
// Domain management state
|
||||||
|
domains: [],
|
||||||
|
domainsLoading: false,
|
||||||
|
showAddDomainForm: false,
|
||||||
|
domainSaving: false,
|
||||||
|
newDomain: { domain: '', platform_id: '' },
|
||||||
|
|
||||||
// Initialize
|
// Initialize
|
||||||
async init() {
|
async init() {
|
||||||
// Load i18n translations
|
// Load i18n translations
|
||||||
@@ -42,9 +49,12 @@ function adminStoreDetail() {
|
|||||||
this.storeCode = match[1];
|
this.storeCode = match[1];
|
||||||
detailLog.info('Viewing store:', this.storeCode);
|
detailLog.info('Viewing store:', this.storeCode);
|
||||||
await this.loadStore();
|
await this.loadStore();
|
||||||
// Load subscription after store is loaded
|
// Load subscription and domains after store is loaded
|
||||||
if (this.store?.id) {
|
if (this.store?.id) {
|
||||||
await this.loadSubscriptions();
|
await Promise.all([
|
||||||
|
this.loadSubscriptions(),
|
||||||
|
this.loadDomains(),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
detailLog.error('No store code in URL');
|
detailLog.error('No store code in URL');
|
||||||
@@ -180,6 +190,90 @@ function adminStoreDetail() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ====================================================================
|
||||||
|
// DOMAIN MANAGEMENT
|
||||||
|
// ====================================================================
|
||||||
|
|
||||||
|
async loadDomains() {
|
||||||
|
if (!this.store?.id) return;
|
||||||
|
this.domainsLoading = true;
|
||||||
|
try {
|
||||||
|
const url = `/admin/stores/${this.store.id}/domains`;
|
||||||
|
const response = await apiClient.get(url);
|
||||||
|
this.domains = response.domains || [];
|
||||||
|
detailLog.info('Domains loaded:', this.domains.length);
|
||||||
|
} catch (error) {
|
||||||
|
if (error.status === 404) {
|
||||||
|
this.domains = [];
|
||||||
|
} else {
|
||||||
|
detailLog.warn('Failed to load domains:', error.message);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.domainsLoading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async addDomain() {
|
||||||
|
if (!this.newDomain.domain || this.domainSaving) return;
|
||||||
|
this.domainSaving = true;
|
||||||
|
try {
|
||||||
|
const payload = { domain: this.newDomain.domain };
|
||||||
|
if (this.newDomain.platform_id) {
|
||||||
|
payload.platform_id = parseInt(this.newDomain.platform_id);
|
||||||
|
}
|
||||||
|
await apiClient.post(`/admin/stores/${this.store.id}/domains`, payload);
|
||||||
|
Utils.showToast('Domain added successfully', 'success');
|
||||||
|
this.showAddDomainForm = false;
|
||||||
|
this.newDomain = { domain: '', platform_id: '' };
|
||||||
|
await this.loadDomains();
|
||||||
|
} catch (error) {
|
||||||
|
Utils.showToast(error.message || 'Failed to add domain', 'error');
|
||||||
|
} finally {
|
||||||
|
this.domainSaving = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async verifyDomain(domainId) {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post(`/admin/stores/domains/${domainId}/verify`);
|
||||||
|
Utils.showToast(response.message || 'Domain verified!', 'success');
|
||||||
|
await this.loadDomains();
|
||||||
|
} catch (error) {
|
||||||
|
Utils.showToast(error.message || 'Verification failed — check DNS records', 'error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async toggleDomainActive(domainId, activate) {
|
||||||
|
try {
|
||||||
|
await apiClient.put(`/admin/stores/domains/${domainId}`, { is_active: activate });
|
||||||
|
Utils.showToast(activate ? 'Domain activated' : 'Domain deactivated', 'success');
|
||||||
|
await this.loadDomains();
|
||||||
|
} catch (error) {
|
||||||
|
Utils.showToast(error.message || 'Failed to update domain', 'error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async setDomainPrimary(domainId) {
|
||||||
|
try {
|
||||||
|
await apiClient.put(`/admin/stores/domains/${domainId}`, { is_primary: true });
|
||||||
|
Utils.showToast('Domain set as primary', 'success');
|
||||||
|
await this.loadDomains();
|
||||||
|
} catch (error) {
|
||||||
|
Utils.showToast(error.message || 'Failed to set primary domain', 'error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteDomain(domainId, domainName) {
|
||||||
|
if (!confirm(`Delete domain "${domainName}"? This cannot be undone.`)) return;
|
||||||
|
try {
|
||||||
|
await apiClient.delete(`/admin/stores/domains/${domainId}`);
|
||||||
|
Utils.showToast('Domain deleted', 'success');
|
||||||
|
await this.loadDomains();
|
||||||
|
} catch (error) {
|
||||||
|
Utils.showToast(error.message || 'Failed to delete domain', 'error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// Refresh store data
|
// Refresh store data
|
||||||
async refresh() {
|
async refresh() {
|
||||||
detailLog.info('=== STORE REFRESH TRIGGERED ===');
|
detailLog.info('=== STORE REFRESH TRIGGERED ===');
|
||||||
|
|||||||
@@ -356,6 +356,142 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Custom Domains -->
|
||||||
|
<div class="px-4 py-3 mb-8 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||||
|
Custom Domains
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
@click="showAddDomainForm = !showAddDomainForm"
|
||||||
|
class="flex items-center px-3 py-1.5 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700">
|
||||||
|
<span x-html="$icon('plus', 'w-4 h-4 mr-1')"></span>
|
||||||
|
Add Domain
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add Domain Form -->
|
||||||
|
<div x-show="showAddDomainForm" x-transition class="mb-4 p-4 bg-gray-50 rounded-lg dark:bg-gray-700">
|
||||||
|
<div class="grid gap-4 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase mb-1">Domain</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
x-model="newDomain.domain"
|
||||||
|
placeholder="shop.example.com"
|
||||||
|
class="w-full px-3 py-2 text-sm border rounded-lg dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 focus:outline-none focus:ring-2 focus:ring-purple-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase mb-1">Platform</label>
|
||||||
|
<select
|
||||||
|
x-model="newDomain.platform_id"
|
||||||
|
class="w-full px-3 py-2 text-sm border rounded-lg dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 focus:outline-none focus:ring-2 focus:ring-purple-500">
|
||||||
|
<option value="">Select platform...</option>
|
||||||
|
<template x-for="entry in subscriptions" :key="entry.subscription?.id">
|
||||||
|
<option :value="entry.platform_id" x-text="entry.platform_name"></option>
|
||||||
|
</template>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end gap-2 mt-3">
|
||||||
|
<button
|
||||||
|
@click="showAddDomainForm = false; newDomain = {domain: '', platform_id: ''}"
|
||||||
|
class="px-3 py-1.5 text-sm font-medium text-gray-600 bg-gray-200 rounded-lg hover:bg-gray-300 dark:bg-gray-600 dark:text-gray-300 dark:hover:bg-gray-500">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="addDomain()"
|
||||||
|
:disabled="!newDomain.domain || domainSaving"
|
||||||
|
class="px-3 py-1.5 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||||
|
<span x-show="!domainSaving">Add Domain</span>
|
||||||
|
<span x-show="domainSaving">Adding...</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Domains Loading -->
|
||||||
|
<div x-show="domainsLoading" class="text-center py-4">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Loading domains...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Domains List -->
|
||||||
|
<div x-show="!domainsLoading && domains.length > 0" class="space-y-3">
|
||||||
|
<template x-for="domain in domains" :key="domain.id">
|
||||||
|
<div class="p-3 bg-gray-50 rounded-lg dark:bg-gray-700 flex items-center justify-between">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-center gap-2 mb-1">
|
||||||
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-200 truncate" x-text="domain.domain"></span>
|
||||||
|
<span x-show="domain.is_primary"
|
||||||
|
class="px-1.5 py-0.5 text-xs font-medium text-purple-800 bg-purple-100 rounded-full dark:bg-purple-900 dark:text-purple-300">
|
||||||
|
Primary
|
||||||
|
</span>
|
||||||
|
<span :class="domain.is_verified
|
||||||
|
? 'text-green-800 bg-green-100 dark:bg-green-900 dark:text-green-300'
|
||||||
|
: 'text-yellow-800 bg-yellow-100 dark:bg-yellow-900 dark:text-yellow-300'"
|
||||||
|
class="px-1.5 py-0.5 text-xs font-medium rounded-full"
|
||||||
|
x-text="domain.is_verified ? 'Verified' : 'Unverified'">
|
||||||
|
</span>
|
||||||
|
<span :class="domain.is_active
|
||||||
|
? 'text-green-800 bg-green-100 dark:bg-green-900 dark:text-green-300'
|
||||||
|
: 'text-red-800 bg-red-100 dark:bg-red-900 dark:text-red-300'"
|
||||||
|
class="px-1.5 py-0.5 text-xs font-medium rounded-full"
|
||||||
|
x-text="domain.is_active ? 'Active' : 'Inactive'">
|
||||||
|
</span>
|
||||||
|
<span x-show="domain.ssl_status === 'active'"
|
||||||
|
class="px-1.5 py-0.5 text-xs font-medium text-green-800 bg-green-100 rounded-full dark:bg-green-900 dark:text-green-300">
|
||||||
|
SSL
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<!-- Verification instructions (shown when unverified) -->
|
||||||
|
<div x-show="!domain.is_verified && domain.verification_token" class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Add TXT record: <code class="px-1 py-0.5 bg-gray-200 dark:bg-gray-600 rounded text-xs">_orion-verify.<span x-text="domain.domain"></span></code>
|
||||||
|
with value <code class="px-1 py-0.5 bg-gray-200 dark:bg-gray-600 rounded text-xs" x-text="domain.verification_token"></code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1 ml-3 flex-shrink-0">
|
||||||
|
<!-- Verify Button -->
|
||||||
|
<button
|
||||||
|
x-show="!domain.is_verified"
|
||||||
|
@click="verifyDomain(domain.id)"
|
||||||
|
class="px-2 py-1 text-xs font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400"
|
||||||
|
title="Verify DNS">
|
||||||
|
<span x-html="$icon('badge-check', 'w-4 h-4')"></span>
|
||||||
|
</button>
|
||||||
|
<!-- Toggle Active -->
|
||||||
|
<button
|
||||||
|
x-show="domain.is_verified"
|
||||||
|
@click="toggleDomainActive(domain.id, !domain.is_active)"
|
||||||
|
:class="domain.is_active ? 'text-yellow-600 hover:text-yellow-700' : 'text-green-600 hover:text-green-700'"
|
||||||
|
class="px-2 py-1 text-xs font-medium"
|
||||||
|
:title="domain.is_active ? 'Deactivate' : 'Activate'">
|
||||||
|
<span x-html="$icon(domain.is_active ? 'eye-off' : 'eye', 'w-4 h-4')"></span>
|
||||||
|
</button>
|
||||||
|
<!-- Set Primary -->
|
||||||
|
<button
|
||||||
|
x-show="domain.is_verified && !domain.is_primary"
|
||||||
|
@click="setDomainPrimary(domain.id)"
|
||||||
|
class="px-2 py-1 text-xs font-medium text-purple-600 hover:text-purple-700 dark:text-purple-400"
|
||||||
|
title="Set as primary">
|
||||||
|
<span x-html="$icon('star', 'w-4 h-4')"></span>
|
||||||
|
</button>
|
||||||
|
<!-- Delete -->
|
||||||
|
<button
|
||||||
|
@click="deleteDomain(domain.id, domain.domain)"
|
||||||
|
class="px-2 py-1 text-xs font-medium text-red-600 hover:text-red-700 dark:text-red-400"
|
||||||
|
title="Delete domain">
|
||||||
|
<span x-html="$icon('delete', 'w-4 h-4')"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- No Domains -->
|
||||||
|
<div x-show="!domainsLoading && domains.length === 0" class="text-center py-4">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">No custom domains configured.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- More Actions -->
|
<!-- More Actions -->
|
||||||
<div class="px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
<div class="px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||||
|
|||||||
@@ -56,6 +56,7 @@
|
|||||||
|
|
||||||
<!-- 1.5: Store Configuration (resolved via PlatformSettingsService) -->
|
<!-- 1.5: Store Configuration (resolved via PlatformSettingsService) -->
|
||||||
<script>
|
<script>
|
||||||
|
window.STORE_CODE = '{{ store_code | default("") }}';
|
||||||
window.STORE_CONFIG = {
|
window.STORE_CONFIG = {
|
||||||
locale: '{{ storefront_locale }}',
|
locale: '{{ storefront_locale }}',
|
||||||
currency: '{{ storefront_currency }}',
|
currency: '{{ storefront_currency }}',
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
|
|
||||||
<div class="action-buttons">
|
<div class="action-buttons">
|
||||||
<a href="javascript:history.back()" class="btn btn-primary">Go Back</a>
|
<a href="javascript:history.back()" class="btn btn-primary">Go Back</a>
|
||||||
<a href="/store/dashboard" class="btn btn-secondary">Dashboard</a>
|
<a href="/store/{{ store_code }}/dashboard" class="btn btn-secondary">Dashboard</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if show_debug %}
|
{% if show_debug %}
|
||||||
@@ -39,6 +39,6 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="support-link">
|
<div class="support-link">
|
||||||
Need help? <a href="/store/support">Contact Support</a>
|
Need help? <a href="/store/{{ store_code }}/support">Contact Support</a>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -14,8 +14,8 @@
|
|||||||
<div class="error-code">Error Code: {{ error_code }}</div>
|
<div class="error-code">Error Code: {{ error_code }}</div>
|
||||||
|
|
||||||
<div class="action-buttons">
|
<div class="action-buttons">
|
||||||
<a href="/store/login" class="btn btn-primary">Log In</a>
|
<a href="/store/{{ store_code }}/login" class="btn btn-primary">Log In</a>
|
||||||
<a href="/store/dashboard" class="btn btn-secondary">Dashboard</a>
|
<a href="/store/{{ store_code }}/dashboard" class="btn btn-secondary">Dashboard</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if show_debug %}
|
{% if show_debug %}
|
||||||
@@ -39,6 +39,6 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="support-link">
|
<div class="support-link">
|
||||||
Having login issues? <a href="/store/support">Contact Support</a>
|
Having login issues? <a href="/store/{{ store_code }}/support">Contact Support</a>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
<div class="error-code">Error Code: {{ error_code }}</div>
|
<div class="error-code">Error Code: {{ error_code }}</div>
|
||||||
|
|
||||||
<div class="action-buttons">
|
<div class="action-buttons">
|
||||||
<a href="/store/dashboard" class="btn btn-primary">Go to Dashboard</a>
|
<a href="/store/{{ store_code }}/dashboard" class="btn btn-primary">Go to Dashboard</a>
|
||||||
<a href="javascript:history.back()" class="btn btn-secondary">Go Back</a>
|
<a href="javascript:history.back()" class="btn btn-secondary">Go Back</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -39,6 +39,6 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="support-link">
|
<div class="support-link">
|
||||||
Need elevated permissions? <a href="/store/support">Contact Your Manager</a>
|
Need elevated permissions? <a href="/store/{{ store_code }}/support">Contact Your Manager</a>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
<div class="error-code">Error Code: {{ error_code }}</div>
|
<div class="error-code">Error Code: {{ error_code }}</div>
|
||||||
|
|
||||||
<div class="action-buttons">
|
<div class="action-buttons">
|
||||||
<a href="/store/dashboard" class="btn btn-primary">Go to Dashboard</a>
|
<a href="/store/{{ store_code }}/dashboard" class="btn btn-primary">Go to Dashboard</a>
|
||||||
<a href="javascript:history.back()" class="btn btn-secondary">Go Back</a>
|
<a href="javascript:history.back()" class="btn btn-secondary">Go Back</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -39,6 +39,6 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="support-link">
|
<div class="support-link">
|
||||||
Can't find what you're looking for? <a href="/store/support">Contact Support</a>
|
Can't find what you're looking for? <a href="/store/{{ store_code }}/support">Contact Support</a>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
|
|
||||||
<div class="action-buttons">
|
<div class="action-buttons">
|
||||||
<a href="javascript:history.back()" class="btn btn-primary">Go Back</a>
|
<a href="javascript:history.back()" class="btn btn-primary">Go Back</a>
|
||||||
<a href="/store/dashboard" class="btn btn-secondary">Dashboard</a>
|
<a href="/store/{{ store_code }}/dashboard" class="btn btn-secondary">Dashboard</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if show_debug %}
|
{% if show_debug %}
|
||||||
@@ -53,6 +53,6 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="support-link">
|
<div class="support-link">
|
||||||
Still having issues? <a href="/store/support">Contact Support</a>
|
Still having issues? <a href="/store/{{ store_code }}/support">Contact Support</a>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
|
|
||||||
<div class="action-buttons">
|
<div class="action-buttons">
|
||||||
<a href="javascript:location.reload()" class="btn btn-primary">Retry</a>
|
<a href="javascript:location.reload()" class="btn btn-primary">Retry</a>
|
||||||
<a href="/store/dashboard" class="btn btn-secondary">Dashboard</a>
|
<a href="/store/{{ store_code }}/dashboard" class="btn btn-secondary">Dashboard</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if show_debug %}
|
{% if show_debug %}
|
||||||
@@ -47,6 +47,6 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="support-link">
|
<div class="support-link">
|
||||||
Experiencing persistent rate limits? <a href="/store/support">Contact Support</a>
|
Experiencing persistent rate limits? <a href="/store/{{ store_code }}/support">Contact Support</a>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
<div class="error-code">Error Code: {{ error_code }}</div>
|
<div class="error-code">Error Code: {{ error_code }}</div>
|
||||||
|
|
||||||
<div class="action-buttons">
|
<div class="action-buttons">
|
||||||
<a href="/store/dashboard" class="btn btn-primary">Go to Dashboard</a>
|
<a href="/store/{{ store_code }}/dashboard" class="btn btn-primary">Go to Dashboard</a>
|
||||||
<a href="javascript:location.reload()" class="btn btn-secondary">Retry</a>
|
<a href="javascript:location.reload()" class="btn btn-secondary">Retry</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -39,6 +39,6 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="support-link">
|
<div class="support-link">
|
||||||
Issue persisting? <a href="/store/support">Report this error</a>
|
Issue persisting? <a href="/store/{{ store_code }}/support">Report this error</a>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
|
|
||||||
<div class="action-buttons">
|
<div class="action-buttons">
|
||||||
<a href="javascript:location.reload()" class="btn btn-primary">Retry</a>
|
<a href="javascript:location.reload()" class="btn btn-primary">Retry</a>
|
||||||
<a href="/store/dashboard" class="btn btn-secondary">Dashboard</a>
|
<a href="/store/{{ store_code }}/dashboard" class="btn btn-secondary">Dashboard</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if show_debug %}
|
{% if show_debug %}
|
||||||
@@ -39,6 +39,6 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="support-link">
|
<div class="support-link">
|
||||||
Service unavailable for extended period? <a href="/store/support">Check Status</a>
|
Service unavailable for extended period? <a href="/store/{{ store_code }}/support">Check Status</a>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -185,7 +185,7 @@
|
|||||||
|
|
||||||
<div class="action-buttons">
|
<div class="action-buttons">
|
||||||
{% block action_buttons %}
|
{% block action_buttons %}
|
||||||
<a href="/store/dashboard" class="btn btn-primary">Go to Dashboard</a>
|
<a href="/store/{{ store_code }}/dashboard" class="btn btn-primary">Go to Dashboard</a>
|
||||||
<a href="javascript:history.back()" class="btn btn-secondary">Go Back</a>
|
<a href="javascript:history.back()" class="btn btn-secondary">Go Back</a>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
@@ -214,10 +214,10 @@
|
|||||||
|
|
||||||
<div class="support-link">
|
<div class="support-link">
|
||||||
{% block support_link %}
|
{% block support_link %}
|
||||||
Need help? <a href="/store/support">Contact Store Support</a>
|
Need help? <a href="/store/{{ store_code }}/support">Contact Store Support</a>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
<div class="error-code">Error Code: {{ error_code }}</div>
|
<div class="error-code">Error Code: {{ error_code }}</div>
|
||||||
|
|
||||||
<div class="action-buttons">
|
<div class="action-buttons">
|
||||||
<a href="/store/dashboard" class="btn btn-primary">Go to Dashboard</a>
|
<a href="/store/{{ store_code }}/dashboard" class="btn btn-primary">Go to Dashboard</a>
|
||||||
<a href="javascript:history.back()" class="btn btn-secondary">Go Back</a>
|
<a href="javascript:history.back()" class="btn btn-secondary">Go Back</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -37,6 +37,6 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="support-link">
|
<div class="support-link">
|
||||||
Need assistance? <a href="/store/support">Contact Support</a>
|
Need assistance? <a href="/store/{{ store_code }}/support">Contact Support</a>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
18
main.py
18
main.py
@@ -417,13 +417,18 @@ for route_info in merchant_page_routes:
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
# STORE PAGES
|
# STORE PAGES
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
# Store dashboard pages - Register at TWO prefixes:
|
||||||
|
# 1. /store/* (for prod: subdomain/custom domain, after path rewrite by middleware)
|
||||||
|
# 2. /store/{store_code}/* (for dev: path-based, after /platforms/{code}/ strip)
|
||||||
logger.info("Auto-discovering store page routes...")
|
logger.info("Auto-discovering store page routes...")
|
||||||
store_page_routes = get_store_page_routes()
|
store_page_routes = get_store_page_routes()
|
||||||
logger.info(f" Found {len(store_page_routes)} store page route modules")
|
logger.info(f" Found {len(store_page_routes)} store page route modules")
|
||||||
|
|
||||||
|
# Mount 1: /store/* (production subdomain/custom domain — store resolved by middleware)
|
||||||
|
logger.info(" Registering store routes at /store/*")
|
||||||
for route_info in store_page_routes:
|
for route_info in store_page_routes:
|
||||||
prefix = f"/store{route_info.custom_prefix}" if route_info.custom_prefix else "/store"
|
prefix = f"/store{route_info.custom_prefix}" if route_info.custom_prefix else "/store"
|
||||||
logger.info(f" Registering {route_info.module_code} store pages at {prefix} (priority={route_info.priority})")
|
logger.info(f" - {route_info.module_code} at {prefix} (priority={route_info.priority})")
|
||||||
app.include_router(
|
app.include_router(
|
||||||
route_info.router,
|
route_info.router,
|
||||||
prefix=prefix,
|
prefix=prefix,
|
||||||
@@ -431,6 +436,17 @@ for route_info in store_page_routes:
|
|||||||
include_in_schema=route_info.include_in_schema,
|
include_in_schema=route_info.include_in_schema,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Mount 2: /store/{store_code}/* (dev path-based + production path-based demo/trial)
|
||||||
|
logger.info(" Registering store routes at /store/{store_code}/*")
|
||||||
|
for route_info in store_page_routes:
|
||||||
|
prefix = f"/store/{{store_code}}{route_info.custom_prefix}" if route_info.custom_prefix else "/store/{store_code}"
|
||||||
|
app.include_router(
|
||||||
|
route_info.router,
|
||||||
|
prefix=prefix,
|
||||||
|
tags=route_info.tags,
|
||||||
|
include_in_schema=False,
|
||||||
|
)
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# STOREFRONT PAGES (Customer Shop)
|
# STOREFRONT PAGES (Customer Shop)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -410,7 +410,7 @@ class PlatformContextMiddleware:
|
|||||||
_RESERVED = (
|
_RESERVED = (
|
||||||
"/store/", "/admin/", "/api/", "/static/",
|
"/store/", "/admin/", "/api/", "/static/",
|
||||||
"/storefront/", "/health", "/docs", "/redoc",
|
"/storefront/", "/health", "/docs", "/redoc",
|
||||||
"/media/", "/assets/",
|
"/media/", "/assets/", "/merchants/",
|
||||||
)
|
)
|
||||||
if not any(clean_path.startswith(p) for p in _RESERVED):
|
if not any(clean_path.startswith(p) for p in _RESERVED):
|
||||||
new_path = "/storefront" + clean_path
|
new_path = "/storefront" + clean_path
|
||||||
|
|||||||
@@ -185,6 +185,35 @@ class StoreContextManager:
|
|||||||
# Method 2 & 3: Subdomain or path-based lookup
|
# Method 2 & 3: Subdomain or path-based lookup
|
||||||
if "subdomain" in context:
|
if "subdomain" in context:
|
||||||
subdomain = context["subdomain"]
|
subdomain = context["subdomain"]
|
||||||
|
|
||||||
|
# 2a. Check StorePlatform.custom_subdomain (platform-specific override)
|
||||||
|
# e.g. acme-rewards.rewardflow.lu → StorePlatform with custom_subdomain="acme-rewards"
|
||||||
|
platform = context.get("_platform")
|
||||||
|
if platform and context.get("detection_method") == "subdomain":
|
||||||
|
from app.modules.tenancy.models.store_platform import StorePlatform
|
||||||
|
|
||||||
|
store_platform = (
|
||||||
|
db.query(StorePlatform)
|
||||||
|
.filter(
|
||||||
|
func.lower(StorePlatform.custom_subdomain) == subdomain.lower(),
|
||||||
|
StorePlatform.platform_id == platform.id,
|
||||||
|
StorePlatform.is_active.is_(True),
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if store_platform:
|
||||||
|
store = (
|
||||||
|
db.query(Store)
|
||||||
|
.filter(Store.id == store_platform.store_id, Store.is_active.is_(True))
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if store:
|
||||||
|
logger.info(
|
||||||
|
f"[OK] Store found via custom_subdomain: {subdomain} → {store.name} (platform={platform.code})"
|
||||||
|
)
|
||||||
|
return store
|
||||||
|
|
||||||
|
# 2b. Fallback to Store.subdomain (global default)
|
||||||
store = (
|
store = (
|
||||||
db.query(Store)
|
db.query(Store)
|
||||||
.filter(func.lower(Store.subdomain) == subdomain.lower())
|
.filter(func.lower(Store.subdomain) == subdomain.lower())
|
||||||
@@ -493,6 +522,12 @@ class StoreContextMiddleware(BaseHTTPMiddleware):
|
|||||||
store_context = StoreContextManager.detect_store_context(request)
|
store_context = StoreContextManager.detect_store_context(request)
|
||||||
|
|
||||||
if store_context:
|
if store_context:
|
||||||
|
# Pass platform from middleware state so subdomain lookup can check
|
||||||
|
# StorePlatform.custom_subdomain for platform-specific overrides
|
||||||
|
platform = getattr(request.state, "platform", None)
|
||||||
|
if platform:
|
||||||
|
store_context["_platform"] = platform
|
||||||
|
|
||||||
db_gen = get_db()
|
db_gen = get_db()
|
||||||
db = next(db_gen)
|
db = next(db_gen)
|
||||||
try:
|
try:
|
||||||
|
|||||||
119
tests/unit/api/test_get_resolved_store_code.py
Normal file
119
tests/unit/api/test_get_resolved_store_code.py
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
# tests/unit/api/test_get_resolved_store_code.py
|
||||||
|
"""
|
||||||
|
Unit tests for get_resolved_store_code dependency.
|
||||||
|
|
||||||
|
Tests the store code resolution logic that supports both:
|
||||||
|
- Path-based routing: /store/{store_code}/dashboard (store_code in URL)
|
||||||
|
- Subdomain/custom domain: /store/dashboard (store resolved by middleware)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, Mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from app.api.deps import get_resolved_store_code
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.stores
|
||||||
|
class TestGetResolvedStoreCode:
|
||||||
|
"""Test suite for get_resolved_store_code dependency."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_returns_store_code_from_path_params(self):
|
||||||
|
"""When store_code is in path params (path-based routing), return it."""
|
||||||
|
request = Mock()
|
||||||
|
request.path_params = {"store_code": "ACME"}
|
||||||
|
request.state = MagicMock()
|
||||||
|
|
||||||
|
result = await get_resolved_store_code(request)
|
||||||
|
|
||||||
|
assert result == "ACME"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_returns_store_code_from_middleware_state(self):
|
||||||
|
"""When no path param but middleware resolved store, return store.store_code."""
|
||||||
|
mock_store = Mock()
|
||||||
|
mock_store.store_code = "ORION"
|
||||||
|
|
||||||
|
request = Mock()
|
||||||
|
request.path_params = {}
|
||||||
|
request.state = MagicMock()
|
||||||
|
request.state.store = mock_store
|
||||||
|
|
||||||
|
result = await get_resolved_store_code(request)
|
||||||
|
|
||||||
|
assert result == "ORION"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_path_param_takes_priority_over_middleware(self):
|
||||||
|
"""Path param should be preferred even if middleware also resolved a store."""
|
||||||
|
mock_store = Mock()
|
||||||
|
mock_store.store_code = "MIDDLEWARE_STORE"
|
||||||
|
|
||||||
|
request = Mock()
|
||||||
|
request.path_params = {"store_code": "PATH_STORE"}
|
||||||
|
request.state = MagicMock()
|
||||||
|
request.state.store = mock_store
|
||||||
|
|
||||||
|
result = await get_resolved_store_code(request)
|
||||||
|
|
||||||
|
assert result == "PATH_STORE"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_raises_404_when_no_store_found(self):
|
||||||
|
"""When neither path param nor middleware provides store, raise 404."""
|
||||||
|
request = Mock()
|
||||||
|
request.path_params = {}
|
||||||
|
request.state = MagicMock()
|
||||||
|
request.state.store = None
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await get_resolved_store_code(request)
|
||||||
|
|
||||||
|
assert exc_info.value.status_code == 404
|
||||||
|
assert "Store not found" in str(exc_info.value.detail)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_empty_string_path_param_falls_through(self):
|
||||||
|
"""Empty string path param should fall through to middleware."""
|
||||||
|
mock_store = Mock()
|
||||||
|
mock_store.store_code = "FALLBACK"
|
||||||
|
|
||||||
|
request = Mock()
|
||||||
|
request.path_params = {"store_code": ""}
|
||||||
|
request.state = MagicMock()
|
||||||
|
request.state.store = mock_store
|
||||||
|
|
||||||
|
result = await get_resolved_store_code(request)
|
||||||
|
|
||||||
|
assert result == "FALLBACK"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_no_path_params_key_falls_through(self):
|
||||||
|
"""When path_params doesn't contain store_code, fall through to middleware."""
|
||||||
|
mock_store = Mock()
|
||||||
|
mock_store.store_code = "WIDGET"
|
||||||
|
|
||||||
|
request = Mock()
|
||||||
|
request.path_params = {"order_id": "123"}
|
||||||
|
request.state = MagicMock()
|
||||||
|
request.state.store = mock_store
|
||||||
|
|
||||||
|
result = await get_resolved_store_code(request)
|
||||||
|
|
||||||
|
assert result == "WIDGET"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_raises_404_when_no_state_store_attribute(self):
|
||||||
|
"""When request.state has no store attribute at all, raise 404."""
|
||||||
|
request = Mock()
|
||||||
|
request.path_params = {}
|
||||||
|
# Simulate missing attribute
|
||||||
|
request.state = MagicMock(spec=[])
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await get_resolved_store_code(request)
|
||||||
|
|
||||||
|
assert exc_info.value.status_code == 404
|
||||||
221
tests/unit/middleware/test_store_context_custom_subdomain.py
Normal file
221
tests/unit/middleware/test_store_context_custom_subdomain.py
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
# tests/unit/middleware/test_store_context_custom_subdomain.py
|
||||||
|
"""
|
||||||
|
Unit tests for StorePlatform.custom_subdomain resolution in StoreContextMiddleware.
|
||||||
|
|
||||||
|
Tests the platform-specific subdomain override that allows a single store
|
||||||
|
to have different subdomains on different platforms:
|
||||||
|
- acme.omsflow.lu → Store.subdomain="acme" (default, works for all platforms)
|
||||||
|
- acme-rewards.rewardflow.lu → StorePlatform.custom_subdomain="acme-rewards" on Loyalty platform
|
||||||
|
"""
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, Mock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from middleware.store_context import StoreContextManager
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.stores
|
||||||
|
class TestCustomSubdomainResolution:
|
||||||
|
"""Test StorePlatform.custom_subdomain lookup in get_store_from_context."""
|
||||||
|
|
||||||
|
def test_custom_subdomain_found_returns_store(self):
|
||||||
|
"""When custom_subdomain matches on the platform, return the store."""
|
||||||
|
mock_db = Mock(spec=Session)
|
||||||
|
mock_platform = Mock()
|
||||||
|
mock_platform.id = 2
|
||||||
|
mock_platform.code = "loyalty"
|
||||||
|
|
||||||
|
mock_store_platform = Mock()
|
||||||
|
mock_store_platform.store_id = 42
|
||||||
|
|
||||||
|
mock_store = Mock()
|
||||||
|
mock_store.id = 42
|
||||||
|
mock_store.is_active = True
|
||||||
|
mock_store.name = "Acme Corp"
|
||||||
|
|
||||||
|
# First query: StorePlatform lookup (custom_subdomain match)
|
||||||
|
# Second query: Store lookup by id
|
||||||
|
call_count = [0]
|
||||||
|
|
||||||
|
def side_effect_query(model):
|
||||||
|
result = MagicMock()
|
||||||
|
call_count[0] += 1
|
||||||
|
if call_count[0] == 1:
|
||||||
|
# StorePlatform query
|
||||||
|
result.filter.return_value.first.return_value = mock_store_platform
|
||||||
|
else:
|
||||||
|
# Store query
|
||||||
|
result.filter.return_value.first.return_value = mock_store
|
||||||
|
return result
|
||||||
|
|
||||||
|
mock_db.query.side_effect = side_effect_query
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"detection_method": "subdomain",
|
||||||
|
"subdomain": "acme-rewards",
|
||||||
|
"_platform": mock_platform,
|
||||||
|
}
|
||||||
|
|
||||||
|
store = StoreContextManager.get_store_from_context(mock_db, context)
|
||||||
|
|
||||||
|
assert store is mock_store
|
||||||
|
|
||||||
|
def test_custom_subdomain_not_found_falls_back_to_store_subdomain(self):
|
||||||
|
"""When custom_subdomain doesn't match, fall back to Store.subdomain."""
|
||||||
|
mock_db = Mock(spec=Session)
|
||||||
|
mock_platform = Mock()
|
||||||
|
mock_platform.id = 2
|
||||||
|
mock_platform.code = "loyalty"
|
||||||
|
|
||||||
|
mock_store = Mock()
|
||||||
|
mock_store.is_active = True
|
||||||
|
mock_store.name = "Acme Corp"
|
||||||
|
|
||||||
|
# Query sequence:
|
||||||
|
# 1. StorePlatform query → None (no custom_subdomain match)
|
||||||
|
# 2. Store query → mock_store (subdomain match)
|
||||||
|
call_count = [0]
|
||||||
|
|
||||||
|
def side_effect_query(model):
|
||||||
|
result = MagicMock()
|
||||||
|
call_count[0] += 1
|
||||||
|
if call_count[0] == 1:
|
||||||
|
# StorePlatform query → no match
|
||||||
|
result.filter.return_value.first.return_value = None
|
||||||
|
else:
|
||||||
|
# Store.subdomain fallback
|
||||||
|
result.filter.return_value.filter.return_value.first.return_value = mock_store
|
||||||
|
return result
|
||||||
|
|
||||||
|
mock_db.query.side_effect = side_effect_query
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"detection_method": "subdomain",
|
||||||
|
"subdomain": "acme",
|
||||||
|
"_platform": mock_platform,
|
||||||
|
}
|
||||||
|
|
||||||
|
store = StoreContextManager.get_store_from_context(mock_db, context)
|
||||||
|
|
||||||
|
assert store is mock_store
|
||||||
|
|
||||||
|
def test_no_platform_skips_custom_subdomain_lookup(self):
|
||||||
|
"""When no platform in context, skip custom_subdomain and use Store.subdomain."""
|
||||||
|
mock_db = Mock(spec=Session)
|
||||||
|
mock_store = Mock()
|
||||||
|
mock_store.is_active = True
|
||||||
|
|
||||||
|
mock_db.query.return_value.filter.return_value.filter.return_value.first.return_value = mock_store
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"detection_method": "subdomain",
|
||||||
|
"subdomain": "acme",
|
||||||
|
# No "_platform" key
|
||||||
|
}
|
||||||
|
|
||||||
|
store = StoreContextManager.get_store_from_context(mock_db, context)
|
||||||
|
|
||||||
|
assert store is mock_store
|
||||||
|
|
||||||
|
def test_path_based_detection_skips_custom_subdomain(self):
|
||||||
|
"""Path-based detection should not check custom_subdomain (only subdomain mode)."""
|
||||||
|
mock_db = Mock(spec=Session)
|
||||||
|
mock_platform = Mock()
|
||||||
|
mock_platform.id = 1
|
||||||
|
|
||||||
|
mock_store = Mock()
|
||||||
|
mock_store.is_active = True
|
||||||
|
|
||||||
|
mock_db.query.return_value.filter.return_value.filter.return_value.first.return_value = mock_store
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"detection_method": "path",
|
||||||
|
"subdomain": "acme",
|
||||||
|
"_platform": mock_platform,
|
||||||
|
}
|
||||||
|
|
||||||
|
store = StoreContextManager.get_store_from_context(mock_db, context)
|
||||||
|
|
||||||
|
assert store is mock_store
|
||||||
|
|
||||||
|
def test_custom_subdomain_store_inactive_returns_none(self):
|
||||||
|
"""When custom_subdomain matches but the store is inactive, fall back."""
|
||||||
|
mock_db = Mock(spec=Session)
|
||||||
|
mock_platform = Mock()
|
||||||
|
mock_platform.id = 2
|
||||||
|
mock_platform.code = "loyalty"
|
||||||
|
|
||||||
|
mock_store_platform = Mock()
|
||||||
|
mock_store_platform.store_id = 42
|
||||||
|
|
||||||
|
# Query sequence:
|
||||||
|
# 1. StorePlatform → match
|
||||||
|
# 2. Store by id → None (inactive)
|
||||||
|
# 3. Store by subdomain → None (fallback also fails)
|
||||||
|
call_count = [0]
|
||||||
|
|
||||||
|
def side_effect_query(model):
|
||||||
|
result = MagicMock()
|
||||||
|
call_count[0] += 1
|
||||||
|
if call_count[0] == 1:
|
||||||
|
# StorePlatform query → match
|
||||||
|
result.filter.return_value.first.return_value = mock_store_platform
|
||||||
|
elif call_count[0] == 2:
|
||||||
|
# Store by id → None (inactive, filtered out)
|
||||||
|
result.filter.return_value.first.return_value = None
|
||||||
|
else:
|
||||||
|
# Store.subdomain fallback → None
|
||||||
|
result.filter.return_value.filter.return_value.first.return_value = None
|
||||||
|
return result
|
||||||
|
|
||||||
|
mock_db.query.side_effect = side_effect_query
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"detection_method": "subdomain",
|
||||||
|
"subdomain": "acme-rewards",
|
||||||
|
"_platform": mock_platform,
|
||||||
|
}
|
||||||
|
|
||||||
|
store = StoreContextManager.get_store_from_context(mock_db, context)
|
||||||
|
|
||||||
|
assert store is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.middleware
|
||||||
|
class TestPlatformInjectionIntoStoreContext:
|
||||||
|
"""Test that platform is properly injected into store_context for subdomain lookup."""
|
||||||
|
|
||||||
|
def test_platform_injected_into_context_when_available(self):
|
||||||
|
"""StoreContextMiddleware should inject platform into store_context."""
|
||||||
|
# This tests the dispatch method's platform injection logic
|
||||||
|
# We verify that when request.state.platform is set, it gets added
|
||||||
|
# to the store_context dict before get_store_from_context is called
|
||||||
|
|
||||||
|
mock_platform = Mock()
|
||||||
|
mock_platform.id = 1
|
||||||
|
mock_platform.code = "oms"
|
||||||
|
|
||||||
|
mock_request = Mock()
|
||||||
|
mock_request.state = MagicMock()
|
||||||
|
mock_request.state.platform = mock_platform
|
||||||
|
mock_request.state.platform_clean_path = "/store/login"
|
||||||
|
mock_request.headers = {"host": "acme.omsflow.lu"}
|
||||||
|
mock_request.url = Mock(path="/store/login")
|
||||||
|
|
||||||
|
with patch("middleware.store_context.settings") as mock_settings:
|
||||||
|
mock_settings.platform_domain = "omsflow.lu"
|
||||||
|
|
||||||
|
context = StoreContextManager.detect_store_context(mock_request)
|
||||||
|
|
||||||
|
assert context is not None
|
||||||
|
assert context["detection_method"] == "subdomain"
|
||||||
|
assert context["subdomain"] == "acme"
|
||||||
|
|
||||||
|
# The middleware dispatch adds _platform after detect_store_context
|
||||||
|
# Verify the context is suitable for platform injection
|
||||||
|
context["_platform"] = mock_platform
|
||||||
|
assert context.get("_platform") is mock_platform
|
||||||
Reference in New Issue
Block a user