Skip to content

Commit

Permalink
Improve gradebook csv exportation
Browse files Browse the repository at this point in the history
  • Loading branch information
dzhuang committed Feb 9, 2018
1 parent c53d8bb commit f204428
Show file tree
Hide file tree
Showing 4 changed files with 243 additions and 38 deletions.
255 changes: 223 additions & 32 deletions course/grades.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@

from typing import cast

from django.conf import settings
from django.utils.translation import (
ugettext_lazy as _, pgettext_lazy, ugettext)
from django.shortcuts import ( # noqa
Expand Down Expand Up @@ -62,7 +63,7 @@
# {{{ for mypy

if False:
from typing import Tuple, Text, Optional, Any, Iterable, List # noqa
from typing import Tuple, Text, Optional, Any, Iterable, List, Union, Dict # noqa
from course.utils import CoursePageContext # noqa
from course.content import FlowDesc # noqa
from course.models import Course, FlowPageVisitGrade # noqa
Expand Down Expand Up @@ -203,8 +204,17 @@ def __init__(self, opportunity, grade_state_machine):
self.grade_state_machine = grade_state_machine


def get_grade_table(course):
# type: (Course) -> Tuple[List[Participation], List[GradingOpportunity], List[List[GradeInfo]]] # noqa
def get_grade_table(course, excluded_roles=None):
# type: (Course, Optional[List[Text]]) -> Tuple[List[Participation], List[GradingOpportunity], List[List[GradeInfo]]] # noqa

participations_exlcude_kwargs = {} # type: Dict
grade_changes_exclude_kwargs = {} # type: Dict
if excluded_roles:
assert isinstance(excluded_roles, (list, tuple))
participations_exlcude_kwargs = {
"roles__identifier__in": excluded_roles}
grade_changes_exclude_kwargs = {
"participation__roles__identifier__in": excluded_roles}

# NOTE: It's important that these queries are sorted consistently,
# also consistently with the code below.
Expand All @@ -219,13 +229,15 @@ def get_grade_table(course):
.filter(
course=course,
status=participation_status.active)
.exclude(**participations_exlcude_kwargs)
.order_by("id")
.select_related("user"))

grade_changes = list(GradeChange.objects
.filter(
opportunity__course=course,
opportunity__shown_in_grade_book=True)
.exclude(**grade_changes_exclude_kwargs)
.order_by(
"participation__id",
"opportunity__identifier",
Expand Down Expand Up @@ -295,43 +307,222 @@ def grade_key(entry):
})


class ExportGradeBookForm(StyledForm):
def __init__(self, user_info_fields_options, prefered_encodings=None,
*args, **kwargs):
# type: (Tuple[Union[List[Text], Tuple[Text]], Text], Optional[Union[List[Text], Tuple[Text]]], *Any, **Any) -> None # noqa
super(ExportGradeBookForm, self).__init__(*args, **kwargs)

self.fields["user_info_fields"] = forms.ChoiceField(
choices=(user_info_fields_options),
initial=user_info_fields_options[0],
label=_("User Fields to Export"),
)

self.fields["exclude_instructors"] = forms.BooleanField(
required=False,
initial=True,
label=_("Exclude Grades of Instructors")
)

self.fields["exclude_tas"] = forms.BooleanField(
required=False,
initial=True,
label=_("Exclude Grades of Teaching assistants")
)

self.fields["zero_for_none"] = forms.BooleanField(
required=False,
initial=True,
label=_("Use 0 for `NONE` points")
)

self.fields["maximum_points"] = forms.FloatField(
required=True,
initial=100,
label=_("Maximum allowed points")
)

self.fields["minimum_points"] = forms.FloatField(
required=True,
initial=0,
label=_("Minimum points")
)

self.fields["round_digits"] = forms.IntegerField(
required=True,
min_value=0,
initial=2,
max_value=4,
label=_("Round Digits")
)

if len(user_info_fields_options) == 1:
self.fields["user_info_fields"].widget = forms.HiddenInput()

if prefered_encodings is not None:
encoding_choices = []
for i, ecd in enumerate(prefered_encodings):
desc = ecd
if i == 0:
desc = string_concat(_("Default"), " ('%s')" % ecd)
encoding_choices.append((ecd, desc))
if "utf-8" not in prefered_encodings:
encoding_choices.append(('utf-8', 'utf-8'))

self.fields["encoding_used"] = forms.ChoiceField(
choices=tuple(encoding_choices),
label=_("Using Encoding"),
help_text=_(
"The encoding used for the exported file."),
)

self.helper.add_input(Submit("download", _("Download")))

def clean(self):
super(ExportGradeBookForm, self).clean()
maximum_points = self.cleaned_data["maximum_points"]
minimum_points = self.cleaned_data["minimum_points"]
if not maximum_points > minimum_points:
raise forms.ValidationError(
_("'maximum_points' must be greater than 'minimum_points'."))

return self.cleaned_data

def clean_user_info_fields(self):
user_info_fields_str = self.cleaned_data["user_info_fields"]
user_info_fields = user_info_fields_str.split(",")
return user_info_fields


def get_user_info_fields_choices():
user_info_fields_choices = getattr(
settings,
"RELATE_EXPORT_GRADEBOOK_CSV_OUTPUT_FIELDS_CHIOCES",
(['username', 'last_name', 'first_name'],))

from django.contrib.auth import get_user_model
from django.utils.encoding import force_text
user_model = get_user_model()
options = []
for choice in user_info_fields_choices:
choices_desc = []
errors = []
for item in choice:
if item == "get_full_name":
choices_desc.append(ugettext(_("Full name")))
else:
try:
choices_desc.append(
force_text(user_model._meta.get_field(item).verbose_name))
except Exception as e:
errors.append(e)

if errors:
from django.core.exceptions import ImproperlyConfigured
raise ImproperlyConfigured(
"; ".join(["%s: %s" % (type(e).__name__, str(e)) for e in errors])
)
options.append((",".join(choice), ", ".join(choices_desc)))
return tuple(options)


@course_view
def export_gradebook_csv(pctx):
if not pctx.has_permission(pperm.batch_export_grade):
raise PermissionDenied(_("may not batch-export grades"))

participations, grading_opps, grade_table = get_grade_table(pctx.course)
request = pctx.request

prefered_encodings = getattr(settings, "RELATE_PREFERED_CSV_ENCODING", None)
user_info_fields = get_user_info_fields_choices()

if request.method == "POST":
form = ExportGradeBookForm(
user_info_fields, prefered_encodings, request.POST)

if form.is_valid():
excluded_roles = []
if form.cleaned_data["exclude_instructors"]:
excluded_roles.append("instructor")
if form.cleaned_data["exclude_tas"]:
excluded_roles.append("ta")

if prefered_encodings is not None:
encoding_used = form.cleaned_data["encoding_used"]
else:
encoding_used = "utf-8"

from six import StringIO
csvfile = StringIO()
participations, grading_opps, grade_table = (
get_grade_table(pctx.course, excluded_roles))

from six import StringIO
csvfile = StringIO()

if six.PY2:
import unicodecsv as csv
else:
import csv

info_fields = form.cleaned_data["user_info_fields"]
print("here")
print(type(info_fields))
print(info_fields)

fieldnames = info_fields + [
gopp.identifier for gopp in grading_opps]

writer = csv.writer(csvfile)

writer.writerow(fieldnames)

alias_for_none = None
if form.cleaned_data["zero_for_none"]:
alias_for_none = "0"

def callback_for_percentage(percentage):
maximum_points = form.cleaned_data["maximum_points"]
minimum_points = form.cleaned_data["minimum_points"]
round_digits = form.cleaned_data["round_digits"]
assert maximum_points >= minimum_points

if percentage > maximum_points:
percentage = maximum_points
if percentage < minimum_points:
percentage = minimum_points
return round(percentage, round_digits)

# def get_user_info(user):
# user_info = []
# for attr in info_fields:
# info = getattr(user, attr)
# user_info.append(info)
# print(user_info)
# return user_info

for participation, grades in zip(participations, grade_table):
writer.writerow(
[getattr(participation.user, attr) for attr in info_fields]
+ [grade_info.grade_state_machine.stringify_machine_readable_state( # noqa
alias_for_none=alias_for_none,
callback_for_percentage=callback_for_percentage)
for grade_info in grades])

response = http.HttpResponse(
csvfile.getvalue().encode(encoding_used),
content_type="text/plain; charset=utf-8")
response['Content-Disposition'] = (
'attachment; filename="grades-%s.csv"'
% pctx.course.identifier)
return response

if six.PY2:
import unicodecsv as csv
else:
import csv

fieldnames = ['user_name', 'last_name', 'first_name'] + [
gopp.identifier for gopp in grading_opps]

writer = csv.writer(csvfile)

writer.writerow(fieldnames)

for participation, grades in zip(participations, grade_table):
writer.writerow([
participation.user.username,
participation.user.last_name,
participation.user.first_name,
] + [grade_info.grade_state_machine.stringify_machine_readable_state()
for grade_info in grades])

response = http.HttpResponse(
csvfile.getvalue().encode("utf-8"),
content_type="text/plain; charset=utf-8")
response['Content-Disposition'] = (
'attachment; filename="grades-%s.csv"'
% pctx.course.identifier)
return response
form = ExportGradeBookForm(user_info_fields, prefered_encodings)

return render_course_page(pctx, "course/generic-course-form.html", {
"form": form,
"form_description": _("Export Gradebook as a CSV File")
})

# }}}

Expand Down
15 changes: 11 additions & 4 deletions course/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1759,16 +1759,23 @@ def stringify_state(self):
else:
return "_((other state))"

def stringify_machine_readable_state(self):
def stringify_machine_readable_state(self, alias_for_none=None,
callback_for_percentage=None):
if alias_for_none is None:
alias_for_none = u"NONE"

if callback_for_percentage is None:
callback_for_percentage = lambda x: "%.3f" % x # noqa

if self.state is None:
return u"NONE"
return alias_for_none
elif self.state == grade_state_change_types.exempt:
return "EXEMPT"
elif self.state == grade_state_change_types.graded:
if self.valid_percentages:
return "%.3f" % self.percentage()
return callback_for_percentage(self.percentage())
else:
return u"NONE"
return alias_for_none
else:
return u"OTHER_STATE"

Expand Down
5 changes: 5 additions & 0 deletions local_settings.example.py
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,11 @@

# }}}

RELATE_EXPORT_GRADEBOOK_CSV_OUTPUT_FIELDS_CHIOCES = (
['username', 'last_name', 'first_name'],
['username', 'last_name', 'first_name', 'institutional_id'],
)

# {{{ docker

# A string containing the image ID of the docker image to be used to run
Expand Down
6 changes: 4 additions & 2 deletions tests/test_grades.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,10 @@ def test_view_export_gradebook_csv(self):
resp = self.c.get(reverse("relate-export_gradebook_csv",
args=[self.course.identifier]))
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp["Content-Disposition"],
'attachment; filename="grades-test-course.csv"')

# TODO: test post
# self.assertEqual(resp["Content-Disposition"],
# 'attachment; filename="grades-test-course.csv"')

def test_view_grades_by_opportunity(self):
# Check attributes
Expand Down

0 comments on commit f204428

Please sign in to comment.