"""Per-session settings (reserves, FX rates) + global header-mapping rules.""" from __future__ import annotations from datetime import date from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from sqlalchemy.orm import Session as OrmSession from ...core.column_map import FIELD_ORDER, normalize_header from ...db import models from ..deps import db_dep, ensure_editable, get_session_or_404, to_dict router = APIRouter(prefix="/api/sessions", tags=["settings"]) rules_router = APIRouter(prefix="/api/mapping-rules", tags=["mapping"]) meta_router = APIRouter(prefix="/api", tags=["meta"]) @meta_router.get("/definitions") def definitions() -> dict: """What every dashboard figure means: formula + source columns/types, per metric key. Content lives in core/definitions.py, next to the engine code it documents, so the UI's (i) buttons cannot drift from what the engine actually computes.""" from ...core.definitions import DEFINITIONS return DEFINITIONS class RuleIn(BaseModel): header: str # raw or normalized source header field: str # canonical internal field @rules_router.get("") def list_rules(db: OrmSession = Depends(db_dep)) -> dict: rows = db.query(models.MappingRule).order_by(models.MappingRule.normalized_header).all() return { "fields": FIELD_ORDER, "rules": [{"id": r.id, "normalized_header": r.normalized_header, "field": r.field} for r in rows], } @rules_router.put("") def upsert_rules(items: list[RuleIn], db: OrmSession = Depends(db_dep)) -> dict: """Save admin header-alias rules; applied to every future parse (no code change needed).""" for it in items: if it.field not in FIELD_ORDER: raise HTTPException(400, f"Unknown field '{it.field}'.") norm = normalize_header(it.header) if not norm: raise HTTPException(400, "Empty header.") row = db.query(models.MappingRule).filter( models.MappingRule.normalized_header == norm).first() if row is None: db.add(models.MappingRule(normalized_header=norm, field=it.field)) else: row.field = it.field db.commit() return list_rules(db) @rules_router.delete("/{rule_id}") def delete_rule(rule_id: int, db: OrmSession = Depends(db_dep)) -> dict: db.query(models.MappingRule).filter(models.MappingRule.id == rule_id).delete() db.commit() return list_rules(db) class ReserveIn(BaseModel): marketplace: str account_type: str amount: float = 0.0 class FxIn(BaseModel): marketplace: str currency: str = "USD" rate: float = 1.0 source: str = "manual" rate_date: date | None = None @router.get("/{session_id}/reserves") def get_reserves(session_id: int, db: OrmSession = Depends(db_dep)) -> list[dict]: get_session_or_404(session_id, db) rows = db.query(models.Reserve).filter(models.Reserve.session_id == session_id).all() return [to_dict(r, ["marketplace", "account_type", "amount"]) for r in rows] @router.put("/{session_id}/reserves") def put_reserves(session_id: int, items: list[ReserveIn], db: OrmSession = Depends(db_dep)) -> list[dict]: ensure_editable(get_session_or_404(session_id, db)) db.query(models.Reserve).filter(models.Reserve.session_id == session_id).delete() for it in items: db.add(models.Reserve(session_id=session_id, marketplace=it.marketplace, account_type=it.account_type, amount=it.amount)) db.commit() return get_reserves(session_id, db) @router.get("/{session_id}/fx") def get_fx(session_id: int, db: OrmSession = Depends(db_dep)) -> list[dict]: get_session_or_404(session_id, db) rows = db.query(models.FxRate).filter(models.FxRate.session_id == session_id).all() return [to_dict(r, ["marketplace", "currency", "rate", "source", "rate_date"]) for r in rows] @router.put("/{session_id}/fx") def put_fx(session_id: int, items: list[FxIn], db: OrmSession = Depends(db_dep)) -> list[dict]: s = get_session_or_404(session_id, db) ensure_editable(s) # Upsert per marketplace — NOT delete-all-then-insert. This used to wipe every rate not # named in the body, so a partial PUT silently removed the other marketplaces' rates and # the close fell back to the hardcoded Jan-26 defaults without a word. existing = {r.marketplace: r for r in db.query(models.FxRate).filter( models.FxRate.session_id == session_id)} for it in items: row = existing.get(it.marketplace) if row is None: row = models.FxRate(session_id=session_id, marketplace=it.marketplace) db.add(row) # Changing a rate withdraws the confirmation that was given for the old one. if row.rate != it.rate or (row.currency or "") != it.currency: row.confirmed_by = "" row.confirmed_at = None row.confirmed_month = "" row.currency, row.rate, row.source, row.rate_date = ( it.currency, it.rate, it.source, it.rate_date) db.commit() if s.status in ("processed", "blocked", "completed"): from ...services.controls_run import run_and_persist run_and_persist(db, session_id) return get_fx(session_id, db)