Coverage for model/submission.py: 65%
298 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
1import io
2from typing import Optional
3import requests as rq
4import random
5import secrets
6import json
7from flask import (
8 Blueprint,
9 send_file,
10 request,
11 current_app,
12)
13from datetime import datetime, timedelta
14from mongo import *
15from mongo import engine
16from mongo.utils import (
17 RedisCache,
18 drop_none,
19)
20from .utils import *
21from .auth import *
23__all__ = ['submission_api']
24submission_api = Blueprint('submission_api', __name__)
27@submission_api.route('/', methods=['POST'])
28@login_required
29@Request.json('language_type: int', 'problem_id: int')
30def create_submission(user, language_type, problem_id):
31 # the user reach the rate limit for submitting
32 now = datetime.now()
33 delta = timedelta.total_seconds(now - user.last_submit)
34 if delta <= Submission.config().rate_limit:
35 wait_for = Submission.config().rate_limit - delta
36 return HTTPError(
37 'Submit too fast!\n'
38 f'Please wait for {wait_for:.2f} seconds to submit.',
39 429,
40 data={
41 'waitFor': wait_for,
42 },
43 ) # Too many request
44 # check for fields
45 if problem_id is None:
46 return HTTPError(
47 'problemId is required!',
48 400,
49 )
50 # search for problem
51 problem = Problem(problem_id)
52 if not problem:
53 return HTTPError('Unexisted problem id.', 404)
54 # problem permissoion
55 if not problem.permission(user, Problem.Permission.VIEW):
56 return HTTPError('problem permission denied!', 403)
57 # check deadline
58 for homework in problem.obj.homeworks:
59 if now < homework.duration.start:
60 return HTTPError('this homework hasn\'t start.', 403)
61 # ip validation
62 if not problem.is_valid_ip(get_ip()):
63 return HTTPError('Invalid IP address.', 403)
64 # handwritten problem doesn't need language type
65 if language_type is None:
66 if problem.problem_type != 2:
67 return HTTPError(
68 'post data missing!',
69 400,
70 data={
71 'languageType': language_type,
72 'problemId': problem_id
73 },
74 )
75 language_type = 3
76 # not allowed language
77 if not problem.allowed(language_type):
78 return HTTPError(
79 'not allowed language',
80 403,
81 data={
82 'allowed': problem.obj.allowed_language,
83 'got': language_type
84 },
85 )
86 # check if the user has used all his quota
87 if problem.obj.quota != -1:
88 no_grade_permission = not any(
89 c.permission(user=user, req=Course.Permission.GRADE)
90 for c in map(Course, problem.courses))
92 run_out_of_quota = problem.submit_count(user) >= problem.quota
93 if no_grade_permission and run_out_of_quota:
94 return HTTPError('you have used all your quotas', 403)
95 user.problem_submission[str(problem_id)] = problem.submit_count(user) + 1
96 user.save()
97 # insert submission to DB
98 ip_addr = request.headers.get('cf-connecting-ip', request.remote_addr)
99 try:
100 submission = Submission.add(problem_id=problem_id,
101 username=user.username,
102 lang=language_type,
103 timestamp=now,
104 ip_addr=ip_addr)
105 except ValidationError:
106 return HTTPError('invalid data!', 400)
107 except engine.DoesNotExist as e:
108 return HTTPError(str(e), 404)
109 except TestCaseNotFound as e:
110 return HTTPError(str(e), 403)
111 # update user
112 user.update(
113 last_submit=now,
114 push__submissions=submission.obj,
115 )
116 # update problem
117 submission.problem.update(inc__submitter=1)
118 return HTTPResponse(
119 'submission recieved.\n'
120 'please send source code with given submission id later.',
121 data={
122 'submissionId': submission.id,
123 },
124 )
127@submission_api.route('/', methods=['GET'])
128@login_required
129@Request.args('offset', 'count', 'problem_id', 'username', 'status',
130 'language_type', 'course', 'before', 'after', 'ip_addr')
131def get_submission_list(
132 user,
133 offset,
134 count,
135 problem_id,
136 username,
137 status,
138 course,
139 before,
140 after,
141 language_type,
142 ip_addr,
143):
144 '''
145 get the list of submission data
146 '''
148 def parse_int(val: Optional[int], name: str):
149 if val is None:
150 return None
151 try:
152 return int(val)
153 except ValueError:
154 raise ValueError(f'can not convert {name} to integer')
156 def parse_str(val: Optional[str], name: str):
157 if val is None:
158 return None
159 try:
160 return str(val)
161 except ValueError:
162 raise ValueError(f'can not convert {name} to string')
164 def parse_timestamp(val: Optional[int], name: str):
165 if val is None:
166 return None
167 try:
168 return datetime.fromtimestamp(val)
169 except ValueError:
170 raise ValueError(f'can not convert {name} to timestamp')
172 cache_key = (
173 'SUBMISSION_LIST_API',
174 user,
175 problem_id,
176 username,
177 status,
178 language_type,
179 course,
180 offset,
181 count,
182 before,
183 after,
184 )
186 cache_key = '_'.join(map(str, cache_key))
187 cache = RedisCache()
188 # check cache
189 if cache.exists(cache_key):
190 submissions = json.loads(cache.get(cache_key))
191 submission_count = submissions['submission_count']
192 submissions = submissions['submissions']
193 else:
194 # convert args
195 offset = parse_int(offset, 'offset')
196 count = parse_int(count, 'count')
197 problem_id = parse_int(problem_id, 'problemId')
198 status = parse_int(status, 'status')
199 before = parse_timestamp(before, 'before')
200 after = parse_timestamp(after, 'after')
201 ip_addr = parse_str(ip_addr, 'ip_addr')
203 if language_type is not None:
204 try:
205 language_type = list(map(int, language_type.split(',')))
206 except ValueError as e:
207 return HTTPError(
208 'cannot parse integers from languageType',
209 400,
210 )
211 # students can only get their own submissions
212 if user.role == User.engine.Role.STUDENT:
213 username = user.username
214 try:
215 params = drop_none({
216 'user': user,
217 'offset': offset,
218 'count': count,
219 'problem': problem_id,
220 'q_user': username,
221 'status': status,
222 'language_type': language_type,
223 'course': course,
224 'before': before,
225 'after': after,
226 'ip_addr': ip_addr
227 })
228 submissions, submission_count = Submission.filter(
229 **params,
230 with_count=True,
231 )
232 submissions = [s.to_dict() for s in submissions]
233 cache.set(
234 cache_key,
235 json.dumps({
236 'submissions': submissions,
237 'submission_count': submission_count,
238 }), 15)
239 except ValueError as e:
240 return HTTPError(str(e), 400)
241 # unicorn gifs
242 unicorns = [
243 'https://media.giphy.com/media/xTiTnLmaxrlBHxsMMg/giphy.gif',
244 'https://media.giphy.com/media/26AHG5KGFxSkUWw1i/giphy.gif',
245 'https://media.giphy.com/media/g6i1lEax9Pa24/giphy.gif',
246 'https://media.giphy.com/media/tTyTbFF9uEbPW/giphy.gif',
247 ]
248 ret = {
249 'unicorn': random.choice(unicorns),
250 'submissions': submissions,
251 'submissionCount': submission_count,
252 }
253 return HTTPResponse(
254 'here you are, bro',
255 data=ret,
256 )
259@submission_api.route('/<submission>', methods=['GET'])
260@login_required
261@Request.doc('submission', Submission)
262def get_submission(user, submission: Submission):
263 user_feedback_perm = submission.permission(user,
264 Submission.Permission.FEEDBACK)
265 # check permission
266 if submission.handwritten and not user_feedback_perm:
267 return HTTPError('forbidden.', 403)
268 # ip validation
269 problem = Problem(submission.problem_id)
270 if not problem.is_valid_ip(get_ip()):
271 return HTTPError('Invalid IP address.', 403)
272 if not all(submission.timestamp in hw.duration
273 for hw in problem.running_homeworks() if hw.ip_filters):
274 return HTTPError('You cannot view this submission during quiz.', 403)
275 # serialize submission
276 has_code = not submission.handwritten and user_feedback_perm
277 has_output = submission.problem.can_view_stdout
278 ret = submission.to_dict()
279 if has_code:
280 try:
281 ret['code'] = submission.get_main_code()
282 except UnicodeDecodeError:
283 ret['code'] = False
284 if has_output:
285 ret['tasks'] = submission.get_detailed_result()
286 else:
287 ret['tasks'] = submission.get_result()
288 return HTTPResponse(data=ret)
291@submission_api.get('/<submission>/output/<int:task_no>/<int:case_no>')
292@login_required
293@Request.doc('submission', Submission)
294def get_submission_output(
295 user,
296 submission: Submission,
297 task_no: int,
298 case_no: int,
299):
300 if not submission.permission(user, Submission.Permission.VIEW_OUTPUT):
301 return HTTPError('permission denied', 403)
302 try:
303 output = submission.get_single_output(task_no, case_no)
304 except FileNotFoundError as e:
305 return HTTPError(str(e), 400)
306 except AttributeError as e:
307 return HTTPError(str(e), 102)
308 return HTTPResponse('ok', data=output)
311@submission_api.route('/<submission>/pdf/<item>', methods=['GET'])
312@login_required
313@Request.doc('submission', Submission)
314def get_submission_pdf(user, submission: Submission, item):
315 # check the permission
316 if not submission.permission(user, Submission.Permission.FEEDBACK):
317 return HTTPError('forbidden.', 403)
318 # non-handwritten submissions have no pdf file
319 if not submission.handwritten:
320 return HTTPError('it is not a handwritten submission.', 400)
321 if item not in ['comment', 'upload']:
322 return HTTPError('/<submission_id>/pdf/<"upload" or "comment">', 400)
323 try:
324 if item == 'comment':
325 data = submission.get_comment()
326 else:
327 data = submission.get_code('main.pdf', binary=True)
328 except FileNotFoundError as e:
329 return HTTPError('File not found.', 404)
330 return send_file(
331 io.BytesIO(data),
332 mimetype='application/pdf',
333 as_attachment=True,
334 max_age=0,
335 download_name=f'{item}-{submission.id[-6:] or "missing-id"}.pdf',
336 )
339@submission_api.route('/<submission>/complete', methods=['PUT'])
340@Request.json('tasks: list', 'token: str')
341@Request.doc('submission', Submission)
342def on_submission_complete(submission: Submission, tasks, token):
343 if not Submission.verify_token(submission.id, token):
344 return HTTPError('i don\'t know you', 403)
345 try:
346 submission.process_result(tasks)
347 except (ValidationError, KeyError) as e:
348 return HTTPError(
349 'invalid data!\n'
350 f'{type(e).__name__}: {e}',
351 400,
352 )
353 return HTTPResponse(f'{submission} result recieved.')
356@submission_api.route('/<submission>', methods=['PUT'])
357@login_required
358@Request.doc('submission', Submission)
359@Request.files('code')
360def update_submission(user, submission: Submission, code):
361 # validate this reques
362 if submission.status >= 0:
363 return HTTPError(
364 f'{submission} has finished judgement.',
365 403,
366 )
367 # if user not equal, reject
368 if not secrets.compare_digest(submission.user.username, user.username):
369 return HTTPError('user not equal!', 403)
370 # if source code not found
371 if code is None:
372 return HTTPError(
373 f'can not find the source file',
374 400,
375 )
376 # or empty file
377 if len(code.read()) == 0:
378 return HTTPError('empty file', 400)
379 code.seek(0)
380 # has been uploaded
381 if submission.has_code():
382 return HTTPError(
383 f'{submission} has been uploaded source file!',
384 403,
385 )
386 try:
387 success = submission.submit(code)
388 except FileExistsError:
389 exit(10086)
390 except ValueError as e:
391 return HTTPError(str(e), 400)
392 except JudgeQueueFullError as e:
393 return HTTPResponse(str(e), 202)
394 except ValidationError as e:
395 return HTTPError(str(e), 400, data=e.to_dict())
396 except TestCaseNotFound as e:
397 return HTTPError(str(e), 403)
398 if success:
399 return HTTPResponse(
400 f'{submission} {"is finished." if submission.handwritten else "send to judgement."}'
401 )
402 else:
403 return HTTPError('Some error occurred, please contact the admin', 500)
406@submission_api.route('/<submission>/grade', methods=['PUT'])
407@login_required
408@Request.json('score: int')
409@Request.doc('submission', Submission)
410def grade_submission(user: User, submission: Submission, score: int):
411 if not submission.permission(user, Submission.Permission.GRADE):
412 return HTTPError('forbidden.', 403)
414 if score < 0 or score > 100:
415 return HTTPError('score must be between 0 to 100.', 400)
417 # AC if the score is 100, WA otherwise
418 submission.update(score=score, status=(0 if score == 100 else 1))
419 submission.finish_judging()
420 return HTTPResponse(f'{submission} score recieved.')
423@submission_api.route('/<submission>/comment', methods=['PUT'])
424@login_required
425@Request.files('comment')
426@Request.doc('submission', Submission)
427def comment_submission(user, submission: Submission, comment):
428 if not submission.permission(user, Submission.Permission.COMMENT):
429 return HTTPError('forbidden.', 403)
431 if comment is None:
432 return HTTPError(
433 f'can not find the comment',
434 400,
435 )
436 try:
437 submission.add_comment(comment)
438 except ValueError as e:
439 return HTTPError(str(e), 400)
440 return HTTPResponse(f'{submission} comment recieved.')
443@submission_api.route('/<submission>/rejudge', methods=['GET'])
444@login_required
445@Request.doc('submission', Submission)
446def rejudge(user, submission: Submission):
447 if submission.status == -2 or (submission.status == -1 and
448 (datetime.now() -
449 submission.last_send).seconds < 300):
450 return HTTPError(f'{submission} haven\'t be judged', 403)
451 if not submission.permission(user, Submission.Permission.REJUDGE):
452 return HTTPError('forbidden.', 403)
453 try:
454 success = submission.rejudge()
455 except ValueError as e:
456 return HTTPError(str(e), 400)
457 except JudgeQueueFullError as e:
458 return HTTPResponse(str(e), 202)
459 except ValidationError as e:
460 return HTTPError(str(e), 422, data=e.to_dict())
461 if success:
462 return HTTPResponse(f'{submission} is sent to judgement.')
463 else:
464 return HTTPError('Some error occurred, please contact the admin', 500)
467@submission_api.route('/config', methods=['GET', 'PUT'])
468@login_required
469@identity_verify(0)
470def config(user):
471 config = Submission.config()
473 def get_config():
474 ret = config.to_mongo()
475 del ret['_cls']
476 del ret['_id']
477 return HTTPResponse('success.', data=ret)
479 @Request.json('rate_limit: int', 'sandbox_instances: list')
480 def modify_config(rate_limit, sandbox_instances):
481 # try to convert json object to Sandbox instance
482 try:
483 sandbox_instances = [
484 *map(
485 lambda s: engine.Sandbox(**s),
486 sandbox_instances,
487 )
488 ]
489 except engine.ValidationError as e:
490 return HTTPError(
491 'wrong Sandbox schema',
492 400,
493 data=e.to_dict(),
494 )
495 # skip if during testing
496 if not current_app.config['TESTING']:
497 resps = []
498 # check sandbox status
499 for sb in sandbox_instances:
500 resp = rq.get(f'{sb.url}/status')
501 if not resp.ok:
502 resps.append((sb.name, resp))
503 # some exception occurred
504 if len(resps) != 0:
505 return HTTPError(
506 'some error occurred when check sandbox status',
507 400,
508 data=[{
509 'name': name,
510 'statusCode': resp.status_code,
511 'response': resp.text,
512 } for name, resp in resps],
513 )
514 try:
515 config.update(
516 rate_limit=rate_limit,
517 sandbox_instances=sandbox_instances,
518 )
519 except ValidationError as e:
520 return HTTPError(str(e), 400)
522 return HTTPResponse('success.')
524 methods = {'GET': get_config, 'PUT': modify_config}
525 return methods[request.method]()
528@submission_api.post('/<submission>/migrate-code')
529@login_required
530@identity_verify(0)
531@Request.doc('submission', Submission)
532def migrate_code(user: User, submission: Submission):
533 if not submission.permission(
534 user,
535 Submission.Permission.MANAGER,
536 ):
537 return HTTPError('forbidden.', 403)
539 submission.migrate_code_to_minio()
540 return HTTPResponse('ok')