Coverage for e2xgrader/exchange/submit.py: 98%
115 statements
« prev ^ index » next coverage.py v7.4.2, created at 2024-03-14 14:22 +0100
« prev ^ index » next coverage.py v7.4.2, created at 2024-03-14 14:22 +0100
1import base64
2import glob
3import os
4import sys
5from datetime import datetime
6from stat import (
7 S_IRGRP,
8 S_IROTH,
9 S_IRUSR,
10 S_ISGID,
11 S_IWGRP,
12 S_IWOTH,
13 S_IWUSR,
14 S_IXGRP,
15 S_IXOTH,
16 S_IXUSR,
17)
18from textwrap import dedent
20from nbgrader.exchange.default import ExchangeSubmit
21from nbgrader.utils import check_mode, get_username
22from traitlets import Type
24from ..exporters import SubmissionExporter
25from ..utils.mode import E2xGraderMode, infer_e2xgrader_mode
26from .exchange import E2xExchange
27from .hash_utils import (
28 compute_hashcode_of_file,
29 generate_directory_hash_file,
30 truncate_hashcode,
31)
32from .utils import generate_student_info_file, generate_submission_html
35class E2xExchangeSubmit(E2xExchange, ExchangeSubmit):
37 submission_exporter_class = Type(
38 SubmissionExporter,
39 klass="nbconvert.exporters.HTMLExporter",
40 help=dedent(
41 """
42 The class used for creating HTML files from exam notebooks.
43 Must be a subclass of `nbconvert.exporters.HTMLExporter`.
44 The exporter will receive the hashcode and timestamp as resources.
45 """
46 ),
47 ).tag(config=True)
49 def init_dest(self):
50 if self.coursedir.course_id == "":
51 self.fail("No course id specified. Re-run with --course flag.")
52 if not self.authenticator.has_access(
53 self.coursedir.student_id, self.coursedir.course_id
54 ):
55 self.fail("You do not have access to this course.")
57 self.inbound_path = self.get_inbound_path()
59 if self.personalized_inbound:
60 self.create_personalized_inbound_directory()
62 self.ensure_inbound_directory_exists()
64 self.ensure_write_permissions()
66 self.cache_path = self.get_cache_path()
68 self.set_assignment_filename()
70 self.timestamp_file = "timestamp.txt"
72 def get_inbound_path(self):
73 inbound_path = os.path.join(
74 self.root, self.coursedir.course_id, self.inbound_directory
75 )
77 if self.personalized_inbound:
78 inbound_path = os.path.join(inbound_path, get_username())
80 return inbound_path
82 def create_personalized_inbound_directory(self):
83 if not os.path.isdir(self.inbound_path):
84 self.log.warning(
85 "Inbound directory doesn't exist, creating {}".format(self.inbound_path)
86 )
87 # 0777 with set GID so student instructors can read students' submissions
88 self.ensure_directory(
89 self.inbound_path,
90 S_ISGID
91 | S_IRUSR
92 | S_IWUSR
93 | S_IXUSR
94 | S_IRGRP
95 | S_IWGRP
96 | S_IXGRP
97 | S_IROTH
98 | S_IWOTH
99 | S_IXOTH
100 | (S_IRGRP if self.coursedir.groupshared else 0),
101 )
103 def ensure_inbound_directory_exists(self):
104 if not os.path.isdir(self.inbound_path):
105 self.fail("Inbound directory doesn't exist: {}".format(self.inbound_path))
107 def ensure_write_permissions(self):
108 if not check_mode(self.inbound_path, write=True, execute=True):
109 self.fail(
110 "You don't have write permissions to the directory: {}".format(
111 self.inbound_path
112 )
113 )
115 def get_cache_path(self):
116 return os.path.join(self.cache, self.coursedir.course_id)
118 def set_assignment_filename(self):
119 if self.coursedir.student_id != "*":
120 # An explicit student id has been specified on the command line; we use it as student_id
121 if "*" in self.coursedir.student_id or "+" in self.coursedir.student_id:
122 self.fail(
123 "The student ID should contain no '*' nor '+'; got {}".format(
124 self.coursedir.student_id
125 )
126 )
127 student_id = self.coursedir.student_id
128 else:
129 student_id = get_username()
130 if self.add_random_string:
131 random_str = base64.urlsafe_b64encode(os.urandom(9)).decode("ascii")
132 self.assignment_filename = "{}+{}+{}+{}".format(
133 student_id, self.coursedir.assignment_id, self.timestamp, random_str
134 )
135 else:
136 self.assignment_filename = "{}+{}+{}".format(
137 student_id, self.coursedir.assignment_id, self.timestamp
138 )
140 def format_timestamp(self, format: str = "%H:%M:%S") -> str:
141 return datetime.strptime(self.timestamp, "%Y-%m-%d %H:%M:%S.%f %Z").strftime(
142 format
143 )
145 def create_exam_files(self):
146 username = get_username()
147 generate_directory_hash_file(
148 self.src_path,
149 method="sha1",
150 exclude_files=[self.timestamp_file, f"{username}_info.txt", "*.html"],
151 exclude_subfolders=[".ipynb_checkpoints"],
152 output_file="SHA1SUM.txt",
153 )
154 hashcode = truncate_hashcode(
155 compute_hashcode_of_file(
156 os.path.join(self.src_path, "SHA1SUM.txt"), method="sha1"
157 ),
158 number_of_chunks=3,
159 chunk_size=4,
160 )
161 generate_student_info_file(
162 os.path.join(self.src_path, f"{username}_info.txt"),
163 username=username,
164 hashcode=hashcode,
165 timestamp=self.format_timestamp(),
166 )
168 # Discover all ipynb files in the src_path and generate HTML files for them
169 exporter = self.submission_exporter_class(config=self.config)
170 ipynb_files = glob.glob(os.path.join(self.src_path, "*.ipynb"))
171 for ipynb_file in ipynb_files:
172 generate_submission_html(
173 ipynb_file,
174 os.path.join(
175 self.src_path,
176 os.path.splitext(os.path.basename(ipynb_file))[0]
177 + "_hashcode.html",
178 ),
179 hashcode,
180 self.format_timestamp(format="%Y-%m-%d %H:%M:%S"),
181 exporter,
182 )
183 return hashcode
185 def copy_files(self):
186 self.init_release()
188 hashcode = "No hashcode generated"
190 if infer_e2xgrader_mode() == E2xGraderMode.STUDENT_EXAM.value:
191 self.log.info("Exam mode detected. Generating exam files.")
192 hashcode = self.create_exam_files()
194 dest_path = os.path.join(self.inbound_path, self.assignment_filename)
195 if self.add_random_string:
196 cache_path = os.path.join(
197 self.cache_path, self.assignment_filename.rsplit("+", 1)[0]
198 )
199 else:
200 cache_path = os.path.join(self.cache_path, self.assignment_filename)
202 self.log.info("Source: {}".format(self.src_path))
203 self.log.info("Destination: {}".format(dest_path))
205 # copy to the real location
206 self.check_filename_diff()
207 self.do_copy(self.src_path, dest_path)
208 with open(os.path.join(dest_path, self.timestamp_file), "w") as fh:
209 fh.write(self.timestamp)
210 self.set_perms(
211 dest_path,
212 fileperms=(S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH),
213 dirperms=(
214 S_IRUSR | S_IWUSR | S_IXUSR | S_IRGRP | S_IXGRP | S_IROTH | S_IXOTH
215 ),
216 )
218 # Make this 0777=ugo=rwx so the instructor can delete later.
219 # Hidden from other users by the timestamp.
220 os.chmod(
221 dest_path,
222 S_IRUSR
223 | S_IWUSR
224 | S_IXUSR
225 | S_IRGRP
226 | S_IWGRP
227 | S_IXGRP
228 | S_IROTH
229 | S_IWOTH
230 | S_IXOTH,
231 )
233 # also copy to the cache
234 if not os.path.isdir(self.cache_path):
235 os.makedirs(self.cache_path)
236 self.do_copy(self.src_path, cache_path)
237 with open(os.path.join(cache_path, self.timestamp_file), "w") as fh:
238 fh.write(self.timestamp)
240 self.log.info(
241 "Submitted as: {} {} {}".format(
242 self.coursedir.course_id,
243 self.coursedir.assignment_id,
244 str(self.timestamp),
245 )
246 )
248 return hashcode.upper(), self.timestamp
250 def init_release(self):
251 if self.coursedir.course_id == "":
252 self.fail("No course id specified. Re-run with --course flag.")
254 course_path = os.path.join(self.root, self.coursedir.course_id)
255 outbound_path = os.path.join(course_path, self.outbound_directory)
256 if self.personalized_outbound:
257 self.release_path = os.path.join(
258 outbound_path,
259 get_username(),
260 self.coursedir.assignment_id,
261 )
262 else:
263 self.release_path = os.path.join(
264 outbound_path, self.coursedir.assignment_id
265 )
267 if not os.path.isdir(self.release_path):
268 self.fail("Assignment not found: {}".format(self.release_path))
269 if not check_mode(self.release_path, read=True, execute=True):
270 self.fail(
271 "You don't have read permissions for the directory: {}".format(
272 self.release_path
273 )
274 )
276 def start(self):
277 if sys.platform == "win32":
278 self.fail("Sorry, the exchange is not available on Windows.")
279 if not self.coursedir.groupshared:
280 # This just makes sure that directory is o+rwx. In group shared
281 # case, it is up to admins to ensure that instructors can write
282 # there.
283 self.ensure_root()
285 self.set_timestamp()
286 self.init_src()
287 self.init_dest()
288 return self.copy_files()