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)
_id = models.TextField(blank=True)
feed_id = models.TextField(blank=True)
image = JSONField(null=True, blank=True)
info = models.TextField(blank=True)
info_detail = JSONField(null=True, blank=True)
@ -249,7 +249,7 @@ class Entry(models.Model):
enclosures = JSONField(null=True, blank=True)
expired = models.TextField(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)
link = models.TextField(blank=True)
links = JSONField(null=True, blank=True)

View file

@ -1,6 +1,7 @@
from __future__ import annotations
import os
import sys
from pathlib import Path
from dotenv import find_dotenv, load_dotenv
@ -37,6 +38,10 @@ SITE_ID = 1
PASSWORD_HASHERS: list[str] = ["django.contrib.auth.hashers.Argon2PasswordHasher"]
ROOT_URLCONF = "feedvault.urls"
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] = [
"feedvault.apps.FeedVaultConfig",

View file

@ -5,6 +5,7 @@ import os
from typing import TYPE_CHECKING
from discord_webhook import DiscordWebhook
from django.conf import settings
from django.contrib.auth.models import User
from django.db.models.signals import post_save
from django.dispatch import receiver
@ -16,7 +17,7 @@ logger: logging.Logger = logging.getLogger(__name__)
@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.
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}"
webhook = DiscordWebhook(url=webhook_url, content=msg)
if not settings.TESTING:
response: Response = webhook.execute()
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 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 feedvault.models import Domain, Feed
from feedvault.models import Domain, Entry, Feed
if TYPE_CHECKING:
from django.http import HttpResponse
@ -98,47 +99,62 @@ class TestDomains(TestCase):
class TestAPI(TestCase):
def test_api_page(self) -> None:
"""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}"
class TestAPIFeeds(TestCase):
def test_api_feeds_page(self) -> None:
"""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}"
class TestAPIFeed(TestCase):
class FeedVaultAPITests(TestCase):
def setUp(self) -> None:
"""Create a test feed."""
self.domain: Domain = Domain.objects.create(
name="feedvault",
url="feedvault.se",
)
# Set up data for the whole TestCase
self.client = Client()
self.user: User = User.objects.create_user(
username="testuser",
email="hello@feedvault.se",
password="testpassword", # noqa: S106
)
# Creating a domain instance
self.domain: Domain = Domain.objects.create(name="Example Domain")
self.feed: Feed = Feed.objects.create(
user=self.user,
bozo=False,
feed_url="https://feedvault.se/feed.xml",
domain=self.domain,
)
# Creating a feed instance
self.feed: Feed = Feed.objects.create(title="Example Feed", domain=self.domain, bozo=False)
def test_api_feed_page(self) -> None:
"""Test if the API feed page is accessible."""
response: HttpResponse = self.client.get(reverse("api_feeds_id", kwargs={"feed_id": 1}))
# Creating entry instances
self.entry1: Entry = Entry.objects.create(title="Example Entry 1", feed=self.feed)
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 "Example Feed" in response.content.decode()
def test_api_feed_page_not_found(self) -> None:
"""Test if the API feed page is accessible."""
response: HttpResponse = self.client.get(reverse("api_feeds_id", kwargs={"feed_id": 2}))
assert response.status_code == 404, f"Expected 404, got {response.status_code}"
def test_get_feed(self) -> None:
response: HttpResponse = self.client.get(f"/api/v1/feeds/{self.feed.pk}/")
assert response.status_code == 200, f"Expected 200, 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):

View file

@ -6,12 +6,14 @@ from django.urls import URLPattern, path
from django.views.decorators.cache import cache_page
from feedvault import views
from feedvault.api import api_v1
from feedvault.models import Domain, Feed
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"
sitemaps = {
"static": StaticViewSitemap,
"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="domain/<int:domain_id>/", view=views.DomainView.as_view(), name="domain"),
path(route="api/", view=APIView.as_view(), name="api"),
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("api/v1/", api_v1.urls), # type: ignore # noqa: PGH003
path(route="accounts/login/", view=CustomLoginView.as_view(), name="login"),
path(route="accounts/register/", view=RegisterView.as_view(), name="register"),
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.views import LoginView, LogoutView, PasswordChangeView
from django.contrib.messages.views import SuccessMessageMixin
from django.core.paginator import EmptyPage, Page, PageNotAnInteger, Paginator
from django.forms.models import model_to_dict
from django.http import HttpRequest, HttpResponse, JsonResponse
from django.db.models.manager import BaseManager
from django.http import HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404, render
from django.template import loader
from django.urls import reverse_lazy
@ -22,6 +21,8 @@ from feedvault.models import Domain, Entry, Feed
if TYPE_CHECKING:
from django.contrib.auth.models import User
from django.core.files.uploadedfile import UploadedFile
from django.db.models.manager import BaseManager
class IndexView(View):
@ -49,8 +50,8 @@ class FeedView(View):
if not feed_id:
return HttpResponse(content="No id", status=400)
feed = get_object_or_404(Feed, id=feed_id)
entries = Entry.objects.filter(feed=feed).order_by("-created_parsed")[:100]
feed: Feed = get_object_or_404(Feed, id=feed_id)
entries: BaseManager[Entry] = Entry.objects.filter(feed=feed).order_by("-created_parsed")[:100]
context = {
"feed": feed,
@ -91,7 +92,7 @@ class AddView(View):
def get(self, request: HttpRequest) -> HttpResponse:
"""Load the index page."""
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.",
"keywords": "feed, rss, atom, archive, rss list",
"author": "TheLovinator",
@ -134,7 +135,7 @@ class UploadView(View):
def get(self, request: HttpRequest) -> HttpResponse:
"""Load the index page."""
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.",
"keywords": "feed, rss, atom, archive, rss list",
"author": "TheLovinator",
@ -150,7 +151,7 @@ class UploadView(View):
if not request.user.is_active:
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:
return HttpResponse(content="No file", status=400)
@ -193,7 +194,7 @@ class RegisterView(CreateView):
# Add context data to the view
def get_context_data(self, **kwargs) -> dict: # noqa: ANN003
"""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["keywords"] = "register, account, feed, rss, atom, archive, rss list"
context["author"] = "TheLovinator"
@ -234,7 +235,7 @@ class ProfileView(View):
"""Load the profile page."""
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] = {
"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))
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):
"""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):
"""All domains."""
def get(self: DomainsView, request: HttpRequest) -> HttpResponse:
"""Load the domains page."""
domains = Domain.objects.all()
domains: BaseManager[Domain] = Domain.objects.all()
template = loader.get_template(template_name="domains.html")
context = {
"domains": domains,
@ -471,8 +282,8 @@ class DomainView(View):
def get(self: DomainView, request: HttpRequest, domain_id: int) -> HttpResponse:
"""Load the domain page."""
domain = get_object_or_404(Domain, id=domain_id)
feeds = Feed.objects.filter(domain=domain).order_by("-created_at")[:100]
domain: Domain = get_object_or_404(Domain, id=domain_id)
feeds: BaseManager[Feed] = Feed.objects.filter(domain=domain).order_by("-created_at")[:100]
context = {
"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.
[[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]]
name = "argon2-cffi"
version = "23.1.0"
@ -345,6 +356,26 @@ tzdata = {version = "*", markers = "sys_platform == \"win32\""}
argon2 = ["argon2-cffi (>=19.1.0)"]
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]]
name = "djlint"
version = "1.34.1"
@ -507,6 +538,116 @@ files = [
{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]]
name = "python-dateutil"
version = "2.9.0.post0"
@ -812,6 +953,17 @@ notebook = ["ipywidgets (>=6)"]
slack = ["slack-sdk"]
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]]
name = "tzdata"
version = "2024.1"
@ -860,4 +1012,4 @@ zstd = ["zstandard (>=0.18.0)"]
[metadata]
lock-version = "2.0"
python-versions = "^3.12"
content-hash = "69201bbe395e34bc094c4d0c9b5a67ce291e9e38cfcd604c7193c0a7856a7786"
content-hash = "4ad21c4b598e168cb8df3e8970ad48b1427dab627303eea66d7cf1b55f817ed0"

View file

@ -13,6 +13,7 @@ feedparser = "^6.0.11"
gunicorn = "^21.2.0"
dateparser = "^1.2.0"
discord-webhook = "^1.3.1"
django-ninja = "^1.1.0"
[tool.poetry.group.dev.dependencies]
ruff = "^0.3.0"
@ -44,6 +45,7 @@ lint.ignore = [
"COM812", # Checks for the absence of trailing commas.
"ISC001", # Checks for implicitly concatenated strings on a single line.
"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]

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 'domains' %}">Domains</a> |
<a href="{% url 'feeds' %}">Feeds</a> |
<a href="{% url 'api' %}">API</a>
<a href="{% url 'api_v1:openapi-view' %}">API</a>
</div>
<div class="right">
<a href="https://github.com/TheLovinator1/FeedVault">GitHub</a> |

View file

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