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
+12 -1
View File
@@ -26,6 +26,7 @@ INSTALLED_APPS = [
'django.contrib.messages',
'django.contrib.staticfiles',
'django_htmx',
'rest_framework'
]
MIDDLEWARE = [
@@ -85,6 +86,16 @@ AUTHENTICATION_BACKENDS = (
'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_SECRET = os.getenv('OIDC_RP_CLIENT_SECRET')
OIDC_OP_AUTHORIZATION_ENDPOINT = os.getenv('OIDC_OP_AUTHORIZATION_ENDPOINT')
@@ -103,4 +114,4 @@ USE_TZ = True
STATIC_URL = 'static/'
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
SESSION_COOKIE_AGE = 60*60*24*4
SESSION_COOKIE_AGE = 60*60*24*4
+6 -6
View File
@@ -49,20 +49,20 @@ class Profile(models.Model):
self.quota = min(self.quota, MAX_QUOTA)
print(f"{self.user.username}: Er zijn {elapsed.total_seconds()}s voorbij, je nieuwe quota is {self.quota}")
self.quota_last_updated = timezone.now()
@property
def can_nominate(self):
"""Is this user allowed to nominate at this point in time?"""
end_date = os.getenv('DATE_NOM_END')
if self.user.is_superuser:
return (True,)
return (True, None)
if end_date:
end_date = datetime.fromisoformat(end_date)
if end_date <= datetime.now(tz=UTC):
return (False, "over")
if self.quota < 1:
return (False, "quota")
return (True,)
return (True, None)
@property
def can_vote(self):
@@ -86,7 +86,7 @@ class Profile(models.Model):
def init_spt(self, code):
"""Initial setting of spotify tokens from user accept code"""
data = spt.get_token_json(
grant_type='authorization_code',
grant_type='authorization_code',
code=code
)
self.save_spt(data)
@@ -109,7 +109,7 @@ class Profile(models.Model):
self.spt_refresh_token = refresh
self.spt_expires = timezone.now() + timedelta(seconds=expires - 300)
self.save()
def unlink_spt(self):
self.spt_access_token = None
self.spt_refresh_token = None
@@ -122,7 +122,7 @@ class Profile(models.Model):
self.spt_expires and
(timezone.now() < self.spt_expires)
)
def get_or_update_spt(self):
if self.spt_refresh_token:
if not self.can_spt:
+81 -1
View File
@@ -4,12 +4,92 @@ from random import choice
from .album import Album
from .artist import Artist
from .vote import Vote
from playlist.spotify import spt
#from playlist.utils import track_from_json
import re
class TrackManager(models.Manager):
def get_queryset(self):
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):
def __str__(self):
s = f"'{self.name}'"
@@ -23,7 +103,7 @@ class Track(models.Model):
if self.artists.count() > 0:
artists = list(map(lambda a:a.name, self.artists.all()))
return " & ".join(artists)
@property
def name_sanitized(self):
return re.match("[^\-\(]*\w", self.name)[0]
+2 -1
View File
@@ -1,10 +1,11 @@
from django.db import models
from django.contrib.auth.models import User
from django.core.validators import MinValueValidator, MaxValueValidator
class Vote(models.Model):
class Meta:
unique_together = ["user", "track"]
track = models.ForeignKey('Track', 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)
+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
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('vote/', views.VoteView.as_view(), name="vote"),
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
View File
@@ -10,10 +10,10 @@ import os
from datetime import UTC, datetime
from collections import OrderedDict
from dotenv import load_dotenv
from .models import Track, Artist, Album, Vote, Background, Profile, Playlist
from .utils import track_from_json, get_unvoted, get_unvoted_count
from .spotify import spt
from .tasks import get_banter, get_and_dither_image
from ..models import Track, Artist, Album, Vote, Background, Profile, Playlist
from ..utils import track_from_json, get_unvoted, get_unvoted_count
from ..spotify import spt
from ..tasks import get_banter, get_and_dither_image
load_dotenv()
@@ -90,7 +90,7 @@ class NominateView(LoginRequiredMixin, View):
get_banter.delay(track.id)
return TemplateResponse(request, "Nominate.html", {
"message_type": "warning",
"message": f"Hey een klassieker, die stond al in {track.from_playlist}. Geen probleem, we proberen hem nog een keer dit jaar!",
"message": f"Hey een klassieker, die stond al in {track.from_playlist}. Geen probleem, we proberen hem nog een keer dit jaar!",
"message_title": str(track)
})
return TemplateResponse(request, "Nominate.html", {
@@ -119,7 +119,7 @@ class NominateView(LoginRequiredMixin, View):
get_banter.delay(track.id)
profile.quota -= 1
profile.save()
return TemplateResponse(request, "Nominate.html", {"message_type": "nominated", "message_title": str(track), "message": "Genomineerd!"})
class SettingsView(LoginRequiredMixin, View):
@@ -153,7 +153,7 @@ class SettingsView(LoginRequiredMixin, View):
pass
finally:
return self.get(request)
class UndoView(LoginRequiredMixin, View):
def get(self, request):
return HttpResponseRedirect('/vote/')
@@ -189,7 +189,7 @@ class SkipView(LoginRequiredMixin, View):
pass
profile.last_voted = None
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()
return self.get(request)
@@ -208,7 +208,7 @@ class OverView(LoginRequiredMixin, View):
artists[artist.name][vote.track.album.name] = []
artists[artist.name][vote.track.album.name] += [vote.track]
artists_ordered = OrderedDict(sorted(artists.items()))
context = {
"artists": artists_ordered
}
@@ -293,7 +293,7 @@ class PlaylistImportView(LoginRequiredMixin, View):
print(e)
return self.get(request)
class StroopwafelView(View):
def get(self, request):
nominated_count = Track.objects.all().count()
+4
View File
@@ -5,3 +5,7 @@ python-dotenv
mozilla-django-oidc
django-htmx
celery
djangorestframework
markdown
PyYAML
Pygments