Skip to content
New issue

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

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

Already on GitHub? # to your account

Add problem points vote functionality #1645

Merged
merged 5 commits into from
Dec 20, 2022
Merged
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
7 changes: 7 additions & 0 deletions dmoj/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,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 # when voting on problem, minimum point value user can select
DMOJ_PROBLEM_MAX_USER_POINTS_VOTE = 50 # when voting on problem, maximum point value user can select
DMOJ_PROBLEM_HOT_PROBLEM_COUNT = 7
DMOJ_PROBLEM_STATEMENT_DISALLOWED_CHARACTERS = {'“', '”', '‘', '’', '−', 'ff', 'fi', 'fl', 'ffi', 'ffl'}
DMOJ_RATING_COLORS = True
Expand Down Expand Up @@ -178,6 +180,7 @@
'judge.ProblemGroup',
'judge.ProblemType',
'judge.License',
'judge.ProblemPointsVote',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't expose comment votes in the sidebar, probably don't need to do so here.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was done to give access to ProblemPointsVote.note.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, should we surface a link to a filtered admin view in the UI, or embed justifications in it? It seems like we can view problem vote distributions in the problem UI, but then an admin has to go to admin, ProblemPointsVote view, search by the same problem ID, and then read notes.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh wait, this filtered link already exists...

],
},
('judge.Submission', 'fa-check-square-o'),
Expand Down Expand Up @@ -588,3 +591,7 @@
exec(f.read(), globals())
except IOError:
pass


# Check settings are consistent
assert DMOJ_PROBLEM_MIN_USER_POINTS_VOTE >= DMOJ_PROBLEM_MIN_PROBLEM_POINTS
4 changes: 4 additions & 0 deletions dmoj/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,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('/vote', problem.ProblemVote.as_view(), name='problem_vote'),
path('/vote/delete', problem.DeleteProblemVote.as_view(), name='delete_problem_vote'),
path('/vote/stats', problem.ProblemVoteStats.as_view(), name='problem_vote_stats'),

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
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 ClassAdmin, 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, Class, 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 @@ -35,6 +35,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
31 changes: 29 additions & 2 deletions judge/admin/problem.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@
from django.contrib import admin
from django.db import transaction
from django.forms import ModelForm
from django.urls import reverse_lazy
from django.urls import reverse, reverse_lazy
from django.utils import timezone
from django.utils.html import format_html
from django.utils.translation import gettext, gettext_lazy as _, ngettext
from reversion.admin import VersionAdmin

from judge.models import LanguageLimit, Problem, ProblemClarification, ProblemTranslation, Profile, Solution
from judge.models import LanguageLimit, Problem, ProblemClarification, ProblemPointsVote, ProblemTranslation, Profile, \
Solution
from judge.utils.views import NoBatchDeleteMixin
from judge.widgets import AdminHeavySelect2MultipleWidget, AdminMartorWidget, AdminSelect2MultipleWidget, \
AdminSelect2Widget, CheckboxSelectMultipleWithSelectAll
Expand Down Expand Up @@ -248,3 +249,29 @@ 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', 'linked_problem', 'vote_time')
search_fields = ('voter__user__username', 'problem__code', 'problem__name')
readonly_fields = ('voter', 'problem', 'vote_time')

def get_queryset(self, request):
return ProblemPointsVote.objects.filter(problem__in=Problem.get_editable_problems(request.user))

def has_add_permission(self, request):
return False

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',)

def linked_problem(self, obj):
link = reverse('problem_detail', args=[obj.problem.code])
return format_html('<a href="{0}">{1}</a>', link, obj.problem.name)
linked_problem.short_description = _('problem')
linked_problem.admin_order_field = 'problem__name'
4 changes: 2 additions & 2 deletions judge/admin/profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,8 @@ def has_add_permission(self, request, obj=None):

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')
'math_engine', 'last_access', 'ip', 'mute', 'is_unlisted', 'is_banned_from_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
11 changes: 10 additions & 1 deletion judge/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
from django.utils.translation import gettext_lazy as _, ngettext_lazy

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,11 @@ def clean_key(self):
if Contest.objects.filter(key=key).exists():
raise ValidationError(_('Contest with key already exists.'))
return key


class ProblemPointsVoteForm(ModelForm):
note = CharField(max_length=8192, required=False)

class Meta:
model = ProblemPointsVote
fields = ['points', 'note']
44 changes: 44 additions & 0 deletions judge/migrations/0134_add_voting_functionality.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import django.core.validators
import django.db.models.deletion
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('judge', '0133_add_problem_data_hints'),
]

operations = [
migrations.AddField(
model_name='profile',
name='is_banned_from_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 on problem point values'),
),
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 the voter thinks this problem deserves.',
validators=[django.core.validators.MinValueValidator(1),
django.core.validators.MaxValueValidator(50)],
verbose_name='proposed points')),
('note', models.TextField(blank=True, default='', help_text='Justification for problem point value.',
max_length=8192, verbose_name='note')),
('problem', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE,
related_name='problem_points_votes', to='judge.Problem',
verbose_name='problem')),
('voter', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE,
related_name='problem_points_votes', to='judge.Profile',
verbose_name='voter')),
('vote_time', models.DateTimeField(auto_now_add=True, help_text='The time this vote was cast.',
verbose_name='vote time')),
],
options={
'verbose_name': 'problem vote',
'verbose_name_plural': 'problem votes',
},
),
]
3 changes: 2 additions & 1 deletion judge/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +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, TranslatedProblemQuerySet
ProblemPointsVote, ProblemTranslation, ProblemType, Solution, SubmissionSourceAccess, \
TranslatedProblemQuerySet
from judge.models.problem_data import CHECKERS, ProblemData, ProblemTestCase, problem_data_storage, \
problem_directory_file
from judge.models.profile import Class, Organization, OrganizationRequest, Profile, WebAuthnCredential
Expand Down
64 changes: 64 additions & 0 deletions judge/models/problem.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from enum import IntEnum
from operator import attrgetter

from django.conf import settings
Expand Down Expand Up @@ -93,6 +94,18 @@ class SubmissionSourceAccess:
FOLLOW = 'F'


class VotePermission(IntEnum):
NONE = 0
VIEW = 1
VOTE = 2

def can_view(self):
return self >= VotePermission.VIEW

def can_vote(self):
return self >= VotePermission.VOTE
Comment on lines +102 to +106
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I cared I'd ask for this to be @property, but this has dragged on for long enough.



class Problem(models.Model):
SUBMISSION_SOURCE_ACCESS = (
(SubmissionSourceAccess.FOLLOW, _('Follow global setting')),
Expand Down Expand Up @@ -434,6 +447,32 @@ def save(self, *args, **kwargs):

save.alters_data = True

def is_solved_by(self, user):
# Return true if a full AC submission to the problem from the user exists.
return self.submission_set.filter(user=user.profile, result='AC', points__gte=F('problem__points')).exists()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is going to have issues with 9999/10000 in a 5 point problem or something, but I couldn't be bothered to care.


def vote_permission_for_user(self, user):
if not user.is_authenticated:
return VotePermission.NONE

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

# If the user is not allowed to vote.
if user.profile.is_unlisted or user.profile.is_banned_from_problem_voting:
return VotePermission.VIEW

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

# If the user has not solved the problem.
if not self.is_solved_by(user):
return VotePermission.VIEW

return VotePermission.VOTE

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


class ProblemPointsVote(models.Model):
points = models.IntegerField(
verbose_name=_('proposed points'),
help_text=_('The amount of points the voter thinks this problem deserves.'),
validators=[
MinValueValidator(settings.DMOJ_PROBLEM_MIN_USER_POINTS_VOTE),
MaxValueValidator(settings.DMOJ_PROBLEM_MAX_USER_POINTS_VOTE),
],
)
voter = models.ForeignKey(Profile, verbose_name=_('voter'), related_name='problem_points_votes', on_delete=CASCADE)
problem = models.ForeignKey(Problem, verbose_name=_('problem'), related_name='problem_points_votes',
on_delete=CASCADE)
vote_time = models.DateTimeField(verbose_name=_('vote time'), help_text=_('The time this vote was cast.'),
auto_now_add=True)
note = models.TextField(verbose_name=_('note'), help_text=_('Justification for problem point value.'),
max_length=8192, blank=True, default='')

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

def __str__(self):
return _('Points vote by %(voter)s for %(problem)s') % {'voter': self.voter, 'problem': self.problem}
5 changes: 5 additions & 0 deletions judge/models/profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,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_from_problem_voting = models.BooleanField(
verbose_name=_('banned from voting on problem point values'),
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
3 changes: 1 addition & 2 deletions judge/models/submission.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,8 +151,7 @@ def can_see_detail(self, user):
return True
elif source_visibility == SubmissionSourceAccess.SOLVED and \
(self.problem.is_public or self.problem.testers.filter(id=profile.id).exists()) and \
self.problem.submission_set.filter(user_id=profile.id, result='AC',
points=self.problem.points).exists():
self.problem.is_solved_by(user):
return True
elif source_visibility == SubmissionSourceAccess.ONLY_OWN and \
self.problem.testers.filter(id=profile.id).exists():
Expand Down
Loading