feat: add logging, marketplace, and admin enhancements

Database & Migrations:
- Add application_logs table migration for hybrid cloud logging
- Add companies table migration and restructure vendor relationships

Logging System:
- Implement hybrid logging system (database + file)
- Add log_service for centralized log management
- Create admin logs page with filtering and viewing capabilities
- Add init_log_settings.py script for log configuration
- Enhance core logging with database integration

Marketplace Integration:
- Add marketplace admin page with product management
- Create marketplace vendor page with product listings
- Implement marketplace.js for both admin and vendor interfaces
- Add marketplace integration documentation

Admin Enhancements:
- Add imports management page and functionality
- Create settings page for admin configuration
- Add vendor themes management page
- Enhance vendor detail and edit pages
- Improve code quality dashboard and violation details
- Add logs viewing and management
- Update icons guide and shared icon system

Architecture & Documentation:
- Document frontend structure and component architecture
- Document models structure and relationships
- Add vendor-in-token architecture documentation
- Add vendor RBAC (role-based access control) documentation
- Document marketplace integration patterns
- Update architecture patterns documentation

Infrastructure:
- Add platform static files structure (css, img, js)
- Move architecture_scan.py to proper models location
- Update model imports and registrations
- Enhance exception handling
- Update dependency injection patterns

UI/UX:
- Improve vendor edit interface
- Update admin user interface
- Enhance page templates documentation
- Add vendor marketplace interface
This commit is contained in:
2025-12-01 21:51:07 +01:00
parent 915734e9b4
commit cc74970223
56 changed files with 8440 additions and 202 deletions

342
app/api/v1/admin/logs.py Normal file
View File

@@ -0,0 +1,342 @@
# 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, Response
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.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.database.user import User
from models.schema.admin import (
ApplicationLogFilters,
ApplicationLogListResponse,
FileLogResponse,
LogSettingsResponse,
LogSettingsUpdate,
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: User = 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: User = 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")
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: User = Depends(get_current_admin_api),
):
"""
Delete logs older than retention period.
Requires confirmation parameter.
"""
from fastapi import HTTPException
if not confirm:
raise HTTPException(
status_code=400,
detail="Cleanup requires confirmation parameter: confirm=true",
)
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 {
"message": f"Deleted {deleted_count} log entries older than {retention_days} days",
"deleted_count": deleted_count,
}
@router.delete("/database/{log_id}")
def delete_log(
log_id: int,
db: Session = Depends(get_db),
current_admin: User = 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 {"message": message}
# ============================================================================
# FILE LOGS ENDPOINTS
# ============================================================================
@router.get("/files")
def list_log_files(
current_admin: User = Depends(get_current_admin_api),
):
"""
List all available log files.
Returns list of log files with size and modification date.
"""
return {"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: User = 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: User = Depends(get_current_admin_api),
):
"""
Download log file.
Returns the entire log file for download.
"""
from pathlib import Path
from app.core.config import settings
from fastapi import HTTPException
from fastapi.responses import FileResponse
# 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 HTTPException(status_code=404, detail=f"Log file '{filename}' not found")
# 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: User = 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")
def update_log_settings(
settings_update: LogSettingsUpdate,
db: Session = Depends(get_db),
current_admin: User = 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 {
"message": "Log settings updated successfully",
"updated_fields": updated,
"note": "Log level changes are applied immediately. File rotation settings require restart.",
}

View File

@@ -142,28 +142,36 @@ def vendor_login(
f"for vendor {vendor.vendor_code} as {vendor_role}"
)
# Create vendor-scoped access token with vendor information
token_data = auth_service.auth_manager.create_access_token(
user=user,
vendor_id=vendor.id,
vendor_code=vendor.vendor_code,
vendor_role=vendor_role,
)
# Set HTTP-only cookie for browser navigation
# CRITICAL: path=/vendor restricts cookie to vendor routes only
response.set_cookie(
key="vendor_token",
value=login_result["token_data"]["access_token"],
value=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
max_age=token_data["expires_in"], # Match JWT expiry
path="/vendor", # RESTRICTED TO VENDOR ROUTES ONLY
)
logger.debug(
f"Set vendor_token cookie with {login_result['token_data']['expires_in']}s expiry "
f"Set vendor_token cookie with {token_data['expires_in']}s expiry "
f"(path=/vendor, httponly=True, secure={should_use_secure_cookies()})"
)
# Return full login response
# Return full login response with vendor-scoped token
return VendorLoginResponse(
access_token=login_result["token_data"]["access_token"],
token_type=login_result["token_data"]["token_type"],
expires_in=login_result["token_data"]["expires_in"],
access_token=token_data["access_token"],
token_type=token_data["token_type"],
expires_in=token_data["expires_in"],
user={
"id": user.id,
"username": user.username,

View File

@@ -32,31 +32,29 @@ def get_vendor_dashboard_stats(
- Total customers
- Revenue metrics
Vendor is determined from the authenticated user's vendor_user association.
Vendor is determined from the JWT token (vendor_id claim).
Requires Authorization header (API endpoint).
"""
# Get vendor from authenticated user's vendor_user record
from models.database.vendor import VendorUser
vendor_user = (
db.query(VendorUser).filter(VendorUser.user_id == current_user.id).first()
)
if not vendor_user:
from fastapi import HTTPException
from fastapi import HTTPException
# Get vendor ID from token (set by get_current_vendor_api)
if not hasattr(current_user, "token_vendor_id"):
raise HTTPException(
status_code=403, detail="User is not associated with any vendor"
status_code=400,
detail="Token missing vendor information. Please login again.",
)
vendor = vendor_user.vendor
if not vendor or not vendor.is_active:
from fastapi import HTTPException
vendor_id = current_user.token_vendor_id
# Get vendor object to include in response
from models.database.vendor import Vendor
vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first()
if not vendor or not vendor.is_active:
raise HTTPException(status_code=404, detail="Vendor not found or inactive")
# Get vendor-scoped statistics
stats_data = stats_service.get_vendor_stats(db=db, vendor_id=vendor.id)
stats_data = stats_service.get_vendor_stats(db=db, vendor_id=vendor_id)
return {
"vendor": {

View File

@@ -11,9 +11,7 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api
from app.core.database import get_db
from app.services.order_service import order_service
from middleware.vendor_context import require_vendor_context
from models.database.user import User
from models.database.vendor import Vendor
from models.schema.order import (
OrderDetailResponse,
OrderListResponse,
@@ -31,7 +29,6 @@ def get_vendor_orders(
limit: int = Query(100, ge=1, le=1000),
status: str | None = Query(None, description="Filter by order status"),
customer_id: int | None = Query(None, description="Filter by customer"),
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
@@ -42,11 +39,23 @@ def get_vendor_orders(
- status: Order status (pending, processing, shipped, delivered, cancelled)
- customer_id: Filter orders from specific customer
Vendor is determined from JWT token (vendor_id claim).
Requires Authorization header (API endpoint).
"""
from fastapi import HTTPException
# Get vendor ID from token
if not hasattr(current_user, "token_vendor_id"):
raise HTTPException(
status_code=400,
detail="Token missing vendor information. Please login again.",
)
vendor_id = current_user.token_vendor_id
orders, total = order_service.get_vendor_orders(
db=db,
vendor_id=vendor.id,
vendor_id=vendor_id,
skip=skip,
limit=limit,
status=status,
@@ -64,7 +73,6 @@ def get_vendor_orders(
@router.get("/{order_id}", response_model=OrderDetailResponse)
def get_order_details(
order_id: int,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
@@ -73,7 +81,18 @@ def get_order_details(
Requires Authorization header (API endpoint).
"""
order = order_service.get_order(db=db, vendor_id=vendor.id, order_id=order_id)
from fastapi import HTTPException
# Get vendor ID from token
if not hasattr(current_user, "token_vendor_id"):
raise HTTPException(
status_code=400,
detail="Token missing vendor information. Please login again.",
)
vendor_id = current_user.token_vendor_id
order = order_service.get_order(db=db, vendor_id=vendor_id, order_id=order_id)
return OrderDetailResponse.model_validate(order)
@@ -82,7 +101,6 @@ def get_order_details(
def update_order_status(
order_id: int,
order_update: OrderUpdate,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
@@ -99,8 +117,19 @@ def update_order_status(
Requires Authorization header (API endpoint).
"""
from fastapi import HTTPException
# Get vendor ID from token
if not hasattr(current_user, "token_vendor_id"):
raise HTTPException(
status_code=400,
detail="Token missing vendor information. Please login again.",
)
vendor_id = current_user.token_vendor_id
order = order_service.update_order_status(
db=db, vendor_id=vendor.id, order_id=order_id, order_update=order_update
db=db, vendor_id=vendor_id, order_id=order_id, order_update=order_update
)
logger.info(

View File

@@ -11,9 +11,7 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api
from app.core.database import get_db
from app.services.product_service import product_service
from middleware.vendor_context import require_vendor_context
from models.database.user import User
from models.database.vendor import Vendor
from models.schema.product import (
ProductCreate,
ProductDetailResponse,
@@ -32,7 +30,6 @@ def get_vendor_products(
limit: int = Query(100, ge=1, le=1000),
is_active: bool | None = Query(None),
is_featured: bool | None = Query(None),
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
@@ -42,10 +39,23 @@ def get_vendor_products(
Supports filtering by:
- is_active: Filter active/inactive products
- is_featured: Filter featured products
Vendor is determined from JWT token (vendor_id claim).
"""
from fastapi import HTTPException
# Get vendor ID from token
if not hasattr(current_user, "token_vendor_id"):
raise HTTPException(
status_code=400,
detail="Token missing vendor information. Please login again.",
)
vendor_id = current_user.token_vendor_id
products, total = product_service.get_vendor_products(
db=db,
vendor_id=vendor.id,
vendor_id=vendor_id,
skip=skip,
limit=limit,
is_active=is_active,
@@ -63,13 +73,23 @@ def get_vendor_products(
@router.get("/{product_id}", response_model=ProductDetailResponse)
def get_product_details(
product_id: int,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Get detailed product information including inventory."""
from fastapi import HTTPException
# Get vendor ID from token
if not hasattr(current_user, "token_vendor_id"):
raise HTTPException(
status_code=400,
detail="Token missing vendor information. Please login again.",
)
vendor_id = current_user.token_vendor_id
product = product_service.get_product(
db=db, vendor_id=vendor.id, product_id=product_id
db=db, vendor_id=vendor_id, product_id=product_id
)
return ProductDetailResponse.model_validate(product)
@@ -78,7 +98,6 @@ def get_product_details(
@router.post("", response_model=ProductResponse)
def add_product_to_catalog(
product_data: ProductCreate,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
@@ -87,13 +106,24 @@ def add_product_to_catalog(
This publishes a MarketplaceProduct to the vendor's public catalog.
"""
from fastapi import HTTPException
# Get vendor ID from token
if not hasattr(current_user, "token_vendor_id"):
raise HTTPException(
status_code=400,
detail="Token missing vendor information. Please login again.",
)
vendor_id = current_user.token_vendor_id
product = product_service.create_product(
db=db, vendor_id=vendor.id, product_data=product_data
db=db, vendor_id=vendor_id, product_data=product_data
)
logger.info(
f"Product {product.id} added to catalog by user {current_user.username} "
f"for vendor {vendor.vendor_code}"
f"for vendor {current_user.token_vendor_code}"
)
return ProductResponse.model_validate(product)
@@ -103,18 +133,28 @@ def add_product_to_catalog(
def update_product(
product_id: int,
product_data: ProductUpdate,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Update product in vendor catalog."""
from fastapi import HTTPException
# Get vendor ID from token
if not hasattr(current_user, "token_vendor_id"):
raise HTTPException(
status_code=400,
detail="Token missing vendor information. Please login again.",
)
vendor_id = current_user.token_vendor_id
product = product_service.update_product(
db=db, vendor_id=vendor.id, product_id=product_id, product_update=product_data
db=db, vendor_id=vendor_id, product_id=product_id, product_update=product_data
)
logger.info(
f"Product {product_id} updated by user {current_user.username} "
f"for vendor {vendor.vendor_code}"
f"for vendor {current_user.token_vendor_code}"
)
return ProductResponse.model_validate(product)
@@ -123,16 +163,26 @@ def update_product(
@router.delete("/{product_id}")
def remove_product_from_catalog(
product_id: int,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Remove product from vendor catalog."""
product_service.delete_product(db=db, vendor_id=vendor.id, product_id=product_id)
from fastapi import HTTPException
# Get vendor ID from token
if not hasattr(current_user, "token_vendor_id"):
raise HTTPException(
status_code=400,
detail="Token missing vendor information. Please login again.",
)
vendor_id = current_user.token_vendor_id
product_service.delete_product(db=db, vendor_id=vendor_id, product_id=product_id)
logger.info(
f"Product {product_id} removed from catalog by user {current_user.username} "
f"for vendor {vendor.vendor_code}"
f"for vendor {current_user.token_vendor_code}"
)
return {"message": f"Product {product_id} removed from catalog"}
@@ -141,7 +191,6 @@ def remove_product_from_catalog(
@router.post("/from-import/{marketplace_product_id}", response_model=ProductResponse)
def publish_from_marketplace(
marketplace_product_id: int,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
@@ -150,17 +199,28 @@ def publish_from_marketplace(
Shortcut endpoint for publishing directly from marketplace import.
"""
from fastapi import HTTPException
# Get vendor ID from token
if not hasattr(current_user, "token_vendor_id"):
raise HTTPException(
status_code=400,
detail="Token missing vendor information. Please login again.",
)
vendor_id = current_user.token_vendor_id
product_data = ProductCreate(
marketplace_product_id=marketplace_product_id, is_active=True
)
product = product_service.create_product(
db=db, vendor_id=vendor.id, product_data=product_data
db=db, vendor_id=vendor_id, product_data=product_data
)
logger.info(
f"Marketplace product {marketplace_product_id} published to catalog "
f"by user {current_user.username} for vendor {vendor.vendor_code}"
f"by user {current_user.username} for vendor {current_user.token_vendor_code}"
)
return ProductResponse.model_validate(product)
@@ -169,19 +229,29 @@ def publish_from_marketplace(
@router.put("/{product_id}/toggle-active")
def toggle_product_active(
product_id: int,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Toggle product active status."""
product = product_service.get_product(db, vendor.id, product_id)
from fastapi import HTTPException
# Get vendor ID from token
if not hasattr(current_user, "token_vendor_id"):
raise HTTPException(
status_code=400,
detail="Token missing vendor information. Please login again.",
)
vendor_id = current_user.token_vendor_id
product = product_service.get_product(db, vendor_id, product_id)
product.is_active = not product.is_active
db.commit()
db.refresh(product)
status = "activated" if product.is_active else "deactivated"
logger.info(f"Product {product_id} {status} for vendor {vendor.vendor_code}")
logger.info(f"Product {product_id} {status} for vendor {current_user.token_vendor_code}")
return {"message": f"Product {status}", "is_active": product.is_active}
@@ -189,18 +259,28 @@ def toggle_product_active(
@router.put("/{product_id}/toggle-featured")
def toggle_product_featured(
product_id: int,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Toggle product featured status."""
product = product_service.get_product(db, vendor.id, product_id)
from fastapi import HTTPException
# Get vendor ID from token
if not hasattr(current_user, "token_vendor_id"):
raise HTTPException(
status_code=400,
detail="Token missing vendor information. Please login again.",
)
vendor_id = current_user.token_vendor_id
product = product_service.get_product(db, vendor_id, product_id)
product.is_featured = not product.is_featured
db.commit()
db.refresh(product)
status = "featured" if product.is_featured else "unfeatured"
logger.info(f"Product {product_id} {status} for vendor {vendor.vendor_code}")
logger.info(f"Product {product_id} {status} for vendor {current_user.token_vendor_code}")
return {"message": f"Product {status}", "is_featured": product.is_featured}