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:
IT Dog
2026-07-21 02:34:30 +08:00
parent 6f90d60b13
commit d5d9d644a0
5 changed files with 1543 additions and 18 deletions
+123 -5
View File
@@ -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,