quizcomp.uploader.gradescope

  1import logging
  2import os
  3import re
  4import typing
  5
  6import bs4
  7import edq.util.dirent
  8import edq.util.json
  9import requests
 10
 11import quizcomp.model.constants
 12import quizcomp.model.quiz
 13
 14_logger = logging.getLogger(__name__)
 15
 16URL_BASE: str = 'https://www.gradescope.com'
 17URL_HOMEPAGE = URL_BASE
 18URL_LOGIN: str = f"{URL_BASE}/login"
 19URL_ASSIGNMENTS: str = f"{URL_BASE}/courses/%s/assignments"
 20URL_ASSIGNMENT: str = f"{URL_BASE}/courses/%s/assignments/%s"
 21URL_ASSIGNMENT_GROUP: str = f"{URL_BASE}/courses/%s/assignment_containers"
 22URL_ASSIGNMENT_EDIT: str = f"{URL_BASE}/courses/%s/assignments/%s/edit"
 23URL_ASSIGNMENT_RUBRIC: str = f"{URL_BASE}/courses/%s/assignments/%s/rubric/edit"
 24URL_ASSIGNMENT_ADD_RUBRIC_ITEM: str = f"{URL_BASE}/courses/%s/questions/%s/rubric_items"
 25URL_NEW_ASSIGNMENT_FORM: str = f"{URL_BASE}/courses/%s/assignments/new"
 26URL_EDIT_OUTLINE: str = f"{URL_BASE}/courses/%s/assignments/%s/outline/edit"
 27URL_PATCH_OUTLINE: str = f"{URL_BASE}/courses/%s/assignments/%s/outline"
 28
 29NAME_BOX_ID: str = 'name'
 30ID_BOX_ID: str = 'id'
 31SIGNATURE_BOX_ID: str = 'signature'
 32MANUAL_GRADING_BOX_ID: str = 'manual_grading'
 33
 34SPECIAL_QUESTION_TYPES: typing.List[str] = [
 35    NAME_BOX_ID,
 36    ID_BOX_ID,
 37    SIGNATURE_BOX_ID,
 38    MANUAL_GRADING_BOX_ID,
 39]
 40
 41EXTEND_BOX_QUESTION_TYPES: typing.List[str] = [
 42    quizcomp.model.constants.QuestionType.MA.value,
 43    quizcomp.model.constants.QuestionType.MCQ.value,
 44    quizcomp.model.constants.QuestionType.MDD.value,
 45    quizcomp.model.constants.QuestionType.TF.value,
 46]
 47
 48STANDARD_BOX_QUESTION_TYPES: typing.List[str] = [
 49    quizcomp.model.constants.QuestionType.ESSAY.value,
 50    quizcomp.model.constants.QuestionType.FIMB.value,
 51    quizcomp.model.constants.QuestionType.FITB.value,
 52    quizcomp.model.constants.QuestionType.MATCHING.value,
 53    quizcomp.model.constants.QuestionType.NUMERICAL.value,
 54    quizcomp.model.constants.QuestionType.SA.value,
 55]
 56
 57BOX_TYPES: typing.List[str] = EXTEND_BOX_QUESTION_TYPES + STANDARD_BOX_QUESTION_TYPES + SPECIAL_QUESTION_TYPES
 58
 59SP_PER_PT: int = 65536
 60
 61SESSION_ID_CREATE_ASSIGNMENT_GROUP: str = 'gradescope_create_assignment_group'
 62SESSION_ID_UPLOAD: str = 'gradescope_upload'
 63
 64class GradeScopeUploader:
 65    """
 66    Upload quizzes to GradeScope.
 67    """
 68
 69    def __init__(self,
 70            course_id: str,
 71            user: str,
 72            password: str,
 73            force: bool = False,
 74            rubric: bool = False,
 75            save_http: bool = False,
 76            **kwargs: typing.Any) -> None:
 77        super().__init__(**kwargs)
 78
 79        self.course_id: str = course_id
 80        """ The GradeScope course ID. """
 81
 82        self.user: str = user
 83        """ The GradeScope user/email to authenticate with. """
 84
 85        self.password: str = password
 86        """ The GradeScope password to authenticate with. """
 87
 88        self.force: bool = force
 89        """ Whether to remove existing quizzes before upload. """
 90
 91        self.rubric: bool = rubric
 92        """ Whether to upload the rubric items for the quiz. """
 93
 94        self.save_http = save_http
 95        """ Whether to save the HTTP exchanges to disk. """
 96
 97    def upload_quiz(self,
 98            variant: quizcomp.model.quiz.Variant,
 99            base_dir: typing.Union[str, None] = None,
100            **kwargs: typing.Any,
101            ) -> typing.Tuple[str, bool]:
102        """
103        Compile a quiz and upload it to GradeScope.
104
105        If base_dir is left None, then a temp dir will be created which will be destroyed on exit.
106        If supplied, the base_dir will be left alone when finished.
107        """
108
109        if (not isinstance(variant, quizcomp.model.quiz.Variant)):
110            raise ValueError(f"GradeScope quiz uploader requires a quizcomp.model.quiz.Variant type, found {type(variant)}.")
111
112        if (base_dir is None):
113            base_dir = edq.util.dirent.get_temp_path(prefix = 'quizcomp-gradescope-')
114
115        boxes, special_boxes = self.get_bounding_boxes(variant, base_dir)
116        return self.upload(variant, base_dir, boxes, special_boxes)
117
118    def create_assignment_group(self, title: str, gradescope_ids: typing.List[str]) -> None:
119        """ Create an assignment group with the given IDs. """
120
121        session = requests.Session()
122
123        self.login(session)
124
125        assignments_url = URL_ASSIGNMENTS % (self.course_id)
126        csrf_token = self.get_csrf_token(session, assignments_url)
127
128        headers = {
129            'x-csrf-token': csrf_token,
130        }
131
132        post_url = URL_ASSIGNMENT_GROUP % (self. course_id)
133        data = {
134            'title': title,
135            'assignment_ids[]': gradescope_ids,
136        }
137
138        response = session.post(post_url, params = data, headers = headers)  # type: ignore[arg-type]
139        response.raise_for_status()
140
141    def get_bounding_boxes(self,
142            variant: quizcomp.model.quiz.Variant,
143            base_dir: str,
144            ) -> typing.Tuple[typing.Dict[str, typing.Dict[str, typing.Any]], typing.Dict[str, typing.Dict[str, typing.Any]]]:
145        """ Get bounding boxes for answers. """
146
147        # {<quetion id>: {<part id>: box, ...}, ...}
148        boxes: typing.Dict[str, typing.Dict[str, typing.Any]] = {}
149        # {NAME_BOX_ID: box, ID_BOX_ID: box, SIGNATURE_BOX_ID: box}
150        special_boxes: typing.Dict[str, typing.Dict[str, typing.Any]] = {}
151
152        path = os.path.join(base_dir, f"{variant.get_name()}.pos")
153        if (not os.path.exists(path)):
154            raise ValueError(f"Could not find path for quiz bounding boxes: '{path}'.")
155
156        with open(path, 'r', encoding = edq.util.dirent.DEFAULT_ENCODING) as file:
157            for line in file:
158                line = line.strip()
159                if (line == ""):
160                    continue
161
162                parts = [part.strip() for part in line.split(',')]
163                if (len(parts) != 12):
164                    raise ValueError(f"Position file has row with bad number of parts. Expecting 11, found {len(parts)}.")
165
166                # "ll" == "lower-left"
167                # "ur" == "upper-right"
168                (question_id, part_id, _, raw_question_type, raw_page_number, ll_x, ll_y, ur_x, ur_y, page_width, page_height, origin) = parts
169
170                if (origin != 'bottom-left'):
171                    raise ValueError(f"Unknown bounding box origin: '{origin}'.")
172
173                # Note that the position file and GradeScope use 1-indexed pages.
174                page_number = int(raw_page_number)
175
176                if (raw_question_type not in BOX_TYPES):
177                    raise ValueError(f"Unknown content type: '{raw_question_type}'.")
178
179                extend_box_right = False
180                if (raw_question_type in EXTEND_BOX_QUESTION_TYPES):
181                    extend_box_right = True
182
183                    # There is a special case for inline MA questions.
184                    if (raw_question_type == quizcomp.model.constants.QuestionType.MA.value):
185                        extend_box_right = False
186
187                (x1, y1), (x2, y2) = self._compute_box(ll_x, ll_y, ur_x, ur_y, page_width, page_height, extend_box_right = extend_box_right)
188
189                if (raw_question_type in SPECIAL_QUESTION_TYPES):
190                    # These boxes are special.
191                    if (raw_question_type in special_boxes):
192                        raise ValueError(f"Multiple {raw_question_type} bounding boxes found.")
193
194                    special_boxes[raw_question_type] = {
195                        'page_number': page_number,
196                        'x1': x1,
197                        'y1': y1,
198                        'x2': x2,
199                        'y2': y2,
200                    }
201
202                    continue
203
204                if (question_id not in boxes):
205                    boxes[question_id] = {}
206
207                # If there is an existing box, extend it.
208                if (part_id in boxes[question_id]):
209                    old_box = boxes[question_id][part_id]
210
211                    old_x1 = old_box['x1']
212                    old_y1 = old_box['y1']
213                    old_x2 = old_box['x2']
214                    old_y2 = old_box['y2']
215
216                    old_page = old_box['page_number']
217                    if (old_page != page_number):
218                        raise ValueError(f"Question {question_id} has bounding boxes that span pages.")
219
220                    x1 = min(x1, old_x1)
221                    y1 = min(y1, old_y1)
222                    x2 = max(x2, old_x2)
223                    y2 = max(y2, old_y2)
224
225                boxes[question_id][part_id] = {
226                    'page_number': page_number,
227                    'x1': x1,
228                    'y1': y1,
229                    'x2': x2,
230                    'y2': y2,
231                }
232
233        return boxes, special_boxes
234
235    def _compute_box(self,
236            raw_ll_x: str,
237            raw_ll_y: str,
238            raw_ur_x: str,
239            raw_ur_y: str,
240            raw_page_width: str,
241            raw_page_height: str,
242            extend_box_right: bool = False,
243            ) -> typing.Tuple[typing.Tuple[float, float], typing.Tuple[float, float]]:
244        """ Compute a bounding box. """
245
246        ll_x = float(raw_ll_x.removesuffix('sp'))
247        ll_y = float(raw_ll_y.removesuffix('sp'))
248        ur_x = float(raw_ur_x.removesuffix('sp'))
249        ur_y = float(raw_ur_y.removesuffix('sp'))
250
251        page_width = float(raw_page_width.removesuffix('pt')) * SP_PER_PT
252        page_height = float(raw_page_height.removesuffix('pt')) * SP_PER_PT
253
254        # Origin is upper-left, point 1 is upper-left, point 2 is lower-right.
255        x1 = round(100.0 * (ll_x / page_width), 1)
256        y1 = round(100.0 * (1.0 - (ur_y / page_height)), 1)
257        # The lower right x should always extend (at least) to the end of the page (to capture the answers).
258        x2 = round(100.0 * (ur_x / page_width), 1)
259        y2 = round(100.0 * (1.0 - (ll_y / page_height)), 1)
260
261        if (extend_box_right):
262            # In some question types, we want to extend (at least) to the end of the page (to capture the answers).
263            x2 = max(95.0, x2)
264
265        return (x1, y1), (x2, y2)
266
267    def create_outline(self,
268            variant: quizcomp.model.quiz.Variant,
269            bounding_boxes: typing.Dict[str, typing.Dict[str, typing.Any]],
270            special_boxes: typing.Dict[str, typing.Dict[str, typing.Any]],
271            ) -> typing.Dict[str, typing.Any]:
272        """ Create GradeScope outline data. """
273
274        question_data = []
275        for (question_id, parts) in bounding_boxes.items():
276            (_, group_index, question_index) = map(int, question_id.split('.'))
277            question = variant.children[group_index].children[question_index]
278
279            if (len(parts) == 1):
280                # Single-part question.
281                question_data.append({
282                    'title': question.get_name(),
283                    'weight': question.get_points(),
284                    'crop_rect_list': list(parts.values()),
285                })
286            else:
287                # Multi-part question.
288                children = []
289                for (part_id, box) in parts.items():
290                    children.append({
291                        'title': f"{question.get_name()} - {part_id}",
292                        'weight': round(question.get_points() / len(parts), 2),
293                        'crop_rect_list': [box],
294                    })
295
296                question_data.append({
297                    'title': question.get_name(),
298                    'weight': question.get_points(),
299                    # The top-level question just needs one of the bounding boxes.
300                    'crop_rect_list': [list(parts.values())[0]],
301                    'children': children,
302                })
303
304        name_box = None
305        id_box = None
306
307        for (question_type, box) in special_boxes.items():
308            if (question_type == NAME_BOX_ID):
309                name_box = special_boxes[NAME_BOX_ID]
310            elif (question_type == ID_BOX_ID):
311                id_box = special_boxes[ID_BOX_ID]
312            else:
313                question_data.append({
314                    'title': question_type,
315                    'weight': 0,
316                    'crop_rect_list': [box],
317                })
318
319        outline = {
320            'assignment': {
321                'identification_regions': {
322                    'name': name_box,
323                    'sid': id_box
324                }
325            },
326            'question_data': question_data,
327        }
328
329        return outline
330
331    def upload(self,
332            variant: quizcomp.model.quiz.Variant,
333            base_dir: str,
334            bounding_boxes: typing.Dict[str, typing.Dict[str, typing.Any]],
335            special_boxes: typing.Dict[str, typing.Dict[str, typing.Any]],
336            ) -> typing.Tuple[str, bool]:
337        """ Upload a variant. """
338
339        path = os.path.join(base_dir, f"{variant.get_name()}.pdf")
340        if (not os.path.exists(path)):
341            raise ValueError(f"Could not find path for quiz pdf: '{path}'.")
342
343        outline = self.create_outline(variant, bounding_boxes, special_boxes)
344
345        session = requests.Session()
346
347        self.login(session)
348        _logger.debug("Logged in as '%s'.", self.user)
349
350        assignment_id = self.get_assignment_id(session, variant)
351        if (assignment_id is not None):
352            if (not self.force):
353                _logger.info("Assignment '%s' (%s) already exists. Skipping upload.", variant.get_name(), assignment_id)
354                return assignment_id, False
355
356            self.delete_assignment(session, assignment_id)
357            _logger.debug("Deleted assignment: '%s'", assignment_id)
358
359        assignment_id = self.create_assignment(session, variant, base_dir)
360        _logger.debug("Created assignment: '%s'", assignment_id)
361
362        self.submit_outline(session, assignment_id, outline)
363        _logger.debug('Submitted outline.')
364
365        if (self.rubric):
366            self.create_rubric(session, assignment_id, variant)
367            _logger.debug('Created assignment rubric.')
368
369        return assignment_id, True
370
371    def login(self, session: requests.Session) -> None:
372        """ Login to GradeScope. """
373
374        token = self.get_authenticity_token(session, URL_HOMEPAGE, action = '/login')
375
376        data = {
377            'utf8': '✓',
378            'session[email]': self.user,
379            'session[password]': self.password,
380            'session[remember_me]': 0,
381            'commit': 'Log+In',
382            'session[remember_me_sso]': 0,
383            'authenticity_token': token,
384        }
385
386        # Login.
387        response = session.post(URL_LOGIN, params = data)  # type: ignore[arg-type]
388        response.raise_for_status()
389
390    def get_authenticity_token(self, session: requests.Session, url: str, action: typing.Union[str, None] = None) -> str:
391        """ Get a token from GradeScope. """
392
393        response = session.get(url)
394        response.raise_for_status()
395
396        document = bs4.BeautifulSoup(response.text, 'html.parser')
397
398        form_selector = 'form'
399        if (action is not None):
400            form_selector = f"form[action='{action}']"
401
402        auth_input = document.select(f"{form_selector} input[name='authenticity_token']")
403        if (len(auth_input) != 1):
404            raise ValueError(f"Did not find exactly one authentication token input, found {len(auth_input)}.")
405
406        return str(auth_input[0].get('value'))
407
408    def get_csrf_token(self, session: requests.Session, url: str) -> str:
409        """ Get a CSRF token. """
410
411        # Get outline submission csrf token.
412        response = session.get(url)
413        response.raise_for_status()
414
415        document = bs4.BeautifulSoup(response.text, 'html.parser')
416        return self.parse_csrf_token(document)
417
418    def parse_csrf_token(self, document: bs4.BeautifulSoup) -> str:
419        """ Parse a CSRF token from a page. """
420
421        meta_tag = document.select('meta[name="csrf-token"]')
422        if (len(meta_tag) != 1):
423            raise ValueError(f"Did not find exactly one CSRF meta tag, found {len(meta_tag)}.")
424
425        return str(meta_tag[0].get('content'))
426
427    def get_assignment_id(self, session: requests.Session, variant: quizcomp.model.quiz.Variant) -> typing.Union[str, None]:
428        """ Get an assignment ID for the given variant, or None if there is no matching variant. """
429
430        url = URL_ASSIGNMENTS % (self.course_id)
431
432        response = session.get(url)
433        response.raise_for_status()
434
435        document = bs4.BeautifulSoup(response.text, 'html.parser')
436
437        nodes = document.select('div[data-react-class="AssignmentsTable"]')
438        if (len(nodes) != 1):
439            raise ValueError(f"Did not find exactly one assignments table, found {len(nodes)}.")
440
441        assignment_data = edq.util.json.loads(str(nodes[0].get('data-react-props')))
442
443        for row in assignment_data['table_data']:
444            if (row['type'] != 'assignment'):
445                continue
446
447            id = str(row['id']).strip().removeprefix('assignment_')
448            name = row['title'].strip()
449
450            if (name == variant.get_name()):
451                return id
452
453        return None
454
455    def delete_assignment(self, session: requests.Session, assignment_id: str) -> None:
456        """ Delete an assignment from GradeScope. """
457
458        form_url = URL_ASSIGNMENT_EDIT % (self.course_id, assignment_id)
459        token = self.get_csrf_token(session, form_url)
460
461        data = {
462            '_method': 'delete',
463            'authenticity_token': token,
464        }
465
466        delete_url = URL_ASSIGNMENT % (self.course_id, assignment_id)
467        response = session.post(delete_url, data = data)
468        response.raise_for_status()
469
470    def create_assignment(self, session: requests.Session, variant: quizcomp.model.quiz.Variant, base_dir: str) -> str:
471        """ Create an assignment in GradeScope and get its ID. """
472
473        form_url = URL_NEW_ASSIGNMENT_FORM % (self.course_id)
474        token = self.get_csrf_token(session, form_url)
475
476        data = {
477            'authenticity_token': token,
478            'assignment[title]': variant.get_name(),
479            'assignment[submissions_anonymized]': 0,
480            'assignment[student_submission]': "false",
481            'assignment[when_to_create_rubric]': 'while_grading',
482            'assignment[scoring_type]': 'negative',
483        }
484
485        path = os.path.join(base_dir, f"{variant.get_name()}.pdf")
486        files = {
487            'template_pdf': (
488                os.path.basename(path),
489                open(path, 'rb'),  # pylint: disable=consider-using-with
490            ),
491        }
492
493        create_url = URL_ASSIGNMENTS % (self.course_id)
494        response = session.post(create_url, data = data, files = files)
495        response.raise_for_status()
496
497        if (len(response.history) == 0):
498            raise ValueError(f"Failed to create assignment. Is the name ('{variant.get_name()}') unique?")
499
500        match = re.search(r'/assignments/(\d+)/outline/edit', response.history[0].text)
501        if (match is None):
502            _logger.error("--- Create Body ---\n%s\n------", response.history[0].text)
503            raise ValueError("Could not parse assignment ID from response body.")
504
505        return match.group(1)
506
507    def submit_outline(self, session: requests.Session, assignment_id: str, outline: typing.Dict[str, typing.Any]) -> None:
508        """ Submit an output to a quiz. """
509
510        edit_url = URL_EDIT_OUTLINE % (self.course_id, assignment_id)
511        csrf_token = self.get_csrf_token(session, edit_url)
512
513        headers = {
514            'Content-Type': 'application/json',
515            'x-csrf-token': csrf_token,
516        }
517
518        patch_outline_url = URL_PATCH_OUTLINE % (self.course_id, assignment_id)
519        response = session.patch(patch_outline_url,
520            data = edq.util.json.dumps(outline, separators = (',', ':')),
521            headers = headers,  # type: ignore[arg-type]
522        )
523        response.raise_for_status()
524
525    def create_rubric(self, session: requests.Session, assignment_id: str, variant: quizcomp.model.quiz.Variant) -> None:
526        """ Create rubric items for a quiz. """
527
528        questions_ids, csrf_token = self.fetch_question_ids(session, assignment_id)
529
530        for group in variant.get_groups():
531            for question in group.get_questions():
532                if (question.get_name() not in questions_ids):
533                    continue
534
535                question_ids = questions_ids[question.get_name()]
536                score = round(question.get_points() / len(question_ids), 2)
537
538                for question_id in question_ids:
539                    self.add_rubric_item(session, csrf_token, question_id, "Incorrect", score)
540
541    def add_rubric_item(self, session: requests.Session, csrf_token: str, question_id: str, description: str, score: float) -> None:
542        """ Add a single rubric item to a quiz. """
543
544        url = URL_ASSIGNMENT_ADD_RUBRIC_ITEM % (self.course_id, question_id)
545
546        headers = {
547            'Content-Type': 'application/json',
548            'x-csrf-token': csrf_token,
549        }
550
551        data = {
552            "rubric_item":{
553                "description": description,
554                "weight": str(score),
555                "group_id": None,
556            },
557        }
558
559        response = session.post(url, json = data, headers = headers)  # type: ignore[arg-type]
560        response.raise_for_status()
561
562    def fetch_question_ids(self, session: requests.Session, assignment_id: str) -> typing.Tuple[typing.Dict[str, typing.List[str]], str]:
563        """
564        Get the GradeScope id of all questions/subquestions.
565
566        Returns: {<question base name>: [question id, ...], ...}
567        """
568
569        url = URL_ASSIGNMENT_RUBRIC % (self.course_id, assignment_id)
570
571        response = session.get(url)
572        response.raise_for_status()
573
574        document = bs4.BeautifulSoup(response.text, 'html.parser')
575
576        csrf_token = self.parse_csrf_token(document)
577
578        data_tag = document.select('div[data-react-class="AssignmentRubric"]')
579        if (len(data_tag) != 1):
580            raise ValueError(f"Did not find exactly one rubric data tag, found {len(data_tag)}.")
581
582        data = edq.util.json.loads(str(data_tag[0].get('data-react-props')))
583
584        ids = {}
585        for question in data['questions']:
586            question_ids = []
587
588            children = question.get('children', None)
589            if (children is None):
590                question_ids.append(str(question['id']))
591            else:
592                question_ids = [str(child['id']) for child in children]
593
594            ids[question['title']] = question_ids
595
596        return ids, csrf_token
URL_BASE: str = 'https://www.gradescope.com'
URL_HOMEPAGE = 'https://www.gradescope.com'
URL_LOGIN: str = 'https://wwwquizcomp.uploader.gradescope.com/login'
URL_ASSIGNMENTS: str = 'https://wwwquizcomp.uploader.gradescope.com/courses/%s/assignments'
URL_ASSIGNMENT: str = 'https://wwwquizcomp.uploader.gradescope.com/courses/%s/assignments/%s'
URL_ASSIGNMENT_GROUP: str = 'https://wwwquizcomp.uploader.gradescope.com/courses/%s/assignment_containers'
URL_ASSIGNMENT_EDIT: str = 'https://wwwquizcomp.uploader.gradescope.com/courses/%s/assignments/%s/edit'
URL_ASSIGNMENT_RUBRIC: str = 'https://wwwquizcomp.uploader.gradescope.com/courses/%s/assignments/%s/rubric/edit'
URL_ASSIGNMENT_ADD_RUBRIC_ITEM: str = 'https://wwwquizcomp.uploader.gradescope.com/courses/%s/questions/%s/rubric_items'
URL_NEW_ASSIGNMENT_FORM: str = 'https://wwwquizcomp.uploader.gradescope.com/courses/%s/assignments/new'
URL_EDIT_OUTLINE: str = 'https://wwwquizcomp.uploader.gradescope.com/courses/%s/assignments/%s/outline/edit'
URL_PATCH_OUTLINE: str = 'https://wwwquizcomp.uploader.gradescope.com/courses/%s/assignments/%s/outline'
NAME_BOX_ID: str = 'name'
ID_BOX_ID: str = 'id'
SIGNATURE_BOX_ID: str = 'signature'
MANUAL_GRADING_BOX_ID: str = 'manual_grading'
SPECIAL_QUESTION_TYPES: List[str] = ['name', 'id', 'signature', 'manual_grading']
EXTEND_BOX_QUESTION_TYPES: List[str] = ['multiple_answers', 'multiple_choice', 'multiple_dropdowns', 'true_false']
STANDARD_BOX_QUESTION_TYPES: List[str] = ['essay', 'fill_in_multiple_blanks', 'fill_in_the_blank', 'matching', 'numerical', 'short_answer']
BOX_TYPES: List[str] = ['multiple_answers', 'multiple_choice', 'multiple_dropdowns', 'true_false', 'essay', 'fill_in_multiple_blanks', 'fill_in_the_blank', 'matching', 'numerical', 'short_answer', 'name', 'id', 'signature', 'manual_grading']
SP_PER_PT: int = 65536
SESSION_ID_CREATE_ASSIGNMENT_GROUP: str = 'gradescope_create_assignment_group'
SESSION_ID_UPLOAD: str = 'gradescope_upload'
class GradeScopeUploader:
 65class GradeScopeUploader:
 66    """
 67    Upload quizzes to GradeScope.
 68    """
 69
 70    def __init__(self,
 71            course_id: str,
 72            user: str,
 73            password: str,
 74            force: bool = False,
 75            rubric: bool = False,
 76            save_http: bool = False,
 77            **kwargs: typing.Any) -> None:
 78        super().__init__(**kwargs)
 79
 80        self.course_id: str = course_id
 81        """ The GradeScope course ID. """
 82
 83        self.user: str = user
 84        """ The GradeScope user/email to authenticate with. """
 85
 86        self.password: str = password
 87        """ The GradeScope password to authenticate with. """
 88
 89        self.force: bool = force
 90        """ Whether to remove existing quizzes before upload. """
 91
 92        self.rubric: bool = rubric
 93        """ Whether to upload the rubric items for the quiz. """
 94
 95        self.save_http = save_http
 96        """ Whether to save the HTTP exchanges to disk. """
 97
 98    def upload_quiz(self,
 99            variant: quizcomp.model.quiz.Variant,
100            base_dir: typing.Union[str, None] = None,
101            **kwargs: typing.Any,
102            ) -> typing.Tuple[str, bool]:
103        """
104        Compile a quiz and upload it to GradeScope.
105
106        If base_dir is left None, then a temp dir will be created which will be destroyed on exit.
107        If supplied, the base_dir will be left alone when finished.
108        """
109
110        if (not isinstance(variant, quizcomp.model.quiz.Variant)):
111            raise ValueError(f"GradeScope quiz uploader requires a quizcomp.model.quiz.Variant type, found {type(variant)}.")
112
113        if (base_dir is None):
114            base_dir = edq.util.dirent.get_temp_path(prefix = 'quizcomp-gradescope-')
115
116        boxes, special_boxes = self.get_bounding_boxes(variant, base_dir)
117        return self.upload(variant, base_dir, boxes, special_boxes)
118
119    def create_assignment_group(self, title: str, gradescope_ids: typing.List[str]) -> None:
120        """ Create an assignment group with the given IDs. """
121
122        session = requests.Session()
123
124        self.login(session)
125
126        assignments_url = URL_ASSIGNMENTS % (self.course_id)
127        csrf_token = self.get_csrf_token(session, assignments_url)
128
129        headers = {
130            'x-csrf-token': csrf_token,
131        }
132
133        post_url = URL_ASSIGNMENT_GROUP % (self. course_id)
134        data = {
135            'title': title,
136            'assignment_ids[]': gradescope_ids,
137        }
138
139        response = session.post(post_url, params = data, headers = headers)  # type: ignore[arg-type]
140        response.raise_for_status()
141
142    def get_bounding_boxes(self,
143            variant: quizcomp.model.quiz.Variant,
144            base_dir: str,
145            ) -> typing.Tuple[typing.Dict[str, typing.Dict[str, typing.Any]], typing.Dict[str, typing.Dict[str, typing.Any]]]:
146        """ Get bounding boxes for answers. """
147
148        # {<quetion id>: {<part id>: box, ...}, ...}
149        boxes: typing.Dict[str, typing.Dict[str, typing.Any]] = {}
150        # {NAME_BOX_ID: box, ID_BOX_ID: box, SIGNATURE_BOX_ID: box}
151        special_boxes: typing.Dict[str, typing.Dict[str, typing.Any]] = {}
152
153        path = os.path.join(base_dir, f"{variant.get_name()}.pos")
154        if (not os.path.exists(path)):
155            raise ValueError(f"Could not find path for quiz bounding boxes: '{path}'.")
156
157        with open(path, 'r', encoding = edq.util.dirent.DEFAULT_ENCODING) as file:
158            for line in file:
159                line = line.strip()
160                if (line == ""):
161                    continue
162
163                parts = [part.strip() for part in line.split(',')]
164                if (len(parts) != 12):
165                    raise ValueError(f"Position file has row with bad number of parts. Expecting 11, found {len(parts)}.")
166
167                # "ll" == "lower-left"
168                # "ur" == "upper-right"
169                (question_id, part_id, _, raw_question_type, raw_page_number, ll_x, ll_y, ur_x, ur_y, page_width, page_height, origin) = parts
170
171                if (origin != 'bottom-left'):
172                    raise ValueError(f"Unknown bounding box origin: '{origin}'.")
173
174                # Note that the position file and GradeScope use 1-indexed pages.
175                page_number = int(raw_page_number)
176
177                if (raw_question_type not in BOX_TYPES):
178                    raise ValueError(f"Unknown content type: '{raw_question_type}'.")
179
180                extend_box_right = False
181                if (raw_question_type in EXTEND_BOX_QUESTION_TYPES):
182                    extend_box_right = True
183
184                    # There is a special case for inline MA questions.
185                    if (raw_question_type == quizcomp.model.constants.QuestionType.MA.value):
186                        extend_box_right = False
187
188                (x1, y1), (x2, y2) = self._compute_box(ll_x, ll_y, ur_x, ur_y, page_width, page_height, extend_box_right = extend_box_right)
189
190                if (raw_question_type in SPECIAL_QUESTION_TYPES):
191                    # These boxes are special.
192                    if (raw_question_type in special_boxes):
193                        raise ValueError(f"Multiple {raw_question_type} bounding boxes found.")
194
195                    special_boxes[raw_question_type] = {
196                        'page_number': page_number,
197                        'x1': x1,
198                        'y1': y1,
199                        'x2': x2,
200                        'y2': y2,
201                    }
202
203                    continue
204
205                if (question_id not in boxes):
206                    boxes[question_id] = {}
207
208                # If there is an existing box, extend it.
209                if (part_id in boxes[question_id]):
210                    old_box = boxes[question_id][part_id]
211
212                    old_x1 = old_box['x1']
213                    old_y1 = old_box['y1']
214                    old_x2 = old_box['x2']
215                    old_y2 = old_box['y2']
216
217                    old_page = old_box['page_number']
218                    if (old_page != page_number):
219                        raise ValueError(f"Question {question_id} has bounding boxes that span pages.")
220
221                    x1 = min(x1, old_x1)
222                    y1 = min(y1, old_y1)
223                    x2 = max(x2, old_x2)
224                    y2 = max(y2, old_y2)
225
226                boxes[question_id][part_id] = {
227                    'page_number': page_number,
228                    'x1': x1,
229                    'y1': y1,
230                    'x2': x2,
231                    'y2': y2,
232                }
233
234        return boxes, special_boxes
235
236    def _compute_box(self,
237            raw_ll_x: str,
238            raw_ll_y: str,
239            raw_ur_x: str,
240            raw_ur_y: str,
241            raw_page_width: str,
242            raw_page_height: str,
243            extend_box_right: bool = False,
244            ) -> typing.Tuple[typing.Tuple[float, float], typing.Tuple[float, float]]:
245        """ Compute a bounding box. """
246
247        ll_x = float(raw_ll_x.removesuffix('sp'))
248        ll_y = float(raw_ll_y.removesuffix('sp'))
249        ur_x = float(raw_ur_x.removesuffix('sp'))
250        ur_y = float(raw_ur_y.removesuffix('sp'))
251
252        page_width = float(raw_page_width.removesuffix('pt')) * SP_PER_PT
253        page_height = float(raw_page_height.removesuffix('pt')) * SP_PER_PT
254
255        # Origin is upper-left, point 1 is upper-left, point 2 is lower-right.
256        x1 = round(100.0 * (ll_x / page_width), 1)
257        y1 = round(100.0 * (1.0 - (ur_y / page_height)), 1)
258        # The lower right x should always extend (at least) to the end of the page (to capture the answers).
259        x2 = round(100.0 * (ur_x / page_width), 1)
260        y2 = round(100.0 * (1.0 - (ll_y / page_height)), 1)
261
262        if (extend_box_right):
263            # In some question types, we want to extend (at least) to the end of the page (to capture the answers).
264            x2 = max(95.0, x2)
265
266        return (x1, y1), (x2, y2)
267
268    def create_outline(self,
269            variant: quizcomp.model.quiz.Variant,
270            bounding_boxes: typing.Dict[str, typing.Dict[str, typing.Any]],
271            special_boxes: typing.Dict[str, typing.Dict[str, typing.Any]],
272            ) -> typing.Dict[str, typing.Any]:
273        """ Create GradeScope outline data. """
274
275        question_data = []
276        for (question_id, parts) in bounding_boxes.items():
277            (_, group_index, question_index) = map(int, question_id.split('.'))
278            question = variant.children[group_index].children[question_index]
279
280            if (len(parts) == 1):
281                # Single-part question.
282                question_data.append({
283                    'title': question.get_name(),
284                    'weight': question.get_points(),
285                    'crop_rect_list': list(parts.values()),
286                })
287            else:
288                # Multi-part question.
289                children = []
290                for (part_id, box) in parts.items():
291                    children.append({
292                        'title': f"{question.get_name()} - {part_id}",
293                        'weight': round(question.get_points() / len(parts), 2),
294                        'crop_rect_list': [box],
295                    })
296
297                question_data.append({
298                    'title': question.get_name(),
299                    'weight': question.get_points(),
300                    # The top-level question just needs one of the bounding boxes.
301                    'crop_rect_list': [list(parts.values())[0]],
302                    'children': children,
303                })
304
305        name_box = None
306        id_box = None
307
308        for (question_type, box) in special_boxes.items():
309            if (question_type == NAME_BOX_ID):
310                name_box = special_boxes[NAME_BOX_ID]
311            elif (question_type == ID_BOX_ID):
312                id_box = special_boxes[ID_BOX_ID]
313            else:
314                question_data.append({
315                    'title': question_type,
316                    'weight': 0,
317                    'crop_rect_list': [box],
318                })
319
320        outline = {
321            'assignment': {
322                'identification_regions': {
323                    'name': name_box,
324                    'sid': id_box
325                }
326            },
327            'question_data': question_data,
328        }
329
330        return outline
331
332    def upload(self,
333            variant: quizcomp.model.quiz.Variant,
334            base_dir: str,
335            bounding_boxes: typing.Dict[str, typing.Dict[str, typing.Any]],
336            special_boxes: typing.Dict[str, typing.Dict[str, typing.Any]],
337            ) -> typing.Tuple[str, bool]:
338        """ Upload a variant. """
339
340        path = os.path.join(base_dir, f"{variant.get_name()}.pdf")
341        if (not os.path.exists(path)):
342            raise ValueError(f"Could not find path for quiz pdf: '{path}'.")
343
344        outline = self.create_outline(variant, bounding_boxes, special_boxes)
345
346        session = requests.Session()
347
348        self.login(session)
349        _logger.debug("Logged in as '%s'.", self.user)
350
351        assignment_id = self.get_assignment_id(session, variant)
352        if (assignment_id is not None):
353            if (not self.force):
354                _logger.info("Assignment '%s' (%s) already exists. Skipping upload.", variant.get_name(), assignment_id)
355                return assignment_id, False
356
357            self.delete_assignment(session, assignment_id)
358            _logger.debug("Deleted assignment: '%s'", assignment_id)
359
360        assignment_id = self.create_assignment(session, variant, base_dir)
361        _logger.debug("Created assignment: '%s'", assignment_id)
362
363        self.submit_outline(session, assignment_id, outline)
364        _logger.debug('Submitted outline.')
365
366        if (self.rubric):
367            self.create_rubric(session, assignment_id, variant)
368            _logger.debug('Created assignment rubric.')
369
370        return assignment_id, True
371
372    def login(self, session: requests.Session) -> None:
373        """ Login to GradeScope. """
374
375        token = self.get_authenticity_token(session, URL_HOMEPAGE, action = '/login')
376
377        data = {
378            'utf8': '✓',
379            'session[email]': self.user,
380            'session[password]': self.password,
381            'session[remember_me]': 0,
382            'commit': 'Log+In',
383            'session[remember_me_sso]': 0,
384            'authenticity_token': token,
385        }
386
387        # Login.
388        response = session.post(URL_LOGIN, params = data)  # type: ignore[arg-type]
389        response.raise_for_status()
390
391    def get_authenticity_token(self, session: requests.Session, url: str, action: typing.Union[str, None] = None) -> str:
392        """ Get a token from GradeScope. """
393
394        response = session.get(url)
395        response.raise_for_status()
396
397        document = bs4.BeautifulSoup(response.text, 'html.parser')
398
399        form_selector = 'form'
400        if (action is not None):
401            form_selector = f"form[action='{action}']"
402
403        auth_input = document.select(f"{form_selector} input[name='authenticity_token']")
404        if (len(auth_input) != 1):
405            raise ValueError(f"Did not find exactly one authentication token input, found {len(auth_input)}.")
406
407        return str(auth_input[0].get('value'))
408
409    def get_csrf_token(self, session: requests.Session, url: str) -> str:
410        """ Get a CSRF token. """
411
412        # Get outline submission csrf token.
413        response = session.get(url)
414        response.raise_for_status()
415
416        document = bs4.BeautifulSoup(response.text, 'html.parser')
417        return self.parse_csrf_token(document)
418
419    def parse_csrf_token(self, document: bs4.BeautifulSoup) -> str:
420        """ Parse a CSRF token from a page. """
421
422        meta_tag = document.select('meta[name="csrf-token"]')
423        if (len(meta_tag) != 1):
424            raise ValueError(f"Did not find exactly one CSRF meta tag, found {len(meta_tag)}.")
425
426        return str(meta_tag[0].get('content'))
427
428    def get_assignment_id(self, session: requests.Session, variant: quizcomp.model.quiz.Variant) -> typing.Union[str, None]:
429        """ Get an assignment ID for the given variant, or None if there is no matching variant. """
430
431        url = URL_ASSIGNMENTS % (self.course_id)
432
433        response = session.get(url)
434        response.raise_for_status()
435
436        document = bs4.BeautifulSoup(response.text, 'html.parser')
437
438        nodes = document.select('div[data-react-class="AssignmentsTable"]')
439        if (len(nodes) != 1):
440            raise ValueError(f"Did not find exactly one assignments table, found {len(nodes)}.")
441
442        assignment_data = edq.util.json.loads(str(nodes[0].get('data-react-props')))
443
444        for row in assignment_data['table_data']:
445            if (row['type'] != 'assignment'):
446                continue
447
448            id = str(row['id']).strip().removeprefix('assignment_')
449            name = row['title'].strip()
450
451            if (name == variant.get_name()):
452                return id
453
454        return None
455
456    def delete_assignment(self, session: requests.Session, assignment_id: str) -> None:
457        """ Delete an assignment from GradeScope. """
458
459        form_url = URL_ASSIGNMENT_EDIT % (self.course_id, assignment_id)
460        token = self.get_csrf_token(session, form_url)
461
462        data = {
463            '_method': 'delete',
464            'authenticity_token': token,
465        }
466
467        delete_url = URL_ASSIGNMENT % (self.course_id, assignment_id)
468        response = session.post(delete_url, data = data)
469        response.raise_for_status()
470
471    def create_assignment(self, session: requests.Session, variant: quizcomp.model.quiz.Variant, base_dir: str) -> str:
472        """ Create an assignment in GradeScope and get its ID. """
473
474        form_url = URL_NEW_ASSIGNMENT_FORM % (self.course_id)
475        token = self.get_csrf_token(session, form_url)
476
477        data = {
478            'authenticity_token': token,
479            'assignment[title]': variant.get_name(),
480            'assignment[submissions_anonymized]': 0,
481            'assignment[student_submission]': "false",
482            'assignment[when_to_create_rubric]': 'while_grading',
483            'assignment[scoring_type]': 'negative',
484        }
485
486        path = os.path.join(base_dir, f"{variant.get_name()}.pdf")
487        files = {
488            'template_pdf': (
489                os.path.basename(path),
490                open(path, 'rb'),  # pylint: disable=consider-using-with
491            ),
492        }
493
494        create_url = URL_ASSIGNMENTS % (self.course_id)
495        response = session.post(create_url, data = data, files = files)
496        response.raise_for_status()
497
498        if (len(response.history) == 0):
499            raise ValueError(f"Failed to create assignment. Is the name ('{variant.get_name()}') unique?")
500
501        match = re.search(r'/assignments/(\d+)/outline/edit', response.history[0].text)
502        if (match is None):
503            _logger.error("--- Create Body ---\n%s\n------", response.history[0].text)
504            raise ValueError("Could not parse assignment ID from response body.")
505
506        return match.group(1)
507
508    def submit_outline(self, session: requests.Session, assignment_id: str, outline: typing.Dict[str, typing.Any]) -> None:
509        """ Submit an output to a quiz. """
510
511        edit_url = URL_EDIT_OUTLINE % (self.course_id, assignment_id)
512        csrf_token = self.get_csrf_token(session, edit_url)
513
514        headers = {
515            'Content-Type': 'application/json',
516            'x-csrf-token': csrf_token,
517        }
518
519        patch_outline_url = URL_PATCH_OUTLINE % (self.course_id, assignment_id)
520        response = session.patch(patch_outline_url,
521            data = edq.util.json.dumps(outline, separators = (',', ':')),
522            headers = headers,  # type: ignore[arg-type]
523        )
524        response.raise_for_status()
525
526    def create_rubric(self, session: requests.Session, assignment_id: str, variant: quizcomp.model.quiz.Variant) -> None:
527        """ Create rubric items for a quiz. """
528
529        questions_ids, csrf_token = self.fetch_question_ids(session, assignment_id)
530
531        for group in variant.get_groups():
532            for question in group.get_questions():
533                if (question.get_name() not in questions_ids):
534                    continue
535
536                question_ids = questions_ids[question.get_name()]
537                score = round(question.get_points() / len(question_ids), 2)
538
539                for question_id in question_ids:
540                    self.add_rubric_item(session, csrf_token, question_id, "Incorrect", score)
541
542    def add_rubric_item(self, session: requests.Session, csrf_token: str, question_id: str, description: str, score: float) -> None:
543        """ Add a single rubric item to a quiz. """
544
545        url = URL_ASSIGNMENT_ADD_RUBRIC_ITEM % (self.course_id, question_id)
546
547        headers = {
548            'Content-Type': 'application/json',
549            'x-csrf-token': csrf_token,
550        }
551
552        data = {
553            "rubric_item":{
554                "description": description,
555                "weight": str(score),
556                "group_id": None,
557            },
558        }
559
560        response = session.post(url, json = data, headers = headers)  # type: ignore[arg-type]
561        response.raise_for_status()
562
563    def fetch_question_ids(self, session: requests.Session, assignment_id: str) -> typing.Tuple[typing.Dict[str, typing.List[str]], str]:
564        """
565        Get the GradeScope id of all questions/subquestions.
566
567        Returns: {<question base name>: [question id, ...], ...}
568        """
569
570        url = URL_ASSIGNMENT_RUBRIC % (self.course_id, assignment_id)
571
572        response = session.get(url)
573        response.raise_for_status()
574
575        document = bs4.BeautifulSoup(response.text, 'html.parser')
576
577        csrf_token = self.parse_csrf_token(document)
578
579        data_tag = document.select('div[data-react-class="AssignmentRubric"]')
580        if (len(data_tag) != 1):
581            raise ValueError(f"Did not find exactly one rubric data tag, found {len(data_tag)}.")
582
583        data = edq.util.json.loads(str(data_tag[0].get('data-react-props')))
584
585        ids = {}
586        for question in data['questions']:
587            question_ids = []
588
589            children = question.get('children', None)
590            if (children is None):
591                question_ids.append(str(question['id']))
592            else:
593                question_ids = [str(child['id']) for child in children]
594
595            ids[question['title']] = question_ids
596
597        return ids, csrf_token

Upload quizzes to GradeScope.

GradeScopeUploader( course_id: str, user: str, password: str, force: bool = False, rubric: bool = False, save_http: bool = False, **kwargs: Any)
70    def __init__(self,
71            course_id: str,
72            user: str,
73            password: str,
74            force: bool = False,
75            rubric: bool = False,
76            save_http: bool = False,
77            **kwargs: typing.Any) -> None:
78        super().__init__(**kwargs)
79
80        self.course_id: str = course_id
81        """ The GradeScope course ID. """
82
83        self.user: str = user
84        """ The GradeScope user/email to authenticate with. """
85
86        self.password: str = password
87        """ The GradeScope password to authenticate with. """
88
89        self.force: bool = force
90        """ Whether to remove existing quizzes before upload. """
91
92        self.rubric: bool = rubric
93        """ Whether to upload the rubric items for the quiz. """
94
95        self.save_http = save_http
96        """ Whether to save the HTTP exchanges to disk. """
course_id: str

The GradeScope course ID.

user: str

The GradeScope user/email to authenticate with.

password: str

The GradeScope password to authenticate with.

force: bool

Whether to remove existing quizzes before upload.

rubric: bool

Whether to upload the rubric items for the quiz.

save_http

Whether to save the HTTP exchanges to disk.

def upload_quiz( self, variant: quizcomp.model.quiz.Variant, base_dir: Optional[str] = None, **kwargs: Any) -> Tuple[str, bool]:
 98    def upload_quiz(self,
 99            variant: quizcomp.model.quiz.Variant,
100            base_dir: typing.Union[str, None] = None,
101            **kwargs: typing.Any,
102            ) -> typing.Tuple[str, bool]:
103        """
104        Compile a quiz and upload it to GradeScope.
105
106        If base_dir is left None, then a temp dir will be created which will be destroyed on exit.
107        If supplied, the base_dir will be left alone when finished.
108        """
109
110        if (not isinstance(variant, quizcomp.model.quiz.Variant)):
111            raise ValueError(f"GradeScope quiz uploader requires a quizcomp.model.quiz.Variant type, found {type(variant)}.")
112
113        if (base_dir is None):
114            base_dir = edq.util.dirent.get_temp_path(prefix = 'quizcomp-gradescope-')
115
116        boxes, special_boxes = self.get_bounding_boxes(variant, base_dir)
117        return self.upload(variant, base_dir, boxes, special_boxes)

Compile a quiz and upload it to GradeScope.

If base_dir is left None, then a temp dir will be created which will be destroyed on exit. If supplied, the base_dir will be left alone when finished.

def create_assignment_group(self, title: str, gradescope_ids: List[str]) -> None:
119    def create_assignment_group(self, title: str, gradescope_ids: typing.List[str]) -> None:
120        """ Create an assignment group with the given IDs. """
121
122        session = requests.Session()
123
124        self.login(session)
125
126        assignments_url = URL_ASSIGNMENTS % (self.course_id)
127        csrf_token = self.get_csrf_token(session, assignments_url)
128
129        headers = {
130            'x-csrf-token': csrf_token,
131        }
132
133        post_url = URL_ASSIGNMENT_GROUP % (self. course_id)
134        data = {
135            'title': title,
136            'assignment_ids[]': gradescope_ids,
137        }
138
139        response = session.post(post_url, params = data, headers = headers)  # type: ignore[arg-type]
140        response.raise_for_status()

Create an assignment group with the given IDs.

def get_bounding_boxes( self, variant: quizcomp.model.quiz.Variant, base_dir: str) -> Tuple[Dict[str, Dict[str, Any]], Dict[str, Dict[str, Any]]]:
142    def get_bounding_boxes(self,
143            variant: quizcomp.model.quiz.Variant,
144            base_dir: str,
145            ) -> typing.Tuple[typing.Dict[str, typing.Dict[str, typing.Any]], typing.Dict[str, typing.Dict[str, typing.Any]]]:
146        """ Get bounding boxes for answers. """
147
148        # {<quetion id>: {<part id>: box, ...}, ...}
149        boxes: typing.Dict[str, typing.Dict[str, typing.Any]] = {}
150        # {NAME_BOX_ID: box, ID_BOX_ID: box, SIGNATURE_BOX_ID: box}
151        special_boxes: typing.Dict[str, typing.Dict[str, typing.Any]] = {}
152
153        path = os.path.join(base_dir, f"{variant.get_name()}.pos")
154        if (not os.path.exists(path)):
155            raise ValueError(f"Could not find path for quiz bounding boxes: '{path}'.")
156
157        with open(path, 'r', encoding = edq.util.dirent.DEFAULT_ENCODING) as file:
158            for line in file:
159                line = line.strip()
160                if (line == ""):
161                    continue
162
163                parts = [part.strip() for part in line.split(',')]
164                if (len(parts) != 12):
165                    raise ValueError(f"Position file has row with bad number of parts. Expecting 11, found {len(parts)}.")
166
167                # "ll" == "lower-left"
168                # "ur" == "upper-right"
169                (question_id, part_id, _, raw_question_type, raw_page_number, ll_x, ll_y, ur_x, ur_y, page_width, page_height, origin) = parts
170
171                if (origin != 'bottom-left'):
172                    raise ValueError(f"Unknown bounding box origin: '{origin}'.")
173
174                # Note that the position file and GradeScope use 1-indexed pages.
175                page_number = int(raw_page_number)
176
177                if (raw_question_type not in BOX_TYPES):
178                    raise ValueError(f"Unknown content type: '{raw_question_type}'.")
179
180                extend_box_right = False
181                if (raw_question_type in EXTEND_BOX_QUESTION_TYPES):
182                    extend_box_right = True
183
184                    # There is a special case for inline MA questions.
185                    if (raw_question_type == quizcomp.model.constants.QuestionType.MA.value):
186                        extend_box_right = False
187
188                (x1, y1), (x2, y2) = self._compute_box(ll_x, ll_y, ur_x, ur_y, page_width, page_height, extend_box_right = extend_box_right)
189
190                if (raw_question_type in SPECIAL_QUESTION_TYPES):
191                    # These boxes are special.
192                    if (raw_question_type in special_boxes):
193                        raise ValueError(f"Multiple {raw_question_type} bounding boxes found.")
194
195                    special_boxes[raw_question_type] = {
196                        'page_number': page_number,
197                        'x1': x1,
198                        'y1': y1,
199                        'x2': x2,
200                        'y2': y2,
201                    }
202
203                    continue
204
205                if (question_id not in boxes):
206                    boxes[question_id] = {}
207
208                # If there is an existing box, extend it.
209                if (part_id in boxes[question_id]):
210                    old_box = boxes[question_id][part_id]
211
212                    old_x1 = old_box['x1']
213                    old_y1 = old_box['y1']
214                    old_x2 = old_box['x2']
215                    old_y2 = old_box['y2']
216
217                    old_page = old_box['page_number']
218                    if (old_page != page_number):
219                        raise ValueError(f"Question {question_id} has bounding boxes that span pages.")
220
221                    x1 = min(x1, old_x1)
222                    y1 = min(y1, old_y1)
223                    x2 = max(x2, old_x2)
224                    y2 = max(y2, old_y2)
225
226                boxes[question_id][part_id] = {
227                    'page_number': page_number,
228                    'x1': x1,
229                    'y1': y1,
230                    'x2': x2,
231                    'y2': y2,
232                }
233
234        return boxes, special_boxes

Get bounding boxes for answers.

def create_outline( self, variant: quizcomp.model.quiz.Variant, bounding_boxes: Dict[str, Dict[str, Any]], special_boxes: Dict[str, Dict[str, Any]]) -> Dict[str, Any]:
268    def create_outline(self,
269            variant: quizcomp.model.quiz.Variant,
270            bounding_boxes: typing.Dict[str, typing.Dict[str, typing.Any]],
271            special_boxes: typing.Dict[str, typing.Dict[str, typing.Any]],
272            ) -> typing.Dict[str, typing.Any]:
273        """ Create GradeScope outline data. """
274
275        question_data = []
276        for (question_id, parts) in bounding_boxes.items():
277            (_, group_index, question_index) = map(int, question_id.split('.'))
278            question = variant.children[group_index].children[question_index]
279
280            if (len(parts) == 1):
281                # Single-part question.
282                question_data.append({
283                    'title': question.get_name(),
284                    'weight': question.get_points(),
285                    'crop_rect_list': list(parts.values()),
286                })
287            else:
288                # Multi-part question.
289                children = []
290                for (part_id, box) in parts.items():
291                    children.append({
292                        'title': f"{question.get_name()} - {part_id}",
293                        'weight': round(question.get_points() / len(parts), 2),
294                        'crop_rect_list': [box],
295                    })
296
297                question_data.append({
298                    'title': question.get_name(),
299                    'weight': question.get_points(),
300                    # The top-level question just needs one of the bounding boxes.
301                    'crop_rect_list': [list(parts.values())[0]],
302                    'children': children,
303                })
304
305        name_box = None
306        id_box = None
307
308        for (question_type, box) in special_boxes.items():
309            if (question_type == NAME_BOX_ID):
310                name_box = special_boxes[NAME_BOX_ID]
311            elif (question_type == ID_BOX_ID):
312                id_box = special_boxes[ID_BOX_ID]
313            else:
314                question_data.append({
315                    'title': question_type,
316                    'weight': 0,
317                    'crop_rect_list': [box],
318                })
319
320        outline = {
321            'assignment': {
322                'identification_regions': {
323                    'name': name_box,
324                    'sid': id_box
325                }
326            },
327            'question_data': question_data,
328        }
329
330        return outline

Create GradeScope outline data.

def upload( self, variant: quizcomp.model.quiz.Variant, base_dir: str, bounding_boxes: Dict[str, Dict[str, Any]], special_boxes: Dict[str, Dict[str, Any]]) -> Tuple[str, bool]:
332    def upload(self,
333            variant: quizcomp.model.quiz.Variant,
334            base_dir: str,
335            bounding_boxes: typing.Dict[str, typing.Dict[str, typing.Any]],
336            special_boxes: typing.Dict[str, typing.Dict[str, typing.Any]],
337            ) -> typing.Tuple[str, bool]:
338        """ Upload a variant. """
339
340        path = os.path.join(base_dir, f"{variant.get_name()}.pdf")
341        if (not os.path.exists(path)):
342            raise ValueError(f"Could not find path for quiz pdf: '{path}'.")
343
344        outline = self.create_outline(variant, bounding_boxes, special_boxes)
345
346        session = requests.Session()
347
348        self.login(session)
349        _logger.debug("Logged in as '%s'.", self.user)
350
351        assignment_id = self.get_assignment_id(session, variant)
352        if (assignment_id is not None):
353            if (not self.force):
354                _logger.info("Assignment '%s' (%s) already exists. Skipping upload.", variant.get_name(), assignment_id)
355                return assignment_id, False
356
357            self.delete_assignment(session, assignment_id)
358            _logger.debug("Deleted assignment: '%s'", assignment_id)
359
360        assignment_id = self.create_assignment(session, variant, base_dir)
361        _logger.debug("Created assignment: '%s'", assignment_id)
362
363        self.submit_outline(session, assignment_id, outline)
364        _logger.debug('Submitted outline.')
365
366        if (self.rubric):
367            self.create_rubric(session, assignment_id, variant)
368            _logger.debug('Created assignment rubric.')
369
370        return assignment_id, True

Upload a variant.

def login(self, session: requests.sessions.Session) -> None:
372    def login(self, session: requests.Session) -> None:
373        """ Login to GradeScope. """
374
375        token = self.get_authenticity_token(session, URL_HOMEPAGE, action = '/login')
376
377        data = {
378            'utf8': '✓',
379            'session[email]': self.user,
380            'session[password]': self.password,
381            'session[remember_me]': 0,
382            'commit': 'Log+In',
383            'session[remember_me_sso]': 0,
384            'authenticity_token': token,
385        }
386
387        # Login.
388        response = session.post(URL_LOGIN, params = data)  # type: ignore[arg-type]
389        response.raise_for_status()

Login to GradeScope.

def get_authenticity_token( self, session: requests.sessions.Session, url: str, action: Optional[str] = None) -> str:
391    def get_authenticity_token(self, session: requests.Session, url: str, action: typing.Union[str, None] = None) -> str:
392        """ Get a token from GradeScope. """
393
394        response = session.get(url)
395        response.raise_for_status()
396
397        document = bs4.BeautifulSoup(response.text, 'html.parser')
398
399        form_selector = 'form'
400        if (action is not None):
401            form_selector = f"form[action='{action}']"
402
403        auth_input = document.select(f"{form_selector} input[name='authenticity_token']")
404        if (len(auth_input) != 1):
405            raise ValueError(f"Did not find exactly one authentication token input, found {len(auth_input)}.")
406
407        return str(auth_input[0].get('value'))

Get a token from GradeScope.

def get_csrf_token(self, session: requests.sessions.Session, url: str) -> str:
409    def get_csrf_token(self, session: requests.Session, url: str) -> str:
410        """ Get a CSRF token. """
411
412        # Get outline submission csrf token.
413        response = session.get(url)
414        response.raise_for_status()
415
416        document = bs4.BeautifulSoup(response.text, 'html.parser')
417        return self.parse_csrf_token(document)

Get a CSRF token.

def parse_csrf_token(self, document: bs4.BeautifulSoup) -> str:
419    def parse_csrf_token(self, document: bs4.BeautifulSoup) -> str:
420        """ Parse a CSRF token from a page. """
421
422        meta_tag = document.select('meta[name="csrf-token"]')
423        if (len(meta_tag) != 1):
424            raise ValueError(f"Did not find exactly one CSRF meta tag, found {len(meta_tag)}.")
425
426        return str(meta_tag[0].get('content'))

Parse a CSRF token from a page.

def get_assignment_id( self, session: requests.sessions.Session, variant: quizcomp.model.quiz.Variant) -> Optional[str]:
428    def get_assignment_id(self, session: requests.Session, variant: quizcomp.model.quiz.Variant) -> typing.Union[str, None]:
429        """ Get an assignment ID for the given variant, or None if there is no matching variant. """
430
431        url = URL_ASSIGNMENTS % (self.course_id)
432
433        response = session.get(url)
434        response.raise_for_status()
435
436        document = bs4.BeautifulSoup(response.text, 'html.parser')
437
438        nodes = document.select('div[data-react-class="AssignmentsTable"]')
439        if (len(nodes) != 1):
440            raise ValueError(f"Did not find exactly one assignments table, found {len(nodes)}.")
441
442        assignment_data = edq.util.json.loads(str(nodes[0].get('data-react-props')))
443
444        for row in assignment_data['table_data']:
445            if (row['type'] != 'assignment'):
446                continue
447
448            id = str(row['id']).strip().removeprefix('assignment_')
449            name = row['title'].strip()
450
451            if (name == variant.get_name()):
452                return id
453
454        return None

Get an assignment ID for the given variant, or None if there is no matching variant.

def delete_assignment(self, session: requests.sessions.Session, assignment_id: str) -> None:
456    def delete_assignment(self, session: requests.Session, assignment_id: str) -> None:
457        """ Delete an assignment from GradeScope. """
458
459        form_url = URL_ASSIGNMENT_EDIT % (self.course_id, assignment_id)
460        token = self.get_csrf_token(session, form_url)
461
462        data = {
463            '_method': 'delete',
464            'authenticity_token': token,
465        }
466
467        delete_url = URL_ASSIGNMENT % (self.course_id, assignment_id)
468        response = session.post(delete_url, data = data)
469        response.raise_for_status()

Delete an assignment from GradeScope.

def create_assignment( self, session: requests.sessions.Session, variant: quizcomp.model.quiz.Variant, base_dir: str) -> str:
471    def create_assignment(self, session: requests.Session, variant: quizcomp.model.quiz.Variant, base_dir: str) -> str:
472        """ Create an assignment in GradeScope and get its ID. """
473
474        form_url = URL_NEW_ASSIGNMENT_FORM % (self.course_id)
475        token = self.get_csrf_token(session, form_url)
476
477        data = {
478            'authenticity_token': token,
479            'assignment[title]': variant.get_name(),
480            'assignment[submissions_anonymized]': 0,
481            'assignment[student_submission]': "false",
482            'assignment[when_to_create_rubric]': 'while_grading',
483            'assignment[scoring_type]': 'negative',
484        }
485
486        path = os.path.join(base_dir, f"{variant.get_name()}.pdf")
487        files = {
488            'template_pdf': (
489                os.path.basename(path),
490                open(path, 'rb'),  # pylint: disable=consider-using-with
491            ),
492        }
493
494        create_url = URL_ASSIGNMENTS % (self.course_id)
495        response = session.post(create_url, data = data, files = files)
496        response.raise_for_status()
497
498        if (len(response.history) == 0):
499            raise ValueError(f"Failed to create assignment. Is the name ('{variant.get_name()}') unique?")
500
501        match = re.search(r'/assignments/(\d+)/outline/edit', response.history[0].text)
502        if (match is None):
503            _logger.error("--- Create Body ---\n%s\n------", response.history[0].text)
504            raise ValueError("Could not parse assignment ID from response body.")
505
506        return match.group(1)

Create an assignment in GradeScope and get its ID.

def submit_outline( self, session: requests.sessions.Session, assignment_id: str, outline: Dict[str, Any]) -> None:
508    def submit_outline(self, session: requests.Session, assignment_id: str, outline: typing.Dict[str, typing.Any]) -> None:
509        """ Submit an output to a quiz. """
510
511        edit_url = URL_EDIT_OUTLINE % (self.course_id, assignment_id)
512        csrf_token = self.get_csrf_token(session, edit_url)
513
514        headers = {
515            'Content-Type': 'application/json',
516            'x-csrf-token': csrf_token,
517        }
518
519        patch_outline_url = URL_PATCH_OUTLINE % (self.course_id, assignment_id)
520        response = session.patch(patch_outline_url,
521            data = edq.util.json.dumps(outline, separators = (',', ':')),
522            headers = headers,  # type: ignore[arg-type]
523        )
524        response.raise_for_status()

Submit an output to a quiz.

def create_rubric( self, session: requests.sessions.Session, assignment_id: str, variant: quizcomp.model.quiz.Variant) -> None:
526    def create_rubric(self, session: requests.Session, assignment_id: str, variant: quizcomp.model.quiz.Variant) -> None:
527        """ Create rubric items for a quiz. """
528
529        questions_ids, csrf_token = self.fetch_question_ids(session, assignment_id)
530
531        for group in variant.get_groups():
532            for question in group.get_questions():
533                if (question.get_name() not in questions_ids):
534                    continue
535
536                question_ids = questions_ids[question.get_name()]
537                score = round(question.get_points() / len(question_ids), 2)
538
539                for question_id in question_ids:
540                    self.add_rubric_item(session, csrf_token, question_id, "Incorrect", score)

Create rubric items for a quiz.

def add_rubric_item( self, session: requests.sessions.Session, csrf_token: str, question_id: str, description: str, score: float) -> None:
542    def add_rubric_item(self, session: requests.Session, csrf_token: str, question_id: str, description: str, score: float) -> None:
543        """ Add a single rubric item to a quiz. """
544
545        url = URL_ASSIGNMENT_ADD_RUBRIC_ITEM % (self.course_id, question_id)
546
547        headers = {
548            'Content-Type': 'application/json',
549            'x-csrf-token': csrf_token,
550        }
551
552        data = {
553            "rubric_item":{
554                "description": description,
555                "weight": str(score),
556                "group_id": None,
557            },
558        }
559
560        response = session.post(url, json = data, headers = headers)  # type: ignore[arg-type]
561        response.raise_for_status()

Add a single rubric item to a quiz.

def fetch_question_ids( self, session: requests.sessions.Session, assignment_id: str) -> Tuple[Dict[str, List[str]], str]:
563    def fetch_question_ids(self, session: requests.Session, assignment_id: str) -> typing.Tuple[typing.Dict[str, typing.List[str]], str]:
564        """
565        Get the GradeScope id of all questions/subquestions.
566
567        Returns: {<question base name>: [question id, ...], ...}
568        """
569
570        url = URL_ASSIGNMENT_RUBRIC % (self.course_id, assignment_id)
571
572        response = session.get(url)
573        response.raise_for_status()
574
575        document = bs4.BeautifulSoup(response.text, 'html.parser')
576
577        csrf_token = self.parse_csrf_token(document)
578
579        data_tag = document.select('div[data-react-class="AssignmentRubric"]')
580        if (len(data_tag) != 1):
581            raise ValueError(f"Did not find exactly one rubric data tag, found {len(data_tag)}.")
582
583        data = edq.util.json.loads(str(data_tag[0].get('data-react-props')))
584
585        ids = {}
586        for question in data['questions']:
587            question_ids = []
588
589            children = question.get('children', None)
590            if (children is None):
591                question_ids.append(str(question['id']))
592            else:
593                question_ids = [str(child['id']) for child in children]
594
595            ids[question['title']] = question_ids
596
597        return ids, csrf_token

Get the GradeScope id of all questions/subquestions.

Returns: {: [question id, ...], ...}