Add startup re-import queue to apply importer fixes to signed rounds

The loan-aware NAV fix (0.2.47) only affects new imports; an already-signed
quarter keeps its old holdings until re-imported. Add a boot-time queue: an
operator stages an eNAV plus a small JSON descriptor under
data/pending_reimports and the app re-imports it on startup via the same code
path as the admin Import screen, then removes the processed files. Guarded so a
queue problem never blocks boot. Bumps the package to 0.2.48.

Used to refresh SATS Credit Fund LP Q4 2025 to its full NAV including the
Fold Holdings loan.
This commit is contained in:
Jonathan Kirkwood
2026-09-02 09:35:54 -05:00
parent 12a6e33bd8
commit 34842cd994
6 changed files with 163 additions and 3 deletions
+84
View File
@@ -0,0 +1,84 @@
"""One-shot re-import queue, drained on startup.
An operator can drop an eNAV workbook plus a small JSON descriptor into
``<data>/pending_reimports/`` and the app re-imports it on the next boot using the
same code path as the admin Import screen. This exists so a fix to the importer
(e.g. reading the loan schedule) can be applied to an already-signed round without a
manual re-upload. Each workbook is processed exactly once and then deleted, because
its JSON descriptor may carry the workbook's open password.
Descriptor ``<name>.json`` fields:
entity_id (required) the fund/SPV to import into
password (optional) open password for an encrypted workbook
replace (optional) wipe the entity's holdings first (default false)
"""
import io
import json
import logging
import os
from pathlib import Path
from sqlmodel import Session, select
from ten31portal import config
log = logging.getLogger("ten31portal.pending_reimports")
def _queue_dir() -> Path:
return Path(os.path.dirname(config.DB_PATH) or ".") / "pending_reimports"
def process_pending_reimports() -> None:
"""Import every queued eNAV, then delete the ones that succeed. Never raises."""
base = _queue_dir()
if not base.is_dir():
return
# Imported lazily so importing this module never pulls in the router graph.
from fastapi import UploadFile
from ten31portal.database import engine
from ten31portal.models import EntityType, User, UserRole
from ten31portal.routers.import_router import import_schedule
for xlsx in sorted(base.glob("*.xlsx")):
meta = xlsx.with_suffix(".json")
try:
cfg = json.loads(meta.read_text()) if meta.exists() else {}
entity_id = cfg.get("entity_id")
if entity_id is None:
log.warning("pending re-import %s has no entity_id; skipping", xlsx.name)
continue
data = xlsx.read_bytes()
with Session(engine) as session:
# Attribute the audit entry to a signer-capable staff account.
actor = (
session.exec(select(User).where(User.role == UserRole.approver)).first()
or session.exec(select(User)).first()
)
upload = UploadFile(io.BytesIO(data), filename=xlsx.name)
result = import_schedule(
file=upload,
as_of=None,
entity_id=int(entity_id),
create_entity_type=EntityType.fund,
create_vintage_year=None,
commit=True,
replace_existing=bool(cfg.get("replace", False)),
password=cfg.get("password"),
user=actor,
session=session,
)
log.info("pending re-import %s applied: %s", xlsx.name, result)
except Exception:
# Leave a failed item queued so a later boot can retry once the cause is fixed.
log.exception("pending re-import %s failed; leaving queued", xlsx.name)
continue
# Success: remove both files. The JSON holds a workbook password, so it must not linger.
for path in (xlsx, meta):
try:
path.unlink()
except FileNotFoundError:
pass