Spaces:
Running
Running
| 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) | |
| ) | |