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:
@@ -2,6 +2,15 @@
|
||||
"""
|
||||
Tenancy module API routes.
|
||||
|
||||
Admin routes:
|
||||
- /auth/* - Admin authentication (login, logout, /me, platform selection)
|
||||
- /admin-users/* - Admin user management
|
||||
- /users/* - Platform user management
|
||||
- /companies/* - Company management
|
||||
- /platforms/* - Platform management
|
||||
- /vendors/* - Vendor management
|
||||
- /vendor-domains/* - Vendor domain configuration
|
||||
|
||||
Vendor routes:
|
||||
- /info/{vendor_code} - Public vendor info lookup
|
||||
- /auth/* - Vendor authentication (login, logout, /me)
|
||||
@@ -9,12 +18,14 @@ Vendor routes:
|
||||
- /team/* - Team member management, roles, permissions
|
||||
"""
|
||||
|
||||
from .admin import admin_router
|
||||
from .vendor import vendor_router
|
||||
from .vendor_auth import vendor_auth_router
|
||||
from .vendor_profile import vendor_profile_router
|
||||
from .vendor_team import vendor_team_router
|
||||
|
||||
__all__ = [
|
||||
"admin_router",
|
||||
"vendor_router",
|
||||
"vendor_auth_router",
|
||||
"vendor_profile_router",
|
||||
|
||||
36
app/modules/tenancy/routes/api/admin.py
Normal file
36
app/modules/tenancy/routes/api/admin.py
Normal file
@@ -0,0 +1,36 @@
|
||||
# app/modules/tenancy/routes/api/admin.py
|
||||
"""
|
||||
Tenancy module admin API routes.
|
||||
|
||||
Aggregates all admin tenancy routes:
|
||||
- /auth/* - Admin authentication (login, logout, /me, platform selection)
|
||||
- /admin-users/* - Admin user management (super admin only)
|
||||
- /users/* - Platform user management
|
||||
- /companies/* - Company management
|
||||
- /platforms/* - Platform management (super admin only)
|
||||
- /vendors/* - Vendor management
|
||||
- /vendor-domains/* - Vendor domain configuration
|
||||
|
||||
The tenancy module owns identity and organizational hierarchy.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from .admin_auth import admin_auth_router
|
||||
from .admin_users import admin_users_router
|
||||
from .admin_platform_users import admin_platform_users_router
|
||||
from .admin_companies import admin_companies_router
|
||||
from .admin_platforms import admin_platforms_router
|
||||
from .admin_vendors import admin_vendors_router
|
||||
from .admin_vendor_domains import admin_vendor_domains_router
|
||||
|
||||
admin_router = APIRouter()
|
||||
|
||||
# Aggregate all tenancy admin routes
|
||||
admin_router.include_router(admin_auth_router, tags=["admin-auth"])
|
||||
admin_router.include_router(admin_users_router, tags=["admin-admin-users"])
|
||||
admin_router.include_router(admin_platform_users_router, tags=["admin-users"])
|
||||
admin_router.include_router(admin_companies_router, tags=["admin-companies"])
|
||||
admin_router.include_router(admin_platforms_router, tags=["admin-platforms"])
|
||||
admin_router.include_router(admin_vendors_router, tags=["admin-vendors"])
|
||||
admin_router.include_router(admin_vendor_domains_router, tags=["admin-vendor-domains"])
|
||||
221
app/modules/tenancy/routes/api/admin_auth.py
Normal file
221
app/modules/tenancy/routes/api/admin_auth.py
Normal file
@@ -0,0 +1,221 @@
|
||||
# app/modules/tenancy/routes/api/admin_auth.py
|
||||
"""
|
||||
Admin authentication endpoints.
|
||||
|
||||
Implements dual token storage with path restriction:
|
||||
- Sets HTTP-only cookie with path=/admin (restricted to admin routes only)
|
||||
- Returns token in response for localStorage (API calls)
|
||||
|
||||
This prevents admin cookies from being sent to vendor routes.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, Response
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_admin_api, get_current_admin_from_cookie_or_header
|
||||
from app.core.database import get_db
|
||||
from app.core.environment import should_use_secure_cookies
|
||||
from app.modules.tenancy.exceptions import InsufficientPermissionsException, InvalidCredentialsException
|
||||
from app.modules.tenancy.services.admin_platform_service import admin_platform_service
|
||||
from app.modules.core.services.auth_service import auth_service
|
||||
from middleware.auth import AuthManager
|
||||
from models.database.platform import Platform # noqa: API-007 - Admin needs to query platforms
|
||||
from models.schema.auth import UserContext
|
||||
from models.schema.auth import LoginResponse, LogoutResponse, UserLogin, UserResponse
|
||||
|
||||
admin_auth_router = APIRouter(prefix="/auth")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@admin_auth_router.post("/login", response_model=LoginResponse)
|
||||
def admin_login(
|
||||
user_credentials: UserLogin, response: Response, db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Admin login endpoint.
|
||||
|
||||
Only allows users with 'admin' role to login.
|
||||
Returns JWT token for authenticated admin users.
|
||||
|
||||
Sets token in two places:
|
||||
1. HTTP-only cookie with path=/admin (for browser page navigation)
|
||||
2. Response body (for localStorage and API calls)
|
||||
|
||||
The cookie is restricted to /admin/* routes only to prevent
|
||||
it from being sent to vendor or other routes.
|
||||
"""
|
||||
# Authenticate user
|
||||
login_result = auth_service.login_user(db=db, user_credentials=user_credentials)
|
||||
|
||||
# Verify user is admin
|
||||
if login_result["user"].role != "admin":
|
||||
logger.warning(
|
||||
f"Non-admin user attempted admin login: {user_credentials.email_or_username}"
|
||||
)
|
||||
raise InvalidCredentialsException("Admin access required")
|
||||
|
||||
logger.info(f"Admin login successful: {login_result['user'].username}")
|
||||
|
||||
# Set HTTP-only cookie for browser navigation
|
||||
# CRITICAL: path=/admin restricts cookie to admin routes only
|
||||
response.set_cookie(
|
||||
key="admin_token",
|
||||
value=login_result["token_data"]["access_token"],
|
||||
httponly=True, # JavaScript cannot access (XSS protection)
|
||||
secure=should_use_secure_cookies(), # HTTPS only in production/staging
|
||||
samesite="lax", # CSRF protection
|
||||
max_age=login_result["token_data"]["expires_in"], # Match JWT expiry
|
||||
path="/admin", # RESTRICTED TO ADMIN ROUTES ONLY
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"Set admin_token cookie with {login_result['token_data']['expires_in']}s expiry "
|
||||
f"(path=/admin, httponly=True, secure={should_use_secure_cookies()})"
|
||||
)
|
||||
|
||||
# Also return token in response for localStorage (API calls)
|
||||
return LoginResponse(
|
||||
access_token=login_result["token_data"]["access_token"],
|
||||
token_type=login_result["token_data"]["token_type"],
|
||||
expires_in=login_result["token_data"]["expires_in"],
|
||||
user=login_result["user"],
|
||||
)
|
||||
|
||||
|
||||
@admin_auth_router.get("/me", response_model=UserResponse)
|
||||
def get_current_admin(current_user: UserContext = Depends(get_current_admin_api)):
|
||||
"""
|
||||
Get current authenticated admin user.
|
||||
|
||||
This endpoint validates the token and ensures the user has admin privileges.
|
||||
Returns the current user's information.
|
||||
|
||||
Token can come from:
|
||||
- Authorization header (API calls)
|
||||
- admin_token cookie (browser navigation, path=/admin only)
|
||||
"""
|
||||
logger.info(f"Admin user info requested: {current_user.username}")
|
||||
return current_user
|
||||
|
||||
|
||||
@admin_auth_router.post("/logout", response_model=LogoutResponse)
|
||||
def admin_logout(response: Response):
|
||||
"""
|
||||
Admin logout endpoint.
|
||||
|
||||
Clears the admin_token cookie.
|
||||
Client should also remove token from localStorage.
|
||||
"""
|
||||
logger.info("Admin logout")
|
||||
|
||||
# Clear the cookie (must match path used when setting)
|
||||
response.delete_cookie(
|
||||
key="admin_token",
|
||||
path="/admin",
|
||||
)
|
||||
|
||||
# Also clear legacy cookie with path=/ (from before path isolation was added)
|
||||
# This handles users who logged in before the path=/admin change
|
||||
response.delete_cookie(
|
||||
key="admin_token",
|
||||
path="/",
|
||||
)
|
||||
|
||||
logger.debug("Deleted admin_token cookies (both /admin and / paths)")
|
||||
|
||||
return LogoutResponse(message="Logged out successfully")
|
||||
|
||||
|
||||
@admin_auth_router.get("/accessible-platforms")
|
||||
def get_accessible_platforms(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: UserContext = Depends(get_current_admin_from_cookie_or_header),
|
||||
):
|
||||
"""
|
||||
Get list of platforms this admin can access.
|
||||
|
||||
Returns:
|
||||
- For super admins: All active platforms
|
||||
- For platform admins: Only assigned platforms
|
||||
"""
|
||||
if current_user.is_super_admin:
|
||||
platforms = admin_platform_service.get_all_active_platforms(db)
|
||||
else:
|
||||
platforms = admin_platform_service.get_platforms_for_admin(db, current_user.id)
|
||||
|
||||
return {
|
||||
"platforms": [
|
||||
{
|
||||
"id": p.id,
|
||||
"code": p.code,
|
||||
"name": p.name,
|
||||
"logo": p.logo,
|
||||
}
|
||||
for p in platforms
|
||||
],
|
||||
"is_super_admin": current_user.is_super_admin,
|
||||
"requires_platform_selection": not current_user.is_super_admin and len(platforms) > 0,
|
||||
}
|
||||
|
||||
|
||||
@admin_auth_router.post("/select-platform")
|
||||
def select_platform(
|
||||
platform_id: int,
|
||||
response: Response,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: UserContext = Depends(get_current_admin_from_cookie_or_header),
|
||||
):
|
||||
"""
|
||||
Select platform context for platform admin.
|
||||
|
||||
Issues a new JWT token with platform context.
|
||||
Super admins skip this step (they have global access).
|
||||
|
||||
Args:
|
||||
platform_id: Platform ID to select
|
||||
|
||||
Returns:
|
||||
LoginResponse with new token containing platform context
|
||||
"""
|
||||
if current_user.is_super_admin:
|
||||
raise InvalidCredentialsException(
|
||||
"Super admins don't need platform selection - they have global access"
|
||||
)
|
||||
|
||||
# Verify admin has access to this platform (raises exception if not)
|
||||
admin_platform_service.validate_admin_platform_access(current_user, platform_id)
|
||||
|
||||
# Load platform
|
||||
platform = admin_platform_service.get_platform_by_id(db, platform_id)
|
||||
if not platform:
|
||||
raise InvalidCredentialsException("Platform not found")
|
||||
|
||||
# Issue new token with platform context
|
||||
auth_manager = AuthManager()
|
||||
token_data = auth_manager.create_access_token(
|
||||
user=current_user,
|
||||
platform_id=platform.id,
|
||||
platform_code=platform.code,
|
||||
)
|
||||
|
||||
# Set cookie with new token
|
||||
response.set_cookie(
|
||||
key="admin_token",
|
||||
value=token_data["access_token"],
|
||||
httponly=True,
|
||||
secure=should_use_secure_cookies(),
|
||||
samesite="lax",
|
||||
max_age=token_data["expires_in"],
|
||||
path="/admin",
|
||||
)
|
||||
|
||||
logger.info(f"Admin {current_user.username} selected platform {platform.code}")
|
||||
|
||||
return LoginResponse(
|
||||
access_token=token_data["access_token"],
|
||||
token_type=token_data["token_type"],
|
||||
expires_in=token_data["expires_in"],
|
||||
user=current_user,
|
||||
)
|
||||
365
app/modules/tenancy/routes/api/admin_companies.py
Normal file
365
app/modules/tenancy/routes/api/admin_companies.py
Normal file
@@ -0,0 +1,365 @@
|
||||
# app/modules/tenancy/routes/api/admin_companies.py
|
||||
"""
|
||||
Company management endpoints for admin.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, Path, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_admin_api
|
||||
from app.core.database import get_db
|
||||
from app.modules.tenancy.exceptions import CompanyHasVendorsException, ConfirmationRequiredException
|
||||
from app.modules.tenancy.services.company_service import company_service
|
||||
from models.schema.auth import UserContext
|
||||
from models.schema.company import (
|
||||
CompanyCreate,
|
||||
CompanyCreateResponse,
|
||||
CompanyDetailResponse,
|
||||
CompanyListResponse,
|
||||
CompanyResponse,
|
||||
CompanyTransferOwnership,
|
||||
CompanyTransferOwnershipResponse,
|
||||
CompanyUpdate,
|
||||
)
|
||||
|
||||
admin_companies_router = APIRouter(prefix="/companies")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@admin_companies_router.post("", response_model=CompanyCreateResponse)
|
||||
def create_company_with_owner(
|
||||
company_data: CompanyCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Create a new company with owner user account (Admin only).
|
||||
|
||||
This endpoint:
|
||||
1. Creates a new company record
|
||||
2. Creates an owner user account with owner_email (if not exists)
|
||||
3. Returns credentials (temporary password shown ONCE if new user created)
|
||||
|
||||
**Email Fields:**
|
||||
- `owner_email`: Used for owner's login/authentication (stored in users.email)
|
||||
- `contact_email`: Public business contact (stored in companies.contact_email)
|
||||
|
||||
Returns company details with owner credentials.
|
||||
"""
|
||||
company, owner_user, temp_password = company_service.create_company_with_owner(
|
||||
db, company_data
|
||||
)
|
||||
|
||||
db.commit() # ✅ ARCH: Commit at API level for transaction control
|
||||
|
||||
return CompanyCreateResponse(
|
||||
company=CompanyResponse(
|
||||
id=company.id,
|
||||
name=company.name,
|
||||
description=company.description,
|
||||
owner_user_id=company.owner_user_id,
|
||||
contact_email=company.contact_email,
|
||||
contact_phone=company.contact_phone,
|
||||
website=company.website,
|
||||
business_address=company.business_address,
|
||||
tax_number=company.tax_number,
|
||||
is_active=company.is_active,
|
||||
is_verified=company.is_verified,
|
||||
created_at=company.created_at.isoformat(),
|
||||
updated_at=company.updated_at.isoformat(),
|
||||
),
|
||||
owner_user_id=owner_user.id,
|
||||
owner_username=owner_user.username,
|
||||
owner_email=owner_user.email,
|
||||
temporary_password=temp_password or "N/A (Existing user)",
|
||||
login_url="http://localhost:8000/admin/login",
|
||||
)
|
||||
|
||||
|
||||
@admin_companies_router.get("", response_model=CompanyListResponse)
|
||||
def get_all_companies(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
search: str | None = Query(None, description="Search by company name"),
|
||||
is_active: bool | None = Query(None),
|
||||
is_verified: bool | None = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Get all companies with filtering (Admin only)."""
|
||||
companies, total = company_service.get_companies(
|
||||
db,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
search=search,
|
||||
is_active=is_active,
|
||||
is_verified=is_verified,
|
||||
)
|
||||
|
||||
return CompanyListResponse(
|
||||
companies=[
|
||||
CompanyResponse(
|
||||
id=c.id,
|
||||
name=c.name,
|
||||
description=c.description,
|
||||
owner_user_id=c.owner_user_id,
|
||||
contact_email=c.contact_email,
|
||||
contact_phone=c.contact_phone,
|
||||
website=c.website,
|
||||
business_address=c.business_address,
|
||||
tax_number=c.tax_number,
|
||||
is_active=c.is_active,
|
||||
is_verified=c.is_verified,
|
||||
created_at=c.created_at.isoformat(),
|
||||
updated_at=c.updated_at.isoformat(),
|
||||
)
|
||||
for c in companies
|
||||
],
|
||||
total=total,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
@admin_companies_router.get("/{company_id}", response_model=CompanyDetailResponse)
|
||||
def get_company_details(
|
||||
company_id: int = Path(..., description="Company ID"),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Get detailed company information including vendor counts (Admin only).
|
||||
"""
|
||||
company = company_service.get_company_by_id(db, company_id)
|
||||
|
||||
# Count vendors
|
||||
vendor_count = len(company.vendors)
|
||||
active_vendor_count = sum(1 for v in company.vendors if v.is_active)
|
||||
|
||||
# Build vendors list for detail view
|
||||
vendors_list = [
|
||||
{
|
||||
"id": v.id,
|
||||
"vendor_code": v.vendor_code,
|
||||
"name": v.name,
|
||||
"subdomain": v.subdomain,
|
||||
"is_active": v.is_active,
|
||||
"is_verified": v.is_verified,
|
||||
}
|
||||
for v in company.vendors
|
||||
]
|
||||
|
||||
return CompanyDetailResponse(
|
||||
id=company.id,
|
||||
name=company.name,
|
||||
description=company.description,
|
||||
owner_user_id=company.owner_user_id,
|
||||
owner_email=company.owner.email if company.owner else None,
|
||||
owner_username=company.owner.username if company.owner else None,
|
||||
contact_email=company.contact_email,
|
||||
contact_phone=company.contact_phone,
|
||||
website=company.website,
|
||||
business_address=company.business_address,
|
||||
tax_number=company.tax_number,
|
||||
is_active=company.is_active,
|
||||
is_verified=company.is_verified,
|
||||
created_at=company.created_at.isoformat(),
|
||||
updated_at=company.updated_at.isoformat(),
|
||||
vendor_count=vendor_count,
|
||||
active_vendor_count=active_vendor_count,
|
||||
vendors=vendors_list,
|
||||
)
|
||||
|
||||
|
||||
@admin_companies_router.put("/{company_id}", response_model=CompanyResponse)
|
||||
def update_company(
|
||||
company_id: int = Path(..., description="Company ID"),
|
||||
company_update: CompanyUpdate = Body(...),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Update company information (Admin only).
|
||||
|
||||
**Can update:**
|
||||
- Basic info: name, description
|
||||
- Business contact: contact_email, contact_phone, website
|
||||
- Business details: business_address, tax_number
|
||||
- Status: is_active, is_verified
|
||||
|
||||
**Cannot update:**
|
||||
- `owner_user_id` (would require ownership transfer feature)
|
||||
"""
|
||||
company = company_service.update_company(db, company_id, company_update)
|
||||
db.commit() # ✅ ARCH: Commit at API level for transaction control
|
||||
|
||||
return CompanyResponse(
|
||||
id=company.id,
|
||||
name=company.name,
|
||||
description=company.description,
|
||||
owner_user_id=company.owner_user_id,
|
||||
contact_email=company.contact_email,
|
||||
contact_phone=company.contact_phone,
|
||||
website=company.website,
|
||||
business_address=company.business_address,
|
||||
tax_number=company.tax_number,
|
||||
is_active=company.is_active,
|
||||
is_verified=company.is_verified,
|
||||
created_at=company.created_at.isoformat(),
|
||||
updated_at=company.updated_at.isoformat(),
|
||||
)
|
||||
|
||||
|
||||
@admin_companies_router.put("/{company_id}/verification", response_model=CompanyResponse)
|
||||
def toggle_company_verification(
|
||||
company_id: int = Path(..., description="Company ID"),
|
||||
verification_data: dict = Body(..., example={"is_verified": True}),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Toggle company verification status (Admin only).
|
||||
|
||||
Request body: { "is_verified": true/false }
|
||||
"""
|
||||
is_verified = verification_data.get("is_verified", False)
|
||||
company = company_service.toggle_verification(db, company_id, is_verified)
|
||||
db.commit() # ✅ ARCH: Commit at API level for transaction control
|
||||
|
||||
return CompanyResponse(
|
||||
id=company.id,
|
||||
name=company.name,
|
||||
description=company.description,
|
||||
owner_user_id=company.owner_user_id,
|
||||
contact_email=company.contact_email,
|
||||
contact_phone=company.contact_phone,
|
||||
website=company.website,
|
||||
business_address=company.business_address,
|
||||
tax_number=company.tax_number,
|
||||
is_active=company.is_active,
|
||||
is_verified=company.is_verified,
|
||||
created_at=company.created_at.isoformat(),
|
||||
updated_at=company.updated_at.isoformat(),
|
||||
)
|
||||
|
||||
|
||||
@admin_companies_router.put("/{company_id}/status", response_model=CompanyResponse)
|
||||
def toggle_company_status(
|
||||
company_id: int = Path(..., description="Company ID"),
|
||||
status_data: dict = Body(..., example={"is_active": True}),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Toggle company active status (Admin only).
|
||||
|
||||
Request body: { "is_active": true/false }
|
||||
"""
|
||||
is_active = status_data.get("is_active", True)
|
||||
company = company_service.toggle_active(db, company_id, is_active)
|
||||
db.commit() # ✅ ARCH: Commit at API level for transaction control
|
||||
|
||||
return CompanyResponse(
|
||||
id=company.id,
|
||||
name=company.name,
|
||||
description=company.description,
|
||||
owner_user_id=company.owner_user_id,
|
||||
contact_email=company.contact_email,
|
||||
contact_phone=company.contact_phone,
|
||||
website=company.website,
|
||||
business_address=company.business_address,
|
||||
tax_number=company.tax_number,
|
||||
is_active=company.is_active,
|
||||
is_verified=company.is_verified,
|
||||
created_at=company.created_at.isoformat(),
|
||||
updated_at=company.updated_at.isoformat(),
|
||||
)
|
||||
|
||||
|
||||
@admin_companies_router.post(
|
||||
"/{company_id}/transfer-ownership",
|
||||
response_model=CompanyTransferOwnershipResponse,
|
||||
)
|
||||
def transfer_company_ownership(
|
||||
company_id: int = Path(..., description="Company ID"),
|
||||
transfer_data: CompanyTransferOwnership = Body(...),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Transfer company ownership to another user (Admin only).
|
||||
|
||||
**This is a critical operation that:**
|
||||
- Changes the company's owner_user_id
|
||||
- Updates all associated vendors' owner_user_id
|
||||
- Creates audit trail
|
||||
|
||||
⚠️ **This action is logged and should be used carefully.**
|
||||
|
||||
**Requires:**
|
||||
- `new_owner_user_id`: ID of user who will become owner
|
||||
- `confirm_transfer`: Must be true
|
||||
- `transfer_reason`: Optional reason for audit trail
|
||||
"""
|
||||
company, old_owner, new_owner = company_service.transfer_ownership(
|
||||
db, company_id, transfer_data
|
||||
)
|
||||
db.commit() # ✅ ARCH: Commit at API level for transaction control
|
||||
|
||||
return CompanyTransferOwnershipResponse(
|
||||
message="Ownership transferred successfully",
|
||||
company_id=company.id,
|
||||
company_name=company.name,
|
||||
old_owner={
|
||||
"id": old_owner.id,
|
||||
"username": old_owner.username,
|
||||
"email": old_owner.email,
|
||||
},
|
||||
new_owner={
|
||||
"id": new_owner.id,
|
||||
"username": new_owner.username,
|
||||
"email": new_owner.email,
|
||||
},
|
||||
transferred_at=datetime.now(UTC),
|
||||
transfer_reason=transfer_data.transfer_reason,
|
||||
)
|
||||
|
||||
|
||||
@admin_companies_router.delete("/{company_id}")
|
||||
def delete_company(
|
||||
company_id: int = Path(..., description="Company ID"),
|
||||
confirm: bool = Query(False, description="Must be true to confirm deletion"),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Delete company and all associated vendors (Admin only).
|
||||
|
||||
⚠️ **WARNING: This is destructive and will delete:**
|
||||
- Company account
|
||||
- All vendors under this company
|
||||
- All products under those vendors
|
||||
- All orders, customers, team members
|
||||
|
||||
Requires confirmation parameter: `confirm=true`
|
||||
"""
|
||||
if not confirm:
|
||||
raise ConfirmationRequiredException(
|
||||
operation="delete_company",
|
||||
message="Deletion requires confirmation parameter: confirm=true",
|
||||
)
|
||||
|
||||
# Get company to check vendor count
|
||||
company = company_service.get_company_by_id(db, company_id)
|
||||
vendor_count = len(company.vendors)
|
||||
|
||||
if vendor_count > 0:
|
||||
raise CompanyHasVendorsException(company_id, vendor_count)
|
||||
|
||||
company_service.delete_company(db, company_id)
|
||||
db.commit() # ✅ ARCH: Commit at API level for transaction control
|
||||
|
||||
return {"message": f"Company {company_id} deleted successfully"}
|
||||
236
app/modules/tenancy/routes/api/admin_platform_users.py
Normal file
236
app/modules/tenancy/routes/api/admin_platform_users.py
Normal file
@@ -0,0 +1,236 @@
|
||||
# app/modules/tenancy/routes/api/admin_platform_users.py
|
||||
"""
|
||||
User management endpoints for admin.
|
||||
|
||||
All endpoints use the admin_service for business logic.
|
||||
Domain exceptions are raised by the service and converted to HTTP responses
|
||||
by the global exception handler.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, Path, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_admin_api
|
||||
from app.core.database import get_db
|
||||
from app.modules.tenancy.services.admin_service import admin_service
|
||||
from app.modules.analytics.services.stats_service import stats_service
|
||||
from models.schema.auth import UserContext
|
||||
from models.schema.auth import (
|
||||
UserCreate,
|
||||
UserDeleteResponse,
|
||||
UserDetailResponse,
|
||||
UserListResponse,
|
||||
UserResponse,
|
||||
UserSearchResponse,
|
||||
UserStatusToggleResponse,
|
||||
UserUpdate,
|
||||
)
|
||||
|
||||
admin_platform_users_router = APIRouter(prefix="/users")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@admin_platform_users_router.get("", response_model=UserListResponse)
|
||||
def get_all_users(
|
||||
page: int = Query(1, ge=1),
|
||||
per_page: int = Query(10, ge=1, le=100),
|
||||
search: str = Query("", description="Search by username or email"),
|
||||
role: str = Query("", description="Filter by role"),
|
||||
is_active: str = Query("", description="Filter by active status"),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Get paginated list of all users (Admin only)."""
|
||||
# Convert string params to proper types
|
||||
is_active_bool = None
|
||||
if is_active:
|
||||
is_active_bool = is_active.lower() == "true"
|
||||
|
||||
users, total, pages = admin_service.list_users(
|
||||
db=db,
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
search=search if search else None,
|
||||
role=role if role else None,
|
||||
is_active=is_active_bool,
|
||||
)
|
||||
|
||||
return UserListResponse(
|
||||
items=[UserResponse.model_validate(user) for user in users],
|
||||
total=total,
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
pages=pages,
|
||||
)
|
||||
|
||||
|
||||
@admin_platform_users_router.post("", response_model=UserDetailResponse)
|
||||
def create_user(
|
||||
user_data: UserCreate = Body(...),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Create a new user (Admin only)."""
|
||||
user = admin_service.create_user(
|
||||
db=db,
|
||||
email=user_data.email,
|
||||
username=user_data.username,
|
||||
password=user_data.password,
|
||||
first_name=user_data.first_name,
|
||||
last_name=user_data.last_name,
|
||||
role=user_data.role,
|
||||
current_admin_id=current_admin.id,
|
||||
)
|
||||
db.commit()
|
||||
|
||||
return UserDetailResponse(
|
||||
id=user.id,
|
||||
email=user.email,
|
||||
username=user.username,
|
||||
role=user.role,
|
||||
is_active=user.is_active,
|
||||
last_login=user.last_login,
|
||||
created_at=user.created_at,
|
||||
updated_at=user.updated_at,
|
||||
first_name=user.first_name,
|
||||
last_name=user.last_name,
|
||||
full_name=user.full_name,
|
||||
is_email_verified=user.is_email_verified,
|
||||
owned_companies_count=len(user.owned_companies) if user.owned_companies else 0,
|
||||
vendor_memberships_count=len(user.vendor_memberships)
|
||||
if user.vendor_memberships
|
||||
else 0,
|
||||
)
|
||||
|
||||
|
||||
@admin_platform_users_router.get("/stats")
|
||||
def get_user_statistics(
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Get user statistics for admin dashboard (Admin only)."""
|
||||
return stats_service.get_user_statistics(db)
|
||||
|
||||
|
||||
@admin_platform_users_router.get("/search", response_model=UserSearchResponse)
|
||||
def search_users(
|
||||
q: str = Query(..., min_length=2, description="Search query (username or email)"),
|
||||
limit: int = Query(10, ge=1, le=50),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Search users by username or email (Admin only).
|
||||
|
||||
Used for autocomplete in ownership transfer.
|
||||
"""
|
||||
users = admin_service.search_users(db=db, query=q, limit=limit)
|
||||
return UserSearchResponse(users=users)
|
||||
|
||||
|
||||
@admin_platform_users_router.get("/{user_id}", response_model=UserDetailResponse)
|
||||
def get_user_details(
|
||||
user_id: int = Path(..., description="User ID"),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Get detailed user information (Admin only)."""
|
||||
user = admin_service.get_user_details(db=db, user_id=user_id)
|
||||
|
||||
return UserDetailResponse(
|
||||
id=user.id,
|
||||
email=user.email,
|
||||
username=user.username,
|
||||
role=user.role,
|
||||
is_active=user.is_active,
|
||||
last_login=user.last_login,
|
||||
created_at=user.created_at,
|
||||
updated_at=user.updated_at,
|
||||
first_name=user.first_name,
|
||||
last_name=user.last_name,
|
||||
full_name=user.full_name,
|
||||
is_email_verified=user.is_email_verified,
|
||||
owned_companies_count=len(user.owned_companies) if user.owned_companies else 0,
|
||||
vendor_memberships_count=len(user.vendor_memberships)
|
||||
if user.vendor_memberships
|
||||
else 0,
|
||||
)
|
||||
|
||||
|
||||
@admin_platform_users_router.put("/{user_id}", response_model=UserDetailResponse)
|
||||
def update_user(
|
||||
user_id: int = Path(..., description="User ID"),
|
||||
user_update: UserUpdate = Body(...),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Update user information (Admin only)."""
|
||||
update_data = user_update.model_dump(exclude_unset=True)
|
||||
|
||||
user = admin_service.update_user(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
current_admin_id=current_admin.id,
|
||||
email=update_data.get("email"),
|
||||
username=update_data.get("username"),
|
||||
first_name=update_data.get("first_name"),
|
||||
last_name=update_data.get("last_name"),
|
||||
role=update_data.get("role"),
|
||||
is_active=update_data.get("is_active"),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
return UserDetailResponse(
|
||||
id=user.id,
|
||||
email=user.email,
|
||||
username=user.username,
|
||||
role=user.role,
|
||||
is_active=user.is_active,
|
||||
last_login=user.last_login,
|
||||
created_at=user.created_at,
|
||||
updated_at=user.updated_at,
|
||||
first_name=user.first_name,
|
||||
last_name=user.last_name,
|
||||
full_name=user.full_name,
|
||||
is_email_verified=user.is_email_verified,
|
||||
owned_companies_count=len(user.owned_companies) if user.owned_companies else 0,
|
||||
vendor_memberships_count=len(user.vendor_memberships)
|
||||
if user.vendor_memberships
|
||||
else 0,
|
||||
)
|
||||
|
||||
|
||||
@admin_platform_users_router.put("/{user_id}/status", response_model=UserStatusToggleResponse)
|
||||
def toggle_user_status(
|
||||
user_id: int = Path(..., description="User ID"),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Toggle user active status (Admin only)."""
|
||||
user, message = admin_service.toggle_user_status(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
current_admin_id=current_admin.id,
|
||||
)
|
||||
db.commit()
|
||||
|
||||
return UserStatusToggleResponse(message=message, is_active=user.is_active)
|
||||
|
||||
|
||||
@admin_platform_users_router.delete("/{user_id}", response_model=UserDeleteResponse)
|
||||
def delete_user(
|
||||
user_id: int = Path(..., description="User ID"),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Delete a user (Admin only)."""
|
||||
message = admin_service.delete_user(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
current_admin_id=current_admin.id,
|
||||
)
|
||||
db.commit()
|
||||
|
||||
return UserDeleteResponse(message=message)
|
||||
224
app/modules/tenancy/routes/api/admin_platforms.py
Normal file
224
app/modules/tenancy/routes/api/admin_platforms.py
Normal file
@@ -0,0 +1,224 @@
|
||||
# app/modules/tenancy/routes/api/admin_platforms.py
|
||||
"""
|
||||
Admin API endpoints for Platform management (Multi-Platform CMS).
|
||||
|
||||
Provides CRUD operations for platforms:
|
||||
- GET /platforms - List all platforms
|
||||
- GET /platforms/{code} - Get platform details
|
||||
- PUT /platforms/{code} - Update platform settings
|
||||
- GET /platforms/{code}/stats - Get platform statistics
|
||||
|
||||
Platforms are business offerings (OMS, Loyalty, Site Builder) with their own:
|
||||
- Marketing pages (homepage, pricing, features)
|
||||
- Vendor defaults (about, terms, privacy)
|
||||
- Configuration and branding
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Path, Query
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_admin_from_cookie_or_header, get_db
|
||||
from app.modules.tenancy.services.platform_service import platform_service
|
||||
from models.schema.auth import UserContext
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
admin_platforms_router = APIRouter(prefix="/platforms")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Pydantic Schemas
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class PlatformResponse(BaseModel):
|
||||
"""Platform response schema."""
|
||||
|
||||
id: int
|
||||
code: str
|
||||
name: str
|
||||
description: str | None = None
|
||||
domain: str | None = None
|
||||
path_prefix: str | None = None
|
||||
logo: str | None = None
|
||||
logo_dark: str | None = None
|
||||
favicon: str | None = None
|
||||
theme_config: dict[str, Any] = Field(default_factory=dict)
|
||||
default_language: str = "fr"
|
||||
supported_languages: list[str] = Field(default_factory=lambda: ["fr", "de", "en"])
|
||||
is_active: bool = True
|
||||
is_public: bool = True
|
||||
settings: dict[str, Any] = Field(default_factory=dict)
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
# Computed fields (added by endpoint)
|
||||
vendor_count: int = 0
|
||||
platform_pages_count: int = 0
|
||||
vendor_defaults_count: int = 0
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class PlatformListResponse(BaseModel):
|
||||
"""Response for platform list."""
|
||||
|
||||
platforms: list[PlatformResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class PlatformUpdateRequest(BaseModel):
|
||||
"""Request schema for updating a platform."""
|
||||
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
domain: str | None = None
|
||||
path_prefix: str | None = None
|
||||
logo: str | None = None
|
||||
logo_dark: str | None = None
|
||||
favicon: str | None = None
|
||||
theme_config: dict[str, Any] | None = None
|
||||
default_language: str | None = None
|
||||
supported_languages: list[str] | None = None
|
||||
is_active: bool | None = None
|
||||
is_public: bool | None = None
|
||||
settings: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class PlatformStatsResponse(BaseModel):
|
||||
"""Platform statistics response."""
|
||||
|
||||
platform_id: int
|
||||
platform_code: str
|
||||
platform_name: str
|
||||
vendor_count: int
|
||||
platform_pages_count: int
|
||||
vendor_defaults_count: int
|
||||
vendor_overrides_count: int
|
||||
published_pages_count: int
|
||||
draft_pages_count: int
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Helper Functions
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _build_platform_response(db: Session, platform) -> PlatformResponse:
|
||||
"""Build PlatformResponse from Platform model with computed fields."""
|
||||
return PlatformResponse(
|
||||
id=platform.id,
|
||||
code=platform.code,
|
||||
name=platform.name,
|
||||
description=platform.description,
|
||||
domain=platform.domain,
|
||||
path_prefix=platform.path_prefix,
|
||||
logo=platform.logo,
|
||||
logo_dark=platform.logo_dark,
|
||||
favicon=platform.favicon,
|
||||
theme_config=platform.theme_config or {},
|
||||
default_language=platform.default_language,
|
||||
supported_languages=platform.supported_languages or ["fr", "de", "en"],
|
||||
is_active=platform.is_active,
|
||||
is_public=platform.is_public,
|
||||
settings=platform.settings or {},
|
||||
created_at=platform.created_at.isoformat(),
|
||||
updated_at=platform.updated_at.isoformat(),
|
||||
vendor_count=platform_service.get_vendor_count(db, platform.id),
|
||||
platform_pages_count=platform_service.get_platform_pages_count(db, platform.id),
|
||||
vendor_defaults_count=platform_service.get_vendor_defaults_count(db, platform.id),
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# API Endpoints
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@admin_platforms_router.get("", response_model=PlatformListResponse)
|
||||
async def list_platforms(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: UserContext = Depends(get_current_admin_from_cookie_or_header),
|
||||
include_inactive: bool = Query(False, description="Include inactive platforms"),
|
||||
):
|
||||
"""
|
||||
List all platforms with their statistics.
|
||||
|
||||
Returns all platforms (OMS, Loyalty, etc.) with vendor counts and page counts.
|
||||
"""
|
||||
platforms = platform_service.list_platforms(db, include_inactive=include_inactive)
|
||||
|
||||
result = [_build_platform_response(db, platform) for platform in platforms]
|
||||
|
||||
logger.info(f"[PLATFORMS] Listed {len(result)} platforms")
|
||||
|
||||
return PlatformListResponse(platforms=result, total=len(result))
|
||||
|
||||
|
||||
@admin_platforms_router.get("/{code}", response_model=PlatformResponse)
|
||||
async def get_platform(
|
||||
code: str = Path(..., description="Platform code (oms, loyalty, etc.)"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: UserContext = Depends(get_current_admin_from_cookie_or_header),
|
||||
):
|
||||
"""
|
||||
Get platform details by code.
|
||||
|
||||
Returns full platform configuration including statistics.
|
||||
"""
|
||||
platform = platform_service.get_platform_by_code(db, code)
|
||||
return _build_platform_response(db, platform)
|
||||
|
||||
|
||||
@admin_platforms_router.put("/{code}", response_model=PlatformResponse)
|
||||
async def update_platform(
|
||||
update_data: PlatformUpdateRequest,
|
||||
code: str = Path(..., description="Platform code"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: UserContext = Depends(get_current_admin_from_cookie_or_header),
|
||||
):
|
||||
"""
|
||||
Update platform settings.
|
||||
|
||||
Allows updating name, description, branding, and configuration.
|
||||
"""
|
||||
platform = platform_service.get_platform_by_code(db, code)
|
||||
|
||||
update_dict = update_data.model_dump(exclude_unset=True)
|
||||
platform = platform_service.update_platform(db, platform, update_dict)
|
||||
|
||||
db.commit()
|
||||
db.refresh(platform)
|
||||
|
||||
return _build_platform_response(db, platform)
|
||||
|
||||
|
||||
@admin_platforms_router.get("/{code}/stats", response_model=PlatformStatsResponse)
|
||||
async def get_platform_stats(
|
||||
code: str = Path(..., description="Platform code"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: UserContext = Depends(get_current_admin_from_cookie_or_header),
|
||||
):
|
||||
"""
|
||||
Get detailed statistics for a platform.
|
||||
|
||||
Returns counts for vendors, pages, and content breakdown.
|
||||
"""
|
||||
platform = platform_service.get_platform_by_code(db, code)
|
||||
stats = platform_service.get_platform_stats(db, platform)
|
||||
|
||||
return PlatformStatsResponse(
|
||||
platform_id=stats.platform_id,
|
||||
platform_code=stats.platform_code,
|
||||
platform_name=stats.platform_name,
|
||||
vendor_count=stats.vendor_count,
|
||||
platform_pages_count=stats.platform_pages_count,
|
||||
vendor_defaults_count=stats.vendor_defaults_count,
|
||||
vendor_overrides_count=stats.vendor_overrides_count,
|
||||
published_pages_count=stats.published_pages_count,
|
||||
draft_pages_count=stats.draft_pages_count,
|
||||
)
|
||||
397
app/modules/tenancy/routes/api/admin_users.py
Normal file
397
app/modules/tenancy/routes/api/admin_users.py
Normal file
@@ -0,0 +1,397 @@
|
||||
# app/modules/tenancy/routes/api/admin_users.py
|
||||
"""
|
||||
Admin user management endpoints (Super Admin only).
|
||||
|
||||
This module provides endpoints for:
|
||||
- Listing all admin users with their platform assignments
|
||||
- Creating platform admins and super admins
|
||||
- Assigning/removing platform access
|
||||
- Promoting/demoting super admin status
|
||||
- Toggling admin status
|
||||
- Deleting admin users
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, Path, Query
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_super_admin, get_current_super_admin_api
|
||||
from app.core.database import get_db
|
||||
from app.exceptions import ValidationException
|
||||
from app.modules.tenancy.services.admin_platform_service import admin_platform_service
|
||||
from models.database.user import User # noqa: API-007 - Internal helper uses User model
|
||||
from models.schema.auth import UserContext
|
||||
|
||||
admin_users_router = APIRouter(prefix="/admin-users")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# SCHEMAS
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class PlatformAssignmentResponse(BaseModel):
|
||||
"""Response for a platform assignment."""
|
||||
|
||||
platform_id: int
|
||||
platform_code: str
|
||||
platform_name: str
|
||||
is_active: bool
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class AdminUserResponse(BaseModel):
|
||||
"""Response for an admin user."""
|
||||
|
||||
id: int
|
||||
email: str
|
||||
username: str
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
is_active: bool
|
||||
is_super_admin: bool
|
||||
platform_assignments: list[PlatformAssignmentResponse] = []
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
last_login: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class AdminUserListResponse(BaseModel):
|
||||
"""Response for listing admin users."""
|
||||
|
||||
admins: list[AdminUserResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class CreateAdminUserRequest(BaseModel):
|
||||
"""Request to create a new admin user (platform admin or super admin)."""
|
||||
|
||||
email: EmailStr
|
||||
username: str
|
||||
password: str
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
is_super_admin: bool = False
|
||||
platform_ids: list[int] = []
|
||||
|
||||
|
||||
class AssignPlatformRequest(BaseModel):
|
||||
"""Request to assign admin to platform."""
|
||||
|
||||
platform_id: int
|
||||
|
||||
|
||||
class ToggleSuperAdminRequest(BaseModel):
|
||||
"""Request to toggle super admin status."""
|
||||
|
||||
is_super_admin: bool
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# HELPER FUNCTIONS
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def _build_admin_response(admin: User) -> AdminUserResponse:
|
||||
"""Build AdminUserResponse from User model."""
|
||||
assignments = []
|
||||
if not admin.is_super_admin:
|
||||
for ap in admin.admin_platforms:
|
||||
if ap.is_active and ap.platform:
|
||||
assignments.append(
|
||||
PlatformAssignmentResponse(
|
||||
platform_id=ap.platform_id,
|
||||
platform_code=ap.platform.code,
|
||||
platform_name=ap.platform.name,
|
||||
is_active=ap.is_active,
|
||||
)
|
||||
)
|
||||
|
||||
return AdminUserResponse(
|
||||
id=admin.id,
|
||||
email=admin.email,
|
||||
username=admin.username,
|
||||
first_name=admin.first_name,
|
||||
last_name=admin.last_name,
|
||||
is_active=admin.is_active,
|
||||
is_super_admin=admin.is_super_admin,
|
||||
platform_assignments=assignments,
|
||||
created_at=admin.created_at,
|
||||
updated_at=admin.updated_at,
|
||||
last_login=admin.last_login,
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# ENDPOINTS
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@admin_users_router.get("", response_model=AdminUserListResponse)
|
||||
def list_admin_users(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
include_super_admins: bool = Query(True),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_super_admin),
|
||||
):
|
||||
"""
|
||||
List all admin users with their platform assignments.
|
||||
|
||||
Super admin only.
|
||||
"""
|
||||
admins, total = admin_platform_service.list_admin_users(
|
||||
db=db,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
include_super_admins=include_super_admins,
|
||||
)
|
||||
|
||||
admin_responses = [_build_admin_response(admin) for admin in admins]
|
||||
|
||||
return AdminUserListResponse(admins=admin_responses, total=total)
|
||||
|
||||
|
||||
@admin_users_router.post("", response_model=AdminUserResponse)
|
||||
def create_admin_user(
|
||||
request: CreateAdminUserRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_super_admin_api),
|
||||
):
|
||||
"""
|
||||
Create a new admin user (super admin or platform admin).
|
||||
|
||||
Super admin only.
|
||||
"""
|
||||
# Validate platform_ids required for non-super admin
|
||||
if not request.is_super_admin and not request.platform_ids:
|
||||
raise ValidationException(
|
||||
"Platform admins must be assigned to at least one platform",
|
||||
field="platform_ids",
|
||||
)
|
||||
|
||||
if request.is_super_admin:
|
||||
# Create super admin using service
|
||||
user = admin_platform_service.create_super_admin(
|
||||
db=db,
|
||||
email=request.email,
|
||||
username=request.username,
|
||||
password=request.password,
|
||||
created_by_user_id=current_admin.id,
|
||||
first_name=request.first_name,
|
||||
last_name=request.last_name,
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
return AdminUserResponse(
|
||||
id=user.id,
|
||||
email=user.email,
|
||||
username=user.username,
|
||||
first_name=user.first_name,
|
||||
last_name=user.last_name,
|
||||
is_active=user.is_active,
|
||||
is_super_admin=user.is_super_admin,
|
||||
platform_assignments=[],
|
||||
)
|
||||
else:
|
||||
# Create platform admin with assignments using service
|
||||
user, assignments = admin_platform_service.create_platform_admin(
|
||||
db=db,
|
||||
email=request.email,
|
||||
username=request.username,
|
||||
password=request.password,
|
||||
platform_ids=request.platform_ids,
|
||||
created_by_user_id=current_admin.id,
|
||||
first_name=request.first_name,
|
||||
last_name=request.last_name,
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
return _build_admin_response(user)
|
||||
|
||||
|
||||
@admin_users_router.get("/{user_id}", response_model=AdminUserResponse)
|
||||
def get_admin_user(
|
||||
user_id: int = Path(...),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_super_admin),
|
||||
):
|
||||
"""
|
||||
Get admin user details with platform assignments.
|
||||
|
||||
Super admin only.
|
||||
"""
|
||||
admin = admin_platform_service.get_admin_user(db=db, user_id=user_id)
|
||||
return _build_admin_response(admin)
|
||||
|
||||
|
||||
@admin_users_router.post("/{user_id}/platforms/{platform_id}")
|
||||
def assign_admin_to_platform(
|
||||
user_id: int = Path(...),
|
||||
platform_id: int = Path(...),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_super_admin_api),
|
||||
):
|
||||
"""
|
||||
Assign an admin to a platform.
|
||||
|
||||
Super admin only.
|
||||
"""
|
||||
admin_platform_service.assign_admin_to_platform(
|
||||
db=db,
|
||||
admin_user_id=user_id,
|
||||
platform_id=platform_id,
|
||||
assigned_by_user_id=current_admin.id,
|
||||
)
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"message": "Admin assigned to platform successfully",
|
||||
"platform_id": platform_id,
|
||||
"user_id": user_id,
|
||||
}
|
||||
|
||||
|
||||
@admin_users_router.delete("/{user_id}/platforms/{platform_id}")
|
||||
def remove_admin_from_platform(
|
||||
user_id: int = Path(...),
|
||||
platform_id: int = Path(...),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_super_admin_api),
|
||||
):
|
||||
"""
|
||||
Remove an admin's access to a platform.
|
||||
|
||||
Super admin only.
|
||||
"""
|
||||
admin_platform_service.remove_admin_from_platform(
|
||||
db=db,
|
||||
admin_user_id=user_id,
|
||||
platform_id=platform_id,
|
||||
removed_by_user_id=current_admin.id,
|
||||
)
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"message": "Admin removed from platform successfully",
|
||||
"platform_id": platform_id,
|
||||
"user_id": user_id,
|
||||
}
|
||||
|
||||
|
||||
@admin_users_router.put("/{user_id}/super-admin")
|
||||
def toggle_super_admin_status(
|
||||
user_id: int = Path(...),
|
||||
request: ToggleSuperAdminRequest = Body(...),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_super_admin_api),
|
||||
):
|
||||
"""
|
||||
Promote or demote an admin to/from super admin.
|
||||
|
||||
Super admin only.
|
||||
"""
|
||||
user = admin_platform_service.toggle_super_admin(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
is_super_admin=request.is_super_admin,
|
||||
current_admin_id=current_admin.id,
|
||||
)
|
||||
db.commit()
|
||||
|
||||
action = "promoted to" if request.is_super_admin else "demoted from"
|
||||
|
||||
return {
|
||||
"message": f"Admin {action} super admin successfully",
|
||||
"user_id": user_id,
|
||||
"is_super_admin": user.is_super_admin,
|
||||
}
|
||||
|
||||
|
||||
@admin_users_router.get("/{user_id}/platforms")
|
||||
def get_admin_platforms(
|
||||
user_id: int = Path(...),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_super_admin),
|
||||
):
|
||||
"""
|
||||
Get all platforms assigned to an admin.
|
||||
|
||||
Super admin only.
|
||||
"""
|
||||
platforms = admin_platform_service.get_platforms_for_admin(db, user_id)
|
||||
|
||||
return {
|
||||
"platforms": [
|
||||
{
|
||||
"id": p.id,
|
||||
"code": p.code,
|
||||
"name": p.name,
|
||||
}
|
||||
for p in platforms
|
||||
],
|
||||
"user_id": user_id,
|
||||
}
|
||||
|
||||
|
||||
@admin_users_router.put("/{user_id}/status")
|
||||
def toggle_admin_status(
|
||||
user_id: int = Path(...),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_super_admin_api),
|
||||
):
|
||||
"""
|
||||
Toggle admin user active status.
|
||||
|
||||
Super admin only. Cannot deactivate yourself.
|
||||
"""
|
||||
admin = admin_platform_service.toggle_admin_status(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
current_admin_id=current_admin.id,
|
||||
)
|
||||
db.commit()
|
||||
|
||||
action = "activated" if admin.is_active else "deactivated"
|
||||
|
||||
return {
|
||||
"message": f"Admin user {action} successfully",
|
||||
"user_id": user_id,
|
||||
"is_active": admin.is_active,
|
||||
}
|
||||
|
||||
|
||||
@admin_users_router.delete("/{user_id}")
|
||||
def delete_admin_user(
|
||||
user_id: int = Path(...),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_super_admin_api),
|
||||
):
|
||||
"""
|
||||
Delete an admin user.
|
||||
|
||||
Super admin only. Cannot delete yourself.
|
||||
"""
|
||||
admin_platform_service.delete_admin_user(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
current_admin_id=current_admin.id,
|
||||
)
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"message": "Admin user deleted successfully",
|
||||
"user_id": user_id,
|
||||
}
|
||||
309
app/modules/tenancy/routes/api/admin_vendor_domains.py
Normal file
309
app/modules/tenancy/routes/api/admin_vendor_domains.py
Normal file
@@ -0,0 +1,309 @@
|
||||
# app/modules/tenancy/routes/api/admin_vendor_domains.py
|
||||
"""
|
||||
Admin endpoints for managing vendor custom domains.
|
||||
|
||||
Follows the architecture pattern:
|
||||
- Endpoints only handle HTTP layer
|
||||
- Business logic in service layer
|
||||
- Domain exceptions bubble up to global handler
|
||||
- Pydantic schemas for validation
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, Path
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_admin_api
|
||||
from app.core.database import get_db
|
||||
from app.modules.tenancy.services.vendor_domain_service import vendor_domain_service
|
||||
from app.modules.tenancy.services.vendor_service import vendor_service
|
||||
from models.schema.auth import UserContext
|
||||
from models.schema.vendor_domain import (
|
||||
DomainDeletionResponse,
|
||||
DomainVerificationInstructions,
|
||||
DomainVerificationResponse,
|
||||
VendorDomainCreate,
|
||||
VendorDomainListResponse,
|
||||
VendorDomainResponse,
|
||||
VendorDomainUpdate,
|
||||
)
|
||||
|
||||
admin_vendor_domains_router = APIRouter(prefix="/vendors")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@admin_vendor_domains_router.post("/{vendor_id}/domains", response_model=VendorDomainResponse)
|
||||
def add_vendor_domain(
|
||||
vendor_id: int = Path(..., description="Vendor ID", gt=0),
|
||||
domain_data: VendorDomainCreate = Body(...),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Add a custom domain to vendor (Admin only).
|
||||
|
||||
This endpoint:
|
||||
1. Validates the domain format
|
||||
2. Checks if domain is already registered
|
||||
3. Generates verification token
|
||||
4. Creates domain record (unverified, inactive)
|
||||
5. Returns domain with verification instructions
|
||||
|
||||
**Domain Examples:**
|
||||
- myshop.com
|
||||
- shop.mybrand.com
|
||||
- customstore.net
|
||||
|
||||
**Next Steps:**
|
||||
1. Vendor adds DNS TXT record
|
||||
2. Admin clicks "Verify Domain" to confirm ownership
|
||||
3. Once verified, domain can be activated
|
||||
|
||||
**Raises:**
|
||||
- 404: Vendor not found
|
||||
- 409: Domain already registered
|
||||
- 422: Invalid domain format or reserved subdomain
|
||||
"""
|
||||
domain = vendor_domain_service.add_domain(
|
||||
db=db, vendor_id=vendor_id, domain_data=domain_data
|
||||
)
|
||||
db.commit()
|
||||
|
||||
return VendorDomainResponse(
|
||||
id=domain.id,
|
||||
vendor_id=domain.vendor_id,
|
||||
domain=domain.domain,
|
||||
is_primary=domain.is_primary,
|
||||
is_active=domain.is_active,
|
||||
is_verified=domain.is_verified,
|
||||
ssl_status=domain.ssl_status,
|
||||
verification_token=domain.verification_token,
|
||||
verified_at=domain.verified_at,
|
||||
ssl_verified_at=domain.ssl_verified_at,
|
||||
created_at=domain.created_at,
|
||||
updated_at=domain.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@admin_vendor_domains_router.get("/{vendor_id}/domains", response_model=VendorDomainListResponse)
|
||||
def list_vendor_domains(
|
||||
vendor_id: int = Path(..., description="Vendor ID", gt=0),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
List all domains for a vendor (Admin only).
|
||||
|
||||
Returns domains ordered by:
|
||||
1. Primary domains first
|
||||
2. Creation date (newest first)
|
||||
|
||||
**Raises:**
|
||||
- 404: Vendor not found
|
||||
"""
|
||||
# Verify vendor exists (raises VendorNotFoundException if not found)
|
||||
vendor_service.get_vendor_by_id(db, vendor_id)
|
||||
|
||||
domains = vendor_domain_service.get_vendor_domains(db, vendor_id)
|
||||
|
||||
return VendorDomainListResponse(
|
||||
domains=[
|
||||
VendorDomainResponse(
|
||||
id=d.id,
|
||||
vendor_id=d.vendor_id,
|
||||
domain=d.domain,
|
||||
is_primary=d.is_primary,
|
||||
is_active=d.is_active,
|
||||
is_verified=d.is_verified,
|
||||
ssl_status=d.ssl_status,
|
||||
verification_token=d.verification_token if not d.is_verified else None,
|
||||
verified_at=d.verified_at,
|
||||
ssl_verified_at=d.ssl_verified_at,
|
||||
created_at=d.created_at,
|
||||
updated_at=d.updated_at,
|
||||
)
|
||||
for d in domains
|
||||
],
|
||||
total=len(domains),
|
||||
)
|
||||
|
||||
|
||||
@admin_vendor_domains_router.get("/domains/{domain_id}", response_model=VendorDomainResponse)
|
||||
def get_domain_details(
|
||||
domain_id: int = Path(..., description="Domain ID", gt=0),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Get detailed information about a specific domain (Admin only).
|
||||
|
||||
**Raises:**
|
||||
- 404: Domain not found
|
||||
"""
|
||||
domain = vendor_domain_service.get_domain_by_id(db, domain_id)
|
||||
|
||||
return VendorDomainResponse(
|
||||
id=domain.id,
|
||||
vendor_id=domain.vendor_id,
|
||||
domain=domain.domain,
|
||||
is_primary=domain.is_primary,
|
||||
is_active=domain.is_active,
|
||||
is_verified=domain.is_verified,
|
||||
ssl_status=domain.ssl_status,
|
||||
verification_token=(
|
||||
domain.verification_token if not domain.is_verified else None
|
||||
),
|
||||
verified_at=domain.verified_at,
|
||||
ssl_verified_at=domain.ssl_verified_at,
|
||||
created_at=domain.created_at,
|
||||
updated_at=domain.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@admin_vendor_domains_router.put("/domains/{domain_id}", response_model=VendorDomainResponse)
|
||||
def update_vendor_domain(
|
||||
domain_id: int = Path(..., description="Domain ID", gt=0),
|
||||
domain_update: VendorDomainUpdate = Body(...),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Update domain settings (Admin only).
|
||||
|
||||
**Can update:**
|
||||
- `is_primary`: Set as primary domain for vendor
|
||||
- `is_active`: Activate or deactivate domain
|
||||
|
||||
**Important:**
|
||||
- Cannot activate unverified domains
|
||||
- Setting a domain as primary will unset other primary domains
|
||||
- Cannot modify domain name (delete and recreate instead)
|
||||
|
||||
**Raises:**
|
||||
- 404: Domain not found
|
||||
- 400: Cannot activate unverified domain
|
||||
"""
|
||||
domain = vendor_domain_service.update_domain(
|
||||
db=db, domain_id=domain_id, domain_update=domain_update
|
||||
)
|
||||
db.commit()
|
||||
|
||||
return VendorDomainResponse(
|
||||
id=domain.id,
|
||||
vendor_id=domain.vendor_id,
|
||||
domain=domain.domain,
|
||||
is_primary=domain.is_primary,
|
||||
is_active=domain.is_active,
|
||||
is_verified=domain.is_verified,
|
||||
ssl_status=domain.ssl_status,
|
||||
verification_token=None, # Don't expose token after updates
|
||||
verified_at=domain.verified_at,
|
||||
ssl_verified_at=domain.ssl_verified_at,
|
||||
created_at=domain.created_at,
|
||||
updated_at=domain.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@admin_vendor_domains_router.delete("/domains/{domain_id}", response_model=DomainDeletionResponse)
|
||||
def delete_vendor_domain(
|
||||
domain_id: int = Path(..., description="Domain ID", gt=0),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Delete a custom domain (Admin only).
|
||||
|
||||
**Warning:** This is permanent and cannot be undone.
|
||||
|
||||
**Raises:**
|
||||
- 404: Domain not found
|
||||
"""
|
||||
# Get domain details before deletion
|
||||
domain = vendor_domain_service.get_domain_by_id(db, domain_id)
|
||||
vendor_id = domain.vendor_id
|
||||
domain_name = domain.domain
|
||||
|
||||
# Delete domain
|
||||
message = vendor_domain_service.delete_domain(db, domain_id)
|
||||
db.commit()
|
||||
|
||||
return DomainDeletionResponse(
|
||||
message=message, domain=domain_name, vendor_id=vendor_id
|
||||
)
|
||||
|
||||
|
||||
@admin_vendor_domains_router.post("/domains/{domain_id}/verify", response_model=DomainVerificationResponse)
|
||||
def verify_domain_ownership(
|
||||
domain_id: int = Path(..., description="Domain ID", gt=0),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Verify domain ownership via DNS TXT record (Admin only).
|
||||
|
||||
**Verification Process:**
|
||||
1. Queries DNS for TXT record: `_wizamart-verify.{domain}`
|
||||
2. Checks if verification token matches
|
||||
3. If found, marks domain as verified
|
||||
|
||||
**Requirements:**
|
||||
- Vendor must have added TXT record to their DNS
|
||||
- DNS propagation may take 5-15 minutes
|
||||
- Record format: `_wizamart-verify.domain.com` TXT `{token}`
|
||||
|
||||
**After verification:**
|
||||
- Domain can be activated
|
||||
- Domain will be available for routing
|
||||
|
||||
**Raises:**
|
||||
- 404: Domain not found
|
||||
- 400: Already verified, or verification failed
|
||||
- 502: DNS query failed
|
||||
"""
|
||||
domain, message = vendor_domain_service.verify_domain(db, domain_id)
|
||||
db.commit()
|
||||
|
||||
return DomainVerificationResponse(
|
||||
message=message,
|
||||
domain=domain.domain,
|
||||
verified_at=domain.verified_at,
|
||||
is_verified=domain.is_verified,
|
||||
)
|
||||
|
||||
|
||||
@admin_vendor_domains_router.get(
|
||||
"/domains/{domain_id}/verification-instructions",
|
||||
response_model=DomainVerificationInstructions,
|
||||
)
|
||||
def get_domain_verification_instructions(
|
||||
domain_id: int = Path(..., description="Domain ID", gt=0),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Get DNS verification instructions for domain (Admin only).
|
||||
|
||||
Returns step-by-step instructions for:
|
||||
1. Where to add DNS records
|
||||
2. What TXT record to create
|
||||
3. Links to common registrars
|
||||
4. Verification token
|
||||
|
||||
**Use this endpoint to:**
|
||||
- Show vendors how to verify their domain
|
||||
- Get the exact TXT record values
|
||||
- Access registrar links
|
||||
|
||||
**Raises:**
|
||||
- 404: Domain not found
|
||||
"""
|
||||
instructions = vendor_domain_service.get_verification_instructions(db, domain_id)
|
||||
|
||||
return DomainVerificationInstructions(
|
||||
domain=instructions["domain"],
|
||||
verification_token=instructions["verification_token"],
|
||||
instructions=instructions["instructions"],
|
||||
txt_record=instructions["txt_record"],
|
||||
common_registrars=instructions["common_registrars"],
|
||||
)
|
||||
492
app/modules/tenancy/routes/api/admin_vendors.py
Normal file
492
app/modules/tenancy/routes/api/admin_vendors.py
Normal file
@@ -0,0 +1,492 @@
|
||||
# app/modules/tenancy/routes/api/admin_vendors.py
|
||||
"""
|
||||
Vendor management endpoints for admin.
|
||||
|
||||
Architecture Notes:
|
||||
- All business logic is in vendor_service (no direct DB operations here)
|
||||
- Uses domain exceptions from app/exceptions/vendor.py
|
||||
- Exception handler middleware converts domain exceptions to HTTP responses
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, Path, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_admin_api
|
||||
from app.core.database import get_db
|
||||
from app.modules.tenancy.exceptions import ConfirmationRequiredException
|
||||
from app.modules.tenancy.services.admin_service import admin_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.analytics.schemas import VendorStatsResponse
|
||||
from models.schema.vendor import (
|
||||
LetzshopExportRequest,
|
||||
LetzshopExportResponse,
|
||||
VendorCreate,
|
||||
VendorCreateResponse,
|
||||
VendorDetailResponse,
|
||||
VendorListResponse,
|
||||
VendorUpdate,
|
||||
)
|
||||
|
||||
admin_vendors_router = APIRouter(prefix="/vendors")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@admin_vendors_router.post("", response_model=VendorCreateResponse)
|
||||
def create_vendor(
|
||||
vendor_data: VendorCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Create a new vendor (storefront/brand) under an existing company (Admin only).
|
||||
|
||||
This endpoint:
|
||||
1. Validates that the parent company exists
|
||||
2. Creates a new vendor record linked to the company
|
||||
3. Sets up default roles (Owner, Manager, Editor, Viewer)
|
||||
|
||||
The vendor inherits owner and contact information from its parent company.
|
||||
"""
|
||||
vendor = admin_service.create_vendor(db=db, vendor_data=vendor_data)
|
||||
db.commit()
|
||||
|
||||
return VendorCreateResponse(
|
||||
# Vendor fields
|
||||
id=vendor.id,
|
||||
vendor_code=vendor.vendor_code,
|
||||
subdomain=vendor.subdomain,
|
||||
name=vendor.name,
|
||||
description=vendor.description,
|
||||
company_id=vendor.company_id,
|
||||
letzshop_csv_url_fr=vendor.letzshop_csv_url_fr,
|
||||
letzshop_csv_url_en=vendor.letzshop_csv_url_en,
|
||||
letzshop_csv_url_de=vendor.letzshop_csv_url_de,
|
||||
is_active=vendor.is_active,
|
||||
is_verified=vendor.is_verified,
|
||||
created_at=vendor.created_at,
|
||||
updated_at=vendor.updated_at,
|
||||
# Company info
|
||||
company_name=vendor.company.name,
|
||||
company_contact_email=vendor.company.contact_email,
|
||||
company_contact_phone=vendor.company.contact_phone,
|
||||
company_website=vendor.company.website,
|
||||
# Owner info (from company)
|
||||
owner_email=vendor.company.owner.email,
|
||||
owner_username=vendor.company.owner.username,
|
||||
login_url=f"http://localhost:8000/vendor/{vendor.subdomain}/login",
|
||||
)
|
||||
|
||||
|
||||
@admin_vendors_router.get("", response_model=VendorListResponse)
|
||||
def get_all_vendors_admin(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
search: str | None = Query(None, description="Search by name or vendor code"),
|
||||
is_active: bool | None = Query(None),
|
||||
is_verified: bool | None = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Get all vendors with filtering (Admin only)."""
|
||||
vendors, total = admin_service.get_all_vendors(
|
||||
db=db,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
search=search,
|
||||
is_active=is_active,
|
||||
is_verified=is_verified,
|
||||
)
|
||||
return VendorListResponse(vendors=vendors, total=total, skip=skip, limit=limit)
|
||||
|
||||
|
||||
@admin_vendors_router.get("/stats", response_model=VendorStatsResponse)
|
||||
def get_vendor_statistics_endpoint(
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Get vendor statistics for admin dashboard (Admin only)."""
|
||||
stats = stats_service.get_vendor_statistics(db)
|
||||
|
||||
# Use schema-compatible keys (with fallback to legacy keys)
|
||||
return VendorStatsResponse(
|
||||
total=stats.get("total", stats.get("total_vendors", 0)),
|
||||
verified=stats.get("verified", stats.get("verified_vendors", 0)),
|
||||
pending=stats.get("pending", stats.get("pending_vendors", 0)),
|
||||
inactive=stats.get("inactive", stats.get("inactive_vendors", 0)),
|
||||
)
|
||||
|
||||
|
||||
def _build_vendor_detail_response(vendor) -> VendorDetailResponse:
|
||||
"""
|
||||
Helper to build VendorDetailResponse with resolved contact info.
|
||||
|
||||
Contact fields are resolved using vendor override or company fallback.
|
||||
Inheritance flags indicate if value comes from company.
|
||||
"""
|
||||
contact_info = vendor.get_contact_info_with_inheritance()
|
||||
|
||||
return VendorDetailResponse(
|
||||
# Vendor fields
|
||||
id=vendor.id,
|
||||
vendor_code=vendor.vendor_code,
|
||||
subdomain=vendor.subdomain,
|
||||
name=vendor.name,
|
||||
description=vendor.description,
|
||||
company_id=vendor.company_id,
|
||||
letzshop_csv_url_fr=vendor.letzshop_csv_url_fr,
|
||||
letzshop_csv_url_en=vendor.letzshop_csv_url_en,
|
||||
letzshop_csv_url_de=vendor.letzshop_csv_url_de,
|
||||
is_active=vendor.is_active,
|
||||
is_verified=vendor.is_verified,
|
||||
created_at=vendor.created_at,
|
||||
updated_at=vendor.updated_at,
|
||||
# Company info
|
||||
company_name=vendor.company.name,
|
||||
# Owner details (from company)
|
||||
owner_email=vendor.company.owner.email,
|
||||
owner_username=vendor.company.owner.username,
|
||||
# Resolved contact info with inheritance flags
|
||||
**contact_info,
|
||||
# Original company values for UI reference
|
||||
company_contact_email=vendor.company.contact_email,
|
||||
company_contact_phone=vendor.company.contact_phone,
|
||||
company_website=vendor.company.website,
|
||||
company_business_address=vendor.company.business_address,
|
||||
company_tax_number=vendor.company.tax_number,
|
||||
)
|
||||
|
||||
|
||||
@admin_vendors_router.get("/{vendor_identifier}", response_model=VendorDetailResponse)
|
||||
def get_vendor_details(
|
||||
vendor_identifier: str = Path(..., description="Vendor ID or vendor_code"),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Get detailed vendor information including company and owner details (Admin only).
|
||||
|
||||
Accepts either vendor ID (integer) or vendor_code (string).
|
||||
|
||||
Returns vendor info with company contact details, owner info, and
|
||||
resolved contact fields (vendor override or company default).
|
||||
|
||||
Raises:
|
||||
VendorNotFoundException: If vendor not found (404)
|
||||
"""
|
||||
vendor = vendor_service.get_vendor_by_identifier(db, vendor_identifier)
|
||||
return _build_vendor_detail_response(vendor)
|
||||
|
||||
|
||||
@admin_vendors_router.put("/{vendor_identifier}", response_model=VendorDetailResponse)
|
||||
def update_vendor(
|
||||
vendor_identifier: str = Path(..., description="Vendor ID or vendor_code"),
|
||||
vendor_update: VendorUpdate = Body(...),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Update vendor information (Admin only).
|
||||
|
||||
Accepts either vendor ID (integer) or vendor_code (string).
|
||||
|
||||
**Can update:**
|
||||
- Basic info: name, description, subdomain
|
||||
- Marketplace URLs
|
||||
- Status: is_active, is_verified
|
||||
- Contact info: contact_email, contact_phone, website, business_address, tax_number
|
||||
(these override company defaults; set to empty to reset to inherit)
|
||||
|
||||
**Cannot update:**
|
||||
- `vendor_code` (immutable)
|
||||
|
||||
Raises:
|
||||
VendorNotFoundException: If vendor not found (404)
|
||||
"""
|
||||
vendor = vendor_service.get_vendor_by_identifier(db, vendor_identifier)
|
||||
vendor = admin_service.update_vendor(db, vendor.id, vendor_update)
|
||||
db.commit()
|
||||
return _build_vendor_detail_response(vendor)
|
||||
|
||||
|
||||
# NOTE: Ownership transfer is now at the Company level.
|
||||
# Use PUT /api/v1/admin/companies/{id}/transfer-ownership instead.
|
||||
# This endpoint is kept for backwards compatibility but may be removed in future versions.
|
||||
|
||||
|
||||
@admin_vendors_router.put("/{vendor_identifier}/verification", response_model=VendorDetailResponse)
|
||||
def toggle_vendor_verification(
|
||||
vendor_identifier: str = Path(..., description="Vendor ID or vendor_code"),
|
||||
verification_data: dict = Body(..., example={"is_verified": True}),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Set vendor verification status (Admin only).
|
||||
|
||||
Accepts either vendor ID (integer) or vendor_code (string).
|
||||
|
||||
Request body: { "is_verified": true/false }
|
||||
|
||||
Raises:
|
||||
VendorNotFoundException: If vendor not found (404)
|
||||
"""
|
||||
vendor = vendor_service.get_vendor_by_identifier(db, vendor_identifier)
|
||||
|
||||
if "is_verified" in verification_data:
|
||||
vendor, message = vendor_service.set_verification(
|
||||
db, vendor.id, verification_data["is_verified"]
|
||||
)
|
||||
db.commit() # ✅ ARCH: Commit at API level for transaction control
|
||||
logger.info(f"Vendor verification updated: {message}")
|
||||
|
||||
return _build_vendor_detail_response(vendor)
|
||||
|
||||
|
||||
@admin_vendors_router.put("/{vendor_identifier}/status", response_model=VendorDetailResponse)
|
||||
def toggle_vendor_status(
|
||||
vendor_identifier: str = Path(..., description="Vendor ID or vendor_code"),
|
||||
status_data: dict = Body(..., example={"is_active": True}),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Set vendor active status (Admin only).
|
||||
|
||||
Accepts either vendor ID (integer) or vendor_code (string).
|
||||
|
||||
Request body: { "is_active": true/false }
|
||||
|
||||
Raises:
|
||||
VendorNotFoundException: If vendor not found (404)
|
||||
"""
|
||||
vendor = vendor_service.get_vendor_by_identifier(db, vendor_identifier)
|
||||
|
||||
if "is_active" in status_data:
|
||||
vendor, message = vendor_service.set_status(
|
||||
db, vendor.id, status_data["is_active"]
|
||||
)
|
||||
db.commit() # ✅ ARCH: Commit at API level for transaction control
|
||||
logger.info(f"Vendor status updated: {message}")
|
||||
|
||||
return _build_vendor_detail_response(vendor)
|
||||
|
||||
|
||||
@admin_vendors_router.delete("/{vendor_identifier}")
|
||||
def delete_vendor(
|
||||
vendor_identifier: str = Path(..., description="Vendor ID or vendor_code"),
|
||||
confirm: bool = Query(False, description="Must be true to confirm deletion"),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Delete vendor and all associated data (Admin only).
|
||||
|
||||
Accepts either vendor ID (integer) or vendor_code (string).
|
||||
|
||||
⚠️ **WARNING: This is destructive and will delete:**
|
||||
- Vendor account
|
||||
- All products
|
||||
- All orders
|
||||
- All customers
|
||||
- All team members
|
||||
|
||||
Requires confirmation parameter: `confirm=true`
|
||||
|
||||
Raises:
|
||||
ConfirmationRequiredException: If confirm=true not provided (400)
|
||||
VendorNotFoundException: If vendor not found (404)
|
||||
"""
|
||||
if not confirm:
|
||||
raise ConfirmationRequiredException(
|
||||
operation="delete_vendor",
|
||||
message="Deletion requires confirmation parameter: confirm=true",
|
||||
)
|
||||
|
||||
vendor = vendor_service.get_vendor_by_identifier(db, vendor_identifier)
|
||||
message = admin_service.delete_vendor(db, vendor.id)
|
||||
db.commit()
|
||||
return {"message": message}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# LETZSHOP EXPORT
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@admin_vendors_router.get("/{vendor_identifier}/export/letzshop")
|
||||
def export_vendor_products_letzshop(
|
||||
vendor_identifier: str = Path(..., description="Vendor ID or vendor_code"),
|
||||
language: str = Query(
|
||||
"en", description="Language for title/description (en, fr, de)"
|
||||
),
|
||||
include_inactive: bool = Query(False, description="Include inactive products"),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Export vendor products in Letzshop CSV format (Admin only).
|
||||
|
||||
Generates a Google Shopping compatible CSV file for Letzshop marketplace.
|
||||
The file uses tab-separated values and includes all required Letzshop fields.
|
||||
|
||||
**Supported languages:** en, fr, de
|
||||
|
||||
**CSV Format:**
|
||||
- Delimiter: Tab (\\t)
|
||||
- Encoding: UTF-8
|
||||
- Fields: id, title, description, price, availability, image_link, etc.
|
||||
|
||||
Returns:
|
||||
CSV file as attachment (vendor_code_letzshop_export.csv)
|
||||
"""
|
||||
from fastapi.responses import Response
|
||||
|
||||
from app.modules.marketplace.services.letzshop_export_service import letzshop_export_service
|
||||
|
||||
vendor = vendor_service.get_vendor_by_identifier(db, vendor_identifier)
|
||||
|
||||
csv_content = letzshop_export_service.export_vendor_products(
|
||||
db=db,
|
||||
vendor_id=vendor.id,
|
||||
language=language,
|
||||
include_inactive=include_inactive,
|
||||
)
|
||||
|
||||
filename = f"{vendor.vendor_code.lower()}_letzshop_export.csv"
|
||||
|
||||
return Response(
|
||||
content=csv_content,
|
||||
media_type="text/csv; charset=utf-8",
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="{filename}"',
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@admin_vendors_router.post("/{vendor_identifier}/export/letzshop", response_model=LetzshopExportResponse)
|
||||
def export_vendor_products_letzshop_to_folder(
|
||||
vendor_identifier: str = Path(..., description="Vendor ID or vendor_code"),
|
||||
request: LetzshopExportRequest = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Export vendor products to Letzshop pickup folder (Admin only).
|
||||
|
||||
Generates CSV files for all languages (FR, DE, EN) and places them in a folder
|
||||
that Letzshop scheduler can fetch from. This is the preferred method for
|
||||
automated product sync.
|
||||
|
||||
**Behavior:**
|
||||
- When Celery is enabled: Queues export as background task, returns immediately
|
||||
- When Celery is disabled: Runs synchronously and returns file paths
|
||||
- Creates CSV files for each language (fr, de, en)
|
||||
- Places files in: exports/letzshop/{vendor_code}/
|
||||
- Filename format: {vendor_code}_products_{language}.csv
|
||||
|
||||
Returns:
|
||||
JSON with export status and file paths (or task_id if async)
|
||||
"""
|
||||
import os
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path as FilePath
|
||||
|
||||
from app.core.config import settings
|
||||
from app.modules.marketplace.services.letzshop_export_service import letzshop_export_service
|
||||
|
||||
vendor = vendor_service.get_vendor_by_identifier(db, vendor_identifier)
|
||||
include_inactive = request.include_inactive if request else False
|
||||
|
||||
# If Celery is enabled, dispatch as async task
|
||||
if settings.use_celery:
|
||||
from app.tasks.dispatcher import task_dispatcher
|
||||
|
||||
celery_task_id = task_dispatcher.dispatch_product_export(
|
||||
vendor_id=vendor.id,
|
||||
triggered_by=f"admin:{current_admin.id}",
|
||||
include_inactive=include_inactive,
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Export task queued for vendor {vendor.vendor_code}. Check Flower for status.",
|
||||
"vendor_code": vendor.vendor_code,
|
||||
"export_directory": f"exports/letzshop/{vendor.vendor_code.lower()}",
|
||||
"files": [],
|
||||
"celery_task_id": celery_task_id,
|
||||
"is_async": True,
|
||||
}
|
||||
|
||||
# Synchronous export (when Celery is disabled)
|
||||
started_at = datetime.now(UTC)
|
||||
|
||||
# Create export directory
|
||||
export_dir = FilePath(f"exports/letzshop/{vendor.vendor_code.lower()}")
|
||||
export_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
exported_files = []
|
||||
languages = ["fr", "de", "en"]
|
||||
total_records = 0
|
||||
failed_count = 0
|
||||
|
||||
for lang in languages:
|
||||
try:
|
||||
csv_content = letzshop_export_service.export_vendor_products(
|
||||
db=db,
|
||||
vendor_id=vendor.id,
|
||||
language=lang,
|
||||
include_inactive=include_inactive,
|
||||
)
|
||||
|
||||
filename = f"{vendor.vendor_code.lower()}_products_{lang}.csv"
|
||||
filepath = export_dir / filename
|
||||
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
f.write(csv_content)
|
||||
|
||||
# Count lines (minus header)
|
||||
line_count = csv_content.count("\n")
|
||||
if line_count > 0:
|
||||
total_records = max(total_records, line_count - 1)
|
||||
|
||||
exported_files.append({
|
||||
"language": lang,
|
||||
"filename": filename,
|
||||
"path": str(filepath),
|
||||
"size_bytes": os.path.getsize(filepath),
|
||||
})
|
||||
except Exception as e:
|
||||
failed_count += 1
|
||||
exported_files.append({
|
||||
"language": lang,
|
||||
"error": str(e),
|
||||
})
|
||||
|
||||
# Log the export operation via service
|
||||
completed_at = datetime.now(UTC)
|
||||
letzshop_export_service.log_export(
|
||||
db=db,
|
||||
vendor_id=vendor.id,
|
||||
started_at=started_at,
|
||||
completed_at=completed_at,
|
||||
files_processed=len(languages),
|
||||
files_succeeded=len(languages) - failed_count,
|
||||
files_failed=failed_count,
|
||||
products_exported=total_records,
|
||||
triggered_by=f"admin:{current_admin.id}",
|
||||
error_details={"files": exported_files} if failed_count > 0 else None,
|
||||
)
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Exported {len([f for f in exported_files if 'error' not in f])} language(s) to {export_dir}",
|
||||
"vendor_code": vendor.vendor_code,
|
||||
"export_directory": str(export_dir),
|
||||
"files": exported_files,
|
||||
"is_async": False,
|
||||
}
|
||||
@@ -17,7 +17,7 @@ from fastapi import APIRouter, Depends, Path
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.services.vendor_service import vendor_service # noqa: mod-004
|
||||
from app.modules.tenancy.services.vendor_service import vendor_service # noqa: mod-004
|
||||
from models.schema.vendor import VendorDetailResponse
|
||||
|
||||
vendor_router = APIRouter()
|
||||
|
||||
@@ -21,8 +21,8 @@ from sqlalchemy.orm import Session
|
||||
from app.api.deps import get_current_vendor_api
|
||||
from app.core.database import get_db
|
||||
from app.core.environment import should_use_secure_cookies
|
||||
from app.exceptions import InvalidCredentialsException
|
||||
from app.services.auth_service import auth_service
|
||||
from app.modules.tenancy.exceptions import InvalidCredentialsException
|
||||
from app.modules.core.services.auth_service import auth_service
|
||||
from middleware.vendor_context import get_current_vendor
|
||||
from models.schema.auth import UserContext
|
||||
from models.schema.auth import LogoutResponse, UserLogin, VendorUserResponse
|
||||
|
||||
@@ -13,7 +13,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_vendor_api
|
||||
from app.core.database import get_db
|
||||
from app.services.vendor_service import vendor_service
|
||||
from app.modules.tenancy.services.vendor_service import vendor_service
|
||||
from models.schema.auth import UserContext
|
||||
from models.schema.vendor import VendorResponse, VendorUpdate
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ from app.api.deps import (
|
||||
)
|
||||
from app.core.database import get_db
|
||||
from app.core.permissions import VendorPermissions
|
||||
from app.services.vendor_team_service import vendor_team_service
|
||||
from app.modules.tenancy.services.vendor_team_service import vendor_team_service
|
||||
from models.schema.auth import UserContext
|
||||
from models.schema.team import (
|
||||
BulkRemoveRequest,
|
||||
@@ -237,7 +237,7 @@ def get_team_member(
|
||||
|
||||
member = next((m for m in members if m["id"] == user_id), None)
|
||||
if not member:
|
||||
from app.exceptions import UserNotFoundException
|
||||
from app.modules.tenancy.exceptions import UserNotFoundException
|
||||
|
||||
raise UserNotFoundException(str(user_id))
|
||||
|
||||
|
||||
2
app/modules/tenancy/routes/pages/__init__.py
Normal file
2
app/modules/tenancy/routes/pages/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# app/modules/tenancy/routes/pages/__init__.py
|
||||
"""Tenancy module page routes."""
|
||||
548
app/modules/tenancy/routes/pages/admin.py
Normal file
548
app/modules/tenancy/routes/pages/admin.py
Normal file
@@ -0,0 +1,548 @@
|
||||
# app/modules/tenancy/routes/pages/admin.py
|
||||
"""
|
||||
Tenancy Admin Page Routes (HTML rendering).
|
||||
|
||||
Admin pages for multi-tenant management:
|
||||
- Companies
|
||||
- Vendors
|
||||
- Vendor domains
|
||||
- Vendor themes
|
||||
- Admin users
|
||||
- Platforms
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, Path, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_db, require_menu_access
|
||||
from app.modules.core.utils.page_context import get_admin_context
|
||||
from app.templates_config import templates
|
||||
from models.database.admin_menu_config import FrontendType
|
||||
from models.database.user import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# COMPANY MANAGEMENT ROUTES
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get("/companies", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def admin_companies_list_page(
|
||||
request: Request,
|
||||
current_user: User = Depends(require_menu_access("companies", FrontendType.ADMIN)),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Render companies management page.
|
||||
Shows list of all companies with stats.
|
||||
"""
|
||||
return templates.TemplateResponse(
|
||||
"tenancy/admin/companies.html",
|
||||
get_admin_context(request, current_user),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/companies/create", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def admin_company_create_page(
|
||||
request: Request,
|
||||
current_user: User = Depends(require_menu_access("companies", FrontendType.ADMIN)),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Render company creation form.
|
||||
"""
|
||||
return templates.TemplateResponse(
|
||||
"tenancy/admin/company-create.html",
|
||||
get_admin_context(request, current_user),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/companies/{company_id}", response_class=HTMLResponse, include_in_schema=False
|
||||
)
|
||||
async def admin_company_detail_page(
|
||||
request: Request,
|
||||
company_id: int = Path(..., description="Company ID"),
|
||||
current_user: User = Depends(require_menu_access("companies", FrontendType.ADMIN)),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Render company detail view.
|
||||
"""
|
||||
return templates.TemplateResponse(
|
||||
"tenancy/admin/company-detail.html",
|
||||
get_admin_context(request, current_user, company_id=company_id),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/companies/{company_id}/edit",
|
||||
response_class=HTMLResponse,
|
||||
include_in_schema=False,
|
||||
)
|
||||
async def admin_company_edit_page(
|
||||
request: Request,
|
||||
company_id: int = Path(..., description="Company ID"),
|
||||
current_user: User = Depends(require_menu_access("companies", FrontendType.ADMIN)),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Render company edit form.
|
||||
"""
|
||||
return templates.TemplateResponse(
|
||||
"tenancy/admin/company-edit.html",
|
||||
get_admin_context(request, current_user, company_id=company_id),
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# VENDOR MANAGEMENT ROUTES
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get("/vendors", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def admin_vendors_list_page(
|
||||
request: Request,
|
||||
current_user: User = Depends(require_menu_access("vendors", FrontendType.ADMIN)),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Render vendors management page.
|
||||
Shows list of all vendors with stats.
|
||||
"""
|
||||
return templates.TemplateResponse(
|
||||
"tenancy/admin/vendors.html",
|
||||
get_admin_context(request, current_user),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/vendors/create", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def admin_vendor_create_page(
|
||||
request: Request,
|
||||
current_user: User = Depends(require_menu_access("vendors", FrontendType.ADMIN)),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Render vendor creation form.
|
||||
"""
|
||||
return templates.TemplateResponse(
|
||||
"tenancy/admin/vendor-create.html",
|
||||
get_admin_context(request, current_user),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/vendors/{vendor_code}", response_class=HTMLResponse, include_in_schema=False
|
||||
)
|
||||
async def admin_vendor_detail_page(
|
||||
request: Request,
|
||||
vendor_code: str = Path(..., description="Vendor code"),
|
||||
current_user: User = Depends(require_menu_access("vendors", FrontendType.ADMIN)),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Render vendor detail page.
|
||||
Shows full vendor information.
|
||||
"""
|
||||
return templates.TemplateResponse(
|
||||
"tenancy/admin/vendor-detail.html",
|
||||
get_admin_context(request, current_user, vendor_code=vendor_code),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/vendors/{vendor_code}/edit", response_class=HTMLResponse, include_in_schema=False
|
||||
)
|
||||
async def admin_vendor_edit_page(
|
||||
request: Request,
|
||||
vendor_code: str = Path(..., description="Vendor code"),
|
||||
current_user: User = Depends(require_menu_access("vendors", FrontendType.ADMIN)),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Render vendor edit form.
|
||||
"""
|
||||
return templates.TemplateResponse(
|
||||
"tenancy/admin/vendor-edit.html",
|
||||
get_admin_context(request, current_user, vendor_code=vendor_code),
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# VENDOR DOMAINS ROUTES
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get(
|
||||
"/vendors/{vendor_code}/domains",
|
||||
response_class=HTMLResponse,
|
||||
include_in_schema=False,
|
||||
)
|
||||
async def admin_vendor_domains_page(
|
||||
request: Request,
|
||||
vendor_code: str = Path(..., description="Vendor code"),
|
||||
current_user: User = Depends(require_menu_access("vendors", FrontendType.ADMIN)),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Render vendor domains management page.
|
||||
Shows custom domains, verification status, and DNS configuration.
|
||||
"""
|
||||
return templates.TemplateResponse(
|
||||
"tenancy/admin/vendor-domains.html",
|
||||
get_admin_context(request, current_user, vendor_code=vendor_code),
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# VENDOR THEMES ROUTES
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get("/vendor-themes", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def admin_vendor_themes_page(
|
||||
request: Request,
|
||||
current_user: User = Depends(
|
||||
require_menu_access("vendor-themes", FrontendType.ADMIN)
|
||||
),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Render vendor themes selection page.
|
||||
Allows admins to select a vendor to customize their theme.
|
||||
"""
|
||||
return templates.TemplateResponse(
|
||||
"tenancy/admin/vendor-themes.html",
|
||||
get_admin_context(request, current_user),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/vendors/{vendor_code}/theme",
|
||||
response_class=HTMLResponse,
|
||||
include_in_schema=False,
|
||||
)
|
||||
async def admin_vendor_theme_page(
|
||||
request: Request,
|
||||
vendor_code: str = Path(..., description="Vendor code"),
|
||||
current_user: User = Depends(
|
||||
require_menu_access("vendor-themes", FrontendType.ADMIN)
|
||||
),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Render vendor theme customization page.
|
||||
Allows admins to customize colors, fonts, layout, and branding.
|
||||
"""
|
||||
return templates.TemplateResponse(
|
||||
"tenancy/admin/vendor-theme.html",
|
||||
get_admin_context(request, current_user, vendor_code=vendor_code),
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# ADMIN USER MANAGEMENT ROUTES (Super Admin Only)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get("/admin-users", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def admin_users_list_page(
|
||||
request: Request,
|
||||
current_user: User = Depends(
|
||||
require_menu_access("admin-users", FrontendType.ADMIN)
|
||||
),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Render admin users management page.
|
||||
Shows list of all admin users (super admins and platform admins).
|
||||
Super admin only (menu is in super_admin_only section).
|
||||
"""
|
||||
return templates.TemplateResponse(
|
||||
"tenancy/admin/admin-users.html",
|
||||
get_admin_context(request, current_user),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/admin-users/create", response_class=HTMLResponse, include_in_schema=False
|
||||
)
|
||||
async def admin_user_create_page(
|
||||
request: Request,
|
||||
current_user: User = Depends(
|
||||
require_menu_access("admin-users", FrontendType.ADMIN)
|
||||
),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Render admin user creation form.
|
||||
Super admin only (menu is in super_admin_only section).
|
||||
"""
|
||||
return templates.TemplateResponse(
|
||||
"tenancy/admin/user-create.html",
|
||||
get_admin_context(request, current_user),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/admin-users/{user_id}", response_class=HTMLResponse, include_in_schema=False
|
||||
)
|
||||
async def admin_user_detail_page(
|
||||
request: Request,
|
||||
user_id: int = Path(..., description="User ID"),
|
||||
current_user: User = Depends(
|
||||
require_menu_access("admin-users", FrontendType.ADMIN)
|
||||
),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Render admin user detail view.
|
||||
Super admin only (menu is in super_admin_only section).
|
||||
"""
|
||||
return templates.TemplateResponse(
|
||||
"tenancy/admin/admin-user-detail.html",
|
||||
get_admin_context(request, current_user, user_id=user_id),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/admin-users/{user_id}/edit", response_class=HTMLResponse, include_in_schema=False
|
||||
)
|
||||
async def admin_user_edit_page(
|
||||
request: Request,
|
||||
user_id: int = Path(..., description="User ID"),
|
||||
current_user: User = Depends(
|
||||
require_menu_access("admin-users", FrontendType.ADMIN)
|
||||
),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Render admin user edit form.
|
||||
Super admin only (menu is in super_admin_only section).
|
||||
"""
|
||||
return templates.TemplateResponse(
|
||||
"tenancy/admin/admin-user-edit.html",
|
||||
get_admin_context(request, current_user, user_id=user_id),
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# USER MANAGEMENT ROUTES (Legacy - Redirects)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get("/users", response_class=RedirectResponse, include_in_schema=False)
|
||||
async def admin_users_page_redirect():
|
||||
"""
|
||||
Redirect old /admin/users to /admin/admin-users.
|
||||
"""
|
||||
return RedirectResponse(url="/admin/admin-users", status_code=302)
|
||||
|
||||
|
||||
@router.get("/users/create", response_class=RedirectResponse, include_in_schema=False)
|
||||
async def admin_user_create_page_redirect():
|
||||
"""
|
||||
Redirect old /admin/users/create to /admin/admin-users/create.
|
||||
"""
|
||||
return RedirectResponse(url="/admin/admin-users/create", status_code=302)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/users/{user_id}", response_class=RedirectResponse, include_in_schema=False
|
||||
)
|
||||
async def admin_user_detail_page_redirect(
|
||||
user_id: int = Path(..., description="User ID"),
|
||||
):
|
||||
"""
|
||||
Redirect old /admin/users/{id} to /admin/admin-users/{id}.
|
||||
"""
|
||||
return RedirectResponse(url=f"/admin/admin-users/{user_id}", status_code=302)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/users/{user_id}/edit", response_class=RedirectResponse, include_in_schema=False
|
||||
)
|
||||
async def admin_user_edit_page_redirect(
|
||||
user_id: int = Path(..., description="User ID"),
|
||||
):
|
||||
"""
|
||||
Redirect old /admin/users/{id}/edit to /admin/admin-users/{id}/edit.
|
||||
"""
|
||||
return RedirectResponse(url=f"/admin/admin-users/{user_id}/edit", status_code=302)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# PLATFORM MANAGEMENT ROUTES (Multi-Platform Support)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get("/platforms", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def admin_platforms_list(
|
||||
request: Request,
|
||||
current_user: User = Depends(require_menu_access("platforms", FrontendType.ADMIN)),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Render platforms management page.
|
||||
Shows all platforms (OMS, Loyalty, etc.) with their configuration.
|
||||
"""
|
||||
return templates.TemplateResponse(
|
||||
"tenancy/admin/platforms.html",
|
||||
get_admin_context(request, current_user),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/platforms/{platform_code}", response_class=HTMLResponse, include_in_schema=False
|
||||
)
|
||||
async def admin_platform_detail(
|
||||
request: Request,
|
||||
platform_code: str = Path(..., description="Platform code (oms, loyalty, etc.)"),
|
||||
current_user: User = Depends(require_menu_access("platforms", FrontendType.ADMIN)),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Render platform detail page.
|
||||
Shows platform configuration, marketing pages, and vendor defaults.
|
||||
"""
|
||||
return templates.TemplateResponse(
|
||||
"tenancy/admin/platform-detail.html",
|
||||
get_admin_context(request, current_user, platform_code=platform_code),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/platforms/{platform_code}/edit",
|
||||
response_class=HTMLResponse,
|
||||
include_in_schema=False,
|
||||
)
|
||||
async def admin_platform_edit(
|
||||
request: Request,
|
||||
platform_code: str = Path(..., description="Platform code"),
|
||||
current_user: User = Depends(require_menu_access("platforms", FrontendType.ADMIN)),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Render platform edit form.
|
||||
Allows editing platform settings, branding, and configuration.
|
||||
"""
|
||||
return templates.TemplateResponse(
|
||||
"tenancy/admin/platform-edit.html",
|
||||
get_admin_context(request, current_user, platform_code=platform_code),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/platforms/{platform_code}/menu-config",
|
||||
response_class=HTMLResponse,
|
||||
include_in_schema=False,
|
||||
)
|
||||
async def admin_platform_menu_config(
|
||||
request: Request,
|
||||
platform_code: str = Path(..., description="Platform code"),
|
||||
current_user: User = Depends(require_menu_access("platforms", FrontendType.ADMIN)),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Render platform menu configuration page.
|
||||
Super admin only - allows configuring which menu items are visible
|
||||
for the platform's admin and vendor frontends.
|
||||
"""
|
||||
if not current_user.is_super_admin:
|
||||
return RedirectResponse(
|
||||
url=f"/admin/platforms/{platform_code}", status_code=302
|
||||
)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"tenancy/admin/platform-menu-config.html",
|
||||
get_admin_context(request, current_user, platform_code=platform_code),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/platforms/{platform_code}/modules",
|
||||
response_class=HTMLResponse,
|
||||
include_in_schema=False,
|
||||
)
|
||||
async def admin_platform_modules(
|
||||
request: Request,
|
||||
platform_code: str = Path(..., description="Platform code"),
|
||||
current_user: User = Depends(require_menu_access("platforms", FrontendType.ADMIN)),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Render platform module configuration page.
|
||||
Super admin only - allows enabling/disabling feature modules
|
||||
for the platform.
|
||||
"""
|
||||
if not current_user.is_super_admin:
|
||||
return RedirectResponse(
|
||||
url=f"/admin/platforms/{platform_code}", status_code=302
|
||||
)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"tenancy/admin/platform-modules.html",
|
||||
get_admin_context(request, current_user, platform_code=platform_code),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/platforms/{platform_code}/modules/{module_code}",
|
||||
response_class=HTMLResponse,
|
||||
include_in_schema=False,
|
||||
)
|
||||
async def admin_module_info(
|
||||
request: Request,
|
||||
platform_code: str = Path(..., description="Platform code"),
|
||||
module_code: str = Path(..., description="Module code"),
|
||||
current_user: User = Depends(require_menu_access("platforms", FrontendType.ADMIN)),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Render module info/detail page.
|
||||
Shows module details including features, menu items, dependencies,
|
||||
and self-contained module information.
|
||||
"""
|
||||
if not current_user.is_super_admin:
|
||||
return RedirectResponse(
|
||||
url=f"/admin/platforms/{platform_code}", status_code=302
|
||||
)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"tenancy/admin/module-info.html",
|
||||
get_admin_context(
|
||||
request, current_user, platform_code=platform_code, module_code=module_code
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/platforms/{platform_code}/modules/{module_code}/config",
|
||||
response_class=HTMLResponse,
|
||||
include_in_schema=False,
|
||||
)
|
||||
async def admin_module_config(
|
||||
request: Request,
|
||||
platform_code: str = Path(..., description="Platform code"),
|
||||
module_code: str = Path(..., description="Module code"),
|
||||
current_user: User = Depends(require_menu_access("platforms", FrontendType.ADMIN)),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Render module configuration page.
|
||||
Allows configuring module-specific settings.
|
||||
"""
|
||||
if not current_user.is_super_admin:
|
||||
return RedirectResponse(
|
||||
url=f"/admin/platforms/{platform_code}", status_code=302
|
||||
)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"tenancy/admin/module-config.html",
|
||||
get_admin_context(
|
||||
request, current_user, platform_code=platform_code, module_code=module_code
|
||||
),
|
||||
)
|
||||
156
app/modules/tenancy/routes/pages/vendor.py
Normal file
156
app/modules/tenancy/routes/pages/vendor.py
Normal file
@@ -0,0 +1,156 @@
|
||||
# app/modules/tenancy/routes/pages/vendor.py
|
||||
"""
|
||||
Tenancy Vendor Page Routes (HTML rendering).
|
||||
|
||||
Vendor pages for authentication and account management:
|
||||
- Root redirect
|
||||
- Login
|
||||
- Team management
|
||||
- Profile
|
||||
- Settings
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, Path, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import (
|
||||
get_current_vendor_from_cookie_or_header,
|
||||
get_current_vendor_optional,
|
||||
get_db,
|
||||
)
|
||||
from app.modules.core.utils.page_context import get_vendor_context
|
||||
from app.templates_config import templates
|
||||
from models.database.user import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# PUBLIC ROUTES (No Authentication Required)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get("/{vendor_code}", response_class=RedirectResponse, include_in_schema=False)
|
||||
async def vendor_root_no_slash(vendor_code: str = Path(..., description="Vendor code")):
|
||||
"""
|
||||
Redirect /vendor/{code} (no trailing slash) to login page.
|
||||
Handles requests without trailing slash.
|
||||
"""
|
||||
return RedirectResponse(url=f"/vendor/{vendor_code}/login", status_code=302)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{vendor_code}/", response_class=RedirectResponse, include_in_schema=False
|
||||
)
|
||||
async def vendor_root(
|
||||
vendor_code: str = Path(..., description="Vendor code"),
|
||||
current_user: User | None = Depends(get_current_vendor_optional),
|
||||
):
|
||||
"""
|
||||
Redirect /vendor/{code}/ based on authentication status.
|
||||
|
||||
- Authenticated vendor users -> /vendor/{code}/dashboard
|
||||
- Unauthenticated users -> /vendor/{code}/login
|
||||
"""
|
||||
if current_user:
|
||||
return RedirectResponse(
|
||||
url=f"/vendor/{vendor_code}/dashboard", status_code=302
|
||||
)
|
||||
|
||||
return RedirectResponse(url=f"/vendor/{vendor_code}/login", status_code=302)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{vendor_code}/login", response_class=HTMLResponse, include_in_schema=False
|
||||
)
|
||||
async def vendor_login_page(
|
||||
request: Request,
|
||||
vendor_code: str = Path(..., description="Vendor code"),
|
||||
current_user: User | None = Depends(get_current_vendor_optional),
|
||||
):
|
||||
"""
|
||||
Render vendor login page.
|
||||
|
||||
If user is already authenticated as vendor, redirect to dashboard.
|
||||
Otherwise, show login form.
|
||||
|
||||
JavaScript will:
|
||||
- Load vendor info via API
|
||||
- Handle login form submission
|
||||
- Redirect to dashboard on success
|
||||
"""
|
||||
if current_user:
|
||||
return RedirectResponse(
|
||||
url=f"/vendor/{vendor_code}/dashboard", status_code=302
|
||||
)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"tenancy/vendor/login.html",
|
||||
{
|
||||
"request": request,
|
||||
"vendor_code": vendor_code,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# AUTHENTICATED ROUTES (Vendor Users Only)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{vendor_code}/team", response_class=HTMLResponse, include_in_schema=False
|
||||
)
|
||||
async def vendor_team_page(
|
||||
request: Request,
|
||||
vendor_code: str = Path(..., description="Vendor code"),
|
||||
current_user: User = Depends(get_current_vendor_from_cookie_or_header),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Render team management page.
|
||||
JavaScript loads team members via API.
|
||||
"""
|
||||
return templates.TemplateResponse(
|
||||
"tenancy/vendor/team.html",
|
||||
get_vendor_context(request, db, current_user, vendor_code),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{vendor_code}/profile", response_class=HTMLResponse, include_in_schema=False
|
||||
)
|
||||
async def vendor_profile_page(
|
||||
request: Request,
|
||||
vendor_code: str = Path(..., description="Vendor code"),
|
||||
current_user: User = Depends(get_current_vendor_from_cookie_or_header),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Render vendor profile page.
|
||||
User can manage their personal profile information.
|
||||
"""
|
||||
return templates.TemplateResponse(
|
||||
"tenancy/vendor/profile.html",
|
||||
get_vendor_context(request, db, current_user, vendor_code),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{vendor_code}/settings", response_class=HTMLResponse, include_in_schema=False
|
||||
)
|
||||
async def vendor_settings_page(
|
||||
request: Request,
|
||||
vendor_code: str = Path(..., description="Vendor code"),
|
||||
current_user: User = Depends(get_current_vendor_from_cookie_or_header),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Render vendor settings page.
|
||||
JavaScript loads settings via API.
|
||||
"""
|
||||
return templates.TemplateResponse(
|
||||
"tenancy/vendor/settings.html",
|
||||
get_vendor_context(request, db, current_user, vendor_code),
|
||||
)
|
||||
Reference in New Issue
Block a user