Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 23 additions & 10 deletions apps/analytics/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 12 additions & 4 deletions apps/analytics/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)

Expand Down
101 changes: 101 additions & 0 deletions apps/analytics/tests/test_services.py
Original file line number Diff line number Diff line change
@@ -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
41 changes: 41 additions & 0 deletions apps/analytics/tests/test_tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading