diff --git a/oioioi/contests/admin.py b/oioioi/contests/admin.py index f276cd721..00feb07f4 100644 --- a/oioioi/contests/admin.py +++ b/oioioi/contests/admin.py @@ -470,7 +470,7 @@ def _replace_statement_href(self, instance): return ( reverse('problem_site', args=(instance.problem.problemsite.url_key,)) + '?' - + urllib.parse.urlencode({'key': 'replace_problem_statement'}) + + urllib.parse.urlencode({'key': 'replace_statement_or_editorial'}) ) def _package_manage_href(self, instance): diff --git a/oioioi/problems/migrations/0039_problemeditorial.py b/oioioi/problems/migrations/0039_problemeditorial.py new file mode 100644 index 000000000..56d2decd2 --- /dev/null +++ b/oioioi/problems/migrations/0039_problemeditorial.py @@ -0,0 +1,29 @@ +# Generated by Django 5.2.3 on 2025-06-10 20:59 + +import django.db.models.deletion +import oioioi.filetracker.fields +import oioioi.problems.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('problems', '0038_alter_algorithmtaglocalization_language_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='ProblemEditorial', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('language', models.CharField(blank=True, max_length=6, null=True, verbose_name='language code')), + ('content', oioioi.filetracker.fields.FileField(max_length=255, upload_to=oioioi.problems.models.make_problem_filename, verbose_name='content')), + ('problem', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='editorials', to='problems.problem')), + ], + options={ + 'verbose_name': 'problem editorial', + 'verbose_name_plural': 'problem editorials', + }, + ), + ] diff --git a/oioioi/problems/models.py b/oioioi/problems/models.py index 0f3687eb6..ae4c9c3a0 100644 --- a/oioioi/problems/models.py +++ b/oioioi/problems/models.py @@ -257,6 +257,40 @@ def __str__(self): return '%s / %s' % (self.problem.name, self.filename) +class ProblemEditorial(models.Model): + """Represents a file containing problem editoral. + + Problem may have multiple editorials, for example in various languages + or formats. + """ + + problem = models.ForeignKey( + Problem, related_name='editorials', on_delete=models.CASCADE + ) + language = models.CharField( + max_length=6, blank=True, null=True, verbose_name=_("language code") + ) + content = FileField(upload_to=make_problem_filename, verbose_name=_("content")) + + @property + def filename(self): + return os.path.split(self.content.name)[1] + + @property + def download_name(self): + return self.problem.short_name + self.extension + + @property + def extension(self): + return os.path.splitext(self.content.name)[1].lower() + + class Meta(object): + verbose_name = _("problem editorial") + verbose_name_plural = _("problem editorials") + + def __str__(self): + return '%s / %s' % (self.problem.name, self.filename) + class ProblemAttachment(models.Model): """Represents an additional file visible to the contestant, linked to diff --git a/oioioi/problems/problem_site.py b/oioioi/problems/problem_site.py index 9e9be53e8..af5f93d41 100644 --- a/oioioi/problems/problem_site.py +++ b/oioioi/problems/problem_site.py @@ -34,6 +34,7 @@ ProblemAttachment, ProblemPackage, ProblemStatement, + ProblemEditorial, ) from oioioi.problems.problem_sources import UploadedPackageSource from oioioi.problems.utils import ( @@ -41,6 +42,7 @@ can_admin_problem, generate_add_to_contest_metadata, generate_model_solutions_context, + query_editorial, query_statement, query_zip, ) @@ -79,53 +81,91 @@ def decorator(func): return decorator -def problem_site_statement_zip_view(request, site_key, path): +def problem_site_document_zip_view(request, site_key, path, type='statement'): problem = get_object_or_404(Problem, problemsite__url_key=site_key) - statement = query_statement(problem.id) - if not statement: - raise Http404 - return query_zip(statement, path) + document = None + if type == 'editorial': + document = query_editorial(problem.id) + elif type == 'statement': + document = query_statement(problem.id) -def check_for_statement(request, problem): - """Function checking if given problem has a ProblemStatement.""" - return bool(ProblemStatement.objects.filter(problem=problem)) + if not document: + raise Http404 + return query_zip(document, path) -@problem_site_tab( - _("Problem statement"), key='statement', order=100, condition=check_for_statement -) -def problem_site_statement(request, problem): - statement = query_statement(problem.id) - if not statement: - statement_html = render_to_string( - 'problems/no-problem-statement.html', - {'problem': problem, - 'can_admin_problem': can_admin_problem(request, problem)} - ) - elif statement.extension == '.zip': - response = problem_site_statement_zip_view( - request, problem.problemsite.url_key, 'index.html' +def problem_site_document(request, problem, document, type): + if not document: + if type == 'statement': + document_html = render_to_string( + 'problems/no-problem-statement.html', + {'problem': problem, + 'can_admin_problem': can_admin_problem(request, problem)} + ) + elif type == 'editorial': + document_html = render_to_string( + 'problems/no-problem-editorial.html', + {'problem': problem, + 'can_admin_problem': can_admin_problem(request, problem)} + ) + else: + raise Http404("Document not found") + elif document.extension == '.zip': + response = problem_site_document_zip_view( + request, problem.problemsite.url_key, 'index.html', type ) - statement_html = render_to_string( - 'problems/from-zip-statement.html', + document_html = render_to_string( + 'problems/from-zip-document.html', {'problem': problem, 'statement': mark_safe(response.content.decode(errors="replace")), 'can_admin_problem': can_admin_problem(request, problem)} ) else: - statement_url = reverse( - 'problem_site_external_statement', - kwargs={'site_key': problem.problemsite.url_key}, - ) - statement_html = render_to_string( - 'problems/external-statement.html', + document_url = None + if type == 'statement': + document_url = reverse( + 'problem_site_external_statement', + kwargs={'site_key': problem.problemsite.url_key}, + ) + elif type == 'editorial': + document_url = reverse( + 'problem_site_external_editorial', + kwargs={'site_key': problem.problemsite.url_key}, + ) + else: + raise Http404("Document not found") + + document_html = render_to_string( + 'problems/external-document.html', {'problem': problem, - 'statement_url': statement_url, + 'document_url': document_url, + 'document_type': type, 'can_admin_problem': can_admin_problem(request, problem)}, ) - return statement_html + return document_html + +def check_for_statement(request, problem): + return ProblemStatement.objects.filter(problem=problem).exists() + +@problem_site_tab( + _("Problem statement"), key='statement', order=100, condition=check_for_statement +) +def problem_site_statement(request, problem): + statement = query_statement(problem.id) + return problem_site_document(request, problem, statement, type='statement') + + +def show_editorial(request, problem): + return ProblemEditorial.objects.filter(problem=problem).exists() and not request.contest + +@problem_site_tab( + _("Editorial"), key='editorial', order=750, condition=show_editorial +) +def problem_site_editorial(request, problem): + statement = query_editorial(problem.id) + return problem_site_document(request, problem, statement, type='editorial') def check_for_downloads(request, problem): @@ -309,37 +349,50 @@ def problem_site_add_to_contest(request, problem): @problem_site_tab( - _("Replace problem statement"), - key='replace_problem_statement', + _("Replace statement or editorial"), + key='replace_statement_or_editorial', order=800, condition=can_admin_problem, ) -def problem_site_replace_statement(request, problem): - statements = ProblemStatement.objects.filter(problem=problem) - filenames = [statement.filename for statement in statements] +def problem_site_replace_statement_or_editorial(request, problem): + statements = ProblemStatement .objects.filter(problem=problem) + editorials = ProblemEditorial.objects.filter(problem=problem) + + stmt_names = [s.filename for s in statements] + ed_names = [e.filename for e in editorials] + + stmt_form = ProblemStatementReplaceForm(stmt_names) + ed_form = ProblemStatementReplaceForm(ed_names) if request.method == 'POST': - form = PackageFileReuploadForm(filenames, request.POST, request.FILES) - if form.is_valid(): - statement_filename = form.cleaned_data['file_name'] - statements = [s for s in statements if s.filename == statement_filename] - if statements: - statement = statements[0] - new_statement_file = form.cleaned_data['file_replacement'] - statement.content = new_statement_file - statement.save() - url = reverse( - 'problem_site', kwargs={'site_key': problem.problemsite.url_key} - ) - return redirect(url + '?key=replace_problem_statement') - else: - form.add_error(None, _("Picked statement file does not exist.")) - else: - form = ProblemStatementReplaceForm(filenames) + form_type = request.POST.get('form_type') + if form_type == 'statement': + stmt_form = ProblemStatementReplaceForm(stmt_names, request.POST, request.FILES) + if stmt_form.is_valid(): + fn = stmt_form.cleaned_data['file_name'] + stmt = next(s for s in statements if s.filename == fn) + stmt.content = stmt_form.cleaned_data['file_replacement'] + stmt.save() + url = reverse('problem_site', kwargs={'site_key': problem.problemsite.url_key}) + return redirect(url + '?key=replace_statement_or_editorial') + elif form_type == 'editorial': + ed_form = ProblemStatementReplaceForm(ed_names, request.POST, request.FILES) + if ed_form.is_valid(): + fn = ed_form.cleaned_data['file_name'] + ed = next(e for e in editorials if e.filename == fn) + ed.content = ed_form.cleaned_data['file_replacement'] + ed.save() + url = reverse('problem_site', kwargs={'site_key': problem.problemsite.url_key}) + return redirect(url + '?key=replace_statement_or_editorial') + return TemplateResponse( request, 'problems/replace-problem-statement.html', - {'form': form, 'problem': problem}, + { + 'problem': problem, + 'form': stmt_form, + 'editorial_form': ed_form, + }, ) diff --git a/oioioi/problems/templates/problems/external-document.html b/oioioi/problems/templates/problems/external-document.html new file mode 100644 index 000000000..1d2b9c6d2 --- /dev/null +++ b/oioioi/problems/templates/problems/external-document.html @@ -0,0 +1,22 @@ +{% load i18n %} +
+ +
+ +
+ {% if document_type == 'statement' %} + {% blocktrans %}You can also open the problem's statement by clicking here.{% endblocktrans %} + {% elif document_type == 'editorial' %} + {% blocktrans %}You can also open the problem's editorial by clicking here.{% endblocktrans %} + {% else %} + {% blocktrans %}You can also open this document by clicking here.{% endblocktrans %} + {% endif %} +
+ +{% if can_admin_problem %} +
+ {% blocktrans %}

Problem's author:

{% endblocktrans %} +

{{ problem.author.first_name }} {{problem.author.last_name}} ({{problem.author.username}})

+

Send e-mail to the author

+
+{% endif %} diff --git a/oioioi/problems/templates/problems/from-zip-statement.html b/oioioi/problems/templates/problems/from-zip-document.html similarity index 100% rename from oioioi/problems/templates/problems/from-zip-statement.html rename to oioioi/problems/templates/problems/from-zip-document.html diff --git a/oioioi/problems/templates/problems/ingredients/document-replace-form.html b/oioioi/problems/templates/problems/ingredients/document-replace-form.html new file mode 100644 index 000000000..53e81e58a --- /dev/null +++ b/oioioi/problems/templates/problems/ingredients/document-replace-form.html @@ -0,0 +1,51 @@ +

{{ title }}

+
+ + {% csrf_token %} + {% if form.non_field_errors %} +
+ {% for error in form.non_field_errors %} + + {% endfor %} +
+ {% endif %} + +
+ {% if display_labels != False %} + + {% endif %} + {{ form.file_name | add_class:"form-control" }} + {% for error in form.file_name.errors %} +
{{ error }}
+ {% endfor %} + {% if form.file_name.help_text %} +
{{ form.file_name.help_text }}
+ {% endif %} +
+ +
+ {% if display_labels != False %} + + {% endif %} + {{ form.file_replacement | add_class:"form-control" }} + {% for error in form.file_replacement.errors %} +
{{ error }}
+ {% endfor %} + {% if form.file_replacement.help_text %} +
{{ form.file_replacement.help_text }}
+ {% endif %} +
+ +
+ {% block upload_btn %} + + {% endblock %} +
+
diff --git a/oioioi/problems/templates/problems/external-statement.html b/oioioi/problems/templates/problems/no-problem-editorial.html similarity index 55% rename from oioioi/problems/templates/problems/external-statement.html rename to oioioi/problems/templates/problems/no-problem-editorial.html index d4a92954f..bac1e586b 100644 --- a/oioioi/problems/templates/problems/external-statement.html +++ b/oioioi/problems/templates/problems/no-problem-editorial.html @@ -1,10 +1,9 @@ {% load i18n %} -
- -
-
- {% blocktrans %}You can also open the problem's statement by clicking here.{% endblocktrans %} +
+ {% blocktrans %} +

This problem doesn't have any editorial available .

+ {% endblocktrans %}
{% if can_admin_problem %} diff --git a/oioioi/problems/tests/test_problem.py b/oioioi/problems/tests/test_problem.py index c799d805d..892203395 100644 --- a/oioioi/problems/tests/test_problem.py +++ b/oioioi/problems/tests/test_problem.py @@ -476,7 +476,7 @@ def test_tags_tab_user_with_permission(self): def test_statement_replacement(self): url = ( reverse('problem_site', kwargs={'site_key': '123'}) - + '?key=replace_problem_statement' + + '?key=replace_statement_or_editorial' ) self.assertTrue(self.client.login(username='test_user')) diff --git a/oioioi/problems/urls.py b/oioioi/problems/urls.py index d64d7579b..d560aea52 100644 --- a/oioioi/problems/urls.py +++ b/oioioi/problems/urls.py @@ -9,14 +9,28 @@ path('site/', views.problem_site_view, name='problem_site'), path( 'site/', - views.problem_site_statement_zip_view, + views.problem_site_document_zip_view, + {'type': 'statement'}, name='problem_site_statement_zip', ), + path( + 'site/editorial/', + views.problem_site_document_zip_view, + {'type': 'editorial'}, + name='problem_site_editorial_zip', + ), path( 'statement/', - views.problem_site_external_statement_view, + views.problem_site_external_document_view, + {'type': 'statement'}, name='problem_site_external_statement', ), + path( + 'editorial/', + views.problem_site_external_document_view, + {'type': 'editorial'}, + name='problem_site_external_editorial', + ), path( 'attachment//', views.problem_site_external_attachment_view, diff --git a/oioioi/problems/utils.py b/oioioi/problems/utils.py index 3f7fd17fc..baf167dea 100644 --- a/oioioi/problems/utils.py +++ b/oioioi/problems/utils.py @@ -20,6 +20,7 @@ from oioioi.problems.models import ( AlgorithmTagProposal, DifficultyTagProposal, + ProblemEditorial, ProblemStatement, ProblemStatistics, UserStatistics, @@ -131,9 +132,8 @@ def can_add_to_problemset(request): return request.user.has_perm('problems.problems_db_admin') -def query_statement(problem_id): - statements = ProblemStatement.objects.filter(problem=problem_id) - if not statements: +def query_document(documents): + if not documents: return None lang_prefs = ( @@ -152,15 +152,23 @@ def sort_key(statement): ext_pref = (sys.maxsize, statement.extension) return lang_pref, ext_pref - return sorted(statements, key=sort_key)[0] + return sorted(documents, key=sort_key)[0] + +def query_statement(problem_id): + statements = ProblemStatement.objects.filter(problem=problem_id) + return query_document(statements) + +def query_editorial(problem_id): + editorials = ProblemEditorial.objects.filter(problem=problem_id) + return query_document(editorials) -def query_zip(statement, path): - if statement.extension != '.zip': +def query_zip(document, path): + if document.extension != '.zip': raise SuspiciousOperation # ZipFile will call seek(), so we need a real file here - zip = zipfile.ZipFile(statement.content.read_using_cache()) + zip = zipfile.ZipFile(document.content.read_using_cache()) try: info = zip.getinfo(path) except KeyError: diff --git a/oioioi/problems/views.py b/oioioi/problems/views.py index e22e73012..cfa402b0d 100644 --- a/oioioi/problems/views.py +++ b/oioioi/problems/views.py @@ -72,12 +72,12 @@ UserStatistics, ) -# problem_site_statement_zip_view is used in one of the tabs +# problem_site_document_zip_view is used in some of the tabs # in problem_site.py. We placed the view in problem_site.py # instead of views.py to avoid circular imports. We still import # it here to use it in urls.py. from oioioi.problems.problem_site import ( - problem_site_statement_zip_view, + problem_site_document_zip_view, problem_site_tab_registry, ) from oioioi.problems.problem_sources import problem_sources @@ -90,6 +90,7 @@ generate_add_to_contest_metadata, generate_model_solutions_context, get_prefetched_value, + query_editorial, query_statement, show_proposal_form, ) @@ -615,14 +616,20 @@ def build_link(tab): ) -def problem_site_external_statement_view(request, site_key): +def problem_site_external_document_view(request, site_key, type='statement'): problem = get_object_or_404(Problem, problemsite__url_key=site_key) - statement = query_statement(problem.id) - if not statement: + + document = None + if type == 'editorial': + document = query_editorial(problem.id) + elif type == 'statement': + document = query_statement(problem.id) + + if not document: raise Http404 - if statement.extension == '.zip' and not can_admin_problem(request, problem): + if document.extension == '.zip' and not can_admin_problem(request, problem): raise PermissionDenied - return stream_file(statement.content, statement.download_name) + return stream_file(document.content, document.download_name) def problem_site_external_attachment_view(request, site_key, attachment_id):