feat(admin): add feed/source management, rewrite editor, reopen flow, and WP block output
This commit is contained in:
parent
50f737f434
commit
88b2ee1d01
9 changed files with 555 additions and 70 deletions
|
|
@ -23,7 +23,11 @@ from .relevance import article_age_days, article_relevance
|
||||||
from .rewrite import rewrite_article_text
|
from .rewrite import rewrite_article_text
|
||||||
from .repositories import (
|
from .repositories import (
|
||||||
FeedCreate,
|
FeedCreate,
|
||||||
|
FeedUpdate,
|
||||||
SourceCreate,
|
SourceCreate,
|
||||||
|
SourceUpdate,
|
||||||
|
delete_feed,
|
||||||
|
delete_source,
|
||||||
create_feed,
|
create_feed,
|
||||||
create_source,
|
create_source,
|
||||||
get_article_by_id,
|
get_article_by_id,
|
||||||
|
|
@ -36,6 +40,8 @@ from .repositories import (
|
||||||
set_article_image_decision,
|
set_article_image_decision,
|
||||||
set_article_legal_review,
|
set_article_legal_review,
|
||||||
upsert_article,
|
upsert_article,
|
||||||
|
update_feed,
|
||||||
|
update_source,
|
||||||
update_article_status,
|
update_article_status,
|
||||||
ArticleUpsert,
|
ArticleUpsert,
|
||||||
)
|
)
|
||||||
|
|
@ -48,10 +54,11 @@ ALLOWED_TRANSITIONS: dict[str, tuple[str, ...]] = {
|
||||||
"new": ("rewrite", "close"),
|
"new": ("rewrite", "close"),
|
||||||
"rewrite": ("publish", "close"),
|
"rewrite": ("publish", "close"),
|
||||||
"publish": ("published", "close"),
|
"publish": ("published", "close"),
|
||||||
"published": ("close",),
|
"published": ("rewrite", "close"),
|
||||||
"close": ("rewrite",),
|
"close": ("rewrite",),
|
||||||
}
|
}
|
||||||
IMAGE_PROXY_USER_AGENT = "rss-news-admin/1.0"
|
IMAGE_PROXY_USER_AGENT = "rss-news-admin/1.0"
|
||||||
|
_UNSET = object()
|
||||||
|
|
||||||
|
|
||||||
def _admin_user(request: Request) -> str | None:
|
def _admin_user(request: Request) -> str | None:
|
||||||
|
|
@ -364,6 +371,51 @@ def _run_connectivity_check(target: dict[str, str]) -> dict[str, object]:
|
||||||
row["duration_ms"] = int((time.perf_counter() - started) * 1000)
|
row["duration_ms"] = int((time.perf_counter() - started) * 1000)
|
||||||
|
|
||||||
|
|
||||||
|
def _upsert_article_from_existing(
|
||||||
|
article: dict,
|
||||||
|
*,
|
||||||
|
content_rewritten: str | None = None,
|
||||||
|
status: str | None = None,
|
||||||
|
wp_post_id: int | None | object = _UNSET,
|
||||||
|
wp_post_url: str | None | object = _UNSET,
|
||||||
|
publish_attempts: int | object = _UNSET,
|
||||||
|
publish_last_error: str | None | object = _UNSET,
|
||||||
|
published_to_wp_at: str | None | object = _UNSET,
|
||||||
|
) -> None:
|
||||||
|
rewritten = article.get("content_rewritten") if content_rewritten is None else content_rewritten
|
||||||
|
upsert_article(
|
||||||
|
ArticleUpsert(
|
||||||
|
feed_id=article.get("feed_id"),
|
||||||
|
source_article_id=article.get("source_article_id"),
|
||||||
|
source_hash=article.get("source_hash"),
|
||||||
|
title=article.get("title"),
|
||||||
|
source_url=article.get("source_url"),
|
||||||
|
canonical_url=article.get("canonical_url"),
|
||||||
|
published_at=article.get("published_at"),
|
||||||
|
author=article.get("author"),
|
||||||
|
summary=article.get("summary"),
|
||||||
|
content_raw=article.get("content_raw"),
|
||||||
|
content_rewritten=rewritten,
|
||||||
|
image_urls_json=article.get("image_urls_json"),
|
||||||
|
press_contact=article.get("press_contact"),
|
||||||
|
source_name_snapshot=article.get("source_name_snapshot"),
|
||||||
|
source_terms_url_snapshot=article.get("source_terms_url_snapshot"),
|
||||||
|
source_license_name_snapshot=article.get("source_license_name_snapshot"),
|
||||||
|
legal_checked=bool(int(article.get("legal_checked", 0))),
|
||||||
|
legal_checked_at=article.get("legal_checked_at"),
|
||||||
|
legal_note=article.get("legal_note"),
|
||||||
|
wp_post_id=article.get("wp_post_id") if wp_post_id is _UNSET else wp_post_id,
|
||||||
|
wp_post_url=article.get("wp_post_url") if wp_post_url is _UNSET else wp_post_url,
|
||||||
|
publish_attempts=int(article.get("publish_attempts", 0)) if publish_attempts is _UNSET else publish_attempts,
|
||||||
|
publish_last_error=article.get("publish_last_error") if publish_last_error is _UNSET else publish_last_error,
|
||||||
|
published_to_wp_at=article.get("published_to_wp_at") if published_to_wp_at is _UNSET else published_to_wp_at,
|
||||||
|
word_count=len(str(rewritten or "").split()),
|
||||||
|
status=article.get("status") if status is None else status,
|
||||||
|
meta_json=article.get("meta_json"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/admin", response_class=HTMLResponse)
|
@router.get("/admin", response_class=HTMLResponse)
|
||||||
def admin_index(request: Request):
|
def admin_index(request: Request):
|
||||||
user = _admin_user(request)
|
user = _admin_user(request)
|
||||||
|
|
@ -427,7 +479,7 @@ def admin_dashboard(request: Request):
|
||||||
articles = list_articles(limit=100, status_filter=internal_filter)
|
articles = list_articles(limit=100, status_filter=internal_filter)
|
||||||
else:
|
else:
|
||||||
status_filter = ""
|
status_filter = ""
|
||||||
articles = list_articles(limit=100)
|
articles = [a for a in list_articles(limit=250) if internal_to_ui_status(a.get("status")) != "close"][:100]
|
||||||
for article in articles:
|
for article in articles:
|
||||||
meta = _parse_meta_json(article.get("meta_json"))
|
meta = _parse_meta_json(article.get("meta_json"))
|
||||||
extraction = meta.get("extraction") if isinstance(meta.get("extraction"), dict) else {}
|
extraction = meta.get("extraction") if isinstance(meta.get("extraction"), dict) else {}
|
||||||
|
|
@ -659,6 +711,54 @@ def admin_create_source(
|
||||||
return _dashboard_redirect(msg="Quelle gespeichert")
|
return _dashboard_redirect(msg="Quelle gespeichert")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/admin/sources/{source_id}/update")
|
||||||
|
def admin_update_source(
|
||||||
|
request: Request,
|
||||||
|
source_id: int,
|
||||||
|
name: str = Form(...),
|
||||||
|
base_url: str = Form(""),
|
||||||
|
terms_url: str = Form(""),
|
||||||
|
license_name: str = Form(""),
|
||||||
|
risk_level: str = Form("yellow"),
|
||||||
|
is_enabled: str = Form("1"),
|
||||||
|
notes: str = Form(""),
|
||||||
|
last_reviewed_at: str = Form(""),
|
||||||
|
):
|
||||||
|
user = _admin_user(request)
|
||||||
|
if not user:
|
||||||
|
return RedirectResponse(url="/admin/login", status_code=303)
|
||||||
|
try:
|
||||||
|
ok = update_source(
|
||||||
|
source_id,
|
||||||
|
SourceUpdate(
|
||||||
|
name=name,
|
||||||
|
base_url=base_url or None,
|
||||||
|
terms_url=terms_url or None,
|
||||||
|
license_name=license_name or None,
|
||||||
|
risk_level=risk_level,
|
||||||
|
is_enabled=is_enabled == "1",
|
||||||
|
notes=notes or None,
|
||||||
|
last_reviewed_at=last_reviewed_at or None,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
return _dashboard_redirect(msg=f"Quelle #{source_id} Update fehlgeschlagen: {exc}", msg_type="error")
|
||||||
|
if not ok:
|
||||||
|
return _dashboard_redirect(msg=f"Quelle #{source_id} nicht gefunden", msg_type="error")
|
||||||
|
return _dashboard_redirect(msg=f"Quelle #{source_id} aktualisiert")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/admin/sources/{source_id}/delete")
|
||||||
|
def admin_delete_source(request: Request, source_id: int):
|
||||||
|
user = _admin_user(request)
|
||||||
|
if not user:
|
||||||
|
return RedirectResponse(url="/admin/login", status_code=303)
|
||||||
|
ok = delete_source(source_id)
|
||||||
|
if not ok:
|
||||||
|
return _dashboard_redirect(msg=f"Quelle #{source_id} nicht gefunden", msg_type="error")
|
||||||
|
return _dashboard_redirect(msg=f"Quelle #{source_id} gelöscht")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/admin/feeds/create")
|
@router.post("/admin/feeds/create")
|
||||||
def admin_create_feed(
|
def admin_create_feed(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|
@ -684,6 +784,46 @@ def admin_create_feed(
|
||||||
return _dashboard_redirect(msg="Feed gespeichert")
|
return _dashboard_redirect(msg="Feed gespeichert")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/admin/feeds/{feed_id}/update")
|
||||||
|
def admin_update_feed(
|
||||||
|
request: Request,
|
||||||
|
feed_id: int,
|
||||||
|
name: str = Form(...),
|
||||||
|
url: str = Form(...),
|
||||||
|
source_id: str = Form(""),
|
||||||
|
is_enabled: str = Form("1"),
|
||||||
|
):
|
||||||
|
user = _admin_user(request)
|
||||||
|
if not user:
|
||||||
|
return RedirectResponse(url="/admin/login", status_code=303)
|
||||||
|
try:
|
||||||
|
ok = update_feed(
|
||||||
|
feed_id,
|
||||||
|
FeedUpdate(
|
||||||
|
name=name,
|
||||||
|
url=url,
|
||||||
|
source_id=_to_optional_int(source_id),
|
||||||
|
is_enabled=is_enabled == "1",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
return _dashboard_redirect(msg=f"Feed #{feed_id} Update fehlgeschlagen: {exc}", msg_type="error")
|
||||||
|
if not ok:
|
||||||
|
return _dashboard_redirect(msg=f"Feed #{feed_id} nicht gefunden", msg_type="error")
|
||||||
|
return _dashboard_redirect(msg=f"Feed #{feed_id} aktualisiert")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/admin/feeds/{feed_id}/delete")
|
||||||
|
def admin_delete_feed(request: Request, feed_id: int):
|
||||||
|
user = _admin_user(request)
|
||||||
|
if not user:
|
||||||
|
return RedirectResponse(url="/admin/login", status_code=303)
|
||||||
|
ok = delete_feed(feed_id)
|
||||||
|
if not ok:
|
||||||
|
return _dashboard_redirect(msg=f"Feed #{feed_id} nicht gefunden", msg_type="error")
|
||||||
|
return _dashboard_redirect(msg=f"Feed #{feed_id} gelöscht")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/admin/ingestion/run")
|
@router.post("/admin/ingestion/run")
|
||||||
def admin_run_ingestion(request: Request, feed_id: str = Form("")):
|
def admin_run_ingestion(request: Request, feed_id: str = Form("")):
|
||||||
user = _admin_user(request)
|
user = _admin_user(request)
|
||||||
|
|
@ -719,41 +859,51 @@ def admin_rewrite_run(request: Request, article_id: int):
|
||||||
rewritten = rewrite_article_text(article)
|
rewritten = rewrite_article_text(article)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return _dashboard_redirect(msg=f"Rewrite fehlgeschlagen fuer Artikel #{article_id}: {exc}", msg_type="error")
|
return _dashboard_redirect(msg=f"Rewrite fehlgeschlagen fuer Artikel #{article_id}: {exc}", msg_type="error")
|
||||||
|
_upsert_article_from_existing(article, content_rewritten=rewritten, status="approved")
|
||||||
upsert_article(
|
|
||||||
ArticleUpsert(
|
|
||||||
feed_id=article.get("feed_id"),
|
|
||||||
source_article_id=article.get("source_article_id"),
|
|
||||||
source_hash=article.get("source_hash"),
|
|
||||||
title=article.get("title"),
|
|
||||||
source_url=article.get("source_url"),
|
|
||||||
canonical_url=article.get("canonical_url"),
|
|
||||||
published_at=article.get("published_at"),
|
|
||||||
author=article.get("author"),
|
|
||||||
summary=article.get("summary"),
|
|
||||||
content_raw=article.get("content_raw"),
|
|
||||||
content_rewritten=rewritten,
|
|
||||||
image_urls_json=article.get("image_urls_json"),
|
|
||||||
press_contact=article.get("press_contact"),
|
|
||||||
source_name_snapshot=article.get("source_name_snapshot"),
|
|
||||||
source_terms_url_snapshot=article.get("source_terms_url_snapshot"),
|
|
||||||
source_license_name_snapshot=article.get("source_license_name_snapshot"),
|
|
||||||
legal_checked=bool(int(article.get("legal_checked", 0))),
|
|
||||||
legal_checked_at=article.get("legal_checked_at"),
|
|
||||||
legal_note=article.get("legal_note"),
|
|
||||||
wp_post_id=article.get("wp_post_id"),
|
|
||||||
wp_post_url=article.get("wp_post_url"),
|
|
||||||
publish_attempts=int(article.get("publish_attempts", 0)),
|
|
||||||
publish_last_error=article.get("publish_last_error"),
|
|
||||||
published_to_wp_at=article.get("published_to_wp_at"),
|
|
||||||
word_count=len(rewritten.split()),
|
|
||||||
status="approved",
|
|
||||||
meta_json=article.get("meta_json"),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return _dashboard_redirect(msg=f"Rewrite fertig fuer Artikel #{article_id} -> publish")
|
return _dashboard_redirect(msg=f"Rewrite fertig fuer Artikel #{article_id} -> publish")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/admin/articles/{article_id}/rewrite-save")
|
||||||
|
def admin_rewrite_save(request: Request, article_id: int, content_rewritten: str = Form(...)):
|
||||||
|
user = _admin_user(request)
|
||||||
|
if not user:
|
||||||
|
return RedirectResponse(url="/admin/login", status_code=303)
|
||||||
|
article = get_article_by_id(article_id)
|
||||||
|
if not article:
|
||||||
|
return _dashboard_redirect(msg=f"Artikel #{article_id} nicht gefunden", msg_type="error")
|
||||||
|
text = (content_rewritten or "").strip()
|
||||||
|
if not text:
|
||||||
|
return RedirectResponse(
|
||||||
|
url=f"/admin/articles/{article_id}?msg=Rewrite-Text%20darf%20nicht%20leer%20sein&type=error",
|
||||||
|
status_code=303,
|
||||||
|
)
|
||||||
|
_upsert_article_from_existing(article, content_rewritten=text)
|
||||||
|
return RedirectResponse(url=f"/admin/articles/{article_id}?msg=Rewrite-Text%20gespeichert&type=success", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/admin/articles/{article_id}/reopen")
|
||||||
|
def admin_reopen_article(request: Request, article_id: int):
|
||||||
|
user = _admin_user(request)
|
||||||
|
if not user:
|
||||||
|
return RedirectResponse(url="/admin/login", status_code=303)
|
||||||
|
article = get_article_by_id(article_id)
|
||||||
|
if not article:
|
||||||
|
return _dashboard_redirect(msg=f"Artikel #{article_id} nicht gefunden", msg_type="error")
|
||||||
|
_upsert_article_from_existing(
|
||||||
|
article,
|
||||||
|
status="rewrite",
|
||||||
|
wp_post_id=None,
|
||||||
|
wp_post_url=None,
|
||||||
|
publish_attempts=0,
|
||||||
|
publish_last_error=None,
|
||||||
|
published_to_wp_at=None,
|
||||||
|
)
|
||||||
|
return RedirectResponse(
|
||||||
|
url=f"/admin/articles/{article_id}?msg=Artikel%20zurueck%20in%20Rewrite-Workflow%20gesetzt&type=success",
|
||||||
|
status_code=303,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/admin/articles/{article_id}/transition")
|
@router.post("/admin/articles/{article_id}/transition")
|
||||||
def admin_transition_article(request: Request, article_id: int, target_status: str = Form(...), note: str = Form("")):
|
def admin_transition_article(request: Request, article_id: int, target_status: str = Form(...), note: str = Form("")):
|
||||||
user = _admin_user(request)
|
user = _admin_user(request)
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,26 @@ class FeedCreate:
|
||||||
is_enabled: bool
|
is_enabled: bool
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SourceUpdate:
|
||||||
|
name: str
|
||||||
|
base_url: str | None
|
||||||
|
terms_url: str | None
|
||||||
|
license_name: str | None
|
||||||
|
risk_level: str
|
||||||
|
is_enabled: bool
|
||||||
|
notes: str | None
|
||||||
|
last_reviewed_at: str | None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class FeedUpdate:
|
||||||
|
name: str
|
||||||
|
url: str
|
||||||
|
source_id: int | None
|
||||||
|
is_enabled: bool
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class RunCreate:
|
class RunCreate:
|
||||||
run_type: str
|
run_type: str
|
||||||
|
|
@ -118,6 +138,35 @@ def get_source_by_id(source_id: int) -> dict[str, Any] | None:
|
||||||
return dict(row) if row else None
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def update_source(source_id: int, payload: SourceUpdate) -> bool:
|
||||||
|
with get_conn() as conn:
|
||||||
|
cur = conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE sources
|
||||||
|
SET name = ?, base_url = ?, terms_url = ?, license_name = ?, risk_level = ?, is_enabled = ?, notes = ?, last_reviewed_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
payload.name.strip(),
|
||||||
|
payload.base_url,
|
||||||
|
payload.terms_url,
|
||||||
|
payload.license_name,
|
||||||
|
payload.risk_level,
|
||||||
|
1 if payload.is_enabled else 0,
|
||||||
|
payload.notes,
|
||||||
|
payload.last_reviewed_at,
|
||||||
|
source_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return cur.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
def delete_source(source_id: int) -> bool:
|
||||||
|
with get_conn() as conn:
|
||||||
|
cur = conn.execute("DELETE FROM sources WHERE id = ?", (source_id,))
|
||||||
|
return cur.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
def create_feed(payload: FeedCreate) -> int:
|
def create_feed(payload: FeedCreate) -> int:
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
cur = conn.execute(
|
cur = conn.execute(
|
||||||
|
|
@ -177,6 +226,31 @@ def get_feed_by_id(feed_id: int) -> dict[str, Any] | None:
|
||||||
return dict(row) if row else None
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def update_feed(feed_id: int, payload: FeedUpdate) -> bool:
|
||||||
|
with get_conn() as conn:
|
||||||
|
cur = conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE feeds
|
||||||
|
SET name = ?, url = ?, source_id = ?, is_enabled = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
payload.name.strip(),
|
||||||
|
payload.url.strip(),
|
||||||
|
payload.source_id,
|
||||||
|
1 if payload.is_enabled else 0,
|
||||||
|
feed_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return cur.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
def delete_feed(feed_id: int) -> bool:
|
||||||
|
with get_conn() as conn:
|
||||||
|
cur = conn.execute("DELETE FROM feeds WHERE id = ?", (feed_id,))
|
||||||
|
return cur.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
def update_feed_fetch_state(feed_id: int, etag: str | None, last_modified: str | None) -> None:
|
def update_feed_fetch_state(feed_id: int, etag: str | None, last_modified: str | None) -> None:
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
|
|
|
||||||
|
|
@ -61,17 +61,18 @@ def _selected_image_url_from_meta(meta_json: str | None) -> str | None:
|
||||||
return selected if isinstance(selected, str) and selected.strip() else None
|
return selected if isinstance(selected, str) and selected.strip() else None
|
||||||
|
|
||||||
|
|
||||||
def _download_image_bytes(url: str) -> tuple[bytes, str]:
|
def _download_image_bytes(url: str, referer: str | None = None) -> tuple[bytes, str]:
|
||||||
req = Request(
|
headers = {
|
||||||
url=url,
|
|
||||||
headers={
|
|
||||||
"User-Agent": "rss-news-publisher/1.0",
|
"User-Agent": "rss-news-publisher/1.0",
|
||||||
"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
|
"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
|
||||||
},
|
}
|
||||||
)
|
if referer:
|
||||||
|
headers["Referer"] = referer
|
||||||
|
req = Request(url=url, headers=headers)
|
||||||
with urlopen(req, timeout=20) as resp:
|
with urlopen(req, timeout=20) as resp:
|
||||||
raw = resp.read()
|
raw = resp.read()
|
||||||
content_type = resp.headers.get("Content-Type", "application/octet-stream")
|
content_type = resp.headers.get("Content-Type", "application/octet-stream")
|
||||||
|
content_type = content_type.split(";")[0].strip() if content_type else "application/octet-stream"
|
||||||
if not content_type.lower().startswith("image/"):
|
if not content_type.lower().startswith("image/"):
|
||||||
raise RuntimeError(f"Ausgewählte Bild-URL liefert kein Bild ({content_type})")
|
raise RuntimeError(f"Ausgewählte Bild-URL liefert kein Bild ({content_type})")
|
||||||
return raw, content_type
|
return raw, content_type
|
||||||
|
|
@ -94,7 +95,7 @@ def _upload_featured_media(
|
||||||
article_title: str,
|
article_title: str,
|
||||||
source_url: str,
|
source_url: str,
|
||||||
) -> int:
|
) -> int:
|
||||||
image_bytes, content_type = _download_image_bytes(image_url)
|
image_bytes, content_type = _download_image_bytes(image_url, referer=source_url or None)
|
||||||
filename = _guess_filename(image_url, content_type)
|
filename = _guess_filename(image_url, content_type)
|
||||||
|
|
||||||
media_url = f"{base_url.rstrip('/')}/wp-json/wp/v2/media"
|
media_url = f"{base_url.rstrip('/')}/wp-json/wp/v2/media"
|
||||||
|
|
@ -143,6 +144,29 @@ def _as_paragraph_html(text: str) -> str:
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _as_block_paragraphs(text: str) -> str:
|
||||||
|
chunks = [chunk.strip() for chunk in re.split(r"\n{2,}", text.strip()) if chunk.strip()]
|
||||||
|
if not chunks:
|
||||||
|
return ""
|
||||||
|
lines = []
|
||||||
|
for chunk in chunks:
|
||||||
|
compact = re.sub(r"\s*\n\s*", " ", chunk)
|
||||||
|
lines.append(f"<!-- wp:paragraph --><p>{escape(compact)}</p><!-- /wp:paragraph -->")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _as_block_heading(level: int, text: str) -> str:
|
||||||
|
safe_level = min(6, max(1, int(level)))
|
||||||
|
return f'<!-- wp:heading {{"level":{safe_level}}} --><h{safe_level}>{escape(text)}</h{safe_level}><!-- /wp:heading -->'
|
||||||
|
|
||||||
|
|
||||||
|
def _as_block_list(items: list[str]) -> str:
|
||||||
|
if not items:
|
||||||
|
return ""
|
||||||
|
content = "".join(f"<li>{item}</li>" for item in items)
|
||||||
|
return f"<!-- wp:list --><ul>{content}</ul><!-- /wp:list -->"
|
||||||
|
|
||||||
|
|
||||||
def _sanitize_publish_text(text: str) -> str:
|
def _sanitize_publish_text(text: str) -> str:
|
||||||
raw = (text or "").strip()
|
raw = (text or "").strip()
|
||||||
if not raw:
|
if not raw:
|
||||||
|
|
@ -164,11 +188,13 @@ def _build_post_content(article: dict[str, Any]) -> tuple[str, str | None]:
|
||||||
if not body_text:
|
if not body_text:
|
||||||
body_text = summary
|
body_text = summary
|
||||||
|
|
||||||
# Keep existing HTML if already present, otherwise wrap plain text into paragraphs.
|
# Keep existing HTML if already present, otherwise wrap plain text into block paragraphs.
|
||||||
has_html = bool(re.search(r"<[a-zA-Z][^>]*>", body_text))
|
has_html = bool(re.search(r"<[a-zA-Z][^>]*>", body_text))
|
||||||
body_html = body_text if has_html else _as_paragraph_html(body_text)
|
body_html = body_text if has_html else _as_block_paragraphs(body_text)
|
||||||
if not body_html:
|
if not body_html:
|
||||||
body_html = "<p>Kein Inhalt verfügbar.</p>"
|
body_html = "<!-- wp:paragraph --><p>Kein Inhalt verfügbar.</p><!-- /wp:paragraph -->"
|
||||||
|
elif has_html:
|
||||||
|
body_html = f"<!-- wp:html -->\n{body_html}\n<!-- /wp:html -->"
|
||||||
|
|
||||||
author = (article.get("author") or "").strip()
|
author = (article.get("author") or "").strip()
|
||||||
published_at = (article.get("published_at") or "").strip()
|
published_at = (article.get("published_at") or "").strip()
|
||||||
|
|
@ -176,35 +202,35 @@ def _build_post_content(article: dict[str, Any]) -> tuple[str, str | None]:
|
||||||
license_name = (article.get("source_license_name_snapshot") or "").strip()
|
license_name = (article.get("source_license_name_snapshot") or "").strip()
|
||||||
terms_url = (article.get("source_terms_url_snapshot") or "").strip()
|
terms_url = (article.get("source_terms_url_snapshot") or "").strip()
|
||||||
|
|
||||||
lead_html = f"<p><em>{escape(summary)}</em></p>\n" if summary else ""
|
lead_html = f"<!-- wp:paragraph --><p><em>{escape(summary)}</em></p><!-- /wp:paragraph -->\n" if summary else ""
|
||||||
|
|
||||||
facts: list[str] = []
|
facts: list[str] = []
|
||||||
if author:
|
if author:
|
||||||
facts.append(f"<li><strong>Autor:</strong> {escape(author)}</li>")
|
facts.append(f"<strong>Autor:</strong> {escape(author)}")
|
||||||
if published_at:
|
if published_at:
|
||||||
facts.append(f"<li><strong>Veröffentlicht (Quelle):</strong> {escape(published_at)}</li>")
|
facts.append(f"<strong>Veröffentlicht (Quelle):</strong> {escape(published_at)}")
|
||||||
if source_name:
|
if source_name:
|
||||||
facts.append(f"<li><strong>Quelle:</strong> {escape(source_name)}</li>")
|
facts.append(f"<strong>Quelle:</strong> {escape(source_name)}")
|
||||||
if license_name:
|
if license_name:
|
||||||
facts.append(f"<li><strong>Lizenz:</strong> {escape(license_name)}</li>")
|
facts.append(f"<strong>Lizenz:</strong> {escape(license_name)}")
|
||||||
if terms_url:
|
if terms_url:
|
||||||
facts.append(f"<li><strong>Lizenzhinweise:</strong> <a href=\"{escape(terms_url)}\">{escape(terms_url)}</a></li>")
|
facts.append(f"<strong>Lizenzhinweise:</strong> <a href=\"{escape(terms_url)}\">{escape(terms_url)}</a>")
|
||||||
|
|
||||||
facts_html = (
|
facts_html = ""
|
||||||
"<h3>Artikeldetails</h3>\n<ul>\n" + "\n".join(facts) + "\n</ul>\n"
|
if facts:
|
||||||
if facts
|
facts_html = _as_block_heading(3, "Artikeldetails") + "\n" + _as_block_list(facts)
|
||||||
else ""
|
|
||||||
)
|
attribution_parts = [
|
||||||
attribution_html = (
|
_as_block_heading(3, "Quelle"),
|
||||||
"<hr />\n<section class=\"rss-news-attribution\">\n"
|
f'<!-- wp:paragraph --><p>Originalartikel: <a href="{escape(source_url)}">{escape(source_url)}</a></p><!-- /wp:paragraph -->',
|
||||||
"<h3>Quelle</h3>\n"
|
]
|
||||||
f"<p>Originalartikel: <a href=\"{escape(source_url)}\">{escape(source_url)}</a></p>\n"
|
|
||||||
)
|
|
||||||
if canonical_url and canonical_url != source_url:
|
if canonical_url and canonical_url != source_url:
|
||||||
attribution_html += f"<p>Canonical: <a href=\"{escape(canonical_url)}\">{escape(canonical_url)}</a></p>\n"
|
attribution_parts.append(
|
||||||
attribution_html += "</section>"
|
f'<!-- wp:paragraph --><p>Canonical: <a href="{escape(canonical_url)}">{escape(canonical_url)}</a></p><!-- /wp:paragraph -->'
|
||||||
|
)
|
||||||
|
attribution_html = "\n".join(attribution_parts)
|
||||||
|
|
||||||
content = f"{lead_html}{body_html}\n\n{facts_html}{attribution_html}".strip()
|
content = f"{lead_html}{body_html}\n\n{facts_html}\n{attribution_html}".strip()
|
||||||
excerpt_source = summary or re.sub(r"\s+", " ", body_text).strip()
|
excerpt_source = summary or re.sub(r"\s+", " ", body_text).strip()
|
||||||
excerpt = excerpt_source[:220] if excerpt_source else None
|
excerpt = excerpt_source[:220] if excerpt_source else None
|
||||||
return content, excerpt
|
return content, excerpt
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,6 @@ ALLOWED_UI_TRANSITIONS: dict[str, set[str]] = {
|
||||||
"new": {"rewrite", "close"},
|
"new": {"rewrite", "close"},
|
||||||
"rewrite": {"publish", "close"},
|
"rewrite": {"publish", "close"},
|
||||||
"publish": {"published", "close"},
|
"publish": {"published", "close"},
|
||||||
"published": {"close"},
|
"published": {"rewrite", "close"},
|
||||||
"close": {"rewrite"},
|
"close": {"rewrite"},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,7 @@ th, td {
|
||||||
vertical-align: top;
|
vertical-align: top;
|
||||||
}
|
}
|
||||||
|
|
||||||
input, select, button {
|
input, select, button, textarea {
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
border: 1px solid #cbd5e1;
|
border: 1px solid #cbd5e1;
|
||||||
|
|
|
||||||
|
|
@ -164,6 +164,15 @@
|
||||||
<div class="pre">{{ article.content_raw or "-" }}</div>
|
<div class="pre">{{ article.content_raw or "-" }}</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Rewrite-Text (editierbar)</h2>
|
||||||
|
<form method="post" action="/admin/articles/{{ article.id }}/rewrite-save" class="stack">
|
||||||
|
<textarea name="content_rewritten" rows="14" style="width:100%;">{{ article.content_rewritten or "" }}</textarea>
|
||||||
|
<button type="submit">Rewrite-Text speichern</button>
|
||||||
|
</form>
|
||||||
|
<p class="subtle">Dieser Text wird für den WordPress-Entwurf verwendet, falls vorhanden.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<h2>Rechtsfreigabe</h2>
|
<h2>Rechtsfreigabe</h2>
|
||||||
<p><strong>Freigabe:</strong>
|
<p><strong>Freigabe:</strong>
|
||||||
|
|
@ -192,6 +201,11 @@
|
||||||
<button type="submit">Rewrite ausführen (OpenAI)</button>
|
<button type="submit">Rewrite ausführen (OpenAI)</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if article.status_ui == "published" %}
|
||||||
|
<form method="post" action="/admin/articles/{{ article.id }}/reopen" class="row" style="margin-bottom:8px;">
|
||||||
|
<button type="submit">Zurück in Rewrite-Workflow</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
<form method="post" action="/admin/articles/{{ article.id }}/transition" class="row">
|
<form method="post" action="/admin/articles/{{ article.id }}/transition" class="row">
|
||||||
<select name="target_status">
|
<select name="target_status">
|
||||||
{% for s in allowed_transitions %}
|
{% for s in allowed_transitions %}
|
||||||
|
|
|
||||||
|
|
@ -130,6 +130,99 @@
|
||||||
</table>
|
</table>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Quellen verwalten</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>ID</th><th>Name</th><th>URLs</th><th>Meta</th><th>Aktionen</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for s in sources %}
|
||||||
|
{% set source_form_id = 'source-update-' ~ s.id %}
|
||||||
|
<tr>
|
||||||
|
<td>#{{ s.id }}</td>
|
||||||
|
<td>
|
||||||
|
<input form="{{ source_form_id }}" name="name" value="{{ s.name }}" required />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input form="{{ source_form_id }}" name="base_url" value="{{ s.base_url or '' }}" placeholder="Base URL" />
|
||||||
|
<input form="{{ source_form_id }}" name="terms_url" value="{{ s.terms_url or '' }}" placeholder="Terms URL" />
|
||||||
|
<input form="{{ source_form_id }}" name="license_name" value="{{ s.license_name or '' }}" placeholder="Lizenz" />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<select form="{{ source_form_id }}" name="risk_level">
|
||||||
|
<option value="green" {% if s.risk_level == 'green' %}selected{% endif %}>green</option>
|
||||||
|
<option value="yellow" {% if s.risk_level == 'yellow' %}selected{% endif %}>yellow</option>
|
||||||
|
<option value="red" {% if s.risk_level == 'red' %}selected{% endif %}>red</option>
|
||||||
|
</select>
|
||||||
|
<select form="{{ source_form_id }}" name="is_enabled">
|
||||||
|
<option value="1" {% if s.is_enabled %}selected{% endif %}>aktiv</option>
|
||||||
|
<option value="0" {% if not s.is_enabled %}selected{% endif %}>inaktiv</option>
|
||||||
|
</select>
|
||||||
|
<input form="{{ source_form_id }}" name="last_reviewed_at" value="{{ s.last_reviewed_at or '' }}" placeholder="last_reviewed_at" />
|
||||||
|
<input form="{{ source_form_id }}" name="notes" value="{{ s.notes or '' }}" placeholder="Notiz" />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="inline">
|
||||||
|
<form method="post" action="/admin/sources/{{ s.id }}/update" id="{{ source_form_id }}" class="inline">
|
||||||
|
<button type="submit" class="secondary">Speichern</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/admin/sources/{{ s.id }}/delete" class="inline" onsubmit="return confirm('Quelle wirklich löschen?');">
|
||||||
|
<button type="submit">Löschen</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Feeds verwalten</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>ID</th><th>Name</th><th>URL</th><th>Quelle</th><th>Status</th><th>Aktionen</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for f in feeds %}
|
||||||
|
{% set feed_form_id = 'feed-update-' ~ f.id %}
|
||||||
|
<tr>
|
||||||
|
<td>#{{ f.id }}</td>
|
||||||
|
<td>
|
||||||
|
<input form="{{ feed_form_id }}" name="name" value="{{ f.name }}" required />
|
||||||
|
</td>
|
||||||
|
<td><input form="{{ feed_form_id }}" name="url" value="{{ f.url }}" required /></td>
|
||||||
|
<td>
|
||||||
|
<select form="{{ feed_form_id }}" name="source_id">
|
||||||
|
<option value="">-- keine --</option>
|
||||||
|
{% for s in sources %}
|
||||||
|
<option value="{{ s.id }}" {% if f.source_id == s.id %}selected{% endif %}>{{ s.name }} (#{{ s.id }})</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<select form="{{ feed_form_id }}" name="is_enabled">
|
||||||
|
<option value="1" {% if f.is_enabled %}selected{% endif %}>aktiv</option>
|
||||||
|
<option value="0" {% if not f.is_enabled %}selected{% endif %}>inaktiv</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="inline">
|
||||||
|
<form method="post" action="/admin/feeds/{{ f.id }}/update" id="{{ feed_form_id }}" class="inline">
|
||||||
|
<button type="submit" class="secondary">Speichern</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/admin/feeds/{{ f.id }}/delete" class="inline" onsubmit="return confirm('Feed wirklich löschen?');">
|
||||||
|
<button type="submit">Löschen</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<h2>Artikel (Review)</h2>
|
<h2>Artikel (Review)</h2>
|
||||||
<form method="get" action="/admin/dashboard" class="row filter-row">
|
<form method="get" action="/admin/dashboard" class="row filter-row">
|
||||||
|
|
|
||||||
|
|
@ -145,6 +145,132 @@ class TestAdminUi(unittest.TestCase):
|
||||||
self.assertIsNotNone(article)
|
self.assertIsNotNone(article)
|
||||||
self.assertIn("selected_url", article.get("meta_json", ""))
|
self.assertIn("selected_url", article.get("meta_json", ""))
|
||||||
|
|
||||||
|
def test_manage_source_and_feed(self) -> None:
|
||||||
|
source_id = create_source(
|
||||||
|
SourceCreate(
|
||||||
|
name="Edit Source",
|
||||||
|
base_url="https://example.org",
|
||||||
|
terms_url="https://example.org/terms",
|
||||||
|
license_name="cc-by",
|
||||||
|
risk_level="yellow",
|
||||||
|
is_enabled=True,
|
||||||
|
notes=None,
|
||||||
|
last_reviewed_at=None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
feed_id = create_feed(
|
||||||
|
FeedCreate(
|
||||||
|
name="Edit Feed",
|
||||||
|
url="https://example.org/feed.xml",
|
||||||
|
source_id=source_id,
|
||||||
|
is_enabled=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.client.post("/admin/login", data={"username": "admin", "password": "secret"}, follow_redirects=True)
|
||||||
|
|
||||||
|
update_source_res = self.client.post(
|
||||||
|
f"/admin/sources/{source_id}/update",
|
||||||
|
data={
|
||||||
|
"name": "Edit Source 2",
|
||||||
|
"base_url": "https://example.org/new",
|
||||||
|
"terms_url": "https://example.org/new-terms",
|
||||||
|
"license_name": "cc0",
|
||||||
|
"risk_level": "green",
|
||||||
|
"is_enabled": "1",
|
||||||
|
"notes": "ok",
|
||||||
|
"last_reviewed_at": "2026-02-21T12:00:00Z",
|
||||||
|
},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
self.assertEqual(update_source_res.status_code, 303)
|
||||||
|
|
||||||
|
update_feed_res = self.client.post(
|
||||||
|
f"/admin/feeds/{feed_id}/update",
|
||||||
|
data={
|
||||||
|
"name": "Edit Feed 2",
|
||||||
|
"url": "https://example.org/feed2.xml",
|
||||||
|
"source_id": str(source_id),
|
||||||
|
"is_enabled": "0",
|
||||||
|
},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
self.assertEqual(update_feed_res.status_code, 303)
|
||||||
|
|
||||||
|
delete_feed_res = self.client.post(f"/admin/feeds/{feed_id}/delete", follow_redirects=False)
|
||||||
|
self.assertEqual(delete_feed_res.status_code, 303)
|
||||||
|
delete_source_res = self.client.post(f"/admin/sources/{source_id}/delete", follow_redirects=False)
|
||||||
|
self.assertEqual(delete_source_res.status_code, 303)
|
||||||
|
|
||||||
|
def test_rewrite_save_and_reopen(self) -> None:
|
||||||
|
source_id = create_source(
|
||||||
|
SourceCreate(
|
||||||
|
name="Test Source",
|
||||||
|
base_url="https://example.org",
|
||||||
|
terms_url="https://example.org/terms",
|
||||||
|
license_name="cc-by",
|
||||||
|
risk_level="green",
|
||||||
|
is_enabled=True,
|
||||||
|
notes=None,
|
||||||
|
last_reviewed_at="2026-02-18T00:00:00Z",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
feed_id = create_feed(
|
||||||
|
FeedCreate(
|
||||||
|
name="Test Feed",
|
||||||
|
url="https://example.org/feed.xml",
|
||||||
|
source_id=source_id,
|
||||||
|
is_enabled=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
article_id = upsert_article(
|
||||||
|
ArticleUpsert(
|
||||||
|
feed_id=feed_id,
|
||||||
|
source_article_id="id-published",
|
||||||
|
source_hash="hash-published",
|
||||||
|
title="Titel Published",
|
||||||
|
source_url="https://example.org/published",
|
||||||
|
canonical_url="https://example.org/published",
|
||||||
|
published_at=None,
|
||||||
|
author="Autor A",
|
||||||
|
summary="Summary",
|
||||||
|
content_raw="Raw",
|
||||||
|
content_rewritten="<p>Alt</p>",
|
||||||
|
image_urls_json=None,
|
||||||
|
press_contact=None,
|
||||||
|
source_name_snapshot="Test Source",
|
||||||
|
source_terms_url_snapshot="https://example.org/terms",
|
||||||
|
source_license_name_snapshot="cc-by",
|
||||||
|
legal_checked=True,
|
||||||
|
legal_checked_at="2026-02-21T10:00:00Z",
|
||||||
|
legal_note=None,
|
||||||
|
wp_post_id=123,
|
||||||
|
wp_post_url="https://example.org/?p=123",
|
||||||
|
publish_attempts=2,
|
||||||
|
publish_last_error=None,
|
||||||
|
published_to_wp_at="2026-02-21T10:10:00Z",
|
||||||
|
word_count=1,
|
||||||
|
status="published",
|
||||||
|
meta_json="{}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.client.post("/admin/login", data={"username": "admin", "password": "secret"}, follow_redirects=True)
|
||||||
|
|
||||||
|
save_res = self.client.post(
|
||||||
|
f"/admin/articles/{article_id}/rewrite-save",
|
||||||
|
data={"content_rewritten": "<h2>Neu</h2><p>Text</p>"},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
self.assertEqual(save_res.status_code, 303)
|
||||||
|
|
||||||
|
reopen_res = self.client.post(f"/admin/articles/{article_id}/reopen", follow_redirects=False)
|
||||||
|
self.assertEqual(reopen_res.status_code, 303)
|
||||||
|
|
||||||
|
article = get_article_by_id(article_id)
|
||||||
|
self.assertIsNotNone(article)
|
||||||
|
self.assertEqual(article.get("status"), "rewrite")
|
||||||
|
self.assertIn("Neu", article.get("content_rewritten") or "")
|
||||||
|
self.assertIsNone(article.get("wp_post_id"))
|
||||||
|
|
||||||
@patch("backend.app.admin_ui.urlopen")
|
@patch("backend.app.admin_ui.urlopen")
|
||||||
def test_image_proxy_returns_image_data(self, mock_urlopen) -> None:
|
def test_image_proxy_returns_image_data(self, mock_urlopen) -> None:
|
||||||
class _FakeHeaders:
|
class _FakeHeaders:
|
||||||
|
|
|
||||||
|
|
@ -76,10 +76,13 @@ class TestArticleWorkflow(unittest.TestCase):
|
||||||
t3 = self.client.post(f"/api/articles/{article_id}/transition", json={"target_status": "published"})
|
t3 = self.client.post(f"/api/articles/{article_id}/transition", json={"target_status": "published"})
|
||||||
self.assertEqual(t3.status_code, 200)
|
self.assertEqual(t3.status_code, 200)
|
||||||
|
|
||||||
|
t4 = self.client.post(f"/api/articles/{article_id}/transition", json={"target_status": "rewrite"})
|
||||||
|
self.assertEqual(t4.status_code, 200)
|
||||||
|
|
||||||
final = self.client.get(f"/api/articles/{article_id}")
|
final = self.client.get(f"/api/articles/{article_id}")
|
||||||
self.assertEqual(final.status_code, 200)
|
self.assertEqual(final.status_code, 200)
|
||||||
self.assertEqual(final.json()["item"]["status"], "published")
|
self.assertEqual(final.json()["item"]["status"], "rewrite")
|
||||||
self.assertEqual(final.json()["item"]["status_ui"], "published")
|
self.assertEqual(final.json()["item"]["status_ui"], "rewrite")
|
||||||
|
|
||||||
def test_invalid_transition_rejected(self) -> None:
|
def test_invalid_transition_rejected(self) -> None:
|
||||||
article_id = self._create_article()
|
article_id = self._create_article()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue