Files

202 lines
7.4 KiB
Python

import csv
import io
import json
import uuid
import zipfile
from collections.abc import Sequence
from datetime import UTC, datetime, timedelta
from sqlalchemy.orm import Session
from app.audit.models import AuditEvent
from app.budgeting.models import BudgetPeriod, ExpectedIncome, FundingMovement, PlanAssignment
from app.config import get_settings
from app.errors import conflict, not_found
from app.exports.models import DataExport, DeletionLedger
from app.imports.blob import get_blob_store, new_object_key
from app.imports.crypto import decrypt_file, encrypt_file
from app.imports.models import StatementDocument
from app.ledger.models import Account, Category, Operation
from app.spaces.models import FinancialSpace
def request_export(db: Session, *, space_id: uuid.UUID, user_id: uuid.UUID) -> DataExport:
job = DataExport(space_id=space_id, requested_by_user_id=user_id, status="queued")
db.add(job)
db.flush()
return job
def get_export(db: Session, *, space_id: uuid.UUID, export_id: uuid.UUID) -> DataExport:
job = db.query(DataExport).filter_by(id=export_id, space_id=space_id).one_or_none()
if job is None:
raise not_found("EXPORT_NOT_FOUND", "Data export not found")
return job
def _json_rows(rows: Sequence[object], fields: tuple[str, ...]) -> list[dict]:
return [{field: getattr(row, field) for field in fields} for row in rows]
def _csv_bytes(rows: list[dict]) -> bytes:
output = io.StringIO()
if rows:
writer = csv.DictWriter(output, fieldnames=list(rows[0]))
writer.writeheader()
writer.writerows(rows)
return output.getvalue().encode()
def build_export(db: Session, *, space_id: uuid.UUID, export_id: uuid.UUID) -> None:
job = get_export(db, space_id=space_id, export_id=export_id)
if job.status == "ready":
return
job.status = "processing"
datasets = {
"accounts": _json_rows(
db.query(Account).filter_by(space_id=space_id).all(),
("id", "name", "kind", "currency", "opening_amount_kopecks", "opened_on", "status"),
),
"operations": _json_rows(
db.query(Operation).filter_by(space_id=space_id).all(),
(
"id",
"type",
"status",
"amount_kopecks",
"account_id",
"counter_account_id",
"category_id",
"occurred_on",
"posted_on",
"description",
),
),
"categories": _json_rows(
db.query(Category).filter_by(space_id=space_id).all(),
("id", "kind", "system_key", "name_override", "custom_name", "archived"),
),
"budget_periods": _json_rows(
db.query(BudgetPeriod).filter_by(space_id=space_id).all(),
("id", "year", "month", "status"),
),
"plan_assignments": _json_rows(
db.query(PlanAssignment).filter_by(space_id=space_id).all(),
("id", "period_id", "category_id", "planned_kopecks"),
),
"funding": _json_rows(
db.query(FundingMovement).filter_by(space_id=space_id).all(),
("id", "period_id", "category_id", "kind", "amount_kopecks"),
),
"expected_incomes": _json_rows(
db.query(ExpectedIncome).filter_by(space_id=space_id).all(),
("id", "period_id", "expected_on", "amount_kopecks", "source_name"),
),
"audit": _json_rows(
db.query(AuditEvent).filter_by(space_id=space_id).all(),
("id", "event_type", "created_at", "detail"),
),
}
raw = io.BytesIO()
with zipfile.ZipFile(raw, "w", compression=zipfile.ZIP_DEFLATED) as archive:
for name, rows in datasets.items():
archive.writestr(f"{name}.csv", _csv_bytes(rows))
archive.writestr(
"data.json", json.dumps(datasets, default=str, ensure_ascii=False, indent=2)
)
encrypted = encrypt_file(raw.getvalue())
key = new_object_key()
get_blob_store().put(key, encrypted.ciphertext)
job.object_key = key
job.dek_wrapped = encrypted.dek_wrapped
job.dek_wrap_nonce = encrypted.wrap_nonce
job.content_nonce = encrypted.content_nonce
job.expires_at = datetime.now(UTC) + timedelta(hours=1)
job.status = "ready"
def consume_export(db: Session, *, space_id: uuid.UUID, export_id: uuid.UUID) -> bytes:
job = get_export(db, space_id=space_id, export_id=export_id)
now = datetime.now(UTC)
if (
job.status != "ready"
or job.consumed_at is not None
or not job.expires_at
or job.expires_at <= now
):
raise conflict("EXPORT_UNAVAILABLE", "Export is not ready, expired, or already downloaded")
if not all((job.object_key, job.dek_wrapped, job.dek_wrap_nonce, job.content_nonce)):
raise conflict("EXPORT_UNAVAILABLE", "Export key material is unavailable")
assert job.object_key is not None
assert job.dek_wrapped is not None
assert job.dek_wrap_nonce is not None
assert job.content_nonce is not None
raw = decrypt_file(
ciphertext=get_blob_store().get(job.object_key),
content_nonce=job.content_nonce,
dek_wrapped=job.dek_wrapped,
wrap_nonce=job.dek_wrap_nonce,
)
job.consumed_at = now
job.status = "consumed"
job.dek_wrapped = None
job.dek_wrap_nonce = None
job.content_nonce = None
return raw
def schedule_deletion(db: Session, *, space: FinancialSpace) -> datetime:
if space.status == "blocked" and space.deletion_scheduled_at:
return space.deletion_scheduled_at
when = datetime.now(UTC) + timedelta(days=get_settings().deletion_window_days)
space.status = "blocked"
space.deletion_scheduled_at = when
marker = db.query(DeletionLedger).filter_by(space_id=space.id).one_or_none()
if marker is None:
db.add(DeletionLedger(space_id=space.id, scheduled_at=when))
else:
marker.scheduled_at = when
marker.completed_at = None
return when
def cancel_deletion(db: Session, *, space: FinancialSpace) -> None:
if space.status != "blocked" or not space.deletion_scheduled_at:
raise conflict("SPACE_DELETION_NOT_SCHEDULED", "Space deletion is not scheduled")
space.status = "active"
space.deletion_scheduled_at = None
marker = db.query(DeletionLedger).filter_by(space_id=space.id).one_or_none()
if marker:
db.delete(marker)
def purge_due_spaces(db: Session) -> int:
now = datetime.now(UTC)
spaces = (
db.query(FinancialSpace)
.filter(
FinancialSpace.status == "blocked",
FinancialSpace.deletion_scheduled_at.is_not(None),
FinancialSpace.deletion_scheduled_at <= now,
)
.all()
)
store = get_blob_store()
purged = 0
for space in spaces:
from app.db.session import set_space_context
set_space_context(db, space.id)
for document in db.query(StatementDocument).filter_by(space_id=space.id).all():
store.delete(document.object_key)
document.dek_wrapped = None
for export in db.query(DataExport).filter_by(space_id=space.id).all():
if export.object_key:
store.delete(export.object_key)
export.dek_wrapped = None
marker = db.query(DeletionLedger).filter_by(space_id=space.id).one()
marker.completed_at = now
db.delete(space)
purged += 1
return purged