feat: prepare ARR for controlled public deployment

This commit is contained in:
Wyndham ARR
2026-07-29 16:38:05 +08:00
commit a701de9f0e
271 changed files with 48472 additions and 0 deletions

View File

@@ -0,0 +1,23 @@
# Channel analytics database provider
`channel_analytics` implements BI Schema 1.2 and paginated channel detail directly from the current ARR daily facts. It does not read a monthly workbook, `dashboard.json`, `report_versions`, or a frozen report manifest.
Read model:
- available months and source pins: `finance.current_daily_versions`;
- channels/order/counts: `finance.daily_channel_metrics` for those current versions;
- aggregates and detail: `finance.v_active_daily_facts` (`current + retained` only);
- projection identity: deterministic SHA-256 over the selected business date, current daily version ID and source artifact SHA-256.
Dashboard rules:
- sold rooms = `sum(NO_OF_ROOMS)`;
- total price = `sum(TOTAL PRICE)` without multiplying again;
- room nights = `sum(NIGHTS * NO_OF_ROOMS)`;
- room types sort by sold rooms descending, then name;
- channels follow first current business date/order, then channel name;
- the channel × room-type matrix and company totals use the same current facts.
The detail response contains only arrival, departure, nights, room count, company, rate code, room type, real price, and total price. It never returns guest name, confirmation number, displayed room number, comments, traces, products, source coordinates, credentials, or object keys.
`DASHBOARD_DATABASE_URL` may use a dedicated read-only role; otherwise it falls back to `ARR_DATABASE_URL`. Transactions are `REPEATABLE READ READ ONLY`, and this validation build rejects any database other than `booking_test`.

View File

@@ -0,0 +1,5 @@
"""Privacy-minimized database analytics for channel BI and detail views."""
from channel_analytics.contracts import DASHBOARD_VERSION
__all__ = ["DASHBOARD_VERSION"]

View File

@@ -0,0 +1,223 @@
"""Pure aggregation rules for the frozen channel BI contract 1.2."""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from decimal import Decimal, InvalidOperation
from typing import Any, Dict, Iterable, List, Optional, Tuple
DASHBOARD_VERSION = "1.2"
DETAIL_VERSION = "1.0"
UNLABELED_ROOM_TYPE = "未标注房型"
MONTH_RE = re.compile(r"^(\d{4})-(\d{2})$")
SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
class AnalyticsError(RuntimeError):
def __init__(self, code: str, safe_message: str):
super().__init__(safe_message)
self.code = code
self.safe_message = safe_message
@dataclass(frozen=True)
class AggregatedRoomFact:
worksheet: str
room_type: str
rooms_sold: int
total_price: Decimal
room_nights: int
reservation_rows: int
@dataclass
class _RoomMetrics:
rooms_sold: int = 0
total_price: Decimal = Decimal(0)
room_nights: int = 0
reservation_rows: int = 0
def add(self, fact: AggregatedRoomFact) -> None:
self.rooms_sold += fact.rooms_sold
self.total_price += fact.total_price
self.room_nights += fact.room_nights
self.reservation_rows += fact.reservation_rows
@dataclass
class _ScopeMetrics:
rooms: Dict[str, _RoomMetrics] = field(default_factory=dict)
def add(self, fact: AggregatedRoomFact) -> None:
room_type = fact.room_type.strip() or UNLABELED_ROOM_TYPE
self.rooms.setdefault(room_type, _RoomMetrics()).add(fact)
@property
def rooms_sold(self) -> int:
return sum(item.rooms_sold for item in self.rooms.values())
@property
def total_price(self) -> Decimal:
return sum((item.total_price for item in self.rooms.values()), Decimal(0))
@property
def room_nights(self) -> int:
return sum(item.room_nights for item in self.rooms.values())
@property
def reservation_rows(self) -> int:
return sum(item.reservation_rows for item in self.rooms.values())
def validate_month_key(month_key: str) -> Tuple[int, int]:
match = MONTH_RE.fullmatch(month_key)
if not match:
raise AnalyticsError("ANALYTICS_MONTH_INVALID", "month must use YYYY-MM")
year, month = int(match.group(1)), int(match.group(2))
if year < 1900 or month < 1 or month > 12:
raise AnalyticsError("ANALYTICS_MONTH_INVALID", "month is invalid")
return year, month
def _integer(value: Any, field_name: str) -> int:
if isinstance(value, bool):
raise AnalyticsError("ANALYTICS_SOURCE_INVALID", f"{field_name} is invalid")
try:
number = Decimal(str(value))
except (InvalidOperation, TypeError, ValueError):
raise AnalyticsError("ANALYTICS_SOURCE_INVALID", f"{field_name} is invalid") from None
if not number.is_finite() or number < 0 or number != number.to_integral_value():
raise AnalyticsError("ANALYTICS_SOURCE_INVALID", f"{field_name} is invalid")
return int(number)
def _decimal(value: Any, field_name: str) -> Decimal:
try:
number = Decimal(str(value))
except (InvalidOperation, TypeError, ValueError):
raise AnalyticsError("ANALYTICS_SOURCE_INVALID", f"{field_name} is invalid") from None
if not number.is_finite() or number < 0:
raise AnalyticsError("ANALYTICS_SOURCE_INVALID", f"{field_name} is invalid")
return number
def json_number(value: Decimal) -> int | float:
return int(value) if value == value.to_integral_value() else float(value)
def normalized_fact(fact: AggregatedRoomFact) -> AggregatedRoomFact:
if not isinstance(fact.worksheet, str) or not fact.worksheet.strip():
raise AnalyticsError("ANALYTICS_SOURCE_INVALID", "worksheet is invalid")
if not isinstance(fact.room_type, str):
raise AnalyticsError("ANALYTICS_SOURCE_INVALID", "room type is invalid")
normalized = AggregatedRoomFact(
worksheet=fact.worksheet,
room_type=fact.room_type.strip() or UNLABELED_ROOM_TYPE,
rooms_sold=_integer(fact.rooms_sold, "rooms_sold"),
total_price=_decimal(fact.total_price, "total_price"),
room_nights=_integer(fact.room_nights, "room_nights"),
reservation_rows=_integer(fact.reservation_rows, "reservation_rows"),
)
if normalized.reservation_rows == 0 and (
normalized.rooms_sold or normalized.room_nights or normalized.total_price
):
raise AnalyticsError(
"ANALYTICS_SOURCE_INVALID",
"aggregate row count does not match its values",
)
return normalized
def _room_rows(scope: _ScopeMetrics) -> List[Dict[str, Any]]:
denominator = scope.rooms_sold
return [
{
"room_type": room_type,
"rooms_sold": metrics.rooms_sold,
"rooms_share": metrics.rooms_sold / denominator if denominator else 0,
"total_price": json_number(metrics.total_price),
"room_nights": metrics.room_nights,
"reservation_rows": metrics.reservation_rows,
}
for room_type, metrics in sorted(
scope.rooms.items(),
key=lambda item: (-item[1].rooms_sold, item[0]),
)
]
def _totals(scope: _ScopeMetrics, channel_count: Optional[int] = None) -> Dict[str, Any]:
payload: Dict[str, Any] = {
"rooms_sold": scope.rooms_sold,
"total_price": json_number(scope.total_price),
"room_nights": scope.room_nights,
"reservation_rows": scope.reservation_rows,
"room_type_count": len(scope.rooms),
}
if channel_count is not None:
payload["channel_count"] = channel_count
return payload
def build_dashboard(
month_key: str,
updated_at: str,
max_arrival_date: Optional[str],
source_monthly_sha256: str,
channel_names: Iterable[str],
facts: Iterable[AggregatedRoomFact],
) -> Dict[str, Any]:
validate_month_key(month_key)
names = tuple(channel_names)
if (
not names
or len(set(names)) != len(names)
or any(not isinstance(name, str) or not name.strip() for name in names)
or SHA256_RE.fullmatch(source_monthly_sha256) is None
):
raise AnalyticsError("ANALYTICS_SOURCE_INVALID", "dashboard manifest is invalid")
channels = {name: _ScopeMetrics() for name in names}
overall = _ScopeMetrics()
for raw_fact in facts:
fact = normalized_fact(raw_fact)
if fact.worksheet not in channels:
raise AnalyticsError(
"ANALYTICS_SOURCE_INVALID",
"aggregate contains a channel outside the report manifest",
)
channels[fact.worksheet].add(fact)
overall.add(fact)
channel_payload = [
{
"worksheet": name,
"totals": _totals(channels[name]),
"room_types": _room_rows(channels[name]),
}
for name in names
]
if (
overall.rooms_sold != sum(item["totals"]["rooms_sold"] for item in channel_payload)
or overall.room_nights != sum(item["totals"]["room_nights"] for item in channel_payload)
or overall.reservation_rows
!= sum(item["totals"]["reservation_rows"] for item in channel_payload)
or overall.total_price
!= sum(
(Decimal(str(item["totals"]["total_price"])) for item in channel_payload),
Decimal(0),
)
):
raise AnalyticsError("ANALYTICS_SOURCE_INVALID", "dashboard totals do not balance")
return {
"version": DASHBOARD_VERSION,
"month_key": month_key,
"updated_at": updated_at,
"max_arrival_date": max_arrival_date,
"source_monthly_sha256": source_monthly_sha256,
"overall": {
"totals": _totals(overall, channel_count=len(names)),
"room_types": _room_rows(overall),
},
"channels": channel_payload,
}

View File

@@ -0,0 +1,584 @@
"""Read-only PostgreSQL provider for live channel analytics and details."""
from __future__ import annotations
import calendar
import hashlib
import os
from dataclasses import dataclass
from datetime import date, datetime
from decimal import Decimal, InvalidOperation
from typing import Any, Callable, Dict, List, Optional, Tuple
from channel_analytics.contracts import (
DETAIL_VERSION,
UNLABELED_ROOM_TYPE,
AggregatedRoomFact,
AnalyticsError,
build_dashboard,
json_number,
validate_month_key,
)
TARGET_DATABASE = "booking_test"
DATABASE_ENV = "DASHBOARD_DATABASE_URL"
FALLBACK_DATABASE_ENV = "ARR_DATABASE_URL"
CURRENT_REPORT_SQL = """
SELECT
max(current_version.business_date) AS as_of_date,
max(current_version.activated_at) AS updated_at,
count(*) AS daily_version_count
FROM finance.current_daily_versions AS current_version
WHERE current_version.business_date BETWEEN %s AND %s
""".strip()
REPORT_MANIFEST_SQL = """
SELECT
metrics.channel_key,
sum(metrics.row_count) AS row_count
FROM finance.current_daily_versions AS current_version
JOIN finance.daily_channel_metrics AS metrics
ON metrics.daily_version_id = current_version.daily_version_id
WHERE current_version.business_date BETWEEN %s AND %s
GROUP BY metrics.channel_key
ORDER BY
min(current_version.business_date),
min(metrics.channel_order),
metrics.channel_key
""".strip()
REPORT_PIN_COUNT_SQL = """
SELECT
current_version.business_date,
current_version.daily_version_id,
source.sha256
FROM finance.current_daily_versions AS current_version
JOIN finance.daily_versions AS version
ON version.id = current_version.daily_version_id
JOIN ingestion.artifacts AS source
ON source.id = version.source_artifact_id
WHERE current_version.business_date BETWEEN %s AND %s
ORDER BY current_version.business_date, current_version.daily_version_id
""".strip()
ROOM_AGGREGATES_SQL = """
SELECT
facts.channel_key,
coalesce(nullif(btrim(facts.room_category_label), ''), %s) AS room_type,
count(*) AS reservation_rows,
sum(facts.no_of_rooms) AS rooms_sold,
sum(facts.no_of_rooms * facts.nights) AS room_nights,
sum(facts.total_price) AS total_price
FROM finance.v_active_daily_facts AS facts
WHERE facts.business_date BETWEEN %s AND %s
GROUP BY facts.channel_key, room_type
ORDER BY facts.channel_key, room_type
""".strip()
CHANNEL_DETAIL_SQL = """
SELECT
facts.arrival,
facts.departure,
facts.nights,
facts.no_of_rooms,
facts.company_name,
facts.rate_code,
coalesce(nullif(btrim(facts.room_category_label), ''), %s) AS room_type,
facts.real_price,
facts.total_price
FROM finance.v_active_daily_facts AS facts
WHERE facts.business_date BETWEEN %s AND %s
AND facts.channel_key = %s
ORDER BY facts.arrival, facts.id
LIMIT %s OFFSET %s
""".strip()
MONTHS_SQL = """
SELECT DISTINCT date_trunc('month', business_date)::date AS period_start
FROM finance.current_daily_versions
ORDER BY period_start DESC
""".strip()
class AnalyticsRepositoryError(AnalyticsError):
pass
class AnalyticsMonthNotFound(AnalyticsRepositoryError):
def __init__(self) -> None:
super().__init__(
"ANALYTICS_MONTH_NOT_FOUND",
"current monthly database projection was not found",
)
@dataclass(frozen=True)
class DatabaseConfig:
dsn: str
@classmethod
def from_environment(cls) -> "DatabaseConfig":
dsn = os.environ.get(DATABASE_ENV, "").strip()
if not dsn:
dsn = os.environ.get(FALLBACK_DATABASE_ENV, "").strip()
if not dsn:
raise AnalyticsRepositoryError(
"ANALYTICS_DATABASE_CONFIG_MISSING",
f"{DATABASE_ENV} or {FALLBACK_DATABASE_ENV} is required",
)
return cls(dsn)
@dataclass(frozen=True)
class ChannelManifestItem:
worksheet: str
worksheet_order: int
row_count: int
@dataclass(frozen=True)
class ReportContext:
period_start: date
period_end: date
as_of_date: date
activated_at: datetime
source_projection_sha256: str
daily_version_count: int
channels: Tuple[ChannelManifestItem, ...]
@property
def month_key(self) -> str:
return f"{self.period_start.year:04d}-{self.period_start.month:02d}"
@property
def filename(self) -> str:
return f"database-projection-{self.month_key}.json"
def _default_connect(dsn: str) -> Any:
try:
import psycopg # type: ignore[import-not-found]
except ImportError:
raise AnalyticsRepositoryError(
"ANALYTICS_DATABASE_DRIVER_UNAVAILABLE",
"PostgreSQL driver is unavailable",
) from None
try:
return psycopg.connect(dsn, autocommit=False)
except Exception:
raise AnalyticsRepositoryError(
"ANALYTICS_DATABASE_UNAVAILABLE",
"database connection failed",
) from None
def _integer(value: Any, field_name: str) -> int:
if isinstance(value, bool):
raise AnalyticsRepositoryError(
"ANALYTICS_DATABASE_RESULT_INVALID",
f"database {field_name} is invalid",
)
try:
number = Decimal(str(value))
except (InvalidOperation, TypeError, ValueError):
raise AnalyticsRepositoryError(
"ANALYTICS_DATABASE_RESULT_INVALID",
f"database {field_name} is invalid",
) from None
if (
not number.is_finite()
or number < 0
or number != number.to_integral_value()
):
raise AnalyticsRepositoryError(
"ANALYTICS_DATABASE_RESULT_INVALID",
f"database {field_name} is invalid",
)
return int(number)
def _decimal(value: Any, field_name: str) -> Decimal:
try:
number = Decimal(str(value))
except (InvalidOperation, TypeError, ValueError):
raise AnalyticsRepositoryError(
"ANALYTICS_DATABASE_RESULT_INVALID",
f"database {field_name} is invalid",
) from None
if not number.is_finite() or number < 0:
raise AnalyticsRepositoryError(
"ANALYTICS_DATABASE_RESULT_INVALID",
f"database {field_name} is invalid",
)
return number
def _date(value: Any, field_name: str) -> date:
if isinstance(value, datetime):
value = value.date()
if isinstance(value, date):
return value
try:
return date.fromisoformat(str(value))
except ValueError:
raise AnalyticsRepositoryError(
"ANALYTICS_DATABASE_RESULT_INVALID",
f"database {field_name} is invalid",
) from None
def _datetime(value: Any) -> datetime:
if isinstance(value, datetime):
return value
try:
return datetime.fromisoformat(str(value).replace("Z", "+00:00"))
except ValueError:
raise AnalyticsRepositoryError(
"ANALYTICS_DATABASE_RESULT_INVALID",
"database activated_at is invalid",
) from None
def _projection_sha256(rows: List[Tuple[Any, ...]]) -> str:
identity = "\n".join(
f"{_date(row[0], 'business_date').isoformat()}:{_integer(row[1], 'daily_version_id')}:{str(row[2]).lower()}"
for row in rows
)
return hashlib.sha256(identity.encode("utf-8")).hexdigest()
class PostgresAnalyticsRepository:
def __init__(
self,
config: DatabaseConfig,
connect: Optional[Callable[[str], Any]] = None,
) -> None:
self._config = config
self._connect = connect or _default_connect
def _open(self) -> Any:
try:
return self._connect(self._config.dsn)
except AnalyticsRepositoryError:
raise
except Exception:
raise AnalyticsRepositoryError(
"ANALYTICS_DATABASE_UNAVAILABLE",
"database connection failed",
) from None
@staticmethod
def _begin(cursor: Any) -> None:
cursor.execute(
"SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY"
)
cursor.execute("SET LOCAL statement_timeout = '10s'")
cursor.execute("SET LOCAL lock_timeout = '3s'")
cursor.execute(
"SELECT current_database(), current_setting('transaction_read_only')"
)
row = cursor.fetchone()
if not row or row[0] != TARGET_DATABASE or row[1] != "on":
raise AnalyticsRepositoryError(
"ANALYTICS_DATABASE_TARGET_INVALID",
"database target or read-only mode is invalid",
)
@staticmethod
def _period(month_key: str) -> Tuple[date, date]:
year, month = validate_month_key(month_key)
return (
date(year, month, 1),
date(year, month, calendar.monthrange(year, month)[1]),
)
@staticmethod
def _load_context(
cursor: Any,
period_start: date,
period_end: date,
) -> ReportContext:
cursor.execute(CURRENT_REPORT_SQL, (period_start, period_end))
row = cursor.fetchone()
if not row or row[0] is None or _integer(row[2], "daily_version_count") == 0:
raise AnalyticsMonthNotFound()
as_of_date = _date(row[0], "as_of_date")
activated_at = _datetime(row[1])
daily_version_count = _integer(row[2], "daily_version_count")
cursor.execute(REPORT_PIN_COUNT_SQL, (period_start, period_end))
identity_rows = list(cursor.fetchall())
if len(identity_rows) != daily_version_count:
raise AnalyticsRepositoryError(
"ANALYTICS_DATABASE_RESULT_INVALID",
"current daily version identity is incomplete",
)
for identity_row in identity_rows:
digest = str(identity_row[2] or "").lower()
if len(digest) != 64 or any(
character not in "0123456789abcdef" for character in digest
):
raise AnalyticsRepositoryError(
"ANALYTICS_DATABASE_RESULT_INVALID",
"current source artifact identity is invalid",
)
cursor.execute(REPORT_MANIFEST_SQL, (period_start, period_end))
manifest_rows = list(cursor.fetchall())
channels = tuple(
ChannelManifestItem(
worksheet=str(item[0] or ""),
worksheet_order=order,
row_count=_integer(item[1], "row_count"),
)
for order, item in enumerate(manifest_rows, 1)
)
if (
not channels
or len({item.worksheet for item in channels}) != len(channels)
or any(not item.worksheet.strip() for item in channels)
):
raise AnalyticsRepositoryError(
"ANALYTICS_CHANNEL_MANIFEST_INVALID",
"monthly channel projection is invalid",
)
return ReportContext(
period_start=period_start,
period_end=period_end,
as_of_date=as_of_date,
activated_at=activated_at,
source_projection_sha256=_projection_sha256(identity_rows),
daily_version_count=daily_version_count,
channels=channels,
)
@staticmethod
def _aggregates(
cursor: Any,
context: ReportContext,
) -> Tuple[AggregatedRoomFact, ...]:
cursor.execute(
ROOM_AGGREGATES_SQL,
(
UNLABELED_ROOM_TYPE,
context.period_start,
context.period_end,
),
)
facts = tuple(
AggregatedRoomFact(
worksheet=str(row[0] or ""),
room_type=str(row[1] or UNLABELED_ROOM_TYPE),
reservation_rows=_integer(row[2], "reservation_rows"),
rooms_sold=_integer(row[3], "rooms_sold"),
room_nights=_integer(row[4], "room_nights"),
total_price=_decimal(row[5], "total_price"),
)
for row in cursor.fetchall()
)
actual_counts: Dict[str, int] = {}
for fact in facts:
actual_counts[fact.worksheet] = (
actual_counts.get(fact.worksheet, 0)
+ fact.reservation_rows
)
expected_names = {item.worksheet for item in context.channels}
if (
not set(actual_counts).issubset(expected_names)
or any(
item.row_count != actual_counts.get(item.worksheet, 0)
for item in context.channels
)
):
raise AnalyticsRepositoryError(
"ANALYTICS_CHANNEL_MANIFEST_INVALID",
"monthly channel projection does not match active facts",
)
return facts
def read_dashboard(self, month_key: str) -> Dict[str, Any]:
period_start, period_end = self._period(month_key)
connection = self._open()
try:
with connection.transaction():
with connection.cursor() as cursor:
self._begin(cursor)
context = self._load_context(
cursor,
period_start,
period_end,
)
facts = self._aggregates(cursor, context)
return build_dashboard(
month_key=context.month_key,
updated_at=context.activated_at.isoformat(),
max_arrival_date=context.as_of_date.isoformat(),
source_monthly_sha256=context.source_projection_sha256,
channel_names=(item.worksheet for item in context.channels),
facts=facts,
)
except AnalyticsError:
raise
except Exception:
raise AnalyticsRepositoryError(
"ANALYTICS_DATABASE_QUERY_FAILED",
"database analytics query failed",
) from None
finally:
connection.close()
def read_channel_detail(
self,
month_key: str,
worksheet: str,
limit: int = 100,
offset: int = 0,
) -> Dict[str, Any]:
if (
not isinstance(worksheet, str)
or not worksheet.strip()
or worksheet != worksheet.strip()
or isinstance(limit, bool)
or isinstance(offset, bool)
or limit < 1
or limit > 500
or offset < 0
):
raise AnalyticsRepositoryError(
"ANALYTICS_DETAIL_REQUEST_INVALID",
"channel detail request is invalid",
)
period_start, period_end = self._period(month_key)
connection = self._open()
try:
with connection.transaction():
with connection.cursor() as cursor:
self._begin(cursor)
context = self._load_context(
cursor,
period_start,
period_end,
)
manifest = {
item.worksheet: item for item in context.channels
}
if worksheet not in manifest:
raise AnalyticsRepositoryError(
"ANALYTICS_CHANNEL_NOT_FOUND",
"channel is not present in the current month",
)
cursor.execute(
CHANNEL_DETAIL_SQL,
(
UNLABELED_ROOM_TYPE,
context.period_start,
context.period_end,
worksheet,
limit,
offset,
),
)
rows = [
{
"arrival": _date(row[0], "arrival").isoformat(),
"departure": _date(
row[1],
"departure",
).isoformat(),
"nights": _integer(row[2], "nights"),
"no_of_rooms": _integer(
row[3],
"no_of_rooms",
),
"company_name": str(row[4] or ""),
"rate_code": str(row[5] or ""),
"room_type": str(
row[6] or UNLABELED_ROOM_TYPE
),
"real_price": json_number(
_decimal(row[7], "real_price")
),
"total_price": json_number(
_decimal(row[8], "total_price")
),
}
for row in cursor.fetchall()
]
return {
"version": DETAIL_VERSION,
"month_key": context.month_key,
"worksheet": worksheet,
"updated_at": context.activated_at.isoformat(),
"max_arrival_date": context.as_of_date.isoformat(),
"source_monthly_sha256": (
context.source_projection_sha256
),
"total_rows": manifest[worksheet].row_count,
"limit": limit,
"offset": offset,
"rows": rows,
}
except AnalyticsError:
raise
except Exception:
raise AnalyticsRepositoryError(
"ANALYTICS_DATABASE_QUERY_FAILED",
"database channel detail query failed",
) from None
finally:
connection.close()
def list_months(self) -> List[Dict[str, Any]]:
connection = self._open()
try:
with connection.transaction():
with connection.cursor() as cursor:
self._begin(cursor)
cursor.execute(MONTHS_SQL)
period_rows = list(cursor.fetchall())
contexts = [
self._load_context(
cursor,
period_start,
date(
period_start.year,
period_start.month,
calendar.monthrange(
period_start.year,
period_start.month,
)[1],
),
)
for period_start in (
_date(row[0], "period_start")
for row in period_rows
)
]
return [
{
"month_key": context.month_key,
"max_arrival_date": context.as_of_date.isoformat(),
"updated_at": context.activated_at.isoformat(),
"filename": context.filename,
"source_monthly_sha256": (
context.source_projection_sha256
),
"channel_count": len(context.channels),
"row_count": sum(
item.row_count for item in context.channels
),
}
for context in contexts
]
except AnalyticsError:
raise
except Exception:
raise AnalyticsRepositoryError(
"ANALYTICS_DATABASE_QUERY_FAILED",
"database monthly list query failed",
) from None
finally:
connection.close()