Compare commits

...

2 commits

Author SHA1 Message Date
f9cac0974d
Refactor archive_feed command to confirm deletion of entries before proceeding
All checks were successful
Deploy to Server / deploy (push) Successful in 12s
2026-03-27 04:29:15 +01:00
603090205a
Use templates for rendering 2026-03-27 04:26:09 +01:00
7 changed files with 271 additions and 82 deletions

View file

@ -56,14 +56,18 @@ class Command(BaseCommand):
if count == 0: if count == 0:
msg = f"No entries found for feed: {url}" msg = f"No entries found for feed: {url}"
self.stdout.write(self.style.WARNING(msg)) self.stdout.write(self.style.WARNING(msg))
else: else:
proceed: bool = True
if not force: if not force:
return self.confirm_and_list_entries(url, entries_qs, count) proceed: bool = self.confirm_and_list_entries(
url=url,
entries_qs.delete() entries_qs=entries_qs,
msg = f"Deleted {count} entries for feed: {url}" count=count,
self.stdout.write(self.style.SUCCESS(msg)) )
if proceed:
entries_qs.delete()
msg = f"Deleted {count} entries for feed: {url}"
self.stdout.write(self.style.SUCCESS(msg))
new_entries: int = fetch_and_archive_feed(feed) new_entries: int = fetch_and_archive_feed(feed)
if new_entries: if new_entries:
@ -73,15 +77,23 @@ class Command(BaseCommand):
else: else:
msg: str = "\tFeed is up to date, but no new entries were archived." msg: str = "\tFeed is up to date, but no new entries were archived."
self.stdout.write(self.style.WARNING(msg)) self.stdout.write(self.style.WARNING(msg))
return None
def confirm_and_list_entries( def confirm_and_list_entries(
self, self,
url: str, url: str,
entries_qs: QuerySet[Entry, Entry], entries_qs: QuerySet[Entry, Entry],
count: int, count: int,
) -> None: ) -> bool:
"""Confirm with the user before deleting entries and list some of them.""" """Confirm with the user before deleting entries and list some of them.
Args:
url (str): The URL of the feed.
entries_qs (QuerySet[Entry, Entry]): The queryset of entries to be deleted.
count (int): The total number of entries to be deleted.
Returns:
True if user confirms, False otherwise.
"""
msg: str = f"The following {count} entries will be removed for feed: {url}" msg: str = f"The following {count} entries will be removed for feed: {url}"
self.stdout.write(self.style.WARNING(msg)) self.stdout.write(self.style.WARNING(msg))
@ -94,7 +106,8 @@ class Command(BaseCommand):
confirm: str = input("Are you sure you want to proceed? (yes/no): ") confirm: str = input("Are you sure you want to proceed? (yes/no): ")
if confirm.lower() != "yes": if confirm.lower() != "yes":
self.stdout.write(self.style.ERROR("Operation cancelled.")) self.stdout.write(self.style.ERROR("Operation cancelled."))
return return False
return True
def get_entry_title(entry: Entry) -> str | None: def get_entry_title(entry: Entry) -> str | None:

View file

@ -10,9 +10,25 @@ if TYPE_CHECKING:
urlpatterns: list[URLPattern | URLResolver] = [ urlpatterns: list[URLPattern | URLResolver] = [
path("", views.home, name="home"), # /
path("feeds/", views.feed_list, name="feed-list"), path(
path("feeds/<int:feed_id>/", views.feed_detail, name="feed-detail"), "",
views.home,
name="home",
),
# /feeds/
path(
"feeds/",
views.feed_list,
name="feeds",
),
# /feeds/<feed_id>/
path(
"feeds/<int:feed_id>/",
views.feed_detail,
name="details",
),
# /feeds/<feed_id>/entries/<entry_id>/
path( path(
"feeds/<int:feed_id>/entries/<int:entry_id>/", "feeds/<int:feed_id>/entries/<int:entry_id>/",
views.entry_detail, views.entry_detail,

View file

@ -4,7 +4,7 @@ import html
import json import json
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from django.http import HttpResponse from django.db.models.query import QuerySet
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.shortcuts import redirect from django.shortcuts import redirect
from django.shortcuts import render from django.shortcuts import render
@ -14,6 +14,7 @@ from feeds.models import Feed
if TYPE_CHECKING: if TYPE_CHECKING:
from django.http import HttpRequest from django.http import HttpRequest
from django.http import HttpResponse
from pytest_django.asserts import QuerySet from pytest_django.asserts import QuerySet
@ -38,35 +39,16 @@ def feed_detail(request: HttpRequest, feed_id: int) -> HttpResponse:
HttpResponse: An HTML response containing the feed details and its entries. HttpResponse: An HTML response containing the feed details and its entries.
""" """
feed: Feed = get_object_or_404(Feed, id=feed_id) feed: Feed = get_object_or_404(Feed, id=feed_id)
entries: QuerySet[Entry, Entry] = Entry.objects.filter(feed=feed).order_by( entries: QuerySet[Entry, Entry] = Entry.objects.filter(feed=feed).order_by(
"-published_at", "-published_at",
"-fetched_at", "-fetched_at",
)[:50] )[:50]
html: list[str] = [ context: dict[str, Feed | QuerySet[Entry, Entry]] = {
"<!DOCTYPE html>", "feed": feed,
f"<html><head><title>FeedVault - {feed.url}</title></head><body>", "entries": entries,
"<h1>Feed Detail</h1>", }
f"<p><b>URL:</b> {feed.url}</p>", return render(request, "feeds/feed_detail.html", context)
f"<p><b>Domain:</b> {feed.domain}</p>",
f"<p><b>Active:</b> {'yes' if feed.is_active else 'no'}</p>",
f"<p><b>Created:</b> {feed.created_at}</p>",
f"<p><b>Last fetched:</b> {feed.last_fetched_at}</p>",
"<h2>Entries (latest 50)</h2>",
"<ul>",
]
for entry in entries:
title: str | None = entry.data.get("title") if entry.data else None
summary: str | None = entry.data.get("summary") if entry.data else None
snippet: str = title or summary or "[no title]"
html.append(
f"<li><b>{entry.published_at or entry.fetched_at}:</b> "
f'<a href="/feeds/{feed.pk}/entries/{entry.pk}/">{snippet}</a> '
f"<small>(id: {entry.entry_id})</small></li>",
)
html.extend(("</ul>", '<p><a href="/">Back to list</a></p>', "</body></html>"))
return HttpResponse("\n".join(html))
def entry_detail(request: HttpRequest, feed_id: int, entry_id: int) -> HttpResponse: def entry_detail(request: HttpRequest, feed_id: int, entry_id: int) -> HttpResponse:
@ -83,32 +65,19 @@ def entry_detail(request: HttpRequest, feed_id: int, entry_id: int) -> HttpRespo
feed: Feed = get_object_or_404(Feed, id=feed_id) feed: Feed = get_object_or_404(Feed, id=feed_id)
entry: Entry = get_object_or_404(Entry, id=entry_id, feed=feed) entry: Entry = get_object_or_404(Entry, id=entry_id, feed=feed)
# Render images if present in entry.data # Prepare entry data for display
entry_data_html: str = ""
if entry.data: if entry.data:
formatted_json: str = json.dumps(entry.data, indent=2, ensure_ascii=False) formatted_json: str = json.dumps(entry.data, indent=2, ensure_ascii=False)
escaped_json: str = html.escape(formatted_json) escaped_json: str | None = html.escape(formatted_json)
note: str = '<div style="font-size:small;color:#666;margin-bottom:4px;">Note: HTML in the JSON is escaped for display and will not be rendered as HTML.</div>'
entry_data_html: str = f"{note}<pre>{escaped_json}</pre>"
else: else:
entry_data_html: str = "<p>[No data]</p>" escaped_json: str | None = None
html_lines: list[str] = [ context = {
"<!DOCTYPE html>", "feed": feed,
f"<html><head><title>FeedVault - Entry {entry.entry_id}</title></head><body>", "entry": entry,
"<h1>Entry Detail</h1>", "escaped_json": escaped_json,
f"<p><b>Feed:</b> <a href='/feeds/{feed.pk}/'>{feed.url}</a></p>", }
f"<p><b>Entry ID:</b> {entry.entry_id}</p>", return render(request, "feeds/entry_detail.html", context)
f"<p><b>Published:</b> {entry.published_at}</p>",
f"<p><b>Fetched:</b> {entry.fetched_at}</p>",
f"<p><b>Content Hash:</b> {entry.content_hash}</p>",
f"<p><b>Error Message:</b> {entry.error_message or '[none]'} </p>",
"<h2>Entry Data</h2>",
entry_data_html,
f'<p><a href="/feeds/{feed.pk}/">Back to feed</a></p>',
"</body></html>",
]
return HttpResponse("\n".join(html_lines))
def home(request: HttpRequest) -> HttpResponse: def home(request: HttpRequest) -> HttpResponse:

View file

@ -3,33 +3,141 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="keywords" content="feeds, archive, FeedVault" /> {% if description %}<meta name="description" content="{{ description }}" />{% endif %}
<title> {% if keywords %}<meta name="keywords" content="{{ keywords }}" />{% endif %}
{% block title %} {% if author %}<meta name="author" content="{{ author }}" />{% endif %}
FeedVault {% if canonical %}<link rel="canonical" href="{{ canonical }}" />{% endif %}
{% endblock title %} <title>{{ title|default:"FeedVault" }}</title>
</title> <style>
html {
max-width: 88ch;
padding: calc(1vmin + 0.5rem);
margin-inline: auto;
font-size: clamp(1em, 0.909em + 0.45vmin, 1.25em);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Helvetica, Arial, sans-serif;
color-scheme: light dark;
}
h1 {
font-size: 2.5rem;
font-weight: 600;
margin: 0;
}
header {
text-align: center;
}
.search {
display: flex;
justify-content: center;
margin-top: 1rem;
margin-inline: auto;
}
.leftright {
display: flex;
justify-content: center;
}
.left {
margin-right: auto;
}
.right {
margin-left: auto;
}
textarea {
width: 100%;
height: 10rem;
resize: vertical;
}
.messages {
list-style-type: none;
}
.error {
color: red;
}
.success {
color: green;
}
.warning {
color: orange;
}
</style>
</head> </head>
<body> <body>
{% if messages %}
<ul class="messages" role="alert" aria-live="polite">
{% for message in messages %}
<li {% if message.tags %}class="{{ message.tags }}"{% endif %}
role="alert">{{ message }}</li>
{% endfor %}
</ul>
{% endif %}
<header> <header>
<h1>FeedVault</h1> <h1>
<nav> <a href="{% url 'home' %}" aria-label="FeedVault Home">FeedVault</a>
<ul> </h1>
<li>
<a href="{% url 'home' %}">Home</a>
</li>
<li>
<a href="{% url 'feed-list' %}">Feeds</a>
</li>
</ul>
</nav>
</header> </header>
<div class="leftright">
<div class="left">
<small>Archive of
<a href="https://en.wikipedia.org/wiki/Web_feed"
aria-label="Wikipedia article on web feeds">web feeds</a>.
{{ stats }}
</small>
</div>
<div class="right">
<form role="search"
action="#"
method="get"
class="search"
aria-label="Search form">
<input type="search" name="q" placeholder="Search" aria-label="Search input" />
<button type="submit" aria-label="Search button">Search</button>
</form>
</div>
</div>
<nav aria-label="Main navigation">
<small>
<div class="leftright">
<div class="left">
<a href="{% url 'home' %}" aria-label="Home page">Home</a> |
<a href="{% url 'feeds' %}" aria-label="Feeds page">Feeds</a> |
<a href="#" aria-label="Contact page">Contact</a>
</div>
<div class="right">
<a href="https://github.com/TheLovinator1/FeedVault"
aria-label="GitHub page">GitHub</a> |
<a href="https://github.com/sponsors/TheLovinator1"
aria-label="Donate page">Donate</a>
</div>
</div>
</small>
</nav>
<hr />
<main> <main>
{% block content %} {% block content %}<!-- default content -->{% endblock %}
{% endblock content %}
</main> </main>
<hr />
<footer> <footer>
<p>Web scraping is not a crime. - No rights reserved. - A birthday present for Plipp ❤️</p> <small>
<div class="leftright">
<div class="left">Web scraping is not a crime.</div>
<div class="right">No rights reserved.</div>
</div>
<div class="leftright">
<div class="left">TheLovinator#9276 on Discord</div>
<div class="right">A birthday present for Plipp ❤️</div>
</div>
</small>
</footer> </footer>
</body> </body>
</html> </html>

View file

@ -0,0 +1,34 @@
{% extends "base.html" %}
{% block content %}
<h1>Entry Detail</h1>
<p>
<b>Feed:</b> <a href="{% url 'details' feed.pk %}">{{ feed.url }}</a>
</p>
<p>
<b>Entry ID:</b> {{ entry.entry_id }}
</p>
<p>
<b>Published:</b> {{ entry.published_at }}
</p>
<p>
<b>Fetched:</b> {{ entry.fetched_at }}
</p>
<p>
<b>Content Hash:</b> {{ entry.content_hash }}
</p>
<p>
<b>Error Message:</b> {{ entry.error_message|default:"[none]" }}
</p>
<h2>Entry Data</h2>
{% if escaped_json %}
<div style="font-size:small;color:#666;margin-bottom:4px;">
Note: HTML in the JSON is escaped for display and will not be rendered as HTML.
</div>
<pre>{{ escaped_json|safe }}</pre>
{% else %}
<p>[No data]</p>
{% endif %}
<p>
<a href="{% url 'details' feed.pk %}">Back to feed</a>
</p>
{% endblock content %}

View file

@ -0,0 +1,49 @@
{% extends "base.html" %}
{% block content %}
<h1>Feed Detail</h1>
<p>
<b>URL:</b> {{ feed.url }}
</p>
<p>
<b>Domain:</b> {{ feed.domain }}
</p>
<p>
<b>Active:</b> {{ feed.is_active|yesno:"yes,no" }}
</p>
<p>
<b>Created:</b> {{ feed.created_at }}
</p>
<p>
<b>Last fetched:</b> {{ feed.last_fetched_at }}
</p>
<h2>Entries (latest 50)</h2>
<table>
<thead>
<tr>
<th>Title/Description</th>
<th>Entry ID</th>
</tr>
</thead>
<tbody>
{% for entry in entries %}
<tr>
<td>
<a href="{% url 'entry-detail' feed.pk entry.pk %}">
{{ entry.data.title|default:entry.data.description|default:"[no title]" }}
</a>
<br />
<small>{{ entry.published_at|default:entry.fetched_at }}</small>
</td>
<td>
<small>
<a href="{{ entry.entry_id }}">{{ entry.entry_id|cut:'https://'|truncatechars:50 }}</a>
</small>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<p>
<a href="{% url 'feeds' %}">Back to list</a>
</p>
{% endblock content %}

View file

@ -4,7 +4,7 @@
<ul> <ul>
{% for feed in feeds %} {% for feed in feeds %}
<li> <li>
<a href="{% url 'feed-detail' feed.pk %}">{{ feed.url }}</a> <a href="{% url 'details' feed.pk %}">{{ feed.url }}</a>
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>