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/__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/contestquestion.py b/cms/server/admin/handlers/contestquestion.py index f99678d5e0..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 @@ -64,20 +65,24 @@ 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. - """ - QUICK_ANSWERS = { - "yes": "Yes", - "no": "No", - "invalid": "Invalid Question (not a Yes/No Question)", - "nocomment": "No Comment/Please refer to task statement", - } +class QuestionActionHandler(BaseHandler, metaclass=ABCMeta): + """Base class for handlers for actions on questions.""" + + @abstractmethod + def process_question(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): - ref = self.url("contest", contest_id, "questions") + 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) @@ -85,17 +90,26 @@ def post(self, contest_id, question_id): if self.contest is not question.participation.contest: raise tornado_web.HTTPError(404) + self.process_question(question) + self.redirect(ref) + +class QuestionReplyHandler(QuestionActionHandler): + """Called when the manager replies to a question made by a user. + + """ + + 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", "") # 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() @@ -106,26 +120,14 @@ def post(self, contest_id, question_id): "question with id %s.", question.participation.user.username, question.participation.contest.name, - question_id) - - self.redirect(ref) - + question.id) -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): - 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 process_question(self, question): should_ignore = self.get_argument("ignore", "no") == "yes" # Commit the change. @@ -139,22 +141,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): - 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 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) @@ -174,5 +165,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/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/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_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 33e47bbac5..0db0273d1b 100644 --- a/cms/server/admin/static/aws_utils.js +++ b/cms/server/admin/static/aws_utils.js @@ -826,3 +826,52 @@ 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).parent().find(".alternative_answer"); + if (invoker.value == "other") { + obj.css("display", ""); + } else { + 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 a4a49736a9..5e976d5539 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,11 +24,12 @@

Announcements

- - + + {{ macro_markdown.markdown_input("text") }}
+ {{ macro_markdown.markdown_preview() }} @@ -42,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/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 new file mode 100644 index 0000000000..1f79b94075 --- /dev/null +++ b/cms/server/admin/templates/macro/question.html @@ -0,0 +1,113 @@ +{# 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. + +msg (Question): the question object. +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. +#} +
+
+
+ {% 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 | markdown }}
+
+
+ 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:
+ {{ macro_markdown.markdown_input("reply_question_text") }} +
+ + + {{ macro_markdown.markdown_preview() }} + +
+
+{% endif %} +
+
+{% endmacro %} diff --git a/cms/server/admin/templates/participation.html b/cms/server/admin/templates/participation.html index c3adf16c77..9abb3b5e8a 100644 --- a/cms/server/admin/templates/participation.html +++ b/cms/server/admin/templates/participation.html @@ -2,33 +2,8 @@ {% 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 %} +{% import 'macro/markdown_input.html' as macro_markdown %} {% block core %}

@@ -148,43 +123,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:
-
-
- -
-
-
-
+ {{ macro_question.question(msg, selected_user.id) }} {% endfor %}
@@ -210,9 +149,10 @@

Messages

Text: - + {{ macro_markdown.markdown_input("message_text") }}
+ {{ macro_markdown.markdown_preview() }} @@ -224,7 +164,7 @@

Messages

{{ 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/questions.html b/cms/server/admin/templates/questions.html index 83010be351..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,91 +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:
-
-
- -
-
-{% endif %} -
-
+ {{ macro_question.question(msg, none) }} {% endfor %}
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