From 9edc04345186a60feff0fae003a168201331863b Mon Sep 17 00:00:00 2001 From: Luca Date: Sat, 16 Dec 2023 01:05:05 +0100 Subject: [PATCH] feat: implement rating --- pretalx_musicrate/forms.py | 12 +- ...ettings_last_submission_rating_and_more.py | 103 ++++++++++++ pretalx_musicrate/models.py | 47 ++++++ .../static/pretalx_musicrate/may-advance.js | 20 ++- .../static/pretalx_musicrate/point-fill.svg | 24 +++ .../static/pretalx_musicrate/point-stroke.svg | 27 ++++ .../static/pretalx_musicrate/rating.css | 55 +++++++ .../static/pretalx_musicrate/rating.js | 27 ++++ .../templates/pretalx_musicrate/join.html | 12 +- .../templates/pretalx_musicrate/present.html | 10 +- .../templates/pretalx_musicrate/qrcode.html | 5 + .../templates/pretalx_musicrate/rating.html | 42 +++++ pretalx_musicrate/urls.py | 2 + pretalx_musicrate/views.py | 146 +++++++++++++++++- 14 files changed, 512 insertions(+), 20 deletions(-) create mode 100644 pretalx_musicrate/migrations/0005_juror_musicratesettings_last_submission_rating_and_more.py create mode 100644 pretalx_musicrate/static/pretalx_musicrate/point-fill.svg create mode 100644 pretalx_musicrate/static/pretalx_musicrate/point-stroke.svg create mode 100644 pretalx_musicrate/static/pretalx_musicrate/rating.css create mode 100644 pretalx_musicrate/static/pretalx_musicrate/rating.js create mode 100644 pretalx_musicrate/templates/pretalx_musicrate/rating.html diff --git a/pretalx_musicrate/forms.py b/pretalx_musicrate/forms.py index 553c1e9..96ec3fb 100644 --- a/pretalx_musicrate/forms.py +++ b/pretalx_musicrate/forms.py @@ -2,7 +2,7 @@ from django import forms from django_scopes.forms import SafeModelChoiceField, SafeModelMultipleChoiceField from i18nfield.forms import I18nModelForm -from .models import MusicrateSettings +from .models import MusicrateSettings, Rating class MusicrateSettingsForm(I18nModelForm): @@ -42,3 +42,13 @@ class MusicrateSettingsForm(I18nModelForm): "genre_question": SafeModelChoiceField, "origin_question": SafeModelChoiceField, } + + +class RatingForm(forms.ModelForm): + rating = forms.ChoiceField( + choices=Rating.RATING_CHOICES, required=False, widget=forms.RadioSelect + ) + + class Meta: + model = Rating + fields = ("rating",) diff --git a/pretalx_musicrate/migrations/0005_juror_musicratesettings_last_submission_rating_and_more.py b/pretalx_musicrate/migrations/0005_juror_musicratesettings_last_submission_rating_and_more.py new file mode 100644 index 0000000..15ad5bc --- /dev/null +++ b/pretalx_musicrate/migrations/0005_juror_musicratesettings_last_submission_rating_and_more.py @@ -0,0 +1,103 @@ +# Generated by Django 4.2.8 on 2023-12-15 18:27 + +import django.db.models.deletion +import pretalx.common.mixins.models +from django.db import migrations, models + +import pretalx_musicrate.models + + +class Migration(migrations.Migration): + dependencies = [ + ("submission", "0074_created_updated_everywhere"), + ("event", "0035_created_updated_everywhere"), + ("pretalx_musicrate", "0004_musicratesettings_advance_threshold"), + ] + + operations = [ + migrations.CreateModel( + name="Juror", + fields=[ + ("created", models.DateTimeField(auto_now_add=True, null=True)), + ("updated", models.DateTimeField(auto_now=True, null=True)), + ( + "token", + models.CharField( + default=pretalx_musicrate.models.generate_token, + max_length=43, + primary_key=True, + serialize=False, + ), + ), + ( + "event", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="jurors", + to="event.event", + ), + ), + ( + "last_submission", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="jurors", + to="submission.submission", + ), + ), + ], + options={ + "abstract": False, + }, + bases=( + pretalx.common.mixins.models.LogMixin, + pretalx.common.mixins.models.FileCleanupMixin, + models.Model, + ), + ), + migrations.AddField( + model_name="musicratesettings", + name="last_submission", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="pretalx_musicrate_settings", + to="submission.submission", + ), + ), + migrations.CreateModel( + name="Rating", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, primary_key=True, serialize=False + ), + ), + ("rating", models.CharField(max_length=2)), + ( + "juror", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="ratings", + to="pretalx_musicrate.juror", + ), + ), + ( + "submission", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="ratings", + to="submission.submission", + ), + ), + ], + ), + migrations.AddConstraint( + model_name="rating", + constraint=models.UniqueConstraint( + fields=("submission", "juror"), name="unique_rating" + ), + ), + ] diff --git a/pretalx_musicrate/models.py b/pretalx_musicrate/models.py index dac3e0a..0e537b1 100644 --- a/pretalx_musicrate/models.py +++ b/pretalx_musicrate/models.py @@ -3,6 +3,7 @@ from secrets import token_urlsafe from django.db import models from django.utils.translation import gettext_lazy as _ +from pretalx.common.mixins.models import PretalxModel def generate_token(): @@ -50,3 +51,49 @@ class MusicrateSettings(models.Model): ), verbose_name=_("Advance Threshold"), ) + last_submission = models.ForeignKey( + "submission.Submission", + on_delete=models.SET_NULL, + related_name="pretalx_musicrate_settings", + null=True, + ) + + +class Juror(PretalxModel): + event = models.ForeignKey( + "event.Event", on_delete=models.CASCADE, related_name="jurors" + ) + token = models.CharField(max_length=43, default=generate_token, primary_key=True) + last_submission = models.ForeignKey( + "submission.Submission", + on_delete=models.SET_NULL, + related_name="jurors", + null=True, + ) + + +class Rating(models.Model): + RATING_CHOICES = [ + ("10", "sehr gut"), + ("9", "ziemlich gut"), + ("8", "gut"), + ("7", "eher gut"), + ("6", "ok"), + ("5", "naja"), + ("4", "eher schlecht"), + ("3", "schlecht"), + ("2", "ziemlich schlecht"), + ("1", "sehr schlecht"), + ] + rating = models.CharField(max_length=2, choices=RATING_CHOICES, blank=True) + submission = models.ForeignKey( + "submission.Submission", on_delete=models.CASCADE, related_name="ratings" + ) + juror = models.ForeignKey(Juror, on_delete=models.CASCADE, related_name="ratings") + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=("submission", "juror"), name="unique_rating" + ), + ] diff --git a/pretalx_musicrate/static/pretalx_musicrate/may-advance.js b/pretalx_musicrate/static/pretalx_musicrate/may-advance.js index 92983fa..3bd19f1 100644 --- a/pretalx_musicrate/static/pretalx_musicrate/may-advance.js +++ b/pretalx_musicrate/static/pretalx_musicrate/may-advance.js @@ -1,16 +1,22 @@ const mayAdvance = (callback) => fetch("may-advance?") .then(response => response.json()) - .then(mayAdvance => { - if (mayAdvance === true) { - callback(); - } + .then(({mayAdvance, statusText}) => { + callback({mayAdvance, statusText}); }); const timeout = setTimeout(() => { setInterval(() => { - mayAdvance(() => { - location = document.getElementById("next").href; + mayAdvance(({mayAdvance, statusText}) => { + document.getElementById("status").innerText = statusText; + if (mayAdvance === true) { + location = document.getElementById("next").href; + } }); }, 3000); }, 3000); -mayAdvance(() => clearTimeout(timeout)); +mayAdvance(({mayAdvance, statusText}) => { + document.getElementById("status").innerText = statusText; + if (mayAdvance === true) { + clearTimeout(timeout); + } +}); diff --git a/pretalx_musicrate/static/pretalx_musicrate/point-fill.svg b/pretalx_musicrate/static/pretalx_musicrate/point-fill.svg new file mode 100644 index 0000000..59f206f --- /dev/null +++ b/pretalx_musicrate/static/pretalx_musicrate/point-fill.svg @@ -0,0 +1,24 @@ + +image/svg+xml diff --git a/pretalx_musicrate/static/pretalx_musicrate/point-stroke.svg b/pretalx_musicrate/static/pretalx_musicrate/point-stroke.svg new file mode 100644 index 0000000..4f54266 --- /dev/null +++ b/pretalx_musicrate/static/pretalx_musicrate/point-stroke.svg @@ -0,0 +1,27 @@ + +image/svg+xml diff --git a/pretalx_musicrate/static/pretalx_musicrate/rating.css b/pretalx_musicrate/static/pretalx_musicrate/rating.css new file mode 100644 index 0000000..8a086a8 --- /dev/null +++ b/pretalx_musicrate/static/pretalx_musicrate/rating.css @@ -0,0 +1,55 @@ +.rating-container { + display: flex; + flex-direction: column; + margin-bottom: 1rem; +} + +.rating-container > .legend { + display: flex; + font-size: 0.8em; +} + +.rating-container > .legend > span { + flex: 1 0; + text-align: center; +} + +.rating-container > .legend > :first-child { + text-align: left; +} + +.rating-container > .legend > :last-child { + text-align: right; +} + +.rating-container > .rating { + display: flex; + flex-direction: row-reverse; + justify-content: center; +} + +.rating-container > .rating > input[type=radio] { + display: none; +} + +.rating-container > .rating > input[type=radio]:checked ~ .rating-point { + background-image: url(point-fill.svg); +} + +.rating-container > .rating > .rating-point { + --max-size: calc((100vw - 2em - 2 * 0.1em * var(--num-choices)) / var(--num-choices)); + background: url(point-stroke.svg) no-repeat center/100%; + height: 3em; + margin: 0 0.1em; + max-height: var(--max-size); + max-width: var(--max-size); + width: 3em; +} + +.rating-container > .rating > .rating-point:first-of-type { + margin-right: 0; +} + +.rating-container > .rating > .rating-point:last-of-type { + margin-left: 0; +} diff --git a/pretalx_musicrate/static/pretalx_musicrate/rating.js b/pretalx_musicrate/static/pretalx_musicrate/rating.js new file mode 100644 index 0000000..c4554a9 --- /dev/null +++ b/pretalx_musicrate/static/pretalx_musicrate/rating.js @@ -0,0 +1,27 @@ +const options = document.querySelectorAll('input[name=rating]'); +const submitRating = document.getElementById('submit_rating'); + +let empty = true; +options.forEach(option => { + empty &&= !option.checked; +}); + +if (empty) { + submitRating.innerText = 'Enthalten'; + + options.forEach(option => { + option.addEventListener('change', () => { + submitRating.innerText = 'Speichern'; + }); + }); +} + +document.getElementById('reset_rating').addEventListener('click', () => { + options.forEach(option => { + if (option.attributes.getNamedItem('checked') !== null) { + option.attributes.removeNamedItem('checked'); + } + }); + + submitRating.innerText = 'Enthalten'; +}); diff --git a/pretalx_musicrate/templates/pretalx_musicrate/join.html b/pretalx_musicrate/templates/pretalx_musicrate/join.html index b397055..2bc2dc9 100644 --- a/pretalx_musicrate/templates/pretalx_musicrate/join.html +++ b/pretalx_musicrate/templates/pretalx_musicrate/join.html @@ -4,8 +4,12 @@ {% block content %}

{% translate "Join collective rating" %}


-
- {% csrf_token %} - -
+ {% if juror %} +

{% translate "You have already joined the collective rating, but there are no submissions yet." %}

+ {% else %} +
+ {% csrf_token %} + +
+ {% endif %} {% endblock %} diff --git a/pretalx_musicrate/templates/pretalx_musicrate/present.html b/pretalx_musicrate/templates/pretalx_musicrate/present.html index 35398ad..ba384b8 100644 --- a/pretalx_musicrate/templates/pretalx_musicrate/present.html +++ b/pretalx_musicrate/templates/pretalx_musicrate/present.html @@ -1,11 +1,17 @@ {% extends "pretalx_musicrate/submission_base.html" %} {% load compress %} +{% load i18n %} {% load static %} -{% block submission_content %} +{% block submission_header %} {% if next %} {% compress js %} - + {% endcompress %} {% endif %} {% endblock %} + +{% block submission_content %} + {% translate "Show Join QR Code" %} +

+{% endblock %} diff --git a/pretalx_musicrate/templates/pretalx_musicrate/qrcode.html b/pretalx_musicrate/templates/pretalx_musicrate/qrcode.html index 7444a2f..7d78c98 100644 --- a/pretalx_musicrate/templates/pretalx_musicrate/qrcode.html +++ b/pretalx_musicrate/templates/pretalx_musicrate/qrcode.html @@ -1,4 +1,5 @@ {% extends "cfp/event/base.html" %} +{% load i18n %} {% load qrcode %} {% block content %} @@ -6,4 +7,8 @@ {% qrcode contents %} {{ contents | urlize }} + {% if last_submission %} +
+ {% translate "Start collective rating" %} + {% endif %} {% endblock %} diff --git a/pretalx_musicrate/templates/pretalx_musicrate/rating.html b/pretalx_musicrate/templates/pretalx_musicrate/rating.html new file mode 100644 index 0000000..b2b07b6 --- /dev/null +++ b/pretalx_musicrate/templates/pretalx_musicrate/rating.html @@ -0,0 +1,42 @@ +{% extends "pretalx_musicrate/submission_base.html" %} +{% load compress %} +{% load i18n %} +{% load static %} + +{% block submission_header %} + {% compress css %} + + {% endcompress %} + {% compress js %} + + {% endcompress %} +{% endblock %} + +{% block submission_content %} +
+ {% csrf_token %} +
+
+
+ {% for radio in form.rating %} + {{ radio.tag }} + + {% endfor %} +
+
+ sehr schlecht + sehr neutral + sehr gut +
+
+
+
+ + + + + + +
+
+{% endblock %} diff --git a/pretalx_musicrate/urls.py b/pretalx_musicrate/urls.py index 8036fde..75ef4fd 100644 --- a/pretalx_musicrate/urls.py +++ b/pretalx_musicrate/urls.py @@ -6,6 +6,7 @@ from .views import ( MusicrateSettingsView, PresenterView, QRCodeView, + RatingView, ) urlpatterns = [ @@ -26,6 +27,7 @@ urlpatterns = [ name="may_advance", ), path("/", JoinView.as_view(), name="join"), + path("//", RatingView.as_view(), name="rating"), ] ), ), diff --git a/pretalx_musicrate/views.py b/pretalx_musicrate/views.py index 073da58..c1fd85d 100644 --- a/pretalx_musicrate/views.py +++ b/pretalx_musicrate/views.py @@ -2,22 +2,28 @@ from hmac import compare_digest from django.contrib import messages from django.http import JsonResponse -from django.shortcuts import redirect +from django.shortcuts import get_object_or_404, redirect from django.urls import reverse from django.utils.functional import cached_property -from django.utils.translation import gettext_lazy as _ +from django.utils.translation import gettext_lazy as _, ngettext from django.views.generic import FormView, TemplateView, View from django.views.generic.detail import SingleObjectMixin from django_context_decorator import context from pretalx.common.mixins.views import EventPermissionRequired -from .forms import MusicrateSettingsForm +from .forms import MusicrateSettingsForm, RatingForm +from .models import Juror, Rating class JoinView(TemplateView): template_name = "pretalx_musicrate/join.html" def validate_token(self, token): + try: + self.juror = Juror.objects.get(token=token) + return True + except Juror.DoesNotExist: + self.juror = None if compare_digest( token.encode("utf-8"), self.request.event.pretalx_musicrate_settings.join_token.encode("utf-8"), @@ -28,17 +34,55 @@ class JoinView(TemplateView): def get_context_data(self, token_valid=False, **kwargs): context = super().get_context_data(**kwargs) + context["juror"] = self.juror context["token_valid"] = token_valid return context def get(self, request, *args, token, **kwargs): token_valid = self.validate_token(token) + if self.juror is not None: + submission = ( + self.juror.last_submission + or self.request.event.pretalx_musicrate_settings.last_submission + or self.request.event.submissions.filter( + submission_type__in=self.request.event.pretalx_musicrate_settings.submission_types.all() + ) + .order_by("created") + .first() + ) + if submission is not None: + return redirect( + "plugins:pretalx_musicrate:rating", + event=self.request.event.slug, + token=self.juror.token, + code=submission.code, + ) return super().get(request, *args, token_valid=token_valid, **kwargs) def post(self, request, *args, token, **kwargs): token_valid = self.validate_token(token) if token_valid: - return redirect(request.path) + if self.juror is None: + self.juror = Juror.objects.create( + event=self.request.event, + last_submission=self.request.event.pretalx_musicrate_settings.last_submission, + ) + submission = ( + self.juror.last_submission + or self.request.event.pretalx_musicrate_settings.last_submission + or self.request.event.submissions.filter( + submission_type__in=self.request.event.pretalx_musicrate_settings.submission_types.all() + ) + .order_by("created") + .first() + ) + if submission is not None: + return redirect( + "plugins:pretalx_musicrate:rating", + event=self.request.event.slug, + token=self.juror.token, + code=submission.code, + ) return self.render_to_response( self.get_context_data(token_valid=token_valid, **kwargs) ) @@ -80,6 +124,14 @@ class QRCodeView(EventPermissionRequired, TemplateView): }, ) ) + context["last_submission"] = ( + self.request.event.pretalx_musicrate_settings.last_submission + or self.request.event.submissions.filter( + submission_type__in=self.request.event.pretalx_musicrate_settings.submission_types.all() + ) + .order_by("created") + .first() + ) return context @@ -174,6 +226,62 @@ class SubmissionMixin(SingleObjectMixin): ) +class RatingView(FormView, SubmissionMixin): + template_name = "pretalx_musicrate/rating.html" + form_class = RatingForm + + @cached_property + def juror(self): + return get_object_or_404( + Juror, token=self.request.resolver_match.kwargs["token"] + ) + + @context + @cached_property + def can_continue(self): + return self.submission.ratings.filter(juror=self.juror).exists() + + def get_form(self, form_class=None): + if form_class is None: + form_class = self.get_form_class() + try: + instance = Rating.objects.get(submission=self.submission, juror=self.juror) + except Rating.DoesNotExist: + instance = None + return form_class(instance=instance, **self.get_form_kwargs()) + + def get_success_url(self): + return self.request.path + + def form_valid(self, form): + obj = form.save(commit=False) + obj.submission = self.submission + obj.juror = self.juror + obj.save() + try: + self.juror.last_submission = self.submission + self.juror.save() + except Exception: + pass + messages.success(self.request, _("Saved!")) + return super().form_valid(form) + + def get(self, request, *args, **kwargs): + self.object = self.get_object() + if ( + (settings := self.request.event.pretalx_musicrate_settings).last_submission + is not None + and self.object.created > settings.last_submission.created + ): + return redirect( + self.request.resolver_match.view_name, + event=self.request.event.slug, + token=self.juror.token, + code=settings.last_submission.code, + ) + return super().get(request, *args, **kwargs) + + class PresenterView(EventPermissionRequired, SubmissionMixin, TemplateView): permission_required = "orga.view_submissions" template_name = "pretalx_musicrate/present.html" @@ -185,11 +293,37 @@ class PresenterView(EventPermissionRequired, SubmissionMixin, TemplateView): def get(self, request, *args, **kwargs): self.object = self.get_object() + if ( + (settings := self.request.event.pretalx_musicrate_settings).last_submission + is None + or self.object.created > settings.last_submission.created + ): + try: + settings.last_submission = self.object + settings.save() + except Exception: + pass return super().get(request, *args, **kwargs) -class MayAdvanceView(EventPermissionRequired, View): +class MayAdvanceView(EventPermissionRequired, SubmissionMixin, View): permission_required = "orga.view_submissions" def get(self, request, *args, **kwargs): - return JsonResponse(True, safe=False) + num_ratings = self.submission.ratings.count() + num_jurors = self.request.event.jurors.count() + return JsonResponse( + { + "mayAdvance": num_ratings + >= int( + num_jurors + * self.request.event.pretalx_musicrate_settings.advance_threshold + ), + "statusText": ngettext( + "%(num_ratings)d of %(num_jurors)d has rated this submission", + "%(num_ratings)d of %(num_jurors)d have rated this submission", + num_ratings, + ) + % {"num_jurors": num_jurors, "num_ratings": num_ratings}, + } + )