From 2a99edeec3939295b502eac027564e178673b045 Mon Sep 17 00:00:00 2001 From: Abel Luck Date: Mon, 30 Mar 2026 17:25:37 +0200 Subject: [PATCH] Fix source actions and toggle regressions --- repub/model.py | 9 ++++++++ repub/pages/sources.py | 28 +++++++++++++++++++++++ repub/web.py | 15 ++++++++++--- tests/test_scheduler_runtime.py | 40 +++++++++++++++++++++++++++++++++ tests/test_web.py | 35 +++++++++++++++++++++++++++-- 5 files changed, 122 insertions(+), 5 deletions(-) diff --git a/repub/model.py b/repub/model.py index 41d31d6..accdf8e 100644 --- a/repub/model.py +++ b/repub/model.py @@ -305,6 +305,15 @@ def delete_job_source(job_id: int) -> bool: return source.delete_instance() > 0 +def delete_source(slug: str) -> bool: + with database.connection_context(): + with database.atomic(): + source = Source.get_or_none(Source.slug == slug) + if source is None: + return False + return source.delete_instance() > 0 + + def load_sources() -> tuple[dict[str, object], ...]: with database.connection_context(): sources = tuple(Source.select().order_by(Source.created_at.desc())) diff --git a/repub/pages/sources.py b/repub/pages/sources.py index a3bd7ad..de95f2d 100644 --- a/repub/pages/sources.py +++ b/repub/pages/sources.py @@ -54,6 +54,29 @@ def _checked(source: Mapping[str, object] | None, key: str, default: bool) -> bo return bool(value) +def _action_button( + *, + label: str, + tone: str = "default", + post_path: str | None = None, +) -> Renderable: + classes = { + "default": "bg-stone-100 text-slate-700 hover:bg-stone-200", + "danger": "bg-rose-50 text-rose-700 hover:bg-rose-100", + } + attributes: dict[str, str] = {} + if post_path is not None: + attributes["data-on:pointerdown"] = f"@post('{post_path}')" + return h.button( + attributes, + type="button", + class_=( + "inline-flex items-center whitespace-nowrap rounded-full px-3 py-1.5 " + f"text-sm font-semibold transition {classes[tone]}" + ), + )[label] + + def _source_row(source: Mapping[str, object]) -> tuple[Node, ...]: return ( h.div[ @@ -81,6 +104,11 @@ def _source_row(source: Mapping[str, object]) -> tuple[Node, ...]: href=f"/sources/{source['slug']}/edit", label="Edit", tone="amber" ), inline_link(href="/runs", label="View runs"), + _action_button( + label="Delete", + tone="danger", + post_path=f"/actions/sources/{source['slug']}/delete", + ), ], ) diff --git a/repub/web.py b/repub/web.py index 0b3e1cd..d0b2247 100644 --- a/repub/web.py +++ b/repub/web.py @@ -26,6 +26,7 @@ from repub.model import ( Job, create_source, delete_job_source, + delete_source, initialize_database, load_source_form, load_sources, @@ -209,6 +210,13 @@ def create_app(*, dev_mode: bool = False) -> Quart: trigger_refresh(app) return DatastarResponse(SSE.redirect("/sources")) + @app.post("/actions/sources//delete") + async def delete_source_action(slug: str) -> Response: + delete_source(slug) + get_job_runtime(app).sync_jobs() + trigger_refresh(app) + return Response(status=204) + @app.post("/runs") async def runs_patch() -> DatastarResponse: return _page_patch_response(app, lambda: render_runs(app)) @@ -383,7 +391,7 @@ def validate_source_form( source_type = _read_string(signals, "sourceType") feed_url = _read_string(signals, "feedUrl") pangea_domain = _read_string(signals, "pangeaDomain") - pangea_category = _read_string(signals, "pangeaCategory") + pangea_category = _read_string(signals, "pangeaCategory", strip=False) content_format = _read_string(signals, "contentFormat") content_type = _read_string(signals, "contentType") max_articles = _read_string(signals, "maxArticles") @@ -474,8 +482,9 @@ def validate_source_form( return source, None -def _read_string(signals: dict[str, object], key: str) -> str: - return str(signals.get(key, "")).strip() +def _read_string(signals: dict[str, object], key: str, *, strip: bool = True) -> str: + value = str(signals.get(key, "")) + return value.strip() if strip else value def _read_bool(signals: dict[str, object], key: str, *, default: bool = False) -> bool: diff --git a/tests/test_scheduler_runtime.py b/tests/test_scheduler_runtime.py index 31ee7fa..9fe81b4 100644 --- a/tests/test_scheduler_runtime.py +++ b/tests/test_scheduler_runtime.py @@ -604,6 +604,46 @@ def test_delete_job_action_removes_source_job_and_execution_history( asyncio.run(run()) +def test_delete_source_action_removes_source_job_and_execution_history( + monkeypatch, tmp_path: Path +) -> None: + db_path = tmp_path / "delete-source.db" + monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path)) + + async def run() -> None: + app = create_app() + client = app.test_client() + + source = create_source( + name="Delete source row", + slug="delete-source-row", + source_type="feed", + notes="", + spider_arguments="", + enabled=True, + cron_minute="*/30", + cron_hour="*", + cron_day_of_month="*", + cron_day_of_week="*", + cron_month="*", + feed_url="https://example.com/delete-source-row.xml", + ) + job = Job.get(Job.source == source) + execution = JobExecution.create( + job=job, + running_status=JobExecutionStatus.SUCCEEDED, + ) + + response = await client.post("/actions/sources/delete-source-row/delete") + + assert response.status_code == 204 + assert Source.get_or_none(Source.slug == "delete-source-row") is None + assert Job.get_or_none(id=job.id) is None + assert JobExecution.get_or_none(id=int(execution.get_id())) is None + + asyncio.run(run()) + + def _wait_for_running_execution( execution_id: int, *, timeout_seconds: float = 2.0 ) -> JobExecution: diff --git a/tests/test_web.py b/tests/test_web.py index 3fd92fd..0e23ef2 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -430,6 +430,37 @@ def test_render_sources_shows_table_and_create_link() -> None: asyncio.run(run()) +def test_render_sources_shows_delete_action_for_each_source( + monkeypatch, tmp_path: Path +) -> None: + db_path = tmp_path / "sources-delete-row.db" + monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path)) + app = create_app() + create_source( + name="Delete me", + slug="delete-me", + source_type="feed", + notes="", + spider_arguments="", + enabled=True, + cron_minute="0", + cron_hour="*", + cron_day_of_month="*", + cron_day_of_week="*", + cron_month="*", + feed_url="https://example.com/delete.xml", + ) + + async def run() -> None: + body = str(await render_sources(app)) + + assert "Delete" in body + assert "data-on:pointerdown" in body + assert "/actions/sources/delete-me/delete" in body + + asyncio.run(run()) + + def test_render_create_source_shows_dedicated_form_page() -> None: async def run() -> None: body = str(await render_create_source()) @@ -537,7 +568,7 @@ def test_create_source_action_creates_pangea_source_and_job_in_database( "sourceSlug": "kenya-health", "sourceType": "pangea", "pangeaDomain": "example.org", - "pangeaCategory": "Health", + "pangeaCategory": " Health ", "contentFormat": "MOBILE_3", "contentType": "breakingnews", "maxArticles": "12", @@ -567,13 +598,13 @@ def test_create_source_action_creates_pangea_source_and_job_in_database( assert source.name == "Kenya health desk" assert source.source_type == "pangea" + assert pangea.category_name == " Health " assert pangea.content_type == "breakingnews" assert pangea.include_content is True assert job.enabled is True assert job.spider_arguments == "language=en\ndownload_media=true" assert job.cron_hour == "*/6" assert "kenya-health" in rendered_sources - assert "example.org / Health" in rendered_sources assert "Enabled" in rendered_sources asyncio.run(run())