12 Commits

Author SHA1 Message Date
mark e7cc031588 Supports profile updates
only background for now, but hey.
2025-05-22 22:53:39 +02:00
mark a54f8947bb Default authentication on endpoints
Ik was er namelijk alweer eentje vergeten dus heh.
2025-05-22 22:30:06 +02:00
mark ce06b16881 track nomination
moet nog wel een hoop oude code weg, maar dat is een probleem voor later
2025-05-22 19:41:31 +02:00
mark 18dd107cfd fixes small bug when skipping 2025-05-22 15:20:11 +02:00
mark 3de8234fb9 adds vote posting
also moves some validation code to the model
2025-05-22 15:17:35 +02:00
mark 501044eef2 adds vote api 2025-05-20 23:18:57 +02:00
mark ddb99f5056 nu met een hoeveelheid meer profile 2025-05-20 23:09:34 +02:00
mark fed5612f46 adds albums 2025-05-20 22:53:31 +02:00
mark e41b9980a7 nu met 50 procent meer eindpunt authenticatie 2025-05-20 22:40:32 +02:00
mark 3223785a76 nu met 100 procent meer user endpoints 2025-05-20 22:35:18 +02:00
mark 8b2bbfd4d3 initial API views 2025-05-20 19:08:07 +02:00
mark 5a432ae1ef adds DRF 2025-05-20 19:07:25 +02:00
22 changed files with 445 additions and 19 deletions
+11
View File
@@ -26,6 +26,7 @@ INSTALLED_APPS = [
'django.contrib.messages', 'django.contrib.messages',
'django.contrib.staticfiles', 'django.contrib.staticfiles',
'django_htmx', 'django_htmx',
'rest_framework'
] ]
MIDDLEWARE = [ MIDDLEWARE = [
@@ -85,6 +86,16 @@ AUTHENTICATION_BACKENDS = (
'django.contrib.auth.backends.ModelBackend', 'django.contrib.auth.backends.ModelBackend',
) )
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': [
'mozilla_django_oidc.contrib.drf.OIDCAuthentication',
'rest_framework.authentication.SessionAuthentication',
],
'DEFAULT_PERMISSION_CLASSES': [
'rest_framework.permissions.IsAuthenticated',
]
}
OIDC_RP_CLIENT_ID = os.getenv('OIDC_RP_CLIENT_ID') OIDC_RP_CLIENT_ID = os.getenv('OIDC_RP_CLIENT_ID')
OIDC_RP_CLIENT_SECRET = os.getenv('OIDC_RP_CLIENT_SECRET') OIDC_RP_CLIENT_SECRET = os.getenv('OIDC_RP_CLIENT_SECRET')
OIDC_OP_AUTHORIZATION_ENDPOINT = os.getenv('OIDC_OP_AUTHORIZATION_ENDPOINT') OIDC_OP_AUTHORIZATION_ENDPOINT = os.getenv('OIDC_OP_AUTHORIZATION_ENDPOINT')
+2 -2
View File
@@ -55,14 +55,14 @@ class Profile(models.Model):
"""Is this user allowed to nominate at this point in time?""" """Is this user allowed to nominate at this point in time?"""
end_date = os.getenv('DATE_NOM_END') end_date = os.getenv('DATE_NOM_END')
if self.user.is_superuser: if self.user.is_superuser:
return (True,) return (True, None)
if end_date: if end_date:
end_date = datetime.fromisoformat(end_date) end_date = datetime.fromisoformat(end_date)
if end_date <= datetime.now(tz=UTC): if end_date <= datetime.now(tz=UTC):
return (False, "over") return (False, "over")
if self.quota < 1: if self.quota < 1:
return (False, "quota") return (False, "quota")
return (True,) return (True, None)
@property @property
def can_vote(self): def can_vote(self):
+80
View File
@@ -4,12 +4,92 @@ from random import choice
from .album import Album from .album import Album
from .artist import Artist from .artist import Artist
from .vote import Vote from .vote import Vote
from playlist.spotify import spt
#from playlist.utils import track_from_json
import re import re
class TrackManager(models.Manager): class TrackManager(models.Manager):
def get_queryset(self): def get_queryset(self):
return super().get_queryset().filter(old=False) return super().get_queryset().filter(old=False)
def from_json(self, cls, json):
try:
return cls.objects.get(pk=json["id"])
except cls.DoesNotExist:
keys = [f.name for f in cls._meta.get_fields()]
subset = {key:json[key] for key in set(keys) & set(json.keys())}
return cls.objects.create(**subset)
def track_from_json(self, json):
"""Creates the track, album and artist from spotify response."""
# Album artists
album_artists = []
for artist in json["album"]["artists"]:
album_artists.append(self.from_json(Artist, artist))
del json["album"]["artists"]
# Album
album = self.from_json(Album, json["album"])
album.image_url = json["album"]["images"][0]["url"]
album.save()
del json["album"]
for artist in album_artists:
album.artists.add(artist)
# Track artists
track_artists = []
for artist in json["artists"]:
track_artists.append(self.from_json(Artist, artist))
del json["artists"]
# Track
track = self.from_json(Track, json)
for artist in track_artists:
track.artists.add(artist)
track.album = album
track.save()
return track
def create_from_spotify(self, spotify_link, profile):
if re.match(r"^https:\/\/spotify.link.*", spotify_link):
spotify_link = spt.unshort(spotify_link)
m = re.match(r"^https:\/\/open.spotify.com\/track\/([a-zA-Z0-9]+)\??", spotify_link)
if m is None:
return None, Exception("Invalid Spotify track URL")
spotify_id = m.group(1)
try:
existing_track = self.model.all_tracks.get(pk=spotify_id)
if existing_track.old:
existing_track.old = False
existing_track.user = profile.user
existing_track.save()
return (existing_track, "Updated old track")
else:
return (None, "Track already exists")
except:
pass
try:
json = spt.get_song_info(spotify_id)
except Exception as e:
print(e)
return (None, "Error fetching track")
#return TemplateResponse(request, "Nominate.html", {"message_type": "error", "message": "Fout bij het ophalen van het liedje."})
track = self.track_from_json(json)
track.nominated_by = profile.user
track.save()
dup = track.is_duplicate_of()
if dup:
track.delete()
return (None, "Track already exists")
#get_banter.delay(track.id)
profile.quota -= 1
profile.save()
return (track, None)
class Track(models.Model): class Track(models.Model):
def __str__(self): def __str__(self):
s = f"'{self.name}'" s = f"'{self.name}'"
+2 -1
View File
@@ -1,10 +1,11 @@
from django.db import models from django.db import models
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core.validators import MinValueValidator, MaxValueValidator
class Vote(models.Model): class Vote(models.Model):
class Meta: class Meta:
unique_together = ["user", "track"] unique_together = ["user", "track"]
track = models.ForeignKey('Track', on_delete=models.CASCADE) track = models.ForeignKey('Track', on_delete=models.CASCADE)
user = models.ForeignKey(User, on_delete=models.CASCADE) user = models.ForeignKey(User, on_delete=models.CASCADE)
points = models.IntegerField() points = models.IntegerField(validators=[MinValueValidator(1), MaxValueValidator(5)])
skipped = models.BooleanField(default=False) skipped = models.BooleanField(default=False)
+6
View File
@@ -0,0 +1,6 @@
from .track import *
from .artist import *
from .user import *
from .album import *
from .profile import *
from .vote import *
+12
View File
@@ -0,0 +1,12 @@
from django.db.models.functions import Extract
from playlist.models import Album
from rest_framework import serializers
class AlbumSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = Album
fields = '__all__'
extra_kwargs = {
'url': {'view_name': 'api-album', 'lookup_field': 'id'},
'artists': {'view_name': 'api-artist', 'lookup_field': 'id'},
}
+10
View File
@@ -0,0 +1,10 @@
from playlist.models import Artist
from rest_framework import serializers
class ArtistSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = Artist
fields = ['id', 'name', 'url']
extra_kwargs = {
'url': {'view_name': 'api-artist', 'lookup_field': 'id'},
}
+20
View File
@@ -0,0 +1,20 @@
from playlist.models import Profile, Background
from rest_framework import serializers
class BackgroundSerializer(serializers.ModelSerializer):
class Meta:
model = Background
fields = '__all__'
class ProfileSerializer(serializers.ModelSerializer):
background = BackgroundSerializer()
class Meta:
model = Profile
fields = '__all__'
class ProfileUpdateSerializer(serializers.ModelSerializer):
background = serializers.PrimaryKeyRelatedField(many=False, queryset=Background.objects.all())
class Meta:
model = Profile
fields = ['background']
+13
View File
@@ -0,0 +1,13 @@
from playlist.models import Track
from rest_framework import serializers
class TrackSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = Track
fields = '__all__'
extra_kwargs = {
'url': {'view_name': 'api-track', 'lookup_field': 'id'},
'artists': {'view_name': 'api-artist', 'lookup_field': 'id'},
'album': {'view_name': 'api-album', 'lookup_field': 'id'},
'nominated_by': {'view_name': 'api-user', 'lookup_field': 'id'}
}
+17
View File
@@ -0,0 +1,17 @@
from django.contrib.auth.models import User
from rest_framework import serializers
class UserSerializer(serializers.HyperlinkedModelSerializer):
# Add explicit reverse lookup to profile
profile = serializers.HyperlinkedRelatedField(
view_name='api-profile',
lookup_field='id',
read_only=True
)
class Meta:
model = User
fields = ['url', 'id', 'username', 'email', 'profile']
extra_kwargs = {
'url': {'view_name': 'api-user', 'lookup_field': 'id'}
}
+28
View File
@@ -0,0 +1,28 @@
from rest_framework import serializers
from playlist.models import Vote, Track
class VoteSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = Vote
fields = '__all__'
extra_kwargs = {
'url': {'view_name': 'api-vote', 'lookup_field': 'id'},
'track': {'view_name': 'api-track', 'lookup_field': 'id'},
'user': {'view_name': 'api-user', 'lookup_field': 'id'}
}
class VoteSaveSerializer(serializers.ModelSerializer):
class Meta:
model = Vote
fields = ['track', 'points', 'skipped']
def create(self, validated_data):
"""
Create a new vote. Deletes a previous skipped vote if it exists.
"""
user = self.context['request'].user
track = validated_data['track']
previous_vote = Vote.objects.filter(user=user, track=track).first()
if previous_vote and previous_vote.skipped:
previous_vote.delete()
return Vote.objects.create(**validated_data)
+18
View File
@@ -4,6 +4,24 @@ from django.conf import settings
from django.conf.urls.static import static from django.conf.urls.static import static
urlpatterns = [ urlpatterns = [
### API
path('api/', views.api_root, name="api-root"),
path('api/track/', views.TrackListView.as_view(), name="api-tracks"),
path('api/track/<id>', views.TrackDetailView.as_view(), name="api-track"),
path('api/artist/<id>', views.ArtistDetailView.as_view(), name="api-artist"),
path('api/artist', views.ArtistListView.as_view(), name="api-artists"),
path('api/album/<id>', views.AlbumDetailView.as_view(), name="api-album"),
path('api/album/', views.AlbumListView.as_view(), name="api-albums"),
path('api/vote/', views.VoteListView.as_view(), name="api-votes"),
path('api/vote/<id>', views.VoteDetailView.as_view(), name="api-vote"),
path('api/user/', views.UserListView.as_view(), name="api-users"),
path('api/user/<int:id>', views.UserDetailView.as_view(), name="api-user"),
path('api/user/<int:user_id>/votes', views.VoteListView.as_view(), name="api-user-votes"),
path('api/profile/<int:id>', views.ProfileDetailView.as_view(), name="api-profile"),
path('api/profile/background', views.BackgroundListView.as_view(), name="api-backgrounds"),
path('api/profile/background/<int:id>', views.BackgroundDetailView.as_view(), name="api-background"),
### Classic
path('track/', views.NominateView.as_view(), name="nominate"), path('track/', views.NominateView.as_view(), name="nominate"),
path('vote/', views.VoteView.as_view(), name="vote"), path('vote/', views.VoteView.as_view(), name="vote"),
path('vote/undo', views.UndoView.as_view(), name="undo"), path('vote/undo', views.UndoView.as_view(), name="undo"),
+8
View File
@@ -0,0 +1,8 @@
from .static import *
from .api.root import *
from .api.track import *
from .api.artist import *
from .api.user import *
from .api.album import *
from .api.profile import *
from .api.vote import *
+24
View File
@@ -0,0 +1,24 @@
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.authentication import SessionAuthentication
from rest_framework.permissions import IsAuthenticated
from playlist.models import Album
from playlist.serializers import AlbumSerializer
class AlbumListView(APIView):
authentication_classes = [SessionAuthentication]
permission_classes = [IsAuthenticated]
def get(self, request):
albums = Album.objects.all()
serializer = AlbumSerializer(albums, many=True, context={'request': request})
return Response(serializer.data)
class AlbumDetailView(APIView):
authentication_classes = [SessionAuthentication]
permission_classes = [IsAuthenticated]
def get(self, request, id):
album = Album.objects.get(pk=id)
serializer = AlbumSerializer(album, context={'request': request})
return Response(serializer.data)
+17
View File
@@ -0,0 +1,17 @@
from itertools import permutations
from rest_framework.views import APIView
from rest_framework.response import Response
from playlist.models import Artist
from playlist.serializers import ArtistSerializer
class ArtistListView(APIView):
def get(self, request):
artists = Artist.objects.all()
serializer = ArtistSerializer(artists, many=True, context={'request': request})
return Response(serializer.data)
class ArtistDetailView(APIView):
def get(self, request, id):
artist = Artist.objects.get(pk=id)
serializer = ArtistSerializer(artist, context={'request': request})
return Response(serializer.data)
+32
View File
@@ -0,0 +1,32 @@
from pickle import BUILD
from playlist import serializers
from rest_framework.views import APIView
from rest_framework.response import Response
from playlist.serializers import ProfileSerializer, ProfileUpdateSerializer, BackgroundSerializer
from playlist.models import Profile, Background
class ProfileDetailView(APIView):
def get(self, request, id):
profile = Profile.objects.get(pk=id)
serializer = ProfileSerializer(profile)
return Response(serializer.data)
def put(self, request, id):
profile = Profile.objects.get(pk=id)
serializer = ProfileUpdateSerializer(profile, data=request.data)
serializer.is_valid()
serializer.save()
return Response(serializer.data)
#return Response()
class BackgroundListView(APIView):
def get(self, request):
backgrounds = Background.objects.all()
serializer = BackgroundSerializer(backgrounds, many=True)
return Response(serializer.data)
class BackgroundDetailView(APIView):
def get(self, request, id):
background = Background.objects.get(pk=id)
serializer = BackgroundSerializer(background)
return Response(serializer.data)
+14
View File
@@ -0,0 +1,14 @@
from rest_framework.decorators import api_view, permission_classes
from rest_framework.response import Response
from rest_framework.reverse import reverse
@api_view(['GET'])
@permission_classes([])
def api_root(request, format=None):
return Response({
'tracks': reverse('api-tracks', request=request, format=format),
'artists': reverse('api-artists', request=request, format=format),
'users': reverse('api-users', request=request, format=format),
'albums': reverse('api-albums', request=request, format=format),
'votes': reverse('api-votes', request=request, format=format),
})
+47
View File
@@ -0,0 +1,47 @@
from rest_framework.views import APIView
from rest_framework.response import Response
from playlist.models import Track, Profile
from playlist.serializers import TrackSerializer
from playlist.spotify import spt
from playlist.tasks import get_banter, get_and_dither_image
class TrackListView(APIView):
def get(self, request):
tracks = Track.objects.all()
serializer = TrackSerializer(tracks, many=True, context={'request': request})
return Response(serializer.data)
def post(self, request):
(profile, _) = Profile.objects.get_or_create(user=request.user)
(can_nom, reason) = profile.can_nominate
if not can_nom:
return Response({'error': reason}, status=403)
(track, message) = Track.objects.create_from_spotify(request.data['spotify_link'], profile)
serializer = TrackSerializer(track, context={'request': request})
return Response(serializer.data)
def delete(self, request):
if request.user.is_superuser:
Track.objects.all().delete()
return Response(status=204)
else:
return Response({'error': 'Only superusers can delete all tracks'}, status=403)
class TrackDetailView(APIView):
def get(self, request, id):
track = Track.objects.get(pk=id)
serializer = TrackSerializer(track, context={'request': request})
return Response(serializer.data)
def put(self, request, id):
track = Track.objects.get(pk=id)
serializer = TrackSerializer(track, data=request.data, context={'request': request})
if serializer.is_valid():
serializer.save()
return Response(serializer.data)
return Response(serializer.errors, status=400)
def post(self, request):
serializer = TrackSerializer(data=request.data, context={'request': request})
if serializer.is_valid():
serializer.save()
return Response(serializer.data)
return Response(serializer.errors, status=400)
+16
View File
@@ -0,0 +1,16 @@
from rest_framework.views import APIView
from rest_framework.response import Response
from django.contrib.auth.models import User
from playlist.serializers import UserSerializer
class UserListView(APIView):
def get(self, request):
users = User.objects.all()
serializer = UserSerializer(users, many=True, context={'request': request})
return Response(serializer.data)
class UserDetailView(APIView):
def get(self, request, id):
user = User.objects.get(pk=id)
serializer = UserSerializer(user, context={'request': request})
return Response(serializer.data)
+48
View File
@@ -0,0 +1,48 @@
from django.db import IntegrityError
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.authentication import SessionAuthentication
from rest_framework.permissions import IsAuthenticated
from playlist.models import Vote, Profile
from playlist.serializers import VoteSerializer, VoteSaveSerializer
class VoteListView(APIView):
authentication_classes = [SessionAuthentication]
permission_classes = [IsAuthenticated]
def get(self, request, user_id = None):
if user_id:
votes = Vote.objects.filter(user_id=user_id)
else:
votes = Vote.objects.all()
serializer = VoteSerializer(votes, many=True, context={'request': request})
return Response(serializer.data)
def post(self, request):
(profile,_) = Profile.objects.get_or_create(user=request.user)
if not profile.can_vote:
return Response({'error': 'User cannot vote'}, status=403)
serializer = VoteSaveSerializer(data=request.data, context={'request': request})
if serializer.is_valid():
try:
serializer.save(user=request.user)
return Response(serializer.data, status=201)
except IntegrityError:
return Response({'error': 'Duplicate vote'}, status=400)
return Response(serializer.errors, status=400)
def delete(self, request):
if request.user.is_superuser:
Vote.objects.all().delete()
return Response(status=204)
else:
return Response(status=403)
class VoteDetailView(APIView):
authentication_classes = [SessionAuthentication]
permission_classes = [IsAuthenticated]
def get(self, request, id):
vote = Vote.objects.get(pk=id)
serializer = VoteSerializer(vote, context={'request': request})
return Response(serializer.data)
@@ -10,10 +10,10 @@ import os
from datetime import UTC, datetime from datetime import UTC, datetime
from collections import OrderedDict from collections import OrderedDict
from dotenv import load_dotenv from dotenv import load_dotenv
from .models import Track, Artist, Album, Vote, Background, Profile, Playlist from ..models import Track, Artist, Album, Vote, Background, Profile, Playlist
from .utils import track_from_json, get_unvoted, get_unvoted_count from ..utils import track_from_json, get_unvoted, get_unvoted_count
from .spotify import spt from ..spotify import spt
from .tasks import get_banter, get_and_dither_image from ..tasks import get_banter, get_and_dither_image
load_dotenv() load_dotenv()
@@ -189,7 +189,7 @@ class SkipView(LoginRequiredMixin, View):
pass pass
profile.last_voted = None profile.last_voted = None
profile.save() profile.save()
vote = Vote.objects.create(track=track, user=request.user, points=0, skipped=True) vote = Vote.objects.create(track=track, user=request.user, points=1, skipped=True)
profile.save() profile.save()
return self.get(request) return self.get(request)
+4
View File
@@ -5,3 +5,7 @@ python-dotenv
mozilla-django-oidc mozilla-django-oidc
django-htmx django-htmx
celery celery
djangorestframework
markdown
PyYAML
Pygments