From 0381692def2581a4c3c278000b2679ff40ee1c26 Mon Sep 17 00:00:00 2001 From: Heshan Wanigasooriya Date: Sat, 27 Jun 2026 15:08:13 -0400 Subject: [PATCH] Prefer fresher post metrics & refresh empty followers Prefer per-post summed series when its captured_at is fresher than account-level rows for content-attribution metrics. Track per-metric captured_at (captured_by_metric) and only apply post fallback when it improves freshness or account data is missing. Add logic to avoid using post fallback for metrics that don't support it. Also add follower-total refresh handling: introduce _FOLLOWER_TOTAL_REFRESH_PLATFORMS and _needs_empty_follower_count_refresh, update _sync_account_metrics to refresh today's follower total when account.follower_count is empty even if daily snapshots exist, and update sync_all_account_analytics to trigger the refresh. Add tests: service tests for preferring fresher post fallback and preserving account reach, and task test for refreshing empty follower_count when today rows exist. --- apps/analytics/services.py | 33 ++++++--- apps/analytics/tasks.py | 16 +++- apps/analytics/tests/test_services.py | 101 ++++++++++++++++++++++++++ apps/analytics/tests/test_tasks.py | 41 +++++++++++ 4 files changed, 177 insertions(+), 14 deletions(-) create mode 100644 apps/analytics/tests/test_services.py diff --git a/apps/analytics/services.py b/apps/analytics/services.py index 4562c66b..59e5c67f 100644 --- a/apps/analytics/services.py +++ b/apps/analytics/services.py @@ -263,27 +263,40 @@ def account_analytics_bundle(account: SocialAccount, days: int) -> dict[str, Any ) ) by_metric: dict[str, dict[dt_date, float]] = defaultdict(dict) + captured_by_metric: dict[str, Any] = {} max_captured: Any = None metrics_with_account_data: set[str] = set() for r in rows: by_metric[r.metric_key][r.date] = r.value metrics_with_account_data.add(r.metric_key) + if r.metric_key not in captured_by_metric or r.captured_at > captured_by_metric[r.metric_key]: + captured_by_metric[r.metric_key] = r.captured_at if max_captured is None or r.captured_at > max_captured: max_captured = r.captured_at - # Hybrid fallback: for content-attribution metrics without account-level - # rows in the window, derive the daily series by summing per-post deltas - # so platforms without ``get_account_metrics`` (YouTube, TikTok, etc.) - # still get populated hero cards and charts. Roll the per-post - # ``captured_at`` into ``max_captured`` so freshness consumers don't - # report "no data" while the response is in fact populated. + # Hybrid fallback: for content-attribution metrics, derive a daily series + # by summing per-post deltas so platforms without ``get_account_metrics`` + # (YouTube, TikTok, etc.) still get populated hero cards and charts. + # + # Also prefer the per-post series when it is fresher than account-level + # rows. Facebook account insights are synced at most daily, while per-post + # metrics can refresh hourly; without this freshness check the main graph + # can lag behind the post drawer/table even though newer post snapshots are + # already stored. for m in platform_metrics: - if m in metrics_with_account_data or not _supports_post_fallback(m): + if not _supports_post_fallback(m): continue daily, fallback_captured = _post_summed_series_for_metric(account, m, start, end) - by_metric[m].update(daily) - if fallback_captured is not None and (max_captured is None or fallback_captured > max_captured): - max_captured = fallback_captured + if not daily: + continue + account_captured = captured_by_metric.get(m) + should_use_fallback = m not in metrics_with_account_data or ( + fallback_captured is not None and account_captured is not None and fallback_captured > account_captured + ) + if should_use_fallback: + by_metric[m].update(daily) + if fallback_captured is not None and (max_captured is None or fallback_captured > max_captured): + max_captured = fallback_captured series_map = { m: [by_metric[m].get(start + timedelta(days=i), 0.0) for i in range(2 * days)] for m in platform_metrics diff --git a/apps/analytics/tasks.py b/apps/analytics/tasks.py index 4ae75c43..81f0c846 100644 --- a/apps/analytics/tasks.py +++ b/apps/analytics/tasks.py @@ -380,6 +380,12 @@ def _write_post_snapshot( "youtube": frozenset({"watch_time", "avg_view_pct", "shares"}), } +_FOLLOWER_TOTAL_REFRESH_PLATFORMS: frozenset[str] = frozenset({"facebook", "instagram", "instagram_login"}) + + +def _needs_empty_follower_count_refresh(account) -> bool: + return account.follower_count <= 0 and account.platform in _FOLLOWER_TOTAL_REFRESH_PLATFORMS + def _sync_account_metrics(account, on_date: dt_date) -> None: """Fetch account-level metrics for ``on_date`` and any recent missing days. @@ -415,7 +421,9 @@ def _sync_account_metrics(account, on_date: dt_date) -> None: current_followers = None for offset in range(recent_days): target = on_date - timedelta(days=offset) - if AccountInsightsSnapshot.objects.filter(social_account=account, date=target).exists(): + has_rows_for_day = AccountInsightsSnapshot.objects.filter(social_account=account, date=target).exists() + needs_current_follower_refresh = target == on_date and _needs_empty_follower_count_refresh(account) + if has_rows_for_day and not needs_current_follower_refresh: continue start = datetime.combine(target, time.min, tzinfo=tz) end = datetime.combine(target, time.max, tzinfo=tz) @@ -687,9 +695,9 @@ def sync_all_account_analytics() -> None: # backfill (see backfill_account_analytics), so the cron resumes on its # own. The per-post Data-API loop below still runs (it uses the # publish/read scopes the account already has). - if ( - not account.analytics_needs_reconnect - and not AccountInsightsSnapshot.objects.filter(social_account=account, date=today).exists() + has_today_rows = AccountInsightsSnapshot.objects.filter(social_account=account, date=today).exists() + if not account.analytics_needs_reconnect and ( + not has_today_rows or _needs_empty_follower_count_refresh(account) ): _sync_account_metrics(account, today) diff --git a/apps/analytics/tests/test_services.py b/apps/analytics/tests/test_services.py new file mode 100644 index 00000000..6a2d6161 --- /dev/null +++ b/apps/analytics/tests/test_services.py @@ -0,0 +1,101 @@ +"""Tests for analytics read-side services.""" + +from datetime import timedelta + +import pytest +from django.utils import timezone + +from apps.social_accounts.models import SocialAccount + + +@pytest.fixture +def workspace(db, organization): + from apps.workspaces.models import Workspace + + return Workspace.objects.create(name="Analytics Services WS", organization=organization) + + +@pytest.fixture +def facebook_account(workspace): + return SocialAccount.objects.create( + workspace=workspace, + platform="facebook", + account_platform_id="page-1", + account_name="Facebook Page", + oauth_access_token="token", + connection_status=SocialAccount.ConnectionStatus.CONNECTED, + ) + + +def _published_platform_post(account): + from apps.composer.models import PlatformPost, Post + + post = Post.objects.create(workspace=account.workspace, caption="hello") + return PlatformPost.objects.create( + post=post, + social_account=account, + status=PlatformPost.Status.PUBLISHED, + published_at=timezone.now(), + platform_post_id="post-1", + ) + + +@pytest.mark.django_db +def test_account_bundle_prefers_fresher_post_fallback_for_content_metrics(facebook_account): + """Per-post Facebook analytics can refresh hourly while account snapshots are + daily. The main graph should use the fresher post-derived value instead of + leaving the overall insight chip/card stuck on the stale account row. + """ + from apps.analytics.models import AccountInsightsSnapshot, PostInsightsSnapshot + from apps.analytics.services import account_analytics_bundle + + today = timezone.now().date() + old_capture = timezone.now() - timedelta(hours=2) + new_capture = timezone.now() + platform_post = _published_platform_post(facebook_account) + + account_row = AccountInsightsSnapshot.objects.create( + social_account=facebook_account, + metric_key="views", + date=today, + value=10, + ) + AccountInsightsSnapshot.objects.filter(id=account_row.id).update(captured_at=old_capture) + + post_row = PostInsightsSnapshot.objects.create( + platform_post=platform_post, + metric_key="views", + date=today, + value=42, + ) + PostInsightsSnapshot.objects.filter(id=post_row.id).update(captured_at=new_capture) + + series = account_analytics_bundle(facebook_account, 7)["series_map"]["views"] + + assert series[-1] == 42 + + +@pytest.mark.django_db +def test_account_bundle_keeps_account_reach_instead_of_summing_post_reach(facebook_account): + from apps.analytics.models import AccountInsightsSnapshot, PostInsightsSnapshot + from apps.analytics.services import account_analytics_bundle + + today = timezone.now().date() + platform_post = _published_platform_post(facebook_account) + + AccountInsightsSnapshot.objects.create( + social_account=facebook_account, + metric_key="reach", + date=today, + value=10, + ) + PostInsightsSnapshot.objects.create( + platform_post=platform_post, + metric_key="reach", + date=today, + value=42, + ) + + series = account_analytics_bundle(facebook_account, 7)["series_map"]["reach"] + + assert series[-1] == 10 diff --git a/apps/analytics/tests/test_tasks.py b/apps/analytics/tests/test_tasks.py index 6f33201e..2a0d6627 100644 --- a/apps/analytics/tests/test_tasks.py +++ b/apps/analytics/tests/test_tasks.py @@ -157,3 +157,44 @@ def test_sync_account_metrics_recovers_followers_from_later_offset(workspace): assert not AccountInsightsSnapshot.objects.filter( social_account=account, date__lt=today, metric_key="followers" ).exists() + + +@pytest.mark.django_db +def test_sync_account_metrics_refreshes_empty_follower_count_when_today_rows_exist(workspace): + """Existing daily account snapshots must not strand the header follower total + at 0. This commonly affects Facebook accounts connected before + ``followers_count`` was persisted during page selection. + """ + from datetime import date + from unittest.mock import MagicMock, patch + + from apps.analytics.models import AccountInsightsSnapshot + from apps.analytics.tasks import _sync_account_metrics + from providers.types import AccountMetrics + + account = SocialAccount.objects.create( + workspace=workspace, + platform="facebook", + account_platform_id="page-1", + account_name="FB One", + follower_count=0, + oauth_access_token="token", + oauth_refresh_token="refresh", + connection_status=SocialAccount.ConnectionStatus.CONNECTED, + ) + today = date(2026, 6, 24) + AccountInsightsSnapshot.objects.create( + social_account=account, + date=today, + metric_key="views", + value=10, + ) + fake_provider = MagicMock() + fake_provider.account_metrics_supports_date_range = True + fake_provider.get_account_metrics.return_value = AccountMetrics(followers=1234, followers_gained=2) + + with patch("apps.analytics.tasks._resolve_provider", return_value=fake_provider): + _sync_account_metrics(account, today) + + account.refresh_from_db() + assert account.follower_count == 1234