3
3
from .. import utils
4
4
from ..api import Gradebook , MissingEntry
5
5
from . import NbGraderPreprocessor
6
+ from ..nbgraderformat import MetadataValidator
6
7
from nbconvert .exporters .exporter import ResourcesDict
7
8
from nbformat .notebooknode import NotebookNode
9
+ from traitlets import Bool , Unicode
8
10
from typing import Tuple , Any
11
+ from textwrap import dedent
9
12
10
13
11
14
class OverwriteCells (NbGraderPreprocessor ):
12
15
"""A preprocessor to overwrite information about grade and solution cells."""
13
16
17
+ add_missing_cells = Bool (
18
+ False ,
19
+ help = dedent (
20
+ """
21
+ Whether or not missing grade_cells should be added back
22
+ to the notebooks being graded.
23
+ """
24
+ ),
25
+ ).tag (config = True )
26
+
27
+ missing_cell_notification = Unicode (
28
+ "This cell (id:{cell_id}) was missing from the submission. " +
29
+ "It was added back by nbgrader.\n \n " , # Markdown requires two newlines
30
+ help = dedent (
31
+ """
32
+ A text to add at the beginning of every missing cell re-added to the notebook during autograding.
33
+ """
34
+ )
35
+ ).tag (config = True )
36
+
37
+ def missing_cell_transform (self , source_cell , max_score , is_solution = False , is_task = False ):
38
+ """
39
+ Converts source_cell obtained from Gradebook into a cell that can be added to the notebook.
40
+ It is assumed that the cell is a grade_cell (unless is_task=True)
41
+ """
42
+
43
+ missing_cell_notification = self .missing_cell_notification .format (cell_id = source_cell .name )
44
+
45
+ cell = {
46
+ "cell_type" : source_cell .cell_type ,
47
+ "metadata" : {
48
+ "deletable" : False ,
49
+ "editable" : False ,
50
+ "nbgrader" : {
51
+ "grade" : True ,
52
+ "grade_id" : source_cell .name ,
53
+ "locked" : source_cell .locked ,
54
+ "checksum" : source_cell .checksum ,
55
+ "cell_type" : source_cell .cell_type ,
56
+ "points" : max_score ,
57
+ "solution" : False
58
+ }
59
+ },
60
+ "source" : missing_cell_notification + source_cell .source
61
+ }
62
+
63
+ # Code cell format is slightly different
64
+ if cell ["cell_type" ] == "code" :
65
+ cell ["execution_count" ] = None
66
+ cell ["outputs" ] = []
67
+ cell ["source" ] = "# " + cell ["source" ] # make the notification we add a comment
68
+
69
+ # some grade cells are editable (manually graded answers)
70
+ if is_solution :
71
+ del cell ["metadata" ]["editable" ]
72
+ cell ["metadata" ]["nbgrader" ]["solution" ] = True
73
+ # task cells are also a bit different
74
+ elif is_task :
75
+ cell ["metadata" ]["nbgrader" ]["grade" ] = False
76
+ cell ["metadata" ]["nbgrader" ]["task" ] = True
77
+ # this is when task cells were added so metadata validation should start from here
78
+ cell ["metadata" ]["nbgrader" ]["schema_version" ] = 3
79
+
80
+ cell = NotebookNode (cell )
81
+ cell = MetadataValidator ().upgrade_cell_metadata (cell )
82
+ return cell
83
+
14
84
def preprocess (self , nb : NotebookNode , resources : ResourcesDict ) -> Tuple [NotebookNode , ResourcesDict ]:
15
85
# pull information from the resources
16
86
self .notebook_id = resources ['nbgrader' ]['notebook' ]
@@ -22,6 +92,77 @@ def preprocess(self, nb: NotebookNode, resources: ResourcesDict) -> Tuple[Notebo
22
92
23
93
with self .gradebook :
24
94
nb , resources = super (OverwriteCells , self ).preprocess (nb , resources )
95
+ if self .add_missing_cells :
96
+ nb , resources = self .add_missing_grade_cells (nb , resources )
97
+ nb , resources = self .add_missing_task_cells (nb , resources )
98
+
99
+ return nb , resources
100
+
101
+ def add_missing_grade_cells (self , nb : NotebookNode , resources : ResourcesDict ) -> Tuple [NotebookNode , ResourcesDict ]:
102
+ """
103
+ Add missing grade cells back to the notebook.
104
+ If missing, find the previous solution/grade cell, and add the current cell after it.
105
+ It is assumed such a cell exists because
106
+ presumably the grade_cell exists to grade some work in the solution cell.
107
+ """
108
+ source_nb = self .gradebook .find_notebook (self .notebook_id , self .assignment_id )
109
+ source_cells = source_nb .source_cells
110
+ source_cell_ids = [cell .name for cell in source_cells ]
111
+ grade_cells = {cell .name : cell for cell in source_nb .grade_cells }
112
+ solution_cell_ids = [cell .name for cell in source_nb .solution_cells ]
113
+
114
+ # track indices of solution and grade cells in the submitted notebook
115
+ submitted_cell_idxs = dict ()
116
+ for idx , cell in enumerate (nb .cells ):
117
+ if utils .is_grade (cell ) or utils .is_solution (cell ):
118
+ submitted_cell_idxs [cell .metadata .nbgrader ["grade_id" ]] = idx
119
+
120
+ # Every time we add a cell, the idxs above get shifted
121
+ # We could process the notebook backwards, but that makes adding the cells in the right place more difficult
122
+ # So we keep track of how many we have added so far
123
+ added_count = 0
124
+
125
+ for grade_cell_id , grade_cell in grade_cells .items ():
126
+ # If missing, find the previous solution/grade cell, and add the current cell after it.
127
+ if grade_cell_id not in submitted_cell_idxs :
128
+ self .log .warning (f"Missing grade cell { grade_cell_id } encountered, adding to notebook" )
129
+ source_cell_idx = source_cell_ids .index (grade_cell_id )
130
+ cell_to_add = source_cells [source_cell_idx ]
131
+ cell_to_add = self .missing_cell_transform (cell_to_add , grade_cell .max_score ,
132
+ is_solution = grade_cell_id in solution_cell_ids )
133
+ # First cell was deleted, add it to start
134
+ if source_cell_idx == 0 :
135
+ nb .cells .insert (0 , cell_to_add )
136
+ submitted_cell_idxs [grade_cell_id ] = 0
137
+ # Deleted cell is not the first, add it after the previous solution/grade cell
138
+ else :
139
+ prev_cell_id = source_cell_ids [source_cell_idx - 1 ]
140
+ prev_cell_idx = submitted_cell_idxs [prev_cell_id ] + added_count
141
+ nb .cells .insert (prev_cell_idx + 1 , cell_to_add ) # +1 to add it after
142
+ submitted_cell_idxs [grade_cell_id ] = submitted_cell_idxs [prev_cell_id ]
143
+
144
+ # If the cell we just added is followed by other missing cells, we need to know its index in the nb
145
+ # However, no need to add `added_count` to avoid double-counting
146
+
147
+ added_count += 1 # shift idxs
148
+
149
+ return nb , resources
150
+
151
+ def add_missing_task_cells (self , nb : NotebookNode , resources : ResourcesDict ) -> Tuple [NotebookNode , ResourcesDict ]:
152
+ """
153
+ Add missing task cells back to the notebook.
154
+ We can't figure out their original location, so they are added at the end, in their original order.
155
+ """
156
+ source_nb = self .gradebook .find_notebook (self .notebook_id , self .assignment_id )
157
+ source_cells = source_nb .source_cells
158
+ source_cell_ids = [cell .name for cell in source_cells ]
159
+ submitted_ids = [cell ["metadata" ]["nbgrader" ]["grade_id" ] for cell in nb .cells if
160
+ "nbgrader" in cell ["metadata" ]]
161
+ for task_cell in source_nb .task_cells :
162
+ if task_cell .name not in submitted_ids :
163
+ cell_to_add = source_cells [source_cell_ids .index (task_cell .name )]
164
+ cell_to_add = self .missing_cell_transform (cell_to_add , task_cell .max_score , is_task = True )
165
+ nb .cells .append (cell_to_add )
25
166
26
167
return nb , resources
27
168
0 commit comments