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

"""Grade revision UI pages.

Implements the grade revision display screen, as well as the interface for
preparing new revisions.
"""

from collections import namedtuple
import csv
from datetime import date
from itertools import groupby
from operator import attrgetter
import re

from ll.xist.ns import html

from uw.stringtools import split_upload_text

from uw.web.html.form import render_hidden
from uw.web.html.format import make_table_format, format_date, format_datetime, format_return

from uw.web.wsgi.delegate import delegate_action, delegate_get_post, status
from uw.web.wsgi.form import use_form_param
from uw.web.wsgi.function import return_html

from uw.local.util.format import format_person
from uw.local.util.identity import use_remote_identity

[docs]def format_unavailable_message (cursor, term): begin, end = cursor.execute_required_tuple ("select upper (term_dates), (upper (term_dates) + '13 months'::interval)::date from uw_term where term_id = %(term_id)s", term_id=term.code ()) today = date.today () if today < begin: return 'The grade revision system is not yet available for %s. Please use Quest to submit revised grades.' % term.description () elif today < end: return None else: return 'The grade revision system is no longer available for %s. In order to revise a grade, a formal petition must be submitted.' % term.description ()
[docs]def format_student_grades (cursor, uw_id, grades, editable, revised_grade=None, rowcount=None): """Format grade information for a student. :param cursor: DB connection cursor. :param str uw_id: The student's UW ID as a string. :param list grades: Database rows corresponding to the student's grade revisions. :param bool editable: Whether the UI should show editing components. :param str revised_grade: The proposed revision to display, or None if the display is only of existing revisions. :param int rowcount: Which row of a set of proposed revisions this is, or None when revised_grade is None. :return: A list of HTML <tr> elements, one for each grade revision associated with the student, but always at least one row. """ assert type (grades) is tuple, 'Grade records must be a tuple' rowspan = len (grades) result = [] for i, r in enumerate (grades): tr = html.tr () if not i: tr.append (html.td (uw_id, rowspan=rowspan)) tr.append (html.td (format_person (cursor, r.person_id), rowspan=rowspan)) tr.append (html.td (r.course_grade, rowspan=rowspan)) if r.preparer_person_id is None: tr.append (html.td (colspan=3)) else: tr.append (html.td (r.student_grade)) tr.append (html.td (format_person (cursor, r.preparer_person_id))) if r.submission_time is None: td = ['Pending'] if revised_grade is None and editable: td.append (' ') td.append (html.form ( render_hidden ("revisions", "%s," % uw_id), html.input (type="submit", name="!review-grades", value="Cancel…"), method="post", action="create" )) else: td = [format_datetime (r.submission_time)] tr.append (html.td (td)) if revised_grade is not None and not i: tr.append (html.td ( revised_grade or '[Cancel Pending]', render_hidden ("person-%d" % rowcount, r.person_id), render_hidden ("grade-%d" % rowcount, revised_grade), rowspan=rowspan )) result.append (tr) return result
@delegate_get_post @return_html def grades_index_handler (cursor, term, admin, roles): """UI implementation of main grade revision display. Shows all grade revisions currently recorded for this admin offering, and links to the form to prepare additional revisions. """ if not set (['ISC', 'INST']) & roles: raise status.HTTPForbidden () result = [format_return ('Main Menu', None, None, 'Offering')] grades = cursor.execute_tuples ("select * from grade_revision_complete where (term_id, admin_id) = (%(term_id)s, %(admin_id)s) and not no_revision order by surname, givennames, revision_updated", term_id=term.code (), admin_id=admin.admin_id) if grades: table = html.table (html.tr ( html.th ('UW ID'), html.th ('Student'), html.th ('Quest Grade'), html.th ('Revision'), html.th ('Prepared by'), html.th ('Submitted'), )) for uw_id, grades in groupby (grades, attrgetter ('external_id')): table.extend (format_student_grades (cursor, uw_id, tuple (grades), 'ISC' in roles)) result.append (table) else: result.append (html.p ('No grade revisions yet.')) if set (['ISC', 'INST']) & roles: message = format_unavailable_message (cursor, term) if message is None: message = html.a ('Prepare…', href="create") result.append (html.p (message)) return "%s (%s) Grade Revisions" % (admin.admin_description, term.description ()), result @return_html def create_get_handler (cursor, term, admin, roles): """UI implementation of grade revision creation form. Displays a box into which proposed grade revisions may be pasted. """ if not set (['ISC', 'INST']) & roles: raise status.HTTPForbidden () message = format_unavailable_message (cursor, term) if message is not None: return 'Error: System Unavailable', html.p (message) result = [format_return ('Main Menu', None, None, 'Offering', dot='Grades')] result.append (html.form ( html.p ('Paste or type some grade revisions, one per line.'), html.p ('One column must have the UW student IDs; another must have the new grades. Columns may be comma-separated or tab-delimited:'), html.textarea (name="revisions", rows=15, cols=40), html.p (html.input (type="submit", name="!review-grades", value="Next…")), method="post" )) return 'Prepare Grade Revisions', result uw_id_re = re.compile ('^[0-9]{8}$') grade_re = re.compile ('^(0|[1-9][0-9]?|100|AEG|CR|DNW|INC|IP|NCR|UR|)$')
[docs]def process_re_column (regex, column): """Filter a column of text values, replacing non-matching values with None. :param regex: The compiled regular expression to match. :param list column: The list of text values to check. """ return [None if regex.match (field or '') is None else field or '' for field in column]
[docs]def find_relevant_columns (grid): """Find the UW ID and proposed grade columns in a grid of uploaded data. :param list grid: A rectangular array of field values. :return: Two lists, the first a list of UW IDs, the second a list of proposed grade revisions. Scan through the provided field values to identify the columns with the most valid UW IDs and proposed grades, and return those columns as the result. """ most_ids_column = None most_ids_count = 0 most_grades_column = None most_grades_count = 0 for column in zip (*grid): this_column = process_re_column (uw_id_re, column) if len ([f for f in this_column if f is not None]) > most_ids_count: most_ids_column = this_column this_column = process_re_column (grade_re, column) if len ([f for f in this_column if f is not None]) > most_grades_count: most_grades_column = this_column return most_ids_column, most_grades_column
student_row = namedtuple ('student_row', ['uw_id', 'grade', 'db'])
[docs]def parse_revision_upload (cursor, term, admin, form): """Handle initial upload of proposed grade revisions. """ revisions = form.optional_field_value ('revisions') revisions = split_upload_text (revisions) ids, grades = find_relevant_columns (revisions) errors = [] if ids is None: errors.append ('No ID column found') if grades is None: errors.append ('No grade column found') result = [format_return ('Main Menu', None, None, 'Offering', dot='Grades')] if errors: result.append (html.ul (html.li (error) for error in errors)) else: students = cursor.execute_tuples ("select * from grade_revision_complete where (term_id, admin_id) = (%(term_id)s, %(admin_id)s) and external_id = any (%(uw_ids)s)", term_id=term.code (), admin_id=admin.admin_id, uw_ids=[id for id in ids if id is not None]) students = dict ((uw_id, tuple (grades)) for uw_id, grades in groupby (students, attrgetter ('external_id'))) students = list(map (student_row, ids, grades, (students.get (id) for id in ids))) table = html.table (html.tr ( html.th ('UW ID'), html.th ('Student'), html.th ('Quest Grade'), html.th ('Revision'), html.th ('Prepared by'), html.th ('Submitted'), html.th ('Proposed Grade'), )) rowcount = 0 for s in students: if s.uw_id is None: error = 'Invalid ID' elif s.grade is None: error = 'Invalid Grade' else: if s.db is None: error = 'Unknown student' elif s.db[0].dropped is not None: error = 'Dropped ' + format_date (s.db[0].dropped) else: error = None if error is None: table.extend (format_student_grades (cursor, s.uw_id, s.db, True, s.grade, rowcount)) rowcount += 1 else: table.append (html.tr ( html.td (s.uw_id), html.td (html.span (error, style="color: red;")), html.td (), html.td (), html.td (), html.td (), html.td ('[Cancel Pending]' if s.grade == '' else s.grade), )) result.append (html.form ( html.p ('Valid revisions to prepare: ', rowcount), table, render_hidden ("!submit", rowcount), html.p (html.input (type="submit", value="Prepare Revisions!")), method="post" )) return "%s (%s) Prepare Grade Revisions" % (admin.admin_description, term.description ()), result
[docs]def process_revisions (cursor, term, admin, form, remote_identity): """Handle submission of parsed and validated grade revisions. """ rowcount = int (form.required_field_value ('!submit')) result = [format_return ('Main Menu', None, None, 'Offering', dot='Grades')] table = html.table (html.tr (html.th ('Student'), html.th ('Revised Grade'))) for i in range (rowcount): student_person_id = form.required_field_value ("person-%d" % i) student_grade = form.required_field_value ("grade-%d" % i) if student_grade: cursor.callproc_none ("grade_create_pending", term.code (), admin.admin_id, student_person_id, student_grade, remote_identity.person_id) else: cursor.callproc_none ("grade_cancel_pending", term.code (), admin.admin_id, student_person_id) table.append (html.tr ( html.td (format_person (cursor, student_person_id)), html.td (student_grade or '[Cancel Pending]'), )) result.append (html.p ('The following grade revisions have been recorded:')) result.append (table) return "%s (%s) Grade Revisions Prepared " % (admin.admin_description, term.description ()), result
@use_form_param @use_remote_identity @return_html def create_post_handler (cursor, term, admin, roles, form, remote_identity): if not set (['ISC', 'INST']) & roles: raise status.HTTPForbidden () message = format_unavailable_message (cursor, term) if message is not None: return 'Error: System Unavailable', html.p (message) if '!submit' in form: return process_revisions (cursor, term, admin, form, remote_identity) else: return parse_revision_upload (cursor, term, admin, form) offering_grades_handler = delegate_action (grades_index_handler, { 'create': delegate_get_post (create_get_handler, create_post_handler), })