diff --git a/server/constants.py b/server/constants.py index 119a349c2..a16f7ca56 100644 --- a/server/constants.py +++ b/server/constants.py @@ -1,5 +1,6 @@ """App constants""" import os +from collections import OrderedDict STUDENT_ROLE = 'student' GRADER_ROLE = 'grader' @@ -21,6 +22,8 @@ 'regrade', 'revision', 'checkpoint 1', 'checkpoint 2', 'private', 'autograder', 'error'] +TIMESCALES = OrderedDict([('days', 86400), ('hours', 3600), ('minutes', 60)]) + 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 b615f4af3..299dcd4ae 100644 --- a/server/controllers/admin.py +++ b/server/controllers/admin.py @@ -33,9 +33,8 @@ from server.extensions import cache import server.forms as forms import server.jobs as jobs -from server.jobs import (example, export, moss, scores_audit, github_search, - scores_notify, checkpoint, effort, upload_scores, - export_grades) +from server.jobs import (example, export, moss, scores_audit, github_search, scores_notify, + checkpoint, effort, upload_scores, export_grades, slips) import server.highlight as highlight import server.utils as utils @@ -1290,6 +1289,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 ce8d4f7c7..757badbf2 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) @@ -769,6 +769,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.keys()], + 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..ef8f8b087 --- /dev/null +++ b/server/jobs/slips.py @@ -0,0 +1,147 @@ +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, User +from server.utils import encode_id, local_time, generate_csv +from server.constants import TIMESCALES + +""" + TODO: + - Support for timezone in filename? + - Remake templates as specified in the old pull request? +""" + + +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') + + def selector_fn(lst): + if len(lst) != len(header): + raise IndexError(str(lst) + " " + str(header)) + result = {} + for i in range(len(lst)): + result[header[i]] = lst[i] + return [result] + + csv_iterable = list(map(lambda x: bytes(x, 'utf-8'), generate_csv(rows, header, selector_fn))) + + logger.info('Uploading...') + upload = ExternalFile.upload(csv_iterable, + user_id=user.id, course_id=course.id, name=csv_name, + prefix='slips_') + 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] + rows = [] + + enrollments = job.course.get_students() + for enrollment in enrollments: + sid = enrollment.sid + student = enrollment.user + email = student.email + row = [sid, email] + student_id = student.id + logger.info('Processing {}\'s submissions'.format(email)) + for assignment in assigns: + deadline = assignment.due_date + subm = assignment.final_submission([student_id]) + if subm: + created = subm.submission_time + slips = max(0, timediff(created, deadline, timescale)) + else: + slips = 0 + row.append(slips) + rows.append(row) + + header = [ + 'User SID', + 'User Email', + ] + for assignment in assigns: + assign_name = assignment.display_name + header.append('Slip {} Used on '.format(timescale.title()) + + assign_name) + + created_time = local_time(dt.now(), course, fmt='%m-%d_%I-%M-%p') + csv_name = '{}_{}.csv'.format(course.display_name.replace('/', '-'), created_time) + + return save_csv(csv_name, header, rows, show_results, user, course, logger) + + +def get_students_with_submission(assignment): + """Get a list of IDs of students who have made a submission + for the current assignment. + + :param ASSIGNMENT instance of the model Assignment + + This code is copied from the assignment_stats() method + in the Assignment model methods. May need refactoring.""" + + data = assignment.course_submissions() + students_ids = set(s['user']['id'] for s in data if s['backup'] and s['backup']['submit']) + return students_ids + + +@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 + students_ids = get_students_with_submission(assignment) + subms = [] + for id in students_ids: + subm = assignment.final_submission([id]) + if subm: + subms.append(subm) + deadline = assignment.due_date + rows = [] + for subm in subms: + curr_user = subm.submitter + enrollment = curr_user.enrollments()[0] + sid = enrollment.sid + email = curr_user.email + created = subm.submission_time + slips = max(0, timediff(created, deadline, timescale)) + if slips > 0: + rows.append([sid, email, slips]) + + header = [ + 'User SID', + 'User Email', + 'Slip {} Used'.format(timescale.title())] + created_time = local_time(dt.now(), course, fmt='%m-%d_%I-%M-%p') + csv_name = '{}_{}.csv'.format(assignment.display_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 9f0503699..a9ca982b4 100644 --- a/server/templates/staff/course/assignment/assignment.html +++ b/server/templates/staff/course/assignment/assignment.html @@ -96,7 +96,11 @@

Actions

Effort Grading
  • - Configure Autograder + Configure Autograder +
  • + +
  • + Calculate Slips
  • {% call forms.render_form_bare(CSRFForm(), action_url=url_for('.autograde', cid=current_course.id, aid=assignment.id), class_='form') %} 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 7fc7631f7..5269ce35d 100644 --- a/server/utils.py +++ b/server/utils.py @@ -256,7 +256,6 @@ def chunks(l, n): yield l[prev_index:index] prev_index = index - 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