Initial kitchen scaffold — Phase 1 kitchen port (build-verified 2026-07-11)
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>
This commit is contained in:
commit
8d688b459d
10003 changed files with 1928395 additions and 0 deletions
374
backend/api/backup.py
Normal file
374
backend/api/backup.py
Normal file
|
|
@ -0,0 +1,374 @@
|
|||
"""
|
||||
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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue