From 7e80de7a1a8ddcaeffd3292d9c5d1726a643e141 Mon Sep 17 00:00:00 2001 From: Heshan Wanigasooriya Date: Wed, 24 Jun 2026 14:24:08 -0400 Subject: [PATCH] fix facebook analytics post ids --- ...18_normalize_facebook_platform_post_ids.py | 29 ++++++++++++ apps/publisher/engine.py | 6 +++ apps/publisher/tests.py | 24 ++++++++++ providers/facebook.py | 47 ++++++++++++++----- tests/providers/test_facebook.py | 37 ++++++++++----- 5 files changed, 121 insertions(+), 22 deletions(-) create mode 100644 apps/composer/migrations/0018_normalize_facebook_platform_post_ids.py diff --git a/apps/composer/migrations/0018_normalize_facebook_platform_post_ids.py b/apps/composer/migrations/0018_normalize_facebook_platform_post_ids.py new file mode 100644 index 00000000..f9a6900f --- /dev/null +++ b/apps/composer/migrations/0018_normalize_facebook_platform_post_ids.py @@ -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), + ] diff --git a/apps/publisher/engine.py b/apps/publisher/engine.py index 0d9fa0c9..f3924b33 100644 --- a/apps/publisher/engine.py +++ b/apps/publisher/engine.py @@ -220,6 +220,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() diff --git a/apps/publisher/tests.py b/apps/publisher/tests.py index 265a0bcb..8abfdc18 100644 --- a/apps/publisher/tests.py +++ b/apps/publisher/tests.py @@ -340,6 +340,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 diff --git a/providers/facebook.py b/providers/facebook.py index b443b2c7..cfcf2512 100644 --- a/providers/facebook.py +++ b/providers/facebook.py @@ -294,8 +294,9 @@ def _publish_text_or_link(self, access_token: str, page_id: str, content: Publis json=payload, ) data = resp.json() + post_id = self._stored_post_id(data["id"]) return PublishResult( - platform_post_id=data["id"], + platform_post_id=post_id, url=f"https://www.facebook.com/{data['id']}", extra=data, ) @@ -314,8 +315,13 @@ def _publish_photo(self, access_token: str, page_id: str, content: PublishConten json=payload, ) data = resp.json() - post_id = data.get("post_id", data["id"]) - return PublishResult(platform_post_id=post_id, url=f"https://www.facebook.com/{post_id}", extra=data) + graph_post_id = data.get("post_id", data["id"]) + post_id = self._stored_post_id(graph_post_id) + return PublishResult( + platform_post_id=post_id, + url=f"https://www.facebook.com/{graph_post_id}", + extra=data, + ) def _publish_multi_photo(self, access_token: str, page_id: str, content: PublishContent) -> PublishResult: urls = content.media_urls @@ -363,8 +369,8 @@ def _publish_multi_photo(self, access_token: str, page_id: str, content: Publish access_token=access_token, json=payload, ).json() - post_id = data.get("id") - if not post_id: + graph_post_id = data.get("id") + if not graph_post_id: raise PublishError( "Failed to publish Facebook multi-photo post", platform=self.platform_name, @@ -378,8 +384,8 @@ def _publish_multi_photo(self, access_token: str, page_id: str, content: Publish raise return PublishResult( - platform_post_id=post_id, - url=f"https://www.facebook.com/{post_id}", + platform_post_id=self._stored_post_id(graph_post_id), + url=f"https://www.facebook.com/{graph_post_id}", extra={**data, "photo_ids": photo_ids}, ) @@ -433,8 +439,9 @@ def _publish_video(self, access_token: str, page_id: str, content: PublishConten # body making .json() raise). Catch broadly; fall back to video_id. logger.debug("Facebook video %s post_id unavailable: %s", video_id, exc) - post_id = video_fields.get("post_id") or video_id - url = video_fields.get("permalink_url") or f"https://www.facebook.com/{post_id}" + graph_post_id = video_fields.get("post_id") or video_id + post_id = self._stored_post_id(graph_post_id) + url = video_fields.get("permalink_url") or f"https://www.facebook.com/{graph_post_id}" return PublishResult( platform_post_id=post_id, url=url, @@ -446,6 +453,7 @@ def _publish_video(self, access_token: str, page_id: str, content: PublishConten # ------------------------------------------------------------------ def publish_comment(self, access_token: str, post_id: str, text: str) -> CommentResult: + post_id = self._page_scoped_post_id(post_id) resp = self._request( "POST", f"{BASE_URL}/{post_id}/comments", @@ -473,12 +481,12 @@ def get_post_metrics(self, access_token: str, post_id: str) -> PostMetrics: shares = self._share_count(fields) return PostMetrics( - reach=values.get("post_total_media_view_unique", 0), + reach=values.get("post_media_view", 0), clicks=values.get("post_clicks", 0), likes=0, comments=comments, shares=shares, - video_views=values.get("post_media_view", 0), + video_views=values.get("post_total_media_view_unique", 0), extra={ "reactions": reactions_total, "raw_fields": fields, @@ -554,6 +562,23 @@ def _get_post_fields(self, access_token: str, post_id: str) -> dict: break return {} + @staticmethod + def _stored_post_id(graph_post_id: str) -> str: + """Store only Facebook's post object id from PAGEID_POSTID values.""" + post_id = str(graph_post_id or "") + if "_" in post_id: + return post_id.rsplit("_", 1)[1] + return post_id + + def _page_scoped_post_id(self, post_id: str) -> str: + post_id = str(post_id or "") + if "_" in post_id: + return post_id + page_id = self.credentials.get("page_id") + if page_id and post_id: + return f"{page_id}_{post_id}" + return post_id + @staticmethod def _summary_total(data: dict, key: str) -> int: value = data.get(key, {}) diff --git a/tests/providers/test_facebook.py b/tests/providers/test_facebook.py index 862844b1..c7ef3599 100644 --- a/tests/providers/test_facebook.py +++ b/tests/providers/test_facebook.py @@ -38,7 +38,7 @@ def test_publish_multi_photo_post_stages_photos_then_publishes_feed_post(): ), ) - assert result.platform_post_id == "page-1_post-1" + assert result.platform_post_id == "post-1" assert result.url == "https://www.facebook.com/page-1_post-1" assert result.extra["photo_ids"] == ["photo-1", "photo-2"] provider._request.assert_has_calls( @@ -124,7 +124,7 @@ def test_publish_single_photo_uses_photos_edge_without_staging(): ), ) - assert result.platform_post_id == "page-1_post-1" + assert result.platform_post_id == "post-1" assert result.url == "https://www.facebook.com/page-1_post-1" provider._request.assert_called_once_with( "POST", @@ -318,8 +318,8 @@ def test_get_post_metrics_uses_v25_media_view_metrics_and_object_counts(): metrics = provider.get_post_metrics("page-token", "page-1_post-1") - assert metrics.video_views == 54 - assert metrics.reach == 42 + assert metrics.reach == 54 + assert metrics.video_views == 42 assert metrics.clicks == 4 assert metrics.likes == 0 assert metrics.comments == 5 @@ -420,8 +420,8 @@ def test_get_post_metrics_resolves_photo_id_to_feed_post_for_comments_and_shares metrics = provider.get_post_metrics("page-token", "photo-1") - assert metrics.video_views == 500 - assert metrics.reach == 300 + assert metrics.reach == 500 + assert metrics.video_views == 300 assert metrics.clicks == 20 assert metrics.comments == 4 assert metrics.shares == 2 @@ -462,8 +462,8 @@ def test_get_post_metrics_tries_page_scoped_feed_id_for_numeric_object_id(): metrics = provider.get_post_metrics("page-token", "1668168861075953") - assert metrics.video_views == 90 - assert metrics.reach == 70 + assert metrics.reach == 90 + assert metrics.video_views == 70 assert metrics.comments == 6 assert metrics.shares == 8 assert metrics.extra["insight_post_id"] == "page-1_1668168861075953" @@ -501,8 +501,8 @@ def test_get_post_metrics_tries_next_candidate_when_feed_id_has_no_insights_edge metrics = provider.get_post_metrics("page-token", "1668168861075953") - assert metrics.video_views == 12 - assert metrics.reach == 10 + assert metrics.reach == 12 + assert metrics.video_views == 10 assert metrics.comments == 2 assert metrics.extra["insight_post_id"] == "1668168861075953" assert metrics.extra["attempted_insight_post_ids"] == ["page-1_1668168861075953", "1668168861075953"] @@ -539,6 +539,21 @@ def test_get_post_metrics_reports_batched_insights_failure_for_each_metric(): assert "post_total_media_view_unique" in metrics.extra["insight_errors"] +def test_publish_comment_reconstructs_page_scoped_facebook_post_id(): + provider = FacebookProvider({"client_id": "id", "client_secret": "secret", "page_id": "page-1"}) + provider._request = MagicMock(return_value=_resp({"id": "comment-1"})) + + result = provider.publish_comment("page-token", "post-1", "Nice") + + assert result.platform_comment_id == "comment-1" + provider._request.assert_called_once_with( + "POST", + "https://graph.facebook.com/v25.0/page-1_post-1/comments", + access_token="page-token", + json={"message": "Nice"}, + ) + + def test_get_account_metrics_uses_v25_page_media_view_metrics_and_followers_count(): provider = FacebookProvider({"client_id": "id", "client_secret": "secret", "page_id": "page-1"}) provider._request = MagicMock( @@ -683,7 +698,7 @@ def test_publish_video_resolves_feed_post_id_for_analytics(): ), ) - assert result.platform_post_id == "page-1_post-1" + assert result.platform_post_id == "post-1" assert result.url == "https://www.facebook.com/page-1/videos/video-1/" assert result.extra["video_id"] == "video-1" provider._request.assert_has_calls(