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:
2026-02-01 14:34:16 +01:00
parent 843703258f
commit 4e28d91a78
542 changed files with 11603 additions and 9037 deletions

View File

@@ -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",

View 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"])

View 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,
)

View 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"}

View 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)

View 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,
)

View 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,
}

View 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"],
)

View 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,
}

View File

@@ -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()

View File

@@ -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

View File

@@ -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

View File

@@ -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))