Files
finance/backend/app/exports/service.py
T
k1nq d582f15681 feat: add tests for import polling, login page, money utilities, and query keys
- Implemented tests for import polling functionality, ensuring correct behavior for active and terminal import statuses.
- Created tests for the login page, validating user interactions and error handling.
- Added unit tests for money utility functions, covering parsing, formatting, and summation of monetary values.
- Developed tests for query keys, ensuring proper scoping and invalidation roots.

chore: setup testing environment and configuration

- Added setup file for testing with Vitest and Testing Library.
- Configured TypeScript and Vite for the frontend project.
- Created Docker and Nginx configurations for local development and production environments.
- Established a PostgreSQL initialization script for setting up application roles.

docs: add README and environment configuration for infra

- Included README for the infra directory, detailing local development setup and service descriptions.
- Created an example environment configuration file for local development with placeholders for sensitive information.
2026-09-14 09:26:33 +05:00

195 lines
7.3 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