from __future__ import annotations from contextlib import contextmanager from contextvars import ContextVar, Token from dataclasses import dataclass from typing import Iterator from psycopg.conninfo import conninfo_to_dict, make_conninfo from app.core.config import get_pgconn_string, get_timescaledb_pgconn_string, settings @dataclass(frozen=True) class ActiveProjectRouting: project_code: str business_dsn: str timescale_dsn: str | None = None @property def business_database_name(self) -> str: """Return the physical BizDB name selected by metadata routing.""" database_name = conninfo_to_dict(self.business_dsn).get("dbname") if not database_name: raise RuntimeError( f"Business database routing for project {self.project_code!r} " "does not contain a database name" ) return database_name _active_project_routing: ContextVar[ActiveProjectRouting | None] = ContextVar( "active_project_routing", default=None, ) def get_active_project_routing() -> ActiveProjectRouting | None: return _active_project_routing.get() @contextmanager def activate_project_routing( routing: ActiveProjectRouting, ) -> Iterator[ActiveProjectRouting]: token: Token[ActiveProjectRouting | None] = _active_project_routing.set(routing) try: yield routing finally: _active_project_routing.reset(token) def _dsn_for_database(dsn: str, database_name: str) -> str: return make_conninfo(dsn, dbname=database_name) def get_project_database_name(name: str) -> str: """Resolve a logical project code to its routed physical BizDB name.""" routing = get_active_project_routing() if routing is not None and name == routing.project_code: return routing.business_database_name return name def get_project_template_database_name(name: str | None = None) -> str: """Return the network-data template paired with one physical BizDB. Project routing is resolved before the suffix is applied, so a logical project such as ``tjwater_v2`` uses ``tjwater_v2_template``. """ database_name = get_project_database_name(name or settings.DB_NAME) return f"{database_name}_template" def get_schema_template_database_name() -> str: """Return the immutable, data-free template used to create BizDB schemas.""" return settings.WNDB_SCHEMA_TEMPLATE_DB_NAME def get_project_pgconn_string(db_name: str | None = None) -> str: routing = get_active_project_routing() if routing is None: return get_pgconn_string(db_name=db_name) if db_name is None or db_name == routing.project_code: return routing.business_dsn return _dsn_for_database(routing.business_dsn, db_name) def get_project_timescale_pgconn_string(db_name: str | None = None) -> str: routing = get_active_project_routing() if routing is None: return get_timescaledb_pgconn_string(db_name=db_name) if routing.timescale_dsn is None: raise RuntimeError( f"TimescaleDB routing is not configured for project {routing.project_code}" ) # Legacy simulation code used to derive the Timescale database name from # the project code. Project-scoped requests must instead use the complete # iot_data DSN selected by metadata routing. return routing.timescale_dsn