diff options
| author | 2018-05-18 16:00:31 +0100 | |
|---|---|---|
| committer | 2018-05-18 16:00:31 +0100 | |
| commit | e1846928439aa2a7e660d870a083872c415c274d (patch) | |
| tree | e716f3466ca3914f80b2ca102d5d345658af7bc8 /pysite | |
| parent | Update wiki footer in line with main site (diff) | |
[Jams] Huge amount of work on code jam admin area
Diffstat (limited to 'pysite')
| -rw-r--r-- | pysite/base_route.py | 13 | ||||
| -rw-r--r-- | pysite/constants.py | 8 | ||||
| -rw-r--r-- | pysite/rst/__init__.py | 99 | ||||
| -rw-r--r-- | pysite/tables.py | 86 | ||||
| -rw-r--r-- | pysite/views/staff/index.py | 4 | ||||
| -rw-r--r-- | pysite/views/staff/jams/actions.py | 36 | ||||
| -rw-r--r-- | pysite/views/staff/jams/create.py | 49 | ||||
| -rw-r--r-- | pysite/views/staff/jams/edit_basics.py | 58 | ||||
| -rw-r--r-- | pysite/views/staff/jams/edit_ending.py | 54 | ||||
| -rw-r--r-- | pysite/views/staff/jams/edit_info.py | 54 | ||||
| -rw-r--r-- | pysite/views/staff/jams/index.py | 15 | ||||
| -rw-r--r-- | pysite/views/staff/render.py | 63 | ||||
| -rw-r--r-- | pysite/views/ws/bot.py | 59 | 
13 files changed, 541 insertions, 57 deletions
| diff --git a/pysite/base_route.py b/pysite/base_route.py index 8449f228..fcd5f4b4 100644 --- a/pysite/base_route.py +++ b/pysite/base_route.py @@ -53,6 +53,7 @@ class BaseView(MethodView, OauthMixin):          context["logged_in"] = self.logged_in          context["static_file"] = self._static_file          context["debug"] = DEBUG_MODE +        context["format_datetime"] = lambda dt: dt.strftime("%b %d %Y, %H:%M:%S")          return render_template(template_names, **context) @@ -128,25 +129,25 @@ class APIView(RouteView):          data = {              "error_code": error_code.value, -            "error_message": "Unknown error" +            "error_message": error_info or "Unknown error"          }          http_code = 200          if error_code is ErrorCodes.unknown_route: -            data["error_message"] = "Unknown API route" +            data["error_message"] = error_info or "Unknown API route"              http_code = 404          elif error_code is ErrorCodes.unauthorized: -            data["error_message"] = "Unauthorized" +            data["error_message"] = error_info or "Unauthorized"              http_code = 401          elif error_code is ErrorCodes.invalid_api_key: -            data["error_message"] = "Invalid API-key" +            data["error_message"] = error_info or "Invalid API-key"              http_code = 401          elif error_code is ErrorCodes.bad_data_format: -            data["error_message"] = "Input data in incorrect format" +            data["error_message"] = error_info or "Input data in incorrect format"              http_code = 400          elif error_code is ErrorCodes.incorrect_parameters: -            data["error_message"] = "Incorrect parameters provided" +            data["error_message"] = error_info or "Incorrect parameters provided"              http_code = 400          response = jsonify(data) diff --git a/pysite/constants.py b/pysite/constants.py index cb8dad62..c93a09d1 100644 --- a/pysite/constants.py +++ b/pysite/constants.py @@ -67,6 +67,14 @@ ERROR_DESCRIPTIONS = {      429: "Please don't send us that many requests."  } +JAM_STATES = [ +    "planning", +    "announced", +    "running", +    "judging", +    "finished" +] +  # PaperTrail logging  PAPERTRAIL_ADDRESS = environ.get("PAPERTRAIL_ADDRESS") or None  PAPERTRAIL_PORT = int(environ.get("PAPERTRAIL_PORT") or 0) diff --git a/pysite/rst/__init__.py b/pysite/rst/__init__.py index 97a77f40..744dd269 100644 --- a/pysite/rst/__init__.py +++ b/pysite/rst/__init__.py @@ -13,8 +13,10 @@ CONTENTS_REGEX = re.compile(r"""<div class=\"contents topic\" id=\"contents\">(.  HREF_REGEX = re.compile(r"""<a class=\"reference internal\" href=\"(.*?)\".*?>(.*?)</a>""") -def render(rst: str): -    rst = RST_TEMPLATE.format(rst) +def render(rst: str, link_headers=True): +    if link_headers: +        rst = RST_TEMPLATE.format(rst) +      html = publish_parts(          source=rst, writer_name="html5", settings_overrides={              "halt_level": 2, "syntax_highlight": "short", "initial_header_level": 3 @@ -26,68 +28,69 @@ def render(rst: str):          "headers": []      } -    match = CONTENTS_REGEX.search(html)  # Find the contents HTML +    if link_headers: +        match = CONTENTS_REGEX.search(html)  # Find the contents HTML -    if match: -        data["html"] = html.replace(match.group(0), "")  # Remove the contents from the document HTML +        if match: +            data["html"] = html.replace(match.group(0), "")  # Remove the contents from the document HTML -        depth = 0 -        headers = [] -        current_header = {} +            depth = 0 +            headers = [] +            current_header = {} -        group = match.group(1) +            group = match.group(1) -        # Sanitize the output so we can more easily parse it -        group = group.replace("<li>", "<li>\n") -        group = group.replace("</li>", "\n</li>") -        group = group.replace("<p>", "<p>\n") -        group = group.replace("</p>", "\n</p>") +            # Sanitize the output so we can more easily parse it +            group = group.replace("<li>", "<li>\n") +            group = group.replace("</li>", "\n</li>") +            group = group.replace("<p>", "<p>\n") +            group = group.replace("</p>", "\n</p>") -        for line in group.split("\n"): -            line = line.strip()  # Remove excess whitespace +            for line in group.split("\n"): +                line = line.strip()  # Remove excess whitespace -            if not line:  # Nothing to process -                continue +                if not line:  # Nothing to process +                    continue -            if line.startswith("<li>") and depth <= 2: -                #  We've found a header, or the start of a header group -                depth += 1 -            elif line.startswith("</li>") and depth >= 0: -                # That's the end of a header or header group +                if line.startswith("<li>") and depth <= 2: +                    #  We've found a header, or the start of a header group +                    depth += 1 +                elif line.startswith("</li>") and depth >= 0: +                    # That's the end of a header or header group -                if depth == 1: -                    # We just dealt with an entire header group, so store it -                    headers.append(current_header.copy())  # Store a copy, since we're clearing the dict -                    current_header.clear() +                    if depth == 1: +                        # We just dealt with an entire header group, so store it +                        headers.append(current_header.copy())  # Store a copy, since we're clearing the dict +                        current_header.clear() -                depth -= 1 -            elif line.startswith("<a") and depth <= 2: -                # We've found an actual URL -                match = HREF_REGEX.match(line)  # Parse the line for the ID and header title +                    depth -= 1 +                elif line.startswith("<a") and depth <= 2: +                    # We've found an actual URL +                    match = HREF_REGEX.match(line)  # Parse the line for the ID and header title -                if depth == 1:  # Top-level header, so just store it in the current header -                    current_header["id"] = match.group(1) +                    if depth == 1:  # Top-level header, so just store it in the current header +                        current_header["id"] = match.group(1) -                    title = match.group(2) +                        title = match.group(2) -                    if title.startswith("<i"):  # We've found an icon, which needs to have a space after it -                        title = title.replace("</i> ", "</i>  ") +                        if title.startswith("<i"):  # We've found an icon, which needs to have a space after it +                            title = title.replace("</i> ", "</i>  ") -                    current_header["title"] = title -                else:  # Second-level (or deeper) header, should be stored in a list of sub-headers under the current -                    sub_headers = current_header.get("sub_headers", []) -                    title = match.group(2) +                        current_header["title"] = title +                    else:  # Second-level (or deeper) header, should be stored in a list of sub-headers +                        sub_headers = current_header.get("sub_headers", []) +                        title = match.group(2) -                    if title.startswith("<i"):  # We've found an icon, which needs to have a space after it -                        title = title.replace("</i> ", "</i>  ") +                        if title.startswith("<i"):  # We've found an icon, which needs to have a space after it +                            title = title.replace("</i> ", "</i>  ") -                    sub_headers.append({ -                        "id": match.group(1), -                        "title": title -                    }) -                    current_header["sub_headers"] = sub_headers +                        sub_headers.append({ +                            "id": match.group(1), +                            "title": title +                        }) +                        current_header["sub_headers"] = sub_headers -        data["headers"] = headers +            data["headers"] = headers      return data diff --git a/pysite/tables.py b/pysite/tables.py index 4d9142fa..8ec550fb 100644 --- a/pysite/tables.py +++ b/pysite/tables.py @@ -8,6 +8,14 @@ class Table(NamedTuple):  TABLES = { +    "bot_events": Table(  # Events to be sent to the bot via websocket +        primary_key="id", +        keys=sorted([ +            "id", +            "data" +        ]) +    ), +      "hiphopify": Table(  # Users in hiphop prison          primary_key="user_id",          keys=sorted([ @@ -26,6 +34,81 @@ TABLES = {          locked=False      ), +    "code_jams": Table(  # Information about each code jam +        primary_key="number", +        keys=sorted([ +            "date_end",  # datetime +            "date_start",  # datetime +            "end_html",  # str +            "end_rst",  # str +            "number",  # int +            "participants",  # list[int] +            "repo",  # str +            "state",  # str +            "task_html",  # str +            "task_rst",  # str +            "teams",  # list[int] +            "theme",  # str +            "title",  # str +            "winners"  # list[int] +        ]) +    ), + +    "code_jam_forms": Table(  # Application forms for each jam +        primary_key="number", +        keys=sorted([ +            "number",  # int +            "questions"  # list[dict[str, str]]  {title, type, input_type, options?} +        ]) +    ), + +    "code_jam_questions": Table(  # Application form questions +        primary_key="id", +        keys=sorted([ +            "id",  # uuid +            ""  # TODO +        ]) +    ), + +    "code_jam_responses": Table(  # Application form responses +        primary_key="id", +        keys=sorted([ +            "id",  # uuid +            "jam",  # int +            "answers",  # dict {question, answer, metadata} +        ]) +    ), + +    "code_jam_teams": Table(  # Teams for each jam +        primary_key="id", +        keys=sorted([ +            "id",  # uuid +            "name",  # str +            "members"  # list[int] +        ]) +    ), + +    "code_jam_infractions": Table(  # Individual infractions for each user +        primary_key="id", +        keys=sorted([ +            "snowflake",  # int +            "participant",  # int +            "reason",  # str +            "number"  # int (optionally -1 for permanent) +        ]) +    ), + +    "code_jam_participants": Table(  # Info for each participant +        primary_key="id", +        keys=sorted([ +            "snowflake",  # int +            "skill_level",  # str +            "age",  # str +            "github_username",  # str +            "timezone"  # str +        ]) +    ), +      "oauth_data": Table(  # OAuth login information          primary_key="id",          keys=sorted([ @@ -70,7 +153,8 @@ TABLES = {      ),      "wiki_revisions": Table(  # Revisions of wiki articles -        primary_key="id", keys=sorted([ +        primary_key="id", +        keys=sorted([              "id",              "date",              "post", diff --git a/pysite/views/staff/index.py b/pysite/views/staff/index.py index fc05f1a7..a090ebdd 100644 --- a/pysite/views/staff/index.py +++ b/pysite/views/staff/index.py @@ -9,12 +9,12 @@ from pysite.decorators import require_roles  class StaffView(RouteView):      path = "/" -    name = "staff_index" +    name = "index"      @require_roles(*ALL_STAFF_ROLES)      def get(self):          return self.render( -            "staff/staff.html", manager=self.is_table_editor(), +            "staff/index.html", manager=self.is_table_editor(),              app_config=pformat(current_app.config, indent=4, width=120)          ) diff --git a/pysite/views/staff/jams/actions.py b/pysite/views/staff/jams/actions.py new file mode 100644 index 00000000..93a7f2c7 --- /dev/null +++ b/pysite/views/staff/jams/actions.py @@ -0,0 +1,36 @@ +from flask import jsonify, request + +from pysite.base_route import APIView +from pysite.constants import ALL_STAFF_ROLES, ErrorCodes +from pysite.decorators import csrf, require_roles +from pysite.mixins import DBMixin + +ACTIONS = ["state"] +KEYS = ["action"] + + +class ActionView(APIView, DBMixin): +    path = "/jams/action/" +    name = "jams.action" +    table_name = "code_jams" + +    @csrf +    @require_roles(*ALL_STAFF_ROLES) +    def post(self): +        action = request.args.get("action") + +        if action not in ACTIONS: +            return self.error(ErrorCodes.incorrect_parameters) + +        if action == "state": +            jam = int(request.args.get("jam")) +            state = request.args.get("state") + +            if not all((jam, state)): +                return self.error(ErrorCodes.incorrect_parameters) + +            jam_obj = self.db.get(self.table_name, jam) +            jam_obj["state"] = state +            self.db.insert(self.table_name, jam_obj, conflict="update") + +            return jsonify({}) diff --git a/pysite/views/staff/jams/create.py b/pysite/views/staff/jams/create.py new file mode 100644 index 00000000..e88056c5 --- /dev/null +++ b/pysite/views/staff/jams/create.py @@ -0,0 +1,49 @@ +from flask import redirect, request, url_for +from werkzeug.exceptions import BadRequest + +from pysite.base_route import RouteView +from pysite.constants import ALL_STAFF_ROLES +from pysite.decorators import csrf, require_roles +from pysite.mixins import DBMixin + +REQUIRED_KEYS = ["title", "date_start", "date_end"] + + +class StaffView(RouteView, DBMixin): +    path = "/jams/create" +    name = "jams.create" +    table_name = "code_jams" + +    @require_roles(*ALL_STAFF_ROLES) +    def get(self): +        number = self.get_next_number() +        return self.render("staff/jams/create.html", number=number) + +    @require_roles(*ALL_STAFF_ROLES) +    @csrf +    def post(self): +        data = {} + +        for key in REQUIRED_KEYS: +            arg = request.form.get(key) + +            if not arg: +                return BadRequest() + +            data[key] = arg + +        data["state"] = "planning" +        data["number"] = self.get_next_number() + +        self.db.insert(self.table_name, data) + +        return redirect(url_for("staff.jams.index")) + +    def get_next_number(self) -> int: +        count = self.db.run(self.table.count(), coerce=int) + +        if count: +            max_num = self.db.run(self.table.max("number"))["number"] + +            return max_num + 1 +        return 1 diff --git a/pysite/views/staff/jams/edit_basics.py b/pysite/views/staff/jams/edit_basics.py new file mode 100644 index 00000000..eb56e7d4 --- /dev/null +++ b/pysite/views/staff/jams/edit_basics.py @@ -0,0 +1,58 @@ +import datetime + +from flask import redirect, request, url_for +from werkzeug.exceptions import BadRequest, NotFound + +from pysite.base_route import RouteView +from pysite.constants import ALL_STAFF_ROLES +from pysite.decorators import csrf, require_roles +from pysite.mixins import DBMixin + +REQUIRED_KEYS = ["title", "date_start", "date_end"] + + +class StaffView(RouteView, DBMixin): +    path = "/jams/<int:jam>/edit/basics" +    name = "jams.edit.basics" +    table_name = "code_jams" + +    @require_roles(*ALL_STAFF_ROLES) +    def get(self, jam): +        jam_obj = self.db.get(self.table_name, jam) + +        if not jam_obj: +            return NotFound() +        return self.render("staff/jams/edit_basics.html", jam=jam_obj) + +    @require_roles(*ALL_STAFF_ROLES) +    @csrf +    def post(self, jam): +        jam_obj = self.db.get(self.table_name, jam) + +        if not jam_obj: +            return NotFound() + +        if not jam_obj["state"] == "planning": +            return BadRequest() + +        for key in REQUIRED_KEYS: +            arg = request.form.get(key) + +            if not arg: +                return BadRequest() + +            jam_obj[key] = arg + +        # Convert given datetime strings into actual objects, adding timezones to keep rethinkdb happy +        date_start = datetime.datetime.strptime(jam_obj["date_start"], "%Y-%m-%d %H:%M") +        date_start = date_start.replace(tzinfo=datetime.timezone.utc) + +        date_end = datetime.datetime.strptime(jam_obj["date_end"], "%Y-%m-%d %H:%M") +        date_end = date_end.replace(tzinfo=datetime.timezone.utc) + +        jam_obj["date_start"] = date_start +        jam_obj["date_end"] = date_end + +        self.db.insert(self.table_name, jam_obj, conflict="replace") + +        return redirect(url_for("staff.jams.index")) diff --git a/pysite/views/staff/jams/edit_ending.py b/pysite/views/staff/jams/edit_ending.py new file mode 100644 index 00000000..69b91e29 --- /dev/null +++ b/pysite/views/staff/jams/edit_ending.py @@ -0,0 +1,54 @@ +from flask import redirect, request, url_for +from werkzeug.exceptions import BadRequest, NotFound + +from pysite.base_route import RouteView +from pysite.constants import ALL_STAFF_ROLES +from pysite.decorators import csrf, require_roles +from pysite.mixins import DBMixin +from pysite.rst import render + +REQUIRED_KEYS = ["end_rst"] + + +class StaffView(RouteView, DBMixin): +    path = "/jams/<int:jam>/edit/ending" +    name = "jams.edit.ending" +    table_name = "code_jams" + +    @require_roles(*ALL_STAFF_ROLES) +    def get(self, jam): +        jam_obj = self.db.get(self.table_name, jam) + +        if not jam_obj: +            return NotFound() + +        if not jam_obj["state"] == "judging": +            return BadRequest() + +        return self.render("staff/jams/edit_ending.html", jam=jam_obj) + +    @require_roles(*ALL_STAFF_ROLES) +    @csrf +    def post(self, jam): +        jam_obj = self.db.get(self.table_name, jam) + +        if not jam_obj: +            return NotFound() + +        if not jam_obj["state"] == "judging": +            return BadRequest() + +        print(request.form) +        for key in REQUIRED_KEYS: +            arg = request.form.get(key) + +            if not arg: +                return BadRequest() + +            jam_obj[key] = arg + +        jam_obj["end_html"] = render(jam_obj["end_rst"], link_headers=False)["html"] + +        self.db.insert(self.table_name, jam_obj, conflict="replace") + +        return redirect(url_for("staff.jams.index")) diff --git a/pysite/views/staff/jams/edit_info.py b/pysite/views/staff/jams/edit_info.py new file mode 100644 index 00000000..2ec67ebb --- /dev/null +++ b/pysite/views/staff/jams/edit_info.py @@ -0,0 +1,54 @@ +from flask import redirect, request, url_for +from werkzeug.exceptions import BadRequest, NotFound + +from pysite.base_route import RouteView +from pysite.constants import ALL_STAFF_ROLES +from pysite.decorators import csrf, require_roles +from pysite.mixins import DBMixin +from pysite.rst import render + +REQUIRED_KEYS = ["repo", "task_rst", "theme"] +ALLOWED_STATES = ["planning", "info"] + + +class StaffView(RouteView, DBMixin): +    path = "/jams/<int:jam>/edit/info" +    name = "jams.edit.info" +    table_name = "code_jams" + +    @require_roles(*ALL_STAFF_ROLES) +    def get(self, jam): +        jam_obj = self.db.get(self.table_name, jam) + +        if not jam_obj: +            return NotFound() + +        if not jam_obj["state"] in ALLOWED_STATES: +            return BadRequest() +        return self.render("staff/jams/edit_info.html", jam=jam_obj) + +    @require_roles(*ALL_STAFF_ROLES) +    @csrf +    def post(self, jam): +        jam_obj = self.db.get(self.table_name, jam) + +        if not jam_obj: +            return NotFound() + +        if not jam_obj["state"] in ALLOWED_STATES: +            return BadRequest() + +        print(request.form) +        for key in REQUIRED_KEYS: +            arg = request.form.get(key) + +            if not arg: +                return BadRequest() + +            jam_obj[key] = arg + +        jam_obj["task_html"] = render(jam_obj["task_rst"], link_headers=False)["html"] + +        self.db.insert(self.table_name, jam_obj, conflict="replace") + +        return redirect(url_for("staff.jams.index")) diff --git a/pysite/views/staff/jams/index.py b/pysite/views/staff/jams/index.py new file mode 100644 index 00000000..40a8387c --- /dev/null +++ b/pysite/views/staff/jams/index.py @@ -0,0 +1,15 @@ +from pysite.base_route import RouteView +from pysite.constants import ALL_STAFF_ROLES, JAM_STATES +from pysite.decorators import require_roles +from pysite.mixins import DBMixin + + +class StaffView(RouteView, DBMixin): +    path = "/jams" +    name = "jams.index" +    table_name = "code_jams" + +    @require_roles(*ALL_STAFF_ROLES) +    def get(self): +        jams = self.db.get_all(self.table_name) +        return self.render("staff/jams/index.html", jams=jams, states=JAM_STATES) diff --git a/pysite/views/staff/render.py b/pysite/views/staff/render.py new file mode 100644 index 00000000..00c9a9f3 --- /dev/null +++ b/pysite/views/staff/render.py @@ -0,0 +1,63 @@ +import re + +from docutils.utils import SystemMessage +from flask import jsonify +from schema import Schema + +from pysite.base_route import APIView +from pysite.constants import EDITOR_ROLES, ValidationTypes +from pysite.decorators import api_params, csrf, require_roles +from pysite.rst import render + +SCHEMA = Schema([{ +    "data": str +}]) + +MESSAGE_REGEX = re.compile(r"<string>:(\d+): \([A-Z]+/\d\) (.*)") + + +class RenderView(APIView): +    path = "/render"  # "path" means that it accepts slashes +    name = "render" + +    @csrf +    @require_roles(*EDITOR_ROLES) +    @api_params(schema=SCHEMA, validation_type=ValidationTypes.json) +    def post(self, data): +        if not len(data): +            return jsonify({"error": "No data!"}) + +        data = data[0]["data"] +        try: +            html = render(data, link_headers=False)["html"] + +            return jsonify({"data": html}) +        except SystemMessage as e: +            lines = str(e) +            data = { +                "error": lines, +                "error_lines": [] +            } + +            if "\n" in lines: +                lines = lines.split("\n") +            else: +                lines = [lines] + +            for message in lines: +                match = MESSAGE_REGEX.match(message) + +                if match: +                    data["error_lines"].append( +                        { +                            "row": int(match.group(1)) - 3, +                            "column": 0, +                            "type": "error", +                            "text": match.group(2) +                        } +                    ) + +            print(data) +            return jsonify(data) +        except Exception as e: +            return jsonify({"error": str(e)}) diff --git a/pysite/views/ws/bot.py b/pysite/views/ws/bot.py new file mode 100644 index 00000000..24af74df --- /dev/null +++ b/pysite/views/ws/bot.py @@ -0,0 +1,59 @@ +import json +import logging + +from geventwebsocket.websocket import WebSocket + +from pysite.constants import BOT_API_KEY +from pysite.mixins import DBMixin +from pysite.websockets import WS + + +class BotWebsocket(WS, DBMixin): +    path = "/bot" +    name = "ws.bot" +    table_name = "bot_events" + +    do_changefeed = True + +    def __init__(self, socket: WebSocket): +        super().__init__(socket) +        self.log = logging.getLogger() + +    def on_open(self): +        self.log.debug("Bot | WS opened.") + +    def on_message(self, message): +        self.log.debug(f"Bot | Message: {message}") + +        try: +            message = json.loads(message) +        except json.JSONDecodeError: +            self.send_json({"error": "Message was not valid JSON"}) +            return self.socket.close() + +        action = message["action"] + +        if action == "login": +            if message["key"] != BOT_API_KEY: +                return self.socket.close() + +            self.do_changefeed = True + +            for document in self.db.changes(self.table_name, include_initial=True, include_types=True): +                if not self.do_changefeed: +                    break + +                if document["type"] not in ["add", "initial"]: +                    continue + +                self.send_json({"action": "event", "event": document["new_val"]}) +                self.db.delete(self.table_name, document["id"]) + +        self.send_json({"error": f"Unknown action: {action}"}) + +    def on_close(self): +        self.log.debug("Bot | WS closed.") +        self.do_changefeed = False + +    def send_json(self, data): +        return self.send(json.dumps(data)) | 
