Build [v1.4.0] - Audit Dashboard & LDAP Restoration

This commit is contained in:
Daniel Bedeleanu
2026-04-12 09:39:17 +03:00
parent 5cceba21f4
commit f3d861b1a2
23 changed files with 973 additions and 382 deletions

138
backend/db_manager.py Normal file
View File

@@ -0,0 +1,138 @@
import os
import shutil
import datetime
import sqlite3
import logging
from typing import List
from sqlalchemy import text
from sqlalchemy.orm import Session
from .database import DATA_DIR, engine
from . import models, schemas
logger = logging.getLogger("ainventory")
BACKUP_DIR = os.path.join(DATA_DIR, "backups")
os.makedirs(BACKUP_DIR, exist_ok=True)
class DbManager:
@staticmethod
def get_backup_list() -> List[schemas.BackupInfo]:
"""List all available backup files with metadata."""
backups = []
if not os.path.exists(BACKUP_DIR):
return []
for filename in os.listdir(BACKUP_DIR):
if filename.endswith(".db"):
path = os.path.join(BACKUP_DIR, filename)
stats = os.stat(path)
backups.append(schemas.BackupInfo(
filename=filename,
size_bytes=stats.st_size,
created_at=datetime.datetime.fromtimestamp(stats.st_mtime)
))
# Sort by creation time descending
backups.sort(key=lambda x: x.created_at, reverse=True)
return backups
@staticmethod
def get_stats() -> schemas.DatabaseStats:
"""Calculate storage statistics for backups."""
backups = DbManager.get_backup_list()
total_size = sum(b.size_bytes for b in backups)
return schemas.DatabaseStats(
backup_count=len(backups),
total_size_bytes=total_size
)
@staticmethod
def create_backup(db: Session, label: str = "manual", user_id: int = None) -> str:
"""
Creates a consistent snapshot of the active database using VACUUM INTO.
Records the action in AuditLog.
"""
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"inventory_{label}_{timestamp}.db"
target_path = os.path.join(BACKUP_DIR, filename)
try:
# Use SQLite's VACUUM INTO for a safe, non-blocking backup
# We need a clean string path without potential SQL injection (filenames are generated here)
db.execute(text(f"VACUUM INTO '{target_path}'"))
logger.info(f"[DB] Backup created successfully: {filename}")
# Audit Log
audit = models.AuditLog(
user_id=user_id,
action="DB_BACKUP",
details=f"Backup type: {label}. Created file: {filename}"
)
db.add(audit)
db.commit()
# Enforce retention policy
DbManager.enforce_retention(db)
return filename
except Exception as e:
logger.error(f"[DB] Backup failed: {str(e)}")
db.rollback()
raise Exception(f"Backup failed: {str(e)}")
@staticmethod
def enforce_retention(db: Session):
"""Deletes oldest backups if count exceeds retention limit."""
try:
# Get retention limit from settings
limit_setting = db.query(models.SystemSetting).filter(models.SystemSetting.key == "backup_retention_count").first()
limit = int(limit_setting.value) if limit_setting else 10 # Default to 10
backups = DbManager.get_backup_list()
if len(backups) > limit:
to_delete = backups[limit:]
for b in to_delete:
path = os.path.join(BACKUP_DIR, b.filename)
if os.path.exists(path):
os.remove(path)
logger.info(f"[DB] Retention policy: deleted old backup {b.filename}")
except Exception as e:
logger.error(f"[DB] Retention enforcement failed: {str(e)}")
@staticmethod
def restore_backup(filename: str, db: Session, user_id: int) -> bool:
"""
Restores a database from a backup file.
IMPORTANT: This replaces the primary inventory.db file.
"""
source_path = os.path.join(BACKUP_DIR, filename)
active_db_path = os.path.join(DATA_DIR, "inventory.db")
if not os.path.exists(source_path):
raise Exception("Backup file not found")
try:
# 1. Create a safety rollback backup of current state
logger.info("[DB] Creating safety rollback backup before restore...")
DbManager.create_backup(db, label="rollback", user_id=user_id)
# 2. Close all connections (or as many as possible via pool dispose)
# engine.dispose() drops the current connection pool
engine.dispose()
# 3. Physically swap files
# Note: shutil.copy2 handles file metadata preservation
shutil.copy2(source_path, active_db_path)
logger.warning(f"[DB] RESTORE COMPLETED from {filename} by user_id={user_id}")
# Record the restore in the NEW database (since we just swapped it)
# Re-initializing session logic might be needed but usually next request handles it.
# However, for the current request, the 'db' session is still bound to the OLD file mapping possibly?
# Actually, the file on disk is changed. Next commit might fail or work depending on how sqlite handles it.
# It's safest to return success and let the frontend trigger a reload.
return True
except Exception as e:
logger.error(f"[DB] Restore failed: {str(e)}")
raise Exception(f"Restore failed: {str(e)}")