From e424e550092c18c288c161d3325fad4f688e276f Mon Sep 17 00:00:00 2001 From: prandla Date: Mon, 9 Jun 2025 04:49:15 +0300 Subject: [PATCH 1/6] Render announcements, question replies, and messages as markdown --- cms/server/admin/templates/announcements.html | 3 ++- cms/server/admin/templates/participation.html | 4 +++- cms/server/admin/templates/questions.html | 1 + cms/server/contest/static/cws_style.css | 5 +++++ cms/server/contest/templates/communication.html | 6 +++--- cms/server/jinja2_toolbox.py | 16 ++++++++++++++++ requirements.txt | 1 + 7 files changed, 31 insertions(+), 5 deletions(-) diff --git a/cms/server/admin/templates/announcements.html b/cms/server/admin/templates/announcements.html index a4a49736a9..704348ff05 100644 --- a/cms/server/admin/templates/announcements.html +++ b/cms/server/admin/templates/announcements.html @@ -24,7 +24,8 @@

Announcements

- +
+ You can use Markdown here. URLs are not automatically linked, surround them with <> like <http://example.org>.
diff --git a/cms/server/admin/templates/participation.html b/cms/server/admin/templates/participation.html index c3adf16c77..2504ec1513 100644 --- a/cms/server/admin/templates/participation.html +++ b/cms/server/admin/templates/participation.html @@ -179,6 +179,7 @@

Questions

Alternative answer:

+ You can use Markdown here. URLs are not automatically linked, surround them with <> like <http://example.org>.
@@ -210,7 +211,8 @@

Messages

Text: - +
+ You can use Markdown here. URLs are not automatically linked, surround them with <> like <http://example.org>.
diff --git a/cms/server/admin/templates/questions.html b/cms/server/admin/templates/questions.html index 83010be351..a8739ef13e 100644 --- a/cms/server/admin/templates/questions.html +++ b/cms/server/admin/templates/questions.html @@ -121,6 +121,7 @@

Questions

Alternative answer:

+ You can use Markdown here. URLs are not automatically linked, surround them with <> like <http://example.org>.
diff --git a/cms/server/contest/static/cws_style.css b/cms/server/contest/static/cws_style.css index 3ce4122ad5..b5786b443f 100644 --- a/cms/server/contest/static/cws_style.css +++ b/cms/server/contest/static/cws_style.css @@ -92,6 +92,11 @@ div.question_list div.answer div.body, div.message_list div.message div.body { padding-top: 5px; clear: both; +} + +/* admin-sent messages have markdown formatting and thus don't need + * mucking with whitespace, but user questions are plaintext */ +div.question_list div.question div.body { white-space: pre-line; } diff --git a/cms/server/contest/templates/communication.html b/cms/server/contest/templates/communication.html index 602298444c..13231fb52b 100644 --- a/cms/server/contest/templates/communication.html +++ b/cms/server/contest/templates/communication.html @@ -22,7 +22,7 @@

{% trans %}(no subject){% endtrans %}

{% endif %} {{ msg.timestamp|format_datetime_smart }} {% if msg.text|length > 0 %} -
{{ msg.text }}
+
{{ msg.text | markdown }}
{% endif %} {% endfor %} @@ -91,7 +91,7 @@

{% trans %}(no subject){% endtrans %}

{% endif %} {{ msg.reply_timestamp|format_datetime_smart }} {% if msg.reply_text|length > 0 %} -
{{ msg.reply_text }}
+
{{ msg.reply_text | markdown }}
{% endif %} {% else %} @@ -118,7 +118,7 @@

{% trans %}(no subject){% endtrans %}

{% endif %} {{ msg.timestamp|format_datetime_smart }} {% if msg.text|length > 0 %} -
{{ msg.text }}
+
{{ msg.text | markdown }}
{% endif %} {% endfor %} diff --git a/cms/server/jinja2_toolbox.py b/cms/server/jinja2_toolbox.py index 5f6f2fe0ee..f13cda437f 100644 --- a/cms/server/jinja2_toolbox.py +++ b/cms/server/jinja2_toolbox.py @@ -28,6 +28,8 @@ from jinja2 import Environment, StrictUndefined, contextfilter, \ contextfunction, environmentfunction from jinja2.runtime import Context +import markdown_it +import markupsafe from cms import TOKEN_MODE_DISABLED, TOKEN_MODE_FINITE, TOKEN_MODE_INFINITE, \ TOKEN_MODE_MIXED, FEEDBACK_LEVEL_FULL, FEEDBACK_LEVEL_RESTRICTED, \ @@ -138,6 +140,19 @@ def today(ctx: Context, dt: datetime) -> bool: == now.replace(tzinfo=utc).astimezone(timezone).date() +def markdown_filter(text: str) -> markupsafe.Markup: + """Renders text as markdown and returns a Markup object + corresponding to it. + + text: The markdown text to render. + + return: rendered HTML wrapped in the Markup class. + """ + md = markdown_it.MarkdownIt('js-default') + html = md.render(text) + return markupsafe.Markup(html) + + def instrument_generic_toolbox(env: Environment): env.globals["iter"] = iter env.globals["next"] = next @@ -163,6 +178,7 @@ def instrument_generic_toolbox(env: Environment): env.filters["any"] = any_ env.filters["dictselect"] = dictselect env.filters["make_timestamp"] = make_timestamp + env.filters["markdown"] = markdown_filter env.tests["contains"] = lambda s, p: p in s env.tests["endswith"] = lambda s, p: s.endswith(p) diff --git a/requirements.txt b/requirements.txt index e907dcf1ea..444a199947 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,6 +15,7 @@ chardet>=3.0,<5.3 # https://pypi.python.org/pypi/chardet babel==2.12.1 # http://babel.pocoo.org/en/latest/changelog.html pyxdg>=0.26,<0.29 # https://freedesktop.org/wiki/Software/pyxdg/ Jinja2>=2.10,<2.11 # http://jinja.pocoo.org/docs/latest/changelog/ +markdown-it-py==3.0.0 # https://github.com/executablebooks/markdown-it-py/blob/master/CHANGELOG.md # See https://github.com/pallets/markupsafe/issues/286 but breaking change in # MarkupSafe causes jinja to break From 0444bd85071d73d517aba0905cbce0c70ae03459 Mon Sep 17 00:00:00 2001 From: prandla Date: Tue, 10 Jun 2025 04:20:09 +0300 Subject: [PATCH 2/6] Refactor question-answering in AWS * Deduplicate code in questions view / participant view * Deduplicate set of quick answers As a result, the question-answering experience in the participant view is now a bit better. --- cms/db/user.py | 6 + cms/server/admin/handlers/contestquestion.py | 28 +++-- cms/server/admin/jinja2_toolbox.py | 2 + cms/server/admin/static/aws_utils.js | 30 +++++ .../admin/templates/macro/question.html | 108 ++++++++++++++++ cms/server/admin/templates/participation.html | 67 +--------- cms/server/admin/templates/questions.html | 115 +----------------- 7 files changed, 167 insertions(+), 189 deletions(-) create mode 100644 cms/server/admin/templates/macro/question.html diff --git a/cms/db/user.py b/cms/db/user.py index 94c05eb87a..8ed43699cd 100644 --- a/cms/db/user.py +++ b/cms/db/user.py @@ -336,6 +336,12 @@ class Question(Base): MAX_SUBJECT_LENGTH = 50 MAX_TEXT_LENGTH = 2000 + QUICK_ANSWERS = { + "yes": "Yes", + "no": "No", + "invalid": "Invalid Question (not a Yes/No Question)", + "nocomment": "No Comment/Please refer to task statement", + } # Auto increment primary key. id: int = Column( diff --git a/cms/server/admin/handlers/contestquestion.py b/cms/server/admin/handlers/contestquestion.py index f99678d5e0..aa4787d303 100644 --- a/cms/server/admin/handlers/contestquestion.py +++ b/cms/server/admin/handlers/contestquestion.py @@ -68,16 +68,14 @@ class QuestionReplyHandler(BaseHandler): """Called when the manager replies to a question made by a user. """ - QUICK_ANSWERS = { - "yes": "Yes", - "no": "No", - "invalid": "Invalid Question (not a Yes/No Question)", - "nocomment": "No Comment/Please refer to task statement", - } @require_permission(BaseHandler.PERMISSION_MESSAGING) def post(self, contest_id, question_id): - ref = self.url("contest", contest_id, "questions") + userid = self.get_argument("user_id", None) + if userid is not None: + ref = self.url("contest", contest_id, "user", userid, "edit") + else: + ref = self.url("contest", contest_id, "questions") question = self.safe_get_item(Question, question_id) self.contest = self.safe_get_item(Contest, contest_id) @@ -90,12 +88,12 @@ def post(self, contest_id, question_id): question.reply_text = self.get_argument("reply_question_text", "") # Ignore invalid answers - if reply_subject_code not in QuestionReplyHandler.QUICK_ANSWERS: + if reply_subject_code not in Question.QUICK_ANSWERS: question.reply_subject = "" else: # Quick answer given, ignore long answer. question.reply_subject = \ - QuestionReplyHandler.QUICK_ANSWERS[reply_subject_code] + Question.QUICK_ANSWERS[reply_subject_code] question.reply_text = "" question.reply_timestamp = make_datetime() @@ -118,7 +116,11 @@ class QuestionIgnoreHandler(BaseHandler): """ @require_permission(BaseHandler.PERMISSION_MESSAGING) def post(self, contest_id, question_id): - ref = self.url("contest", contest_id, "questions") + userid = self.get_argument("user_id", None) + if userid is not None: + ref = self.url("contest", contest_id, "user", userid, "edit") + else: + ref = self.url("contest", contest_id, "questions") question = self.safe_get_item(Question, question_id) self.contest = self.safe_get_item(Contest, contest_id) @@ -147,7 +149,11 @@ class QuestionClaimHandler(BaseHandler): @require_permission(BaseHandler.PERMISSION_MESSAGING) def post(self, contest_id, question_id): - ref = self.url("contest", contest_id, "questions") + userid = self.get_argument("user_id", None) + if userid is not None: + ref = self.url("contest", contest_id, "user", userid, "edit") + else: + ref = self.url("contest", contest_id, "questions") question = self.safe_get_item(Question, question_id) self.contest = self.safe_get_item(Contest, contest_id) diff --git a/cms/server/admin/jinja2_toolbox.py b/cms/server/admin/jinja2_toolbox.py index f9f3282efd..bbee0aa00b 100644 --- a/cms/server/admin/jinja2_toolbox.py +++ b/cms/server/admin/jinja2_toolbox.py @@ -25,6 +25,7 @@ from jinja2 import Environment, PackageLoader +from cms.db.user import Question from cms.grading.languagemanager import LANGUAGES from cms.grading.scoretypes import SCORE_TYPES from cms.grading.tasktypes import TASK_TYPES @@ -47,6 +48,7 @@ def instrument_cms_toolbox(env: Environment): env.globals["LANGUAGES"] = LANGUAGES env.globals["get_hex_random_key"] = get_hex_random_key env.globals["parse_authentication"] = safe_parse_authentication + env.globals["question_quick_answers"] = Question.QUICK_ANSWERS def instrument_formatting_toolbox(env: Environment): diff --git a/cms/server/admin/static/aws_utils.js b/cms/server/admin/static/aws_utils.js index 33e47bbac5..a54eb98f32 100644 --- a/cms/server/admin/static/aws_utils.js +++ b/cms/server/admin/static/aws_utils.js @@ -826,3 +826,33 @@ CMS.AWSUtils.ajax_delete = function(url) { CMS.AWSUtils.ajax_post = function(url) { CMS.AWSUtils.ajax_edit_request("POST", url); }; + + +/** + * Used by templates/macro/question.html. + * Toggles visibility of the question reply box. + */ +CMS.AWSUtils.prototype.question_reply_toggle = function(event, invoker) { + var obj = invoker.parentElement.parentElement.querySelector(".reply_question"); + if (obj.style.display != "block") { + obj.style.display = "block"; + invoker.innerHTML = "Hide reply"; + } else { + obj.style.display = "none"; + invoker.innerHTML = "Reply"; + } + event.preventDefault(); +} + +/** + * Used by templates/macro/question.html. + * Updates visibility of answer box when choosing quick answers. + */ +CMS.AWSUtils.prototype.update_additional_answer = function(event, invoker) { + var obj = invoker.parentElement.querySelector(".alternative_answer"); + if (invoker.value == "other") { + obj.style.display = "block"; + } else { + obj.style.display = "none"; + } +} diff --git a/cms/server/admin/templates/macro/question.html b/cms/server/admin/templates/macro/question.html new file mode 100644 index 0000000000..3c10064655 --- /dev/null +++ b/cms/server/admin/templates/macro/question.html @@ -0,0 +1,108 @@ +{# This macro should be imported "with context". #} + +{% macro question(msg, user_id) %} +{# +Render one question by a participant. + +msg (Question): the question object. +user_id (int|None): if we are rendering a single user's questions, this is the + user's id. if we are rendering all questions of the contest, then None. +#} +
+
+
+ {% if user_id is none %} + {{ msg.participation.user.username }} — + {% endif %} + {{ msg.question_timestamp }} +
+
{{ msg.subject }}
+
{{ msg.text }}
+ {% if msg.reply_timestamp is not none %} +
+
Reply: {{ msg.reply_subject }}
+
{{ msg.reply_text }}
+
+
+ Reply from: {{ msg.admin.name if msg.admin is not none else "" }}
+ {% else %} +
+
Not yet replied.
+
+ {% if msg.admin is not none %} +
+ {{ "Ignored" if msg.ignored else "Claimed" }} by: {{ msg.admin.name }} +
+ {% endif %} + {% endif %} + {% if admin.permission_all or admin.permission_messaging %} + {% if msg.reply_timestamp is none %} + {% if not msg.ignored %} +
+
+ {{ xsrf_form_html|safe }} + {% if user_id is not none %} + + {% endif %} + {% if msg.admin is none %} + + Claim + {% else %} + + Unclaim + {% endif %} +
+
+ {% endif %} +
+
+ {{ xsrf_form_html|safe }} + {% if user_id is not none %} + + {% endif %} + {% if not msg.ignored %} + + Ignore + {% else %} + + Unignore + {% endif %} +
+
+ {% endif %} + +
+
+
+ {{ xsrf_form_html|safe }} + {% if user_id is not none %} + + {% endif %} + Precompiled answer: + +
+
+ Alternative answer:
+
+ You can use Markdown here. URLs are not automatically linked, surround them with <> like <http://example.org>. +
+ +
+
+{% endif %} +
+
+{% endmacro %} diff --git a/cms/server/admin/templates/participation.html b/cms/server/admin/templates/participation.html index 2504ec1513..0839589c01 100644 --- a/cms/server/admin/templates/participation.html +++ b/cms/server/admin/templates/participation.html @@ -2,33 +2,7 @@ {% extends "base.html" %} {% import 'macro/submission.html' as macro_submission %} - -{% block js %} -function question_reply_toggle(element, invoker) -{ - var obj = document.getElementsByClassName("reply_question")[element]; - if (obj.style.display != "block") - { - obj.style.display = "block"; - invoker.innerHTML = "Hide reply"; - } - else - { - obj.style.display = "none"; - invoker.innerHTML = "Reply"; - } - return false; -} - -function update_additional_answer(element, invoker) -{ - var obj = document.getElementsByClassName("alternative_answer")[element]; - if (invoker.selectedIndex == 5) - obj.style.display = "block"; - else - obj.style.display = "none"; -} -{% endblock js %} +{% import 'macro/question.html' as macro_question with context %} {% block core %}

@@ -148,44 +122,7 @@

Questions

{% if participation.questions != [] %}
{% for msg in participation.questions %} -
-
-
{{ msg.question_timestamp }}
-
{{ msg.subject }}
-
{{ msg.text }}
- {% if msg.reply_timestamp is not none %} -
Reply. {{ msg.reply_subject }}
-
{{ msg.reply_text }}
- {% else %} -
Not yet replied.
- {% endif %} -
- Reply -
-
-
-
- {{ xsrf_form_html|safe }} - - Precompiled answer: - -
-
- Alternative answer:
-
- You can use Markdown here. URLs are not automatically linked, surround them with <> like <http://example.org>. -
- -
-
-
-
+ {{ macro_question.question(msg, selected_user.id) }} {% endfor %}
diff --git a/cms/server/admin/templates/questions.html b/cms/server/admin/templates/questions.html index a8739ef13e..e8e4591347 100644 --- a/cms/server/admin/templates/questions.html +++ b/cms/server/admin/templates/questions.html @@ -1,31 +1,5 @@ {% extends "base.html" %} - -{% block js %} -function question_reply_toggle(element, invoker) -{ - var obj = document.getElementsByClassName("reply_question")[element]; - if (obj.style.display != "block") - { - obj.style.display = "block"; - invoker.innerHTML = "Hide reply"; - } - else - { - obj.style.display = "none"; - invoker.innerHTML = "Reply"; - } - return false; -} - -function update_additional_answer(element, invoker) -{ - var obj = document.getElementsByClassName("alternative_answer")[element]; - if (invoker.selectedIndex == 5) - obj.style.display = "block"; - else - obj.style.display = "none"; -} -{% endblock js %} +{% import 'macro/question.html' as macro_question with context %} {% block js_init %} @@ -43,92 +17,7 @@

Questions

{% for msg in questions %} -
-
-
- {{ msg.participation.user.username }} — {{ msg.question_timestamp }} -
-
{{ msg.subject }}
-
{{ msg.text }}
- {% if msg.reply_timestamp is not none %} -
-
Reply: {{ msg.reply_subject }}
-
{{ msg.reply_text }}
-
-
- Reply from: {{ msg.admin.name if msg.admin is not none else "" }}
- {% else %} -
-
Not yet replied.
-
- {% if msg.admin is not none %} -
- {{ "Ignored" if msg.ignored else "Claimed" }} by: {{ msg.admin.name }} -
- {% endif %} - {% endif %} -{% if admin.permission_all or admin.permission_messaging %} - {% if msg.reply_timestamp is none %} - {% if not msg.ignored %} -
-
- {{ xsrf_form_html|safe }} - {% if msg.admin is none %} - - Claim - {% else %} - - Unclaim - {% endif %} -
-
- {% endif %} -
-
- {{ xsrf_form_html|safe }} - {% if not msg.ignored %} - - Ignore - {% else %} - - Unignore - {% endif %} -
-
- {% endif %} - -
-
-
- {{ xsrf_form_html|safe }} - Precompiled answer: - -
-
- Alternative answer:
-
- You can use Markdown here. URLs are not automatically linked, surround them with <> like <http://example.org>. -
- -
-
-{% endif %} -
-
+ {{ macro_question.question(msg, none) }} {% endfor %}
From b2b6cd7102619b1e89c75a3be944f86131324902 Mon Sep 17 00:00:00 2001 From: prandla Date: Tue, 10 Jun 2025 15:21:49 +0300 Subject: [PATCH 3/6] add previewing markdown in AWS --- cms/server/admin/handlers/__init__.py | 4 ++- cms/server/admin/handlers/main.py | 11 ++++++++ cms/server/admin/static/aws_style.css | 6 ++++- cms/server/admin/static/aws_utils.js | 25 ++++++++++++++++--- cms/server/admin/templates/announcements.html | 7 +++--- .../admin/templates/macro/markdown_input.html | 9 +++++++ .../admin/templates/macro/question.html | 8 ++++-- cms/server/admin/templates/participation.html | 5 ++-- 8 files changed, 63 insertions(+), 12 deletions(-) create mode 100644 cms/server/admin/templates/macro/markdown_input.html diff --git a/cms/server/admin/handlers/__init__.py b/cms/server/admin/handlers/__init__.py index 093ea93b6a..7293c2ed3d 100644 --- a/cms/server/admin/handlers/__init__.py +++ b/cms/server/admin/handlers/__init__.py @@ -73,7 +73,8 @@ LoginHandler, \ LogoutHandler, \ ResourcesHandler, \ - NotificationsHandler + NotificationsHandler, \ + MarkdownRenderHandler from .submission import \ SubmissionHandler, \ SubmissionCommentHandler, \ @@ -113,6 +114,7 @@ (r"/resources/([0-9]+|all)/([0-9]+)", ResourcesHandler), (r"/notifications", NotificationsHandler), (r"/file/([a-f0-9]+)/([a-zA-Z0-9_.-]+)", FileFromDigestHandler), + (r"/render_markdown", MarkdownRenderHandler), # Contest diff --git a/cms/server/admin/handlers/main.py b/cms/server/admin/handlers/main.py index 2d71ac27a7..e3ce0760aa 100644 --- a/cms/server/admin/handlers/main.py +++ b/cms/server/admin/handlers/main.py @@ -30,6 +30,7 @@ from cms import ServiceCoord, get_service_shards, get_service_address from cms.db import Admin, Contest, Question +from cms.server.jinja2_toolbox import markdown_filter from cmscommon.crypto import validate_password from cmscommon.datetime import make_datetime, make_timestamp from .base import BaseHandler, SimpleHandler, require_permission @@ -167,3 +168,13 @@ def get(self): self.service.notifications = [] self.write(json.dumps(res)) + +class MarkdownRenderHandler(BaseHandler): + """Renders Markdown for AWS message previews.""" + + @require_permission(BaseHandler.AUTHENTICATED) + def post(self): + data = self.get_argument("input") + rendered = markdown_filter(data) + self.write(rendered) + diff --git a/cms/server/admin/static/aws_style.css b/cms/server/admin/static/aws_style.css index d983773ed9..f2b28a1700 100644 --- a/cms/server/admin/static/aws_style.css +++ b/cms/server/admin/static/aws_style.css @@ -52,6 +52,10 @@ strong, .bold { font-weight: bold; } +em { + font-style: italic; +} + .login_error { color: #F31313; } @@ -420,7 +424,7 @@ th .column-sort { text-decoration: underline; } -.reply_question textarea { +.markdown_input { width: 100%; } diff --git a/cms/server/admin/static/aws_utils.js b/cms/server/admin/static/aws_utils.js index a54eb98f32..0db0273d1b 100644 --- a/cms/server/admin/static/aws_utils.js +++ b/cms/server/admin/static/aws_utils.js @@ -849,10 +849,29 @@ CMS.AWSUtils.prototype.question_reply_toggle = function(event, invoker) { * Updates visibility of answer box when choosing quick answers. */ CMS.AWSUtils.prototype.update_additional_answer = function(event, invoker) { - var obj = invoker.parentElement.querySelector(".alternative_answer"); + var obj = $(invoker).parent().find(".alternative_answer"); if (invoker.value == "other") { - obj.style.display = "block"; + obj.css("display", ""); } else { - obj.style.display = "none"; + obj.css("display", "none"); } } + +/** + * Used by templates/macro/markdown_input.html. + * Asks the server to render the markdown input and displays it. + */ +CMS.AWSUtils.prototype.render_markdown_preview = function(target) { + var form_element = $(target).closest("form"); + var md_text = form_element.find(".markdown_input").val(); + $.ajax({ + type: "POST", + url: this.url("render_markdown"), + data: {input: md_text}, + dataType: "text", + headers: {"X-XSRFToken": get_cookie("_xsrf")}, + success: function(response) { + form_element.find(".markdown_preview").html(response); + }, + }); +} diff --git a/cms/server/admin/templates/announcements.html b/cms/server/admin/templates/announcements.html index 704348ff05..394925a254 100644 --- a/cms/server/admin/templates/announcements.html +++ b/cms/server/admin/templates/announcements.html @@ -1,4 +1,5 @@ {% extends "base.html" %} +{% import 'macro/markdown_input.html' as macro_markdown %} {% block core %}
@@ -23,12 +24,12 @@

Announcements

- -
- You can use Markdown here. URLs are not automatically linked, surround them with <> like <http://example.org>. + + {{ macro_markdown.markdown_input("text") }}
+ {{ macro_markdown.markdown_preview() }} diff --git a/cms/server/admin/templates/macro/markdown_input.html b/cms/server/admin/templates/macro/markdown_input.html new file mode 100644 index 0000000000..73630ed01d --- /dev/null +++ b/cms/server/admin/templates/macro/markdown_input.html @@ -0,0 +1,9 @@ +{% macro markdown_input(name) %} +
+You can use Markdown here. URLs are not automatically linked, surround them with <> like <http://example.org>. +
+{% endmacro %} + +{% macro markdown_preview() %} + +{% endmacro %} diff --git a/cms/server/admin/templates/macro/question.html b/cms/server/admin/templates/macro/question.html index 3c10064655..6303ad66df 100644 --- a/cms/server/admin/templates/macro/question.html +++ b/cms/server/admin/templates/macro/question.html @@ -1,5 +1,7 @@ {# This macro should be imported "with context". #} +{% import 'macro/markdown_input.html' as macro_markdown %} + {% macro question(msg, user_id) %} {# Render one question by a participant. @@ -96,10 +98,12 @@
Alternative answer:
-
- You can use Markdown here. URLs are not automatically linked, surround them with <> like <http://example.org>. + {{ macro_markdown.markdown_input("reply_question_text") }}
+ + {{ macro_markdown.markdown_preview() }} + {% endif %} diff --git a/cms/server/admin/templates/participation.html b/cms/server/admin/templates/participation.html index 0839589c01..55f797937a 100644 --- a/cms/server/admin/templates/participation.html +++ b/cms/server/admin/templates/participation.html @@ -3,6 +3,7 @@ {% extends "base.html" %} {% import 'macro/submission.html' as macro_submission %} {% import 'macro/question.html' as macro_question with context %} +{% import 'macro/markdown_input.html' as macro_markdown %} {% block core %}

@@ -148,10 +149,10 @@

Messages

Text: -
- You can use Markdown here. URLs are not automatically linked, surround them with <> like <http://example.org>. + {{ macro_markdown.markdown_input("message_text") }}
+ {{ macro_markdown.markdown_preview() }} From 89ac3d5d66339c4b464a0eed43c97424d1aa6382 Mon Sep 17 00:00:00 2001 From: prandla Date: Tue, 10 Jun 2025 15:31:09 +0300 Subject: [PATCH 4/6] Render existing messages as markdown in AWS --- cms/server/admin/templates/announcements.html | 2 +- cms/server/admin/templates/macro/question.html | 2 +- cms/server/admin/templates/participation.html | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cms/server/admin/templates/announcements.html b/cms/server/admin/templates/announcements.html index 394925a254..5e976d5539 100644 --- a/cms/server/admin/templates/announcements.html +++ b/cms/server/admin/templates/announcements.html @@ -44,7 +44,7 @@

Announcements

{{ msg.timestamp }}
{{ msg.subject }}
-
{{ msg.text }}
+
{{ msg.text | markdown }}

By: {{ msg.admin.name if msg.admin is not none else "" }} diff --git a/cms/server/admin/templates/macro/question.html b/cms/server/admin/templates/macro/question.html index 6303ad66df..97e74608cf 100644 --- a/cms/server/admin/templates/macro/question.html +++ b/cms/server/admin/templates/macro/question.html @@ -23,7 +23,7 @@ {% if msg.reply_timestamp is not none %}
Reply: {{ msg.reply_subject }}
-
{{ msg.reply_text }}
+
{{ msg.reply_text | markdown }}

Reply from: {{ msg.admin.name if msg.admin is not none else "" }}
diff --git a/cms/server/admin/templates/participation.html b/cms/server/admin/templates/participation.html index 55f797937a..9abb3b5e8a 100644 --- a/cms/server/admin/templates/participation.html +++ b/cms/server/admin/templates/participation.html @@ -164,7 +164,7 @@

Messages

{{ msg.timestamp }}
{{ msg.subject }}
-
{{ msg.text }}
+
{{ msg.text | markdown }}

By: {{ msg.admin.name if msg.admin is not none else "" }} From 160a8f3ab1d2ecc3881d4b55b551edccc549768e Mon Sep 17 00:00:00 2001 From: prandla Date: Tue, 10 Jun 2025 17:50:25 +0300 Subject: [PATCH 5/6] Refactor common logic of Question handlers, improve comments in macro_question --- cms/server/admin/handlers/contestquestion.py | 69 +++++++------------ .../admin/templates/macro/question.html | 5 +- 2 files changed, 28 insertions(+), 46 deletions(-) diff --git a/cms/server/admin/handlers/contestquestion.py b/cms/server/admin/handlers/contestquestion.py index aa4787d303..65456a3c6e 100644 --- a/cms/server/admin/handlers/contestquestion.py +++ b/cms/server/admin/handlers/contestquestion.py @@ -64,18 +64,23 @@ def get(self, contest_id): self.render("questions.html", **self.r_params) -class QuestionReplyHandler(BaseHandler): - """Called when the manager replies to a question made by a user. - """ +class QuestionActionHandler(BaseHandler): + """Base class for handlers for actions on questions.""" + + def do(self, question: Question): + """Called on POST requests. Perform the appropriate action on the + question.""" + pass @require_permission(BaseHandler.PERMISSION_MESSAGING) def post(self, contest_id, question_id): - userid = self.get_argument("user_id", None) - if userid is not None: - ref = self.url("contest", contest_id, "user", userid, "edit") + user_id = self.get_argument("user_id", None) + if user_id is not None: + ref = self.url("contest", contest_id, "user", user_id, "edit") else: ref = self.url("contest", contest_id, "questions") + question = self.safe_get_item(Question, question_id) self.contest = self.safe_get_item(Contest, contest_id) @@ -83,6 +88,15 @@ def post(self, contest_id, question_id): if self.contest is not question.participation.contest: raise tornado_web.HTTPError(404) + self.do(question) + self.redirect(ref) + +class QuestionReplyHandler(QuestionActionHandler): + """Called when the manager replies to a question made by a user. + + """ + + def do(self, question): reply_subject_code: str = self.get_argument( "reply_question_quick_answer", "") question.reply_text = self.get_argument("reply_question_text", "") @@ -104,30 +118,14 @@ def post(self, contest_id, question_id): "question with id %s.", question.participation.user.username, question.participation.contest.name, - question_id) + question.id) - self.redirect(ref) - - -class QuestionIgnoreHandler(BaseHandler): +class QuestionIgnoreHandler(QuestionActionHandler): """Called when the manager chooses to ignore or stop ignoring a question. """ - @require_permission(BaseHandler.PERMISSION_MESSAGING) - def post(self, contest_id, question_id): - userid = self.get_argument("user_id", None) - if userid is not None: - ref = self.url("contest", contest_id, "user", userid, "edit") - else: - ref = self.url("contest", contest_id, "questions") - question = self.safe_get_item(Question, question_id) - self.contest = self.safe_get_item(Contest, contest_id) - - # Protect against URLs providing incompatible parameters. - if self.contest is not question.participation.contest: - raise tornado_web.HTTPError(404) - + def do(self, question): should_ignore = self.get_argument("ignore", "no") == "yes" # Commit the change. @@ -141,26 +139,11 @@ def post(self, contest_id, question_id): question.participation.contest.name, "ignored" if should_ignore else "unignored") - self.redirect(ref) - -class QuestionClaimHandler(BaseHandler): +class QuestionClaimHandler(QuestionActionHandler): """Called when the manager chooses to claim or unclaim a question.""" - @require_permission(BaseHandler.PERMISSION_MESSAGING) - def post(self, contest_id, question_id): - userid = self.get_argument("user_id", None) - if userid is not None: - ref = self.url("contest", contest_id, "user", userid, "edit") - else: - ref = self.url("contest", contest_id, "questions") - question = self.safe_get_item(Question, question_id) - self.contest = self.safe_get_item(Contest, contest_id) - - # Protect against URLs providing incompatible parameters. - if self.contest is not question.participation.contest: - raise tornado_web.HTTPError(404) - + def do(self, question): # Can claim/unclaim only a question not ignored or answered. if question.ignored or question.reply_timestamp is not None: raise tornado_web.HTTPError(405) @@ -180,5 +163,3 @@ def post(self, contest_id, question_id): question.participation.contest.name, "claimed" if should_claim else "unclaimed", self.current_user.name) - - self.redirect(ref) diff --git a/cms/server/admin/templates/macro/question.html b/cms/server/admin/templates/macro/question.html index 97e74608cf..1f79b94075 100644 --- a/cms/server/admin/templates/macro/question.html +++ b/cms/server/admin/templates/macro/question.html @@ -7,8 +7,9 @@ Render one question by a participant. msg (Question): the question object. -user_id (int|None): if we are rendering a single user's questions, this is the - user's id. if we are rendering all questions of the contest, then None. +user_id (int|None): if we are rendering one of the questions on a single user's + page, this is the user's id. if we are rendering one of the questions on + the contest questions page, then None. #}
From 35fb639e8eaff462dd21b41c30ca11083d805b1a Mon Sep 17 00:00:00 2001 From: prandla Date: Tue, 10 Jun 2025 23:42:12 +0300 Subject: [PATCH 6/6] Make QuestionActionHandler an abstract class --- cms/server/admin/handlers/contestquestion.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/cms/server/admin/handlers/contestquestion.py b/cms/server/admin/handlers/contestquestion.py index 65456a3c6e..71cd7624e7 100644 --- a/cms/server/admin/handlers/contestquestion.py +++ b/cms/server/admin/handlers/contestquestion.py @@ -25,6 +25,7 @@ """ +from abc import ABCMeta, abstractmethod import logging import collections @@ -65,10 +66,11 @@ def get(self, contest_id): -class QuestionActionHandler(BaseHandler): +class QuestionActionHandler(BaseHandler, metaclass=ABCMeta): """Base class for handlers for actions on questions.""" - def do(self, question: Question): + @abstractmethod + def process_question(self, question: Question): """Called on POST requests. Perform the appropriate action on the question.""" pass @@ -88,7 +90,7 @@ def post(self, contest_id, question_id): if self.contest is not question.participation.contest: raise tornado_web.HTTPError(404) - self.do(question) + self.process_question(question) self.redirect(ref) class QuestionReplyHandler(QuestionActionHandler): @@ -96,7 +98,7 @@ class QuestionReplyHandler(QuestionActionHandler): """ - def do(self, question): + def process_question(self, question): reply_subject_code: str = self.get_argument( "reply_question_quick_answer", "") question.reply_text = self.get_argument("reply_question_text", "") @@ -125,7 +127,7 @@ class QuestionIgnoreHandler(QuestionActionHandler): question. """ - def do(self, question): + def process_question(self, question): should_ignore = self.get_argument("ignore", "no") == "yes" # Commit the change. @@ -143,7 +145,7 @@ def do(self, question): class QuestionClaimHandler(QuestionActionHandler): """Called when the manager chooses to claim or unclaim a question.""" - def do(self, question): + def process_question(self, question): # Can claim/unclaim only a question not ignored or answered. if question.ignored or question.reply_timestamp is not None: raise tornado_web.HTTPError(405)