canvaslms 5.9__py3-none-any.whl → 5.10__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
canvaslms/cli/users.nw CHANGED
@@ -746,6 +746,47 @@ def test_list_users_sets_course_attribute(
746
746
  assert result[0].course == mock_course
747
747
  @
748
748
 
749
+ \subsubsection{Testing [[process_user_option]] with role filtering}
750
+
751
+ This is a regression test for a bug where [[args.role]] (a string) was passed
752
+ directly to [[filter_users]], causing character-by-character iteration that
753
+ matched all enrollment types.
754
+
755
+ We need to verify that [[process_user_option]] correctly converts the single
756
+ role string from argparse into a list before passing it to [[filter_users]].
757
+ <<test functions>>=
758
+ def test_process_user_option_filters_by_role(
759
+ mock_course, mock_student, mock_ta
760
+ ):
761
+ """Test that process_user_option correctly filters users by role"""
762
+ # Setup mock course and canvas
763
+ mock_course.get_users = Mock(return_value=[mock_student, mock_ta])
764
+ mock_canvas = Mock()
765
+ mock_canvas.get_courses = Mock(return_value=[mock_course])
766
+
767
+ # Setup args with role="student" (as a string, like argparse provides)
768
+ mock_args = Mock()
769
+ mock_args.course = ".*"
770
+ mock_args.user = ".*"
771
+ mock_args.role = "student" # Single string, not a list
772
+
773
+ # Call process_user_option
774
+ result = users_module.process_user_option(mock_canvas, mock_args)
775
+
776
+ # Verify only student is returned, not TA
777
+ assert len(result) == 1
778
+ assert result[0].id == 100 # Alice Student
779
+
780
+ # Now test with TA role
781
+ mock_course.get_users = Mock(return_value=[mock_student, mock_ta])
782
+ mock_args.role = "ta"
783
+ result = users_module.process_user_option(mock_canvas, mock_args)
784
+
785
+ # Verify only TA is returned
786
+ assert len(result) == 1
787
+ assert result[0].id == 200 # Bob TA
788
+ @
789
+
749
790
  Second, we provide the most general function, [[filter_users]], which takes a
750
791
  list of courses, a list of Canvas roles and a regex as arguments.
751
792
  It returns the matching users.
@@ -934,13 +975,35 @@ When processing this option, we need to filter by course first, so we use the
934
975
  processing from the [[courses]] module to get the list of courses matching the
935
976
  courses options.
936
977
  Then we simply filter all users.
978
+
979
+ \subsubsection{Converting role string to list}
980
+
981
+ The [[--role]] option (defined in [[add_user_roles_option]]) accepts a single
982
+ role string like [["student"]] or [["ta"]] via argparse.
983
+ However, [[filter_users]] and [[list_users]] expect a list of role names,
984
+ since they need to iterate over roles when checking if any role matches a
985
+ user's enrollments.
986
+
987
+ If we mistakenly pass a string directly to [[filter_users]], Python will
988
+ iterate over individual characters (for example, [["student"]] becomes
989
+ [['s']], [['t']], [['u']], [['d']], [['e']], [['n']], [['t']]) when checking
990
+ roles.
991
+ This causes all enrollment types to match, since characters like [['t']],
992
+ [['a']], [['e']], [['n']] appear in [["StudentEnrollment"]],
993
+ [["TaEnrollment"]], [["TeacherEnrollment"]], and so on.
994
+
995
+ Therefore, we must convert [[args.role]] to a single-element list before
996
+ passing it to [[filter_users]].
937
997
  <<functions>>=
938
998
  def process_user_option(canvas, args):
939
999
  """Processes the user option from command line, returns a list of users"""
1000
+ # args.role is a single string (e.g., "student"), but filter_users expects
1001
+ # a list of role names. Convert it to a single-element list.
1002
+ roles_list = [args.role] if args.role else []
940
1003
  user_list = list(filter_users(
941
1004
  courses.process_course_option(canvas, args),
942
1005
  args.user,
943
- roles=args.role))
1006
+ roles=roles_list))
944
1007
  if not user_list:
945
1008
  raise canvaslms.cli.EmptyListError("No users found matching the criteria")
946
1009
  return user_list
canvaslms/cli/users.py CHANGED
@@ -404,9 +404,12 @@ def add_user_option(parser, required=False):
404
404
 
405
405
  def process_user_option(canvas, args):
406
406
  """Processes the user option from command line, returns a list of users"""
407
+ # args.role is a single string (e.g., "student"), but filter_users expects
408
+ # a list of role names. Convert it to a single-element list.
409
+ roles_list = [args.role] if args.role else []
407
410
  user_list = list(
408
411
  filter_users(
409
- courses.process_course_option(canvas, args), args.user, roles=args.role
412
+ courses.process_course_option(canvas, args), args.user, roles=roles_list
410
413
  )
411
414
  )
412
415
  if not user_list:
@@ -18,6 +18,20 @@ module must fulfil the following:
18
18
  3) The return value should be a list of lists. Each list should have the
19
19
  form `[user, grade, grade date, grader 1, ..., grader N]`.
20
20
 
21
+ OPTIONAL: A module may also provide a `missing_assignments` function for use
22
+ with the `--missing` flag. If provided, it will be used instead of the default.
23
+
24
+ - `missing_assignments(assignments_list, users_list, passing_regex=...,
25
+ optional_assignments=...)` should yield `(user, assignment, reason)` tuples.
26
+
27
+ - What "missing" means depends on the grading policy:
28
+ * Conjunctive modules (all must pass): Any non-passing assignment is missing
29
+ * Disjunctive modules (at least one must pass): Only report if NO passing
30
+
31
+ - Modules can define `is_passing_grade(grade)` and call the shared default
32
+ implementation from `canvaslms.cli.results.missing_assignments()` with the
33
+ `is_passing` callback parameter.
34
+
21
35
  For more details, see Chapter 11 of the `canvaslms.pdf` file found among the
22
36
  release files at:
23
37
 
@@ -133,7 +133,7 @@ graders += results.all_graders(submission)
133
133
 
134
134
  \subsection{Computing averages}
135
135
 
136
- To compute the average for the A--E grades; we will convert the grades into
136
+ To compute the average for the A--E grades; we will convert the grades into
137
137
  integers, compute the average, round the value to an integer and convert back.
138
138
  <<helper functions>>=
139
139
  def a2e_average(grades):
@@ -153,3 +153,52 @@ def int_to_grade(int_grade):
153
153
  return grade_map_inv[int_grade]
154
154
  @
155
155
 
156
+
157
+ \subsection{Finding missing assignments}
158
+
159
+ For conjunctive average grading, a student is \enquote{missing} any assignment
160
+ that doesn't have a passing grade.
161
+ Since ALL assignments must pass to get a grade, each individual failure
162
+ prevents the student from completing the group.
163
+
164
+ This is in contrast to disjunctive grading (see [[disjunctmax]]), where
165
+ a student only needs ONE passing grade---there, we wouldn't report
166
+ individual failing assignments as \enquote{missing} if the student already
167
+ passed via another assignment.
168
+
169
+ We define what counts as a passing grade for this module.
170
+ A grade is passing if it's one of A--E, P, or \enquote{complete}.
171
+ <<helper functions>>=
172
+ def is_passing_grade(grade):
173
+ """
174
+ Returns True if grade is passing for conjunctive A-E grading.
175
+ """
176
+ if grade is None:
177
+ return False
178
+ if grade in ["A", "B", "C", "D", "E", "P"]:
179
+ return True
180
+ if isinstance(grade, str) and grade.casefold() == "complete":
181
+ return True
182
+ return False
183
+ @
184
+
185
+ We reuse the shared [[missing_assignments]] implementation from
186
+ [[canvaslms.cli.results]], providing our module-specific [[is_passing_grade]]
187
+ function as a callback.
188
+ <<helper functions>>=
189
+ def missing_assignments(assignments_list, users_list,
190
+ passing_regex=None,
191
+ optional_assignments=None):
192
+ """
193
+ Returns missing assignments for conjunctive average grading.
194
+
195
+ Any assignment without a passing grade (A-E, P, or complete) is missing.
196
+ """
197
+ from canvaslms.cli import results
198
+ return results.missing_assignments(
199
+ assignments_list, users_list,
200
+ optional_assignments=optional_assignments,
201
+ is_passing=is_passing_grade
202
+ )
203
+ @
204
+
@@ -94,6 +94,37 @@ def int_to_grade(int_grade):
94
94
  return grade_map_inv[int_grade]
95
95
 
96
96
 
97
+ def is_passing_grade(grade):
98
+ """
99
+ Returns True if grade is passing for conjunctive A-E grading.
100
+ """
101
+ if grade is None:
102
+ return False
103
+ if grade in ["A", "B", "C", "D", "E", "P"]:
104
+ return True
105
+ if isinstance(grade, str) and grade.casefold() == "complete":
106
+ return True
107
+ return False
108
+
109
+
110
+ def missing_assignments(
111
+ assignments_list, users_list, passing_regex=None, optional_assignments=None
112
+ ):
113
+ """
114
+ Returns missing assignments for conjunctive average grading.
115
+
116
+ Any assignment without a passing grade (A-E, P, or complete) is missing.
117
+ """
118
+ from canvaslms.cli import results
119
+
120
+ return results.missing_assignments(
121
+ assignments_list,
122
+ users_list,
123
+ optional_assignments=optional_assignments,
124
+ is_passing=is_passing_grade,
125
+ )
126
+
127
+
97
128
  def summarize_group(assignments_list, users_list):
98
129
  """
99
130
  Summarizes a particular set of assignments (assignments_list) for all
@@ -126,3 +126,53 @@ For who graded, we simply extract the list of graders from the submissions.
126
126
  graders += results.all_graders(submission)
127
127
  @
128
128
 
129
+
130
+ \subsection{Finding missing assignments}
131
+
132
+ For conjunctive average with surveys, the definition of \enquote{passing} is
133
+ broader than plain [[conjunctavg]]: numeric grades (points, percentages) also
134
+ count as passing.
135
+ This accommodates mandatory surveys that aren't graded A--F but still need to
136
+ be completed.
137
+
138
+ We define [[is_passing_grade]] to accept A--E, P, complete, and any numeric
139
+ value.
140
+ <<helper functions>>=
141
+ def is_passing_grade(grade):
142
+ """
143
+ Returns True if grade is passing (includes numeric grades).
144
+ """
145
+ if grade is None:
146
+ return False
147
+ if grade in ["A", "B", "C", "D", "E", "P"]:
148
+ return True
149
+ if isinstance(grade, str):
150
+ if grade.casefold() == "complete":
151
+ return True
152
+ # Numeric grades (points, percentages) count as passing
153
+ if (grade.isdigit()
154
+ or grade.replace('.', '', 1).isdigit()
155
+ or grade.replace('%', '', 1).isdigit()):
156
+ return True
157
+ return False
158
+ @
159
+
160
+ We reuse the shared implementation with our broader [[is_passing_grade]].
161
+ <<helper functions>>=
162
+ def missing_assignments(assignments_list, users_list,
163
+ passing_regex=None,
164
+ optional_assignments=None):
165
+ """
166
+ Returns missing assignments for conjunctive average with surveys.
167
+
168
+ Any assignment without a passing grade (A-E, P, complete, or numeric) is
169
+ missing.
170
+ """
171
+ from canvaslms.cli import results
172
+ return results.missing_assignments(
173
+ assignments_list, users_list,
174
+ optional_assignments=optional_assignments,
175
+ is_passing=is_passing_grade
176
+ )
177
+ @
178
+
@@ -77,6 +77,46 @@ def summarize(user, assignments_list):
77
77
  return (final_grade, final_date, graders)
78
78
 
79
79
 
80
+ def is_passing_grade(grade):
81
+ """
82
+ Returns True if grade is passing (includes numeric grades).
83
+ """
84
+ if grade is None:
85
+ return False
86
+ if grade in ["A", "B", "C", "D", "E", "P"]:
87
+ return True
88
+ if isinstance(grade, str):
89
+ if grade.casefold() == "complete":
90
+ return True
91
+ # Numeric grades (points, percentages) count as passing
92
+ if (
93
+ grade.isdigit()
94
+ or grade.replace(".", "", 1).isdigit()
95
+ or grade.replace("%", "", 1).isdigit()
96
+ ):
97
+ return True
98
+ return False
99
+
100
+
101
+ def missing_assignments(
102
+ assignments_list, users_list, passing_regex=None, optional_assignments=None
103
+ ):
104
+ """
105
+ Returns missing assignments for conjunctive average with surveys.
106
+
107
+ Any assignment without a passing grade (A-E, P, complete, or numeric) is
108
+ missing.
109
+ """
110
+ from canvaslms.cli import results
111
+
112
+ return results.missing_assignments(
113
+ assignments_list,
114
+ users_list,
115
+ optional_assignments=optional_assignments,
116
+ is_passing=is_passing_grade,
117
+ )
118
+
119
+
80
120
  def summarize_group(assignments_list, users_list):
81
121
  """
82
122
  Summarizes a particular set of assignments (assignments_list) for all
@@ -132,4 +132,63 @@ def int_to_grade(int_grade):
132
132
  0: "P",
133
133
  1: "E", 2: "D", 3: "C", 4: "B", 5: "A"}
134
134
  return grade_map_inv[int_grade]
135
+ @
136
+
137
+
138
+ \subsection{Finding missing assignments}
139
+
140
+ For disjunctive maximum grading, the semantics of \enquote{missing} are
141
+ fundamentally different from conjunctive modules.
142
+ Since a student only needs ONE passing grade to pass the group:
143
+ \begin{itemize}
144
+ \item If the student has at least one passing grade, \emph{nothing} is missing.
145
+ \item If the student has no passing grades, we report \emph{all} assignments
146
+ as options they could complete to pass.
147
+ \end{itemize}
148
+
149
+ This is in contrast to conjunctive modules (like [[conjunctavg]]), where each
150
+ non-passing assignment is independently \enquote{missing}.
151
+
152
+ We cannot reuse the shared implementation from [[canvaslms.cli.results]] because
153
+ that iterates assignment-by-assignment.
154
+ Disjunctive logic requires evaluating ALL assignments first, then deciding what
155
+ to report.
156
+ <<helper functions>>=
157
+ def missing_assignments(assignments_list, users_list,
158
+ passing_regex=None,
159
+ optional_assignments=None):
160
+ """
161
+ Returns missing assignments for disjunctive maximum grading.
162
+
163
+ Only reports assignments if the student has NO passing grades.
164
+ If they have at least one passing grade, nothing is missing.
165
+ """
166
+ import re
167
+
168
+ for user in users_list:
169
+ grades_with_assignments = []
170
+
171
+ # First pass: collect all grades
172
+ for assignment in assignments_list:
173
+ # Skip optional assignments
174
+ if optional_assignments:
175
+ if any(re.search(opt, assignment.name) for opt in optional_assignments):
176
+ continue
177
+
178
+ try:
179
+ submission = assignment.get_submission(user)
180
+ grade = submission.grade or "F"
181
+ except ResourceDoesNotExist:
182
+ grade = "F"
183
+
184
+ grades_with_assignments.append((assignment, grade))
185
+
186
+ # Check if student has at least one passing grade (P or better)
187
+ has_passing = any(grade_to_int(g) >= 0 for _, g in grades_with_assignments)
188
+
189
+ if not has_passing:
190
+ # Report all assignments as options to complete
191
+ for assignment, grade in grades_with_assignments:
192
+ yield user, assignment, \
193
+ f"not a passing grade ({grade}) - complete any one to pass"
135
194
 
@@ -88,6 +88,44 @@ def int_to_grade(int_grade):
88
88
  return grade_map_inv[int_grade]
89
89
 
90
90
 
91
+ def missing_assignments(
92
+ assignments_list, users_list, passing_regex=None, optional_assignments=None
93
+ ):
94
+ """
95
+ Returns missing assignments for disjunctive maximum grading.
96
+
97
+ Only reports assignments if the student has NO passing grades.
98
+ If they have at least one passing grade, nothing is missing.
99
+ """
100
+ import re
101
+
102
+ for user in users_list:
103
+ grades_with_assignments = []
104
+
105
+ # First pass: collect all grades
106
+ for assignment in assignments_list:
107
+ # Skip optional assignments
108
+ if optional_assignments:
109
+ if any(re.search(opt, assignment.name) for opt in optional_assignments):
110
+ continue
111
+
112
+ try:
113
+ submission = assignment.get_submission(user)
114
+ grade = submission.grade or "F"
115
+ except ResourceDoesNotExist:
116
+ grade = "F"
117
+
118
+ grades_with_assignments.append((assignment, grade))
119
+
120
+ # Check if student has at least one passing grade (P or better)
121
+ has_passing = any(grade_to_int(g) >= 0 for _, g in grades_with_assignments)
122
+
123
+ if not has_passing:
124
+ # Report all assignments as options to complete
125
+ for assignment, grade in grades_with_assignments:
126
+ yield user, assignment, f"not a passing grade ({grade}) - complete any one to pass"
127
+
128
+
91
129
  def summarize_group(assignments_list, users_list):
92
130
  """Summarizes a particular set of assignments (assignments_list) for all
93
131
  users in users_list"""
@@ -3,10 +3,10 @@
3
3
 
4
4
  This is the documentation for the \texttt{canvaslms.grades} package.
5
5
  <<module doc>>=
6
- This package contains modules to summarize assignment groups in different ways.
6
+ This package contains modules to summarize assignment groups in different ways.
7
7
  These modules are used with the `-S` option of the `results` command.
8
8
 
9
- For a module to be used with the `canvaslms results -S module` option, the
9
+ For a module to be used with the `canvaslms results -S module` option, the
10
10
  module must fulfil the following:
11
11
 
12
12
  1) It must contain a function named `summarize_group`.
@@ -22,7 +22,21 @@ module must fulfil the following:
22
22
  3) The return value should be a list of lists. Each list should have the
23
23
  form `[user, grade, grade date, grader 1, ..., grader N]`.
24
24
 
25
- For more details, see Chapter 11 of the `canvaslms.pdf` file found among the
25
+ OPTIONAL: A module may also provide a `missing_assignments` function for use
26
+ with the `--missing` flag. If provided, it will be used instead of the default.
27
+
28
+ - `missing_assignments(assignments_list, users_list, passing_regex=...,
29
+ optional_assignments=...)` should yield `(user, assignment, reason)` tuples.
30
+
31
+ - What "missing" means depends on the grading policy:
32
+ * Conjunctive modules (all must pass): Any non-passing assignment is missing
33
+ * Disjunctive modules (at least one must pass): Only report if NO passing
34
+
35
+ - Modules can define `is_passing_grade(grade)` and call the shared default
36
+ implementation from `canvaslms.cli.results.missing_assignments()` with the
37
+ `is_passing` callback parameter.
38
+
39
+ For more details, see Chapter 11 of the `canvaslms.pdf` file found among the
26
40
  release files at:
27
41
 
28
42
  https://github.com/dbosk/canvaslms/releases
@@ -101,4 +101,62 @@ def summarize(user, assignments_list):
101
101
  final_grade = None
102
102
 
103
103
  return (final_grade, final_date, graders)
104
+ @
105
+
106
+
107
+ \subsection{Finding missing assignments}
108
+
109
+ Like [[disjunctmax]], this module uses disjunctive grading: a student only needs
110
+ ONE passing grade.
111
+ The semantics of \enquote{missing} are the same:
112
+ \begin{itemize}
113
+ \item If the student has at least one passing grade, nothing is missing.
114
+ \item If the student has no passing grades, all assignments are reported as
115
+ options.
116
+ \end{itemize}
117
+
118
+ The key difference from [[disjunctmax]] is that unknown grades (grades not in
119
+ A--F or P) are treated as F.
120
+ This accommodates surveys where numeric scores don't count as grades.
121
+ <<helper functions>>=
122
+ def missing_assignments(assignments_list, users_list,
123
+ passing_regex=None,
124
+ optional_assignments=None):
125
+ """
126
+ Returns missing assignments for disjunctive maximum grading with surveys.
127
+
128
+ Only reports assignments if the student has NO passing grades.
129
+ Unknown grades (not A-F or P) are treated as F.
130
+ """
131
+ import re
132
+
133
+ for user in users_list:
134
+ grades_with_assignments = []
135
+
136
+ # First pass: collect all grades
137
+ for assignment in assignments_list:
138
+ # Skip optional assignments
139
+ if optional_assignments:
140
+ if any(re.search(opt, assignment.name) for opt in optional_assignments):
141
+ continue
142
+
143
+ try:
144
+ submission = assignment.get_submission(user)
145
+ grade = submission.grade
146
+ # Unknown grades are treated as F
147
+ if grade is None or grade not in "ABCDEPF":
148
+ grade = "F"
149
+ except ResourceDoesNotExist:
150
+ grade = "F"
151
+
152
+ grades_with_assignments.append((assignment, grade))
153
+
154
+ # Check if student has at least one passing grade (P or better)
155
+ has_passing = any(grade_max([g]) != "F" for _, g in grades_with_assignments)
156
+
157
+ if not has_passing:
158
+ # Report all assignments as options to complete
159
+ for assignment, grade in grades_with_assignments:
160
+ yield user, assignment, \
161
+ f"not a passing grade ({grade}) - complete any one to pass"
104
162
 
@@ -60,6 +60,47 @@ def summarize(user, assignments_list):
60
60
  return (final_grade, final_date, graders)
61
61
 
62
62
 
63
+ def missing_assignments(
64
+ assignments_list, users_list, passing_regex=None, optional_assignments=None
65
+ ):
66
+ """
67
+ Returns missing assignments for disjunctive maximum grading with surveys.
68
+
69
+ Only reports assignments if the student has NO passing grades.
70
+ Unknown grades (not A-F or P) are treated as F.
71
+ """
72
+ import re
73
+
74
+ for user in users_list:
75
+ grades_with_assignments = []
76
+
77
+ # First pass: collect all grades
78
+ for assignment in assignments_list:
79
+ # Skip optional assignments
80
+ if optional_assignments:
81
+ if any(re.search(opt, assignment.name) for opt in optional_assignments):
82
+ continue
83
+
84
+ try:
85
+ submission = assignment.get_submission(user)
86
+ grade = submission.grade
87
+ # Unknown grades are treated as F
88
+ if grade is None or grade not in "ABCDEPF":
89
+ grade = "F"
90
+ except ResourceDoesNotExist:
91
+ grade = "F"
92
+
93
+ grades_with_assignments.append((assignment, grade))
94
+
95
+ # Check if student has at least one passing grade (P or better)
96
+ has_passing = any(grade_max([g]) != "F" for _, g in grades_with_assignments)
97
+
98
+ if not has_passing:
99
+ # Report all assignments as options to complete
100
+ for assignment, grade in grades_with_assignments:
101
+ yield user, assignment, f"not a passing grade ({grade}) - complete any one to pass"
102
+
103
+
63
104
  def summarize_group(assignments_list, users_list):
64
105
  """Summarizes a particular set of assignments (assignments_list) for all
65
106
  users in users_list"""
@@ -216,3 +216,30 @@ else:
216
216
  final_date = None
217
217
  final_grade = None
218
218
  @
219
+
220
+
221
+ \subsection{Finding missing assignments}
222
+
223
+ For participation grading, a student is \enquote{missing} any assignment that
224
+ doesn't have a passing grade ([[complete]] or [[100]]).
225
+ Since this is conjunctive grading (all must pass), each incomplete assignment
226
+ prevents the student from passing the group.
227
+
228
+ We already have [[is_passing_grade]] defined above, so we simply reuse the
229
+ shared implementation from [[canvaslms.cli.results]] with our callback.
230
+ <<helper functions>>=
231
+ def missing_assignments(assignments_list, users_list,
232
+ passing_regex=None,
233
+ optional_assignments=None):
234
+ """
235
+ Returns missing assignments for participation grading.
236
+
237
+ Any assignment without 'complete' or '100' is missing.
238
+ """
239
+ from canvaslms.cli import results
240
+ return results.missing_assignments(
241
+ assignments_list, users_list,
242
+ optional_assignments=optional_assignments,
243
+ is_passing=is_passing_grade
244
+ )
245
+ @
@@ -60,6 +60,24 @@ def summarize(user, assignments_list):
60
60
  return (final_grade, final_date, graders)
61
61
 
62
62
 
63
+ def missing_assignments(
64
+ assignments_list, users_list, passing_regex=None, optional_assignments=None
65
+ ):
66
+ """
67
+ Returns missing assignments for participation grading.
68
+
69
+ Any assignment without 'complete' or '100' is missing.
70
+ """
71
+ from canvaslms.cli import results
72
+
73
+ return results.missing_assignments(
74
+ assignments_list,
75
+ users_list,
76
+ optional_assignments=optional_assignments,
77
+ is_passing=is_passing_grade,
78
+ )
79
+
80
+
63
81
  def summarize_group(assignments_list, users_list):
64
82
  """
65
83
  Summarizes participation assignments using conjunctive P/F grading.