feat: add backup/restore/version, fix leave duplicate, add Settings page to nav
- Add /api/admin/backup/list, /create, /download, /restore endpoints - Add /api/version endpoint (git commit info) - Add Settings page to sidebar nav + App.jsx routes - Fix lateness stats: add total_late_count/total_late_minutes keys - Fix leave aggregation: no more duplicate text in status - Settings page now shows backup/restore UI + version info - Fix leave upload: overwrite existing records on re-upload
This commit is contained in:
+123
-5
@@ -1,10 +1,11 @@
|
||||
import os
|
||||
import secret
|
||||
from fastapi import FastAPI, Depends, HTTPException, Query, UploadFile, File, Form, Request
|
||||
from fastapi import FastAPI, Depends, HTTPException, Query, UploadFile, File, Form, Request, Body
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse, RedirectResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import date, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Optional, List
|
||||
import json
|
||||
|
||||
@@ -135,13 +136,19 @@ def enrich_attendance_with_leave(records, db):
|
||||
if not emp_leaves:
|
||||
continue
|
||||
|
||||
# Aggregate all leaves by type (sum hours when multiple records of same type)
|
||||
leave_by_type = {}
|
||||
for lv in emp_leaves:
|
||||
t = (lv.leave_type or "").upper()
|
||||
h_val = float(lv.hours or 0)
|
||||
leave_by_type[t] = leave_by_type.get(t, 0.0) + h_val
|
||||
|
||||
leave_text = " + ".join(f"{t}{h}h" for t, h in sorted(leave_by_type.items()))
|
||||
# Build display text from aggregated values (no duplicates)
|
||||
leave_parts = []
|
||||
for t, h in sorted(leave_by_type.items()):
|
||||
if h > 0:
|
||||
leave_parts.append(f"{t}{h:.1f}h" if h != int(h) else f"{t}{int(h)}h")
|
||||
leave_text = " + ".join(leave_parts)
|
||||
|
||||
original_code = (r.status_code or "").lower()
|
||||
if original_code == "missing":
|
||||
@@ -758,7 +765,16 @@ async def get_lateness_stats(current_user: User = Depends(get_current_user)):
|
||||
totals["ot"] += status["ot_minutes"]
|
||||
|
||||
stats = sorted(employee_stats.values(), key=lambda x: x["late_minutes"], reverse=True)
|
||||
return {"stats": stats, **totals}
|
||||
return {
|
||||
"stats": stats,
|
||||
"total_late_count": sum(s["late_count"] for s in employee_stats.values()),
|
||||
"total_late_minutes": totals["late"],
|
||||
"total_early_count": sum(s["early_count"] for s in employee_stats.values()),
|
||||
"total_early_minutes": totals["early"],
|
||||
"total_ot_count": sum(s["ot_count"] for s in employee_stats.values()),
|
||||
"total_ot_minutes": totals["ot"],
|
||||
"total_missing_count": totals["missing"],
|
||||
}
|
||||
|
||||
@app.get("/api/attendance/lateness/records")
|
||||
async def get_lateness_records(
|
||||
@@ -1764,8 +1780,8 @@ async def upload_leaves(
|
||||
errors.append(f"Row {i+2}: {r['employee_name']} {d} {r['leave_type']} was edited at {existing.last_edited_at.isoformat()} - skipped")
|
||||
skipped += 1
|
||||
continue
|
||||
# Not edited - safe to update
|
||||
existing.hours = r["hours"]
|
||||
# Not edited - safe to update (or multiple identical records exist - merge by adding hours)
|
||||
existing.hours = r["hours"] # overwrite with latest uploaded value
|
||||
existing.reason = r["reason"]
|
||||
existing.approved_by = r["approved_by"]
|
||||
existing.source = "csv_upload"
|
||||
@@ -1796,6 +1812,108 @@ async def upload_leaves(
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Backup / Restore + Version
|
||||
# =============================================================================
|
||||
|
||||
import shutil, hashlib
|
||||
from datetime import datetime as _dt2
|
||||
|
||||
BACKUP_DIR = Path("/app/data/backups")
|
||||
BACKUP_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _backup_path(name):
|
||||
ts = _dt2.now().strftime("%Y%m%d_%H%M%S")
|
||||
return BACKUP_DIR / f"aars_backup_{ts}_{name}.db"
|
||||
|
||||
@app.get("/api/admin/backup/list")
|
||||
async def list_backups(
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""List all backup files."""
|
||||
files = []
|
||||
for p in sorted(BACKUP_DIR.glob("aars_backup_*.db"), reverse=True):
|
||||
files.append({
|
||||
"filename": p.name,
|
||||
"size": p.stat().st_size,
|
||||
"created": _dt2.fromtimestamp(p.stat().st_mtime).isoformat(),
|
||||
})
|
||||
return files
|
||||
|
||||
@app.post("/api/admin/backup/create")
|
||||
async def create_backup(
|
||||
note: str = "",
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Create a manual backup."""
|
||||
src = Path(DB_PATH)
|
||||
if not src.exists():
|
||||
raise HTTPException(status_code=500, detail="Database file not found")
|
||||
dst = _backup_path("manual")
|
||||
shutil.copy2(src, dst)
|
||||
return {"saved": dst.name, "size": dst.stat().st_size, "note": note}
|
||||
|
||||
@app.get("/api/admin/backup/download/{filename}")
|
||||
async def download_backup(
|
||||
filename: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Download a backup file."""
|
||||
fp = BACKUP_DIR / filename
|
||||
if not fp.exists() or ".." in filename:
|
||||
raise HTTPException(status_code=404, detail="Backup not found")
|
||||
from fastapi.responses import FileResponse
|
||||
return FileResponse(fp, media_type="application/x-sqlite3", filename=filename)
|
||||
|
||||
@app.post("/api/admin/backup/restore")
|
||||
async def restore_backup(
|
||||
filename: str = Body(...),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Restore from backup. Creates an auto-backup of current state first."""
|
||||
fp = BACKUP_DIR / filename
|
||||
if not fp.exists() or ".." in filename:
|
||||
raise HTTPException(status_code=404, detail="Backup not found")
|
||||
# Auto-backup current state
|
||||
src = Path(DB_PATH)
|
||||
if src.exists():
|
||||
auto_dst = _backup_path("auto_pre_restore")
|
||||
shutil.copy2(src, auto_dst)
|
||||
# Restore
|
||||
shutil.copy2(fp, src)
|
||||
return {"restored": filename, "auto_backup": auto_dst.name if src.exists() else None}
|
||||
|
||||
@app.get("/api/version")
|
||||
async def get_version(
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Get app version info."""
|
||||
import subprocess
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "log", "-1", "--format=%H|%cd", "--date=iso"],
|
||||
cwd="/app",
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
if result.returncode == 0:
|
||||
parts = result.stdout.strip().split("|")
|
||||
commit = parts[0]
|
||||
date = parts[1] if len(parts) > 1 else ""
|
||||
else:
|
||||
commit = "unknown"
|
||||
date = ""
|
||||
except Exception:
|
||||
commit = "unknown"
|
||||
date = ""
|
||||
return {
|
||||
"version": "1.1.0",
|
||||
"commit": commit,
|
||||
"date": date,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/{section}")
|
||||
async def list_records(
|
||||
section: str,
|
||||
|
||||
Reference in New Issue
Block a user