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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Replace stale account days when fallback wins

When the per-post fallback wins on freshness, daily is a sparse fallback series where omitted dates mean 0, but update() only overwrites dates present in daily. For a Facebook metric with older account rows across the window and fresher post snapshots only on one day, the chart will mix the fresher post value for that day with stale account values on every omitted day instead of using the selected post-derived series. Replace the metric's day map when fallback wins rather than merging it into the stale account map.

Useful? React with 👍 / 👎.

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:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid overwriting metrics during follower refresh

When today's snapshot rows already exist but follower_count is empty, this branch now falls through into the normal full metric upsert below. For Meta providers, unavailable/empty insight metrics are normalized into zero-valued entries such as extra["views"], so a follower-count-only refresh can overwrite existing non-zero account rows for today with zeros. In the has_rows_for_day && needs_current_follower_refresh case, only refresh account.follower_count/the follower total instead of writing the rest of metric_values.

Useful? React with 👍 / 👎.

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
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
from django.db import migrations


def normalize_facebook_platform_post_ids(apps, schema_editor):
PlatformPost = apps.get_model("composer", "PlatformPost")

queryset = PlatformPost.objects.filter(
social_account__platform="facebook",
platform_post_id__contains="_",
).exclude(platform_post_id="")

for platform_post in queryset.iterator():
platform_post.platform_post_id = str(platform_post.platform_post_id).rsplit("_", 1)[1]
platform_post.save(update_fields=["platform_post_id"])


def noop_reverse(apps, schema_editor):
pass


class Migration(migrations.Migration):

dependencies = [
("composer", "0017_post_proposed_publish_at"),
]

operations = [
migrations.RunPython(normalize_facebook_platform_post_ids, noop_reverse),
]
8 changes: 8 additions & 0 deletions apps/publisher/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ def _resolve_publish_credentials(account):
"Bluesky PDS URL failed SSRF check for account %s",
account.id,
)
elif platform == "facebook":
credentials["page_id"] = account.account_platform_id
elif platform == "instagram":
credentials["ig_user_id"] = account.account_platform_id

Expand Down Expand Up @@ -220,6 +222,12 @@ def _publish_platform_post(self, platform_post):

if result["success"]:
platform_post.platform_post_id = result.get("platform_post_id", "")
response_extra = result.get("response")
if isinstance(response_extra, dict) and response_extra:
platform_post.platform_extra = {
**(platform_post.platform_extra or {}),
**response_extra,
}
platform_post.status = PlatformPost.Status.PUBLISHED
platform_post.published_at = timezone.now()
platform_post.save()
Expand Down
35 changes: 35 additions & 0 deletions apps/publisher/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,17 @@ def test_does_not_inject_author_for_other_platforms(self, _mock_creds, mock_get_


class ResolvePublishCredentialsTest(SimpleTestCase):
@patch("apps.publisher.engine.resolve_platform_credentials", return_value={"client_id": "id"})
def test_facebook_credentials_include_selected_page_id(self, _mock_resolve):
account = MagicMock()
account.platform = "facebook"
account.account_platform_id = "page-1"
account.workspace.organization_id = "org-1"

credentials = _resolve_publish_credentials(account)

self.assertEqual(credentials["page_id"], "page-1")

@patch("apps.publisher.engine.resolve_platform_credentials", return_value={"client_id": "id"})
def test_instagram_credentials_include_selected_ig_user_id(self, _mock_resolve):
account = MagicMock()
Expand Down Expand Up @@ -340,6 +351,30 @@ def test_publish_success_removes_queue_entry(self):
# The QueueEntry is gone (slot freed), but the PlatformPost remains.
self.assertFalse(QueueEntry.objects.filter(id=self.entry.id).exists())

def test_publish_success_stores_response_extra_on_platform_extra(self):
from apps.composer.models import PlatformPost

self.pp.platform_extra = {"post_type": "text"}
self.pp.save(update_fields=["platform_extra"])

engine = PublishEngine()
success = {
"success": True,
"platform_post_id": "post-1",
"status_code": 200,
"response": {"id": "page-1_post-1", "tracking": {"source": "graph"}},
}
with patch.object(PublishEngine, "_dispatch_to_provider", return_value=success):
engine._publish_platform_post(self.pp)

self.pp.refresh_from_db()
self.assertEqual(self.pp.status, PlatformPost.Status.PUBLISHED)
self.assertEqual(self.pp.platform_post_id, "post-1")
self.assertEqual(
self.pp.platform_extra,
{"post_type": "text", "id": "page-1_post-1", "tracking": {"source": "graph"}},
)

def test_publish_success_survives_queue_cleanup_failure(self):
from apps.composer.models import PlatformPost

Expand Down
Loading
Loading