fb_posts: past its publish time, a scheduled post reads as published

Facebook does not call back and the reconciliation was never built, so a
row that stayed scheduled forever was a lie of omission. Decided by Mike:
once the time passes with nothing else reported, the post is published.
status keeps what the publisher last said; state is what a reader acts
on, and the open-only list and cancel use state. Cancel on a post whose
time has passed says so.
This commit is contained in:
2026-09-04 19:53:08 -04:00
parent 55e4c67b9a
commit ea22ed53e7
3 changed files with 36 additions and 8 deletions
+3 -2
View File
@@ -1025,8 +1025,9 @@ def cancel_fb_post(request: Request, pid: str, x_admin_token: str = Header(None)
rec = store.get_fb_post(pid)
if not rec:
raise HTTPException(404, "no such post")
if rec["status"] not in ("scheduled",):
raise HTTPException(409, "post is %s, not scheduled" % rec["status"])
if rec["state"] != "scheduled":
raise HTTPException(409, "post is %s, not scheduled" % rec["state"]
+ (" (its publish time has passed)" if rec["status"] == "scheduled" else ""))
if not rec.get("cancel_url"):
raise HTTPException(409, "no cancel link was recorded for this post")
import urllib.request as _ur, urllib.error as _ue
+25 -6
View File
@@ -1191,25 +1191,44 @@ def _image_size(data, mime):
return None, None
def fb_state(rec, now=None):
"""The effective state. A scheduled post whose publish time has passed is
published unless something said otherwise: Facebook does not call back,
and a row that stays 'scheduled' forever is a lie of omission. Decided
by Mike 2026-09-04. `status` stays what was last reported; `state` is
what a reader should act on."""
st = rec.get("status")
if st == "scheduled" and rec.get("scheduled_for") and rec["scheduled_for"] <= (now or _now()):
return "published"
return st
def list_fb_posts(limit=100, include_done=True):
con = connect()
try:
sql = "SELECT * FROM fb_posts"
if not include_done:
sql += " WHERE status IN ('drafted','scheduled','handed_off')"
sql += " ORDER BY COALESCE(scheduled_for, updated_at) DESC LIMIT ?"
return [dict(r) for r in con.execute(sql, (max(1, min(int(limit), 500)),))]
rows = [dict(r) for r in con.execute(
"SELECT * FROM fb_posts ORDER BY COALESCE(scheduled_for, updated_at) DESC LIMIT ?",
(max(1, min(int(limit), 500)),))]
finally:
con.close()
now = _now()
for r in rows:
r["state"] = fb_state(r, now)
if not include_done:
rows = [r for r in rows if r["state"] in ("drafted", "scheduled", "handed_off")]
return rows
def get_fb_post(pid):
con = connect()
try:
r = con.execute("SELECT * FROM fb_posts WHERE id=?", (pid,)).fetchone()
return dict(r) if r else None
finally:
con.close()
if not r:
return None
d = dict(r); d["state"] = fb_state(d)
return d
def mark_fb_cancelled(pid, by):
+8
View File
@@ -402,6 +402,14 @@ check("cancel recorded", c["status"] == "cancelled" and c["cancelled_by"] == "mi
and not any(p["id"] == c["id"] for p in S.list_fb_posts(include_done=False)))
r3 = S.upsert_fb_post({"id": "troop-73/2026-09-12-canoe", "status": "cancelled", "cancelled_by": "ntfy button"})
check("publisher-reported cancel stamps cancelled_at", r3["cancelled_at"] and r3["cancelled_by"] == "ntfy button")
past = S.upsert_fb_post({"id": "pack-73/2020-01-01-old", "status": "scheduled", "scheduled_for": "2020-01-01T12:00:00+00:00"})
future = S.upsert_fb_post({"id": "pack-73/2036-01-01-new", "status": "scheduled", "scheduled_for": "2036-01-01T12:00:00+00:00"})
check("a scheduled post past its time reads as published; a future one stays scheduled",
S.get_fb_post("pack-73/2020-01-01-old")["state"] == "published" and S.get_fb_post("pack-73/2036-01-01-new")["state"] == "scheduled"
and S.get_fb_post("pack-73/2020-01-01-old")["status"] == "scheduled")
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")
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()