Add comprehensive backend features and mobile UI improvements
Backend: - Add 2FA authentication with TOTP support - Add API keys management system - Add audit logging for security events - Add file upload/management system - Add notifications system with preferences - Add session management - Add webhooks integration - Add analytics endpoints - Add export functionality - Add password policy enforcement - Add new database migrations for core tables Frontend: - Add module position system (top/bottom sidebar sections) - Add search and notifications module configuration tabs - Add mobile logo replacing hamburger menu - Center page title absolutely when no tabs present - Align sidebar footer toggles with navigation items - Add lighter icon color in dark theme for mobile - Add API keys management page - Add notifications page with context - Add admin analytics and audit logs pages 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
370
backend/app/api/v1/export.py
Normal file
370
backend/app/api/v1/export.py
Normal file
@@ -0,0 +1,370 @@
|
||||
"""Data export/import endpoints."""
|
||||
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Any, List
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Response
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.dependencies import get_db, get_current_superuser
|
||||
from app.models.user import User
|
||||
from app.models.audit_log import AuditLog
|
||||
from app import crud, schemas
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class ImportResult(BaseModel):
|
||||
"""Import operation result."""
|
||||
success: int = 0
|
||||
failed: int = 0
|
||||
errors: List[str] = []
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# EXPORT ENDPOINTS
|
||||
# =============================================================================
|
||||
|
||||
@router.get("/users/csv")
|
||||
def export_users_csv(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_superuser),
|
||||
):
|
||||
"""
|
||||
Export all users to CSV format.
|
||||
"""
|
||||
users = db.query(User).all()
|
||||
|
||||
# Create CSV in memory
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
|
||||
# Header
|
||||
writer.writerow([
|
||||
"id", "username", "email", "is_active", "is_superuser",
|
||||
"totp_enabled", "created_at", "last_login"
|
||||
])
|
||||
|
||||
# Data rows
|
||||
for user in users:
|
||||
writer.writerow([
|
||||
user.id,
|
||||
user.username,
|
||||
user.email,
|
||||
user.is_active,
|
||||
user.is_superuser,
|
||||
user.totp_enabled,
|
||||
user.created_at.isoformat() if user.created_at else "",
|
||||
user.last_login.isoformat() if user.last_login else ""
|
||||
])
|
||||
|
||||
output.seek(0)
|
||||
|
||||
# Log export
|
||||
crud.audit_log.log_action(
|
||||
db,
|
||||
user_id=current_user.id,
|
||||
username=current_user.username,
|
||||
action="export",
|
||||
resource_type="users",
|
||||
details={"format": "csv", "count": len(users)},
|
||||
status="success"
|
||||
)
|
||||
|
||||
return StreamingResponse(
|
||||
iter([output.getvalue()]),
|
||||
media_type="text/csv",
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename=users_{datetime.utcnow().strftime('%Y%m%d_%H%M%S')}.csv"
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/users/json")
|
||||
def export_users_json(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_superuser),
|
||||
):
|
||||
"""
|
||||
Export all users to JSON format.
|
||||
"""
|
||||
users = db.query(User).all()
|
||||
|
||||
data = {
|
||||
"exported_at": datetime.utcnow().isoformat(),
|
||||
"exported_by": current_user.username,
|
||||
"count": len(users),
|
||||
"users": [
|
||||
{
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
"is_active": user.is_active,
|
||||
"is_superuser": user.is_superuser,
|
||||
"totp_enabled": user.totp_enabled,
|
||||
"permissions": user.permissions,
|
||||
"created_at": user.created_at.isoformat() if user.created_at else None,
|
||||
"last_login": user.last_login.isoformat() if user.last_login else None
|
||||
}
|
||||
for user in users
|
||||
]
|
||||
}
|
||||
|
||||
# Log export
|
||||
crud.audit_log.log_action(
|
||||
db,
|
||||
user_id=current_user.id,
|
||||
username=current_user.username,
|
||||
action="export",
|
||||
resource_type="users",
|
||||
details={"format": "json", "count": len(users)},
|
||||
status="success"
|
||||
)
|
||||
|
||||
content = json.dumps(data, indent=2)
|
||||
|
||||
return Response(
|
||||
content=content,
|
||||
media_type="application/json",
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename=users_{datetime.utcnow().strftime('%Y%m%d_%H%M%S')}.json"
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/settings/json")
|
||||
def export_settings_json(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_superuser),
|
||||
):
|
||||
"""
|
||||
Export all settings to JSON format.
|
||||
"""
|
||||
from app.models.settings import Settings
|
||||
|
||||
settings_list = db.query(Settings).all()
|
||||
|
||||
data = {
|
||||
"exported_at": datetime.utcnow().isoformat(),
|
||||
"exported_by": current_user.username,
|
||||
"count": len(settings_list),
|
||||
"settings": {
|
||||
setting.key: setting.value
|
||||
for setting in settings_list
|
||||
}
|
||||
}
|
||||
|
||||
# Log export
|
||||
crud.audit_log.log_action(
|
||||
db,
|
||||
user_id=current_user.id,
|
||||
username=current_user.username,
|
||||
action="export",
|
||||
resource_type="settings",
|
||||
details={"format": "json", "count": len(settings_list)},
|
||||
status="success"
|
||||
)
|
||||
|
||||
content = json.dumps(data, indent=2)
|
||||
|
||||
return Response(
|
||||
content=content,
|
||||
media_type="application/json",
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename=settings_{datetime.utcnow().strftime('%Y%m%d_%H%M%S')}.json"
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/audit/csv")
|
||||
def export_audit_csv(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_superuser),
|
||||
days: int = 30
|
||||
):
|
||||
"""
|
||||
Export audit logs to CSV format.
|
||||
"""
|
||||
from datetime import timedelta
|
||||
|
||||
since = datetime.utcnow() - timedelta(days=days)
|
||||
logs = db.query(AuditLog).filter(AuditLog.created_at >= since).all()
|
||||
|
||||
# Create CSV in memory
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
|
||||
# Header
|
||||
writer.writerow([
|
||||
"id", "user_id", "username", "action", "resource_type",
|
||||
"resource_id", "status", "ip_address", "created_at"
|
||||
])
|
||||
|
||||
# Data rows
|
||||
for log in logs:
|
||||
writer.writerow([
|
||||
log.id,
|
||||
log.user_id,
|
||||
log.username,
|
||||
log.action,
|
||||
log.resource_type,
|
||||
log.resource_id,
|
||||
log.status,
|
||||
log.ip_address,
|
||||
log.created_at.isoformat() if log.created_at else ""
|
||||
])
|
||||
|
||||
output.seek(0)
|
||||
|
||||
return StreamingResponse(
|
||||
iter([output.getvalue()]),
|
||||
media_type="text/csv",
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename=audit_logs_{datetime.utcnow().strftime('%Y%m%d_%H%M%S')}.csv"
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# IMPORT ENDPOINTS
|
||||
# =============================================================================
|
||||
|
||||
@router.post("/users/json", response_model=ImportResult)
|
||||
async def import_users_json(
|
||||
file: UploadFile = File(...),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_superuser),
|
||||
):
|
||||
"""
|
||||
Import users from JSON file.
|
||||
Only creates new users, does not update existing ones.
|
||||
"""
|
||||
result = ImportResult()
|
||||
|
||||
try:
|
||||
content = await file.read()
|
||||
data = json.loads(content.decode())
|
||||
except json.JSONDecodeError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid JSON file: {str(e)}"
|
||||
)
|
||||
|
||||
users_data = data.get("users", [])
|
||||
if not users_data:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="No users found in file"
|
||||
)
|
||||
|
||||
for user_data in users_data:
|
||||
try:
|
||||
username = user_data.get("username")
|
||||
email = user_data.get("email")
|
||||
|
||||
# Check if user already exists
|
||||
if crud.user.get_by_username(db, username=username):
|
||||
result.errors.append(f"User '{username}' already exists")
|
||||
result.failed += 1
|
||||
continue
|
||||
|
||||
if crud.user.get_by_email(db, email=email):
|
||||
result.errors.append(f"Email '{email}' already exists")
|
||||
result.failed += 1
|
||||
continue
|
||||
|
||||
# Create user with a default password (must be changed)
|
||||
import secrets
|
||||
temp_password = secrets.token_urlsafe(16)
|
||||
|
||||
user_create = schemas.UserCreate(
|
||||
username=username,
|
||||
email=email,
|
||||
password=temp_password,
|
||||
is_active=user_data.get("is_active", True),
|
||||
is_superuser=user_data.get("is_superuser", False),
|
||||
permissions=user_data.get("permissions")
|
||||
)
|
||||
|
||||
crud.user.create(db, obj_in=user_create)
|
||||
result.success += 1
|
||||
|
||||
except Exception as e:
|
||||
result.errors.append(f"Error importing user: {str(e)}")
|
||||
result.failed += 1
|
||||
|
||||
# Log import
|
||||
crud.audit_log.log_action(
|
||||
db,
|
||||
user_id=current_user.id,
|
||||
username=current_user.username,
|
||||
action="import",
|
||||
resource_type="users",
|
||||
details={
|
||||
"format": "json",
|
||||
"success": result.success,
|
||||
"failed": result.failed
|
||||
},
|
||||
status="success" if result.failed == 0 else "partial"
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/settings/json", response_model=ImportResult)
|
||||
async def import_settings_json(
|
||||
file: UploadFile = File(...),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_superuser),
|
||||
):
|
||||
"""
|
||||
Import settings from JSON file.
|
||||
Updates existing settings and creates new ones.
|
||||
"""
|
||||
result = ImportResult()
|
||||
|
||||
try:
|
||||
content = await file.read()
|
||||
data = json.loads(content.decode())
|
||||
except json.JSONDecodeError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid JSON file: {str(e)}"
|
||||
)
|
||||
|
||||
settings_data = data.get("settings", {})
|
||||
if not settings_data:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="No settings found in file"
|
||||
)
|
||||
|
||||
for key, value in settings_data.items():
|
||||
try:
|
||||
crud.settings.update_setting(db, key=key, value=value)
|
||||
result.success += 1
|
||||
except Exception as e:
|
||||
result.errors.append(f"Error importing setting '{key}': {str(e)}")
|
||||
result.failed += 1
|
||||
|
||||
# Log import
|
||||
crud.audit_log.log_action(
|
||||
db,
|
||||
user_id=current_user.id,
|
||||
username=current_user.username,
|
||||
action="import",
|
||||
resource_type="settings",
|
||||
details={
|
||||
"format": "json",
|
||||
"success": result.success,
|
||||
"failed": result.failed
|
||||
},
|
||||
status="success" if result.failed == 0 else "partial"
|
||||
)
|
||||
|
||||
return result
|
||||
Reference in New Issue
Block a user