diff --git a/dmoj/settings.py b/dmoj/settings.py index 11895be7a..e2b94a2e4 100755 --- a/dmoj/settings.py +++ b/dmoj/settings.py @@ -104,6 +104,10 @@ VNOJ_CONTEST_CHEATING_BAN_MESSAGE = 'Banned for multiple cheating offenses during contests' VNOJ_MAX_DISQUALIFICATIONS_BEFORE_BANNING = 3 +VNOJ_PROBLEM_TYPE_VOTING_VOTERS_THRESHOLD = 10 +VNOJ_PROBLEM_TYPE_VOTING_VOTES_LOWERBOUND = 4 +VNOJ_PROBLEM_TYPE_VOTING_TYPES_UPPERBOUND = 3 + # List of subdomain that will be ignored in organization subdomain middleware VNOJ_IGNORED_ORGANIZATION_SUBDOMAINS = ['oj', 'www', 'localhost'] diff --git a/dmoj/urls.py b/dmoj/urls.py index 3cf8663ce..947337396 100644 --- a/dmoj/urls.py +++ b/dmoj/urls.py @@ -143,6 +143,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('/types', include([ + path('/ajax', problem.ProblemTypeVotingAjax.as_view(), name='problem_type_voting_ajax'), + ])), + 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/admin/problem.py b/judge/admin/problem.py index 0c1b29688..9a2ee7ea4 100644 --- a/judge/admin/problem.py +++ b/judge/admin/problem.py @@ -131,7 +131,7 @@ class ProblemAdmin(NoBatchDeleteMixin, VersionAdmin): ), }), (_('Social Media'), {'classes': ('collapse',), 'fields': ('og_image', 'summary')}), - (_('Taxonomy'), {'fields': ('types', 'group')}), + (_('Taxonomy'), {'fields': ('types', 'allow_type_voting', 'automated_type_voting', 'group')}), (_('Points'), {'fields': (('points', 'partial'), 'short_circuit')}), (_('Limits'), {'fields': ('time_limit', 'memory_limit')}), (_('Language'), {'fields': ('allowed_languages',)}), diff --git a/judge/forms.py b/judge/forms.py index bbd769da5..c12245228 100755 --- a/judge/forms.py +++ b/judge/forms.py @@ -14,7 +14,7 @@ from django.db.models import Q from django.forms import BooleanField, CharField, ChoiceField, DateInput, Form, ModelForm, MultipleChoiceField, \ formset_factory, inlineformset_factory -from django.forms.widgets import DateTimeInput +from django.forms.widgets import CheckboxSelectMultiple, DateTimeInput from django.template.defaultfilters import filesizeformat from django.urls import reverse, reverse_lazy from django.utils.text import format_lazy @@ -22,7 +22,7 @@ from django_ace import AceWidget from judge.models import BlogPost, Contest, ContestAnnouncement, ContestProblem, Language, LanguageLimit, \ - Organization, Problem, Profile, Solution, Submission, Tag, WebAuthnCredential + Organization, Problem, ProblemType, Profile, Solution, Submission, Tag, WebAuthnCredential from judge.utils.subscription import newsletter_id from judge.widgets import HeavySelect2MultipleWidget, HeavySelect2Widget, MartorWidget, \ Select2MultipleWidget, Select2Widget @@ -173,6 +173,7 @@ class ProblemEditForm(ModelForm): def __init__(self, *args, **kwargs): self.org_pk = org_pk = kwargs.pop('org_pk', None) self.user = kwargs.pop('user', None) + manage_type_voting = kwargs.pop('manage_type_voting', False) super(ProblemEditForm, self).__init__(*args, **kwargs) # Only allow to public/private problem in organization @@ -185,6 +186,10 @@ def __init__(self, *args, **kwargs): self.fields['testers'].widget.data_url = reverse('organization_profile_select2', args=(org_pk, )) + if not manage_type_voting: + self.fields.pop('allow_type_voting') + self.fields.pop('automated_type_voting') + self.fields['testers'].help_text = \ str(self.fields['testers'].help_text) + ' ' + \ str(_('You can paste a list of usernames into this box.')) @@ -226,7 +231,7 @@ class Meta: model = Problem fields = ['is_public', 'code', 'name', 'time_limit', 'memory_limit', 'points', 'partial', 'statement_file', 'source', 'types', 'group', 'submission_source_visibility_mode', - 'testcase_visibility_mode', 'description', 'testers'] + 'testcase_visibility_mode', 'description', 'testers', 'allow_type_voting', 'automated_type_voting'] widgets = { 'types': Select2MultipleWidget, 'group': Select2Widget, @@ -613,6 +618,28 @@ def clean_code(self): return code +class ProblemTypeVotingForm(Form): + types = forms.ModelMultipleChoiceField( + label=_('Problem types'), + queryset=ProblemType.objects.all(), + widget=Select2MultipleWidget(attrs={'style': 'width: 400px'}), + required=False, + ) + + +class ProblemTypeAcceptForm(Form): + types = forms.MultipleChoiceField( + label=_('Problem types'), + choices=ProblemType.objects.none(), + widget=CheckboxSelectMultiple(), + required=False, + ) + + def __init__(self, choices, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['types'].choices = choices + + class ContestAnnouncementForm(forms.ModelForm): class Meta: model = ContestAnnouncement diff --git a/judge/migrations/0204_problem_type_voting.py b/judge/migrations/0204_problem_type_voting.py new file mode 100644 index 000000000..9e9c1c03c --- /dev/null +++ b/judge/migrations/0204_problem_type_voting.py @@ -0,0 +1,38 @@ +# Generated by Django 3.2.23 on 2024-03-23 11:35 + +import django.db.models.deletion +from django.db import migrations, models + + +def set_type_voting_default(apps, schema_editor): + Problem = apps.get_model('judge', 'Problem') + db_alias = schema_editor.connection.alias + Problem.objects.using(db_alias).filter(is_public=True, is_organization_private=False).update(allow_type_voting=True) + + +class Migration(migrations.Migration): + + dependencies = [ + ('judge', '0203_add_field_vnoj_points'), + ] + + operations = [ + migrations.AddField( + model_name='problem', + name='allow_type_voting', + field=models.BooleanField(default=False, help_text='Allow public voting on problem types.'), + ), + migrations.RunPython(set_type_voting_default, migrations.RunPython.noop, atomic=True), + migrations.CreateModel( + name='ProblemTypeVote', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('problem', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='judge.problem')), + ('problem_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='judge.problemtype')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='judge.profile')), + ], + options={ + 'unique_together': {('user', 'problem', 'problem_type')}, + }, + ), + ] diff --git a/judge/migrations/0205_automated_problem_type_voting.py b/judge/migrations/0205_automated_problem_type_voting.py new file mode 100644 index 000000000..e4f6a050d --- /dev/null +++ b/judge/migrations/0205_automated_problem_type_voting.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.23 on 2024-03-24 05:22 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('judge', '0204_problem_type_voting'), + ] + + operations = [ + migrations.AddField( + model_name='problem', + name='automated_type_voting', + field=models.BooleanField(default=True, help_text='Automatically set problem types based on ' + 'a certain threshold of votes.'), + ), + ] diff --git a/judge/models/__init__.py b/judge/models/__init__.py index 8b2828236..b3b062e1c 100644 --- a/judge/models/__init__.py +++ b/judge/models/__init__.py @@ -6,7 +6,7 @@ ContestSubmission, ContestTag, Rating from judge.models.interface import BlogPost, BlogVote, MiscConfig, NavigationBar, validate_regex from judge.models.problem import LanguageLimit, License, Problem, ProblemClarification, ProblemGroup, \ - ProblemTranslation, ProblemType, Solution, SubmissionSourceAccess, TranslatedProblemQuerySet + ProblemTranslation, ProblemType, ProblemTypeVote, Solution, SubmissionSourceAccess, TranslatedProblemQuerySet from judge.models.problem_data import CHECKERS, ProblemData, ProblemTestCase, problem_data_storage, \ problem_directory_file from judge.models.profile import Badge, Organization, OrganizationRequest, Profile, WebAuthnCredential diff --git a/judge/models/problem.py b/judge/models/problem.py index 7a90824ae..f471efd95 100644 --- a/judge/models/problem.py +++ b/judge/models/problem.py @@ -22,8 +22,8 @@ from judge.user_translations import gettext as user_gettext from judge.utils.url import get_absolute_pdf_url -__all__ = ['ProblemGroup', 'ProblemType', 'Problem', 'ProblemTranslation', 'ProblemClarification', 'License', - 'Solution', 'SubmissionSourceAccess', 'TranslatedProblemQuerySet'] +__all__ = ['ProblemGroup', 'ProblemType', 'ProblemTypeVote', 'Problem', 'ProblemTranslation', 'ProblemClarification', + 'License', 'Solution', 'SubmissionSourceAccess', 'TranslatedProblemQuerySet'] def disallowed_characters_validator(text): @@ -220,6 +220,11 @@ class Problem(models.Model): suggester = models.ForeignKey(Profile, blank=True, null=True, related_name='suggested_problems', on_delete=SET_NULL) + allow_type_voting = models.BooleanField(default=False, help_text=_('Allow public voting on problem types.')) + + automated_type_voting = models.BooleanField(default=True, help_text=_('Automatically set problem types based on ' + 'a certain threshold of votes.')) + allow_view_feedback = models.BooleanField( help_text=_('Allow user to view checker feedback.'), default=False, @@ -341,6 +346,10 @@ def is_testcase_accessible_by(self, user): # Don't need to check for ProblemTestcaseAccess.AUTHOR_ONLY return False + def is_type_voting_manageable_by(self, user): + # Type voting can be enabled on all public problems + return self.is_public and not self.is_organization_private + @classmethod def get_visible_problems(cls, user): # Do unauthenticated check here so we can skip authentication checks later on. @@ -629,6 +638,15 @@ class Meta: verbose_name_plural = _('problems') +class ProblemTypeVote(models.Model): + user = models.ForeignKey(Profile, on_delete=models.CASCADE) + problem = models.ForeignKey(Problem, on_delete=models.CASCADE) + problem_type = models.ForeignKey(ProblemType, on_delete=CASCADE) + + class Meta: + unique_together = ('user', 'problem', 'problem_type') + + class ProblemTranslation(models.Model): problem = models.ForeignKey(Problem, verbose_name=_('problem'), related_name='translations', on_delete=CASCADE) language = models.CharField(verbose_name=_('language'), max_length=7, choices=settings.LANGUAGES) diff --git a/judge/utils/problems.py b/judge/utils/problems.py index fd09f5e87..bf30987c6 100644 --- a/judge/utils/problems.py +++ b/judge/utils/problems.py @@ -40,6 +40,25 @@ def user_completed_ids(profile): return result +def can_vote_problem_type(user, problem): + if not user.is_authenticated: + return False + + if user.profile.current_contest is not None: + return False + + if not problem.is_accessible_by(user): + return False + + if not problem.allow_type_voting: + return False + + if problem.id not in user_completed_ids(user.profile): + return False + + return True + + def contest_attempted_ids(participation): key = 'contest_attempted:%s' % participation.id result = cache.get(key) diff --git a/judge/views/problem.py b/judge/views/problem.py index 4d658723c..bbbe4f22b 100755 --- a/judge/views/problem.py +++ b/judge/views/problem.py @@ -10,7 +10,7 @@ from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin from django.core.exceptions import ObjectDoesNotExist, PermissionDenied from django.db import transaction -from django.db.models import BooleanField, Case, F, Prefetch, Q, When +from django.db.models import BooleanField, Case, Count, F, Prefetch, Q, When from django.db.utils import ProgrammingError from django.http import Http404, HttpResponse, HttpResponseForbidden, HttpResponseRedirect from django.shortcuts import get_object_or_404 @@ -28,17 +28,17 @@ from judge.comments import CommentedDetailView from judge.forms import LanguageLimitFormSet, ProblemCloneForm, ProblemEditForm, ProblemImportPolygonForm, \ - ProblemImportPolygonStatementFormSet, ProblemSubmitForm, ProposeProblemSolutionFormSet + ProblemImportPolygonStatementFormSet, ProblemSubmitForm, ProblemTypeAcceptForm, ProblemTypeVotingForm, \ + ProposeProblemSolutionFormSet from judge.models import ContestSubmission, Judge, Language, Problem, ProblemGroup, \ - ProblemTranslation, ProblemType, RuntimeVersion, Solution, Submission, SubmissionSource + ProblemTranslation, ProblemType, ProblemTypeVote, RuntimeVersion, Solution, Submission, SubmissionSource from judge.tasks import on_new_problem from judge.template_context import misc_config from judge.utils.codeforces_polygon import ImportPolygonError, PolygonImporter from judge.utils.diggpaginator import DiggPaginator from judge.utils.opengraph import generate_opengraph from judge.utils.pdfoid import PDF_RENDERING_ENABLED, render_pdf -from judge.utils.problems import hot_problems, user_attempted_ids, \ - user_completed_ids +from judge.utils.problems import can_vote_problem_type, hot_problems, user_attempted_ids, user_completed_ids from judge.utils.strings import safe_float_or_none, safe_int_or_none from judge.utils.tickets import own_ticket_filter from judge.utils.views import QueryStringSortMixin, SingleObjectFormView, TitleMixin, add_file_response, generic_message @@ -243,6 +243,7 @@ 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_problem_type'] = can_vote_problem_type(self.request.user, self.object) return context @@ -957,6 +958,7 @@ def get_object(self, queryset=None): problem = super(ProblemEdit, self).get_object(queryset) if not problem.is_editable_by(self.request.user): raise PermissionDenied() + self.manage_type_voting = problem.is_type_voting_manageable_by(self.request.user) return problem def get_solution_formset(self): @@ -970,10 +972,23 @@ def get_language_limit_formset(self): form_kwargs={'user': self.request.user}) return LanguageLimitFormSet(instance=self.get_object(), form_kwargs={'user': self.request.user}) + def get_problem_type_accept_form(self): + problem_types = ProblemType.objects.annotate(votes=Count('problemtypevote', + filter=Q(problemtypevote__problem=self.object))) + return ProblemTypeAcceptForm(prefix='type_accept_form', + data=self.request.POST if self.request.POST else None, + choices=[(obj.id, obj) for obj in problem_types]) + def get_context_data(self, **kwargs): data = super().get_context_data(**kwargs) data['lang_limit_formset'] = self.get_language_limit_formset() data['solution_formset'] = self.get_solution_formset() + + if self.manage_type_voting: + # Show the voting statistics & accept form + data['type_accept_form'] = self.get_problem_type_accept_form() + data['type_voter_count'] = (ProblemTypeVote.objects.filter(problem=self.object) + .values('user').distinct().count()) return data def get_form_kwargs(self): @@ -984,6 +999,8 @@ def get_form_kwargs(self): if self.object.organizations.count() == 1: kwargs['org_pk'] = self.object.organizations.values_list('pk', flat=True)[0] + kwargs['manage_type_voting'] = self.manage_type_voting + kwargs['user'] = self.request.user return kwargs @@ -998,17 +1015,29 @@ def post(self, request, *args, **kwargs): form_lang_limit = self.get_language_limit_formset() form_edit = self.get_solution_formset() if form.is_valid() and form_edit.is_valid() and form_lang_limit.is_valid(): - with revisions.create_revision(atomic=True): - problem = form.save() - self.save_statement(form, problem) - problem.save() - form_lang_limit.save() - form_edit.save() + # Handle problem type voting + additional_types = ProblemType.objects.none() + if self.manage_type_voting: + form_type_accept = self.get_problem_type_accept_form() + if form_type_accept.is_valid(): + additional_types = ProblemType.objects.filter(id__in=form_type_accept.cleaned_data['types']) + else: + additional_types = None + + if additional_types is not None: + with revisions.create_revision(atomic=True): + problem = form.save() + self.save_statement(form, problem) + # Add additional problem types from the voting process + problem.types.add(*additional_types) + problem.save() + form_lang_limit.save() + form_edit.save() - revisions.set_comment(_('Edited from site')) - revisions.set_user(self.request.user) + revisions.set_comment(_('Edited from site')) + revisions.set_user(self.request.user) - return HttpResponseRedirect(reverse('problem_detail', args=[self.object.code])) + return HttpResponseRedirect(reverse('problem_detail', args=[self.object.code])) return self.render_to_response(self.get_context_data(object=self.object)) @@ -1018,3 +1047,70 @@ def dispatch(self, request, *args, **kwargs): except PermissionDenied: return generic_message(request, _("Can't edit problem"), _('You are not allowed to edit this problem.'), status=403) + + +class ProblemTypeVotingAjax(ProblemMixin, SingleObjectFormView): + template_name = 'problem/type-voting-ajax.html' + form_class = ProblemTypeVotingForm + + def get_object(self, queryset=None): + problem = super().get_object() + if not can_vote_problem_type(self.request.user, problem): + raise PermissionDenied() + return problem + + def get_suggested_types(self): + return (ProblemTypeVote.objects.filter(user=self.request.profile, problem=self.object) + .values_list('problem_type', flat=True)) + + def get_initial(self): + initial = super().get_initial() + initial['types'] = self.get_suggested_types() + return initial + + def form_valid(self, form): + with transaction.atomic(): + # Remove old votes, add new ones + old_types = set(self.get_suggested_types()) + new_types = [] + types = form.cleaned_data['types'] + for problem_type in types: + if problem_type in old_types: + old_types.remove(problem_type) + else: + new_types.append(problem_type) + ProblemTypeVote.objects.filter(user=self.request.profile, problem=self.object, + problem_type__in=old_types).delete() + ProblemTypeVote.objects.bulk_create(ProblemTypeVote( + user=self.request.profile, + problem=self.object, + problem_type=problem_type, + ) for problem_type in new_types) + + if self.object.automated_type_voting: + voters = ProblemTypeVote.objects.filter(problem=self.object).values('user').distinct().count() + if voters >= settings.VNOJ_PROBLEM_TYPE_VOTING_VOTERS_THRESHOLD: + # If the number of distinct voters exceeds VNOJ_PROBLEM_TYPE_VOTING_VOTERS_THRESHOLD: + # - Sort the votes of each type in descending order + # - Filter the types whose number of votes exceeds VNOJ_PROBLEM_TYPE_VOTING_VOTES_LOWERBOUND + # - Get the first VNOJ_PROBLEM_TYPE_VOTING_TYPES_UPPERBOUND records + problem_types = ( + ProblemType.objects.annotate(votes=Count('problemtypevote', + filter=Q(problemtypevote__problem=self.object))) + .order_by('-votes') + .filter(votes__gte=settings.VNOJ_PROBLEM_TYPE_VOTING_VOTES_LOWERBOUND) + [:settings.VNOJ_PROBLEM_TYPE_VOTING_TYPES_UPPERBOUND]) + self.object.types.add(*problem_types) + + # Disable public voting + self.object.allow_type_voting = False + + self.object.save() + + return super().form_valid(form) + + def form_invalid(self, form): + raise PermissionDenied() + + def get_success_url(self): + return self.object.get_absolute_url() diff --git a/templates/problem/editor.html b/templates/problem/editor.html index 3ae288ad5..83a9827fd 100644 --- a/templates/problem/editor.html +++ b/templates/problem/editor.html @@ -2,6 +2,9 @@ {% block js_media %} {{ form.media.js }} + {% if type_accept_form %} + {{ type_accept_form.media.js }} + {% endif %} {% include "leave-warning.html" %} @@ -20,6 +23,26 @@ $('#lang_limit_title').click(); {% endif %} + {% if type_accept_form %} + $('#type-stats-title').click(function() { + $('#type-stats-title i').toggleClass('fa-caret-down fa-caret-up'); + $('#type-stats-table').toggleClass('hidden'); + }); + $('#type-stats-title').click(); + + // Sort the types by vote + $('.type-stats-entry').sort(function(a, b) { + return $(b).find('td:eq(1)').text() - $(a).find('td:eq(1)').text(); + }).prependTo($('#type-stats-table tbody')); + + $('.checkbox-td').click(function(event) { + var input = $(this).find('input:checkbox').get(0); + if (event.target != input) { + input.click(); + } + }); + {% endif %} + var noResults = function () { return '{{ _('Press Enter to select multiple users...') }}'; }; @@ -59,6 +82,9 @@ {% block media %} {{ form.media.css }} + {% if type_accept_form %} + {{ type_accept_form.media.css }} + {% endif %}