Coverage for mongo/submission.py: 68%

559 statements  

« prev     ^ index     » next       coverage.py v7.9.2, created at 2026-07-24 16:42 +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 httpx 

17from hashlib import md5 

18from bson.son import SON 

19from tempfile import NamedTemporaryFile 

20from datetime import date, datetime 

21from zipfile import ZipFile, is_zipfile 

22from ulid import ULID 

23 

24from config import settings 

25from . import engine 

26from .base import MongoBase 

27from .user import User 

28from .problem import Problem 

29from .homework import Homework 

30from .course import Course 

31from .utils import RedisCache, MinioClient, is_testing 

32 

33__all__ = [ 

34 'SubmissionConfig', 

35 'Submission', 

36 'JudgeQueueFullError', 

37 'TestCaseNotFound', 

38] 

39 

40# TODO: modular token function 

41 

42 

43def gen_key(_id): 

44 return f'stoekn_{_id}' 

45 

46 

47def gen_token(): 

48 return secrets.token_urlsafe() 

49 

50 

51# Errors 

52class JudgeQueueFullError(Exception): 

53 ''' 

54 when sandbox task queue is full 

55 ''' 

56 

57 

58class TestCaseNotFound(Exception): 

59 ''' 

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

61 ''' 

62 __test__ = False 

63 

64 def __init__(self, problem_id): 

65 self.problem_id = problem_id 

66 

67 def __str__(self): 

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

69 

70 

71class SubmissionCodeNotFound(Exception): 

72 ''' 

73 when a submission's code is not found 

74 ''' 

75 

76 

77class SubmissionResultOutput(TypedDict): 

78 ''' 

79 output of a submission result, including stdout and stderr 

80 ''' 

81 stdout: str | bytes 

82 stderr: str | bytes 

83 

84 

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

86 TMP_DIR = pathlib.Path(settings.SUBMISSION_TMP_DIR) 

87 

88 def __init__(self, name: str): 

89 self.name = name 

90 

91 

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

93 

94 class Permission(enum.IntFlag): 

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

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

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

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

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

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

101 VIEW_OUTPUT = enum.auto() 

102 OTHER = VIEW 

103 STUDENT = OTHER | UPLOAD | FEEDBACK 

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

105 

106 _config = None 

107 

108 def __init__(self, submission_id): 

109 self.submission_id = str(submission_id) 

110 

111 def __str__(self): 

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

113 

114 @property 

115 def id(self): 

116 ''' 

117 convert mongo ObjectId to hex string for serialize 

118 ''' 

119 return str(self.obj.id) 

120 

121 @property 

122 def problem_id(self) -> int: 

123 return self.problem.problem_id 

124 

125 @property 

126 def username(self) -> str: 

127 return self.user.username 

128 

129 @property 

130 def status2code(self): 

131 return { 

132 'AC': 0, 

133 'WA': 1, 

134 'CE': 2, 

135 'TLE': 3, 

136 'MLE': 4, 

137 'RE': 5, 

138 'JE': 6, 

139 'OLE': 7, 

140 } 

141 

142 @property 

143 def handwritten(self): 

144 return self.language == 3 

145 

146 @property 

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

148 tmp_dir = self.config().TMP_DIR 

149 tmp_dir.mkdir(exist_ok=True) 

150 tmp_dir = tmp_dir / self.username / self.id 

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

152 return tmp_dir 

153 

154 @property 

155 def main_code_ext(self): 

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

157 return lang2ext[self.language] 

158 

159 def main_code_path(self) -> str: 

160 # handwritten submission didn't provide this function 

161 if self.handwritten: 

162 return 

163 # get excepted code name & temp path 

164 ext = self.main_code_ext 

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

166 # check whether the code has been generated 

167 if not path.exists(): 

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

169 raise SubmissionCodeNotFound 

170 with z as zf: 

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

172 # return absolute path 

173 return str(path.absolute()) 

174 

175 @classmethod 

176 def config(cls): 

177 if not cls._config: 

178 cls._config = SubmissionConfig('submission') 

179 if not cls._config: 

180 cls._config.save() 

181 return cls._config.reload() 

182 

183 def get_single_output( 

184 self, 

185 task_no: int, 

186 case_no: int, 

187 text: bool = True, 

188 ) -> SubmissionResultOutput: 

189 try: 

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

191 except IndexError: 

192 raise FileNotFoundError('task not exist') 

193 ret = {} 

194 try: 

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

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

197 if text: 

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

199 except AttributeError: 

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

201 return ret 

202 

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

204 ''' 

205 get a output blob of a submission result 

206 ''' 

207 if case.output_minio_path is not None: 

208 # get from minio 

209 minio_client = MinioClient() 

210 try: 

211 resp = minio_client.client.get_object( 

212 minio_client.bucket, 

213 case.output_minio_path, 

214 ) 

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

216 finally: 

217 if 'resp' in locals(): 

218 resp.close() 

219 resp.release_conn() 

220 # fallback to gridfs 

221 return case.output 

222 

223 def delete_output(self, *args): 

224 ''' 

225 delete stdout/stderr of this submission 

226 

227 Args: 

228 args: ignored value, don't mind 

229 ''' 

230 for task in self.tasks: 

231 for case in task.cases: 

232 case.output.delete() 

233 case.output_minio_path = None 

234 self.save() 

235 

236 def delete(self, *keeps): 

237 ''' 

238 delete submission and its related file 

239 

240 Args: 

241 keeps: 

242 the field name you want to keep, accepted 

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

244 other value will be ignored 

245 ''' 

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

247 del_funcs = { 

248 'output': self.delete_output, 

249 } 

250 

251 def default_del_func(d): 

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

253 

254 for d in drops: 

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

256 self.obj.delete() 

257 

258 def sandbox_resp_handler(self, resp): 

259 # judge queue is currently full 

260 def on_500(resp): 

261 raise JudgeQueueFullError 

262 

263 # backend send some invalid data 

264 def on_400(resp): 

265 raise ValueError(resp.text) 

266 

267 # send a invalid token 

268 def on_403(resp): 

269 raise ValueError('invalid token') 

270 

271 h = { 

272 500: on_500, 

273 403: on_403, 

274 400: on_400, 

275 200: lambda r: True, 

276 } 

277 try: 

278 return h[resp.status_code](resp) 

279 except KeyError: 

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

281 self.logger.error( 

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

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

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

285 return False 

286 

287 def target_sandbox(self, client: httpx.Client | None = None): 

288 if client is None: 

289 client = httpx.Client(timeout=5.0) 

290 load = 10**3 # current min load 

291 tar = None # target 

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

293 resp = client.get(f'{sb.url}/status') 

294 if not resp.is_success: 

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

296 self.logger.warning( 

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

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

299 continue 

300 resp = resp.json() 

301 if resp['load'] < load: 

302 load = resp['load'] 

303 tar = sb 

304 return tar 

305 

306 def get_comment(self) -> bytes: 

307 ''' 

308 if comment not exist 

309 ''' 

310 if self.comment.grid_id is None: 

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

312 return self.comment.read() 

313 

314 def _check_code(self, file): 

315 if not file: 

316 return 'no file' 

317 if not is_zipfile(file): 

318 return 'not a valid zip file' 

319 

320 # HACK: hard-coded config 

321 MAX_SIZE = 10**7 

322 with ZipFile(file) as zf: 

323 infos = zf.infolist() 

324 

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

326 if size > MAX_SIZE: 

327 return 'code file size too large' 

328 

329 if len(infos) != 1: 

330 return 'more than one file in zip' 

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

332 if name != 'main': 

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

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

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

336 if ext == '.pdf': 

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

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

339 return 'only accept PDF file.' 

340 file.seek(0) 

341 return None 

342 

343 def rejudge(self, client: httpx.Client | None = None) -> bool: 

344 ''' 

345 rejudge this submission 

346 ''' 

347 # delete output file 

348 self.delete_output() 

349 # turn back to haven't be judged 

350 self.update( 

351 status=-1, 

352 last_send=datetime.now(), 

353 tasks=[], 

354 ) 

355 if is_testing(): 

356 return True 

357 return self.send(client=client) 

358 

359 def _generate_code_minio_path(self): 

360 return f'submissions/{self.id}_{ULID()}.zip' 

361 

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

363 ''' 

364 put code file to minio, return the object name 

365 ''' 

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

367 raise ValueError(err) 

368 

369 minio_client = MinioClient() 

370 path = self._generate_code_minio_path() 

371 minio_client.client.put_object( 

372 minio_client.bucket, 

373 path, 

374 code_file, 

375 -1, 

376 part_size=5 * 1024 * 1024, 

377 content_type='application/zip', 

378 ) 

379 return path 

380 

381 def submit(self, code_file, client: httpx.Client | None = None) -> bool: 

382 ''' 

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

384 

385 Args: 

386 code_file: a zip file contains user's code 

387 ''' 

388 # unexisted id 

389 if not self: 

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

391 self.update( 

392 status=-1, 

393 last_send=datetime.now(), 

394 code_minio_path=self._put_code(code_file), 

395 ) 

396 self.reload() 

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

398 # delete old handwritten submission 

399 if self.handwritten: 

400 q = { 

401 'problem': self.problem, 

402 'user': self.user, 

403 'language': 3, 

404 } 

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

406 if submission != self.obj: 

407 for homework in self.problem.homeworks: 

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

409 self.problem_id)] 

410 stat['score'] = 0 

411 stat['problemStatus'] = -1 

412 stat['submissionIds'] = [] 

413 homework.save() 

414 submission.delete() 

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

416 if is_testing() or self.handwritten: 

417 return True 

418 return self.send(client=client) 

419 

420 def send(self, client: httpx.Client | None = None) -> bool: 

421 ''' 

422 send code to sandbox 

423 ''' 

424 if self.handwritten: 

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

426 return False 

427 if client is None: 

428 client = httpx.Client(timeout=httpx.Timeout(5.0, read=30.0)) 

429 # TODO: Ensure problem is ready to submitted 

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

431 # raise TestCaseNotFound(self.problem.problem_id) 

432 # setup post body 

433 files = { 

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

435 } 

436 # look for the target sandbox 

437 tar = self.target_sandbox(client=client) 

438 if tar is None: 

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

440 return False 

441 # save token for validation 

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

443 post_data = { 

444 'token': tar.token, 

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

446 'problem_id': self.problem_id, 

447 'language': self.language, 

448 } 

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

450 # send submission to snadbox for judgement 

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

452 resp = client.post( 

453 judge_url, 

454 data=post_data, 

455 files=files, 

456 ) 

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

458 return self.sandbox_resp_handler(resp) 

459 

460 def process_result(self, tasks: list): 

461 ''' 

462 process results from sandbox 

463 

464 Args: 

465 tasks: 

466 a 2-dim list of the dict with schema 

467 { 

468 'exitCode': int, 

469 'status': str, 

470 'stdout': str, 

471 'stderr': str, 

472 'execTime': int, 

473 'memoryUsage': int 

474 } 

475 ''' 

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

477 for task in tasks: 

478 for case in task: 

479 # we don't need exit code 

480 del case['exitCode'] 

481 # convert status into integer 

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

483 # process task 

484 minio_client = MinioClient() 

485 for i, cases in enumerate(tasks): 

486 # save stdout/stderr 

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

488 for j, case in enumerate(cases): 

489 tf = NamedTemporaryFile(delete=True) 

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

491 for fd in fds: 

492 content = case.pop(fd) 

493 if content is None: 

494 self.logger.error( 

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

496 ) 

497 zf.writestr(fd, content) 

498 tf.seek(0) 

499 # upload to minio 

500 output_minio_path = self._generate_output_minio_path(i, j) 

501 minio_client.client.put_object( 

502 minio_client.bucket, 

503 output_minio_path, 

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

505 -1, 

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

507 content_type='application/zip', 

508 ) 

509 # convert dict to document 

510 cases[j] = engine.CaseResult( 

511 status=case['status'], 

512 exec_time=case['execTime'], 

513 memory_usage=case['memoryUsage'], 

514 output_minio_path=output_minio_path, 

515 ) 

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

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

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

519 tasks[i] = engine.TaskResult( 

520 status=status, 

521 exec_time=exec_time, 

522 memory_usage=memory_usage, 

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

524 if status == 0 else 0, 

525 cases=cases, 

526 ) 

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

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

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

530 self.update( 

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

532 status=status, 

533 tasks=tasks, 

534 exec_time=exec_time, 

535 memory_usage=memory_usage, 

536 ) 

537 self.reload() 

538 self.finish_judging() 

539 return True 

540 

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

542 ''' 

543 generate a output file path for minio 

544 ''' 

545 return f'submissions/{self.id}_task{task_no:02d}_case{case_no:02d}_{ULID()}.zip' 

546 

547 def finish_judging(self): 

548 # update user's submission 

549 User(self.username).add_submission(self) 

550 # update homework data 

551 for homework in self.problem.homeworks: 

552 try: 

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

554 self.problem_id)] 

555 except KeyError: 

556 self.logger.warning( 

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

558 ) 

559 continue 

560 if self.handwritten: 

561 continue 

562 if 'rawScore' not in stat: 

563 stat['rawScore'] = 0 

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

565 # handwritten problem will only keep the last submission 

566 if self.handwritten: 

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

568 # if the homework is overdue, do the penalty 

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

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

571 self, stat) 

572 else: 

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

574 stat['rawScore'] = self.score 

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

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

577 stat['score'] = self.score 

578 stat['problemStatus'] = self.status 

579 

580 homework.save() 

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

582 RedisCache().delete(key) 

583 

584 def add_comment(self, file): 

585 ''' 

586 comment a submission with PDF 

587 

588 Args: 

589 file: a PDF file 

590 ''' 

591 data = file.read() 

592 # check magic number 

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

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

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

596 if self.comment.grid_id is None: 

597 write_func = self.comment.put 

598 # replace its content otherwise 

599 else: 

600 write_func = self.comment.replace 

601 write_func(data) 

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

603 # update submission 

604 self.save() 

605 

606 @staticmethod 

607 def count(): 

608 return len(engine.Submission.objects) 

609 

610 @classmethod 

611 def filter( 

612 cls, 

613 user, 

614 offset: int = 0, 

615 count: int = -1, 

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

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

618 status: Optional[int] = None, 

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

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

621 before: Optional[datetime] = None, 

622 after: Optional[datetime] = None, 

623 sort_by: Optional[str] = None, 

624 with_count: bool = False, 

625 ip_addr: Optional[str] = None, 

626 ): 

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

628 if after > before: 

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

630 if offset < 0: 

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

632 if count < -1: 

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

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

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

636 wont_have_results = False 

637 if isinstance(problem, int): 

638 problem = Problem(problem).obj 

639 if problem is None: 

640 wont_have_results = True 

641 if isinstance(q_user, str): 

642 q_user = User(q_user) 

643 if not q_user: 

644 wont_have_results = True 

645 q_user = q_user.obj 

646 if isinstance(course, str): 

647 course = Course(course) 

648 if not course: 

649 wont_have_results = True 

650 # problem's query key 

651 p_k = 'problem' 

652 if course: 

653 problems = Problem.get_problem_list( 

654 user, 

655 course=course.course_name, 

656 ) 

657 # use all problems under this course to filter 

658 if problem is None: 

659 p_k = 'problem__in' 

660 problem = problems 

661 # if problem not in course 

662 elif problem not in problems: 

663 wont_have_results = True 

664 if wont_have_results: 

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

666 if isinstance(language_type, int): 

667 language_type = [language_type] 

668 # query args 

669 q = { 

670 p_k: problem, 

671 'status': status, 

672 'language__in': language_type, 

673 'user': q_user, 

674 'ip_addr': ip_addr, 

675 'timestamp__lte': before, 

676 'timestamp__gte': after, 

677 } 

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

679 # sort by upload time 

680 submissions = engine.Submission.objects( 

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

682 submission_count = submissions.count() 

683 # truncate 

684 if count == -1: 

685 submissions = submissions[offset:] 

686 else: 

687 submissions = submissions[offset:offset + count] 

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

689 if with_count: 

690 return submissions, submission_count 

691 return submissions 

692 

693 @classmethod 

694 def add( 

695 cls, 

696 problem_id: int, 

697 username: str, 

698 lang: int, 

699 timestamp: Optional[date] = None, 

700 ip_addr: Optional[str] = None, 

701 ) -> 'Submission': 

702 ''' 

703 Insert a new submission into db 

704 

705 Returns: 

706 The created submission 

707 ''' 

708 # check existence 

709 user = User(username) 

710 if not user: 

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

712 problem = Problem(problem_id) 

713 if not problem: 

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

715 # TODO: Ensure problem is ready to submitted 

716 # if not problem.is_test_case_ready(): 

717 # raise TestCaseNotFound(problem_id) 

718 if timestamp is None: 

719 timestamp = datetime.now() 

720 # create a new submission 

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

722 user=user.obj, 

723 language=lang, 

724 timestamp=timestamp, 

725 ip_addr=ip_addr) 

726 submission.save() 

727 return cls(submission.id) 

728 

729 @classmethod 

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

731 ''' 

732 generate a token for the submission 

733 ''' 

734 if token is None: 

735 token = gen_token() 

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

737 return token 

738 

739 @classmethod 

740 def verify_token(cls, submission_id, token): 

741 cache = RedisCache() 

742 key = gen_key(submission_id) 

743 s_token = cache.get(key) 

744 if s_token is None: 

745 return False 

746 s_token = s_token.decode('ascii') 

747 valid = secrets.compare_digest(s_token, token) 

748 if valid: 

749 cache.delete(key) 

750 return valid 

751 

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

753 ret = self._to_dict() 

754 # Convert Bson object to python dictionary 

755 ret = ret.to_dict() 

756 return ret 

757 

758 def _to_dict(self) -> SON: 

759 ret = self.to_mongo() 

760 _ret = { 

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

762 'user': self.user.info, 

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

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

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

766 'ipAddr': self.ip_addr, 

767 } 

768 old = [ 

769 '_id', 

770 'problem', 

771 'code', 

772 'comment', 

773 'tasks', 

774 'ip_addr', 

775 ] 

776 # delete old keys 

777 for o in old: 

778 del ret[o] 

779 # insert new keys 

780 ret.update(**_ret) 

781 return ret 

782 

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

784 ''' 

785 Get results without output 

786 ''' 

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

788 for task in tasks: 

789 for case in task['cases']: 

790 del case['output'] 

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

792 

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

794 ''' 

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

796 ''' 

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

798 for i, task in enumerate(tasks): 

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

800 output = self.get_single_output(i, j) 

801 case['stdout'] = output['stdout'] 

802 case['stderr'] = output['stderr'] 

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

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

805 

806 def _get_code_raw(self): 

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

808 return None 

809 

810 if self.code_minio_path is not None: 

811 minio_client = MinioClient() 

812 try: 

813 resp = minio_client.client.get_object( 

814 minio_client.bucket, 

815 self.code_minio_path, 

816 ) 

817 return [resp.read()] 

818 finally: 

819 if 'resp' in locals(): 

820 resp.close() 

821 resp.release_conn() 

822 

823 # fallback to read from gridfs 

824 return [self.code.read()] 

825 

826 def _get_code_zip(self): 

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

828 return None 

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

830 

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

832 # read file 

833 try: 

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

835 raise SubmissionCodeNotFound 

836 with z as zf: 

837 data = zf.read(path) 

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

839 except KeyError: 

840 return None 

841 # decode byte if need 

842 if not binary: 

843 try: 

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

845 except UnicodeDecodeError: 

846 data = 'Unusual file content, decode fail' 

847 return data 

848 

849 def get_main_code(self) -> str: 

850 ''' 

851 Get source code user submitted 

852 ''' 

853 ext = self.main_code_ext 

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

855 

856 def has_code(self) -> bool: 

857 return self._get_code_zip() is not None 

858 

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

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

861 # Check cache 

862 cache = RedisCache() 

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

864 return self.Permission(int(v)) 

865 

866 # Calculate 

867 if max( 

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

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

870 cap = self.Permission.MANAGER 

871 elif user.username == self.user.username: 

872 cap = self.Permission.STUDENT 

873 elif Problem(self.problem).permission( 

874 user=user, 

875 req=Problem.Permission.VIEW, 

876 ): 

877 cap = self.Permission.OTHER 

878 else: 

879 cap = self.Permission(0) 

880 

881 # students can view outputs of their CE submissions 

882 CE = 2 

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

884 cap |= self.Permission.VIEW_OUTPUT 

885 

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

887 return cap 

888 

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

890 """ 

891 check whether user own `req` permission 

892 """ 

893 

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

895 

896 def migrate_code_to_minio(self): 

897 """ 

898 migrate code from gridfs to minio 

899 """ 

900 # nothing to migrate 

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

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

903 return 

904 

905 # upload code to minio 

906 if self.code_minio_path is None: 

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

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

909 self.reload() 

910 self.logger.info( 

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

912 ) 

913 

914 # remove code in gridfs if it is consistent 

915 if self._check_code_consistency(): 

916 self.logger.info( 

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

918 ) 

919 self._remove_code_in_mongodb() 

920 else: 

921 self.logger.warning( 

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

923 ) 

924 

925 def _remove_code_in_mongodb(self): 

926 self.code.delete() 

927 self.save() 

928 self.reload('code') 

929 

930 def _check_code_consistency(self): 

931 """ 

932 check whether the submission is consistent 

933 """ 

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

935 return False 

936 gridfs_code = self.code.read() 

937 if gridfs_code is None: 

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

939 return False 

940 gridfs_checksum = md5(gridfs_code).hexdigest() 

941 self.logger.info( 

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

943 ) 

944 

945 minio_client = MinioClient() 

946 try: 

947 resp = minio_client.client.get_object( 

948 minio_client.bucket, 

949 self.code_minio_path, 

950 ) 

951 minio_code = resp.read() 

952 finally: 

953 if 'resp' in locals(): 

954 resp.close() 

955 resp.release_conn() 

956 

957 minio_checksum = md5(minio_code).hexdigest() 

958 self.logger.info( 

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

960 ) 

961 return minio_checksum == gridfs_checksum 

962 

963 def migrate_output_to_minio(self): 

964 """ 

965 migrate output from gridfs to minio 

966 """ 

967 for (i, task) in enumerate(self.tasks): 

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

969 self._migrate_case_output_to_minio(case, i, j) 

970 

971 def _migrate_case_output_to_minio( 

972 self, 

973 case: engine.CaseResult, 

974 i: int, 

975 j: int, 

976 ): 

977 """ 

978 migrate a single case output to minio 

979 """ 

980 minio_client = MinioClient() 

981 

982 if case.output is None or case.output.grid_id is None: 

983 self.logger.info( 

984 f"no output to migrate. submission={self.id} task={i} case={j}" 

985 ) 

986 return 

987 

988 if case.output_minio_path is None: 

989 self.logger.info( 

990 f"uploading output to minio. submission={self.id} task={i} case={j}" 

991 ) 

992 output_minio_path = self._generate_output_minio_path(i, j) 

993 minio_client.client.put_object( 

994 minio_client.bucket, 

995 output_minio_path, 

996 io.BytesIO(case.output.read()), 

997 -1, 

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

999 content_type='application/zip', 

1000 ) 

1001 case.output_minio_path = output_minio_path 

1002 self.save() 

1003 self.logger.info( 

1004 f"output uploaded to minio. submission={self.id} task={i} case={j}" 

1005 ) 

1006 

1007 # remove output in gridfs if it is consistent 

1008 if self._check_case_output_consistency(case, i, j): 

1009 self.logger.info( 

1010 f"data consistency validated, removing output in gridfs. submission={self.id} task={i} case={j}" 

1011 ) 

1012 case.output.delete() 

1013 self.save() 

1014 else: 

1015 self.logger.warning( 

1016 f"data inconsistent, keeping output in gridfs. submission={self.id} task={i} case={j}" 

1017 ) 

1018 

1019 def _check_case_output_consistency( 

1020 self, 

1021 case: engine.CaseResult, 

1022 i: int, 

1023 j: int, 

1024 ): 

1025 """ 

1026 check whether the case output is consistent 

1027 """ 

1028 if case.output is None or case.output.grid_id is None: 

1029 return False 

1030 gridfs_output = case.output.read() 

1031 if gridfs_output is None: 

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

1033 return False 

1034 gridfs_checksum = md5(gridfs_output).hexdigest() 

1035 self.logger.info( 

1036 f"calculated grid checksum. submission={self.id} task={i} case={j} checksum={gridfs_checksum}" 

1037 ) 

1038 

1039 minio_client = MinioClient() 

1040 try: 

1041 resp = minio_client.client.get_object( 

1042 minio_client.bucket, 

1043 case.output_minio_path, 

1044 ) 

1045 minio_output = resp.read() 

1046 finally: 

1047 if 'resp' in locals(): 

1048 resp.close() 

1049 resp.release_conn() 

1050 

1051 minio_checksum = md5(minio_output).hexdigest() 

1052 self.logger.info( 

1053 f"calculated minio checksum. submission={self.id} task={i} case={j} checksum={minio_checksum}" 

1054 ) 

1055 return minio_checksum == gridfs_checksum