From 328a70ff9be675951a2e9478bd7c34a9884da380 Mon Sep 17 00:00:00 2001 From: Abel Luck Date: Mon, 30 Mar 2026 13:49:00 +0200 Subject: [PATCH] edit sources --- AGENTS.md | 2 + repub/components.py | 12 ++- repub/model.py | 141 +++++++++++++++++++++++++++ repub/pages/__init__.py | 3 +- repub/pages/sources.py | 129 ++++++++++++++++++------ repub/web.py | 52 +++++++++- tests/test_web.py | 211 +++++++++++++++++++++++++++++++++++++++- 7 files changed, 512 insertions(+), 38 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1ccde1e..39c43e0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -112,3 +112,5 @@ uv run repub crawl -c repub.toml - Runtime ffmpeg availability is provided by the flake package and devshell. - Tests live under `tests/`. - `prompts/` is git ignored intentionally +- Treat the repo-root `republisher.db` as user-owned local state. Do not delete or reset it as part of routine testing or verification. +- For automated tests or isolated verification, use a separate database path via `REPUBLISHER_DB_PATH` instead of mutating or removing the repo-root database. diff --git a/repub/components.py b/repub/components.py index ae60aad..48af6d6 100644 --- a/repub/components.py +++ b/repub/components.py @@ -273,7 +273,16 @@ def input_field( placeholder: str = "", help_text: str | None = None, signal_name: str | None = None, + disabled: bool = False, ) -> Renderable: + class_name = ( + "mt-2 block w-full rounded-2xl border-0 px-3.5 py-2.5 text-sm shadow-sm ring-1 " + + ( + "cursor-not-allowed bg-slate-100 text-slate-500 ring-slate-200" + if disabled + else "bg-white text-slate-900 ring-slate-200 placeholder:text-slate-400 focus:outline-hidden focus:ring-2 focus:ring-amber-500" + ) + ) return h.div[ h.label(for_=field_id, class_="block text-sm font-medium text-slate-900")[ label @@ -285,7 +294,8 @@ def input_field( type="text", value=value, placeholder=placeholder, - class_="mt-2 block w-full rounded-2xl border-0 bg-white px-3.5 py-2.5 text-sm text-slate-900 shadow-sm ring-1 ring-slate-200 placeholder:text-slate-400 focus:outline-hidden focus:ring-2 focus:ring-amber-500", + disabled=disabled, + class_=class_name, ), help_text and h.p(class_="mt-2 text-xs text-slate-500")[help_text], ] diff --git a/repub/model.py b/repub/model.py index 60c4bb4..de62329 100644 --- a/repub/model.py +++ b/repub/model.py @@ -89,6 +89,59 @@ def source_slug_exists(slug: str) -> bool: return Source.select().where(Source.slug == slug).exists() +def load_source_form(slug: str) -> dict[str, object] | None: + with database.connection_context(): + source = Source.get_or_none(Source.slug == slug) + if source is None: + return None + + job = Job.get(Job.source == source) + form_data: dict[str, object] = { + "name": source.name, + "slug": source.slug, + "source_type": source.source_type, + "notes": source.notes, + "spider_arguments": job.spider_arguments, + "enabled": job.enabled, + "cron_minute": job.cron_minute, + "cron_hour": job.cron_hour, + "cron_day_of_month": job.cron_day_of_month, + "cron_day_of_week": job.cron_day_of_week, + "cron_month": job.cron_month, + "feed_url": "", + "pangea_domain": "", + "pangea_category": "", + "content_format": "MOBILE_3", + "content_type": "articles", + "max_articles": "10", + "oldest_article": "3", + "only_newest": True, + "include_authors": True, + "exclude_media": False, + "include_content": True, + } + if source.source_type == "feed": + feed = SourceFeed.get(SourceFeed.source == source) + form_data["feed_url"] = feed.feed_url + else: + pangea = SourcePangea.get(SourcePangea.source == source) + form_data.update( + { + "pangea_domain": pangea.domain, + "pangea_category": pangea.category_name, + "content_format": pangea.content_format, + "content_type": pangea.content_type, + "max_articles": str(pangea.max_articles), + "oldest_article": str(pangea.oldest_article), + "only_newest": pangea.only_newest, + "include_authors": pangea.include_authors, + "exclude_media": pangea.exclude_media, + "include_content": pangea.include_content, + } + ) + return form_data + + def create_source( *, name: str, @@ -154,6 +207,94 @@ def create_source( return source +def update_source( + source_slug: str, + *, + name: str, + slug: str, + source_type: str, + notes: str, + spider_arguments: str, + enabled: bool, + cron_minute: str, + cron_hour: str, + cron_day_of_month: str, + cron_day_of_week: str, + cron_month: str, + feed_url: str = "", + pangea_domain: str = "", + pangea_category: str = "", + content_type: str = "", + only_newest: bool = True, + max_articles: int | None = None, + oldest_article: int | None = None, + include_authors: bool = True, + exclude_media: bool = False, + include_content: bool = True, + content_format: str = "", +) -> Source | None: + with database.connection_context(): + with database.atomic(): + source = Source.get_or_none(Source.slug == source_slug) + if source is None: + return None + + source.name = name + source.notes = notes + source.source_type = source_type + source.save() + + job = Job.get(Job.source == source) + job.enabled = enabled + job.spider_arguments = spider_arguments + job.cron_minute = cron_minute + job.cron_hour = cron_hour + job.cron_day_of_month = cron_day_of_month + job.cron_day_of_week = cron_day_of_week + job.cron_month = cron_month + job.save() + + if source_type == "feed": + SourcePangea.delete().where(SourcePangea.source == source).execute() + feed = SourceFeed.get_or_none(SourceFeed.source == source) + if feed is None: + SourceFeed.create(source=source, feed_url=feed_url) + else: + feed.feed_url = feed_url + feed.save() + else: + SourceFeed.delete().where(SourceFeed.source == source).execute() + pangea = SourcePangea.get_or_none(SourcePangea.source == source) + if pangea is None: + SourcePangea.create( + source=source, + domain=pangea_domain, + category_name=pangea_category, + content_type=content_type, + only_newest=only_newest, + max_articles=max_articles, + oldest_article=oldest_article, + include_authors=include_authors, + exclude_media=exclude_media, + include_content=include_content, + content_format=content_format, + ) + else: + pangea.domain = pangea_domain + pangea.category_name = pangea_category + pangea.content_type = content_type + pangea.only_newest = only_newest + pangea.max_articles = max_articles + pangea.oldest_article = oldest_article + pangea.include_authors = include_authors + pangea.exclude_media = exclude_media + pangea.include_content = include_content + pangea.content_format = content_format + pangea.save() + + return source + + 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/__init__.py b/repub/pages/__init__.py index 5428e35..55612a1 100644 --- a/repub/pages/__init__.py +++ b/repub/pages/__init__.py @@ -1,11 +1,12 @@ from repub.pages.dashboard import dashboard_page from repub.pages.runs import execution_logs_page, runs_page from repub.pages.shim import shim_page -from repub.pages.sources import create_source_page, sources_page +from repub.pages.sources import create_source_page, edit_source_page, sources_page __all__ = [ "create_source_page", "dashboard_page", + "edit_source_page", "execution_logs_page", "runs_page", "shim_page", diff --git a/repub/pages/sources.py b/repub/pages/sources.py index 2da44f9..f8ba517 100644 --- a/repub/pages/sources.py +++ b/repub/pages/sources.py @@ -41,6 +41,19 @@ PANGEA_CONTENT_TYPES = ( ) +def _value(source: Mapping[str, object] | None, key: str, default: str = "") -> str: + if source is None: + return default + return str(source.get(key, default)) + + +def _checked(source: Mapping[str, object] | None, key: str, default: bool) -> bool: + if source is None: + return default + value = source.get(key, default) + return bool(value) + + def _source_row(source: Mapping[str, object]) -> tuple[Node, ...]: return ( h.div[ @@ -64,7 +77,9 @@ def _source_row(source: Mapping[str, object]) -> tuple[Node, ...]: h.p(class_="mt-2 text-xs text-slate-500")[str(source["last_run"])], ], h.div(class_="flex flex-nowrap items-center gap-3")[ - inline_link(href="/sources/create", label="Edit", tone="amber"), + inline_link( + href=f"/sources/{source['slug']}/edit", label="Edit", tone="amber" + ), inline_link(href="/runs", label="View runs"), ], ) @@ -97,7 +112,27 @@ def sources_page( ) -def create_source_form(*, action_path: str = "/actions/sources/create") -> Renderable: +def source_form( + *, + mode: str, + action_path: str, + source: Mapping[str, object] | None = None, +) -> Renderable: + source_type = _value(source, "source_type", "pangea") + slug = _value(source, "slug") + title = "Source and job setup" if mode == "create" else "Edit source" + eyebrow = "Create" if mode == "create" else "Edit" + description = ( + "Create the source and its paired job record." + if mode == "create" + else "Update the existing source and its paired job record." + ) + status_label = "New source" if mode == "create" else "Existing source" + submit_label = "Create source" if mode == "create" else "Save changes" + initial_signals = "{sourceType: 'pangea'}" + if mode == "edit": + initial_signals = f"{{sourceType: '{source_type}', sourceSlug: '{slug}'}}" + return section_card( content=( h.div( @@ -106,20 +141,16 @@ def create_source_form(*, action_path: str = "/actions/sources/create") -> Rende h.div[ h.p( class_="text-xs font-semibold uppercase tracking-[0.22em] text-amber-600" - )["Create"], - h.h2(class_="mt-2 text-xl font-semibold text-slate-950")[ - "Source and job setup" - ], - h.p(class_="mt-2 max-w-3xl text-sm text-slate-600")[ - "The create flow lives on its own page and creates the source plus its paired job record. This pass is visual only, but the fields already reflect the intended shape." - ], + )[eyebrow], + h.h2(class_="mt-2 text-xl font-semibold text-slate-950")[title], + h.p(class_="mt-2 max-w-3xl text-sm text-slate-600")[description], ], - status_badge(label="New source", tone="scheduled"), + status_badge(label=status_label, tone="scheduled"), ], h.form( { "data-signals": "{_formError: '', _formSuccess: ''}", - "data-signals__ifmissing": "{sourceType: 'pangea'}", + "data-signals__ifmissing": initial_signals, "data-on:submit": f"@post('{action_path}')", }, class_="mt-5 space-y-6", @@ -142,13 +173,16 @@ def create_source_form(*, action_path: str = "/actions/sources/create") -> Rende input_field( label="Source name", field_id="source-name", + value=_value(source, "name"), signal_name="sourceName", ), input_field( label="Slug", field_id="source-slug", + value=slug, help_text="Immutable after creation.", signal_name="sourceSlug", + disabled=mode == "edit", ), h.div[ h.label( @@ -161,8 +195,12 @@ def create_source_form(*, action_path: str = "/actions/sources/create") -> Rende name="source-type", class_="mt-2 block w-full rounded-2xl border-0 bg-white px-3.5 py-2.5 text-sm text-slate-900 shadow-sm ring-1 ring-slate-200 focus:outline-hidden focus:ring-2 focus:ring-amber-500", )[ - h.option(value="feed")["feed"], - h.option(value="pangea", selected=True)["pangea"], + h.option(value="feed", selected=source_type == "feed")[ + "feed" + ], + h.option(value="pangea", selected=source_type == "pangea")[ + "pangea" + ], ], ], ], @@ -185,6 +223,7 @@ def create_source_form(*, action_path: str = "/actions/sources/create") -> Rende input_field( label="Feed URL", field_id="feed-url", + value=_value(source, "feed_url"), placeholder="https://example.com/feed.xml", signal_name="feedUrl", ), @@ -209,37 +248,39 @@ def create_source_form(*, action_path: str = "/actions/sources/create") -> Rende input_field( label="Pangea domain", field_id="pangea-domain", + value=_value(source, "pangea_domain"), signal_name="pangeaDomain", ), input_field( label="Category name", field_id="pangea-category", + value=_value(source, "pangea_category"), signal_name="pangeaCategory", ), select_field( label="Content format", field_id="content-format", options=PANGEA_CONTENT_FORMATS, - selected="MOBILE_3", + selected=_value(source, "content_format", "MOBILE_3"), signal_name="contentFormat", ), select_field( label="Content type", field_id="content-type", options=PANGEA_CONTENT_TYPES, - selected="articles", + selected=_value(source, "content_type", "articles"), signal_name="contentType", ), input_field( label="Max articles", field_id="max-articles", - value="10", + value=_value(source, "max_articles", "10"), signal_name="maxArticles", ), input_field( label="Oldest article (days)", field_id="oldest-article", - value="3", + value=_value(source, "oldest_article", "3"), signal_name="oldestArticle", ), ], @@ -248,25 +289,25 @@ def create_source_form(*, action_path: str = "/actions/sources/create") -> Rende label="Only newest", description="Limit Pangea syncs to the newest material available in the selected category.", signal_name="onlyNewest", - checked=True, + checked=_checked(source, "only_newest", True), ), toggle_field( label="Include authors", description="Carry author bylines into mirrored output where upstream data exists.", signal_name="includeAuthors", - checked=True, + checked=_checked(source, "include_authors", True), ), toggle_field( label="Exclude media", description="Skip image and media attachment mirroring for this source.", signal_name="excludeMedia", - checked=False, + checked=_checked(source, "exclude_media", False), ), toggle_field( label="Include content", description="Store article body content in mirrored output when the upstream provides it.", signal_name="includeContent", - checked=True, + checked=_checked(source, "include_content", True), ), ], ], @@ -274,13 +315,17 @@ def create_source_form(*, action_path: str = "/actions/sources/create") -> Rende textarea_field( label="Notes", field_id="source-notes", - value="", + value=_value(source, "notes"), signal_name="sourceNotes", ), textarea_field( label="Spider arguments", field_id="spider-arguments", - value="language=en\ndownload_media=true", + value=_value( + source, + "spider_arguments", + "language=en\ndownload_media=true", + ), signal_name="spiderArguments", ), ], @@ -300,31 +345,31 @@ def create_source_form(*, action_path: str = "/actions/sources/create") -> Rende input_field( label="Minute", field_id="cron-minute", - value="*/30", + value=_value(source, "cron_minute", "*/30"), signal_name="cronMinute", ), input_field( label="Hour", field_id="cron-hour", - value="*", + value=_value(source, "cron_hour", "*"), signal_name="cronHour", ), input_field( label="Day of month", field_id="cron-day-of-month", - value="*", + value=_value(source, "cron_day_of_month", "*"), signal_name="cronDayOfMonth", ), input_field( label="Day of week", field_id="cron-day-of-week", - value="*", + value=_value(source, "cron_day_of_week", "*"), signal_name="cronDayOfWeek", ), input_field( label="Month", field_id="cron-month", - value="*", + value=_value(source, "cron_month", "*"), signal_name="cronMonth", ), ], @@ -341,7 +386,7 @@ def create_source_form(*, action_path: str = "/actions/sources/create") -> Rende label="Job enabled", description="Scheduler will consider the new job immediately after creation.", signal_name="jobEnabled", - checked=True, + checked=_checked(source, "enabled", True), ), ], ], @@ -353,7 +398,7 @@ def create_source_form(*, action_path: str = "/actions/sources/create") -> Rende h.button( type="submit", class_="rounded-full bg-slate-950 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-slate-800", - )["Create source"], + )[submit_label], ], ], ) @@ -369,7 +414,27 @@ def create_source_page(*, action_path: str = "/actions/sources/create") -> Rende current_path="/sources/create", eyebrow="Source creation", title="Create source", - description="Dedicated create page for the source form. The list page stays focused on scanning existing sources, while this page handles the new source and job configuration flow.", + description="Create a new source and its paired job configuration.", actions=actions, - content=create_source_form(action_path=action_path), + content=source_form(mode="create", action_path=action_path), + ) + + +def edit_source_page( + *, + slug: str, + source: Mapping[str, object], + action_path: str, +) -> Renderable: + actions = ( + muted_action_link(href="/sources", label="Back to sources"), + header_action_link(href="/runs", label="View runs"), + ) + return page_shell( + current_path=f"/sources/{slug}/edit", + eyebrow="Source editing", + title="Edit source", + description="Update an existing source and its paired job configuration.", + actions=actions, + content=source_form(mode="edit", action_path=action_path, source=source), ) diff --git a/repub/web.py b/repub/web.py index 350de6a..fdb24fa 100644 --- a/repub/web.py +++ b/repub/web.py @@ -18,12 +18,15 @@ from repub.datastar import RefreshBroker, render_stream from repub.model import ( create_source, initialize_database, + load_source_form, load_sources, source_slug_exists, + update_source, ) from repub.pages import ( create_source_page, dashboard_page, + edit_source_page, execution_logs_page, runs_page, shim_page, @@ -85,12 +88,15 @@ def create_app() -> Quart: @app.get("/") @app.get("/sources") @app.get("/sources/create") + @app.get("/sources//edit") @app.get("/runs") @app.get("/job//execution//logs") async def page_shim( - job_id: int | None = None, execution_id: int | None = None + slug: str | None = None, + job_id: int | None = None, + execution_id: int | None = None, ) -> Response: - del job_id, execution_id + del slug, job_id, execution_id body, etag = _render_shim_page( stylesheet_href=url_for("static", filename="app.css"), datastar_src=url_for("static", filename="datastar@1.0.0-RC.8.js"), @@ -116,6 +122,10 @@ def create_app() -> Quart: async def create_source_patch() -> DatastarResponse: return _page_patch_response(app, lambda: render_create_source(app)) + @app.post("/sources//edit") + async def edit_source_patch(slug: str) -> DatastarResponse: + return _page_patch_response(app, lambda: render_edit_source(slug)) + @app.post("/actions/sources/create") async def create_source_action() -> DatastarResponse: signals = cast(dict[str, object], await read_signals()) @@ -140,6 +150,30 @@ def create_app() -> Quart: trigger_refresh(app) return DatastarResponse(SSE.redirect("/sources")) + @app.post("/actions/sources//edit") + async def edit_source_action(slug: str) -> DatastarResponse: + signals = cast(dict[str, object], await read_signals()) + source, error = validate_source_form( + signals, + slug_exists=lambda candidate: candidate != slug + and source_slug_exists(candidate), + immutable_slug=slug, + ) + if error is not None: + return DatastarResponse( + SSE.patch_signals({"_formError": error, "_formSuccess": ""}) + ) + + assert source is not None + if update_source(slug, **source) is None: + return DatastarResponse( + SSE.patch_signals( + {"_formError": "Source does not exist.", "_formSuccess": ""} + ) + ) + trigger_refresh(app) + return DatastarResponse(SSE.redirect("/sources")) + @app.post("/runs") async def runs_patch() -> DatastarResponse: return _page_patch_response(app, render_runs) @@ -176,6 +210,17 @@ async def render_create_source(app: Quart | None = None) -> Renderable: return create_source_page() +async def render_edit_source(slug: str) -> Renderable: + source = load_source_form(slug) + if source is None: + return sources_page(sources=()) + return edit_source_page( + slug=slug, + source=source, + action_path=f"/actions/sources/{slug}/edit", + ) + + async def render_runs() -> Renderable: return runs_page() @@ -208,6 +253,7 @@ def validate_source_form( signals: dict[str, object] | None, *, slug_exists: Callable[[str], bool], + immutable_slug: str | None = None, ) -> tuple[SourceFormData | None, str | None]: if signals is None: return None, "Missing form data." @@ -235,6 +281,8 @@ def validate_source_form( errors.append("Source name is required.") if source_slug == "": errors.append("Slug is required.") + elif immutable_slug is not None and source_slug != immutable_slug: + errors.append("Slug is immutable.") elif slug_exists(source_slug): errors.append("Slug must be unique.") diff --git a/tests/test_web.py b/tests/test_web.py index b866934..3952930 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -5,12 +5,13 @@ from pathlib import Path from typing import Any, cast from repub.datastar import RefreshBroker, render_sse_event, render_stream -from repub.model import Job, Source, SourceFeed, SourcePangea +from repub.model import Job, Source, SourceFeed, SourcePangea, create_source from repub.web import ( create_app, get_refresh_broker, render_create_source, render_dashboard, + render_edit_source, render_execution_logs, render_runs, render_sources, @@ -171,7 +172,7 @@ def test_render_create_source_shows_dedicated_form_page() -> None: async def run() -> None: body = str(await render_create_source()) - assert "Dedicated create page for the source form" in body + assert "Create a new source and its paired job configuration." in body assert "Source and job setup" in body assert "data-signals__ifmissing" in body assert "/actions/sources/create" in body @@ -206,6 +207,55 @@ def test_render_create_source_shows_dedicated_form_page() -> None: asyncio.run(run()) +def test_render_edit_source_shows_existing_values(monkeypatch, tmp_path: Path) -> None: + db_path = tmp_path / "edit-page.db" + monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path)) + create_app() + create_source( + name="Kenya health desk", + slug="kenya-health", + source_type="pangea", + notes="Regional health alerts.", + spider_arguments="language=en\ndownload_media=true", + enabled=True, + cron_minute="0", + cron_hour="*/6", + cron_day_of_month="*", + cron_day_of_week="*", + cron_month="*", + pangea_domain="example.org", + pangea_category="Health", + content_type="breakingnews", + only_newest=True, + max_articles=12, + oldest_article=5, + include_authors=True, + exclude_media=False, + include_content=True, + content_format="MOBILE_3", + ) + + async def run() -> None: + body = str(await render_edit_source("kenya-health")) + + assert "Edit source" in body + assert "/actions/sources/kenya-health/edit" in body + assert "Kenya health desk" in body + assert "kenya-health" in body + assert 'id="source-slug"' in body + assert ( + 'id="source-slug" name="source-slug" type="text" value="kenya-health"' + in body + ) + assert " disabled " in body + assert "cursor-not-allowed bg-slate-100 text-slate-500" in body + assert "example.org" in body + assert "Health" in body + assert "language=en\ndownload_media=true" in body + + asyncio.run(run()) + + def test_create_source_action_creates_pangea_source_and_job_in_database( monkeypatch, tmp_path: Path ) -> None: @@ -314,6 +364,163 @@ def test_create_source_action_creates_feed_source_and_job_in_database( asyncio.run(run()) +def test_edit_source_action_updates_existing_source_and_job_in_database( + monkeypatch, tmp_path: Path +) -> None: + db_path = tmp_path / "edit-source.db" + monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path)) + create_app() + create_source( + name="Kenya health desk", + slug="kenya-health", + source_type="pangea", + notes="Regional health alerts.", + spider_arguments="language=en\ndownload_media=true", + enabled=True, + cron_minute="0", + cron_hour="*/6", + cron_day_of_month="*", + cron_day_of_week="*", + cron_month="*", + pangea_domain="example.org", + pangea_category="Health", + content_type="breakingnews", + only_newest=True, + max_articles=12, + oldest_article=5, + include_authors=True, + exclude_media=False, + include_content=True, + content_format="MOBILE_3", + ) + + async def run() -> None: + app = create_app() + client = app.test_client() + + response = await client.post( + "/actions/sources/kenya-health/edit", + headers={"Datastar-Request": "true"}, + json={ + "sourceName": "Kenya health desk nightly", + "sourceSlug": "kenya-health", + "sourceType": "pangea", + "pangeaDomain": "example.org", + "pangeaCategory": "Nightly", + "contentFormat": "TEXT_ONLY", + "contentType": "articles", + "maxArticles": "25", + "oldestArticle": "7", + "sourceNotes": "Updated nightly run.", + "spiderArguments": "language=sw\ninclude_audio=false", + "cronMinute": "15", + "cronHour": "2", + "cronDayOfMonth": "*", + "cronDayOfWeek": "*", + "cronMonth": "*", + "jobEnabled": False, + "onlyNewest": False, + "includeAuthors": False, + "excludeMedia": True, + "includeContent": True, + }, + ) + body = await response.get_data(as_text=True) + + assert response.status_code == 200 + assert "window.location = '/sources'" in body + + source = Source.get(Source.slug == "kenya-health") + pangea = SourcePangea.get(SourcePangea.source == source) + job = Job.get(Job.source == source) + rendered_sources = str(await render_sources(app)) + + assert source.name == "Kenya health desk nightly" + assert source.notes == "Updated nightly run." + assert pangea.category_name == "Nightly" + assert pangea.content_format == "TEXT_ONLY" + assert pangea.max_articles == 25 + assert pangea.include_authors is False + assert pangea.exclude_media is True + assert job.enabled is False + assert job.spider_arguments == "language=sw\ninclude_audio=false" + assert job.cron_hour == "2" + assert "Kenya health desk nightly" in rendered_sources + assert "example.org / Nightly" in rendered_sources + assert "Disabled" in rendered_sources + + asyncio.run(run()) + + +def test_edit_source_action_rejects_slug_changes(monkeypatch, tmp_path: Path) -> None: + db_path = tmp_path / "edit-invalid.db" + monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path)) + create_app() + create_source( + name="Kenya health desk", + slug="kenya-health", + source_type="pangea", + notes="Regional health alerts.", + spider_arguments="language=en\ndownload_media=true", + enabled=True, + cron_minute="0", + cron_hour="*/6", + cron_day_of_month="*", + cron_day_of_week="*", + cron_month="*", + pangea_domain="example.org", + pangea_category="Health", + content_type="breakingnews", + only_newest=True, + max_articles=12, + oldest_article=5, + include_authors=True, + exclude_media=False, + include_content=True, + content_format="MOBILE_3", + ) + + async def run() -> None: + app = create_app() + client = app.test_client() + + response = await client.post( + "/actions/sources/kenya-health/edit", + headers={"Datastar-Request": "true"}, + json={ + "sourceName": "Kenya health desk", + "sourceSlug": "kenya-health-renamed", + "sourceType": "pangea", + "pangeaDomain": "example.org", + "pangeaCategory": "Health", + "contentFormat": "MOBILE_3", + "contentType": "breakingnews", + "maxArticles": "12", + "oldestArticle": "5", + "sourceNotes": "Regional health alerts.", + "spiderArguments": "language=en\ndownload_media=true", + "cronMinute": "0", + "cronHour": "*/6", + "cronDayOfMonth": "*", + "cronDayOfWeek": "*", + "cronMonth": "*", + "jobEnabled": True, + "onlyNewest": True, + "includeAuthors": True, + "excludeMedia": False, + "includeContent": True, + }, + ) + body = await response.get_data(as_text=True) + + assert response.status_code == 200 + assert "Slug is immutable." in body + assert Source.get(Source.slug == "kenya-health").name == "Kenya health desk" + assert Source.select().where(Source.slug == "kenya-health-renamed").count() == 0 + + asyncio.run(run()) + + def test_create_source_action_validates_duplicate_slug_and_pangea_type( monkeypatch, tmp_path: Path ) -> None: