canvaslms 5.9__tar.gz → 5.10__tar.gz

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.
Files changed (113) hide show
  1. {canvaslms-5.9 → canvaslms-5.10}/PKG-INFO +1 -1
  2. {canvaslms-5.9 → canvaslms-5.10}/pyproject.toml +1 -1
  3. {canvaslms-5.9 → canvaslms-5.10}/src/canvaslms/cli/cli.nw +25 -2
  4. {canvaslms-5.9 → canvaslms-5.10}/src/canvaslms/cli/quizzes.nw +58 -4
  5. {canvaslms-5.9 → canvaslms-5.10}/src/canvaslms/cli/results.nw +85 -45
  6. {canvaslms-5.9 → canvaslms-5.10}/src/canvaslms/cli/users.nw +64 -1
  7. {canvaslms-5.9 → canvaslms-5.10}/src/canvaslms/grades/conjunctavg.nw +50 -1
  8. {canvaslms-5.9 → canvaslms-5.10}/src/canvaslms/grades/conjunctavgsurvey.nw +50 -0
  9. {canvaslms-5.9 → canvaslms-5.10}/src/canvaslms/grades/disjunctmax.nw +59 -0
  10. {canvaslms-5.9 → canvaslms-5.10}/src/canvaslms/grades/grades.nw +17 -3
  11. {canvaslms-5.9 → canvaslms-5.10}/src/canvaslms/grades/maxgradesurvey.nw +58 -0
  12. {canvaslms-5.9 → canvaslms-5.10}/src/canvaslms/grades/participation.nw +27 -0
  13. {canvaslms-5.9 → canvaslms-5.10}/src/canvaslms/grades/tilkryLAB1.nw +47 -0
  14. {canvaslms-5.9 → canvaslms-5.10}/LICENSE +0 -0
  15. {canvaslms-5.9 → canvaslms-5.10}/README.md +0 -0
  16. {canvaslms-5.9 → canvaslms-5.10}/makefiles/.circleci/config.yml +0 -0
  17. {canvaslms-5.9 → canvaslms-5.10}/makefiles/.git +0 -0
  18. {canvaslms-5.9 → canvaslms-5.10}/makefiles/.gitignore +0 -0
  19. {canvaslms-5.9 → canvaslms-5.10}/makefiles/CONTRIBUTING.md +0 -0
  20. {canvaslms-5.9 → canvaslms-5.10}/makefiles/Dockerfile +0 -0
  21. {canvaslms-5.9 → canvaslms-5.10}/makefiles/Dockerfile.nw +0 -0
  22. {canvaslms-5.9 → canvaslms-5.10}/makefiles/Dockerfile.tex +0 -0
  23. {canvaslms-5.9 → canvaslms-5.10}/makefiles/LICENSE +0 -0
  24. {canvaslms-5.9 → canvaslms-5.10}/makefiles/Makefile +0 -0
  25. {canvaslms-5.9 → canvaslms-5.10}/makefiles/Makefile.nw +0 -0
  26. {canvaslms-5.9 → canvaslms-5.10}/makefiles/Makefile.tex +0 -0
  27. {canvaslms-5.9 → canvaslms-5.10}/makefiles/README.md +0 -0
  28. {canvaslms-5.9 → canvaslms-5.10}/makefiles/doc.mk +0 -0
  29. {canvaslms-5.9 → canvaslms-5.10}/makefiles/doc.mk.nw +0 -0
  30. {canvaslms-5.9 → canvaslms-5.10}/makefiles/doc.tex +0 -0
  31. {canvaslms-5.9 → canvaslms-5.10}/makefiles/exam.bib +0 -0
  32. {canvaslms-5.9 → canvaslms-5.10}/makefiles/exam.mk +0 -0
  33. {canvaslms-5.9 → canvaslms-5.10}/makefiles/exam.mk.nw +0 -0
  34. {canvaslms-5.9 → canvaslms-5.10}/makefiles/exam.tex +0 -0
  35. {canvaslms-5.9 → canvaslms-5.10}/makefiles/gitattributes +0 -0
  36. {canvaslms-5.9 → canvaslms-5.10}/makefiles/haskell.mk +0 -0
  37. {canvaslms-5.9 → canvaslms-5.10}/makefiles/haskell.mk.nw +0 -0
  38. {canvaslms-5.9 → canvaslms-5.10}/makefiles/haskell.tex +0 -0
  39. {canvaslms-5.9 → canvaslms-5.10}/makefiles/intro.tex +0 -0
  40. {canvaslms-5.9 → canvaslms-5.10}/makefiles/latexmkrc +0 -0
  41. {canvaslms-5.9 → canvaslms-5.10}/makefiles/ltxobj/_minted/AAA0D43723DCC5BE9DB71A96B52C1142.highlight.minted +0 -0
  42. {canvaslms-5.9 → canvaslms-5.10}/makefiles/ltxobj/_minted/E5724293DA12A769F97F0E91498CAEEF.highlight.minted +0 -0
  43. {canvaslms-5.9 → canvaslms-5.10}/makefiles/ltxobj/_minted/FFC039B25D180E99FC9FEBDB2D42EBAF.highlight.minted +0 -0
  44. {canvaslms-5.9 → canvaslms-5.10}/makefiles/ltxobj/_minted/_7945F0071C510291A619CE1658F673CB.index.minted +0 -0
  45. {canvaslms-5.9 → canvaslms-5.10}/makefiles/ltxobj/_minted/default.style.minted +0 -0
  46. {canvaslms-5.9 → canvaslms-5.10}/makefiles/ltxobj/makefiles.aux +0 -0
  47. {canvaslms-5.9 → canvaslms-5.10}/makefiles/ltxobj/makefiles.bbl +0 -0
  48. {canvaslms-5.9 → canvaslms-5.10}/makefiles/ltxobj/makefiles.bcf +0 -0
  49. {canvaslms-5.9 → canvaslms-5.10}/makefiles/ltxobj/makefiles.blg +0 -0
  50. {canvaslms-5.9 → canvaslms-5.10}/makefiles/ltxobj/makefiles.fdb_latexmk +0 -0
  51. {canvaslms-5.9 → canvaslms-5.10}/makefiles/ltxobj/makefiles.fls +0 -0
  52. {canvaslms-5.9 → canvaslms-5.10}/makefiles/ltxobj/makefiles.log +0 -0
  53. {canvaslms-5.9 → canvaslms-5.10}/makefiles/ltxobj/makefiles.out +0 -0
  54. {canvaslms-5.9 → canvaslms-5.10}/makefiles/ltxobj/makefiles.pdf +0 -0
  55. {canvaslms-5.9 → canvaslms-5.10}/makefiles/ltxobj/makefiles.run.xml +0 -0
  56. {canvaslms-5.9 → canvaslms-5.10}/makefiles/ltxobj/makefiles.toc +0 -0
  57. {canvaslms-5.9 → canvaslms-5.10}/makefiles/ltxobj/makefiles.xdv +0 -0
  58. {canvaslms-5.9 → canvaslms-5.10}/makefiles/makefiles.bib +0 -0
  59. {canvaslms-5.9 → canvaslms-5.10}/makefiles/makefiles.tex +0 -0
  60. {canvaslms-5.9 → canvaslms-5.10}/makefiles/miun.course.mk +0 -0
  61. {canvaslms-5.9 → canvaslms-5.10}/makefiles/miun.depend.mk +0 -0
  62. {canvaslms-5.9 → canvaslms-5.10}/makefiles/miun.docs.mk +0 -0
  63. {canvaslms-5.9 → canvaslms-5.10}/makefiles/miun.port.mk +0 -0
  64. {canvaslms-5.9 → canvaslms-5.10}/makefiles/miun.pub.mk +0 -0
  65. {canvaslms-5.9 → canvaslms-5.10}/makefiles/noweb.mk +0 -0
  66. {canvaslms-5.9 → canvaslms-5.10}/makefiles/noweb.mk.nw +0 -0
  67. {canvaslms-5.9 → canvaslms-5.10}/makefiles/noweb.tex +0 -0
  68. {canvaslms-5.9 → canvaslms-5.10}/makefiles/pkg.mk +0 -0
  69. {canvaslms-5.9 → canvaslms-5.10}/makefiles/pkg.mk.nw +0 -0
  70. {canvaslms-5.9 → canvaslms-5.10}/makefiles/pkg.tex +0 -0
  71. {canvaslms-5.9 → canvaslms-5.10}/makefiles/portability.mk +0 -0
  72. {canvaslms-5.9 → canvaslms-5.10}/makefiles/portability.mk.nw +0 -0
  73. {canvaslms-5.9 → canvaslms-5.10}/makefiles/portability.tex +0 -0
  74. {canvaslms-5.9 → canvaslms-5.10}/makefiles/preamble.tex +0 -0
  75. {canvaslms-5.9 → canvaslms-5.10}/makefiles/pub.mk +0 -0
  76. {canvaslms-5.9 → canvaslms-5.10}/makefiles/pub.mk.nw +0 -0
  77. {canvaslms-5.9 → canvaslms-5.10}/makefiles/pub.tex +0 -0
  78. {canvaslms-5.9 → canvaslms-5.10}/makefiles/results.mk +0 -0
  79. {canvaslms-5.9 → canvaslms-5.10}/makefiles/results.mk.nw +0 -0
  80. {canvaslms-5.9 → canvaslms-5.10}/makefiles/results.tex +0 -0
  81. {canvaslms-5.9 → canvaslms-5.10}/makefiles/subdir.mk +0 -0
  82. {canvaslms-5.9 → canvaslms-5.10}/makefiles/subdir.mk.nw +0 -0
  83. {canvaslms-5.9 → canvaslms-5.10}/makefiles/subdir.tex +0 -0
  84. {canvaslms-5.9 → canvaslms-5.10}/makefiles/tex.bib +0 -0
  85. {canvaslms-5.9 → canvaslms-5.10}/makefiles/tex.mk +0 -0
  86. {canvaslms-5.9 → canvaslms-5.10}/makefiles/tex.mk.nw +0 -0
  87. {canvaslms-5.9 → canvaslms-5.10}/makefiles/tex.tex +0 -0
  88. {canvaslms-5.9 → canvaslms-5.10}/makefiles/transform.bib +0 -0
  89. {canvaslms-5.9 → canvaslms-5.10}/makefiles/transform.mk +0 -0
  90. {canvaslms-5.9 → canvaslms-5.10}/makefiles/transform.mk.nw +0 -0
  91. {canvaslms-5.9 → canvaslms-5.10}/makefiles/transform.tex +0 -0
  92. {canvaslms-5.9 → canvaslms-5.10}/src/canvaslms/Makefile +0 -0
  93. {canvaslms-5.9 → canvaslms-5.10}/src/canvaslms/__init__.py +0 -0
  94. {canvaslms-5.9 → canvaslms-5.10}/src/canvaslms/cli/Makefile +0 -0
  95. {canvaslms-5.9 → canvaslms-5.10}/src/canvaslms/cli/assignments.nw +0 -0
  96. {canvaslms-5.9 → canvaslms-5.10}/src/canvaslms/cli/cache.nw +0 -0
  97. {canvaslms-5.9 → canvaslms-5.10}/src/canvaslms/cli/calendar.nw +0 -0
  98. {canvaslms-5.9 → canvaslms-5.10}/src/canvaslms/cli/content.nw +0 -0
  99. {canvaslms-5.9 → canvaslms-5.10}/src/canvaslms/cli/courses.nw +0 -0
  100. {canvaslms-5.9 → canvaslms-5.10}/src/canvaslms/cli/discussions.nw +0 -0
  101. {canvaslms-5.9 → canvaslms-5.10}/src/canvaslms/cli/grade.nw +0 -0
  102. {canvaslms-5.9 → canvaslms-5.10}/src/canvaslms/cli/login.nw +0 -0
  103. {canvaslms-5.9 → canvaslms-5.10}/src/canvaslms/cli/modules.nw +0 -0
  104. {canvaslms-5.9 → canvaslms-5.10}/src/canvaslms/cli/pages.nw +0 -0
  105. {canvaslms-5.9 → canvaslms-5.10}/src/canvaslms/cli/results.py.broken +0 -0
  106. {canvaslms-5.9 → canvaslms-5.10}/src/canvaslms/cli/submissions.nw +0 -0
  107. {canvaslms-5.9 → canvaslms-5.10}/src/canvaslms/cli/utils.nw +0 -0
  108. {canvaslms-5.9 → canvaslms-5.10}/src/canvaslms/grades/Makefile +0 -0
  109. {canvaslms-5.9 → canvaslms-5.10}/src/canvaslms/grades/grades.py +0 -0
  110. {canvaslms-5.9 → canvaslms-5.10}/src/canvaslms/hacks/Makefile +0 -0
  111. {canvaslms-5.9 → canvaslms-5.10}/src/canvaslms/hacks/__init__.py +0 -0
  112. {canvaslms-5.9 → canvaslms-5.10}/src/canvaslms/hacks/attachment_cache.nw +0 -0
  113. {canvaslms-5.9 → canvaslms-5.10}/src/canvaslms/hacks/canvasapi.nw +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: canvaslms
3
- Version: 5.9
3
+ Version: 5.10
4
4
  Summary: Command-line interface to Canvas LMS
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "canvaslms"
3
- version = "5.9"
3
+ version = "5.10"
4
4
  description = "Command-line interface to Canvas LMS"
5
5
  authors = [
6
6
  {name = "Daniel Bosk", email = "daniel@bosk.se"}
@@ -356,6 +356,19 @@ argp.add_argument("-v", "--verbose",
356
356
  @
357
357
 
358
358
 
359
+ \subsection{Cache control}
360
+
361
+ Most commands speed up by caching the Canvas API object between runs.
362
+ Occasionally you want to bypass the cache for a single command,
363
+ for example right after creating or updating objects.
364
+
365
+ <<add global options to argp>>=
366
+ argp.add_argument("--no-cache",
367
+ action="store_true",
368
+ help="Do not read or write the persistent Canvas object cache")
369
+ @
370
+
371
+
359
372
  \subsection{Logging in and setting up Canvas}
360
373
 
361
374
  Each subcommand will have its own module in the package.
@@ -445,15 +458,25 @@ Before creating a new Canvas object from scratch, we try to load it from the
445
458
  persistent cache.
446
459
  This can significantly speed up commands by reusing previously fetched course
447
460
  data, assignments, users, and submissions.
461
+ If the cached object becomes stale (for example, right after creating a quiz),
462
+ you can bypass the cache with [[--no-cache]].
463
+
464
+ For example:
465
+ \begin{verbatim}
466
+ canvaslms --no-cache quizzes export -c "My Course" -a "My Quiz"
467
+ \end{verbatim}
448
468
  <<try to load canvas from cache>>=
449
- canvas = canvaslms.cli.cache.load_canvas_cache(token, hostname)
469
+ if args.no_cache:
470
+ canvas = None
471
+ else:
472
+ canvas = canvaslms.cli.cache.load_canvas_cache(token, hostname)
450
473
  @
451
474
 
452
475
  After successfully executing a command, we save the Canvas object to the cache
453
476
  for future use.
454
477
  We only save if the canvas object was actually used (not [[None]]).
455
478
  <<save canvas to cache after command>>=
456
- if canvas:
479
+ if canvas and not args.no_cache:
457
480
  canvaslms.cli.cache.save_canvas_cache(canvas, token, hostname)
458
481
  @
459
482
 
@@ -95,8 +95,13 @@ Here, [[attempt_limit]] of [[null]] means unlimited attempts. The
95
95
  [[score_to_keep]] can be [[highest]] (default) or [[latest]]. Setting
96
96
  [[cooling_period]] to [[true]] requires [[cooling_period_seconds]] to specify
97
97
  the wait time (3600 seconds = 1 hour). Using [[latest]] with a cooling period
98
- lets students ``build on their last result''---they see which questions they
99
- got wrong and can retry without losing progress.
98
+ supports a workflow where students can keep trying and learn from their
99
+ previous attempt.
100
+
101
+ Note that some New Quizzes settings are not reliably settable via the API.
102
+ In particular, in our testing (KTH Canvas, February 2026),
103
+ [[multiple_attempts.build_on_last_attempt]] appears to be ignored on quiz
104
+ creation/import via the New Quizzes API.
100
105
 
101
106
  \paragraph{Controlling what students see after submission.}
102
107
  To show students their score but hide the correct answers:
@@ -2574,6 +2579,9 @@ def create_command(config, canvas, args):
2574
2579
  if args.title:
2575
2580
  quiz_params['title'] = args.title
2576
2581
 
2582
+ if quiz_type == "new":
2583
+ <<warn about ignored New Quiz settings>>
2584
+
2577
2585
  if 'title' not in quiz_params:
2578
2586
  canvaslms.cli.err(1, "Quiz title is required (use --title or include in JSON)")
2579
2587
 
@@ -2622,6 +2630,41 @@ to flatten these into the format the API expects:
2622
2630
  quiz[quiz_settings][multiple_attempts][cooling_period_seconds]=3600
2623
2631
  \end{verbatim}
2624
2632
 
2633
+ \paragraph{Some settings cannot be set via the API.}
2634
+ Canvas does not reliably apply every [[quiz_settings]] field during quiz
2635
+ creation.
2636
+
2637
+ In our testing (KTH Canvas, February 2026),
2638
+ [[multiple_attempts.build_on_last_attempt]] appears to be \emph{read-only via the
2639
+ New Quizzes API}: it shows up in exports when set in the Canvas web UI, but both
2640
+ [[POST]] (create) and [[PATCH]] (update) silently drop the field.
2641
+
2642
+ The official New Quizzes API documentation does not list
2643
+ [[multiple_attempts.build_on_last_attempt]] as a supported field.
2644
+
2645
+ This means the export/create workflow cannot round-trip that setting purely via
2646
+ API calls. We keep the schema and examples so the exported JSON is faithful,
2647
+ but users should expect that field to be ignored on import unless they set it
2648
+ manually in the UI.
2649
+
2650
+ When [[quizzes create]] sees this field in input JSON, we emit a warning to make
2651
+ the limitation explicit.
2652
+
2653
+ <<warn about ignored New Quiz settings>>=
2654
+ try:
2655
+ build_on_last_attempt = (quiz_params
2656
+ .get('quiz_settings', {})
2657
+ .get('multiple_attempts', {})
2658
+ .get('build_on_last_attempt', None))
2659
+ except Exception:
2660
+ build_on_last_attempt = None
2661
+
2662
+ if build_on_last_attempt is not None:
2663
+ canvaslms.cli.warn(
2664
+ "New Quizzes: quiz_settings.multiple_attempts.build_on_last_attempt "
2665
+ "is ignored by the API on some Canvas instances; set it in the web UI")
2666
+ @
2667
+
2625
2668
  <<functions>>=
2626
2669
  def create_new_quiz(course, requester, quiz_params):
2627
2670
  """Creates a New Quiz via the New Quizzes API
@@ -2646,7 +2689,9 @@ def create_new_quiz(course, requester, quiz_params):
2646
2689
  _url="new_quizzes",
2647
2690
  **params
2648
2691
  )
2649
- return response.json()
2692
+ data = response.json()
2693
+
2694
+ return data
2650
2695
  except Exception as e:
2651
2696
  canvaslms.cli.warn(f"Failed to create New Quiz: {e}")
2652
2697
  return None
@@ -2918,6 +2963,10 @@ NEW_QUIZ_MULTIPLE_ATTEMPTS_SCHEMA = {
2918
2963
  'default': 'highest',
2919
2964
  'description': 'Which score to keep: average, first, highest, or latest'
2920
2965
  },
2966
+ 'build_on_last_attempt': {
2967
+ 'default': False,
2968
+ 'description': 'Whether students continue from their previous attempt (may be ignored by the API on import)'
2969
+ },
2921
2970
  'cooling_period': {
2922
2971
  'default': False,
2923
2972
  'description': 'Whether to require a waiting period between attempts'
@@ -6277,6 +6326,11 @@ The [[quiz_settings]] object within [[settings]] controls advanced quiz behavior
6277
6326
  \item[[[session_time_limit_in_seconds]]] Time limit in seconds
6278
6327
  \end{description}
6279
6328
 
6329
+ Some fields may be present in exports but ignored when creating a quiz via the
6330
+ API.
6331
+ In particular, see the note in \cref{sec:advanced-quiz-settings} about
6332
+ [[multiple_attempts.build_on_last_attempt]].
6333
+
6280
6334
  <<constants>>=
6281
6335
  EXAMPLE_FULL_NEW_QUIZ_JSON = {
6282
6336
  "quiz_type": "new",
@@ -6320,6 +6374,7 @@ EXAMPLE_FULL_NEW_QUIZ_JSON = {
6320
6374
  "attempt_limit": False,
6321
6375
  "max_attempts": None,
6322
6376
  "score_to_keep": "latest",
6377
+ "build_on_last_attempt": True,
6323
6378
  "cooling_period": True,
6324
6379
  "cooling_period_seconds": 3600
6325
6380
  },
@@ -7899,4 +7954,3 @@ def get_bank_items(requester, bank_id):
7899
7954
  # Item Banks API often isn't accessible - this is expected
7900
7955
  return []
7901
7956
  @
7902
-
@@ -569,33 +569,46 @@ The available summary functions and the default one can be found in
569
569
  \subsection{Producing a list of missing assignments}
570
570
 
571
571
  Now we want to look at the missing option.
572
- If the user supplies this option, we want to produce a list of missing
573
- assignments.
574
- Similarly to summarizing a group, we also want to use different modules to
575
- produce the missing assignments.
576
- We'll use an option missing which takes an optional name of such a module.
572
+ If the user supplies this option, we want to produce a list of missing
573
+ assignments instead of grades.
574
+
575
+ Previously, this option could take an optional module name, creating a confusing
576
+ interface where both [[--missing module]] and [[-S module]] could specify
577
+ modules.
578
+ We simplify this: [[--missing]] is now a simple flag, and the module specified
579
+ via [[-S]] provides the [[missing_assignments]] function if it has one.
580
+ If the [[-S]] module doesn't provide [[missing_assignments]], we fall back to
581
+ the default implementation in this module.
582
+
583
+ This design follows the principle that each grading module knows best what
584
+ \enquote{missing} means for its grading policy:
585
+ \begin{description}
586
+ \item[Conjunctive modules] (all must pass): Any assignment without a passing
587
+ grade is missing.
588
+ \item[Disjunctive modules] (at least one must pass): Assignments are only
589
+ \enquote{missing} if the student has NO passing grades at all.
590
+ \end{description}
577
591
  <<add option for missing assignments>>=
578
- <<define [[default_missing_module]]>>
579
592
  results_parser.add_argument("--missing",
580
- required=False, nargs="?",
581
- const=default_missing_module, default=None,
593
+ action="store_true",
582
594
  help="Produce a list of missing assignments instead of grades. "
583
- "You can supply a custom module to this option, the module must "
584
- "contain a "
585
- "function `missing_assignments(assignments_list, users_list). "
595
+ "Uses the summary module's missing_assignments() if available, "
596
+ "otherwise uses the default implementation. "
586
597
  <<missing module behaviour>>
587
598
  "This option only has effect when working with assignment groups.")
588
599
  @
589
600
 
590
- This lets us load the module and use it to produce the missing assignments, in
591
- a similar fashion as above.
601
+ Since [[--missing]] is now a simple flag, we use the summary module (specified
602
+ via [[-S]]) to provide the [[missing_assignments]] function.
603
+ If the summary module doesn't have one, we fall back to this module's default.
592
604
  <<load the correct missing module as [[missing]]>>=
593
605
  if args.missing:
594
- try:
595
- missing = load_module(args.missing)
596
- except Exception as err:
597
- canvaslms.cli.err(1, f"Error loading missing module "
598
- f"'{args.missing}': {err}")
606
+ <<load the correct summary module as [[summary]]>>
607
+ if hasattr(summary, 'missing_assignments'):
608
+ missing = summary
609
+ else:
610
+ import canvaslms.cli.results
611
+ missing = canvaslms.cli.results
599
612
  @
600
613
 
601
614
  Now, to the main part of the problem.
@@ -624,8 +637,18 @@ for user, assignment, reason in missing_results:
624
637
  \subsubsection{The default missing module}
625
638
 
626
639
  We'll now cover a default function for the missing assignments.
627
- We'll put it in the same module as the [[results]] CLI command, not in a
640
+ We'll put it in the same module as the [[results]] CLI command, not in a
628
641
  separate module.
642
+
643
+ The function accepts an optional [[is_passing]] callback that determines whether
644
+ a grade counts as passing.
645
+ This allows grading modules to reuse this shared implementation while providing
646
+ their own definition of what \enquote{passing} means.
647
+ For example, [[conjunctavg]] considers only A--E and P as passing, while
648
+ [[conjunctavgsurvey]] also accepts numeric grades.
649
+
650
+ If no [[is_passing]] callback is provided, we fall back to regex matching
651
+ against [[passing_regex]].
629
652
  <<functions>>=
630
653
  def missing_assignments(assignments_list, users_list,
631
654
  <<optional [[missing_assignments]] args>>):
@@ -638,8 +661,6 @@ def missing_assignments(assignments_list, users_list,
638
661
  for assignment in assignments_list:
639
662
  <<skip if [[assignment]] is optional>>
640
663
  <<if [[assignment]] is missing for [[user]], yield it>>
641
- <<define [[default_missing_module]]>>=
642
- default_missing_module = "canvaslms.cli.results"
643
664
  @
644
665
 
645
666
  We'll add [[<<optional [[missing_assignments]] args>>]] to the function to make
@@ -663,10 +684,13 @@ We don't want to make it sound like an optional assignment is mandatory.
663
684
  @
664
685
 
665
686
  This gives us something like this.
687
+ We check if the grade passes using either the [[is_passing]] callback (if
688
+ provided) or the [[passing_regex]].
666
689
  <<if [[assignment]] is missing for [[user]], yield it>>=
667
690
  try:
668
691
  submission = assignment.get_submission(user)
669
692
  except canvasapi.exceptions.ResourceDoesNotExist:
693
+ yield user, assignment, "no submission exists"
670
694
  continue
671
695
 
672
696
  if submission is None:
@@ -677,45 +701,61 @@ elif submission.grade is None:
677
701
  f"submitted on {canvaslms.cli.utils.format_local_time(submission.submitted_at)}, but not graded"
678
702
  else:
679
703
  yield user, assignment, "not done"
680
- elif not filter_grade(submission.grade, passing_regex):
681
- if hasattr(submission, 'submitted_at') and submission.submitted_at and \
682
- hasattr(submission, 'graded_at') and submission.graded_at and \
683
- submission.submitted_at > submission.graded_at:
684
- yield user, assignment, \
685
- f"not a passing grade ({submission.grade}), resubmission not graded"
686
- else:
687
- yield user, assignment, \
688
- f"not a passing grade ({submission.grade})"
704
+ else:
705
+ <<check if grade passes using callback or regex>>
706
+ if not grade_passes:
707
+ if hasattr(submission, 'submitted_at') and submission.submitted_at and \
708
+ hasattr(submission, 'graded_at') and submission.graded_at and \
709
+ submission.submitted_at > submission.graded_at:
710
+ yield user, assignment, \
711
+ f"not a passing grade ({submission.grade}), resubmission not graded"
712
+ else:
713
+ yield user, assignment, \
714
+ f"not a passing grade ({submission.grade})"
715
+ <<check if grade passes using callback or regex>>=
716
+ if is_passing is not None:
717
+ grade_passes = is_passing(submission.grade)
718
+ else:
719
+ grade_passes = filter_grade(submission.grade, passing_regex)
689
720
  @
690
721
 
691
- Now, we need that [[passing_regex]], so we can add it to the optional
722
+ Now, we need that [[passing_regex]], so we can add it to the optional
692
723
  arguments, with a default value (same as above).
693
724
  We add the most common grading scales.
694
- But we also add number scores, which can be used for mandatory surveys and the
725
+ But we also add number scores, which can be used for mandatory surveys and the
695
726
  like.
696
727
  <<optional [[missing_assignments]] args>>=
697
728
  passing_regex=PASSING_REGEX,
698
729
  @
699
730
 
700
- Next, if we want to be able to skip optional assignments, we can add an
731
+ Next, if we want to be able to skip optional assignments, we can add an
701
732
  optional argument for that.
702
733
  <<optional [[missing_assignments]] args>>=
703
- optional_assignments = None,
734
+ optional_assignments=None,
735
+ @
736
+
737
+ Finally, we add the [[is_passing]] callback.
738
+ If provided, this function takes a grade and returns [[True]] if it's passing.
739
+ This lets grading modules define their own semantics for what constitutes a
740
+ passing grade.
741
+ <<optional [[missing_assignments]] args>>=
742
+ is_passing=None,
704
743
  @
705
744
 
706
745
  This allows us to make the call to the function as follows.
707
- We check if it's the default function or not, if it is we can pass additional
708
- arguments from the CLI arguments.
746
+ We check if it's the default function or not.
747
+ If it is, we pass additional arguments from the CLI.
748
+ If not, the module provides its own implementation with module-specific
749
+ semantics (which may or may not use these arguments).
750
+
751
+ We always pass [[passing_regex]] and [[optional_assignments]] since these are
752
+ CLI-level concerns that any module might want to honor.
709
753
  <<let [[missing_results]] be the result of [[missing.missing_assignments]]>>=
710
- if missing.missing_assignments == missing_assignments:
711
- missing_results = missing.missing_assignments(
712
- assignments_list, users_list,
713
- passing_regex=args.filter_grades,
714
- optional_assignments=args.optional_assignments
715
- )
716
- else:
717
- missing_results = missing.missing_assignments(
718
- assignments_list, users_list)
754
+ missing_results = missing.missing_assignments(
755
+ assignments_list, users_list,
756
+ passing_regex=args.filter_grades,
757
+ optional_assignments=args.optional_assignments
758
+ )
719
759
  @
720
760
 
721
761
  All that is missing now is the optional assignments argument for the parser.
@@ -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
@@ -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
+
@@ -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
+
@@ -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
 
@@ -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
 
@@ -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
+ @
@@ -304,6 +304,53 @@ else:
304
304
  final_grade = "E"
305
305
  @
306
306
 
307
+ \subsection{Finding missing assignments}
308
+
309
+ For the tilkry LAB1 grading system, \enquote{missing} has a specific meaning:
310
+ only \emph{mandatory} assignments that aren't passed are reported.
311
+ Optional assignments are never reported as missing since they're, well, optional.
312
+
313
+ A mandatory assignment is missing if it doesn't have a P or P+ grade.
314
+ We use the existing [[is_optional]] helper to distinguish mandatory from
315
+ optional assignments.
316
+ <<helper functions>>=
317
+ def missing_assignments(assignments_list, users_list,
318
+ passing_regex=None,
319
+ optional_assignments=None):
320
+ """
321
+ Returns missing mandatory assignments for tilkry LAB1.
322
+
323
+ Only mandatory assignments (those not prefixed with "Optional:") are
324
+ reported. Optional assignments are never reported as missing.
325
+ """
326
+ for user in users_list:
327
+ for assignment in assignments_list:
328
+ # Skip optional assignments entirely - they're never "missing"
329
+ if is_optional(assignment):
330
+ continue
331
+
332
+ try:
333
+ submission = assignment.get_submission(user)
334
+ except ResourceDoesNotExist:
335
+ yield user, assignment, "no submission exists"
336
+ continue
337
+
338
+ grade = submission.grade
339
+
340
+ # Check if mandatory assignment is passed
341
+ if grade is None:
342
+ yield user, assignment, "not done"
343
+ elif grade == "P+" or grade == "P":
344
+ # Passed, not missing
345
+ pass
346
+ elif grade == "F":
347
+ yield user, assignment, "not passed (F)"
348
+ else:
349
+ # Unexpected grade for mandatory assignment
350
+ yield user, assignment, f"unexpected grade ({grade})"
351
+ @
352
+
353
+
307
354
  \subsection{The complete module}
308
355
 
309
356
  All in all, the module looks like this.
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes