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 %}