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

"""Room-related UI pages.

This includes the master room list and individual room maps, as well as
room maps specific to a sitting or assessment and sitting.  Also includes the
form and handlers for removing a room from a sitting or assessment and
sitting, and for editing the use of a room (reserving/unreserving seats).
"""

import re, time

from operator import attrgetter
from itertools import groupby
from subprocess import Popen, PIPE, STDOUT

from ll.xist.ns import html

from uw.web.wsgi import status
from uw.web.wsgi.delegate import delegate_get_post, delegate_action, delegate_value, always
from uw.web.wsgi.form import use_form_param
from uw.web.wsgi.function import return_html, return_pdf, return_text
from uw.web.pdf.util import handle_pdf_result

from uw.web.html.form import render_select, render_checkbox
from uw.web.html.format import make_table_format, format_email, format_return
from uw.web.html.join import english_join

from .exam_render import render_exam_index
from .room_edit import note_instructions
from .room_render import render_room, render_room_index, room_capacity_columns, room_selected_columns, room_extra_count_columns, room_all_count_columns, render_loading_standard_text, render_href, render_allow_tablet_seats, render_allow_single_seating, render_checkerboard, REMOVE_DIVISION_VALUE
from .ui import render_columns_as_rows, nullif

[docs]def room_from_arc_code (arc, cursor, building, **params): """Interpret a URL path arc as a room code. :param arc: the URL path arc :param cursor: DB connection cursor :param building: building information row containing at least building_code :param params: any additional context not needed for this arc parser """ return cursor.room_by_code (building_code=building.building_code, room_code=arc)
[docs]def room_code_delegate (dir_handler, arc_handler): """Delegate to URL handler based on room code in URL. """ return delegate_value ('room', dir_handler, always (arc_handler), convert_arc=room_from_arc_code)
[docs]def building_from_arc (arc, cursor, **params): """Interpret a URL path arc as a building code. :param arc: the URL path arc :param cursor: DB connection cursor :param params: any additional context not needed for this arc parser """ return cursor.building_by_code (building_code=arc)
[docs]def building_delegate (dir_handler, arc_handler): """Delegate to URL handler based on building code in URL. """ return delegate_value ('building', dir_handler, always (arc_handler), convert_arc=building_from_arc)
@delegate_get_post @return_html def building_index (cursor): """Building index URL handler. :param cursor: DB connection cursor :return: tuple of (title, list of available buildings, including links to each one) :rtype: (string, list) """ result = [] buildings = cursor.buildings () result.append (html.ul ( html.li (html.a (building, href=building)) for building in buildings )) return "Building Room Lists (JSON)", result @delegate_get_post @return_text def building_json_list (cursor, building, form): """Building JSON room list URL handler. :param cursor: DB conenction cursor :param building: building to generate list from :param form: CGI form results :return: JSON list of rooms in a building :rtype: str Generates a JSON list of rooms in a building. The loading standard to use for computing capacities is determined from form parameters. **TODO: should use :func:`uw.web.wsgi.function.return_json`.** """ allow_tablet_seats = 'ts' in form allow_single_seating = 'ss' in form checkerboard = 'cb' in form rooms = cursor.rooms_by_building (building_code=building, allow_tablet_seats=allow_tablet_seats, allow_single_seating=allow_single_seating, checkerboard=checkerboard) return ('[\n' + ',\n'.join ( '{"value": %s, "code": "%s"}' % (room.room_id, room.room_code) for room in rooms) + '\n]') @delegate_get_post @return_text def room_json_list (cursor, form): """JSON room list URL handler. :param cursor: DB conenction cursor :param form: CGI form results :return: JSON list of rooms known to the system :rtype: str Generates a JSON list of rooms known to the system. The loading standard to use for computing capacities is determined from form parameters. **TODO: should use :func:`uw.web.wsgi.function.return_json`.** """ allow_tablet_seats = 'ts' in form allow_single_seating = 'ss' in form checkerboard = 'cb' in form rooms = {} for k, g in groupby (cursor.rooms_sorted (allow_tablet_seats=allow_tablet_seats, allow_single_seating=allow_single_seating, checkerboard=checkerboard), attrgetter ('building_code')): rooms[k] = dict ((r.room_code, r) for r in g) return '{ "codes": [' + ', '.join ('"%s"' % code for code in sorted (rooms.keys ())) + '], "building": {\n' + ',\n'.join ('"%s": { "codes": %s, "room": %s}' % (code, '[' + ', '.join ('"%s"' % code for code in sorted (building.keys ())) + ']', '{\n ' + ',\n '.join ('"%s": { "all_seats": %s, "assignable_seats": %s }' % (r.room_code, r.count_all_seats, r.count_assignable_seats) for r in building.values ()) + '}') for code, building in rooms.items ()) + '}}'
[docs]def render_loading_standard_select (allow_tablet_seats, allow_single_seating, checkerboard): """Render links for switching between loading standards. :param allow_tablet_seats: Whether tablet seats are allowed. :param allow_single_seating: Whether single-seating is allowed. :param checkerboard: Whether we are showing the checkerboard usage pattern. :return: two HTML <p> elements. The first explains the loading standard currently in effect. The second consists of links with query-only URLs to change the loading standard. Intended to allow clicking easily between different loading standard views of a room map. """ return [ html.p ( render_loading_standard_text (allow_tablet_seats, allow_single_seating, checkerboard) ), html.p ( 'View with different loading standard: ', html.a (render_allow_tablet_seats (not allow_tablet_seats), href=render_href (not allow_tablet_seats, allow_single_seating, checkerboard)), ' ', html.a (render_allow_single_seating (not allow_single_seating), href=render_href (allow_tablet_seats, not allow_single_seating, checkerboard)), ' ', html.a (render_checkerboard (not checkerboard), href=render_href (allow_tablet_seats, allow_single_seating, not checkerboard)) ), ]
@delegate_get_post @return_html def room_index (cursor, form, global_roles): """Room list URL handler. :param cursor: DB connection cursor :param form: CGI form results :return: tuple of (title, list of all rooms known to the system) :rtype: (string, list) Displays a list of all rooms known to the system. Capacities are computed according to a loading standard determined by form parameters. """ result = [format_return ('Main Menu')] allow_tablet_seats = 'ts' in form allow_single_seating = 'ss' in form checkerboard = 'cb' in form result.append (render_loading_standard_select (allow_tablet_seats, allow_single_seating, checkerboard)) if 'ROOMEDIT' in global_roles: result.append (html.p (html.a ('Create new room', href='./create'))) result.append (html.p ('Rooms in progress:')) result.append (render_room_index (cursor.rooms_inactive_sorted (allow_tablet_seats=allow_tablet_seats, allow_single_seating=allow_single_seating, checkerboard=checkerboard), "id/", room_capacity_columns, allow_tablet_seats=allow_tablet_seats, allow_single_seating=allow_single_seating, checkerboard=checkerboard)) result.append (html.p ('Current rooms:')) result.append (render_room_index (cursor.rooms_sorted (allow_tablet_seats=allow_tablet_seats, allow_single_seating=allow_single_seating, checkerboard=checkerboard), "id/", room_capacity_columns, allow_tablet_seats=allow_tablet_seats, allow_single_seating=allow_single_seating, checkerboard=checkerboard)) return "Room Index", result @return_pdf def print_room_handler (cursor, room, form): """Room map print handler. :param cursor: DB connection cursor :param room: a database row representing a room :param form: CGI form results :return: PDF room map Downloads a PDF room map using the proctor package and displays it to the user. """ allow_tablet_seats = 'ts' in form allow_single_seating = 'ss' in form checkerboard = 'cb' in form params = ['java', 'ca.uwaterloo.odyssey.exams.printBlankRoomMap', str (room.room_id), str (allow_tablet_seats), str (allow_single_seating), str (checkerboard)] title = '%s-%s_Room.pdf' % (room.building_code, room.room_code) return handle_pdf_result (params, title, ('There was a problem attempting to generate the proctor package. Please contact ', format_email ('odyssey@uwaterloo.ca'), ' for more assistance.')) @return_pdf def room_map_download_pdf (cursor, room, form): """Room Map PDF download URL handler. :param cursor: DB connection cursor :param room: a database row representing a room :param form: CGI form results :return: list of PDF room map and room information :rtype: list Serves the room map PDF. """ room_map = cursor.execute_optional_tuple ("select * from room_room_map where room_id = %(room_id)s", room_id=room.room_id) if room_map is None: raise status.HTTPNotFound () return [room_map.room_map_pdf, '%s %s Room Map.pdf' % (room.building_code, room.room_code)]
[docs]def render_room_map_section (room, edit): """Displays the room map PDF section :param room: a database row representing a room :param edit: a boolean whether a pdf is editable :return: a list of html elements containing the upload option, if available, or the download option for a room map PDF :rtype: list """ result = [] if room.room_map_pdf: result.append (html.table (html.tr (html.td ( html.a ('Download PDF', href="download"))))) if edit: result.append (html.table (html.tr (html.th ('Room Map PDF: '), html.td (html.form (html.input (type="file", name="pdf", accept="application/pdf"), ' ',html.input (type="submit", name="!upload", value="Upload!"), method="post", enctype="multipart/form-data", action="",))))) return result
@return_html def room_get_handler (cursor, room, form, global_roles): """Room map URL GET handler. :param cursor: DB connection cursor :param room: a database row representing a room :param form: CGI form results :return: tupel of (room information, map of the relevant room) :rtype: (string, list) Displays a map of the relevant room, highlighted according to the seat designation that would be done if an assessment had the room to itself, needed the entire room, and used the loading standard determined by the form parameters. For rooms not yet activated, will display an edit link for editing the room map. Also displays an enable and disable buttons depending on the state of the room's effective date. """ result = [format_return ('Main Menu', 'Room Index', None)] allow_tablet_seats = 'ts' in form allow_single_seating = 'ss' in form checkerboard = 'cb' in form result.append (html.h2 ("Room Note")) result.append (note_instructions (cursor, room)) if 'ROOMEDIT' in global_roles: result.append (html.p ("To edit the room note, click ", html.a ('here…', href="../../edit/id/%s/note/" % room.room_id))) result.append (html.h2 ("Room Map")) ## May edit the room map pdf if you have the global permissions and if the room is current and has no pdf or the room is in editing mode edit_room_map_pdf = 'ROOMEDIT' in global_roles and ((room.room_current and room.room_map_pdf is None) or room.room_effective is None) result.append (render_room_map_section (room, edit=edit_room_map_pdf)) result.append (render_loading_standard_select (allow_tablet_seats, allow_single_seating, checkerboard)) result.append (render_columns_as_rows (room, room_capacity_columns)) result.append (render_room (cursor, room, allow_tablet_seats=allow_tablet_seats, allow_single_seating=allow_single_seating, checkerboard=checkerboard)) result.append (html.p (html.a ('Print Room Map…', href="print/" + render_href (allow_tablet_seats, allow_single_seating, checkerboard)))) if 'ROOMEDIT' in global_roles: if room.room_effective is None: result.append (html.p ('You may edit this room with ', (html.a ('Edit Room Map…', href="../../edit/id/%s/" % room.room_id), ' or you may enable this room below:'))) result.append (html.form ( html.div ( html.p ( render_checkbox ("enable_room_check"), ' Confirm enable room', ), html.input (type="submit", name="!enable", value="Enable Room")) if room.room_effective is None else None, html.p ( html.input (type="submit", name="!supersede", value="Supersede Room"), ' ', html.input (type="submit", name="!disable", value="Disable Room")) if room.room_current else None, method="post", action="" )) return 'Room Information—%s %s' % (room.building_code, room.room_code), result @return_html def room_post_handler (cursor, room, form, global_roles): """Room map URL POST handler. :param cursor: DB connection cursor :param room: a database row representing a room :param form: CGI form results Enables or disables a room, setting it's effective date appropriately. """ if 'ROOMEDIT' not in global_roles: raise status.HTTPFound ("") elif "!enable" in form: if 'enable_room_check' in form: cursor.callproc_none ("room_edit_enable", room.room_id) raise status.HTTPFound ("") elif "!disable" in form: cursor.callproc_none ("room_edit_disable", room.room_id) raise status.HTTPFound ("") elif "!supersede" in form: new_room_id = cursor.callproc_required_value ("room_edit_supersede", room.room_id) raise status.HTTPFound ("../../edit/id/%s/" % new_room_id) elif "!upload" in form: map_pdf = form.optional_field_value ("pdf") if not map_pdf: return 'Error: No Room Map Supplied', [html.p ('No PDF was submitted. Please go back and try again.')] cursor.callproc_none ("room_edit_pdf_map", room.room_id, map_pdf) raise status.HTTPFound ("") raise status.HTTPBadRequest () room_handler = delegate_get_post (room_get_handler, room_post_handler) @delegate_get_post @return_pdf def sitting_room_pdf_handler (cursor, term, admin, roles, sitting, room, exam=None): """Display room map for particular sitting. :param cursor: DB connection cursor :param term: the relevant term :param admin: the relevant admin unit :param roles: the active permissions roles :param sitting: the relevant sitting :param room: the relevant room :param exam: the relevant assessment, if any :return: PDF room map At present cheats - always shows sitting view of room even if specific assessment selected, but uses name from assessment. *** TODO: doesn't actually work due to broken Java class. Either remove or repair. Proctor package includes this information so possibly not necessary to have available separately. """ params = [str (room.room_id), str (sitting.sitting_id)] if exam is not None: params.append (str (exam.exam_id)) return handle_pdf_result ( ['java', 'ca.uwaterloo.odyssey.exams.printSittingRoomMap'] + params, '%s %s %s %s Room Map.pdf' % (admin.admin_description, term.description (), room.building_code, room.room_code), ('There was a problem attempting to generate the room map. Please contact ', format_email ('odyssey@uwaterloo.ca'), ' for more assistance.') ) @return_html def sitting_room_html_get_handler (cursor, term, admin, roles, sitting, room): """Sitting room display GET URL handler. :param cursor: DB connection cursor :param term: the relevant term :param admin: the relevant admin unit :param roles: the active permissions roles :param sitting: the relevant sitting :param room: the relevant room :return: tuple of (sitting and room information, room map for relevant sitting) :rtype: (string, list) Displays a map of the relevant room in the relevant sitting. If appropriate, includes form controls for removing assessments from the room, removing the room from the sitting, and adjusting seating. """ result = [format_return ('Main Menu', None, None, 'Offering', None, 'Sitting', None)] result.append (render_columns_as_rows (room, room_selected_columns)) def delete_render (exam): if not isinstance (exam, tuple): # Footer row if exam.count_allocated_seats: # Allow unseating/unrushing/removing (see below) return html.input (type="submit", name="!unseat", value="Unseat/Remove!") else: # Allow removing this room from the sitting return html.input (type="submit", name="!remove", value="Remove Room!") if exam.sequence_assigned is not None: return None boxes = [] if exam.count_occupied_seats > 0: # Offer to unseat candidates boxes.append (html.label ( 'Unseat %d candidates' % exam.count_occupied_seats, render_checkbox ("unseat", value=exam.exam_id), )) if exam.count_used_rush_seats > 0: # Offer to remove rush seats from use boxes.append (html.label ( 'Remove %d not assigned seats' % exam.count_used_rush_seats, render_checkbox ("unrush", value=exam.exam_id), )) # Offer to remove assessment from room boxes.append (html.label ( 'Remove this assessment ', render_checkbox ("remove", value=exam.exam_id), )) return english_join (*boxes) delete_column = (delete_render, 'Action', []) exams_by_sitting_room = cursor.exams_by_sitting_room (sitting_id=sitting.sitting_id, room_id=room.room_id) result.append ( html.form ( render_exam_index (cursor, exams_by_sitting_room, "../../exam/", room_extra_count_columns + (delete_column,), sitting), method="post", action="" ) ) result.append (render_room (cursor, room, sitting, form='ISC' in roles)) return '%s: %s %s' % (sitting.full_description, room.building_code, room.room_code), result seat_code_re = re.compile ("^([0-9]+)-([0-9]+)$")
[docs]def room_update (cursor, term, admin, roles, sitting, room, form, exam=None): """Perform updates of information about a room based on form results. :param cursor: DB connection cursor :param term: the relevant term :param admin: the relevant admin unit :param roles: the active permissions roles :param sitting: the relevant sitting :param room: the relevant room :param form: the form results :param exam: the relevant assessment, if any Analyzes the form results and makes changes as appropriate, possibly including: - allocate or unallocate seats to/from assessments - reserve or un-reserve seats - unseat candidates from seats """ updates = {} if exam is None: allocate = form.optional_field_value ("allocate") if allocate == 'u': # Unallocate updates["exam_id"] = "NULL" updates["seat_assigned"] = "NULL" elif allocate is None or allocate == '': # No change pass else: # Ensure no SQL injection vulnerability updates["exam_id"] = str (int (allocate)) updates["seat_assigned"] = "NULL" reserve = form.optional_field_value ("reserve") if reserve == 'r': reserve = True elif reserve == 'u': reserve = False else: reserve = None if reserve is not None: updates["seat_reserved"] = str (reserve) if reserve: # Must un-designate seats if necessary updates["seat_assigned"] = "NULL" unseat = form.optional_field_value ("unseat") if unseat in ['y']: updates["seat_in_use"] = "FALSE" updates["person_id"] = "NULL" sql = "update exam_sitting_room_seat set %s where (sitting_id, room_id, seat_col, seat_row) = (%%(sitting_id)s, %%(room_id)s, %%(seat_col)s, %%(seat_row)s)" % ', '.join ('%s=%s' % u for u in updates.items ()) division_value = form.optional_field_value ("division_value") for s in form.multiple_field_value ("s"): m = seat_code_re.match (s) if m is not None: if updates != {}: cursor.execute_none (sql, sitting_id=sitting.sitting_id, room_id=room.room_id, seat_col=m.group (1), seat_row=m.group (2)) if division_value: division_seq = form.optional_field_value ("division_seq") cursor.callproc_none ("exam_sitting_room_seat_division_set", sitting.sitting_id, room.room_id, m.group (1), m.group (2), exam.exam_id, division_seq, nullif (division_value, REMOVE_DIVISION_VALUE)) if exam is not None: cursor.callproc_none ("exam_assign_designate_seats", exam.exam_id, sitting.sitting_id)
@use_form_param @return_html def sitting_room_html_post_handler (cursor, term, admin, roles, sitting, room, form): """Sitting room display POST URL handler. :param cursor: DB connection cursor :param term: the relevant term :param admin: the relevant admin unit :param roles: the active permissions roles :param sitting: the relevant sitting :param room: the relevant room :param form: the form results Implements actions based on form results. These can be any one of: - Remove an assessment from the room - Remove the room from the sitting - Update seat use within the room Making the actual changes is done by room_update (). """ if not 'ISC' in roles: raise status.HTTPForbidden () targeturl = "" result = [] exams = set () if "!unseat" in form: # Unseat candidates, stop using rush seats, remove assessments from room for exam_id in form.multiple_field_value ("unseat"): cursor.execute_none ("update exam_sitting_room_seat set seat_in_use = false, person_id = null where seat_assigned and (sitting_id, room_id, exam_id) = (%(sitting_id)s, %(room_id)s, %(exam_id)s)", sitting_id=sitting.sitting_id, room_id=room.room_id, exam_id=exam_id) exams.add (exam_id) for exam_id in form.multiple_field_value ("unrush"): cursor.execute_none ("update exam_sitting_room_seat set seat_in_use = false where not seat_assigned and (sitting_id, room_id, exam_id) = (%(sitting_id)s, %(room_id)s, %(exam_id)s)", sitting_id=sitting.sitting_id, room_id=room.room_id, exam_id=exam_id) exams.add (exam_id) for exam_id in form.multiple_field_value ("remove"): cursor.execute_none ("update exam_sitting_room_seat set exam_id = null, seat_assigned = null where (sitting_id, room_id, exam_id) = (%(sitting_id)s, %(room_id)s, %(exam_id)s)", sitting_id=sitting.sitting_id, room_id=room.room_id, exam_id=exam_id) cursor.execute_none ("delete from exam_exam_sitting_room where (sitting_id, room_id, exam_id) = (%(sitting_id)s, %(room_id)s, %(exam_id)s)", sitting_id=sitting.sitting_id, room_id=room.room_id, exam_id=exam_id) exams.add (exam_id) elif "!remove" in form: # Remove this room from list cursor.execute_none ("delete from exam_sitting_room_seat where (sitting_id, room_id) = (%(sitting_id)s, %(room_id)s)", sitting_id=sitting.sitting_id, room_id=room.room_id) for exam_id in cursor.execute_values ("delete from exam_exam_sitting_room where (sitting_id, room_id) = (%(sitting_id)s, %(room_id)s) returning exam_id", sitting_id=sitting.sitting_id, room_id=room.room_id): exams.add (exam_id) cursor.execute_none ("delete from exam_sitting_room where (sitting_id, room_id) = (%(sitting_id)s, %(room_id)s)", sitting_id=sitting.sitting_id, room_id=room.room_id) targeturl = "../.." elif "!update" in form: room_update (cursor, term, admin, roles, sitting, room, form) for exam_id in cursor.execute_values ("select exam_id from exam_exam_sitting_room where (sitting_id, room_id) = (%(sitting_id)s, %(room_id)s)", sitting_id=sitting.sitting_id, room_id=room.room_id): exams.add (exam_id) for exam_id in exams: cursor.callproc_none ("exam_assign_designate_seats", exam_id, sitting.sitting_id) raise status.HTTPFound (targeturl) sitting_room_handler = delegate_action (delegate_get_post (sitting_room_html_get_handler, sitting_room_html_post_handler), { 'pdf': sitting_room_pdf_handler, }) @return_html def exam_sitting_room_html_get_handler (cursor, term, admin, roles, exam, sitting, room): """Assessment sitting room map URL GET handler. :param cursor: DB connection cursor :param term: the relevant term :param admin: the relevant admin unit :param roles: the active permissions roles :param exam: the relevant assessment :param sitting: the relevant sitting :param room: the relevant room :return: tuple of (exam and sitting and room information, room map for the relevant assessment, sitting, and room) :rtype: (string, list) """ result = [format_return ('Main Menu', None, None, 'Offering', None, 'Assessment', None, 'Sitting', None)] result.append (render_columns_as_rows (room, room_extra_count_columns)) show_edit_form = exam.sequence_assigned is None and 'ISC' in roles if show_edit_form: result.append (html.p (html.a ('Remove this room…', href="../../../../../../sitting/%d/room/%d/" % (sitting.sitting_id, room.room_id)))) result.append (render_room (cursor, room, sitting, exam, form=show_edit_form)) return '%s: %s %s %s' % (exam.full_title, sitting.full_description, room.building_code, room.room_code), result @use_form_param @return_html def exam_sitting_room_html_post_handler (cursor, term, admin, roles, exam, sitting, room, form): """Assessment sitting room map URL POST handler. :param cursor: DB connection cursor :param term: the relevant term :param admin: the relevant admin unit :param roles: the active permissions roles :param exam: the relevant assessment :param sitting: the relevant sitting :param room: the relevant room :param form: the form results Making the actual changes is done by room_update (). """ if not 'ISC' in roles: raise status.HTTPForbidden () room_update (cursor, term, admin, roles, sitting, room, form, exam) cursor.callproc_none ("exam_assign_designate_seats", exam.exam_id, sitting.sitting_id) raise status.HTTPFound ("") exam_sitting_room_handler = delegate_action ( delegate_get_post (exam_sitting_room_html_get_handler, exam_sitting_room_html_post_handler), {'pdf': sitting_room_pdf_handler}) @delegate_get_post @return_html def sitting_room_index_handler (cursor, term, admin, roles, sitting): """Display list of rooms used by the sitting. :param cursor: DB connection cursor :param term: the relevant term :param admin: the relevant admin unit :param roles: the active permissions roles :param sitting: the relevant sitting :return: tuple of (sitting description, list of rooms used for the sitting in the assessment) :rtype: :rtype: (str, list) """ result = [format_return ('Main Menu', None, None, 'Offering', None, 'Sitting')] # sitting unseated candidates result.append (render_room_index (cursor.rooms_by_sitting (sitting_id=sitting.sitting_id), "", room_all_count_columns)) return '%s Rooms' % sitting.full_description, result @delegate_get_post @return_html def exam_sitting_room_index_handler (cursor, term, admin, roles, exam, sitting): """Display list of rooms for the sitting in the assessment. :param cursor: DB connection cursor :param term: the relevant term :param admin: the relevant admin unit :param roles: the active permissions roles :param exam: the relevant assessment :param sitting: the relevant sitting :return: tuple of (exam title and sitting description, list of rooms used for the sitting in the assessment) :rtype: (str, list) """ result = [format_return ('Main Menu', None, None, 'Offering', None, 'Assessment', None, 'Sitting')] # exam-sitting unseated candidates result.append (render_room_index (cursor.rooms_by_exam_sitting (exam_id=exam.exam_id, sitting_id=sitting.sitting_id), "", room_extra_count_columns)) return '%s: %s Rooms' % (exam.full_title, sitting.full_description), result