FastAPI backend (Python 3.11, MSSQL ODBC for SambaPOS, Azure DI OCR),
kitchen_db on central PG. React/TS/Vite frontend with navy sidebar layout.
Backend: auth.py (APP_SLUG=kitchen, SimpleNamespace — archive routes use
.kitchen_id/.is_admin without modification), main.py (51 migrations, scheduler,
internal router for KDS bookings feed), api/internal.py, full archive API
(31 routers: invoices, recipes, menus, sambapos, resos, newbook, disputes,
purchase_orders, etc.), models, migrations, OCR pipeline.
kitchen_id pinned to 1 (B1 — single hotel).
Frontend: AuthGate (app=kitchen, token shim for archive compat — B5b pending),
Layout (navy sidebar, 6 sections, Lucide icons, teal --app-primary),
App.tsx (Outlet pattern, UploadApp outside Layout), index.css (full :root block).
strict: false — archive components have type issues; build clean.
Note: 45 archive components call fetch('/api/...') without /kitchen/ prefix
(B5b). Runtime 404s; deferred until after initial testing.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
374 lines
12 KiB
Python
374 lines
12 KiB
Python
"""
|
|
Backup management API endpoints.
|
|
"""
|
|
import os
|
|
import tempfile
|
|
import shutil
|
|
from datetime import datetime
|
|
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, UploadFile, File
|
|
from fastapi.responses import FileResponse
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy import select
|
|
from pydantic import BaseModel
|
|
from typing import Optional
|
|
|
|
from database import get_db
|
|
from models.user import User
|
|
from models.settings import KitchenSettings
|
|
from models.backup import BackupHistory
|
|
from auth import get_current_user, require_cap
|
|
from services.backup_service import BackupService
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
# ============ Pydantic Models ============
|
|
|
|
class BackupSettingsResponse(BaseModel):
|
|
backup_frequency: str | None
|
|
backup_retention_count: int
|
|
backup_destination: str | None
|
|
backup_time: str | None
|
|
backup_nextcloud_path: str | None
|
|
backup_smb_host: str | None
|
|
backup_smb_share: str | None
|
|
backup_smb_username: str | None
|
|
backup_smb_password_set: bool
|
|
backup_smb_path: str | None
|
|
backup_last_run_at: str | None
|
|
backup_last_status: str | None
|
|
backup_last_error: str | None
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
|
|
class BackupSettingsUpdate(BaseModel):
|
|
backup_frequency: str | None = None # "daily", "weekly", "manual"
|
|
backup_retention_count: int | None = None
|
|
backup_destination: str | None = None # "local", "nextcloud", "smb"
|
|
backup_time: str | None = None # "HH:MM"
|
|
backup_nextcloud_path: str | None = None
|
|
backup_smb_host: str | None = None
|
|
backup_smb_share: str | None = None
|
|
backup_smb_username: str | None = None
|
|
backup_smb_password: str | None = None
|
|
backup_smb_path: str | None = None
|
|
|
|
|
|
class BackupHistoryResponse(BaseModel):
|
|
id: int
|
|
backup_type: str
|
|
destination: str
|
|
status: str
|
|
filename: str
|
|
file_size_bytes: int | None
|
|
invoice_count: int | None
|
|
file_count: int | None
|
|
started_at: str
|
|
completed_at: str | None
|
|
error_message: str | None
|
|
triggered_by_username: str | None
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
|
|
class BackupCreateResponse(BaseModel):
|
|
message: str
|
|
status: str
|
|
backup_id: int | None = None
|
|
|
|
|
|
class BackupRestoreResponse(BaseModel):
|
|
status: str
|
|
message: str
|
|
|
|
|
|
# ============ Settings Endpoints ============
|
|
|
|
@router.get("/settings", response_model=BackupSettingsResponse)
|
|
async def get_backup_settings(
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Get backup settings"""
|
|
result = await db.execute(
|
|
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
|
)
|
|
settings = result.scalar_one_or_none()
|
|
|
|
if not settings:
|
|
return BackupSettingsResponse(
|
|
backup_frequency="manual",
|
|
backup_retention_count=7,
|
|
backup_destination="local",
|
|
backup_time="03:00",
|
|
backup_nextcloud_path="/Backups",
|
|
backup_smb_host=None,
|
|
backup_smb_share=None,
|
|
backup_smb_username=None,
|
|
backup_smb_password_set=False,
|
|
backup_smb_path="/backups",
|
|
backup_last_run_at=None,
|
|
backup_last_status=None,
|
|
backup_last_error=None
|
|
)
|
|
|
|
return BackupSettingsResponse(
|
|
backup_frequency=settings.backup_frequency,
|
|
backup_retention_count=settings.backup_retention_count,
|
|
backup_destination=settings.backup_destination,
|
|
backup_time=settings.backup_time,
|
|
backup_nextcloud_path=settings.backup_nextcloud_path,
|
|
backup_smb_host=settings.backup_smb_host,
|
|
backup_smb_share=settings.backup_smb_share,
|
|
backup_smb_username=settings.backup_smb_username,
|
|
backup_smb_password_set=bool(settings.backup_smb_password),
|
|
backup_smb_path=settings.backup_smb_path,
|
|
backup_last_run_at=settings.backup_last_run_at.isoformat() if settings.backup_last_run_at else None,
|
|
backup_last_status=settings.backup_last_status,
|
|
backup_last_error=settings.backup_last_error
|
|
)
|
|
|
|
|
|
@router.patch("/settings", response_model=BackupSettingsResponse)
|
|
async def update_backup_settings(
|
|
update: BackupSettingsUpdate,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Update backup settings"""
|
|
if not current_user.is_admin:
|
|
raise HTTPException(status_code=403, detail="Admin only")
|
|
|
|
result = await db.execute(
|
|
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
|
)
|
|
settings = result.scalar_one_or_none()
|
|
|
|
if not settings:
|
|
settings = KitchenSettings(kitchen_id=current_user.kitchen_id)
|
|
db.add(settings)
|
|
|
|
update_data = update.model_dump(exclude_unset=True)
|
|
for field, value in update_data.items():
|
|
if field == 'backup_smb_password' and value:
|
|
setattr(settings, field, value)
|
|
elif value is not None:
|
|
setattr(settings, field, value)
|
|
|
|
await db.commit()
|
|
await db.refresh(settings)
|
|
|
|
return BackupSettingsResponse(
|
|
backup_frequency=settings.backup_frequency,
|
|
backup_retention_count=settings.backup_retention_count,
|
|
backup_destination=settings.backup_destination,
|
|
backup_time=settings.backup_time,
|
|
backup_nextcloud_path=settings.backup_nextcloud_path,
|
|
backup_smb_host=settings.backup_smb_host,
|
|
backup_smb_share=settings.backup_smb_share,
|
|
backup_smb_username=settings.backup_smb_username,
|
|
backup_smb_password_set=bool(settings.backup_smb_password),
|
|
backup_smb_path=settings.backup_smb_path,
|
|
backup_last_run_at=settings.backup_last_run_at.isoformat() if settings.backup_last_run_at else None,
|
|
backup_last_status=settings.backup_last_status,
|
|
backup_last_error=settings.backup_last_error
|
|
)
|
|
|
|
|
|
# ============ Backup Operations ============
|
|
|
|
async def _run_backup_task(db: AsyncSession, kitchen_id: int, user_id: int):
|
|
"""Background task to run backup"""
|
|
backup_service = BackupService(db, kitchen_id)
|
|
await backup_service.create_backup(user_id=user_id, backup_type="manual")
|
|
|
|
|
|
@router.post("/create", response_model=BackupCreateResponse)
|
|
async def create_backup(
|
|
background_tasks: BackgroundTasks,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Trigger a manual backup"""
|
|
if not current_user.is_admin:
|
|
raise HTTPException(status_code=403, detail="Admin only")
|
|
|
|
# Run backup synchronously for now to get immediate feedback
|
|
backup_service = BackupService(db, current_user.kitchen_id)
|
|
success, message, backup = await backup_service.create_backup(
|
|
user_id=current_user.id,
|
|
backup_type="manual"
|
|
)
|
|
|
|
if not success:
|
|
raise HTTPException(status_code=500, detail=message)
|
|
|
|
return BackupCreateResponse(
|
|
message=message,
|
|
status="success",
|
|
backup_id=backup.id if backup else None
|
|
)
|
|
|
|
|
|
@router.get("/history", response_model=list[BackupHistoryResponse])
|
|
async def list_backups(
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""List backup history"""
|
|
backup_service = BackupService(db, current_user.kitchen_id)
|
|
backups = await backup_service.list_backups()
|
|
|
|
return [
|
|
BackupHistoryResponse(
|
|
id=b.id,
|
|
backup_type=b.backup_type,
|
|
destination=b.destination,
|
|
status=b.status,
|
|
filename=b.filename,
|
|
file_size_bytes=b.file_size_bytes,
|
|
invoice_count=b.invoice_count,
|
|
file_count=b.file_count,
|
|
started_at=b.started_at.isoformat(),
|
|
completed_at=b.completed_at.isoformat() if b.completed_at else None,
|
|
error_message=b.error_message,
|
|
triggered_by_username=b.triggered_by_user.name if b.triggered_by_user else None
|
|
)
|
|
for b in backups
|
|
]
|
|
|
|
|
|
@router.post("/{backup_id}/restore", response_model=BackupRestoreResponse)
|
|
async def restore_backup(
|
|
backup_id: int,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Restore from a backup"""
|
|
if not current_user.is_admin:
|
|
raise HTTPException(status_code=403, detail="Admin only")
|
|
|
|
backup_service = BackupService(db, current_user.kitchen_id)
|
|
success, message = await backup_service.restore_backup(backup_id)
|
|
|
|
if not success:
|
|
raise HTTPException(status_code=400, detail=message)
|
|
|
|
return BackupRestoreResponse(status="success", message=message)
|
|
|
|
|
|
@router.delete("/{backup_id}")
|
|
async def delete_backup(
|
|
backup_id: int,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Delete a backup"""
|
|
if not current_user.is_admin:
|
|
raise HTTPException(status_code=403, detail="Admin only")
|
|
|
|
backup_service = BackupService(db, current_user.kitchen_id)
|
|
success, message = await backup_service.delete_backup(backup_id)
|
|
|
|
if not success:
|
|
raise HTTPException(status_code=400, detail=message)
|
|
|
|
return {"message": message}
|
|
|
|
|
|
@router.get("/{backup_id}/download")
|
|
async def download_backup(
|
|
backup_id: int,
|
|
token: str,
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Download a backup file. Auth via token query param for direct browser downloads."""
|
|
from auth import get_current_user, require_cap_from_token
|
|
|
|
current_user = await get_current_user_from_token(token, db)
|
|
if not current_user:
|
|
raise HTTPException(status_code=401, detail="Invalid token")
|
|
if not current_user.is_admin:
|
|
raise HTTPException(status_code=403, detail="Admin only")
|
|
|
|
backup_service = BackupService(db, current_user.kitchen_id)
|
|
backup = await backup_service.get_backup(backup_id)
|
|
|
|
if not backup:
|
|
raise HTTPException(status_code=404, detail="Backup not found")
|
|
|
|
if backup.status != "success":
|
|
raise HTTPException(status_code=400, detail="Cannot download failed backup")
|
|
|
|
# Handle local backups
|
|
if not backup.file_path.startswith("nextcloud:"):
|
|
if not os.path.exists(backup.file_path):
|
|
raise HTTPException(status_code=404, detail="Backup file not found on disk")
|
|
|
|
return FileResponse(
|
|
path=backup.file_path,
|
|
filename=backup.filename,
|
|
media_type="application/zip"
|
|
)
|
|
|
|
# Handle Nextcloud backups - download to temp file
|
|
from services.nextcloud_service import NextcloudService
|
|
|
|
result = await db.execute(
|
|
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
|
)
|
|
settings = result.scalar_one_or_none()
|
|
|
|
if not settings or not settings.nextcloud_host:
|
|
raise HTTPException(status_code=400, detail="Nextcloud not configured")
|
|
|
|
nc_path = backup.file_path.replace("nextcloud:", "")
|
|
nc = NextcloudService(
|
|
settings.nextcloud_host,
|
|
settings.nextcloud_username,
|
|
settings.nextcloud_password,
|
|
""
|
|
)
|
|
success, content = await nc.download_file(nc_path)
|
|
await nc.close()
|
|
|
|
if not success:
|
|
raise HTTPException(status_code=500, detail=f"Failed to download from Nextcloud: {content}")
|
|
|
|
# Write to temp file and return
|
|
temp_dir = tempfile.mkdtemp()
|
|
temp_path = os.path.join(temp_dir, backup.filename)
|
|
with open(temp_path, 'wb') as f:
|
|
f.write(content)
|
|
|
|
return FileResponse(
|
|
path=temp_path,
|
|
filename=backup.filename,
|
|
media_type="application/zip",
|
|
background=lambda: shutil.rmtree(temp_dir, ignore_errors=True)
|
|
)
|
|
|
|
|
|
@router.post("/upload", response_model=BackupRestoreResponse)
|
|
async def upload_and_restore_backup(
|
|
file: UploadFile = File(...),
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Upload a backup file and restore from it"""
|
|
if not current_user.is_admin:
|
|
raise HTTPException(status_code=403, detail="Admin only")
|
|
|
|
if not file.filename.endswith('.zip'):
|
|
raise HTTPException(status_code=400, detail="Only ZIP files are accepted")
|
|
|
|
backup_service = BackupService(db, current_user.kitchen_id)
|
|
success, message = await backup_service.restore_from_upload(file)
|
|
|
|
if not success:
|
|
raise HTTPException(status_code=400, detail=message)
|
|
|
|
return BackupRestoreResponse(status="success", message=message)
|