Skip to content

Commit

Permalink
Merge pull request #63 from dbosk/adds-more-summary-modules
Browse files Browse the repository at this point in the history
Refactors grades summary modules, adds disjunctive maximum
  • Loading branch information
dbosk authored Mar 12, 2022
2 parents 9c41da6 + c1dfe28 commit 93d0020
Show file tree
Hide file tree
Showing 12 changed files with 343 additions and 113 deletions.
1 change: 1 addition & 0 deletions doc/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ canvaslms.pdf: ${SRC_DIR}/cli/assignments.tex
canvaslms.pdf: ${SRC_DIR}/cli/submissions.tex
canvaslms.pdf: ${SRC_DIR}/cli/grade.tex
canvaslms.pdf: ${SRC_DIR}/cli/results.tex
canvaslms.pdf: ${SRC_DIR}/grades/grades.tex

${SRC_DIR}/%.tex: ${SRC_DIR}/%.nw
${MAKE} -C $(dir $@) $(notdir $@)
Expand Down
1 change: 1 addition & 0 deletions doc/canvaslms.tex
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ \part{The command-line interface}
\input{../src/canvaslms/cli/submissions.tex}
\input{../src/canvaslms/cli/grade.tex}
\input{../src/canvaslms/cli/results.tex}
\input{../src/canvaslms/grades/grades.tex}


\printbibliography
Expand Down
1 change: 1 addition & 0 deletions src/canvaslms/Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
SUBDIR+= cli
SUBDIR+= grades
SUBDIR+= hacks

INCLUDE_MAKEFILES=../../makefiles
Expand Down
1 change: 0 additions & 1 deletion src/canvaslms/cli/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,3 @@ login.py
login.tex
results.py
results.tex
summary.py
4 changes: 0 additions & 4 deletions src/canvaslms/cli/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,6 @@ all: assignments.py assignments.tex
all: submissions.py submissions.tex
all: grade.py grade.tex
all: results.py results.tex
all: summary.py

summary.py: results.nw
${NOTANGLE.py}

__init__.py: cli.py
mv $< $@
Expand Down
145 changes: 37 additions & 108 deletions src/canvaslms/cli/results.nw
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
\chapter{The \texttt{results} command}
\label{results-command}

This chapter provides the subcommand [[results]], which lists the results of a
course.
Expand All @@ -12,6 +13,7 @@ package\footnote{%

We outline the module:
<<results.py>>=
import canvaslms.cli
import canvaslms.cli.assignments as assignments
import canvaslms.cli.courses as courses
import canvaslms.cli.submissions as submissions
Expand All @@ -21,6 +23,10 @@ import argparse
import csv
import datetime as dt
import importlib
import importlib.machinery
import importlib.util
import os
import pathlib
import re
import sys

Expand All @@ -43,12 +49,8 @@ results_parser = subp.add_parser("results",
"<course code> <component code> <student ID> <grade> <grade date>.",
epilog="If you specify an assignment group, the results of the "
"assignments in that group will be summarized. You can supply your "
"own function for summarizing grades through the -S option. The "
"default works as follows: "
"All assignments must have a passing grade. If there are assignments "
"with A--F grading scales (in addition to P/F) the avergage of the "
"A--F grades will be used as final grade for the entire group. If any "
"assignment has an F, the whole group will evaluate to an F.")
"own function for summarizing grades through the -S option. "
"See `pydoc3 canvaslms.grades` for different options.")
results_parser.set_defaults(func=results_command)
assignments.add_assignment_option(results_parser, ungraded=False)
<<add option for custom summary module>>
Expand Down Expand Up @@ -189,15 +191,18 @@ Different teachers have different policies for merging several assignments into
one grade.
We now want to provide a way to override the default function.
<<add option for custom summary module>>=
default_summary_module = "canvaslms.grades.conjunctavg"
results_parser.add_argument("-S", "--summary-module",
required=False, default="canvaslms.cli.summary",
help="Name of Python module to load with a custom summarization function "
required=False, default=default_summary_module,
help="Name of Python module or file containing module to "
"load with a custom summarization function "
"to summarize assignment groups. The default module is part of the "
"`canvaslms` package: `canvaslms.cli.summary`. This module must contain "
"a function `summarize_group(assignments, users)`, where `assignments` "
"is a list of assignment `canvasapi.assignment.Assignment` objects and "
f"`canvaslms` package: `{default_summary_module}`. "
"This module must contain a function "
"`summarize_group(assignments, users)`, where `assignments` "
"is a list of `canvasapi.assignment.Assignment` objects and "
"`users` is a list of `canvasapi.user.User` objects. The return value "
"must be a tuple "
"must be a list of tuples of the form "
"`(user object, grade, grade date)`.")
@

Expand All @@ -207,103 +212,27 @@ An attacker can potentially load their own module and have it execute when
reporting grades.
For instance, a malicious module could change grades, \eg always set
A's.
<<load the correct summary module as summary>>=
summary = importlib.import_module(args.summary_module)
@


\subsection{The default results summarizing module}

We have one requirement on the summary module: it must contain a function
[[summarize_group]] that takes two arguments;
the first being a list of assignments,
the second being a list of users.
The [[summarize_group]] function is the function that the above code will call.
This gives the following outline of the module.
<<summary.py>>=
"""Module with a summary function for summarizing assignment groups"""

import datetime as dt

<<helper functions>>

def summarize_group(assignments_list, users_list):
"""Summarizes a particular set of assignments (assignments_list) for all
users in users_list"""

for user in users_list:
grade, grade_date = summarize(user, assignments_list)
yield (user, grade, grade_date)
@


\subsection{Summarizing grades: assignment grades to component grade}

Now we will describe the [[summarize]] helper function.
We want to establish two things: the most recent date and a suitable grade.

For the most recent date, we just check the dates as we iterate through the
submissions.

For the grade, as we iterate through we look for P/F and A--E grades.
We can then check for Fs among the P/F grades, if we find an F the summarized
grade will be an F.
If we find no Fs, then we can compute the average over all A--E grades and use
that as the final grade.
<<helper functions>>=
def summarize(user, assignments_list):
"""Extracts user's submissions for assignments in assingments_list to
summarize results into one grade and a grade date"""

pf_grades = []
a2e_grades = []
recent_date = dt.date(year=1970, month=1, day=1)

for assignment in assignments_list:
submission = assignment.get_submission(user)
grade = submission.grade

if grade is None:
grade = "F"

if grade in "ABCDE":
a2e_grades.append(grade)
else:
pf_grades.append(grade)

grade_date = submission.submitted_at or submission.graded_at

if not grade_date:
grade_date = recent_date
else:
grade_date = dt.date.fromisoformat(grade_date.split("T")[0])

if grade_date > recent_date:
recent_date = grade_date

if not all(map(lambda x: x == "P", pf_grades)):
return ("F", recent_date)

if a2e_grades:
return (a2e_average(a2e_grades), recent_date)
return ("P", recent_date)
Now to the loader, we first try to load a system module, then we look for a
module in the current working directory.
<<load the correct summary module as summary>>=
try:
summary = importlib.import_module(args.summary_module)
except ModuleNotFoundError:
module_path = pathlib.Path.cwd() / args.summary_module
module = module_path.stem

try:
loader = importlib.machinery.SourceFileLoader(
module, str(module_path))
spec = importlib.util.spec_from_loader(module, loader)
summary = importlib.util.module_from_spec(spec)
loader.exec_module(summary)
except Exception as err:
canvaslms.cli.err(1, f"Error loading summary module "
f"'{args.summary_module}': {err}")
@

To compute the average for the A--E grades; we will convert the grades into
integers, compute the average, round the value to an integer and convert back.
<<helper functions>>=
def a2e_average(grades):
"""Takes a list of A--E grades, returns the average."""
num_grades = map(grade_to_int, grades)
avg_grade = round(sum(num_grades)/len(grades))
return int_to_grade(avg_grade)

def grade_to_int(grade):
grade_map = {"E": 1, "D": 2, "C": 3, "B": 4, "A": 5}
return grade_map[grade]

def int_to_grade(int_grade):
grade_map_inv = {1: "E", 2: "D", 3: "C", 4: "B", 5: "A"}
return grade_map_inv[int_grade]
@
The available summary functions and the default one can be found in
\cref{summary-modules}.

6 changes: 6 additions & 0 deletions src/canvaslms/grades/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
grades.tex
mysum.py
conjunctavg.tex
conjunctavg.py
disjunctmax.tex
disjunctmax.py
25 changes: 25 additions & 0 deletions src/canvaslms/grades/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
NOWEAVEFLAGS.tex= -n -delay -t2
NOTANGLEFLAGS.py=


.PHONY: all
all: grades.tex
all: conjunctavg.py conjunctavg.tex
all: disjunctmax.py disjunctmax.tex

grades.tex: conjunctavg.tex
grades.tex: disjunctmax.tex


.PHONY: clean
clean:
${RM} grades.tex
${RM} conjunctavg.py conjunctavg.tex
${RM} disjunctmax.py disjunctmax.tex


INCLUDE_MAKEFILES=../../../makefiles
include ${INCLUDE_MAKEFILES}/tex.mk
include ${INCLUDE_MAKEFILES}/noweb.mk
include ${INCLUDE_MAKEFILES}/pkg.mk

19 changes: 19 additions & 0 deletions src/canvaslms/grades/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
"""
Package containing modules to summarize assignment groups in different ways.
For a module to be used with the `canvaslms results -S module` option, the
module must fulfil the following:
1) It must contain a function named `summarize_group`.
2) `summarize_group` must take two arguments:
I) `assignment_list`, a list of `canvasapi.assignment.Assignment`
objects.
II) `users_list`, a list of `canvasapi.user.User` objects.
3) The return value should be a list of tuples. Each tuple should have the
form `(user, grade, grade date)`.
See the built-in modules below.
"""
108 changes: 108 additions & 0 deletions src/canvaslms/grades/conjunctavg.nw
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
\section{Conjunctive average}

We have one requirement on the summary module: it must contain a function
[[summarize_group]] that takes two arguments;
the first being a list of assignments,
the second being a list of users.
The [[summarize_group]] function is the function that the above code will call.
This gives the following outline of the module.
<<conjunctavg.py>>=
"""
Module that summarizes an assignment group by conjunctive average.

Conjunctive average means:

1) We need all assignments to have a non-F grade.
2) If there are A--F assignments present, we will compute the average of
those grades. For instance; an A and a C will result in a B; an A and a B
will result in an A, but an A with two Bs will become a B (standard
rounding).
"""

import datetime as dt

<<helper functions>>

def summarize_group(assignments_list, users_list):
"""Summarizes a particular set of assignments (assignments_list) for all
users in users_list"""

for user in users_list:
grade, grade_date = summarize(user, assignments_list)
yield (user, grade, grade_date)
@


\subsection{Summarizing grades: assignment grades to component grade}

Now we will describe the [[summarize]] helper function.
We want to establish two things: the most recent date and a suitable grade.

For the most recent date, we just check the dates as we iterate through the
submissions.

For the grade, as we iterate through we look for P/F and A--E grades.
We can then check for Fs among the P/F grades, if we find an F the summarized
grade will be an F.
If we find no Fs, then we can compute the average over all A--E grades and use
that as the final grade.
<<helper functions>>=
def summarize(user, assignments_list):
"""Extracts user's submissions for assignments in assingments_list to
summarize results into one grade and a grade date. Summarize by conjunctive
average."""

pf_grades = []
a2e_grades = []
recent_date = dt.date(year=1970, month=1, day=1)

for assignment in assignments_list:
submission = assignment.get_submission(user)
grade = submission.grade

if grade is None:
grade = "F"

if grade in "ABCDE":
a2e_grades.append(grade)
else:
pf_grades.append(grade)

grade_date = submission.submitted_at or submission.graded_at

if not grade_date:
grade_date = recent_date
else:
grade_date = dt.date.fromisoformat(grade_date.split("T")[0])

if grade_date > recent_date:
recent_date = grade_date

if not all(map(lambda x: x == "P", pf_grades)):
return ("F", recent_date)

if a2e_grades:
return (a2e_average(a2e_grades), recent_date)
return ("P", recent_date)
@

\subsection{Computing averages}

To compute the average for the A--E grades; we will convert the grades into
integers, compute the average, round the value to an integer and convert back.
<<helper functions>>=
def a2e_average(grades):
"""Takes a list of A--E grades, returns the average."""
num_grades = map(grade_to_int, grades)
avg_grade = round(sum(num_grades)/len(grades))
return int_to_grade(avg_grade)

def grade_to_int(grade):
grade_map = {"E": 1, "D": 2, "C": 3, "B": 4, "A": 5}
return grade_map[grade]

def int_to_grade(int_grade):
grade_map_inv = {1: "E", 2: "D", 3: "C", 4: "B", 5: "A"}
return grade_map_inv[int_grade]
@

Loading

0 comments on commit 93d0020

Please sign in to comment.