diff --git a/apps/api/serializers.py b/apps/api/serializers.py index a8ea309..06f7519 100644 --- a/apps/api/serializers.py +++ b/apps/api/serializers.py @@ -4,13 +4,14 @@ import logging from rest_framework import serializers -from apps.home.models import Subscription, TrackedContent, DiscordChannel +from apps.home.models import Subscription, SubscriptionChannel, TrackedContent log = logging.getLogger(__name__) class DynamicModelSerializer(serializers.ModelSerializer): - """For use with GET requests, to specify which fields to include or exclude + """ + For use with GET requests, to specify which fields to include or exclude Mimics some graphql functionality. Usage: Inherit your ModelSerializer with this class. Add "only_fields" or @@ -104,33 +105,35 @@ class DynamicModelSerializer(serializers.ModelSerializer): abstract = True -class DiscordChannelSerializer(DynamicModelSerializer): - """Serializer for the Discord Channel Model.""" - - class Meta: - model = DiscordChannel - fields = ("id", "creation_datetime") - - class SubscriptionSerializer(DynamicModelSerializer): - """Serializer for the Subscription Model.""" + """ + Serializer for the Subscription Model. + """ image = serializers.ImageField() class Meta: model = Subscription - fields = ( - "uuid", "name", "rss_url", "image", "server", - "channels", "creation_datetime" - ) + fields = ("uuid", "name", "rss_url", "image", "server", "creation_datetime") + + +class SubscriptionChannelSerializer(DynamicModelSerializer): + """ + Serializer for the SubscriptionChannel Model. + """ + + subscription = SubscriptionSerializer() + + class Meta: + model = SubscriptionChannel + fields = ("uuid", "id", "subscription", "creation_datetime") class TrackedContentSerializer(DynamicModelSerializer): - """Serializer for the TrackedContent Model.""" + """ + Serializer for the TrackedContent Model. + """ class Meta: model = TrackedContent - fields = ( - "uuid", "content_url", "subscription", - "creation_datetime" - ) + fields = ("uuid", "content_url", "subscription", "creation_datetime") diff --git a/apps/api/urls.py b/apps/api/urls.py index b1e5373..d46b5ea 100644 --- a/apps/api/urls.py +++ b/apps/api/urls.py @@ -4,10 +4,10 @@ from django.urls import path, include from rest_framework.authtoken.views import obtain_auth_token from .views import ( - DiscordChannel_ListView, - DiscordChannel_DetailView, Subscription_ListView, Subscription_DetailView, + SubscriptionChannel_ListView, + SubscriptionChannel_DetailView, TrackedContent_ListView, TrackedContent_DetailView ) @@ -17,12 +17,11 @@ urlpatterns = [ path("api-auth/", include("rest_framework.urls", namespace="rest_framework")), path("api-token-auth/", obtain_auth_token), - path("channel/", include([ - path("", DiscordChannel_ListView.as_view(), name="discordchannel"), - path("/", DiscordChannel_DetailView.as_view(), name="discordchannel-detail") - ])), - path("subscription/", include([ + path("channel/", include([ + path("", SubscriptionChannel_ListView.as_view(), name="subscriptionchannel"), + path("/", SubscriptionChannel_DetailView.as_view(), name="subscriptionchannel-detail") + ])), path("", Subscription_ListView.as_view(), name="subscription"), path("/", Subscription_DetailView.as_view(), name="subscription-detail") ])), diff --git a/apps/api/views.py b/apps/api/views.py index 7f97b0c..60220cf 100644 --- a/apps/api/views.py +++ b/apps/api/views.py @@ -10,8 +10,8 @@ from rest_framework.pagination import PageNumberPagination from rest_framework.authentication import SessionAuthentication, TokenAuthentication from rest_framework.parsers import MultiPartParser, FormParser -from apps.home.models import DiscordChannel, Subscription, TrackedContent -from .serializers import DiscordChannelSerializer, SubscriptionSerializer, TrackedContentSerializer +from apps.home.models import Subscription, SubscriptionChannel, TrackedContent +from .serializers import SubscriptionSerializer, SubscriptionChannelSerializer, TrackedContentSerializer log = logging.getLogger(__name__) @@ -25,38 +25,16 @@ class DefaultPagination(PageNumberPagination): max_page_size = 25 -# Discord Channel Views - -class DiscordChannel_ListView(generics.ListAPIView, generics.CreateAPIView): - """""" - - authentication_classes = [SessionAuthentication, TokenAuthentication] - permission_classes = [permissions.IsAuthenticated] - - pagination_class = DefaultPagination - serializer_class = DiscordChannelSerializer - queryset = DiscordChannel.objects.all().order_by("-creation_datetime") - - filter_backends = [rest_filters.DjangoFilterBackend, filters.OrderingFilter] - filterset_fields = ["id"] - ordering_fields = ["creation_datetime"] - - -class DiscordChannel_DetailView(generics.RetrieveDestroyAPIView): - """""" - - authentication_classes = [SessionAuthentication, TokenAuthentication] - permission_classes = [permissions.IsAuthenticated] - parser_classes = [MultiPartParser, FormParser] - - serializer_class = DiscordChannelSerializer - queryset = DiscordChannel.objects.all().order_by("-creation_datetime") - - +# ================================================================================================= # Subscription Views -class Subscription_ListView(generics.ListAPIView, generics.CreateAPIView): - """""" +class Subscription_ListView(generics.ListCreateAPIView): + """ + View to provide a list of Subscription model instances. + Can also be used to create a new instance. + + Supports: GET, POST + """ authentication_classes = [SessionAuthentication, TokenAuthentication] permission_classes = [permissions.IsAuthenticated] @@ -66,25 +44,33 @@ class Subscription_ListView(generics.ListAPIView, generics.CreateAPIView): queryset = Subscription.objects.all().order_by("-creation_datetime") filter_backends = [filters.SearchFilter, rest_filters.DjangoFilterBackend, filters.OrderingFilter] - filterset_fields = ["uuid", "name", "rss_url", "server", "channels", "creation_datetime"] + filterset_fields = ["uuid", "name", "rss_url", "server", "creation_datetime"] search_fields = ["name"] ordering_fields = ["creation_datetime"] -# def post(self, request): -# serializer = self.get_serializer(data=request.data) -# serializer.is_valid(raise_exception=True) + def post(self, request): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) -# try: -# self.perform_create(serializer) -# except IntegrityError: -# return Response({"detail": "RSS Feed name must be unique"}, status=status.HTTP_409_CONFLICT, exception=True) + try: + self.perform_create(serializer) + except IntegrityError: + return Response( + {"detail": "Subscription name must be unique"}, + status=status.HTTP_409_CONFLICT, + exception=True + ) -# headers = self.get_success_headers(serializer.data) -# return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) class Subscription_DetailView(generics.RetrieveUpdateDestroyAPIView): - """""" + """ + View to provide details on a particular Subscription model instances. + + Supports: GET, DELETE + """ authentication_classes = [SessionAuthentication, TokenAuthentication] permission_classes = [permissions.IsAuthenticated] @@ -94,10 +80,86 @@ class Subscription_DetailView(generics.RetrieveUpdateDestroyAPIView): queryset = Subscription.objects.all().order_by("-creation_datetime") +# ================================================================================================= +# SubscriptionChannel Views + +class SubscriptionChannel_ListView(generics.ListCreateAPIView): + """ + View to provide a list of SubscriptionChannel model instances. + Can also be used to create a new instance. + + Supports: GET, POST + """ + + authentication_classes = [SessionAuthentication, TokenAuthentication] + permission_classes = [permissions.IsAuthenticated] + + pagination_class = DefaultPagination + serializer_class = SubscriptionChannelSerializer + queryset = SubscriptionChannel.objects.all().order_by("-creation_datetime") + + filter_backends = [rest_filters.DjangoFilterBackend, filters.OrderingFilter] + filterset_fields = ["id", "subscription"] + ordering_fields = ["creation_datetime"] + + def post(self, request): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + + try: + self.perform_create(serializer) + except IntegrityError: + return Response( + {"detail": "Duplicates not allowed"}, + status=status.HTTP_409_CONFLICT, + exception=True + ) + + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) + + +class SubscriptionChannel_DetailView(generics.RetrieveDestroyAPIView): + """ + View to provide details on a particular SubscriptionChannel model instances. + + Supports: GET, DELETE + """ + + authentication_classes = [SessionAuthentication, TokenAuthentication] + permission_classes = [permissions.IsAuthenticated] + parser_classes = [MultiPartParser, FormParser] + + serializer_class = SubscriptionChannelSerializer + queryset = SubscriptionChannel.objects.all().order_by("-creation_datetime") + + def post(self, request): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + + try: + self.perform_create(serializer) + except IntegrityError: + return Response( + {"detail": "Channel must be unique"}, + status=status.HTTP_409_CONFLICT, + exception=True + ) + + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) + + +# ================================================================================================= # Tracked Content Views -class TrackedContent_ListView(generics.ListAPIView, generics.CreateAPIView): - """""" +class TrackedContent_ListView(generics.ListCreateAPIView): + """ + View to provide a list of TrackedContent model instances. + Can also be used to create a new instance. + + Supports: GET, POST + """ authentication_classes = [SessionAuthentication, TokenAuthentication] permission_classes = [permissions.IsAuthenticated] @@ -111,9 +173,29 @@ class TrackedContent_ListView(generics.ListAPIView, generics.CreateAPIView): search_fields = ["name"] ordering_fields = ["creation_datetime"] + def post(self, request): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + + try: + self.perform_create(serializer) + except IntegrityError: + return Response( + {"detail": "Tracked content must be unique"}, + status=status.HTTP_409_CONFLICT, + exception=True + ) + + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) + class TrackedContent_DetailView(generics.RetrieveDestroyAPIView): - """""" + """ + View to provide details on a particular TrackedContent model instances. + + Supports: GET, DELETE + """ authentication_classes = [SessionAuthentication, TokenAuthentication] permission_classes = [permissions.IsAuthenticated] diff --git a/apps/home/admin.py b/apps/home/admin.py index 3e585c0..528bab7 100644 --- a/apps/home/admin.py +++ b/apps/home/admin.py @@ -2,15 +2,10 @@ from django.contrib import admin -from .models import DiscordChannel, Subscription, TrackedContent - - -class DiscordChannelAdmin(admin.ModelAdmin): - list_display = [ - "id", "creation_datetime" - ] +from .models import Subscription, SubscriptionChannel, TrackedContent +@admin.register(Subscription) class SubscriptionAdmin(admin.ModelAdmin): list_display = [ "uuid", "name", "rss_url", "server", @@ -18,14 +13,17 @@ class SubscriptionAdmin(admin.ModelAdmin): ] +@admin.register(SubscriptionChannel) +class SubscriptionAdmin(admin.ModelAdmin): + list_display = [ + "uuid", "id", "subscription", "creation_datetime" + ] + + +@admin.register(TrackedContent) class TrackedContentAdmin(admin.ModelAdmin): list_display = [ "uuid", "content_url", "subscription", "creation_datetime" ] - - -admin.site.register(DiscordChannel, DiscordChannelAdmin) -admin.site.register(Subscription, SubscriptionAdmin) -admin.site.register(TrackedContent, TrackedContentAdmin) - + \ No newline at end of file diff --git a/apps/home/migrations/0001_initial.py b/apps/home/migrations/0001_initial.py index 6f34bfc..af7a34a 100644 --- a/apps/home/migrations/0001_initial.py +++ b/apps/home/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.0.1 on 2024-02-07 23:19 +# Generated by Django 5.0.1 on 2024-02-11 20:20 import apps.home.models import django.db.models.deletion @@ -18,7 +18,7 @@ class Migration(migrations.Migration): migrations.CreateModel( name='DiscordChannel', fields=[ - ('id', models.PositiveBigIntegerField(editable=False, help_text='Unique identifier of the channel, provided by Discord.', primary_key=True, serialize=False, verbose_name='id')), + ('id', models.PositiveBigIntegerField(help_text='Identifier of the channel, provided by Discord.', primary_key=True, serialize=False, verbose_name='id')), ('creation_datetime', models.DateTimeField(default=django.utils.timezone.now, editable=False, help_text='when this instance was created.', verbose_name='creation datetime')), ], options={ @@ -31,12 +31,12 @@ class Migration(migrations.Migration): name='Subscription', fields=[ ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), - ('name', models.CharField(help_text='Reference name for this subscription (max %(max_length)s chars).', max_length=32, verbose_name='name')), + ('name', models.CharField(help_text='Reference name for this subscription (max 32 chars).', max_length=32, verbose_name='name')), ('rss_url', models.URLField(help_text='URL of the subscribed to RSS feed.', verbose_name='rss url')), - ('image', models.ImageField(blank=True, default='../static/images/defaultuser.webp', help_text='image of the RSS feed.', null=True, storage=apps.home.models.OverwriteStorage(), upload_to=apps.home.models.Subscription_IconPathGenerator(), verbose_name='image')), + ('image', models.ImageField(blank=True, default='../static/images/defaultuser.webp', help_text='image of the RSS feed.', null=True, storage=apps.home.models.OverwriteStorage(), upload_to=apps.home.models.IconPathGenerator(), verbose_name='image')), ('creation_datetime', models.DateTimeField(default=django.utils.timezone.now, editable=False, help_text='when this instance was created.', verbose_name='creation datetime')), ('server', models.PositiveBigIntegerField(help_text='Identifier for the discord server that owns this subscription.', verbose_name='server id')), - ('channels', models.ManyToManyField(help_text='List of Discord Channels acting as targets for subscription content.', to='home.discordchannel', verbose_name='channels')), + ('channels', models.ManyToManyField(blank=True, help_text='List of Discord Channels acting as targets for subscription content.', to='home.discordchannel', verbose_name='channels')), ], options={ 'verbose_name': 'subscription', diff --git a/apps/home/migrations/0002_remove_subscription_channels_subscriptionchannel_and_more.py b/apps/home/migrations/0002_remove_subscription_channels_subscriptionchannel_and_more.py new file mode 100644 index 0000000..3edee07 --- /dev/null +++ b/apps/home/migrations/0002_remove_subscription_channels_subscriptionchannel_and_more.py @@ -0,0 +1,41 @@ +# Generated by Django 5.0.1 on 2024-02-11 21:59 + +import django.db.models.deletion +import django.utils.timezone +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('home', '0001_initial'), + ] + + operations = [ + migrations.RemoveField( + model_name='subscription', + name='channels', + ), + migrations.CreateModel( + name='SubscriptionChannel', + fields=[ + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('id', models.PositiveBigIntegerField(help_text='Identifier of the channel, provided by Discord.', verbose_name='id')), + ('creation_datetime', models.DateTimeField(default=django.utils.timezone.now, editable=False, help_text='when this instance was created.', verbose_name='creation datetime')), + ('subscription', models.ForeignKey(help_text='The subscription of this instance.', on_delete=django.db.models.deletion.CASCADE, to='home.subscription', verbose_name='subscription')), + ], + options={ + 'verbose_name': 'subscription channel', + 'verbose_name_plural': 'subscription channels', + 'get_latest_by': '-creation_date', + }, + ), + migrations.DeleteModel( + name='DiscordChannel', + ), + migrations.AddConstraint( + model_name='subscriptionchannel', + constraint=models.UniqueConstraint(fields=('id', 'subscription'), name='unique id & sub pair'), + ), + ] diff --git a/apps/home/models.py b/apps/home/models.py index c9d243f..eab1cf5 100644 --- a/apps/home/models.py +++ b/apps/home/models.py @@ -1,110 +1,90 @@ # -*- encoding: utf-8 -*- -import os import logging from uuid import uuid4 +from pathlib import Path -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 from django.core.files.storage import FileSystemStorage from django.utils.deconstruct import deconstructible -from asgiref.sync import sync_to_async, async_to_sync log = logging.getLogger(__name__) class OverwriteStorage(FileSystemStorage): - """Storage class that allows overriding files, instead of django appending random characters to prevent conflicts.""" + """ + Storage class that allows overriding files, instead of django appending random + characters to prevent conflicts. + """ - def get_available_name(self, name, max_length=None): + def get_available_name(self, name, max_length=None) -> str: if self.exists(name): - os.remove(os.path.join(self.location, name)) + (Path(self.location) / name).unlink() return name @deconstructible -class Subscription_IconPathGenerator: - """Icon path generator for Subscriptions.""" +class IconPathGenerator: + """ + Icon path generator. + """ - def __call__(self, instance, filename: str) -> str: - return os.path.join("subscriptions", str(instance.uuid), "icon.webp") - - -class DiscordChannel(models.Model): - """Represents a Discord Channel.""" - - id = models.PositiveBigIntegerField( - verbose_name=_("id"), - help_text=_("Unique identifier of the channel, provided by Discord."), - primary_key=True - ) - creation_datetime = models.DateTimeField( - verbose_name=_("creation datetime"), - help_text=_("when this instance was created."), - default=timezone.now, - editable=False - ) - - class Meta: - verbose_name = _("discord channel") - verbose_name_plural = _("discord channels") - get_latest_by = "-creation_datetime" - - def __str__(self): - return str(self.id) + def __call__(self, instance, filename: str) -> Path: + return Path(instance.__class__.__name__.lower()) / str(instance.uuid) / "icon.webp" class Subscription(models.Model): - """Stores relevant data for a user submitted RSS Feed.""" + """ + Represents a stored RSS Feed. + """ uuid = models.UUIDField( primary_key=True, default=uuid4, editable=False ) + + # Name attribute acts as a human readable identification and search option. name = models.CharField( verbose_name=_("name"), - help_text=_("Reference name for this subscription (max %(max_length)s chars)."), + help_text=_("Reference name for this subscription (max 32 chars)."), max_length=32, null=False, blank=False ) + 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=Subscription_IconPathGenerator(), + upload_to=IconPathGenerator(), storage=OverwriteStorage(), default="../static/images/defaultuser.webp", null=True, blank=True ) + + # Discord Server ID + server = models.PositiveBigIntegerField( + verbose_name=_("server id"), + help_text=_("Identifier for the discord server that owns this subscription.") + ) + creation_datetime = models.DateTimeField( verbose_name=_("creation datetime"), help_text=_("when this instance was created."), default=timezone.now, editable=False ) - server = models.PositiveBigIntegerField( - verbose_name=_("server id"), - help_text=_("Identifier for the discord server that owns this subscription.") - ) - channels = models.ManyToManyField( - verbose_name=_("channels"), - help_text=_("List of Discord Channels acting as targets for subscription content."), - to=DiscordChannel, - ) class Meta: verbose_name = "subscription" @@ -123,15 +103,71 @@ class Subscription(models.Model): log.debug("%sSubscription Saved %s", new_text, self.uuid) super().save(*args, **kwargs) + @property + def channels(self): + """ + Returns all SubscriptionChannel objects linked to this Subscription. + """ -class TrackedContent(models.Model): - """Tracks content shared from an RSS Feed, to prevent duplicates.""" + return SubscriptionChannel.objects.filter(subscription=self) + + +class SubscriptionChannel(models.Model): + """ + Represents a Discord TextChannel that should be subject to the content of a Subscription. + """ uuid = models.UUIDField( primary_key=True, default=uuid4, editable=False ) + + # The ID is not auto generated, but rather be obtained from discord. + id = models.PositiveBigIntegerField( + verbose_name=_("id"), + help_text=_("Identifier of the channel, provided by Discord.") + ) + + subscription = models.ForeignKey( + verbose_name=_("subscription"), + help_text=_("The subscription of this instance."), + to=Subscription, + on_delete=models.CASCADE + ) + + creation_datetime = models.DateTimeField( + verbose_name=_("creation datetime"), + help_text=_("when this instance was created."), + default=timezone.now, + editable=False + ) + + class Meta: + verbose_name = "subscription channel" + verbose_name_plural = "subscription channels" + get_latest_by = "-creation_date" + constraints = [ + models.UniqueConstraint(fields=["id", "subscription"], name="unique id & sub pair") + ] + + def __str__(self): + return str(self.id) + + + +class TrackedContent(models.Model): + """ + Tracks content shared from an RSS Feed Subscription. + Content is tracked to help prevent duplicate content. + """ + + uuid = models.UUIDField( + primary_key=True, + default=uuid4, + editable=False + ) + subscription = models.ForeignKey( verbose_name=_("subscription"), help_text=_("The subscription that this content originated from."), @@ -139,10 +175,12 @@ class TrackedContent(models.Model): on_delete=models.CASCADE, related_name="tracked_content" ) + content_url = models.URLField( verbose_name=_("content url"), help_text=_("URL of the tracked content.") ) + creation_datetime = models.DateTimeField( verbose_name=_("creation datetime"), help_text=_("when this instance was created."), @@ -156,8 +194,10 @@ class TrackedContent(models.Model): @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.""" + """" + Delete the latest tracked content, if the total under + the same subscription reaches 100. + """ log.debug("checking if tracked content can be deleted.")