diff --git a/requirements.txt b/requirements.txt index bdda42ecf..50c617a40 100644 --- a/requirements.txt +++ b/requirements.txt @@ -78,6 +78,7 @@ itsdangerous==0.24 cssmin==0.2.0 jsmin==2.2.1 hashids==1.2.0 +word-identifiers==1.0.0 pygments==2.2.0 humanize==0.5.1 markdown>=2.6,<2.7 diff --git a/server/controllers/admin.py b/server/controllers/admin.py index b615f4af3..bcb899620 100644 --- a/server/controllers/admin.py +++ b/server/controllers/admin.py @@ -205,6 +205,18 @@ def grading(bid): return grading_view(backup, form=form) +@admin.route('/grading_word/') +@is_staff() +def grading_wordid(bid): + return grading(bid) + + +@admin.route('/grading_get_word/') +@is_staff() +def grading_get_wordid(bid): + return "word = {!r}".format(utils.encode_word_id(bid)) + + @admin.route('/composition/') @is_staff() def composition(bid): diff --git a/server/converters.py b/server/converters.py index 1aa2dbbe5..14e0cbb08 100644 --- a/server/converters.py +++ b/server/converters.py @@ -25,6 +25,16 @@ def to_python(self, value): def to_url(self, value): return utils.encode_id(value) +class WordidConverter(BaseConverter): + def to_python(self, value): + try: + return utils.decode_word_id(value) + except (TypeError, ValueError) as e: + raise ValidationError(str(e)) + + def to_url(self, value): + return utils.encode_word_id(value) + name_part = '[^/]+' # TODO: Move the regexes to constants.py @@ -46,5 +56,6 @@ class AssignmentNameConverter(BaseConverter): def init_app(app): app.url_map.converters['bool'] = BoolConverter app.url_map.converters['hashid'] = HashidConverter + app.url_map.converters['wordid'] = WordidConverter app.url_map.converters['offering'] = OfferingConverter app.url_map.converters['assignment_name'] = AssignmentNameConverter diff --git a/server/utils.py b/server/utils.py index 7fc7631f7..b0680538f 100644 --- a/server/utils.py +++ b/server/utils.py @@ -8,6 +8,7 @@ from urllib.parse import urlparse, urljoin from functools import lru_cache +import base64 import bleach from flask import render_template, url_for, Markup from hashids import Hashids @@ -20,6 +21,8 @@ import sendgrid import sendgrid.helpers.mail as sg_helpers +from word_identifiers import words_to_id, id_to_words + from server import constants logger = logging.getLogger(__name__) @@ -40,6 +43,44 @@ def decode_id(value): raise ValueError('Could not decode hash {0} into ID'.format(value)) return numbers[0] +base62_forward = {} +base62_forward.update({i : chr(ord("0") + i) for i in range(10)}) +base62_forward.update({i + 10 : chr(ord("A") + i) for i in range(26)}) +base62_forward.update({i + 36 : chr(ord("a") + i) for i in range(26)}) +base62_backward = {c : i for i, c in base62_forward.items()} + +def hashid_to_int_direct(value): + """ + direct translation of hashid --> int in 1-padded base 62, without hashing. + """ + result = 1 + for c in value: + result *= 62 + result += base62_backward[c] + return result + +def int_to_hashid_direct(number): + """ + direct translation of int --> hashid in 1-padded base 62, without hashing. + """ + result = [] + while number > 0: + result.append(base62_forward[number % 62]) + number //= 62 + result.reverse() + if result.pop(0) != "1": + raise ValueError("Invalid number in 1-padded base 62") + return "".join(result) + +def encode_word_id(id_number): + hashid = encode_id(id_number) + integer_id = hashid_to_int_direct(hashid) + return "-".join(id_to_words(integer_id)) + +def decode_word_id(word_id): + integer_id = words_to_id(word_id.split("-")) + hashid = int_to_hashid_direct(integer_id) + return decode_id(hashid) def convert_markdown(text): # https://pythonadventures.wordpress.com/tag/markdown/ diff --git a/tests/test_web.py b/tests/test_web.py index 340543903..31ea0716c 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -5,6 +5,7 @@ import datetime import json import os +import re import signal import time import urllib.parse @@ -381,6 +382,29 @@ def test_assignment_send_backup_to_ag(self): self.driver.find_element_by_id('autograde-button').click() self.assertIn("Submitted to the autograder", self.driver.page_source) + def test_assignment_send_backup_to_ag(self): + self._login(role="admin") + self.assignment.autograding_key = "test" # Autograder will respond with 200 + models.db.session.commit() + + # find a backup + backup = models.Backup( + submitter_id=self.user1.id, + assignment=self.assignment, + ) + models.db.session.add(backup) + models.db.session.commit() + + bid = utils.encode_id(backup.id) + bid_word = utils.encode_word_id(backup.id) + self.page_load(self.get_server_url() + "/admin/grading/" + bid) + content = self.driver.page_source + self.page_load(self.get_server_url() + "/admin/grading_get_word/" + bid) + extract_word = [x.group(1) for x in re.finditer("word = '(.*)'", self.driver.page_source)] + self.assertEqual([bid_word], extract_word) + self.page_load(self.get_server_url() + "/admin/grading_word/" + bid_word) + self.assertEqual(content, self.driver.page_source) + def test_admin_enrollment(self): self._login(role="admin") self.page_load(self.get_server_url() + "/admin/course/1/enrollment")