edit sources

This commit is contained in:
Abel Luck 2026-03-30 13:49:00 +02:00
parent 847aeae772
commit 328a70ff9b
7 changed files with 512 additions and 38 deletions

View file

@ -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.

View file

@ -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],
]

View file

@ -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()))

View file

@ -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",

View file

@ -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"
)[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],
],
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."
],
],
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),
)

View file

@ -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/<string:slug>/edit")
@app.get("/runs")
@app.get("/job/<int:job_id>/execution/<int:execution_id>/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/<string:slug>/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/<string:slug>/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.")

View file

@ -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: