feat: implement rating
continuous-integration/drone/push Build is passing Details

This commit is contained in:
Luca 2023-12-16 01:05:05 +01:00
parent 1c5f068ea3
commit 9edc043451
14 changed files with 512 additions and 20 deletions

View File

@ -2,7 +2,7 @@ from django import forms
from django_scopes.forms import SafeModelChoiceField, SafeModelMultipleChoiceField from django_scopes.forms import SafeModelChoiceField, SafeModelMultipleChoiceField
from i18nfield.forms import I18nModelForm from i18nfield.forms import I18nModelForm
from .models import MusicrateSettings from .models import MusicrateSettings, Rating
class MusicrateSettingsForm(I18nModelForm): class MusicrateSettingsForm(I18nModelForm):
@ -42,3 +42,13 @@ class MusicrateSettingsForm(I18nModelForm):
"genre_question": SafeModelChoiceField, "genre_question": SafeModelChoiceField,
"origin_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",)

View File

@ -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"
),
),
]

View File

@ -3,6 +3,7 @@ from secrets import token_urlsafe
from django.db import models from django.db import models
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from pretalx.common.mixins.models import PretalxModel
def generate_token(): def generate_token():
@ -50,3 +51,49 @@ class MusicrateSettings(models.Model):
), ),
verbose_name=_("Advance Threshold"), 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"
),
]

View File

@ -1,16 +1,22 @@
const mayAdvance = (callback) => const mayAdvance = (callback) =>
fetch("may-advance?") fetch("may-advance?")
.then(response => response.json()) .then(response => response.json())
.then(mayAdvance => { .then(({mayAdvance, statusText}) => {
if (mayAdvance === true) { callback({mayAdvance, statusText});
callback();
}
}); });
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
setInterval(() => { setInterval(() => {
mayAdvance(() => { mayAdvance(({mayAdvance, statusText}) => {
location = document.getElementById("next").href; document.getElementById("status").innerText = statusText;
if (mayAdvance === true) {
location = document.getElementById("next").href;
}
}); });
}, 3000); }, 3000);
}, 3000); }, 3000);
mayAdvance(() => clearTimeout(timeout)); mayAdvance(({mayAdvance, statusText}) => {
document.getElementById("status").innerText = statusText;
if (mayAdvance === true) {
clearTimeout(timeout);
}
});

View File

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
viewBox="0 0 10 10.000002"
height="10.000002mm"
width="10mm"
xml:space="preserve"
version="1.1"
id="svg2"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/"><metadata
id="metadata8"><rdf:RDF><cc:Work
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /></cc:Work></rdf:RDF></metadata><defs
id="defs6"><clipPath
id="clipPath18"
clipPathUnits="userSpaceOnUse"><path
id="path20"
d="M 0,1114.8 V 0 h 1741.01 v 1114.8 z" /></clipPath></defs><path
style="fill:#231f20;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.193771"
d="M 5.0054876,0 C 2.2542107,0 0,2.2541389 0,5.0054876 0,7.7567643 2.2542107,10.000002 5.0054876,10.000002 7.7567876,10.000002 10,7.7567643 10,5.0054876 10,2.2541389 7.7567876,0 5.0054876,0 Z M 3.4712178,1.5024796 H 4.6667669 L 8.1693968,5.0054876 4.6773643,8.4971423 H 3.4602426 L 6.9628727,5.0054876 Z"
id="path22" /></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
viewBox="0 0 10 10.000002"
height="10.000002mm"
width="10mm"
xml:space="preserve"
version="1.1"
id="svg2"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/"><metadata
id="metadata8"><rdf:RDF><cc:Work
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /></cc:Work></rdf:RDF></metadata><defs
id="defs6"><clipPath
id="clipPath18"
clipPathUnits="userSpaceOnUse"><path
id="path20"
d="M 0,1114.8 V 0 h 1741.01 v 1114.8 z" /></clipPath></defs><path
style="color:#000000;fill:none;stroke:#231f20;stroke-opacity:1;stroke-width:0.1;stroke-dasharray:none"
d="M 3.5917969,1.552734 H 4.6464844 L 8.0996094,5.0058594 4.65625,8.4472656 H 3.5820313 L 7.0332031,5.0058594 Z"
id="path1699" /><path
style="color:#000000;fill:none;stroke:#231f20;stroke-opacity:1;stroke-width:0.1;stroke-dasharray:none"
d="m 5.0058594,0.05078125 c 2.7239873,0 4.9433594,2.23090925 4.9433594,4.95507815 0,2.7240961 -2.2192401,4.9433594 -4.9433594,4.9433594 -2.7240961,0 -4.95507815,-2.2193965 -4.95507815,-4.9433594 0,-2.7240356 2.23111415,-4.95507815 4.95507815,-4.95507815 z"
id="path1695" /></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -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;
}

View File

@ -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';
});

View File

@ -4,8 +4,12 @@
{% block content %} {% block content %}
<h1>{% translate "Join collective rating" %}</h1> <h1>{% translate "Join collective rating" %}</h1>
<hr> <hr>
<form method="post"> {% if juror %}
{% csrf_token %} <p>{% translate "You have already joined the collective rating, but there are no submissions yet." %}</p>
<button class="btn btn-success btn-lg btn-block" type="submit"{% if not token_valid %} disabled{% endif %}>{% translate "Join" %}</button> {% else %}
</form> <form method="post">
{% csrf_token %}
<button class="btn btn-success btn-lg btn-block" type="submit"{% if not token_valid %} disabled{% endif %}>{% translate "Join" %}</button>
</form>
{% endif %}
{% endblock %} {% endblock %}

View File

@ -1,11 +1,17 @@
{% extends "pretalx_musicrate/submission_base.html" %} {% extends "pretalx_musicrate/submission_base.html" %}
{% load compress %} {% load compress %}
{% load i18n %}
{% load static %} {% load static %}
{% block submission_content %} {% block submission_header %}
{% if next %} {% if next %}
{% compress js %} {% compress js %}
<script src="{% static "pretalx_musicrate/may-advance.js" %}"></script> <script defer src="{% static "pretalx_musicrate/may-advance.js" %}"></script>
{% endcompress %} {% endcompress %}
{% endif %} {% endif %}
{% endblock %} {% endblock %}
{% block submission_content %}
<a class="btn btn-primary btn-lg btn-block mb-3" href="{% url "plugins:pretalx_musicrate:qrcode" event=request.event.slug %}">{% translate "Show Join QR Code" %}</a>
<p class="text-center" id="status"></p>
{% endblock %}

View File

@ -1,4 +1,5 @@
{% extends "cfp/event/base.html" %} {% extends "cfp/event/base.html" %}
{% load i18n %}
{% load qrcode %} {% load qrcode %}
{% block content %} {% block content %}
@ -6,4 +7,8 @@
{% qrcode contents %} {% qrcode contents %}
{{ contents | urlize }} {{ contents | urlize }}
</div> </div>
{% if last_submission %}
<hr>
<a class="btn btn-success btn-lg btn-block" href="{% url "plugins:pretalx_musicrate:present" event=request.event.slug code=last_submission.code %}">{% translate "Start collective rating" %}</a>
{% endif %}
{% endblock %} {% endblock %}

View File

@ -0,0 +1,42 @@
{% extends "pretalx_musicrate/submission_base.html" %}
{% load compress %}
{% load i18n %}
{% load static %}
{% block submission_header %}
{% compress css %}
<link rel="stylesheet" href="{% static "pretalx_musicrate/rating.css" %}">
{% endcompress %}
{% compress js %}
<script defer src="{% static "pretalx_musicrate/rating.js" %}"></script>
{% endcompress %}
{% endblock %}
{% block submission_content %}
<form method="post">
{% csrf_token %}
<div class="d-flex justify-content-center">
<div class="rating-container">
<div class="rating" style="--num-choices: {{ form.rating | length }}">
{% for radio in form.rating %}
{{ radio.tag }}
<label class="rating-point" for="{{ radio.id_for_label }}"></label>
{% endfor %}
</div>
<div class="legend">
<span>sehr schlecht</span>
<span>sehr neutral</span>
<span>sehr gut</span>
</div>
</div>
</div>
<div class="submit-group">
<span>
<button class="btn btn-danger btn-lg" id="reset_rating" type="reset">Zurücksetzen</button>
</span>
<span>
<button class="btn btn-success btn-lg" id="submit_rating" type="submit">Speichern</button>
</span>
</div>
</form>
{% endblock %}

View File

@ -6,6 +6,7 @@ from .views import (
MusicrateSettingsView, MusicrateSettingsView,
PresenterView, PresenterView,
QRCodeView, QRCodeView,
RatingView,
) )
urlpatterns = [ urlpatterns = [
@ -26,6 +27,7 @@ urlpatterns = [
name="may_advance", name="may_advance",
), ),
path("<slug:token>/", JoinView.as_view(), name="join"), path("<slug:token>/", JoinView.as_view(), name="join"),
path("<slug:token>/<code>/", RatingView.as_view(), name="rating"),
] ]
), ),
), ),

View File

@ -2,22 +2,28 @@ from hmac import compare_digest
from django.contrib import messages from django.contrib import messages
from django.http import JsonResponse 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.urls import reverse
from django.utils.functional import cached_property 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 import FormView, TemplateView, View
from django.views.generic.detail import SingleObjectMixin from django.views.generic.detail import SingleObjectMixin
from django_context_decorator import context from django_context_decorator import context
from pretalx.common.mixins.views import EventPermissionRequired from pretalx.common.mixins.views import EventPermissionRequired
from .forms import MusicrateSettingsForm from .forms import MusicrateSettingsForm, RatingForm
from .models import Juror, Rating
class JoinView(TemplateView): class JoinView(TemplateView):
template_name = "pretalx_musicrate/join.html" template_name = "pretalx_musicrate/join.html"
def validate_token(self, token): def validate_token(self, token):
try:
self.juror = Juror.objects.get(token=token)
return True
except Juror.DoesNotExist:
self.juror = None
if compare_digest( if compare_digest(
token.encode("utf-8"), token.encode("utf-8"),
self.request.event.pretalx_musicrate_settings.join_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): def get_context_data(self, token_valid=False, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
context["juror"] = self.juror
context["token_valid"] = token_valid context["token_valid"] = token_valid
return context return context
def get(self, request, *args, token, **kwargs): def get(self, request, *args, token, **kwargs):
token_valid = self.validate_token(token) 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) return super().get(request, *args, token_valid=token_valid, **kwargs)
def post(self, request, *args, token, **kwargs): def post(self, request, *args, token, **kwargs):
token_valid = self.validate_token(token) token_valid = self.validate_token(token)
if token_valid: 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( return self.render_to_response(
self.get_context_data(token_valid=token_valid, **kwargs) 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 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): class PresenterView(EventPermissionRequired, SubmissionMixin, TemplateView):
permission_required = "orga.view_submissions" permission_required = "orga.view_submissions"
template_name = "pretalx_musicrate/present.html" template_name = "pretalx_musicrate/present.html"
@ -185,11 +293,37 @@ class PresenterView(EventPermissionRequired, SubmissionMixin, TemplateView):
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
self.object = self.get_object() 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) return super().get(request, *args, **kwargs)
class MayAdvanceView(EventPermissionRequired, View): class MayAdvanceView(EventPermissionRequired, SubmissionMixin, View):
permission_required = "orga.view_submissions" permission_required = "orga.view_submissions"
def get(self, request, *args, **kwargs): 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},
}
)