From c803bf78cabe9bd8131186ae1649db49c7f0168c Mon Sep 17 00:00:00 2001 From: int-y1 Date: Mon, 12 Sep 2022 23:20:50 -0400 Subject: [PATCH 1/5] Add backend for problem point voting --- dmoj/settings.py | 7 ++ judge/admin/__init__.py | 5 +- judge/admin/problem.py | 31 ++++++++- judge/admin/profile.py | 4 +- judge/models/__init__.py | 3 +- judge/models/problem.py | 64 ++++++++++++++++++ judge/models/profile.py | 5 ++ judge/models/submission.py | 3 +- judge/models/tests/test_problem.py | 67 +++++++++++++++++-- .../admin/judge/problem/change_form.html | 12 +++- 10 files changed, 182 insertions(+), 19 deletions(-) diff --git a/dmoj/settings.py b/dmoj/settings.py index 0152f4e4aa..2b087c2b97 100644 --- a/dmoj/settings.py +++ b/dmoj/settings.py @@ -65,6 +65,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 = 1 # when voting on problem, minimum point value user can select +DMOJ_PROBLEM_MAX_USER_POINTS_VOTE = 50 # when voting on problem, maximum point value user can select DMOJ_PROBLEM_HOT_PROBLEM_COUNT = 7 DMOJ_PROBLEM_STATEMENT_DISALLOWED_CHARACTERS = {'“', '”', '‘', '’', '−', 'ff', 'fi', 'fl', 'ffi', 'ffl'} DMOJ_RATING_COLORS = True @@ -178,6 +180,7 @@ 'judge.ProblemGroup', 'judge.ProblemType', 'judge.License', + 'judge.ProblemPointsVote', ], }, ('judge.Submission', 'fa-check-square-o'), @@ -588,3 +591,7 @@ exec(f.read(), globals()) except IOError: pass + + +# Check settings are consistent +assert DMOJ_PROBLEM_MIN_USER_POINTS_VOTE >= DMOJ_PROBLEM_MIN_PROBLEM_POINTS diff --git a/judge/admin/__init__.py b/judge/admin/__init__.py index 5f1c31bba5..6709cc6b58 100644 --- a/judge/admin/__init__.py +++ b/judge/admin/__init__.py @@ -6,7 +6,7 @@ from judge.admin.contest import ContestAdmin, ContestParticipationAdmin, ContestTagAdmin from judge.admin.interface import BlogPostAdmin, FlatPageAdmin, LicenseAdmin, LogEntryAdmin, NavigationBarAdmin from judge.admin.organization import ClassAdmin, OrganizationAdmin, OrganizationRequestAdmin -from judge.admin.problem import ProblemAdmin +from judge.admin.problem import ProblemAdmin, ProblemPointsVoteAdmin from judge.admin.profile import ProfileAdmin from judge.admin.runtime import JudgeAdmin, LanguageAdmin from judge.admin.submission import SubmissionAdmin @@ -14,7 +14,7 @@ from judge.admin.ticket import TicketAdmin from judge.models import BlogPost, Class, 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) @@ -35,6 +35,7 @@ admin.site.register(OrganizationRequest, OrganizationRequestAdmin) admin.site.register(Problem, ProblemAdmin) admin.site.register(ProblemGroup, ProblemGroupAdmin) +admin.site.register(ProblemPointsVote, ProblemPointsVoteAdmin) admin.site.register(ProblemType, ProblemTypeAdmin) admin.site.register(Profile, ProfileAdmin) admin.site.register(Submission, SubmissionAdmin) diff --git a/judge/admin/problem.py b/judge/admin/problem.py index 8b05d55679..8664960e51 100644 --- a/judge/admin/problem.py +++ b/judge/admin/problem.py @@ -4,13 +4,14 @@ from django.contrib import admin from django.db import transaction from django.forms import ModelForm -from django.urls import reverse_lazy +from django.urls import reverse, reverse_lazy from django.utils import timezone from django.utils.html import format_html from django.utils.translation import gettext, gettext_lazy as _, ngettext from reversion.admin import VersionAdmin -from judge.models import LanguageLimit, Problem, ProblemClarification, ProblemTranslation, Profile, Solution +from judge.models import LanguageLimit, Problem, ProblemClarification, ProblemPointsVote, ProblemTranslation, Profile, \ + Solution from judge.utils.views import NoBatchDeleteMixin from judge.widgets import AdminHeavySelect2MultipleWidget, AdminMartorWidget, AdminSelect2MultipleWidget, \ AdminSelect2Widget, CheckboxSelectMultipleWithSelectAll @@ -248,3 +249,29 @@ def construct_change_message(self, request, form, *args, **kwargs): if form.cleaned_data.get('change_message'): return form.cleaned_data['change_message'] return super(ProblemAdmin, self).construct_change_message(request, form, *args, **kwargs) + + +class ProblemPointsVoteAdmin(admin.ModelAdmin): + list_display = ('points', 'voter', 'linked_problem', 'vote_time') + search_fields = ('voter__user__username', 'problem__code', 'problem__name') + readonly_fields = ('voter', 'problem', 'vote_time') + + def get_queryset(self, request): + return ProblemPointsVote.objects.filter(problem__in=Problem.get_editable_problems(request.user)) + + def has_add_permission(self, request): + return False + + def has_change_permission(self, request, obj=None): + if obj is None: + return request.user.has_perm('judge.edit_own_problem') + return obj.problem.is_editable_by(request.user) + + def lookup_allowed(self, key, value): + return super().lookup_allowed(key, value) or key in ('problem__code',) + + def linked_problem(self, obj): + link = reverse('problem_detail', args=[obj.problem.code]) + return format_html('{1}', link, obj.problem.name) + linked_problem.short_description = _('problem') + linked_problem.admin_order_field = 'problem__name' diff --git a/judge/admin/profile.py b/judge/admin/profile.py index 99c9749a51..bf9a3364d0 100644 --- a/judge/admin/profile.py +++ b/judge/admin/profile.py @@ -55,8 +55,8 @@ def has_add_permission(self, request, obj=None): class ProfileAdmin(NoBatchDeleteMixin, VersionAdmin): fields = ('user', 'display_rank', 'about', 'organizations', 'timezone', 'language', 'ace_theme', - 'math_engine', 'last_access', 'ip', 'mute', 'is_unlisted', 'username_display_override', - 'notes', 'is_totp_enabled', 'user_script', 'current_contest') + 'math_engine', 'last_access', 'ip', 'mute', 'is_unlisted', 'is_banned_from_problem_voting', + 'username_display_override', '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/models/__init__.py b/judge/models/__init__.py index cbc0ba1d57..11621e5083 100644 --- a/judge/models/__init__.py +++ b/judge/models/__init__.py @@ -6,7 +6,8 @@ ContestTag, Rating from judge.models.interface import BlogPost, MiscConfig, NavigationBar, validate_regex from judge.models.problem import LanguageLimit, License, Problem, ProblemClarification, ProblemGroup, \ - ProblemTranslation, ProblemType, Solution, SubmissionSourceAccess, TranslatedProblemQuerySet + ProblemPointsVote, ProblemTranslation, ProblemType, Solution, SubmissionSourceAccess, \ + TranslatedProblemQuerySet from judge.models.problem_data import CHECKERS, ProblemData, ProblemTestCase, problem_data_storage, \ problem_directory_file from judge.models.profile import Class, Organization, OrganizationRequest, Profile, WebAuthnCredential diff --git a/judge/models/problem.py b/judge/models/problem.py index bc4d61eeec..4abc8509f5 100644 --- a/judge/models/problem.py +++ b/judge/models/problem.py @@ -1,3 +1,4 @@ +from enum import IntEnum from operator import attrgetter from django.conf import settings @@ -93,6 +94,18 @@ class SubmissionSourceAccess: FOLLOW = 'F' +class VotePermission(IntEnum): + NONE = 0 + VIEW = 1 + VOTE = 2 + + def can_view(self): + return self >= VotePermission.VIEW + + def can_vote(self): + return self >= VotePermission.VOTE + + class Problem(models.Model): SUBMISSION_SOURCE_ACCESS = ( (SubmissionSourceAccess.FOLLOW, _('Follow global setting')), @@ -434,6 +447,32 @@ def save(self, *args, **kwargs): save.alters_data = True + def is_solved_by(self, user): + # Return true if a full AC submission to the problem from the user exists. + return self.submission_set.filter(user=user.profile, result='AC', points=F('problem__points')).exists() + + def vote_permission_for_user(self, user): + if not user.is_authenticated: + return VotePermission.NONE + + # If the user is in contest, nothing should be shown. + if user.profile.current_contest: + return VotePermission.NONE + + # If the user is not allowed to vote. + if user.profile.is_unlisted or user.profile.is_banned_from_problem_voting: + return VotePermission.VIEW + + # If the user is banned from submitting to the problem. + if self.banned_users.filter(pk=user.pk).exists(): + return VotePermission.VIEW + + # If the user has not solved the problem. + if not self.is_solved_by(user): + return VotePermission.VIEW + + return VotePermission.VOTE + class Meta: permissions = ( ('see_private_problem', _('See hidden problems')), @@ -522,3 +561,28 @@ class Meta: ) verbose_name = _('solution') verbose_name_plural = _('solutions') + + +class ProblemPointsVote(models.Model): + points = models.IntegerField( + verbose_name=_('proposed points'), + help_text=_('The amount of points the voter thinks this problem deserves.'), + validators=[ + MinValueValidator(settings.DMOJ_PROBLEM_MIN_USER_POINTS_VOTE), + MaxValueValidator(settings.DMOJ_PROBLEM_MAX_USER_POINTS_VOTE), + ], + ) + voter = models.ForeignKey(Profile, verbose_name=_('voter'), related_name='problem_points_votes', on_delete=CASCADE) + problem = models.ForeignKey(Problem, verbose_name=_('problem'), related_name='problem_points_votes', + on_delete=CASCADE) + vote_time = models.DateTimeField(verbose_name=_('vote time'), help_text=_('The time this vote was cast.'), + auto_now_add=True) + note = models.TextField(verbose_name=_('note'), help_text=_('Justification for problem point value.'), + max_length=8192, blank=True, default='') + + class Meta: + verbose_name = _('problem vote') + verbose_name_plural = _('problem votes') + + def __str__(self): + return _('Points vote by %(voter)s for %(problem)s') % {'voter': self.voter, 'problem': self.problem} diff --git a/judge/models/profile.py b/judge/models/profile.py index 0c257f1639..901139b8f6 100644 --- a/judge/models/profile.py +++ b/judge/models/profile.py @@ -168,6 +168,11 @@ class Profile(models.Model): default=False) is_unlisted = models.BooleanField(verbose_name=_('unlisted user'), help_text=_('User will not be ranked.'), default=False) + is_banned_from_problem_voting = models.BooleanField( + verbose_name=_('banned from voting on problem point values'), + help_text=_("User will not be able to vote on problems' point 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/models/submission.py b/judge/models/submission.py index 8bac0b90ee..555c38ebfb 100644 --- a/judge/models/submission.py +++ b/judge/models/submission.py @@ -151,8 +151,7 @@ def can_see_detail(self, user): return True elif source_visibility == SubmissionSourceAccess.SOLVED and \ (self.problem.is_public or self.problem.testers.filter(id=profile.id).exists()) and \ - self.problem.submission_set.filter(user_id=profile.id, result='AC', - points=self.problem.points).exists(): + self.problem.is_solved_by(user): return True elif source_visibility == SubmissionSourceAccess.ONLY_OWN and \ self.problem.testers.filter(id=profile.id).exists(): diff --git a/judge/models/tests/test_problem.py b/judge/models/tests/test_problem.py index c2a30bb1e9..05b636955d 100644 --- a/judge/models/tests/test_problem.py +++ b/judge/models/tests/test_problem.py @@ -2,10 +2,10 @@ from django.test import SimpleTestCase, TestCase from django.utils import timezone -from judge.models import Language, LanguageLimit, Problem -from judge.models.problem import disallowed_characters_validator -from judge.models.tests.util import CommonDataMixin, create_organization, create_problem, create_problem_type, \ - create_solution, create_user +from judge.models import Language, LanguageLimit, Problem, Submission +from judge.models.problem import VotePermission, disallowed_characters_validator +from judge.models.tests.util import CommonDataMixin, create_contest, create_contest_participation, \ + create_organization, create_problem, create_problem_type, create_solution, create_user class ProblemTestCase(CommonDataMixin, TestCase): @@ -245,6 +245,59 @@ def test_organization_admin_problem_methods(self): } self._test_object_methods_with_users(self.organization_admin_problem, data) + def give_basic_problem_ac(self, user, points=None): + Submission.objects.create( + user=user.profile, + problem=self.basic_problem, + result='AC', + points=self.basic_problem.points if points is None else points, + language=Language.get_python3(), + ) + + def test_problem_voting_permissions(self): + self.assertEqual(self.basic_problem.vote_permission_for_user(self.users['anonymous']), VotePermission.NONE) + + now = timezone.now() + basic_contest = create_contest( + key='basic', + start_time=now - timezone.timedelta(days=1), + end_time=now + timezone.timedelta(days=100), + authors=('superuser', 'staff_contest_edit_own'), + testers=('non_staff_tester',), + ) + in_contest = create_user(username='in_contest') + in_contest.profile.current_contest = create_contest_participation( + user=in_contest, + contest=basic_contest, + ) + self.give_basic_problem_ac(in_contest) + self.assertEqual(self.basic_problem.vote_permission_for_user(in_contest), VotePermission.NONE) + + unlisted = create_user(username='unlisted') + unlisted.profile.is_unlisted = True + self.give_basic_problem_ac(unlisted) + self.assertEqual(self.basic_problem.vote_permission_for_user(unlisted), VotePermission.VIEW) + + banned_from_voting = create_user(username='banned_from_voting') + banned_from_voting.profile.is_banned_from_problem_voting = True + self.give_basic_problem_ac(banned_from_voting) + self.assertEqual(self.basic_problem.vote_permission_for_user(banned_from_voting), VotePermission.VIEW) + + banned_from_problem = create_user(username='banned_from_problem') + self.basic_problem.banned_users.add(banned_from_problem.profile) + self.give_basic_problem_ac(banned_from_problem) + self.assertEqual(self.basic_problem.vote_permission_for_user(banned_from_problem), VotePermission.VIEW) + + self.assertEqual(self.basic_problem.vote_permission_for_user(self.users['normal']), VotePermission.VIEW) + + self.give_basic_problem_ac(self.users['normal']) + self.assertEqual(self.basic_problem.vote_permission_for_user(self.users['normal']), VotePermission.VOTE) + + partial_ac = create_user(username='partial_ac') + self.give_basic_problem_ac(partial_ac, 0.5) # ensure this value is not equal to its point value + self.assertNotEqual(self.basic_problem.points, 0.5) + self.assertEqual(self.basic_problem.vote_permission_for_user(partial_ac), VotePermission.VIEW) + def test_problems_list(self): for name, user in self.users.items(): with self.subTest(user=name): @@ -284,14 +337,14 @@ def setUpTestData(self): ), }) - _now = timezone.now() + now = timezone.now() self.basic_solution = create_solution(problem='basic') self.private_solution = create_solution( problem='private', is_public=False, - publish_on=_now - timezone.timedelta(days=100), + publish_on=now - timezone.timedelta(days=100), ) self.unpublished_problem = create_problem( @@ -302,7 +355,7 @@ def setUpTestData(self): self.unpublished_solution = create_solution( problem=self.unpublished_problem, is_public=False, - publish_on=_now + timezone.timedelta(days=100), + publish_on=now + timezone.timedelta(days=100), authors=('normal',), ) diff --git a/templates/admin/judge/problem/change_form.html b/templates/admin/judge/problem/change_form.html index 4d118f0a88..62655ce767 100644 --- a/templates/admin/judge/problem/change_form.html +++ b/templates/admin/judge/problem/change_form.html @@ -2,19 +2,25 @@ {% load i18n %} {% block extrahead %}{{ block.super }} - {% endblock extrahead %} {% block after_field_sets %}{{ block.super }} {% if original %} - + {% endif %} {% endblock %} From d654bece37e90f09673aa1dfd21d0c5e4fc7f5d9 Mon Sep 17 00:00:00 2001 From: int-y1 Date: Mon, 12 Sep 2022 23:21:37 -0400 Subject: [PATCH 2/5] Add migration for problem point voting --- .../0134_add_voting_functionality.py | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 judge/migrations/0134_add_voting_functionality.py diff --git a/judge/migrations/0134_add_voting_functionality.py b/judge/migrations/0134_add_voting_functionality.py new file mode 100644 index 0000000000..c622304ea2 --- /dev/null +++ b/judge/migrations/0134_add_voting_functionality.py @@ -0,0 +1,44 @@ +import django.core.validators +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('judge', '0133_add_problem_data_hints'), + ] + + operations = [ + migrations.AddField( + model_name='profile', + name='is_banned_from_problem_voting', + field=models.BooleanField(default=False, + help_text="User will not be able to vote on problems' point values.", + verbose_name='banned from voting on problem point values'), + ), + migrations.CreateModel( + name='ProblemPointsVote', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('points', models.IntegerField(help_text='The amount of points the voter thinks this problem deserves.', + validators=[django.core.validators.MinValueValidator(1), + django.core.validators.MaxValueValidator(50)], + verbose_name='proposed points')), + ('note', models.TextField(blank=True, default='', help_text='Justification for problem point value.', + max_length=8192, verbose_name='note')), + ('problem', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, + related_name='problem_points_votes', to='judge.Problem', + verbose_name='problem')), + ('voter', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, + related_name='problem_points_votes', to='judge.Profile', + verbose_name='voter')), + ('vote_time', models.DateTimeField(auto_now_add=True, help_text='The time this vote was cast.', + verbose_name='vote time')), + ], + options={ + 'verbose_name': 'problem vote', + 'verbose_name_plural': 'problem votes', + }, + ), + ] From b73368fc641bb779ef2e4871a401e5c721177194 Mon Sep 17 00:00:00 2001 From: int-y1 Date: Mon, 12 Sep 2022 23:22:45 -0400 Subject: [PATCH 3/5] Add view for problem point voting --- dmoj/urls.py | 4 ++ judge/forms.py | 11 ++++- judge/views/problem.py | 92 ++++++++++++++++++++++++++++++++++++++++-- robots.txt | 3 ++ 4 files changed, 105 insertions(+), 5 deletions(-) diff --git a/dmoj/urls.py b/dmoj/urls.py index de6d0dc505..a9932faa49 100644 --- a/dmoj/urls.py +++ b/dmoj/urls.py @@ -123,6 +123,10 @@ def paged_list_view(view, name): path('/tickets', ticket.ProblemTicketListView.as_view(), name='problem_ticket_list'), path('/tickets/new', ticket.NewProblemTicketView.as_view(), name='new_problem_ticket'), + path('/vote', problem.ProblemVote.as_view(), name='problem_vote'), + path('/vote/delete', problem.DeleteProblemVote.as_view(), name='delete_problem_vote'), + path('/vote/stats', problem.ProblemVoteStats.as_view(), name='problem_vote_stats'), + path('/manage/submission', include([ path('', problem_manage.ManageProblemSubmissionView.as_view(), name='problem_manage_submissions'), path('/rejudge', problem_manage.RejudgeSubmissionsView.as_view(), name='problem_submissions_rejudge'), diff --git a/judge/forms.py b/judge/forms.py index 2d5ee50118..5741a5962f 100644 --- a/judge/forms.py +++ b/judge/forms.py @@ -15,7 +15,8 @@ from django.utils.translation import gettext_lazy as _, ngettext_lazy from django_ace import AceWidget -from judge.models import Contest, Language, Organization, Problem, Profile, Submission, WebAuthnCredential +from judge.models import Contest, Language, Organization, Problem, ProblemPointsVote, Profile, Submission, \ + WebAuthnCredential from judge.utils.subscription import newsletter_id from judge.widgets import HeavyPreviewPageDownWidget, Select2MultipleWidget, Select2Widget @@ -284,3 +285,11 @@ def clean_key(self): if Contest.objects.filter(key=key).exists(): raise ValidationError(_('Contest with key already exists.')) return key + + +class ProblemPointsVoteForm(ModelForm): + note = CharField(max_length=8192, required=False) + + class Meta: + model = ProblemPointsVote + fields = ['points', 'note'] diff --git a/judge/views/problem.py b/judge/views/problem.py index 6206fa443a..5104cd5ff4 100644 --- a/judge/views/problem.py +++ b/judge/views/problem.py @@ -5,6 +5,7 @@ from datetime import timedelta from operator import itemgetter from random import randrange +from statistics import mean, median from django.conf import settings from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin @@ -13,7 +14,7 @@ from django.db.models import BooleanField, Case, CharField, Count, F, FilteredRelation, Prefetch, Q, When from django.db.models.functions import Coalesce from django.db.utils import ProgrammingError -from django.http import Http404, HttpResponse, HttpResponseForbidden, HttpResponseRedirect +from django.http import Http404, HttpResponse, HttpResponseForbidden, HttpResponseRedirect, JsonResponse from django.shortcuts import get_object_or_404 from django.template.loader import get_template from django.urls import reverse @@ -22,13 +23,13 @@ from django.utils.html import escape, format_html from django.utils.safestring import mark_safe from django.utils.translation import gettext as _, gettext_lazy -from django.views.generic import ListView, View +from django.views.generic import DetailView, ListView, View from django.views.generic.detail import SingleObjectMixin from reversion import revisions 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 from judge.pdf_problems import DefaultPdfMaker, HAS_PDF from judge.utils.diggpaginator import DiggPaginator @@ -201,6 +202,89 @@ 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['vote_perm'] = self.object.vote_permission_for_user(user) + if context['vote_perm'].can_vote(): + try: + context['vote'] = ProblemPointsVote.objects.get(voter=user.profile, problem=self.object) + except ObjectDoesNotExist: + context['vote'] = None + else: + context['vote'] = None + + return context + + +class ProblemVote(ProblemMixin, DetailView): + context_object_name = 'problem' + template_name = 'problem/vote-ajax.html' + + def get_context_data(self, **kwargs): + if not self.object.vote_permission_for_user(self.request.user).can_vote(): + raise Http404() + + context = super().get_context_data(**kwargs) + + try: + context['vote'] = ProblemPointsVote.objects.get(voter=self.request.profile, problem=self.object) + except ObjectDoesNotExist: + context['vote'] = None + + 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): + problem = self.get_object() + if not problem.vote_permission_for_user(request.user).can_vote(): + return JsonResponse({'message': _('Not allowed to vote on this problem.')}, status=403) + + form = ProblemPointsVoteForm(request.POST) + if not form.is_valid(): + return JsonResponse(form.errors, status=400) + + with transaction.atomic(): + # Delete any pre-existing votes. + ProblemPointsVote.objects.filter(voter=request.profile, problem=problem).delete() + vote = form.save(commit=False) + vote.voter = request.profile + vote.problem = problem + vote.save() + + return JsonResponse({'points': vote.points}) + + +class DeleteProblemVote(ProblemMixin, SingleObjectMixin, View): + http_method_names = ['options', 'post'] # This disables GET requests, even though ProblemMixin.get exists. + + def post(self, request, *args, **kwargs): + problem = self.get_object() + if not problem.vote_permission_for_user(request.user).can_vote(): + return JsonResponse({'message': _('Not allowed to delete votes on this problem.')}, status=403) + + ProblemPointsVote.objects.filter(voter=request.profile, problem=problem).delete() + return JsonResponse({'message': _('success')}) + + +class ProblemVoteStats(ProblemMixin, DetailView): + context_object_name = 'problem' + template_name = 'problem/vote-stats-ajax.html' + + def get_context_data(self, **kwargs): + if not self.object.vote_permission_for_user(self.request.user).can_view(): + raise Http404() + + context = super().get_context_data(**kwargs) + + votes = list(self.object.problem_points_votes.order_by('points').values_list('points', flat=True)) + context['votes'] = votes + + if votes: + context['mean'] = mean(votes) + context['median'] = median(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 diff --git a/robots.txt b/robots.txt index a12ad11eaf..5bc2eec150 100644 --- a/robots.txt +++ b/robots.txt @@ -36,6 +36,9 @@ Disallow: /problem/*/submissions Disallow: /problem/*/submit Disallow: /problem/*/test_data Disallow: /problem/*/tickets +Disallow: /problem/*/vote +Disallow: /problem/*/vote/delete +Disallow: /problem/*/vote/stats Disallow: /src Disallow: /stats Disallow: /submission From c1bea2356bfbbdc7b6c78e3756421077f733e9b9 Mon Sep 17 00:00:00 2001 From: int-y1 Date: Mon, 12 Sep 2022 23:23:31 -0400 Subject: [PATCH 4/5] Add HTML/CSS for problem point voting --- resources/problem-vote.js | 91 ++++++++++++++++++++++++++ resources/problem-vote.scss | 54 +++++++++++++++ resources/style.scss | 1 + templates/problem/problem.html | 20 ++++++ templates/problem/vote-ajax.html | 42 ++++++++++++ templates/problem/vote-stats-ajax.html | 22 +++++++ 6 files changed, 230 insertions(+) create mode 100644 resources/problem-vote.js create mode 100644 resources/problem-vote.scss create mode 100644 templates/problem/vote-ajax.html create mode 100644 templates/problem/vote-stats-ajax.html diff --git a/resources/problem-vote.js b/resources/problem-vote.js new file mode 100644 index 0000000000..de503ad0a3 --- /dev/null +++ b/resources/problem-vote.js @@ -0,0 +1,91 @@ +var voteChart = null; + +function init_problem_vote_form() { + $('#delete-problem-vote-form').on('submit', function (e) { + e.preventDefault(); + $.ajax({ + url: $('#delete-problem-vote-form').prop('action'), + type: 'POST', + data: $('#delete-problem-vote-form').serialize(), + success: function () { + $('#problem-vote-button').text(gettext('Vote on problem points')); + $.featherlight.close(); + }, + error: function (data) { + var msg = 'responseJSON' in data ? data.responseJSON.message : data.statusText; + alert(interpolate(gettext('Unable to delete vote: %s'), [msg])); + } + }); + }); + + $('#problem-vote-form').on('submit', function (e) { + e.preventDefault(); + $.ajax({ + url: $('#problem-vote-form').prop('action'), + type: 'POST', + data: $('#problem-vote-form').serialize(), + success: function (data) { + $('#problem-vote-button').text(interpolate(gettext('Edit points vote (%s)'), [data.points])); + $.featherlight.close(); + }, + error: function (data) { + var errors = 'responseJSON' in data ? data.responseJSON : {'message': data.statusText}; + if ('message' in errors) { + alert(interpolate(gettext('Unable to cast vote: %s'), [errors.message])); + } + $('#points-error').text('points' in errors ? errors.points[0] : ''); + $('#note-error').text('note' in errors ? errors.note[0] : ''); + } + }); + }); +} + +function reload_problem_vote_graph(data, min_possible_vote, max_possible_vote) { + if (voteChart !== null) voteChart.destroy(); + + // Give the graph some padding on both sides. + var min_points = Math.max(data[0] - 2, min_possible_vote); + var max_points = Math.min(data[data.length - 1] + 2, max_possible_vote); + + var xlabels = []; + var voteFreq = []; + for (var i = min_points; i <= max_points; i++) { + xlabels.push(i); + voteFreq.push(0); + } + + data.forEach(function (x) { voteFreq[x - min_points]++; }); + var max_number_of_votes = Math.max.apply(null, voteFreq); + + var voteData = { + labels: xlabels, + datasets: [{ + label: gettext('Number of votes for this point value'), + data: voteFreq, + borderColor: 'red', + backgroundColor: 'pink', + }], + }; + var voteDataConfig = { + type: 'bar', + data: voteData, + options: { + responsive: true, + scales: { + yAxes: [{ + ticks: { + precision: 0, + suggestedMax: Math.ceil(max_number_of_votes * 1.2), + beginAtZero: true, + } + }], + xAxes: [{ + ticks: { + beginAtZero: false, + } + }], + }, + }, + }; + voteChart = new Chart($('#problem-vote-chart').get(0), voteDataConfig); +} diff --git a/resources/problem-vote.scss b/resources/problem-vote.scss new file mode 100644 index 0000000000..b99dafb17c --- /dev/null +++ b/resources/problem-vote.scss @@ -0,0 +1,54 @@ +.problem-vote-container { + margin: 1em; + min-width: 25em; +} + +.problem-vote-form-header { + display: flex; + justify-content: space-between; + align-items: flex-end; + border-bottom: 1px solid #aaa; +} + +.problem-vote-form-title { + font-size: 2em; + margin-right: 0.5em; +} + +.problem-vote-date { + font-style: italic; +} + +.problem-vote-form-info { + font-size: 1.2em; + margin-right: 0.5em; +} + +#problem-vote-form textarea { + margin-top: 0.5em; + width: 100%; + font-size: 14px; +} + +.problem-voting-form-error { + font-size: 1.2em; + color: red; +} + +.problem-vote-submits { + display: flex; + justify-content: space-between; + flex-direction: row-reverse; +} + +.problem-vote-stats-bar { + font-size: 1.2em; + font-weight: 500; + margin: 0.6em 0; + display: flex; + justify-content: space-around; +} + +.problem-vote-stats-bar span { + margin: 0 0.6em; +} diff --git a/resources/style.scss b/resources/style.scss index 2873fe8014..08184b70ef 100644 --- a/resources/style.scss +++ b/resources/style.scss @@ -4,6 +4,7 @@ @import "status"; @import "blog"; @import "problem"; +@import "problem-vote"; @import "ranks"; @import "users"; @import "content-description"; diff --git a/templates/problem/problem.html b/templates/problem/problem.html index 3cef570125..d9909d9845 100644 --- a/templates/problem/problem.html +++ b/templates/problem/problem.html @@ -46,6 +46,11 @@ {% block content_js_media %} {% include "comments/media-js.html" %} + + {% if vote_perm.can_view() %} + + + {% endif %} {% endblock %} {% block title_row %} @@ -126,6 +131,21 @@

{{ title }}

{% endif %}
{{ _('All submissions') }}
{{ _('Best submissions') }}
+ + {% if vote_perm.can_view() %} +
+ {% if vote_perm.can_vote() %} +
+ {%- if vote is none -%} + {{ _('Vote on problem points') }} + {%- else -%} + {{ _('Edit points vote (%(points)d)', points=vote.points) }} + {%- endif -%} +
+ {% endif %} +
{{ _('Voting statistics') }}
+ {% endif %} + {% if (editorial and editorial.is_accessible_by(request.user)) and not request.in_contest %}
{{ _('Read editorial') }}
diff --git a/templates/problem/vote-ajax.html b/templates/problem/vote-ajax.html new file mode 100644 index 0000000000..bb4748791a --- /dev/null +++ b/templates/problem/vote-ajax.html @@ -0,0 +1,42 @@ +
+
+ {% if vote %} + {{ _('Change vote') }} + + {%- with vote_date=vote.vote_time|date(_('N j, Y, g:i a')) -%} + {{ _('Last voted on %(date)s', date=vote_date) }} + {%- endwith -%} + + {% else %} + {{ _('Cast vote') }} + {% endif %} +
+
+ {% csrf_token %} +
+
+
+ {% csrf_token %} + {{ _('Points:') }} + +
+
+ {{ _('Justification:') }} +
+ +
+
+
+ + {% if vote %} + + {% endif %} +
+
+ +
diff --git a/templates/problem/vote-stats-ajax.html b/templates/problem/vote-stats-ajax.html new file mode 100644 index 0000000000..e17accb5aa --- /dev/null +++ b/templates/problem/vote-stats-ajax.html @@ -0,0 +1,22 @@ +
+
+ {{ _('Voting statistics') }} +
+ {% if not votes %} +
+ {{ _('No votes available!') }} +
+ {% else %} + + +
+ {% with median_str=median|floatformat(1), mean_str=mean|floatformat(1), vote_count=votes|length %} + {{ _('Median vote: %(val)s', val=median_str) }} + {{ _('Mean vote: %(val)s', val=mean_str) }} + {{ _('Number of votes: %(val)d', val=vote_count) }} + {% endwith %} +
+ {% endif %} +
From c07b4ba35255961b49fec8e011cb9abcdc88fdd7 Mon Sep 17 00:00:00 2001 From: int-y1 Date: Tue, 13 Sep 2022 01:50:32 -0400 Subject: [PATCH 5/5] Fix problem solved condition --- judge/models/problem.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/judge/models/problem.py b/judge/models/problem.py index 4abc8509f5..327e3dcd99 100644 --- a/judge/models/problem.py +++ b/judge/models/problem.py @@ -449,7 +449,7 @@ def save(self, *args, **kwargs): def is_solved_by(self, user): # Return true if a full AC submission to the problem from the user exists. - return self.submission_set.filter(user=user.profile, result='AC', points=F('problem__points')).exists() + return self.submission_set.filter(user=user.profile, result='AC', points__gte=F('problem__points')).exists() def vote_permission_for_user(self, user): if not user.is_authenticated: