diff --git a/app/admin_api.py b/app/admin_api.py index bcb42fd..6378e41 100644 --- a/app/admin_api.py +++ b/app/admin_api.py @@ -1043,3 +1043,59 @@ def cancel_fb_post(request: Request, pid: str, x_admin_token: str = Header(None) out = store.mark_fb_cancelled(pid, actor) _log(request, "fbpost.cancelled", pid) return out + + +@router.post("/fbposts/{pid:path}/reschedule") +def reschedule_fb_post(request: Request, pid: str, payload: dict = Body(...), x_admin_token: str = Header(None)): + """Edit and repost a scheduled post: body {message, scheduled_for}. The + site asks the publisher's control server, signed with the same per-post + signature as the cancel link, to delete the post on Facebook and redraft + the queue file; the publisher schedules it again on its next cycle + (within about 15 minutes) and reports the new id. Until then the row is + drafted with no cancel link.""" + actor = _auth(request, x_admin_token, "fbposts:read") + rec = store.get_fb_post(pid) + if not rec: + raise HTTPException(404, "no such post") + if rec["state"] != "scheduled": + raise HTTPException(409, "post is %s, not scheduled" % rec["state"]) + if not rec.get("cancel_url") or not rec.get("fb_post_id"): + raise HTTPException(409, "no signed link was recorded for this post") + message = (payload.get("message") or "").strip() + if not message: + raise HTTPException(422, "message is required") + if len(message) > 5000: + raise HTTPException(422, "message is over 5000 characters") + when = (payload.get("scheduled_for") or "").strip() or None + if when: + try: + import datetime as _dt + d = _dt.datetime.fromisoformat(when.replace("Z", "+00:00")) + if d.tzinfo is None: + raise ValueError + if d <= _dt.datetime.now(_dt.timezone.utc) + _dt.timedelta(minutes=15): + raise HTTPException(422, "scheduled_for must be at least 15 minutes from now") + except ValueError: + raise HTTPException(422, "scheduled_for must be an ISO timestamp with an offset") + import urllib.parse as _up, urllib.request as _ur, urllib.error as _ue, json as _json + u = _up.urlparse(rec["cancel_url"]); q = _up.parse_qs(u.query) + sig = (q.get("sig") or [""])[0] + if not sig: + raise HTTPException(409, "the recorded cancel link carries no signature") + target = _up.urlunparse((u.scheme, u.netloc, "/reschedule", "", "", "")) + body = _json.dumps({"id": rec["fb_post_id"], "sig": sig, "message": message, "publish_at": when}).encode() + try: + with _ur.urlopen(_ur.Request(target, data=body, method="POST", + headers={"Content-Type": "application/json", "User-Agent": "scout-website"}), timeout=60) as resp: + status = resp.status + except _ue.HTTPError as e: + raise HTTPException(502, "publisher answered %d on reschedule" % e.code) + except Exception as e: + raise HTTPException(502, "publisher unreachable: %s" % e) + if status >= 300: + raise HTTPException(502, "publisher answered %d on reschedule" % status) + out = store.upsert_fb_post({"id": pid, "status": "drafted", "message": message, "scheduled_for": when, + "fb_post_id": None, "cancel_url": None, "unit": rec["unit"], "page_id": rec.get("page_id"), + "image_ref": rec.get("image_ref"), "queue_file": rec.get("queue_file"), "link": rec.get("link")}) + _log(request, "fbpost.redrafted", "%s by %s%s" % (pid, actor, (" for " + when) if when else "")) + return out diff --git a/app/store.py b/app/store.py index 4d84a1b..528ad1d 100644 --- a/app/store.py +++ b/app/store.py @@ -1158,7 +1158,9 @@ def upsert_fb_post(rec, image=None): fields["cancelled_at"] = rec.get("cancelled_at") or _now() fields["cancelled_by"] = rec.get("cancelled_by") or "publisher" if old: - if fields.get("fb_post_id") is None: + # A missing fb_post_id keeps the old one; an explicit null clears it + # (a redraft has no Facebook id until the next cycle). + if fields.get("fb_post_id") is None and "fb_post_id" not in rec: fields.pop("fb_post_id") sets = ", ".join("%s=?" % k for k in fields) con.execute("UPDATE fb_posts SET %s WHERE id=?" % sets, (*fields.values(), pid)) diff --git a/tests/smoke_admin.py b/tests/smoke_admin.py index 3cf140e..329f17f 100644 --- a/tests/smoke_admin.py +++ b/tests/smoke_admin.py @@ -410,6 +410,11 @@ check("a scheduled post past its time reads as published; a future one stays sch check("open-only list drops the past one and keeps the future one", [p["id"] for p in S.list_fb_posts(include_done=False) if p["id"].startswith("pack-73/20")] == ["pack-73/2036-01-01-new"]) check("cancelled beats the clock", S.fb_state({"status": "cancelled", "scheduled_for": "2020-01-01T00:00:00+00:00"}) == "cancelled") +rd = S.upsert_fb_post({"id": "pack-73/2036-01-01-new", "status": "drafted", "message": "edited", "fb_post_id": None, "cancel_url": None}) +check("a redraft clears the facebook id and cancel link", rd["status"] == "drafted" and rd["fb_post_id"] is None and rd["cancel_url"] is None) +rd2 = S.upsert_fb_post({"id": "pack-73/2036-01-01-new", "status": "scheduled", "message": "edited", "fb_post_id": "141_NEW"}) +rd3 = S.upsert_fb_post({"id": "pack-73/2036-01-01-new", "status": "scheduled", "message": "edited again"}) +check("a report without an id keeps the id", rd2["fb_post_id"] == "141_NEW" and rd3["fb_post_id"] == "141_NEW") check("fbposts caps: read for leaders, ingest admin-only and scopable", "fbposts:read" in I.CAPS["leader"] and "fbposts:ingest" in I.CAPS["admin"] and "fbposts:ingest" not in I.KEY_UNSCOPABLE) con = S.connect(); con.execute("DELETE FROM fb_posts"); con.commit(); con.close()