Compare commits
3 commits
06c44e8f7d
...
2a99edeec3
| Author | SHA1 | Date | |
|---|---|---|---|
| 2a99edeec3 | |||
| 94717b1d1b | |||
| 6e03e747ff |
8 changed files with 151 additions and 6 deletions
|
|
@ -81,6 +81,7 @@ The only way for actions to affect the view returned by the render-fn running in
|
||||||
- Enter the dev environment with `nix develop` if you are not already inside it
|
- Enter the dev environment with `nix develop` if you are not already inside it
|
||||||
- Sync Python dependencies with `uv sync --all-groups`.
|
- Sync Python dependencies with `uv sync --all-groups`.
|
||||||
- Run the app with `uv run repub`.
|
- Run the app with `uv run repub`.
|
||||||
|
- Generate CSS with `tailwindcss -i ./path/to/input.css -o ./path/to/output.css` and add `--watch` when you need live rebuilds.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
uv sync --all-groups
|
uv sync --all-groups
|
||||||
|
|
|
||||||
|
|
@ -305,6 +305,15 @@ def delete_job_source(job_id: int) -> bool:
|
||||||
return source.delete_instance() > 0
|
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], ...]:
|
def load_sources() -> tuple[dict[str, object], ...]:
|
||||||
with database.connection_context():
|
with database.connection_context():
|
||||||
sources = tuple(Source.select().order_by(Source.created_at.desc()))
|
sources = tuple(Source.select().order_by(Source.created_at.desc()))
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,29 @@ def _checked(source: Mapping[str, object] | None, key: str, default: bool) -> bo
|
||||||
return bool(value)
|
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, ...]:
|
def _source_row(source: Mapping[str, object]) -> tuple[Node, ...]:
|
||||||
return (
|
return (
|
||||||
h.div[
|
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"
|
href=f"/sources/{source['slug']}/edit", label="Edit", tone="amber"
|
||||||
),
|
),
|
||||||
inline_link(href="/runs", label="View runs"),
|
inline_link(href="/runs", label="View runs"),
|
||||||
|
_action_button(
|
||||||
|
label="Delete",
|
||||||
|
tone="danger",
|
||||||
|
post_path=f"/actions/sources/{source['slug']}/delete",
|
||||||
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -392,6 +392,10 @@
|
||||||
--tw-translate-x: calc(var(--spacing) * 0);
|
--tw-translate-x: calc(var(--spacing) * 0);
|
||||||
translate: var(--tw-translate-x) var(--tw-translate-y);
|
translate: var(--tw-translate-x) var(--tw-translate-y);
|
||||||
}
|
}
|
||||||
|
.translate-x-5 {
|
||||||
|
--tw-translate-x: calc(var(--spacing) * 5);
|
||||||
|
translate: var(--tw-translate-x) var(--tw-translate-y);
|
||||||
|
}
|
||||||
.cursor-not-allowed {
|
.cursor-not-allowed {
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
@ -541,6 +545,9 @@
|
||||||
.bg-amber-400 {
|
.bg-amber-400 {
|
||||||
background-color: var(--color-amber-400);
|
background-color: var(--color-amber-400);
|
||||||
}
|
}
|
||||||
|
.bg-amber-500 {
|
||||||
|
background-color: var(--color-amber-500);
|
||||||
|
}
|
||||||
.bg-emerald-100 {
|
.bg-emerald-100 {
|
||||||
background-color: var(--color-emerald-100);
|
background-color: var(--color-emerald-100);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1 +1,2 @@
|
||||||
@import "tailwindcss" source("../");
|
@import "tailwindcss" source("../");
|
||||||
|
@source inline("bg-amber-500 translate-x-5");
|
||||||
|
|
|
||||||
15
repub/web.py
15
repub/web.py
|
|
@ -26,6 +26,7 @@ from repub.model import (
|
||||||
Job,
|
Job,
|
||||||
create_source,
|
create_source,
|
||||||
delete_job_source,
|
delete_job_source,
|
||||||
|
delete_source,
|
||||||
initialize_database,
|
initialize_database,
|
||||||
load_source_form,
|
load_source_form,
|
||||||
load_sources,
|
load_sources,
|
||||||
|
|
@ -209,6 +210,13 @@ def create_app(*, dev_mode: bool = False) -> Quart:
|
||||||
trigger_refresh(app)
|
trigger_refresh(app)
|
||||||
return DatastarResponse(SSE.redirect("/sources"))
|
return DatastarResponse(SSE.redirect("/sources"))
|
||||||
|
|
||||||
|
@app.post("/actions/sources/<string:slug>/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")
|
@app.post("/runs")
|
||||||
async def runs_patch() -> DatastarResponse:
|
async def runs_patch() -> DatastarResponse:
|
||||||
return _page_patch_response(app, lambda: render_runs(app))
|
return _page_patch_response(app, lambda: render_runs(app))
|
||||||
|
|
@ -383,7 +391,7 @@ def validate_source_form(
|
||||||
source_type = _read_string(signals, "sourceType")
|
source_type = _read_string(signals, "sourceType")
|
||||||
feed_url = _read_string(signals, "feedUrl")
|
feed_url = _read_string(signals, "feedUrl")
|
||||||
pangea_domain = _read_string(signals, "pangeaDomain")
|
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_format = _read_string(signals, "contentFormat")
|
||||||
content_type = _read_string(signals, "contentType")
|
content_type = _read_string(signals, "contentType")
|
||||||
max_articles = _read_string(signals, "maxArticles")
|
max_articles = _read_string(signals, "maxArticles")
|
||||||
|
|
@ -474,8 +482,9 @@ def validate_source_form(
|
||||||
return source, None
|
return source, None
|
||||||
|
|
||||||
|
|
||||||
def _read_string(signals: dict[str, object], key: str) -> str:
|
def _read_string(signals: dict[str, object], key: str, *, strip: bool = True) -> str:
|
||||||
return str(signals.get(key, "")).strip()
|
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:
|
def _read_bool(signals: dict[str, object], key: str, *, default: bool = False) -> bool:
|
||||||
|
|
|
||||||
|
|
@ -604,6 +604,46 @@ def test_delete_job_action_removes_source_job_and_execution_history(
|
||||||
asyncio.run(run())
|
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(
|
def _wait_for_running_execution(
|
||||||
execution_id: int, *, timeout_seconds: float = 2.0
|
execution_id: int, *, timeout_seconds: float = 2.0
|
||||||
) -> JobExecution:
|
) -> JobExecution:
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ from datetime import UTC, datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, cast
|
from typing import Any, cast
|
||||||
|
|
||||||
from repub.components import status_badge
|
from repub.components import status_badge, toggle_field
|
||||||
from repub.datastar import RefreshBroker, render_sse_event, render_stream
|
from repub.datastar import RefreshBroker, render_sse_event, render_stream
|
||||||
from repub.jobs import load_dashboard_view
|
from repub.jobs import load_dashboard_view
|
||||||
from repub.model import (
|
from repub.model import (
|
||||||
|
|
@ -38,6 +38,25 @@ def test_status_badge_uses_green_done_tone() -> None:
|
||||||
assert "Succeeded" in badge
|
assert "Succeeded" in badge
|
||||||
|
|
||||||
|
|
||||||
|
def test_toggle_field_active_state_utilities_exist_in_built_css() -> None:
|
||||||
|
markup = str(
|
||||||
|
toggle_field(
|
||||||
|
label="Enabled",
|
||||||
|
description="Enable this source",
|
||||||
|
signal_name="enabled",
|
||||||
|
checked=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
css = (
|
||||||
|
Path(__file__).resolve().parents[1] / "repub" / "static" / "app.css"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert "data-class:bg-amber-500" in markup
|
||||||
|
assert "data-class:translate-x-5" in markup
|
||||||
|
assert ".bg-amber-500" in css
|
||||||
|
assert ".translate-x-5" in css
|
||||||
|
|
||||||
|
|
||||||
def test_runs_page_renders_completed_execution_end_time_as_relative_hoverable_time() -> (
|
def test_runs_page_renders_completed_execution_end_time_as_relative_hoverable_time() -> (
|
||||||
None
|
None
|
||||||
):
|
):
|
||||||
|
|
@ -411,6 +430,37 @@ def test_render_sources_shows_table_and_create_link() -> None:
|
||||||
asyncio.run(run())
|
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:
|
def test_render_create_source_shows_dedicated_form_page() -> None:
|
||||||
async def run() -> None:
|
async def run() -> None:
|
||||||
body = str(await render_create_source())
|
body = str(await render_create_source())
|
||||||
|
|
@ -518,7 +568,7 @@ def test_create_source_action_creates_pangea_source_and_job_in_database(
|
||||||
"sourceSlug": "kenya-health",
|
"sourceSlug": "kenya-health",
|
||||||
"sourceType": "pangea",
|
"sourceType": "pangea",
|
||||||
"pangeaDomain": "example.org",
|
"pangeaDomain": "example.org",
|
||||||
"pangeaCategory": "Health",
|
"pangeaCategory": " Health ",
|
||||||
"contentFormat": "MOBILE_3",
|
"contentFormat": "MOBILE_3",
|
||||||
"contentType": "breakingnews",
|
"contentType": "breakingnews",
|
||||||
"maxArticles": "12",
|
"maxArticles": "12",
|
||||||
|
|
@ -548,13 +598,13 @@ def test_create_source_action_creates_pangea_source_and_job_in_database(
|
||||||
|
|
||||||
assert source.name == "Kenya health desk"
|
assert source.name == "Kenya health desk"
|
||||||
assert source.source_type == "pangea"
|
assert source.source_type == "pangea"
|
||||||
|
assert pangea.category_name == " Health "
|
||||||
assert pangea.content_type == "breakingnews"
|
assert pangea.content_type == "breakingnews"
|
||||||
assert pangea.include_content is True
|
assert pangea.include_content is True
|
||||||
assert job.enabled is True
|
assert job.enabled is True
|
||||||
assert job.spider_arguments == "language=en\ndownload_media=true"
|
assert job.spider_arguments == "language=en\ndownload_media=true"
|
||||||
assert job.cron_hour == "*/6"
|
assert job.cron_hour == "*/6"
|
||||||
assert "kenya-health" in rendered_sources
|
assert "kenya-health" in rendered_sources
|
||||||
assert "example.org / Health" in rendered_sources
|
|
||||||
assert "Enabled" in rendered_sources
|
assert "Enabled" in rendered_sources
|
||||||
|
|
||||||
asyncio.run(run())
|
asyncio.run(run())
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue