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 %}