Use FastAPI instead of Django
This commit is contained in:
parent
bfe90aa69d
commit
b462be40af
43 changed files with 1105 additions and 1688 deletions
|
|
@ -1 +0,0 @@
|
|||
# Register your models here.
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class FeedsConfig(AppConfig):
|
||||
default_auto_field: str = "django.db.models.BigAutoField"
|
||||
name = "feeds"
|
||||
|
|
@ -1,224 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from functools import lru_cache
|
||||
from typing import TYPE_CHECKING, Any, Iterable, Iterator, Self
|
||||
|
||||
from django.db.models import Q
|
||||
from reader import ExceptionInfo, FeedExistsError, FeedNotFoundError, Reader, make_reader
|
||||
from reader._types import (
|
||||
EntryForUpdate, # noqa: PLC2701
|
||||
EntryUpdateIntent,
|
||||
FeedData,
|
||||
FeedFilter,
|
||||
FeedForUpdate, # noqa: PLC2701
|
||||
FeedUpdateIntent,
|
||||
SearchType, # noqa: PLC2701
|
||||
StorageType, # noqa: PLC2701
|
||||
)
|
||||
|
||||
from .models import Entry, Feed
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import datetime
|
||||
|
||||
from django.db.models.manager import BaseManager
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EmptySearch(SearchType): ...
|
||||
|
||||
|
||||
class EntriesForUpdateIterator:
|
||||
def __init__(self, entries: Iterable[tuple[str, str]]) -> None:
|
||||
self.entries: Iterator[tuple[str, str]] = iter(entries)
|
||||
|
||||
def __iter__(self) -> Self:
|
||||
return self
|
||||
|
||||
def __next__(self) -> EntryForUpdate:
|
||||
try:
|
||||
feed_url, entry_id = next(self.entries)
|
||||
except StopIteration:
|
||||
raise StopIteration from None
|
||||
|
||||
print(f"{feed_url=}, {entry_id=}") # noqa: T201
|
||||
entry_data: dict[str, Any] | None = (
|
||||
Entry.objects.filter(Q(feed__url=feed_url) & Q(id=entry_id))
|
||||
.values("updated", "published", "data_hash", "data_hash_changed")
|
||||
.first()
|
||||
)
|
||||
|
||||
if not entry_data:
|
||||
return None
|
||||
|
||||
return EntryForUpdate(
|
||||
updated=entry_data.get("updated"),
|
||||
published=entry_data.get("published"),
|
||||
hash=entry_data.get("data_hash"),
|
||||
hash_changed=entry_data.get("data_hash_changed"),
|
||||
)
|
||||
|
||||
|
||||
class DjangoStorage(StorageType):
|
||||
# TODO(TheLovinator): Implement all methods from StorageType.
|
||||
default_search_cls = EmptySearch
|
||||
|
||||
def __enter__(self: DjangoStorage) -> None:
|
||||
"""Called when Reader is used as a context manager."""
|
||||
# TODO(TheLovinator): Should we check if we have migrations to apply?
|
||||
|
||||
def __exit__(self: DjangoStorage, *_: object) -> None:
|
||||
"""Called when Reader is used as a context manager."""
|
||||
# TODO(TheLovinator): Should we close the connection?
|
||||
|
||||
def close(self: DjangoStorage) -> None:
|
||||
"""Called by Reader.close()."""
|
||||
# TODO(TheLovinator): Should we close the connection?
|
||||
|
||||
def add_feed(self, url: str, /, added: datetime.datetime) -> None:
|
||||
"""Called by Reader.add_feed().
|
||||
|
||||
Args:
|
||||
url: The URL of the feed.
|
||||
added: The time the feed was added.
|
||||
|
||||
Raises:
|
||||
FeedExistsError: Feed already exists. Bases: FeedError
|
||||
"""
|
||||
if Feed.objects.filter(url=url).exists():
|
||||
msg: str = f"Feed already exists: {url}"
|
||||
raise FeedExistsError(msg)
|
||||
|
||||
feed = Feed(url=url, added=added)
|
||||
feed.save()
|
||||
|
||||
def get_feeds_for_update(self, filter: FeedFilter): # noqa: A002
|
||||
"""Called by update logic.
|
||||
|
||||
Args:
|
||||
filter: The filter to apply.
|
||||
|
||||
Returns:
|
||||
A lazy iterable.
|
||||
"""
|
||||
logger.debug(f"{filter=}") # noqa: G004
|
||||
feeds: BaseManager[Feed] = Feed.objects.all() # TODO(TheLovinator): Don't get all values, use filter.
|
||||
|
||||
for feed in feeds:
|
||||
yield FeedForUpdate(
|
||||
url=feed.url,
|
||||
updated=feed.updated,
|
||||
http_etag=feed.http_etag,
|
||||
http_last_modified=feed.http_last_modified,
|
||||
stale=feed.stale,
|
||||
last_updated=feed.last_updated,
|
||||
last_exception=bool(feed.last_exception_type_name),
|
||||
hash=feed.data_hash,
|
||||
)
|
||||
|
||||
def update_feed(self, intent: FeedUpdateIntent, /) -> None:
|
||||
"""Called by update logic.
|
||||
|
||||
Args:
|
||||
intent: Data to be passed to Storage when updating a feed.
|
||||
|
||||
Raises:
|
||||
FeedNotFoundError
|
||||
|
||||
"""
|
||||
feed: Feed = Feed.objects.get(url=intent.url)
|
||||
if feed is None:
|
||||
msg: str = f"Feed not found: {intent.url}"
|
||||
raise FeedNotFoundError(msg)
|
||||
|
||||
feed.last_updated = intent.last_updated
|
||||
feed.http_etag = intent.http_etag
|
||||
feed.http_last_modified = intent.http_last_modified
|
||||
|
||||
feed_data: FeedData | None = intent.feed
|
||||
if feed_data is not None:
|
||||
feed.title = feed_data.title
|
||||
feed.link = feed_data.link
|
||||
feed.author = feed_data.author
|
||||
feed.subtitle = feed_data.subtitle
|
||||
feed.version = feed_data.version
|
||||
|
||||
if intent.last_exception is not None:
|
||||
last_exception: ExceptionInfo = intent.last_exception
|
||||
feed.last_exception_type_name = last_exception.type_name
|
||||
feed.last_exception_value = last_exception.value_str
|
||||
feed.last_exception_traceback = last_exception.traceback_str
|
||||
|
||||
feed.save()
|
||||
|
||||
def set_feed_stale(self, url: str, stale: bool, /) -> None: # noqa: FBT001
|
||||
"""Used by update logic tests.
|
||||
|
||||
Args:
|
||||
url: The URL of the feed.
|
||||
stale: Whether the next update should update all entries, regardless of their hash or updated.
|
||||
|
||||
Raises:
|
||||
FeedNotFoundError
|
||||
"""
|
||||
feed: Feed = Feed.objects.get(url=url)
|
||||
if feed is None:
|
||||
msg: str = f"Feed not found: {url}"
|
||||
raise FeedNotFoundError(msg)
|
||||
|
||||
feed.stale = stale
|
||||
feed.save()
|
||||
|
||||
def get_entries_for_update(self, entries: Iterable[tuple[str, str]], /) -> EntriesForUpdateIterator:
|
||||
for feed_url, entry_id in entries:
|
||||
logger.debug(f"{feed_url=}, {entry_id=}") # noqa: G004
|
||||
|
||||
entries_list = list(entries)
|
||||
print(f"{entries_list=}") # noqa: T201
|
||||
return EntriesForUpdateIterator(entries)
|
||||
|
||||
def add_or_update_entries(self, intents: Iterable[EntryUpdateIntent], /) -> None:
|
||||
"""Called by update logic.
|
||||
|
||||
Args:
|
||||
intents: Data to be passed to Storage when updating a feed.
|
||||
|
||||
Raises:
|
||||
FeedNotFoundError
|
||||
"""
|
||||
msg = "Not implemented yet."
|
||||
raise NotImplementedError(msg)
|
||||
for intent in intents:
|
||||
feed_id, entry_id = intent.entry.resource_id
|
||||
logger.debug(f"{feed_id=}, {entry_id=}") # noqa: G004
|
||||
# TODO(TheLovinator): Implement this method. Use Entry.objects.get_or_create()/Entry.objects.bulk_create()?
|
||||
# TODO(TheLovinator): Raise FeedNotFoundError if feed does not exist.
|
||||
|
||||
def make_search(self) -> SearchType:
|
||||
"""Called by Reader.make_search().
|
||||
|
||||
Returns:
|
||||
A Search instance.
|
||||
"""
|
||||
return EmptySearch()
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_reader() -> Reader:
|
||||
"""Create a Reader instance.
|
||||
|
||||
reader = get_reader()
|
||||
reader.add_feed("https://example.com/feed", added=datetime.datetime.now())
|
||||
reader.update_feeds()
|
||||
|
||||
Returns:
|
||||
A Reader instance.
|
||||
"""
|
||||
return make_reader(
|
||||
"",
|
||||
_storage=DjangoStorage(),
|
||||
search_enabled=False,
|
||||
)
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
from typing import TYPE_CHECKING
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from reader import Reader
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Update feeds"
|
||||
|
||||
def handle(self, *args, **options) -> None:
|
||||
from feeds.get_reader import get_reader # noqa: PLC0415
|
||||
|
||||
reader: Reader = get_reader()
|
||||
reader.update_feeds()
|
||||
|
|
@ -1,110 +0,0 @@
|
|||
# Generated by Django 5.0.6 on 2024-05-20 00:49
|
||||
|
||||
import django.db.models.deletion
|
||||
import feeds.models
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Entry',
|
||||
fields=[
|
||||
('id', models.TextField(help_text='The entry id.', primary_key=True, serialize=False)),
|
||||
('updated', models.DateTimeField(help_text='The date the entry was last updated, according to the feed.', null=True)),
|
||||
('title', models.TextField(help_text='The title of the entry.', null=True)),
|
||||
('link', models.TextField(help_text='The URL of the entry.', null=True)),
|
||||
('author', models.TextField(help_text='The author of the feed.', null=True)),
|
||||
('published', models.DateTimeField(help_text='The date the entry was published.', null=True)),
|
||||
('summary', models.TextField(help_text='A summary of the entry.', null=True)),
|
||||
('read', models.BooleanField(default=False, help_text='Whether the entry has been read.')),
|
||||
('read_modified', models.DateTimeField(help_text='When read was last modified, None if that never.', null=True)),
|
||||
('added', models.DateTimeField(help_text='The date when the entry was added (first updated) to reader.', null=True)),
|
||||
('added_by', models.TextField(help_text="The source of the entry. One of 'feed', 'user'.", null=True)),
|
||||
('last_updated', models.DateTimeField(help_text='The date when the entry was last retrieved by reader.', null=True)),
|
||||
('first_updated', models.DateTimeField(help_text='The date when the entry was first retrieved by reader.', null=True)),
|
||||
('first_updated_epoch', models.DateTimeField(help_text='The date when the entry was first retrieved by reader, as an epoch timestamp.', null=True)),
|
||||
('feed_order', models.PositiveIntegerField(help_text='The order of the entry in the feed.', null=True)),
|
||||
('recent_sort', models.PositiveIntegerField(help_text='The order of the entry in the recent list.', null=True)),
|
||||
('sequence', models.BinaryField(help_text='The sequence of the entry in the feed.', null=True)),
|
||||
('original_feed', models.TextField(help_text='The URL of the original feed of the entry. If the feed URL never changed, the same as feed_url.', null=True)),
|
||||
('data_hash', models.TextField(help_text='The hash of the entry data.', null=True)),
|
||||
('data_hash_changed', models.BooleanField(default=False, help_text='Whether the data hash has changed since the last update.')),
|
||||
('important', models.BooleanField(default=False, help_text='Whether the entry is important.')),
|
||||
('important_modified', models.DateTimeField(help_text='When important was last modified, None if that never.', null=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Feed',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('url', models.URLField(help_text='The URL of the feed.', unique=True)),
|
||||
('updated', models.DateTimeField(help_text='The date the feed was last updated, according to the feed.', null=True)),
|
||||
('title', models.TextField(help_text='The title of the feed.', null=True)),
|
||||
('link', models.TextField(help_text='The URL of a page associated with the feed.', null=True)),
|
||||
('author', models.TextField(help_text='The author of the feed.', null=True)),
|
||||
('subtitle', models.TextField(help_text='A description or subtitle for the feed.', null=True)),
|
||||
('version', models.TextField(help_text='The version of the feed.', null=True)),
|
||||
('user_title', models.TextField(help_text='User-defined feed title.', null=True)),
|
||||
('added', models.DateTimeField(auto_now_add=True, help_text='The date when the feed was added.')),
|
||||
('last_updated', models.DateTimeField(help_text='The date when the feed was last retrieved by reader.', null=True)),
|
||||
('last_exception_type_name', models.TextField(help_text='The fully qualified name of the exception type.', null=True)),
|
||||
('last_exception_value', models.TextField(help_text='The exception value.', null=True)),
|
||||
('last_exception_traceback', models.TextField(help_text='The exception traceback.', null=True)),
|
||||
('updates_enabled', models.BooleanField(default=True, help_text='Whether updates are enabled for the feed.')),
|
||||
('stale', models.BooleanField(default=False, help_text='Whether the next update should update all entries, regardless of their hash or updated.')),
|
||||
('http_etag', models.TextField(help_text='The HTTP ETag header.', null=True)),
|
||||
('http_last_modified', models.TextField(help_text='The HTTP Last-Modified header.', null=True)),
|
||||
('data_hash', models.TextField(help_text='The hash of the feed data.', null=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='UploadedFeed',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('file', models.FileField(help_text='The file that was uploaded.', upload_to=feeds.models.get_upload_path)),
|
||||
('original_filename', models.TextField(help_text='The original filename of the file.')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, help_text='The time the file was uploaded.')),
|
||||
('has_been_processed', models.BooleanField(default=False, help_text='Has the file content been added to the archive?')),
|
||||
('public', models.BooleanField(default=False, help_text='Is the file public?')),
|
||||
('description', models.TextField(blank=True, help_text='Description added by user.')),
|
||||
('notes', models.TextField(blank=True, help_text='Notes from admin.')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Uploaded file',
|
||||
'verbose_name_plural': 'Uploaded files',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Enclosure',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('href', models.TextField(help_text='The file URL.')),
|
||||
('type', models.TextField(help_text='The file content type.', null=True)),
|
||||
('length', models.PositiveIntegerField(help_text='The file length.', null=True)),
|
||||
('entry', models.ForeignKey(help_text='The entry this enclosure is for.', on_delete=django.db.models.deletion.CASCADE, related_name='enclosures', to='feeds.entry')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Content',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('value', models.TextField(help_text='The content value.')),
|
||||
('type', models.TextField(help_text='The content type.', null=True)),
|
||||
('language', models.TextField(help_text='The content language.', null=True)),
|
||||
('entry', models.ForeignKey(help_text='The entry this content is for.', on_delete=django.db.models.deletion.CASCADE, related_name='content', to='feeds.entry')),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='entry',
|
||||
name='feed',
|
||||
field=models.ForeignKey(help_text='The feed this entry is from.', on_delete=django.db.models.deletion.CASCADE, related_name='entries', to='feeds.feed'),
|
||||
),
|
||||
]
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
# Generated by Django 5.0.6 on 2024-05-20 01:19
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('feeds', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='feed',
|
||||
name='data_hash',
|
||||
field=models.BinaryField(help_text='The hash of the feed data.', null=True),
|
||||
),
|
||||
]
|
||||
131
feeds/models.py
131
feeds/models.py
|
|
@ -1,131 +0,0 @@
|
|||
"""These models are used to store the data from https://reader.readthedocs.io/en/latest/api.html#reader.Feed."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from django.db import models
|
||||
|
||||
|
||||
def get_upload_path(instance: UploadedFeed, filename: str) -> str:
|
||||
"""Don't save the file with the original filename."""
|
||||
ext: str = Path(filename).suffix
|
||||
unix_time: int = int(instance.created_at.timestamp())
|
||||
filename = f"{unix_time}-{uuid.uuid4().hex}{ext}"
|
||||
return f"uploads/{filename}"
|
||||
|
||||
|
||||
class UploadedFeed(models.Model):
|
||||
"""A file uploaded to the server by a user."""
|
||||
|
||||
file = models.FileField(upload_to=get_upload_path, help_text="The file that was uploaded.")
|
||||
original_filename = models.TextField(help_text="The original filename of the file.")
|
||||
created_at = models.DateTimeField(auto_now_add=True, help_text="The time the file was uploaded.")
|
||||
has_been_processed = models.BooleanField(default=False, help_text="Has the file content been added to the archive?")
|
||||
public = models.BooleanField(default=False, help_text="Is the file public?")
|
||||
description = models.TextField(blank=True, help_text="Description added by user.")
|
||||
notes = models.TextField(blank=True, help_text="Notes from admin.")
|
||||
|
||||
class Meta:
|
||||
"""Meta information for the uploaded file model."""
|
||||
|
||||
ordering: typing.ClassVar[list[str]] = ["-created_at"]
|
||||
verbose_name: str = "Uploaded file"
|
||||
verbose_name_plural: str = "Uploaded files"
|
||||
|
||||
def __str__(self: UploadedFeed) -> str:
|
||||
return f"{self.original_filename} - {self.created_at}"
|
||||
|
||||
|
||||
class Feed(models.Model):
|
||||
url = models.URLField(unique=True, help_text="The URL of the feed.")
|
||||
updated = models.DateTimeField(help_text="The date the feed was last updated, according to the feed.", null=True)
|
||||
title = models.TextField(help_text="The title of the feed.", null=True)
|
||||
link = models.TextField(help_text="The URL of a page associated with the feed.", null=True)
|
||||
author = models.TextField(help_text="The author of the feed.", null=True)
|
||||
subtitle = models.TextField(help_text="A description or subtitle for the feed.", null=True)
|
||||
version = models.TextField(help_text="The version of the feed.", null=True)
|
||||
user_title = models.TextField(help_text="User-defined feed title.", null=True)
|
||||
added = models.DateTimeField(help_text="The date when the feed was added.", auto_now_add=True)
|
||||
last_updated = models.DateTimeField(help_text="The date when the feed was last retrieved by reader.", null=True)
|
||||
last_exception_type_name = models.TextField(help_text="The fully qualified name of the exception type.", null=True)
|
||||
last_exception_value = models.TextField(help_text="The exception value.", null=True)
|
||||
last_exception_traceback = models.TextField(help_text="The exception traceback.", null=True)
|
||||
updates_enabled = models.BooleanField(help_text="Whether updates are enabled for the feed.", default=True)
|
||||
stale = models.BooleanField(
|
||||
help_text="Whether the next update should update all entries, regardless of their hash or updated.",
|
||||
default=False,
|
||||
)
|
||||
http_etag = models.TextField(help_text="The HTTP ETag header.", null=True)
|
||||
http_last_modified = models.TextField(help_text="The HTTP Last-Modified header.", null=True)
|
||||
data_hash = models.BinaryField(help_text="The hash of the feed data.", null=True)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.title} ({self.url})" if self.title else self.url
|
||||
|
||||
|
||||
class Entry(models.Model):
|
||||
feed = models.ForeignKey(
|
||||
Feed, on_delete=models.CASCADE, help_text="The feed this entry is from.", related_name="entries"
|
||||
)
|
||||
id = models.TextField(primary_key=True, help_text="The entry id.")
|
||||
updated = models.DateTimeField(help_text="The date the entry was last updated, according to the feed.", null=True)
|
||||
title = models.TextField(help_text="The title of the entry.", null=True)
|
||||
link = models.TextField(help_text="The URL of the entry.", null=True)
|
||||
author = models.TextField(help_text="The author of the feed.", null=True)
|
||||
published = models.DateTimeField(help_text="The date the entry was published.", null=True)
|
||||
summary = models.TextField(help_text="A summary of the entry.", null=True)
|
||||
read = models.BooleanField(help_text="Whether the entry has been read.", default=False)
|
||||
read_modified = models.DateTimeField(help_text="When read was last modified, None if that never.", null=True)
|
||||
added = models.DateTimeField(help_text="The date when the entry was added (first updated) to reader.", null=True)
|
||||
added_by = models.TextField(help_text="The source of the entry. One of 'feed', 'user'.", null=True)
|
||||
last_updated = models.DateTimeField(help_text="The date when the entry was last retrieved by reader.", null=True)
|
||||
first_updated = models.DateTimeField(help_text="The date when the entry was first retrieved by reader.", null=True)
|
||||
first_updated_epoch = models.DateTimeField(
|
||||
help_text="The date when the entry was first retrieved by reader, as an epoch timestamp.", null=True
|
||||
)
|
||||
feed_order = models.PositiveIntegerField(help_text="The order of the entry in the feed.", null=True)
|
||||
recent_sort = models.PositiveIntegerField(help_text="The order of the entry in the recent list.", null=True)
|
||||
sequence = models.BinaryField(help_text="The sequence of the entry in the feed.", null=True)
|
||||
original_feed = models.TextField(
|
||||
help_text="The URL of the original feed of the entry. If the feed URL never changed, the same as feed_url.",
|
||||
null=True,
|
||||
)
|
||||
data_hash = models.TextField(help_text="The hash of the entry data.", null=True)
|
||||
data_hash_changed = models.BooleanField(
|
||||
help_text="Whether the data hash has changed since the last update.", default=False
|
||||
)
|
||||
important = models.BooleanField(help_text="Whether the entry is important.", default=False)
|
||||
important_modified = models.DateTimeField(
|
||||
help_text="When important was last modified, None if that never.", null=True
|
||||
)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.title} ({self.link})" if self.title and self.link else self.id
|
||||
|
||||
|
||||
class Content(models.Model):
|
||||
entry = models.ForeignKey(
|
||||
Entry, on_delete=models.CASCADE, help_text="The entry this content is for.", related_name="content"
|
||||
)
|
||||
value = models.TextField(help_text="The content value.")
|
||||
type = models.TextField(help_text="The content type.", null=True)
|
||||
language = models.TextField(help_text="The content language.", null=True)
|
||||
|
||||
def __str__(self) -> str:
|
||||
max_length = 50
|
||||
return self.value[:max_length] + "..." if len(self.value) > max_length else self.value
|
||||
|
||||
|
||||
class Enclosure(models.Model):
|
||||
entry = models.ForeignKey(
|
||||
Entry, on_delete=models.CASCADE, help_text="The entry this enclosure is for.", related_name="enclosures"
|
||||
)
|
||||
href = models.TextField(help_text="The file URL.")
|
||||
type = models.TextField(help_text="The file content type.", null=True)
|
||||
length = models.PositiveIntegerField(help_text="The file length.", null=True)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.href
|
||||
|
|
@ -1 +0,0 @@
|
|||
# Create your tests here.
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from django.contrib.sitemaps import GenericSitemap
|
||||
from django.urls import include, path
|
||||
|
||||
from feedvault.sitemaps import StaticViewSitemap
|
||||
|
||||
from .models import Feed
|
||||
from .views import AddView, FeedsView, FeedView, IndexView, SearchView, UploadView
|
||||
|
||||
app_name: str = "feeds"
|
||||
|
||||
sitemaps = {
|
||||
"static": StaticViewSitemap,
|
||||
"feeds": GenericSitemap({"queryset": Feed.objects.all(), "date_field": "created_at"}),
|
||||
}
|
||||
|
||||
urlpatterns: list = [
|
||||
path(route="", view=IndexView.as_view(), name="index"),
|
||||
path("__debug__/", include("debug_toolbar.urls")),
|
||||
path(route="feed/<int:feed_id>/", view=FeedView.as_view(), name="feed"),
|
||||
path(route="feeds/", view=FeedsView.as_view(), name="feeds"),
|
||||
path(route="add/", view=AddView.as_view(), name="add"),
|
||||
path(route="upload/", view=UploadView.as_view(), name="upload"),
|
||||
path(route="search/", view=SearchView.as_view(), name="search"),
|
||||
]
|
||||
175
feeds/views.py
175
feeds/views.py
|
|
@ -1,175 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from django.contrib import messages
|
||||
from django.core.paginator import EmptyPage, Page, Paginator
|
||||
from django.db.models.manager import BaseManager
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.template import loader
|
||||
from django.views import View
|
||||
from reader import InvalidFeedURLError
|
||||
|
||||
from feeds.get_reader import get_reader
|
||||
from feeds.models import Entry, Feed, UploadedFeed
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from django.core.files.uploadedfile import UploadedFile
|
||||
from django.db.models.manager import BaseManager
|
||||
from reader import Reader
|
||||
|
||||
logger: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HtmxHttpRequest(HttpRequest):
|
||||
htmx: Any
|
||||
|
||||
|
||||
class IndexView(View):
|
||||
"""Index path."""
|
||||
|
||||
def get(self, request: HttpRequest) -> HttpResponse:
|
||||
"""Load the index page."""
|
||||
template = loader.get_template(template_name="index.html")
|
||||
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",
|
||||
"canonical": "https://feedvault.se/",
|
||||
"title": "FeedVault",
|
||||
}
|
||||
return HttpResponse(content=template.render(context=context, request=request))
|
||||
|
||||
|
||||
class FeedView(View):
|
||||
"""A single feed."""
|
||||
|
||||
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: # noqa: ANN002, ANN003
|
||||
"""Load the feed page."""
|
||||
feed_id: str = kwargs.get("feed_id", None)
|
||||
if not feed_id:
|
||||
return HttpResponse(content="No id", status=400)
|
||||
|
||||
feed: Feed = get_object_or_404(Feed, pk=feed_id)
|
||||
entries: BaseManager[Entry] = Entry.objects.filter(feed=feed).order_by("-added")[:100]
|
||||
|
||||
context: dict[str, Any] = {
|
||||
"feed": feed,
|
||||
"entries": entries,
|
||||
"description": f"{feed.subtitle}" or f"Archive of {feed.url}",
|
||||
"keywords": "feed, rss, atom, archive, rss list",
|
||||
"author": f"{feed.author}" or "FeedVault",
|
||||
"canonical": f"https://feedvault.se/feed/{feed.pk}/",
|
||||
"title": f"{feed.title}" or "FeedVault",
|
||||
}
|
||||
|
||||
return render(request=request, template_name="feed.html", context=context)
|
||||
|
||||
|
||||
class FeedsView(View):
|
||||
"""All feeds."""
|
||||
|
||||
def get(self, request: HtmxHttpRequest) -> HttpResponse:
|
||||
"""All feeds."""
|
||||
feeds: BaseManager[Feed] = Feed.objects.only("id", "url")
|
||||
|
||||
paginator = Paginator(object_list=feeds, per_page=100)
|
||||
page_number = int(request.GET.get("page", default=1))
|
||||
|
||||
try:
|
||||
pages: Page = paginator.get_page(page_number)
|
||||
except EmptyPage:
|
||||
return HttpResponse("")
|
||||
|
||||
context: dict[str, str | Page | int] = {
|
||||
"feeds": pages,
|
||||
"description": "An archive of web feeds",
|
||||
"keywords": "feed, rss, atom, archive, rss list",
|
||||
"author": "TheLovinator",
|
||||
"canonical": "https://feedvault.se/feeds/",
|
||||
"title": "Feeds",
|
||||
"page": page_number,
|
||||
}
|
||||
|
||||
template_name = "partials/feeds.html" if request.htmx else "feeds.html"
|
||||
return render(request, template_name, context)
|
||||
|
||||
|
||||
class AddView(View):
|
||||
"""Add a feed."""
|
||||
|
||||
def get(self, request: HttpRequest) -> HttpResponse:
|
||||
"""Load the index page."""
|
||||
template = loader.get_template(template_name="index.html")
|
||||
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",
|
||||
"canonical": "https://feedvault.se/",
|
||||
}
|
||||
return HttpResponse(content=template.render(context=context, request=request))
|
||||
|
||||
def post(self, request: HttpRequest) -> HttpResponse:
|
||||
"""Add a feed."""
|
||||
urls: str | None = request.POST.get("urls", None)
|
||||
if not urls:
|
||||
return HttpResponse(content="No urls", status=400)
|
||||
|
||||
reader: Reader = get_reader()
|
||||
for url in urls.split("\n"):
|
||||
clean_url: str = url.strip()
|
||||
try:
|
||||
reader.add_feed(clean_url)
|
||||
messages.success(request, f"Added {clean_url}")
|
||||
except InvalidFeedURLError:
|
||||
logger.exception("Error adding %s", clean_url)
|
||||
messages.error(request, f"Error adding {clean_url}")
|
||||
|
||||
messages.success(request, "Feeds added")
|
||||
return redirect("feeds:index")
|
||||
|
||||
|
||||
class UploadView(View):
|
||||
"""Upload a file."""
|
||||
|
||||
def post(self, request: HttpRequest) -> HttpResponse:
|
||||
"""Upload a file."""
|
||||
file: UploadedFile | None = request.FILES.get("file", None)
|
||||
if not file:
|
||||
return HttpResponse(content="No file", status=400)
|
||||
|
||||
# Save file to media folder
|
||||
UploadedFeed.objects.create(user=request.user, file=file, original_filename=file.name)
|
||||
|
||||
# Render the index page.
|
||||
messages.success(request, f"{file.name} uploaded")
|
||||
messages.info(request, "If the file was marked as public, it will be shown on the feeds page. ")
|
||||
return redirect("feeds:index")
|
||||
|
||||
|
||||
class SearchView(View):
|
||||
"""Search view."""
|
||||
|
||||
def get(self, request: HtmxHttpRequest) -> HttpResponse:
|
||||
"""Load the search page."""
|
||||
query: str | None = request.GET.get("q", None)
|
||||
if not query:
|
||||
return FeedsView().get(request)
|
||||
|
||||
# TODO(TheLovinator): #20 Search more fields
|
||||
# https://github.com/TheLovinator1/FeedVault/issues/20
|
||||
feeds: BaseManager[Feed] = Feed.objects.filter(url__icontains=query).order_by("-added")[:100]
|
||||
|
||||
context = {
|
||||
"feeds": feeds,
|
||||
"description": f"Search results for {query}",
|
||||
"keywords": f"feed, rss, atom, archive, rss list, {query}",
|
||||
"author": "TheLovinator",
|
||||
"canonical": f"https://feedvault.se/search/?q={query}",
|
||||
"title": f"Search results for {query}",
|
||||
"query": query,
|
||||
}
|
||||
|
||||
return render(request, "search.html", context)
|
||||
Loading…
Add table
Add a link
Reference in a new issue