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
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),
]
6 changes: 6 additions & 0 deletions apps/publisher/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
24 changes: 24 additions & 0 deletions apps/publisher/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
47 changes: 36 additions & 11 deletions providers/facebook.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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},
)

Expand Down Expand Up @@ -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,
Expand All @@ -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",
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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, {})
Expand Down
37 changes: 26 additions & 11 deletions tests/providers/test_facebook.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"]
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
Loading