Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Slip Calculator Jobs for Assignment and Course #1148

Open
wants to merge 11 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions server/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
71 changes: 70 additions & 1 deletion server/controllers/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -974,6 +974,75 @@ def checkpoint_grading(cid, aid):
form=form,
)

@admin.route("/course/<int:cid>/assignments/<int:aid>/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()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suggest keeping the timescale lowercase, so it matches up with the TIMESCALES enum. And you won't need the .lower() on L16 in slips.py

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/<int:cid>/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 #
Expand Down
13 changes: 12 additions & 1 deletion server/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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],
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

c.lower() seems redundant here, as the TIMESCALES enum is already lowercase

description="Time scale for slip calculation.")
show_results = BooleanField('Show Results', default=False)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if this is useful as an option. We should either always show the results, or don't


class CourseSlipCalculatorForm(AssignSlipCalculatorForm):
assigns = MultiCheckboxField('Completed Assignments ', coerce=int,
description="Select which completed assignments to calculate slips for.")

##########
# Canvas #
##########
Expand Down
106 changes: 106 additions & 0 deletions server/jobs/slips.py
Original file line number Diff line number Diff line change
@@ -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}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should go in constants as TIMESCALES, so there's not both TIMESCALES and timescales that have the same keys

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did this originally, but realized if I would then have to use the dictionary keys to fill in the form options, which wouldn't have a guaranteed ordering.

Now that I've thought about it, I think I'll just use an OrderedDict to preserve the ordering of the keys for display on the form.


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)
3 changes: 3 additions & 0 deletions server/templates/staff/course/assignment/assignment.html
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,9 @@ <h3 class="box-title">Actions</h3>
<li> <a href="{{ url_for('.create_extension', cid=current_course.id, aid=assignment.id) }}" type="button">
<i class="fa fa-calendar"></i> Grant Extension
</a></li>
<li> <a href="{{ url_for('.calculate_assign_slips', cid=current_course.id, aid=assignment.id) }}" type="button">
<i class="fa fa-calculator"></i> Calculate Slips
</a></li>
<li> <a href="http://autograder.cs61a.org" target="_blank" type="button">
<i class="fa fa-gear"></i> Configure Autograder
</a></li>
Expand Down
22 changes: 20 additions & 2 deletions server/templates/staff/course/assignment/assignments.html
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,25 @@ <h1>
<section class="content">
{% include 'alerts.html' %}

<!-- Default box -->
<div class="box box-solid">
<div class="box-header with-border">
<h3 class="box-title">Actions</h3>
<div class="box-tools pull-right">
<button type="button" class="btn btn-box-tool" data-widget="collapse" data-toggle="tooltip" title="" data-original-title="Collapse">
<i class="fa fa-minus"></i></button>
</div>
</div>
<div class="box-body">
<ul class="nav nav-pills">
<li> <a href="{{url_for('.calculate_course_slips', cid=current_course.id)}}" type="button">
<i class="fa fa-calculator"></i> Calculate Slips
</a></li>
</ul>
</div>
<!-- /.box-body -->
</div>

<!-- Default box -->
<div class="box">
<div class="box-header with-border">
Expand Down Expand Up @@ -66,9 +85,8 @@ <h3 class="box-title">Active Assignments</h3>
<div class="box">
<div class="box-header with-border">
<h3 class="box-title">Completed Assignments</h3>

<div class="box-tools pull-right">
<button type="button" class="btn btn-box-tool" data-widget="collapse" data-toggle="tooltip" title="Collapse">
<button type="button" class="btn btn-box-tool" data-widget="collapse" data-toggle="tooltip" title="" data-original-title="Collapse">
<i class="fa fa-minus"></i></button>
</div>
</div>
Expand Down
44 changes: 44 additions & 0 deletions server/templates/staff/jobs/slips/slips.assign.html
Original file line number Diff line number Diff line change
@@ -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 %}
<section class="content-header">
<h1>
Calculate Slips for {{ assignment.display_name }}
<small>{{ current_course.offering }}</small>
</h1>
<ol class="breadcrumb">
<li><a href="{{ url_for(".course", cid=current_course.id) }}">
<i class="fa fa-university"></i> {{ current_course.offering }}
</a></li>
<li><a href="{{ url_for('.course_assignments', cid=current_course.id) }}">
<i class="fa fa-list"></i> Assignments</a>
</li>
<li> <a href="{{ url_for('.assignment', cid=current_course.id, aid=assignment.id) }}"><i class="fa fa-book"></i> {{ assignment.display_name }} </a></li>
<li><a href="{{ url_for(".course_jobs", cid=current_course.id) }}">
<i class="fa fa-list"></i>Jobs
</a></li>
<li class="active"><a href="#">
<i class="fa fa-inbox"></i>Calculate Slips</a>
</li>
</ol>
</section>
<section class="content">
{% include 'alerts.html' %}
<div class="row">
<div class="col-md-12">
<div class="box">
<div class="box-body">
<p> Calculate slips and save as a .csv file. </p>
{% call forms.render_form(form, action_text='Calculate Slips') %}
{{ forms.render_field(form.timescale) }}
{{ forms.render_checkbox_field(form.show_results) }}
{% endcall %}
</div>
</div>
</div>
</div>
</section>
{% endblock %}
44 changes: 44 additions & 0 deletions server/templates/staff/jobs/slips/slips.course.html
Original file line number Diff line number Diff line change
@@ -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 %}
<section class="content-header">
<h1>
Calculate Slips for {{ current_course.display_name }}
<small>{{ current_course.offering }}</small>
</h1>
<ol class="breadcrumb">
<li><a href="{{ url_for(".course", cid=current_course.id) }}">
<i class="fa fa-university"></i> {{ current_course.offering }}
</a></li>
<li><a href="{{ url_for('.course_assignments', cid=current_course.id) }}">
<i class="fa fa-list"></i> Assignments</a>
</li>
<li><a href="{{ url_for(".course_jobs", cid=current_course.id) }}">
<i class="fa fa-list"></i>Jobs
</a></li>
<li class="active"><a href="#">
<i class="fa fa-inbox"></i>Calculate Slips</a>
</li>
</ol>
</section>
<section class="content">
{% include 'alerts.html' %}
<div class="row">
<div class="col-md-12">
<div class="box">
<div class="box-body">
<p> Calculate slips and save as a .csv file. </p>
{% 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 %}
</div>
</div>
</div>
</div>
</section>
{% endblock %}
10 changes: 10 additions & 0 deletions server/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,16 @@ def chunks(l, n):
prev_index = index


def output_csv_iterable(header, rows):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is too similar to generate_csv below to be another function. It has other problems: it uses Windows line endings, and will buffer the whole thing in memory (which isn't really an issue for these jobs, but it defeats the while purpose of using iterables).

Instead, try to use generate_csv. It would looks something like

    csv = generate_csv(subms, header, get_row)

where subms is the thing you're iterating over, and get_row is a function that returns a dict of CSV values.

""" 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
Expand Down