Coverage for mongo/submission.py: 68%
559 statements
« prev ^ index » next coverage.py v7.9.2, created at 2026-07-24 16:42 +0000
« 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
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
33__all__ = [
34 'SubmissionConfig',
35 'Submission',
36 'JudgeQueueFullError',
37 'TestCaseNotFound',
38]
40# TODO: modular token function
43def gen_key(_id):
44 return f'stoekn_{_id}'
47def gen_token():
48 return secrets.token_urlsafe()
51# Errors
52class JudgeQueueFullError(Exception):
53 '''
54 when sandbox task queue is full
55 '''
58class TestCaseNotFound(Exception):
59 '''
60 when a problem's testcase havn't been uploaded
61 '''
62 __test__ = False
64 def __init__(self, problem_id):
65 self.problem_id = problem_id
67 def __str__(self):
68 return f'{Problem(self.problem_id)}\'s testcase is not found'
71class SubmissionCodeNotFound(Exception):
72 '''
73 when a submission's code is not found
74 '''
77class SubmissionResultOutput(TypedDict):
78 '''
79 output of a submission result, including stdout and stderr
80 '''
81 stdout: str | bytes
82 stderr: str | bytes
85class SubmissionConfig(MongoBase, engine=engine.SubmissionConfig):
86 TMP_DIR = pathlib.Path(settings.SUBMISSION_TMP_DIR)
88 def __init__(self, name: str):
89 self.name = name
92class Submission(MongoBase, engine=engine.Submission):
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
106 _config = None
108 def __init__(self, submission_id):
109 self.submission_id = str(submission_id)
111 def __str__(self):
112 return f'submission [{self.submission_id}]'
114 @property
115 def id(self):
116 '''
117 convert mongo ObjectId to hex string for serialize
118 '''
119 return str(self.obj.id)
121 @property
122 def problem_id(self) -> int:
123 return self.problem.problem_id
125 @property
126 def username(self) -> str:
127 return self.user.username
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 }
142 @property
143 def handwritten(self):
144 return self.language == 3
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
154 @property
155 def main_code_ext(self):
156 lang2ext = {0: '.c', 1: '.cpp', 2: '.py', 3: '.pdf'}
157 return lang2ext[self.language]
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())
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()
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
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
223 def delete_output(self, *args):
224 '''
225 delete stdout/stderr of this submission
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()
236 def delete(self, *keeps):
237 '''
238 delete submission and its related file
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 }
251 def default_del_func(d):
252 return self.obj[d].delete()
254 for d in drops:
255 del_funcs.get(d, default_del_func)(d)
256 self.obj.delete()
258 def sandbox_resp_handler(self, resp):
259 # judge queue is currently full
260 def on_500(resp):
261 raise JudgeQueueFullError
263 # backend send some invalid data
264 def on_400(resp):
265 raise ValueError(resp.text)
267 # send a invalid token
268 def on_403(resp):
269 raise ValueError('invalid token')
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
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
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()
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'
320 # HACK: hard-coded config
321 MAX_SIZE = 10**7
322 with ZipFile(file) as zf:
323 infos = zf.infolist()
325 size = sum(i.file_size for i in infos)
326 if size > MAX_SIZE:
327 return 'code file size too large'
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
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)
359 def _generate_code_minio_path(self):
360 return f'submissions/{self.id}_{ULID()}.zip'
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)
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
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
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)
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)
460 def process_result(self, tasks: list):
461 '''
462 process results from sandbox
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
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'
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
580 homework.save()
581 key = Problem(self.problem).high_score_key(user=self.user)
582 RedisCache().delete(key)
584 def add_comment(self, file):
585 '''
586 comment a submission with PDF
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()
606 @staticmethod
607 def count():
608 return len(engine.Submission.objects)
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
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
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)
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
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
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
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
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]
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]
806 def _get_code_raw(self):
807 if self.code.grid_id is None and self.code_minio_path is None:
808 return None
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()
823 # fallback to read from gridfs
824 return [self.code.read()]
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)))
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
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}')
856 def has_code(self) -> bool:
857 return self._get_code_zip() is not None
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))
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)
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
886 cache.set(key, cap.value, 60)
887 return cap
889 def permission(self, user, req: Permission):
890 """
891 check whether user own `req` permission
892 """
894 return bool(self.own_permission(user) & req)
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
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 )
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 )
925 def _remove_code_in_mongodb(self):
926 self.code.delete()
927 self.save()
928 self.reload('code')
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 )
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()
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
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)
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()
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
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 )
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 )
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 )
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()
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