Coverage for mongo/submission.py: 71%
517 statements
« prev ^ index » next coverage.py v7.9.2, created at 2025-07-11 18:37 +0000
« 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
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
34__all__ = [
35 'SubmissionConfig',
36 'Submission',
37 'JudgeQueueFullError',
38 'TestCaseNotFound',
39]
41# TODO: modular token function
44def gen_key(_id):
45 return f'stoekn_{_id}'
48def gen_token():
49 return secrets.token_urlsafe()
52# Errors
53class JudgeQueueFullError(Exception):
54 '''
55 when sandbox task queue is full
56 '''
59class TestCaseNotFound(Exception):
60 '''
61 when a problem's testcase havn't been uploaded
62 '''
63 __test__ = False
65 def __init__(self, problem_id):
66 self.problem_id = problem_id
68 def __str__(self):
69 return f'{Problem(self.problem_id)}\'s testcase is not found'
72class SubmissionCodeNotFound(Exception):
73 '''
74 when a submission's code is not found
75 '''
78class SubmissionResultOutput(TypedDict):
79 '''
80 output of a submission result, including stdout and stderr
81 '''
82 stdout: str | bytes
83 stderr: str | bytes
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 ), )
93 def __init__(self, name: str):
94 self.name = name
97class Submission(MongoBase, engine=engine.Submission):
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
111 _config = None
113 def __init__(self, submission_id):
114 self.submission_id = str(submission_id)
116 def __str__(self):
117 return f'submission [{self.submission_id}]'
119 @property
120 def id(self):
121 '''
122 convert mongo ObjectId to hex string for serialize
123 '''
124 return str(self.obj.id)
126 @property
127 def problem_id(self) -> int:
128 return self.problem.problem_id
130 @property
131 def username(self) -> str:
132 return self.user.username
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 }
147 @property
148 def handwritten(self):
149 return self.language == 3
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
159 @property
160 def main_code_ext(self):
161 lang2ext = {0: '.c', 1: '.cpp', 2: '.py', 3: '.pdf'}
162 return lang2ext[self.language]
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())
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()
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
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
228 def delete_output(self, *args):
229 '''
230 delete stdout/stderr of this submission
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()
241 def delete(self, *keeps):
242 '''
243 delete submission and its related file
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 }
256 def default_del_func(d):
257 return self.obj[d].delete()
259 for d in drops:
260 del_funcs.get(d, default_del_func)(d)
261 self.obj.delete()
263 def sandbox_resp_handler(self, resp):
264 # judge queue is currently full
265 def on_500(resp):
266 raise JudgeQueueFullError
268 # backend send some invalid data
269 def on_400(resp):
270 raise ValueError(resp.text)
272 # send a invalid token
273 def on_403(resp):
274 raise ValueError('invalid token')
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
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
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()
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'
323 # HACK: hard-coded config
324 MAX_SIZE = 10**7
325 with ZipFile(file) as zf:
326 infos = zf.infolist()
328 size = sum(i.file_size for i in infos)
329 if size > MAX_SIZE:
330 return 'code file size too large'
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
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()
362 def _generate_code_minio_path(self):
363 return f'submissions/{ULID()}.zip'
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)
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
384 def submit(self, code_file) -> bool:
385 '''
386 prepare data for submit code to sandbox and then send it
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()
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)
461 def process_result(self, tasks: list):
462 '''
463 process results from sandbox
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
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'
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
581 homework.save()
582 key = Problem(self.problem).high_score_key(user=self.user)
583 RedisCache().delete(key)
585 def add_comment(self, file):
586 '''
587 comment a submission with PDF
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()
607 @staticmethod
608 def count():
609 return len(engine.Submission.objects)
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
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
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)
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
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
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
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
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]
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]
807 def _get_code_raw(self):
808 if self.code.grid_id is None and self.code_minio_path is None:
809 return None
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()
824 # fallback to read from gridfs
825 return [self.code.read()]
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)))
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
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}')
857 def has_code(self) -> bool:
858 return self._get_code_zip() is not None
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))
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)
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
887 cache.set(key, cap.value, 60)
888 return cap
890 def permission(self, user, req: Permission):
891 """
892 check whether user own `req` permission
893 """
895 return bool(self.own_permission(user) & req)
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
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 )
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 )
926 def _remove_code_in_mongodb(self):
927 self.code.delete()
928 self.save()
929 self.reload('code')
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 )
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()
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