diff --git a/dmoj/urls.py b/dmoj/urls.py index c138aee..b0829d5 100644 --- a/dmoj/urls.py +++ b/dmoj/urls.py @@ -20,7 +20,7 @@ from judge.sitemap import BlogPostSitemap, ContestSitemap, HomePageSitemap, Orga SolutionSitemap, UrlSitemap, UserSitemap from judge.views import TitledTemplateView, about, api, blog, comment, contests, language, license, mailgun, \ notification, organization, preview, problem, problem_manage, ranked_submission, register, stats, status, submission, tasks, \ - ticket, totp, user, widgets + ticket, totp, user, volunteer, widgets, internal from judge.views.problem_data import ProblemDataView, ProblemSubmissionDiff, \ problem_data_file, problem_init_view, ProblemZipUploadView from judge.views.register import ActivationView, RegistrationView @@ -118,6 +118,7 @@ urlpatterns = [ url(r'^problems/random/$', problem.RandomProblem.as_view(), name='problem_random'), url(r'^problems/feed/', paged_list_view(problem.ProblemFeed, 'problem_feed', feed_type='for_you')), url(r'^problems/feed/new/', paged_list_view(problem.ProblemFeed, 'problem_feed_new', feed_type='new')), + url(r'^problems/feed/volunteer/', paged_list_view(problem.ProblemFeed, 'problem_feed_volunteer', feed_type='volunteer')), url(r'^problem/(?P[^/]+)', include([ url(r'^$', problem.ProblemDetail.as_view(), name='problem_detail'), @@ -396,6 +397,10 @@ urlpatterns = [ url(r'^get_unread_boxes$', chat.get_unread_boxes, name='get_unread_boxes'), ])), + url(r'^internal/', include([ + url(r'^problem$', internal.InternalProblem.as_view(), name='internal_problem'), + ])), + url(r'^notifications/', login_required(notification.NotificationList.as_view()), name='notification'), @@ -406,6 +411,10 @@ urlpatterns = [ url(r'submit/$', user.import_users_submit, name='import_users_submit'), url(r'sample/$', user.sample_import_users, name='import_users_sample') ])), + + url(r'^volunteer/', include([ + url(r'^problem/vote$', volunteer.vote_problem, name='volunteer_problem_vote'), + ])), ] favicon_paths = ['apple-touch-icon-180x180.png', 'apple-touch-icon-114x114.png', 'android-chrome-72x72.png', diff --git a/judge/admin/__init__.py b/judge/admin/__init__.py index 0414d58..c2e12f9 100644 --- a/judge/admin/__init__.py +++ b/judge/admin/__init__.py @@ -11,9 +11,11 @@ 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.admin.volunteer import VolunteerProblemVoteAdmin from judge.models import BlogPost, Comment, CommentLock, Contest, ContestParticipation, \ ContestTag, Judge, Language, License, MiscConfig, NavigationBar, Organization, \ - OrganizationRequest, Problem, ProblemGroup, ProblemPointsVote, ProblemType, Profile, Submission, Ticket + OrganizationRequest, Problem, ProblemGroup, ProblemPointsVote, ProblemType, Profile, Submission, Ticket, \ + VolunteerProblemVote admin.site.register(BlogPost, BlogPostAdmin) @@ -37,3 +39,4 @@ admin.site.register(ProblemType, ProblemTypeAdmin) admin.site.register(Profile, ProfileAdmin) admin.site.register(Submission, SubmissionAdmin) admin.site.register(Ticket, TicketAdmin) +admin.site.register(VolunteerProblemVote, VolunteerProblemVoteAdmin) \ No newline at end of file diff --git a/judge/admin/volunteer.py b/judge/admin/volunteer.py new file mode 100644 index 0000000..58ebc86 --- /dev/null +++ b/judge/admin/volunteer.py @@ -0,0 +1,18 @@ +from django.contrib import admin +from django.utils.html import format_html +from django.urls import reverse +from django.utils.translation import gettext, gettext_lazy as _, ungettext + +from judge.models import VolunteerProblemVote + +class VolunteerProblemVoteAdmin(admin.ModelAdmin): + fields = ('voter', 'problem', 'time', 'thinking_points', 'knowledge_points', 'feedback') + readonly_fields = ('time', 'problem', 'voter') + list_display = ('voter', 'problem_link', 'time', 'thinking_points', 'knowledge_points', 'feedback') + date_hierarchy = 'time' + + def problem_link(self, obj): + url = reverse('admin:judge_problem_change', args=(obj.problem.id,)) + return format_html(f"{obj.problem.code}") + problem_link.short_description = _('Problem') + problem_link.admin_order_field = 'problem__code' \ No newline at end of file diff --git a/judge/migrations/0123_auto_20220502_2356.py b/judge/migrations/0123_auto_20220502_2356.py new file mode 100644 index 0000000..f681a51 --- /dev/null +++ b/judge/migrations/0123_auto_20220502_2356.py @@ -0,0 +1,36 @@ +# Generated by Django 2.2.25 on 2022-05-02 16:56 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('judge', '0122_auto_20220425_1202'), + ] + + operations = [ + migrations.AlterModelOptions( + name='problem', + options={'permissions': (('see_private_problem', 'See hidden problems'), ('edit_own_problem', 'Edit own problems'), ('edit_all_problem', 'Edit all problems'), ('edit_public_problem', 'Edit all public problems'), ('clone_problem', 'Clone problem'), ('change_public_visibility', 'Change is_public field'), ('change_manually_managed', 'Change is_manually_managed field'), ('see_organization_problem', 'See organization-private problems'), ('suggest_problem_changes', 'Suggest changes to problem')), 'verbose_name': 'problem', 'verbose_name_plural': 'problems'}, + ), + migrations.CreateModel( + name='VolunteerProblemVote', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('time', models.DateTimeField(auto_now_add=True)), + ('knowledge_points', models.PositiveIntegerField(help_text='Points awarded by knowledge difficulty', verbose_name='knowledge points')), + ('thinking_points', models.PositiveIntegerField(help_text='Points awarded by thinking difficulty', verbose_name='thinking points')), + ('feedback', models.TextField(blank=True, verbose_name='feedback')), + ('problem', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='volunteer_user_votes', to='judge.Problem')), + ('types', models.ManyToManyField(help_text="The type of problem, as shown on the problem's page.", to='judge.ProblemType', verbose_name='problem types')), + ('voter', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='volunteer_problem_votes', to='judge.Profile')), + ], + options={ + 'verbose_name': 'volunteer vote', + 'verbose_name_plural': 'volunteer votes', + 'unique_together': {('voter', 'problem')}, + }, + ), + ] diff --git a/judge/models/__init__.py b/judge/models/__init__.py index 2518bc2..88367c3 100644 --- a/judge/models/__init__.py +++ b/judge/models/__init__.py @@ -14,6 +14,7 @@ from judge.models.profile import Organization, OrganizationRequest, Profile, Fri from judge.models.runtime import Judge, Language, RuntimeVersion from judge.models.submission import SUBMISSION_RESULT, Submission, SubmissionSource, SubmissionTestCase from judge.models.ticket import Ticket, TicketMessage +from judge.models.volunteer import VolunteerProblemVote revisions.register(Profile, exclude=['points', 'last_access', 'ip', 'rating']) revisions.register(Problem, follow=['language_limits']) diff --git a/judge/models/problem.py b/judge/models/problem.py index a4ee885..ce66da5 100644 --- a/judge/models/problem.py +++ b/judge/models/problem.py @@ -377,6 +377,7 @@ class Problem(models.Model): save.alters_data = True def can_vote(self, request): + return False user = request.user if not user.is_authenticated: return False diff --git a/judge/models/volunteer.py b/judge/models/volunteer.py new file mode 100644 index 0000000..90c351c --- /dev/null +++ b/judge/models/volunteer.py @@ -0,0 +1,28 @@ +from django.db import models +from django.db.models import CASCADE +from django.utils.translation import gettext_lazy as _ + +from judge.models import Profile, Problem, ProblemType + +__all__ = ['VolunteerProblemVote'] + +class VolunteerProblemVote(models.Model): + voter = models.ForeignKey(Profile, related_name='volunteer_problem_votes', on_delete=CASCADE) + problem = models.ForeignKey(Problem, related_name='volunteer_user_votes', on_delete=CASCADE) + time = models.DateTimeField(auto_now_add=True) + knowledge_points = models.PositiveIntegerField(verbose_name=_('knowledge points'), + help_text=_('Points awarded by knowledge difficulty')) + thinking_points = models.PositiveIntegerField(verbose_name=_('thinking points'), + help_text=_('Points awarded by thinking difficulty')) + types = models.ManyToManyField(ProblemType, verbose_name=_('problem types'), + help_text=_('The type of problem, ' + "as shown on the problem's page.")) + feedback = models.TextField(verbose_name=_('feedback'), blank=True) + + class Meta: + verbose_name = _('volunteer vote') + verbose_name_plural = _('volunteer votes') + unique_together = ['voter', 'problem'] + + def __str__(self): + return f'{self.voter} for {self.problem.code}' diff --git a/judge/views/internal.py b/judge/views/internal.py new file mode 100644 index 0000000..dd19e88 --- /dev/null +++ b/judge/views/internal.py @@ -0,0 +1,35 @@ +from django.views.generic import ListView +from django.utils.translation import gettext as _, gettext_lazy +from django.db.models import Count +from django.http import HttpResponseForbidden + +from judge.utils.diggpaginator import DiggPaginator +from judge.models import VolunteerProblemVote, Problem + +class InternalProblem(ListView): + model = Problem + title = _('Internal problems') + template_name = 'internal/base.html' + paginate_by = 100 + context_object_name = 'problems' + + def get_paginator(self, queryset, per_page, orphans=0, + allow_empty_first_page=True, **kwargs): + return DiggPaginator(queryset, per_page, body=6, padding=2, orphans=orphans, + allow_empty_first_page=allow_empty_first_page, **kwargs) + + def get_queryset(self): + queryset = Problem.objects.annotate(vote_count=Count('volunteer_user_votes')) \ + .filter(vote_count__gte=1).order_by('-vote_count') + return queryset + + def get_context_data(self, **kwargs): + context = super(InternalProblem, self).get_context_data(**kwargs) + context['page_type'] = 'problem' + context['title'] = self.title + return context + + def get(self, request, *args, **kwargs): + if request.user.is_superuser: + return super(InternalProblem, self).get(request, *args, **kwargs) + return HttpResponseForbidden() \ No newline at end of file diff --git a/judge/views/problem.py b/judge/views/problem.py index 09563e7..a00e9df 100644 --- a/judge/views/problem.py +++ b/judge/views/problem.py @@ -30,7 +30,7 @@ from judge.comments import CommentedDetailView from judge.forms import ProblemCloneForm, ProblemSubmitForm, ProblemPointsVoteForm from judge.models import ContestProblem, ContestSubmission, Judge, Language, Problem, ProblemClarification, \ ProblemGroup, ProblemTranslation, ProblemType, ProblemPointsVote, RuntimeVersion, Solution, Submission, SubmissionSource, \ - TranslatedProblemForeignKeyQuerySet, Organization + TranslatedProblemForeignKeyQuerySet, Organization , VolunteerProblemVote from judge.pdf_problems import DefaultPdfMaker, HAS_PDF from judge.utils.diggpaginator import DiggPaginator from judge.utils.opengraph import generate_opengraph @@ -594,6 +594,7 @@ class ProblemList(QueryStringSortMixin, TitleMixin, SolvedProblemMixin, ListView cf_logger = logging.getLogger('judge.ml.collab_filter') + class ProblemFeed(ProblemList): model = Problem context_object_name = 'problems' @@ -640,6 +641,9 @@ class ProblemFeed(ProblemList): if self.feed_type == 'new': return queryset.order_by('-date') + elif user and self.feed_type == 'volunteer': + voted_problems = user.volunteer_problem_votes.values_list('problem', flat=True) + return queryset.exclude(id__in=voted_problems).order_by('?') if not settings.ML_OUTPUT_PATH or not user: return queryset.order_by('?') diff --git a/judge/views/volunteer.py b/judge/views/volunteer.py new file mode 100644 index 0000000..28c9d80 --- /dev/null +++ b/judge/views/volunteer.py @@ -0,0 +1,33 @@ +from django.http import HttpResponseBadRequest, JsonResponse +from django.db import transaction + +from judge.models import VolunteerProblemVote, Problem, ProblemType + + +def vote_problem(request): + if not request.user or not request.user.has_perm('judge.suggest_problem_changes'): + return HttpResponseBadRequest() + if not request.method == 'POST': + return HttpResponseBadRequest() + try: + types_id = request.POST.getlist('types[]') + types = ProblemType.objects.filter(id__in=types_id) + problem = Problem.objects.get(code=request.POST['problem']) + knowledge_points = request.POST['knowledge_points'] + thinking_points = request.POST['thinking_points'] + feedback = request.POST['feedback'] + except Exception as e: + return HttpResponseBadRequest() + + with transaction.atomic(): + vote, _ = VolunteerProblemVote.objects.get_or_create( + voter=request.profile, + problem=problem, + defaults={'knowledge_points': 0, 'thinking_points': 0}, + ) + vote.knowledge_points = knowledge_points + vote.thinking_points = thinking_points + vote.feedback = feedback + vote.types.set(types) + vote.save() + return JsonResponse({}) diff --git a/templates/base.html b/templates/base.html index bbd1c9c..8e1654f 100644 --- a/templates/base.html +++ b/templates/base.html @@ -260,6 +260,9 @@ {% if request.user.is_staff or request.user.is_superuser %}
  • {{ _('Admin') }}
  • {% endif %} + {% if request.user.is_superuser %} +
  • {{ _('Internal') }}
  • + {% endif %}
  • {{ _('Edit profile') }}
  • {% if request.user.is_impersonate %}
  • Stop impersonating
  • diff --git a/templates/contest/list.html b/templates/contest/list.html index d75f546..e0b94a7 100644 --- a/templates/contest/list.html +++ b/templates/contest/list.html @@ -214,7 +214,6 @@ {% block body %}
    -
    diff --git a/templates/internal/base.html b/templates/internal/base.html new file mode 100644 index 0000000..d3fba7c --- /dev/null +++ b/templates/internal/base.html @@ -0,0 +1,92 @@ +{% extends "three-column-content.html" %} +{% block three_col_media %} + +{% endblock %} + +{% block three_col_js %} + +{% endblock %} + +{% block left_sidebar %} + +{% endblock %} + +{% block middle_content %} + + + + + + + + + + {% for problem in problems %} + + + + + + {% endfor %} + +
    {{_('Problem')}}{{_('Code')}}{{_('Vote count')}}
    {{problem.name}}{{problem.code}}{{problem.vote_count}}
    + {% if page_obj.num_pages > 1 %} +
    {% include "list-pages.html" %}
    + {% endif %} +{% endblock %} + +{% block right_sidebar %} +
    +
    {{_('Admin')}}
    + {% for problem in problems %} + + {% endfor %} +{% endblock %} \ No newline at end of file diff --git a/templates/problem/feed.html b/templates/problem/feed.html index 56e4d5b..b61a702 100644 --- a/templates/problem/feed.html +++ b/templates/problem/feed.html @@ -31,5 +31,68 @@ {% cache 86400 'problem_html' problem.id MATH_ENGINE LANGUAGE_CODE %} {{ problem.description|markdown("problem", MATH_ENGINE)|reference|str|safe }} {% endcache %} + {% if feed_type=='volunteer' and request.user.has_perm('judge.suggest_problem_changes') %} +
    +

    {{_('Volunteer form')}}

    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + {{_('Field')}} + + {{_('Value')}} +
    + + + +
    + + + +
    + + + +
    + + + +
    + + + {% endif %}
    +
    \ No newline at end of file diff --git a/templates/problem/list.html b/templates/problem/list.html index 8627526..316d466 100644 --- a/templates/problem/list.html +++ b/templates/problem/list.html @@ -36,7 +36,15 @@ #content { width: 99%; margin-left: 0; - } + } + .volunteer-types { + width: 100%; + } + + .point-input { + height: 2em; + padding-top: 4px; + } {% endif %} {% endblock %} @@ -151,6 +159,59 @@ $end.prop('disabled', end === point_values.max).val(end); }); } + {% if feed_type=='volunteer' and request.user.has_perm('judge.suggest_problem_changes') %} + $(".edit-btn").on('click', function() { + var pid = $(this).attr('pid'); + $('#volunteer-types-' + pid).css({'width': '100%'}); + $('#volunteer-types-' + pid).select2({multiple: 1, placeholder: '{{ _('Add types...') }}'}) + .css({'visibility': 'visible'}); + + $('#form-' + pid).show(); + $('#submit-' + pid).show(); + $(this).hide(); + }); + + let isChecking = false; + $(".volunteer-submit-btn").on('click', function(e) { + var pid = $(this).attr('pid'); + var pcode = $(this).attr('pcode'); + var $form = $('#form-' + pid); + + if (!$form[0].checkValidity()) { + if (isChecking) return; + isChecking = true; + // The form won't actually submit; + $(this).click(); + } + else { + isChecking = false; + } + if (isChecking) return; + + e.preventDefault(); + $('#volunteer-types-' + pid).select2({multiple: 1, placeholder: '{{ _('Add types...') }}'}) + .css({'visibility': 'visible'}); + $('#form-' + pid).hide(); + $('#edit-' + pid).show(); + $('#thank-' + pid).show(); + $(this).hide(); + + var data = { + problem: pcode, + types: $('#volunteer-types-' + pid).val(), + knowledge_points: $('#knowledge_point-' + pid).val(), + thinking_points: $('#thinking_point-' + pid).val(), + feedback: $('#feedback-' + pid).val(), + }; + $.post("{{url('volunteer_problem_vote')}}", data) + .fail(function() { + $('#thank-' + pid).html("{{_('Fail to vote!')}}"); + }) + .done(function() { + $('#thank-' + pid).html("{{_('Successful vote! Thank you!')}}"); + }); + }); + {% endif %} }); {% endcompress %} @@ -381,6 +442,11 @@ {{_('NEW')}} + {% if request.user.has_perm('judge.suggest_problem_changes') %} + + {{_('VOLUNTEER')}} + + {% endif %} {% for problem in problems %} {% include "problem/feed.html" %}