Skip to content

Commit

Permalink
Added problem voting functionality
Browse files Browse the repository at this point in the history
Co-authored-by: magicalsoup <amagicalsoup@gmail.com>
  • Loading branch information
lakshy-gupta and magicalsoup committed Sep 13, 2021
1 parent 10365cb commit 9e862fc
Show file tree
Hide file tree
Showing 18 changed files with 656 additions and 15 deletions.
6 changes: 6 additions & 0 deletions dmoj/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = 1
DMOJ_PROBLEM_MAX_USER_POINTS_VOTE = 50
DMOJ_PROBLEM_HOT_PROBLEM_COUNT = 7
DMOJ_PROBLEM_STATEMENT_DISALLOWED_CHARACTERS = {'“', '”', '‘', '’'}
DMOJ_RATING_COLORS = True
Expand Down Expand Up @@ -185,6 +187,10 @@
'judge.Judge',
],
},
{
'model': 'judge.ProblemPointsVote',
'icon': 'fa-envelope',
},
{
'model': 'judge.Contest',
'icon': 'fa-bar-chart',
Expand Down
3 changes: 3 additions & 0 deletions dmoj/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,9 @@ def paged_list_view(view, name):
url(r'^/tickets$', ticket.ProblemTicketListView.as_view(), name='problem_ticket_list'),
url(r'^/tickets/new$', ticket.NewProblemTicketView.as_view(), name='new_problem_ticket'),

url(r'^/delete_vote$', problem.DeleteVote.as_view(), name='delete_vote'),
url(r'^/vote$', problem.Vote.as_view(), name='vote'),

url(r'^/manage/submission', include([
url('^$', problem_manage.ManageProblemSubmissionView.as_view(), name='problem_manage_submissions'),
url('^/rejudge$', problem_manage.RejudgeSubmissionsView.as_view(), name='problem_submissions_rejudge'),
Expand Down
5 changes: 3 additions & 2 deletions judge/admin/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,15 @@
from judge.admin.contest import ContestAdmin, ContestParticipationAdmin, ContestTagAdmin
from judge.admin.interface import BlogPostAdmin, FlatPageAdmin, LicenseAdmin, LogEntryAdmin, NavigationBarAdmin
from judge.admin.organization import 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
from judge.admin.taxon import ProblemGroupAdmin, ProblemTypeAdmin
from judge.admin.ticket import TicketAdmin
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)
Expand All @@ -34,6 +34,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)
Expand Down
13 changes: 13 additions & 0 deletions judge/admin/problem.py
Original file line number Diff line number Diff line change
Expand Up @@ -245,3 +245,16 @@ 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', 'problem', 'note')
search_fields = ('voter', 'problem')

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',)
10 changes: 7 additions & 3 deletions judge/admin/profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,13 @@ def has_add_permission(self, request):


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')
fields = (
'user', 'display_rank', 'about', 'organizations', 'timezone',
'language', 'ace_theme', 'math_engine', 'last_access',
'ip', 'mute', 'is_unlisted', 'is_banned_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')
Expand Down
9 changes: 8 additions & 1 deletion judge/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
from django.utils.translation import gettext_lazy as _

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

Expand Down Expand Up @@ -284,3 +285,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']
40 changes: 40 additions & 0 deletions judge/migrations/0125_add_voting_functionality.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

dependencies = [
('judge', '0124_contest_show_short_display'),
]

operations = [
migrations.AddField(
model_name='profile',
name='is_banned_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'),
),
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 you think this problem deserves.',
validators=[django.core.validators.MinValueValidator(1),
django.core.validators.MaxValueValidator(50)],
verbose_name='How much this vote is worth')),
('note', models.TextField(blank=True, default='', help_text='Justification for problem points value.',
max_length=2048, verbose_name='note to go along with vote')),
('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',
},
),
]
4 changes: 2 additions & 2 deletions judge/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +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, TranslatedProblemForeignKeyQuerySet, \
TranslatedProblemQuerySet
ProblemPointsVote, ProblemTranslation, ProblemType, Solution, SubmissionSourceAccess, \
TranslatedProblemForeignKeyQuerySet, TranslatedProblemQuerySet
from judge.models.problem_data import CHECKERS, ProblemData, ProblemTestCase, problem_data_storage, \
problem_directory_file
from judge.models.profile import Organization, OrganizationRequest, Profile, WebAuthnCredential
Expand Down
48 changes: 48 additions & 0 deletions judge/models/problem.py
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,25 @@ def save(self, *args, **kwargs):

save.alters_data = True

def can_vote(self, user):
if not user.is_authenticated:
return False

# If the user is in contest, nothing should be shown.
if user.profile.current_contest:
return False

# If the user is not allowed to vote
if user.profile.is_unlisted or user.profile.is_banned_problem_voting:
return False

# If the user is banned from submitting to the problem.
if self.banned_users.filter(pk=user.pk).exists():
return False

# If the user has a full AC submission to the problem (solved the problem).
return self.submission_set.filter(user=user.profile, result='AC', points=F('problem__points')).exists()

class Meta:
permissions = (
('see_private_problem', _('See hidden problems')),
Expand Down Expand Up @@ -525,3 +544,32 @@ class Meta:
)
verbose_name = _('solution')
verbose_name_plural = _('solutions')


class ProblemPointsVote(models.Model):
points = models.IntegerField(
verbose_name=_('how much this vote is worth'),
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),
],
)

voter = models.ForeignKey(Profile, related_name='problem_points_votes', on_delete=CASCADE, db_index=True)
problem = models.ForeignKey(Problem, related_name='problem_points_votes', on_delete=CASCADE, db_index=True)

note = models.TextField(
verbose_name=_('note to go along with vote'),
help_text=_('Justification for problem points value.'),
max_length=2048,
blank=True,
default='',
)

class Meta:
verbose_name = _('vote')
verbose_name_plural = _('votes')

def __str__(self):
return f'{self.voter}: {self.points} for {self.problem.code}'
5 changes: 5 additions & 0 deletions judge/models/profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,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_problem_voting = models.BooleanField(
verbose_name=_('banned from voting'),
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.'))
Expand Down
52 changes: 50 additions & 2 deletions judge/models/tests/test_problem.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@
from django.test import SimpleTestCase, TestCase
from django.utils import timezone

from judge.models import Language, LanguageLimit, Problem
from judge.models import Language, LanguageLimit, Problem, Submission
from judge.models.problem import disallowed_characters_validator
from judge.models.tests.util import CommonDataMixin, create_organization, create_problem, create_problem_type, \
from judge.models.tests.util import CommonDataMixin, create_contest, create_contest_participation, create_organization, create_problem, create_problem_type, \
create_solution, create_user


Expand Down Expand Up @@ -237,6 +237,54 @@ 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):
Submission.objects.create(
user=user.profile,
problem=self.basic_problem,
result='AC',
points=self.basic_problem.points,
language=Language.get_python3(),
)

def test_problem_voting_permissions(self):
self.assertFalse(self.basic_problem.can_vote(self.users['anonymous']))

_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.assertFalse(self.basic_problem.can_vote(in_contest))

unlisted = create_user(username='unlisted')
unlisted.profile.is_unlisted = True
self.give_basic_problem_ac(unlisted)
self.assertFalse(self.basic_problem.can_vote(unlisted))

banned_from_voting = create_user(username='banned_from_voting')
banned_from_voting.profile.is_banned_problem_voting = True
self.give_basic_problem_ac(banned_from_voting)
self.assertFalse(self.basic_problem.can_vote(banned_from_voting))

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.assertFalse(self.basic_problem.can_vote(banned_from_problem))

self.assertFalse(self.basic_problem.can_vote(self.users['normal']))

self.give_basic_problem_ac(self.users['normal'])
self.assertTrue(self.basic_problem.can_vote(self.users['normal']))

def test_problems_list(self):
for name, user in self.users.items():
with self.subTest(user=name):
Expand Down
61 changes: 58 additions & 3 deletions judge/views/problem.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from django.db import transaction
from django.db.models import Count, F, Prefetch, Q
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
Expand All @@ -26,8 +26,8 @@
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, \
TranslatedProblemForeignKeyQuerySet
from judge.pdf_problems import DefaultPdfMaker, HAS_PDF
Expand Down Expand Up @@ -219,9 +219,64 @@ 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.object.can_vote(user)
# The vote this user has already cast on this problem.
if context['can_vote']:
try:
context['vote'] = ProblemPointsVote.objects.get(voter=user.profile, problem=self.object)
except ObjectDoesNotExist:
context['vote'] = None
else:
context['vote'] = None

all_votes = list(self.object.problem_points_votes.order_by('points').values_list('points', flat=True))

context['has_votes'] = len(all_votes) > 0

# If the user is not currently in contest.
if not user.is_authenticated or user.profile.current_contest is None:
context['all_votes'] = all_votes

context['max_possible_vote'] = settings.DMOJ_PROBLEM_MAX_USER_POINTS_VOTE
context['min_possible_vote'] = max(settings.DMOJ_PROBLEM_MIN_USER_POINTS_VOTE,
settings.DMOJ_PROBLEM_MIN_PROBLEM_POINTS)
return context


class DeleteVote(ProblemMixin, SingleObjectMixin, View):
def post(self, request, *args, **kwargs):
self.object = self.get_object()
if not request.user.is_authenticated:
return HttpResponseForbidden('Not signed in.', content_type='text/plain')
elif self.object.can_vote(request.user):
ProblemPointsVote.objects.filter(voter=request.profile, problem=self.object).delete()
return HttpResponse('success', content_type='text/plain')
else:
return HttpResponseForbidden('Not allowed to delete votes on this problem.', content_type='text/plain')


class Vote(ProblemMixin, SingleObjectMixin, View):
def post(self, request, *args, **kwargs):
self.object = self.get_object()
if not self.object.can_vote(request.user): # Not allowed to vote for some reason.
return HttpResponseForbidden('Not allowed to vote on this problem.', content_type='text/plain')

form = ProblemPointsVoteForm(request.POST)
if form.is_valid():
with transaction.atomic():
# Delete any pre existing votes.
ProblemPointsVote.objects.filter(voter=request.profile, problem=self.object).delete()
vote = form.save(commit=False)
vote.voter = request.profile
vote.problem = self.object
vote.note = vote.note.strip()
vote.save()
return JsonResponse({'points': vote.points, 'note': vote.note})
else:
return JsonResponse(form.errors, status=400)


class LatexError(Exception):
pass

Expand Down
2 changes: 2 additions & 0 deletions robots.txt
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ Disallow: /problem/*/submissions
Disallow: /problem/*/submit
Disallow: /problem/*/test_data
Disallow: /problem/*/tickets
Disallow: /problem/*/delete_vote
Disallow: /problem/*/vote
Disallow: /src
Disallow: /stats
Disallow: /submission
Expand Down
Loading

0 comments on commit 9e862fc

Please sign in to comment.