fixes and refactor
This commit is contained in:
360
app/api/admin.py
360
app/api/admin.py
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Comprehensive Admin API endpoints - User management, system settings, audit logging
|
||||
"""
|
||||
from typing import List, Dict, Any, Optional
|
||||
from typing import List, Dict, Any, Optional, Union
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Query, Body, Request
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
@@ -13,24 +13,27 @@ import hashlib
|
||||
import secrets
|
||||
import shutil
|
||||
import time
|
||||
from datetime import datetime, timedelta, date
|
||||
from datetime import datetime, timedelta, date, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from app.database.base import get_db
|
||||
from app.api.search_highlight import build_query_tokens
|
||||
|
||||
# Track application start time
|
||||
APPLICATION_START_TIME = time.time()
|
||||
from app.models import User, Rolodex, File as FileModel, Ledger, QDRO, AuditLog, LoginAttempt
|
||||
from app.models.lookups import SystemSetup, Employee, FileType, FileStatus, TransactionType, TransactionCode, State, FormIndex
|
||||
from app.models.lookups import SystemSetup, Employee, FileType, FileStatus, TransactionType, TransactionCode, State, FormIndex, PrinterSetup
|
||||
from app.auth.security import get_admin_user, get_password_hash, create_access_token
|
||||
from app.services.audit import audit_service
|
||||
from app.config import settings
|
||||
from app.services.query_utils import apply_sorting, tokenized_ilike_filter, paginate_with_total
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# Enhanced Admin Schemas
|
||||
from pydantic import BaseModel, Field, EmailStr
|
||||
from pydantic.config import ConfigDict
|
||||
|
||||
class SystemStats(BaseModel):
|
||||
"""Enhanced system statistics"""
|
||||
@@ -91,8 +94,7 @@ class UserResponse(BaseModel):
|
||||
created_at: Optional[datetime]
|
||||
updated_at: Optional[datetime]
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
class PasswordReset(BaseModel):
|
||||
"""Password reset request"""
|
||||
@@ -124,8 +126,7 @@ class AuditLogEntry(BaseModel):
|
||||
user_agent: Optional[str]
|
||||
timestamp: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
class BackupInfo(BaseModel):
|
||||
"""Backup information"""
|
||||
@@ -135,6 +136,132 @@ class BackupInfo(BaseModel):
|
||||
backup_type: str
|
||||
status: str
|
||||
|
||||
|
||||
class PrinterSetupBase(BaseModel):
|
||||
"""Base schema for printer setup"""
|
||||
description: Optional[str] = None
|
||||
driver: Optional[str] = None
|
||||
port: Optional[str] = None
|
||||
default_printer: Optional[bool] = None
|
||||
active: Optional[bool] = None
|
||||
number: Optional[int] = None
|
||||
page_break: Optional[str] = None
|
||||
setup_st: Optional[str] = None
|
||||
reset_st: Optional[str] = None
|
||||
b_underline: Optional[str] = None
|
||||
e_underline: Optional[str] = None
|
||||
b_bold: Optional[str] = None
|
||||
e_bold: Optional[str] = None
|
||||
phone_book: Optional[bool] = None
|
||||
rolodex_info: Optional[bool] = None
|
||||
envelope: Optional[bool] = None
|
||||
file_cabinet: Optional[bool] = None
|
||||
accounts: Optional[bool] = None
|
||||
statements: Optional[bool] = None
|
||||
calendar: Optional[bool] = None
|
||||
|
||||
|
||||
class PrinterSetupCreate(PrinterSetupBase):
|
||||
printer_name: str
|
||||
|
||||
|
||||
class PrinterSetupUpdate(PrinterSetupBase):
|
||||
pass
|
||||
|
||||
|
||||
class PrinterSetupResponse(PrinterSetupBase):
|
||||
printer_name: str
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
# Printer Setup Management
|
||||
|
||||
@router.get("/printers", response_model=List[PrinterSetupResponse])
|
||||
async def list_printers(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_admin_user)
|
||||
):
|
||||
printers = db.query(PrinterSetup).order_by(PrinterSetup.printer_name.asc()).all()
|
||||
return printers
|
||||
|
||||
|
||||
@router.get("/printers/{printer_name}", response_model=PrinterSetupResponse)
|
||||
async def get_printer(
|
||||
printer_name: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_admin_user)
|
||||
):
|
||||
printer = db.query(PrinterSetup).filter(PrinterSetup.printer_name == printer_name).first()
|
||||
if not printer:
|
||||
raise HTTPException(status_code=404, detail="Printer not found")
|
||||
return printer
|
||||
|
||||
|
||||
@router.post("/printers", response_model=PrinterSetupResponse)
|
||||
async def create_printer(
|
||||
payload: PrinterSetupCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_admin_user)
|
||||
):
|
||||
exists = db.query(PrinterSetup).filter(PrinterSetup.printer_name == payload.printer_name).first()
|
||||
if exists:
|
||||
raise HTTPException(status_code=400, detail="Printer already exists")
|
||||
data = payload.model_dump(exclude_unset=True)
|
||||
instance = PrinterSetup(**data)
|
||||
db.add(instance)
|
||||
# Enforce single default printer
|
||||
if data.get("default_printer"):
|
||||
try:
|
||||
db.query(PrinterSetup).filter(PrinterSetup.printer_name != instance.printer_name).update({PrinterSetup.default_printer: False})
|
||||
except Exception:
|
||||
pass
|
||||
db.commit()
|
||||
db.refresh(instance)
|
||||
return instance
|
||||
|
||||
|
||||
@router.put("/printers/{printer_name}", response_model=PrinterSetupResponse)
|
||||
async def update_printer(
|
||||
printer_name: str,
|
||||
payload: PrinterSetupUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_admin_user)
|
||||
):
|
||||
instance = db.query(PrinterSetup).filter(PrinterSetup.printer_name == printer_name).first()
|
||||
if not instance:
|
||||
raise HTTPException(status_code=404, detail="Printer not found")
|
||||
updates = payload.model_dump(exclude_unset=True)
|
||||
for k, v in updates.items():
|
||||
setattr(instance, k, v)
|
||||
# Enforce single default printer when set true
|
||||
if updates.get("default_printer"):
|
||||
try:
|
||||
db.query(PrinterSetup).filter(PrinterSetup.printer_name != instance.printer_name).update({PrinterSetup.default_printer: False})
|
||||
except Exception:
|
||||
pass
|
||||
db.commit()
|
||||
db.refresh(instance)
|
||||
return instance
|
||||
|
||||
|
||||
@router.delete("/printers/{printer_name}")
|
||||
async def delete_printer(
|
||||
printer_name: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_admin_user)
|
||||
):
|
||||
instance = db.query(PrinterSetup).filter(PrinterSetup.printer_name == printer_name).first()
|
||||
if not instance:
|
||||
raise HTTPException(status_code=404, detail="Printer not found")
|
||||
db.delete(instance)
|
||||
db.commit()
|
||||
return {"message": "Printer deleted"}
|
||||
|
||||
|
||||
|
||||
class LookupTableInfo(BaseModel):
|
||||
"""Lookup table information"""
|
||||
table_name: str
|
||||
@@ -202,7 +329,7 @@ async def system_health(
|
||||
# Count active sessions (simplified)
|
||||
try:
|
||||
active_sessions = db.query(User).filter(
|
||||
User.last_login > datetime.now() - timedelta(hours=24)
|
||||
User.last_login > datetime.now(timezone.utc) - timedelta(hours=24)
|
||||
).count()
|
||||
except:
|
||||
active_sessions = 0
|
||||
@@ -215,7 +342,7 @@ async def system_health(
|
||||
backup_files = list(backup_dir.glob("*.db"))
|
||||
if backup_files:
|
||||
latest_backup = max(backup_files, key=lambda p: p.stat().st_mtime)
|
||||
backup_age = datetime.now() - datetime.fromtimestamp(latest_backup.stat().st_mtime)
|
||||
backup_age = datetime.now(timezone.utc) - datetime.fromtimestamp(latest_backup.stat().st_mtime, tz=timezone.utc)
|
||||
last_backup = latest_backup.name
|
||||
if backup_age.days > 7:
|
||||
alerts.append(f"Last backup is {backup_age.days} days old")
|
||||
@@ -257,7 +384,7 @@ async def system_statistics(
|
||||
|
||||
# Count active users (logged in within last 30 days)
|
||||
total_active_users = db.query(func.count(User.id)).filter(
|
||||
User.last_login > datetime.now() - timedelta(days=30)
|
||||
User.last_login > datetime.now(timezone.utc) - timedelta(days=30)
|
||||
).scalar()
|
||||
|
||||
# Count admin users
|
||||
@@ -308,7 +435,7 @@ async def system_statistics(
|
||||
recent_activity.append({
|
||||
"type": "customer_added",
|
||||
"description": f"Customer {customer.first} {customer.last} added",
|
||||
"timestamp": datetime.now().isoformat()
|
||||
"timestamp": datetime.now(timezone.utc).isoformat()
|
||||
})
|
||||
except:
|
||||
pass
|
||||
@@ -409,7 +536,7 @@ async def export_table(
|
||||
# Create exports directory if it doesn't exist
|
||||
os.makedirs("exports", exist_ok=True)
|
||||
|
||||
filename = f"exports/{table_name}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
|
||||
filename = f"exports/{table_name}_{datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')}.csv"
|
||||
|
||||
try:
|
||||
if table_name.lower() == "customers" or table_name.lower() == "rolodex":
|
||||
@@ -470,10 +597,10 @@ async def download_backup(
|
||||
if "sqlite" in settings.database_url:
|
||||
db_path = settings.database_url.replace("sqlite:///", "")
|
||||
if os.path.exists(db_path):
|
||||
return FileResponse(
|
||||
return FileResponse(
|
||||
db_path,
|
||||
media_type='application/octet-stream',
|
||||
filename=f"delphi_backup_{datetime.now().strftime('%Y%m%d_%H%M%S')}.db"
|
||||
filename=f"delphi_backup_{datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')}.db"
|
||||
)
|
||||
|
||||
raise HTTPException(
|
||||
@@ -484,12 +611,20 @@ async def download_backup(
|
||||
|
||||
# User Management Endpoints
|
||||
|
||||
@router.get("/users", response_model=List[UserResponse])
|
||||
class PaginatedUsersResponse(BaseModel):
|
||||
items: List[UserResponse]
|
||||
total: int
|
||||
|
||||
|
||||
@router.get("/users", response_model=Union[List[UserResponse], PaginatedUsersResponse])
|
||||
async def list_users(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
search: Optional[str] = Query(None),
|
||||
active_only: bool = Query(False),
|
||||
sort_by: Optional[str] = Query(None, description="Sort by: username, email, first_name, last_name, created, updated"),
|
||||
sort_dir: Optional[str] = Query("asc", description="Sort direction: asc or desc"),
|
||||
include_total: bool = Query(False, description="When true, returns {items, total} instead of a plain list"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_admin_user)
|
||||
):
|
||||
@@ -498,19 +633,38 @@ async def list_users(
|
||||
query = db.query(User)
|
||||
|
||||
if search:
|
||||
query = query.filter(
|
||||
or_(
|
||||
User.username.ilike(f"%{search}%"),
|
||||
User.email.ilike(f"%{search}%"),
|
||||
User.first_name.ilike(f"%{search}%"),
|
||||
User.last_name.ilike(f"%{search}%")
|
||||
)
|
||||
)
|
||||
# DRY: tokenize and apply case-insensitive multi-field search
|
||||
tokens = build_query_tokens(search)
|
||||
filter_expr = tokenized_ilike_filter(tokens, [
|
||||
User.username,
|
||||
User.email,
|
||||
User.first_name,
|
||||
User.last_name,
|
||||
])
|
||||
if filter_expr is not None:
|
||||
query = query.filter(filter_expr)
|
||||
|
||||
if active_only:
|
||||
query = query.filter(User.is_active == True)
|
||||
|
||||
users = query.offset(skip).limit(limit).all()
|
||||
# Sorting (whitelisted)
|
||||
query = apply_sorting(
|
||||
query,
|
||||
sort_by,
|
||||
sort_dir,
|
||||
allowed={
|
||||
"username": [User.username],
|
||||
"email": [User.email],
|
||||
"first_name": [User.first_name],
|
||||
"last_name": [User.last_name],
|
||||
"created": [User.created_at],
|
||||
"updated": [User.updated_at],
|
||||
},
|
||||
)
|
||||
|
||||
users, total = paginate_with_total(query, skip, limit, include_total)
|
||||
if include_total:
|
||||
return {"items": users, "total": total or 0}
|
||||
return users
|
||||
|
||||
|
||||
@@ -567,8 +721,8 @@ async def create_user(
|
||||
hashed_password=hashed_password,
|
||||
is_admin=user_data.is_admin,
|
||||
is_active=user_data.is_active,
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now()
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
db.add(new_user)
|
||||
@@ -659,7 +813,7 @@ async def update_user(
|
||||
changes[field] = {"from": getattr(user, field), "to": value}
|
||||
setattr(user, field, value)
|
||||
|
||||
user.updated_at = datetime.now()
|
||||
user.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
@@ -702,7 +856,7 @@ async def delete_user(
|
||||
|
||||
# Soft delete by deactivating
|
||||
user.is_active = False
|
||||
user.updated_at = datetime.now()
|
||||
user.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
db.commit()
|
||||
|
||||
@@ -744,7 +898,7 @@ async def reset_user_password(
|
||||
|
||||
# Update password
|
||||
user.hashed_password = get_password_hash(password_data.new_password)
|
||||
user.updated_at = datetime.now()
|
||||
user.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
db.commit()
|
||||
|
||||
@@ -1046,7 +1200,7 @@ async def create_backup(
|
||||
backup_dir.mkdir(exist_ok=True)
|
||||
|
||||
# Generate backup filename
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
|
||||
backup_filename = f"delphi_backup_{timestamp}.db"
|
||||
backup_path = backup_dir / backup_filename
|
||||
|
||||
@@ -1063,7 +1217,7 @@ async def create_backup(
|
||||
"backup_info": {
|
||||
"filename": backup_filename,
|
||||
"size": f"{backup_size / (1024*1024):.1f} MB",
|
||||
"created_at": datetime.now().isoformat(),
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"backup_type": "manual",
|
||||
"status": "completed"
|
||||
}
|
||||
@@ -1118,45 +1272,61 @@ async def get_audit_logs(
|
||||
resource_type: Optional[str] = Query(None),
|
||||
action: Optional[str] = Query(None),
|
||||
hours_back: int = Query(168, ge=1, le=8760), # Default 7 days, max 1 year
|
||||
sort_by: Optional[str] = Query("timestamp", description="Sort by: timestamp, username, action, resource_type"),
|
||||
sort_dir: Optional[str] = Query("desc", description="Sort direction: asc or desc"),
|
||||
include_total: bool = Query(False, description="When true, returns {items, total} instead of a plain list"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_admin_user)
|
||||
):
|
||||
"""Get audit log entries with filtering"""
|
||||
|
||||
cutoff_time = datetime.now() - timedelta(hours=hours_back)
|
||||
|
||||
"""Get audit log entries with filtering, sorting, and pagination"""
|
||||
|
||||
cutoff_time = datetime.now(timezone.utc) - timedelta(hours=hours_back)
|
||||
|
||||
query = db.query(AuditLog).filter(AuditLog.timestamp >= cutoff_time)
|
||||
|
||||
|
||||
if user_id:
|
||||
query = query.filter(AuditLog.user_id == user_id)
|
||||
|
||||
|
||||
if resource_type:
|
||||
query = query.filter(AuditLog.resource_type.ilike(f"%{resource_type}%"))
|
||||
|
||||
|
||||
if action:
|
||||
query = query.filter(AuditLog.action.ilike(f"%{action}%"))
|
||||
|
||||
total_count = query.count()
|
||||
logs = query.order_by(AuditLog.timestamp.desc()).offset(skip).limit(limit).all()
|
||||
|
||||
return {
|
||||
"total": total_count,
|
||||
"logs": [
|
||||
{
|
||||
"id": log.id,
|
||||
"user_id": log.user_id,
|
||||
"username": log.username,
|
||||
"action": log.action,
|
||||
"resource_type": log.resource_type,
|
||||
"resource_id": log.resource_id,
|
||||
"details": log.details,
|
||||
"ip_address": log.ip_address,
|
||||
"user_agent": log.user_agent,
|
||||
"timestamp": log.timestamp.isoformat()
|
||||
}
|
||||
for log in logs
|
||||
]
|
||||
}
|
||||
|
||||
# Sorting (whitelisted)
|
||||
query = apply_sorting(
|
||||
query,
|
||||
sort_by,
|
||||
sort_dir,
|
||||
allowed={
|
||||
"timestamp": [AuditLog.timestamp],
|
||||
"username": [AuditLog.username],
|
||||
"action": [AuditLog.action],
|
||||
"resource_type": [AuditLog.resource_type],
|
||||
},
|
||||
)
|
||||
|
||||
logs, total = paginate_with_total(query, skip, limit, include_total)
|
||||
|
||||
items = [
|
||||
{
|
||||
"id": log.id,
|
||||
"user_id": log.user_id,
|
||||
"username": log.username,
|
||||
"action": log.action,
|
||||
"resource_type": log.resource_type,
|
||||
"resource_id": log.resource_id,
|
||||
"details": log.details,
|
||||
"ip_address": log.ip_address,
|
||||
"user_agent": log.user_agent,
|
||||
"timestamp": log.timestamp.isoformat(),
|
||||
}
|
||||
for log in logs
|
||||
]
|
||||
|
||||
if include_total:
|
||||
return {"items": items, "total": total or 0}
|
||||
return items
|
||||
|
||||
|
||||
@router.get("/audit/login-attempts")
|
||||
@@ -1166,39 +1336,55 @@ async def get_login_attempts(
|
||||
username: Optional[str] = Query(None),
|
||||
failed_only: bool = Query(False),
|
||||
hours_back: int = Query(168, ge=1, le=8760), # Default 7 days
|
||||
sort_by: Optional[str] = Query("timestamp", description="Sort by: timestamp, username, ip_address, success"),
|
||||
sort_dir: Optional[str] = Query("desc", description="Sort direction: asc or desc"),
|
||||
include_total: bool = Query(False, description="When true, returns {items, total} instead of a plain list"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_admin_user)
|
||||
):
|
||||
"""Get login attempts with filtering"""
|
||||
|
||||
cutoff_time = datetime.now() - timedelta(hours=hours_back)
|
||||
|
||||
"""Get login attempts with filtering, sorting, and pagination"""
|
||||
|
||||
cutoff_time = datetime.now(timezone.utc) - timedelta(hours=hours_back)
|
||||
|
||||
query = db.query(LoginAttempt).filter(LoginAttempt.timestamp >= cutoff_time)
|
||||
|
||||
|
||||
if username:
|
||||
query = query.filter(LoginAttempt.username.ilike(f"%{username}%"))
|
||||
|
||||
|
||||
if failed_only:
|
||||
query = query.filter(LoginAttempt.success == 0)
|
||||
|
||||
total_count = query.count()
|
||||
attempts = query.order_by(LoginAttempt.timestamp.desc()).offset(skip).limit(limit).all()
|
||||
|
||||
return {
|
||||
"total": total_count,
|
||||
"attempts": [
|
||||
{
|
||||
"id": attempt.id,
|
||||
"username": attempt.username,
|
||||
"ip_address": attempt.ip_address,
|
||||
"user_agent": attempt.user_agent,
|
||||
"success": bool(attempt.success),
|
||||
"failure_reason": attempt.failure_reason,
|
||||
"timestamp": attempt.timestamp.isoformat()
|
||||
}
|
||||
for attempt in attempts
|
||||
]
|
||||
}
|
||||
|
||||
# Sorting (whitelisted)
|
||||
query = apply_sorting(
|
||||
query,
|
||||
sort_by,
|
||||
sort_dir,
|
||||
allowed={
|
||||
"timestamp": [LoginAttempt.timestamp],
|
||||
"username": [LoginAttempt.username],
|
||||
"ip_address": [LoginAttempt.ip_address],
|
||||
"success": [LoginAttempt.success],
|
||||
},
|
||||
)
|
||||
|
||||
attempts, total = paginate_with_total(query, skip, limit, include_total)
|
||||
|
||||
items = [
|
||||
{
|
||||
"id": attempt.id,
|
||||
"username": attempt.username,
|
||||
"ip_address": attempt.ip_address,
|
||||
"user_agent": attempt.user_agent,
|
||||
"success": bool(attempt.success),
|
||||
"failure_reason": attempt.failure_reason,
|
||||
"timestamp": attempt.timestamp.isoformat(),
|
||||
}
|
||||
for attempt in attempts
|
||||
]
|
||||
|
||||
if include_total:
|
||||
return {"items": items, "total": total or 0}
|
||||
return items
|
||||
|
||||
|
||||
@router.get("/audit/user-activity/{user_id}")
|
||||
@@ -1251,7 +1437,7 @@ async def get_security_alerts(
|
||||
):
|
||||
"""Get security alerts and suspicious activity"""
|
||||
|
||||
cutoff_time = datetime.now() - timedelta(hours=hours_back)
|
||||
cutoff_time = datetime.now(timezone.utc) - timedelta(hours=hours_back)
|
||||
|
||||
# Get failed login attempts
|
||||
failed_logins = db.query(LoginAttempt).filter(
|
||||
@@ -1356,7 +1542,7 @@ async def get_audit_statistics(
|
||||
):
|
||||
"""Get audit statistics and metrics"""
|
||||
|
||||
cutoff_time = datetime.now() - timedelta(days=days_back)
|
||||
cutoff_time = datetime.now(timezone.utc) - timedelta(days=days_back)
|
||||
|
||||
# Total activity counts
|
||||
total_audit_entries = db.query(func.count(AuditLog.id)).filter(
|
||||
|
||||
Reference in New Issue
Block a user