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:
parent
8676ace102
commit
2d02b56b65
3 changed files with 160 additions and 0 deletions
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue