Add publisher dashboard routes
This commit is contained in:
parent
96551c2788
commit
e4a5246ab3
31 changed files with 1603 additions and 516 deletions
|
|
@ -37,6 +37,7 @@ from repub.web import (
|
|||
render_dashboard,
|
||||
render_edit_source,
|
||||
render_execution_logs,
|
||||
render_publisher,
|
||||
render_runs,
|
||||
render_settings,
|
||||
render_sources,
|
||||
|
|
@ -55,7 +56,12 @@ def _db_writer(fn):
|
|||
|
||||
|
||||
def test_web_routes_do_not_access_peewee_models_directly() -> None:
|
||||
web_source = Path("repub/web.py").read_text(encoding="utf-8")
|
||||
web_paths = (
|
||||
tuple(sorted(Path("repub/web").rglob("*.py")))
|
||||
if Path("repub/web").is_dir()
|
||||
else (Path("repub/web.py"),)
|
||||
)
|
||||
web_source = "\n".join(path.read_text(encoding="utf-8") for path in web_paths)
|
||||
|
||||
assert (
|
||||
re.search(
|
||||
|
|
@ -111,7 +117,7 @@ def test_action_button_omits_post_handler_when_disabled() -> None:
|
|||
action_button(
|
||||
label="Queued",
|
||||
disabled=True,
|
||||
post_path="/actions/jobs/7/run-now",
|
||||
post_path="/admin/actions/jobs/7/run-now",
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -138,11 +144,13 @@ def test_action_button_supports_datastar_pointerdown_post() -> None:
|
|||
action_button(
|
||||
label="Delete",
|
||||
tone="danger",
|
||||
post_path="/actions/jobs/7/delete",
|
||||
post_path="/admin/actions/jobs/7/delete",
|
||||
)
|
||||
)
|
||||
|
||||
assert 'data-on:pointerdown="@post('/actions/jobs/7/delete')"' in markup
|
||||
assert (
|
||||
'data-on:pointerdown="@post('/admin/actions/jobs/7/delete')"' in markup
|
||||
)
|
||||
|
||||
|
||||
def test_runs_page_renders_completed_execution_end_time_as_relative_hoverable_time() -> (
|
||||
|
|
@ -163,7 +171,7 @@ def test_runs_page_renders_completed_execution_end_time_as_relative_hoverable_ti
|
|||
"status_tone": "done",
|
||||
"stats": "1 requests • 1 items • 1 bytes",
|
||||
"summary": "Worker exited successfully",
|
||||
"log_href": "/job/7/execution/42/logs",
|
||||
"log_href": "/admin/job/7/execution/42/logs",
|
||||
},
|
||||
)
|
||||
)
|
||||
|
|
@ -195,7 +203,7 @@ def test_runs_page_renders_completed_execution_state_cell_with_duration_and_end_
|
|||
"status_tone": "done",
|
||||
"stats": "1 requests • 1 items • 1 bytes",
|
||||
"summary": "Worker exited successfully",
|
||||
"log_href": "/job/7/execution/42/logs",
|
||||
"log_href": "/admin/job/7/execution/42/logs",
|
||||
},
|
||||
)
|
||||
)
|
||||
|
|
@ -234,8 +242,8 @@ def test_runs_page_renders_combined_running_jobs_table() -> None:
|
|||
"status_tone": "idle",
|
||||
"run_label": "Queued",
|
||||
"run_disabled": True,
|
||||
"run_post_path": "/actions/jobs/7/run-now",
|
||||
"cancel_post_path": "/actions/queued-executions/42/cancel",
|
||||
"run_post_path": "/admin/actions/jobs/7/run-now",
|
||||
"cancel_post_path": "/admin/actions/queued-executions/42/cancel",
|
||||
"move_up_disabled": True,
|
||||
"move_up_post_path": None,
|
||||
"move_down_disabled": True,
|
||||
|
|
@ -249,7 +257,7 @@ def test_runs_page_renders_combined_running_jobs_table() -> None:
|
|||
assert "queued-source" in body
|
||||
assert ">Queued<" in body
|
||||
assert "bg-amber-200 text-amber-950" in body
|
||||
assert "/actions/queued-executions/42/cancel" in body
|
||||
assert "/admin/actions/queued-executions/42/cancel" in body
|
||||
|
||||
|
||||
def test_runs_page_renders_running_state_cell_with_duration_and_started_at() -> None:
|
||||
|
|
@ -269,9 +277,9 @@ def test_runs_page_renders_running_state_cell_with_duration_and_started_at() ->
|
|||
"status": "Running",
|
||||
"stats": "1 requests • 1 items • 1 byte",
|
||||
"worker": "streaming stats from worker",
|
||||
"log_href": "/job/1/execution/11/logs",
|
||||
"log_href": "/admin/job/1/execution/11/logs",
|
||||
"cancel_label": "Stop",
|
||||
"cancel_post_path": "/actions/executions/11/cancel",
|
||||
"cancel_post_path": "/admin/actions/executions/11/cancel",
|
||||
},
|
||||
)
|
||||
)
|
||||
|
|
@ -316,9 +324,9 @@ def test_runs_page_moves_scheduled_jobs_state_column_to_second_position() -> Non
|
|||
"run_disabled": False,
|
||||
"run_reason": "Ready",
|
||||
"toggle_label": "Disable",
|
||||
"toggle_post_path": "/actions/jobs/7/toggle-enabled",
|
||||
"run_post_path": "/actions/jobs/7/run-now",
|
||||
"delete_post_path": "/actions/jobs/7/delete",
|
||||
"toggle_post_path": "/admin/actions/jobs/7/toggle-enabled",
|
||||
"run_post_path": "/admin/actions/jobs/7/run-now",
|
||||
"delete_post_path": "/admin/actions/jobs/7/delete",
|
||||
},
|
||||
)
|
||||
)
|
||||
|
|
@ -371,7 +379,7 @@ def test_runs_page_renders_clear_completed_button_and_pagination() -> None:
|
|||
"status_tone": "done",
|
||||
"stats": "1 requests • 1 items • 1 bytes",
|
||||
"summary": "Worker exited successfully",
|
||||
"log_href": f"/job/7/execution/{index}/logs",
|
||||
"log_href": f"/admin/job/7/execution/{index}/logs",
|
||||
}
|
||||
for index in range(1, 21)
|
||||
)
|
||||
|
|
@ -385,29 +393,41 @@ def test_runs_page_renders_clear_completed_button_and_pagination() -> None:
|
|||
)
|
||||
)
|
||||
|
||||
assert "/actions/completed-executions/clear" in body
|
||||
assert "/admin/actions/completed-executions/clear" in body
|
||||
assert ">Clear history<" in body
|
||||
assert "Showing" in body
|
||||
assert "21" in body
|
||||
assert "@post('/actions/runs/completed-page/1')" in body
|
||||
assert "@post('/actions/runs/completed-page/2')" in body
|
||||
assert "@post('/admin/actions/runs/completed-page/1')" in body
|
||||
assert "@post('/admin/actions/runs/completed-page/2')" in body
|
||||
assert 'aria-current="page"' in body
|
||||
|
||||
|
||||
def test_root_get_serves_datastar_shim() -> None:
|
||||
def test_root_get_redirects_to_publisher() -> None:
|
||||
async def run() -> None:
|
||||
client = create_app().test_client()
|
||||
|
||||
response = await client.get("/")
|
||||
|
||||
assert response.status_code == 302
|
||||
assert response.headers["Location"] == "/publisher"
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_admin_get_serves_datastar_shim_with_admin_static_assets() -> None:
|
||||
async def run() -> None:
|
||||
client = create_app().test_client()
|
||||
|
||||
response = await client.get("/admin")
|
||||
body = await response.get_data(as_text=True)
|
||||
stylesheet_href = versioned_static_asset_href("app.css")
|
||||
stylesheet_href = versioned_static_asset_href("app.css", prefix="/admin")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["ETag"]
|
||||
assert body.startswith("<!doctype html>")
|
||||
assert f'<link rel="stylesheet" href="{stylesheet_href}">' in body
|
||||
assert (
|
||||
'<script id="js" defer type="module" src="/static/datastar@1.0.0-RC.8.js"></script>'
|
||||
'<script id="js" defer type="module" src="/admin/static/datastar@1.0.0-RC.8.js"></script>'
|
||||
in body
|
||||
)
|
||||
assert 'data-signals:tabid="self.crypto.randomUUID().substring(0,8)"' in body
|
||||
|
|
@ -417,47 +437,110 @@ def test_root_get_serves_datastar_shim() -> None:
|
|||
assert '<main id="morph"' in body
|
||||
assert "lg:grid-cols-[14rem_minmax(0,1fr)]" in body
|
||||
assert "lg:px-5 lg:py-4" in body
|
||||
assert 'href="/sources"' in body
|
||||
assert 'href="/runs"' in body
|
||||
assert 'href="/settings"' in body
|
||||
assert 'href="/admin/sources"' in body
|
||||
assert 'href="/admin/runs"' in body
|
||||
assert 'href="/admin/settings"' in body
|
||||
assert "Connecting" in body
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_publisher_get_serves_datastar_shim_with_publisher_static_assets() -> None:
|
||||
async def run() -> None:
|
||||
client = create_app().test_client()
|
||||
|
||||
response = await client.get("/publisher")
|
||||
body = await response.get_data(as_text=True)
|
||||
stylesheet_href = versioned_static_asset_href("app.css", prefix="/publisher")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert f'<link rel="stylesheet" href="{stylesheet_href}">' in body
|
||||
assert (
|
||||
'<script id="js" defer type="module" src="/publisher/static/datastar@1.0.0-RC.8.js"></script>'
|
||||
in body
|
||||
)
|
||||
assert '<main id="morph"' in body
|
||||
assert "Connecting" in body
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_old_root_level_admin_routes_do_not_serve_admin_pages() -> None:
|
||||
async def run() -> None:
|
||||
client = create_app().test_client()
|
||||
|
||||
for path in (
|
||||
"/sources",
|
||||
"/sources/create",
|
||||
"/runs",
|
||||
"/settings",
|
||||
"/job/1/execution/1/logs",
|
||||
):
|
||||
response = await client.get(path)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_versioned_static_asset_href_uses_truncated_file_hash() -> None:
|
||||
href = versioned_static_asset_href("app.css")
|
||||
|
||||
assert re.fullmatch(r"/static/app-[0-9a-f]{12}\.css", href)
|
||||
assert re.fullmatch(r"/admin/static/app-[0-9a-f]{12}\.css", href)
|
||||
|
||||
|
||||
def test_versioned_static_asset_route_serves_registered_css_file() -> None:
|
||||
def test_versioned_static_asset_href_supports_publisher_prefix() -> None:
|
||||
href = versioned_static_asset_href("app.css", prefix="/publisher")
|
||||
|
||||
assert re.fullmatch(r"/publisher/static/app-[0-9a-f]{12}\.css", href)
|
||||
|
||||
|
||||
def test_section_static_asset_routes_serve_registered_css_file() -> None:
|
||||
async def run() -> None:
|
||||
client = create_app().test_client()
|
||||
expected = (
|
||||
Path(__file__).resolve().parents[1] / "repub" / "static" / "app.css"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
response = await client.get("/static/app-deadbeefcafe.css")
|
||||
body = await response.get_data(as_text=True)
|
||||
for path in (
|
||||
"/admin/static/app-deadbeefcafe.css",
|
||||
"/publisher/static/app-deadbeefcafe.css",
|
||||
):
|
||||
response = await client.get(path)
|
||||
body = await response.get_data(as_text=True)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.mimetype == "text/css"
|
||||
assert body == expected
|
||||
assert response.status_code == 200
|
||||
assert response.mimetype == "text/css"
|
||||
assert body == expected
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_versioned_static_asset_route_preserves_existing_hyphenated_files() -> None:
|
||||
def test_section_static_asset_routes_preserve_existing_hyphenated_files() -> None:
|
||||
async def run() -> None:
|
||||
client = create_app().test_client()
|
||||
|
||||
for path in (
|
||||
"/admin/static/datastar@1.0.0-RC.8.js",
|
||||
"/publisher/static/datastar@1.0.0-RC.8.js",
|
||||
):
|
||||
response = await client.get(path)
|
||||
body = await response.get_data(as_text=True)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.mimetype == "text/javascript"
|
||||
assert body.startswith("// Datastar v1.0.0-RC.8")
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_root_static_asset_route_no_longer_serves_app_assets() -> None:
|
||||
async def run() -> None:
|
||||
client = create_app().test_client()
|
||||
|
||||
response = await client.get("/static/datastar@1.0.0-RC.8.js")
|
||||
body = await response.get_data(as_text=True)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.mimetype == "text/javascript"
|
||||
assert body.startswith("// Datastar v1.0.0-RC.8")
|
||||
assert response.status_code == 404
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
|
@ -473,14 +556,14 @@ def test_create_app_bootstraps_default_database_path(
|
|||
assert (tmp_path / "republisher.db").exists()
|
||||
|
||||
|
||||
def test_root_get_honors_if_none_match() -> None:
|
||||
def test_admin_get_honors_if_none_match() -> None:
|
||||
async def run() -> None:
|
||||
client = create_app().test_client()
|
||||
|
||||
initial = await client.get("/")
|
||||
initial = await client.get("/admin")
|
||||
etag = initial.headers["ETag"]
|
||||
|
||||
response = await client.get("/", headers={"If-None-Match": etag})
|
||||
response = await client.get("/admin", headers={"If-None-Match": etag})
|
||||
|
||||
assert response.status_code == 304
|
||||
assert response.headers["ETag"] == etag
|
||||
|
|
@ -491,7 +574,7 @@ def test_root_get_honors_if_none_match() -> None:
|
|||
def test_dashboard_post_serves_morph_component() -> None:
|
||||
async def run() -> None:
|
||||
client = create_app().test_client()
|
||||
async with client.request("/?u=shim", method="POST") as connection:
|
||||
async with client.request("/admin?u=shim", method="POST") as connection:
|
||||
await connection.send_complete()
|
||||
chunk = await asyncio.wait_for(connection.receive(), timeout=1)
|
||||
raw_connection = cast(Any, connection)
|
||||
|
|
@ -651,15 +734,46 @@ def test_render_dashboard_shows_dashboard_information_architecture(
|
|||
assert "Operational snapshot" in body
|
||||
assert "Running jobs" in body
|
||||
assert "Published feeds" in body
|
||||
assert 'href="/sources"' in body
|
||||
assert 'href="/runs"' in body
|
||||
assert "Create source" in body
|
||||
assert 'href="/admin/sources"' in body
|
||||
assert 'href="/admin/runs"' in body
|
||||
assert 'href="/admin/publisher"' in body
|
||||
assert "Publisher View" in body
|
||||
assert "Create source" not in body
|
||||
assert "View sources" not in body
|
||||
assert "lg:grid-cols-[14rem_minmax(0,1fr)]" in body
|
||||
assert "lg:px-5 lg:py-4" in body
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_render_dashboard_shows_configured_reader_app_link(
|
||||
monkeypatch, tmp_path: Path
|
||||
) -> None:
|
||||
db_path = tmp_path / "dashboard-reader-link.db"
|
||||
monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path))
|
||||
monkeypatch.setenv(
|
||||
"REPUBLISHER_READER_APP_URL",
|
||||
"https://s3.amazonaws.com/anynews/marti-noticias/index.html",
|
||||
)
|
||||
|
||||
async def run() -> None:
|
||||
app = create_app()
|
||||
body = str(await render_dashboard(app))
|
||||
link_match = re.search(
|
||||
r'<a href="https://s3\.amazonaws\.com/anynews/marti-noticias/index\.html"[^>]*>Open AnyNews</a>',
|
||||
body,
|
||||
)
|
||||
|
||||
assert link_match is not None
|
||||
link = link_match.group(0)
|
||||
assert 'target="_blank"' in link
|
||||
assert 'rel="noopener noreferrer"' in link
|
||||
assert "bg-white" in link
|
||||
assert "bg-amber-400" not in link
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_render_dashboard_shows_empty_state_rows(monkeypatch, tmp_path: Path) -> None:
|
||||
db_path = tmp_path / "dashboard-empty.db"
|
||||
monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path))
|
||||
|
|
@ -688,9 +802,9 @@ def test_dashboard_running_table_matches_runs_page_live_table_markup() -> None:
|
|||
"status": "Running",
|
||||
"stats": "1 requests • 1 items • 1 byte",
|
||||
"worker": "streaming stats from worker",
|
||||
"log_href": "/job/1/execution/11/logs",
|
||||
"log_href": "/admin/job/1/execution/11/logs",
|
||||
"cancel_label": "Stop",
|
||||
"cancel_post_path": "/actions/executions/11/cancel",
|
||||
"cancel_post_path": "/admin/actions/executions/11/cancel",
|
||||
},
|
||||
)
|
||||
queued_executions = (
|
||||
|
|
@ -706,8 +820,8 @@ def test_dashboard_running_table_matches_runs_page_live_table_markup() -> None:
|
|||
"status_tone": "idle",
|
||||
"run_label": "Queued",
|
||||
"run_disabled": True,
|
||||
"run_post_path": "/actions/jobs/2/run-now",
|
||||
"cancel_post_path": "/actions/queued-executions/22/cancel",
|
||||
"run_post_path": "/admin/actions/jobs/2/run-now",
|
||||
"cancel_post_path": "/admin/actions/queued-executions/22/cancel",
|
||||
"move_up_disabled": True,
|
||||
"move_up_post_path": None,
|
||||
"move_down_disabled": True,
|
||||
|
|
@ -733,7 +847,7 @@ def test_dashboard_running_table_matches_runs_page_live_table_markup() -> None:
|
|||
assert "running-source" in dashboard_body
|
||||
assert "queued-source" in dashboard_body
|
||||
assert "bg-sky-100 text-sky-800" in dashboard_body
|
||||
assert "/job/1/execution/11/logs" in dashboard_body
|
||||
assert "/admin/job/1/execution/11/logs" in dashboard_body
|
||||
assert runs_body.count(">State<") >= 1
|
||||
|
||||
|
||||
|
|
@ -829,7 +943,11 @@ def test_load_dashboard_view_lists_source_feed_artifacts(
|
|||
|
||||
source_feeds = cast(
|
||||
tuple[dict[str, object], ...],
|
||||
load_dashboard_view(log_dir=log_dir, now=reference_time)["source_feeds"],
|
||||
load_dashboard_view(
|
||||
log_dir=log_dir,
|
||||
now=reference_time,
|
||||
path_prefix="/admin",
|
||||
)["source_feeds"],
|
||||
)
|
||||
|
||||
assert source_feeds == (
|
||||
|
|
@ -845,7 +963,7 @@ def test_load_dashboard_view_lists_source_feed_artifacts(
|
|||
"next_run": "Not scheduled",
|
||||
"next_run_at": None,
|
||||
"run_disabled": False,
|
||||
"run_post_path": f"/actions/jobs/{available_job.id}/run-now",
|
||||
"run_post_path": f"/admin/actions/jobs/{available_job.id}/run-now",
|
||||
"artifact_footprint": "3.0 KB",
|
||||
},
|
||||
{
|
||||
|
|
@ -860,7 +978,7 @@ def test_load_dashboard_view_lists_source_feed_artifacts(
|
|||
"next_run": "Not scheduled",
|
||||
"next_run_at": None,
|
||||
"run_disabled": False,
|
||||
"run_post_path": f"/actions/jobs/{missing_job.id}/run-now",
|
||||
"run_post_path": f"/admin/actions/jobs/{missing_job.id}/run-now",
|
||||
"artifact_footprint": "0 B",
|
||||
},
|
||||
)
|
||||
|
|
@ -991,19 +1109,164 @@ def test_render_dashboard_shows_source_feed_links_and_statuses(
|
|||
assert "Never published" in body
|
||||
assert "Next run" in body
|
||||
assert ">Run now<" in body
|
||||
assert f"/actions/jobs/{published_job.id}/run-now" in body
|
||||
assert f"/actions/jobs/{missing_job.id}/run-now" in body
|
||||
assert f"/admin/actions/jobs/{published_job.id}/run-now" in body
|
||||
assert f"/admin/actions/jobs/{missing_job.id}/run-now" in body
|
||||
assert "data-next-run-at" in body
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_render_publisher_shows_published_feeds_with_publisher_actions(
|
||||
monkeypatch, tmp_path: Path
|
||||
) -> None:
|
||||
db_path = tmp_path / "publisher-render.db"
|
||||
monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path))
|
||||
app = create_app()
|
||||
app.config["REPUB_LOG_DIR"] = tmp_path / "out" / "logs"
|
||||
|
||||
source = create_source(
|
||||
name="Publisher source",
|
||||
slug="publisher-source",
|
||||
source_type="feed",
|
||||
notes="",
|
||||
spider_arguments="",
|
||||
enabled=True,
|
||||
cron_minute="*/5",
|
||||
cron_hour="*",
|
||||
cron_day_of_month="*",
|
||||
cron_day_of_week="*",
|
||||
cron_month="*",
|
||||
feed_url="https://example.com/publisher.xml",
|
||||
)
|
||||
|
||||
async def run() -> None:
|
||||
feed_path = tmp_path / "out" / "feeds" / "publisher-source" / "feed.rss"
|
||||
feed_path.parent.mkdir(parents=True)
|
||||
feed_path.write_text("<rss/>\n", encoding="utf-8")
|
||||
job = _db_reader(lambda: Job.get(Job.source == source))
|
||||
|
||||
body = str(await render_publisher(app, current_path="/publisher"))
|
||||
|
||||
assert body.count("Published feeds") == 1
|
||||
assert "Publisher source" in body
|
||||
assert 'href="/feeds/publisher-source/feed.rss"' in body
|
||||
assert "Available" in body
|
||||
assert "Next run" in body
|
||||
assert "Disk usage" not in body
|
||||
assert f"/publisher/actions/jobs/{job.id}/run-now" in body
|
||||
assert f"/admin/actions/jobs/{job.id}/run-now" not in body
|
||||
assert 'href="/admin/sources"' not in body
|
||||
assert "Create source" not in body
|
||||
assert "Manage sources" not in body
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_render_publisher_shows_live_work_without_admin_controls(
|
||||
monkeypatch, tmp_path: Path
|
||||
) -> None:
|
||||
db_path = tmp_path / "publisher-live-work.db"
|
||||
monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path))
|
||||
app = create_app()
|
||||
app.config["REPUB_LOG_DIR"] = tmp_path / "out" / "logs"
|
||||
|
||||
running_source = create_source(
|
||||
name="Publisher running source",
|
||||
slug="publisher-running-source",
|
||||
source_type="feed",
|
||||
notes="",
|
||||
spider_arguments="",
|
||||
enabled=True,
|
||||
cron_minute="*/5",
|
||||
cron_hour="*",
|
||||
cron_day_of_month="*",
|
||||
cron_day_of_week="*",
|
||||
cron_month="*",
|
||||
feed_url="https://example.com/running.xml",
|
||||
)
|
||||
queued_source = create_source(
|
||||
name="Publisher queued source",
|
||||
slug="publisher-queued-source",
|
||||
source_type="feed",
|
||||
notes="",
|
||||
spider_arguments="",
|
||||
enabled=True,
|
||||
cron_minute="*/5",
|
||||
cron_hour="*",
|
||||
cron_day_of_month="*",
|
||||
cron_day_of_week="*",
|
||||
cron_month="*",
|
||||
feed_url="https://example.com/queued.xml",
|
||||
)
|
||||
running_job, running_execution, queued_execution = _db_writer(
|
||||
lambda: (
|
||||
Job.get(Job.source == running_source),
|
||||
JobExecution.create(
|
||||
job=Job.get(Job.source == running_source),
|
||||
running_status=JobExecutionStatus.RUNNING,
|
||||
started_at=datetime(2026, 3, 30, 12, 0, tzinfo=UTC),
|
||||
),
|
||||
JobExecution.create(
|
||||
job=Job.get(Job.source == queued_source),
|
||||
running_status=JobExecutionStatus.PENDING,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
async def run() -> None:
|
||||
body = str(await render_publisher(app, current_path="/publisher"))
|
||||
|
||||
assert body.index("Published feeds") < body.index("Live work")
|
||||
assert "Running jobs" in body
|
||||
assert "Publisher running source" in body
|
||||
assert "Publisher queued source" in body
|
||||
assert "Queue position #1" in body
|
||||
assert f"/publisher/job/{running_job.id}/execution/" not in body
|
||||
assert (
|
||||
f"/publisher/actions/executions/{int(running_execution.get_id())}/cancel"
|
||||
not in body
|
||||
)
|
||||
assert (
|
||||
f"/publisher/actions/queued-executions/{int(queued_execution.get_id())}/cancel"
|
||||
not in body
|
||||
)
|
||||
assert "View log" not in body
|
||||
assert ">Stop<" not in body
|
||||
assert ">Cancel<" not in body
|
||||
assert body.count(">Actions<") == 1
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_render_publisher_shows_configured_reader_app_link(
|
||||
monkeypatch, tmp_path: Path
|
||||
) -> None:
|
||||
db_path = tmp_path / "publisher-reader-link.db"
|
||||
monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path))
|
||||
monkeypatch.setenv(
|
||||
"REPUBLISHER_READER_APP_URL",
|
||||
"https://s3.amazonaws.com/anynews/marti-noticias/index.html",
|
||||
)
|
||||
app = create_app()
|
||||
|
||||
async def run() -> None:
|
||||
body = str(await render_publisher(app, current_path="/publisher"))
|
||||
|
||||
assert (
|
||||
'href="https://s3.amazonaws.com/anynews/marti-noticias/index.html"' in body
|
||||
)
|
||||
assert 'target="_blank"' in body
|
||||
assert "Open AnyNews" in body
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_render_sources_shows_table_and_create_link() -> None:
|
||||
async def run() -> None:
|
||||
body = str(await render_sources())
|
||||
|
||||
assert ">Sources<" in body
|
||||
assert 'href="/sources/create"' in body
|
||||
assert 'href="/admin/sources/create"' in body
|
||||
assert "No sources yet." in body
|
||||
assert "guardian-feed" not in body
|
||||
assert "podcast-audio" not in body
|
||||
|
|
@ -1048,12 +1311,12 @@ def test_render_sources_shows_live_sidebar_badges(monkeypatch, tmp_path: Path) -
|
|||
body = str(await render_sources(app))
|
||||
|
||||
assert re.search(
|
||||
r'href="/sources"[^>]*>.*?<span>Sources</span>\s*<span[^>]*>2</span>',
|
||||
r'href="/admin/sources"[^>]*>.*?<span>Sources</span>\s*<span[^>]*>2</span>',
|
||||
body,
|
||||
re.S,
|
||||
)
|
||||
assert re.search(
|
||||
r'href="/runs"[^>]*>.*?<span>Runs</span>\s*<span[^>]*>0</span>',
|
||||
r'href="/admin/runs"[^>]*>.*?<span>Runs</span>\s*<span[^>]*>0</span>',
|
||||
body,
|
||||
re.S,
|
||||
)
|
||||
|
|
@ -1086,12 +1349,12 @@ def test_render_dashboard_shows_live_sidebar_badges(
|
|||
body = str(await render_dashboard(app))
|
||||
|
||||
assert re.search(
|
||||
r'href="/sources"[^>]*>.*?<span>Sources</span>\s*<span[^>]*>1</span>',
|
||||
r'href="/admin/sources"[^>]*>.*?<span>Sources</span>\s*<span[^>]*>1</span>',
|
||||
body,
|
||||
re.S,
|
||||
)
|
||||
assert re.search(
|
||||
r'href="/runs"[^>]*>.*?<span>Runs</span>\s*<span[^>]*>0</span>',
|
||||
r'href="/admin/runs"[^>]*>.*?<span>Runs</span>\s*<span[^>]*>0</span>',
|
||||
body,
|
||||
re.S,
|
||||
)
|
||||
|
|
@ -1125,7 +1388,7 @@ def test_render_sources_shows_delete_action_for_each_source(
|
|||
|
||||
assert "Delete" in body
|
||||
assert "data-on:pointerdown" in body
|
||||
assert "/actions/sources/delete-me/delete" in body
|
||||
assert "/admin/actions/sources/delete-me/delete" in body
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
|
@ -1137,7 +1400,7 @@ def test_render_create_source_shows_dedicated_form_page() -> None:
|
|||
assert ">Create source<" in body
|
||||
assert "Source and job setup" in body
|
||||
assert "data-signals__ifmissing" in body
|
||||
assert "/actions/sources/create" in body
|
||||
assert "/admin/actions/sources/create" in body
|
||||
assert 'data-show="$sourceType === 'feed'"' in body
|
||||
assert 'data-show="$sourceType === 'pangea'"' in body
|
||||
assert "jobEnabled" in body
|
||||
|
|
@ -1206,7 +1469,7 @@ def test_render_edit_source_shows_existing_values(monkeypatch, tmp_path: Path) -
|
|||
body = str(await render_edit_source("kenya-health"))
|
||||
|
||||
assert "Edit source" in body
|
||||
assert "/actions/sources/kenya-health/edit" in body
|
||||
assert "/admin/actions/sources/kenya-health/edit" in body
|
||||
assert "Kenya health desk" in body
|
||||
assert "kenya-health" in body
|
||||
assert 'id="source-slug"' in body
|
||||
|
|
@ -1239,7 +1502,7 @@ def test_render_settings_shows_current_max_concurrent_jobs(
|
|||
body = str(await render_settings(app))
|
||||
|
||||
assert ">Settings<" in body
|
||||
assert "/actions/settings" in body
|
||||
assert "/admin/actions/settings" in body
|
||||
assert 'value="3"' in body
|
||||
assert 'value="https://mirror.example"' in body
|
||||
assert "Max concurrent jobs" in body
|
||||
|
|
@ -1263,7 +1526,7 @@ def test_create_source_action_creates_pangea_source_and_job_in_database(
|
|||
client = app.test_client()
|
||||
|
||||
response = await client.post(
|
||||
"/actions/sources/create",
|
||||
"/admin/actions/sources/create",
|
||||
headers={"Datastar-Request": "true"},
|
||||
json={
|
||||
"sourceName": "Kenya health desk",
|
||||
|
|
@ -1291,7 +1554,7 @@ def test_create_source_action_creates_pangea_source_and_job_in_database(
|
|||
body = await response.get_data(as_text=True)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "window.location = '/sources'" in body
|
||||
assert "window.location = '/admin/sources'" in body
|
||||
|
||||
source, pangea, job = _db_reader(
|
||||
lambda: (
|
||||
|
|
@ -1331,7 +1594,7 @@ def test_create_source_action_creates_feed_source_and_job_in_database(
|
|||
client = app.test_client()
|
||||
|
||||
response = await client.post(
|
||||
"/actions/sources/create",
|
||||
"/admin/actions/sources/create",
|
||||
headers={"Datastar-Request": "true"},
|
||||
json={
|
||||
"sourceName": "NASA feed",
|
||||
|
|
@ -1351,7 +1614,7 @@ def test_create_source_action_creates_feed_source_and_job_in_database(
|
|||
body = await response.get_data(as_text=True)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "window.location = '/sources'" in body
|
||||
assert "window.location = '/admin/sources'" in body
|
||||
|
||||
source, feed, job = _db_reader(
|
||||
lambda: (
|
||||
|
|
@ -1409,7 +1672,7 @@ def test_edit_source_action_updates_existing_source_and_job_in_database(
|
|||
client = app.test_client()
|
||||
|
||||
response = await client.post(
|
||||
"/actions/sources/kenya-health/edit",
|
||||
"/admin/actions/sources/kenya-health/edit",
|
||||
headers={"Datastar-Request": "true"},
|
||||
json={
|
||||
"sourceName": "Kenya health desk nightly",
|
||||
|
|
@ -1440,7 +1703,7 @@ def test_edit_source_action_updates_existing_source_and_job_in_database(
|
|||
body = await response.get_data(as_text=True)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "window.location = '/sources'" in body
|
||||
assert "window.location = '/admin/sources'" in body
|
||||
|
||||
source, pangea, job = _db_reader(
|
||||
lambda: (
|
||||
|
|
@ -1505,7 +1768,7 @@ def test_edit_source_action_rejects_slug_changes(monkeypatch, tmp_path: Path) ->
|
|||
client = app.test_client()
|
||||
|
||||
response = await client.post(
|
||||
"/actions/sources/kenya-health/edit",
|
||||
"/admin/actions/sources/kenya-health/edit",
|
||||
headers={"Datastar-Request": "true"},
|
||||
json={
|
||||
"sourceName": "Kenya health desk",
|
||||
|
|
@ -1569,7 +1832,7 @@ def test_create_source_action_validates_duplicate_slug_and_pangea_type(
|
|||
client = app.test_client()
|
||||
|
||||
response = await client.post(
|
||||
"/actions/sources/create",
|
||||
"/admin/actions/sources/create",
|
||||
headers={"Datastar-Request": "true"},
|
||||
json={
|
||||
"sourceName": "Duplicate guardian",
|
||||
|
|
@ -1619,7 +1882,7 @@ def test_settings_action_updates_max_concurrent_jobs(
|
|||
client = app.test_client()
|
||||
|
||||
response = await client.post(
|
||||
"/actions/settings",
|
||||
"/admin/actions/settings",
|
||||
headers={"Datastar-Request": "true"},
|
||||
json={
|
||||
"maxConcurrentJobs": "3",
|
||||
|
|
@ -1629,7 +1892,7 @@ def test_settings_action_updates_max_concurrent_jobs(
|
|||
body = await response.get_data(as_text=True)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "window.location = '/settings'" in body
|
||||
assert "window.location = '/admin/settings'" in body
|
||||
assert load_max_concurrent_jobs() == 3
|
||||
assert load_settings_form()["feed_url"] == "https://mirror.example"
|
||||
assert 'value="3"' in str(await render_settings(app))
|
||||
|
|
@ -1648,7 +1911,7 @@ def test_settings_action_rejects_non_positive_max_concurrent_jobs(
|
|||
client = app.test_client()
|
||||
|
||||
response = await client.post(
|
||||
"/actions/settings",
|
||||
"/admin/actions/settings",
|
||||
headers={"Datastar-Request": "true"},
|
||||
json={"maxConcurrentJobs": "0", "feedUrl": "https://mirror.example"},
|
||||
)
|
||||
|
|
@ -1670,7 +1933,7 @@ def test_settings_action_rejects_invalid_feed_url(monkeypatch, tmp_path: Path) -
|
|||
client = app.test_client()
|
||||
|
||||
response = await client.post(
|
||||
"/actions/settings",
|
||||
"/admin/actions/settings",
|
||||
headers={"Datastar-Request": "true"},
|
||||
json={"maxConcurrentJobs": "2", "feedUrl": "mirror.example"},
|
||||
)
|
||||
|
|
@ -1722,7 +1985,7 @@ def test_render_runs_shows_running_scheduled_and_completed_tables(
|
|||
assert "Scheduled jobs" in body
|
||||
assert "Completed job executions" in body
|
||||
assert "runs-render-source" in body
|
||||
assert f"/job/{job.id}/execution/{execution.get_id()}/logs" in body
|
||||
assert f"/admin/job/{job.id}/execution/{execution.get_id()}/logs" in body
|
||||
assert "data-next-run-at" in body
|
||||
assert "in " in body
|
||||
|
||||
|
|
@ -1797,7 +2060,7 @@ def test_runs_pagination_action_updates_only_the_current_tab(
|
|||
)
|
||||
|
||||
async with client.request(
|
||||
"/runs?u=shim",
|
||||
"/admin/runs?u=shim",
|
||||
method="POST",
|
||||
headers={
|
||||
"Datastar-Request": "true",
|
||||
|
|
@ -1805,7 +2068,7 @@ def test_runs_pagination_action_updates_only_the_current_tab(
|
|||
},
|
||||
) as first_connection:
|
||||
async with client.request(
|
||||
"/runs?u=shim",
|
||||
"/admin/runs?u=shim",
|
||||
method="POST",
|
||||
headers={
|
||||
"Datastar-Request": "true",
|
||||
|
|
@ -1825,7 +2088,7 @@ def test_runs_pagination_action_updates_only_the_current_tab(
|
|||
).decode()
|
||||
|
||||
assert (
|
||||
'href="/runs?completed_page=1" aria-current="page"'
|
||||
'href="/admin/runs?completed_page=1" aria-current="page"'
|
||||
not in first_body
|
||||
)
|
||||
assert (
|
||||
|
|
@ -1840,7 +2103,7 @@ def test_runs_pagination_action_updates_only_the_current_tab(
|
|||
) in second_body
|
||||
|
||||
response = await client.post(
|
||||
"/actions/runs/completed-page/2",
|
||||
"/admin/actions/runs/completed-page/2",
|
||||
headers={"Datastar-Request": "true"},
|
||||
json={"tabid": "tab-1"},
|
||||
)
|
||||
|
|
@ -1878,7 +2141,7 @@ def test_runs_patch_creates_and_cleans_up_tab_state(
|
|||
client = app.test_client()
|
||||
|
||||
async with client.request(
|
||||
"/runs?u=shim",
|
||||
"/admin/runs?u=shim",
|
||||
method="POST",
|
||||
headers={
|
||||
"Datastar-Request": "true",
|
||||
|
|
@ -1955,7 +2218,7 @@ def test_render_runs_keeps_queued_execution_in_scheduled_jobs_table(
|
|||
assert "scheduled-source" in body
|
||||
assert ">Queued<" in body
|
||||
assert (
|
||||
f"/actions/queued-executions/{int(queued_execution.get_id())}/cancel"
|
||||
f"/admin/actions/queued-executions/{int(queued_execution.get_id())}/cancel"
|
||||
in body
|
||||
)
|
||||
assert "Ready" in body
|
||||
|
|
@ -2004,9 +2267,12 @@ def test_render_runs_shows_cancel_button_for_running_row_with_queued_follow_up(
|
|||
async def run() -> None:
|
||||
body = str(await render_runs(app))
|
||||
|
||||
assert f"/job/{job.id}/execution/{int(running_execution.get_id())}/logs" in body
|
||||
assert (
|
||||
f"/actions/queued-executions/{int(pending_execution.get_id())}/cancel"
|
||||
f"/admin/job/{job.id}/execution/{int(running_execution.get_id())}/logs"
|
||||
in body
|
||||
)
|
||||
assert (
|
||||
f"/admin/actions/queued-executions/{int(pending_execution.get_id())}/cancel"
|
||||
in body
|
||||
)
|
||||
assert ">Cancel<" in body
|
||||
|
|
@ -2031,9 +2297,9 @@ def test_render_runs_keeps_all_action_controls_visible_in_html_after_compaction(
|
|||
"status": "Running",
|
||||
"stats": "1 requests • 1 items • 1 byte",
|
||||
"worker": "streaming stats from worker",
|
||||
"log_href": "/job/1/execution/11/logs",
|
||||
"log_href": "/admin/job/1/execution/11/logs",
|
||||
"cancel_label": "Stop",
|
||||
"cancel_post_path": "/actions/executions/11/cancel",
|
||||
"cancel_post_path": "/admin/actions/executions/11/cancel",
|
||||
},
|
||||
),
|
||||
queued_executions=(
|
||||
|
|
@ -2049,8 +2315,8 @@ def test_render_runs_keeps_all_action_controls_visible_in_html_after_compaction(
|
|||
"status_tone": "idle",
|
||||
"run_label": "Queued",
|
||||
"run_disabled": True,
|
||||
"run_post_path": "/actions/jobs/2/run-now",
|
||||
"cancel_post_path": "/actions/queued-executions/22/cancel",
|
||||
"run_post_path": "/admin/actions/jobs/2/run-now",
|
||||
"cancel_post_path": "/admin/actions/queued-executions/22/cancel",
|
||||
"move_up_disabled": True,
|
||||
"move_up_post_path": None,
|
||||
"move_down_disabled": True,
|
||||
|
|
@ -2070,9 +2336,9 @@ def test_render_runs_keeps_all_action_controls_visible_in_html_after_compaction(
|
|||
"run_disabled": False,
|
||||
"run_reason": "Ready",
|
||||
"toggle_label": "Disable",
|
||||
"toggle_post_path": "/actions/jobs/3/toggle-enabled",
|
||||
"run_post_path": "/actions/jobs/3/run-now",
|
||||
"delete_post_path": "/actions/jobs/3/delete",
|
||||
"toggle_post_path": "/admin/actions/jobs/3/toggle-enabled",
|
||||
"run_post_path": "/admin/actions/jobs/3/run-now",
|
||||
"delete_post_path": "/admin/actions/jobs/3/delete",
|
||||
},
|
||||
),
|
||||
completed_executions=(
|
||||
|
|
@ -2087,7 +2353,7 @@ def test_render_runs_keeps_all_action_controls_visible_in_html_after_compaction(
|
|||
"status_tone": "done",
|
||||
"stats": "1 requests • 1 items • 1 byte",
|
||||
"summary": "Worker exited successfully",
|
||||
"log_href": "/job/4/execution/44/logs",
|
||||
"log_href": "/admin/job/4/execution/44/logs",
|
||||
},
|
||||
),
|
||||
)
|
||||
|
|
@ -2098,7 +2364,7 @@ def test_render_runs_keeps_all_action_controls_visible_in_html_after_compaction(
|
|||
assert ">Cancel<" in body
|
||||
assert ">Run now<" in body
|
||||
assert ">Disable<" in body
|
||||
assert "/job/4/execution/44/logs" in body
|
||||
assert "/admin/job/4/execution/44/logs" in body
|
||||
|
||||
|
||||
def test_cancel_queued_execution_action_deletes_pending_row_without_touching_running_execution(
|
||||
|
|
@ -2143,7 +2409,7 @@ def test_cancel_queued_execution_action_deletes_pending_row_without_touching_run
|
|||
)
|
||||
|
||||
response = await client.post(
|
||||
f"/actions/queued-executions/{int(pending_execution.get_id())}/cancel"
|
||||
f"/admin/actions/queued-executions/{int(pending_execution.get_id())}/cancel"
|
||||
)
|
||||
|
||||
assert response.status_code == 204
|
||||
|
|
@ -2217,7 +2483,7 @@ def test_clear_completed_executions_action_removes_history_and_log_artifacts(
|
|||
completed_prefix.with_suffix(suffix).write_text("history", encoding="utf-8")
|
||||
running_log_path.write_text("running", encoding="utf-8")
|
||||
|
||||
response = await client.post("/actions/completed-executions/clear")
|
||||
response = await client.post("/admin/actions/completed-executions/clear")
|
||||
|
||||
assert response.status_code == 204
|
||||
assert (
|
||||
|
|
@ -2297,18 +2563,18 @@ def test_move_queued_execution_action_reorders_queue(
|
|||
)
|
||||
|
||||
response = await client.post(
|
||||
f"/actions/queued-executions/{int(second_execution.get_id())}/move-up"
|
||||
f"/admin/actions/queued-executions/{int(second_execution.get_id())}/move-up"
|
||||
)
|
||||
|
||||
assert response.status_code == 204
|
||||
body = str(await render_runs(app))
|
||||
assert body.index("second-queued-source") < body.index("first-queued-source")
|
||||
assert (
|
||||
f"/actions/queued-executions/{int(second_execution.get_id())}/move-down"
|
||||
f"/admin/actions/queued-executions/{int(second_execution.get_id())}/move-down"
|
||||
in body
|
||||
)
|
||||
assert (
|
||||
f"/actions/queued-executions/{int(first_execution.get_id())}/move-up"
|
||||
f"/admin/actions/queued-executions/{int(first_execution.get_id())}/move-up"
|
||||
in body
|
||||
)
|
||||
|
||||
|
|
@ -2349,7 +2615,7 @@ def test_toggle_job_enabled_action_removes_queued_execution(
|
|||
)
|
||||
)
|
||||
|
||||
response = await client.post(f"/actions/jobs/{job.id}/toggle-enabled")
|
||||
response = await client.post(f"/admin/actions/jobs/{job.id}/toggle-enabled")
|
||||
|
||||
assert response.status_code == 204
|
||||
assert _db_reader(lambda: Job.get_by_id(job.id).enabled) is False
|
||||
|
|
@ -2361,7 +2627,7 @@ def test_toggle_job_enabled_action_removes_queued_execution(
|
|||
)
|
||||
body = str(await render_runs(app))
|
||||
assert (
|
||||
f"/actions/queued-executions/{int(queued_execution.get_id())}/cancel"
|
||||
f"/admin/actions/queued-executions/{int(queued_execution.get_id())}/cancel"
|
||||
not in body
|
||||
)
|
||||
assert "Disabled" in body
|
||||
|
|
@ -2439,7 +2705,7 @@ def test_render_execution_logs_uses_app_route(monkeypatch, tmp_path: Path) -> No
|
|||
)
|
||||
|
||||
assert f"Job {job.id} / execution {execution.get_id()}" in body
|
||||
assert f"/job/{job.id}/execution/{execution.get_id()}/logs" in body
|
||||
assert f"/admin/job/{job.id}/execution/{execution.get_id()}/logs" in body
|
||||
assert "waiting for more log lines" in body
|
||||
|
||||
asyncio.run(run())
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue