Skip to content
Draft
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
1 change: 1 addition & 0 deletions dmoj/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,7 @@
'model': 'judge.Problem',
'icon': 'fa-question-circle',
'children': [
'judge.EditorialProposal',
'judge.ProblemGroup',
'judge.ProblemType',
'judge.License',
Expand Down
3 changes: 3 additions & 0 deletions dmoj/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,9 @@ def paged_list_view(view, name):
path('/import-polygon', problem.ProblemImportPolygon.as_view(), name='problem_import_polygon'),
])),

path('/editorial_proposal/<int:pk>', problem.EditorialProposalDetail.as_view(), name='editorial_proposal'),
path('editorial_proposal_list', problem.EditorialProposalList.as_view(), name='editorial_proposal_list'),

path('problem/<str:problem>', include([
path('', problem.ProblemDetail.as_view(), name='problem_detail'),
path('/edit', problem.ProblemEdit.as_view(), name='problem_edit'),
Expand Down
5 changes: 3 additions & 2 deletions judge/admin/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,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 EditorialProposalAdmin, ProblemAdmin
from judge.admin.profile import ProfileAdmin, UserAdmin
from judge.admin.runtime import JudgeAdmin, LanguageAdmin
from judge.admin.submission import SubmissionAdmin
from judge.admin.tag import TagAdmin, TagGroupAdmin, TagProblemAdmin
from judge.admin.taxon import ProblemGroupAdmin, ProblemTypeAdmin
from judge.admin.ticket import TicketAdmin
from judge.models import Badge, BlogPost, Comment, CommentLock, Contest, ContestParticipation, \
ContestTag, Judge, Language, License, MiscConfig, NavigationBar, Organization, \
ContestTag, EditorialProposal, Judge, Language, License, MiscConfig, NavigationBar, Organization, \
OrganizationRequest, Problem, ProblemGroup, ProblemType, Profile, Submission, Tag, \
TagGroup, TagProblem, Ticket

Expand All @@ -25,6 +25,7 @@
admin.site.register(Contest, ContestAdmin)
admin.site.register(ContestParticipation, ContestParticipationAdmin)
admin.site.register(ContestTag, ContestTagAdmin)
admin.site.register(EditorialProposal, EditorialProposalAdmin)
admin.site.unregister(FlatPage)
admin.site.register(FlatPage, FlatPageAdmin)
admin.site.register(Judge, JudgeAdmin)
Expand Down
19 changes: 19 additions & 0 deletions judge/admin/problem.py
Original file line number Diff line number Diff line change
Expand Up @@ -232,3 +232,22 @@ 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 EditorialProposalForm(ModelForm):
class Meta:
widgets = {
'problem': AdminHeavySelect2Widget(data_view='problem_select2'),
'author': AdminHeavySelect2Widget(data_view='profile_select2'),
'content': AdminMartorWidget(attrs={'data-markdownfy-url': reverse_lazy('solution_preview')}),
}


class EditorialProposalAdmin(VersionAdmin):
fields = ('problem', 'author', 'content')
list_display = ('problem', 'author', 'show_public')
form = EditorialProposalForm

@admin.display(description='')
def show_public(self, obj):
return format_html('<a href="{1}">{0}</a>', gettext('View on site'), obj.get_absolute_url())
10 changes: 10 additions & 0 deletions judge/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@

from judge.models import BlogPost, Contest, ContestAnnouncement, ContestProblem, Language, LanguageLimit, \
Organization, Problem, Profile, Solution, Submission, Tag, WebAuthnCredential
from judge.models.problem import EditorialProposal
from judge.utils.subscription import newsletter_id
from judge.widgets import AceWidget, HeavySelect2MultipleWidget, HeavySelect2Widget, MartorWidget, \
Select2MultipleWidget, Select2Widget
Expand Down Expand Up @@ -137,6 +138,15 @@ class Meta:
}


class EditorialProposalForm(ModelForm):
class Meta:
model = EditorialProposal
fields = ('content',)
widgets = {
'content': MartorWidget(attrs={'data-markdownfy-url': reverse_lazy('solution_preview')}),
}


class LanguageLimitForm(ModelForm):
def __init__(self, *args, **kwargs):
self.user = kwargs.pop('user', None)
Expand Down
30 changes: 30 additions & 0 deletions judge/migrations/0213_editorial_proposal.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Generated by Django 4.2.21 on 2025-06-01 07:22

from django.db import migrations, models
import django.db.models.deletion
import judge.models.problem


class Migration(migrations.Migration):

dependencies = [
('judge', '0212_rename_credit_fields'),
]

operations = [
migrations.CreateModel(
name='EditorialProposal',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('content', models.TextField(validators=[judge.models.problem.disallowed_characters_validator], verbose_name='editorial content')),
('author', models.ForeignKey(blank=True, on_delete=django.db.models.deletion.CASCADE, to='judge.profile', verbose_name='author')),
('problem', models.OneToOneField(blank=True, on_delete=django.db.models.deletion.CASCADE, related_name='editorial_proposals', to='judge.problem', verbose_name='associated problem')),
],
options={
'verbose_name': 'editorial proposal',
'verbose_name_plural': 'editorial proposals',
'permissions': (('see_editorial_proposal', 'See editorial proposals'),),
'unique_together': {('problem', 'author')},
},
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Generated by Django 4.2.21 on 2025-06-07 11:44

from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

dependencies = [
('judge', '0213_editorial_proposal'),
]

operations = [
migrations.AlterField(
model_name='editorialproposal',
name='problem',
field=models.ForeignKey(blank=True, on_delete=django.db.models.deletion.CASCADE, related_name='editorial_proposals', to='judge.problem', verbose_name='associated problem'),
),
]
4 changes: 2 additions & 2 deletions judge/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
from judge.models.contest import Contest, ContestAnnouncement, ContestMoss, ContestParticipation, ContestProblem, \
ContestSubmission, ContestTag, Rating
from judge.models.interface import BlogPost, BlogVote, MiscConfig, NavigationBar, validate_regex
from judge.models.problem import LanguageLimit, License, Problem, ProblemClarification, ProblemGroup, \
ProblemTranslation, ProblemType, Solution, SubmissionSourceAccess, TranslatedProblemQuerySet
from judge.models.problem import EditorialProposal, LanguageLimit, License, Problem, ProblemClarification, \
ProblemGroup, 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 Badge, Organization, OrganizationMonthlyUsage, OrganizationRequest, \
Expand Down
30 changes: 30 additions & 0 deletions judge/models/problem.py
Original file line number Diff line number Diff line change
Expand Up @@ -698,3 +698,33 @@ class Meta:
)
verbose_name = _('solution')
verbose_name_plural = _('solutions')


class EditorialProposal(models.Model):
problem = models.ForeignKey(Problem, on_delete=CASCADE, verbose_name=_('associated problem'),
blank=True, related_name='editorial_proposals')
author = models.ForeignKey(Profile, on_delete=CASCADE, verbose_name=_('author'), blank=True)
content = models.TextField(verbose_name=_('editorial content'), validators=[disallowed_characters_validator])

def get_absolute_url(self):
problem = self.problem
if problem is None:
return reverse('home')
else:
return reverse('editorial_proposal', args=(self.pk,))

def __str__(self):
return _('Editorial proposal for %s') % self.problem.name

def is_accessible_by(self, user):
return (self.problem.is_public
and not self.problem.is_organization_private
and user.has_perm('judge.approve_editorial_proposal'))

class Meta:
permissions = (
('approve_editorial_proposal', _('Approve editorial proposals')),
)
unique_together = ('problem', 'author')
verbose_name = _('editorial proposal')
verbose_name_plural = _('editorial proposals')
61 changes: 60 additions & 1 deletion judge/views/problem.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
from django.utils.html import escape, format_html
from django.utils.safestring import mark_safe
from django.utils.translation import gettext as _, gettext_lazy
from django.views.generic import CreateView, FormView, ListView, UpdateView, View
from django.views.generic import CreateView, DetailView, FormView, ListView, UpdateView, View
from django.views.generic.base import TemplateResponseMixin
from django.views.generic.detail import SingleObjectMixin
from reversion import revisions
Expand All @@ -31,10 +31,12 @@
ProblemImportPolygonForm, ProblemImportPolygonStatementFormSet, ProblemSubmitForm, ProposeProblemSolutionFormSet
from judge.models import ContestSubmission, Judge, Language, Problem, ProblemGroup, \
ProblemTranslation, ProblemType, RuntimeVersion, Solution, Submission, SubmissionSource
from judge.models.problem import EditorialProposal
from judge.tasks import on_new_problem
from judge.template_context import misc_config
from judge.utils.codeforces_polygon import ImportPolygonError, PolygonImporter
from judge.utils.diggpaginator import DiggPaginator
from judge.utils.infinite_paginator import InfinitePaginationMixin
from judge.utils.opengraph import generate_opengraph
from judge.utils.pdfoid import PDF_RENDERING_ENABLED, render_pdf
from judge.utils.problems import hot_problems, user_attempted_ids, \
Expand Down Expand Up @@ -137,6 +139,62 @@ def no_such_problem(self):
_('Could not find an editorial with the code "%s".') % code, status=404)


class EditorialProposalDetail(SolvedProblemMixin, TitleMixin, DetailView):
model = EditorialProposal
template_name = 'problem/editorial-proposal.html'

def get_title(self):
return _('Editorial proposal for %s') % self.problem.name

def get_content_title(self):
return mark_safe(escape(_('Editorial proposal for {0}')).format(
format_html('<a href="{1}">{0}</a>', self.problem.name, reverse('problem_detail', args=[self.problem.code])),
))

def get_context_data(self, **kwargs):
self.problem = self.object.problem
context = super().get_context_data(**kwargs)

solution = self.get_object()

if not solution.is_accessible_by(self.request.user) or self.request.in_contest:
raise Http404()
context['solution'] = solution
context['problem'] = self.problem
context['has_solved_problem'] = self.object.id in self.get_completed_problems()
return context


class EditorialProposalList(SolvedProblemMixin, TitleMixin, ListView):
model = EditorialProposal
title = gettext_lazy('Editorial proposals')
context_object_name = 'proposals'
template_name = 'problem/editorial-proposal-list.html'
paginate_by = 50

def get_queryset(self):
queryset = EditorialProposal.objects.filter(problem__is_public=True, problem__is_organization_private=False)
if not self.request.user.has_perm('judge.approve_editorial_proposal'):
queryset = queryset.filter(author=self.request.user)
if 'code' in self.request.GET:
code = self.request.GET.get('code', '').strip()
queryset = queryset.filter(problem__code=code)
return queryset

def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
proposals = context['proposals']
seen = set()
problems = []
for proposal in proposals:
prob = proposal.problem
if prob.code not in seen:
problems.append(prob)
seen.add(prob.code)
context['problems'] = problems
return context


class ProblemRaw(ProblemMixin, TitleMixin, TemplateResponseMixin, SingleObjectMixin, View):
context_object_name = 'problem'
template_name = 'problem/raw.html'
Expand Down Expand Up @@ -1071,3 +1129,4 @@ def post(self, request, *args, **kwargs):
return HttpResponseRedirect(reverse('problem_detail', args=[self.object.code]))

return self.render_to_response(self.get_context_data(object=self.object))

63 changes: 63 additions & 0 deletions templates/problem/editorial-proposal-list.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
{% extends "common-content.html" %}

{% block title_ruler %}{% endblock %}

{% block title_row %}
{% set tab = 'editorials' %}
{% include "problem/problem-list-tabs.html" %}
{% endblock %}

{% block body %}
{% if page_obj.has_other_pages() %}
<div class="top-pagination-bar">{% include "list-pages.html" %}</div>
{% endif %}

<div id="common-content">
{% block before_table %}{% endblock %}
<div id="content-right" class="problems">
<div class="info-float">
{% include "problem/search-form.html" %}
</div>
</div>
<div id="content-left" class="problems h-scrollable-table">
<table id="problem-table" class="table striped">
<thead>
<tr>
<th class="problem-code">
<a>{{ _('ID') }}</a>
</th>
<th class="problem-name">
<a>{{ _('Problem') }}</a>
</th>
<th class="author">
<a>{{ _('Author') }}</a>
</th>
<th class="link">
<a>{{ _('Link') }}</a>
</th>
</tr>
</thead>
<tbody>
{% for proposal in proposals %}
{% with problem=proposal.problem %}
<tr>
<td class="problem-code">
<a href="{{ url('problem_detail', problem.code) }}">{{ problem.code }}</a>
</td>
<td class="problem-name">
<a href="{{ url('problem_detail', problem.code) }}">{{ problem.i18n_name or problem.name }}</a>
</td>
<td class="author">
{{ link_user(proposal.author) }}
</td>
<td class="link">
<a href="{{ proposal.get_absolute_url() }}">{{ _('Link to the proposal') }}</a>
</td>
</tr>
{% endwith %}
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
32 changes: 32 additions & 0 deletions templates/problem/editorial-proposal.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{% extends "common-content.html" %}

{% block header %}
<div style="float:right">
{% if problem.is_editable_by(request.user) %}
[<a href="{{ url('admin:judge_problem_change', problem.id) }}">{{ _('Edit') }}</a>]
{% endif %}
</div>
{% endblock %}

{% block body %}
<div class="content-description">
{% if not has_solved_problem %}
<div class="alert alert-danger">
{{ _('Remember to use this editorial **only** when stuck, and **not to copy-paste code from it**. Please be respectful to the problem author and editorialist.')|markdown('default', strip_paragraphs=True) }}<br>
<b>{{ _('Submitting an official solution before solving the problem yourself is a bannable offence.') }}</b>
</div>
{% endif %}
<p>
{{ _('Author:') }}
{{ link_user(solution.author) }}
</p>
{{ solution.content|markdown('solution', MATH_ENGINE)|reference|str|safe }}
</div>
<hr>
{% endblock %}

{% block bodyend %}
{% if REQUIRE_JAX %}
{% include "mathjax-load.html" %}
{% endif %}
{% endblock %}
1 change: 1 addition & 0 deletions templates/problem/problem-list-tabs.html
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
{% if perms.judge.edit_all_problem or perms.judge.edit_public_problem or perms.judge.edit_own_problem %}
{{ make_tab('suggest', 'fa-list', url('problem_suggest_list'), _('Suggest')) }}
{% endif %}
{{ make_tab('editorials', 'fa-list', url('editorial_proposal_list'), _('Editorial proposals')) }}
{% if request.user.is_superuser %}
{{ make_tab('admin', 'fa-edit', url('admin:judge_problem_changelist'), _('Admin')) }}
{% endif %}
Expand Down
Loading