diff --git a/server/constants.py b/server/constants.py index 3e5443757..3604eb439 100644 --- a/server/constants.py +++ b/server/constants.py @@ -20,6 +20,8 @@ 'regrade', 'revision', 'checkpoint 1', 'checkpoint 2', 'private', 'autograder', 'error'] +TIMESCALES = ['days', 'hours', 'minutes'] + API_PREFIX = '/api' OAUTH_SCOPES = ['all', 'email'] OAUTH_OUT_OF_BAND_URI = 'urn:ietf:wg:oauth:2.0:oob' diff --git a/server/controllers/admin.py b/server/controllers/admin.py index 8b091a970..48f26c720 100644 --- a/server/controllers/admin.py +++ b/server/controllers/admin.py @@ -30,7 +30,7 @@ import server.forms as forms import server.jobs as jobs from server.jobs import (example, export, moss, scores_audit, github_search, - scores_notify, checkpoint) + scores_notify, checkpoint, slips) import server.highlight as highlight import server.utils as utils @@ -974,6 +974,75 @@ def checkpoint_grading(cid, aid): form=form, ) +@admin.route("/course//assignments//slips", + methods=["GET", "POST"]) +@is_staff(course_arg='cid') +def calculate_assign_slips(cid, aid): + courses, current_course = get_courses(cid) + assign = Assignment.query.filter_by(id=aid, course_id=cid).one_or_none() + if not assign or not Assignment.can(assign, current_user, 'grade'): + flash('Cannot access assignment', 'error') + return abort(404) + + form = forms.AssignSlipCalculatorForm() + timescale = form.timescale.data.title() + if form.validate_on_submit(): + job = jobs.enqueue_job( + slips.calculate_assign_slips, + description='Calculate Slip {} for {}'.format(timescale, assign.display_name), + timeout=600, + course_id=cid, + user_id=current_user.id, + assign_id=assign.id, + timescale=timescale, + show_results=form.show_results.data, + result_kind='link', + ) + return redirect(url_for('.course_job', cid=cid, job_id=job.id)) + + return render_template( + 'staff/jobs/slips/slips.assign.html', + courses=courses, + current_course=current_course, + assignment=assign, + form=form, + ) + +@admin.route("/course//assignments/slips", + methods=["GET", "POST"]) +@is_staff(course_arg='cid') +def calculate_course_slips(cid): + courses, current_course = get_courses(cid) + assignments = current_course.assignments + + form = forms.CourseSlipCalculatorForm() + inactive_assigns = [a for a in assignments if not a.active] + form.assigns.choices = [(a.id, a.display_name) for a in inactive_assigns] + form.assigns.default = [a.id for a in inactive_assigns] + form.process(request.form) + + timescale = form.timescale.data.title() + if form.validate_on_submit(): + job = jobs.enqueue_job( + slips.calculate_course_slips, + description="Calculate Slip {} for {}'s Assignments" + .format(timescale, current_course.display_name), + timeout=600, + course_id=cid, + user_id=current_user.id, + timescale=timescale, + assigns=form.assigns.data, + show_results=form.show_results.data, + result_kind='link', + ) + return redirect(url_for('.course_job', cid=cid, job_id=job.id)) + + return render_template( + 'staff/jobs/slips/slips.course.html', + courses=courses, + current_course=current_course, + form=form, + ) ############## # Enrollment # diff --git a/server/forms.py b/server/forms.py index f5a872174..900e81489 100644 --- a/server/forms.py +++ b/server/forms.py @@ -15,7 +15,7 @@ from server import utils import server.canvas.api as canvas_api from server.models import Assignment, User, Client, Course, Message, CanvasCourse -from server.constants import (SCORE_KINDS, COURSE_ENDPOINT_FORMAT, +from server.constants import (SCORE_KINDS, TIMESCALES, COURSE_ENDPOINT_FORMAT, TIMEZONE, STUDENT_ROLE, ASSIGNMENT_ENDPOINT_FORMAT, COMMON_LANGUAGES, ROLE_DISPLAY_NAMES, OAUTH_OUT_OF_BAND_URI) @@ -558,6 +558,17 @@ class ExportAssignment(BaseForm): anonymize = BooleanField('Anonymize', default=False, description="Enable to remove identifying information from submissions") + +class AssignSlipCalculatorForm(BaseForm): + timescale = SelectField('Time Scale', default="days", + choices=[(c.lower(), c.title()) for c in TIMESCALES], + description="Time scale for slip calculation.") + show_results = BooleanField('Show Results', default=False) + +class CourseSlipCalculatorForm(AssignSlipCalculatorForm): + assigns = MultiCheckboxField('Completed Assignments ', coerce=int, + description="Select which completed assignments to calculate slips for.") + ########## # Canvas # ########## diff --git a/server/jobs/slips.py b/server/jobs/slips.py new file mode 100644 index 000000000..1fe8712be --- /dev/null +++ b/server/jobs/slips.py @@ -0,0 +1,106 @@ +import math +import io +import csv +from collections import defaultdict +from datetime import datetime as dt + +from server import jobs +from server.models import Assignment, ExternalFile +from server.utils import encode_id, local_time, output_csv_iterable +from server.constants import TIMESCALES + +timescales = {'days':86400, 'hours':3600, 'minutes':60} + +def timediff(created, deadline, timescale): + secs_over = (created - deadline).total_seconds() + return math.ceil(secs_over / timescales[timescale.lower()]) + + +def save_csv(csv_name, header, rows, show_results, user, course, logger): + logger.info('Outputting csv...\n') + csv_iterable = output_csv_iterable(header, rows) + + logger.info('Uploading...') + upload = ExternalFile.upload(csv_iterable, + user_id=user.id, course_id=course.id, name=csv_name, + prefix='jobs/slips/{}'.format(course.offering)) + logger.info('Saved as: {}'.format(upload.object_name)) + + download_link = "/files/{}".format(encode_id(upload.id)) + logger.info('Download link: {} (see "result" above)\n'.format(download_link)) + + if show_results: + logger.info('Results:\n') + csv_data = ''.join([row.decode('utf-8') for row in csv_iterable]) + logger.info(csv_data) + + return download_link + + +@jobs.background_job +def calculate_course_slips(assigns, timescale, show_results): + logger = jobs.get_job_logger() + logger.info('Calculating Slip {}...\n'.format(timescale.title())) + + job = jobs.get_current_job() + user = job.user + course = job.course + assigns_set = set(assigns) + assigns = (a for a in course.assignments if a.id in assigns_set) + + course_slips = defaultdict(list) + for i, assign in enumerate(assigns, 1): + logger.info('Processing {} ({} of {})...' + .format(assign.display_name, i, len(assigns_set))) + subms = assign.course_submissions(include_empty=False) + deadline = assign.due_date + assign_slips = {} + for subm in subms: + email = subm['user']['email'] + created = subm['backup']['created'] + slips = max(0, timediff(created, deadline, timescale)) + assign_slips[email] = [(assign.display_name, slips)] + course_slips = {k:course_slips[k] + assign_slips[k] + for k in course_slips.keys() | assign_slips.keys()} + + def get_row(email, assign_slips): + total_slips = sum((s for a, s in assign_slips)) + assignments = ', '.join([a for a, s in assign_slips if s > 0]) + return (email, total_slips, assignments) + + header = ( + 'User Email', + 'Slip {} Used'.format(timescale.title()), + 'Late Assignments') + rows = (get_row(*user_slips) for user_slips in course_slips.items()) + created_time = local_time(dt.now(), course, fmt='%m-%d-%I-%M-%p') + csv_name = '{}_{}.csv'.format(course.offering.replace('/', '-'), created_time) + + return save_csv(csv_name, header, rows, show_results, user, course, logger) + + +@jobs.background_job +def calculate_assign_slips(assign_id, timescale, show_results): + logger = jobs.get_job_logger() + logger.info('Calculating Slip {}...'.format(timescale.title())) + + user = jobs.get_current_job().user + assignment = Assignment.query.get(assign_id) + course = assignment.course + subms = assignment.course_submissions(include_empty=False) + deadline = assignment.due_date + + def get_row(subm): + email = subm['user']['email'] + created = subm['backup']['created'] + slips = max(0, timediff(created, deadline, timescale)) + return (email, slips) + + header = ( + 'User Email', + 'Slip {} Used'.format(timescale.title())) + rows = (get_row(subm) for subm in subms) + created_time = local_time(dt.now(), course, fmt='%m-%d-%I-%M-%p') + csv_name = '{}_{}.csv'.format(assignment.name.replace('/', '-'), created_time) + + return save_csv(csv_name, header, rows, show_results, user, course, logger) \ No newline at end of file diff --git a/server/templates/staff/course/assignment/assignment.html b/server/templates/staff/course/assignment/assignment.html index 2904f57f8..6f0f649d8 100644 --- a/server/templates/staff/course/assignment/assignment.html +++ b/server/templates/staff/course/assignment/assignment.html @@ -92,6 +92,9 @@

Actions

  • Grant Extension
  • +
  • + Calculate Slips +
  • Configure Autograder
  • diff --git a/server/templates/staff/course/assignment/assignments.html b/server/templates/staff/course/assignment/assignments.html index bdfdb9edb..2a10d0b49 100644 --- a/server/templates/staff/course/assignment/assignments.html +++ b/server/templates/staff/course/assignment/assignments.html @@ -19,6 +19,25 @@

    {% include 'alerts.html' %} + +
    +
    +

    Actions

    +
    + +
    +
    + + +
    +
    @@ -66,9 +85,8 @@

    Active Assignments

    Completed Assignments

    -
    -
    diff --git a/server/templates/staff/jobs/slips/slips.assign.html b/server/templates/staff/jobs/slips/slips.assign.html new file mode 100644 index 000000000..08047151b --- /dev/null +++ b/server/templates/staff/jobs/slips/slips.assign.html @@ -0,0 +1,44 @@ +{% extends "staff/base.html" %} +{% import "staff/_formhelpers.html" as forms %} + +{% block title %} Calculate Slips for {{ assignment.display_name }} {% endblock %} + +{% block main %} +
    +

    + Calculate Slips for {{ assignment.display_name }} + {{ current_course.offering }} +

    + +
    +
    + {% include 'alerts.html' %} +
    +
    +
    +
    +

    Calculate slips and save as a .csv file.

    + {% call forms.render_form(form, action_text='Calculate Slips') %} + {{ forms.render_field(form.timescale) }} + {{ forms.render_checkbox_field(form.show_results) }} + {% endcall %} +
    +
    +
    +
    +
    +{% endblock %} diff --git a/server/templates/staff/jobs/slips/slips.course.html b/server/templates/staff/jobs/slips/slips.course.html new file mode 100644 index 000000000..365509541 --- /dev/null +++ b/server/templates/staff/jobs/slips/slips.course.html @@ -0,0 +1,44 @@ +{% extends "staff/base.html" %} +{% import "staff/_formhelpers.html" as forms %} + +{% block title %} Calculate Slips for {{ current_course.display_name }} {% endblock %} + +{% block main %} +
    +

    + Calculate Slips for {{ current_course.display_name }} + {{ current_course.offering }} +

    + +
    +
    + {% include 'alerts.html' %} +
    +
    +
    +
    +

    Calculate slips and save as a .csv file.

    + {% call forms.render_form(form, action_text='Calculate Slips') %} + {{ forms.render_field(form.timescale) }} + {{ forms.render_checkbox_field(form.assigns) }} + {{ forms.render_checkbox_field(form.show_results) }} + {% endcall %} +
    +
    +
    +
    +
    +{% endblock %} diff --git a/server/utils.py b/server/utils.py index 2c1d80bb6..481e1bd70 100644 --- a/server/utils.py +++ b/server/utils.py @@ -236,6 +236,16 @@ def chunks(l, n): prev_index = index +def output_csv_iterable(header, rows): + """ Generate csv string for given header list and list of rows (lists). """ + output = StringIO() + writer = csv.writer(output, quoting=csv.QUOTE_NONNUMERIC) + writer.writerow(header) + [writer.writerow(row) for row in rows] + rows = output.getvalue().split('\r\n') + return [bytes(row + '\r\n', 'utf-8') for row in rows] + + def generate_csv(query, items, selector_fn): """ Generate csv export of scores for assignment. selector_fn: 1 arg function that returns a list of dictionaries