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

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 

19 

20from nbgrader.exchange.default import ExchangeSubmit 

21from nbgrader.utils import check_mode, get_username 

22from traitlets import Type 

23 

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 

33 

34 

35class E2xExchangeSubmit(E2xExchange, ExchangeSubmit): 

36 

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) 

48 

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.") 

56 

57 self.inbound_path = self.get_inbound_path() 

58 

59 if self.personalized_inbound: 

60 self.create_personalized_inbound_directory() 

61 

62 self.ensure_inbound_directory_exists() 

63 

64 self.ensure_write_permissions() 

65 

66 self.cache_path = self.get_cache_path() 

67 

68 self.set_assignment_filename() 

69 

70 self.timestamp_file = "timestamp.txt" 

71 

72 def get_inbound_path(self): 

73 inbound_path = os.path.join( 

74 self.root, self.coursedir.course_id, self.inbound_directory 

75 ) 

76 

77 if self.personalized_inbound: 

78 inbound_path = os.path.join(inbound_path, get_username()) 

79 

80 return inbound_path 

81 

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 ) 

102 

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)) 

106 

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 ) 

114 

115 def get_cache_path(self): 

116 return os.path.join(self.cache, self.coursedir.course_id) 

117 

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 ) 

139 

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 ) 

144 

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 ) 

167 

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 

184 

185 def copy_files(self): 

186 self.init_release() 

187 

188 hashcode = "No hashcode generated" 

189 

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() 

193 

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) 

201 

202 self.log.info("Source: {}".format(self.src_path)) 

203 self.log.info("Destination: {}".format(dest_path)) 

204 

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 ) 

217 

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 ) 

232 

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) 

239 

240 self.log.info( 

241 "Submitted as: {} {} {}".format( 

242 self.coursedir.course_id, 

243 self.coursedir.assignment_id, 

244 str(self.timestamp), 

245 ) 

246 ) 

247 

248 return hashcode.upper(), self.timestamp 

249 

250 def init_release(self): 

251 if self.coursedir.course_id == "": 

252 self.fail("No course id specified. Re-run with --course flag.") 

253 

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 ) 

266 

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 ) 

275 

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() 

284 

285 self.set_timestamp() 

286 self.init_src() 

287 self.init_dest() 

288 return self.copy_files()