Use Django-ninja for API

This commit is contained in:
Joakim Hellsén 2024-03-15 20:38:40 +01:00
commit 869a931bda
No known key found for this signature in database
GPG key ID: D196AE66FEBE1DC9
13 changed files with 355 additions and 432 deletions

78
feedvault/api.py Normal file
View file

@ -0,0 +1,78 @@
from __future__ import annotations
from django.http import HttpRequest # noqa: TCH002
from ninja import ModelSchema, NinjaAPI
from ninja.pagination import paginate
from feedvault.models import Domain, Entry, Feed
api_v1 = NinjaAPI(
title="FeedVault API",
version="0.1.0",
description="FeedVault API",
urls_namespace="api_v1",
)
class FeedOut(ModelSchema):
class Meta:
model = Feed
fields: str = "__all__"
class EntriesOut(ModelSchema):
class Meta:
model = Entry
fields: str = "__all__"
class DomainsOut(ModelSchema):
class Meta:
model = Domain
fields: str = "__all__"
@api_v1.get("/feeds/", response=list[FeedOut])
@paginate
def list_feeds(request: HttpRequest) -> None:
"""Get a list of feeds."""
return Feed.objects.all() # type: ignore # noqa: PGH003
@api_v1.get("/feeds/{feed_id}/", response=FeedOut)
def get_feed(request: HttpRequest, feed_id: int) -> Feed:
"""Get a feed by ID."""
return Feed.objects.get(id=feed_id)
@api_v1.get("/feeds/{feed_id}/entries/", response=list[EntriesOut])
@paginate
def list_entries(request: HttpRequest, feed_id: int) -> list[Entry]:
"""Get a list of entries for a feed."""
return Entry.objects.filter(feed_id=feed_id) # type: ignore # noqa: PGH003
@api_v1.get("/entries/", response=list[EntriesOut])
@paginate
def list_all_entries(request: HttpRequest) -> list[Entry]:
"""Get a list of entries."""
return Entry.objects.all() # type: ignore # noqa: PGH003
@api_v1.get("/entries/{entry_id}/", response=EntriesOut)
def get_entry(request: HttpRequest, entry_id: int) -> Entry:
"""Get an entry by ID."""
return Entry.objects.get(id=entry_id)
@api_v1.get("/domains/", response=list[DomainsOut])
@paginate
def list_domains(request: HttpRequest) -> list[Domain]:
"""Get a list of domains."""
return Domain.objects.all() # type: ignore # noqa: PGH003
@api_v1.get("/domains/{domain_id}/", response=DomainsOut)
def get_domain(request: HttpRequest, domain_id: int) -> Domain:
"""Get a domain by ID."""
return Domain.objects.get(id=domain_id)

View file

@ -0,0 +1,23 @@
# Generated by Django 5.0.3 on 2024-03-15 16:42
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('feedvault', '0002_alter_feed_status'),
]
operations = [
migrations.RenameField(
model_name='entry',
old_name='_id',
new_name='entry_id',
),
migrations.RenameField(
model_name='feed',
old_name='_id',
new_name='feed_id',
),
]

View file

@ -177,7 +177,7 @@ class Feed(models.Model):
) )
icon = models.TextField(blank=True) icon = models.TextField(blank=True)
_id = models.TextField(blank=True) feed_id = models.TextField(blank=True)
image = JSONField(null=True, blank=True) image = JSONField(null=True, blank=True)
info = models.TextField(blank=True) info = models.TextField(blank=True)
info_detail = JSONField(null=True, blank=True) info_detail = JSONField(null=True, blank=True)
@ -249,7 +249,7 @@ class Entry(models.Model):
enclosures = JSONField(null=True, blank=True) enclosures = JSONField(null=True, blank=True)
expired = models.TextField(blank=True) expired = models.TextField(blank=True)
expired_parsed = models.DateTimeField(null=True, blank=True) expired_parsed = models.DateTimeField(null=True, blank=True)
_id = models.TextField(blank=True) entry_id = models.TextField(blank=True)
license = models.TextField(blank=True) license = models.TextField(blank=True)
link = models.TextField(blank=True) link = models.TextField(blank=True)
links = JSONField(null=True, blank=True) links = JSONField(null=True, blank=True)

View file

@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import os import os
import sys
from pathlib import Path from pathlib import Path
from dotenv import find_dotenv, load_dotenv from dotenv import find_dotenv, load_dotenv
@ -37,6 +38,10 @@ SITE_ID = 1
PASSWORD_HASHERS: list[str] = ["django.contrib.auth.hashers.Argon2PasswordHasher"] PASSWORD_HASHERS: list[str] = ["django.contrib.auth.hashers.Argon2PasswordHasher"]
ROOT_URLCONF = "feedvault.urls" ROOT_URLCONF = "feedvault.urls"
WSGI_APPLICATION = "feedvault.wsgi.application" WSGI_APPLICATION = "feedvault.wsgi.application"
NINJA_PAGINATION_PER_PAGE = 1000
# Is True when running tests, used for not spamming Discord when new users are created
TESTING: bool = len(sys.argv) > 1 and sys.argv[1] == "test"
INSTALLED_APPS: list[str] = [ INSTALLED_APPS: list[str] = [
"feedvault.apps.FeedVaultConfig", "feedvault.apps.FeedVaultConfig",

View file

@ -5,6 +5,7 @@ import os
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from discord_webhook import DiscordWebhook from discord_webhook import DiscordWebhook
from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.db.models.signals import post_save from django.db.models.signals import post_save
from django.dispatch import receiver from django.dispatch import receiver
@ -16,7 +17,7 @@ logger: logging.Logger = logging.getLogger(__name__)
@receiver(post_save, sender=User) @receiver(post_save, sender=User)
def notify_when_new_user(sender: User, instance: User, *, created: bool, **kwargs) -> None: # noqa: ANN003, ARG001 def notify_when_new_user(sender: User, instance: User, *, created: bool, **kwargs) -> None: # noqa: ANN003
"""Send a Discord notification when a new user is created. """Send a Discord notification when a new user is created.
Args: Args:
@ -33,5 +34,6 @@ def notify_when_new_user(sender: User, instance: User, *, created: bool, **kwarg
msg: str = f"New user registered on FeedVault 👀: {instance.username}" msg: str = f"New user registered on FeedVault 👀: {instance.username}"
webhook = DiscordWebhook(url=webhook_url, content=msg) webhook = DiscordWebhook(url=webhook_url, content=msg)
if not settings.TESTING:
response: Response = webhook.execute() response: Response = webhook.execute()
logger.info("Discord notification sent: (%s) %s", response.status_code, response.text) logger.info("Discord notification sent: (%s) %s", response.status_code, response.text)

View file

@ -3,10 +3,11 @@ from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.test import TestCase from django.http.response import HttpResponse
from django.test import Client, TestCase
from django.urls import reverse from django.urls import reverse
from feedvault.models import Domain, Feed from feedvault.models import Domain, Entry, Feed
if TYPE_CHECKING: if TYPE_CHECKING:
from django.http import HttpResponse from django.http import HttpResponse
@ -98,47 +99,62 @@ class TestDomains(TestCase):
class TestAPI(TestCase): class TestAPI(TestCase):
def test_api_page(self) -> None: def test_api_page(self) -> None:
"""Test if the API page is accessible.""" """Test if the API page is accessible."""
response: HttpResponse = self.client.get(reverse("api")) response: HttpResponse = self.client.get(reverse("api_v1:openapi-view"))
assert response.status_code == 200, f"Expected 200, got {response.status_code}" assert response.status_code == 200, f"Expected 200, got {response.status_code}"
class TestAPIFeeds(TestCase): class TestAPIFeeds(TestCase):
def test_api_feeds_page(self) -> None: def test_api_feeds_page(self) -> None:
"""Test if the API feeds page is accessible.""" """Test if the API feeds page is accessible."""
response: HttpResponse = self.client.get(reverse("api_feeds")) response: HttpResponse = self.client.get(reverse("api_v1:list_feeds"))
assert response.status_code == 200, f"Expected 200, got {response.status_code}" assert response.status_code == 200, f"Expected 200, got {response.status_code}"
class TestAPIFeed(TestCase): class FeedVaultAPITests(TestCase):
def setUp(self) -> None: def setUp(self) -> None:
"""Create a test feed.""" # Set up data for the whole TestCase
self.domain: Domain = Domain.objects.create( self.client = Client()
name="feedvault",
url="feedvault.se",
)
self.user: User = User.objects.create_user( # Creating a domain instance
username="testuser", self.domain: Domain = Domain.objects.create(name="Example Domain")
email="hello@feedvault.se",
password="testpassword", # noqa: S106
)
self.feed: Feed = Feed.objects.create( # Creating a feed instance
user=self.user, self.feed: Feed = Feed.objects.create(title="Example Feed", domain=self.domain, bozo=False)
bozo=False,
feed_url="https://feedvault.se/feed.xml",
domain=self.domain,
)
def test_api_feed_page(self) -> None: # Creating entry instances
"""Test if the API feed page is accessible.""" self.entry1: Entry = Entry.objects.create(title="Example Entry 1", feed=self.feed)
response: HttpResponse = self.client.get(reverse("api_feeds_id", kwargs={"feed_id": 1})) self.entry2: Entry = Entry.objects.create(title="Example Entry 2", feed=self.feed)
def test_list_feeds(self) -> None:
response: HttpResponse = self.client.get("/api/v1/feeds/")
assert response.status_code == 200, f"Expected 200, got {response.status_code}" assert response.status_code == 200, f"Expected 200, got {response.status_code}"
assert "Example Feed" in response.content.decode()
def test_api_feed_page_not_found(self) -> None: def test_get_feed(self) -> None:
"""Test if the API feed page is accessible.""" response: HttpResponse = self.client.get(f"/api/v1/feeds/{self.feed.pk}/")
response: HttpResponse = self.client.get(reverse("api_feeds_id", kwargs={"feed_id": 2})) assert response.status_code == 200, f"Expected 200, got {response.status_code}"
assert response.status_code == 404, f"Expected 404, got {response.status_code}" assert "Example Feed" in response.content.decode()
def test_list_entries(self) -> None:
response: HttpResponse = self.client.get(f"/api/v1/feeds/{self.feed.pk}/entries/")
assert response.status_code == 200, f"Expected 200, got {response.status_code}"
assert "Example Entry 1" in response.content.decode()
assert "Example Entry 2" in response.content.decode()
def test_get_entry(self) -> None:
response: HttpResponse = self.client.get(f"/api/v1/entries/{self.entry1.pk}/")
assert response.status_code == 200, f"Expected 200, got {response.status_code}"
assert "Example Entry 1" in response.content.decode()
def test_list_domains(self) -> None:
response: HttpResponse = self.client.get("/api/v1/domains/")
assert response.status_code == 200, f"Expected 200, got {response.status_code}"
assert "Example Domain" in response.content.decode()
def test_get_domain(self) -> None:
response: HttpResponse = self.client.get(f"/api/v1/domains/{self.domain.pk}/")
assert response.status_code == 200, f"Expected 200, got {response.status_code}"
assert "Example Domain" in response.content.decode()
class TestAccount(TestCase): class TestAccount(TestCase):

View file

@ -6,12 +6,14 @@ from django.urls import URLPattern, path
from django.views.decorators.cache import cache_page from django.views.decorators.cache import cache_page
from feedvault import views from feedvault import views
from feedvault.api import api_v1
from feedvault.models import Domain, Feed from feedvault.models import Domain, Feed
from feedvault.sitemaps import StaticViewSitemap from feedvault.sitemaps import StaticViewSitemap
from feedvault.views import APIView, CustomLoginView, CustomLogoutView, ProfileView, RegisterView from feedvault.views import CustomLoginView, CustomLogoutView, ProfileView, RegisterView
app_name: str = "feedvault" app_name: str = "feedvault"
sitemaps = { sitemaps = {
"static": StaticViewSitemap, "static": StaticViewSitemap,
"feeds": GenericSitemap({"queryset": Feed.objects.all(), "date_field": "created_at"}), "feeds": GenericSitemap({"queryset": Feed.objects.all(), "date_field": "created_at"}),
@ -34,12 +36,7 @@ urlpatterns: list[URLPattern] = [
), ),
path(route="domains/", view=views.DomainsView.as_view(), name="domains"), path(route="domains/", view=views.DomainsView.as_view(), name="domains"),
path(route="domain/<int:domain_id>/", view=views.DomainView.as_view(), name="domain"), path(route="domain/<int:domain_id>/", view=views.DomainView.as_view(), name="domain"),
path(route="api/", view=APIView.as_view(), name="api"), path("api/v1/", api_v1.urls), # type: ignore # noqa: PGH003
path(route="api/feeds/", view=views.APIFeedsView.as_view(), name="api_feeds"),
path(route="api/feeds/<int:feed_id>/", view=views.APIFeedView.as_view(), name="api_feeds_id"),
path(route="api/feeds/<int:feed_id>/entries/", view=views.APIFeedEntriesView.as_view(), name="api_feed_entries"),
path(route="api/entries/", view=views.APIEntriesView.as_view(), name="api_entries"),
path(route="api/entries/<int:entry_id>/", view=views.APIEntryView.as_view(), name="api_entries_id"),
path(route="accounts/login/", view=CustomLoginView.as_view(), name="login"), path(route="accounts/login/", view=CustomLoginView.as_view(), name="login"),
path(route="accounts/register/", view=RegisterView.as_view(), name="register"), path(route="accounts/register/", view=RegisterView.as_view(), name="register"),
path(route="accounts/logout/", view=CustomLogoutView.as_view(), name="logout"), path(route="accounts/logout/", view=CustomLogoutView.as_view(), name="logout"),

View file

@ -7,9 +7,8 @@ from django.contrib.auth import login
from django.contrib.auth.forms import AuthenticationForm, UserCreationForm from django.contrib.auth.forms import AuthenticationForm, UserCreationForm
from django.contrib.auth.views import LoginView, LogoutView, PasswordChangeView from django.contrib.auth.views import LoginView, LogoutView, PasswordChangeView
from django.contrib.messages.views import SuccessMessageMixin from django.contrib.messages.views import SuccessMessageMixin
from django.core.paginator import EmptyPage, Page, PageNotAnInteger, Paginator from django.db.models.manager import BaseManager
from django.forms.models import model_to_dict from django.http import HttpRequest, HttpResponse
from django.http import HttpRequest, HttpResponse, JsonResponse
from django.shortcuts import get_object_or_404, render from django.shortcuts import get_object_or_404, render
from django.template import loader from django.template import loader
from django.urls import reverse_lazy from django.urls import reverse_lazy
@ -22,6 +21,8 @@ from feedvault.models import Domain, Entry, Feed
if TYPE_CHECKING: if TYPE_CHECKING:
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core.files.uploadedfile import UploadedFile
from django.db.models.manager import BaseManager
class IndexView(View): class IndexView(View):
@ -49,8 +50,8 @@ class FeedView(View):
if not feed_id: if not feed_id:
return HttpResponse(content="No id", status=400) return HttpResponse(content="No id", status=400)
feed = get_object_or_404(Feed, id=feed_id) feed: Feed = get_object_or_404(Feed, id=feed_id)
entries = Entry.objects.filter(feed=feed).order_by("-created_parsed")[:100] entries: BaseManager[Entry] = Entry.objects.filter(feed=feed).order_by("-created_parsed")[:100]
context = { context = {
"feed": feed, "feed": feed,
@ -91,7 +92,7 @@ class AddView(View):
def get(self, request: HttpRequest) -> HttpResponse: def get(self, request: HttpRequest) -> HttpResponse:
"""Load the index page.""" """Load the index page."""
template = loader.get_template(template_name="index.html") template = loader.get_template(template_name="index.html")
context = { context: dict[str, str] = {
"description": "FeedVault allows users to archive and search their favorite web feeds.", "description": "FeedVault allows users to archive and search their favorite web feeds.",
"keywords": "feed, rss, atom, archive, rss list", "keywords": "feed, rss, atom, archive, rss list",
"author": "TheLovinator", "author": "TheLovinator",
@ -134,7 +135,7 @@ class UploadView(View):
def get(self, request: HttpRequest) -> HttpResponse: def get(self, request: HttpRequest) -> HttpResponse:
"""Load the index page.""" """Load the index page."""
template = loader.get_template(template_name="index.html") template = loader.get_template(template_name="index.html")
context = { context: dict[str, str] = {
"description": "FeedVault allows users to archive and search their favorite web feeds.", "description": "FeedVault allows users to archive and search their favorite web feeds.",
"keywords": "feed, rss, atom, archive, rss list", "keywords": "feed, rss, atom, archive, rss list",
"author": "TheLovinator", "author": "TheLovinator",
@ -150,7 +151,7 @@ class UploadView(View):
if not request.user.is_active: if not request.user.is_active:
return HttpResponse(content="User is not active", status=403) return HttpResponse(content="User is not active", status=403)
file = request.FILES.get("file", None) file: UploadedFile | None = request.FILES.get("file", None)
if not file: if not file:
return HttpResponse(content="No file", status=400) return HttpResponse(content="No file", status=400)
@ -193,7 +194,7 @@ class RegisterView(CreateView):
# Add context data to the view # Add context data to the view
def get_context_data(self, **kwargs) -> dict: # noqa: ANN003 def get_context_data(self, **kwargs) -> dict: # noqa: ANN003
"""Get the context data.""" """Get the context data."""
context = super().get_context_data(**kwargs) context: dict[str, Any] = super().get_context_data(**kwargs)
context["description"] = "Register a new account" context["description"] = "Register a new account"
context["keywords"] = "register, account, feed, rss, atom, archive, rss list" context["keywords"] = "register, account, feed, rss, atom, archive, rss list"
context["author"] = "TheLovinator" context["author"] = "TheLovinator"
@ -234,7 +235,7 @@ class ProfileView(View):
"""Load the profile page.""" """Load the profile page."""
template = loader.get_template(template_name="accounts/profile.html") template = loader.get_template(template_name="accounts/profile.html")
user_feeds = Feed.objects.filter(user=request.user).order_by("-created_at")[:100] user_feeds: BaseManager[Feed] = Feed.objects.filter(user=request.user).order_by("-created_at")[:100]
context: dict[str, str | Any] = { context: dict[str, str | Any] = {
"description": f"Profile page for {request.user.get_username()}", "description": f"Profile page for {request.user.get_username()}",
@ -247,22 +248,6 @@ class ProfileView(View):
return HttpResponse(content=template.render(context=context, request=request)) return HttpResponse(content=template.render(context=context, request=request))
class APIView(View):
"""API documentation page."""
def get(self, request: HttpRequest) -> HttpResponse:
"""Load the API page."""
template = loader.get_template(template_name="api.html")
context = {
"description": "FeedVault allows users to archive and search their favorite web feeds.",
"keywords": "feed, rss, atom, archive, rss list",
"author": "TheLovinator",
"canonical": "https://feedvault.se/api/",
"title": "API Documentation",
}
return HttpResponse(content=template.render(context=context, request=request))
class RobotsView(View): class RobotsView(View):
"""Robots.txt view.""" """Robots.txt view."""
@ -274,186 +259,12 @@ class RobotsView(View):
) )
class APIFeedsView(View):
"""API Feeds view."""
def get(self, request: HttpRequest) -> HttpResponse:
"""Get all feeds with pagination."""
# Retrieve all feeds
feeds_list = Feed.objects.all()
# Pagination settings
page: int = int(request.GET.get("page", 1)) # Get the page number from the query parameters, default to 1
per_page: int = int(request.GET.get("per_page", 1000)) # Number of feeds per page, default to 1000 (max 1000)
# Add a ceiling to the per_page value
max_per_page = 1000
if per_page > max_per_page:
per_page = max_per_page
# Create Paginator instance
paginator = Paginator(feeds_list, per_page)
try:
feeds: Page = paginator.page(page)
except PageNotAnInteger:
# If page is not an integer, deliver first page.
feeds = paginator.page(1)
except EmptyPage:
# If page is out of range (e.g., 9999), deliver last page of results.
feeds = paginator.page(paginator.num_pages)
# Convert feeds to dictionary
feeds_dict = [model_to_dict(feed) for feed in feeds]
# Return the paginated entries as JsonResponse
response = JsonResponse(feeds_dict, safe=False)
# Add pagination headers
response["X-Page"] = feeds.number
response["X-Page-Count"] = paginator.num_pages
response["X-Per-Page"] = per_page
response["X-Total-Count"] = paginator.count
response["X-First-Page"] = 1
response["X-Last-Page"] = paginator.num_pages
# Next and previous page links
if feeds.has_next():
response["X-Next-Page"] = feeds.next_page_number()
if feeds.has_previous():
response["X-Prev-Page"] = feeds.previous_page_number()
return response
class APIFeedView(View):
"""API Feed view."""
def get(self, request: HttpRequest, feed_id: int) -> HttpResponse: # noqa: ARG002
"""Get a single feed."""
feed = get_object_or_404(Feed, id=feed_id)
return JsonResponse(model_to_dict(feed), safe=False)
class APIEntriesView(View):
"""API Entries view."""
def get(self: APIEntriesView, request: HttpRequest) -> HttpResponse:
"""Get all entries with pagination."""
# Retrieve all entries
entries_list = Entry.objects.all()
# Pagination settings
page: int = int(request.GET.get("page", 1)) # Get the page number from the query parameters, default to 1
per_page: int = int(request.GET.get("per_page", 1000))
# Add a ceiling to the per_page value
max_per_page = 1000
if per_page > max_per_page:
per_page = max_per_page
# Create Paginator instance
paginator = Paginator(entries_list, per_page)
try:
entries: Page = paginator.page(page)
except PageNotAnInteger:
# If page is not an integer, deliver first page.
entries = paginator.page(1)
except EmptyPage:
# If page is out of range (e.g. 9999), deliver last page of results.
entries = paginator.page(paginator.num_pages)
# Convert entries to dictionary
entries_dict = [model_to_dict(entry) for entry in entries]
# Return the paginated entries as JsonResponse
response = JsonResponse(entries_dict, safe=False)
# Add pagination headers
response["X-Page"] = entries.number
response["X-Page-Count"] = paginator.num_pages
response["X-Per-Page"] = per_page
response["X-Total-Count"] = paginator.count
response["X-First-Page"] = 1
response["X-Last-Page"] = paginator.num_pages
# Next and previous page links
if entries.has_next():
response["X-Next-Page"] = entries.next_page_number()
if entries.has_previous():
response["X-Prev-Page"] = entries.previous_page_number()
return response
class APIEntryView(View):
"""API Entry view."""
def get(self: APIEntryView, request: HttpRequest, entry_id: int) -> HttpResponse: # noqa: ARG002
"""Get a single entry."""
entry = get_object_or_404(Entry, id=entry_id)
return JsonResponse(model_to_dict(entry), safe=False)
class APIFeedEntriesView(View):
"""API Feed Entries view."""
def get(self: APIFeedEntriesView, request: HttpRequest, feed_id: int) -> HttpResponse:
"""Get all entries for a single feed with pagination."""
# Retrieve all entries for a single feed
entries_list = Entry.objects.filter(feed_id=feed_id)
# Pagination settings
page: int = int(request.GET.get("page", 1)) # Get the page number from the query parameters, default to 1
per_page: int = int(request.GET.get("per_page", 1000))
# Add a ceiling to the per_page value
max_per_page = 1000
if per_page > max_per_page:
per_page = max_per_page
# Create Paginator instance
paginator = Paginator(entries_list, per_page)
try:
entries: Page = paginator.page(page)
except PageNotAnInteger:
# If page is not an integer, deliver first page.
entries = paginator.page(1)
except EmptyPage:
# If page is out of range (e.g. 9999), deliver last page of results.
entries = paginator.page(paginator.num_pages)
# Convert entries to dictionary
entries_dict = [model_to_dict(entry) for entry in entries]
# Return the paginated entries as JsonResponse
response = JsonResponse(entries_dict, safe=False)
# Add pagination headers
response["X-Page"] = entries.number
response["X-Page-Count"] = paginator.num_pages
response["X-Per-Page"] = per_page
response["X-Total-Count"] = paginator.count
response["X-First-Page"] = 1
response["X-Last-Page"] = paginator.num_pages
# Next and previous page links
if entries.has_next():
response["X-Next-Page"] = entries.next_page_number()
if entries.has_previous():
response["X-Prev-Page"] = entries.previous_page_number()
return response
class DomainsView(View): class DomainsView(View):
"""All domains.""" """All domains."""
def get(self: DomainsView, request: HttpRequest) -> HttpResponse: def get(self: DomainsView, request: HttpRequest) -> HttpResponse:
"""Load the domains page.""" """Load the domains page."""
domains = Domain.objects.all() domains: BaseManager[Domain] = Domain.objects.all()
template = loader.get_template(template_name="domains.html") template = loader.get_template(template_name="domains.html")
context = { context = {
"domains": domains, "domains": domains,
@ -471,8 +282,8 @@ class DomainView(View):
def get(self: DomainView, request: HttpRequest, domain_id: int) -> HttpResponse: def get(self: DomainView, request: HttpRequest, domain_id: int) -> HttpResponse:
"""Load the domain page.""" """Load the domain page."""
domain = get_object_or_404(Domain, id=domain_id) domain: Domain = get_object_or_404(Domain, id=domain_id)
feeds = Feed.objects.filter(domain=domain).order_by("-created_at")[:100] feeds: BaseManager[Feed] = Feed.objects.filter(domain=domain).order_by("-created_at")[:100]
context = { context = {
"domain": domain, "domain": domain,

154
poetry.lock generated
View file

@ -1,5 +1,16 @@
# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. # This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand.
[[package]]
name = "annotated-types"
version = "0.6.0"
description = "Reusable constraint types to use with typing.Annotated"
optional = false
python-versions = ">=3.8"
files = [
{file = "annotated_types-0.6.0-py3-none-any.whl", hash = "sha256:0641064de18ba7a25dee8f96403ebc39113d0cb953a01429249d5c7564666a43"},
{file = "annotated_types-0.6.0.tar.gz", hash = "sha256:563339e807e53ffd9c267e99fc6d9ea23eb8443c08f112651963e24e22f84a5d"},
]
[[package]] [[package]]
name = "argon2-cffi" name = "argon2-cffi"
version = "23.1.0" version = "23.1.0"
@ -345,6 +356,26 @@ tzdata = {version = "*", markers = "sys_platform == \"win32\""}
argon2 = ["argon2-cffi (>=19.1.0)"] argon2 = ["argon2-cffi (>=19.1.0)"]
bcrypt = ["bcrypt"] bcrypt = ["bcrypt"]
[[package]]
name = "django-ninja"
version = "1.1.0"
description = "Django Ninja - Fast Django REST framework"
optional = false
python-versions = ">=3.7"
files = [
{file = "django_ninja-1.1.0-py3-none-any.whl", hash = "sha256:6330c3497061d9fd1f43c1200f85c13aab7687110e2899f8304e5aa476c10b44"},
{file = "django_ninja-1.1.0.tar.gz", hash = "sha256:87bff046416a2653ed2fbef1408e101292bf8170684821bac82accfd73bef059"},
]
[package.dependencies]
Django = ">=3.1"
pydantic = ">=2.0,<3.0.0"
[package.extras]
dev = ["pre-commit"]
doc = ["markdown-include", "mkdocs", "mkdocs-material", "mkdocstrings"]
test = ["django-stubs", "mypy (==1.7.1)", "psycopg2-binary", "pytest", "pytest-asyncio", "pytest-cov", "pytest-django", "ruff (==0.1.7)"]
[[package]] [[package]]
name = "djlint" name = "djlint"
version = "1.34.1" version = "1.34.1"
@ -507,6 +538,116 @@ files = [
{file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"}, {file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"},
] ]
[[package]]
name = "pydantic"
version = "2.6.4"
description = "Data validation using Python type hints"
optional = false
python-versions = ">=3.8"
files = [
{file = "pydantic-2.6.4-py3-none-any.whl", hash = "sha256:cc46fce86607580867bdc3361ad462bab9c222ef042d3da86f2fb333e1d916c5"},
{file = "pydantic-2.6.4.tar.gz", hash = "sha256:b1704e0847db01817624a6b86766967f552dd9dbf3afba4004409f908dcc84e6"},
]
[package.dependencies]
annotated-types = ">=0.4.0"
pydantic-core = "2.16.3"
typing-extensions = ">=4.6.1"
[package.extras]
email = ["email-validator (>=2.0.0)"]
[[package]]
name = "pydantic-core"
version = "2.16.3"
description = ""
optional = false
python-versions = ">=3.8"
files = [
{file = "pydantic_core-2.16.3-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:75b81e678d1c1ede0785c7f46690621e4c6e63ccd9192af1f0bd9d504bbb6bf4"},
{file = "pydantic_core-2.16.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9c865a7ee6f93783bd5d781af5a4c43dadc37053a5b42f7d18dc019f8c9d2bd1"},
{file = "pydantic_core-2.16.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:162e498303d2b1c036b957a1278fa0899d02b2842f1ff901b6395104c5554a45"},
{file = "pydantic_core-2.16.3-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2f583bd01bbfbff4eaee0868e6fc607efdfcc2b03c1c766b06a707abbc856187"},
{file = "pydantic_core-2.16.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b926dd38db1519ed3043a4de50214e0d600d404099c3392f098a7f9d75029ff8"},
{file = "pydantic_core-2.16.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:716b542728d4c742353448765aa7cdaa519a7b82f9564130e2b3f6766018c9ec"},
{file = "pydantic_core-2.16.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc4ad7f7ee1a13d9cb49d8198cd7d7e3aa93e425f371a68235f784e99741561f"},
{file = "pydantic_core-2.16.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bd87f48924f360e5d1c5f770d6155ce0e7d83f7b4e10c2f9ec001c73cf475c99"},
{file = "pydantic_core-2.16.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:0df446663464884297c793874573549229f9eca73b59360878f382a0fc085979"},
{file = "pydantic_core-2.16.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4df8a199d9f6afc5ae9a65f8f95ee52cae389a8c6b20163762bde0426275b7db"},
{file = "pydantic_core-2.16.3-cp310-none-win32.whl", hash = "sha256:456855f57b413f077dff513a5a28ed838dbbb15082ba00f80750377eed23d132"},
{file = "pydantic_core-2.16.3-cp310-none-win_amd64.whl", hash = "sha256:732da3243e1b8d3eab8c6ae23ae6a58548849d2e4a4e03a1924c8ddf71a387cb"},
{file = "pydantic_core-2.16.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:519ae0312616026bf4cedc0fe459e982734f3ca82ee8c7246c19b650b60a5ee4"},
{file = "pydantic_core-2.16.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b3992a322a5617ded0a9f23fd06dbc1e4bd7cf39bc4ccf344b10f80af58beacd"},
{file = "pydantic_core-2.16.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8d62da299c6ecb04df729e4b5c52dc0d53f4f8430b4492b93aa8de1f541c4aac"},
{file = "pydantic_core-2.16.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2acca2be4bb2f2147ada8cac612f8a98fc09f41c89f87add7256ad27332c2fda"},
{file = "pydantic_core-2.16.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1b662180108c55dfbf1280d865b2d116633d436cfc0bba82323554873967b340"},
{file = "pydantic_core-2.16.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e7c6ed0dc9d8e65f24f5824291550139fe6f37fac03788d4580da0d33bc00c97"},
{file = "pydantic_core-2.16.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a6b1bb0827f56654b4437955555dc3aeeebeddc47c2d7ed575477f082622c49e"},
{file = "pydantic_core-2.16.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e56f8186d6210ac7ece503193ec84104da7ceb98f68ce18c07282fcc2452e76f"},
{file = "pydantic_core-2.16.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:936e5db01dd49476fa8f4383c259b8b1303d5dd5fb34c97de194560698cc2c5e"},
{file = "pydantic_core-2.16.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:33809aebac276089b78db106ee692bdc9044710e26f24a9a2eaa35a0f9fa70ba"},
{file = "pydantic_core-2.16.3-cp311-none-win32.whl", hash = "sha256:ded1c35f15c9dea16ead9bffcde9bb5c7c031bff076355dc58dcb1cb436c4721"},
{file = "pydantic_core-2.16.3-cp311-none-win_amd64.whl", hash = "sha256:d89ca19cdd0dd5f31606a9329e309d4fcbb3df860960acec32630297d61820df"},
{file = "pydantic_core-2.16.3-cp311-none-win_arm64.whl", hash = "sha256:6162f8d2dc27ba21027f261e4fa26f8bcb3cf9784b7f9499466a311ac284b5b9"},
{file = "pydantic_core-2.16.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0f56ae86b60ea987ae8bcd6654a887238fd53d1384f9b222ac457070b7ac4cff"},
{file = "pydantic_core-2.16.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c9bd22a2a639e26171068f8ebb5400ce2c1bc7d17959f60a3b753ae13c632975"},
{file = "pydantic_core-2.16.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4204e773b4b408062960e65468d5346bdfe139247ee5f1ca2a378983e11388a2"},
{file = "pydantic_core-2.16.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f651dd19363c632f4abe3480a7c87a9773be27cfe1341aef06e8759599454120"},
{file = "pydantic_core-2.16.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aaf09e615a0bf98d406657e0008e4a8701b11481840be7d31755dc9f97c44053"},
{file = "pydantic_core-2.16.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8e47755d8152c1ab5b55928ab422a76e2e7b22b5ed8e90a7d584268dd49e9c6b"},
{file = "pydantic_core-2.16.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:500960cb3a0543a724a81ba859da816e8cf01b0e6aaeedf2c3775d12ee49cade"},
{file = "pydantic_core-2.16.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cf6204fe865da605285c34cf1172879d0314ff267b1c35ff59de7154f35fdc2e"},
{file = "pydantic_core-2.16.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d33dd21f572545649f90c38c227cc8631268ba25c460b5569abebdd0ec5974ca"},
{file = "pydantic_core-2.16.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:49d5d58abd4b83fb8ce763be7794d09b2f50f10aa65c0f0c1696c677edeb7cbf"},
{file = "pydantic_core-2.16.3-cp312-none-win32.whl", hash = "sha256:f53aace168a2a10582e570b7736cc5bef12cae9cf21775e3eafac597e8551fbe"},
{file = "pydantic_core-2.16.3-cp312-none-win_amd64.whl", hash = "sha256:0d32576b1de5a30d9a97f300cc6a3f4694c428d956adbc7e6e2f9cad279e45ed"},
{file = "pydantic_core-2.16.3-cp312-none-win_arm64.whl", hash = "sha256:ec08be75bb268473677edb83ba71e7e74b43c008e4a7b1907c6d57e940bf34b6"},
{file = "pydantic_core-2.16.3-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:b1f6f5938d63c6139860f044e2538baeee6f0b251a1816e7adb6cbce106a1f01"},
{file = "pydantic_core-2.16.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:2a1ef6a36fdbf71538142ed604ad19b82f67b05749512e47f247a6ddd06afdc7"},
{file = "pydantic_core-2.16.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:704d35ecc7e9c31d48926150afada60401c55efa3b46cd1ded5a01bdffaf1d48"},
{file = "pydantic_core-2.16.3-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d937653a696465677ed583124b94a4b2d79f5e30b2c46115a68e482c6a591c8a"},
{file = "pydantic_core-2.16.3-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c9803edf8e29bd825f43481f19c37f50d2b01899448273b3a7758441b512acf8"},
{file = "pydantic_core-2.16.3-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:72282ad4892a9fb2da25defeac8c2e84352c108705c972db82ab121d15f14e6d"},
{file = "pydantic_core-2.16.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f752826b5b8361193df55afcdf8ca6a57d0232653494ba473630a83ba50d8c9"},
{file = "pydantic_core-2.16.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4384a8f68ddb31a0b0c3deae88765f5868a1b9148939c3f4121233314ad5532c"},
{file = "pydantic_core-2.16.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:a4b2bf78342c40b3dc830880106f54328928ff03e357935ad26c7128bbd66ce8"},
{file = "pydantic_core-2.16.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:13dcc4802961b5f843a9385fc821a0b0135e8c07fc3d9949fd49627c1a5e6ae5"},
{file = "pydantic_core-2.16.3-cp38-none-win32.whl", hash = "sha256:e3e70c94a0c3841e6aa831edab1619ad5c511199be94d0c11ba75fe06efe107a"},
{file = "pydantic_core-2.16.3-cp38-none-win_amd64.whl", hash = "sha256:ecdf6bf5f578615f2e985a5e1f6572e23aa632c4bd1dc67f8f406d445ac115ed"},
{file = "pydantic_core-2.16.3-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:bda1ee3e08252b8d41fa5537413ffdddd58fa73107171a126d3b9ff001b9b820"},
{file = "pydantic_core-2.16.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:21b888c973e4f26b7a96491c0965a8a312e13be108022ee510248fe379a5fa23"},
{file = "pydantic_core-2.16.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be0ec334369316fa73448cc8c982c01e5d2a81c95969d58b8f6e272884df0074"},
{file = "pydantic_core-2.16.3-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5b6079cc452a7c53dd378c6f881ac528246b3ac9aae0f8eef98498a75657805"},
{file = "pydantic_core-2.16.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ee8d5f878dccb6d499ba4d30d757111847b6849ae07acdd1205fffa1fc1253c"},
{file = "pydantic_core-2.16.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7233d65d9d651242a68801159763d09e9ec96e8a158dbf118dc090cd77a104c9"},
{file = "pydantic_core-2.16.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c6119dc90483a5cb50a1306adb8d52c66e447da88ea44f323e0ae1a5fcb14256"},
{file = "pydantic_core-2.16.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:578114bc803a4c1ff9946d977c221e4376620a46cf78da267d946397dc9514a8"},
{file = "pydantic_core-2.16.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d8f99b147ff3fcf6b3cc60cb0c39ea443884d5559a30b1481e92495f2310ff2b"},
{file = "pydantic_core-2.16.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:4ac6b4ce1e7283d715c4b729d8f9dab9627586dafce81d9eaa009dd7f25dd972"},
{file = "pydantic_core-2.16.3-cp39-none-win32.whl", hash = "sha256:e7774b570e61cb998490c5235740d475413a1f6de823169b4cf94e2fe9e9f6b2"},
{file = "pydantic_core-2.16.3-cp39-none-win_amd64.whl", hash = "sha256:9091632a25b8b87b9a605ec0e61f241c456e9248bfdcf7abdf344fdb169c81cf"},
{file = "pydantic_core-2.16.3-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:36fa178aacbc277bc6b62a2c3da95226520da4f4e9e206fdf076484363895d2c"},
{file = "pydantic_core-2.16.3-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:dcca5d2bf65c6fb591fff92da03f94cd4f315972f97c21975398bd4bd046854a"},
{file = "pydantic_core-2.16.3-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2a72fb9963cba4cd5793854fd12f4cfee731e86df140f59ff52a49b3552db241"},
{file = "pydantic_core-2.16.3-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b60cc1a081f80a2105a59385b92d82278b15d80ebb3adb200542ae165cd7d183"},
{file = "pydantic_core-2.16.3-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cbcc558401de90a746d02ef330c528f2e668c83350f045833543cd57ecead1ad"},
{file = "pydantic_core-2.16.3-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:fee427241c2d9fb7192b658190f9f5fd6dfe41e02f3c1489d2ec1e6a5ab1e04a"},
{file = "pydantic_core-2.16.3-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f4cb85f693044e0f71f394ff76c98ddc1bc0953e48c061725e540396d5c8a2e1"},
{file = "pydantic_core-2.16.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:b29eeb887aa931c2fcef5aa515d9d176d25006794610c264ddc114c053bf96fe"},
{file = "pydantic_core-2.16.3-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:a425479ee40ff021f8216c9d07a6a3b54b31c8267c6e17aa88b70d7ebd0e5e5b"},
{file = "pydantic_core-2.16.3-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:5c5cbc703168d1b7a838668998308018a2718c2130595e8e190220238addc96f"},
{file = "pydantic_core-2.16.3-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:99b6add4c0b39a513d323d3b93bc173dac663c27b99860dd5bf491b240d26137"},
{file = "pydantic_core-2.16.3-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75f76ee558751746d6a38f89d60b6228fa174e5172d143886af0f85aa306fd89"},
{file = "pydantic_core-2.16.3-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:00ee1c97b5364b84cb0bd82e9bbf645d5e2871fb8c58059d158412fee2d33d8a"},
{file = "pydantic_core-2.16.3-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:287073c66748f624be4cef893ef9174e3eb88fe0b8a78dc22e88eca4bc357ca6"},
{file = "pydantic_core-2.16.3-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:ed25e1835c00a332cb10c683cd39da96a719ab1dfc08427d476bce41b92531fc"},
{file = "pydantic_core-2.16.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:86b3d0033580bd6bbe07590152007275bd7af95f98eaa5bd36f3da219dcd93da"},
{file = "pydantic_core-2.16.3.tar.gz", hash = "sha256:1cac689f80a3abab2d3c0048b29eea5751114054f032a941a32de4c852c59cad"},
]
[package.dependencies]
typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0"
[[package]] [[package]]
name = "python-dateutil" name = "python-dateutil"
version = "2.9.0.post0" version = "2.9.0.post0"
@ -812,6 +953,17 @@ notebook = ["ipywidgets (>=6)"]
slack = ["slack-sdk"] slack = ["slack-sdk"]
telegram = ["requests"] telegram = ["requests"]
[[package]]
name = "typing-extensions"
version = "4.10.0"
description = "Backported and Experimental Type Hints for Python 3.8+"
optional = false
python-versions = ">=3.8"
files = [
{file = "typing_extensions-4.10.0-py3-none-any.whl", hash = "sha256:69b1a937c3a517342112fb4c6df7e72fc39a38e7891a5730ed4985b5214b5475"},
{file = "typing_extensions-4.10.0.tar.gz", hash = "sha256:b0abd7c89e8fb96f98db18d86106ff1d90ab692004eb746cf6eda2682f91b3cb"},
]
[[package]] [[package]]
name = "tzdata" name = "tzdata"
version = "2024.1" version = "2024.1"
@ -860,4 +1012,4 @@ zstd = ["zstandard (>=0.18.0)"]
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = "^3.12" python-versions = "^3.12"
content-hash = "69201bbe395e34bc094c4d0c9b5a67ce291e9e38cfcd604c7193c0a7856a7786" content-hash = "4ad21c4b598e168cb8df3e8970ad48b1427dab627303eea66d7cf1b55f817ed0"

View file

@ -13,6 +13,7 @@ feedparser = "^6.0.11"
gunicorn = "^21.2.0" gunicorn = "^21.2.0"
dateparser = "^1.2.0" dateparser = "^1.2.0"
discord-webhook = "^1.3.1" discord-webhook = "^1.3.1"
django-ninja = "^1.1.0"
[tool.poetry.group.dev.dependencies] [tool.poetry.group.dev.dependencies]
ruff = "^0.3.0" ruff = "^0.3.0"
@ -44,6 +45,7 @@ lint.ignore = [
"COM812", # Checks for the absence of trailing commas. "COM812", # Checks for the absence of trailing commas.
"ISC001", # Checks for implicitly concatenated strings on a single line. "ISC001", # Checks for implicitly concatenated strings on a single line.
"PLR6301", # Checks for the presence of unused self parameter in methods definitions. "PLR6301", # Checks for the presence of unused self parameter in methods definitions.
"ARG001", # Checks for the presence of unused arguments in function definitions.
] ]
[tool.ruff.lint.pydocstyle] [tool.ruff.lint.pydocstyle]

View file

@ -1,160 +0,0 @@
{% extends "base.html" %}
{% block content %}
<h2>API Documentation</h2>
<p>
Missing something? <a href="mailto:hello@feedvault.se">Let me know</a>.
</p>
<h2>Get All Feeds</h2>
<p>GET /api/feeds/</p>
<h3>Query Parameters</h3>
<ul>
<li>
<strong>page</strong> (optional): The page number of feeds to retrieve. Defaults to 1 if not specified.
</li>
<li>
<strong>per_page</strong> (optional): The number of feeds per page. Defaults to 1000 if not specified. The maximum value is 1000.
</li>
</ul>
<h3>Headers</h3>
<p>
The following headers are sent with the response:
<ul>
<li>
<strong>X-Page</strong>: The current page number.
</li>
<li>
<strong>X-Page-Count</strong>: The total number of pages.
</li>
<li>
<strong>X-Per-Page</strong>: The number of feeds per page.
</li>
<li>
<strong>X-Total-Count</strong>: The total number of feeds.
</li>
<li>
<strong>X-First-Page</strong>: The page number of the first page.
</li>
<li>
<strong>X-Last-Page</strong>: The page number of the last page.
</li>
</ul>
</p>
<h3>Python example</h3>
<pre>
import requests
page_num = 1
per_page = 1000
url = f'https://feedvault.se/api/feeds/?page={page_num}&per_page={per_page}'
response = requests.get(url)
print(response.json())
</pre>
<h3>Example URL</h3>
<p>
<a href="https://feedvault.se/api/feeds/?page=1&per_page=1000">https://feedvault.se/api/feeds/?page=1&per_page=1000</a>
</p>
<hr>
<h2>Get All Entries</h2>
<p>GET /api/entries/</p>
<h3>Query Parameters</h3>
<ul>
<li>
<strong>page</strong> (optional): The page number of entries to retrieve. Defaults to 1 if not specified.
</li>
<li>
<strong>per_page</strong> (optional): The number of entries per page. Defaults to 1000 if not specified. The maximum value is 1000.
</li>
</ul>
<h3>Headers</h3>
<p>
The following headers are sent with the response:
<ul>
<li>
<strong>X-Page</strong>: The current page number.
</li>
<li>
<strong>X-Page-Count</strong>: The total number of pages.
</li>
<li>
<strong>X-Per-Page</strong>: The number of feeds per page.
</li>
<li>
<strong>X-Total-Count</strong>: The total number of feeds.
</li>
<li>
<strong>X-First-Page</strong>: The page number of the first page.
</li>
<li>
<strong>X-Last-Page</strong>: The page number of the last page.
</li>
</ul>
</p>
<h3>Python example</h3>
<pre>
import requests
page_num = 1
per_page = 1000
url = f'https://feedvault.se/api/entries/?page={page_num}&per_page={per_page}'
response = requests.get(url)
print(response.json())
</pre>
<h3>Example URL</h3>
<p>
<a href="https://feedvault.se/api/entries/?page=1&per_page=1000">https://feedvault.se/api/entries/?page=1&per_page=1000</a>
</p>
<hr>
<h2>Get All Entries for a Feed</h2>
<p>GET /api/feeds/{feed_id}/entries/</p>
<h3>Query Parameters</h3>
<ul>
<li>
<strong>page</strong> (optional): The page number of entries to retrieve. Defaults to 1 if not specified.
</li>
<li>
<strong>per_page</strong> (optional): The number of entries per page. Defaults to 1000 if not specified. The maximum value is 1000.
</li>
</ul>
<h3>Headers</h3>
<p>
The following headers are sent with the response:
<ul>
<li>
<strong>X-Page</strong>: The current page number.
</li>
<li>
<strong>X-Page-Count</strong>: The total number of pages.
</li>
<li>
<strong>X-Per-Page</strong>: The number of feeds per page.
</li>
<li>
<strong>X-Total-Count</strong>: The total number of feeds.
</li>
<li>
<strong>X-First-Page</strong>: The page number of the first page.
</li>
<li>
<strong>X-Last-Page</strong>: The page number of the last page.
</li>
</ul>
</p>
<h3>Python example</h3>
<pre>
import requests
page_num = 1
per_page = 1000
feed_id = 1
url = f'https://feedvault.se/api/feeds/{feed_id}/entries/?page={page_num}&per_page={per_page}'
response = requests.get(url)
print(response.json())
</pre>
<h3>Example URL</h3>
<p>
<a href="https://feedvault.se/api/feeds/1/entries/?page=1&per_page=1000">https://feedvault.se/api/feeds/1/entries/?page=1&per_page=1000</a>
</p>
{% endblock %}

View file

@ -107,7 +107,7 @@
<a href="{% url 'index' %}">Home</a> | <a href="{% url 'index' %}">Home</a> |
<a href="{% url 'domains' %}">Domains</a> | <a href="{% url 'domains' %}">Domains</a> |
<a href="{% url 'feeds' %}">Feeds</a> | <a href="{% url 'feeds' %}">Feeds</a> |
<a href="{% url 'api' %}">API</a> <a href="{% url 'api_v1:openapi-view' %}">API</a>
</div> </div>
<div class="right"> <div class="right">
<a href="https://github.com/TheLovinator1/FeedVault">GitHub</a> | <a href="https://github.com/TheLovinator1/FeedVault">GitHub</a> |

View file

@ -1,14 +1,11 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% block content %}
<h2>Feeds</h2> <h2>Latest Feeds</h2>
<ul> {% if feeds %}
{% for feed in feeds %} {% for feed in feeds %}
<li>{{ feed.feed_url }} - {{ feed.created_at|date }}</li> <a href="{% url 'feed' feed.id %}">{{ feed.feed_url|default:"Unknown Feed" }} →</a>
<li>
<a href="{% url 'feed' feed.id %}">View</a>
</li>
{% empty %}
<li>No feeds yet.</li>
{% endfor %} {% endfor %}
</ul> {% else %}
<p>No feeds yet. Time to add some!</p>
{% endif %}
{% endblock %} {% endblock %}