diff --git a/webapp/public/js/domjudge.js b/webapp/public/js/domjudge.js index f25233fb88..e3b9250c3b 100644 --- a/webapp/public/js/domjudge.js +++ b/webapp/public/js/domjudge.js @@ -1235,3 +1235,60 @@ $(function() { }); }); }); + +function loadSubmissions(dataElement, $displayElement) { + const url = dataElement.dataset.submissionsUrl + fetch(url) + .then(data => data.json()) + .then(data => { + const teamId = dataElement.dataset.teamId; + const problemId = dataElement.dataset.problemId; + const teamKey = `team-${teamId}`; + const problemKey = `problem-${problemId}`; + if (!data.submissions || !data.submissions[teamKey] || !data.submissions[teamKey][problemKey]) { + return; + } + + const submissions = data.submissions[teamKey][problemKey]; + if (submissions.length === 0) { + $displayElement.html(document.querySelector('#empty-submission-list').innerHTML); + } else { + let templateData = document.querySelector('#submission-list').innerHTML; + const $table = $(templateData); + const itemTemplateData = document.querySelector('#submission-list-item').innerHTML; + const $itemTemplate = $(itemTemplateData); + const $submissionList = $table.find('[data-submission-list]'); + for (const submission of submissions) { + const $item = $itemTemplate.clone(); + $item.find('[data-time]').html(submission.time); + $item.find('[data-language-id]').html(submission.language); + $item.find('[data-verdict]').html(submission.verdict); + $submissionList.append($item); + } + $displayElement.find('.spinner-border').remove(); + $displayElement.append($table); + } + }); +} + +function initScoreboardSubmissions() { + $('[data-submissions-url]').on('click', function (e) { + const linkEl = e.currentTarget; + e.preventDefault(); + const $modal = $('[data-submissions-modal] .modal').clone(); + const $teamEl = $(`[data-team-external-id="${linkEl.dataset.teamId}"]`); + const $problemEl = $(`[data-problem-external-id="${linkEl.dataset.problemId}"]`); + $modal.find('[data-team]').html($teamEl.data('teamName')); + $modal.find('[data-problem-badge]').html($problemEl.data('problemBadge')); + $modal.find('[data-problem-name]').html($problemEl.data('problemName')); + $modal.modal(); + $modal.modal('show'); + $modal.on('hidden.bs.modal', function (e) { + $(e.currentTarget).remove(); + }); + $modal.on('shown.bs.modal', function (e) { + const $modalBody = $(e.currentTarget).find('.modal-body'); + loadSubmissions(linkEl, $modalBody); + }); + }); +} diff --git a/webapp/public/style_domjudge.css b/webapp/public/style_domjudge.css index 48c794c925..18eb31ef24 100644 --- a/webapp/public/style_domjudge.css +++ b/webapp/public/style_domjudge.css @@ -647,6 +647,14 @@ tr.ignore td, td.ignore, span.ignore { min-width: 2em; } +h5 .problem-badge { + font-size: 1rem; +} + +h1 .problem-badge { + font-size: 2rem; +} + .tooltip .tooltip-inner { max-width: 500px; } diff --git a/webapp/src/Controller/PublicController.php b/webapp/src/Controller/PublicController.php index 9d368ad964..f507a3e5f2 100644 --- a/webapp/src/Controller/PublicController.php +++ b/webapp/src/Controller/PublicController.php @@ -2,8 +2,10 @@ namespace App\Controller; +use App\DataTransferObject\SubmissionRestriction; use App\Entity\Contest; use App\Entity\ContestProblem; +use App\Entity\Submission; use App\Entity\Team; use App\Entity\TeamCategory; use App\Service\ConfigurationService; @@ -11,8 +13,11 @@ use App\Service\EventLogService; use App\Service\ScoreboardService; use App\Service\StatisticsService; +use App\Service\SubmissionService; +use App\Twig\TwigExtension; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\NonUniqueResultException; +use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\RequestStack; @@ -33,6 +38,8 @@ public function __construct( protected readonly ConfigurationService $config, protected readonly ScoreboardService $scoreboardService, protected readonly StatisticsService $stats, + protected readonly SubmissionService $submissionService, + protected readonly TwigExtension $twigExtension, EntityManagerInterface $em, EventLogService $eventLog, KernelInterface $kernel, @@ -79,6 +86,18 @@ public function scoreboardAction( if ($static) { $data['hide_menu'] = true; + $submissions = $this->submissionService->getSubmissionList( + [$contest->getCid() => $contest], + new SubmissionRestriction(valid: true), + paginated: false + )[0]; + + $submissionsPerTeamAndProblem = []; + foreach ($submissions as $submission) { + $submissionsPerTeamAndProblem[$submission->getTeam()->getTeamid()][$submission->getProblem()->getProbid()][] = $submission; + } + $data['submissionsPerTeamAndProblem'] = $submissionsPerTeamAndProblem; + $data['verificationRequired'] = $this->config->get('verification_required'); } $data['current_contest'] = $contest; @@ -267,4 +286,129 @@ protected function getBinaryFile(int $probId, callable $response): StreamedRespo return $response($probId, $contest, $contestProblem); } + + #[Route(path: '/submissions/team/{teamId}/problem/{problemId}', name: 'public_submissions')] + public function submissionsAction(Request $request, string $teamId, string $problemId): Response + { + $contest = $this->dj->getCurrentContest(onlyPublic: true); + + if (!$contest) { + throw $this->createNotFoundException('No active contest found'); + } + + /** @var Team|null $team */ + $team = $this->em->getRepository(Team::class)->findOneBy(['externalid' => $teamId]); + if ($team && $team->getCategory() && !$team->getCategory()->getVisible()) { + $team = null; + } + + if (!$team) { + throw $this->createNotFoundException('Team not found.'); + } + + /** @var ContestProblem|null $problem */ + $problem = $this->em->createQueryBuilder() + ->from(ContestProblem::class, 'cp') + ->select('cp') + ->innerJoin('cp.problem', 'p') + ->andWhere('cp.contest = :contest') + ->andWhere('p.externalid = :problem') + ->setParameter('contest', $contest) + ->setParameter('problem', $problemId) + ->getQuery() + ->getOneOrNullResult(); + + if (!$problem) { + throw $this->createNotFoundException('Problem not found'); + } + + $data = [ + 'contest' => $contest, + 'problem' => $problem, + 'team' => $team, + ]; + + return $this->render('public/team_submissions.html.twig', $data); + } + + #[Route(path: '/submissions-data.json', name: 'public_submissions_data')] + #[Route(path: '/submissions-data/team/{teamId}/problem/{problemId}.json', name: 'public_submissions_data_cell')] + public function submissionsDataAction(Request $request, ?string $teamId, ?string $problemId): JsonResponse + { + $contest = $this->dj->getCurrentContest(onlyPublic: true); + + if (!$contest) { + throw $this->createNotFoundException('No active contest found'); + } + + $scoreboard = $this->scoreboardService->getScoreboard($contest); + + /** @var Submission[] $submissions */ + $submissions = $this->submissionService->getSubmissionList( + [$contest->getCid() => $contest], + restrictions: new SubmissionRestriction(valid: true), + paginated: false + )[0]; + + $submissionData = []; + + // We prepend IDs with team- and problem- to make sure they are not + // consecutive integers + foreach ($scoreboard->getTeamsInDescendingOrder() as $team) { + if ($teamId && $teamId !== $team->getExternalid()) { + continue; + } + $teamKey = 'team-' . $team->getExternalid(); + $submissionData[$teamKey] = []; + foreach ($scoreboard->getProblems() as $problem) { + if ($problemId && $problemId !== $problem->getExternalId()) { + continue; + } + $problemKey = 'problem-' . $problem->getExternalId(); + $submissionData[$teamKey][$problemKey] = []; + } + } + + $verificationRequired = $this->config->get('verification_required'); + + foreach ($submissions as $submission) { + $teamKey = 'team-' . $submission->getTeam()->getExternalid(); + $problemKey = 'problem-' . $submission->getProblem()->getExternalid(); + if ($teamId && $teamId !== $submission->getTeam()->getExternalid()) { + continue; + } + if ($problemId && $problemId !== $submission->getProblem()->getExternalid()) { + continue; + } + $submissionData[$teamKey][$problemKey][] = [ + 'time' => $this->twigExtension->printtime($submission->getSubmittime(), contest: $contest), + 'language' => $submission->getLanguageId(), + 'verdict' => $this->submissionVerdict($submission, $contest, $verificationRequired), + ]; + } + + return new JsonResponse([ + 'submissions' => $submissionData, + ]); + } + + protected function submissionVerdict( + Submission $submission, + Contest $contest, + bool $verificationRequired + ): string { + if ($submission->getSubmittime() >= $contest->getEndtime()) { + return $this->twigExtension->printResult('too-late'); + } + if ($contest->getFreezetime() && $submission->getSubmittime() >= $contest->getFreezetime() && !$contest->getFreezeData()->showFinal()) { + return $this->twigExtension->printResult(''); + } + if (!$submission->getJudgings()->first() || !$submission->getJudgings()->first()->getResult()) { + return $this->twigExtension->printResult(''); + } + if ($verificationRequired && !$submission->getJudgings()->first()->getVerified()) { + return $this->twigExtension->printResult(''); + } + return $this->twigExtension->printResult($submission->getJudgings()->first()->getResult(), onlyRejectedForIncorrect: true); + } } diff --git a/webapp/src/DataTransferObject/SubmissionRestriction.php b/webapp/src/DataTransferObject/SubmissionRestriction.php index b62057d68a..c23f86bf4f 100644 --- a/webapp/src/DataTransferObject/SubmissionRestriction.php +++ b/webapp/src/DataTransferObject/SubmissionRestriction.php @@ -69,5 +69,6 @@ public function __construct( public ?bool $externallyJudged = null, public ?bool $externallyVerified = null, public ?bool $withExternalId = null, + public ?bool $valid = null, ) {} } diff --git a/webapp/src/Service/DOMJudgeService.php b/webapp/src/Service/DOMJudgeService.php index 132beee23c..1e838feb8d 100644 --- a/webapp/src/Service/DOMJudgeService.php +++ b/webapp/src/Service/DOMJudgeService.php @@ -46,6 +46,7 @@ use Symfony\Component\HttpFoundation\Cookie; use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\HttpFoundation\InputBag; +use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\Response; @@ -1507,6 +1508,7 @@ public function getScoreboardZip( $assetRegex = '|/CHANGE_ME/([/a-z0-9_\-\.]*)(\??[/a-z0-9_\-\.=]*)|i'; preg_match_all($assetRegex, $contestPage, $assetMatches); $contestPage = preg_replace($assetRegex, '$1$2', $contestPage); + $contestPage = str_replace('/public/submissions-data.json', 'submissions-data.json', $contestPage); $zip = new ZipArchive(); if (!($tempFilename = tempnam($this->getDomjudgeTmpDir(), "contest-"))) { @@ -1519,6 +1521,13 @@ public function getScoreboardZip( } $zip->addFromString('index.html', $contestPage); + $submissionsDataRequest = Request::create('/public/submissions-data.json', Request::METHOD_GET); + $submissionsDataRequest->setSession($this->requestStack->getSession()); + /** @var JsonResponse $response */ + $response = $this->httpKernel->handle($submissionsDataRequest, HttpKernelInterface::SUB_REQUEST); + $submissionsData = $response->getContent(); + $zip->addFromString('submissions-data.json', $submissionsData); + $publicPath = realpath(sprintf('%s/public/', $this->projectDir)); foreach ($assetMatches[1] as $file) { $filepath = realpath($publicPath . '/' . $file); diff --git a/webapp/src/Service/SubmissionService.php b/webapp/src/Service/SubmissionService.php index 60efff8bdd..792038885d 100644 --- a/webapp/src/Service/SubmissionService.php +++ b/webapp/src/Service/SubmissionService.php @@ -306,6 +306,12 @@ public function getSubmissionList( ->setParameter('results', $restrictions->results); } + if (isset($restrictions->valid)) { + $queryBuilder + ->andWhere('s.valid = :valid') + ->setParameter('valid', $restrictions->valid); + } + if ($this->dj->shadowMode()) { // When we are shadow, also load the external results $queryBuilder diff --git a/webapp/src/Twig/TwigExtension.php b/webapp/src/Twig/TwigExtension.php index 0155f53e9f..aed9adf2ae 100644 --- a/webapp/src/Twig/TwigExtension.php +++ b/webapp/src/Twig/TwigExtension.php @@ -23,6 +23,7 @@ use App\Service\EventLogService; use App\Service\SubmissionService; use App\Utils\Scoreboard\ScoreboardMatrixItem; +use App\Utils\Scoreboard\TeamScore; use App\Utils\Utils; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\EntityManagerInterface; @@ -30,6 +31,7 @@ use Symfony\Component\Intl\Countries; use Symfony\Component\Intl\Exception\MissingResourceException; use Symfony\Component\PropertyAccess\PropertyAccess; +use Symfony\Component\Routing\RouterInterface; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; use Twig\Environment; @@ -51,6 +53,7 @@ public function __construct( protected readonly AwardService $awards, protected readonly TokenStorageInterface $tokenStorage, protected readonly AuthorizationCheckerInterface $authorizationChecker, + protected readonly RouterInterface $router, #[Autowire('%kernel.project_dir%')] protected readonly string $projectDir ) {} @@ -513,8 +516,12 @@ public function displayTestcaseResults(array $testcases, bool $submissionDone, b return $results; } - public function printResult(?string $result, bool $valid = true, bool $jury = false): string - { + public function printResult( + ?string $result, + bool $valid = true, + bool $jury = false, + bool $onlyRejectedForIncorrect = false, + ): string { $result = strtolower($result ?? ''); switch ($result) { case 'too-late': @@ -539,6 +546,9 @@ public function printResult(?string $result, bool $valid = true, bool $jury = fa break; default: $style = 'sol_incorrect'; + if ($onlyRejectedForIncorrect) { + $result = 'rejected'; + } } return sprintf('%s', $valid ? $style : 'disabled', $result); @@ -1217,8 +1227,12 @@ public function problemBadge(ContestProblem $problem, bool $grayedOut = false): ); } - public function problemBadgeMaybe(ContestProblem $problem, ScoreboardMatrixItem $matrixItem): string - { + public function problemBadgeMaybe( + ContestProblem $problem, + ScoreboardMatrixItem $matrixItem, + TeamScore $score, + bool $static = false, + ): string { $rgb = Utils::convertToHex($problem->getColor() ?? '#ffffff'); if (!$matrixItem->isCorrect || empty($rgb)) { $rgb = Utils::convertToHex('whitesmoke'); @@ -1231,10 +1245,27 @@ public function problemBadgeMaybe(ContestProblem $problem, ScoreboardMatrixItem $border = 'linen'; } - $ret = sprintf( - '%s', + $submissionsUrl = $static + ? $this->router->generate('public_submissions_data') + : $this->router->generate('public_submissions_data_cell', [ + 'teamId' => $score->team->getExternalid(), + 'problemId' => $problem->getExternalId(), + ]); + + $ret = sprintf(<< + %s + + HTML, $rgb, $border, + $submissionsUrl, + $score->team->getExternalid(), + $problem->getExternalId(), $foreground, $problem->getShortname() ); diff --git a/webapp/templates/partials/scoreboard_table.html.twig b/webapp/templates/partials/scoreboard_table.html.twig index 6b2da2f314..e85fcaa897 100644 --- a/webapp/templates/partials/scoreboard_table.html.twig +++ b/webapp/templates/partials/scoreboard_table.html.twig @@ -98,7 +98,13 @@ {% endif %} {% endif %} {% endif %} -