From 2dc4419024301eb0b09403b5d3571d6c83394199 Mon Sep 17 00:00:00 2001 From: jay Date: Tue, 30 Jun 2026 14:29:55 -0400 Subject: [PATCH] images/analytics: purge on policy revoke + engagement warm-up note (Codex close-out) - newsimg.purge_source(): when a source leaves 'cache' (permission revoked / re-classified), the admin image-policy endpoint now deletes that source's re-hosted copies immediately, rather than leaving them inaccessible-but-on-disk. Endpoint returns {purged}. - Admin "Engaged readers" carries a warm-up note: tracking began 2026-06-30, so low rolling windows are partly warm-up, not all bots (compare d7 after a week, the window after its full span). Guards against misreading "6 engaged vs 135 visits" as 129 bots. Tests: purge_source removes only the target source's copies; endpoint reports purged. Co-Authored-By: Claude Opus 4.8 --- frontend/src/routes/admin/+page.svelte | 1 + goodnews/api.py | 6 +++++- goodnews/newsimg.py | 25 +++++++++++++++++++++++++ tests/test_admin.py | 3 ++- tests/test_newsimg.py | 16 ++++++++++++++++ 5 files changed, 49 insertions(+), 2 deletions(-) diff --git a/frontend/src/routes/admin/+page.svelte b/frontend/src/routes/admin/+page.svelte index 4007681..cdcde1c 100644 --- a/frontend/src/routes/admin/+page.svelte +++ b/frontend/src/routes/admin/+page.svelte @@ -1091,6 +1091,7 @@

export audience CSV ({range}d) ↓

Engaged readers (distinct visitor-days with a real gesture or deliberate action)

+

Engagement tracking began 2026-06-30 — rolling windows are still filling, so a low 7d/{range}d here is partly warm-up, not all bots. Compare d7 confidently after a week, the {range}d window after {range} days.

{stats.visitors.engaged_today}Today
{stats.visitors.engaged_d7}Last 7 days
diff --git a/goodnews/api.py b/goodnews/api.py index b281beb..95c4713 100644 --- a/goodnews/api.py +++ b/goodnews/api.py @@ -1437,7 +1437,11 @@ def create_app() -> FastAPI: if cur.rowcount == 0: raise HTTPException(status_code=404, detail="source not found") conn.commit() - return {"ok": True, "policy": pol} + # Leaving 'cache' (e.g. permission revoked) → take the re-hosted copies down now, + # not just make them inaccessible. Setting TO 'cache' just flips the flag; the + # cycle warms it. + purged = newsimg.purge_source(conn, sid) if pol != "cache" else 0 + return {"ok": True, "policy": pol, "purged": purged} # --- Source candidates (supervised add-a-source pipeline) ---------------- diff --git a/goodnews/newsimg.py b/goodnews/newsimg.py index 281ce10..015762a 100644 --- a/goodnews/newsimg.py +++ b/goodnews/newsimg.py @@ -216,6 +216,31 @@ def fetch_and_cache(url: str | None) -> Path | None: return dest +def purge_source(conn, source_id: int) -> int: + """Delete every cached file for a source's article image URLs. Called when a source + leaves 'cache' policy (revoked permission / re-classified), so the re-hosted copies + come down immediately rather than lingering inaccessible on disk. Returns webp count.""" + rows = conn.execute( + "SELECT DISTINCT image_url FROM articles WHERE source_id = ? " + "AND image_url IS NOT NULL AND image_url != ''", + (source_id,), + ).fetchall() + cdir = cache_dir() + removed = 0 + for r in rows: + key = _key(r[0]) + for suffix in (".webp", ".fail"): + p = cdir / f"{key}{suffix}" + try: + if p.exists(): + p.unlink() + if suffix == ".webp": + removed += 1 + except OSError: + pass + return removed + + def warm(conn, limit: int = 200) -> int: """Pre-fetch display copies for the newest ACCEPTED, CANONICAL articles whose SOURCE is cleared to cache (image_policy='cache'), so the API only ever serves cache hits. diff --git a/tests/test_admin.py b/tests/test_admin.py index 4827e9e..51c6f2a 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -600,6 +600,7 @@ def test_source_image_policy(tmp_path, monkeypatch): c = sqlite3.connect(os.environ["GOODNEWS_DB"]) assert c.execute("SELECT image_policy FROM sources WHERE id=2").fetchone()[0] == "cache" c.close() - assert tc.post("/api/admin/sources/2/image-policy", json={"policy": "remote"}).json()["policy"] == "remote" + r = tc.post("/api/admin/sources/2/image-policy", json={"policy": "remote"}).json() + assert r["policy"] == "remote" and r["purged"] == 0 # leaving cache purges (no files here) assert tc.post("/api/admin/sources/2/image-policy", json={"policy": "bogus"}).status_code == 422 assert tc.post("/api/admin/sources/999/image-policy", json={"policy": "cache"}).status_code == 404 diff --git a/tests/test_newsimg.py b/tests/test_newsimg.py index 3976847..07636a6 100644 --- a/tests/test_newsimg.py +++ b/tests/test_newsimg.py @@ -193,6 +193,22 @@ def test_warm_only_caches_cache_policy_sources(cache, monkeypatch): assert newsimg.warm(conn) == 0 # idempotent — already cached +def test_purge_source_removes_cached_copies(cache, monkeypatch): + conn = connect(":memory:"); init_db(conn) + conn.execute("INSERT INTO sources (id,name,feed_url,image_policy) VALUES (1,'C','http://c/f','cache')") + conn.execute("INSERT INTO sources (id,name,feed_url,image_policy) VALUES (2,'O','http://o/f','cache')") + for aid, sid, img in ((1, 1, "https://x/1.jpg"), (2, 1, "https://x/2.jpg"), (3, 2, "https://x/3.jpg")): + conn.execute("INSERT INTO articles (id,source_id,canonical_url,title,url_hash,image_url) " + "VALUES (?,?,?,?,?,?)", (aid, sid, f"http://s/{aid}", f"t{aid}", f"h{aid}", img)) + conn.execute("INSERT INTO article_scores (article_id, accepted) VALUES (?,1)", (aid,)) + conn.commit() + assert newsimg.warm(conn) == 3 # all three cached (both sources 'cache') + removed = newsimg.purge_source(conn, 1) # source 1 leaves cache → its 2 copies go + assert removed == 2 + assert newsimg.path_for("https://x/1.jpg") is None and newsimg.path_for("https://x/2.jpg") is None + assert newsimg.path_for("https://x/3.jpg") is not None # source 2 untouched + + @pytest.fixture def client(tmp_path, monkeypatch): monkeypatch.setenv("GOODNEWS_DB", str(tmp_path / "t.sqlite3"))