diff --git a/apps/api/serializers.py b/apps/api/serializers.py index 9f06d51..fe3b382 100644 --- a/apps/api/serializers.py +++ b/apps/api/serializers.py @@ -2,8 +2,10 @@ import logging +from rest_framework import serializers -from apps.home.models import RSSFeed, FeedChannel +from apps.home.models import Subscription, TrackedContent +# from apps.home.models import RSSFeed, FeedChannel log = logging.getLogger(__name__) @@ -103,21 +105,25 @@ class DynamicModelSerializer(serializers.ModelSerializer): abstract = True -class RssFeedSerializer(DynamicModelSerializer): +class SubscriptionSerializer(DynamicModelSerializer): + """Serializer for the Subscription Model.""" + image = serializers.ImageField() class Meta: - model = RSSFeed + model = Subscription fields = ( - "uuid", "name", "url", "image", "discord_server_id", "created_at" + "uuid", "name", "url", "image", "server", + "channels", "creation_datetime" ) -class FeedChannelSerializer(DynamicModelSerializer): - feeds = RssFeedSerializer(many=True) +class TrackedContent(DynamicModelSerializer): + """Serializer for the TrackedContent Model.""" class Meta: - model = FeedChannel + model = TrackedContent fields = ( - "uuid", "discord_server_id", "discord_channel_id", "feeds", "created_at" + "uuid", "content_url", "subscription", + "creation_datetime" ) diff --git a/apps/api/urls.py b/apps/api/urls.py index 7856db4..922dd17 100644 --- a/apps/api/urls.py +++ b/apps/api/urls.py @@ -10,9 +10,20 @@ urlpatterns = [ path("api-auth/", include("rest_framework.urls", namespace="rest_framework")), path("api-token-auth/", obtain_auth_token), - path("rssfeed/", include([ - path("", views.RSSFeedList.as_view(), name="rssfeed"), - path("/", views.RSSFeedDetail.as_view(), name="rssfeed-detail") + path("subscription/", include([ + path("", views.SubscriptionList.as_view(), name="subscription"), + path("/", views.SubscriptionDetail.as_view(), name="subscription-detail") ])), - path("feedchannel/", views.FeedChannelListApiView.as_view(), name="feedchannel") + + path("tracked/", include([ + path("", views.TrackedContentList.as_view(), name="tracked"), + path("/", views.TrackedContentDetail.as_view(), name="tracked-detail"), + # path("") + ])), + + # path("rssfeed/", include([ + # path("", views.RSSFeedList.as_view(), name="rssfeed"), + # path("/", views.RSSFeedDetail.as_view(), name="rssfeed-detail") + # ])), + # path("feedchannel/", views.FeedChannelListApiView.as_view(), name="feedchannel") ] \ No newline at end of file diff --git a/apps/home/__init__.py b/apps/home/__init__.py index 58cca0e..dae354a 100644 --- a/apps/home/__init__.py +++ b/apps/home/__init__.py @@ -1,4 +1 @@ # -*- encoding: utf-8 -*- -""" -Copyright (c) 2019 - present AppSeed.us -""" diff --git a/apps/home/admin.py b/apps/home/admin.py index e9a02f2..752576e 100644 --- a/apps/home/admin.py +++ b/apps/home/admin.py @@ -3,17 +3,23 @@ from django.contrib import admin from .models import RSSFeed, FeedChannel +from .models import Subscription, TrackedContent -class RSSFeedAdmin(admin.ModelAdmin): - list_display = ["uuid", "name", "url", "discord_server_id", "created_at"] +class SubscriptionAdmin(admin.ModelAdmin): + list_display = [ + "uuid", "name", "url", "server", + "channels", "creation_datetime" + ] -admin.site.register(RSSFeed, RSSFeedAdmin) +class TrackedContentAdmin(admin.ModelAdmin): + list_display = [ + "uuid", "content_url", "subscription", + "creation_datetime" + ] -class FeedChannelAdmin(admin.ModelAdmin): - list_display = ["uuid", "discord_server_id", "discord_channel_id", "created_at"] +admin.site.register(Subscription, SubscriptionAdmin) +admin.site.register(TrackedContent, TrackedContentAdmin) - -admin.site.register(FeedChannel, FeedChannelAdmin) diff --git a/apps/home/models.py b/apps/home/models.py index bdc0aa3..4de5aa2 100644 --- a/apps/home/models.py +++ b/apps/home/models.py @@ -8,6 +8,8 @@ import httpx import feedparser from django.db import models from django.utils import timezone +from django.dispatch import receiver +from django.db.models.signals import pre_save from django.utils.translation import gettext_lazy as _ from django.core.exceptions import ValidationError from django.core.validators import URLValidator @@ -29,126 +31,112 @@ class OverwriteStorage(FileSystemStorage): @deconstructible -class RSSFeedIconPathGenerator: - """Icon path generator for RSS Feed.""" +class Subscription_IconPathGenerator: + """Icon path generator for Subscriptions.""" def __call__(self, instance, filename: str) -> str: - return os.path.join("rssfeed", str(instance.uuid), "icon.webp") + return os.path.join("subscriptions", str(instance.uuid), "icon.webp") -@async_to_sync -async def validate_rss_url(url: str): - - log.debug("validating RSS url: %s" % url) - - try: - validator = URLValidator(schemes=["https"]) - validator(url) - except ValidationError as exc: - raise ValidationError("URL scheme must be 'https'") from exc - - async with httpx.AsyncClient() as client: - response = await client.get(url) - try: - response.raise_for_status() - except httpx.HTTPStatusError as error: - log.error(error) - raise ValidationError(error) - - content = response.text - feed = feedparser.parse(content) - - if not feed.version: - log.error("not a feed") - raise ValidationError(f"{url} does not point to a valid RSS feed") - - log.debug("success") - - return True - - -class RSSFeed(models.Model): - """Represents an RSS Feed.""" - - uuid = models.UUIDField(primary_key=True, default=uuid4, editable=False) +class Subscription(models.Model): + """Stores relevant data for a user submitted RSS Feed.""" + uuid = models.UUIDField( + primary_key=True, + default=uuid4, + editable=False + ) name = models.CharField( verbose_name=_("name"), - help_text=_("a human readable nickname for this item"), - null=False, blank=False, - max_length=120 + help_text=_("Reference name for this subscription (max %(max_length)s chars)."), + max_length=32, + null=False, + blank=False ) - - url = models.URLField( - verbose_name=_("url"), - help_text=_("url to the RSS feed"), + rss_url = models.URLField( + verbose_name=_("rss url"), + help_text=_("URL of the subscribed to RSS feed.") ) - image = models.ImageField( verbose_name=_("image"), - help_text=_("image of the RSS feed"), - upload_to=RSSFeedIconPathGenerator(), + help_text=_("image of the RSS feed."), + upload_to=Subscription_IconPathGenerator(), storage=OverwriteStorage(), + default="../static/images/defaultuser.webp", null=True, blank=True ) - - created_at = models.DateTimeField( - verbose_name=_("creation date & time"), - help_text=_("when this item was created"), + creation_datetime = models.DateTimeField( + verbose_name=_("creation datetime"), + help_text=_("when this instance was created."), default=timezone.now, editable=False ) - - discord_server_id = models.PositiveBigIntegerField( - verbose_name=_("discord server id"), - help_text=_("the discord server id of this item") + server = models.PositiveBigIntegerField( + verbose_name=_("server id"), + help_text=_("Identifier for the discord server that owns this subscription.") ) + channels = models.ManyToManyField(int) class Meta: - verbose_name = _("RSS Feed") - verbose_name_plural = _("RSS Feeds") + verbose_name = "subscription" + verbose_name_plural = "subscriptions" + get_latest_by = "-creation_datetime" constraints = [ - models.UniqueConstraint(fields=["name", "discord_server_id"], name="unique name & server pair") + # Prevent servers from having subscriptions with duplicate names + models.UniqueConstraint(fields=["name", "server_id"], name="unique name & server pair") ] def __str__(self): return self.name -class FeedChannel(models.Model): - """Represents a Discord Text Channel to act as a feed.""" +class TrackedContent(models.Model): + """Tracks content shared from an RSS Feed, to prevent duplicates.""" - uuid = models.UUIDField(primary_key=True, default=uuid4, editable=False) - - discord_server_id = models.PositiveBigIntegerField( - verbose_name=_("discord server id"), - help_text=_("the discord server id of this item") + uuid = models.UUIDField( + primary_key=True, + default=uuid4, + editable=False ) - - discord_channel_id = models.PositiveBigIntegerField( - verbose_name=_("discord channel id"), - help_text=_("the discord channel id of this item"), - null=False, blank=False + subscription = models.ForeignKey( + verbose_name=_("subscription"), + help_text=_("The subscription that this content originated from."), + to=Subscription, + on_delete=models.CASCADE, + related_name="tracked_content" ) - - feeds = models.ManyToManyField( - to=RSSFeed, - verbose_name=_("feeds"), - help_text=_("the feeds to include in this item"), - related_name=_("queues") + content_url = models.URLField( + verbose_name=_("content url"), + help_text=_("URL of the tracked content.") ) - - created_at = models.DateTimeField( - verbose_name=_("creation date & time"), - help_text=_("when this item was created"), + creation_datetime = models.DatetimeField( + verbose_name=_("creation datetime"), + help_text=_("when this instance was created."), default=timezone.now, editable=False ) - class Meta: - verbose_name = _("Feed Channel") - verbose_name_plural = _("Feed Channels") - def __str__(self): - return str(self.discord_channel_id) + return str(self.content_url) + + +@receiver(pre_save, sender=TrackedContent) +def maintain_item_cap(sender, instance, **kwargs): + """"Delete the latest tracked content, if the total under + the same subscription reaches 100.""" + + log.debug("checking if tracked content can be deleted.") + + queryset = sender.objects.filter(subscription=instance.subscription) + total_tracked = queryset.count() + + if total_tracked < 100: + log.debug("tracked content cannot be deleted, less than 100 items: %s", total_tracked) + return + + oldest = queryset.earliest() + + log.info("tracked content limit exceeded, deleting oldest item with uuid: %s", oldest.uuid) + + oldest.delete()