# Copyright (c) 2026 PowerOn AG # All rights reserved. """ FK label resolution: resolve foreign-key IDs to human-readable labels. Works with the fk_target annotations on Pydantic models (see fkRegistry.py) to auto-build label resolvers for paginated record sets. """ import logging from functools import partial from typing import Any, Callable, Dict, List, Optional logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- # Individual FK label resolvers (db, ids) -> {id: label} # --------------------------------------------------------------------------- def resolveMandateLabels(db, ids: List[str]) -> Dict[str, Optional[str]]: """Resolve mandate IDs to labels. Returns None (not the ID!) for unresolvable entries so the caller can distinguish "resolved" from "missing". """ from modules.datamodels.datamodelUam import Mandate uniqueIds = list(set(ids)) records = db.getRecordset(Mandate, recordFilter={"id": uniqueIds}) or [] found: Dict[str, dict] = {} for rec in records: mid = rec.get("id", "") found[mid] = rec result: Dict[str, Optional[str]] = {} for mid in ids: m = found.get(mid) label = (m.get("label") or m.get("name")) if m else None if not label: logger.debug("resolveMandateLabels: no label for id=%s (found=%s)", mid, m is not None) result[mid] = label or None return result def resolveInstanceLabels(db, ids: List[str]) -> Dict[str, Optional[str]]: """Resolve feature-instance IDs to labels. Returns None for unresolvable.""" from modules.datamodels.datamodelFeatures import FeatureInstance result: Dict[str, Optional[str]] = {} for iid in ids: records = db.getRecordset(FeatureInstance, recordFilter={"id": iid}) if records: label = records[0].get("label") or None result[iid] = label else: logger.debug("resolveInstanceLabels: no label for id=%s", iid) result[iid] = None return result def resolveUserLabels(db, ids: List[str]) -> Dict[str, Optional[str]]: """Resolve user IDs to display names. Returns None for unresolvable.""" from modules.datamodels.datamodelUam import UserInDB as _UserInDB uniqueIds = list(set(ids)) users = db.getRecordset( _UserInDB, recordFilter={"id": uniqueIds}, ) result: Dict[str, Optional[str]] = {} found: Dict[str, dict] = {} for u in (users or []): uid = u.get("id", "") found[uid] = u for uid in ids: u = found.get(uid) if u: result[uid] = u.get("displayName") or u.get("username") or u.get("email") or None else: result[uid] = None return result def resolveRoleLabels(db, ids: List[str]) -> Dict[str, Optional[str]]: """Resolve Role.id to roleLabel. Returns None for unresolvable.""" if not ids: return {} from modules.datamodels.datamodelRbac import Role as _Role recs = db.getRecordset( _Role, recordFilter={"id": list(set(ids))}, ) or [] out: Dict[str, Optional[str]] = {i: None for i in ids} for r in recs: rid = r.get("id") if rid: out[rid] = r.get("roleLabel") or None for rid in ids: if out.get(rid) is None: logger.debug("resolveRoleLabels: no label for id=%s", rid) return out # --------------------------------------------------------------------------- # Resolver registry # --------------------------------------------------------------------------- _BUILTIN_FK_RESOLVERS: Dict[str, Callable] = { "Mandate": resolveMandateLabels, "FeatureInstance": resolveInstanceLabels, "UserInDB": resolveUserLabels, "Role": resolveRoleLabels, } def buildLabelResolversFromModel( modelClass: type, db=None, ) -> Dict[str, Callable[[List[str]], Dict[str, str]]]: """ Auto-build labelResolvers dict from ``json_schema_extra.fk_target`` on a Pydantic model. Maps field names to resolver functions when the target table has a registered builtin resolver and ``fk_target.labelField`` is set (non-None). When ``db`` is provided, the returned resolvers are pre-bound with partial(resolver, db) so they can be called as resolver(ids). """ resolvers: Dict[str, Callable[[List[str]], Dict[str, str]]] = {} for name, fieldInfo in modelClass.model_fields.items(): extra = fieldInfo.json_schema_extra if not extra or not isinstance(extra, dict): continue tgt = extra.get("fk_target") if not isinstance(tgt, dict): continue if tgt.get("labelField") is None: continue fkModel = tgt.get("table") if fkModel and fkModel in _BUILTIN_FK_RESOLVERS: fn = _BUILTIN_FK_RESOLVERS[fkModel] resolvers[name] = partial(fn, db) if db else fn return resolvers def enrichRowsWithFkLabels( rows: List[Dict[str, Any]], modelClass: type = None, *, db=None, labelResolvers: Optional[Dict[str, Callable[[List[str]], Dict[str, Optional[str]]]]] = None, extraResolvers: Optional[Dict[str, Callable[[List[str]], Dict[str, Optional[str]]]]] = None, ) -> List[Dict[str, Any]]: """Add ``{field}Label`` columns to each row for every FK field that has a registered resolver. ``modelClass`` — if provided, resolvers are auto-built from ``fk_target`` annotations on the Pydantic model (via ``buildLabelResolversFromModel``). Requires ``db`` to be passed. ``labelResolvers`` — explicit resolver map that overrides auto-built ones. Each resolver has signature ``(ids: List[str]) -> Dict[str, Optional[str]]``. ``extraResolvers`` — merged on top of auto-built / explicit resolvers. Use for ad-hoc fields that are not FK-annotated on the model (e.g. ``createdByUserId`` on billing transactions). If a label cannot be resolved the ``{field}Label`` value is ``None`` (never the raw ID — that would reintroduce the silent-truncation bug). """ resolvers: Dict[str, Callable] = {} if modelClass is not None and labelResolvers is None: resolvers = buildLabelResolversFromModel(modelClass, db) elif labelResolvers is not None: resolvers = dict(labelResolvers) if extraResolvers: resolvers.update(extraResolvers) if not resolvers or not rows: return rows for field, resolver in resolvers.items(): ids = list({str(r.get(field)) for r in rows if r.get(field)}) if not ids: continue try: labelMap = resolver(ids) except Exception as e: logger.error("enrichRowsWithFkLabels: resolver for '%s' raised: %s", field, e) labelMap = {} labelKey = f"{field}Label" for r in rows: fkVal = r.get(field) if fkVal: r[labelKey] = labelMap.get(str(fkVal)) else: r[labelKey] = None return rows