diff --git a/dmoj/urls.py b/dmoj/urls.py index 91ce3b2..0a73d5e 100644 --- a/dmoj/urls.py +++ b/dmoj/urls.py @@ -225,6 +225,8 @@ urlpatterns = [ url(r'^/participation/disqualify$', contests.ContestParticipationDisqualify.as_view(), name='contest_participation_disqualify'), + url(r'^/clarification$', contests.NewContestClarificationView.as_view(), name='new_contest_clarification'), + url(r'^/$', lambda _, contest: HttpResponsePermanentRedirect(reverse('contest_view', args=[contest]))), ])), diff --git a/judge/models/problem.py b/judge/models/problem.py index 4f7e545..e56b1c7 100644 --- a/judge/models/problem.py +++ b/judge/models/problem.py @@ -11,7 +11,9 @@ from django.db.models.functions import Coalesce from django.urls import reverse from django.utils.functional import cached_property from django.utils.translation import gettext_lazy as _ +from django.utils import timezone +from judge import event_poster as event from judge.fulltext import SearchQuerySet from judge.models.profile import Organization, Profile from judge.models.runtime import Language @@ -407,6 +409,26 @@ class ProblemClarification(models.Model): description = models.TextField(verbose_name=_('clarification body')) date = models.DateTimeField(verbose_name=_('clarification timestamp'), auto_now_add=True) + def save(self, *args, **kwargs): + super(ProblemClarification, self).save(*args, **kwargs) + + if event.real: + from judge.models import ContestProblem + + now = timezone.now() + # List all ongoing contests containing this problem + contest_problems = ContestProblem.objects.filter( + contest__start_time__lte=now, + contest__end_time__gt=now, + problem=self.problem).values_list('order', 'contest') + + for order, contest_id in contest_problems.iterator(): + event.post('contest_clarification_' + str(contest_id), { + 'problem_label': order, + 'problem_name': self.problem.name, + 'problem_code': self.problem.code, + 'body': self.description + }) class LanguageLimit(models.Model): problem = models.ForeignKey(Problem, verbose_name=_('problem'), related_name='language_limits', on_delete=CASCADE) diff --git a/judge/views/blog.py b/judge/views/blog.py index 7c6cc11..07912b2 100644 --- a/judge/views/blog.py +++ b/judge/views/blog.py @@ -104,6 +104,12 @@ class PostList(ListView): context['open_tickets'] = filter_visible_tickets(tickets, self.request.user, profile)[:10] else: context['open_tickets'] = [] + + if self.request.in_contest: + if self.request.user.is_superuser or \ + self.request.profile in self.request.participation.contest.authors.all() or \ + self.request.profile in self.request.participation.contest.curators.all(): + context['can_edit_contest'] = True return context diff --git a/judge/views/contests.py b/judge/views/contests.py index a20117f..90dc3c2 100644 --- a/judge/views/contests.py +++ b/judge/views/contests.py @@ -18,10 +18,10 @@ from django.db.models.expressions import CombinedExpression from django.http import Http404, HttpResponse, HttpResponseBadRequest, HttpResponseRedirect from django.shortcuts import get_object_or_404, render from django.template.defaultfilters import date as date_filter -from django.urls import reverse +from django.urls import reverse, reverse_lazy from django.utils import timezone from django.utils.functional import cached_property -from django.utils.html import format_html +from django.utils.html import format_html, escape from django.utils.safestring import mark_safe from django.utils.timezone import make_aware from django.utils.translation import gettext as _, gettext_lazy @@ -32,7 +32,7 @@ from judge import event_poster as event from judge.comments import CommentedDetailView from judge.forms import ContestCloneForm from judge.models import Contest, ContestMoss, ContestParticipation, ContestProblem, ContestTag, \ - Organization, Problem, Profile, Submission + Organization, Problem, Profile, Submission, ProblemClarification from judge.tasks import run_moss from judge.utils.celery import redirect_to_task_status from judge.utils.opengraph import generate_opengraph @@ -40,11 +40,12 @@ from judge.utils.problems import _get_result_data from judge.utils.ranker import ranker from judge.utils.stats import get_bar_chart, get_pie_chart, get_histogram from judge.utils.views import DiggPaginatorMixin, SingleObjectFormView, TitleMixin, generic_message +from judge.widgets import HeavyPreviewPageDownWidget __all__ = ['ContestList', 'ContestDetail', 'ContestRanking', 'ContestJoin', 'ContestLeave', 'ContestCalendar', 'ContestClone', 'ContestStats', 'ContestMossView', 'ContestMossDelete', 'contest_ranking_ajax', 'ContestParticipationList', 'ContestParticipationDisqualify', 'get_contest_ranking_list', - 'base_contest_ranking_list'] + 'base_contest_ranking_list', 'ContestClarificationView'] def _find_contest(request, key, private_check=True): @@ -854,3 +855,64 @@ class ContestTagDetail(TitleMixin, ContestTagDetailAjax): def get_title(self): return _('Contest tag: %s') % self.object.name + + +class ProblemClarificationForm(forms.Form): + body = forms.CharField(widget=HeavyPreviewPageDownWidget(preview=reverse_lazy('comment_preview'), + preview_timeout=1000, hide_preview_button=True)) + + def __init__(self, request, *args, **kwargs): + self.request = request + super(ProblemClarificationForm, self).__init__(*args, **kwargs) + self.fields['body'].widget.attrs.update({'placeholder': _('Issue description')}) + + +class NewContestClarificationView(ContestMixin, TitleMixin, SingleObjectFormView): + form_class = ProblemClarificationForm + template_name = 'contest/clarification.html' + + def get_form_kwargs(self): + kwargs = super(NewContestClarificationView, self).get_form_kwargs() + kwargs['request'] = self.request + return kwargs + + def is_accessible(self): + if not self.request.user.is_authenticated: + return False + if not self.request.in_contest: + return False + if not self.request.participation.contest == self.get_object(): + return False + return self.request.user.is_superuser or \ + self.request.profile in self.request.participation.contest.authors.all() or \ + self.request.profile in self.request.participation.contest.curators.all() + + def get(self, request, *args, **kwargs): + if not self.is_accessible(): + raise Http404() + return super().get(self, request, *args, **kwargs) + + def form_valid(self, form): + problem_code = self.request.POST['problem'] + description = form.cleaned_data['body'] + + clarification = ProblemClarification(description=description) + clarification.problem = Problem.objects.get(code=problem_code) + clarification.save() + + link = reverse('home') + return HttpResponseRedirect(link) + + def get_title(self): + return "New clarification for %s" % self.object.name + + def get_content_title(self): + return mark_safe(escape(_('New clarification for %s')) % + format_html('{1}', reverse('problem_detail', args=[self.object.key]), + self.object.name)) + + def get_context_data(self, **kwargs): + context = super(NewContestClarificationView, self).get_context_data(**kwargs) + context['problems'] = ContestProblem.objects.filter(contest=self.object)\ + .order_by('order') + return context \ No newline at end of file diff --git a/judge/views/problem.py b/judge/views/problem.py index 4c8e345..bfa6bb0 100644 --- a/judge/views/problem.py +++ b/judge/views/problem.py @@ -25,6 +25,7 @@ from django.views.generic import ListView, View from django.views.generic.base import TemplateResponseMixin from django.views.generic.detail import SingleObjectMixin +from judge import event_poster as event from judge.comments import CommentedDetailView from judge.forms import ProblemCloneForm, ProblemSubmitForm from judge.models import ContestProblem, ContestSubmission, Judge, Language, Problem, ProblemGroup, \ @@ -170,8 +171,10 @@ class ProblemDetail(ProblemMixin, SolvedProblemMixin, CommentedDetailView): contest_problem = (None if not authed or user.profile.current_contest is None else get_contest_problem(self.object, user.profile)) context['contest_problem'] = contest_problem + if contest_problem: clarifications = self.object.clarifications + context['last_msg'] = event.last() context['has_clarifications'] = clarifications.count() > 0 context['clarifications'] = clarifications.order_by('-date') context['submission_limit'] = contest_problem.max_submissions @@ -434,6 +437,7 @@ class ProblemList(QueryStringSortMixin, TitleMixin, SolvedProblemMixin, ListView context['hot_problems'] = hot_problems(timedelta(days=1), 7) context['point_start'], context['point_end'], context['point_values'] = self.get_noui_slider_points() else: + context['last_msg'] = event.last() context['hot_problems'] = None context['point_start'], context['point_end'], context['point_values'] = 0, 0, {} context['hide_contest_scoreboard'] = self.contest.scoreboard_visibility in \ diff --git a/logo.png b/logo.png new file mode 100644 index 0000000..4e1a813 Binary files /dev/null and b/logo.png differ diff --git a/resources/common.js b/resources/common.js index 04e8ebe..d2955ab 100644 --- a/resources/common.js +++ b/resources/common.js @@ -322,6 +322,10 @@ window.register_notify = function (type, options) { status_change(); }; +window.notify_clarification = function(msg) { + var message = `Problem ${msg.problem_label} (${msg.problem_name}):\n` + msg.body; + alert(message); +} $(function () { // Close dismissable boxes diff --git a/templates/blog/list.html b/templates/blog/list.html index cf11c72..38ead62 100644 --- a/templates/blog/list.html +++ b/templates/blog/list.html @@ -48,6 +48,14 @@ h3 a { color: lightcyan; } + + #add-clarification { + float: left; + color: chartreuse; + } + #add-clarification:hover { + color: cyan; + } {% endblock %} @@ -104,7 +112,16 @@