Improve uploading/downloading files
This commit is contained in:
parent
5ca163d35a
commit
7005490bf4
18 changed files with 4596 additions and 134 deletions
|
|
@ -9,7 +9,7 @@ import feedparser
|
|||
from django.utils import timezone
|
||||
from feedparser import FeedParserDict
|
||||
|
||||
from feedvault.models import Author, Domain, Entry, Feed, Generator, Publisher
|
||||
from feedvault.models import Author, Domain, Entry, Feed, FeedAddResult, Generator, Publisher
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import datetime
|
||||
|
|
@ -171,59 +171,58 @@ def add_entry(feed: Feed, entry: FeedParserDict) -> Entry | None:
|
|||
dateparser.parse(date_string=str(pre_created_parsed)) if pre_created_parsed else None
|
||||
)
|
||||
|
||||
_entry = Entry(
|
||||
entry_id = entry.get("id", "")
|
||||
if not entry_id:
|
||||
logger.error("Entry ID not found: %s", entry)
|
||||
|
||||
added_entry, created = Entry.objects.update_or_create(
|
||||
feed=feed,
|
||||
author=entry.get("author", ""),
|
||||
author_detail=author,
|
||||
comments=entry.get("comments", ""),
|
||||
content=entry.get("content", {}),
|
||||
contributors=entry.get("contributors", {}),
|
||||
created=entry.get("created", ""),
|
||||
created_parsed=created_parsed,
|
||||
enclosures=entry.get("enclosures", []),
|
||||
expired=entry.get("expired", ""),
|
||||
expired_parsed=expired_parsed,
|
||||
_id=entry.get("id", ""),
|
||||
license=entry.get("license", ""),
|
||||
link=entry.get("link", ""),
|
||||
links=entry.get("links", []),
|
||||
published=entry.get("published", ""),
|
||||
published_parsed=published_parsed,
|
||||
publisher=entry.get("publisher", ""),
|
||||
publisher_detail=publisher,
|
||||
source=entry.get("source", {}),
|
||||
summary=entry.get("summary", ""),
|
||||
summary_detail=entry.get("summary_detail", {}),
|
||||
tags=entry.get("tags", []),
|
||||
title=entry.get("title", ""),
|
||||
title_detail=entry.get("title_detail", {}),
|
||||
updated=entry.get("updated", ""),
|
||||
updated_parsed=updated_parsed,
|
||||
entry_id=entry_id,
|
||||
defaults={
|
||||
"author": entry.get("author", ""),
|
||||
"author_detail": author,
|
||||
"comments": entry.get("comments", ""),
|
||||
"content": entry.get("content", {}),
|
||||
"contributors": entry.get("contributors", {}),
|
||||
"created": entry.get("created", ""),
|
||||
"created_parsed": created_parsed,
|
||||
"enclosures": entry.get("enclosures", []),
|
||||
"expired": entry.get("expired", ""),
|
||||
"expired_parsed": expired_parsed,
|
||||
"license": entry.get("license", ""),
|
||||
"link": entry.get("link", ""),
|
||||
"links": entry.get("links", []),
|
||||
"published": entry.get("published", ""),
|
||||
"published_parsed": published_parsed,
|
||||
"publisher": entry.get("publisher", ""),
|
||||
"publisher_detail": publisher,
|
||||
"source": entry.get("source", {}),
|
||||
"summary": entry.get("summary", ""),
|
||||
"summary_detail": entry.get("summary_detail", {}),
|
||||
"tags": entry.get("tags", []),
|
||||
"title": entry.get("title", ""),
|
||||
"title_detail": entry.get("title_detail", {}),
|
||||
"updated": entry.get("updated", ""),
|
||||
"updated_parsed": updated_parsed,
|
||||
},
|
||||
)
|
||||
if created:
|
||||
logger.info("Created entry: %s", added_entry)
|
||||
return added_entry
|
||||
|
||||
# Save the entry.
|
||||
_entry.save()
|
||||
|
||||
logger.info("Created entry: %s", _entry)
|
||||
|
||||
return _entry
|
||||
logger.info("Updated entry: %s", added_entry)
|
||||
return added_entry
|
||||
|
||||
|
||||
def add_feed(url: str | None, user: AbstractBaseUser | AnonymousUser) -> Feed | None: # noqa: PLR0914
|
||||
"""Add a feed to the database.
|
||||
def add_domain_to_db(url: str | None) -> Domain | None:
|
||||
"""Add a domain to the database.
|
||||
|
||||
Args:
|
||||
url: The URL of the feed.
|
||||
user: The user adding the feed.
|
||||
url: The URL of the domain.
|
||||
|
||||
Returns:
|
||||
The feed that was added.
|
||||
The domain that was added.
|
||||
"""
|
||||
# Parse the feed.
|
||||
parsed_feed: dict | None = parse_feed(url=url)
|
||||
if not parsed_feed:
|
||||
return None
|
||||
|
||||
domain_url: None | str = get_domain(url=url)
|
||||
if not domain_url:
|
||||
return None
|
||||
|
|
@ -235,6 +234,28 @@ def add_feed(url: str | None, user: AbstractBaseUser | AnonymousUser) -> Feed |
|
|||
logger.info("Created domain: %s", domain.url)
|
||||
domain.save()
|
||||
|
||||
return domain
|
||||
|
||||
|
||||
def populate_feed(url: str | None, user: AbstractBaseUser | AnonymousUser) -> Feed | None:
|
||||
"""Populate the feed with entries.
|
||||
|
||||
Args:
|
||||
url: The URL of the feed.
|
||||
user: The user adding the feed.
|
||||
|
||||
Returns:
|
||||
The feed that was added.
|
||||
"""
|
||||
domain: Domain | None = add_domain_to_db(url=url)
|
||||
if not domain:
|
||||
return None
|
||||
|
||||
# Parse the feed.
|
||||
parsed_feed: dict | None = parse_feed(url=url)
|
||||
if not parsed_feed:
|
||||
return None
|
||||
|
||||
author: Author = get_author(parsed_feed=parsed_feed)
|
||||
generator: Generator = def_generator(parsed_feed=parsed_feed)
|
||||
publisher: Publisher = get_publisher(parsed_feed=parsed_feed)
|
||||
|
|
@ -252,58 +273,78 @@ def add_feed(url: str | None, user: AbstractBaseUser | AnonymousUser) -> Feed |
|
|||
pre_modified: str = str(parsed_feed.get("modified", ""))
|
||||
modified: timezone.datetime | None = dateparser.parse(date_string=pre_modified) if pre_modified else None
|
||||
|
||||
# Create the feed
|
||||
feed = Feed(
|
||||
# Create or update the feed
|
||||
feed, created = Feed.objects.update_or_create(
|
||||
feed_url=url,
|
||||
user=user,
|
||||
domain=domain,
|
||||
last_checked=timezone.now(),
|
||||
bozo=parsed_feed.get("bozo", 0),
|
||||
bozo_exception=parsed_feed.get("bozo_exception", ""),
|
||||
encoding=parsed_feed.get("encoding", ""),
|
||||
etag=parsed_feed.get("etag", ""),
|
||||
headers=parsed_feed.get("headers", {}),
|
||||
href=parsed_feed.get("href", ""),
|
||||
modified=modified,
|
||||
namespaces=parsed_feed.get("namespaces", {}),
|
||||
status=parsed_feed.get("status", 0),
|
||||
version=parsed_feed.get("version", ""),
|
||||
author=parsed_feed.get("author", ""),
|
||||
author_detail=author,
|
||||
cloud=parsed_feed.get("cloud", {}),
|
||||
contributors=parsed_feed.get("contributors", {}),
|
||||
docs=parsed_feed.get("docs", ""),
|
||||
errorreportsto=parsed_feed.get("errorreportsto", ""),
|
||||
generator=parsed_feed.get("generator", ""),
|
||||
generator_detail=generator,
|
||||
icon=parsed_feed.get("icon", ""),
|
||||
_id=parsed_feed.get("id", ""),
|
||||
image=parsed_feed.get("image", {}),
|
||||
info=parsed_feed.get("info", ""),
|
||||
language=parsed_feed.get("language", ""),
|
||||
license=parsed_feed.get("license", ""),
|
||||
link=parsed_feed.get("link", ""),
|
||||
links=parsed_feed.get("links", []),
|
||||
logo=parsed_feed.get("logo", ""),
|
||||
published=parsed_feed.get("published", ""),
|
||||
published_parsed=published_parsed,
|
||||
publisher=parsed_feed.get("publisher", ""),
|
||||
publisher_detail=publisher,
|
||||
rights=parsed_feed.get("rights", ""),
|
||||
rights_detail=parsed_feed.get("rights_detail", {}),
|
||||
subtitle=parsed_feed.get("subtitle", ""),
|
||||
subtitle_detail=parsed_feed.get("subtitle_detail", {}),
|
||||
tags=parsed_feed.get("tags", []),
|
||||
textinput=parsed_feed.get("textinput", {}),
|
||||
title=parsed_feed.get("title", ""),
|
||||
title_detail=parsed_feed.get("title_detail", {}),
|
||||
ttl=parsed_feed.get("ttl", ""),
|
||||
updated=parsed_feed.get("updated", ""),
|
||||
updated_parsed=updated_parsed,
|
||||
defaults={
|
||||
"user": user,
|
||||
"last_checked": timezone.now(),
|
||||
"bozo": parsed_feed.get("bozo", 0),
|
||||
"bozo_exception": parsed_feed.get("bozo_exception", ""),
|
||||
"encoding": parsed_feed.get("encoding", ""),
|
||||
"etag": parsed_feed.get("etag", ""),
|
||||
"headers": parsed_feed.get("headers", {}),
|
||||
"href": parsed_feed.get("href", ""),
|
||||
"modified": modified,
|
||||
"namespaces": parsed_feed.get("namespaces", {}),
|
||||
"status": parsed_feed.get("status", 0),
|
||||
"version": parsed_feed.get("version", ""),
|
||||
"author": parsed_feed.get("author", ""),
|
||||
"author_detail": author,
|
||||
"cloud": parsed_feed.get("cloud", {}),
|
||||
"contributors": parsed_feed.get("contributors", {}),
|
||||
"docs": parsed_feed.get("docs", ""),
|
||||
"errorreportsto": parsed_feed.get("errorreportsto", ""),
|
||||
"generator": parsed_feed.get("generator", ""),
|
||||
"generator_detail": generator,
|
||||
"icon": parsed_feed.get("icon", ""),
|
||||
"feed_id": parsed_feed.get("id", ""),
|
||||
"image": parsed_feed.get("image", {}),
|
||||
"info": parsed_feed.get("info", ""),
|
||||
"language": parsed_feed.get("language", ""),
|
||||
"license": parsed_feed.get("license", ""),
|
||||
"link": parsed_feed.get("link", ""),
|
||||
"links": parsed_feed.get("links", []),
|
||||
"logo": parsed_feed.get("logo", ""),
|
||||
"published": parsed_feed.get("published", ""),
|
||||
"published_parsed": published_parsed,
|
||||
"publisher": parsed_feed.get("publisher", ""),
|
||||
"publisher_detail": publisher,
|
||||
"rights": parsed_feed.get("rights", ""),
|
||||
"rights_detail": parsed_feed.get("rights_detail", {}),
|
||||
"subtitle": parsed_feed.get("subtitle", ""),
|
||||
"subtitle_detail": parsed_feed.get("subtitle_detail", {}),
|
||||
"tags": parsed_feed.get("tags", []),
|
||||
"textinput": parsed_feed.get("textinput", {}),
|
||||
"title": parsed_feed.get("title", ""),
|
||||
"title_detail": parsed_feed.get("title_detail", {}),
|
||||
"ttl": parsed_feed.get("ttl", ""),
|
||||
"updated": parsed_feed.get("updated", ""),
|
||||
"updated_parsed": updated_parsed,
|
||||
},
|
||||
)
|
||||
|
||||
# Save the feed.
|
||||
feed.save()
|
||||
grab_entries(feed=feed)
|
||||
|
||||
if created:
|
||||
logger.info("Created feed: %s", feed)
|
||||
return feed
|
||||
|
||||
logger.info("Updated feed: %s", feed)
|
||||
return feed
|
||||
|
||||
|
||||
def grab_entries(feed: Feed) -> None:
|
||||
"""Grab the entries from a feed.
|
||||
|
||||
Args:
|
||||
feed: The feed to grab the entries from.
|
||||
"""
|
||||
# Parse the feed.
|
||||
parsed_feed: dict | None = parse_feed(url=feed.feed_url)
|
||||
if not parsed_feed:
|
||||
return
|
||||
|
||||
entries = parsed_feed.get("entries", [])
|
||||
for entry in entries:
|
||||
|
|
@ -311,5 +352,16 @@ def add_feed(url: str | None, user: AbstractBaseUser | AnonymousUser) -> Feed |
|
|||
if not added_entry:
|
||||
continue
|
||||
|
||||
logger.info("Created feed: %s", feed)
|
||||
return feed
|
||||
logger.info("Grabbed entries for feed: %s", feed)
|
||||
return
|
||||
|
||||
|
||||
def add_url(url: str, user: AbstractBaseUser | AnonymousUser) -> FeedAddResult:
|
||||
"""Add a feed to the database so we can grab entries from it later."""
|
||||
domain: Domain | None = add_domain_to_db(url=url)
|
||||
if not domain:
|
||||
return FeedAddResult(feed=None, created=False, error="Domain not found")
|
||||
|
||||
# Add the URL to the database.
|
||||
_feed, _created = Feed.objects.get_or_create(feed_url=url, user=user, domain=domain)
|
||||
return FeedAddResult(feed=_feed, created=_created, error=None)
|
||||
|
|
|
|||
18
feedvault/migrations/0004_alter_feed_bozo.py
Normal file
18
feedvault/migrations/0004_alter_feed_bozo.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 5.0.3 on 2024-03-17 02:49
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('feedvault', '0003_rename__id_entry_entry_id_rename__id_feed_feed_id'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='feed',
|
||||
name='bozo',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
32
feedvault/migrations/0005_useruploadedfile.py
Normal file
32
feedvault/migrations/0005_useruploadedfile.py
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
# Generated by Django 5.0.3 on 2024-03-17 03:19
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('feedvault', '0004_alter_feed_bozo'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='UserUploadedFile',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('file', models.FileField(upload_to='uploads/')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('modified_at', models.DateTimeField(auto_now=True)),
|
||||
('has_been_processed', models.BooleanField(default=False)),
|
||||
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Uploaded file',
|
||||
'verbose_name_plural': 'Uploaded files',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
# Generated by Django 5.0.3 on 2024-03-17 03:29
|
||||
|
||||
import feedvault.models
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('feedvault', '0005_useruploadedfile'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='useruploadedfile',
|
||||
name='original_filename',
|
||||
field=models.TextField(default='a', help_text='The original filename of the file.'),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='useruploadedfile',
|
||||
name='file',
|
||||
field=models.FileField(upload_to=feedvault.models.get_upload_path),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
# Generated by Django 5.0.3 on 2024-03-17 16:00
|
||||
|
||||
import django.db.models.deletion
|
||||
import feedvault.models
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('feedvault', '0006_useruploadedfile_original_filename_and_more'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='useruploadedfile',
|
||||
name='description',
|
||||
field=models.TextField(blank=True, help_text='Description added by user.'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='useruploadedfile',
|
||||
name='notes',
|
||||
field=models.TextField(blank=True, help_text='Notes from admin.'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='useruploadedfile',
|
||||
name='created_at',
|
||||
field=models.DateTimeField(auto_now_add=True, help_text='The time the file was uploaded.'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='useruploadedfile',
|
||||
name='file',
|
||||
field=models.FileField(help_text='The file that was uploaded.', upload_to=feedvault.models.get_upload_path),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='useruploadedfile',
|
||||
name='has_been_processed',
|
||||
field=models.BooleanField(default=False, help_text='Has the file content been added to the archive?'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='useruploadedfile',
|
||||
name='modified_at',
|
||||
field=models.DateTimeField(auto_now=True, help_text='The last time the file was modified.'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='useruploadedfile',
|
||||
name='user',
|
||||
field=models.ForeignKey(blank=True, help_text='The user that uploaded the file.', null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
]
|
||||
|
|
@ -2,6 +2,9 @@ from __future__ import annotations
|
|||
|
||||
import logging
|
||||
import typing
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from django.db import models
|
||||
|
|
@ -10,6 +13,15 @@ from django.db.models import JSONField
|
|||
logger: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FeedAddResult:
|
||||
"""The result of adding a feed to the database."""
|
||||
|
||||
feed: Feed | None
|
||||
created: bool
|
||||
error: str | None
|
||||
|
||||
|
||||
class Domain(models.Model):
|
||||
"""A domain that has one or more feeds."""
|
||||
|
||||
|
|
@ -142,7 +154,7 @@ class Feed(models.Model):
|
|||
active = models.BooleanField(default=True)
|
||||
|
||||
# General data
|
||||
bozo = models.BooleanField()
|
||||
bozo = models.BooleanField(default=False)
|
||||
bozo_exception = models.TextField(blank=True)
|
||||
encoding = models.TextField(blank=True)
|
||||
etag = models.TextField(blank=True)
|
||||
|
|
@ -282,3 +294,46 @@ class Entry(models.Model):
|
|||
def __str__(self) -> str:
|
||||
"""Return string representation of the entry."""
|
||||
return f"{self.feed} - {self.title}"
|
||||
|
||||
|
||||
def get_upload_path(instance: UserUploadedFile, filename: str) -> str:
|
||||
"""Don't save the file with the original filename."""
|
||||
ext: str = Path(filename).suffix
|
||||
filename = f"{uuid.uuid4()}{ext}" # For example: 51dc07a7-a299-473c-a737-1ef16bc71609.opml
|
||||
return f"uploads/{instance.user.id}/{filename}" # type: ignore # noqa: PGH003
|
||||
|
||||
|
||||
class UserUploadedFile(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.")
|
||||
modified_at = models.DateTimeField(auto_now=True, help_text="The last time the file was modified.")
|
||||
user = models.ForeignKey(
|
||||
"auth.User",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="The user that uploaded the file.",
|
||||
)
|
||||
has_been_processed = models.BooleanField(default=False, help_text="Has the file content been added to the archive?")
|
||||
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) -> str:
|
||||
return f"{self.original_filename} - {self.created_at}"
|
||||
|
||||
def get_absolute_url(self) -> str:
|
||||
"""Return the absolute URL of the uploaded file.
|
||||
|
||||
Note that you will need to be logged in to access the file.
|
||||
"""
|
||||
return f"/download/{self.pk}"
|
||||
|
|
|
|||
|
|
@ -42,13 +42,14 @@ ROOT_URLCONF = "feedvault.urls"
|
|||
WSGI_APPLICATION = "feedvault.wsgi.application"
|
||||
NINJA_PAGINATION_PER_PAGE = 1000
|
||||
STATIC_URL = "static/"
|
||||
STATIC_ROOT = BASE_DIR / "static"
|
||||
STATIC_ROOT: Path = BASE_DIR / "static"
|
||||
STATIC_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
MEDIA_URL = "media/"
|
||||
MEDIA_ROOT = BASE_DIR / "media"
|
||||
MEDIA_ROOT: Path = BASE_DIR / "media"
|
||||
MEDIA_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
LOGIN_REDIRECT_URL = "/"
|
||||
LOGOUT_REDIRECT_URL = "/"
|
||||
|
||||
|
||||
# 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"
|
||||
|
||||
|
|
@ -56,6 +57,7 @@ INSTALLED_APPS: list[str] = [
|
|||
"feedvault.apps.FeedVaultConfig",
|
||||
"debug_toolbar",
|
||||
"django.contrib.auth",
|
||||
"whitenoise.runserver_nostatic",
|
||||
"django.contrib.staticfiles",
|
||||
"django.contrib.contenttypes",
|
||||
"django.contrib.sessions",
|
||||
|
|
@ -66,6 +68,7 @@ INSTALLED_APPS: list[str] = [
|
|||
MIDDLEWARE: list[str] = [
|
||||
"debug_toolbar.middleware.DebugToolbarMiddleware",
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"whitenoise.middleware.WhiteNoiseMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
|
|
@ -169,3 +172,12 @@ LOGGING = {
|
|||
},
|
||||
},
|
||||
}
|
||||
|
||||
STORAGES: dict[str, dict[str, str]] = {
|
||||
"default": {
|
||||
"BACKEND": "django.core.files.storage.FileSystemStorage",
|
||||
},
|
||||
"staticfiles": {
|
||||
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,8 +24,11 @@ urlpatterns: list = [
|
|||
path("__debug__/", include("debug_toolbar.urls")),
|
||||
path(route="feed/<int:feed_id>/", view=views.FeedView.as_view(), name="feed"),
|
||||
path(route="feeds/", view=views.FeedsView.as_view(), name="feeds"),
|
||||
path(route="add", view=views.AddView.as_view(), name="add"),
|
||||
path(route="upload", view=views.UploadView.as_view(), name="upload"),
|
||||
path(route="add/", view=views.AddView.as_view(), name="add"),
|
||||
path(route="upload/", view=views.UploadView.as_view(), name="upload"),
|
||||
path(route="download/", view=views.DownloadView.as_view(), name="download"),
|
||||
path(route="delete_upload/", view=views.DeleteUploadView.as_view(), name="delete_upload"),
|
||||
path(route="edit_description/", view=views.EditDescriptionView.as_view(), name="edit_description"),
|
||||
path(route="robots.txt", view=cache_page(timeout=60 * 60 * 365)(views.RobotsView.as_view()), name="robots"),
|
||||
path(
|
||||
"sitemap.xml",
|
||||
|
|
|
|||
|
|
@ -1,14 +1,20 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from mimetypes import guess_type
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth import login
|
||||
from django.contrib.auth.forms import AuthenticationForm, UserCreationForm
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.contrib.auth.views import LoginView, LogoutView, PasswordChangeView
|
||||
from django.contrib.messages.views import SuccessMessageMixin
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
from django.db.models.manager import BaseManager
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.http import FileResponse, Http404, HttpRequest, HttpResponse
|
||||
from django.shortcuts import get_object_or_404, render
|
||||
from django.template import loader
|
||||
from django.urls import reverse_lazy
|
||||
|
|
@ -16,14 +22,16 @@ from django.views import View
|
|||
from django.views.generic.edit import CreateView
|
||||
from django.views.generic.list import ListView
|
||||
|
||||
from feedvault.add_feeds import add_feed
|
||||
from feedvault.models import Domain, Entry, Feed
|
||||
from feedvault.add_feeds import add_url
|
||||
from feedvault.models import Domain, Entry, Feed, FeedAddResult, UserUploadedFile
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.files.uploadedfile import UploadedFile
|
||||
from django.db.models.manager import BaseManager
|
||||
|
||||
logger: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class IndexView(View):
|
||||
"""Index path."""
|
||||
|
|
@ -86,7 +94,7 @@ class FeedsView(ListView):
|
|||
return context
|
||||
|
||||
|
||||
class AddView(View):
|
||||
class AddView(LoginRequiredMixin, View):
|
||||
"""Add a feed."""
|
||||
|
||||
def get(self, request: HttpRequest) -> HttpResponse:
|
||||
|
|
@ -114,22 +122,22 @@ class AddView(View):
|
|||
|
||||
# Split the urls by newline.
|
||||
for url in urls.split("\n"):
|
||||
feed: None | Feed = add_feed(url, request.user)
|
||||
if not feed:
|
||||
messages.error(request, f"{url} - Failed to add")
|
||||
feed_result: FeedAddResult = add_url(url, request.user)
|
||||
feed: Feed | None = feed_result.feed
|
||||
if not feed_result or not feed:
|
||||
messages.error(request, f"{url} - Failed to add, {feed_result.error}")
|
||||
continue
|
||||
# Check if bozo is true.
|
||||
if feed.bozo:
|
||||
messages.warning(request, f"{feed.feed_url} - Bozo: {feed.bozo_exception}")
|
||||
|
||||
messages.success(request, f"{feed.feed_url} added")
|
||||
if feed_result.created:
|
||||
messages.success(request, f"{feed.feed_url} added to queue")
|
||||
else:
|
||||
messages.warning(request, f"{feed.feed_url} already exists")
|
||||
|
||||
# Render the index page.
|
||||
template = loader.get_template(template_name="index.html")
|
||||
return HttpResponse(content=template.render(context={}, request=request))
|
||||
|
||||
|
||||
class UploadView(View):
|
||||
class UploadView(LoginRequiredMixin, View):
|
||||
"""Upload a file."""
|
||||
|
||||
def get(self, request: HttpRequest) -> HttpResponse:
|
||||
|
|
@ -155,23 +163,140 @@ class UploadView(View):
|
|||
if not file:
|
||||
return HttpResponse(content="No file", status=400)
|
||||
|
||||
# Split the urls by newline.
|
||||
for url in file.read().decode("utf-8").split("\n"):
|
||||
feed: None | Feed = add_feed(url, request.user)
|
||||
if not feed:
|
||||
messages.error(request, f"{url} - Failed to add")
|
||||
continue
|
||||
# Check if bozo is true.
|
||||
if feed.bozo:
|
||||
messages.warning(request, f"{feed.feed_url} - Bozo: {feed.bozo_exception}")
|
||||
|
||||
messages.success(request, f"{feed.feed_url} added")
|
||||
# Save file to media folder
|
||||
UserUploadedFile.objects.create(user=request.user, file=file, original_filename=file.name)
|
||||
|
||||
# Render the index page.
|
||||
template = loader.get_template(template_name="index.html")
|
||||
messages.success(request, f"{file.name} uploaded")
|
||||
messages.info(
|
||||
request,
|
||||
"You can find your uploads on your profile page. Files will be parsed and added to the archive when possible. Thanks.", # noqa: E501
|
||||
)
|
||||
return HttpResponse(content=template.render(context={}, request=request))
|
||||
|
||||
|
||||
class DeleteUploadView(LoginRequiredMixin, View):
|
||||
"""Delete an uploaded file."""
|
||||
|
||||
def post(self, request: HttpRequest) -> HttpResponse:
|
||||
"""Delete an uploaded file."""
|
||||
file_id: str | None = request.POST.get("file_id", None)
|
||||
if not file_id:
|
||||
return HttpResponse("No file_id provided", status=400)
|
||||
|
||||
user_file: UserUploadedFile | None = UserUploadedFile.objects.filter(user=request.user, id=file_id).first()
|
||||
if not user_file:
|
||||
msg = "File not found"
|
||||
raise Http404(msg)
|
||||
|
||||
user_upload_dir: Path = Path(settings.MEDIA_ROOT) / "uploads" / f"{request.user.id}" # type: ignore # noqa: PGH003
|
||||
file_path: Path = user_upload_dir / Path(user_file.file.name).name
|
||||
logger.debug("file_path: %s", file_path)
|
||||
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
logger.error("User '%s' attempted to delete a file that does not exist: %s", request.user, file_path)
|
||||
msg = "File not found"
|
||||
raise Http404(msg)
|
||||
|
||||
if user_upload_dir not in file_path.parents:
|
||||
logger.error(
|
||||
"User '%s' attempted to delete a file that is not in their upload directory: %s",
|
||||
request.user,
|
||||
file_path,
|
||||
)
|
||||
msg = "Attempted unauthorized file access"
|
||||
raise SuspiciousOperation(msg)
|
||||
|
||||
user_file.delete()
|
||||
|
||||
# Go back to the profile page
|
||||
messages.success(request, f"{file_path.name} deleted")
|
||||
return HttpResponse(status=204)
|
||||
|
||||
|
||||
class EditDescriptionView(LoginRequiredMixin, View):
|
||||
"""Edit the description of an uploaded file."""
|
||||
|
||||
def post(self, request: HttpRequest) -> HttpResponse:
|
||||
"""Edit the description of an uploaded file."""
|
||||
new_description: str | None = request.POST.get("description", None)
|
||||
file_id: str | None = request.POST.get("file_id", None)
|
||||
if not new_description:
|
||||
return HttpResponse("No description provided", status=400)
|
||||
if not file_id:
|
||||
return HttpResponse("No file_id provided", status=400)
|
||||
|
||||
user_file: UserUploadedFile | None = UserUploadedFile.objects.filter(user=request.user, id=file_id).first()
|
||||
if not user_file:
|
||||
msg = "File not found"
|
||||
raise Http404(msg)
|
||||
|
||||
user_upload_dir: Path = Path(settings.MEDIA_ROOT) / "uploads" / f"{request.user.id}" # type: ignore # noqa: PGH003
|
||||
file_path: Path = user_upload_dir / Path(user_file.file.name).name
|
||||
logger.debug("file_path: %s", file_path)
|
||||
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
logger.error("User '%s' attempted to delete a file that does not exist: %s", request.user, file_path)
|
||||
msg = "File not found"
|
||||
raise Http404(msg)
|
||||
|
||||
if user_upload_dir not in file_path.parents:
|
||||
logger.error(
|
||||
"User '%s' attempted to delete a file that is not in their upload directory: %s",
|
||||
request.user,
|
||||
file_path,
|
||||
)
|
||||
msg = "Attempted unauthorized file access"
|
||||
raise SuspiciousOperation(msg)
|
||||
|
||||
old_description: str = user_file.description
|
||||
user_file.description = new_description
|
||||
user_file.save()
|
||||
|
||||
logger.info(
|
||||
"User '%s' updated the description of file '%s' from '%s' to '%s'",
|
||||
request.user,
|
||||
file_path,
|
||||
old_description,
|
||||
new_description,
|
||||
)
|
||||
return HttpResponse(content=new_description, status=200)
|
||||
|
||||
|
||||
class DownloadView(LoginRequiredMixin, View):
|
||||
"""Download a file."""
|
||||
|
||||
def get(self, request: HttpRequest) -> HttpResponse | FileResponse:
|
||||
"""/download/?file_id=1."""
|
||||
file_id: str | None = request.GET.get("file_id", None)
|
||||
|
||||
if not file_id:
|
||||
return HttpResponse("No file_id provided", status=400)
|
||||
|
||||
user_file: UserUploadedFile | None = UserUploadedFile.objects.filter(user=request.user, id=file_id).first()
|
||||
if not user_file:
|
||||
msg = "File not found"
|
||||
raise Http404(msg)
|
||||
|
||||
user_upload_dir: Path = Path(settings.MEDIA_ROOT) / "uploads" / f"{request.user.id}" # type: ignore # noqa: PGH003
|
||||
file_path: Path = user_upload_dir / Path(user_file.file.name).name
|
||||
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
msg = "File not found"
|
||||
raise Http404(msg)
|
||||
|
||||
if user_upload_dir not in file_path.parents:
|
||||
msg = "Attempted unauthorized file access"
|
||||
raise SuspiciousOperation(msg)
|
||||
|
||||
content_type, _ = guess_type(file_path)
|
||||
response = FileResponse(file_path.open("rb"), content_type=content_type or "application/octet-stream")
|
||||
response["Content-Disposition"] = f'attachment; filename="{user_file.original_filename or file_path.name}"'
|
||||
|
||||
return response
|
||||
|
||||
|
||||
class CustomLoginView(LoginView):
|
||||
"""Custom login view."""
|
||||
|
||||
|
|
@ -228,7 +353,7 @@ class CustomPasswordChangeView(SuccessMessageMixin, PasswordChangeView):
|
|||
return context
|
||||
|
||||
|
||||
class ProfileView(View):
|
||||
class ProfileView(LoginRequiredMixin, View):
|
||||
"""Profile page."""
|
||||
|
||||
def get(self, request: HttpRequest) -> HttpResponse:
|
||||
|
|
@ -236,6 +361,9 @@ class ProfileView(View):
|
|||
template = loader.get_template(template_name="accounts/profile.html")
|
||||
|
||||
user_feeds: BaseManager[Feed] = Feed.objects.filter(user=request.user).order_by("-created_at")[:100]
|
||||
user_uploads: BaseManager[UserUploadedFile] = UserUploadedFile.objects.filter(user=request.user).order_by(
|
||||
"-created_at",
|
||||
)[:100]
|
||||
|
||||
context: dict[str, str | Any] = {
|
||||
"description": f"Profile page for {request.user.get_username()}",
|
||||
|
|
@ -244,6 +372,7 @@ class ProfileView(View):
|
|||
"canonical": "https://feedvault.se/accounts/profile/",
|
||||
"title": f"{request.user.get_username()}",
|
||||
"user_feeds": user_feeds,
|
||||
"user_uploads": user_uploads,
|
||||
}
|
||||
return HttpResponse(content=template.render(context=context, request=request))
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue