diff --git a/muzak/settings.py b/muzak/settings.py index 4682432..67f7ac8 100644 --- a/muzak/settings.py +++ b/muzak/settings.py @@ -14,6 +14,7 @@ DEBUG = True ALLOWED_HOSTS = ['*'] +CURRENT_HOST = 'http://127.0.0.1:8000' # Needs to be set for spotify callback to work. INSTALLED_APPS = [ 'playlist.apps.PlaylistConfig', diff --git a/playlist/models/__init__.py b/playlist/models/__init__.py index 2af94c6..7213a1b 100644 --- a/playlist/models/__init__.py +++ b/playlist/models/__init__.py @@ -3,3 +3,4 @@ from .artist import Artist from .album import Album from .vote import Vote from .profile import Profile, Background +from .playlist import Playlist diff --git a/playlist/models/playlist.py b/playlist/models/playlist.py new file mode 100644 index 0000000..ad030ea --- /dev/null +++ b/playlist/models/playlist.py @@ -0,0 +1,8 @@ +from django.db import models +from .track import Track + +class Playlist(models.Model): + def __str__(self): + return self.name + name = models.CharField(max_length=255) + tracks = models.ManyToManyField(Track) \ No newline at end of file diff --git a/playlist/models/profile.py b/playlist/models/profile.py index 192c553..ed701c5 100644 --- a/playlist/models/profile.py +++ b/playlist/models/profile.py @@ -5,6 +5,7 @@ from django.db.models.signals import post_save from django.dispatch import receiver from django.utils import timezone from datetime import datetime, timedelta +from ..spotify import spt NOMINATIONS_PER_DAY = 10 MAX_QUOTA = 22 @@ -25,6 +26,10 @@ class Profile(models.Model): quota = models.FloatField(default=10.0) quota_last_updated = models.DateTimeField(default=timezone.now, null=False) + spt_access_token = models.CharField(max_length=128, default="", null=True) + spt_refresh_token = models.CharField(max_length=128, default="", null=True) + spt_expires = models.DateTimeField(default=None, null=True) + @property def quota_display(self): return int(self.quota) @@ -38,6 +43,53 @@ class Profile(models.Model): print(f"Er zijn {elapsed.seconds}s voorbij, je nieuwe quota is {self.quota}") self.quota_last_updated = timezone.now() + def init_spt(self, code): + """Initial setting of spotify tokens from user accept code""" + data = spt.get_token_json( + grant_type='authorization_code', + code=code + ) + self.save_spt(data) + + def refresh_spt(self): + """Refreshes expired access token using stored refresh token""" + if self.spt_refresh_token: + data = spt.get_token_json( + grant_type='refresh_token', + refresh_token=self.spt_refresh_token + ) + self.save_spt(data) + + def save_spt(self, data): + """Saves tokens from json response""" + access = data.get('access_token', None) + refresh = data.get('refresh_token', self.spt_refresh_token) + expires = data.get('expires_in', 3600) + self.spt_access_token = access + 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 + self.spt_expires = None + + @property + def can_spt(self): + return ( + self.spt_access_token and + 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: + self.refresh_spt() + return self.spt_access_token + return None + def save(self, *args, **kwargs): self.update_quota() super().save(*args, **kwargs) diff --git a/playlist/models/track.py b/playlist/models/track.py index d12c6d4..7ecbc55 100644 --- a/playlist/models/track.py +++ b/playlist/models/track.py @@ -2,6 +2,7 @@ from django.db import models from random import choice from .album import Album from .artist import Artist +from .vote import Vote import re class TrackManager(models.Manager): @@ -30,6 +31,10 @@ class Track(models.Model): def link(self): return f'https://open.spotify.com/track/{self.id}' + @property + def score(self): + return Vote.objects.filter(track=self.id).aggregate(rating=models.Avg("points"))["rating"] + def is_duplicate_of(self): tracks = Track.objects.exclude(id=self.id).filter(name__startswith=self.name_sanitized) artists = [artist.name for artist in self.artists.all()] @@ -37,6 +42,7 @@ class Track(models.Model): tracks = tracks.filter(artists__name__exact=artist) return tracks.first() + id = models.CharField(primary_key=True, max_length=128) name = models.CharField(max_length=255) artists = models.ManyToManyField(Artist) diff --git a/playlist/models/vote.py b/playlist/models/vote.py index 3586528..d05712c 100644 --- a/playlist/models/vote.py +++ b/playlist/models/vote.py @@ -1,10 +1,9 @@ from django.db import models from django.contrib.auth.models import User -from . import Track class Vote(models.Model): class Meta: 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) points = models.IntegerField() diff --git a/playlist/spotify.py b/playlist/spotify.py index 30f7ba4..ee530be 100644 --- a/playlist/spotify.py +++ b/playlist/spotify.py @@ -3,46 +3,75 @@ import os import re from datetime import datetime, timedelta from dotenv import load_dotenv +from django.urls import reverse +from django.conf import settings load_dotenv() + class Spotify(): def __init__(self): self.token = None self.valid_until = None + def redirect_uri(self): + return settings.CURRENT_HOST + reverse('spotify-callback') + def token_valid(self): if self.token and self.valid_until and (datetime.now() < self.valid_until): return True return False - def get_token(self): - if self.token_valid(): - print("Token still valid") - return self.token - print("New token") + def get_token_json(self, grant_type, code=None, refresh_token=None): url = 'https://accounts.spotify.com/api/token' headers = { 'Content-Type': 'application/x-www-form-urlencoded', 'Authorization': 'Basic ' + os.getenv('SPOTIFY_ENCODED_ID') } data = { - 'grant_type': 'client_credentials' + 'grant_type': grant_type } + if code: + data['code'] = code + data['redirect_uri'] = self.redirect_uri() + if refresh_token: + data['refresh_token'] = refresh_token r = requests.post(url, headers=headers, data=data) - data = r.json() + return r.json() + + def get_system_token(self): + if self.token_valid(): + return self.token + data = self.get_token_json('client_credentials') self.token = data["access_token"] self.valid_until = datetime.now() + timedelta(seconds=data["expires_in"]-300) return self.token + def get_user_token(self, code): + data = self.get_token_json('authorization_code', code, redirect=True) + print(data) + return data + return (data.get('access_token', None), data.get('refresh_token', None)) + + def get_oauth_redirect(self): + scopes = ['user-read-private', 'user-read-email'] + CLIENT_ID = 'bcc523219d1d4248a7e8892e809a5767' #TODO: delet. + url = f'https://accounts.spotify.com/authorize?' + url += f'client_id={CLIENT_ID}&' + url += f'response_type=code&' + url += f'scope={"%20".join(scopes)}&' + url += f'redirect_uri={self.redirect_uri()}' + print(url) + return url + def unshort(self, url): - """Converts a spotify.link url into a normal spotify ID""" + """Converts a spotify.link url into a normal spotify URL with ID""" r = requests.get(url, allow_redirects=False) r.raise_for_status() return r.headers["Location"] def get_song_info(self, spotify_id): - token = self.get_token() + token = self.get_system_token() url = 'https://api.spotify.com/v1/tracks/' + spotify_id headers = { 'Content-Type': 'application/json', @@ -51,3 +80,15 @@ class Spotify(): r = requests.get(url, headers=headers) r.raise_for_status() return r.json() + + def get_profile(self, access_token): + url = 'https://api.spotify.com/v1/me' + headers = { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer ' + access_token + } + r = requests.get(url, headers=headers) + r.raise_for_status() + return r.json() + +spt = Spotify() \ No newline at end of file diff --git a/playlist/static/img/spotify-cancel.png b/playlist/static/img/spotify-cancel.png new file mode 100644 index 0000000..827afbd Binary files /dev/null and b/playlist/static/img/spotify-cancel.png differ diff --git a/playlist/static/img/spotify.png b/playlist/static/img/spotify.png new file mode 100644 index 0000000..01417df Binary files /dev/null and b/playlist/static/img/spotify.png differ diff --git a/playlist/static/style.css b/playlist/static/style.css index e4aeb29..b5d850d 100644 --- a/playlist/static/style.css +++ b/playlist/static/style.css @@ -477,11 +477,27 @@ input[type="range"]#volume::-webkit-slider-thumb { margin-bottom: 8px; } -.settings .admin-panel { - background-image: url(img/gears.png); +.settings .icon { height: 40px; background-repeat: no-repeat; background-position: 50% 50%; width: 40px; min-width: 0; +} + +.settings .admin-panel { + background-image: url(img/gears.png); +} + +.settings .spotify { + background-image: url(img/spotify.png); + background-size: 32px; +} + +.settings #spotify-cancel { + background-image: url(img/spotify-cancel.png); + background-size: 16px; + background-position: 50% 50%; + background-repeat: no-repeat; + min-width: 0; } \ No newline at end of file diff --git a/playlist/templates/PlaylistMaker.html b/playlist/templates/PlaylistMaker.html new file mode 100644 index 0000000..c3ac988 --- /dev/null +++ b/playlist/templates/PlaylistMaker.html @@ -0,0 +1,22 @@ +{% extends "Base.html" %} + +{% block window-title %} +Nomineren +{% endblock %} + +{% block main-content %} + +{% if tracks %} + {% for track in tracks %} +

{{track}}: {{track.score}}

+ {% endfor %} +{% else %} +
+ {% csrf_token %} + Hoe veel nummers wil je? + + +
+{% endif %} + +{% endblock %} \ No newline at end of file diff --git a/playlist/templates/Settings.html b/playlist/templates/Settings.html index 2654aa1..93da805 100644 --- a/playlist/templates/Settings.html +++ b/playlist/templates/Settings.html @@ -13,6 +13,24 @@ Instellingen + {% if sptDisplayName %} +
+ {% csrf_token %} +
+ + {{sptDisplayName}} + + +
+
+ {% else %} +
+
+ + +
+
+ {% endif %} {% endif %}
{% csrf_token %} @@ -32,9 +50,8 @@ Instellingen {% endif %}
-
- - {{user.profile.quota|floatformat:3}} -
- +
+ + {{user.profile.quota|floatformat:3}} +
{% endblock %} diff --git a/playlist/templates/Vote.html b/playlist/templates/Vote.html index 3b2a562..dfceba4 100644 --- a/playlist/templates/Vote.html +++ b/playlist/templates/Vote.html @@ -18,18 +18,6 @@ Stemmen
Hoeveel sterren geef jij dit?
- - - + + +
{% if undo %}
{% csrf_token %} -

Je hebt {{undo.track}} een score van {{undo.points | add:3}} sterren gegeven!

+

Je hebt {{undo.track}} een score van {{undo.points}} sterren gegeven!

{% endif %} diff --git a/playlist/urls.py b/playlist/urls.py index 140d926..0e04732 100644 --- a/playlist/urls.py +++ b/playlist/urls.py @@ -8,6 +8,9 @@ urlpatterns = [ path('vote/', views.VoteView.as_view(), name="vote"), path('vote/undo', views.UndoView.as_view(), name="undo"), path('settings/', views.SettingsView.as_view(), name="settings"), + path('settings/make-playlist', views.PlaylistMake.as_view(), name="playlist"), path('overview/', views.OverView.as_view(), name="overview"), + path('spotify/', views.SpotifyView.as_view(), name="spotify"), + path('spotify/callback', views.SpotifyCallbackView.as_view(), name="spotify-callback"), path('', views.AuthView.as_view()), ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/playlist/views.py b/playlist/views.py index a453b24..9836eeb 100644 --- a/playlist/views.py +++ b/playlist/views.py @@ -3,15 +3,15 @@ from django.views import View from django.http import HttpResponse, HttpResponseRedirect from django.template.response import TemplateResponse from django.contrib.auth.mixins import LoginRequiredMixin +from django.db.models import Avg +from django.urls import reverse import re from collections import OrderedDict from .models import Track, Artist, Album, Vote, Background, Profile from .utils import from_json, get_unvoted -from . import spotify +from .spotify import spt from .tasks import get_banter, get_and_dither_image -spt = spotify.Spotify() - class AuthView(View): def get(self, request): if request.user.is_authenticated: @@ -29,7 +29,7 @@ class VoteView(LoginRequiredMixin, View): try: spotify_id = self.request.POST['spotify_id'] score = int(self.request.POST['vote']) - score = min(max(-2, score), 2) + score = min(max(1, score), 5) track = Track.objects.get(pk=spotify_id) try: Vote.objects.get(user=request.user, track=track) @@ -82,7 +82,8 @@ class NominateView(LoginRequiredMixin, View): except Track.DoesNotExist: try: json = spt.get_song_info(spotify_id) - except: + except Exception as e: + print(e) return TemplateResponse(request, "Nominate.html", {"error": "Fout bij het ophalen van het liedje."}) # Album artists @@ -133,12 +134,22 @@ class SettingsView(LoginRequiredMixin, View): "backgrounds": backgrounds, "currentBackground": (profile.background.id if profile.background else 1) } + spt_token = profile.get_or_update_spt() + if spt_token: + context["sptDisplayName"] = spt.get_profile(spt_token).get('display_name') + return TemplateResponse(request, "Settings.html", context) def post(self, request): try: - bg = Background.objects.get(pk=request.POST['background']) + bg = request.POST.get('background', None) + unlink_spotify = request.POST.get('unlink-spotify', None) (profile,_) = Profile.objects.get_or_create(user=request.user) - profile.background = bg + if bg: + bg = Background.objects.get(pk=request.POST['background']) + profile.background = bg + if unlink_spotify: + print("unlink spt") + profile.unlink_spt() profile.save() except: pass @@ -183,4 +194,46 @@ class OverView(LoginRequiredMixin, View): context = { "artists": artists_ordered } - return TemplateResponse(request, "Overview.html", context) \ No newline at end of file + return TemplateResponse(request, "Overview.html", context) + +class PlaylistMake(LoginRequiredMixin, View): + def get(self, request, count=None): + context = {} + if count: + #tracks = Track.objects.order_by('score')[count] + tracks = Track.objects.annotate(average_points=Avg('vote__points')).order_by('-average_points')[:count] + context["tracks"] = tracks + return TemplateResponse(request, "PlaylistMaker.html", context) + def post(self, request): + try: + count = int(self.request.POST['count']) + return self.get(request, count=count) + except Exception as e: + print(e) + return self.get(request) + +class SpotifyView(LoginRequiredMixin, View): + def get(self, request): + url = spt.get_oauth_redirect() + return HttpResponseRedirect(url) + +class SpotifyCallbackView(LoginRequiredMixin, View): + def get(self, request): + error = self.request.GET.get('error') + code = self.request.GET.get('code') + if error: + return TemplateResponse(request, "Nominate.html", {"error": "Je moet wel accepteren, pannenkoek."}) + elif code: + (profile,_) = Profile.objects.get_or_create(user=request.user) + profile.init_spt(code) + # data = spt.get_user_token(code) + # access = data.get('access_token', None) + # refresh = data.get('refresh_token', None) + # expires = data.get('expires_in', 3600) + + # if access and refresh: + # profile.set_spotify(access, refresh, expires) + # else: + # return TemplateResponse(request, "Nominate.html", {"error": "No."}) + return HttpResponseRedirect(reverse('settings')) + #return TemplateResponse(request, "Nominate.html", {"warning": code})