feat(admin): WordPress→DB sync for scheduled slots

Adds sync_db_from_wordpress() that treats WordPress as source of truth:
- future posts: update scheduled_publish_at to WP's actual date
- draft posts: clear scheduled_publish_at (not yet scheduled)
- published posts: mark article as 'published' in DB
- trashed/deleted posts: clear wp_post_id + wp_post_url + slot so article
  can be re-processed

Exposed via POST /admin/wp-sync with a sync button on the schedule page.
Run after any manual rescheduling in WordPress to bring DB back in sync.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OliverGiertz 2026-04-10 08:53:44 +00:00
parent 8676ace102
commit 2d02b56b65
3 changed files with 160 additions and 0 deletions

View file

@ -931,6 +931,28 @@ def admin_transition_article(request: Request, article_id: int, target_status: s
return _dashboard_redirect(msg=f"Ungueltiger Statuswechsel fuer Artikel #{article_id}", msg_type="error")
@router.post("/admin/wp-sync")
def admin_wp_sync(request: Request):
"""Sync scheduled_publish_at and WP references in the DB from WordPress."""
user = _admin_user(request)
if not user:
return RedirectResponse(url="/admin/login", status_code=303)
try:
from .wordpress import sync_db_from_wordpress
stats = sync_db_from_wordpress()
msg = (
f"WP-Sync abgeschlossen: "
f"{stats['slot_updated']} Slots aktualisiert, "
f"{stats['slot_cleared_draft']} Slots geleert (Draft), "
f"{stats['marked_published']} als veröffentlicht markiert, "
f"{stats['wp_reference_cleared']} WP-Referenzen gelöscht (Papierkorb), "
f"{stats['already_in_sync']} bereits synchron."
)
return RedirectResponse(url=f"/admin/schedule?msg={msg}&type=success", status_code=303)
except Exception as exc:
return RedirectResponse(url=f"/admin/schedule?msg=Sync fehlgeschlagen: {exc}&type=error", status_code=303)
@router.post("/admin/articles/{article_id}/retry")
def admin_retry_article(request: Request, article_id: int):
"""Reset a failed article to 'new' so the pipeline picks it up on next run."""

View file

@ -559,3 +559,131 @@ def delete_wp_post(wp_post_id: int) -> None:
method="DELETE",
endpoint=f"posts/{wp_post_id}?force=true",
)
def sync_db_from_wordpress() -> dict[str, Any]:
"""Sync scheduled_publish_at and wp_post_url in the DB from WordPress.
WordPress is treated as the source of truth for scheduling.
For each DB article that has a wp_post_id:
- If WP post exists as 'future': update scheduled_publish_at to WP date.
- If WP post exists as 'draft': clear scheduled_publish_at (not yet scheduled).
- If WP post exists as 'publish': mark article as published in DB.
- If WP post is trashed/deleted (404 or trash status): clear wp_post_id,
wp_post_url, and scheduled_publish_at so the article can be re-processed.
Returns a stats dict with counts of each action taken.
"""
from .db import get_conn
settings = get_settings()
if not settings.wordpress_base_url or not settings.wordpress_username or not settings.wordpress_app_password:
raise RuntimeError("WordPress Konfiguration fehlt")
auth = _auth_header(settings.wordpress_username, settings.wordpress_app_password)
base_url = settings.wordpress_base_url.rstrip("/")
# Fetch all future + draft + published WP posts in one pass (up to 300 per status)
wp_posts: dict[int, dict] = {}
for status in ("future", "draft", "publish"):
for page in range(1, 4): # max 300 per status
try:
result = _wp_request(
base_url=base_url,
auth_header=auth,
method="GET",
endpoint=f"posts?status={status}&per_page=100&page={page}&_fields=id,date,status,link",
)
except Exception:
break
if not isinstance(result, list) or not result:
break
for post in result:
try:
wp_posts[int(post["id"])] = post
except Exception:
pass
if len(result) < 100:
break
# Load all DB articles that have a wp_post_id
with get_conn() as conn:
rows = conn.execute(
"""
SELECT id, wp_post_id, wp_post_url, scheduled_publish_at, status
FROM articles
WHERE wp_post_id IS NOT NULL
AND status NOT IN ('no_image')
ORDER BY id
"""
).fetchall()
stats: dict[str, int] = {
"total_db_articles": len(rows),
"wp_posts_found": len(wp_posts),
"slot_updated": 0,
"slot_cleared_draft": 0,
"marked_published": 0,
"wp_reference_cleared": 0,
"already_in_sync": 0,
}
for row in rows:
article_id = row["id"]
wp_post_id = int(row["wp_post_id"])
wp_post = wp_posts.get(wp_post_id)
if wp_post is None:
# Post not found in future/draft/publish — likely trashed or deleted
# Clear wp reference so article can be re-processed if needed
with get_conn() as conn:
conn.execute(
"""UPDATE articles
SET wp_post_id = NULL, wp_post_url = NULL, scheduled_publish_at = NULL
WHERE id = ?""",
(article_id,),
)
stats["wp_reference_cleared"] += 1
continue
wp_status = wp_post.get("status", "")
wp_date = wp_post.get("date", "") # local CET datetime, e.g. "2026-05-05T09:00:00"
wp_link = wp_post.get("link") or row["wp_post_url"]
if wp_status == "publish":
# Already published in WP — mark as published in DB if not already
if row["status"] != "published":
with get_conn() as conn:
conn.execute(
"UPDATE articles SET status = 'published', wp_post_url = ? WHERE id = ?",
(wp_link, article_id),
)
stats["marked_published"] += 1
else:
stats["already_in_sync"] += 1
elif wp_status == "future":
# Scheduled — sync the date into scheduled_publish_at
current_slot = row["scheduled_publish_at"] or ""
# WP returns e.g. "2026-05-05T09:00:00" — compare ignoring seconds
if current_slot[:16] != wp_date[:16]:
with get_conn() as conn:
conn.execute(
"UPDATE articles SET scheduled_publish_at = ?, wp_post_url = ? WHERE id = ?",
(wp_date, wp_link, article_id),
)
stats["slot_updated"] += 1
else:
stats["already_in_sync"] += 1
elif wp_status == "draft":
# Draft without a schedule — clear scheduled_publish_at if set
if row["scheduled_publish_at"]:
with get_conn() as conn:
conn.execute(
"UPDATE articles SET scheduled_publish_at = NULL WHERE id = ?",
(article_id,),
)
stats["slot_cleared_draft"] += 1
else:
stats["already_in_sync"] += 1
return stats

View file

@ -37,6 +37,16 @@
</section>
{% endif %}
<section class="card" style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:1rem;">
<div>
<h2 style="margin:0;">WordPress → DB Synchronisieren</h2>
<p class="subtle" style="margin:4px 0 0;">Liest alle geplanten WP-Beiträge und aktualisiert die Slots in der lokalen DB.<br>Nutze dies nach manuellen Änderungen in WordPress.</p>
</div>
<form method="post" action="/admin/wp-sync">
<button type="submit">🔄 Jetzt synchronisieren</button>
</form>
</section>
<section class="card">
<h2>Slot-Übersicht (nächste 60 Tage)</h2>
<div class="summary-bar">