d582f15681
- 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.
195 lines
7.3 KiB
Python
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
|