Add new webhook detail view

This commit is contained in:
Joakim Hellsén 2026-03-16 01:10:49 +01:00
commit bf94f3f3e4
Signed by: Joakim Hellsén
SSH key fingerprint: SHA256:/9h/CsExpFp+PRhsfA0xznFx2CGfTT5R/kpuFfUgEQk
4 changed files with 214 additions and 11 deletions

View file

@ -1473,12 +1473,14 @@ def modify_webhook(
old_hook: Annotated[str, Form()], old_hook: Annotated[str, Form()],
new_hook: Annotated[str, Form()], new_hook: Annotated[str, Form()],
reader: Annotated[Reader, Depends(get_reader_dependency)], reader: Annotated[Reader, Depends(get_reader_dependency)],
redirect_to: Annotated[str, Form()] = "",
): ):
"""Modify a webhook. """Modify a webhook.
Args: Args:
old_hook: The webhook to modify. old_hook: The webhook to modify.
new_hook: The new webhook. new_hook: The new webhook.
redirect_to: Optional redirect URL after the update.
reader: The Reader instance. reader: The Reader instance.
Returns: Returns:
@ -1515,8 +1517,13 @@ def modify_webhook(
if webhook == old_hook.strip(): if webhook == old_hook.strip():
reader.set_tag(feed.url, "webhook", new_hook.strip()) # pyright: ignore[reportArgumentType] reader.set_tag(feed.url, "webhook", new_hook.strip()) # pyright: ignore[reportArgumentType]
# Redirect to the webhook page. redirect_url: str = redirect_to.strip() or "/webhooks"
return RedirectResponse(url="/webhooks", status_code=303) if redirect_to:
redirect_url = redirect_url.replace(urllib.parse.quote(old_hook.strip()), urllib.parse.quote(new_hook.strip()))
redirect_url = redirect_url.replace(old_hook.strip(), new_hook.strip())
# Redirect to the requested page.
return RedirectResponse(url=redirect_url, status_code=303)
def extract_youtube_video_id(url: str) -> str | None: def extract_youtube_video_id(url: str) -> str | None:
@ -1577,6 +1584,8 @@ async def get_webhook_entries( # noqa: C901, PLR0914
if not webhook_name: if not webhook_name:
raise HTTPException(status_code=404, detail=f"Webhook not found: {clean_webhook_url}") raise HTTPException(status_code=404, detail=f"Webhook not found: {clean_webhook_url}")
hook_info: WebhookInfo = get_data_from_hook_url(hook_name=webhook_name, hook_url=clean_webhook_url)
# Get all feeds associated with this webhook # Get all feeds associated with this webhook
all_feeds: list[Feed] = list(reader.get_feeds()) all_feeds: list[Feed] = list(reader.get_feeds())
webhook_feeds: list[Feed] = [] webhook_feeds: list[Feed] = []
@ -1632,8 +1641,10 @@ async def get_webhook_entries( # noqa: C901, PLR0914
context = { context = {
"request": request, "request": request,
"hook_info": hook_info,
"webhook_name": webhook_name, "webhook_name": webhook_name,
"webhook_url": clean_webhook_url, "webhook_url": clean_webhook_url,
"webhook_feeds": webhook_feeds,
"entries": paginated_entries, "entries": paginated_entries,
"html": html, "html": html,
"last_entry": last_entry, "last_entry": last_entry,

View file

@ -33,7 +33,8 @@
<div class="p-2 mb-3 border border-dark"> <div class="p-2 mb-3 border border-dark">
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
<h2 class="h5 mb-0"> <h2 class="h5 mb-0">
<a class="text-muted" href="/webhooks">{{ hook_from_context.name }}</a> <a class="text-muted"
href="/webhook_entries?webhook_url={{ hook_from_context.url|encode_url }}">{{ hook_from_context.name }}</a>
</h2> </h2>
<a class="text-muted" <a class="text-muted"
href="/webhook_entries?webhook_url={{ hook_from_context.url|encode_url }}">View Latest Entries</a> href="/webhook_entries?webhook_url={{ hook_from_context.url|encode_url }}">View Latest Entries</a>

View file

@ -1,20 +1,96 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %} {% block title %}
| {{ webhook_name }} - Latest Entries | {{ webhook_name }}
{% endblock title %} {% endblock title %}
{% block content %} {% block content %}
<div class="card mb-3 border border-dark p-3 text-light"> <div class="card mb-3 border border-dark p-3 text-light">
<!-- Webhook Title --> <div class="d-flex flex-column flex-md-row justify-content-between gap-3">
<h2>{{ webhook_name }} - Latest Entries ({{ total_entries }} total from {{ feeds_count }} feeds)</h2> <div>
<!-- Webhook Info --> <h2 class="mb-2">{{ webhook_name }}</h2>
<div class="mt-3"> <p class="text-muted mb-1">
<p class="text-muted"> {{ total_entries }} total from {{ feeds_count }} feed{{ 's' if feeds_count != 1 else '' }}
</p>
<p class="text-muted mb-0">
<code>{{ webhook_url }}</code> <code>{{ webhook_url }}</code>
</p> </p>
</div> </div>
<div class="d-flex gap-2 align-items-start">
<a class="btn btn-outline-light btn-sm" href="/">Back to dashboard</a>
<a class="btn btn-outline-info btn-sm" href="/webhooks">All webhooks</a>
</div>
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-lg-5">
<div class="card border border-dark p-3 text-light h-100">
<h3 class="h5">Settings</h3>
<ul class="list-unstyled text-muted mb-3">
<li>
<strong>Custom name:</strong> {{ hook_info.custom_name }}
</li>
<li>
<strong>Discord name:</strong> {{ hook_info.name or 'Unavailable' }}
</li>
<li>
<strong>Webhook:</strong>
<a class="text-muted" href="{{ hook_info.url }}">{{ hook_info.url | replace('https://discord.com/api/webhooks', '') }}</a>
</li>
</ul>
<form action="/modify_webhook" method="post" class="row g-3 mb-3">
<input type="hidden" name="old_hook" value="{{ webhook_url }}" />
<input type="hidden"
name="redirect_to"
value="/webhook_entries?webhook_url={{ webhook_url|encode_url }}" />
<div class="col-12">
<label for="new_hook" class="form-label">Modify Webhook</label>
<input type="text"
name="new_hook"
id="new_hook"
class="form-control border text-muted bg-dark"
placeholder="Enter new webhook URL" />
</div>
<div class="col-12">
<button type="submit" class="btn btn-primary w-100">Save Webhook URL</button>
</div>
</form>
<form action="/delete_webhook" method="post">
<input type="hidden" name="webhook_url" value="{{ webhook_url }}" />
<button type="submit"
class="btn btn-danger w-100"
onclick="return confirm('Are you sure you want to delete this webhook?');">
Delete Webhook
</button>
</form>
</div>
</div>
<div class="col-lg-7">
<div class="card border border-dark p-3 text-light h-100">
<h3 class="h5">Attached feeds</h3>
{% if webhook_feeds %}
<ul class="list-group list-unstyled mb-0">
{% for feed in webhook_feeds %}
<li class="mb-2">
<a class="text-muted" href="/feed?feed_url={{ feed.url|encode_url }}">
{% if feed.title %}
{{ feed.title }}
{% else %}
{{ feed.url }}
{% endif %}
</a>
{% if not feed.updates_enabled %}<span class="text-warning">Disabled</span>{% endif %}
{% if feed.last_exception %}<span class="text-danger">({{ feed.last_exception.value_str }})</span>{% endif %}
</li>
{% endfor %}
</ul>
{% else %}
<p class="text-muted mb-0">No feeds are attached to this webhook yet.</p>
{% endif %}
</div>
</div>
</div> </div>
{# Rendered HTML content #} {# Rendered HTML content #}
{% if entries %} {% if entries %}
<h3 class="h5 text-light">Latest entries</h3>
<pre>{{ html|safe }}</pre> <pre>{{ html|safe }}</pre>
{% if is_show_more_entries_button_visible and last_entry %} {% if is_show_more_entries_button_visible and last_entry %}
<a class="btn btn-dark mt-3" <a class="btn btn-dark mt-3"

View file

@ -158,6 +158,9 @@ def test_get() -> None:
response: Response = client.get(url="/webhooks") response: Response = client.get(url="/webhooks")
assert response.status_code == 200, f"/webhooks failed: {response.text}" assert response.status_code == 200, f"/webhooks failed: {response.text}"
response = client.get(url="/webhook_entries", params={"webhook_url": webhook_url})
assert response.status_code == 200, f"/webhook_entries failed: {response.text}"
response: Response = client.get(url="/whitelist", params={"feed_url": encoded_feed_url(feed_url)}) response: Response = client.get(url="/whitelist", params={"feed_url": encoded_feed_url(feed_url)})
assert response.status_code == 200, f"/whitelist failed: {response.text}" assert response.status_code == 200, f"/whitelist failed: {response.text}"
@ -882,6 +885,32 @@ def test_webhook_entries_no_feeds() -> None:
assert "No feeds found" in response.text or "Add feeds" in response.text, "Expected message about no feeds" assert "No feeds found" in response.text or "Add feeds" in response.text, "Expected message about no feeds"
def test_webhook_entries_no_feeds_still_shows_webhook_settings() -> None:
"""The webhook detail view should show settings/actions even with no attached feeds."""
client.post(url="/delete_webhook", data={"webhook_url": webhook_url})
response: Response = client.post(
url="/add_webhook",
data={"webhook_name": webhook_name, "webhook_url": webhook_url},
)
assert response.status_code == 200, f"Failed to add webhook: {response.text}"
response = client.get(
url="/webhook_entries",
params={"webhook_url": webhook_url},
)
assert response.status_code == 200, f"Failed to get /webhook_entries: {response.text}"
assert "Settings" in response.text, "Expected settings card on webhook detail view"
assert "Modify Webhook" in response.text, "Expected modify form on webhook detail view"
assert "Delete Webhook" in response.text, "Expected delete action on webhook detail view"
assert "Back to dashboard" in response.text, "Expected dashboard navigation link"
assert "All webhooks" in response.text, "Expected all webhooks navigation link"
assert f'name="old_hook" value="{webhook_url}"' in response.text, "Expected old_hook hidden input"
assert f'value="/webhook_entries?webhook_url={urllib.parse.quote(webhook_url)}"' in response.text, (
"Expected modify form to redirect back to the current webhook detail view"
)
def test_webhook_entries_with_feeds_no_entries() -> None: def test_webhook_entries_with_feeds_no_entries() -> None:
"""Test webhook_entries endpoint when webhook has feeds but no entries yet.""" """Test webhook_entries endpoint when webhook has feeds but no entries yet."""
# Clean up and create fresh webhook # Clean up and create fresh webhook
@ -943,6 +972,38 @@ def test_webhook_entries_with_entries() -> None:
assert webhook_name in response.text, "Webhook name not found in response" assert webhook_name in response.text, "Webhook name not found in response"
# Should show entries (the feed has entries) # Should show entries (the feed has entries)
assert "total from" in response.text, "Expected to see entry count" assert "total from" in response.text, "Expected to see entry count"
assert "Modify Webhook" in response.text, "Expected webhook settings to be visible"
assert "Attached feeds" in response.text, "Expected attached feeds section to be visible"
def test_webhook_entries_shows_attached_feed_link() -> None:
"""The webhook detail view should list attached feeds linking to their feed pages."""
client.post(url="/delete_webhook", data={"webhook_url": webhook_url})
response: Response = client.post(
url="/add_webhook",
data={"webhook_name": webhook_name, "webhook_url": webhook_url},
)
assert response.status_code == 200, f"Failed to add webhook: {response.text}"
client.post(url="/remove", data={"feed_url": feed_url})
response = client.post(
url="/add",
data={"feed_url": feed_url, "webhook_dropdown": webhook_name},
)
assert response.status_code == 200, f"Failed to add feed: {response.text}"
response = client.get(
url="/webhook_entries",
params={"webhook_url": webhook_url},
)
assert response.status_code == 200, f"Failed to get /webhook_entries: {response.text}"
assert f"/feed?feed_url={urllib.parse.quote(feed_url)}" in response.text, (
"Expected attached feed to link to its feed detail page"
)
assert "Latest entries" in response.text, "Expected latest entries heading on webhook detail view"
client.post(url="/remove", data={"feed_url": feed_url})
def test_webhook_entries_multiple_feeds() -> None: def test_webhook_entries_multiple_feeds() -> None:
@ -1047,6 +1108,60 @@ def test_webhook_entries_url_encoding() -> None:
client.post(url="/remove", data={"feed_url": feed_url}) client.post(url="/remove", data={"feed_url": feed_url})
def test_dashboard_webhook_name_links_to_webhook_detail() -> None:
"""Webhook names on the dashboard should open the webhook detail view."""
client.post(url="/delete_webhook", data={"webhook_url": webhook_url})
response: Response = client.post(
url="/add_webhook",
data={"webhook_name": webhook_name, "webhook_url": webhook_url},
)
assert response.status_code == 200, f"Failed to add webhook: {response.text}"
client.post(url="/remove", data={"feed_url": feed_url})
response = client.post(url="/add", data={"feed_url": feed_url, "webhook_dropdown": webhook_name})
assert response.status_code == 200, f"Failed to add feed: {response.text}"
response = client.get(url="/")
assert response.status_code == 200, f"Failed to get /: {response.text}"
expected_link = f"/webhook_entries?webhook_url={urllib.parse.quote(webhook_url)}"
assert expected_link in response.text, "Expected dashboard webhook link to point to the webhook detail view"
client.post(url="/remove", data={"feed_url": feed_url})
def test_modify_webhook_redirects_back_to_webhook_detail() -> None:
"""Webhook updates from the detail view should redirect back to that view with the new URL."""
original_webhook_url = "https://discord.com/api/webhooks/1234567890/abcdefghijklmnopqrstuvwxyz"
new_webhook_url = "https://discord.com/api/webhooks/1234567890/updated-token"
client.post(url="/delete_webhook", data={"webhook_url": original_webhook_url})
client.post(url="/delete_webhook", data={"webhook_url": new_webhook_url})
response = client.post(
url="/add_webhook",
data={"webhook_name": webhook_name, "webhook_url": original_webhook_url},
)
assert response.status_code == 200, f"Failed to add webhook: {response.text}"
no_redirect_client = TestClient(app, follow_redirects=False)
response = no_redirect_client.post(
url="/modify_webhook",
data={
"old_hook": original_webhook_url,
"new_hook": new_webhook_url,
"redirect_to": f"/webhook_entries?webhook_url={urllib.parse.quote(original_webhook_url)}",
},
)
assert response.status_code == 303, f"Expected 303 redirect, got {response.status_code}: {response.text}"
assert response.headers["location"] == (f"/webhook_entries?webhook_url={urllib.parse.quote(new_webhook_url)}"), (
f"Unexpected redirect location: {response.headers['location']}"
)
client.post(url="/delete_webhook", data={"webhook_url": new_webhook_url})
def test_reader_dependency_override_is_used() -> None: def test_reader_dependency_override_is_used() -> None:
"""Reader should be injectable and overridable via FastAPI dependency overrides.""" """Reader should be injectable and overridable via FastAPI dependency overrides."""