Admin features (audit, log, settings)
This commit is contained in:
116
app/api/v1/admin/audit.py
Normal file
116
app/api/v1/admin/audit.py
Normal file
@@ -0,0 +1,116 @@
|
||||
# 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 typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_admin_user
|
||||
from app.core.database import get_db
|
||||
from app.services.admin_audit_service import admin_audit_service
|
||||
from models.schema.admin import (
|
||||
AdminAuditLogResponse,
|
||||
AdminAuditLogFilters,
|
||||
AdminAuditLogListResponse
|
||||
)
|
||||
from models.database.user import User
|
||||
|
||||
router = APIRouter(prefix="/audit")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@router.get("/logs", response_model=AdminAuditLogListResponse)
|
||||
def get_audit_logs(
|
||||
admin_user_id: Optional[int] = Query(None, description="Filter by admin user"),
|
||||
action: Optional[str] = Query(None, description="Filter by action type"),
|
||||
target_type: Optional[str] = Query(None, description="Filter by target type"),
|
||||
date_from: Optional[datetime] = Query(None, description="Filter from date"),
|
||||
date_to: Optional[datetime] = 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: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""
|
||||
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: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""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: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""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: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""
|
||||
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
|
||||
)
|
||||
@@ -17,7 +17,7 @@ from app.services.auth_service import auth_service
|
||||
from app.exceptions import InvalidCredentialsException
|
||||
from models.schema.auth import LoginResponse, UserLogin
|
||||
|
||||
router = APIRouter()
|
||||
router = APIRouter(prefix="/auth")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
||||
151
app/api/v1/admin/notifications.py
Normal file
151
app/api/v1/admin/notifications.py
Normal file
@@ -0,0 +1,151 @@
|
||||
# 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 typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_admin_user
|
||||
from app.core.database import get_db
|
||||
from models.schema.admin import (
|
||||
AdminNotificationCreate,
|
||||
AdminNotificationResponse,
|
||||
AdminNotificationListResponse,
|
||||
PlatformAlertCreate,
|
||||
PlatformAlertResponse,
|
||||
PlatformAlertListResponse,
|
||||
PlatformAlertResolve
|
||||
)
|
||||
from models.database.user import User
|
||||
|
||||
router = APIRouter(prefix="/notifications")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# ADMIN NOTIFICATIONS
|
||||
# ============================================================================
|
||||
|
||||
@router.get("", response_model=AdminNotificationListResponse)
|
||||
def get_notifications(
|
||||
priority: Optional[str] = Query(None, description="Filter by priority"),
|
||||
is_read: Optional[bool] = 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: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Get admin notifications with filtering."""
|
||||
# TODO: Implement notification service
|
||||
return AdminNotificationListResponse(
|
||||
notifications=[],
|
||||
total=0,
|
||||
unread_count=0,
|
||||
skip=skip,
|
||||
limit=limit
|
||||
)
|
||||
|
||||
|
||||
@router.get("/unread-count")
|
||||
def get_unread_count(
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Get count of unread notifications."""
|
||||
# TODO: Implement
|
||||
return {"unread_count": 0}
|
||||
|
||||
|
||||
@router.put("/{notification_id}/read")
|
||||
def mark_as_read(
|
||||
notification_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Mark notification as read."""
|
||||
# TODO: Implement
|
||||
return {"message": "Notification marked as read"}
|
||||
|
||||
|
||||
@router.put("/mark-all-read")
|
||||
def mark_all_as_read(
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Mark all notifications as read."""
|
||||
# TODO: Implement
|
||||
return {"message": "All notifications marked as read"}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# PLATFORM ALERTS
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/alerts", response_model=PlatformAlertListResponse)
|
||||
def get_platform_alerts(
|
||||
severity: Optional[str] = Query(None, description="Filter by severity"),
|
||||
is_resolved: Optional[bool] = 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: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Get platform alerts with filtering."""
|
||||
# TODO: Implement alert service
|
||||
return PlatformAlertListResponse(
|
||||
alerts=[],
|
||||
total=0,
|
||||
active_count=0,
|
||||
critical_count=0,
|
||||
skip=skip,
|
||||
limit=limit
|
||||
)
|
||||
|
||||
|
||||
@router.post("/alerts", response_model=PlatformAlertResponse)
|
||||
def create_platform_alert(
|
||||
alert_data: PlatformAlertCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Create new platform alert (manual)."""
|
||||
# TODO: Implement
|
||||
logger.info(f"Admin {current_admin.username} created alert: {alert_data.title}")
|
||||
return {}
|
||||
|
||||
|
||||
@router.put("/alerts/{alert_id}/resolve")
|
||||
def resolve_platform_alert(
|
||||
alert_id: int,
|
||||
resolve_data: PlatformAlertResolve,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Resolve platform alert."""
|
||||
# TODO: Implement
|
||||
logger.info(f"Admin {current_admin.username} resolved alert {alert_id}")
|
||||
return {"message": "Alert resolved successfully"}
|
||||
|
||||
|
||||
@router.get("/alerts/stats")
|
||||
def get_alert_statistics(
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Get alert statistics for dashboard."""
|
||||
# TODO: Implement
|
||||
return {
|
||||
"total_alerts": 0,
|
||||
"active_alerts": 0,
|
||||
"critical_alerts": 0,
|
||||
"resolved_today": 0
|
||||
}
|
||||
217
app/api/v1/admin/settings.py
Normal file
217
app/api/v1/admin/settings.py
Normal file
@@ -0,0 +1,217 @@
|
||||
# app/api/v1/admin/settings.py
|
||||
"""
|
||||
Platform settings management endpoints.
|
||||
|
||||
Provides endpoints for:
|
||||
- Viewing all platform settings
|
||||
- Creating/updating settings
|
||||
- Managing configuration by category
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_admin_user
|
||||
from app.core.database import get_db
|
||||
from app.services.admin_settings_service import admin_settings_service
|
||||
from app.services.admin_audit_service import admin_audit_service
|
||||
from models.schema.admin import (
|
||||
AdminSettingCreate,
|
||||
AdminSettingResponse,
|
||||
AdminSettingUpdate,
|
||||
AdminSettingListResponse
|
||||
)
|
||||
from models.database.user import User
|
||||
|
||||
router = APIRouter(prefix="/settings")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@router.get("", response_model=AdminSettingListResponse)
|
||||
def get_all_settings(
|
||||
category: Optional[str] = Query(None, description="Filter by category"),
|
||||
is_public: Optional[bool] = Query(None, description="Filter by public flag"),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""
|
||||
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: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""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)
|
||||
def get_setting(
|
||||
key: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Get specific setting by key."""
|
||||
setting = admin_settings_service.get_setting_by_key(db, key)
|
||||
|
||||
if not setting:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=404, detail=f"Setting '{key}' not found")
|
||||
|
||||
return AdminSettingResponse.model_validate(setting)
|
||||
|
||||
|
||||
@router.post("", response_model=AdminSettingResponse)
|
||||
def create_setting(
|
||||
setting_data: AdminSettingCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""
|
||||
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}
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.put("/{key}", response_model=AdminSettingResponse)
|
||||
def update_setting(
|
||||
key: str,
|
||||
update_data: AdminSettingUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""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}
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/upsert", response_model=AdminSettingResponse)
|
||||
def upsert_setting(
|
||||
setting_data: AdminSettingCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""
|
||||
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}
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@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: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""
|
||||
Delete platform setting.
|
||||
|
||||
Requires confirmation parameter.
|
||||
WARNING: Deleting settings may affect platform functionality.
|
||||
"""
|
||||
from fastapi import HTTPException
|
||||
|
||||
if not confirm:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="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={}
|
||||
)
|
||||
|
||||
return {"message": message}
|
||||
250
app/services/admin_audit_service.py
Normal file
250
app/services/admin_audit_service.py
Normal file
@@ -0,0 +1,250 @@
|
||||
# app/services/admin_audit_service.py
|
||||
"""
|
||||
Admin audit service for tracking admin actions.
|
||||
|
||||
This module provides functions for:
|
||||
- Logging admin actions
|
||||
- Querying audit logs
|
||||
- Generating audit reports
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional, Dict, Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import and_, or_
|
||||
|
||||
from models.database.admin import AdminAuditLog
|
||||
from models.database.user import User
|
||||
from models.schema.admin import AdminAuditLogFilters, AdminAuditLogResponse
|
||||
from app.exceptions import AdminOperationException
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AdminAuditService:
|
||||
"""Service for admin audit logging."""
|
||||
|
||||
def log_action(
|
||||
self,
|
||||
db: Session,
|
||||
admin_user_id: int,
|
||||
action: str,
|
||||
target_type: str,
|
||||
target_id: str,
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
ip_address: Optional[str] = None,
|
||||
user_agent: Optional[str] = None,
|
||||
request_id: Optional[str] = None
|
||||
) -> AdminAuditLog:
|
||||
"""
|
||||
Log an admin action to the audit trail.
|
||||
|
||||
Args:
|
||||
admin_user_id: ID of the admin performing the action
|
||||
action: Action performed (e.g., 'create_vendor', 'delete_user')
|
||||
target_type: Type of target (e.g., 'vendor', 'user')
|
||||
target_id: ID of the target entity
|
||||
details: Additional context about the action
|
||||
ip_address: IP address of the admin
|
||||
user_agent: User agent string
|
||||
request_id: Request ID for correlation
|
||||
|
||||
Returns:
|
||||
Created AdminAuditLog instance
|
||||
"""
|
||||
try:
|
||||
audit_log = AdminAuditLog(
|
||||
admin_user_id=admin_user_id,
|
||||
action=action,
|
||||
target_type=target_type,
|
||||
target_id=str(target_id),
|
||||
details=details or {},
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
request_id=request_id
|
||||
)
|
||||
|
||||
db.add(audit_log)
|
||||
db.commit()
|
||||
db.refresh(audit_log)
|
||||
|
||||
logger.info(
|
||||
f"Admin action logged: {action} on {target_type}:{target_id} "
|
||||
f"by admin {admin_user_id}"
|
||||
)
|
||||
|
||||
return audit_log
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Failed to log admin action: {str(e)}")
|
||||
# Don't raise exception - audit logging should not break operations
|
||||
return None
|
||||
|
||||
def get_audit_logs(
|
||||
self,
|
||||
db: Session,
|
||||
filters: AdminAuditLogFilters
|
||||
) -> List[AdminAuditLogResponse]:
|
||||
"""
|
||||
Get filtered admin audit logs with pagination.
|
||||
|
||||
Args:
|
||||
filters: Filter criteria for audit logs
|
||||
|
||||
Returns:
|
||||
List of audit log responses
|
||||
"""
|
||||
try:
|
||||
query = db.query(AdminAuditLog).join(User, AdminAuditLog.admin_user_id == User.id)
|
||||
|
||||
# Apply filters
|
||||
conditions = []
|
||||
|
||||
if filters.admin_user_id:
|
||||
conditions.append(AdminAuditLog.admin_user_id == filters.admin_user_id)
|
||||
|
||||
if filters.action:
|
||||
conditions.append(AdminAuditLog.action.ilike(f"%{filters.action}%"))
|
||||
|
||||
if filters.target_type:
|
||||
conditions.append(AdminAuditLog.target_type == filters.target_type)
|
||||
|
||||
if filters.date_from:
|
||||
conditions.append(AdminAuditLog.created_at >= filters.date_from)
|
||||
|
||||
if filters.date_to:
|
||||
conditions.append(AdminAuditLog.created_at <= filters.date_to)
|
||||
|
||||
if conditions:
|
||||
query = query.filter(and_(*conditions))
|
||||
|
||||
# Execute query with pagination
|
||||
logs = (
|
||||
query
|
||||
.order_by(AdminAuditLog.created_at.desc())
|
||||
.offset(filters.skip)
|
||||
.limit(filters.limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
# Convert to response models
|
||||
return [
|
||||
AdminAuditLogResponse(
|
||||
id=log.id,
|
||||
admin_user_id=log.admin_user_id,
|
||||
admin_username=log.admin_user.username if log.admin_user else None,
|
||||
action=log.action,
|
||||
target_type=log.target_type,
|
||||
target_id=log.target_id,
|
||||
details=log.details,
|
||||
ip_address=log.ip_address,
|
||||
user_agent=log.user_agent,
|
||||
request_id=log.request_id,
|
||||
created_at=log.created_at
|
||||
)
|
||||
for log in logs
|
||||
]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to retrieve audit logs: {str(e)}")
|
||||
raise AdminOperationException(
|
||||
operation="get_audit_logs",
|
||||
reason="Database query failed"
|
||||
)
|
||||
|
||||
def get_audit_logs_count(
|
||||
self,
|
||||
db: Session,
|
||||
filters: AdminAuditLogFilters
|
||||
) -> int:
|
||||
"""Get total count of audit logs matching filters."""
|
||||
try:
|
||||
query = db.query(AdminAuditLog)
|
||||
|
||||
# Apply same filters as get_audit_logs
|
||||
conditions = []
|
||||
|
||||
if filters.admin_user_id:
|
||||
conditions.append(AdminAuditLog.admin_user_id == filters.admin_user_id)
|
||||
|
||||
if filters.action:
|
||||
conditions.append(AdminAuditLog.action.ilike(f"%{filters.action}%"))
|
||||
|
||||
if filters.target_type:
|
||||
conditions.append(AdminAuditLog.target_type == filters.target_type)
|
||||
|
||||
if filters.date_from:
|
||||
conditions.append(AdminAuditLog.created_at >= filters.date_from)
|
||||
|
||||
if filters.date_to:
|
||||
conditions.append(AdminAuditLog.created_at <= filters.date_to)
|
||||
|
||||
if conditions:
|
||||
query = query.filter(and_(*conditions))
|
||||
|
||||
return query.count()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to count audit logs: {str(e)}")
|
||||
return 0
|
||||
|
||||
def get_recent_actions_by_admin(
|
||||
self,
|
||||
db: Session,
|
||||
admin_user_id: int,
|
||||
limit: int = 10
|
||||
) -> List[AdminAuditLogResponse]:
|
||||
"""Get recent actions by a specific admin."""
|
||||
filters = AdminAuditLogFilters(
|
||||
admin_user_id=admin_user_id,
|
||||
limit=limit
|
||||
)
|
||||
return self.get_audit_logs(db, filters)
|
||||
|
||||
def get_actions_by_target(
|
||||
self,
|
||||
db: Session,
|
||||
target_type: str,
|
||||
target_id: str,
|
||||
limit: int = 50
|
||||
) -> List[AdminAuditLogResponse]:
|
||||
"""Get all actions performed on a specific target."""
|
||||
try:
|
||||
logs = (
|
||||
db.query(AdminAuditLog)
|
||||
.filter(
|
||||
and_(
|
||||
AdminAuditLog.target_type == target_type,
|
||||
AdminAuditLog.target_id == str(target_id)
|
||||
)
|
||||
)
|
||||
.order_by(AdminAuditLog.created_at.desc())
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
return [
|
||||
AdminAuditLogResponse(
|
||||
id=log.id,
|
||||
admin_user_id=log.admin_user_id,
|
||||
admin_username=log.admin_user.username if log.admin_user else None,
|
||||
action=log.action,
|
||||
target_type=log.target_type,
|
||||
target_id=log.target_id,
|
||||
details=log.details,
|
||||
ip_address=log.ip_address,
|
||||
created_at=log.created_at
|
||||
)
|
||||
for log in logs
|
||||
]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get actions by target: {str(e)}")
|
||||
return []
|
||||
|
||||
|
||||
# Create service instance
|
||||
admin_audit_service = AdminAuditService()
|
||||
@@ -32,7 +32,7 @@ from app.exceptions import (
|
||||
from models.schema.marketplace_import_job import MarketplaceImportJobResponse
|
||||
from models.schema.vendor import VendorCreate
|
||||
from models.database.marketplace_import_job import MarketplaceImportJob
|
||||
from models.database.vendor import Vendor, Role
|
||||
from models.database.vendor import Vendor, Role, VendorUser
|
||||
from models.database.user import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -108,6 +108,12 @@ class AdminService:
|
||||
"""
|
||||
Create vendor with owner user account.
|
||||
|
||||
Creates:
|
||||
1. User account with owner_email (for authentication)
|
||||
2. Vendor with contact_email (for business contact)
|
||||
|
||||
If contact_email not provided, defaults to owner_email.
|
||||
|
||||
Returns: (vendor, owner_user, temporary_password)
|
||||
"""
|
||||
try:
|
||||
@@ -132,13 +138,12 @@ class AdminService:
|
||||
# Generate temporary password for owner
|
||||
temp_password = self._generate_temp_password()
|
||||
|
||||
# Create owner user
|
||||
# Create owner user with owner_email
|
||||
from middleware.auth import AuthManager
|
||||
auth_manager = AuthManager()
|
||||
|
||||
owner_username = f"{vendor_data.vendor_code.lower()}_owner"
|
||||
owner_email = vendor_data.owner_email if hasattr(vendor_data,
|
||||
'owner_email') else f"{owner_username}@{vendor_data.subdomain}.com"
|
||||
owner_username = f"{vendor_data.subdomain}_owner"
|
||||
owner_email = vendor_data.owner_email # ✅ For User authentication
|
||||
|
||||
# Check if user with this email already exists
|
||||
existing_user = db.query(User).filter(
|
||||
@@ -151,31 +156,35 @@ class AdminService:
|
||||
else:
|
||||
# Create new owner user
|
||||
owner_user = User(
|
||||
email=owner_email,
|
||||
email=owner_email, # ✅ Authentication email
|
||||
username=owner_username,
|
||||
hashed_password=auth_manager.hash_password(temp_password),
|
||||
role="user", # Will be vendor owner through relationship
|
||||
role="user",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(owner_user)
|
||||
db.flush() # Get owner_user.id
|
||||
|
||||
# Determine contact_email
|
||||
# If provided, use it; otherwise default to owner_email
|
||||
contact_email = vendor_data.contact_email or owner_email
|
||||
|
||||
# Create vendor
|
||||
vendor = Vendor(
|
||||
vendor_code=vendor_data.vendor_code.upper(),
|
||||
subdomain=vendor_data.subdomain.lower(),
|
||||
name=vendor_data.name,
|
||||
description=getattr(vendor_data, 'description', None),
|
||||
description=vendor_data.description,
|
||||
owner_user_id=owner_user.id,
|
||||
contact_email=owner_email,
|
||||
contact_phone=getattr(vendor_data, 'contact_phone', None),
|
||||
website=getattr(vendor_data, 'website', None),
|
||||
business_address=getattr(vendor_data, 'business_address', None),
|
||||
tax_number=getattr(vendor_data, 'tax_number', None),
|
||||
letzshop_csv_url_fr=getattr(vendor_data, 'letzshop_csv_url_fr', None),
|
||||
letzshop_csv_url_en=getattr(vendor_data, 'letzshop_csv_url_en', None),
|
||||
letzshop_csv_url_de=getattr(vendor_data, 'letzshop_csv_url_de', None),
|
||||
theme_config=getattr(vendor_data, 'theme_config', {}),
|
||||
contact_email=contact_email, # ✅ Business contact email
|
||||
contact_phone=vendor_data.contact_phone,
|
||||
website=vendor_data.website,
|
||||
business_address=vendor_data.business_address,
|
||||
tax_number=vendor_data.tax_number,
|
||||
letzshop_csv_url_fr=vendor_data.letzshop_csv_url_fr,
|
||||
letzshop_csv_url_en=vendor_data.letzshop_csv_url_en,
|
||||
letzshop_csv_url_de=vendor_data.letzshop_csv_url_de,
|
||||
theme_config=vendor_data.theme_config or {},
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
@@ -190,7 +199,8 @@ class AdminService:
|
||||
db.refresh(owner_user)
|
||||
|
||||
logger.info(
|
||||
f"Vendor {vendor.vendor_code} created with owner {owner_user.username}"
|
||||
f"Vendor {vendor.vendor_code} created with owner {owner_user.username} "
|
||||
f"(owner_email: {owner_email}, contact_email: {contact_email})"
|
||||
)
|
||||
|
||||
# TODO: Send welcome email to owner with credentials
|
||||
@@ -340,6 +350,231 @@ class AdminService:
|
||||
reason="Database deletion failed"
|
||||
)
|
||||
|
||||
def update_vendor(
|
||||
self,
|
||||
db: Session,
|
||||
vendor_id: int,
|
||||
vendor_update # VendorUpdate schema
|
||||
) -> Vendor:
|
||||
"""
|
||||
Update vendor information (Admin only).
|
||||
|
||||
Can update:
|
||||
- Vendor details (name, description, subdomain)
|
||||
- Business contact info (contact_email, phone, etc.)
|
||||
- Status (is_active, is_verified)
|
||||
|
||||
Cannot update:
|
||||
- owner_email (use transfer_vendor_ownership instead)
|
||||
- vendor_code (immutable)
|
||||
- owner_user_id (use transfer_vendor_ownership instead)
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_id: ID of vendor to update
|
||||
vendor_update: VendorUpdate schema with updated data
|
||||
|
||||
Returns:
|
||||
Updated vendor object
|
||||
|
||||
Raises:
|
||||
VendorNotFoundException: If vendor not found
|
||||
ValidationException: If subdomain already taken
|
||||
"""
|
||||
vendor = self._get_vendor_by_id_or_raise(db, vendor_id)
|
||||
|
||||
try:
|
||||
# Get update data
|
||||
update_data = vendor_update.model_dump(exclude_unset=True)
|
||||
|
||||
# Check subdomain uniqueness if changing
|
||||
if 'subdomain' in update_data and update_data['subdomain'] != vendor.subdomain:
|
||||
existing = db.query(Vendor).filter(
|
||||
Vendor.subdomain == update_data['subdomain'],
|
||||
Vendor.id != vendor_id
|
||||
).first()
|
||||
if existing:
|
||||
raise ValidationException(
|
||||
f"Subdomain '{update_data['subdomain']}' is already taken"
|
||||
)
|
||||
|
||||
# Update vendor fields
|
||||
for field, value in update_data.items():
|
||||
setattr(vendor, field, value)
|
||||
|
||||
vendor.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
db.commit()
|
||||
db.refresh(vendor)
|
||||
|
||||
logger.info(
|
||||
f"Vendor {vendor_id} ({vendor.vendor_code}) updated by admin. "
|
||||
f"Fields updated: {', '.join(update_data.keys())}"
|
||||
)
|
||||
return vendor
|
||||
|
||||
except ValidationException:
|
||||
db.rollback()
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Failed to update vendor {vendor_id}: {str(e)}")
|
||||
raise AdminOperationException(
|
||||
operation="update_vendor",
|
||||
reason=f"Database update failed: {str(e)}"
|
||||
)
|
||||
|
||||
# Add this NEW method for transferring ownership:
|
||||
|
||||
def transfer_vendor_ownership(
|
||||
self,
|
||||
db: Session,
|
||||
vendor_id: int,
|
||||
transfer_data # VendorTransferOwnership schema
|
||||
) -> Tuple[Vendor, User, User]:
|
||||
"""
|
||||
Transfer vendor ownership to another user.
|
||||
|
||||
This method:
|
||||
1. Validates new owner exists and is active
|
||||
2. Removes old owner from "Owner" role (demotes to Manager)
|
||||
3. Assigns new owner to "Owner" role
|
||||
4. Updates vendor.owner_user_id
|
||||
5. Creates audit log entry
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_id: ID of vendor
|
||||
transfer_data: Transfer details (new owner ID, confirmation, reason)
|
||||
|
||||
Returns:
|
||||
Tuple of (vendor, old_owner, new_owner)
|
||||
|
||||
Raises:
|
||||
VendorNotFoundException: If vendor not found
|
||||
UserNotFoundException: If new owner user not found
|
||||
ValidationException: If confirmation not provided or user already owner
|
||||
"""
|
||||
|
||||
# Require confirmation
|
||||
if not transfer_data.confirm_transfer:
|
||||
raise ValidationException(
|
||||
"Ownership transfer requires confirmation (confirm_transfer=true)"
|
||||
)
|
||||
|
||||
# Get vendor
|
||||
vendor = self._get_vendor_by_id_or_raise(db, vendor_id)
|
||||
old_owner = vendor.owner
|
||||
|
||||
# Get new owner
|
||||
new_owner = db.query(User).filter(
|
||||
User.id == transfer_data.new_owner_user_id
|
||||
).first()
|
||||
|
||||
if not new_owner:
|
||||
raise UserNotFoundException(str(transfer_data.new_owner_user_id))
|
||||
|
||||
# Check if new owner is active
|
||||
if not new_owner.is_active:
|
||||
raise ValidationException(
|
||||
f"User {new_owner.username} (ID: {new_owner.id}) is not active"
|
||||
)
|
||||
|
||||
# Check if already owner
|
||||
if new_owner.id == old_owner.id:
|
||||
raise ValidationException(
|
||||
f"User {new_owner.username} is already the owner of this vendor"
|
||||
)
|
||||
|
||||
try:
|
||||
# Get Owner role for this vendor
|
||||
owner_role = db.query(Role).filter(
|
||||
Role.vendor_id == vendor_id,
|
||||
Role.name == "Owner"
|
||||
).first()
|
||||
|
||||
if not owner_role:
|
||||
raise ValidationException("Owner role not found for vendor")
|
||||
|
||||
# Get Manager role (to demote old owner)
|
||||
manager_role = db.query(Role).filter(
|
||||
Role.vendor_id == vendor_id,
|
||||
Role.name == "Manager"
|
||||
).first()
|
||||
|
||||
# Remove old owner from Owner role
|
||||
old_owner_link = db.query(VendorUser).filter(
|
||||
VendorUser.vendor_id == vendor_id,
|
||||
VendorUser.user_id == old_owner.id,
|
||||
VendorUser.role_id == owner_role.id
|
||||
).first()
|
||||
|
||||
if old_owner_link:
|
||||
if manager_role:
|
||||
# Demote to Manager role
|
||||
old_owner_link.role_id = manager_role.id
|
||||
logger.info(
|
||||
f"Old owner {old_owner.username} demoted to Manager role "
|
||||
f"for vendor {vendor.vendor_code}"
|
||||
)
|
||||
else:
|
||||
# No Manager role, just remove Owner link
|
||||
db.delete(old_owner_link)
|
||||
logger.warning(
|
||||
f"Old owner {old_owner.username} removed from vendor {vendor.vendor_code} "
|
||||
f"(no Manager role available)"
|
||||
)
|
||||
|
||||
# Check if new owner already has a vendor_user link
|
||||
new_owner_link = db.query(VendorUser).filter(
|
||||
VendorUser.vendor_id == vendor_id,
|
||||
VendorUser.user_id == new_owner.id
|
||||
).first()
|
||||
|
||||
if new_owner_link:
|
||||
# Update existing link to Owner role
|
||||
new_owner_link.role_id = owner_role.id
|
||||
new_owner_link.is_active = True
|
||||
else:
|
||||
# Create new Owner link
|
||||
new_owner_link = VendorUser(
|
||||
vendor_id=vendor_id,
|
||||
user_id=new_owner.id,
|
||||
role_id=owner_role.id,
|
||||
is_active=True
|
||||
)
|
||||
db.add(new_owner_link)
|
||||
|
||||
# Update vendor owner_user_id
|
||||
vendor.owner_user_id = new_owner.id
|
||||
vendor.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
db.commit()
|
||||
db.refresh(vendor)
|
||||
|
||||
logger.warning(
|
||||
f"OWNERSHIP TRANSFERRED for vendor {vendor.vendor_code}: "
|
||||
f"{old_owner.username} (ID: {old_owner.id}) -> "
|
||||
f"{new_owner.username} (ID: {new_owner.id}). "
|
||||
f"Reason: {transfer_data.transfer_reason or 'Not provided'}"
|
||||
)
|
||||
|
||||
# TODO: Send notification emails to both old and new owners
|
||||
# self._send_ownership_transfer_emails(vendor, old_owner, new_owner, transfer_data.transfer_reason)
|
||||
|
||||
return vendor, old_owner, new_owner
|
||||
|
||||
except (ValidationException, UserNotFoundException):
|
||||
db.rollback()
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Failed to transfer ownership for vendor {vendor_id}: {str(e)}")
|
||||
raise AdminOperationException(
|
||||
operation="transfer_vendor_ownership",
|
||||
reason=f"Ownership transfer failed: {str(e)}"
|
||||
)
|
||||
|
||||
# ============================================================================
|
||||
# MARKETPLACE IMPORT JOBS
|
||||
# ============================================================================
|
||||
|
||||
335
app/services/admin_settings_service.py
Normal file
335
app/services/admin_settings_service.py
Normal file
@@ -0,0 +1,335 @@
|
||||
# app/services/admin_settings_service.py
|
||||
"""
|
||||
Admin settings service for platform-wide configuration.
|
||||
|
||||
This module provides functions for:
|
||||
- Managing platform settings
|
||||
- Getting/setting configuration values
|
||||
- Encrypting sensitive settings
|
||||
"""
|
||||
|
||||
import logging
|
||||
import json
|
||||
from typing import Optional, List, Any, Dict
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
|
||||
from models.database.admin import AdminSetting
|
||||
from models.schema.admin import (
|
||||
AdminSettingCreate,
|
||||
AdminSettingResponse,
|
||||
AdminSettingUpdate
|
||||
)
|
||||
from app.exceptions import (
|
||||
AdminOperationException,
|
||||
ValidationException,
|
||||
ResourceNotFoundException
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AdminSettingsService:
|
||||
"""Service for managing platform-wide settings."""
|
||||
|
||||
def get_setting_by_key(
|
||||
self,
|
||||
db: Session,
|
||||
key: str
|
||||
) -> Optional[AdminSetting]:
|
||||
"""Get setting by key."""
|
||||
try:
|
||||
return db.query(AdminSetting).filter(
|
||||
func.lower(AdminSetting.key) == key.lower()
|
||||
).first()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get setting {key}: {str(e)}")
|
||||
return None
|
||||
|
||||
def get_setting_value(
|
||||
self,
|
||||
db: Session,
|
||||
key: str,
|
||||
default: Any = None
|
||||
) -> Any:
|
||||
"""
|
||||
Get setting value with type conversion.
|
||||
|
||||
Args:
|
||||
key: Setting key
|
||||
default: Default value if setting doesn't exist
|
||||
|
||||
Returns:
|
||||
Typed setting value
|
||||
"""
|
||||
setting = self.get_setting_by_key(db, key)
|
||||
|
||||
if not setting:
|
||||
return default
|
||||
|
||||
# Convert value based on type
|
||||
try:
|
||||
if setting.value_type == "integer":
|
||||
return int(setting.value)
|
||||
elif setting.value_type == "float":
|
||||
return float(setting.value)
|
||||
elif setting.value_type == "boolean":
|
||||
return setting.value.lower() in ('true', '1', 'yes')
|
||||
elif setting.value_type == "json":
|
||||
return json.loads(setting.value)
|
||||
else:
|
||||
return setting.value
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to convert setting {key} value: {str(e)}")
|
||||
return default
|
||||
|
||||
def get_all_settings(
|
||||
self,
|
||||
db: Session,
|
||||
category: Optional[str] = None,
|
||||
is_public: Optional[bool] = None
|
||||
) -> List[AdminSettingResponse]:
|
||||
"""Get all settings with optional filtering."""
|
||||
try:
|
||||
query = db.query(AdminSetting)
|
||||
|
||||
if category:
|
||||
query = query.filter(AdminSetting.category == category)
|
||||
|
||||
if is_public is not None:
|
||||
query = query.filter(AdminSetting.is_public == is_public)
|
||||
|
||||
settings = query.order_by(AdminSetting.category, AdminSetting.key).all()
|
||||
|
||||
return [
|
||||
AdminSettingResponse.model_validate(setting)
|
||||
for setting in settings
|
||||
]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get settings: {str(e)}")
|
||||
raise AdminOperationException(
|
||||
operation="get_all_settings",
|
||||
reason="Database query failed"
|
||||
)
|
||||
|
||||
def get_settings_by_category(
|
||||
self,
|
||||
db: Session,
|
||||
category: str
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Get all settings in a category as a dictionary.
|
||||
|
||||
Returns:
|
||||
Dictionary of key-value pairs
|
||||
"""
|
||||
settings = self.get_all_settings(db, category=category)
|
||||
|
||||
result = {}
|
||||
for setting in settings:
|
||||
# Convert value based on type
|
||||
if setting.value_type == "integer":
|
||||
result[setting.key] = int(setting.value)
|
||||
elif setting.value_type == "float":
|
||||
result[setting.key] = float(setting.value)
|
||||
elif setting.value_type == "boolean":
|
||||
result[setting.key] = setting.value.lower() in ('true', '1', 'yes')
|
||||
elif setting.value_type == "json":
|
||||
result[setting.key] = json.loads(setting.value)
|
||||
else:
|
||||
result[setting.key] = setting.value
|
||||
|
||||
return result
|
||||
|
||||
def create_setting(
|
||||
self,
|
||||
db: Session,
|
||||
setting_data: AdminSettingCreate,
|
||||
admin_user_id: int
|
||||
) -> AdminSettingResponse:
|
||||
"""Create new setting."""
|
||||
try:
|
||||
# Check if setting already exists
|
||||
existing = self.get_setting_by_key(db, setting_data.key)
|
||||
if existing:
|
||||
raise ValidationException(
|
||||
f"Setting with key '{setting_data.key}' already exists"
|
||||
)
|
||||
|
||||
# Validate value based on type
|
||||
self._validate_setting_value(setting_data.value, setting_data.value_type)
|
||||
|
||||
# TODO: Encrypt value if is_encrypted=True
|
||||
value_to_store = setting_data.value
|
||||
if setting_data.is_encrypted:
|
||||
# value_to_store = self._encrypt_value(setting_data.value)
|
||||
pass
|
||||
|
||||
setting = AdminSetting(
|
||||
key=setting_data.key.lower(),
|
||||
value=value_to_store,
|
||||
value_type=setting_data.value_type,
|
||||
category=setting_data.category,
|
||||
description=setting_data.description,
|
||||
is_encrypted=setting_data.is_encrypted,
|
||||
is_public=setting_data.is_public,
|
||||
last_modified_by_user_id=admin_user_id
|
||||
)
|
||||
|
||||
db.add(setting)
|
||||
db.commit()
|
||||
db.refresh(setting)
|
||||
|
||||
logger.info(f"Setting '{setting.key}' created by admin {admin_user_id}")
|
||||
|
||||
return AdminSettingResponse.model_validate(setting)
|
||||
|
||||
except ValidationException:
|
||||
db.rollback()
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Failed to create setting: {str(e)}")
|
||||
raise AdminOperationException(
|
||||
operation="create_setting",
|
||||
reason="Database operation failed"
|
||||
)
|
||||
|
||||
def update_setting(
|
||||
self,
|
||||
db: Session,
|
||||
key: str,
|
||||
update_data: AdminSettingUpdate,
|
||||
admin_user_id: int
|
||||
) -> AdminSettingResponse:
|
||||
"""Update existing setting."""
|
||||
setting = self.get_setting_by_key(db, key)
|
||||
|
||||
if not setting:
|
||||
raise ResourceNotFoundException(
|
||||
resource_type="setting",
|
||||
identifier=key
|
||||
)
|
||||
|
||||
try:
|
||||
# Validate new value
|
||||
self._validate_setting_value(update_data.value, setting.value_type)
|
||||
|
||||
# TODO: Encrypt value if needed
|
||||
value_to_store = update_data.value
|
||||
if setting.is_encrypted:
|
||||
# value_to_store = self._encrypt_value(update_data.value)
|
||||
pass
|
||||
|
||||
setting.value = value_to_store
|
||||
if update_data.description is not None:
|
||||
setting.description = update_data.description
|
||||
setting.last_modified_by_user_id = admin_user_id
|
||||
setting.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
db.commit()
|
||||
db.refresh(setting)
|
||||
|
||||
logger.info(f"Setting '{setting.key}' updated by admin {admin_user_id}")
|
||||
|
||||
return AdminSettingResponse.model_validate(setting)
|
||||
|
||||
except ValidationException:
|
||||
db.rollback()
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Failed to update setting {key}: {str(e)}")
|
||||
raise AdminOperationException(
|
||||
operation="update_setting",
|
||||
reason="Database operation failed"
|
||||
)
|
||||
|
||||
def upsert_setting(
|
||||
self,
|
||||
db: Session,
|
||||
setting_data: AdminSettingCreate,
|
||||
admin_user_id: int
|
||||
) -> AdminSettingResponse:
|
||||
"""Create or update setting (upsert)."""
|
||||
existing = self.get_setting_by_key(db, setting_data.key)
|
||||
|
||||
if existing:
|
||||
update_data = AdminSettingUpdate(
|
||||
value=setting_data.value,
|
||||
description=setting_data.description
|
||||
)
|
||||
return self.update_setting(db, setting_data.key, update_data, admin_user_id)
|
||||
else:
|
||||
return self.create_setting(db, setting_data, admin_user_id)
|
||||
|
||||
def delete_setting(
|
||||
self,
|
||||
db: Session,
|
||||
key: str,
|
||||
admin_user_id: int
|
||||
) -> str:
|
||||
"""Delete setting."""
|
||||
setting = self.get_setting_by_key(db, key)
|
||||
|
||||
if not setting:
|
||||
raise ResourceNotFoundException(
|
||||
resource_type="setting",
|
||||
identifier=key
|
||||
)
|
||||
|
||||
try:
|
||||
db.delete(setting)
|
||||
db.commit()
|
||||
|
||||
logger.warning(f"Setting '{key}' deleted by admin {admin_user_id}")
|
||||
|
||||
return f"Setting '{key}' successfully deleted"
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Failed to delete setting {key}: {str(e)}")
|
||||
raise AdminOperationException(
|
||||
operation="delete_setting",
|
||||
reason="Database operation failed"
|
||||
)
|
||||
|
||||
# ============================================================================
|
||||
# HELPER METHODS
|
||||
# ============================================================================
|
||||
|
||||
def _validate_setting_value(self, value: str, value_type: str):
|
||||
"""Validate setting value matches declared type."""
|
||||
try:
|
||||
if value_type == "integer":
|
||||
int(value)
|
||||
elif value_type == "float":
|
||||
float(value)
|
||||
elif value_type == "boolean":
|
||||
if value.lower() not in ('true', 'false', '1', '0', 'yes', 'no'):
|
||||
raise ValueError("Invalid boolean value")
|
||||
elif value_type == "json":
|
||||
json.loads(value)
|
||||
except Exception as e:
|
||||
raise ValidationException(
|
||||
f"Value '{value}' is not valid for type '{value_type}': {str(e)}"
|
||||
)
|
||||
|
||||
def _encrypt_value(self, value: str) -> str:
|
||||
"""Encrypt sensitive setting value."""
|
||||
# TODO: Implement encryption using Fernet or similar
|
||||
# from cryptography.fernet import Fernet
|
||||
# return encrypted_value
|
||||
return value
|
||||
|
||||
def _decrypt_value(self, encrypted_value: str) -> str:
|
||||
"""Decrypt sensitive setting value."""
|
||||
# TODO: Implement decryption
|
||||
return encrypted_value
|
||||
|
||||
|
||||
# Create service instance
|
||||
admin_settings_service = AdminSettingsService()
|
||||
@@ -1 +1,162 @@
|
||||
# Admin-specific models
|
||||
# models/database/admin.py
|
||||
"""
|
||||
Admin-specific database models.
|
||||
|
||||
This module provides models for:
|
||||
- Admin audit logging (compliance and security tracking)
|
||||
- Admin notifications (system alerts and warnings)
|
||||
- Platform settings (global configuration)
|
||||
- Platform alerts (system-wide issues)
|
||||
"""
|
||||
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Boolean, Text, JSON, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.core.database import Base
|
||||
from .base import TimestampMixin
|
||||
|
||||
|
||||
class AdminAuditLog(Base, TimestampMixin):
|
||||
"""
|
||||
Track all admin actions for compliance and security.
|
||||
|
||||
Separate from regular audit logs - focuses on admin-specific operations
|
||||
like vendor creation, user management, and system configuration changes.
|
||||
"""
|
||||
__tablename__ = "admin_audit_logs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
admin_user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||
action = Column(String(100), nullable=False, index=True) # create_vendor, delete_vendor, etc.
|
||||
target_type = Column(String(50), nullable=False, index=True) # vendor, user, import_job, setting
|
||||
target_id = Column(String(100), nullable=False, index=True)
|
||||
details = Column(JSON) # Additional context about the action
|
||||
ip_address = Column(String(45)) # IPv4 or IPv6
|
||||
user_agent = Column(Text)
|
||||
request_id = Column(String(100)) # For correlating with application logs
|
||||
|
||||
# Relationships
|
||||
admin_user = relationship("User", foreign_keys=[admin_user_id])
|
||||
|
||||
def __repr__(self):
|
||||
return f"<AdminAuditLog(id={self.id}, action='{self.action}', target={self.target_type}:{self.target_id})>"
|
||||
|
||||
|
||||
class AdminNotification(Base, TimestampMixin):
|
||||
"""
|
||||
Admin-specific notifications for system alerts and warnings.
|
||||
|
||||
Different from vendor/customer notifications - these are for platform
|
||||
administrators to track system health and issues requiring attention.
|
||||
"""
|
||||
__tablename__ = "admin_notifications"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
type = Column(String(50), nullable=False, index=True) # system_alert, vendor_issue, import_failure
|
||||
priority = Column(String(20), default="normal", index=True) # low, normal, high, critical
|
||||
title = Column(String(200), nullable=False)
|
||||
message = Column(Text, nullable=False)
|
||||
is_read = Column(Boolean, default=False, index=True)
|
||||
read_at = Column(DateTime, nullable=True)
|
||||
read_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
action_required = Column(Boolean, default=False, index=True)
|
||||
action_url = Column(String(500)) # Link to relevant admin page
|
||||
notification_metadata = Column(JSON) # Additional contextual data
|
||||
|
||||
# Relationships
|
||||
read_by = relationship("User", foreign_keys=[read_by_user_id])
|
||||
|
||||
def __repr__(self):
|
||||
return f"<AdminNotification(id={self.id}, type='{self.type}', priority='{self.priority}')>"
|
||||
|
||||
|
||||
class AdminSetting(Base, TimestampMixin):
|
||||
"""
|
||||
Platform-wide admin settings and configuration.
|
||||
|
||||
Stores global settings that affect the entire platform, different from
|
||||
vendor-specific settings. Supports encryption for sensitive values.
|
||||
|
||||
Examples:
|
||||
- max_vendors_allowed
|
||||
- maintenance_mode
|
||||
- default_vendor_trial_days
|
||||
- smtp_settings
|
||||
- stripe_api_keys (encrypted)
|
||||
"""
|
||||
__tablename__ = "admin_settings"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
key = Column(String(100), unique=True, nullable=False, index=True)
|
||||
value = Column(Text, nullable=False)
|
||||
value_type = Column(String(20), default="string") # string, integer, boolean, json
|
||||
category = Column(String(50), index=True) # system, security, marketplace, notifications
|
||||
description = Column(Text)
|
||||
is_encrypted = Column(Boolean, default=False)
|
||||
is_public = Column(Boolean, default=False) # Can be exposed to frontend?
|
||||
last_modified_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
|
||||
# Relationships
|
||||
last_modified_by = relationship("User", foreign_keys=[last_modified_by_user_id])
|
||||
|
||||
def __repr__(self):
|
||||
return f"<AdminSetting(key='{self.key}', category='{self.category}')>"
|
||||
|
||||
|
||||
class PlatformAlert(Base, TimestampMixin):
|
||||
"""
|
||||
System-wide alerts that admins need to be aware of.
|
||||
|
||||
Tracks platform issues, performance problems, security incidents,
|
||||
and other system-level concerns that require admin attention.
|
||||
"""
|
||||
__tablename__ = "platform_alerts"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
alert_type = Column(String(50), nullable=False, index=True) # security, performance, capacity, integration
|
||||
severity = Column(String(20), nullable=False, index=True) # info, warning, error, critical
|
||||
title = Column(String(200), nullable=False)
|
||||
description = Column(Text)
|
||||
affected_vendors = Column(JSON) # List of affected vendor IDs
|
||||
affected_systems = Column(JSON) # List of affected system components
|
||||
is_resolved = Column(Boolean, default=False, index=True)
|
||||
resolved_at = Column(DateTime, nullable=True)
|
||||
resolved_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
resolution_notes = Column(Text)
|
||||
auto_generated = Column(Boolean, default=True) # System-generated vs manual
|
||||
occurrence_count = Column(Integer, default=1) # Track repeated occurrences
|
||||
first_occurred_at = Column(DateTime, nullable=False)
|
||||
last_occurred_at = Column(DateTime, nullable=False)
|
||||
|
||||
# Relationships
|
||||
resolved_by = relationship("User", foreign_keys=[resolved_by_user_id])
|
||||
|
||||
def __repr__(self):
|
||||
return f"<PlatformAlert(id={self.id}, type='{self.alert_type}', severity='{self.severity}')>"
|
||||
|
||||
|
||||
class AdminSession(Base, TimestampMixin):
|
||||
"""
|
||||
Track admin login sessions for security monitoring.
|
||||
|
||||
Helps identify suspicious login patterns, track concurrent sessions,
|
||||
and enforce session policies for admin users.
|
||||
"""
|
||||
__tablename__ = "admin_sessions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
admin_user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||
session_token = Column(String(255), unique=True, nullable=False, index=True)
|
||||
ip_address = Column(String(45), nullable=False)
|
||||
user_agent = Column(Text)
|
||||
login_at = Column(DateTime, nullable=False, index=True)
|
||||
last_activity_at = Column(DateTime, nullable=False)
|
||||
logout_at = Column(DateTime, nullable=True)
|
||||
is_active = Column(Boolean, default=True, index=True)
|
||||
logout_reason = Column(String(50)) # manual, timeout, forced, suspicious
|
||||
|
||||
# Relationships
|
||||
admin_user = relationship("User", foreign_keys=[admin_user_id])
|
||||
|
||||
def __repr__(self):
|
||||
return f"<AdminSession(id={self.id}, admin_user_id={self.admin_user_id}, is_active={self.is_active})>"
|
||||
|
||||
@@ -1 +1,365 @@
|
||||
# Admin operation models
|
||||
# models/schema/admin.py
|
||||
"""
|
||||
Admin-specific Pydantic schemas for API validation and responses.
|
||||
|
||||
This module provides schemas for:
|
||||
- Admin audit logs
|
||||
- Admin notifications
|
||||
- Platform settings
|
||||
- Platform alerts
|
||||
- Bulk operations
|
||||
- System health checks
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional, List, Dict, Any
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# ADMIN AUDIT LOG SCHEMAS
|
||||
# ============================================================================
|
||||
|
||||
class AdminAuditLogResponse(BaseModel):
|
||||
"""Response model for admin audit logs."""
|
||||
id: int
|
||||
admin_user_id: int
|
||||
admin_username: Optional[str] = None
|
||||
action: str
|
||||
target_type: str
|
||||
target_id: str
|
||||
details: Optional[Dict[str, Any]] = None
|
||||
ip_address: Optional[str] = None
|
||||
user_agent: Optional[str] = None
|
||||
request_id: Optional[str] = None
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class AdminAuditLogFilters(BaseModel):
|
||||
"""Filters for querying audit logs."""
|
||||
admin_user_id: Optional[int] = None
|
||||
action: Optional[str] = None
|
||||
target_type: Optional[str] = None
|
||||
date_from: Optional[datetime] = None
|
||||
date_to: Optional[datetime] = None
|
||||
skip: int = Field(0, ge=0)
|
||||
limit: int = Field(100, ge=1, le=1000)
|
||||
|
||||
|
||||
class AdminAuditLogListResponse(BaseModel):
|
||||
"""Paginated list of audit logs."""
|
||||
logs: List[AdminAuditLogResponse]
|
||||
total: int
|
||||
skip: int
|
||||
limit: int
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# ADMIN NOTIFICATION SCHEMAS
|
||||
# ============================================================================
|
||||
|
||||
class AdminNotificationCreate(BaseModel):
|
||||
"""Create admin notification."""
|
||||
type: str = Field(..., max_length=50, description="Notification type")
|
||||
priority: str = Field(default="normal", description="Priority level")
|
||||
title: str = Field(..., max_length=200)
|
||||
message: str = Field(..., description="Notification message")
|
||||
action_required: bool = Field(default=False)
|
||||
action_url: Optional[str] = Field(None, max_length=500)
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
|
||||
@field_validator('priority')
|
||||
@classmethod
|
||||
def validate_priority(cls, v):
|
||||
allowed = ['low', 'normal', 'high', 'critical']
|
||||
if v not in allowed:
|
||||
raise ValueError(f"Priority must be one of: {', '.join(allowed)}")
|
||||
return v
|
||||
|
||||
|
||||
class AdminNotificationResponse(BaseModel):
|
||||
"""Admin notification response."""
|
||||
id: int
|
||||
type: str
|
||||
priority: str
|
||||
title: str
|
||||
message: str
|
||||
is_read: bool
|
||||
read_at: Optional[datetime] = None
|
||||
read_by_user_id: Optional[int] = None
|
||||
action_required: bool
|
||||
action_url: Optional[str] = None
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class AdminNotificationUpdate(BaseModel):
|
||||
"""Mark notification as read."""
|
||||
is_read: bool = True
|
||||
|
||||
|
||||
class AdminNotificationListResponse(BaseModel):
|
||||
"""Paginated list of notifications."""
|
||||
notifications: List[AdminNotificationResponse]
|
||||
total: int
|
||||
unread_count: int
|
||||
skip: int
|
||||
limit: int
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# ADMIN SETTINGS SCHEMAS
|
||||
# ============================================================================
|
||||
|
||||
class AdminSettingCreate(BaseModel):
|
||||
"""Create or update admin setting."""
|
||||
key: str = Field(..., max_length=100, description="Unique setting key")
|
||||
value: str = Field(..., description="Setting value")
|
||||
value_type: str = Field(default="string", description="Data type")
|
||||
category: Optional[str] = Field(None, max_length=50)
|
||||
description: Optional[str] = None
|
||||
is_encrypted: bool = Field(default=False)
|
||||
is_public: bool = Field(default=False, description="Can be exposed to frontend")
|
||||
|
||||
@field_validator('value_type')
|
||||
@classmethod
|
||||
def validate_value_type(cls, v):
|
||||
allowed = ['string', 'integer', 'boolean', 'json', 'float']
|
||||
if v not in allowed:
|
||||
raise ValueError(f"Value type must be one of: {', '.join(allowed)}")
|
||||
return v
|
||||
|
||||
@field_validator('key')
|
||||
@classmethod
|
||||
def validate_key_format(cls, v):
|
||||
# Setting keys should be lowercase with underscores
|
||||
if not v.replace('_', '').isalnum():
|
||||
raise ValueError("Setting key must contain only letters, numbers, and underscores")
|
||||
return v.lower()
|
||||
|
||||
|
||||
class AdminSettingResponse(BaseModel):
|
||||
"""Admin setting response."""
|
||||
id: int
|
||||
key: str
|
||||
value: str
|
||||
value_type: str
|
||||
category: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
is_encrypted: bool
|
||||
is_public: bool
|
||||
last_modified_by_user_id: Optional[int] = None
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class AdminSettingUpdate(BaseModel):
|
||||
"""Update admin setting value."""
|
||||
value: str
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class AdminSettingListResponse(BaseModel):
|
||||
"""List of settings by category."""
|
||||
settings: List[AdminSettingResponse]
|
||||
total: int
|
||||
category: Optional[str] = None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# PLATFORM ALERT SCHEMAS
|
||||
# ============================================================================
|
||||
|
||||
class PlatformAlertCreate(BaseModel):
|
||||
"""Create platform alert."""
|
||||
alert_type: str = Field(..., max_length=50)
|
||||
severity: str = Field(..., description="Alert severity")
|
||||
title: str = Field(..., max_length=200)
|
||||
description: Optional[str] = None
|
||||
affected_vendors: Optional[List[int]] = None
|
||||
affected_systems: Optional[List[str]] = None
|
||||
auto_generated: bool = Field(default=True)
|
||||
|
||||
@field_validator('severity')
|
||||
@classmethod
|
||||
def validate_severity(cls, v):
|
||||
allowed = ['info', 'warning', 'error', 'critical']
|
||||
if v not in allowed:
|
||||
raise ValueError(f"Severity must be one of: {', '.join(allowed)}")
|
||||
return v
|
||||
|
||||
@field_validator('alert_type')
|
||||
@classmethod
|
||||
def validate_alert_type(cls, v):
|
||||
allowed = ['security', 'performance', 'capacity', 'integration', 'database', 'system']
|
||||
if v not in allowed:
|
||||
raise ValueError(f"Alert type must be one of: {', '.join(allowed)}")
|
||||
return v
|
||||
|
||||
|
||||
class PlatformAlertResponse(BaseModel):
|
||||
"""Platform alert response."""
|
||||
id: int
|
||||
alert_type: str
|
||||
severity: str
|
||||
title: str
|
||||
description: Optional[str] = None
|
||||
affected_vendors: Optional[List[int]] = None
|
||||
affected_systems: Optional[List[str]] = None
|
||||
is_resolved: bool
|
||||
resolved_at: Optional[datetime] = None
|
||||
resolved_by_user_id: Optional[int] = None
|
||||
resolution_notes: Optional[str] = None
|
||||
auto_generated: bool
|
||||
occurrence_count: int
|
||||
first_occurred_at: datetime
|
||||
last_occurred_at: datetime
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class PlatformAlertResolve(BaseModel):
|
||||
"""Resolve platform alert."""
|
||||
is_resolved: bool = True
|
||||
resolution_notes: Optional[str] = None
|
||||
|
||||
|
||||
class PlatformAlertListResponse(BaseModel):
|
||||
"""Paginated list of platform alerts."""
|
||||
alerts: List[PlatformAlertResponse]
|
||||
total: int
|
||||
active_count: int
|
||||
critical_count: int
|
||||
skip: int
|
||||
limit: int
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# BULK OPERATION SCHEMAS
|
||||
# ============================================================================
|
||||
|
||||
class BulkVendorAction(BaseModel):
|
||||
"""Bulk actions on vendors."""
|
||||
vendor_ids: List[int] = Field(..., min_length=1, max_length=100)
|
||||
action: str = Field(..., description="Action to perform")
|
||||
confirm: bool = Field(default=False, description="Required for destructive actions")
|
||||
reason: Optional[str] = Field(None, description="Reason for bulk action")
|
||||
|
||||
@field_validator('action')
|
||||
@classmethod
|
||||
def validate_action(cls, v):
|
||||
allowed = ['activate', 'deactivate', 'verify', 'unverify', 'delete']
|
||||
if v not in allowed:
|
||||
raise ValueError(f"Action must be one of: {', '.join(allowed)}")
|
||||
return v
|
||||
|
||||
|
||||
class BulkVendorActionResponse(BaseModel):
|
||||
"""Response for bulk vendor actions."""
|
||||
successful: List[int]
|
||||
failed: Dict[int, str] # vendor_id -> error_message
|
||||
total_processed: int
|
||||
action_performed: str
|
||||
message: str
|
||||
|
||||
|
||||
class BulkUserAction(BaseModel):
|
||||
"""Bulk actions on users."""
|
||||
user_ids: List[int] = Field(..., min_length=1, max_length=100)
|
||||
action: str = Field(..., description="Action to perform")
|
||||
confirm: bool = Field(default=False)
|
||||
reason: Optional[str] = None
|
||||
|
||||
@field_validator('action')
|
||||
@classmethod
|
||||
def validate_action(cls, v):
|
||||
allowed = ['activate', 'deactivate', 'delete']
|
||||
if v not in allowed:
|
||||
raise ValueError(f"Action must be one of: {', '.join(allowed)}")
|
||||
return v
|
||||
|
||||
|
||||
class BulkUserActionResponse(BaseModel):
|
||||
"""Response for bulk user actions."""
|
||||
successful: List[int]
|
||||
failed: Dict[int, str]
|
||||
total_processed: int
|
||||
action_performed: str
|
||||
message: str
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# ADMIN DASHBOARD SCHEMAS
|
||||
# ============================================================================
|
||||
|
||||
class AdminDashboardStats(BaseModel):
|
||||
"""Comprehensive admin dashboard statistics."""
|
||||
platform: Dict[str, Any]
|
||||
users: Dict[str, Any]
|
||||
vendors: Dict[str, Any]
|
||||
products: Dict[str, Any]
|
||||
orders: Dict[str, Any]
|
||||
imports: Dict[str, Any]
|
||||
recent_vendors: List[Dict[str, Any]]
|
||||
recent_imports: List[Dict[str, Any]]
|
||||
unread_notifications: int
|
||||
active_alerts: int
|
||||
critical_alerts: int
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# SYSTEM HEALTH SCHEMAS
|
||||
# ============================================================================
|
||||
|
||||
class ComponentHealthStatus(BaseModel):
|
||||
"""Health status for a system component."""
|
||||
status: str # healthy, degraded, unhealthy
|
||||
response_time_ms: Optional[float] = None
|
||||
error_message: Optional[str] = None
|
||||
last_checked: datetime
|
||||
details: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class SystemHealthResponse(BaseModel):
|
||||
"""System health check response."""
|
||||
overall_status: str # healthy, degraded, critical
|
||||
database: ComponentHealthStatus
|
||||
redis: ComponentHealthStatus
|
||||
celery: ComponentHealthStatus
|
||||
storage: ComponentHealthStatus
|
||||
api_response_time_ms: float
|
||||
uptime_seconds: int
|
||||
timestamp: datetime
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# ADMIN SESSION SCHEMAS
|
||||
# ============================================================================
|
||||
|
||||
class AdminSessionResponse(BaseModel):
|
||||
"""Admin session information."""
|
||||
id: int
|
||||
admin_user_id: int
|
||||
admin_username: Optional[str] = None
|
||||
ip_address: str
|
||||
user_agent: Optional[str] = None
|
||||
login_at: datetime
|
||||
last_activity_at: datetime
|
||||
logout_at: Optional[datetime] = None
|
||||
is_active: bool
|
||||
logout_reason: Optional[str] = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class AdminSessionListResponse(BaseModel):
|
||||
"""List of admin sessions."""
|
||||
sessions: List[AdminSessionResponse]
|
||||
total: int
|
||||
active_count: int
|
||||
|
||||
Reference in New Issue
Block a user