diff --git a/feeds/management/commands/archive_feed.py b/feeds/management/commands/archive_feed.py index 83bb4e0..8f938dc 100644 --- a/feeds/management/commands/archive_feed.py +++ b/feeds/management/commands/archive_feed.py @@ -1,106 +1,34 @@ from typing import TYPE_CHECKING -from typing import Any from django.core.management.base import BaseCommand -from feeds.models import Entry from feeds.models import Feed from feeds.services import fetch_and_archive_feed if TYPE_CHECKING: from django.core.management.base import CommandParser - from pytest_django.asserts import QuerySet class Command(BaseCommand): """Django management command to fetch and archive a feed by URL.""" help = "Fetch and archive a feed by URL." - amount_to_show: int = 10 def add_arguments(self, parser: CommandParser) -> None: - """Add URL argument and --reset option to the command.""" - parser.add_argument( - "url", - type=str, - help="Feed URL to fetch and archive.", - ) - parser.add_argument( - "--reset", - action="store_true", - help="Remove all entries for this feed before archiving.", - ) + """Add URL argument to the command.""" + parser.add_argument("url", type=str, help="Feed URL to fetch and archive.") def handle(self, *args, **options) -> None: # noqa: ARG002 """Handle the command execution.""" url: str = options["url"] - reset: bool = options.get("reset", False) - feed, created = Feed.objects.get_or_create(url=url) - if created: - msg = f"Created new feed for URL: {url}" - self.stdout.write(self.style.SUCCESS(msg)) - - if reset: - entries_qs: QuerySet[Entry, Entry] = Entry.objects.filter(feed=feed) - count: int = entries_qs.count() - - if count == 0: - msg = f"No entries found for feed: {url}" - self.stdout.write(self.style.WARNING(msg)) - - else: - msg = f"The following {count} entries will be removed for feed: {url}" - self.stdout.write(self.style.WARNING(msg)) - - entries = entries_qs.order_by("-published_at")[: self.amount_to_show] - for entry in entries: - title: str | None = get_entry_title(entry) - - msg = f"- entry_id: {entry.entry_id}, published_at: {entry.published_at}, title: {title}" - self.stdout.write(self.style.WARNING(msg)) - - if count > self.amount_to_show: - self.stdout.write(f"...and {count - self.amount_to_show} more.") - - prompt = "Are you sure you want to delete these entries? Type 'yes' to confirm: " - confirm: str = input(prompt) - - if confirm.strip().lower() == "yes": - deleted, _ = entries_qs.delete() - - msg = f"Deleted {deleted} entr{'y' if deleted == 1 else 'ies'} for feed: {url}" - self.stdout.write(self.style.SUCCESS(msg)) - - else: - msg = "Aborted reset. No entries were deleted." - self.stdout.write(self.style.ERROR(msg)) - return + self.stdout.write(self.style.SUCCESS(f"Created new feed for URL: {url}")) new_entries: int = fetch_and_archive_feed(feed) if new_entries: msg: str = f"Archived {new_entries} new entr{'y' if new_entries == 1 else 'ies'} for URL: {url}" self.stdout.write(self.style.SUCCESS(msg)) - else: msg: str = "\tFeed is up to date, but no new entries were archived." self.stdout.write(self.style.WARNING(msg)) - - -def get_entry_title(entry: Entry) -> str | None: - """Get the title from an entry's data. - - Args: - entry (Entry): The Entry object from which to extract the title. - - Returns: - str | None: The title of the entry if available, otherwise None. - """ - # entry_data is a JSONField - entry_data: dict[str, Any] | list[Any] | None = entry.data - - if not isinstance(entry_data, dict): - return None - - return entry_data.get("title") diff --git a/feeds/tests/test_archive_feed_reset.py b/feeds/tests/test_archive_feed_reset.py deleted file mode 100644 index b4eae1c..0000000 --- a/feeds/tests/test_archive_feed_reset.py +++ /dev/null @@ -1,39 +0,0 @@ -import builtins -from typing import TYPE_CHECKING - -import pytest -from django.core.management import call_command - -from feeds.models import Entry -from feeds.models import Feed - -if TYPE_CHECKING: - from collections.abc import Callable - - -@pytest.mark.django_db -def test_reset_option_removes_only_feed_entries(db: None) -> None: - """Test that the --reset option in the archive_feed command only removes entries for the specified feed.""" - url1 = "http://example.com/feed1.xml" - url2 = "http://example.com/feed2.xml" - feed1: Feed = Feed.objects.create(url=url1, domain="example.com") - feed2: Feed = Feed.objects.create(url=url2, domain="example.com") - - # Create entries for both feeds - _e1: Entry = Entry.objects.create(feed=feed1, entry_id="a", content_hash=1) - _e2: Entry = Entry.objects.create(feed=feed1, entry_id="b", content_hash=2) - _e3: Entry = Entry.objects.create(feed=feed2, entry_id="c", content_hash=3) - assert Entry.objects.filter(feed=feed1).count() == 2 - assert Entry.objects.filter(feed=feed2).count() == 1 - - # Simulate user confirmation by patching input - orig_input: Callable[[object], str] = builtins.input - builtins.input = lambda _: "yes" - try: - call_command("archive_feed", url1, "--reset") - finally: - builtins.input = orig_input - - # Only feed1's entries should be deleted - assert Entry.objects.filter(feed=feed1).count() == 0 - assert Entry.objects.filter(feed=feed2).count() == 1 diff --git a/feeds/urls.py b/feeds/urls.py index 60725e9..cfb1cff 100644 --- a/feeds/urls.py +++ b/feeds/urls.py @@ -12,9 +12,4 @@ if TYPE_CHECKING: urlpatterns: list[URLPattern | URLResolver] = [ path("", views.feed_list, name="feed-list"), path("feeds//", views.feed_detail, name="feed-detail"), - path( - "feeds//entries//", - views.entry_detail, - name="entry-detail", - ), ] diff --git a/feeds/views.py b/feeds/views.py index ab79681..38a5d93 100644 --- a/feeds/views.py +++ b/feeds/views.py @@ -1,5 +1,3 @@ -import html -import json from typing import TYPE_CHECKING from django.http import HttpResponse @@ -49,7 +47,6 @@ def feed_detail(request: HttpRequest, feed_id: int) -> HttpResponse: "-published_at", "-fetched_at", )[:50] - html: list[str] = [ "", f"FeedVault - {feed.url}", @@ -67,51 +64,7 @@ def feed_detail(request: HttpRequest, feed_id: int) -> HttpResponse: summary: str | None = entry.data.get("summary") if entry.data else None snippet: str = title or summary or "[no title]" html.append( - f"
  • {entry.published_at or entry.fetched_at}: " - f'{snippet} ' - f"(id: {entry.entry_id})
  • ", + f"
  • {entry.published_at or entry.fetched_at}: {snippet} (id: {entry.entry_id})
  • ", ) html.extend(("", '

    Back to list

    ', "")) return HttpResponse("\n".join(html)) - - -def entry_detail(request: HttpRequest, feed_id: int, entry_id: int) -> HttpResponse: - """View to display the details of a specific entry. - - Args: - request (HttpRequest): The HTTP request object. - feed_id (int): The ID of the feed the entry belongs to. - entry_id (int): The ID of the entry to display. - - Returns: - HttpResponse: An HTML response containing the entry details. - """ - feed: Feed = get_object_or_404(Feed, id=feed_id) - entry: Entry = get_object_or_404(Entry, id=entry_id, feed=feed) - - # Render images if present in entry.data - entry_data_html: str = "" - if entry.data: - formatted_json: str = json.dumps(entry.data, indent=2, ensure_ascii=False) - escaped_json: str = html.escape(formatted_json) - note: str = '
    Note: HTML in the JSON is escaped for display and will not be rendered as HTML.
    ' - entry_data_html: str = f"{note}
    {escaped_json}
    " - else: - entry_data_html: str = "

    [No data]

    " - - html_lines: list[str] = [ - "", - f"FeedVault - Entry {entry.entry_id}", - "

    Entry Detail

    ", - f"

    Feed: {feed.url}

    ", - f"

    Entry ID: {entry.entry_id}

    ", - f"

    Published: {entry.published_at}

    ", - f"

    Fetched: {entry.fetched_at}

    ", - f"

    Content Hash: {entry.content_hash}

    ", - f"

    Error Message: {entry.error_message or '[none]'}

    ", - "

    Entry Data

    ", - entry_data_html, - f'

    Back to feed

    ', - "", - ] - return HttpResponse("\n".join(html_lines)) diff --git a/tools/systemd/feedvault-backup.service b/tools/systemd/feedvault-backup.service deleted file mode 100644 index 46e5ed6..0000000 --- a/tools/systemd/feedvault-backup.service +++ /dev/null @@ -1,10 +0,0 @@ -[Unit] -Description=FeedVault database backup - -[Service] -Type=oneshot -User=feedvault -Group=feedvault -WorkingDirectory=/home/feedvault/feedvault -EnvironmentFile=/home/feedvault/feedvault/.env -ExecStart=/usr/bin/uv run python manage.py backup_db diff --git a/tools/systemd/feedvault-backup.timer b/tools/systemd/feedvault-backup.timer deleted file mode 100644 index 9811378..0000000 --- a/tools/systemd/feedvault-backup.timer +++ /dev/null @@ -1,9 +0,0 @@ -[Unit] -Description=Nightly FeedVault database backup - -[Timer] -OnCalendar=*-*-* 03:15:00 -Persistent=true - -[Install] -WantedBy=timers.target diff --git a/tools/systemd/feedvault.service b/tools/systemd/feedvault.service deleted file mode 100644 index b6b5d16..0000000 --- a/tools/systemd/feedvault.service +++ /dev/null @@ -1,27 +0,0 @@ -[Unit] -Description=FeedVault -Requires=feedvault.socket - -[Service] -User=feedvault -Group=feedvault -WorkingDirectory=/home/feedvault/feedvault -EnvironmentFile=/home/feedvault/feedvault/.env -RuntimeDirectory=feedvault -ExecStart=/usr/bin/uv run gunicorn config.wsgi:application --bind unix:/run/feedvault/feedvault.sock --workers 13 --name feedvault --max-requests-jitter 50 --max-requests 1200 -ReadWritePaths=/home/feedvault/feedvault /run/feedvault - -NoNewPrivileges=yes -PrivateTmp=yes -ProtectSystem=full -ProtectHome=no -CapabilityBoundingSet= -AmbientCapabilities= -RestrictRealtime=yes -LockPersonality=yes - -Restart=on-failure -RestartSec=5 - -[Install] -WantedBy=multi-user.target diff --git a/tools/systemd/feedvault.socket b/tools/systemd/feedvault.socket deleted file mode 100644 index 4318c96..0000000 --- a/tools/systemd/feedvault.socket +++ /dev/null @@ -1,11 +0,0 @@ -[Unit] -Description=FeedVault Socket - -[Socket] -ListenStream=/run/feedvault/feedvault.sock -SocketUser=feedvault -SocketGroup=feedvault -SocketMode=0660 - -[Install] -WantedBy=sockets.target