diff --git a/oioioi/contests/api.py b/oioioi/contests/api.py index 6af22f277..9be47233a 100644 --- a/oioioi/contests/api.py +++ b/oioioi/contests/api.py @@ -1,16 +1,46 @@ from django.http import Http404 from django.shortcuts import get_object_or_404 +from django.utils.timezone import now + +from oioioi.base.utils import request_cached from oioioi.base.utils.api import make_path_coreapi_schema +from oioioi.contests.controllers import submission_template_context from oioioi.contests.forms import SubmissionFormForProblemInstance -from oioioi.contests.models import Contest, ProblemInstance -from oioioi.contests.serializers import SubmissionSerializer -from oioioi.contests.utils import can_enter_contest -from oioioi.problems.models import Problem +from oioioi.contests.models import Contest, ProblemInstance, Submission +from oioioi.contests.serializers import ( + ContestSerializer, + ProblemSerializer, + RoundSerializer, + SubmissionSerializer, + UserResultForProblemSerializer, +) +from oioioi.contests.utils import ( + can_enter_contest, + get_problem_statements, + visible_contests, +) +from oioioi.default_settings import MIDDLEWARE +from oioioi.problems.models import Problem, ProblemInstance +from oioioi.base.permissions import enforce_condition, not_anonymous + +from oioioi.problems.utils import query_statement +from oioioi.programs.models import ProgramSubmission +from oioioi.programs.utils import decode_str, get_submission_source_file_or_error from rest_framework import permissions, status, views from rest_framework.parsers import MultiPartParser from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.schemas import AutoSchema +from rest_framework import serializers +from rest_framework.decorators import api_view + + +@api_view(['GET']) +@enforce_condition(not_anonymous, login_redirect=False) +def contest_list(request): + contests = visible_contests(request) + serializer = ContestSerializer(contests, many=True) + return Response(serializer.data) class CanEnterContest(permissions.BasePermission): @@ -18,6 +48,198 @@ def has_object_permission(self, request, view, obj): return can_enter_contest(request) +class UnsafeApiAllowed(permissions.BasePermission): + def has_object_permission(self, request, view, obj): + return not any('IpDnsAuthMiddleware' in x for x in MIDDLEWARE) + + +class GetContestRounds(views.APIView): + permission_classes = ( + IsAuthenticated, + CanEnterContest, + ) + + schema = AutoSchema( + [ + make_path_coreapi_schema( + name='contest_id', + title="Contest id", + description="Id of the contest from contest_list endpoint", + ), + ] + ) + + def get(self, request, contest_id): + contest = get_object_or_404(Contest, id=contest_id) + rounds = contest.round_set.all() + serializer = RoundSerializer(rounds, many=True) + return Response(serializer.data) + + +class GetContestProblems(views.APIView): + permission_classes = ( + IsAuthenticated, + CanEnterContest, + ) + + schema = AutoSchema( + [ + make_path_coreapi_schema( + name='contest_id', + title="Contest id", + description="Id of the contest from contest_list endpoint", + ), + ] + ) + + def get(self, request, contest_id): + contest: Contest = get_object_or_404(Contest, id=contest_id) + controller = contest.controller + problem_instances = ( + ProblemInstance.objects.filter(contest=request.contest) + .select_related('problem') + .prefetch_related('round') + ) + + # Problem statements in order + # 0) problem instance + # 1) statement_visible + # 2) round end time + # 3) user result + # 4) number of submissions left + # 5) submissions_limit + # 6) can_submit + # Sorted by (start_date, end_date, round name, problem name) + problem_statements = get_problem_statements( + request, controller, problem_instances + ) + + data = [] + for problem_stmt in problem_statements: + if problem_stmt[1]: + serialized = dict(ProblemSerializer(problem_stmt[0], many=False).data) + serialized["full_name"] = problem_stmt[0].problem.legacy_name + serialized["user_result"] = UserResultForProblemSerializer( + problem_stmt[3], many=False + ).data + serialized["submissions_left"] = problem_stmt[4] + serialized["can_submit"] = problem_stmt[6] + serialized["statement_extension"] = ( + st.extension + if (st := query_statement(problem_stmt[0].problem)) + else None + ) + data.append(serialized) + + return Response(data) + + +class GetUserProblemSubmissionList(views.APIView): + permission_classes = (IsAuthenticated, CanEnterContest, UnsafeApiAllowed) + + schema = AutoSchema( + [ + make_path_coreapi_schema( + name='contest_id', + title="Contest id", + description="Id of the contest to which the problem you want to " + "query belongs. You can find this id after /c/ in urls " + "when using SIO 2 web interface.", + ), + make_path_coreapi_schema( + name='problem_short_name', + title="Problem short name", + description="Short name of the problem you want to query. " + "You can find it for example the in first column " + "of the problem list when using SIO 2 web interface.", + ), + ] + ) + + def get(self, request, contest_id, problem_short_name): + contest = get_object_or_404(Contest, id=contest_id) + problem_instance = get_object_or_404( + ProblemInstance, contest=contest, problem__short_name=problem_short_name + ) + + user_problem_submits = ( + Submission.objects.filter( + user=request.user, problem_instance=problem_instance + ) + .order_by('-date') + .select_related( + 'problem_instance', + 'problem_instance__contest', + 'problem_instance__round', + 'problem_instance__problem', + ) + ) + last_20_submits = user_problem_submits[:20] + submissions = [submission_template_context(request, s) for s in last_20_submits] + submissions_data = {'submissions': []} + for submission_entry in submissions: + score = ( + submission_entry['submission'].score + if submission_entry['can_see_score'] + else None + ) + submission_status = ( + submission_entry['submission'].status + if submission_entry['can_see_status'] + else None + ) + submissions_data['submissions'].append( + { + 'id': submission_entry['submission'].id, + 'date': submission_entry['submission'].date, + 'score': score.to_int() if score else None, + 'status': submission_status, + } + ) + submissions_data['is_truncated_to_20'] = len(user_problem_submits) > 20 + return Response(submissions_data, status=status.HTTP_200_OK) + + +class GetUserProblemSubmissionCode(views.APIView): + permission_classes = (IsAuthenticated, CanEnterContest, UnsafeApiAllowed) + + schema = AutoSchema( + [ + make_path_coreapi_schema( + name='contest_id', + title="Name of the contest", + description="Id of the contest to which the problem you want to " + "query belongs. You can find this id after /c/ in urls " + "when using SIO 2 web interface.", + ), + make_path_coreapi_schema( + name='submission_id', + title="Submission id", + description="You can query submission ID list at problem_submission_list endpoint.", + ), + ] + ) + + def get(self, request, contest_id, submission_id): + # Make sure user made this submission, not somebody else. + submission = get_object_or_404(ProgramSubmission, id=submission_id) + if submission.user != request.user: + raise Http404("Submission not found.") + + source_file = get_submission_source_file_or_error(request, int(submission_id)) + raw_source, decode_error = decode_str(source_file.read()) + if decode_error: + return Response( + 'Error during decoding the source code.', + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + return Response( + {"lang": source_file.name.split('.')[-1], "code": raw_source}, + status=status.HTTP_200_OK, + ) + + class GetProblemIdView(views.APIView): permission_classes = ( IsAuthenticated, @@ -60,8 +282,11 @@ def get(self, request, contest_id, problem_short_name): return Response(response_data, status=status.HTTP_200_OK) +# This is a base class for submitting a solution for contests and problemsets. +# It lacks get_problem_instance, as it's specific to problem source. class SubmitSolutionView(views.APIView): - permission_classes = (IsAuthenticated,) + permission_classes = (IsAuthenticated, CanEnterContest, UnsafeApiAllowed) + parser_classes = (MultiPartParser,) def get_problem_instance(self, **kwargs): @@ -90,10 +315,6 @@ def post(self, request, **kwargs): class SubmitContestSolutionView(SubmitSolutionView): - permission_classes = ( - IsAuthenticated, - CanEnterContest, - ) schema = AutoSchema( [ make_path_coreapi_schema( diff --git a/oioioi/contests/serializers.py b/oioioi/contests/serializers.py index c7c46b3ee..68fe2d34b 100644 --- a/oioioi/contests/serializers.py +++ b/oioioi/contests/serializers.py @@ -1,4 +1,6 @@ +from oioioi.contests.models import Contest, ProblemInstance, Round, UserResultForProblem from rest_framework import serializers +from django.utils.timezone import now class SubmissionSerializer(serializers.Serializer): @@ -30,3 +32,45 @@ def validate(self, data): class Meta: fields = ('file', 'kind', 'problem_instance_id') + + +class ContestSerializer(serializers.ModelSerializer): + class Meta: + model = Contest + fields = ['id', 'name'] + + +class RoundSerializer(serializers.ModelSerializer): + is_active = serializers.SerializerMethodField() + + class Meta: + model = Round + fields = [ + "name", + "start_date", + "end_date", + "is_active", + "results_date", + "public_results_date", + "is_trial", + ] + + def get_is_active(self, obj: Round): + if obj.end_date: + return now() < obj.end_date + return True + + +# This is a partial serializer and it serves as a base for the API response. +class ProblemSerializer(serializers.ModelSerializer): + + class Meta: + model = ProblemInstance + exclude = ['needs_rejudge', 'problem', 'contest'] + + +class UserResultForProblemSerializer(serializers.ModelSerializer): + + class Meta: + model = UserResultForProblem + fields = ['score', 'status'] diff --git a/oioioi/contests/tests/tests.py b/oioioi/contests/tests/tests.py index 3ef63fc62..3f2ab6482 100755 --- a/oioioi/contests/tests/tests.py +++ b/oioioi/contests/tests/tests.py @@ -1,6 +1,7 @@ # pylint: disable=abstract-method from __future__ import print_function +import bs4 import os import re from datetime import datetime, timedelta, timezone # pylint: disable=E0611 @@ -75,6 +76,7 @@ from oioioi.teachers.views import ( contest_dashboard_redirect as teachers_contest_dashboard, ) +from oioioi.testspackages.models import TestsPackage from rest_framework.test import APITestCase @@ -941,6 +943,42 @@ def test_mixin_past_rounds_hidden_during_prep_time(self): response = self.client.get(reverse('select_contest')) self.assertEqual(len(response.context['contests']), 1) + def test_round_dates(self): + contest = Contest.objects.get() + url = reverse('problems_list', kwargs={'contest_id': contest.id}) + with fake_time(datetime(2024, 1, 1, tzinfo=timezone.utc)): + for user in ['test_admin', 'test_contest_admin', 'test_user', 'test_observer']: + self.assertTrue(self.client.login(username=user)) + response = self.client.get(url) + self.assertContains(response, "(31 July 2011, 20:27 - )") + self.assertContains(response, "(31 July 2012, 20:27 - 21:27)") + self.assertContains(response, "(30 July 2012, 20:27 - 31 July 2012, 21:27)") + + def test_polish_round_dates(self): + self.client.cookies['lang'] = 'pl' + contest = Contest.objects.get() + url = reverse('problems_list', kwargs={'contest_id': contest.id}) + with fake_time(datetime(2024, 1, 1, tzinfo=timezone.utc)): + for user in ['test_admin', 'test_contest_admin', 'test_user', 'test_observer']: + self.assertTrue(self.client.login(username=user)) + response = self.client.get(url) + self.assertContains(response, "(31 lipca 2011, 20:27 - )") + self.assertContains(response, "(31 lipca 2012, 20:27 - 21:27)") + self.assertContains(response, "(30 lipca 2012, 20:27 - 31 lipca 2012, 21:27)") + self.client.cookies['lang'] = 'en' + + @override_settings(TIME_ZONE='Europe/Warsaw') + def test_round_dates_with_other_timezone(self): + contest = Contest.objects.get() + url = reverse('problems_list', kwargs={'contest_id': contest.id}) + with fake_time(datetime(2024, 1, 1, tzinfo=timezone.utc)): + for user in ['test_admin', 'test_contest_admin', 'test_user', 'test_observer']: + self.assertTrue(self.client.login(username=user)) + response = self.client.get(url) + self.assertContains(response, "(31 July 2011, 22:27 - )") + self.assertContains(response, "(31 July 2012, 22:27 - 23:27)") + self.assertContains(response, "(30 July 2012, 22:27 - 31 July 2012, 23:27)") + def test_rules_visibility(self): contest = Contest.objects.get() contest.controller_name = 'oioioi.oi.controllers.ProgrammingContestController' @@ -1603,6 +1641,50 @@ def check(visible, invisible): with fake_time(get_time(23)): check([ca, cb, pa, ra, rb, rc], []) + def test_attachments_order(self): + contest = Contest.objects.get() + problem = Problem.objects.get() + list_url = reverse('contest_files', kwargs={'contest_id': contest.id}) + self.assertTrue(self.client.login(username='test_admin')) + + # Models have names that would make them sorted in a wrong order with old sorting. + TestsPackage.objects.create( + problem=problem, + name='A-2-test-package', + ) + TestsPackage.objects.create( + problem=problem, + name='A-1-test-package', + ) + ProblemAttachment.objects.create( + problem=problem, + description='problem-attachment', + content=ContentFile(b'content-of-pa', name='B-2-pa.txt'), + ) + ProblemAttachment.objects.create( + problem=problem, + description='problem-attachment', + content=ContentFile(b'content-of-pa', name='B-1-pa.txt'), + ) + ContestAttachment.objects.create( + contest=contest, + description='contest-attachment', + content=ContentFile(b'content-of-ca', name='C-2-ca.txt'), + ) + ContestAttachment.objects.create( + contest=contest, + description='contest-attachment', + content=ContentFile(b'content-of-ca', name='C-1-ca.txt'), + ) + + response = self.client.get(list_url) + last = 0 + for name in ['C-1-ca.txt', 'C-2-ca.txt', 'B-1-pa.txt', 'B-2-pa.txt', 'A-1-test-package', 'A-2-test-package']: + self.assertContains(response, name) + pos = response.content.find(name.encode()) + self.assertTrue(pos > last) + last = pos + class TestRoundExtension(TestCase, SubmitFileMixin): fixtures = [ @@ -3558,6 +3640,150 @@ def test_problemset_submission(self): self._assertSubmitted(response, 2) +class TestAPIContestList(TestCase): + fixtures = [ + 'test_users', + 'test_participant', + 'test_contest', + ] + + def test(self): + contest_list_endpoint = reverse('api_contest_list') + request_anon = self.client.get(contest_list_endpoint) + self.assertEqual(403, request_anon.status_code) + + self.assertTrue(self.client.login(username='test_user')) + request_auth = self.client.get(contest_list_endpoint) + self.assertEqual(200, request_auth.status_code) + + +class TestAPIRoundList(TestCase): + fixtures = [ + 'test_users', + 'test_contest', + ] + + def test(self): + contest_id = Contest.objects.get(pk='c').id + round_list_endpoint = reverse('api_round_list', args=(contest_id)) + request_anon = self.client.get(round_list_endpoint) + + self.assertEqual(401, request_anon.status_code) + self.assertTrue(self.client.login(username='test_user')) + request_auth = self.client.get(round_list_endpoint) + self.assertEqual(200, request_auth.status_code) + + json_data = request_auth.json() + self.assertEqual(1, len(json_data)) + + json_data_0 = json_data[0] + self.assertEqual('Round 1', json_data_0['name']) + self.assertEqual(None, json_data_0['end_date']) + self.assertTrue(json_data_0['is_active']) + self.assertFalse(json_data_0['is_trial']) + + +class TestAPIProblemList(TestCase): + fixtures = [ + 'test_users', + 'test_contest', + 'test_full_package', + 'test_problem_instance', + ] + + def test(self): + contest_id = Contest.objects.get(pk='c').id + problem_list_endpoint = reverse('api_problem_list', args=(contest_id)) + request_anon = self.client.get(problem_list_endpoint) + + self.assertEqual(401, request_anon.status_code) + self.assertTrue(self.client.login(username='test_user')) + request_auth = self.client.get(problem_list_endpoint) + self.assertEqual(200, request_auth.status_code) + + json_data = request_auth.json() + self.assertEqual(1, len(json_data)) + + json_data_0 = json_data[0] + self.assertEqual(1, json_data_0['id']) + self.assertEqual('zad1', json_data_0['short_name']) + self.assertEqual(1, json_data_0['round']) + self.assertEqual(10, json_data_0['submissions_limit']) + self.assertEqual(1, json_data_0['round']) + self.assertEqual('Sumżyce', json_data_0['full_name']) + self.assertEqual(10, json_data_0['submissions_left']) + self.assertTrue(json_data_0['can_submit']) + self.assertEqual('.pdf', json_data_0['statement_extension']) + + +class TestAPIProblemSubmissionList(TestCase): + fixtures = [ + 'test_users', + 'test_contest', + 'test_full_package', + 'test_problem_instance', + 'test_submission', + ] + + def test(self): + pi = ProblemInstance.objects.get(pk=1) + # It is really important, that ProblemInstance.short_name matches + # Problem.short_name, as otherwise this endpoint does not work. + # Situation, where it doesn't match is only possible in test. + pi.short_name = pi.problem.short_name + pi.save() + submission_list_endpoint = reverse( + 'api_user_problem_submission_list', args=( + pi.contest.id, + pi.problem.short_name + ) + ) + request_anon = self.client.get(submission_list_endpoint) + + self.assertEqual(401, request_anon.status_code) + self.assertTrue(self.client.login(username='test_user')) + request_auth = self.client.get(submission_list_endpoint) + self.assertEqual(200, request_auth.status_code) + + json_data = request_auth.json() + self.assertFalse(json_data['is_truncated_to_20']) + self.assertEqual(len(json_data['submissions']), 1) + self.assertEqual(json_data['submissions'][0]['id'], 1) + self.assertEqual(json_data['submissions'][0]['score'], 34) + self.assertEqual(json_data['submissions'][0]['status'], 'OK') + + +class TestAPIProblemSubmissionCode(TestCase): + fixtures = [ + 'test_users', + 'test_contest', + 'test_full_package', + 'test_problem_instance', + 'test_submission', + 'test_submission_source', + ] + + def test(self): + pi = ProblemInstance.objects.get(pk=1) + # A submission of a file `submission.cpp` + submission_code_endpoint = reverse( + 'api_user_problem_submission_code', args=( + pi.contest.id, + 1 + ) + ) + request_anon = self.client.get(submission_code_endpoint) + + self.assertEqual(401, request_anon.status_code) + self.assertTrue(self.client.login(username='test_user')) + request_auth = self.client.get(submission_code_endpoint, follow=True) + self.assertEqual(200, request_auth.status_code) + + json_data = request_auth.json() + self.assertEqual(json_data['lang'], 'cpp'); + self.assertTrue('#include ' in json_data['code']); + + class TestManyRoundsNoEnd(TestCase): fixtures = [ 'test_users', @@ -4091,3 +4317,30 @@ def test_registration(self): response = self.client.get(url) self.assertEqual(403, response.status_code) + +class TestScoreBadges(TestCase): + fixtures = [ + 'test_users', + 'test_contest', + 'test_three_problem_instances', + 'test_full_package', + 'test_three_submissions', + ] + + def _get_badge_for_problem(self, content, problem): + soup = bs4.BeautifulSoup(content, 'html.parser') + problem_row = soup.find('td', string=problem).parent + return problem_row.find_all('td')[2].a.div.attrs['class'] + + def test_score_badge(self): + contest = Contest.objects.get() + url = reverse('problems_list', kwargs={'contest_id': contest.id}) + + self.assertTrue(self.client.login(username='test_user')) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + self.assertIn('badge-success', self._get_badge_for_problem(response.content, 'zad1')) + self.assertIn('badge-warning', self._get_badge_for_problem(response.content, 'zad2')) + self.assertIn('badge-danger', self._get_badge_for_problem(response.content, 'zad3')) + diff --git a/oioioi/contests/urls.py b/oioioi/contests/urls.py index 7490fe5ac..59018c76e 100644 --- a/oioioi/contests/urls.py +++ b/oioioi/contests/urls.py @@ -193,16 +193,37 @@ def glob_namespaced_patterns(namespace): if settings.USE_API: nonc_patterns += [ # the contest information is managed manually and added after api prefix + re_path(r'^api/contest_list', api.contest_list, name="api_contest_list"), re_path( r'^api/c/(?P[a-z0-9_-]+)/submit/(?P[a-z0-9_-]+)$', api.SubmitContestSolutionView.as_view(), name='api_contest_submit', ), re_path( - r'^api/c/(?P[a-z0-9_-]+)/problems/(?P[a-z0-9_-]+)$', + r'^api/c/(?P[a-z0-9_-]+)/problems/(?P[a-z0-9_-]+)/$', api.GetProblemIdView.as_view(), name='api_contest_get_problem_id', ), + re_path( + r'^api/c/(?P[a-z0-9_-]+)/problem_submission_list/(?P[a-z0-9_-]+)/$', + api.GetUserProblemSubmissionList.as_view(), + name='api_user_problem_submission_list', + ), + re_path( + r'^api/c/(?P[a-z0-9_-]+)/problem_submission_code/(?P[a-z0-9_-]+)/$', + api.GetUserProblemSubmissionCode.as_view(), + name='api_user_problem_submission_code', + ), + re_path( + r'^api/c/(?P[a-z0-9_-]+)/round_list/$', + api.GetContestRounds.as_view(), + name='api_round_list', + ), + re_path( + r'^api/c/(?P[a-z0-9_-]+)/problem_list/$', + api.GetContestProblems.as_view(), + name='api_problem_list', + ), re_path( r'^api/problemset/submit/(?P[0-9a-zA-Z-_=]+)$', api.SubmitProblemsetSolutionView.as_view(), diff --git a/oioioi/contests/utils.py b/oioioi/contests/utils.py index ffbc4656a..96b9041f2 100755 --- a/oioioi/contests/utils.py +++ b/oioioi/contests/utils.py @@ -21,6 +21,7 @@ FilesMessage, SubmissionsMessage, SubmitMessage, + UserResultForProblem, ) @@ -603,10 +604,7 @@ def get_submit_message(request): @make_request_condition @request_cached def is_contest_archived(request): - return ( - hasattr(request, 'contest') - and request.contest.is_archived - ) + return hasattr(request, 'contest') and request.contest.is_archived def get_inline_for_contest(inline, contest): @@ -637,3 +635,47 @@ def has_view_permission(self, request, obj=None): return True return ArchivedInlineWrapper + + +def get_problem_statements(request, controller, problem_instances): + # Problem statements in order + # 1) problem instance + # 2) statement_visible + # 3) round end time + # 4) user result + # 5) number of submissions left + # 6) submissions_limit + # 7) can_submit + # Sorted by (start_date, end_date, round name, problem name) + return sorted( + [ + ( + pi, + controller.can_see_statement(request, pi), + controller.get_round_times(request, pi.round), + # Because this view can be accessed by an anynomous user we can't + # use `user=request.user` (it would cause TypeError). Surprisingly + # using request.user.id is ok since for AnynomousUser id is set + # to None. + next( + ( + r + for r in UserResultForProblem.objects.filter( + user__id=request.user.id, problem_instance=pi + ) + if r + and r.submission_report + and controller.can_see_submission_score( + request, r.submission_report.submission + ) + ), + None, + ), + pi.controller.get_submissions_left(request, pi), + pi.controller.get_submissions_limit(request, pi), + controller.can_submit(request, pi) and not is_contest_archived(request), + ) + for pi in problem_instances + ], + key=lambda p: (p[2].get_key_for_comparison(), p[0].round.name, p[0].short_name), + ) diff --git a/oioioi/default_settings.py b/oioioi/default_settings.py index 40fc79ba6..b84aa2093 100755 --- a/oioioi/default_settings.py +++ b/oioioi/default_settings.py @@ -870,3 +870,12 @@ # Experimental USE_ACE_EDITOR = False + +REST_FRAMEWORK['DEFAULT_THROTTLE_CLASSES'] = [ + 'rest_framework.throttling.AnonRateThrottle', + 'rest_framework.throttling.UserRateThrottle' +] +REST_FRAMEWORK['DEFAULT_THROTTLE_RATES'] = { + 'anon': '1000/day', + 'user': '1000/hour' +} diff --git a/oioioi/problems/views.py b/oioioi/problems/views.py index 3aab16ba6..d03b4d3ae 100644 --- a/oioioi/problems/views.py +++ b/oioioi/problems/views.py @@ -141,7 +141,7 @@ def download_package_traceback_view(request, package_id): def add_or_update_problem(request, contest, template): if contest and contest.is_archived: raise PermissionDenied - + if 'problem' in request.GET: existing_problem = get_object_or_404(Problem, id=request.GET['problem']) if ( @@ -669,7 +669,7 @@ def get_report_row_begin_HTML_view(request, submission_id): return TemplateResponse( request, 'contests/my_submission_table_base_row_begin.html', - { + { 'record': submission_template_context(request, submission), 'show_scores': json.loads(request.POST.get('show_scores', "false")), 'can_admin': can_admin_problem_instance(request, submission.problem_instance) and