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:
@@ -39,7 +39,7 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.exceptions import (
|
||||
from app.modules.tenancy.exceptions import (
|
||||
AdminRequiredException,
|
||||
InsufficientPermissionsException,
|
||||
InsufficientVendorPermissionsException,
|
||||
@@ -48,7 +48,7 @@ from app.exceptions import (
|
||||
VendorNotFoundException,
|
||||
VendorOwnerOnlyException,
|
||||
)
|
||||
from app.services.vendor_service import vendor_service
|
||||
from app.modules.tenancy.services.vendor_service import vendor_service
|
||||
from middleware.auth import AuthManager
|
||||
from middleware.rate_limiter import RateLimiter
|
||||
from models.database.user import User as UserModel
|
||||
@@ -552,7 +552,7 @@ def require_menu_access(menu_item_id: str, frontend_type: "FrontendType"):
|
||||
"""
|
||||
from app.modules.registry import get_menu_item_module
|
||||
from app.modules.service import module_service
|
||||
from app.services.menu_service import menu_service
|
||||
from app.modules.core.services.menu_service import menu_service
|
||||
from models.database.admin_menu_config import FrontendType as FT
|
||||
|
||||
def _check_menu_access(
|
||||
|
||||
@@ -5,13 +5,12 @@ API router configuration for multi-tenant ecommerce platform.
|
||||
This module provides:
|
||||
- API version 1 route aggregation
|
||||
- Route organization by user type (admin, vendor, storefront)
|
||||
- Proper route prefixing and tagging
|
||||
- Auto-discovery of module routes
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v1 import admin, platform, storefront, vendor
|
||||
from app.api.v1.shared import language, webhooks
|
||||
from app.api.v1 import admin, public, storefront, vendor, webhooks
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
@@ -38,16 +37,17 @@ api_router.include_router(vendor.router, prefix="/v1/vendor", tags=["vendor"])
|
||||
api_router.include_router(storefront.router, prefix="/v1/storefront", tags=["storefront"])
|
||||
|
||||
# ============================================================================
|
||||
# PLATFORM ROUTES (Public marketing and signup)
|
||||
# Prefix: /api/v1/platform
|
||||
# PUBLIC ROUTES (Unauthenticated endpoints)
|
||||
# Prefix: /api/v1/public
|
||||
# Includes: /signup, /pricing, /letzshop-vendors, /language
|
||||
# ============================================================================
|
||||
|
||||
api_router.include_router(platform.router, prefix="/v1/platform", tags=["platform"])
|
||||
api_router.include_router(public.router, prefix="/v1/public", tags=["public"])
|
||||
|
||||
# ============================================================================
|
||||
# SHARED ROUTES (Cross-context utilities)
|
||||
# Prefix: /api/v1
|
||||
# WEBHOOK ROUTES (External service callbacks via auto-discovery)
|
||||
# Prefix: /api/v1/webhooks
|
||||
# Includes: /stripe
|
||||
# ============================================================================
|
||||
|
||||
api_router.include_router(language.router, prefix="/v1", tags=["language"])
|
||||
api_router.include_router(webhooks.router, prefix="/v1", tags=["webhooks"])
|
||||
api_router.include_router(webhooks.router, prefix="/v1/webhooks", tags=["webhooks"])
|
||||
|
||||
@@ -2,68 +2,45 @@
|
||||
"""
|
||||
Admin API router aggregation.
|
||||
|
||||
This module combines all admin-related JSON API endpoints:
|
||||
- Authentication (login/logout)
|
||||
- Vendor management (CRUD, bulk operations)
|
||||
- Vendor domains management (custom domains, DNS verification)
|
||||
- Vendor themes management (theme editor, presets)
|
||||
- User management (status, roles)
|
||||
- Dashboard and statistics
|
||||
- Marketplace monitoring
|
||||
- Audit logging
|
||||
- Platform settings
|
||||
- Notifications and alerts
|
||||
- Code quality and architecture validation
|
||||
This module combines legacy admin routes with auto-discovered module routes.
|
||||
|
||||
LEGACY ROUTES (defined in app/api/v1/admin/):
|
||||
- /menu-config/* - Navigation configuration (super admin)
|
||||
- /modules/* - Module management (super admin)
|
||||
- /module-config/* - Module settings (super admin)
|
||||
|
||||
AUTO-DISCOVERED MODULE ROUTES:
|
||||
- tenancy: auth, admin_users, users, companies, platforms, vendors, vendor_domains
|
||||
- core: dashboard, settings
|
||||
- messaging: messages, notifications, email-templates
|
||||
- monitoring: logs, tasks, tests, code_quality, audit, platform-health
|
||||
- billing: subscriptions, invoices, payments
|
||||
- inventory: stock management
|
||||
- orders: order management, fulfillment, exceptions
|
||||
- marketplace: letzshop integration, product sync
|
||||
- catalog: vendor product catalog
|
||||
- cms: content-pages, images, media, vendor-themes
|
||||
- customers: customer management
|
||||
|
||||
IMPORTANT:
|
||||
- This router is for JSON API endpoints only
|
||||
- HTML page routes are mounted separately in main.py at /vendor/*
|
||||
- Do NOT include pages.router here - it causes route conflicts
|
||||
|
||||
MODULE SYSTEM:
|
||||
Routes can be module-gated using require_module_access() dependency.
|
||||
For multi-tenant apps, module enablement is checked at request time
|
||||
based on platform context (not at route registration time).
|
||||
|
||||
Self-contained modules (auto-discovered from app/modules/{module}/routes/api/admin.py):
|
||||
- billing: Subscription tiers, vendor billing, invoices
|
||||
- inventory: Stock management, inventory tracking
|
||||
- orders: Order management, fulfillment, exceptions
|
||||
- marketplace: Letzshop integration, product sync, marketplace products
|
||||
- catalog: Vendor product catalog management
|
||||
- cms: Content pages management
|
||||
- customers: Customer management
|
||||
- HTML page routes are mounted separately in main.py
|
||||
- Module routes are auto-discovered from app/modules/{module}/routes/api/admin.py
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
# Import all admin routers (legacy routes that haven't been migrated to modules)
|
||||
# NOTE: Migrated to modules (auto-discovered):
|
||||
# - tenancy: auth, admin_users, users, companies, platforms, vendors, vendor_domains
|
||||
# - core: dashboard, settings
|
||||
# - messaging: messages, notifications, email_templates
|
||||
# - monitoring: logs, tasks, tests, code_quality, audit, platform_health
|
||||
# - cms: content_pages, images, media, vendor_themes
|
||||
from . import (
|
||||
admin_users,
|
||||
audit,
|
||||
auth,
|
||||
background_tasks,
|
||||
code_quality,
|
||||
companies,
|
||||
dashboard,
|
||||
email_templates,
|
||||
images,
|
||||
logs,
|
||||
media,
|
||||
menu_config,
|
||||
messages,
|
||||
module_config,
|
||||
modules,
|
||||
monitoring,
|
||||
notifications,
|
||||
platform_health,
|
||||
platforms,
|
||||
settings,
|
||||
tests,
|
||||
users,
|
||||
vendor_domains,
|
||||
vendor_themes,
|
||||
vendors,
|
||||
)
|
||||
|
||||
# Create admin router
|
||||
@@ -71,32 +48,9 @@ router = APIRouter()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Authentication & Authorization
|
||||
# Framework Config (remain in legacy - super admin only)
|
||||
# ============================================================================
|
||||
|
||||
# Include authentication endpoints
|
||||
router.include_router(auth.router, tags=["admin-auth"])
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Company & Vendor Management
|
||||
# ============================================================================
|
||||
|
||||
# Include company management endpoints
|
||||
router.include_router(companies.router, tags=["admin-companies"])
|
||||
|
||||
# Include vendor management endpoints
|
||||
router.include_router(vendors.router, tags=["admin-vendors"])
|
||||
|
||||
# Include vendor domains management endpoints
|
||||
router.include_router(vendor_domains.router, tags=["admin-vendor-domains"])
|
||||
|
||||
# Include vendor themes management endpoints
|
||||
router.include_router(vendor_themes.router, tags=["admin-vendor-themes"])
|
||||
|
||||
# Include platforms management endpoints (multi-platform CMS)
|
||||
router.include_router(platforms.router, tags=["admin-platforms"])
|
||||
|
||||
# Include menu configuration endpoints (super admin only)
|
||||
router.include_router(menu_config.router, tags=["admin-menu-config"])
|
||||
|
||||
@@ -107,82 +61,13 @@ router.include_router(modules.router, tags=["admin-modules"])
|
||||
router.include_router(module_config.router, tags=["admin-module-config"])
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# User Management
|
||||
# ============================================================================
|
||||
|
||||
# Include user management endpoints
|
||||
router.include_router(users.router, tags=["admin-users"])
|
||||
|
||||
# Include admin user management endpoints (super admin only)
|
||||
router.include_router(admin_users.router, tags=["admin-admin-users"])
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Dashboard & Statistics
|
||||
# ============================================================================
|
||||
|
||||
# Include dashboard and statistics endpoints
|
||||
router.include_router(dashboard.router, tags=["admin-dashboard"])
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Platform Administration
|
||||
# ============================================================================
|
||||
|
||||
# Include background tasks monitoring endpoints
|
||||
router.include_router(
|
||||
background_tasks.router, prefix="/background-tasks", tags=["admin-background-tasks"]
|
||||
)
|
||||
|
||||
# Include audit logging endpoints
|
||||
router.include_router(audit.router, tags=["admin-audit"])
|
||||
|
||||
# Include platform settings endpoints
|
||||
router.include_router(settings.router, tags=["admin-settings"])
|
||||
|
||||
# Include notifications and alerts endpoints
|
||||
router.include_router(notifications.router, tags=["admin-notifications"])
|
||||
|
||||
# Include messaging endpoints
|
||||
router.include_router(messages.router, tags=["admin-messages"])
|
||||
|
||||
# Include email templates management endpoints
|
||||
router.include_router(email_templates.router, tags=["admin-email-templates"])
|
||||
|
||||
# Include log management endpoints
|
||||
router.include_router(logs.router, tags=["admin-logs"])
|
||||
|
||||
# Include image management endpoints
|
||||
router.include_router(images.router, tags=["admin-images"])
|
||||
|
||||
# Include media library management endpoints
|
||||
router.include_router(media.router, tags=["admin-media"])
|
||||
|
||||
# Include platform health endpoints
|
||||
router.include_router(
|
||||
platform_health.router, prefix="/platform", tags=["admin-platform-health"]
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Code Quality & Architecture
|
||||
# ============================================================================
|
||||
|
||||
# Include code quality and architecture validation endpoints
|
||||
router.include_router(
|
||||
code_quality.router, prefix="/code-quality", tags=["admin-code-quality"]
|
||||
)
|
||||
|
||||
# Include test runner endpoints
|
||||
router.include_router(tests.router, prefix="/tests", tags=["admin-tests"])
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Auto-discovered Module Routes
|
||||
# ============================================================================
|
||||
# Routes from self-contained modules are auto-discovered and registered.
|
||||
# Modules include: billing, inventory, orders, marketplace, cms, customers
|
||||
# Modules include: billing, inventory, orders, marketplace, cms, customers,
|
||||
# monitoring (logs, tasks, tests, code_quality, audit, platform_health),
|
||||
# messaging (messages, notifications, email_templates)
|
||||
|
||||
from app.modules.routes import get_admin_api_routes
|
||||
|
||||
|
||||
@@ -1,397 +0,0 @@
|
||||
# app/api/v1/admin/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.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
|
||||
|
||||
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
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
|
||||
@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,
|
||||
}
|
||||
|
||||
|
||||
@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,
|
||||
}
|
||||
|
||||
|
||||
@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,
|
||||
}
|
||||
|
||||
|
||||
@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,
|
||||
}
|
||||
|
||||
|
||||
@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,
|
||||
}
|
||||
|
||||
|
||||
@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,
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
# app/api/v1/admin/audit.py
|
||||
"""
|
||||
Admin audit log endpoints.
|
||||
|
||||
Provides endpoints for:
|
||||
- Viewing audit logs with filtering
|
||||
- Tracking admin actions
|
||||
- Generating audit reports
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_admin_api
|
||||
from app.core.database import get_db
|
||||
from app.services.admin_audit_service import admin_audit_service
|
||||
from models.schema.auth import UserContext
|
||||
from models.schema.admin import (
|
||||
AdminAuditLogFilters,
|
||||
AdminAuditLogListResponse,
|
||||
AdminAuditLogResponse,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/audit")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@router.get("/logs", response_model=AdminAuditLogListResponse)
|
||||
def get_audit_logs(
|
||||
admin_user_id: int | None = Query(None, description="Filter by admin user"),
|
||||
action: str | None = Query(None, description="Filter by action type"),
|
||||
target_type: str | None = Query(None, description="Filter by target type"),
|
||||
date_from: datetime | None = Query(None, description="Filter from date"),
|
||||
date_to: datetime | None = Query(None, description="Filter to date"),
|
||||
skip: int = Query(0, ge=0, description="Number of records to skip"),
|
||||
limit: int = Query(100, ge=1, le=1000, description="Maximum records to return"),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Get filtered admin audit logs.
|
||||
|
||||
Returns paginated list of all admin actions with filtering options.
|
||||
Useful for compliance, security audits, and tracking admin activities.
|
||||
"""
|
||||
filters = AdminAuditLogFilters(
|
||||
admin_user_id=admin_user_id,
|
||||
action=action,
|
||||
target_type=target_type,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
logs = admin_audit_service.get_audit_logs(db, filters)
|
||||
total = admin_audit_service.get_audit_logs_count(db, filters)
|
||||
|
||||
logger.info(f"Admin {current_admin.username} retrieved {len(logs)} audit logs")
|
||||
|
||||
return AdminAuditLogListResponse(logs=logs, total=total, skip=skip, limit=limit)
|
||||
|
||||
|
||||
@router.get("/logs/recent", response_model=list[AdminAuditLogResponse])
|
||||
def get_recent_audit_logs(
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Get recent audit logs (last 20 by default)."""
|
||||
filters = AdminAuditLogFilters(limit=limit)
|
||||
return admin_audit_service.get_audit_logs(db, filters)
|
||||
|
||||
|
||||
@router.get("/logs/my-actions", response_model=list[AdminAuditLogResponse])
|
||||
def get_my_actions(
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Get audit logs for current admin's actions."""
|
||||
return admin_audit_service.get_recent_actions_by_admin(
|
||||
db=db, admin_user_id=current_admin.id, limit=limit
|
||||
)
|
||||
|
||||
|
||||
@router.get("/logs/target/{target_type}/{target_id}")
|
||||
def get_actions_by_target(
|
||||
target_type: str,
|
||||
target_id: str,
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Get all actions performed on a specific target.
|
||||
|
||||
Useful for tracking the history of a specific vendor, user, or entity.
|
||||
"""
|
||||
return admin_audit_service.get_actions_by_target(
|
||||
db=db, target_type=target_type, target_id=target_id, limit=limit
|
||||
)
|
||||
@@ -1,221 +0,0 @@
|
||||
# app/api/v1/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.exceptions import InsufficientPermissionsException, InvalidCredentialsException
|
||||
from app.services.admin_platform_service import admin_platform_service
|
||||
from app.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
|
||||
|
||||
router = APIRouter(prefix="/auth")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@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"],
|
||||
)
|
||||
|
||||
|
||||
@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
|
||||
|
||||
|
||||
@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")
|
||||
|
||||
|
||||
@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,
|
||||
}
|
||||
|
||||
|
||||
@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,
|
||||
)
|
||||
@@ -1,258 +0,0 @@
|
||||
# app/api/v1/admin/background_tasks.py
|
||||
"""
|
||||
Background Tasks Monitoring API
|
||||
Provides unified view of all background tasks across the system
|
||||
"""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_admin_api
|
||||
from app.core.database import get_db
|
||||
from app.services.background_tasks_service import background_tasks_service
|
||||
from models.schema.auth import UserContext
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class BackgroundTaskResponse(BaseModel):
|
||||
"""Unified background task response"""
|
||||
|
||||
id: int
|
||||
task_type: str # 'import', 'test_run', or 'code_quality_scan'
|
||||
status: str
|
||||
started_at: str | None
|
||||
completed_at: str | None
|
||||
duration_seconds: float | None
|
||||
description: str
|
||||
triggered_by: str | None
|
||||
error_message: str | None
|
||||
details: dict | None
|
||||
celery_task_id: str | None = None # Celery task ID for Flower linking
|
||||
|
||||
|
||||
class BackgroundTasksStatsResponse(BaseModel):
|
||||
"""Statistics for background tasks"""
|
||||
|
||||
total_tasks: int
|
||||
running: int
|
||||
completed: int
|
||||
failed: int
|
||||
tasks_today: int
|
||||
avg_duration_seconds: float | None
|
||||
|
||||
# By type
|
||||
import_jobs: dict
|
||||
test_runs: dict
|
||||
code_quality_scans: dict
|
||||
|
||||
|
||||
def _convert_import_to_response(job) -> BackgroundTaskResponse:
|
||||
"""Convert MarketplaceImportJob to BackgroundTaskResponse"""
|
||||
duration = None
|
||||
if job.started_at and job.completed_at:
|
||||
duration = (job.completed_at - job.started_at).total_seconds()
|
||||
elif job.started_at and job.status == "processing":
|
||||
duration = (datetime.now(UTC) - job.started_at).total_seconds()
|
||||
|
||||
return BackgroundTaskResponse(
|
||||
id=job.id,
|
||||
task_type="import",
|
||||
status=job.status,
|
||||
started_at=job.started_at.isoformat() if job.started_at else None,
|
||||
completed_at=job.completed_at.isoformat() if job.completed_at else None,
|
||||
duration_seconds=duration,
|
||||
description=f"Import from {job.marketplace}: {job.source_url[:50]}..."
|
||||
if len(job.source_url) > 50
|
||||
else f"Import from {job.marketplace}: {job.source_url}",
|
||||
triggered_by=job.user.username if job.user else None,
|
||||
error_message=job.error_message,
|
||||
details={
|
||||
"marketplace": job.marketplace,
|
||||
"vendor_id": job.vendor_id,
|
||||
"imported": job.imported_count,
|
||||
"updated": job.updated_count,
|
||||
"errors": job.error_count,
|
||||
"total_processed": job.total_processed,
|
||||
},
|
||||
celery_task_id=getattr(job, "celery_task_id", None),
|
||||
)
|
||||
|
||||
|
||||
def _convert_test_run_to_response(run) -> BackgroundTaskResponse:
|
||||
"""Convert TestRun to BackgroundTaskResponse"""
|
||||
duration = run.duration_seconds
|
||||
if run.status == "running" and run.timestamp:
|
||||
duration = (datetime.now(UTC) - run.timestamp).total_seconds()
|
||||
|
||||
return BackgroundTaskResponse(
|
||||
id=run.id,
|
||||
task_type="test_run",
|
||||
status=run.status,
|
||||
started_at=run.timestamp.isoformat() if run.timestamp else None,
|
||||
completed_at=None,
|
||||
duration_seconds=duration,
|
||||
description=f"Test run: {run.test_path}",
|
||||
triggered_by=run.triggered_by,
|
||||
error_message=None,
|
||||
details={
|
||||
"test_path": run.test_path,
|
||||
"total_tests": run.total_tests,
|
||||
"passed": run.passed,
|
||||
"failed": run.failed,
|
||||
"errors": run.errors,
|
||||
"pass_rate": run.pass_rate,
|
||||
"git_branch": run.git_branch,
|
||||
},
|
||||
celery_task_id=getattr(run, "celery_task_id", None),
|
||||
)
|
||||
|
||||
|
||||
def _convert_scan_to_response(scan) -> BackgroundTaskResponse:
|
||||
"""Convert ArchitectureScan to BackgroundTaskResponse"""
|
||||
duration = scan.duration_seconds
|
||||
if scan.status in ["pending", "running"] and scan.started_at:
|
||||
duration = (datetime.now(UTC) - scan.started_at).total_seconds()
|
||||
|
||||
# Map validator type to human-readable name
|
||||
validator_names = {
|
||||
"architecture": "Architecture",
|
||||
"security": "Security",
|
||||
"performance": "Performance",
|
||||
}
|
||||
validator_name = validator_names.get(scan.validator_type, scan.validator_type)
|
||||
|
||||
return BackgroundTaskResponse(
|
||||
id=scan.id,
|
||||
task_type="code_quality_scan",
|
||||
status=scan.status,
|
||||
started_at=scan.started_at.isoformat() if scan.started_at else None,
|
||||
completed_at=scan.completed_at.isoformat() if scan.completed_at else None,
|
||||
duration_seconds=duration,
|
||||
description=f"{validator_name} code quality scan",
|
||||
triggered_by=scan.triggered_by,
|
||||
error_message=scan.error_message,
|
||||
details={
|
||||
"validator_type": scan.validator_type,
|
||||
"total_files": scan.total_files,
|
||||
"total_violations": scan.total_violations,
|
||||
"errors": scan.errors,
|
||||
"warnings": scan.warnings,
|
||||
"git_commit_hash": scan.git_commit_hash,
|
||||
"progress_message": scan.progress_message,
|
||||
},
|
||||
celery_task_id=getattr(scan, "celery_task_id", None),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tasks", response_model=list[BackgroundTaskResponse])
|
||||
async def list_background_tasks(
|
||||
status: str | None = Query(None, description="Filter by status"),
|
||||
task_type: str | None = Query(
|
||||
None, description="Filter by type (import, test_run, code_quality_scan)"
|
||||
),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
List all background tasks across the system
|
||||
|
||||
Returns a unified view of import jobs, test runs, and code quality scans.
|
||||
"""
|
||||
tasks = []
|
||||
|
||||
# Get import jobs
|
||||
if task_type is None or task_type == "import":
|
||||
import_jobs = background_tasks_service.get_import_jobs(
|
||||
db, status=status, limit=limit
|
||||
)
|
||||
tasks.extend([_convert_import_to_response(job) for job in import_jobs])
|
||||
|
||||
# Get test runs
|
||||
if task_type is None or task_type == "test_run":
|
||||
test_runs = background_tasks_service.get_test_runs(
|
||||
db, status=status, limit=limit
|
||||
)
|
||||
tasks.extend([_convert_test_run_to_response(run) for run in test_runs])
|
||||
|
||||
# Get code quality scans
|
||||
if task_type is None or task_type == "code_quality_scan":
|
||||
scans = background_tasks_service.get_code_quality_scans(
|
||||
db, status=status, limit=limit
|
||||
)
|
||||
tasks.extend([_convert_scan_to_response(scan) for scan in scans])
|
||||
|
||||
# Sort by start time (most recent first)
|
||||
tasks.sort(
|
||||
key=lambda t: t.started_at or "1970-01-01T00:00:00",
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
return tasks[:limit]
|
||||
|
||||
|
||||
@router.get("/tasks/stats", response_model=BackgroundTasksStatsResponse)
|
||||
async def get_background_tasks_stats(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Get statistics for background tasks
|
||||
"""
|
||||
import_stats = background_tasks_service.get_import_stats(db)
|
||||
test_stats = background_tasks_service.get_test_run_stats(db)
|
||||
scan_stats = background_tasks_service.get_scan_stats(db)
|
||||
|
||||
# Combined stats
|
||||
total_running = (
|
||||
import_stats["running"] + test_stats["running"] + scan_stats["running"]
|
||||
)
|
||||
total_completed = (
|
||||
import_stats["completed"] + test_stats["completed"] + scan_stats["completed"]
|
||||
)
|
||||
total_failed = (
|
||||
import_stats["failed"] + test_stats["failed"] + scan_stats["failed"]
|
||||
)
|
||||
total_tasks = import_stats["total"] + test_stats["total"] + scan_stats["total"]
|
||||
tasks_today = import_stats["today"] + test_stats["today"] + scan_stats["today"]
|
||||
|
||||
return BackgroundTasksStatsResponse(
|
||||
total_tasks=total_tasks,
|
||||
running=total_running,
|
||||
completed=total_completed,
|
||||
failed=total_failed,
|
||||
tasks_today=tasks_today,
|
||||
avg_duration_seconds=test_stats.get("avg_duration"),
|
||||
import_jobs=import_stats,
|
||||
test_runs=test_stats,
|
||||
code_quality_scans=scan_stats,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tasks/running", response_model=list[BackgroundTaskResponse])
|
||||
async def list_running_tasks(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
List currently running background tasks
|
||||
"""
|
||||
tasks = []
|
||||
|
||||
# Running imports
|
||||
running_imports = background_tasks_service.get_running_imports(db)
|
||||
tasks.extend([_convert_import_to_response(job) for job in running_imports])
|
||||
|
||||
# Running test runs
|
||||
running_tests = background_tasks_service.get_running_test_runs(db)
|
||||
tasks.extend([_convert_test_run_to_response(run) for run in running_tests])
|
||||
|
||||
# Running code quality scans
|
||||
running_scans = background_tasks_service.get_running_scans(db)
|
||||
tasks.extend([_convert_scan_to_response(scan) for scan in running_scans])
|
||||
|
||||
return tasks
|
||||
@@ -1,619 +0,0 @@
|
||||
"""
|
||||
Code Quality API Endpoints
|
||||
RESTful API for code quality validation and violation management
|
||||
Supports multiple validator types: architecture, security, performance
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, Query
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_admin_api
|
||||
from app.core.database import get_db
|
||||
from app.exceptions import ScanNotFoundException, ViolationNotFoundException
|
||||
from app.services.code_quality_service import (
|
||||
VALID_VALIDATOR_TYPES,
|
||||
code_quality_service,
|
||||
)
|
||||
from app.tasks.code_quality_tasks import execute_code_quality_scan
|
||||
from app.modules.dev_tools.models import ArchitectureScan
|
||||
from models.schema.auth import UserContext
|
||||
from app.modules.analytics.schemas import CodeQualityDashboardStatsResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# Enums and Constants
|
||||
|
||||
|
||||
class ValidatorType(str, Enum):
|
||||
"""Supported validator types"""
|
||||
|
||||
ARCHITECTURE = "architecture"
|
||||
SECURITY = "security"
|
||||
PERFORMANCE = "performance"
|
||||
|
||||
|
||||
# Pydantic Models for API
|
||||
|
||||
|
||||
class ScanResponse(BaseModel):
|
||||
"""Response model for a scan"""
|
||||
|
||||
id: int
|
||||
timestamp: str
|
||||
validator_type: str
|
||||
status: str
|
||||
started_at: str | None
|
||||
completed_at: str | None
|
||||
progress_message: str | None
|
||||
total_files: int
|
||||
total_violations: int
|
||||
errors: int
|
||||
warnings: int
|
||||
duration_seconds: float
|
||||
triggered_by: str | None
|
||||
git_commit_hash: str | None
|
||||
error_message: str | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ScanRequest(BaseModel):
|
||||
"""Request model for triggering scans"""
|
||||
|
||||
validator_types: list[ValidatorType] = Field(
|
||||
default=[ValidatorType.ARCHITECTURE, ValidatorType.SECURITY, ValidatorType.PERFORMANCE],
|
||||
description="Validator types to run",
|
||||
)
|
||||
|
||||
|
||||
class ScanJobResponse(BaseModel):
|
||||
"""Response model for a queued scan job"""
|
||||
|
||||
id: int
|
||||
validator_type: str
|
||||
status: str
|
||||
message: str
|
||||
|
||||
|
||||
class MultiScanJobResponse(BaseModel):
|
||||
"""Response model for multiple queued scans (background task pattern)"""
|
||||
|
||||
scans: list[ScanJobResponse]
|
||||
message: str
|
||||
status_url: str
|
||||
|
||||
|
||||
class MultiScanResponse(BaseModel):
|
||||
"""Response model for completed scans (legacy sync pattern)"""
|
||||
|
||||
scans: list[ScanResponse]
|
||||
total_violations: int
|
||||
total_errors: int
|
||||
total_warnings: int
|
||||
|
||||
|
||||
class ViolationResponse(BaseModel):
|
||||
"""Response model for a violation"""
|
||||
|
||||
id: int
|
||||
scan_id: int
|
||||
validator_type: str
|
||||
rule_id: str
|
||||
rule_name: str
|
||||
severity: str
|
||||
file_path: str
|
||||
line_number: int
|
||||
message: str
|
||||
context: str | None
|
||||
suggestion: str | None
|
||||
status: str
|
||||
assigned_to: int | None
|
||||
resolved_at: str | None
|
||||
resolved_by: int | None
|
||||
resolution_note: str | None
|
||||
created_at: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ViolationListResponse(BaseModel):
|
||||
"""Response model for paginated violations list"""
|
||||
|
||||
violations: list[ViolationResponse]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
total_pages: int
|
||||
|
||||
|
||||
class ViolationDetailResponse(ViolationResponse):
|
||||
"""Response model for single violation with relationships"""
|
||||
|
||||
assignments: list = []
|
||||
comments: list = []
|
||||
|
||||
|
||||
class AssignViolationRequest(BaseModel):
|
||||
"""Request model for assigning a violation"""
|
||||
|
||||
user_id: int = Field(..., description="User ID to assign to")
|
||||
due_date: datetime | None = Field(None, description="Due date for resolution")
|
||||
priority: str = Field(
|
||||
"medium", description="Priority level (low, medium, high, critical)"
|
||||
)
|
||||
|
||||
|
||||
class ResolveViolationRequest(BaseModel):
|
||||
"""Request model for resolving a violation"""
|
||||
|
||||
resolution_note: str = Field(..., description="Note about the resolution")
|
||||
|
||||
|
||||
class IgnoreViolationRequest(BaseModel):
|
||||
"""Request model for ignoring a violation"""
|
||||
|
||||
reason: str = Field(..., description="Reason for ignoring")
|
||||
|
||||
|
||||
class AddCommentRequest(BaseModel):
|
||||
"""Request model for adding a comment"""
|
||||
|
||||
comment: str = Field(..., min_length=1, description="Comment text")
|
||||
|
||||
|
||||
# API Endpoints
|
||||
|
||||
|
||||
def _scan_to_response(scan: ArchitectureScan) -> ScanResponse:
|
||||
"""Convert ArchitectureScan to ScanResponse."""
|
||||
return ScanResponse(
|
||||
id=scan.id,
|
||||
timestamp=scan.timestamp.isoformat() if scan.timestamp else None,
|
||||
validator_type=scan.validator_type,
|
||||
status=scan.status,
|
||||
started_at=scan.started_at.isoformat() if scan.started_at else None,
|
||||
completed_at=scan.completed_at.isoformat() if scan.completed_at else None,
|
||||
progress_message=scan.progress_message,
|
||||
total_files=scan.total_files or 0,
|
||||
total_violations=scan.total_violations or 0,
|
||||
errors=scan.errors or 0,
|
||||
warnings=scan.warnings or 0,
|
||||
duration_seconds=scan.duration_seconds or 0.0,
|
||||
triggered_by=scan.triggered_by,
|
||||
git_commit_hash=scan.git_commit_hash,
|
||||
error_message=scan.error_message,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/scan", response_model=MultiScanJobResponse, status_code=202)
|
||||
async def trigger_scan(
|
||||
request: ScanRequest = None,
|
||||
background_tasks: BackgroundTasks = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Trigger code quality scan(s) as background tasks.
|
||||
|
||||
By default runs all validators. Specify validator_types to run specific validators.
|
||||
Returns immediately with job IDs. Poll /scan/{scan_id}/status for progress.
|
||||
|
||||
Scans run asynchronously - users can browse other pages while scans execute.
|
||||
"""
|
||||
if request is None:
|
||||
request = ScanRequest()
|
||||
|
||||
scan_jobs = []
|
||||
triggered_by = f"manual:{current_user.username}"
|
||||
|
||||
# Import dispatcher for Celery support
|
||||
from app.tasks.dispatcher import task_dispatcher
|
||||
|
||||
for vtype in request.validator_types:
|
||||
# Create scan record with pending status via service
|
||||
scan = code_quality_service.create_pending_scan(
|
||||
db, validator_type=vtype.value, triggered_by=triggered_by
|
||||
)
|
||||
|
||||
# Dispatch via task dispatcher (supports Celery or BackgroundTasks)
|
||||
celery_task_id = task_dispatcher.dispatch_code_quality_scan(
|
||||
background_tasks=background_tasks,
|
||||
scan_id=scan.id,
|
||||
)
|
||||
|
||||
# Store Celery task ID if using Celery
|
||||
if celery_task_id:
|
||||
scan.celery_task_id = celery_task_id
|
||||
|
||||
scan_jobs.append(
|
||||
ScanJobResponse(
|
||||
id=scan.id,
|
||||
validator_type=vtype.value,
|
||||
status="pending",
|
||||
message=f"{vtype.value.capitalize()} scan queued",
|
||||
)
|
||||
)
|
||||
|
||||
db.commit()
|
||||
|
||||
validator_names = ", ".join(vtype.value for vtype in request.validator_types)
|
||||
return MultiScanJobResponse(
|
||||
scans=scan_jobs,
|
||||
message=f"Scans queued for: {validator_names}",
|
||||
status_url="/admin/code-quality/scans/running",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/scans/{scan_id}/status", response_model=ScanResponse)
|
||||
async def get_scan_status(
|
||||
scan_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Get status of a specific scan.
|
||||
|
||||
Use this endpoint to poll for scan completion.
|
||||
"""
|
||||
scan = code_quality_service.get_scan_by_id(db, scan_id)
|
||||
if not scan:
|
||||
raise ScanNotFoundException(scan_id)
|
||||
|
||||
return _scan_to_response(scan)
|
||||
|
||||
|
||||
@router.get("/scans/running", response_model=list[ScanResponse])
|
||||
async def get_running_scans(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Get all currently running scans.
|
||||
|
||||
Returns scans with status 'pending' or 'running'.
|
||||
"""
|
||||
scans = code_quality_service.get_running_scans(db)
|
||||
return [_scan_to_response(scan) for scan in scans]
|
||||
|
||||
|
||||
@router.get("/scans", response_model=list[ScanResponse])
|
||||
async def list_scans(
|
||||
limit: int = Query(30, ge=1, le=100, description="Number of scans to return"),
|
||||
validator_type: ValidatorType | None = Query(
|
||||
None, description="Filter by validator type"
|
||||
),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Get scan history
|
||||
|
||||
Returns recent scans for trend analysis.
|
||||
Optionally filter by validator type.
|
||||
"""
|
||||
scans = code_quality_service.get_scan_history(
|
||||
db, limit=limit, validator_type=validator_type.value if validator_type else None
|
||||
)
|
||||
|
||||
return [_scan_to_response(scan) for scan in scans]
|
||||
|
||||
|
||||
@router.get("/violations", response_model=ViolationListResponse)
|
||||
async def list_violations(
|
||||
scan_id: int | None = Query(
|
||||
None, description="Filter by scan ID (defaults to latest)"
|
||||
),
|
||||
validator_type: ValidatorType | None = Query(
|
||||
None, description="Filter by validator type"
|
||||
),
|
||||
severity: str | None = Query(
|
||||
None, description="Filter by severity (error, warning, info)"
|
||||
),
|
||||
status: str | None = Query(
|
||||
None, description="Filter by status (open, assigned, resolved, ignored)"
|
||||
),
|
||||
rule_id: str | None = Query(None, description="Filter by rule ID"),
|
||||
file_path: str | None = Query(
|
||||
None, description="Filter by file path (partial match)"
|
||||
),
|
||||
page: int = Query(1, ge=1, description="Page number"),
|
||||
page_size: int = Query(50, ge=1, le=200, description="Items per page"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Get violations with filtering and pagination
|
||||
|
||||
Returns violations from latest scan(s) by default.
|
||||
Filter by validator_type to get violations from a specific validator.
|
||||
"""
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
violations, total = code_quality_service.get_violations(
|
||||
db,
|
||||
scan_id=scan_id,
|
||||
validator_type=validator_type.value if validator_type else None,
|
||||
severity=severity,
|
||||
status=status,
|
||||
rule_id=rule_id,
|
||||
file_path=file_path,
|
||||
limit=page_size,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
total_pages = (total + page_size - 1) // page_size
|
||||
|
||||
return ViolationListResponse(
|
||||
violations=[
|
||||
ViolationResponse(
|
||||
id=v.id,
|
||||
scan_id=v.scan_id,
|
||||
validator_type=v.validator_type,
|
||||
rule_id=v.rule_id,
|
||||
rule_name=v.rule_name,
|
||||
severity=v.severity,
|
||||
file_path=v.file_path,
|
||||
line_number=v.line_number,
|
||||
message=v.message,
|
||||
context=v.context,
|
||||
suggestion=v.suggestion,
|
||||
status=v.status,
|
||||
assigned_to=v.assigned_to,
|
||||
resolved_at=v.resolved_at.isoformat() if v.resolved_at else None,
|
||||
resolved_by=v.resolved_by,
|
||||
resolution_note=v.resolution_note,
|
||||
created_at=v.created_at.isoformat(),
|
||||
)
|
||||
for v in violations
|
||||
],
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
total_pages=total_pages,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/violations/{violation_id}", response_model=ViolationDetailResponse)
|
||||
async def get_violation(
|
||||
violation_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Get single violation with details
|
||||
|
||||
Includes assignments and comments.
|
||||
"""
|
||||
violation = code_quality_service.get_violation_by_id(db, violation_id)
|
||||
|
||||
if not violation:
|
||||
raise ViolationNotFoundException(violation_id)
|
||||
|
||||
# Format assignments
|
||||
assignments = [
|
||||
{
|
||||
"id": a.id,
|
||||
"user_id": a.user_id,
|
||||
"assigned_at": a.assigned_at.isoformat(),
|
||||
"assigned_by": a.assigned_by,
|
||||
"due_date": a.due_date.isoformat() if a.due_date else None,
|
||||
"priority": a.priority,
|
||||
}
|
||||
for a in violation.assignments
|
||||
]
|
||||
|
||||
# Format comments
|
||||
comments = [
|
||||
{
|
||||
"id": c.id,
|
||||
"user_id": c.user_id,
|
||||
"comment": c.comment,
|
||||
"created_at": c.created_at.isoformat(),
|
||||
}
|
||||
for c in violation.comments
|
||||
]
|
||||
|
||||
return ViolationDetailResponse(
|
||||
id=violation.id,
|
||||
scan_id=violation.scan_id,
|
||||
validator_type=violation.validator_type,
|
||||
rule_id=violation.rule_id,
|
||||
rule_name=violation.rule_name,
|
||||
severity=violation.severity,
|
||||
file_path=violation.file_path,
|
||||
line_number=violation.line_number,
|
||||
message=violation.message,
|
||||
context=violation.context,
|
||||
suggestion=violation.suggestion,
|
||||
status=violation.status,
|
||||
assigned_to=violation.assigned_to,
|
||||
resolved_at=(
|
||||
violation.resolved_at.isoformat() if violation.resolved_at else None
|
||||
),
|
||||
resolved_by=violation.resolved_by,
|
||||
resolution_note=violation.resolution_note,
|
||||
created_at=violation.created_at.isoformat(),
|
||||
assignments=assignments,
|
||||
comments=comments,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/violations/{violation_id}/assign")
|
||||
async def assign_violation(
|
||||
violation_id: int,
|
||||
request: AssignViolationRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Assign violation to a developer
|
||||
|
||||
Updates violation status to 'assigned'.
|
||||
"""
|
||||
assignment = code_quality_service.assign_violation(
|
||||
db,
|
||||
violation_id=violation_id,
|
||||
user_id=request.user_id,
|
||||
assigned_by=current_user.id,
|
||||
due_date=request.due_date,
|
||||
priority=request.priority,
|
||||
)
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"id": assignment.id,
|
||||
"violation_id": assignment.violation_id,
|
||||
"user_id": assignment.user_id,
|
||||
"assigned_at": assignment.assigned_at.isoformat(),
|
||||
"assigned_by": assignment.assigned_by,
|
||||
"due_date": (assignment.due_date.isoformat() if assignment.due_date else None),
|
||||
"priority": assignment.priority,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/violations/{violation_id}/resolve")
|
||||
async def resolve_violation(
|
||||
violation_id: int,
|
||||
request: ResolveViolationRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Mark violation as resolved
|
||||
|
||||
Records resolution timestamp and user.
|
||||
ViolationNotFoundException bubbles up if violation doesn't exist.
|
||||
"""
|
||||
violation = code_quality_service.resolve_violation(
|
||||
db,
|
||||
violation_id=violation_id,
|
||||
resolved_by=current_user.id,
|
||||
resolution_note=request.resolution_note,
|
||||
)
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"id": violation.id,
|
||||
"status": violation.status,
|
||||
"resolved_at": (
|
||||
violation.resolved_at.isoformat() if violation.resolved_at else None
|
||||
),
|
||||
"resolved_by": violation.resolved_by,
|
||||
"resolution_note": violation.resolution_note,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/violations/{violation_id}/ignore")
|
||||
async def ignore_violation(
|
||||
violation_id: int,
|
||||
request: IgnoreViolationRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Mark violation as ignored (won't fix)
|
||||
|
||||
Records reason for ignoring.
|
||||
ViolationNotFoundException bubbles up if violation doesn't exist.
|
||||
"""
|
||||
violation = code_quality_service.ignore_violation(
|
||||
db,
|
||||
violation_id=violation_id,
|
||||
ignored_by=current_user.id,
|
||||
reason=request.reason,
|
||||
)
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"id": violation.id,
|
||||
"status": violation.status,
|
||||
"resolved_at": (
|
||||
violation.resolved_at.isoformat() if violation.resolved_at else None
|
||||
),
|
||||
"resolved_by": violation.resolved_by,
|
||||
"resolution_note": violation.resolution_note,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/violations/{violation_id}/comments")
|
||||
async def add_comment(
|
||||
violation_id: int,
|
||||
request: AddCommentRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Add comment to violation
|
||||
|
||||
For team collaboration and discussion.
|
||||
"""
|
||||
comment = code_quality_service.add_comment(
|
||||
db,
|
||||
violation_id=violation_id,
|
||||
user_id=current_user.id,
|
||||
comment=request.comment,
|
||||
)
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"id": comment.id,
|
||||
"violation_id": comment.violation_id,
|
||||
"user_id": comment.user_id,
|
||||
"comment": comment.comment,
|
||||
"created_at": comment.created_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/stats", response_model=CodeQualityDashboardStatsResponse)
|
||||
async def get_dashboard_stats(
|
||||
validator_type: ValidatorType | None = Query(
|
||||
None, description="Filter by validator type (returns combined stats if not specified)"
|
||||
),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Get dashboard statistics
|
||||
|
||||
Returns comprehensive stats for the dashboard including:
|
||||
- Total counts by severity and status
|
||||
- Technical debt score
|
||||
- Trend data (last 7 scans)
|
||||
- Top violating files
|
||||
- Violations by rule and module
|
||||
- Per-validator breakdown
|
||||
|
||||
When validator_type is specified, returns stats for that type only.
|
||||
When not specified, returns combined stats across all validators.
|
||||
"""
|
||||
stats = code_quality_service.get_dashboard_stats(
|
||||
db, validator_type=validator_type.value if validator_type else None
|
||||
)
|
||||
|
||||
return CodeQualityDashboardStatsResponse(**stats)
|
||||
|
||||
|
||||
@router.get("/validator-types")
|
||||
async def get_validator_types(
|
||||
current_user: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Get list of available validator types
|
||||
|
||||
Returns the supported validator types for filtering.
|
||||
"""
|
||||
return {
|
||||
"validator_types": VALID_VALIDATOR_TYPES,
|
||||
"descriptions": {
|
||||
"architecture": "Architectural patterns and code organization rules",
|
||||
"security": "Security vulnerabilities and best practices",
|
||||
"performance": "Performance issues and optimizations",
|
||||
},
|
||||
}
|
||||
@@ -1,365 +0,0 @@
|
||||
# app/api/v1/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.exceptions import CompanyHasVendorsException, ConfirmationRequiredException
|
||||
from app.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,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/companies")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@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",
|
||||
)
|
||||
|
||||
|
||||
@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,
|
||||
)
|
||||
|
||||
|
||||
@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,
|
||||
)
|
||||
|
||||
|
||||
@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(),
|
||||
)
|
||||
|
||||
|
||||
@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(),
|
||||
)
|
||||
|
||||
|
||||
@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(),
|
||||
)
|
||||
|
||||
|
||||
@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,
|
||||
)
|
||||
|
||||
|
||||
@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"}
|
||||
@@ -1,127 +0,0 @@
|
||||
# app/api/v1/admin/dashboard.py
|
||||
"""
|
||||
Admin dashboard and statistics endpoints.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_admin_api
|
||||
from app.core.database import get_db
|
||||
from app.services.admin_service import admin_service
|
||||
from app.services.stats_service import stats_service
|
||||
from models.schema.auth import UserContext
|
||||
from app.modules.analytics.schemas import (
|
||||
AdminDashboardResponse,
|
||||
ImportStatsResponse,
|
||||
MarketplaceStatsResponse,
|
||||
OrderStatsBasicResponse,
|
||||
PlatformStatsResponse,
|
||||
ProductStatsResponse,
|
||||
StatsResponse,
|
||||
UserStatsResponse,
|
||||
VendorStatsResponse,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/dashboard")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@router.get("", response_model=AdminDashboardResponse)
|
||||
def get_admin_dashboard(
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Get admin dashboard with platform statistics (Admin only)."""
|
||||
user_stats = stats_service.get_user_statistics(db)
|
||||
vendor_stats = stats_service.get_vendor_statistics(db)
|
||||
|
||||
return AdminDashboardResponse(
|
||||
platform={
|
||||
"name": "Multi-Tenant Ecommerce Platform",
|
||||
"version": "1.0.0",
|
||||
},
|
||||
users=UserStatsResponse(**user_stats),
|
||||
vendors=VendorStatsResponse(
|
||||
total=vendor_stats.get("total", vendor_stats.get("total_vendors", 0)),
|
||||
verified=vendor_stats.get(
|
||||
"verified", vendor_stats.get("verified_vendors", 0)
|
||||
),
|
||||
pending=vendor_stats.get("pending", vendor_stats.get("pending_vendors", 0)),
|
||||
inactive=vendor_stats.get(
|
||||
"inactive", vendor_stats.get("inactive_vendors", 0)
|
||||
),
|
||||
),
|
||||
recent_vendors=admin_service.get_recent_vendors(db, limit=5),
|
||||
recent_imports=admin_service.get_recent_import_jobs(db, limit=10),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/stats", response_model=StatsResponse)
|
||||
def get_comprehensive_stats(
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Get comprehensive platform statistics (Admin only)."""
|
||||
stats_data = stats_service.get_comprehensive_stats(db=db)
|
||||
|
||||
return StatsResponse(
|
||||
total_products=stats_data["total_products"],
|
||||
unique_brands=stats_data["unique_brands"],
|
||||
unique_categories=stats_data["unique_categories"],
|
||||
unique_marketplaces=stats_data["unique_marketplaces"],
|
||||
unique_vendors=stats_data["unique_vendors"],
|
||||
total_inventory_entries=stats_data["total_inventory_entries"],
|
||||
total_inventory_quantity=stats_data["total_inventory_quantity"],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/stats/marketplace", response_model=list[MarketplaceStatsResponse])
|
||||
def get_marketplace_stats(
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Get statistics broken down by marketplace (Admin only)."""
|
||||
marketplace_stats = stats_service.get_marketplace_breakdown_stats(db=db)
|
||||
|
||||
return [
|
||||
MarketplaceStatsResponse(
|
||||
marketplace=stat["marketplace"],
|
||||
total_products=stat["total_products"],
|
||||
unique_vendors=stat["unique_vendors"],
|
||||
unique_brands=stat["unique_brands"],
|
||||
)
|
||||
for stat in marketplace_stats
|
||||
]
|
||||
|
||||
|
||||
@router.get("/stats/platform", response_model=PlatformStatsResponse)
|
||||
def get_platform_statistics(
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Get comprehensive platform statistics (Admin only)."""
|
||||
user_stats = stats_service.get_user_statistics(db)
|
||||
vendor_stats = stats_service.get_vendor_statistics(db)
|
||||
product_stats = stats_service.get_product_statistics(db)
|
||||
order_stats = stats_service.get_order_statistics(db)
|
||||
import_stats = stats_service.get_import_statistics(db)
|
||||
|
||||
return PlatformStatsResponse(
|
||||
users=UserStatsResponse(**user_stats),
|
||||
vendors=VendorStatsResponse(
|
||||
total=vendor_stats.get("total", vendor_stats.get("total_vendors", 0)),
|
||||
verified=vendor_stats.get(
|
||||
"verified", vendor_stats.get("verified_vendors", 0)
|
||||
),
|
||||
pending=vendor_stats.get("pending", vendor_stats.get("pending_vendors", 0)),
|
||||
inactive=vendor_stats.get(
|
||||
"inactive", vendor_stats.get("inactive_vendors", 0)
|
||||
),
|
||||
),
|
||||
products=ProductStatsResponse(**product_stats),
|
||||
orders=OrderStatsBasicResponse(**order_stats),
|
||||
imports=ImportStatsResponse(**import_stats),
|
||||
)
|
||||
@@ -1,357 +0,0 @@
|
||||
# app/api/v1/admin/email_templates.py
|
||||
"""
|
||||
Admin email template management endpoints.
|
||||
|
||||
Allows platform administrators to:
|
||||
- View all email templates
|
||||
- Edit template content for all languages
|
||||
- Preview templates with sample data
|
||||
- Send test emails
|
||||
- View email logs
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_admin_api
|
||||
from app.core.database import get_db
|
||||
from app.exceptions.base import ResourceNotFoundException, ValidationException
|
||||
from app.services.email_service import EmailService
|
||||
from app.services.email_template_service import EmailTemplateService
|
||||
from models.schema.auth import UserContext
|
||||
|
||||
router = APIRouter(prefix="/email-templates")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SCHEMAS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TemplateUpdate(BaseModel):
|
||||
"""Schema for updating a platform template."""
|
||||
|
||||
subject: str = Field(..., min_length=1, max_length=500)
|
||||
body_html: str = Field(..., min_length=1)
|
||||
body_text: str | None = None
|
||||
|
||||
|
||||
class PreviewRequest(BaseModel):
|
||||
"""Schema for previewing a template."""
|
||||
|
||||
template_code: str
|
||||
language: str = "en"
|
||||
variables: dict[str, Any] = {}
|
||||
|
||||
|
||||
class TestEmailRequest(BaseModel):
|
||||
"""Schema for sending a test email."""
|
||||
|
||||
template_code: str
|
||||
language: str = "en"
|
||||
to_email: EmailStr
|
||||
variables: dict[str, Any] = {}
|
||||
|
||||
|
||||
class TemplateListItem(BaseModel):
|
||||
"""Schema for a template in the list."""
|
||||
|
||||
code: str
|
||||
name: str
|
||||
description: str | None = None
|
||||
category: str
|
||||
languages: list[str] # Matches service output field name
|
||||
is_platform_only: bool = False
|
||||
variables: list[str] = []
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class TemplateListResponse(BaseModel):
|
||||
"""Response schema for listing templates."""
|
||||
|
||||
templates: list[TemplateListItem]
|
||||
|
||||
|
||||
class CategoriesResponse(BaseModel):
|
||||
"""Response schema for template categories."""
|
||||
|
||||
categories: list[str]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ENDPOINTS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@router.get("", response_model=TemplateListResponse)
|
||||
def list_templates(
|
||||
current_user: UserContext = Depends(get_current_admin_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
List all platform email templates.
|
||||
|
||||
Returns templates grouped by code with available languages.
|
||||
"""
|
||||
service = EmailTemplateService(db)
|
||||
return TemplateListResponse(templates=service.list_platform_templates())
|
||||
|
||||
|
||||
@router.get("/categories", response_model=CategoriesResponse)
|
||||
def get_categories(
|
||||
current_user: UserContext = Depends(get_current_admin_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Get list of email template categories."""
|
||||
service = EmailTemplateService(db)
|
||||
return CategoriesResponse(categories=service.get_template_categories())
|
||||
|
||||
|
||||
@router.get("/{code}")
|
||||
def get_template(
|
||||
code: str,
|
||||
current_user: UserContext = Depends(get_current_admin_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Get a specific template with all language versions.
|
||||
|
||||
Returns template metadata and content for all available languages.
|
||||
"""
|
||||
service = EmailTemplateService(db)
|
||||
return service.get_platform_template(code)
|
||||
|
||||
|
||||
@router.get("/{code}/{language}")
|
||||
def get_template_language(
|
||||
code: str,
|
||||
language: str,
|
||||
current_user: UserContext = Depends(get_current_admin_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Get a specific template for a specific language.
|
||||
|
||||
Returns template content with variables information.
|
||||
"""
|
||||
service = EmailTemplateService(db)
|
||||
template = service.get_platform_template_language(code, language)
|
||||
|
||||
return {
|
||||
"code": template.code,
|
||||
"language": template.language,
|
||||
"name": template.name,
|
||||
"description": template.description,
|
||||
"category": template.category,
|
||||
"subject": template.subject,
|
||||
"body_html": template.body_html,
|
||||
"body_text": template.body_text,
|
||||
"variables": template.variables,
|
||||
"required_variables": template.required_variables,
|
||||
"is_platform_only": template.is_platform_only,
|
||||
}
|
||||
|
||||
|
||||
@router.put("/{code}/{language}")
|
||||
def update_template(
|
||||
code: str,
|
||||
language: str,
|
||||
template_data: TemplateUpdate,
|
||||
current_user: UserContext = Depends(get_current_admin_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Update a platform email template.
|
||||
|
||||
Updates the template content for a specific language.
|
||||
"""
|
||||
service = EmailTemplateService(db)
|
||||
service.update_platform_template(
|
||||
code=code,
|
||||
language=language,
|
||||
subject=template_data.subject,
|
||||
body_html=template_data.body_html,
|
||||
body_text=template_data.body_text,
|
||||
)
|
||||
db.commit()
|
||||
|
||||
return {"message": "Template updated successfully"}
|
||||
|
||||
|
||||
@router.post("/{code}/preview")
|
||||
def preview_template(
|
||||
code: str,
|
||||
preview_data: PreviewRequest,
|
||||
current_user: UserContext = Depends(get_current_admin_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Preview a template with sample variables.
|
||||
|
||||
Renders the template with provided variables and returns the result.
|
||||
"""
|
||||
service = EmailTemplateService(db)
|
||||
|
||||
# Merge with sample variables if not provided
|
||||
variables = {
|
||||
**_get_sample_variables(code),
|
||||
**preview_data.variables,
|
||||
}
|
||||
|
||||
return service.preview_template(code, preview_data.language, variables)
|
||||
|
||||
|
||||
@router.post("/{code}/test")
|
||||
def send_test_email(
|
||||
code: str,
|
||||
test_data: TestEmailRequest,
|
||||
current_user: UserContext = Depends(get_current_admin_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Send a test email using the template.
|
||||
|
||||
Sends the template to the specified email address with sample data.
|
||||
"""
|
||||
# Merge with sample variables
|
||||
variables = {
|
||||
**_get_sample_variables(code),
|
||||
**test_data.variables,
|
||||
}
|
||||
|
||||
try:
|
||||
email_svc = EmailService(db)
|
||||
email_log = email_svc.send_template(
|
||||
template_code=code,
|
||||
to_email=test_data.to_email,
|
||||
variables=variables,
|
||||
language=test_data.language,
|
||||
)
|
||||
|
||||
if email_log.status == "sent":
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Test email sent to {test_data.to_email}",
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"success": False,
|
||||
"message": email_log.error_message or "Failed to send email",
|
||||
}
|
||||
except Exception as e:
|
||||
logger.exception(f"Failed to send test email: {e}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": str(e),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{code}/logs")
|
||||
def get_template_logs(
|
||||
code: str,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
current_user: UserContext = Depends(get_current_admin_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Get email logs for a specific template.
|
||||
|
||||
Returns recent email send attempts for the template.
|
||||
"""
|
||||
service = EmailTemplateService(db)
|
||||
logs, total = service.get_template_logs(code, limit, offset)
|
||||
|
||||
return {
|
||||
"logs": logs,
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# HELPERS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _get_sample_variables(template_code: str) -> dict[str, Any]:
|
||||
"""Get sample variables for testing templates."""
|
||||
samples = {
|
||||
"signup_welcome": {
|
||||
"first_name": "John",
|
||||
"company_name": "Acme Corp",
|
||||
"email": "john@example.com",
|
||||
"vendor_code": "acme",
|
||||
"login_url": "https://example.com/login",
|
||||
"trial_days": "14",
|
||||
"tier_name": "Business",
|
||||
"platform_name": "Wizamart",
|
||||
},
|
||||
"order_confirmation": {
|
||||
"customer_name": "Jane Doe",
|
||||
"order_number": "ORD-12345",
|
||||
"order_total": "€99.99",
|
||||
"order_items_count": "3",
|
||||
"order_date": "2024-01-15",
|
||||
"shipping_address": "123 Main St, Luxembourg City, L-1234",
|
||||
"platform_name": "Wizamart",
|
||||
},
|
||||
"password_reset": {
|
||||
"customer_name": "John Doe",
|
||||
"reset_link": "https://example.com/reset?token=abc123",
|
||||
"expiry_hours": "1",
|
||||
"platform_name": "Wizamart",
|
||||
},
|
||||
"team_invite": {
|
||||
"invitee_name": "Jane",
|
||||
"inviter_name": "John",
|
||||
"vendor_name": "Acme Corp",
|
||||
"role": "Admin",
|
||||
"accept_url": "https://example.com/accept",
|
||||
"expires_in_days": "7",
|
||||
"platform_name": "Wizamart",
|
||||
},
|
||||
"subscription_welcome": {
|
||||
"vendor_name": "Acme Corp",
|
||||
"tier_name": "Business",
|
||||
"billing_cycle": "Monthly",
|
||||
"amount": "€49.99",
|
||||
"next_billing_date": "2024-02-15",
|
||||
"dashboard_url": "https://example.com/dashboard",
|
||||
"platform_name": "Wizamart",
|
||||
},
|
||||
"payment_failed": {
|
||||
"vendor_name": "Acme Corp",
|
||||
"tier_name": "Business",
|
||||
"amount": "€49.99",
|
||||
"retry_date": "2024-01-18",
|
||||
"update_payment_url": "https://example.com/billing",
|
||||
"support_email": "support@wizamart.com",
|
||||
"platform_name": "Wizamart",
|
||||
},
|
||||
"subscription_cancelled": {
|
||||
"vendor_name": "Acme Corp",
|
||||
"tier_name": "Business",
|
||||
"end_date": "2024-02-15",
|
||||
"reactivate_url": "https://example.com/billing",
|
||||
"platform_name": "Wizamart",
|
||||
},
|
||||
"trial_ending": {
|
||||
"vendor_name": "Acme Corp",
|
||||
"tier_name": "Business",
|
||||
"days_remaining": "3",
|
||||
"trial_end_date": "2024-01-18",
|
||||
"upgrade_url": "https://example.com/upgrade",
|
||||
"features_list": "Unlimited products, API access, Priority support",
|
||||
"platform_name": "Wizamart",
|
||||
},
|
||||
}
|
||||
return samples.get(template_code, {"platform_name": "Wizamart"})
|
||||
@@ -1,99 +0,0 @@
|
||||
# app/api/v1/admin/images.py
|
||||
"""
|
||||
Admin image management endpoints.
|
||||
|
||||
Provides:
|
||||
- Image upload with automatic processing
|
||||
- Image deletion
|
||||
- Storage statistics
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, UploadFile
|
||||
|
||||
from app.api.deps import get_current_admin_api
|
||||
from app.services.image_service import image_service
|
||||
from models.schema.auth import UserContext
|
||||
from models.schema.image import (
|
||||
ImageDeleteResponse,
|
||||
ImageStorageStats,
|
||||
ImageUploadResponse,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/images")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@router.post("/upload", response_model=ImageUploadResponse)
|
||||
async def upload_image(
|
||||
file: UploadFile = File(...),
|
||||
vendor_id: int = Form(...),
|
||||
product_id: int | None = Form(None),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Upload and process an image.
|
||||
|
||||
The image will be:
|
||||
- Converted to WebP format
|
||||
- Resized to multiple variants (original, 800px, 200px)
|
||||
- Stored in a sharded directory structure
|
||||
|
||||
Args:
|
||||
file: Image file to upload
|
||||
vendor_id: Vendor ID for the image
|
||||
product_id: Optional product ID
|
||||
|
||||
Returns:
|
||||
Image URLs and metadata
|
||||
"""
|
||||
# Read file content
|
||||
content = await file.read()
|
||||
|
||||
# Delegate all validation and processing to service
|
||||
result = image_service.upload_product_image(
|
||||
file_content=content,
|
||||
filename=file.filename or "image.jpg",
|
||||
content_type=file.content_type,
|
||||
vendor_id=vendor_id,
|
||||
product_id=product_id,
|
||||
)
|
||||
|
||||
logger.info(f"Image uploaded: {result['id']} for vendor {vendor_id}")
|
||||
|
||||
return ImageUploadResponse(success=True, image=result)
|
||||
|
||||
|
||||
@router.delete("/{image_hash}", response_model=ImageDeleteResponse)
|
||||
async def delete_image(
|
||||
image_hash: str,
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Delete an image and all its variants.
|
||||
|
||||
Args:
|
||||
image_hash: The image ID/hash
|
||||
|
||||
Returns:
|
||||
Deletion status
|
||||
"""
|
||||
deleted = image_service.delete_product_image(image_hash)
|
||||
|
||||
if deleted:
|
||||
logger.info(f"Image deleted: {image_hash}")
|
||||
return ImageDeleteResponse(success=True, message="Image deleted successfully")
|
||||
else:
|
||||
return ImageDeleteResponse(success=False, message="Image not found")
|
||||
|
||||
|
||||
@router.get("/stats", response_model=ImageStorageStats)
|
||||
async def get_storage_stats(
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Get image storage statistics.
|
||||
|
||||
Returns:
|
||||
Storage metrics including file counts, sizes, and directory info
|
||||
"""
|
||||
stats = image_service.get_storage_stats()
|
||||
return ImageStorageStats(**stats)
|
||||
@@ -1,342 +0,0 @@
|
||||
# app/api/v1/admin/logs.py
|
||||
"""
|
||||
Log management endpoints for admin.
|
||||
|
||||
Provides endpoints for:
|
||||
- Viewing database logs with filters
|
||||
- Reading file logs
|
||||
- Log statistics
|
||||
- Log settings management
|
||||
- Log cleanup operations
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_admin_api
|
||||
from app.core.database import get_db
|
||||
from app.core.logging import reload_log_level
|
||||
from app.exceptions import ConfirmationRequiredException, ResourceNotFoundException
|
||||
from app.services.admin_audit_service import admin_audit_service
|
||||
from app.services.admin_settings_service import admin_settings_service
|
||||
from app.services.log_service import log_service
|
||||
from models.schema.auth import UserContext
|
||||
from models.schema.admin import (
|
||||
ApplicationLogFilters,
|
||||
ApplicationLogListResponse,
|
||||
FileLogResponse,
|
||||
LogCleanupResponse,
|
||||
LogDeleteResponse,
|
||||
LogFileListResponse,
|
||||
LogSettingsResponse,
|
||||
LogSettingsUpdate,
|
||||
LogSettingsUpdateResponse,
|
||||
LogStatistics,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/logs")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# DATABASE LOGS ENDPOINTS
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get("/database", response_model=ApplicationLogListResponse)
|
||||
def get_database_logs(
|
||||
level: str | None = Query(None, description="Filter by log level"),
|
||||
logger_name: str | None = Query(None, description="Filter by logger name"),
|
||||
module: str | None = Query(None, description="Filter by module"),
|
||||
user_id: int | None = Query(None, description="Filter by user ID"),
|
||||
vendor_id: int | None = Query(None, description="Filter by vendor ID"),
|
||||
search: str | None = Query(None, description="Search in message"),
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Get logs from database with filtering.
|
||||
|
||||
Supports filtering by level, logger, module, user, vendor, and date range.
|
||||
Returns paginated results.
|
||||
"""
|
||||
filters = ApplicationLogFilters(
|
||||
level=level,
|
||||
logger_name=logger_name,
|
||||
module=module,
|
||||
user_id=user_id,
|
||||
vendor_id=vendor_id,
|
||||
search=search,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
return log_service.get_database_logs(db, filters)
|
||||
|
||||
|
||||
@router.get("/statistics", response_model=LogStatistics)
|
||||
def get_log_statistics(
|
||||
days: int = Query(7, ge=1, le=90, description="Number of days to analyze"),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Get log statistics for the last N days.
|
||||
|
||||
Returns counts by level, module, and recent critical errors.
|
||||
"""
|
||||
return log_service.get_log_statistics(db, days)
|
||||
|
||||
|
||||
@router.delete("/database/cleanup", response_model=LogCleanupResponse)
|
||||
def cleanup_old_logs(
|
||||
retention_days: int = Query(30, ge=1, le=365),
|
||||
confirm: bool = Query(False, description="Must be true to confirm cleanup"),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Delete logs older than retention period.
|
||||
|
||||
Requires confirmation parameter.
|
||||
"""
|
||||
if not confirm:
|
||||
raise ConfirmationRequiredException(operation="cleanup_logs")
|
||||
|
||||
deleted_count = log_service.cleanup_old_logs(db, retention_days)
|
||||
|
||||
# Log action
|
||||
admin_audit_service.log_action(
|
||||
db=db,
|
||||
admin_user_id=current_admin.id,
|
||||
action="cleanup_logs",
|
||||
target_type="application_logs",
|
||||
target_id="bulk",
|
||||
details={"retention_days": retention_days, "deleted_count": deleted_count},
|
||||
)
|
||||
|
||||
return LogCleanupResponse(
|
||||
message=f"Deleted {deleted_count} log entries older than {retention_days} days",
|
||||
deleted_count=deleted_count,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/database/{log_id}", response_model=LogDeleteResponse)
|
||||
def delete_log(
|
||||
log_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Delete a specific log entry."""
|
||||
message = log_service.delete_log(db, log_id)
|
||||
|
||||
# Log action
|
||||
admin_audit_service.log_action(
|
||||
db=db,
|
||||
admin_user_id=current_admin.id,
|
||||
action="delete_log",
|
||||
target_type="application_log",
|
||||
target_id=str(log_id),
|
||||
details={},
|
||||
)
|
||||
|
||||
return LogDeleteResponse(message=message)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# FILE LOGS ENDPOINTS
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get("/files", response_model=LogFileListResponse)
|
||||
def list_log_files(
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
List all available log files.
|
||||
|
||||
Returns list of log files with size and modification date.
|
||||
"""
|
||||
return LogFileListResponse(files=log_service.list_log_files())
|
||||
|
||||
|
||||
@router.get("/files/{filename}", response_model=FileLogResponse)
|
||||
def get_file_log(
|
||||
filename: str,
|
||||
lines: int = Query(500, ge=1, le=10000, description="Number of lines to read"),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Read log file content.
|
||||
|
||||
Returns the last N lines from the specified log file.
|
||||
"""
|
||||
return log_service.get_file_logs(filename, lines)
|
||||
|
||||
|
||||
@router.get("/files/{filename}/download")
|
||||
def download_log_file(
|
||||
filename: str,
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Download log file.
|
||||
|
||||
Returns the entire log file for download.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
# Determine log file path
|
||||
log_file_path = settings.log_file
|
||||
if log_file_path:
|
||||
log_file = Path(log_file_path).parent / filename
|
||||
else:
|
||||
log_file = Path("logs") / filename
|
||||
|
||||
if not log_file.exists():
|
||||
raise ResourceNotFoundException(resource_type="LogFile", identifier=filename)
|
||||
|
||||
# Log action
|
||||
from app.core.database import get_db
|
||||
|
||||
db_gen = get_db()
|
||||
db = next(db_gen)
|
||||
try:
|
||||
admin_audit_service.log_action(
|
||||
db=db,
|
||||
admin_user_id=current_admin.id,
|
||||
action="download_log_file",
|
||||
target_type="log_file",
|
||||
target_id=filename,
|
||||
details={"size_bytes": log_file.stat().st_size},
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
return FileResponse(
|
||||
log_file,
|
||||
media_type="text/plain",
|
||||
filename=filename,
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# LOG SETTINGS ENDPOINTS
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get("/settings", response_model=LogSettingsResponse)
|
||||
def get_log_settings(
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Get current log configuration settings."""
|
||||
log_level = admin_settings_service.get_setting_value(db, "log_level", "INFO")
|
||||
max_size_mb = admin_settings_service.get_setting_value(
|
||||
db, "log_file_max_size_mb", 10
|
||||
)
|
||||
backup_count = admin_settings_service.get_setting_value(
|
||||
db, "log_file_backup_count", 5
|
||||
)
|
||||
retention_days = admin_settings_service.get_setting_value(
|
||||
db, "db_log_retention_days", 30
|
||||
)
|
||||
file_enabled = admin_settings_service.get_setting_value(
|
||||
db, "file_logging_enabled", "true"
|
||||
)
|
||||
db_enabled = admin_settings_service.get_setting_value(
|
||||
db, "db_logging_enabled", "true"
|
||||
)
|
||||
|
||||
return LogSettingsResponse(
|
||||
log_level=str(log_level),
|
||||
log_file_max_size_mb=int(max_size_mb),
|
||||
log_file_backup_count=int(backup_count),
|
||||
db_log_retention_days=int(retention_days),
|
||||
file_logging_enabled=str(file_enabled).lower() == "true",
|
||||
db_logging_enabled=str(db_enabled).lower() == "true",
|
||||
)
|
||||
|
||||
|
||||
@router.put("/settings", response_model=LogSettingsUpdateResponse)
|
||||
def update_log_settings(
|
||||
settings_update: LogSettingsUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Update log configuration settings.
|
||||
|
||||
Changes are applied immediately without restart (for log level).
|
||||
File rotation settings require restart.
|
||||
"""
|
||||
from models.schema.admin import AdminSettingUpdate
|
||||
|
||||
updated = []
|
||||
|
||||
# Update log level
|
||||
if settings_update.log_level:
|
||||
admin_settings_service.update_setting(
|
||||
db,
|
||||
"log_level",
|
||||
AdminSettingUpdate(value=settings_update.log_level),
|
||||
current_admin.id,
|
||||
)
|
||||
updated.append("log_level")
|
||||
|
||||
# Reload log level immediately
|
||||
reload_log_level()
|
||||
|
||||
# Update file rotation settings
|
||||
if settings_update.log_file_max_size_mb:
|
||||
admin_settings_service.update_setting(
|
||||
db,
|
||||
"log_file_max_size_mb",
|
||||
AdminSettingUpdate(value=str(settings_update.log_file_max_size_mb)),
|
||||
current_admin.id,
|
||||
)
|
||||
updated.append("log_file_max_size_mb")
|
||||
|
||||
if settings_update.log_file_backup_count is not None:
|
||||
admin_settings_service.update_setting(
|
||||
db,
|
||||
"log_file_backup_count",
|
||||
AdminSettingUpdate(value=str(settings_update.log_file_backup_count)),
|
||||
current_admin.id,
|
||||
)
|
||||
updated.append("log_file_backup_count")
|
||||
|
||||
# Update retention
|
||||
if settings_update.db_log_retention_days:
|
||||
admin_settings_service.update_setting(
|
||||
db,
|
||||
"db_log_retention_days",
|
||||
AdminSettingUpdate(value=str(settings_update.db_log_retention_days)),
|
||||
current_admin.id,
|
||||
)
|
||||
updated.append("db_log_retention_days")
|
||||
|
||||
# Log action
|
||||
admin_audit_service.log_action(
|
||||
db=db,
|
||||
admin_user_id=current_admin.id,
|
||||
action="update_log_settings",
|
||||
target_type="settings",
|
||||
target_id="logging",
|
||||
details={"updated_fields": updated},
|
||||
)
|
||||
|
||||
return LogSettingsUpdateResponse(
|
||||
message="Log settings updated successfully",
|
||||
updated_fields=updated,
|
||||
note="Log level changes are applied immediately. File rotation settings require restart.",
|
||||
)
|
||||
@@ -1,138 +0,0 @@
|
||||
# app/api/v1/admin/media.py
|
||||
"""
|
||||
Admin media management endpoints for vendor media libraries.
|
||||
|
||||
Allows admins to manage media files on behalf of vendors.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Query, UploadFile
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_admin_api
|
||||
from app.core.database import get_db
|
||||
from app.services.media_service import media_service
|
||||
from models.schema.auth import UserContext
|
||||
from models.schema.media import (
|
||||
MediaDetailResponse,
|
||||
MediaItemResponse,
|
||||
MediaListResponse,
|
||||
MediaUploadResponse,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/media")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@router.get("/vendors/{vendor_id}", response_model=MediaListResponse)
|
||||
def get_vendor_media_library(
|
||||
vendor_id: int,
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
media_type: str | None = Query(None, description="image, video, document"),
|
||||
folder: str | None = Query(None, description="Filter by folder"),
|
||||
search: str | None = Query(None),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Get media library for a specific vendor.
|
||||
|
||||
Admin can browse any vendor's media library.
|
||||
"""
|
||||
media_files, total = media_service.get_media_library(
|
||||
db=db,
|
||||
vendor_id=vendor_id,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
media_type=media_type,
|
||||
folder=folder,
|
||||
search=search,
|
||||
)
|
||||
|
||||
return MediaListResponse(
|
||||
media=[MediaItemResponse.model_validate(m) for m in media_files],
|
||||
total=total,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/vendors/{vendor_id}/upload", response_model=MediaUploadResponse)
|
||||
async def upload_vendor_media(
|
||||
vendor_id: int,
|
||||
file: UploadFile = File(...),
|
||||
folder: str | None = Query("products", description="products, general, etc."),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Upload media file for a specific vendor.
|
||||
|
||||
Admin can upload media on behalf of any vendor.
|
||||
Files are stored in vendor-specific directories.
|
||||
"""
|
||||
# Read file content
|
||||
file_content = await file.read()
|
||||
|
||||
# Upload using service
|
||||
media_file = await media_service.upload_file(
|
||||
db=db,
|
||||
vendor_id=vendor_id,
|
||||
file_content=file_content,
|
||||
filename=file.filename or "unnamed",
|
||||
folder=folder or "products",
|
||||
)
|
||||
|
||||
logger.info(f"Admin uploaded media for vendor {vendor_id}: {media_file.id}")
|
||||
|
||||
return MediaUploadResponse(
|
||||
success=True,
|
||||
message="File uploaded successfully",
|
||||
media=MediaItemResponse.model_validate(media_file),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/vendors/{vendor_id}/{media_id}", response_model=MediaDetailResponse)
|
||||
def get_vendor_media_detail(
|
||||
vendor_id: int,
|
||||
media_id: int,
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Get detailed info about a specific media file.
|
||||
"""
|
||||
media_file = media_service.get_media_by_id(db=db, media_id=media_id)
|
||||
|
||||
# Verify media belongs to the specified vendor
|
||||
if media_file.vendor_id != vendor_id:
|
||||
from app.exceptions.media import MediaNotFoundException
|
||||
raise MediaNotFoundException(media_id)
|
||||
|
||||
return MediaDetailResponse.model_validate(media_file)
|
||||
|
||||
|
||||
@router.delete("/vendors/{vendor_id}/{media_id}")
|
||||
def delete_vendor_media(
|
||||
vendor_id: int,
|
||||
media_id: int,
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Delete a media file for a vendor.
|
||||
"""
|
||||
media_file = media_service.get_media_by_id(db=db, media_id=media_id)
|
||||
|
||||
# Verify media belongs to the specified vendor
|
||||
if media_file.vendor_id != vendor_id:
|
||||
from app.exceptions.media import MediaNotFoundException
|
||||
raise MediaNotFoundException(media_id)
|
||||
|
||||
media_service.delete_media(db=db, media_id=media_id)
|
||||
|
||||
logger.info(f"Admin deleted media {media_id} for vendor {vendor_id}")
|
||||
|
||||
return {"success": True, "message": "Media deleted successfully"}
|
||||
@@ -27,8 +27,8 @@ from app.api.deps import (
|
||||
get_current_super_admin,
|
||||
get_db,
|
||||
)
|
||||
from app.services.menu_service import MenuItemConfig, menu_service
|
||||
from app.services.platform_service import platform_service
|
||||
from app.modules.core.services.menu_service import MenuItemConfig, menu_service
|
||||
from app.modules.tenancy.services.platform_service import platform_service
|
||||
from models.database.admin_menu_config import FrontendType # noqa: API-007 - Enum for type safety
|
||||
from models.schema.auth import UserContext
|
||||
|
||||
|
||||
@@ -1,624 +0,0 @@
|
||||
# app/api/v1/admin/messages.py
|
||||
"""
|
||||
Admin messaging endpoints.
|
||||
|
||||
Provides endpoints for:
|
||||
- Viewing conversations (admin_vendor and admin_customer channels)
|
||||
- Sending and receiving messages
|
||||
- Managing conversation status
|
||||
- File attachments
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, Query, UploadFile
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_admin_api
|
||||
from app.core.database import get_db
|
||||
from app.exceptions import (
|
||||
ConversationClosedException,
|
||||
ConversationNotFoundException,
|
||||
InvalidConversationTypeException,
|
||||
InvalidRecipientTypeException,
|
||||
MessageAttachmentException,
|
||||
)
|
||||
from app.services.message_attachment_service import message_attachment_service
|
||||
from app.services.messaging_service import messaging_service
|
||||
from app.modules.messaging.models import ConversationType, ParticipantType
|
||||
from app.modules.messaging.schemas import (
|
||||
AdminConversationListResponse,
|
||||
AdminConversationSummary,
|
||||
AttachmentResponse,
|
||||
CloseConversationResponse,
|
||||
ConversationCreate,
|
||||
ConversationDetailResponse,
|
||||
MarkReadResponse,
|
||||
MessageCreate,
|
||||
MessageResponse,
|
||||
NotificationPreferencesUpdate,
|
||||
ParticipantInfo,
|
||||
ParticipantResponse,
|
||||
RecipientListResponse,
|
||||
RecipientOption,
|
||||
ReopenConversationResponse,
|
||||
UnreadCountResponse,
|
||||
)
|
||||
from models.schema.auth import UserContext
|
||||
|
||||
router = APIRouter(prefix="/messages")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# HELPER FUNCTIONS
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def _enrich_message(
|
||||
db: Session, message: Any, include_attachments: bool = True
|
||||
) -> MessageResponse:
|
||||
"""Enrich message with sender info and attachments."""
|
||||
sender_info = messaging_service.get_participant_info(
|
||||
db, message.sender_type, message.sender_id
|
||||
)
|
||||
|
||||
attachments = []
|
||||
if include_attachments and message.attachments:
|
||||
for att in message.attachments:
|
||||
attachments.append(
|
||||
AttachmentResponse(
|
||||
id=att.id,
|
||||
filename=att.filename,
|
||||
original_filename=att.original_filename,
|
||||
file_size=att.file_size,
|
||||
mime_type=att.mime_type,
|
||||
is_image=att.is_image,
|
||||
image_width=att.image_width,
|
||||
image_height=att.image_height,
|
||||
download_url=message_attachment_service.get_download_url(
|
||||
att.file_path
|
||||
),
|
||||
thumbnail_url=(
|
||||
message_attachment_service.get_download_url(att.thumbnail_path)
|
||||
if att.thumbnail_path
|
||||
else None
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
return MessageResponse(
|
||||
id=message.id,
|
||||
conversation_id=message.conversation_id,
|
||||
sender_type=message.sender_type,
|
||||
sender_id=message.sender_id,
|
||||
content=message.content,
|
||||
is_system_message=message.is_system_message,
|
||||
is_deleted=message.is_deleted,
|
||||
created_at=message.created_at,
|
||||
sender_name=sender_info["name"] if sender_info else None,
|
||||
sender_email=sender_info["email"] if sender_info else None,
|
||||
attachments=attachments,
|
||||
)
|
||||
|
||||
|
||||
def _enrich_conversation_summary(
|
||||
db: Session, conversation: Any, current_user_id: int
|
||||
) -> AdminConversationSummary:
|
||||
"""Enrich conversation with other participant info and unread count."""
|
||||
# Get current user's participant record
|
||||
my_participant = next(
|
||||
(
|
||||
p
|
||||
for p in conversation.participants
|
||||
if p.participant_type == ParticipantType.ADMIN
|
||||
and p.participant_id == current_user_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
unread_count = my_participant.unread_count if my_participant else 0
|
||||
|
||||
# Get other participant info
|
||||
other = messaging_service.get_other_participant(
|
||||
conversation, ParticipantType.ADMIN, current_user_id
|
||||
)
|
||||
other_info = None
|
||||
if other:
|
||||
info = messaging_service.get_participant_info(
|
||||
db, other.participant_type, other.participant_id
|
||||
)
|
||||
if info:
|
||||
other_info = ParticipantInfo(
|
||||
id=info["id"],
|
||||
type=info["type"],
|
||||
name=info["name"],
|
||||
email=info.get("email"),
|
||||
)
|
||||
|
||||
# Get last message preview
|
||||
last_message_preview = None
|
||||
if conversation.messages:
|
||||
last_msg = conversation.messages[-1] if conversation.messages else None
|
||||
if last_msg:
|
||||
preview = last_msg.content[:100]
|
||||
if len(last_msg.content) > 100:
|
||||
preview += "..."
|
||||
last_message_preview = preview
|
||||
|
||||
# Get vendor info if applicable
|
||||
vendor_name = None
|
||||
vendor_code = None
|
||||
if conversation.vendor:
|
||||
vendor_name = conversation.vendor.name
|
||||
vendor_code = conversation.vendor.vendor_code
|
||||
|
||||
return AdminConversationSummary(
|
||||
id=conversation.id,
|
||||
conversation_type=conversation.conversation_type,
|
||||
subject=conversation.subject,
|
||||
vendor_id=conversation.vendor_id,
|
||||
is_closed=conversation.is_closed,
|
||||
closed_at=conversation.closed_at,
|
||||
last_message_at=conversation.last_message_at,
|
||||
message_count=conversation.message_count,
|
||||
created_at=conversation.created_at,
|
||||
unread_count=unread_count,
|
||||
other_participant=other_info,
|
||||
last_message_preview=last_message_preview,
|
||||
vendor_name=vendor_name,
|
||||
vendor_code=vendor_code,
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# CONVERSATION LIST
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get("", response_model=AdminConversationListResponse)
|
||||
def list_conversations(
|
||||
conversation_type: ConversationType | None = Query(None, description="Filter by type"),
|
||||
is_closed: bool | None = Query(None, description="Filter by status"),
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
) -> AdminConversationListResponse:
|
||||
"""List conversations for admin (admin_vendor and admin_customer channels)."""
|
||||
conversations, total, total_unread = messaging_service.list_conversations(
|
||||
db=db,
|
||||
participant_type=ParticipantType.ADMIN,
|
||||
participant_id=current_admin.id,
|
||||
conversation_type=conversation_type,
|
||||
is_closed=is_closed,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
return AdminConversationListResponse(
|
||||
conversations=[
|
||||
_enrich_conversation_summary(db, c, current_admin.id) for c in conversations
|
||||
],
|
||||
total=total,
|
||||
total_unread=total_unread,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/unread-count", response_model=UnreadCountResponse)
|
||||
def get_unread_count(
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
) -> UnreadCountResponse:
|
||||
"""Get total unread message count for header badge."""
|
||||
count = messaging_service.get_unread_count(
|
||||
db=db,
|
||||
participant_type=ParticipantType.ADMIN,
|
||||
participant_id=current_admin.id,
|
||||
)
|
||||
return UnreadCountResponse(total_unread=count)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# RECIPIENTS
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get("/recipients", response_model=RecipientListResponse)
|
||||
def get_recipients(
|
||||
recipient_type: ParticipantType = Query(..., description="Type of recipients to list"),
|
||||
search: str | None = Query(None, description="Search by name/email"),
|
||||
vendor_id: int | None = Query(None, description="Filter by vendor"),
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
) -> RecipientListResponse:
|
||||
"""Get list of available recipients for compose modal."""
|
||||
if recipient_type == ParticipantType.VENDOR:
|
||||
recipient_data, total = messaging_service.get_vendor_recipients(
|
||||
db=db,
|
||||
vendor_id=vendor_id,
|
||||
search=search,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
recipients = [
|
||||
RecipientOption(
|
||||
id=r["id"],
|
||||
type=r["type"],
|
||||
name=r["name"],
|
||||
email=r["email"],
|
||||
vendor_id=r["vendor_id"],
|
||||
vendor_name=r.get("vendor_name"),
|
||||
)
|
||||
for r in recipient_data
|
||||
]
|
||||
elif recipient_type == ParticipantType.CUSTOMER:
|
||||
recipient_data, total = messaging_service.get_customer_recipients(
|
||||
db=db,
|
||||
vendor_id=vendor_id,
|
||||
search=search,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
recipients = [
|
||||
RecipientOption(
|
||||
id=r["id"],
|
||||
type=r["type"],
|
||||
name=r["name"],
|
||||
email=r["email"],
|
||||
vendor_id=r["vendor_id"],
|
||||
)
|
||||
for r in recipient_data
|
||||
]
|
||||
else:
|
||||
recipients = []
|
||||
total = 0
|
||||
|
||||
return RecipientListResponse(recipients=recipients, total=total)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# CREATE CONVERSATION
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.post("", response_model=ConversationDetailResponse)
|
||||
def create_conversation(
|
||||
data: ConversationCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
) -> ConversationDetailResponse:
|
||||
"""Create a new conversation."""
|
||||
# Validate conversation type for admin
|
||||
if data.conversation_type not in [
|
||||
ConversationType.ADMIN_VENDOR,
|
||||
ConversationType.ADMIN_CUSTOMER,
|
||||
]:
|
||||
raise InvalidConversationTypeException(
|
||||
message="Admin can only create admin_vendor or admin_customer conversations",
|
||||
allowed_types=["admin_vendor", "admin_customer"],
|
||||
)
|
||||
|
||||
# Validate recipient type matches conversation type
|
||||
if (
|
||||
data.conversation_type == ConversationType.ADMIN_VENDOR
|
||||
and data.recipient_type != ParticipantType.VENDOR
|
||||
):
|
||||
raise InvalidRecipientTypeException(
|
||||
conversation_type="admin_vendor",
|
||||
expected_recipient_type="vendor",
|
||||
)
|
||||
if (
|
||||
data.conversation_type == ConversationType.ADMIN_CUSTOMER
|
||||
and data.recipient_type != ParticipantType.CUSTOMER
|
||||
):
|
||||
raise InvalidRecipientTypeException(
|
||||
conversation_type="admin_customer",
|
||||
expected_recipient_type="customer",
|
||||
)
|
||||
|
||||
# Create conversation
|
||||
conversation = messaging_service.create_conversation(
|
||||
db=db,
|
||||
conversation_type=data.conversation_type,
|
||||
subject=data.subject,
|
||||
initiator_type=ParticipantType.ADMIN,
|
||||
initiator_id=current_admin.id,
|
||||
recipient_type=data.recipient_type,
|
||||
recipient_id=data.recipient_id,
|
||||
vendor_id=data.vendor_id,
|
||||
initial_message=data.initial_message,
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(conversation)
|
||||
|
||||
logger.info(
|
||||
f"Admin {current_admin.username} created conversation {conversation.id} "
|
||||
f"with {data.recipient_type.value}:{data.recipient_id}"
|
||||
)
|
||||
|
||||
# Return full detail response
|
||||
return _build_conversation_detail(db, conversation, current_admin.id)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# CONVERSATION DETAIL
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def _build_conversation_detail(
|
||||
db: Session, conversation: Any, current_user_id: int
|
||||
) -> ConversationDetailResponse:
|
||||
"""Build full conversation detail response."""
|
||||
# Get my participant for unread count
|
||||
my_participant = next(
|
||||
(
|
||||
p
|
||||
for p in conversation.participants
|
||||
if p.participant_type == ParticipantType.ADMIN
|
||||
and p.participant_id == current_user_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
unread_count = my_participant.unread_count if my_participant else 0
|
||||
|
||||
# Build participant responses
|
||||
participants = []
|
||||
for p in conversation.participants:
|
||||
info = messaging_service.get_participant_info(
|
||||
db, p.participant_type, p.participant_id
|
||||
)
|
||||
participants.append(
|
||||
ParticipantResponse(
|
||||
id=p.id,
|
||||
participant_type=p.participant_type,
|
||||
participant_id=p.participant_id,
|
||||
unread_count=p.unread_count,
|
||||
last_read_at=p.last_read_at,
|
||||
email_notifications=p.email_notifications,
|
||||
muted=p.muted,
|
||||
participant_info=(
|
||||
ParticipantInfo(
|
||||
id=info["id"],
|
||||
type=info["type"],
|
||||
name=info["name"],
|
||||
email=info.get("email"),
|
||||
)
|
||||
if info
|
||||
else None
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
# Build message responses
|
||||
messages = [_enrich_message(db, m) for m in conversation.messages]
|
||||
|
||||
# Get vendor name if applicable
|
||||
vendor_name = None
|
||||
if conversation.vendor:
|
||||
vendor_name = conversation.vendor.name
|
||||
|
||||
return ConversationDetailResponse(
|
||||
id=conversation.id,
|
||||
conversation_type=conversation.conversation_type,
|
||||
subject=conversation.subject,
|
||||
vendor_id=conversation.vendor_id,
|
||||
is_closed=conversation.is_closed,
|
||||
closed_at=conversation.closed_at,
|
||||
closed_by_type=conversation.closed_by_type,
|
||||
closed_by_id=conversation.closed_by_id,
|
||||
last_message_at=conversation.last_message_at,
|
||||
message_count=conversation.message_count,
|
||||
created_at=conversation.created_at,
|
||||
updated_at=conversation.updated_at,
|
||||
participants=participants,
|
||||
messages=messages,
|
||||
unread_count=unread_count,
|
||||
vendor_name=vendor_name,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{conversation_id}", response_model=ConversationDetailResponse)
|
||||
def get_conversation(
|
||||
conversation_id: int,
|
||||
mark_read: bool = Query(True, description="Automatically mark as read"),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
) -> ConversationDetailResponse:
|
||||
"""Get conversation detail with messages."""
|
||||
conversation = messaging_service.get_conversation(
|
||||
db=db,
|
||||
conversation_id=conversation_id,
|
||||
participant_type=ParticipantType.ADMIN,
|
||||
participant_id=current_admin.id,
|
||||
)
|
||||
|
||||
if not conversation:
|
||||
raise ConversationNotFoundException(str(conversation_id))
|
||||
|
||||
# Mark as read if requested
|
||||
if mark_read:
|
||||
messaging_service.mark_conversation_read(
|
||||
db=db,
|
||||
conversation_id=conversation_id,
|
||||
reader_type=ParticipantType.ADMIN,
|
||||
reader_id=current_admin.id,
|
||||
)
|
||||
db.commit()
|
||||
|
||||
return _build_conversation_detail(db, conversation, current_admin.id)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# SEND MESSAGE
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.post("/{conversation_id}/messages", response_model=MessageResponse)
|
||||
async def send_message(
|
||||
conversation_id: int,
|
||||
content: str = Form(...),
|
||||
files: list[UploadFile] = File(default=[]),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
) -> MessageResponse:
|
||||
"""Send a message in a conversation, optionally with attachments."""
|
||||
# Verify access
|
||||
conversation = messaging_service.get_conversation(
|
||||
db=db,
|
||||
conversation_id=conversation_id,
|
||||
participant_type=ParticipantType.ADMIN,
|
||||
participant_id=current_admin.id,
|
||||
)
|
||||
|
||||
if not conversation:
|
||||
raise ConversationNotFoundException(str(conversation_id))
|
||||
|
||||
if conversation.is_closed:
|
||||
raise ConversationClosedException(conversation_id)
|
||||
|
||||
# Process attachments
|
||||
attachments = []
|
||||
for file in files:
|
||||
try:
|
||||
att_data = await message_attachment_service.validate_and_store(
|
||||
db=db, file=file, conversation_id=conversation_id
|
||||
)
|
||||
attachments.append(att_data)
|
||||
except ValueError as e:
|
||||
raise MessageAttachmentException(str(e))
|
||||
|
||||
# Send message
|
||||
message = messaging_service.send_message(
|
||||
db=db,
|
||||
conversation_id=conversation_id,
|
||||
sender_type=ParticipantType.ADMIN,
|
||||
sender_id=current_admin.id,
|
||||
content=content,
|
||||
attachments=attachments if attachments else None,
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(message)
|
||||
|
||||
logger.info(
|
||||
f"Admin {current_admin.username} sent message {message.id} "
|
||||
f"in conversation {conversation_id}"
|
||||
)
|
||||
|
||||
return _enrich_message(db, message)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# CONVERSATION ACTIONS
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.post("/{conversation_id}/close", response_model=CloseConversationResponse)
|
||||
def close_conversation(
|
||||
conversation_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
) -> CloseConversationResponse:
|
||||
"""Close a conversation."""
|
||||
conversation = messaging_service.close_conversation(
|
||||
db=db,
|
||||
conversation_id=conversation_id,
|
||||
closer_type=ParticipantType.ADMIN,
|
||||
closer_id=current_admin.id,
|
||||
)
|
||||
|
||||
if not conversation:
|
||||
raise ConversationNotFoundException(str(conversation_id))
|
||||
|
||||
db.commit()
|
||||
logger.info(
|
||||
f"Admin {current_admin.username} closed conversation {conversation_id}"
|
||||
)
|
||||
|
||||
return CloseConversationResponse(
|
||||
success=True,
|
||||
message="Conversation closed",
|
||||
conversation_id=conversation_id,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{conversation_id}/reopen", response_model=ReopenConversationResponse)
|
||||
def reopen_conversation(
|
||||
conversation_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
) -> ReopenConversationResponse:
|
||||
"""Reopen a closed conversation."""
|
||||
conversation = messaging_service.reopen_conversation(
|
||||
db=db,
|
||||
conversation_id=conversation_id,
|
||||
opener_type=ParticipantType.ADMIN,
|
||||
opener_id=current_admin.id,
|
||||
)
|
||||
|
||||
if not conversation:
|
||||
raise ConversationNotFoundException(str(conversation_id))
|
||||
|
||||
db.commit()
|
||||
logger.info(
|
||||
f"Admin {current_admin.username} reopened conversation {conversation_id}"
|
||||
)
|
||||
|
||||
return ReopenConversationResponse(
|
||||
success=True,
|
||||
message="Conversation reopened",
|
||||
conversation_id=conversation_id,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{conversation_id}/read", response_model=MarkReadResponse)
|
||||
def mark_read(
|
||||
conversation_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
) -> MarkReadResponse:
|
||||
"""Mark conversation as read."""
|
||||
success = messaging_service.mark_conversation_read(
|
||||
db=db,
|
||||
conversation_id=conversation_id,
|
||||
reader_type=ParticipantType.ADMIN,
|
||||
reader_id=current_admin.id,
|
||||
)
|
||||
db.commit()
|
||||
|
||||
return MarkReadResponse(
|
||||
success=success,
|
||||
conversation_id=conversation_id,
|
||||
unread_count=0,
|
||||
)
|
||||
|
||||
|
||||
class PreferencesUpdateResponse(BaseModel):
|
||||
"""Response for preferences update."""
|
||||
success: bool
|
||||
|
||||
|
||||
@router.put("/{conversation_id}/preferences", response_model=PreferencesUpdateResponse)
|
||||
def update_preferences(
|
||||
conversation_id: int,
|
||||
preferences: NotificationPreferencesUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
) -> PreferencesUpdateResponse:
|
||||
"""Update notification preferences for a conversation."""
|
||||
success = messaging_service.update_notification_preferences(
|
||||
db=db,
|
||||
conversation_id=conversation_id,
|
||||
participant_type=ParticipantType.ADMIN,
|
||||
participant_id=current_admin.id,
|
||||
email_notifications=preferences.email_notifications,
|
||||
muted=preferences.muted,
|
||||
)
|
||||
db.commit()
|
||||
|
||||
return PreferencesUpdateResponse(success=success)
|
||||
@@ -21,7 +21,7 @@ from app.api.deps import get_current_super_admin, get_db
|
||||
from app.exceptions import ValidationException
|
||||
from app.modules.registry import MODULES
|
||||
from app.modules.service import module_service
|
||||
from app.services.platform_service import platform_service
|
||||
from app.modules.tenancy.services.platform_service import platform_service
|
||||
from models.schema.auth import UserContext
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -21,7 +21,7 @@ from sqlalchemy.orm import Session
|
||||
from app.api.deps import get_current_super_admin, get_db
|
||||
from app.modules.registry import MODULES, get_core_module_codes
|
||||
from app.modules.service import module_service
|
||||
from app.services.platform_service import platform_service
|
||||
from app.modules.tenancy.services.platform_service import platform_service
|
||||
from models.schema.auth import UserContext
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -263,7 +263,7 @@ async def enable_module(
|
||||
|
||||
# Validate module code
|
||||
if request.module_code not in MODULES:
|
||||
from app.exceptions import BadRequestException
|
||||
from app.modules.tenancy.exceptions import BadRequestException
|
||||
|
||||
raise BadRequestException(f"Unknown module: {request.module_code}")
|
||||
|
||||
@@ -307,13 +307,13 @@ async def disable_module(
|
||||
|
||||
# Validate module code
|
||||
if request.module_code not in MODULES:
|
||||
from app.exceptions import BadRequestException
|
||||
from app.modules.tenancy.exceptions import BadRequestException
|
||||
|
||||
raise BadRequestException(f"Unknown module: {request.module_code}")
|
||||
|
||||
# Check if core module
|
||||
if request.module_code in get_core_module_codes():
|
||||
from app.exceptions import BadRequestException
|
||||
from app.modules.tenancy.exceptions import BadRequestException
|
||||
|
||||
raise BadRequestException(f"Cannot disable core module: {request.module_code}")
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
# Platform monitoring and alerts
|
||||
@@ -1,327 +0,0 @@
|
||||
# app/api/v1/admin/notifications.py
|
||||
"""
|
||||
Admin notifications and platform alerts endpoints.
|
||||
|
||||
Provides endpoints for:
|
||||
- Viewing admin notifications
|
||||
- Managing platform alerts
|
||||
- System health monitoring
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_admin_api
|
||||
from app.core.database import get_db
|
||||
from app.services.admin_notification_service import (
|
||||
admin_notification_service,
|
||||
platform_alert_service,
|
||||
)
|
||||
from models.schema.auth import UserContext
|
||||
from models.schema.admin import (
|
||||
AdminNotificationCreate,
|
||||
AdminNotificationListResponse,
|
||||
AdminNotificationResponse,
|
||||
PlatformAlertCreate,
|
||||
PlatformAlertListResponse,
|
||||
PlatformAlertResolve,
|
||||
PlatformAlertResponse,
|
||||
)
|
||||
from app.modules.messaging.schemas import (
|
||||
AlertStatisticsResponse,
|
||||
MessageResponse,
|
||||
UnreadCountResponse,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/notifications")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# ADMIN NOTIFICATIONS
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get("", response_model=AdminNotificationListResponse)
|
||||
def get_notifications(
|
||||
priority: str | None = Query(None, description="Filter by priority"),
|
||||
notification_type: str | None = Query(None, description="Filter by type"),
|
||||
is_read: bool | None = Query(None, description="Filter by read status"),
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
) -> AdminNotificationListResponse:
|
||||
"""Get admin notifications with filtering."""
|
||||
notifications, total, unread_count = admin_notification_service.get_notifications(
|
||||
db=db,
|
||||
priority=priority,
|
||||
is_read=is_read,
|
||||
notification_type=notification_type,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
return AdminNotificationListResponse(
|
||||
notifications=[
|
||||
AdminNotificationResponse(
|
||||
id=n.id,
|
||||
type=n.type,
|
||||
priority=n.priority,
|
||||
title=n.title,
|
||||
message=n.message,
|
||||
is_read=n.is_read,
|
||||
read_at=n.read_at,
|
||||
read_by_user_id=n.read_by_user_id,
|
||||
action_required=n.action_required,
|
||||
action_url=n.action_url,
|
||||
metadata=n.notification_metadata,
|
||||
created_at=n.created_at,
|
||||
)
|
||||
for n in notifications
|
||||
],
|
||||
total=total,
|
||||
unread_count=unread_count,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=AdminNotificationResponse)
|
||||
def create_notification(
|
||||
notification_data: AdminNotificationCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
) -> AdminNotificationResponse:
|
||||
"""Create a new admin notification (manual)."""
|
||||
notification = admin_notification_service.create_from_schema(
|
||||
db=db, data=notification_data
|
||||
)
|
||||
db.commit()
|
||||
|
||||
logger.info(f"Admin {current_admin.username} created notification: {notification.title}")
|
||||
|
||||
return AdminNotificationResponse(
|
||||
id=notification.id,
|
||||
type=notification.type,
|
||||
priority=notification.priority,
|
||||
title=notification.title,
|
||||
message=notification.message,
|
||||
is_read=notification.is_read,
|
||||
read_at=notification.read_at,
|
||||
read_by_user_id=notification.read_by_user_id,
|
||||
action_required=notification.action_required,
|
||||
action_url=notification.action_url,
|
||||
metadata=notification.notification_metadata,
|
||||
created_at=notification.created_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/recent")
|
||||
def get_recent_notifications(
|
||||
limit: int = Query(5, ge=1, le=10),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
) -> dict:
|
||||
"""Get recent unread notifications for header dropdown."""
|
||||
notifications = admin_notification_service.get_recent_notifications(
|
||||
db=db, limit=limit
|
||||
)
|
||||
unread_count = admin_notification_service.get_unread_count(db)
|
||||
|
||||
return {
|
||||
"notifications": [
|
||||
{
|
||||
"id": n.id,
|
||||
"type": n.type,
|
||||
"priority": n.priority,
|
||||
"title": n.title,
|
||||
"message": n.message[:100] + "..." if len(n.message) > 100 else n.message,
|
||||
"action_url": n.action_url,
|
||||
"created_at": n.created_at.isoformat(),
|
||||
}
|
||||
for n in notifications
|
||||
],
|
||||
"unread_count": unread_count,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/unread-count", response_model=UnreadCountResponse)
|
||||
def get_unread_count(
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
) -> UnreadCountResponse:
|
||||
"""Get count of unread notifications."""
|
||||
count = admin_notification_service.get_unread_count(db)
|
||||
return UnreadCountResponse(unread_count=count)
|
||||
|
||||
|
||||
@router.put("/{notification_id}/read", response_model=MessageResponse)
|
||||
def mark_as_read(
|
||||
notification_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
) -> MessageResponse:
|
||||
"""Mark notification as read."""
|
||||
notification = admin_notification_service.mark_as_read(
|
||||
db=db, notification_id=notification_id, user_id=current_admin.id
|
||||
)
|
||||
db.commit()
|
||||
|
||||
if notification:
|
||||
return MessageResponse(message="Notification marked as read")
|
||||
return MessageResponse(message="Notification not found")
|
||||
|
||||
|
||||
@router.put("/mark-all-read", response_model=MessageResponse)
|
||||
def mark_all_as_read(
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
) -> MessageResponse:
|
||||
"""Mark all notifications as read."""
|
||||
count = admin_notification_service.mark_all_as_read(
|
||||
db=db, user_id=current_admin.id
|
||||
)
|
||||
db.commit()
|
||||
|
||||
return MessageResponse(message=f"Marked {count} notifications as read")
|
||||
|
||||
|
||||
@router.delete("/{notification_id}", response_model=MessageResponse)
|
||||
def delete_notification(
|
||||
notification_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
) -> MessageResponse:
|
||||
"""Delete a notification."""
|
||||
deleted = admin_notification_service.delete_notification(
|
||||
db=db, notification_id=notification_id
|
||||
)
|
||||
db.commit()
|
||||
|
||||
if deleted:
|
||||
return MessageResponse(message="Notification deleted")
|
||||
|
||||
return MessageResponse(message="Notification not found")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# PLATFORM ALERTS
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get("/alerts", response_model=PlatformAlertListResponse)
|
||||
def get_platform_alerts(
|
||||
severity: str | None = Query(None, description="Filter by severity"),
|
||||
alert_type: str | None = Query(None, description="Filter by alert type"),
|
||||
is_resolved: bool | None = Query(None, description="Filter by resolution status"),
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
) -> PlatformAlertListResponse:
|
||||
"""Get platform alerts with filtering."""
|
||||
alerts, total, active_count, critical_count = platform_alert_service.get_alerts(
|
||||
db=db,
|
||||
severity=severity,
|
||||
alert_type=alert_type,
|
||||
is_resolved=is_resolved,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
return PlatformAlertListResponse(
|
||||
alerts=[
|
||||
PlatformAlertResponse(
|
||||
id=a.id,
|
||||
alert_type=a.alert_type,
|
||||
severity=a.severity,
|
||||
title=a.title,
|
||||
description=a.description,
|
||||
affected_vendors=a.affected_vendors,
|
||||
affected_systems=a.affected_systems,
|
||||
is_resolved=a.is_resolved,
|
||||
resolved_at=a.resolved_at,
|
||||
resolved_by_user_id=a.resolved_by_user_id,
|
||||
resolution_notes=a.resolution_notes,
|
||||
auto_generated=a.auto_generated,
|
||||
occurrence_count=a.occurrence_count,
|
||||
first_occurred_at=a.first_occurred_at,
|
||||
last_occurred_at=a.last_occurred_at,
|
||||
created_at=a.created_at,
|
||||
)
|
||||
for a in alerts
|
||||
],
|
||||
total=total,
|
||||
active_count=active_count,
|
||||
critical_count=critical_count,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/alerts", response_model=PlatformAlertResponse)
|
||||
def create_platform_alert(
|
||||
alert_data: PlatformAlertCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
) -> PlatformAlertResponse:
|
||||
"""Create new platform alert (manual)."""
|
||||
alert = platform_alert_service.create_from_schema(db=db, data=alert_data)
|
||||
db.commit()
|
||||
|
||||
logger.info(f"Admin {current_admin.username} created alert: {alert.title}")
|
||||
|
||||
return PlatformAlertResponse(
|
||||
id=alert.id,
|
||||
alert_type=alert.alert_type,
|
||||
severity=alert.severity,
|
||||
title=alert.title,
|
||||
description=alert.description,
|
||||
affected_vendors=alert.affected_vendors,
|
||||
affected_systems=alert.affected_systems,
|
||||
is_resolved=alert.is_resolved,
|
||||
resolved_at=alert.resolved_at,
|
||||
resolved_by_user_id=alert.resolved_by_user_id,
|
||||
resolution_notes=alert.resolution_notes,
|
||||
auto_generated=alert.auto_generated,
|
||||
occurrence_count=alert.occurrence_count,
|
||||
first_occurred_at=alert.first_occurred_at,
|
||||
last_occurred_at=alert.last_occurred_at,
|
||||
created_at=alert.created_at,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/alerts/{alert_id}/resolve", response_model=MessageResponse)
|
||||
def resolve_platform_alert(
|
||||
alert_id: int,
|
||||
resolve_data: PlatformAlertResolve,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
) -> MessageResponse:
|
||||
"""Resolve platform alert."""
|
||||
alert = platform_alert_service.resolve_alert(
|
||||
db=db,
|
||||
alert_id=alert_id,
|
||||
user_id=current_admin.id,
|
||||
resolution_notes=resolve_data.resolution_notes,
|
||||
)
|
||||
db.commit()
|
||||
|
||||
if alert:
|
||||
logger.info(f"Admin {current_admin.username} resolved alert {alert_id}")
|
||||
return MessageResponse(message="Alert resolved successfully")
|
||||
|
||||
return MessageResponse(message="Alert not found or already resolved")
|
||||
|
||||
|
||||
@router.get("/alerts/stats", response_model=AlertStatisticsResponse)
|
||||
def get_alert_statistics(
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
) -> AlertStatisticsResponse:
|
||||
"""Get alert statistics for dashboard."""
|
||||
stats = platform_alert_service.get_statistics(db)
|
||||
return AlertStatisticsResponse(**stats)
|
||||
@@ -1,214 +0,0 @@
|
||||
# app/api/v1/admin/platform_health.py
|
||||
"""
|
||||
Platform health and capacity monitoring endpoints.
|
||||
|
||||
Provides:
|
||||
- Overall platform health status
|
||||
- Capacity metrics and thresholds
|
||||
- Scaling recommendations
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_admin_api
|
||||
from app.core.database import get_db
|
||||
from app.services.platform_health_service import platform_health_service
|
||||
from models.schema.auth import UserContext
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Schemas
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class SystemMetrics(BaseModel):
|
||||
"""System resource metrics."""
|
||||
|
||||
cpu_percent: float
|
||||
memory_percent: float
|
||||
memory_used_gb: float
|
||||
memory_total_gb: float
|
||||
disk_percent: float
|
||||
disk_used_gb: float
|
||||
disk_total_gb: float
|
||||
|
||||
|
||||
class DatabaseMetrics(BaseModel):
|
||||
"""Database metrics."""
|
||||
|
||||
size_mb: float
|
||||
products_count: int
|
||||
orders_count: int
|
||||
vendors_count: int
|
||||
inventory_count: int
|
||||
|
||||
|
||||
class ImageStorageMetrics(BaseModel):
|
||||
"""Image storage metrics."""
|
||||
|
||||
total_files: int
|
||||
total_size_mb: float
|
||||
total_size_gb: float
|
||||
max_files_per_dir: int
|
||||
products_estimated: int
|
||||
|
||||
|
||||
class CapacityThreshold(BaseModel):
|
||||
"""Capacity threshold status."""
|
||||
|
||||
name: str
|
||||
current: float
|
||||
warning: float
|
||||
critical: float
|
||||
limit: float
|
||||
status: str # ok, warning, critical
|
||||
percent_used: float
|
||||
|
||||
|
||||
class ScalingRecommendation(BaseModel):
|
||||
"""Scaling recommendation."""
|
||||
|
||||
priority: str # info, warning, critical
|
||||
title: str
|
||||
description: str
|
||||
action: str | None = None
|
||||
|
||||
|
||||
class PlatformHealthResponse(BaseModel):
|
||||
"""Complete platform health response."""
|
||||
|
||||
timestamp: str
|
||||
overall_status: str # healthy, degraded, critical
|
||||
system: SystemMetrics
|
||||
database: DatabaseMetrics
|
||||
image_storage: ImageStorageMetrics
|
||||
thresholds: list[CapacityThreshold]
|
||||
recommendations: list[ScalingRecommendation]
|
||||
infrastructure_tier: str
|
||||
next_tier_trigger: str | None = None
|
||||
|
||||
|
||||
class CapacityMetricsResponse(BaseModel):
|
||||
"""Capacity-focused metrics."""
|
||||
|
||||
products_total: int
|
||||
products_by_vendor: dict[str, int]
|
||||
images_total: int
|
||||
storage_used_gb: float
|
||||
database_size_mb: float
|
||||
orders_this_month: int
|
||||
active_vendors: int
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Endpoints
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get("/health", response_model=PlatformHealthResponse)
|
||||
async def get_platform_health(
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Get comprehensive platform health status.
|
||||
|
||||
Returns system metrics, database stats, storage info, and recommendations.
|
||||
"""
|
||||
health_data = platform_health_service.get_full_health_report(db)
|
||||
|
||||
return PlatformHealthResponse(
|
||||
timestamp=health_data["timestamp"],
|
||||
overall_status=health_data["overall_status"],
|
||||
system=SystemMetrics(**health_data["system"]),
|
||||
database=DatabaseMetrics(**health_data["database"]),
|
||||
image_storage=ImageStorageMetrics(**health_data["image_storage"]),
|
||||
thresholds=[CapacityThreshold(**t) for t in health_data["thresholds"]],
|
||||
recommendations=[ScalingRecommendation(**r) for r in health_data["recommendations"]],
|
||||
infrastructure_tier=health_data["infrastructure_tier"],
|
||||
next_tier_trigger=health_data["next_tier_trigger"],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/capacity", response_model=CapacityMetricsResponse)
|
||||
async def get_capacity_metrics(
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Get capacity-focused metrics for planning."""
|
||||
metrics = platform_health_service.get_capacity_metrics(db)
|
||||
return CapacityMetricsResponse(**metrics)
|
||||
|
||||
|
||||
@router.get("/subscription-capacity")
|
||||
async def get_subscription_capacity(
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Get subscription-based capacity metrics.
|
||||
|
||||
Shows theoretical vs actual capacity based on all vendor subscriptions.
|
||||
"""
|
||||
return platform_health_service.get_subscription_capacity(db)
|
||||
|
||||
|
||||
@router.get("/trends")
|
||||
async def get_growth_trends(
|
||||
days: int = 30,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Get growth trends over the specified period.
|
||||
|
||||
Returns growth rates and projections for key metrics.
|
||||
"""
|
||||
from app.services.capacity_forecast_service import capacity_forecast_service
|
||||
|
||||
return capacity_forecast_service.get_growth_trends(db, days=days)
|
||||
|
||||
|
||||
@router.get("/recommendations")
|
||||
async def get_scaling_recommendations(
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Get scaling recommendations based on current capacity and growth.
|
||||
|
||||
Returns prioritized list of recommendations.
|
||||
"""
|
||||
from app.services.capacity_forecast_service import capacity_forecast_service
|
||||
|
||||
return capacity_forecast_service.get_scaling_recommendations(db)
|
||||
|
||||
|
||||
@router.post("/snapshot")
|
||||
async def capture_snapshot(
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Manually capture a capacity snapshot.
|
||||
|
||||
Normally run automatically by daily background job.
|
||||
"""
|
||||
from app.services.capacity_forecast_service import capacity_forecast_service
|
||||
|
||||
snapshot = capacity_forecast_service.capture_daily_snapshot(db)
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"id": snapshot.id,
|
||||
"snapshot_date": snapshot.snapshot_date.isoformat(),
|
||||
"total_vendors": snapshot.total_vendors,
|
||||
"total_products": snapshot.total_products,
|
||||
"message": "Snapshot captured successfully",
|
||||
}
|
||||
@@ -1,224 +0,0 @@
|
||||
# app/api/v1/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.services.platform_service import platform_service
|
||||
from models.schema.auth import UserContext
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
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
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@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))
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
|
||||
@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,
|
||||
)
|
||||
@@ -1,715 +0,0 @@
|
||||
# app/api/v1/admin/settings.py
|
||||
"""
|
||||
Platform settings management endpoints.
|
||||
|
||||
Provides endpoints for:
|
||||
- Viewing all platform settings
|
||||
- Creating/updating settings
|
||||
- Managing configuration by category
|
||||
- Email configuration status and testing
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_admin_api
|
||||
from app.core.config import settings as app_settings
|
||||
from app.core.database import get_db
|
||||
from app.exceptions import ConfirmationRequiredException, ResourceNotFoundException
|
||||
from app.services.admin_audit_service import admin_audit_service
|
||||
from app.services.admin_settings_service import admin_settings_service
|
||||
from models.schema.auth import UserContext
|
||||
from models.schema.admin import (
|
||||
AdminSettingCreate,
|
||||
AdminSettingDefaultResponse,
|
||||
AdminSettingListResponse,
|
||||
AdminSettingResponse,
|
||||
AdminSettingUpdate,
|
||||
PublicDisplaySettingsResponse,
|
||||
RowsPerPageResponse,
|
||||
RowsPerPageUpdateResponse,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/settings")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@router.get("", response_model=AdminSettingListResponse)
|
||||
def get_all_settings(
|
||||
category: str | None = Query(None, description="Filter by category"),
|
||||
is_public: bool | None = Query(None, description="Filter by public flag"),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Get all platform settings.
|
||||
|
||||
Can be filtered by category (system, security, marketplace, notifications)
|
||||
and by public flag (settings that can be exposed to frontend).
|
||||
"""
|
||||
settings = admin_settings_service.get_all_settings(db, category, is_public)
|
||||
|
||||
return AdminSettingListResponse(
|
||||
settings=settings, total=len(settings), category=category
|
||||
)
|
||||
|
||||
|
||||
@router.get("/categories")
|
||||
def get_setting_categories(
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Get list of all setting categories."""
|
||||
# This could be enhanced to return counts per category
|
||||
return {
|
||||
"categories": [
|
||||
"system",
|
||||
"security",
|
||||
"marketplace",
|
||||
"notifications",
|
||||
"integrations",
|
||||
"payments",
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{key}", response_model=AdminSettingResponse | AdminSettingDefaultResponse)
|
||||
def get_setting(
|
||||
key: str,
|
||||
default: str | None = Query(None, description="Default value if setting not found"),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
) -> AdminSettingResponse | AdminSettingDefaultResponse:
|
||||
"""Get specific setting by key.
|
||||
|
||||
If `default` is provided and the setting doesn't exist, returns a response
|
||||
with the default value instead of 404.
|
||||
"""
|
||||
setting = admin_settings_service.get_setting_by_key(db, key)
|
||||
|
||||
if not setting:
|
||||
if default is not None:
|
||||
# Return default value without creating the setting
|
||||
return AdminSettingDefaultResponse(key=key, value=default, exists=False)
|
||||
raise ResourceNotFoundException(resource_type="Setting", identifier=key)
|
||||
|
||||
return AdminSettingResponse.model_validate(setting)
|
||||
|
||||
|
||||
@router.post("", response_model=AdminSettingResponse)
|
||||
def create_setting(
|
||||
setting_data: AdminSettingCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Create new platform setting.
|
||||
|
||||
Setting keys should be lowercase with underscores (e.g., max_vendors_allowed).
|
||||
"""
|
||||
result = admin_settings_service.create_setting(
|
||||
db=db, setting_data=setting_data, admin_user_id=current_admin.id
|
||||
)
|
||||
|
||||
# Log action
|
||||
admin_audit_service.log_action(
|
||||
db=db,
|
||||
admin_user_id=current_admin.id,
|
||||
action="create_setting",
|
||||
target_type="setting",
|
||||
target_id=setting_data.key,
|
||||
details={
|
||||
"category": setting_data.category,
|
||||
"value_type": setting_data.value_type,
|
||||
},
|
||||
)
|
||||
db.commit()
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.put("/{key}", response_model=AdminSettingResponse)
|
||||
def update_setting(
|
||||
key: str,
|
||||
update_data: AdminSettingUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Update existing setting value."""
|
||||
old_value = admin_settings_service.get_setting_value(db, key)
|
||||
|
||||
result = admin_settings_service.update_setting(
|
||||
db=db, key=key, update_data=update_data, admin_user_id=current_admin.id
|
||||
)
|
||||
|
||||
# Log action
|
||||
admin_audit_service.log_action(
|
||||
db=db,
|
||||
admin_user_id=current_admin.id,
|
||||
action="update_setting",
|
||||
target_type="setting",
|
||||
target_id=key,
|
||||
details={"old_value": str(old_value), "new_value": update_data.value},
|
||||
)
|
||||
db.commit()
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/upsert", response_model=AdminSettingResponse)
|
||||
def upsert_setting(
|
||||
setting_data: AdminSettingCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Create or update setting (upsert).
|
||||
|
||||
If setting exists, updates its value. If not, creates new setting.
|
||||
"""
|
||||
result = admin_settings_service.upsert_setting(
|
||||
db=db, setting_data=setting_data, admin_user_id=current_admin.id
|
||||
)
|
||||
|
||||
# Log action
|
||||
admin_audit_service.log_action(
|
||||
db=db,
|
||||
admin_user_id=current_admin.id,
|
||||
action="upsert_setting",
|
||||
target_type="setting",
|
||||
target_id=setting_data.key,
|
||||
details={"category": setting_data.category},
|
||||
)
|
||||
db.commit()
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# CONVENIENCE ENDPOINTS FOR COMMON SETTINGS
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get("/display/rows-per-page", response_model=RowsPerPageResponse)
|
||||
def get_rows_per_page(
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
) -> RowsPerPageResponse:
|
||||
"""Get the platform-wide rows per page setting."""
|
||||
value = admin_settings_service.get_setting_value(db, "rows_per_page", default="20")
|
||||
return RowsPerPageResponse(rows_per_page=int(value))
|
||||
|
||||
|
||||
@router.put("/display/rows-per-page", response_model=RowsPerPageUpdateResponse)
|
||||
def set_rows_per_page(
|
||||
rows: int = Query(..., ge=10, le=100, description="Rows per page (10-100)"),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
) -> RowsPerPageUpdateResponse:
|
||||
"""
|
||||
Set the platform-wide rows per page setting.
|
||||
|
||||
Valid values: 10, 20, 50, 100
|
||||
"""
|
||||
valid_values = [10, 20, 50, 100]
|
||||
if rows not in valid_values:
|
||||
# Round to nearest valid value
|
||||
rows = min(valid_values, key=lambda x: abs(x - rows))
|
||||
|
||||
setting_data = AdminSettingCreate(
|
||||
key="rows_per_page",
|
||||
value=str(rows),
|
||||
value_type="integer",
|
||||
category="display",
|
||||
description="Default number of rows per page in admin tables",
|
||||
is_public=True,
|
||||
)
|
||||
|
||||
admin_settings_service.upsert_setting(
|
||||
db=db, setting_data=setting_data, admin_user_id=current_admin.id
|
||||
)
|
||||
|
||||
admin_audit_service.log_action(
|
||||
db=db,
|
||||
admin_user_id=current_admin.id,
|
||||
action="update_setting",
|
||||
target_type="setting",
|
||||
target_id="rows_per_page",
|
||||
details={"value": rows},
|
||||
)
|
||||
db.commit()
|
||||
|
||||
return RowsPerPageUpdateResponse(
|
||||
rows_per_page=rows, message="Rows per page setting updated"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/display/public", response_model=PublicDisplaySettingsResponse)
|
||||
def get_public_display_settings(
|
||||
db: Session = Depends(get_db),
|
||||
) -> PublicDisplaySettingsResponse:
|
||||
"""
|
||||
Get public display settings (no auth required).
|
||||
|
||||
Returns settings that can be used by frontend without admin auth.
|
||||
"""
|
||||
rows_per_page = admin_settings_service.get_setting_value(
|
||||
db, "rows_per_page", default="20"
|
||||
)
|
||||
|
||||
return PublicDisplaySettingsResponse(rows_per_page=int(rows_per_page))
|
||||
|
||||
|
||||
@router.delete("/{key}")
|
||||
def delete_setting(
|
||||
key: str,
|
||||
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 platform setting.
|
||||
|
||||
Requires confirmation parameter.
|
||||
WARNING: Deleting settings may affect platform functionality.
|
||||
"""
|
||||
if not confirm:
|
||||
raise ConfirmationRequiredException(
|
||||
operation="delete_setting",
|
||||
message="Deletion requires confirmation parameter: confirm=true",
|
||||
)
|
||||
|
||||
message = admin_settings_service.delete_setting(
|
||||
db=db, key=key, admin_user_id=current_admin.id
|
||||
)
|
||||
|
||||
# Log action
|
||||
admin_audit_service.log_action(
|
||||
db=db,
|
||||
admin_user_id=current_admin.id,
|
||||
action="delete_setting",
|
||||
target_type="setting",
|
||||
target_id=key,
|
||||
details={},
|
||||
)
|
||||
db.commit()
|
||||
|
||||
return {"message": message}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# EMAIL CONFIGURATION ENDPOINTS
|
||||
# ============================================================================
|
||||
|
||||
# Email setting keys stored in admin_settings table
|
||||
EMAIL_SETTING_KEYS = {
|
||||
"email_provider": "smtp",
|
||||
"email_from_address": "",
|
||||
"email_from_name": "",
|
||||
"email_reply_to": "",
|
||||
"smtp_host": "",
|
||||
"smtp_port": "587",
|
||||
"smtp_user": "",
|
||||
"smtp_password": "",
|
||||
"smtp_use_tls": "true",
|
||||
"smtp_use_ssl": "false",
|
||||
"sendgrid_api_key": "",
|
||||
"mailgun_api_key": "",
|
||||
"mailgun_domain": "",
|
||||
"aws_access_key_id": "",
|
||||
"aws_secret_access_key": "",
|
||||
"aws_region": "eu-west-1",
|
||||
"email_enabled": "true",
|
||||
"email_debug": "false",
|
||||
}
|
||||
|
||||
|
||||
def get_email_setting(db: Session, key: str) -> str | None:
|
||||
"""Get email setting from database, returns None if not set."""
|
||||
setting = admin_settings_service.get_setting_by_key(db, key)
|
||||
return setting.value if setting else None
|
||||
|
||||
|
||||
def get_effective_email_config(db: Session) -> dict:
|
||||
"""
|
||||
Get effective email configuration.
|
||||
|
||||
Priority: Database settings > Environment variables
|
||||
"""
|
||||
config = {}
|
||||
|
||||
# Provider
|
||||
db_provider = get_email_setting(db, "email_provider")
|
||||
config["provider"] = db_provider if db_provider else app_settings.email_provider
|
||||
|
||||
# From settings
|
||||
db_from_email = get_email_setting(db, "email_from_address")
|
||||
config["from_email"] = db_from_email if db_from_email else app_settings.email_from_address
|
||||
|
||||
db_from_name = get_email_setting(db, "email_from_name")
|
||||
config["from_name"] = db_from_name if db_from_name else app_settings.email_from_name
|
||||
|
||||
db_reply_to = get_email_setting(db, "email_reply_to")
|
||||
config["reply_to"] = db_reply_to if db_reply_to else app_settings.email_reply_to
|
||||
|
||||
# SMTP settings
|
||||
db_smtp_host = get_email_setting(db, "smtp_host")
|
||||
config["smtp_host"] = db_smtp_host if db_smtp_host else app_settings.smtp_host
|
||||
|
||||
db_smtp_port = get_email_setting(db, "smtp_port")
|
||||
config["smtp_port"] = int(db_smtp_port) if db_smtp_port else app_settings.smtp_port
|
||||
|
||||
db_smtp_user = get_email_setting(db, "smtp_user")
|
||||
config["smtp_user"] = db_smtp_user if db_smtp_user else app_settings.smtp_user
|
||||
|
||||
db_smtp_password = get_email_setting(db, "smtp_password")
|
||||
config["smtp_password"] = db_smtp_password if db_smtp_password else app_settings.smtp_password
|
||||
|
||||
db_smtp_use_tls = get_email_setting(db, "smtp_use_tls")
|
||||
config["smtp_use_tls"] = db_smtp_use_tls.lower() in ("true", "1", "yes") if db_smtp_use_tls else app_settings.smtp_use_tls
|
||||
|
||||
db_smtp_use_ssl = get_email_setting(db, "smtp_use_ssl")
|
||||
config["smtp_use_ssl"] = db_smtp_use_ssl.lower() in ("true", "1", "yes") if db_smtp_use_ssl else app_settings.smtp_use_ssl
|
||||
|
||||
# SendGrid
|
||||
db_sendgrid_key = get_email_setting(db, "sendgrid_api_key")
|
||||
config["sendgrid_api_key"] = db_sendgrid_key if db_sendgrid_key else app_settings.sendgrid_api_key
|
||||
|
||||
# Mailgun
|
||||
db_mailgun_key = get_email_setting(db, "mailgun_api_key")
|
||||
config["mailgun_api_key"] = db_mailgun_key if db_mailgun_key else app_settings.mailgun_api_key
|
||||
|
||||
db_mailgun_domain = get_email_setting(db, "mailgun_domain")
|
||||
config["mailgun_domain"] = db_mailgun_domain if db_mailgun_domain else app_settings.mailgun_domain
|
||||
|
||||
# AWS SES
|
||||
db_aws_key = get_email_setting(db, "aws_access_key_id")
|
||||
config["aws_access_key_id"] = db_aws_key if db_aws_key else app_settings.aws_access_key_id
|
||||
|
||||
db_aws_secret = get_email_setting(db, "aws_secret_access_key")
|
||||
config["aws_secret_access_key"] = db_aws_secret if db_aws_secret else app_settings.aws_secret_access_key
|
||||
|
||||
db_aws_region = get_email_setting(db, "aws_region")
|
||||
config["aws_region"] = db_aws_region if db_aws_region else app_settings.aws_region
|
||||
|
||||
# Behavior
|
||||
db_enabled = get_email_setting(db, "email_enabled")
|
||||
config["enabled"] = db_enabled.lower() in ("true", "1", "yes") if db_enabled else app_settings.email_enabled
|
||||
|
||||
db_debug = get_email_setting(db, "email_debug")
|
||||
config["debug"] = db_debug.lower() in ("true", "1", "yes") if db_debug else app_settings.email_debug
|
||||
|
||||
# Track source for each field (DB override or .env)
|
||||
config["_sources"] = {}
|
||||
for key in ["provider", "from_email", "from_name", "smtp_host", "smtp_port"]:
|
||||
db_key = "email_provider" if key == "provider" else ("email_from_address" if key == "from_email" else ("email_from_name" if key == "from_name" else key))
|
||||
config["_sources"][key] = "database" if get_email_setting(db, db_key) else "env"
|
||||
|
||||
return config
|
||||
|
||||
|
||||
class EmailStatusResponse(BaseModel):
|
||||
"""Platform email configuration status."""
|
||||
|
||||
provider: str
|
||||
from_email: str
|
||||
from_name: str
|
||||
reply_to: str | None = None
|
||||
smtp_host: str | None = None
|
||||
smtp_port: int | None = None
|
||||
smtp_user: str | None = None
|
||||
mailgun_domain: str | None = None
|
||||
aws_region: str | None = None
|
||||
debug: bool
|
||||
enabled: bool
|
||||
is_configured: bool
|
||||
has_db_overrides: bool = False
|
||||
|
||||
|
||||
class EmailSettingsUpdate(BaseModel):
|
||||
"""Update email settings."""
|
||||
|
||||
provider: str | None = None
|
||||
from_email: EmailStr | None = None
|
||||
from_name: str | None = None
|
||||
reply_to: EmailStr | None = None
|
||||
# SMTP
|
||||
smtp_host: str | None = None
|
||||
smtp_port: int | None = None
|
||||
smtp_user: str | None = None
|
||||
smtp_password: str | None = None
|
||||
smtp_use_tls: bool | None = None
|
||||
smtp_use_ssl: bool | None = None
|
||||
# SendGrid
|
||||
sendgrid_api_key: str | None = None
|
||||
# Mailgun
|
||||
mailgun_api_key: str | None = None
|
||||
mailgun_domain: str | None = None
|
||||
# AWS SES
|
||||
aws_access_key_id: str | None = None
|
||||
aws_secret_access_key: str | None = None
|
||||
aws_region: str | None = None
|
||||
# Behavior
|
||||
enabled: bool | None = None
|
||||
debug: bool | None = None
|
||||
|
||||
|
||||
class TestEmailRequest(BaseModel):
|
||||
"""Request body for test email."""
|
||||
|
||||
to_email: EmailStr
|
||||
|
||||
|
||||
class TestEmailResponse(BaseModel):
|
||||
"""Response for test email."""
|
||||
|
||||
success: bool
|
||||
message: str
|
||||
|
||||
|
||||
@router.get("/email/status", response_model=EmailStatusResponse)
|
||||
def get_email_status(
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
) -> EmailStatusResponse:
|
||||
"""
|
||||
Get platform email configuration status.
|
||||
|
||||
Returns the effective email configuration (DB overrides > .env).
|
||||
Sensitive values (passwords, API keys) are NOT exposed.
|
||||
"""
|
||||
config = get_effective_email_config(db)
|
||||
provider = config["provider"].lower()
|
||||
|
||||
# Determine if email is configured based on provider
|
||||
is_configured = False
|
||||
if provider == "smtp":
|
||||
is_configured = bool(config["smtp_host"] and config["smtp_host"] != "localhost")
|
||||
elif provider == "sendgrid":
|
||||
is_configured = bool(config["sendgrid_api_key"])
|
||||
elif provider == "mailgun":
|
||||
is_configured = bool(config["mailgun_api_key"] and config["mailgun_domain"])
|
||||
elif provider == "ses":
|
||||
is_configured = bool(config["aws_access_key_id"] and config["aws_secret_access_key"])
|
||||
|
||||
# Check if any DB overrides exist
|
||||
has_db_overrides = any(v == "database" for v in config["_sources"].values())
|
||||
|
||||
return EmailStatusResponse(
|
||||
provider=provider,
|
||||
from_email=config["from_email"],
|
||||
from_name=config["from_name"],
|
||||
reply_to=config["reply_to"] or None,
|
||||
smtp_host=config["smtp_host"] if provider == "smtp" else None,
|
||||
smtp_port=config["smtp_port"] if provider == "smtp" else None,
|
||||
smtp_user=config["smtp_user"] if provider == "smtp" else None,
|
||||
mailgun_domain=config["mailgun_domain"] if provider == "mailgun" else None,
|
||||
aws_region=config["aws_region"] if provider == "ses" else None,
|
||||
debug=config["debug"],
|
||||
enabled=config["enabled"],
|
||||
is_configured=is_configured,
|
||||
has_db_overrides=has_db_overrides,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/email/settings")
|
||||
def update_email_settings(
|
||||
settings_update: EmailSettingsUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Update platform email settings.
|
||||
|
||||
Settings are stored in the database and override .env values.
|
||||
Only non-null values are updated.
|
||||
"""
|
||||
from models.schema.admin import AdminSettingCreate
|
||||
|
||||
updated_keys = []
|
||||
|
||||
# Map request fields to database keys
|
||||
field_mappings = {
|
||||
"provider": ("email_provider", "string"),
|
||||
"from_email": ("email_from_address", "string"),
|
||||
"from_name": ("email_from_name", "string"),
|
||||
"reply_to": ("email_reply_to", "string"),
|
||||
"smtp_host": ("smtp_host", "string"),
|
||||
"smtp_port": ("smtp_port", "integer"),
|
||||
"smtp_user": ("smtp_user", "string"),
|
||||
"smtp_password": ("smtp_password", "string"),
|
||||
"smtp_use_tls": ("smtp_use_tls", "boolean"),
|
||||
"smtp_use_ssl": ("smtp_use_ssl", "boolean"),
|
||||
"sendgrid_api_key": ("sendgrid_api_key", "string"),
|
||||
"mailgun_api_key": ("mailgun_api_key", "string"),
|
||||
"mailgun_domain": ("mailgun_domain", "string"),
|
||||
"aws_access_key_id": ("aws_access_key_id", "string"),
|
||||
"aws_secret_access_key": ("aws_secret_access_key", "string"),
|
||||
"aws_region": ("aws_region", "string"),
|
||||
"enabled": ("email_enabled", "boolean"),
|
||||
"debug": ("email_debug", "boolean"),
|
||||
}
|
||||
|
||||
# Sensitive fields that should be marked as encrypted
|
||||
sensitive_keys = {
|
||||
"smtp_password", "sendgrid_api_key", "mailgun_api_key",
|
||||
"aws_access_key_id", "aws_secret_access_key"
|
||||
}
|
||||
|
||||
for field, (db_key, value_type) in field_mappings.items():
|
||||
value = getattr(settings_update, field, None)
|
||||
if value is not None:
|
||||
# Convert value to string for storage
|
||||
if value_type == "boolean":
|
||||
str_value = "true" if value else "false"
|
||||
elif value_type == "integer":
|
||||
str_value = str(value)
|
||||
else:
|
||||
str_value = str(value)
|
||||
|
||||
# Create or update setting
|
||||
setting_data = AdminSettingCreate(
|
||||
key=db_key,
|
||||
value=str_value,
|
||||
value_type=value_type,
|
||||
category="email",
|
||||
description=f"Email setting: {field}",
|
||||
is_encrypted=db_key in sensitive_keys,
|
||||
is_public=False,
|
||||
)
|
||||
|
||||
admin_settings_service.upsert_setting(db, setting_data, current_admin.id)
|
||||
updated_keys.append(field)
|
||||
|
||||
# Log action
|
||||
admin_audit_service.log_action(
|
||||
db=db,
|
||||
admin_user_id=current_admin.id,
|
||||
action="update_email_settings",
|
||||
target_type="email_settings",
|
||||
target_id="platform",
|
||||
details={"updated_keys": updated_keys},
|
||||
)
|
||||
db.commit()
|
||||
|
||||
logger.info(f"Email settings updated by admin {current_admin.id}: {updated_keys}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Updated {len(updated_keys)} email setting(s)",
|
||||
"updated_keys": updated_keys,
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/email/settings")
|
||||
def reset_email_settings(
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Reset email settings to use .env values.
|
||||
|
||||
Deletes all email settings from the database, reverting to .env configuration.
|
||||
"""
|
||||
deleted_count = 0
|
||||
|
||||
for key in EMAIL_SETTING_KEYS:
|
||||
setting = admin_settings_service.get_setting_by_key(db, key)
|
||||
if setting:
|
||||
# Use service method for deletion (API-002 compliance)
|
||||
admin_settings_service.delete_setting(db, key, current_admin.id)
|
||||
deleted_count += 1
|
||||
|
||||
# Log action
|
||||
admin_audit_service.log_action(
|
||||
db=db,
|
||||
admin_user_id=current_admin.id,
|
||||
action="reset_email_settings",
|
||||
target_type="email_settings",
|
||||
target_id="platform",
|
||||
details={"deleted_count": deleted_count},
|
||||
)
|
||||
db.commit()
|
||||
|
||||
logger.info(f"Email settings reset by admin {current_admin.id}, deleted {deleted_count} settings")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Reset {deleted_count} email setting(s) to .env defaults",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/email/test", response_model=TestEmailResponse)
|
||||
def send_test_email(
|
||||
request: TestEmailRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
) -> TestEmailResponse:
|
||||
"""
|
||||
Send a test email using the platform email configuration.
|
||||
|
||||
This tests the email provider configuration from environment variables.
|
||||
"""
|
||||
from app.services.email_service import EmailService
|
||||
|
||||
try:
|
||||
email_service = EmailService(db)
|
||||
|
||||
# Send test email using platform configuration
|
||||
email_log = email_service.send_raw(
|
||||
to_email=request.to_email,
|
||||
to_name=None,
|
||||
subject="Wizamart Platform - Test Email",
|
||||
body_html="""
|
||||
<html>
|
||||
<body style="font-family: Arial, sans-serif; padding: 20px;">
|
||||
<h2 style="color: #6b46c1;">Test Email from Wizamart</h2>
|
||||
<p>This is a test email to verify your platform email configuration.</p>
|
||||
<p>If you received this email, your email settings are working correctly!</p>
|
||||
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 20px 0;">
|
||||
<p style="color: #6b7280; font-size: 12px;">
|
||||
Provider: {provider}<br>
|
||||
From: {from_email}
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
""".format(
|
||||
provider=app_settings.email_provider,
|
||||
from_email=app_settings.email_from_address,
|
||||
),
|
||||
body_text=f"Test email from Wizamart platform.\n\nProvider: {app_settings.email_provider}\nFrom: {app_settings.email_from_address}",
|
||||
is_platform_email=True,
|
||||
)
|
||||
|
||||
# Check if email was actually sent (send_raw returns EmailLog, not boolean)
|
||||
if email_log.status == "sent":
|
||||
# Log action
|
||||
admin_audit_service.log_action(
|
||||
db=db,
|
||||
admin_user_id=current_admin.id,
|
||||
action="send_test_email",
|
||||
target_type="email",
|
||||
target_id=request.to_email,
|
||||
details={"provider": app_settings.email_provider},
|
||||
)
|
||||
db.commit()
|
||||
|
||||
return TestEmailResponse(
|
||||
success=True,
|
||||
message=f"Test email sent to {request.to_email}",
|
||||
)
|
||||
else:
|
||||
return TestEmailResponse(
|
||||
success=False,
|
||||
message=email_log.error_message or "Failed to send test email. Check server logs for details.",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send test email: {e}")
|
||||
return TestEmailResponse(
|
||||
success=False,
|
||||
message=f"Error sending test email: {str(e)}",
|
||||
)
|
||||
@@ -1,338 +0,0 @@
|
||||
"""
|
||||
Test Runner API Endpoints
|
||||
RESTful API for running pytest and viewing test results
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, Query
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_admin_api
|
||||
from app.core.database import get_db
|
||||
from app.services.test_runner_service import test_runner_service
|
||||
from app.tasks.test_runner_tasks import execute_test_run
|
||||
from models.schema.auth import UserContext
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# Pydantic Models for API
|
||||
|
||||
|
||||
class TestRunResponse(BaseModel):
|
||||
"""Response model for a test run"""
|
||||
|
||||
id: int
|
||||
timestamp: str
|
||||
total_tests: int
|
||||
passed: int
|
||||
failed: int
|
||||
errors: int
|
||||
skipped: int
|
||||
xfailed: int
|
||||
xpassed: int
|
||||
pass_rate: float
|
||||
duration_seconds: float
|
||||
coverage_percent: float | None
|
||||
triggered_by: str | None
|
||||
git_commit_hash: str | None
|
||||
git_branch: str | None
|
||||
test_path: str | None
|
||||
status: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class TestResultResponse(BaseModel):
|
||||
"""Response model for a single test result"""
|
||||
|
||||
id: int
|
||||
node_id: str
|
||||
test_name: str
|
||||
test_file: str
|
||||
test_class: str | None
|
||||
outcome: str
|
||||
duration_seconds: float
|
||||
error_message: str | None
|
||||
traceback: str | None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class RunTestsRequest(BaseModel):
|
||||
"""Request model for running tests"""
|
||||
|
||||
test_path: str = Field("tests", description="Path to tests to run")
|
||||
extra_args: list[str] | None = Field(
|
||||
None, description="Additional pytest arguments"
|
||||
)
|
||||
|
||||
|
||||
class TestDashboardStatsResponse(BaseModel):
|
||||
"""Response model for dashboard statistics"""
|
||||
|
||||
# Current run stats
|
||||
total_tests: int
|
||||
passed: int
|
||||
failed: int
|
||||
errors: int
|
||||
skipped: int
|
||||
pass_rate: float
|
||||
duration_seconds: float
|
||||
coverage_percent: float | None
|
||||
last_run: str | None
|
||||
last_run_status: str | None
|
||||
|
||||
# Collection stats
|
||||
total_test_files: int
|
||||
collected_tests: int
|
||||
unit_tests: int
|
||||
integration_tests: int
|
||||
performance_tests: int
|
||||
system_tests: int
|
||||
last_collected: str | None
|
||||
|
||||
# Trend and breakdown data
|
||||
trend: list[dict]
|
||||
by_category: dict
|
||||
top_failing: list[dict]
|
||||
|
||||
|
||||
# API Endpoints
|
||||
|
||||
|
||||
@router.post("/run", response_model=TestRunResponse)
|
||||
async def run_tests(
|
||||
background_tasks: BackgroundTasks,
|
||||
request: RunTestsRequest | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Start a pytest run in the background
|
||||
|
||||
Requires admin authentication. Creates a test run record and starts
|
||||
pytest execution in the background. Returns immediately with the run ID.
|
||||
Poll GET /runs/{run_id} to check status.
|
||||
"""
|
||||
test_path = request.test_path if request else "tests"
|
||||
extra_args = request.extra_args if request else None
|
||||
|
||||
# Create the test run record
|
||||
run = test_runner_service.create_test_run(
|
||||
db,
|
||||
test_path=test_path,
|
||||
triggered_by=f"manual:{current_user.username}",
|
||||
)
|
||||
db.commit()
|
||||
|
||||
# Dispatch via task dispatcher (supports Celery or BackgroundTasks)
|
||||
from app.tasks.dispatcher import task_dispatcher
|
||||
|
||||
celery_task_id = task_dispatcher.dispatch_test_run(
|
||||
background_tasks=background_tasks,
|
||||
run_id=run.id,
|
||||
test_path=test_path,
|
||||
extra_args=extra_args,
|
||||
)
|
||||
|
||||
# Store Celery task ID if using Celery
|
||||
if celery_task_id:
|
||||
run.celery_task_id = celery_task_id
|
||||
db.commit()
|
||||
|
||||
return TestRunResponse(
|
||||
id=run.id,
|
||||
timestamp=run.timestamp.isoformat(),
|
||||
total_tests=run.total_tests,
|
||||
passed=run.passed,
|
||||
failed=run.failed,
|
||||
errors=run.errors,
|
||||
skipped=run.skipped,
|
||||
xfailed=run.xfailed,
|
||||
xpassed=run.xpassed,
|
||||
pass_rate=run.pass_rate,
|
||||
duration_seconds=run.duration_seconds,
|
||||
coverage_percent=run.coverage_percent,
|
||||
triggered_by=run.triggered_by,
|
||||
git_commit_hash=run.git_commit_hash,
|
||||
git_branch=run.git_branch,
|
||||
test_path=run.test_path,
|
||||
status=run.status,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/runs", response_model=list[TestRunResponse])
|
||||
async def list_runs(
|
||||
limit: int = Query(20, ge=1, le=100, description="Number of runs to return"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Get test run history
|
||||
|
||||
Returns recent test runs for trend analysis.
|
||||
"""
|
||||
runs = test_runner_service.get_run_history(db, limit=limit)
|
||||
|
||||
return [
|
||||
TestRunResponse(
|
||||
id=run.id,
|
||||
timestamp=run.timestamp.isoformat(),
|
||||
total_tests=run.total_tests,
|
||||
passed=run.passed,
|
||||
failed=run.failed,
|
||||
errors=run.errors,
|
||||
skipped=run.skipped,
|
||||
xfailed=run.xfailed,
|
||||
xpassed=run.xpassed,
|
||||
pass_rate=run.pass_rate,
|
||||
duration_seconds=run.duration_seconds,
|
||||
coverage_percent=run.coverage_percent,
|
||||
triggered_by=run.triggered_by,
|
||||
git_commit_hash=run.git_commit_hash,
|
||||
git_branch=run.git_branch,
|
||||
test_path=run.test_path,
|
||||
status=run.status,
|
||||
)
|
||||
for run in runs
|
||||
]
|
||||
|
||||
|
||||
@router.get("/runs/{run_id}", response_model=TestRunResponse)
|
||||
async def get_run(
|
||||
run_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Get a specific test run
|
||||
"""
|
||||
run = test_runner_service.get_run_by_id(db, run_id)
|
||||
|
||||
if not run:
|
||||
from app.exceptions.base import ResourceNotFoundException
|
||||
|
||||
raise ResourceNotFoundException("TestRun", str(run_id))
|
||||
|
||||
return TestRunResponse(
|
||||
id=run.id,
|
||||
timestamp=run.timestamp.isoformat(),
|
||||
total_tests=run.total_tests,
|
||||
passed=run.passed,
|
||||
failed=run.failed,
|
||||
errors=run.errors,
|
||||
skipped=run.skipped,
|
||||
xfailed=run.xfailed,
|
||||
xpassed=run.xpassed,
|
||||
pass_rate=run.pass_rate,
|
||||
duration_seconds=run.duration_seconds,
|
||||
coverage_percent=run.coverage_percent,
|
||||
triggered_by=run.triggered_by,
|
||||
git_commit_hash=run.git_commit_hash,
|
||||
git_branch=run.git_branch,
|
||||
test_path=run.test_path,
|
||||
status=run.status,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/runs/{run_id}/results", response_model=list[TestResultResponse])
|
||||
async def get_run_results(
|
||||
run_id: int,
|
||||
outcome: str | None = Query(
|
||||
None, description="Filter by outcome (passed, failed, error, skipped)"
|
||||
),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Get test results for a specific run
|
||||
"""
|
||||
results = test_runner_service.get_run_results(db, run_id, outcome=outcome)
|
||||
|
||||
return [
|
||||
TestResultResponse(
|
||||
id=r.id,
|
||||
node_id=r.node_id,
|
||||
test_name=r.test_name,
|
||||
test_file=r.test_file,
|
||||
test_class=r.test_class,
|
||||
outcome=r.outcome,
|
||||
duration_seconds=r.duration_seconds,
|
||||
error_message=r.error_message,
|
||||
traceback=r.traceback,
|
||||
)
|
||||
for r in results
|
||||
]
|
||||
|
||||
|
||||
@router.get("/runs/{run_id}/failures", response_model=list[TestResultResponse])
|
||||
async def get_run_failures(
|
||||
run_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Get failed tests from a specific run
|
||||
"""
|
||||
failures = test_runner_service.get_failed_tests(db, run_id)
|
||||
|
||||
return [
|
||||
TestResultResponse(
|
||||
id=r.id,
|
||||
node_id=r.node_id,
|
||||
test_name=r.test_name,
|
||||
test_file=r.test_file,
|
||||
test_class=r.test_class,
|
||||
outcome=r.outcome,
|
||||
duration_seconds=r.duration_seconds,
|
||||
error_message=r.error_message,
|
||||
traceback=r.traceback,
|
||||
)
|
||||
for r in failures
|
||||
]
|
||||
|
||||
|
||||
@router.get("/stats", response_model=TestDashboardStatsResponse)
|
||||
async def get_dashboard_stats(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Get dashboard statistics
|
||||
|
||||
Returns comprehensive stats for the testing dashboard including:
|
||||
- Total counts by outcome
|
||||
- Pass rate
|
||||
- Trend data
|
||||
- Tests by category
|
||||
- Top failing tests
|
||||
"""
|
||||
stats = test_runner_service.get_dashboard_stats(db)
|
||||
return TestDashboardStatsResponse(**stats)
|
||||
|
||||
|
||||
@router.post("/collect")
|
||||
async def collect_tests(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Collect test information without running tests
|
||||
|
||||
Updates the test collection cache with current test counts.
|
||||
"""
|
||||
collection = test_runner_service.collect_tests(db)
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"total_tests": collection.total_tests,
|
||||
"total_files": collection.total_files,
|
||||
"unit_tests": collection.unit_tests,
|
||||
"integration_tests": collection.integration_tests,
|
||||
"performance_tests": collection.performance_tests,
|
||||
"system_tests": collection.system_tests,
|
||||
"collected_at": collection.collected_at.isoformat(),
|
||||
}
|
||||
@@ -1,236 +0,0 @@
|
||||
# app/api/v1/admin/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.services.admin_service import admin_service
|
||||
from app.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,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/users")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@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,
|
||||
)
|
||||
|
||||
|
||||
@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,
|
||||
)
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
|
||||
@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,
|
||||
)
|
||||
|
||||
|
||||
@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,
|
||||
)
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
|
||||
@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)
|
||||
@@ -1,309 +0,0 @@
|
||||
# app/api/v1/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.services.vendor_domain_service import vendor_domain_service
|
||||
from app.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,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/vendors")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@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,
|
||||
)
|
||||
|
||||
|
||||
@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),
|
||||
)
|
||||
|
||||
|
||||
@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,
|
||||
)
|
||||
|
||||
|
||||
@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,
|
||||
)
|
||||
|
||||
|
||||
@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
|
||||
)
|
||||
|
||||
|
||||
@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,
|
||||
)
|
||||
|
||||
|
||||
@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"],
|
||||
)
|
||||
@@ -1,234 +0,0 @@
|
||||
# app/api/v1/admin/vendor_themes.py
|
||||
"""
|
||||
Vendor theme management endpoints for admin.
|
||||
|
||||
These endpoints allow admins to:
|
||||
- View vendor themes
|
||||
- Apply theme presets
|
||||
- Customize theme settings
|
||||
- Reset themes to default
|
||||
|
||||
All operations use the service layer for business logic.
|
||||
All exceptions are handled by the global exception handler.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, Path
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_admin_api, get_db
|
||||
from app.services.vendor_theme_service import vendor_theme_service
|
||||
from models.schema.auth import UserContext
|
||||
from models.schema.vendor_theme import (
|
||||
ThemeDeleteResponse,
|
||||
ThemePresetListResponse,
|
||||
ThemePresetResponse,
|
||||
VendorThemeResponse,
|
||||
VendorThemeUpdate,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/vendor-themes")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# PRESET ENDPOINTS
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get("/presets", response_model=ThemePresetListResponse)
|
||||
async def get_theme_presets(current_admin: UserContext = Depends(get_current_admin_api)):
|
||||
"""
|
||||
Get all available theme presets with preview information.
|
||||
|
||||
Returns list of presets that can be applied to vendor themes.
|
||||
Each preset includes color palette, fonts, and layout configuration.
|
||||
|
||||
**Permissions:** Admin only
|
||||
|
||||
**Returns:**
|
||||
- List of available theme presets with preview data
|
||||
"""
|
||||
logger.info("Getting theme presets")
|
||||
|
||||
presets = vendor_theme_service.get_available_presets()
|
||||
return ThemePresetListResponse(presets=presets)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# THEME RETRIEVAL
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get("/{vendor_code}", response_model=VendorThemeResponse)
|
||||
async def get_vendor_theme(
|
||||
vendor_code: str = Path(..., description="Vendor code"),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Get theme configuration for a vendor.
|
||||
|
||||
Returns the vendor's custom theme if exists, otherwise returns default theme.
|
||||
|
||||
**Path Parameters:**
|
||||
- `vendor_code`: Vendor code (e.g., VENDOR001)
|
||||
|
||||
**Permissions:** Admin only
|
||||
|
||||
**Returns:**
|
||||
- Complete theme configuration including colors, fonts, layout, and branding
|
||||
|
||||
**Errors:**
|
||||
- `404`: Vendor not found (VendorNotFoundException)
|
||||
"""
|
||||
logger.info(f"Getting theme for vendor: {vendor_code}")
|
||||
|
||||
# Service raises VendorNotFoundException if vendor not found
|
||||
# Global exception handler converts it to HTTP 404
|
||||
theme = vendor_theme_service.get_theme(db, vendor_code)
|
||||
return theme
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# THEME UPDATE
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.put("/{vendor_code}", response_model=VendorThemeResponse)
|
||||
async def update_vendor_theme(
|
||||
vendor_code: str = Path(..., description="Vendor code"),
|
||||
theme_data: VendorThemeUpdate = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Update or create theme for a vendor.
|
||||
|
||||
Accepts partial updates - only provided fields are updated.
|
||||
If vendor has no theme, a new one is created.
|
||||
|
||||
**Path Parameters:**
|
||||
- `vendor_code`: Vendor code (e.g., VENDOR001)
|
||||
|
||||
**Request Body:**
|
||||
- `theme_name`: Optional theme name
|
||||
- `colors`: Optional color palette (primary, secondary, accent, etc.)
|
||||
- `fonts`: Optional font settings (heading, body)
|
||||
- `layout`: Optional layout settings (style, header, product_card)
|
||||
- `branding`: Optional branding assets (logo, favicon, etc.)
|
||||
- `custom_css`: Optional custom CSS rules
|
||||
- `social_links`: Optional social media links
|
||||
|
||||
**Permissions:** Admin only
|
||||
|
||||
**Returns:**
|
||||
- Updated theme configuration
|
||||
|
||||
**Errors:**
|
||||
- `404`: Vendor not found (VendorNotFoundException)
|
||||
- `422`: Validation error (ThemeValidationException, InvalidColorFormatException, etc.)
|
||||
- `500`: Operation failed (ThemeOperationException)
|
||||
"""
|
||||
logger.info(f"Updating theme for vendor: {vendor_code}")
|
||||
|
||||
# Service handles all validation and raises appropriate exceptions
|
||||
# Global exception handler converts them to proper HTTP responses
|
||||
theme = vendor_theme_service.update_theme(db, vendor_code, theme_data)
|
||||
db.commit()
|
||||
return VendorThemeResponse(**theme.to_dict())
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# PRESET APPLICATION
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.post("/{vendor_code}/preset/{preset_name}", response_model=ThemePresetResponse)
|
||||
async def apply_theme_preset(
|
||||
vendor_code: str = Path(..., description="Vendor code"),
|
||||
preset_name: str = Path(..., description="Preset name"),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Apply a theme preset to a vendor.
|
||||
|
||||
Replaces the vendor's current theme with the selected preset.
|
||||
Available presets can be retrieved from the `/presets` endpoint.
|
||||
|
||||
**Path Parameters:**
|
||||
- `vendor_code`: Vendor code (e.g., VENDOR001)
|
||||
- `preset_name`: Name of preset to apply (e.g., 'modern', 'classic')
|
||||
|
||||
**Available Presets:**
|
||||
- `default`: Clean and professional
|
||||
- `modern`: Contemporary tech-inspired
|
||||
- `classic`: Traditional and trustworthy
|
||||
- `minimal`: Ultra-clean black and white
|
||||
- `vibrant`: Bold and energetic
|
||||
- `elegant`: Sophisticated gray tones
|
||||
- `nature`: Fresh and eco-friendly
|
||||
|
||||
**Permissions:** Admin only
|
||||
|
||||
**Returns:**
|
||||
- Success message and applied theme configuration
|
||||
|
||||
**Errors:**
|
||||
- `404`: Vendor not found (VendorNotFoundException) or preset not found (ThemePresetNotFoundException)
|
||||
- `500`: Operation failed (ThemeOperationException)
|
||||
"""
|
||||
logger.info(f"Applying preset '{preset_name}' to vendor {vendor_code}")
|
||||
|
||||
# Service validates preset name and applies it
|
||||
# Raises ThemePresetNotFoundException if preset doesn't exist
|
||||
# Global exception handler converts to HTTP 404
|
||||
theme = vendor_theme_service.apply_theme_preset(db, vendor_code, preset_name)
|
||||
db.commit()
|
||||
|
||||
return ThemePresetResponse(
|
||||
message=f"Applied {preset_name} preset successfully",
|
||||
theme=VendorThemeResponse(**theme.to_dict()),
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# THEME DELETION
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.delete("/{vendor_code}", response_model=ThemeDeleteResponse)
|
||||
async def delete_vendor_theme(
|
||||
vendor_code: str = Path(..., description="Vendor code"),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Delete custom theme for a vendor.
|
||||
|
||||
Removes the vendor's custom theme. After deletion, the vendor
|
||||
will revert to using the default platform theme.
|
||||
|
||||
**Path Parameters:**
|
||||
- `vendor_code`: Vendor code (e.g., VENDOR001)
|
||||
|
||||
**Permissions:** Admin only
|
||||
|
||||
**Returns:**
|
||||
- Success message
|
||||
|
||||
**Errors:**
|
||||
- `404`: Vendor not found (VendorNotFoundException) or no custom theme (VendorThemeNotFoundException)
|
||||
- `500`: Operation failed (ThemeOperationException)
|
||||
"""
|
||||
logger.info(f"Deleting theme for vendor: {vendor_code}")
|
||||
|
||||
# Service handles deletion and raises exceptions if needed
|
||||
# Global exception handler converts them to proper HTTP responses
|
||||
result = vendor_theme_service.delete_theme(db, vendor_code)
|
||||
db.commit()
|
||||
return ThemeDeleteResponse(
|
||||
message=result.get("message", "Theme deleted successfully")
|
||||
)
|
||||
@@ -1,492 +0,0 @@
|
||||
# app/api/v1/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.exceptions import ConfirmationRequiredException
|
||||
from app.services.admin_service import admin_service
|
||||
from app.services.stats_service import stats_service
|
||||
from app.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,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/vendors")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@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",
|
||||
)
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
|
||||
@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,
|
||||
)
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
|
||||
@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.
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
|
||||
@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
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@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}"',
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@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,
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
# app/api/v1/platform/__init__.py
|
||||
"""
|
||||
Platform public API endpoints.
|
||||
|
||||
These endpoints are publicly accessible (no authentication required)
|
||||
and serve the marketing homepage, pricing pages, and signup flows.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v1.platform import pricing, letzshop_vendors, signup
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Public pricing and tier info
|
||||
router.include_router(pricing.router, tags=["platform-pricing"])
|
||||
|
||||
# Letzshop vendor lookup
|
||||
router.include_router(letzshop_vendors.router, tags=["platform-vendors"])
|
||||
|
||||
# Signup flow
|
||||
router.include_router(signup.router, tags=["platform-signup"])
|
||||
@@ -1,271 +0,0 @@
|
||||
# app/api/v1/platform/letzshop_vendors.py
|
||||
"""
|
||||
Letzshop vendor lookup API endpoints.
|
||||
|
||||
Allows potential vendors to find themselves in the Letzshop marketplace
|
||||
and claim their shop during signup.
|
||||
|
||||
All endpoints are public (no authentication required).
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from app.exceptions import ResourceNotFoundException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.modules.marketplace.services.letzshop import LetzshopVendorSyncService
|
||||
from app.services.platform_signup_service import platform_signup_service
|
||||
from app.modules.marketplace.models import LetzshopVendorCache
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Response Schemas
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class LetzshopVendorInfo(BaseModel):
|
||||
"""Letzshop vendor information for display."""
|
||||
|
||||
letzshop_id: str | None = None
|
||||
slug: str
|
||||
name: str
|
||||
company_name: str | None = None
|
||||
description: str | None = None
|
||||
email: str | None = None
|
||||
phone: str | None = None
|
||||
website: str | None = None
|
||||
address: str | None = None
|
||||
city: str | None = None
|
||||
categories: list[str] = []
|
||||
background_image_url: str | None = None
|
||||
social_media_links: list[str] = []
|
||||
letzshop_url: str
|
||||
is_claimed: bool = False
|
||||
|
||||
@classmethod
|
||||
def from_cache(cls, cache: LetzshopVendorCache, lang: str = "en") -> "LetzshopVendorInfo":
|
||||
"""Create from cache entry."""
|
||||
return cls(
|
||||
letzshop_id=cache.letzshop_id,
|
||||
slug=cache.slug,
|
||||
name=cache.name,
|
||||
company_name=cache.company_name,
|
||||
description=cache.get_description(lang),
|
||||
email=cache.email,
|
||||
phone=cache.phone,
|
||||
website=cache.website,
|
||||
address=cache.get_full_address(),
|
||||
city=cache.city,
|
||||
categories=cache.categories or [],
|
||||
background_image_url=cache.background_image_url,
|
||||
social_media_links=cache.social_media_links or [],
|
||||
letzshop_url=cache.letzshop_url,
|
||||
is_claimed=cache.is_claimed,
|
||||
)
|
||||
|
||||
|
||||
class LetzshopVendorListResponse(BaseModel):
|
||||
"""Paginated list of Letzshop vendors."""
|
||||
|
||||
vendors: list[LetzshopVendorInfo]
|
||||
total: int
|
||||
page: int
|
||||
limit: int
|
||||
has_more: bool
|
||||
|
||||
|
||||
class LetzshopLookupRequest(BaseModel):
|
||||
"""Request to lookup a Letzshop vendor by URL."""
|
||||
|
||||
url: str # e.g., https://letzshop.lu/vendors/my-shop or just "my-shop"
|
||||
|
||||
|
||||
class LetzshopLookupResponse(BaseModel):
|
||||
"""Response from Letzshop vendor lookup."""
|
||||
|
||||
found: bool
|
||||
vendor: LetzshopVendorInfo | None = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Helper Functions
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def extract_slug_from_url(url_or_slug: str) -> str:
|
||||
"""
|
||||
Extract vendor slug from Letzshop URL or return as-is if already a slug.
|
||||
|
||||
Handles:
|
||||
- https://letzshop.lu/vendors/my-shop
|
||||
- https://letzshop.lu/en/vendors/my-shop
|
||||
- letzshop.lu/vendors/my-shop
|
||||
- my-shop
|
||||
"""
|
||||
# Clean up the input
|
||||
url_or_slug = url_or_slug.strip()
|
||||
|
||||
# If it looks like a URL, extract the slug
|
||||
if "letzshop" in url_or_slug.lower() or "/" in url_or_slug:
|
||||
# Remove protocol if present
|
||||
url_or_slug = re.sub(r"^https?://", "", url_or_slug)
|
||||
|
||||
# Match pattern like letzshop.lu/[lang/]vendors/SLUG[/...]
|
||||
match = re.search(r"letzshop\.lu/(?:[a-z]{2}/)?vendors?/([^/?#]+)", url_or_slug, re.IGNORECASE)
|
||||
if match:
|
||||
return match.group(1).lower()
|
||||
|
||||
# If just a path like vendors/my-shop
|
||||
match = re.search(r"vendors?/([^/?#]+)", url_or_slug)
|
||||
if match:
|
||||
return match.group(1).lower()
|
||||
|
||||
# Return as-is (assume it's already a slug)
|
||||
return url_or_slug.lower()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Endpoints
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@router.get("/letzshop-vendors", response_model=LetzshopVendorListResponse) # public
|
||||
async def list_letzshop_vendors(
|
||||
search: Annotated[str | None, Query(description="Search by name")] = None,
|
||||
category: Annotated[str | None, Query(description="Filter by category")] = None,
|
||||
city: Annotated[str | None, Query(description="Filter by city")] = None,
|
||||
only_unclaimed: Annotated[bool, Query(description="Only show unclaimed vendors")] = False,
|
||||
lang: Annotated[str, Query(description="Language for descriptions")] = "en",
|
||||
page: Annotated[int, Query(ge=1)] = 1,
|
||||
limit: Annotated[int, Query(ge=1, le=50)] = 20,
|
||||
db: Session = Depends(get_db),
|
||||
) -> LetzshopVendorListResponse:
|
||||
"""
|
||||
List Letzshop vendors from cached directory.
|
||||
|
||||
The cache is periodically synced from Letzshop's public GraphQL API.
|
||||
Run the sync task manually or wait for scheduled sync if cache is empty.
|
||||
"""
|
||||
sync_service = LetzshopVendorSyncService(db)
|
||||
|
||||
vendors, total = sync_service.search_cached_vendors(
|
||||
search=search,
|
||||
city=city,
|
||||
category=category,
|
||||
only_unclaimed=only_unclaimed,
|
||||
page=page,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
return LetzshopVendorListResponse(
|
||||
vendors=[LetzshopVendorInfo.from_cache(v, lang) for v in vendors],
|
||||
total=total,
|
||||
page=page,
|
||||
limit=limit,
|
||||
has_more=(page * limit) < total,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/letzshop-vendors/lookup", response_model=LetzshopLookupResponse) # public
|
||||
async def lookup_letzshop_vendor(
|
||||
request: LetzshopLookupRequest,
|
||||
lang: Annotated[str, Query(description="Language for descriptions")] = "en",
|
||||
db: Session = Depends(get_db),
|
||||
) -> LetzshopLookupResponse:
|
||||
"""
|
||||
Lookup a Letzshop vendor by URL or slug.
|
||||
|
||||
This endpoint:
|
||||
1. Extracts the slug from the provided URL
|
||||
2. Looks up vendor in local cache (or fetches from Letzshop if not cached)
|
||||
3. Checks if the vendor is already claimed on our platform
|
||||
4. Returns vendor info for signup pre-fill
|
||||
"""
|
||||
try:
|
||||
slug = extract_slug_from_url(request.url)
|
||||
|
||||
if not slug:
|
||||
return LetzshopLookupResponse(
|
||||
found=False,
|
||||
error="Could not extract vendor slug from URL",
|
||||
)
|
||||
|
||||
sync_service = LetzshopVendorSyncService(db)
|
||||
|
||||
# First try cache
|
||||
cache_entry = sync_service.get_cached_vendor(slug)
|
||||
|
||||
# If not in cache, try to fetch from Letzshop
|
||||
if not cache_entry:
|
||||
logger.info(f"Vendor {slug} not in cache, fetching from Letzshop...")
|
||||
cache_entry = sync_service.sync_single_vendor(slug)
|
||||
|
||||
if not cache_entry:
|
||||
return LetzshopLookupResponse(
|
||||
found=False,
|
||||
error="Vendor not found on Letzshop",
|
||||
)
|
||||
|
||||
return LetzshopLookupResponse(
|
||||
found=True,
|
||||
vendor=LetzshopVendorInfo.from_cache(cache_entry, lang),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error looking up Letzshop vendor: {e}")
|
||||
return LetzshopLookupResponse(
|
||||
found=False,
|
||||
error="Failed to lookup vendor",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/letzshop-vendors/{slug}", response_model=LetzshopVendorInfo) # public
|
||||
async def get_letzshop_vendor(
|
||||
slug: str,
|
||||
lang: Annotated[str, Query(description="Language for descriptions")] = "en",
|
||||
db: Session = Depends(get_db),
|
||||
) -> LetzshopVendorInfo:
|
||||
"""
|
||||
Get a specific Letzshop vendor by slug.
|
||||
|
||||
Returns 404 if vendor not found in cache or on Letzshop.
|
||||
"""
|
||||
slug = slug.lower()
|
||||
|
||||
sync_service = LetzshopVendorSyncService(db)
|
||||
|
||||
# First try cache
|
||||
cache_entry = sync_service.get_cached_vendor(slug)
|
||||
|
||||
# If not in cache, try to fetch from Letzshop
|
||||
if not cache_entry:
|
||||
logger.info(f"Vendor {slug} not in cache, fetching from Letzshop...")
|
||||
cache_entry = sync_service.sync_single_vendor(slug)
|
||||
|
||||
if not cache_entry:
|
||||
raise ResourceNotFoundException("LetzshopVendor", slug)
|
||||
|
||||
return LetzshopVendorInfo.from_cache(cache_entry, lang)
|
||||
|
||||
|
||||
@router.get("/letzshop-vendors-stats") # public
|
||||
async def get_letzshop_vendor_stats(
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""
|
||||
Get statistics about the Letzshop vendor cache.
|
||||
|
||||
Returns total, active, claimed, and unclaimed vendor counts.
|
||||
"""
|
||||
sync_service = LetzshopVendorSyncService(db)
|
||||
return sync_service.get_sync_stats()
|
||||
@@ -1,247 +0,0 @@
|
||||
# app/api/v1/platform/pricing.py
|
||||
"""
|
||||
Public pricing API endpoints.
|
||||
|
||||
Provides subscription tier and add-on product information
|
||||
for the marketing homepage and signup flow.
|
||||
|
||||
All endpoints are public (no authentication required).
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.exceptions import ResourceNotFoundException
|
||||
from app.services.platform_pricing_service import platform_pricing_service
|
||||
from app.modules.billing.models import TierCode
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Response Schemas
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TierFeature(BaseModel):
|
||||
"""Feature included in a tier."""
|
||||
|
||||
code: str
|
||||
name: str
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class TierResponse(BaseModel):
|
||||
"""Subscription tier details for public display."""
|
||||
|
||||
code: str
|
||||
name: str
|
||||
description: str | None
|
||||
price_monthly: float # Price in euros
|
||||
price_annual: float | None # Price in euros (null for enterprise)
|
||||
price_monthly_cents: int
|
||||
price_annual_cents: int | None
|
||||
orders_per_month: int | None # None = unlimited
|
||||
products_limit: int | None # None = unlimited
|
||||
team_members: int | None # None = unlimited
|
||||
order_history_months: int | None # None = unlimited
|
||||
features: list[str]
|
||||
is_popular: bool = False # Highlight as recommended
|
||||
is_enterprise: bool = False # Contact sales
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class AddOnResponse(BaseModel):
|
||||
"""Add-on product details for public display."""
|
||||
|
||||
code: str
|
||||
name: str
|
||||
description: str | None
|
||||
category: str
|
||||
price: float # Price in euros
|
||||
price_cents: int
|
||||
billing_period: str
|
||||
quantity_unit: str | None
|
||||
quantity_value: int | None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class PricingResponse(BaseModel):
|
||||
"""Complete pricing information."""
|
||||
|
||||
tiers: list[TierResponse]
|
||||
addons: list[AddOnResponse]
|
||||
trial_days: int
|
||||
annual_discount_months: int # e.g., 2 = "2 months free"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Feature Descriptions
|
||||
# =============================================================================
|
||||
|
||||
FEATURE_DESCRIPTIONS = {
|
||||
"letzshop_sync": "Letzshop Order Sync",
|
||||
"inventory_basic": "Basic Inventory Management",
|
||||
"inventory_locations": "Warehouse Locations",
|
||||
"inventory_purchase_orders": "Purchase Orders",
|
||||
"invoice_lu": "Luxembourg VAT Invoicing",
|
||||
"invoice_eu_vat": "EU VAT Invoicing",
|
||||
"invoice_bulk": "Bulk Invoicing",
|
||||
"customer_view": "Customer List",
|
||||
"customer_export": "Customer Export",
|
||||
"analytics_dashboard": "Analytics Dashboard",
|
||||
"accounting_export": "Accounting Export",
|
||||
"api_access": "API Access",
|
||||
"automation_rules": "Automation Rules",
|
||||
"team_roles": "Team Roles & Permissions",
|
||||
"white_label": "White-Label Option",
|
||||
"multi_vendor": "Multi-Vendor Support",
|
||||
"custom_integrations": "Custom Integrations",
|
||||
"sla_guarantee": "SLA Guarantee",
|
||||
"dedicated_support": "Dedicated Account Manager",
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Helper Functions
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _tier_to_response(tier, is_from_db: bool = True) -> TierResponse:
|
||||
"""Convert a tier (from DB or hardcoded) to TierResponse."""
|
||||
if is_from_db:
|
||||
return TierResponse(
|
||||
code=tier.code,
|
||||
name=tier.name,
|
||||
description=tier.description,
|
||||
price_monthly=tier.price_monthly_cents / 100,
|
||||
price_annual=(tier.price_annual_cents / 100) if tier.price_annual_cents else None,
|
||||
price_monthly_cents=tier.price_monthly_cents,
|
||||
price_annual_cents=tier.price_annual_cents,
|
||||
orders_per_month=tier.orders_per_month,
|
||||
products_limit=tier.products_limit,
|
||||
team_members=tier.team_members,
|
||||
order_history_months=tier.order_history_months,
|
||||
features=tier.features or [],
|
||||
is_popular=tier.code == TierCode.PROFESSIONAL.value,
|
||||
is_enterprise=tier.code == TierCode.ENTERPRISE.value,
|
||||
)
|
||||
else:
|
||||
# Hardcoded tier format
|
||||
tier_enum = tier["tier_enum"]
|
||||
limits = tier["limits"]
|
||||
return TierResponse(
|
||||
code=tier_enum.value,
|
||||
name=limits["name"],
|
||||
description=None,
|
||||
price_monthly=limits["price_monthly_cents"] / 100,
|
||||
price_annual=(limits["price_annual_cents"] / 100) if limits.get("price_annual_cents") else None,
|
||||
price_monthly_cents=limits["price_monthly_cents"],
|
||||
price_annual_cents=limits.get("price_annual_cents"),
|
||||
orders_per_month=limits.get("orders_per_month"),
|
||||
products_limit=limits.get("products_limit"),
|
||||
team_members=limits.get("team_members"),
|
||||
order_history_months=limits.get("order_history_months"),
|
||||
features=limits.get("features", []),
|
||||
is_popular=tier_enum == TierCode.PROFESSIONAL,
|
||||
is_enterprise=tier_enum == TierCode.ENTERPRISE,
|
||||
)
|
||||
|
||||
|
||||
def _addon_to_response(addon) -> AddOnResponse:
|
||||
"""Convert an AddOnProduct to AddOnResponse."""
|
||||
return AddOnResponse(
|
||||
code=addon.code,
|
||||
name=addon.name,
|
||||
description=addon.description,
|
||||
category=addon.category,
|
||||
price=addon.price_cents / 100,
|
||||
price_cents=addon.price_cents,
|
||||
billing_period=addon.billing_period,
|
||||
quantity_unit=addon.quantity_unit,
|
||||
quantity_value=addon.quantity_value,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Endpoints
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@router.get("/tiers", response_model=list[TierResponse]) # public
|
||||
def get_tiers(db: Session = Depends(get_db)) -> list[TierResponse]:
|
||||
"""
|
||||
Get all public subscription tiers.
|
||||
|
||||
Returns tiers from database if available, falls back to hardcoded TIER_LIMITS.
|
||||
"""
|
||||
# Try to get from database first
|
||||
db_tiers = platform_pricing_service.get_public_tiers(db)
|
||||
|
||||
if db_tiers:
|
||||
return [_tier_to_response(tier, is_from_db=True) for tier in db_tiers]
|
||||
|
||||
# Fallback to hardcoded tiers
|
||||
from app.modules.billing.models import TIER_LIMITS
|
||||
|
||||
tiers = []
|
||||
for tier_code in TIER_LIMITS:
|
||||
tier_data = platform_pricing_service.get_tier_from_hardcoded(tier_code.value)
|
||||
if tier_data:
|
||||
tiers.append(_tier_to_response(tier_data, is_from_db=False))
|
||||
|
||||
return tiers
|
||||
|
||||
|
||||
@router.get("/tiers/{tier_code}", response_model=TierResponse) # public
|
||||
def get_tier(tier_code: str, db: Session = Depends(get_db)) -> TierResponse:
|
||||
"""Get a specific tier by code."""
|
||||
# Try database first
|
||||
tier = platform_pricing_service.get_tier_by_code(db, tier_code)
|
||||
|
||||
if tier:
|
||||
return _tier_to_response(tier, is_from_db=True)
|
||||
|
||||
# Fallback to hardcoded
|
||||
tier_data = platform_pricing_service.get_tier_from_hardcoded(tier_code)
|
||||
if tier_data:
|
||||
return _tier_to_response(tier_data, is_from_db=False)
|
||||
|
||||
raise ResourceNotFoundException(
|
||||
resource_type="SubscriptionTier",
|
||||
identifier=tier_code,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/addons", response_model=list[AddOnResponse]) # public
|
||||
def get_addons(db: Session = Depends(get_db)) -> list[AddOnResponse]:
|
||||
"""
|
||||
Get all available add-on products.
|
||||
|
||||
Returns add-ons from database, or empty list if none configured.
|
||||
"""
|
||||
addons = platform_pricing_service.get_active_addons(db)
|
||||
return [_addon_to_response(addon) for addon in addons]
|
||||
|
||||
|
||||
@router.get("/pricing", response_model=PricingResponse) # public
|
||||
def get_pricing(db: Session = Depends(get_db)) -> PricingResponse:
|
||||
"""
|
||||
Get complete pricing information (tiers + add-ons).
|
||||
|
||||
This is the main endpoint for the pricing page.
|
||||
"""
|
||||
from app.core.config import settings
|
||||
|
||||
return PricingResponse(
|
||||
tiers=get_tiers(db),
|
||||
addons=get_addons(db),
|
||||
trial_days=settings.stripe_trial_days,
|
||||
annual_discount_months=2, # "2 months free" with annual billing
|
||||
)
|
||||
38
app/api/v1/public/__init__.py
Normal file
38
app/api/v1/public/__init__.py
Normal file
@@ -0,0 +1,38 @@
|
||||
# app/api/v1/public/__init__.py
|
||||
"""
|
||||
Public API endpoints (no authentication required).
|
||||
|
||||
Includes:
|
||||
- signup: /signup/* (multi-step signup flow - cross-cutting)
|
||||
|
||||
Auto-discovers and aggregates public routes from self-contained modules:
|
||||
- billing: /pricing/* (subscription tiers and add-ons)
|
||||
- marketplace: /letzshop-vendors/* (vendor lookup for signup)
|
||||
- core: /language/* (language preferences)
|
||||
|
||||
These endpoints serve the marketing homepage, pricing pages, and signup flows.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v1.public import signup
|
||||
from app.modules.routes import get_public_api_routes
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Cross-cutting signup flow (spans auth, vendors, billing, payments)
|
||||
router.include_router(signup.router, tags=["public-signup"])
|
||||
|
||||
# Auto-discover public routes from modules
|
||||
for route_info in get_public_api_routes():
|
||||
if route_info.custom_prefix:
|
||||
router.include_router(
|
||||
route_info.router,
|
||||
prefix=route_info.custom_prefix,
|
||||
tags=route_info.tags,
|
||||
)
|
||||
else:
|
||||
router.include_router(
|
||||
route_info.router,
|
||||
tags=route_info.tags,
|
||||
)
|
||||
@@ -1,4 +1,4 @@
|
||||
# app/api/v1/platform/signup.py
|
||||
# app/api/v1/public/signup.py
|
||||
"""
|
||||
Platform signup API endpoints.
|
||||
|
||||
@@ -20,7 +20,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.environment import should_use_secure_cookies
|
||||
from app.services.platform_signup_service import platform_signup_service
|
||||
from app.modules.marketplace.services.platform_signup_service import platform_signup_service
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -1 +0,0 @@
|
||||
# Health checks
|
||||
@@ -1,180 +0,0 @@
|
||||
# app/api/v1/shared/language.py
|
||||
"""
|
||||
Language API endpoints for setting user/customer language preferences.
|
||||
|
||||
These endpoints handle:
|
||||
- Setting language preference via cookie
|
||||
- Getting current language info
|
||||
- Listing available languages
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Request, Response
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.utils.i18n import (
|
||||
DEFAULT_LANGUAGE,
|
||||
LANGUAGE_FLAGS,
|
||||
LANGUAGE_NAMES,
|
||||
LANGUAGE_NAMES_EN,
|
||||
SUPPORTED_LANGUAGES,
|
||||
get_language_info,
|
||||
)
|
||||
from middleware.language import LANGUAGE_COOKIE_NAME, set_language_cookie
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/language", tags=["language"])
|
||||
|
||||
|
||||
class SetLanguageRequest(BaseModel):
|
||||
"""Request body for setting language preference."""
|
||||
|
||||
language: str = Field(
|
||||
...,
|
||||
description="Language code (en, fr, de, lb)",
|
||||
min_length=2,
|
||||
max_length=5,
|
||||
)
|
||||
|
||||
|
||||
class SetLanguageResponse(BaseModel):
|
||||
"""Response after setting language preference."""
|
||||
|
||||
success: bool
|
||||
language: str
|
||||
message: str
|
||||
|
||||
|
||||
class LanguageInfo(BaseModel):
|
||||
"""Information about a single language."""
|
||||
|
||||
code: str
|
||||
name: str
|
||||
name_en: str
|
||||
flag: str
|
||||
|
||||
|
||||
class LanguageListResponse(BaseModel):
|
||||
"""Response listing all available languages."""
|
||||
|
||||
languages: list[LanguageInfo]
|
||||
current: str
|
||||
default: str
|
||||
|
||||
|
||||
class CurrentLanguageResponse(BaseModel):
|
||||
"""Response with current language information."""
|
||||
|
||||
code: str
|
||||
name: str
|
||||
name_en: str
|
||||
flag: str
|
||||
source: str # Where the language was determined from (cookie, browser, default)
|
||||
|
||||
|
||||
# public - Language preference can be set without authentication
|
||||
@router.post("/set", response_model=SetLanguageResponse)
|
||||
async def set_language(
|
||||
request: Request,
|
||||
response: Response,
|
||||
body: SetLanguageRequest,
|
||||
) -> SetLanguageResponse:
|
||||
"""
|
||||
Set the user's language preference.
|
||||
|
||||
This sets a cookie that will be used for subsequent requests.
|
||||
The page should be reloaded after calling this endpoint.
|
||||
"""
|
||||
language = body.language.lower()
|
||||
|
||||
if language not in SUPPORTED_LANGUAGES:
|
||||
return SetLanguageResponse(
|
||||
success=False,
|
||||
language=language,
|
||||
message=f"Unsupported language: {language}. Supported: {', '.join(SUPPORTED_LANGUAGES)}",
|
||||
)
|
||||
|
||||
# Set language cookie
|
||||
set_language_cookie(response, language)
|
||||
|
||||
logger.info(f"Language preference set to: {language}")
|
||||
|
||||
return SetLanguageResponse(
|
||||
success=True,
|
||||
language=language,
|
||||
message=f"Language set to {LANGUAGE_NAMES.get(language, language)}",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/current", response_model=CurrentLanguageResponse)
|
||||
async def get_current_language(request: Request) -> CurrentLanguageResponse:
|
||||
"""
|
||||
Get the current language for this request.
|
||||
|
||||
Returns information about the detected language and where it came from.
|
||||
"""
|
||||
# Get language from request state (set by middleware)
|
||||
language = getattr(request.state, "language", DEFAULT_LANGUAGE)
|
||||
language_info = getattr(request.state, "language_info", {})
|
||||
|
||||
# Determine source
|
||||
source = "default"
|
||||
if language_info.get("cookie"):
|
||||
source = "cookie"
|
||||
elif language_info.get("browser"):
|
||||
source = "browser"
|
||||
|
||||
info = get_language_info(language)
|
||||
|
||||
return CurrentLanguageResponse(
|
||||
code=info["code"],
|
||||
name=info["name"],
|
||||
name_en=info["name_en"],
|
||||
flag=info["flag"],
|
||||
source=source,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/list", response_model=LanguageListResponse)
|
||||
async def list_languages(request: Request) -> LanguageListResponse:
|
||||
"""
|
||||
List all available languages.
|
||||
|
||||
Returns all supported languages with their display names.
|
||||
"""
|
||||
current = getattr(request.state, "language", DEFAULT_LANGUAGE)
|
||||
|
||||
languages = [
|
||||
LanguageInfo(
|
||||
code=code,
|
||||
name=LANGUAGE_NAMES.get(code, code),
|
||||
name_en=LANGUAGE_NAMES_EN.get(code, code),
|
||||
flag=LANGUAGE_FLAGS.get(code, ""),
|
||||
)
|
||||
for code in SUPPORTED_LANGUAGES
|
||||
]
|
||||
|
||||
return LanguageListResponse(
|
||||
languages=languages,
|
||||
current=current,
|
||||
default=DEFAULT_LANGUAGE,
|
||||
)
|
||||
|
||||
|
||||
# public - Language preference clearing doesn't require authentication
|
||||
@router.delete("/clear")
|
||||
async def clear_language(response: Response) -> SetLanguageResponse:
|
||||
"""
|
||||
Clear the language preference cookie.
|
||||
|
||||
After clearing, the language will be determined from browser settings or defaults.
|
||||
"""
|
||||
response.delete_cookie(key=LANGUAGE_COOKIE_NAME)
|
||||
|
||||
return SetLanguageResponse(
|
||||
success=True,
|
||||
language=DEFAULT_LANGUAGE,
|
||||
message="Language preference cleared. Using browser/default language.",
|
||||
)
|
||||
@@ -1 +0,0 @@
|
||||
# File upload handling
|
||||
@@ -1,63 +0,0 @@
|
||||
# app/api/v1/shared/webhooks.py
|
||||
"""
|
||||
External webhook endpoints.
|
||||
|
||||
Handles webhooks from:
|
||||
- Stripe (payments and subscriptions)
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Header, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.exceptions import InvalidWebhookSignatureException, WebhookMissingSignatureException
|
||||
from app.services.stripe_service import stripe_service
|
||||
from app.handlers.stripe_webhook import stripe_webhook_handler
|
||||
|
||||
router = APIRouter(prefix="/webhooks")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@router.post("/stripe") # public - Stripe webhooks use signature verification
|
||||
async def stripe_webhook(
|
||||
request: Request,
|
||||
stripe_signature: str = Header(None, alias="Stripe-Signature"),
|
||||
):
|
||||
"""
|
||||
Handle Stripe webhook events.
|
||||
|
||||
Stripe sends events for:
|
||||
- Subscription lifecycle (created, updated, deleted)
|
||||
- Invoice and payment events
|
||||
- Checkout session completion
|
||||
|
||||
The endpoint verifies the webhook signature and processes events idempotently.
|
||||
"""
|
||||
if not stripe_signature:
|
||||
logger.warning("Stripe webhook received without signature")
|
||||
raise WebhookMissingSignatureException()
|
||||
|
||||
# Get raw body for signature verification
|
||||
payload = await request.body()
|
||||
|
||||
try:
|
||||
# Verify and construct event
|
||||
event = stripe_service.construct_event(payload, stripe_signature)
|
||||
except ValueError as e:
|
||||
logger.warning(f"Invalid Stripe webhook: {e}")
|
||||
raise InvalidWebhookSignatureException(str(e))
|
||||
|
||||
# Process the event
|
||||
db = next(get_db())
|
||||
try:
|
||||
result = stripe_webhook_handler.handle_event(db, event)
|
||||
return {"received": True, **result}
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing Stripe webhook: {e}")
|
||||
# Return 200 to prevent Stripe retries for processing errors
|
||||
# The event is marked as failed and can be retried manually
|
||||
return {"received": True, "error": str(e)}
|
||||
finally:
|
||||
db.close()
|
||||
@@ -5,63 +5,48 @@ Storefront API router aggregation.
|
||||
This module aggregates all storefront-related JSON API endpoints (public facing).
|
||||
Uses vendor context from middleware - no vendor_id in URLs.
|
||||
|
||||
Endpoints:
|
||||
- Products: Browse catalog, search products (catalog module)
|
||||
- Cart: Shopping cart operations (cart module)
|
||||
- Orders: Order history viewing (orders module)
|
||||
- Checkout: Order placement (checkout module)
|
||||
- Auth: Customer login, registration, password reset (customers module)
|
||||
- Profile/Addresses: Customer profile management (customers module)
|
||||
- Messages: Customer messaging (messaging module)
|
||||
- Content Pages: CMS pages (cms module)
|
||||
AUTO-DISCOVERED MODULE ROUTES:
|
||||
- cart: Shopping cart operations
|
||||
- catalog: Browse catalog, search products
|
||||
- checkout: Order placement
|
||||
- cms: Content pages
|
||||
- customers: Auth, profile, addresses
|
||||
- messaging: Customer messaging
|
||||
- orders: Order history viewing
|
||||
|
||||
Authentication:
|
||||
- Products, Cart, Content Pages: No auth required
|
||||
- Orders, Profile, Messages: Requires customer authentication
|
||||
- Auth: Public (login, register)
|
||||
|
||||
Note: Routes are now served from their respective modules.
|
||||
Note: Routes are auto-discovered from app/modules/{module}/routes/api/storefront.py
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
# Import module routers
|
||||
from app.modules.cart.routes.api import storefront_router as cart_router
|
||||
from app.modules.catalog.routes.api import storefront_router as catalog_router
|
||||
from app.modules.checkout.routes.api import storefront_router as checkout_router
|
||||
from app.modules.cms.routes.api.storefront import router as cms_storefront_router
|
||||
from app.modules.customers.routes.api import storefront_router as customers_router
|
||||
from app.modules.messaging.routes.api import storefront_router as messaging_router
|
||||
from app.modules.orders.routes.api import storefront_router as orders_router
|
||||
|
||||
# Create storefront router
|
||||
router = APIRouter()
|
||||
|
||||
# ============================================================================
|
||||
# STOREFRONT API ROUTES (All vendor-context aware via middleware)
|
||||
# Auto-discovered Module Routes
|
||||
# ============================================================================
|
||||
# Routes from self-contained modules are auto-discovered and registered.
|
||||
# Modules include: cart, catalog, checkout, cms, customers, messaging, orders
|
||||
|
||||
# Customer authentication and account management (customers module)
|
||||
router.include_router(customers_router, tags=["storefront-auth", "storefront-profile", "storefront-addresses"])
|
||||
from app.modules.routes import get_storefront_api_routes
|
||||
|
||||
# Product catalog browsing (catalog module)
|
||||
router.include_router(catalog_router, tags=["storefront-products"])
|
||||
|
||||
# Shopping cart (cart module)
|
||||
router.include_router(cart_router, tags=["storefront-cart"])
|
||||
|
||||
# Order placement (checkout module)
|
||||
router.include_router(checkout_router, tags=["storefront-checkout"])
|
||||
|
||||
# Order history viewing (orders module)
|
||||
router.include_router(orders_router, tags=["storefront-orders"])
|
||||
|
||||
# Customer messaging (messaging module)
|
||||
router.include_router(messaging_router, tags=["storefront-messages"])
|
||||
|
||||
# CMS content pages (cms module)
|
||||
router.include_router(
|
||||
cms_storefront_router, prefix="/content-pages", tags=["storefront-content-pages"]
|
||||
)
|
||||
for route_info in get_storefront_api_routes():
|
||||
# Only pass prefix if custom_prefix is set (router already has internal prefix)
|
||||
if route_info.custom_prefix:
|
||||
router.include_router(
|
||||
route_info.router,
|
||||
prefix=route_info.custom_prefix,
|
||||
tags=route_info.tags,
|
||||
)
|
||||
else:
|
||||
router.include_router(
|
||||
route_info.router,
|
||||
tags=route_info.tags,
|
||||
)
|
||||
|
||||
__all__ = ["router"]
|
||||
|
||||
29
app/api/v1/webhooks/__init__.py
Normal file
29
app/api/v1/webhooks/__init__.py
Normal file
@@ -0,0 +1,29 @@
|
||||
# app/api/v1/webhooks/__init__.py
|
||||
"""
|
||||
Webhook API endpoints for external service callbacks.
|
||||
|
||||
Auto-discovers and aggregates webhook routes from self-contained modules:
|
||||
- payments: /stripe (Stripe payment webhooks)
|
||||
|
||||
Webhooks use signature verification for security, not user authentication.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.modules.routes import get_webhooks_api_routes
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Auto-discover webhook routes from modules
|
||||
for route_info in get_webhooks_api_routes():
|
||||
if route_info.custom_prefix:
|
||||
router.include_router(
|
||||
route_info.router,
|
||||
prefix=route_info.custom_prefix,
|
||||
tags=route_info.tags,
|
||||
)
|
||||
else:
|
||||
router.include_router(
|
||||
route_info.router,
|
||||
tags=route_info.tags,
|
||||
)
|
||||
Reference in New Issue
Block a user