New model changes

This commit is contained in:
Corban-Lee Jones 2024-02-11 23:53:35 +00:00
parent 7a78cf1215
commit 404aa3680f
7 changed files with 307 additions and 144 deletions

View File

@ -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")

View File

@ -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("<int:pk>/", DiscordChannel_DetailView.as_view(), name="discordchannel-detail")
])),
path("subscription/", include([
path("channel/", include([
path("", SubscriptionChannel_ListView.as_view(), name="subscriptionchannel"),
path("<str:pk>/", SubscriptionChannel_DetailView.as_view(), name="subscriptionchannel-detail")
])),
path("", Subscription_ListView.as_view(), name="subscription"),
path("<str:pk>/", Subscription_DetailView.as_view(), name="subscription-detail")
])),

View File

@ -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]

View File

@ -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)

View File

@ -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',

View File

@ -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'),
),
]

View File

@ -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.")