Coverage for mongo/submission.py: 71%

517 statements  

« prev     ^ index     » next       coverage.py v7.9.2, created at 2025-07-11 18:37 +0000

1from __future__ import annotations 

2import io 

3import os 

4import pathlib 

5import secrets 

6import logging 

7from typing import ( 

8 Any, 

9 Dict, 

10 Optional, 

11 Union, 

12 List, 

13 TypedDict, 

14) 

15import enum 

16import tempfile 

17import requests as rq 

18from hashlib import md5 

19from bson.son import SON 

20from flask import current_app 

21from tempfile import NamedTemporaryFile 

22from datetime import date, datetime 

23from zipfile import ZipFile, is_zipfile 

24from ulid import ULID 

25 

26from . import engine 

27from .base import MongoBase 

28from .user import User 

29from .problem import Problem 

30from .homework import Homework 

31from .course import Course 

32from .utils import RedisCache, MinioClient 

33 

34__all__ = [ 

35 'SubmissionConfig', 

36 'Submission', 

37 'JudgeQueueFullError', 

38 'TestCaseNotFound', 

39] 

40 

41# TODO: modular token function 

42 

43 

44def gen_key(_id): 

45 return f'stoekn_{_id}' 

46 

47 

48def gen_token(): 

49 return secrets.token_urlsafe() 

50 

51 

52# Errors 

53class JudgeQueueFullError(Exception): 

54 ''' 

55 when sandbox task queue is full 

56 ''' 

57 

58 

59class TestCaseNotFound(Exception): 

60 ''' 

61 when a problem's testcase havn't been uploaded 

62 ''' 

63 __test__ = False 

64 

65 def __init__(self, problem_id): 

66 self.problem_id = problem_id 

67 

68 def __str__(self): 

69 return f'{Problem(self.problem_id)}\'s testcase is not found' 

70 

71 

72class SubmissionCodeNotFound(Exception): 

73 ''' 

74 when a submission's code is not found 

75 ''' 

76 

77 

78class SubmissionResultOutput(TypedDict): 

79 ''' 

80 output of a submission result, including stdout and stderr 

81 ''' 

82 stdout: str | bytes 

83 stderr: str | bytes 

84 

85 

86class SubmissionConfig(MongoBase, engine=engine.SubmissionConfig): 

87 TMP_DIR = pathlib.Path( 

88 os.getenv( 

89 'SUBMISSION_TMP_DIR', 

90 tempfile.TemporaryDirectory(suffix='noj-submisisons').name, 

91 ), ) 

92 

93 def __init__(self, name: str): 

94 self.name = name 

95 

96 

97class Submission(MongoBase, engine=engine.Submission): 

98 

99 class Permission(enum.IntFlag): 

100 VIEW = enum.auto() # view submission info 

101 UPLOAD = enum.auto() # student can re-upload 

102 FEEDBACK = enum.auto() # student can view homework feedback 

103 COMMENT = enum.auto() # teacher or TAs can give comment 

104 REJUDGE = enum.auto() # teacher or TAs can rejudge submission 

105 GRADE = enum.auto() # teacher or TAs can grade homework 

106 VIEW_OUTPUT = enum.auto() 

107 OTHER = VIEW 

108 STUDENT = OTHER | UPLOAD | FEEDBACK 

109 MANAGER = STUDENT | COMMENT | REJUDGE | GRADE | VIEW_OUTPUT 

110 

111 _config = None 

112 

113 def __init__(self, submission_id): 

114 self.submission_id = str(submission_id) 

115 

116 def __str__(self): 

117 return f'submission [{self.submission_id}]' 

118 

119 @property 

120 def id(self): 

121 ''' 

122 convert mongo ObjectId to hex string for serialize 

123 ''' 

124 return str(self.obj.id) 

125 

126 @property 

127 def problem_id(self) -> int: 

128 return self.problem.problem_id 

129 

130 @property 

131 def username(self) -> str: 

132 return self.user.username 

133 

134 @property 

135 def status2code(self): 

136 return { 

137 'AC': 0, 

138 'WA': 1, 

139 'CE': 2, 

140 'TLE': 3, 

141 'MLE': 4, 

142 'RE': 5, 

143 'JE': 6, 

144 'OLE': 7, 

145 } 

146 

147 @property 

148 def handwritten(self): 

149 return self.language == 3 

150 

151 @property 

152 def tmp_dir(self) -> pathlib.Path: 

153 tmp_dir = self.config().TMP_DIR 

154 tmp_dir.mkdir(exist_ok=True) 

155 tmp_dir = tmp_dir / self.username / self.id 

156 tmp_dir.mkdir(exist_ok=True, parents=True) 

157 return tmp_dir 

158 

159 @property 

160 def main_code_ext(self): 

161 lang2ext = {0: '.c', 1: '.cpp', 2: '.py', 3: '.pdf'} 

162 return lang2ext[self.language] 

163 

164 def main_code_path(self) -> str: 

165 # handwritten submission didn't provide this function 

166 if self.handwritten: 

167 return 

168 # get excepted code name & temp path 

169 ext = self.main_code_ext 

170 path = self.tmp_dir / f'main{ext}' 

171 # check whether the code has been generated 

172 if not path.exists(): 

173 if (z := self._get_code_zip()) is None: 

174 raise SubmissionCodeNotFound 

175 with z as zf: 

176 path.write_text(zf.read(f'main{ext}').decode('utf-8')) 

177 # return absolute path 

178 return str(path.absolute()) 

179 

180 @classmethod 

181 def config(cls): 

182 if not cls._config: 

183 cls._config = SubmissionConfig('submission') 

184 if not cls._config: 

185 cls._config.save() 

186 return cls._config.reload() 

187 

188 def get_single_output( 

189 self, 

190 task_no: int, 

191 case_no: int, 

192 text: bool = True, 

193 ) -> SubmissionResultOutput: 

194 try: 

195 case = self.tasks[task_no].cases[case_no] 

196 except IndexError: 

197 raise FileNotFoundError('task not exist') 

198 ret = {} 

199 try: 

200 with ZipFile(self._get_output_raw(case)) as zf: 

201 ret = {k: zf.read(k) for k in ('stdout', 'stderr')} 

202 if text: 

203 ret = {k: v.decode('utf-8') for k, v in ret.items()} 

204 except AttributeError: 

205 raise AttributeError('The submission is still in pending') 

206 return ret 

207 

208 def _get_output_raw(self, case: engine.CaseResult) -> io.BytesIO: 

209 ''' 

210 get a output blob of a submission result 

211 ''' 

212 if case.output_minio_path is not None: 

213 # get from minio 

214 minio_client = MinioClient() 

215 try: 

216 resp = minio_client.client.get_object( 

217 minio_client.bucket, 

218 case.output_minio_path, 

219 ) 

220 return io.BytesIO(resp.read()) 

221 finally: 

222 if 'resp' in locals(): 

223 resp.close() 

224 resp.release_conn() 

225 # fallback to gridfs 

226 return case.output 

227 

228 def delete_output(self, *args): 

229 ''' 

230 delete stdout/stderr of this submission 

231 

232 Args: 

233 args: ignored value, don't mind 

234 ''' 

235 for task in self.tasks: 

236 for case in task.cases: 

237 case.output.delete() 

238 case.output_minio_path = None 

239 self.save() 

240 

241 def delete(self, *keeps): 

242 ''' 

243 delete submission and its related file 

244 

245 Args: 

246 keeps: 

247 the field name you want to keep, accepted 

248 value is {'comment', 'code', 'output'} 

249 other value will be ignored 

250 ''' 

251 drops = {'comment', 'code', 'output'} - {*keeps} 

252 del_funcs = { 

253 'output': self.delete_output, 

254 } 

255 

256 def default_del_func(d): 

257 return self.obj[d].delete() 

258 

259 for d in drops: 

260 del_funcs.get(d, default_del_func)(d) 

261 self.obj.delete() 

262 

263 def sandbox_resp_handler(self, resp): 

264 # judge queue is currently full 

265 def on_500(resp): 

266 raise JudgeQueueFullError 

267 

268 # backend send some invalid data 

269 def on_400(resp): 

270 raise ValueError(resp.text) 

271 

272 # send a invalid token 

273 def on_403(resp): 

274 raise ValueError('invalid token') 

275 

276 h = { 

277 500: on_500, 

278 403: on_403, 

279 400: on_400, 

280 200: lambda r: True, 

281 } 

282 try: 

283 return h[resp.status_code](resp) 

284 except KeyError: 

285 self.logger.error('can not handle response from sandbox') 

286 self.logger.error( 

287 f'status code: {resp.status_code}\n' 

288 f'headers: {resp.headers}\n' 

289 f'body: {resp.text}', ) 

290 return False 

291 

292 def target_sandbox(self): 

293 load = 10**3 # current min load 

294 tar = None # target 

295 for sb in self.config().sandbox_instances: 

296 resp = rq.get(f'{sb.url}/status') 

297 if not resp.ok: 

298 self.logger.warning(f'sandbox {sb.name} status exception') 

299 self.logger.warning( 

300 f'status code: {resp.status_code}\n ' 

301 f'body: {resp.text}', ) 

302 continue 

303 resp = resp.json() 

304 if resp['load'] < load: 

305 load = resp['load'] 

306 tar = sb 

307 return tar 

308 

309 def get_comment(self) -> bytes: 

310 ''' 

311 if comment not exist 

312 ''' 

313 if self.comment.grid_id is None: 

314 raise FileNotFoundError('it seems that comment haven\'t upload') 

315 return self.comment.read() 

316 

317 def _check_code(self, file): 

318 if not file: 

319 return 'no file' 

320 if not is_zipfile(file): 

321 return 'not a valid zip file' 

322 

323 # HACK: hard-coded config 

324 MAX_SIZE = 10**7 

325 with ZipFile(file) as zf: 

326 infos = zf.infolist() 

327 

328 size = sum(i.file_size for i in infos) 

329 if size > MAX_SIZE: 

330 return 'code file size too large' 

331 

332 if len(infos) != 1: 

333 return 'more than one file in zip' 

334 name, ext = os.path.splitext(infos[0].filename) 

335 if name != 'main': 

336 return 'only accept file with name \'main\'' 

337 if ext != ['.c', '.cpp', '.py', '.pdf'][self.language]: 

338 return f'invalid file extension, got {ext}' 

339 if ext == '.pdf': 

340 with zf.open('main.pdf') as pdf: 

341 if pdf.read(5) != b'%PDF-': 

342 return 'only accept PDF file.' 

343 file.seek(0) 

344 return None 

345 

346 def rejudge(self) -> bool: 

347 ''' 

348 rejudge this submission 

349 ''' 

350 # delete output file 

351 self.delete_output() 

352 # turn back to haven't be judged 

353 self.update( 

354 status=-1, 

355 last_send=datetime.now(), 

356 tasks=[], 

357 ) 

358 if current_app.config['TESTING']: 

359 return True 

360 return self.send() 

361 

362 def _generate_code_minio_path(self): 

363 return f'submissions/{ULID()}.zip' 

364 

365 def _put_code(self, code_file) -> str: 

366 ''' 

367 put code file to minio, return the object name 

368 ''' 

369 if (err := self._check_code(code_file)) is not None: 

370 raise ValueError(err) 

371 

372 minio_client = MinioClient() 

373 path = self._generate_code_minio_path() 

374 minio_client.client.put_object( 

375 minio_client.bucket, 

376 path, 

377 code_file, 

378 -1, 

379 part_size=5 * 1024 * 1024, 

380 content_type='application/zip', 

381 ) 

382 return path 

383 

384 def submit(self, code_file) -> bool: 

385 ''' 

386 prepare data for submit code to sandbox and then send it 

387 

388 Args: 

389 code_file: a zip file contains user's code 

390 ''' 

391 # unexisted id 

392 if not self: 

393 raise engine.DoesNotExist(f'{self}') 

394 self.update( 

395 status=-1, 

396 last_send=datetime.now(), 

397 code_minio_path=self._put_code(code_file), 

398 ) 

399 self.reload() 

400 self.logger.debug(f'{self} code updated.') 

401 # delete old handwritten submission 

402 if self.handwritten: 

403 q = { 

404 'problem': self.problem, 

405 'user': self.user, 

406 'language': 3, 

407 } 

408 for submission in engine.Submission.objects(**q): 

409 if submission != self.obj: 

410 for homework in self.problem.homeworks: 

411 stat = homework.student_status[self.user.username][str( 

412 self.problem_id)] 

413 stat['score'] = 0 

414 stat['problemStatus'] = -1 

415 stat['submissionIds'] = [] 

416 homework.save() 

417 submission.delete() 

418 # we no need to actually send code to sandbox during testing 

419 if current_app.config['TESTING'] or self.handwritten: 

420 return True 

421 return self.send() 

422 

423 def send(self) -> bool: 

424 ''' 

425 send code to sandbox 

426 ''' 

427 if self.handwritten: 

428 logging.warning(f'try to send a handwritten {self}') 

429 return False 

430 # TODO: Ensure problem is ready to submitted 

431 # if not Problem(self.problem).is_test_case_ready(): 

432 # raise TestCaseNotFound(self.problem.problem_id) 

433 # setup post body 

434 files = { 

435 'src': io.BytesIO(b"".join(self._get_code_raw())), 

436 } 

437 # look for the target sandbox 

438 tar = self.target_sandbox() 

439 if tar is None: 

440 self.logger.error(f'can not target a sandbox for {repr(self)}') 

441 return False 

442 # save token for validation 

443 Submission.assign_token(self.id, tar.token) 

444 post_data = { 

445 'token': tar.token, 

446 'checker': 'print("not implement yet. qaq")', 

447 'problem_id': self.problem_id, 

448 'language': self.language, 

449 } 

450 judge_url = f'{tar.url}/submit/{self.id}' 

451 # send submission to snadbox for judgement 

452 self.logger.info(f'send {self} to {tar.name}') 

453 resp = rq.post( 

454 judge_url, 

455 data=post_data, 

456 files=files, 

457 ) 

458 self.logger.info(f'recieve {self} resp from sandbox') 

459 return self.sandbox_resp_handler(resp) 

460 

461 def process_result(self, tasks: list): 

462 ''' 

463 process results from sandbox 

464 

465 Args: 

466 tasks: 

467 a 2-dim list of the dict with schema 

468 { 

469 'exitCode': int, 

470 'status': str, 

471 'stdout': str, 

472 'stderr': str, 

473 'execTime': int, 

474 'memoryUsage': int 

475 } 

476 ''' 

477 self.logger.info(f'recieve {self} result') 

478 for task in tasks: 

479 for case in task: 

480 # we don't need exit code 

481 del case['exitCode'] 

482 # convert status into integer 

483 case['status'] = self.status2code.get(case['status'], -3) 

484 # process task 

485 minio_client = MinioClient() 

486 for i, cases in enumerate(tasks): 

487 # save stdout/stderr 

488 fds = ['stdout', 'stderr'] 

489 for j, case in enumerate(cases): 

490 tf = NamedTemporaryFile(delete=False) 

491 with ZipFile(tf, 'w') as zf: 

492 for fd in fds: 

493 content = case.pop(fd) 

494 if content is None: 

495 self.logger.error( 

496 f'key {fd} not in case result {self} {i:02d}{j:02d}' 

497 ) 

498 zf.writestr(fd, content) 

499 tf.seek(0) 

500 # upload to minio 

501 output_minio_path = self._generate_output_minio_path(i, j) 

502 minio_client.client.put_object( 

503 minio_client.bucket, 

504 output_minio_path, 

505 io.BytesIO(tf.read()), 

506 -1, 

507 part_size=5 * 1024 * 1024, # 5MB 

508 content_type='application/zip', 

509 ) 

510 # convert dict to document 

511 cases[j] = engine.CaseResult( 

512 status=case['status'], 

513 exec_time=case['execTime'], 

514 memory_usage=case['memoryUsage'], 

515 output_minio_path=output_minio_path, 

516 ) 

517 status = max(c.status for c in cases) 

518 exec_time = max(c.exec_time for c in cases) 

519 memory_usage = max(c.memory_usage for c in cases) 

520 tasks[i] = engine.TaskResult( 

521 status=status, 

522 exec_time=exec_time, 

523 memory_usage=memory_usage, 

524 score=self.problem.test_case.tasks[i].task_score 

525 if status == 0 else 0, 

526 cases=cases, 

527 ) 

528 status = max(t.status for t in tasks) 

529 exec_time = max(t.exec_time for t in tasks) 

530 memory_usage = max(t.memory_usage for t in tasks) 

531 self.update( 

532 score=sum(task.score for task in tasks), 

533 status=status, 

534 tasks=tasks, 

535 exec_time=exec_time, 

536 memory_usage=memory_usage, 

537 ) 

538 self.reload() 

539 self.finish_judging() 

540 return True 

541 

542 def _generate_output_minio_path(self, task_no: int, case_no: int) -> str: 

543 ''' 

544 generate a output file path for minio 

545 ''' 

546 return f'submissions/task{task_no:02d}_case{case_no:02d}_{ULID()}.zip' 

547 

548 def finish_judging(self): 

549 # update user's submission 

550 User(self.username).add_submission(self) 

551 # update homework data 

552 for homework in self.problem.homeworks: 

553 try: 

554 stat = homework.student_status[self.username][str( 

555 self.problem_id)] 

556 except KeyError: 

557 self.logger.warning( 

558 f'{self} not in {homework} [user={self.username}, problem={self.problem_id}]' 

559 ) 

560 continue 

561 if self.handwritten: 

562 continue 

563 if 'rawScore' not in stat: 

564 stat['rawScore'] = 0 

565 stat['submissionIds'].append(self.id) 

566 # handwritten problem will only keep the last submission 

567 if self.handwritten: 

568 stat['submissionIds'] = stat['submissionIds'][-1:] 

569 # if the homework is overdue, do the penalty 

570 if self.timestamp > homework.duration.end and not self.handwritten and homework.penalty is not None: 

571 self.score, stat['rawScore'] = Homework(homework).do_penalty( 

572 self, stat) 

573 else: 

574 if self.score > stat['rawScore']: 

575 stat['rawScore'] = self.score 

576 # update high score / handwritten problem is judged by teacher 

577 if self.score >= stat['score'] or self.handwritten: 

578 stat['score'] = self.score 

579 stat['problemStatus'] = self.status 

580 

581 homework.save() 

582 key = Problem(self.problem).high_score_key(user=self.user) 

583 RedisCache().delete(key) 

584 

585 def add_comment(self, file): 

586 ''' 

587 comment a submission with PDF 

588 

589 Args: 

590 file: a PDF file 

591 ''' 

592 data = file.read() 

593 # check magic number 

594 if data[:5] != b'%PDF-': 

595 raise ValueError('only accept PDF file.') 

596 # write to a new file if it did not exist before 

597 if self.comment.grid_id is None: 

598 write_func = self.comment.put 

599 # replace its content otherwise 

600 else: 

601 write_func = self.comment.replace 

602 write_func(data) 

603 self.logger.debug(f'{self} comment updated.') 

604 # update submission 

605 self.save() 

606 

607 @staticmethod 

608 def count(): 

609 return len(engine.Submission.objects) 

610 

611 @classmethod 

612 def filter( 

613 cls, 

614 user, 

615 offset: int = 0, 

616 count: int = -1, 

617 problem: Optional[Union[Problem, int]] = None, 

618 q_user: Optional[Union[User, str]] = None, 

619 status: Optional[int] = None, 

620 language_type: Optional[Union[List[int], int]] = None, 

621 course: Optional[Union[Course, str]] = None, 

622 before: Optional[datetime] = None, 

623 after: Optional[datetime] = None, 

624 sort_by: Optional[str] = None, 

625 with_count: bool = False, 

626 ip_addr: Optional[str] = None, 

627 ): 

628 if before is not None and after is not None: 

629 if after > before: 

630 raise ValueError('the query period is empty') 

631 if offset < 0: 

632 raise ValueError(f'offset must >= 0!') 

633 if count < -1: 

634 raise ValueError(f'count must >=-1!') 

635 if sort_by is not None and sort_by not in ['runTime', 'memoryUsage']: 

636 raise ValueError(f'can only sort by runTime or memoryUsage') 

637 wont_have_results = False 

638 if isinstance(problem, int): 

639 problem = Problem(problem).obj 

640 if problem is None: 

641 wont_have_results = True 

642 if isinstance(q_user, str): 

643 q_user = User(q_user) 

644 if not q_user: 

645 wont_have_results = True 

646 q_user = q_user.obj 

647 if isinstance(course, str): 

648 course = Course(course) 

649 if not course: 

650 wont_have_results = True 

651 # problem's query key 

652 p_k = 'problem' 

653 if course: 

654 problems = Problem.get_problem_list( 

655 user, 

656 course=course.course_name, 

657 ) 

658 # use all problems under this course to filter 

659 if problem is None: 

660 p_k = 'problem__in' 

661 problem = problems 

662 # if problem not in course 

663 elif problem not in problems: 

664 wont_have_results = True 

665 if wont_have_results: 

666 return ([], 0) if with_count else [] 

667 if isinstance(language_type, int): 

668 language_type = [language_type] 

669 # query args 

670 q = { 

671 p_k: problem, 

672 'status': status, 

673 'language__in': language_type, 

674 'user': q_user, 

675 'ip_addr': ip_addr, 

676 'timestamp__lte': before, 

677 'timestamp__gte': after, 

678 } 

679 q = {k: v for k, v in q.items() if v is not None} 

680 # sort by upload time 

681 submissions = engine.Submission.objects( 

682 **q).order_by(sort_by if sort_by is not None else '-timestamp') 

683 submission_count = submissions.count() 

684 # truncate 

685 if count == -1: 

686 submissions = submissions[offset:] 

687 else: 

688 submissions = submissions[offset:offset + count] 

689 submissions = list(cls(s) for s in submissions) 

690 if with_count: 

691 return submissions, submission_count 

692 return submissions 

693 

694 @classmethod 

695 def add( 

696 cls, 

697 problem_id: int, 

698 username: str, 

699 lang: int, 

700 timestamp: Optional[date] = None, 

701 ip_addr: Optional[str] = None, 

702 ) -> 'Submission': 

703 ''' 

704 Insert a new submission into db 

705 

706 Returns: 

707 The created submission 

708 ''' 

709 # check existence 

710 user = User(username) 

711 if not user: 

712 raise engine.DoesNotExist(f'{user} does not exist') 

713 problem = Problem(problem_id) 

714 if not problem: 

715 raise engine.DoesNotExist(f'{problem} dose not exist') 

716 # TODO: Ensure problem is ready to submitted 

717 # if not problem.is_test_case_ready(): 

718 # raise TestCaseNotFound(problem_id) 

719 if timestamp is None: 

720 timestamp = datetime.now() 

721 # create a new submission 

722 submission = engine.Submission(problem=problem.obj, 

723 user=user.obj, 

724 language=lang, 

725 timestamp=timestamp, 

726 ip_addr=ip_addr) 

727 submission.save() 

728 return cls(submission.id) 

729 

730 @classmethod 

731 def assign_token(cls, submission_id, token=None): 

732 ''' 

733 generate a token for the submission 

734 ''' 

735 if token is None: 

736 token = gen_token() 

737 RedisCache().set(gen_key(submission_id), token) 

738 return token 

739 

740 @classmethod 

741 def verify_token(cls, submission_id, token): 

742 cache = RedisCache() 

743 key = gen_key(submission_id) 

744 s_token = cache.get(key) 

745 if s_token is None: 

746 return False 

747 s_token = s_token.decode('ascii') 

748 valid = secrets.compare_digest(s_token, token) 

749 if valid: 

750 cache.delete(key) 

751 return valid 

752 

753 def to_dict(self) -> Dict[str, Any]: 

754 ret = self._to_dict() 

755 # Convert Bson object to python dictionary 

756 ret = ret.to_dict() 

757 return ret 

758 

759 def _to_dict(self) -> SON: 

760 ret = self.to_mongo() 

761 _ret = { 

762 'problemId': ret['problem'], 

763 'user': self.user.info, 

764 'submissionId': str(self.id), 

765 'timestamp': self.timestamp.timestamp(), 

766 'lastSend': self.last_send.timestamp(), 

767 'ipAddr': self.ip_addr, 

768 } 

769 old = [ 

770 '_id', 

771 'problem', 

772 'code', 

773 'comment', 

774 'tasks', 

775 'ip_addr', 

776 ] 

777 # delete old keys 

778 for o in old: 

779 del ret[o] 

780 # insert new keys 

781 ret.update(**_ret) 

782 return ret 

783 

784 def get_result(self) -> List[Dict[str, Any]]: 

785 ''' 

786 Get results without output 

787 ''' 

788 tasks = [task.to_mongo() for task in self.tasks] 

789 for task in tasks: 

790 for case in task['cases']: 

791 del case['output'] 

792 return [task.to_dict() for task in tasks] 

793 

794 def get_detailed_result(self) -> List[Dict[str, Any]]: 

795 ''' 

796 Get all results (including stdout/stderr) of this submission 

797 ''' 

798 tasks = [task.to_mongo() for task in self.tasks] 

799 for i, task in enumerate(tasks): 

800 for j, case in enumerate(task.cases): 

801 output = self.get_single_output(i, j) 

802 case['stdout'] = output['stdout'] 

803 case['stderr'] = output['stderr'] 

804 del case['output'] # non-serializable field 

805 return [task.to_dict() for task in tasks] 

806 

807 def _get_code_raw(self): 

808 if self.code.grid_id is None and self.code_minio_path is None: 

809 return None 

810 

811 if self.code_minio_path is not None: 

812 minio_client = MinioClient() 

813 try: 

814 resp = minio_client.client.get_object( 

815 minio_client.bucket, 

816 self.code_minio_path, 

817 ) 

818 return [resp.read()] 

819 finally: 

820 if 'resp' in locals(): 

821 resp.close() 

822 resp.release_conn() 

823 

824 # fallback to read from gridfs 

825 return [self.code.read()] 

826 

827 def _get_code_zip(self): 

828 if (raw := self._get_code_raw()) is None: 

829 return None 

830 return ZipFile(io.BytesIO(b"".join(raw))) 

831 

832 def get_code(self, path: str, binary=False) -> Union[str, bytes]: 

833 # read file 

834 try: 

835 if (z := self._get_code_zip()) is None: 

836 raise SubmissionCodeNotFound 

837 with z as zf: 

838 data = zf.read(path) 

839 # file not exists in the zip or code haven't been uploaded 

840 except KeyError: 

841 return None 

842 # decode byte if need 

843 if not binary: 

844 try: 

845 data = data.decode('utf-8') 

846 except UnicodeDecodeError: 

847 data = 'Unusual file content, decode fail' 

848 return data 

849 

850 def get_main_code(self) -> str: 

851 ''' 

852 Get source code user submitted 

853 ''' 

854 ext = self.main_code_ext 

855 return self.get_code(f'main{ext}') 

856 

857 def has_code(self) -> bool: 

858 return self._get_code_zip() is not None 

859 

860 def own_permission(self, user) -> Permission: 

861 key = f'SUBMISSION_PERMISSION_{self.id}_{user.id}_{self.problem.id}' 

862 # Check cache 

863 cache = RedisCache() 

864 if (v := cache.get(key)) is not None: 

865 return self.Permission(int(v)) 

866 

867 # Calculate 

868 if max( 

869 course.own_permission(user) for course in map( 

870 Course, self.problem.courses)) & Course.Permission.GRADE: 

871 cap = self.Permission.MANAGER 

872 elif user.username == self.user.username: 

873 cap = self.Permission.STUDENT 

874 elif Problem(self.problem).permission( 

875 user=user, 

876 req=Problem.Permission.VIEW, 

877 ): 

878 cap = self.Permission.OTHER 

879 else: 

880 cap = self.Permission(0) 

881 

882 # students can view outputs of their CE submissions 

883 CE = 2 

884 if cap & self.Permission.STUDENT and self.status == CE: 

885 cap |= self.Permission.VIEW_OUTPUT 

886 

887 cache.set(key, cap.value, 60) 

888 return cap 

889 

890 def permission(self, user, req: Permission): 

891 """ 

892 check whether user own `req` permission 

893 """ 

894 

895 return bool(self.own_permission(user) & req) 

896 

897 def migrate_code_to_minio(self): 

898 """ 

899 migrate code from gridfs to minio 

900 """ 

901 # nothing to migrate 

902 if self.code is None or self.code.grid_id is None: 

903 self.logger.info(f"no code to migrate. submission={self.id}") 

904 return 

905 

906 # upload code to minio 

907 if self.code_minio_path is None: 

908 self.logger.info(f"uploading code to minio. submission={self.id}") 

909 self.update(code_minio_path=self._put_code(self.code), ) 

910 self.reload() 

911 self.logger.info( 

912 f"code uploaded to minio. submission={self.id} path={self.code_minio_path}" 

913 ) 

914 

915 # remove code in gridfs if it is consistent 

916 if self._check_code_consistency(): 

917 self.logger.info( 

918 f"data consistency validated, removing code in gridfs. submission={self.id}" 

919 ) 

920 self._remove_code_in_mongodb() 

921 else: 

922 self.logger.warning( 

923 f"data inconsistent, keeping code in gridfs. submission={self.id}" 

924 ) 

925 

926 def _remove_code_in_mongodb(self): 

927 self.code.delete() 

928 self.save() 

929 self.reload('code') 

930 

931 def _check_code_consistency(self): 

932 """ 

933 check whether the submission is consistent 

934 """ 

935 if self.code is None or self.code.grid_id is None: 

936 return False 

937 gridfs_code = self.code.read() 

938 if gridfs_code is None: 

939 # if file is deleted but GridFS proxy is not updated 

940 return False 

941 gridfs_checksum = md5(gridfs_code).hexdigest() 

942 self.logger.info( 

943 f"calculated grid checksum. submission={self.id} checksum={gridfs_checksum}" 

944 ) 

945 

946 minio_client = MinioClient() 

947 try: 

948 resp = minio_client.client.get_object( 

949 minio_client.bucket, 

950 self.code_minio_path, 

951 ) 

952 minio_code = resp.read() 

953 finally: 

954 if 'resp' in locals(): 

955 resp.close() 

956 resp.release_conn() 

957 

958 minio_checksum = md5(minio_code).hexdigest() 

959 self.logger.info( 

960 f"calculated minio checksum. submission={self.id} checksum={minio_checksum}" 

961 ) 

962 return minio_checksum == gridfs_checksum