feat(main): main

This commit is contained in:
2026-03-20 15:00:24 -04:00
parent af3076342a
commit c9718c5483
30 changed files with 2513 additions and 559 deletions

156
api/routers/backups.py Normal file
View File

@@ -0,0 +1,156 @@
from datetime import datetime
from django.http import FileResponse
from ninja import File, Router, Schema
from ninja.errors import HttpError
from ninja.files import UploadedFile
from core.models import BackupSchedule
from core.services.backups import (
compute_next_run,
create_backup_now,
get_backup_file,
import_backup_content,
is_backup_due,
list_backups,
)
router = Router(tags=["backups"])
class BackupScheduleSchema(Schema):
enabled: bool
frequency: str
minute: int
hour: int
day_of_week: int
day_of_month: int
retention_count: int
last_run_at: datetime | None = None
next_run_at: datetime | None = None
class BackupScheduleUpdateSchema(Schema):
enabled: bool
frequency: str
minute: int = 0
hour: int = 2
day_of_week: int = 0
day_of_month: int = 1
retention_count: int = 14
class BackupFileSchema(Schema):
filename: str
size_bytes: int
created_at: str
def _require_admin(request):
if not request.user.is_authenticated:
raise HttpError(401, "Authentication required.")
if not request.user.is_staff:
raise HttpError(403, "Admin permissions required.")
@router.get("/settings", response=BackupScheduleSchema)
def get_backup_schedule(request):
_require_admin(request)
schedule = BackupSchedule.get_solo()
return {
"enabled": schedule.enabled,
"frequency": schedule.frequency,
"minute": schedule.minute,
"hour": schedule.hour,
"day_of_week": schedule.day_of_week,
"day_of_month": schedule.day_of_month,
"retention_count": schedule.retention_count,
"last_run_at": schedule.last_run_at,
"next_run_at": compute_next_run(schedule),
}
@router.put("/settings", response=BackupScheduleSchema)
def update_backup_schedule(request, payload: BackupScheduleUpdateSchema):
_require_admin(request)
if payload.frequency not in BackupSchedule.Frequency.values:
raise HttpError(400, "Invalid frequency")
if not 0 <= payload.minute <= 59:
raise HttpError(400, "minute must be 0..59")
if not 0 <= payload.hour <= 23:
raise HttpError(400, "hour must be 0..23")
if not 0 <= payload.day_of_week <= 6:
raise HttpError(400, "day_of_week must be 0..6")
if not 1 <= payload.day_of_month <= 28:
raise HttpError(400, "day_of_month must be 1..28")
if payload.retention_count < 1:
raise HttpError(400, "retention_count must be >= 1")
schedule = BackupSchedule.get_solo()
for attr, value in payload.dict().items():
setattr(schedule, attr, value)
schedule.save()
return {
"enabled": schedule.enabled,
"frequency": schedule.frequency,
"minute": schedule.minute,
"hour": schedule.hour,
"day_of_week": schedule.day_of_week,
"day_of_month": schedule.day_of_month,
"retention_count": schedule.retention_count,
"last_run_at": schedule.last_run_at,
"next_run_at": compute_next_run(schedule),
}
@router.post("/run")
def run_backup_now(request):
_require_admin(request)
path = create_backup_now()
return {"status": "ok", "filename": path.name}
@router.get("/", response=list[BackupFileSchema])
def get_backups(request):
_require_admin(request)
return [x.__dict__ for x in list_backups()]
@router.get("/{filename}/download")
def download_backup(request, filename: str):
_require_admin(request)
try:
target = get_backup_file(filename)
except FileNotFoundError:
raise HttpError(404, "Backup not found")
response = FileResponse(open(target, "rb"), content_type="application/json")
response["Content-Disposition"] = f'attachment; filename="{target.name}"'
return response
@router.post("/import")
def import_backup(request, file: UploadedFile = File(...), mode: str = "append"):
_require_admin(request)
if mode not in {"append", "override"}:
raise HttpError(400, "mode must be append or override")
content = file.read().decode("utf-8")
try:
result = import_backup_content(content, mode=mode)
except ValueError as exc:
raise HttpError(400, str(exc))
return {"status": "ok", **result}
@router.post("/run-if-due")
def run_if_due(request):
_require_admin(request)
schedule = BackupSchedule.get_solo()
if not is_backup_due(schedule):
return {"status": "skipped"}
path = create_backup_now()
return {"status": "ok", "filename": path.name}