Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add problem type voting feature #386

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions dmoj/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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']

Expand Down
4 changes: 4 additions & 0 deletions dmoj/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
2 changes: 1 addition & 1 deletion judge/admin/problem.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',)}),
Expand Down
33 changes: 30 additions & 3 deletions judge/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,15 @@
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
from django.utils.translation import gettext_lazy as _, ngettext_lazy

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
Expand Down Expand Up @@ -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
Expand All @@ -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.'))
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
38 changes: 38 additions & 0 deletions judge/migrations/0204_problem_type_voting.py
Original file line number Diff line number Diff line change
@@ -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')},
},
),
]
19 changes: 19 additions & 0 deletions judge/migrations/0205_automated_problem_type_voting.py
Original file line number Diff line number Diff line change
@@ -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.'),
),
]
2 changes: 1 addition & 1 deletion judge/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 20 additions & 2 deletions judge/models/problem.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down
19 changes: 19 additions & 0 deletions judge/utils/problems.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading