MediaRouter / app /analytics /repository.py
basyx's picture
Upload 340 files
3493993 verified
Raw
History Blame Contribute Delete
16.5 kB
from __future__ import annotations
from datetime import datetime, timezone
from sqlalchemy import or_, select
from sqlalchemy.exc import IntegrityError
from app.analytics.errors import (
AnalyticsIdempotencyConflictError,
AnalyticsNotFoundError,
AnalyticsValidationError,
)
from app.analytics.models import (
AnalyticsMetricSnapshot,
AnalyticsPlatformMetric,
AnalyticsPostMetric,
AnalyticsSyncRun,
)
from app.social.database import SocialDatabase
from app.social.models import SocialPost, SocialPostTarget
class AnalyticsRepository:
def __init__(self, database: SocialDatabase) -> None:
self.database = database
async def create_sync(self, run: AnalyticsSyncRun) -> AnalyticsSyncRun:
try:
async with self.database.session(run.workspace_id) as session:
session.add(run)
await session.commit()
await session.refresh(run)
return run
except IntegrityError as exc:
existing = await self.get_sync_by_idempotency(run.workspace_id, run.idempotency_key)
if existing is not None:
if (
existing.date_from != run.date_from
or existing.date_to != run.date_to
or existing.provider != run.provider
or existing.project_id != run.project_id
):
raise AnalyticsIdempotencyConflictError(
"The analytics idempotency key was already used for a different request."
) from exc
return existing
raise
async def get_sync_by_idempotency(
self, workspace_id: str, idempotency_key: str
) -> AnalyticsSyncRun | None:
async with self.database.session(workspace_id) as session:
return await session.scalar(
select(AnalyticsSyncRun).where(
AnalyticsSyncRun.workspace_id == workspace_id,
AnalyticsSyncRun.idempotency_key == idempotency_key,
)
)
async def get_sync(self, workspace_id: str, run_id: str) -> AnalyticsSyncRun:
async with self.database.session(workspace_id) as session:
run = await session.scalar(
select(AnalyticsSyncRun).where(
AnalyticsSyncRun.id == run_id,
AnalyticsSyncRun.workspace_id == workspace_id,
)
)
if run is None:
raise AnalyticsNotFoundError("Analytics sync run was not found.")
return run
async def list_syncs(
self, workspace_id: str, *, offset: int, limit: int
) -> list[AnalyticsSyncRun]:
async with self.database.session(workspace_id) as session:
return list(
(
await session.scalars(
select(AnalyticsSyncRun)
.where(AnalyticsSyncRun.workspace_id == workspace_id)
.order_by(AnalyticsSyncRun.created_at.desc())
.offset(offset)
.limit(limit)
)
).all()
)
async def claim_due_syncs(self, *, limit: int = 4) -> list[AnalyticsSyncRun]:
async with self.database.worker_session() as session:
now = datetime.now(timezone.utc)
statement = (
select(AnalyticsSyncRun)
.where(
AnalyticsSyncRun.status.in_(["queued", "retrying"]),
(AnalyticsSyncRun.next_attempt_at.is_(None))
| (AnalyticsSyncRun.next_attempt_at <= now),
)
.order_by(AnalyticsSyncRun.created_at)
.limit(limit)
.with_for_update(skip_locked=True)
)
runs = list((await session.scalars(statement)).all())
for run in runs:
run.status = "running"
run.started_at = now
run.attempt_count += 1
run.updated_at = now
await session.commit()
return runs
async def update_sync(
self,
run_id: str,
*,
status: str,
metrics_count: int | None = None,
error_code: str | None = None,
error_message: str | None = None,
next_attempt_at: datetime | None = None,
) -> AnalyticsSyncRun:
async with self.database.worker_session() as session:
run = await session.get(AnalyticsSyncRun, run_id)
if run is None:
raise AnalyticsNotFoundError("Analytics sync run was not found.")
run.status = status
run.error_code = error_code
run.error_message = error_message
run.next_attempt_at = next_attempt_at
if metrics_count is not None:
run.metrics_count = metrics_count
if status in {"succeeded", "partial", "failed", "cancelled"}:
run.completed_at = datetime.now(timezone.utc)
run.updated_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(run)
return run
async def cancel_sync(self, workspace_id: str, run_id: str) -> AnalyticsSyncRun:
async with self.database.session(workspace_id) as session:
run = await session.scalar(
select(AnalyticsSyncRun).where(
AnalyticsSyncRun.id == run_id,
AnalyticsSyncRun.workspace_id == workspace_id,
)
)
if run is None:
raise AnalyticsNotFoundError("Analytics sync run was not found.")
if run.status not in {"queued", "running"}:
raise AnalyticsValidationError(
"Only queued or running analytics synchronization can be cancelled."
)
run.status = "cancelled"
run.completed_at = datetime.now(timezone.utc)
run.updated_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(run)
return run
async def published_targets(
self,
workspace_id: str,
*,
project_id: str | None = None,
provider: str | None = None,
date_from: datetime | None = None,
date_to: datetime | None = None,
) -> list[tuple[SocialPost, SocialPostTarget]]:
async with self.database.session(workspace_id) as session:
statement = (
select(SocialPost, SocialPostTarget)
.join(SocialPostTarget, SocialPostTarget.social_post_id == SocialPost.id)
.where(
SocialPost.workspace_id == workspace_id,
SocialPostTarget.external_post_id.is_not(None),
SocialPostTarget.status == "published",
)
)
if project_id:
statement = statement.where(SocialPost.project_id == project_id)
if provider:
statement = statement.where(SocialPostTarget.provider == provider)
if date_from:
statement = statement.where(SocialPostTarget.published_at >= date_from)
if date_to:
statement = statement.where(SocialPostTarget.published_at <= date_to)
return list((await session.execute(statement)).all())
async def upsert_post_metric(
self,
*,
workspace_id: str,
post: SocialPost,
target: SocialPostTarget,
metric: dict[str, object],
collected_at: datetime,
) -> AnalyticsPostMetric:
bucket = target.published_at or collected_at
async with self.database.session(workspace_id) as session:
existing = await session.scalar(
select(AnalyticsPostMetric).where(
AnalyticsPostMetric.workspace_id == workspace_id,
AnalyticsPostMetric.social_post_target_id == target.id,
AnalyticsPostMetric.metric_date == bucket,
)
)
values = dict(
provider=target.provider,
views=metric.get("views"),
impressions=metric.get("impressions"),
likes=metric.get("likes"),
comments=metric.get("comments"),
shares=metric.get("shares"),
engagement_rate=metric.get("engagement_rate"),
dimensions=(
metric.get("raw_metrics") if isinstance(metric.get("raw_metrics"), dict) else {}
),
source="provider",
collected_at=collected_at,
external_post_id=target.external_post_id,
)
if existing is None:
existing = AnalyticsPostMetric(
workspace_id=workspace_id,
project_id=post.project_id,
social_post_id=post.id,
social_post_target_id=target.id,
social_account_id=target.social_account_id,
metric_date=bucket,
**values,
)
session.add(existing)
else:
for key, value in values.items():
setattr(existing, key, value)
if target.external_post_id:
for metric_name in (
"views",
"impressions",
"likes",
"comments",
"shares",
"engagement_rate",
):
metric_value = metric.get(metric_name)
if not isinstance(metric_value, (int, float)) or isinstance(metric_value, bool):
continue
snapshot = await session.scalar(
select(AnalyticsMetricSnapshot).where(
AnalyticsMetricSnapshot.workspace_id == workspace_id,
AnalyticsMetricSnapshot.provider == target.provider,
AnalyticsMetricSnapshot.external_object_id == target.external_post_id,
AnalyticsMetricSnapshot.metric_name == metric_name,
AnalyticsMetricSnapshot.bucket_start == bucket,
)
)
if snapshot is None:
session.add(
AnalyticsMetricSnapshot(
workspace_id=workspace_id,
project_id=post.project_id,
social_post_id=post.id,
social_account_id=target.social_account_id,
provider=target.provider,
external_object_id=target.external_post_id,
metric_name=metric_name,
metric_value=float(metric_value),
bucket_start=bucket,
dimensions={},
source="provider",
collected_at=collected_at,
)
)
else:
snapshot.metric_value = float(metric_value)
snapshot.collected_at = collected_at
await session.flush()
day_start = bucket.astimezone(timezone.utc).replace(
hour=0, minute=0, second=0, microsecond=0
)
day_end = day_start.replace(hour=23, minute=59, second=59, microsecond=999999)
account_rows = list(
(
await session.scalars(
select(AnalyticsPostMetric).where(
AnalyticsPostMetric.workspace_id == workspace_id,
AnalyticsPostMetric.social_account_id == target.social_account_id,
AnalyticsPostMetric.metric_date >= day_start,
AnalyticsPostMetric.metric_date <= day_end,
)
)
).all()
)
platform = await session.scalar(
select(AnalyticsPlatformMetric).where(
AnalyticsPlatformMetric.workspace_id == workspace_id,
AnalyticsPlatformMetric.provider == target.provider,
AnalyticsPlatformMetric.social_account_id == target.social_account_id,
AnalyticsPlatformMetric.metric_date == day_start,
)
)
aggregates: dict[str, int | float | None] = {}
for name in (
"views",
"impressions",
"likes",
"comments",
"shares",
"engagement_rate",
):
metric_values = [
getattr(item, name) for item in account_rows if getattr(item, name) is not None
]
aggregates[name] = (
(
sum(metric_values) / len(metric_values)
if name == "engagement_rate"
else sum(metric_values)
)
if metric_values
else None
)
if platform is None:
platform = AnalyticsPlatformMetric(
workspace_id=workspace_id,
project_id=post.project_id,
social_account_id=target.social_account_id,
provider=target.provider,
metric_date=day_start,
)
session.add(platform)
platform.posts_count = len(account_rows)
platform.collected_at = collected_at
for name, value in aggregates.items():
setattr(platform, name, value)
await session.commit()
await session.refresh(existing)
return existing
async def metric_rows(
self,
workspace_id: str,
query: dict[str, object],
) -> list[AnalyticsPostMetric]:
async with self.database.session(workspace_id) as session:
statement = select(AnalyticsPostMetric).where(
AnalyticsPostMetric.workspace_id == workspace_id,
AnalyticsPostMetric.metric_date >= query["date_from"],
AnalyticsPostMetric.metric_date <= query["date_to"],
)
for field, model_field in (
("project_id", AnalyticsPostMetric.project_id),
("provider", AnalyticsPostMetric.provider),
("social_account_id", AnalyticsPostMetric.social_account_id),
("post_id", AnalyticsPostMetric.social_post_id),
):
value = query.get(field)
if value:
statement = statement.where(model_field == value)
if query.get("search"):
pattern = f"%{str(query['search']).strip()}%"
statement = statement.join(
SocialPost, SocialPost.id == AnalyticsPostMetric.social_post_id
).where(
or_(
SocialPost.canonical_caption.ilike(pattern),
SocialPost.id.ilike(pattern),
)
)
return list((await session.scalars(statement)).all())
async def latest_sync(
self, workspace_id: str, *, provider: str | None = None, project_id: str | None = None
) -> AnalyticsSyncRun | None:
async with self.database.session(workspace_id) as session:
statement = select(AnalyticsSyncRun).where(
AnalyticsSyncRun.workspace_id == workspace_id,
AnalyticsSyncRun.status.in_(["succeeded", "partial"]),
)
if provider:
statement = statement.where(AnalyticsSyncRun.provider == provider)
if project_id:
statement = statement.where(AnalyticsSyncRun.project_id == project_id)
return await session.scalar(
statement.order_by(AnalyticsSyncRun.completed_at.desc()).limit(1)
)