Coverage for model/submission.py: 63%
290 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
1import io
2import os
3import random
4import secrets
5import json
6import httpx
7from typing import Optional
8from fastapi import APIRouter, Depends, UploadFile, File
9from fastapi.responses import StreamingResponse, Response
10from datetime import datetime, timedelta
11from mongo import *
12from mongo import engine
13from mongo.utils import (
14 RedisCache,
15 drop_none,
16 is_testing,
17)
18from .utils import *
19from .auth import identity_verify, login_required
20from .schemas import (
21 CreateSubmissionBody,
22 GetSubmissionListQuery,
23 OnSubmissionCompleteBody,
24 GradeSubmissionBody,
25 UpdateConfigBody,
26)
28__all__ = ['submission_router']
29submission_router = APIRouter()
32# /config must be defined before /{submission_id} to avoid path conflict
33@submission_router.get('/config')
34def get_config(user: User = identity_verify(0), ):
35 config = Submission.config()
36 ret = config.to_mongo()
37 del ret['_cls']
38 del ret['_id']
39 return HTTPResponse('success.', data=ret)
42@submission_router.put('/config')
43def update_config(
44 body: UpdateConfigBody,
45 user: User = identity_verify(0),
46):
47 rate_limit = body.rate_limit
48 sandbox_instances = body.sandbox_instances
49 config = Submission.config()
50 try:
51 sandbox_instances = [
52 *map(lambda s: engine.Sandbox(**s), sandbox_instances)
53 ]
54 except engine.ValidationError as e:
55 return HTTPError('wrong Sandbox schema', 400, data=e.to_dict())
56 if not is_testing():
57 resps = []
58 for sb in sandbox_instances:
59 try:
60 resp = httpx.get(f'{sb.url}/status', timeout=5.0)
61 except httpx.RequestError as e:
62 resps.append((sb.name, e))
63 continue
64 if not resp.is_success:
65 resps.append((sb.name, resp))
66 if len(resps) != 0:
67 return HTTPError(
68 'some error occurred when check sandbox status',
69 400,
70 data=[{
71 'name':
72 name,
73 'statusCode':
74 resp.status_code
75 if isinstance(resp, httpx.Response) else None,
76 'response':
77 resp.text
78 if isinstance(resp, httpx.Response) else str(resp),
79 } for name, resp in resps],
80 )
81 try:
82 config.update(rate_limit=rate_limit,
83 sandbox_instances=sandbox_instances)
84 except ValidationError as e:
85 return HTTPError(str(e), 400)
86 return HTTPResponse('success.')
89@submission_router.post('')
90def create_submission(
91 body: CreateSubmissionBody,
92 user=Depends(login_required),
93 ip: str = Depends(get_ip),
94):
95 language_type = body.language_type
96 problem_id = body.problem_id
97 now = datetime.now()
98 delta = timedelta.total_seconds(now - user.last_submit)
99 if delta <= Submission.config().rate_limit:
100 wait_for = Submission.config().rate_limit - delta
101 return HTTPError(
102 'Submit too fast!\n'
103 f'Please wait for {wait_for:.2f} seconds to submit.',
104 429,
105 data={'waitFor': wait_for},
106 )
107 if problem_id is None:
108 return HTTPError('problemId is required!', 400)
109 problem = Problem(problem_id)
110 if not problem:
111 return HTTPError('Unexisted problem id.', 404)
112 if not problem.permission(user, Problem.Permission.VIEW):
113 return HTTPError('problem permission denied!', 403)
114 for homework in problem.obj.homeworks:
115 if now < homework.duration.start:
116 return HTTPError('this homework hasn\'t start.', 403)
117 if not problem.is_valid_ip(ip):
118 return HTTPError('Invalid IP address.', 403)
119 if language_type is None:
120 if problem.problem_type != 2:
121 return HTTPError(
122 'post data missing!',
123 400,
124 data={
125 'languageType': language_type,
126 'problemId': problem_id
127 },
128 )
129 language_type = 3
130 if not problem.allowed(language_type):
131 return HTTPError(
132 'not allowed language',
133 403,
134 data={
135 'allowed': problem.obj.allowed_language,
136 'got': language_type
137 },
138 )
139 if problem.obj.quota != -1:
140 no_grade_permission = not any(
141 c.permission(user=user, req=Course.Permission.GRADE)
142 for c in map(Course, problem.courses))
143 run_out_of_quota = problem.submit_count(user) >= problem.quota
144 if no_grade_permission and run_out_of_quota:
145 return HTTPError('you have used all your quotas', 403)
146 user.problem_submission[str(problem_id)] = problem.submit_count(user) + 1
147 user.save()
148 try:
149 submission = Submission.add(
150 problem_id=problem_id,
151 username=user.username,
152 lang=language_type,
153 timestamp=now,
154 ip_addr=ip,
155 )
156 except ValidationError:
157 return HTTPError('invalid data!', 400)
158 except engine.DoesNotExist as e:
159 return HTTPError(str(e), 404)
160 except TestCaseNotFound as e:
161 return HTTPError(str(e), 403)
162 user.update(last_submit=now, push__submissions=submission.obj)
163 submission.problem.update(inc__submitter=1)
164 return HTTPResponse(
165 'submission recieved.\nplease send source code with given submission id later.',
166 data={'submissionId': submission.id},
167 )
170@submission_router.get('')
171def get_submission_list(
172 query: GetSubmissionListQuery = Depends(),
173 user=Depends(login_required),
174):
175 offset = query.offset
176 count = query.count
177 problem_id = query.problem_id
178 username = query.username
179 status = query.status
180 course = query.course
181 before = query.before
182 after = query.after
183 language_type = query.language_type
184 ip_addr = query.ip_addr
186 def parse_int(val, name):
187 if val is None:
188 return None
189 try:
190 return int(val)
191 except ValueError:
192 raise ValueError(f'can not convert {name} to integer')
194 def parse_str(val, name):
195 if val is None:
196 return None
197 return str(val)
199 def parse_timestamp(val, name):
200 if val is None:
201 return None
202 try:
203 return datetime.fromtimestamp(val)
204 except ValueError:
205 raise ValueError(f'can not convert {name} to timestamp')
207 cache_key = '_'.join(
208 map(str, (
209 'SUBMISSION_LIST_API',
210 user,
211 problem_id,
212 username,
213 status,
214 language_type,
215 course,
216 offset,
217 count,
218 before,
219 after,
220 )))
221 cache = RedisCache()
222 if cache.exists(cache_key):
223 submissions = json.loads(cache.get(cache_key))
224 submission_count = submissions['submission_count']
225 submissions = submissions['submissions']
226 else:
227 offset = parse_int(offset, 'offset')
228 count = parse_int(count, 'count')
229 problem_id = parse_int(problem_id, 'problemId')
230 status = parse_int(status, 'status')
231 before = parse_timestamp(before, 'before')
232 after = parse_timestamp(after, 'after')
233 ip_addr = parse_str(ip_addr, 'ip_addr')
235 if language_type is not None:
236 try:
237 language_type = list(map(int, language_type.split(',')))
238 except ValueError:
239 return HTTPError('cannot parse integers from languageType',
240 400)
241 if user.role == User.engine.Role.STUDENT:
242 username = user.username
243 try:
244 params = drop_none({
245 'user': user,
246 'offset': offset,
247 'count': count,
248 'problem': problem_id,
249 'q_user': username,
250 'status': status,
251 'language_type': language_type,
252 'course': course,
253 'before': before,
254 'after': after,
255 'ip_addr': ip_addr,
256 })
257 submissions, submission_count = Submission.filter(**params,
258 with_count=True)
259 submissions = [s.to_dict() for s in submissions]
260 cache.set(
261 cache_key,
262 json.dumps({
263 'submissions': submissions,
264 'submission_count': submission_count
265 }),
266 15,
267 )
268 except ValueError as e:
269 return HTTPError(str(e), 400)
270 unicorns = [
271 'https://media.giphy.com/media/xTiTnLmaxrlBHxsMMg/giphy.gif',
272 'https://media.giphy.com/media/26AHG5KGFxSkUWw1i/giphy.gif',
273 'https://media.giphy.com/media/g6i1lEax9Pa24/giphy.gif',
274 'https://media.giphy.com/media/tTyTbFF9uEbPW/giphy.gif',
275 ]
276 ret = {
277 'unicorn': random.choice(unicorns),
278 'submissions': submissions,
279 'submissionCount': submission_count,
280 }
281 return HTTPResponse('here you are, bro', data=ret)
284@submission_router.get('/{submission_id}')
285def get_submission(
286 ip: str = Depends(get_ip),
287 user=Depends(login_required),
288 submission: Submission = get_doc('submission_id', Submission),
289):
290 user_feedback_perm = submission.permission(user,
291 Submission.Permission.FEEDBACK)
292 if submission.handwritten and not user_feedback_perm:
293 return HTTPError('forbidden.', 403)
294 problem = Problem(submission.problem_id)
295 if not problem.is_valid_ip(ip):
296 return HTTPError('Invalid IP address.', 403)
297 if not all(submission.timestamp in hw.duration
298 for hw in problem.running_homeworks() if hw.ip_filters):
299 return HTTPError('You cannot view this submission during quiz.', 403)
300 has_code = not submission.handwritten and user_feedback_perm
301 has_output = submission.problem.can_view_stdout
302 ret = submission.to_dict()
303 if has_code:
304 try:
305 ret['code'] = submission.get_main_code()
306 except UnicodeDecodeError:
307 ret['code'] = False
308 ret['tasks'] = submission.get_detailed_result(
309 ) if has_output else submission.get_result()
310 return HTTPResponse(data=ret)
313@submission_router.get('/{submission_id}/output/{task_no}/{case_no}')
314def get_submission_output(
315 task_no: int,
316 case_no: int,
317 user=Depends(login_required),
318 submission: Submission = get_doc('submission_id', Submission),
319):
320 if not submission.permission(user, Submission.Permission.VIEW_OUTPUT):
321 return HTTPError('permission denied', 403)
322 try:
323 output = submission.get_single_output(task_no, case_no)
324 except FileNotFoundError as e:
325 return HTTPError(str(e), 400)
326 except AttributeError as e:
327 return HTTPError(str(e), 102)
328 return HTTPResponse('ok', data=output)
331@submission_router.get('/{submission_id}/pdf/{item}')
332def get_submission_pdf(
333 item: str,
334 user=Depends(login_required),
335 submission: Submission = get_doc('submission_id', Submission),
336):
337 if not submission.permission(user, Submission.Permission.FEEDBACK):
338 return HTTPError('forbidden.', 403)
339 if not submission.handwritten:
340 return HTTPError('it is not a handwritten submission.', 400)
341 if item not in ['comment', 'upload']:
342 return HTTPError('/<submission_id>/pdf/<"upload" or "comment">', 400)
343 try:
344 if item == 'comment':
345 data = submission.get_comment()
346 else:
347 data = submission.get_code('main.pdf', binary=True)
348 except FileNotFoundError:
349 return HTTPError('File not found.', 404)
350 return Response(
351 content=data,
352 media_type='application/pdf',
353 headers={
354 'Content-Disposition':
355 (f'attachment; filename="{item}-{submission.id[-6:] or "missing-id"}.pdf"'
356 ),
357 'Cache-Control':
358 'no-store',
359 },
360 )
363@submission_router.put('/{submission_id}/complete')
364def on_submission_complete(
365 body: OnSubmissionCompleteBody,
366 submission: Submission = get_doc('submission_id', Submission),
367):
368 if not Submission.verify_token(submission.id, body.token):
369 return HTTPError('i don\'t know you', 403)
370 try:
371 submission.process_result(body.tasks)
372 except (ValidationError, KeyError) as e:
373 return HTTPError(f'invalid data!\n{type(e).__name__}: {e}', 400)
374 return HTTPResponse(f'{submission} result recieved.')
377@submission_router.put('/{submission_id}')
378def update_submission(
379 submission_id: str,
380 code: Optional[UploadFile] = File(default=None),
381 user=Depends(login_required),
382 submission: Submission = get_doc('submission_id', Submission),
383 http_client: httpx.Client = Depends(get_http_client),
384):
385 if submission.status >= 0:
386 return HTTPError(f'{submission} has finished judgement.', 403)
387 if not secrets.compare_digest(submission.user.username, user.username):
388 return HTTPError('user not equal!', 403)
389 if code is None:
390 return HTTPError('can not find the source file', 400)
391 content = code.file.read()
392 if len(content) == 0:
393 return HTTPError('empty file', 400)
394 code_file = io.BytesIO(content)
395 if submission.has_code():
396 return HTTPError(f'{submission} has been uploaded source file!', 403)
397 try:
398 success = submission.submit(code_file, client=http_client)
399 except FileExistsError as e:
400 return HTTPError(str(e), 409)
401 except ValueError as e:
402 return HTTPError(str(e), 400)
403 except JudgeQueueFullError as e:
404 return HTTPResponse(str(e), 202)
405 except ValidationError as e:
406 return HTTPError(str(e), 400, data=e.to_dict())
407 except TestCaseNotFound as e:
408 return HTTPError(str(e), 403)
409 if success:
410 return HTTPResponse(
411 f'{submission} {"is finished." if submission.handwritten else "send to judgement."}'
412 )
413 else:
414 return HTTPError('Some error occurred, please contact the admin', 500)
417@submission_router.put('/{submission_id}/grade')
418def grade_submission(
419 body: GradeSubmissionBody,
420 user: User = Depends(login_required),
421 submission: Submission = get_doc('submission_id', Submission),
422):
423 if not submission.permission(user, Submission.Permission.GRADE):
424 return HTTPError('forbidden.', 403)
425 if body.score < 0 or body.score > 100:
426 return HTTPError('score must be between 0 to 100.', 400)
427 submission.update(score=body.score, status=(0 if body.score == 100 else 1))
428 submission.finish_judging()
429 return HTTPResponse(f'{submission} score recieved.')
432@submission_router.put('/{submission_id}/comment')
433async def comment_submission(
434 submission_id: str,
435 comment: Optional[UploadFile] = File(default=None),
436 user=Depends(login_required),
437 submission: Submission = get_doc('submission_id', Submission),
438):
439 if not submission.permission(user, Submission.Permission.COMMENT):
440 return HTTPError('forbidden.', 403)
441 if comment is None:
442 return HTTPError('can not find the comment', 400)
443 content = await comment.read()
444 try:
445 submission.add_comment(io.BytesIO(content))
446 except ValueError as e:
447 return HTTPError(str(e), 400)
448 return HTTPResponse(f'{submission} comment recieved.')
451@submission_router.get('/{submission_id}/rejudge')
452def rejudge(
453 user=Depends(login_required),
454 submission: Submission = get_doc('submission_id', Submission),
455 http_client: httpx.Client = Depends(get_http_client),
456):
457 if submission.status == -2 or (submission.status == -1 and
458 (datetime.now() -
459 submission.last_send).seconds < 300):
460 return HTTPError(f'{submission} haven\'t be judged', 403)
461 if not submission.permission(user, Submission.Permission.REJUDGE):
462 return HTTPError('forbidden.', 403)
463 try:
464 success = submission.rejudge(client=http_client)
465 except ValueError as e:
466 return HTTPError(str(e), 400)
467 except JudgeQueueFullError as e:
468 return HTTPResponse(str(e), 202)
469 except ValidationError as e:
470 return HTTPError(str(e), 422, data=e.to_dict())
471 if success:
472 return HTTPResponse(f'{submission} is sent to judgement.')
473 else:
474 return HTTPError('Some error occurred, please contact the admin', 500)
477@submission_router.post('/{submission_id}/migrate-code')
478def migrate_code(
479 user: User = identity_verify(0),
480 submission: Submission = get_doc('submission_id', Submission),
481):
482 if not submission.permission(user, Submission.Permission.MANAGER):
483 return HTTPError('forbidden.', 403)
484 submission.migrate_code_to_minio()
485 return HTTPResponse('ok')
488@submission_router.post('/{submission_id}/migrate-output')
489def migrate_output(
490 user: User = identity_verify(0),
491 submission: Submission = get_doc('submission_id', Submission),
492):
493 if not submission.permission(user, Submission.Permission.MANAGER):
494 return HTTPError('forbidden.', 403)
495 submission.migrate_output_to_minio()
496 return HTTPResponse('ok')