From df7885878872ae8c738eb4759c28629a2e168770 Mon Sep 17 00:00:00 2001 From: Lakshy Gupta Date: Wed, 31 Mar 2021 22:57:16 -0400 Subject: [PATCH] :monkachrist: --- dmoj/settings.py | 6 + judge/admin/__init__.py | 4 +- judge/admin/profile.py | 9 +- judge/admin/vote.py | 18 + judge/comments.py | 9 +- judge/forms.py | 10 +- .../0116_add_voting_functionality.py | 34 ++ judge/models/__init__.py | 1 + judge/models/problem_points_vote.py | 38 ++ judge/models/profile.py | 6 + judge/views/problem.py | 128 +++++- .../admin/judge/problem/change_form.html | 5 + templates/comments/list.html | 1 + templates/lightbox.html | 58 +++ templates/problem/problem.html | 144 +++++++ templates/voting-stats.html | 391 ++++++++++++++++++ 16 files changed, 852 insertions(+), 10 deletions(-) create mode 100644 judge/admin/vote.py create mode 100644 judge/migrations/0116_add_voting_functionality.py create mode 100644 judge/models/problem_points_vote.py create mode 100644 templates/lightbox.html create mode 100644 templates/voting-stats.html diff --git a/dmoj/settings.py b/dmoj/settings.py index fafda889b3..641915a0b5 100644 --- a/dmoj/settings.py +++ b/dmoj/settings.py @@ -63,6 +63,8 @@ DMOJ_PROBLEM_MIN_MEMORY_LIMIT = 0 # kilobytes DMOJ_PROBLEM_MAX_MEMORY_LIMIT = 1048576 # kilobytes DMOJ_PROBLEM_MIN_PROBLEM_POINTS = 0 +DMOJ_PROBLEM_MIN_USER_POINTS_VOTE = 0 +DMOJ_PROBLEM_MAX_USER_POINTS_VOTE = 50 DMOJ_PROBLEM_HOT_PROBLEM_COUNT = 7 DMOJ_RATING_COLORS = True DMOJ_EMAIL_THROTTLING = (10, 60) @@ -181,6 +183,10 @@ 'judge.Judge', ], }, + { + 'model': 'judge.ProblemPointsVote', + 'icon': 'fa-envelope', + }, { 'model': 'judge.Contest', 'icon': 'fa-bar-chart', diff --git a/judge/admin/__init__.py b/judge/admin/__init__.py index 8980c351b1..16d6785953 100644 --- a/judge/admin/__init__.py +++ b/judge/admin/__init__.py @@ -12,9 +12,10 @@ from judge.admin.submission import SubmissionAdmin from judge.admin.taxon import ProblemGroupAdmin, ProblemTypeAdmin from judge.admin.ticket import TicketAdmin +from judge.admin.vote import VoteAdmin from judge.models import BlogPost, Comment, CommentLock, Contest, ContestParticipation, \ ContestTag, Judge, Language, License, MiscConfig, NavigationBar, Organization, \ - OrganizationRequest, Problem, ProblemGroup, ProblemType, Profile, Submission, Ticket + OrganizationRequest, Problem, ProblemGroup, ProblemPointsVote, ProblemType, Profile, Submission, Ticket admin.site.register(BlogPost, BlogPostAdmin) admin.site.register(Comment, CommentAdmin) @@ -38,3 +39,4 @@ admin.site.register(Profile, ProfileAdmin) admin.site.register(Submission, SubmissionAdmin) admin.site.register(Ticket, TicketAdmin) +admin.site.register(ProblemPointsVote, VoteAdmin) diff --git a/judge/admin/profile.py b/judge/admin/profile.py index d9119011c2..b4e5841168 100644 --- a/judge/admin/profile.py +++ b/judge/admin/profile.py @@ -45,9 +45,12 @@ def queryset(self, request, queryset): class ProfileAdmin(NoBatchDeleteMixin, VersionAdmin): - fields = ('user', 'display_rank', 'about', 'organizations', 'timezone', 'language', 'ace_theme', - 'math_engine', 'last_access', 'ip', 'mute', 'is_unlisted', 'notes', 'is_totp_enabled', 'user_script', - 'current_contest') + fields = ( + 'user', 'display_rank', 'about', 'organizations', 'timezone', + 'language', 'ace_theme', 'math_engine', 'last_access', + 'ip', 'mute', 'is_unlisted', 'is_banned_from_voting_problem_points', + 'notes', 'is_totp_enabled', 'user_script', 'current_contest', + ) readonly_fields = ('user',) list_display = ('admin_user_admin', 'email', 'is_totp_enabled', 'timezone_full', 'date_joined', 'last_access', 'ip', 'show_public') diff --git a/judge/admin/vote.py b/judge/admin/vote.py new file mode 100644 index 0000000000..fc72e8c37b --- /dev/null +++ b/judge/admin/vote.py @@ -0,0 +1,18 @@ + +from django.contrib import admin + + +class VoteAdmin(admin.ModelAdmin): + list_display = ('points', 'voter', 'problem', 'note') + search_fields = ('voter', 'problem') + + # if the user has edit all problem or edit own problem perms, so curators authors and superusers + def has_change_permission(self, request, obj=None): + if not request.user.has_perm('judge.edit_own_problem'): + return False + if request.user.has_perm('judge.edit_all_problem') or obj is None: + return True + return obj.problem.is_editor(request.profile) + + def lookup_allowed(self, key, value): + return super(VoteAdmin, self).lookup_allowed(key, value) or key in ('problem__code',) diff --git a/judge/comments.py b/judge/comments.py index b83aed5033..6dad26da77 100644 --- a/judge/comments.py +++ b/judge/comments.py @@ -101,7 +101,7 @@ def get(self, request, *args, **kwargs): self.object = self.get_object() return self.render_to_response(self.get_context_data( object=self.object, - comment_form=CommentForm(request, initial={'page': self.get_comment_page(), 'parent': None}), + comment_request=request, )) def get_context_data(self, **kwargs): @@ -119,4 +119,11 @@ def get_context_data(self, **kwargs): context['comment_list'] = queryset context['vote_hide_threshold'] = settings.DMOJ_COMMENT_VOTE_HIDE_THRESHOLD + # if we have to create a new comment form, the form's 'request' is guaranteed to have been given by GET or POST + if 'comment_form' not in context: + context['comment_form'] = CommentForm( + context['comment_request'], + initial={'page': self.get_comment_page(), 'parent': None}, + ) + return context diff --git a/judge/forms.py b/judge/forms.py index 2979b7d165..fab975d162 100644 --- a/judge/forms.py +++ b/judge/forms.py @@ -14,8 +14,8 @@ from django.utils.translation import gettext_lazy as _ from django_ace import AceWidget -from judge.models import Contest, Language, Organization, PrivateMessage, Problem, Profile, Submission, \ - WebAuthnCredential +from judge.models import Contest, Language, Organization, PrivateMessage, Problem, ProblemPointsVote, Profile, \ + Submission, WebAuthnCredential from judge.utils.subscription import newsletter_id from judge.widgets import HeavyPreviewPageDownWidget, MathJaxPagedownWidget, PagedownWidget, Select2MultipleWidget, \ Select2Widget @@ -277,3 +277,9 @@ def clean_key(self): if Contest.objects.filter(key=key).exists(): raise ValidationError(_('Contest with key already exists.')) return key + + +class ProblemPointsVoteForm(ModelForm): + class Meta: + model = ProblemPointsVote + fields = ['points', 'note'] diff --git a/judge/migrations/0116_add_voting_functionality.py b/judge/migrations/0116_add_voting_functionality.py new file mode 100644 index 0000000000..77a2ee1661 --- /dev/null +++ b/judge/migrations/0116_add_voting_functionality.py @@ -0,0 +1,34 @@ +# Generated by Django 2.2.19 on 2021-04-19 01:20 + +import django.core.validators +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('judge', '0115_contest_scoreboard_visibility'), + ] + + operations = [ + migrations.AddField( + model_name='profile', + name='is_banned_from_voting_problem_points', + field=models.BooleanField(default=False, help_text="User will not be able to vote on problems' points values.", verbose_name='banned from voting'), + ), + migrations.CreateModel( + name='ProblemPointsVote', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('points', models.FloatField(help_text='The amount of points you think this problem deserves.', validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(50)], verbose_name='points')), + ('note', models.TextField(blank=True, default=' ', help_text='Justification for problem points value.', max_length=2048, verbose_name='note')), + ('problem', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='problem_points_votes', to='judge.Problem')), + ('voter', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='problem_points_votes', to='judge.Profile')), + ], + options={ + 'verbose_name': 'Vote', + 'verbose_name_plural': 'Votes', + }, + ), + ] diff --git a/judge/models/__init__.py b/judge/models/__init__.py index afe9524c32..0c971b7886 100644 --- a/judge/models/__init__.py +++ b/judge/models/__init__.py @@ -10,6 +10,7 @@ ProblemTranslation, ProblemType, Solution, TranslatedProblemForeignKeyQuerySet, TranslatedProblemQuerySet from judge.models.problem_data import CHECKERS, ProblemData, ProblemTestCase, problem_data_storage, \ problem_directory_file +from judge.models.problem_points_vote import ProblemPointsVote from judge.models.profile import Organization, OrganizationRequest, Profile, WebAuthnCredential from judge.models.runtime import Judge, Language, RuntimeVersion from judge.models.submission import SUBMISSION_RESULT, Submission, SubmissionSource, SubmissionTestCase diff --git a/judge/models/problem_points_vote.py b/judge/models/problem_points_vote.py new file mode 100644 index 0000000000..0336754773 --- /dev/null +++ b/judge/models/problem_points_vote.py @@ -0,0 +1,38 @@ +from django.conf import settings +from django.core.validators import MaxValueValidator, MinValueValidator +from django.db import models +from django.db.models import CASCADE +from django.utils.translation import gettext as _ + +from judge.models.problem import Problem +from judge.models.profile import Profile + + +class ProblemPointsVote(models.Model): + points = models.FloatField( # How much this vote is worth + verbose_name=_('points'), + help_text=_('The amount of points you think this problem deserves.'), + validators=[ + MinValueValidator(settings.DMOJ_PROBLEM_MIN_USER_POINTS_VOTE), + MaxValueValidator(settings.DMOJ_PROBLEM_MAX_USER_POINTS_VOTE), + ], + ) + # who voted + voter = models.ForeignKey(Profile, related_name='problem_points_votes', on_delete=CASCADE, db_index=True) + # what problem is this vote for + problem = models.ForeignKey(Problem, related_name='problem_points_votes', on_delete=CASCADE, db_index=True) + note = models.TextField( # note to go along with vote + verbose_name=_('note'), + help_text=_('Justification for problem points value.'), + max_length=2048, + blank=True, + default=' ', + ) + + # The name that shows up on the sidebar instead of the model class name + class Meta: + verbose_name = _('Vote') + verbose_name_plural = _('Votes') + + def __str__(self): + return f'{self.voter}: {self.points} for {self.problem.code} - "{self.note}"' diff --git a/judge/models/profile.py b/judge/models/profile.py index 2873b2ddaa..77692b9d16 100644 --- a/judge/models/profile.py +++ b/judge/models/profile.py @@ -107,6 +107,12 @@ class Profile(models.Model): default=False) is_unlisted = models.BooleanField(verbose_name=_('unlisted user'), help_text=_('User will not be ranked.'), default=False) + # field for whether or not users are able to vote on problems + is_banned_from_voting_problem_points = models.BooleanField( + verbose_name=_('banned from voting'), + help_text=_('User will not be able to vote on problems\' points values.'), + default=False, + ) rating = models.IntegerField(null=True, default=None) user_script = models.TextField(verbose_name=_('user script'), default='', blank=True, max_length=65536, help_text=_('User-defined JavaScript for site customization.')) diff --git a/judge/views/problem.py b/judge/views/problem.py index 1df2fb80d4..a731b8bb73 100644 --- a/judge/views/problem.py +++ b/judge/views/problem.py @@ -1,4 +1,5 @@ import logging +import math import os import shutil from datetime import timedelta @@ -7,7 +8,7 @@ from django.conf import settings from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin -from django.core.exceptions import ObjectDoesNotExist, PermissionDenied +from django.core.exceptions import ObjectDoesNotExist, PermissionDenied, ValidationError from django.db import transaction from django.db.models import Count, F, Prefetch, Q from django.db.utils import ProgrammingError @@ -25,8 +26,8 @@ from django.views.generic.detail import SingleObjectMixin from judge.comments import CommentedDetailView -from judge.forms import ProblemCloneForm, ProblemSubmitForm -from judge.models import ContestSubmission, Judge, Language, Problem, ProblemGroup, \ +from judge.forms import ProblemCloneForm, ProblemPointsVoteForm, ProblemSubmitForm +from judge.models import ContestSubmission, Judge, Language, Problem, ProblemGroup, ProblemPointsVote, \ ProblemTranslation, ProblemType, RuntimeVersion, Solution, Submission, SubmissionSource, \ TranslatedProblemForeignKeyQuerySet from judge.pdf_problems import DefaultPdfMaker, HAS_PDF @@ -157,6 +158,18 @@ class ProblemDetail(ProblemMixin, SolvedProblemMixin, CommentedDetailView): def get_comment_page(self): return 'p:%s' % self.object.code + def can_vote(self, user, problem): + if not user.is_authenticated: # reject anons + return False + banned = user.profile.is_banned_from_voting_problem_points # banned from voting site wide + in_contest = user.profile.current_contest is not None # whether or not they're in contest + # already ac'd this q, not in contest, and also not banned + ac = Submission.objects.filter(user=user.profile, problem=problem, result='AC').exists() + return ac and not in_contest and not banned + + def default_note(self): + return _('A short justification for this problem\'s points value.') + def get_context_data(self, **kwargs): context = super(ProblemDetail, self).get_context_data(**kwargs) user = self.request.user @@ -213,8 +226,117 @@ def get_context_data(self, **kwargs): context['description'], 'problem') context['meta_description'] = self.object.summary or metadata[0] context['og_image'] = self.object.og_image or metadata[1] + + context['can_vote'] = self.can_vote(user, self.object) # if this problem is votable by this user + # the vote this user has already cast on this problem + if context['can_vote']: + vote = ProblemPointsVote.objects.filter(voter=user.profile, problem=self.object) + # whether or not they've already voted + context['has_voted'] = context['can_vote'] and vote.exists() + if context['has_voted']: + context['voted_points'] = vote.first().points # the previous vote's points + context['voted_note'] = vote.first().note + + if 'problem_points_vote_form' not in context: + context['problem_points_vote_form'] = ProblemPointsVoteForm({}) + + if 'has_errors' not in context: + context['has_errors'] = False + + if 'points_placeholder' not in context: # placeholder for the points + if context['has_voted']: # if voted, the vote + context['points_placeholder'] = context['voted_points'] + else: # otherwise a *nice* default + context['points_placeholder'] = 69.42069 + + if 'note_placeholder' not in context: + if context['has_voted']: + context['note_placeholder'] = context['voted_note'] + else: + context['note_placeholder'] = self.default_note() + + all_votes = sorted([v.points for v in ProblemPointsVote.objects.filter(problem=self.object)]) + context['has_votes'] = len(all_votes) > 0 + if context['has_votes']: + context['mean_vote'] = sum(all_votes) / len(all_votes) + context['num_votes'] = len(all_votes) + context['min_vote'] = all_votes[0] + context['max_vote'] = all_votes[-1] + + # provides index and value of the median of some range of the data + def median(left_index, right_index, data): + size = right_index - left_index + 1 + index = left_index + (size - 1) / 2 + value = None + if size % 2 == 1: + value = data[int(index)] + else: + value = (data[math.floor(index)] + data[math.ceil(index)]) / 2 + return index, value + + median_data = median(0, len(all_votes) - 1, all_votes) + context['median_vote'] = median_data[1] + median_index = median_data[0] + + context['enough_data_for_plot'] = len(all_votes) > 2 + if context['enough_data_for_plot']: + # box and whisker plot data + q1 = median(0, math.ceil(median_index - 1), all_votes) # first quartile + q3 = median(math.floor(median_index + 1), len(all_votes) - 1, all_votes) # second quartile + context['first_quartile'] = q1[1] + context['third_quartile'] = q3[1] + + context['in_contest'] = contest_problem is not None + + if context['can_vote']: + context['all_votes'] = all_votes + + context['max_possible_vote'] = settings.DMOJ_PROBLEM_MAX_USER_POINTS_VOTE + context['min_possible_vote'] = settings.DMOJ_PROBLEM_MIN_USER_POINTS_VOTE + return context + def post(self, request, *args, **kwargs): + self.object = self.get_object() + + if 'vote_confirmation' in request.POST: # deal with request as problem points vote + if not self.can_vote(request.user, self.object): # not allowed to vote for some reason + return HttpResponseForbidden() + else: + form = ProblemPointsVoteForm(request.POST) + try: + if form.is_valid(): + # delete any pre existing votes (will be replaced by new one) + ProblemPointsVote.objects.filter(voter=request.user.profile, problem=self.object).delete() + vote = form.save(commit=False) + vote.voter = request.user.profile + vote.problem = self.object + if vote.note == self.default_note() or vote.note.strip() == '': + vote.note = ' ' # correct to blank + vote.save() + return self.get(request, *args, **kwargs) + else: + raise ValidationError # go to invalid case + except ValidationError: + context = self.get_context_data( + object=self.object, + comment_request=request, # comment needs this to initialize + problem_points_vote_form=form, # extra context for the form re-rendering + has_errors=True, + points_placeholder=request.POST['points'], + note_placeholder=request.POST['note'], + ) + return self.render_to_response(context) + + elif 'delete_confirmation' in request.POST: + if not request.user.is_authenticated: # un authed person tries to find a way to delete + return HttpResponseForbidden() + # delete anything that matches (if nothing matches it doesn't matter) + ProblemPointsVote.objects.filter(voter=request.user.profile, problem=self.object).delete() + return self.get(request, *args, **kwargs) + else: # forward to next level of post request (comment post request as of writing) + return super(ProblemDetail, self).post(request, *args, **kwargs) + class LatexError(Exception): pass diff --git a/templates/admin/judge/problem/change_form.html b/templates/admin/judge/problem/change_form.html index 4d118f0a88..4d84673661 100644 --- a/templates/admin/judge/problem/change_form.html +++ b/templates/admin/judge/problem/change_form.html @@ -16,5 +16,10 @@ {% trans "View submissions" %} + {% endif %} {% endblock %} diff --git a/templates/comments/list.html b/templates/comments/list.html index 0756cc44fe..135506a9d3 100644 --- a/templates/comments/list.html +++ b/templates/comments/list.html @@ -157,6 +157,7 @@

{{ _('New comment') }}


+ {% endif %} diff --git a/templates/lightbox.html b/templates/lightbox.html new file mode 100644 index 0000000000..700d7313d4 --- /dev/null +++ b/templates/lightbox.html @@ -0,0 +1,58 @@ + + \ No newline at end of file diff --git a/templates/problem/problem.html b/templates/problem/problem.html index b5f3c393e6..5c7099c6c1 100644 --- a/templates/problem/problem.html +++ b/templates/problem/problem.html @@ -41,6 +41,47 @@ .problem-info-entry { padding-top: 0.5em; } + + .vote-form{ + background-color: rgb(255,255,255); + padding: 40px; + border-radius: 25px; + justify-content: center; + align-items: center; + margin: auto; + text-align: center; + } + + .vote-form-info{ + font-size: 24px; + font-weight: bold; + padding:10px + } + + .vote-form-value{ + font-size: 24px; + } + + .vote-form-text{ + font-size: 18px; + } + + .voting-form-error { + font-size: 18px; + color: red; + } + + .vote-button{ + float:right; + } + + .form-button { + cursor: pointer; + } + + .errorlist { + list-style: none; + } {% endblock %} @@ -126,6 +167,109 @@

{{ title }}

{% endif %}
{{ _('All submissions') }}
{{ _('Best submissions') }}
+ + {% if not in_contest %} +
+ {% include 'lightbox.html' %} + {% endif %} + + {% if can_vote %} +
+ {% if has_voted %} + {{ _('Current Vote:') }} + {{ voted_points|floatformat }} +

+ {{ _('Change your vote below!') }} + {% else %} + {{ _("Cast your vote on this problem's weightage below!") }} + {% endif %} +

+
{% csrf_token %} + {{ _('Points') }} + + {% if has_errors and 'points' in problem_points_vote_form.errors %} +
+ {{ _(problem_points_vote_form.errors['points'][0]) }} +
+ {% else %} +

+ {% endif %} + {{ _('Note') }} +
+ + {% if has_errors and 'note' in problem_points_vote_form.errors %} +
+ {{ _(problem_points_vote_form.errors['note'][0]) }} +
+ {% else %} +

+ {% endif %} + + +
+
+ + {% if has_voted %}Change vote{% else %}Vote on problem points{% endif %} + {% if has_voted %} +
{% csrf_token %} + {{ _('Delete vote') }} + +
+ {% endif %} + + + {% endif %} + + {% if not in_contest %} + + + {% include 'voting-stats.html' %} + Voting statistics + + + {% endif %} + {% if (editorial and editorial.is_accessible_by(request.user)) and not request.in_contest %}
{{ _('Read editorial') }}
diff --git a/templates/voting-stats.html b/templates/voting-stats.html new file mode 100644 index 0000000000..8e22876467 --- /dev/null +++ b/templates/voting-stats.html @@ -0,0 +1,391 @@ + +
+ {{ _('Voting Statistics') }} +
+ +
+ {% if has_votes %} + {% if can_vote %} + {{ _('Show Votes') }} + + {% endif %} + {{ _('Show Median') }} + + {{ _('Show Mean') }} + + {% if enough_data_for_plot %} + {{ _('Show Box and Whisker Plot') }} + + {% endif %} +
+ {{ _('Median Vote:') }} + {{ median_vote|floatformat }} + {{ _('Mean Vote:') }} + {{ mean_vote|floatformat }} + {{ _('Number of Votes:') }} + {{ num_votes }} + {% else %} + {{ _('No Votes Available!') }} + {% endif %} +
+ \ No newline at end of file