Source code for uw.local.teaching.webui.upload

"""Assessment PDF master upload UI.

This implements the UI for uploading assessment PDF masters, including the
upload form, processing of the upload, link to editing for permitted aids,
approval and un-approval, pre-stamping and re-stamping of uploaded masters, and
download of master PDFs by authorized individuals.
"""

import datetime, psycopg2, tempfile, shutil
from operator import attrgetter
from pathlib import Path
from subprocess import Popen, PIPE, STDOUT

from ll.xist.ns import html

from uw.web.wsgi.delegate import *
from uw.web.wsgi.form import use_form_param, parse_form_value
from uw.web.wsgi.function import return_html, return_pdf, return_png

from uw.web.html.form import render_checkbox, render_hidden, render_select
from uw.web.html.format import make_table_format, format_email, format_return, format_datetime, format_date
from uw.web.html.join import html_join

from ...util.identity import use_remote_identity
from ..db.aids import ExamAidsEditor
from ..db.exam_print import submit_exam_print_job, paper_colours
from ..db.pdf import embed_fonts
from .delegate import exam_delegate
from .ui import format_duration
from .aids_edit import exam_aids_editor
from .exam_render import format_exam_timeline
from .exam_edit import exam_may_edit_cover
from .exam_version import version_assignment_section, allocate_edit_handler, version_edit_handler, allocate_delete_handler
from .integration import format_integration

@use_remote_identity
@return_html
def upload_index (cursor, remote_identity):
    """Assessment author upload index page.

    Displays an HTML page showing all future assessments for which the
    user is the designated assessment author.
    """
    result = [format_return ('Main Menu')]

    exams = cursor.execute_tuples ("select exam_id, full_title, times_formatted, duration_formatted, admin_sections, admin_instructors from exam_exam_plus where exam_author_person_id = %(person_id)s and primary_start_time > now () order by primary_start_time desc", person_id=remote_identity.person_id)

    if exams:
        result.append (html.p ("You are authorized to upload the following assessment master:"))

        for exam in exams:
            result.append (html.table (
                html.tr (html.td ('Assessment:'),
                 html.td (html.a (exam.full_title, href="%d/" % exam.exam_id))),
                html.tr (html.td ('Times:'),
                 html.td (exam.times_formatted)),
                html.tr (html.td ('Duration:'),
                 html.td (exam.duration_formatted)),
                html.tr (html.td ('Sections:'),
                 html.td (exam.admin_sections)),
                html.tr (html.td ('Instructors:'),
                 html.td (exam.admin_instructors)),
            ))
    else:
        result.append (html.p ("You are not authorized to upload any assessment masters."))

    return 'Upload Assessment Masters', result

duplex_format = {
    None: 'None',
    False: 'Single-sided',
    True: 'Double-sided',
}

duplex_choices = dict ((str (v)[0], v) for v in [None, False, True])

[docs]def plex_form (exam, papers_duplex, type_code): """Render a single-button HTML form for setting the plexing of a master. :param exam: a database row representing an assessment. :param bool duplex: whether this form is for setting double-sided. If the masters have been approved, the form should not appear. If the plexing has already been set to the value indicated by duplex, again the form should not appear. In both cases, return None. """ if exam.master_approved is not None: return None result = [] for duplex in [None, False, True] if type_code == 'A' else [None, True]: if duplex is not papers_duplex.get (type_code): result.append (html.form ( render_hidden ("type_code", type_code), render_hidden ("choice", str (duplex)[0]), html.input (type="submit", name="!paper", value="Set to %s!" % duplex_format[duplex]), method="post", action="", style="display: inline" )) return html_join (result, sep=' ')
[docs]def crowdmark_format_mc_page_count (pages): if pages: return '%d page%s (%d questions)' % (pages, 's' if pages > 1 else '', pages * 100) else: return 'None'
@use_remote_identity @return_html def upload_get_handler (cursor, remote_identity, exam): """Upload form GET handler. Displays the current state of assessment masters for the assessment, along with forms to make whatever changes are currently allowed. Assumes the current user has been checked for authorization as uploader. """ errors = [] result = [format_return ('Main Menu', 'Upload Index')] result.append (html.p (html.a ('Return to main page for assessment', href="../../term/%s/%s/exam/%s/" % (exam.term_id, exam.admin_id, exam.exam_id)))) ro_deadline = cursor.callproc_required_value ("exam_print_deadline_find_deadline_date", exam.exam_id) if ro_deadline: result.append (html.p ("Your submission deadline is %s. Please ensure that you have uploaded and approved your masters by the end of the day. Late submission will result in your department being charged and the printed papers will be delivered to your department instead of being made available at the pickup locations. Please check with your department if you are considering a late submission." % (format_date (ro_deadline)))) result.append (format_exam_timeline (cursor, exam)) result.append (html.h3 ('Verify Cover Page Information')) result.append (html.p ('Please check that the following assessment details are correct:')) if exam.primary_start_time is None and exam.schedule_admin_id is None: errors.append ('Assessment start time is specified') if exam.duration_formatted is None: errors.append ('Assessment duration is specified') print_account = cursor.callproc_required_value ("exam_print_account", exam.exam_id) if print_account is None: errors.append ('Printing account information needs to be set up for this offering') aids_html = ExamAidsEditor (cursor, exam).render_aids () if aids_html is None: errors.append ('Permitted Aids are specified') if exam_may_edit_cover (exam): aids_link = html.a ('Edit…', href="aids-edit") crowdmark_mc_form = html.form ( html.br (), 'Update: ', render_select ("crowdmark_mc_pages", [(r, crowdmark_format_mc_page_count (r)) for r in range (0, 3)], exam.mc_pages, blank=True), html.input (type="submit", name="!mc", value="Change!"), method="post", action="" ) else: aids_link = None crowdmark_mc_form = None result.append (html.table ( html.tr (html.th ('Assessment:'), html.td (exam.full_title)), html.tr (html.th ('Times:'), html.td (exam.times_formatted)), html.tr (html.th ('Duration:'), html.td (exam.duration_formatted)), html.tr (html.th ('Sections:'), html.td (exam.admin_sections)), html.tr (html.th ('Instructors:'), html.td (exam.admin_instructors)), html.tr (html.th ('Permitted Aids & Special Instructions:'), html.td (aids_html, aids_link)), html.tr (html.th ('Master Versions:'), html.td (exam.master_count)), html.tr (html.th ('Integration:'), html.td (format_integration (exam))), html.tr ( html.th ('Crowdmark Multiple Choice:'), html.td ( 'Number of multiple choice pages has not been set' if exam.mc_pages is None else crowdmark_format_mc_page_count (exam.mc_pages), crowdmark_mc_form ) ) if exam.is_crowdmark else None, )) result.append (html.h3 ('Select Examination Booklet Types')) result.append (html.p ('Your examination may have up to 3 separate booklets of different types, for different purposes. Please use the buttons in the “Change” column to select the ones that you will be using:')) masters = cursor.exam_masters_by_exam (exam_id=exam.exam_id) papers_enabled = set (em.type_code for em in masters) papers_duplex = dict ((tc, tc != 'A' or exam.master_duplex) for tc in papers_enabled) purpose = { 'A': 'Provide space for candidates to write their answers; may also include questions', 'Q': 'Distribute questions to candidates separately from answer booklet', 'R': 'Distribute additional information to candidates', } result.append (make_table_format ( ('Booklet', attrgetter ('type_description')), ('Your Choice', lambda r: duplex_format[papers_duplex.get (r.type_code)]), ('Change', lambda r: plex_form (exam, papers_duplex, r.type_code)), ('Purpose', lambda r: purpose[r.type_code]), ('Individualized', lambda r: 'Yes' if r.type_code != 'R' else 'No'), ('To Be Marked', lambda r: 'Yes' if r.type_code == 'A' else 'No'), ('Paper Colour', lambda r: paper_colours[r.type_code]), ) (cursor.execute_tuples ("select * from exam_master_type where type_code <> 'C' order by type_code"))) if (exam.master_duplex != True) and exam.is_crowdmark: result.append (html.div (html.b ("Note:"), " Assessment uses crowdmark and Answers plexing is not set to double-sided", class_="alert alert-warning")) result.append (html.h3 ('Upload and Approve PDF Examination Masters')) table = html.table () missing_master = False overlength_master = False for exam_master in masters: pdf_existing = bool (exam_master.master_pdf_raw) tr = html.tr () tr.append (html.td ( exam_master.master_description )) if not pdf_existing: tr.append (html.td ()) missing_master = True elif exam_master.master_pages_net is None: tr.append (html.td ('Stamped PDF Details Missing')) missing_master = True else: max_pages = 60 * (2 if papers_duplex.get (exam_master.type_code) else 1) if exam_master.master_pages_gross is not None and exam_master.master_pages_gross > max_pages: overlength_master = True overlength_warning = html.span ( ' Overlength (maximum %d pages)!' % max_pages, style="color: red;") else: overlength_warning = None tr.append (html.td ( html.a ('Download PDF', href="download?type_code=%s&version_seq=%s" % (exam_master.type_code, exam_master.version_seq)), ' (', html_join ([ '%i pages' % exam_master.master_pages_gross, exam_master.size_description], sep=', '), ')', overlength_warning, )) if not exam.master_approved: tr.append (html.td ( 'Replace PDF Master: ' if pdf_existing else 'Upload PDF Master:', html.form ( render_hidden ("type_code", exam_master.type_code), render_hidden ("version_seq", exam_master.version_seq), html.input (type="file", name="pdf", accept="application/pdf"), ' ', html.input (type="submit", name="!upload", value="Upload!"), method="post", enctype="multipart/form-data", action="", ) )) if pdf_existing: delete = html.form ( render_hidden ("type_code", exam_master.type_code), render_hidden ("version_seq", exam_master.version_seq), html.input (type="submit", name="!delete", value="Delete!"), method="post", action="", ) else: delete = None tr.append (html.td (delete)) table.append (tr) if table: result.append (table) else: errors.append ('Booklet types are selected') if missing_master: errors.append ('All PDF masters are uploaded') if overlength_master: errors.append ('All overlength masters replaced with shorter masters') if exam.scanning_integration != 'N': if not 'A' in papers_enabled: errors.append ('Answers paper is enabled') if exam.scanning_integration == 'C' and exam.mc_pages is None: errors.append ('Crowdmark multiple choice pages are selected') if exam.master_approved: if exam.master_accepted: result.append (html.p ('The assessment master has been approved for production and sent for printing.')) else: result.append (html.p ('The assessment master has been approved for production but has not been sent for printing. You may still “un-approve” the PDF masters if you need to make more changes.')) result.append (html.p ('Un-approve PDF masters for production: ', html.a ('Un-approve…', href="approve/"))) elif errors: result.append (html.p ('The masters cannot be approved until:')) result.append (html.ul (html.li (error) for error in errors)) else: result.append (html.p ('Approve PDF masters for production: ', html.a ('Approve…', href="approve/"))) ## Exam version assignment section if exam.master_count > 1: result.append (html.h2 ('Versions')) if exam.sequence_assigned is None: result.append (html.p (html.a ('Edit…', href="allocate-edit"))) result.append (version_assignment_section (cursor, exam, edit=False)) result.append (html.p ( html.a ('Assessment master preparation instructions', href="https://uwaterloo.ca/odyssey/instruct/exams/preparation"), ' are available. ', 'Please contact ', format_email ('odyssey@uwaterloo.ca'), ' with any questions or concerns.' )) return 'Upload Assessment Masters', result
[docs]def restamp_master (exam_id, type_code, version_seq, cursor): """Stamp or re-stamp PDF and save stamped edition. :param exam_id: The ID of the assessment. :param type_code: The type code of the master to stamp. :param version_seq: The version sequence of the master to stamp. :param cursor: A database cursor. Invoke the Java process to obtain the unstamped master from the database and save the stamped version back to the database. Also converts the master to individual images and inserts it to the database. Report an HTTP Internal Server Error and show the Java output in the event of any problem. """ restamp_process = Popen (['java', 'ca.uwaterloo.odyssey.exams.restamp', str (exam_id), type_code, str (version_seq)], stdin=PIPE, stdout=PIPE, stderr=STDOUT) restamp_process.stdin.close () result = restamp_process.wait () if result != 0: result = [] out = restamp_process.stdout.read ().decode () if 'Non-permitted page size' in out: result.append (html.p ('Please upload a letter-size PDF document. Contact ', format_email ('odyssey@uwaterloo.ca'), ' for more assistance.')) else: result.append (html.p ('There was a problem attempting to restamp your submitted assessment master. Please contact ', format_email ('odyssey@uwaterloo.ca'), ' for more assistance.')) result.append (html.a (html.i (class_='fa fa-plus'), " Details", href="#uploadStackTrace", class_='collapse-button', style='padding:0;margin:1.5ex 0')) result.append (html.pre (out, id='uploadStackTrace', class_='collapse')) raise status.HTTPInternalServerError (body=result) dirpath = tempfile.mkdtemp () exam_master = cursor.execute_required_tuple ("select eem.master_pdf, eemp.master_pages_gross from exam_exam_master eem join exam_exam_master_plus eemp using (exam_id, type_code, version_seq) where (exam_id, type_code, version_seq) = (%(exam_id)s, %(type_code)s, %(version_seq)s)", exam_id=exam_id, type_code=type_code, version_seq=version_seq) gs_process = Popen (['gs', '-sDEVICE=png16m', '-dTextAlphaBits=4', '-dNOPAUSE', '-dBATCH', '-dQUIET', '-r300', '-sOutputFile=' + dirpath + '/master_%d.png', '-', '-'], stdin=PIPE, stdout=PIPE, stderr=STDOUT) gs_process.stdin.write (exam_master.master_pdf) gs_process.stdin.close () result = gs_process.wait () if result != 0: result = [] result.append (html.p ('There was a problem attempting to generate thumbnails for your submitted assessment master. Please contact ', format_email ('odyssey@uwaterloo.ca'), ' for more assistance.')) result.append (html.pre (line for line in gs_process.stdout)) raise status.HTTPInternalServerError (body=result) params = { 'exam_id': exam_id, 'type_code': type_code, 'version_seq': version_seq, } cursor.execute_none ("delete from exam_exam_master_thumbnail where (exam_id, type_code, version_seq) = (%(exam_id)s, %(type_code)s, %(version_seq)s)", **params) basedir = Path (dirpath) for i in range (1, exam_master.master_pages_gross + 1): filename = "master_%d.png" % i params['master_page'] = i, params['master_thumbnail'] = (basedir / filename).read_bytes () cursor.execute_none ("insert into exam_exam_master_thumbnail select %(exam_id)s, %(type_code)s, %(version_seq)s, %(master_page)s, %(master_thumbnail)s", **params) shutil.rmtree(dirpath)
[docs]def report_process_error (stderr): """Report an error with processing a submitted assessment master. :param stderr: The error output of assessment master processing. Returns an HTML page intended to report a problem, including the contents of the provided stderr output from the PDF processing. """ result = [] result.append (html.p ('There was a problem attempting to process your submitted assessment master. Please contact ', format_email ('odyssey@uwaterloo.ca'), ' for more assistance.')) result.append (html.pre (stderr.decode ())) return 'Error Uploading Assessment Master', result
@use_form_param @use_remote_identity @return_html def upload_post_handler (cursor, remote_identity, exam, form): """Upload form POST handler. Handles the following form actions: - Print (trigger printing manually) - Update (upload new master, adjust duplexing, delete old master) """ if '!paper' in form: type_code = form.required_field_value ('type_code') choice = duplex_choices[form.required_field_value ('choice')] cursor.callproc_none ("exam_exam_master_choose", exam.exam_id, type_code, choice) cursor.callproc_none ("exam_print_clear_stamped", exam.exam_id) elif '!upload' in form: type_code = form.optional_field_value ('type_code') version_seq = form.optional_field_value ('version_seq') if not version_seq: version_seq = cursor.execute_required_value ("select coalesce (max (version_seq) + 1, 0)::text from exam_exam_master where (exam_id, type_code) = (%(exam_id)s, %(type_code)s)", exam_id=exam.exam_id, type_code=type_code) pdf = form.optional_field ('pdf') if pdf is not None and (pdf.filename or pdf.value): pdf = pdf.value # Stamp PDF and save raw and stamped PDFs cursor.callproc_none ("exam_print_clear_stamped", exam.exam_id) cursor.callproc_none ("exam_exam_master_upload_raw", exam.exam_id, type_code, version_seq, pdf, remote_identity.person_id) cursor.connection.commit () success, result = embed_fonts (pdf) if not success: return report_process_error (result) cursor.execute_none ("update exam_exam_master set master_pdf_unstamped = %(pdf)s where (exam_id, type_code, version_seq) = (%(exam_id)s, %(type_code)s, %(version_seq)s)", exam_id=exam.exam_id, type_code=type_code, version_seq=version_seq, pdf=result) cursor.connection.commit () # Ensure that stamping actually works with this PDF restamp_master (exam.exam_id, type_code, version_seq, cursor) else: result = html.p ('No PDF was submitted. Please go back and try again. ', html.a ('Go back.', href="javascript:history.go(-1)")) return 'No Assessment Master Supplied', result elif '!delete' in form: type_code = form.optional_field_value ('type_code') version_seq = form.optional_field_value ('version_seq') cursor.callproc_none ("exam_exam_master_delete", exam.exam_id, type_code, version_seq) cursor.callproc_none ("exam_print_clear_stamped", exam.exam_id) elif '!mc' in form: cursor.execute_none ("update exam_exam_crowdmark set crowdmark_mc_pages = %(crowdmark_mc_pages)s where exam_id = %(exam_id)s", crowdmark_mc_pages=parse_form_value (form.optional_field_value("crowdmark_mc_pages"), int), exam_id=exam.exam_id) cursor.callproc_none ("exam_print_clear_stamped", exam.exam_id) raise status.HTTPFound ("") @use_remote_identity @return_html def master_approve_get_handler (cursor, remote_identity, exam): '''Get handler for exam master approval :param exam: A database row representing an assessment. :return: an html result intended to display the PDFs of the exam master(s) for a particular assessment and display the current approval status and process. ''' result = [format_return ('Upload Masters')] if exam.is_crowdmark: instructions = "crowdmark and exam instructions" else: instructions = "exam instructions" if exam.master_approved: result.append (html.p ("The following PDFs have been approved")) else: result.append (html.p ("Please review the following PDFs before approving to ensure %s do not overlap any of the content on the exam masters" % instructions)) table = html.table () exam_masters = cursor.exam_masters_by_exam (exam_id=exam.exam_id) for exam_master in exam_masters: if exam_master.master_pages_net is not None: row = html.tr () row.append (html.td (exam_master.master_description)) row.append (html.td (html.a ('Download PDF', href="download?type_code=%s&version_seq=%s" % (exam_master.type_code, exam_master.version_seq)), ' (', html_join (['%i pages' % exam_master.master_pages_gross, exam_master.size_description], sep=', '), ')')) table.append (row) if not exam_master.master_pdf: restamp_master (exam.exam_id, exam_master.type_code, exam_master.version_seq, cursor) result.append (table) for exam_master in exam_masters: if not exam_master.master_pages_net: continue result.append (html.p (exam_master.master_description + ' thumbnails:')) for i in range (0, exam_master.master_pages_gross): result.append (html.img (width='230px', alt='', src="thumb?exam_id=%s&type_code=%s&version_seq=%s&master_page=%s&master_description=%s" % (exam.exam_id, exam_master.type_code, exam_master.version_seq, i + 1, exam_master.master_description))) if exam.master_approved: if exam.master_accepted: result.append (html.p ('The assessment master has been approved for production and sent for printing.')) else: result.append (html. form ( html.p ('The assessment master has been approved for production but has not been sent for printing. You may still “un-approve” the PDF masters if you need to make more changes.'), html.p (html.label ('“Un-approve” PDF masters: ', html.input (type="checkbox", name="unapprove", value="")), html.input (type="submit", name="!unapprove", value="Un-Approve!")), method="post", action="")) else: result.append (html.form ( html.p ( html.label ('Approve PDF masters for production: ', html.input (type="checkbox", name="approve", value="") ), ' ', html.input (type="submit", name="!approve", value="Approve!") ), method="post", action="" )) return 'Approve Assessment Masters', result @use_form_param @use_remote_identity @return_html def master_approve_post_handler (cursor, remote_identity, exam, form): '''Post handler for exam master approval :param exam: A database row representing an assessment. :param form: Contains information about the users exam approval. :return: checks the users approval specifications to update database and redirects the user to the upload master page. ''' if '!unapprove' in form: if 'unapprove' in form: cursor.execute_none ("update exam_exam set master_approved=null, master_approver_person_id=null where exam_id = %(exam_id)s and master_accepted is null", exam_id=exam.exam_id) else: return 'Error: Please tick the checkbox to un-approve the assessment masters', [html.p ('Please go back and tick the checkbox if you wish to un-approve the assessment masters.')] elif exam.master_approved is not None: return "Assessment Master Already Approved", [] elif '!approve' in form: if 'approve' in form: cursor.execute_none ("update exam_exam set master_approved=now (), master_approver_person_id=%(person_id)s where exam_id=%(exam_id)s", person_id=remote_identity.person_id, exam_id=exam.exam_id) else: return 'Error: Please tick the checkbox to approve the assessment masters', [html.p ('Please go back and tick the checkbox if you wish to approve the assessment masters for printing.')] raise status.HTTPFound("../") @use_form_param @use_remote_identity @return_pdf def upload_download_pdf (cursor, remote_identity, exam, form, roles=None, **params): """PDF download URL handler. Serves the assessment master PDF to the client, first checking for permissions and re-stamping the master if necessary. """ if not (remote_identity.person_id == exam.exam_author_person_id or 'ISC' in roles and exam.master_approved is not None): raise status.HTTPForbidden () type_code = form.optional_field_value ('type_code') version_seq = form.optional_field_value ('version_seq') exam_master = cursor.execute_optional_tuple ("select * from exam_exam_master_plus where (exam_id, type_code, version_seq) = (%(exam_id)s, %(type_code)s, %(version_seq)s)", exam_id=exam.exam_id, type_code=type_code, version_seq=version_seq) if exam_master is None or not exam_master.master_pdf_raw: raise status.HTTPNotFound () if not exam_master.master_pdf: restamp_master (exam.exam_id, exam_master.type_code, exam_master.version_seq, cursor) # Start a new transaction in order to see results of re-stamping. cursor.connection.commit () master_pdf = cursor.execute_required_value ("select master_pdf from exam_exam_master where (exam_id, type_code, version_seq) = (%(exam_id)s, %(type_code)s, %(version_seq)s)", exam_id=exam.exam_id, type_code=exam_master.type_code, version_seq=exam_master.version_seq) return [master_pdf, '%s %s %s %s %s.pdf' % (exam.admin_description, exam.term_time, exam.term_year, exam.title, exam_master.master_description)] @use_form_param @use_remote_identity @return_png def download_thumbnail (cursor, remote_identity, exam, form, roles=None, **params): """Thumbnail download URL handler. Serves the thumbnail of assessment master PDF to the client. """ exam_id = form.optional_field_value ('exam_id') type_code = form.optional_field_value ('type_code') version_seq = form.optional_field_value ('version_seq') master_page = form.optional_field_value ('master_page') master_description = form.optional_field_value ('master_description') thumbnail_image = cursor.execute_required_value ("select master_thumbnail from exam_exam_master_thumbnail where (exam_id, type_code, version_seq, master_page) = (%(exam_id)s, %(type_code)s, %(version_seq)s, %(master_page)s)", exam_id=exam_id, type_code=type_code, version_seq=version_seq, master_page=master_page) return [thumbnail_image, '%s %s %s %s %s.png' % (exam.admin_description, exam.term_time, exam.term_year, exam.title, master_description)] exam_upload_handler = delegate_action ( delegate_get_post (upload_get_handler, upload_post_handler), { 'aids-edit': exam_aids_editor, 'approve': delegate_action ( delegate_get_post (master_approve_get_handler, master_approve_post_handler), { 'download': upload_download_pdf, 'thumb': download_thumbnail, }), 'download': upload_download_pdf, 'allocate-edit': allocate_edit_handler, 'version-edit': version_edit_handler, 'allocate-delete': allocate_delete_handler } )