first ruggedy draft

This commit is contained in:
2026-06-28 13:48:15 +02:00
parent c916f64fcb
commit 1053f6a2a3
78 changed files with 20905 additions and 2 deletions

View File

@@ -0,0 +1,221 @@
from __future__ import annotations
from datetime import UTC, datetime
from pathlib import Path
from shutil import copy2
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import or_, select
from sqlalchemy.orm import Session
from .. import models
from ..auth import get_optional_local_session, require_household_access
from ..config import get_settings
from ..db import get_session
from ..schemas import BackupResult, HouseholdExport
router = APIRouter(tags=["backup"])
def _row(model: object) -> dict[str, Any]:
table = model.__table__
return {column.name: getattr(model, column.name) for column in table.columns}
def _rows(items: list[object]) -> list[dict[str, Any]]:
return [_row(item) for item in items]
@router.get("/backup/export", response_model=HouseholdExport)
def export_household(
household_id: str,
session: Session = Depends(get_session),
local_session: models.LocalSession | None = Depends(get_optional_local_session),
) -> HouseholdExport:
require_household_access(household_id, local_session)
household = session.get(models.Household, household_id)
if not household:
raise HTTPException(status_code=404, detail="Household not found")
users = list(session.scalars(select(models.User).where(models.User.household_id == household_id)).all())
accounts = list(
session.scalars(select(models.Account).where(models.Account.household_id == household_id)).all()
)
observations = list(
session.scalars(
select(models.RawObservation).where(models.RawObservation.household_id == household_id)
).all()
)
events = list(
session.scalars(
select(models.FinancialEvent).where(models.FinancialEvent.household_id == household_id)
).all()
)
categories = list(
session.scalars(select(models.Category).where(models.Category.household_id == household_id)).all()
)
tags = list(session.scalars(select(models.Tag).where(models.Tag.household_id == household_id)).all())
projects = list(
session.scalars(select(models.Project).where(models.Project.household_id == household_id)).all()
)
recurring = list(
session.scalars(
select(models.RecurringCommitment).where(models.RecurringCommitment.household_id == household_id)
).all()
)
promised = list(
session.scalars(
select(models.PromisedPayment).where(models.PromisedPayment.household_id == household_id)
).all()
)
connections = list(
session.scalars(
select(models.ImportConnection).where(models.ImportConnection.household_id == household_id)
).all()
)
import_runs = list(
session.scalars(select(models.ImportRun).where(models.ImportRun.household_id == household_id)).all()
)
audit_log = list(
session.scalars(select(models.AuditLog).where(models.AuditLog.household_id == household_id)).all()
)
event_ids = [event.id for event in events]
observation_ids = [observation.id for observation in observations]
project_ids = [project.id for project in projects]
event_links = (
list(
session.scalars(
select(models.EventObservationLink).where(
or_(
models.EventObservationLink.event_id.in_(event_ids),
models.EventObservationLink.observation_id.in_(observation_ids),
)
)
).all()
)
if event_ids or observation_ids
else []
)
event_relations = (
list(
session.scalars(
select(models.EventRelation).where(
or_(
models.EventRelation.from_event_id.in_(event_ids),
models.EventRelation.to_event_id.in_(event_ids),
)
)
).all()
)
if event_ids
else []
)
split_lines = (
list(
session.scalars(select(models.SplitLine).where(models.SplitLine.event_id.in_(event_ids))).all()
)
if event_ids
else []
)
event_tags = (
list(session.scalars(select(models.EventTag).where(models.EventTag.event_id.in_(event_ids))).all())
if event_ids
else []
)
project_participants = (
list(
session.scalars(
select(models.ProjectParticipant).where(models.ProjectParticipant.project_id.in_(project_ids))
).all()
)
if project_ids
else []
)
budget_lines = (
list(
session.scalars(
select(models.ProjectBudgetLine).where(models.ProjectBudgetLine.project_id.in_(project_ids))
).all()
)
if project_ids
else []
)
planned_items = (
list(
session.scalars(
select(models.ProjectPlannedItem).where(models.ProjectPlannedItem.project_id.in_(project_ids))
).all()
)
if project_ids
else []
)
planned_item_ids = [item.id for item in planned_items]
payment_lines = (
list(
session.scalars(
select(models.PaymentLine).where(
or_(
models.PaymentLine.event_id.in_(event_ids),
models.PaymentLine.planned_item_id.in_(planned_item_ids),
)
)
).all()
)
if event_ids or planned_item_ids
else []
)
data = {
"users": _rows(users),
"accounts": _rows(accounts),
"raw_observations": _rows(observations),
"financial_events": _rows(events),
"event_observation_links": _rows(event_links),
"event_relations": _rows(event_relations),
"split_lines": _rows(split_lines),
"categories": _rows(categories),
"tags": _rows(tags),
"event_tags": _rows(event_tags),
"projects": _rows(projects),
"project_participants": _rows(project_participants),
"project_budget_lines": _rows(budget_lines),
"project_planned_items": _rows(planned_items),
"payment_lines": _rows(payment_lines),
"recurring_commitments": _rows(recurring),
"promised_payments": _rows(promised),
"import_connections": _rows(connections),
"import_runs": _rows(import_runs),
"audit_log": _rows(audit_log),
}
return HouseholdExport(
household=_row(household),
exported_at=datetime.now(UTC),
counts={key: len(value) for key, value in data.items()},
data=data,
)
@router.post("/backup/sqlite", response_model=BackupResult)
def backup_sqlite() -> BackupResult:
settings = get_settings()
if not settings.database_url.startswith("sqlite:///"):
raise HTTPException(status_code=400, detail="SQLite backup is only available for sqlite:/// databases")
db_path_text = settings.database_url.replace("sqlite:///", "", 1)
if db_path_text in {"", ":memory:"}:
raise HTTPException(status_code=400, detail="In-memory SQLite databases cannot be backed up to a file")
db_path = Path(db_path_text)
if not db_path.is_absolute():
db_path = Path.cwd() / db_path
if not db_path.exists():
raise HTTPException(status_code=404, detail="SQLite database file not found")
backup_dir = db_path.parent / "backups"
backup_dir.mkdir(parents=True, exist_ok=True)
backup_path = backup_dir / f"{db_path.stem}-{datetime.now(UTC).strftime('%Y%m%d-%H%M%S')}.db"
copy2(db_path, backup_path)
return BackupResult(
file_path=str(backup_path),
size_bytes=backup_path.stat().st_size,
created_at=datetime.now(UTC),
)