diff --git a/doc/Makefile b/doc/Makefile index eb420a1..508408f 100644 --- a/doc/Makefile +++ b/doc/Makefile @@ -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 $@) diff --git a/doc/canvaslms.tex b/doc/canvaslms.tex index 82100af..36574b6 100644 --- a/doc/canvaslms.tex +++ b/doc/canvaslms.tex @@ -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 diff --git a/src/canvaslms/Makefile b/src/canvaslms/Makefile index 8a846b5..5104bee 100644 --- a/src/canvaslms/Makefile +++ b/src/canvaslms/Makefile @@ -1,4 +1,5 @@ SUBDIR+= cli +SUBDIR+= grades SUBDIR+= hacks INCLUDE_MAKEFILES=../../makefiles diff --git a/src/canvaslms/cli/.gitignore b/src/canvaslms/cli/.gitignore index 167c569..45f1e61 100644 --- a/src/canvaslms/cli/.gitignore +++ b/src/canvaslms/cli/.gitignore @@ -20,4 +20,3 @@ login.py login.tex results.py results.tex -summary.py diff --git a/src/canvaslms/cli/Makefile b/src/canvaslms/cli/Makefile index 1dae1de..75e9a59 100644 --- a/src/canvaslms/cli/Makefile +++ b/src/canvaslms/cli/Makefile @@ -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 $< $@ diff --git a/src/canvaslms/cli/results.nw b/src/canvaslms/cli/results.nw index fdb9a4f..9ec086d 100644 --- a/src/canvaslms/cli/results.nw +++ b/src/canvaslms/cli/results.nw @@ -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. @@ -12,6 +13,7 @@ package\footnote{% We outline the module: <>= +import canvaslms.cli import canvaslms.cli.assignments as assignments import canvaslms.cli.courses as courses import canvaslms.cli.submissions as submissions @@ -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 @@ -43,12 +49,8 @@ results_parser = subp.add_parser("results", " .", 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) <> @@ -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. <>= +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)`.") @ @@ -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. -<>= -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. -<>= -"""Module with a summary function for summarizing assignment groups""" - -import datetime as dt - -<> - -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. -<>= -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. +<>= +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. -<>= -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}. diff --git a/src/canvaslms/grades/.gitignore b/src/canvaslms/grades/.gitignore new file mode 100644 index 0000000..d59dbab --- /dev/null +++ b/src/canvaslms/grades/.gitignore @@ -0,0 +1,6 @@ +grades.tex +mysum.py +conjunctavg.tex +conjunctavg.py +disjunctmax.tex +disjunctmax.py diff --git a/src/canvaslms/grades/Makefile b/src/canvaslms/grades/Makefile new file mode 100644 index 0000000..89eb8c3 --- /dev/null +++ b/src/canvaslms/grades/Makefile @@ -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 + diff --git a/src/canvaslms/grades/__init__.py b/src/canvaslms/grades/__init__.py new file mode 100644 index 0000000..60c8186 --- /dev/null +++ b/src/canvaslms/grades/__init__.py @@ -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. +""" diff --git a/src/canvaslms/grades/conjunctavg.nw b/src/canvaslms/grades/conjunctavg.nw new file mode 100644 index 0000000..baad0c4 --- /dev/null +++ b/src/canvaslms/grades/conjunctavg.nw @@ -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. +<>= +""" +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 + +<> + +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. +<>= +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. +<>= +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] +@ + diff --git a/src/canvaslms/grades/disjunctmax.nw b/src/canvaslms/grades/disjunctmax.nw new file mode 100644 index 0000000..8d1f9c4 --- /dev/null +++ b/src/canvaslms/grades/disjunctmax.nw @@ -0,0 +1,99 @@ +\section{Disjunctive maximum} + +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. +<>= +""" +Module that summarizes an assignment group by disjunctive maximum. + +Disjunctive maximum means: + + 1) At least one assignment must have a non-F grade. + 2) If there are more than one assignment with a non-F grade, we take the + maximum as the grade. A--E are valued higher than P. The grade F is valued + the lowest. +""" + +import datetime as dt + +<> + +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. +<>= +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 disjunctive + maximum.""" + + 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" + + 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 + + return (grade_max(grades), recent_date) +@ + +\subsection{Computing the maximum} + +To compute the maximum for the A--E grades; we will convert the grades into +integers, compute the maximum, round the value to an integer and convert back. +We also include P/F here, since we can count them as lower than A--E. +<>= +def grade_max(grades): + """Takes a list of A--E/P--F grades, returns the maximum.""" + num_grades = map(grade_to_int, grades) + max_grade = max(num_grades) + return int_to_grade(max_grade) + +def grade_to_int(grade): + grade_map = {"F": -1, "P": 0, "E": 1, "D": 2, "C": 3, "B": 4, "A": 5} + return grade_map[grade] + +def int_to_grade(int_grade): + grade_map_inv = {-1: "F", 0: "P", 1: "E", 2: "D", 3: "C", 4: "B", 5: "A"} + return grade_map_inv[int_grade] +@ + diff --git a/src/canvaslms/grades/grades.nw b/src/canvaslms/grades/grades.nw new file mode 100644 index 0000000..c5da5be --- /dev/null +++ b/src/canvaslms/grades/grades.nw @@ -0,0 +1,46 @@ +\chapter{Computing grades from groups of assignments} +\label{summary-modules} + +This is the documentation for the \texttt{canvaslms.grades} package. +Here we provide modules to be used with the \texttt{-S} option for the +\texttt{results} command, see \cref{results-command}. + +For a module to be used, it must contain a function named +\texttt{summarize\textunderscore group}. +The function must take two arguments: +\begin{enumerate} + \item a list of assignments that all belong to the same group, \ie the + assignments whose grades should be used to compute the student's grade. + \item a list of users, \ie students, for whom to compute the grades. +\end{enumerate} +See the modules below for examples. + +Let's look at a simple example. +This small module just returns a counter as grade: it starts at 0, increases +one per student. +The grading date is set to today's date for all students. +We don't even look at the students' submissions for these assignments. +<>= +import datetime as dt + +count = 0 + +def summarize_group(assignments, users): + global count + date = dt.date.today() + for user in users: + yield (user, str(count), date) + count += 1 +@ + +To use this module we would run +\begin{center} + \texttt{canvaslms results -S mysum.py} +\end{center} +in the directory where \texttt{mysum.py} is located. +We can also give the relative or absolute path to \texttt{mysum.py} instead. + +%%% Modules %%% + +\input{../src/canvaslms/grades/conjunctavg.tex} +\input{../src/canvaslms/grades/disjunctmax.tex}