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 Aug 7, 2021
1 parent e86c114 commit 42c3419
Show file tree
Hide file tree
Showing 17 changed files with 606 additions and 13 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',)
9 changes: 6 additions & 3 deletions judge/admin/profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,12 @@ 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', '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',
'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/0122_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', '0121_per_problem_sub_access_control'),
]

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
67 changes: 64 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,70 @@ 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:
vote = ProblemPointsVote.objects.get(voter=user.profile, problem=self.object)
except ObjectDoesNotExist:
vote = None
context['has_voted'] = vote is not None
else:
context['has_voted'] = False

if context['has_voted']:
context['voted_points'] = vote.points # The previous vote's points.
context['voted_note'] = vote.note

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 and is authenticated.
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')
else:
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
14 changes: 12 additions & 2 deletions templates/admin/judge/problem/change_form.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,24 @@
$('.submissions-link').appendTo('div#bottombar').show();
});
</script>
<script>
django.jQuery(function ($) {
$('.votes-link').appendTo('div#bottombar').show();
});
</script>
{% endblock extrahead %}

{% block after_field_sets %}{{ block.super }}
{% if original %}
<a style="display: none" title="{% trans "View Submissions" %}" class="button submissions-link"
<a style="display: none" title="{{ _('View Submissions') }}" class="button submissions-link"
href="{% url 'admin:judge_submission_changelist' %}?problem__code={{ original.code }}">
<i class="fa fa-lg fa-search-plus"></i>
<span class="text">{% trans "View submissions" %}</span>
<span class="text">{{ _('View submissions') }}</span>
</a>
<a style="display: none" title="{{ _('View votes') }}" class="button votes-link"
href="{% url 'admin:judge_problempointsvote_changelist' %}?problem__code={{ original.code }}">
<i class="fa fa-lg fa-envelope"></i>
<span class="text">{{ _('View votes') }}</span>
</a>
{% endif %}
{% endblock %}
36 changes: 36 additions & 0 deletions templates/lightbox.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<style>
.lightbox {
position: fixed;
z-index: 1000;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(22,27,34,0.8);
display: none;
}

.lightbox-active {
display: flex;
justify-content: center;
align-items: center;
}
</style>
<script>
function closeLightbox(id_lightbox){
$('#'+id_lightbox).removeClass('lightbox-active')
}

function createLightbox(id_lightbox, id_content) {
$(`<div id="${id_lightbox}" class="lightbox"/>`)
.appendTo($('body'))
.append($('#'+id_content))
.on('click', e => {
if (e.target !== e.currentTarget) return;
closeLightbox(id_lightbox);
});
}

function activateLightbox(id_lightbox) {
$('#'+id_lightbox).addClass('lightbox-active')
}
</script>
Loading

0 comments on commit 42c3419

Please sign in to comment.