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 @@ + + 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 @@ + + 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 "You have already joined the collective rating, but there are no submissions yet." %}
+ {% else %} + + {% 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 %} +/", 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},
+ }
+ )