diff --git a/example_problems/boolfind/output_visualizer/boolfind_visual/visualize.py b/example_problems/boolfind/output_visualizer/boolfind_visual/visualize.py new file mode 100644 index 0000000000..533164c807 --- /dev/null +++ b/example_problems/boolfind/output_visualizer/boolfind_visual/visualize.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python3 +# +# Invoke as: +# answer_file feedback_file +import matplotlib.pyplot as plt +import sys + +my_name = sys.argv[0] +my_input = sys.argv[1] +my_feedback = sys.argv[2] + +with open(my_input, 'r') as f: + lines = f.readlines() + vals = [] + for line in lines: + if 'READ' in line: + vals.append(int(line.split(' ')[-1])) + plt.plot([0,1]) + plt.ylabel('Guesses') + plt.savefig(f"{my_feedback}", format='png') diff --git a/example_problems/boolfind/problem.yaml b/example_problems/boolfind/problem.yaml index 2a0e38b965..253a29c138 100644 --- a/example_problems/boolfind/problem.yaml +++ b/example_problems/boolfind/problem.yaml @@ -1,3 +1,4 @@ name: Boolean switch search validation: custom interactive +visualization: default diff --git a/judge/judgedaemon.main.php b/judge/judgedaemon.main.php index b2ab57385b..ed133ef0b9 100644 --- a/judge/judgedaemon.main.php +++ b/judge/judgedaemon.main.php @@ -334,7 +334,10 @@ function fetch_executable_internal( $execrunpath = $execbuilddir . '/run'; $execrunjurypath = $execbuilddir . '/runjury'; if (!is_dir($execdir) || !file_exists($execdeploypath)) { - system('rm -rf ' . dj_escapeshellarg($execdir) . ' ' . dj_escapeshellarg($execbuilddir)); + system('rm -rf ' . dj_escapeshellarg($execdir) . ' ' . dj_escapeshellarg($execbuilddir), $retval); + if ($retval !== 0) { + logmsg(LOG_WARNING, "Deleting '$execdir' or '$execbuilddir' was unsuccessful."); + } system('mkdir -p ' . dj_escapeshellarg($execbuilddir), $retval); if ($retval !== 0) { error("Could not create directory '$execbuilddir'"); @@ -768,7 +771,7 @@ function fetch_executable_internal( $judgehosts = request('judgehosts', 'GET'); if ($judgehosts !== null) { $judgehosts = dj_json_decode($judgehosts); - $judgehost = array_filter($judgehosts, fn($j) => $j['hostname'] === $myhost); + $judgehost = array_values(array_filter($judgehosts, fn($j) => $j['hostname'] === $myhost))[0]; if (!isset($judgehost['enabled']) || !$judgehost['enabled']) { logmsg(LOG_WARNING, "Judgehost needs to be enabled in web interface."); } @@ -844,7 +847,9 @@ function fetch_executable_internal( $debug_cmd = implode(' ', array_map('dj_escapeshellarg', [$runpath, $workdir, $tmpfile])); system($debug_cmd, $retval); - // FIXME: check retval + if ($retval !== 0) { + error("Running '$runpath' failed."); + } request( sprintf('judgehosts/add-debug-info/%s/%s', urlencode($myhost), @@ -872,6 +877,50 @@ function fetch_executable_internal( continue; } + if ($type == 'output_visualization') { + if ($lastWorkdir !== null) { + cleanup_judging($lastWorkdir); + $lastWorkdir = null; + } + foreach ($row as $judgeTask) { + if (isset($judgeTask['output_visualizer_script_id'])) { + // Visualization of output which this host judged requested. + $run_config = dj_json_decode($judgeTask['run_config']); + $tmpfile = tempnam(TMPDIR, 'output_visualization_'); + [$runpath, $error] = fetch_executable_internal( + $workdirpath, + 'output_visualization', + $judgeTask['output_visualizer_script_id'], + $run_config['hash'] + ); + if (isset($error)) { + // FIXME + continue; + } + + $teamoutput = $workdir . "/testcase" . sprintf('%05d', $judgeTask['testcase_id']) . '/1/program.out'; + $visual_cmd = implode(' ', array_map('dj_escapeshellarg', + [$runpath, $teamoutput, $tmpfile])); + system($visual_cmd, $retval); + if ($retval !== 0) { + error("Running '$runpath' failed."); + } + + request( + sprintf('judgehosts/add-visual/%s/%s', urlencode($myhost), + urlencode((string)$judgeTask['judgetaskid'])), + 'POST', + ['visual_output' => rest_encode_file($tmpfile, false), + 'testcase_id' => $judgeTask['testcase_id']], + false + ); + unlink($tmpfile); + + logmsg(LOG_INFO, " ⇡ Uploading visual output of workdir $workdir."); + } + } + continue; + } $success_file = "$workdir/.uuid_pid"; $expected_uuid_pid = $row[0]['uuid'] . '_' . (string)getmypid(); diff --git a/webapp/migrations/Version20241018061817.php b/webapp/migrations/Version20241018061817.php new file mode 100644 index 0000000000..2a58d7d474 --- /dev/null +++ b/webapp/migrations/Version20241018061817.php @@ -0,0 +1,54 @@ +addSql('ALTER TABLE problem ADD special_output_visualizer VARCHAR(32) DEFAULT NULL COMMENT \'Executable ID (string)\', CHANGE multipass_limit multipass_limit INT UNSIGNED DEFAULT NULL COMMENT \'Optional limit on the number of rounds; defaults to 1 for traditional problems, 2 for multi-pass problems if not specified.\''); + $this->addSql('ALTER TABLE problem ADD CONSTRAINT FK_D7E7CCC819F5352E FOREIGN KEY (special_output_visualizer) REFERENCES executable (execid) ON DELETE SET NULL'); + $this->addSql('CREATE INDEX special_output_visualizer ON problem (special_output_visualizer)'); + $this->addSql('ALTER TABLE judgetask ADD output_visualizer_script_id INT UNSIGNED DEFAULT NULL COMMENT \'Output visualizer script ID\''); + $this->addSql('ALTER TABLE `judgetask` + MODIFY COLUMN `type` ENUM(\'judging_run\', \'generic_task\', \'config_check\', \'debug_info\', \'prefetch\', \'output_visualization\') DEFAULT \'judging_run\' NOT NULL COMMENT \'Type of the judge task.(DC2Type:judge_task_type)\''); + $this->addSql('ALTER TABLE judging ADD visualization TINYINT(1) DEFAULT 0 NOT NULL COMMENT \'Explicitly requested to visualize the output.\''); + $this->addSql('CREATE TABLE visualization (visualization_id INT UNSIGNED AUTO_INCREMENT NOT NULL COMMENT \'Visualization ID\', judgingid INT UNSIGNED DEFAULT NULL COMMENT \'Judging ID\', judgehostid INT UNSIGNED DEFAULT NULL COMMENT \'Judgehost ID\', testcaseid INT UNSIGNED DEFAULT NULL COMMENT \'Testcase ID\', filename VARCHAR(255) NOT NULL COMMENT \'Name of the file where we stored the visualization.\', INDEX IDX_E0936C40E0E4FC3E (judgehostid), INDEX IDX_E0936C40D360BB2B (testcaseid), INDEX judgingid (judgingid), PRIMARY KEY(visualization_id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB COMMENT = \'Team output visualization.\' '); + $this->addSql('ALTER TABLE visualization ADD CONSTRAINT FK_E0936C405D5FEA72 FOREIGN KEY (judgingid) REFERENCES judging (judgingid) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE visualization ADD CONSTRAINT FK_E0936C40E0E4FC3E FOREIGN KEY (judgehostid) REFERENCES judgehost (judgehostid) ON DELETE SET NULL'); + $this->addSql('ALTER TABLE visualization ADD CONSTRAINT FK_E0936C40D360BB2B FOREIGN KEY (testcaseid) REFERENCES testcase (testcaseid) ON DELETE CASCADE'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE visualization DROP FOREIGN KEY FK_E0936C405D5FEA72'); + $this->addSql('ALTER TABLE visualization DROP FOREIGN KEY FK_E0936C40E0E4FC3E'); + $this->addSql('ALTER TABLE visualization DROP FOREIGN KEY FK_E0936C40D360BB2B'); + $this->addSql('DROP TABLE visualization'); + $this->addSql('ALTER TABLE judging DROP visualization'); + $this->addSql('ALTER TABLE `judgetask` + MODIFY COLUMN `type` ENUM(\'judging_run\', \'generic_task\', \'config_check\', \'debug_info\', \'prefetch\') DEFAULT \'judging_run\' NOT NULL COMMENT \'Type of the judge task.(DC2Type:judge_task_type)\''); + $this->addSql('ALTER TABLE judgetask DROP output_visualizer_script_id'); + $this->addSql('ALTER TABLE problem DROP FOREIGN KEY FK_D7E7CCC819F5352E'); + $this->addSql('DROP INDEX special_output_visualizer ON problem'); + $this->addSql('ALTER TABLE problem DROP special_output_visualizer, CHANGE multipass_limit multipass_limit INT UNSIGNED DEFAULT NULL COMMENT \'Optional limit on the number of rounds for multi-pass problems; defaults to 2 if not specified.\''); + } + + public function isTransactional(): bool + { + return false; + } +} diff --git a/webapp/src/Controller/API/JudgehostController.php b/webapp/src/Controller/API/JudgehostController.php index 8cfa36c80a..9a4e7b371f 100644 --- a/webapp/src/Controller/API/JudgehostController.php +++ b/webapp/src/Controller/API/JudgehostController.php @@ -2,6 +2,9 @@ namespace App\Controller\API; +use App\Entity\Visualization; +use App\Entity\Testcase; + use App\DataTransferObject\JudgehostFile; use App\Doctrine\DBAL\Types\JudgeTaskType; use App\Entity\Contest; @@ -550,6 +553,58 @@ public function addDebugInfo( $this->em->flush(); } + /** + * Add visual team output. + */ + #[IsGranted('ROLE_JUDGEHOST')] + #[Rest\Post('/add-visual/{hostname}/{judgeTaskId<\d+>}')] + #[OA\Response(response: 200, description: 'When the visual output has been added')] + public function addVisualization( + Request $request, + #[OA\PathParameter(description: 'The hostname of the judgehost that wants to add the debug info')] + string $hostname, + #[OA\PathParameter(description: 'The ID of the judgetask to add', schema: new OA\Schema(type: 'integer'))] + int $judgeTaskId + ): void { + $judgeTask = $this->em->getRepository(JudgeTask::class)->find($judgeTaskId); + if ($judgeTask === null) { + throw new BadRequestHttpException( + 'Inconsistent data, no judgetask known with judgetaskid = ' . $judgeTaskId . '.'); + } + + foreach (['visual_output', 'testcase_id'] as $argument) { + if (!$request->request->has($argument)) { + throw new BadRequestHttpException( + sprintf("Argument '%s' is mandatory", $argument)); + } + } + + $judgehost = $this->em->getRepository(Judgehost::class)->findOneBy(['hostname' => $hostname]); + if (!$judgehost) { + throw new BadRequestHttpException("Who are you and why are you sending us any data?"); + } + + $judging = $this->em->getRepository(Judging::class)->find($judgeTask->getJobId()); + if ($judging === null) { + throw new BadRequestHttpException( + 'Inconsistent data, no judging known with judgingid = ' . $judgeTask->getJobId() . '.'); + } + if ($tempFilename = tempnam($this->dj->getDomjudgeTmpDir(), "visual-")) { + $debug_package = base64_decode($request->request->get('visual_output')); + file_put_contents($tempFilename, $debug_package); + } + // FIXME: error checking + $testcase = $this->em->getRepository(Testcase::class)->findOneBy(['testcaseid' => $request->request->get('testcase_id')]); + $visualization = new Visualization(); + $visualization + ->setJudgehost($judgehost) + ->setJudging($judging) + ->setTestcase($testcase) + ->setFilename($tempFilename); + $this->em->persist($visualization); + $this->em->flush(); + } + /** * Add one JudgingRun. When relevant, finalize the judging. * @throws DBALException @@ -1186,7 +1241,7 @@ public function getFilesAction( return match ($type) { 'source' => $this->getSourceFiles($id), 'testcase' => $this->getTestcaseFiles($id), - 'compare', 'compile', 'debug', 'run' => $this->getExecutableFiles($id), + 'compare', 'compile', 'debug', 'run', 'output_visualization' => $this->getExecutableFiles($id), default => throw new BadRequestHttpException('Unknown type requested.'), }; } @@ -1478,6 +1533,7 @@ public function getJudgeTasksAction(Request $request): array throw new BadRequestHttpException('Argument \'hostname\' is mandatory'); } $hostname = $request->request->get('hostname'); + $hostname = 'Computer'; $judgehost = $this->em->getRepository(Judgehost::class)->findOneBy(['hostname' => $hostname]); if (!$judgehost) { @@ -1649,6 +1705,27 @@ public function getJudgeTasksAction(Request $request): array return $this->serializeJudgeTasks($judgetasks, $judgehost); } + // If there is nothing else, get visualization jobs that are assigned to this host. + /** @var JudgeTask[] $judgetasks */ + $judgetasks = $this->em + ->createQueryBuilder() + ->from(JudgeTask::class, 'jt') + ->select('jt') + ->andWhere('jt.judgehost = :judgehost OR jt.judgehost IS NULL') + //->andWhere('jt.starttime IS NULL') + ->andWhere('jt.valid = 1') + ->andWhere('jt.type = :type') + ->setParameter('judgehost', $judgehost) + ->setParameter('type', JudgeTaskType::OUTPUT_VISUALIZATION) + ->addOrderBy('jt.priority') + ->addOrderBy('jt.judgetaskid') + ->setMaxResults(1) + ->getQuery() + ->getResult(); + if (!empty($judgetasks)) { + return $this->serializeJudgeTasks($judgetasks, $judgehost); + } + return []; } @@ -1667,8 +1744,16 @@ private function serializeJudgeTasks(array $judgeTasks, Judgehost $judgehost): a $submit_id = $judgeTasks[0]->getSubmission()?->getSubmitid(); $judgetaskids = []; foreach ($judgeTasks as $judgeTask) { - if ($judgeTask->getSubmission()?->getSubmitid() == $submit_id) { - $judgetaskids[] = $judgeTask->getJudgetaskid(); + if ($judgeTask->getType() == 'judging_run') { + if ($judgeTask->getSubmission()?->getSubmitid() == $submit_id) { + $judgetaskids[] = $judgeTask->getJudgetaskid(); + } + } else { + // Just pick everything assigned to the judgehost itself or unassigned for now + $assignedJudgehost = $judgeTask->getJudgehost(); + if ($assignedJudgehost === $judgehost || $assignedJudgehost === null) { + $judgetaskids[] = $judgeTask->getJudgetaskid(); + } } } diff --git a/webapp/src/Controller/Jury/ExecutableController.php b/webapp/src/Controller/Jury/ExecutableController.php index b3c2913a47..d8f0204950 100644 --- a/webapp/src/Controller/Jury/ExecutableController.php +++ b/webapp/src/Controller/Jury/ExecutableController.php @@ -90,17 +90,20 @@ public function indexAction(Request $request): Response ->join('cp.problem', 'p') ->leftJoin('p.compare_executable', 'ecomp') ->leftJoin('p.run_executable', 'erun') - ->andWhere('ecomp IS NOT NULL OR erun IS NOT NULL') + ->leftJoin('p.output_visualizer_executable', 'evisual') + ->andWhere('ecomp IS NOT NULL OR erun IS NOT NULL OR evisual IS NOT NULL') ->getQuery()->getResult(); $executablesWithContestProblems = $em->createQueryBuilder() ->select('e') ->from(Executable::class, 'e') ->leftJoin('e.problems_compare', 'pcomp') ->leftJoin('e.problems_run', 'prun') - ->where('pcomp IS NOT NULL OR prun IS NOT NULL') + ->leftJoin('e.problems_output_visualizer', 'pvisual') + ->where('pcomp IS NOT NULL OR prun IS NOT NULL OR pvisual IS NOT NULL') ->leftJoin('pcomp.contest_problems', 'cpcomp') ->leftJoin('prun.contest_problems', 'cprun') - ->andWhere('cprun.contest = :contest OR cpcomp.contest = :contest') + ->leftJoin('pvisual.contest_problems', 'cpvisual') + ->andWhere('cprun.contest = :contest OR cpcomp.contest = :contest OR cpvisual.contest = :contest') ->setParameter('contest', $this->dj->getCurrentContest()) ->getQuery()->getResult(); } @@ -108,7 +111,7 @@ public function indexAction(Request $request): Response foreach ($executables as $e) { $badges = []; if (in_array($e, $executablesWithContestProblems)) { - foreach (array_merge($e->getProblemsRun()->toArray(), $e->getProblemsCompare()->toArray()) as $execProblem) { + foreach (array_merge($e->getProblemsRun()->toArray(), $e->getProblemsCompare()->toArray(), $e->getProblemsOutputVisualizer()->toArray()) as $execProblem) { $execContestProblems = $execProblem->getContestProblems(); foreach ($contestProblemsWithExecutables as $cp) { if ($execContestProblems->contains($cp)) { @@ -142,6 +145,9 @@ public function indexAction(Request $request): Response case 'run': $execdata['icon']['icon'] = 'person-running'; break; + case 'output_visualizer': + $execdata['icon']['icon'] = 'paint-brush'; + break; default: $execdata['icon']['icon'] = 'question'; } diff --git a/webapp/src/Controller/Jury/JudgeRemainingTrait.php b/webapp/src/Controller/Jury/JudgeRemainingTrait.php index 7358f7bc5e..1ba8bbc898 100644 --- a/webapp/src/Controller/Jury/JudgeRemainingTrait.php +++ b/webapp/src/Controller/Jury/JudgeRemainingTrait.php @@ -29,6 +29,7 @@ protected function judgeRemaining(array $judgings): void $numRequested = $this->em->getConnection()->executeStatement( 'UPDATE judgetask SET valid=1' . ' WHERE jobid=:jobid' + . ' AND type="judging_run"' . ' AND judgehostid IS NULL', [ 'jobid' => $judgingId, diff --git a/webapp/src/Controller/Jury/SubmissionController.php b/webapp/src/Controller/Jury/SubmissionController.php index d0f9c473e5..97721fdb62 100644 --- a/webapp/src/Controller/Jury/SubmissionController.php +++ b/webapp/src/Controller/Jury/SubmissionController.php @@ -2,6 +2,7 @@ namespace App\Controller\Jury; +use Doctrine\DBAL\ArrayParameterType; use App\Controller\BaseController; use App\DataTransferObject\SubmissionRestriction; use App\Doctrine\DBAL\Types\JudgeTaskType; @@ -15,12 +16,14 @@ use App\Entity\JudgingRun; use App\Entity\Language; use App\Entity\Problem; +use App\Entity\QueueTask; use App\Entity\Submission; use App\Entity\SubmissionFile; use App\Entity\Team; use App\Entity\TeamAffiliation; use App\Entity\TeamCategory; use App\Entity\Testcase; +use App\Entity\Visualization; use App\Form\Type\SubmissionsFilterType; use App\Service\BalloonService; use App\Service\ConfigurationService; @@ -511,6 +514,14 @@ public function viewAction( ->getSingleScalarResult(); } + $visualization = null; + $createdVisualization = $this->em->getRepository(Visualization::class)->findOneBy([ + 'judging' => $selectedJudging, + ]); + if ($createdVisualization) { + $visualization = ['url' => $this->generateUrl('jury_submission_visual', ['visualId' => $createdVisualization->getVisualizationId()])]; + } + $twigData = [ 'submission' => $submission, 'lastSubmission' => $lastSubmission, @@ -533,6 +544,8 @@ public function viewAction( 'requestedOutputCount' => $requestedOutputCount, 'version_warnings' => [], 'isMultiPassProblem' => $submission->getProblem()->isMultipassProblem(), + 'hasOutputVisualizer' => $submission->getProblem()->getOutputVisualizerExecutable() ?? false, + 'visualization' => $visualization, ]; if ($selectedJudging === null) { @@ -1010,6 +1023,23 @@ public function requestRemainingRuns(Request $request, int $judgingId): Redirect ); } + /** + * @throws DBALException + */ + #[Route(path: '/{judgingId<\d+>}/request-visualization', name: 'jury_submission_request_visualization', methods: ['POST'])] + public function requestVisualizationRuns(Request $request, int $judgingId): RedirectResponse + { + $judging = $this->em->getRepository(Judging::class)->find($judgingId); + if ($judging === null) { + throw new BadRequestHttpException("Unknown judging with '$judgingId' requested."); + } + $this->createVisualization([$judging]); + + return $this->redirectToLocalReferrer($this->router, $request, + $this->generateUrl('jury_submission_by_judging', ['jid' => $judgingId]) + ); + } + /** * @throws DBALException */ @@ -1282,4 +1312,88 @@ private function maybeGetErrors(string $type, string $expectedConfigString, stri array_push($allErrors, ...$errors); } } + + /** + * @param Judging[] $judgings + */ + protected function createVisualization(array $judgings): void + { + $inProgress = []; + $alreadyRequested = []; + $invalidJudgings = []; + $numRequested = 0; + foreach ($judgings as $judging) { + $judgingId = $judging->getJudgingid(); + if ($judging->getResult() === null) { + $inProgress[] = $judgingId; + } elseif ($judging->getVisualization()) { + $alreadyRequested[] = $judgingId; + } elseif (!$judging->getValid()) { + $invalidJudgings[] = $judgingId; + } else { + $outs = $judging->getRuns()->toArray(); + $tmpRun = null; + $lowestId = count($outs); + foreach ($outs as $run) { + if ($tmpRun !== null and $lowestId > $run->getRunId()) { + continue; + } + if ($run->getRunResult() === 'correct' and $run->getRunId()<$lowestId) { + $tmpRun = $run; + $lowestId = $run->getRunId(); + } + } + $submission = $judging->getSubmission(); + $executable = $submission->getProblem()->getOutputVisualizerExecutable()->getImmutableExecutable(); + $judgeTask = new JudgeTask(); + $judgeTask->setType('output_visualization') + ->setValid(true) + ->setJobid($judgingId) + ->setJudgehost($tmpRun->getJudgeTask()->getJudgehost()) + ->setSubmission($submission) + ->setTestcaseId($tmpRun->getTestcase()->getTestcaseId()) + ->setPriority(JudgeTask::PRIORITY_LOW) + ->setOutputVisualizerScriptId($executable->getImmutableExecId()) + ->setRunConfig($this->dj->jsonEncode(['hash' => $executable->getHash()])); + $numRequested += 1; + $this->em->persist($judgeTask); + $this->em->flush(); + } + } + if (count($judgings) === 1) { + if ($inProgress !== []) { + $this->addFlash('warning', 'Please be patient, this visualization is still in progress.'); + } + if ($alreadyRequested != []) { + $this->addFlash('warning', 'This visualization was already requested to be judged completely.'); + } + } else { + if ($inProgress !== []) { + $this->addFlash('warning', sprintf('Please be patient, these visualizations are still in progress: %s', implode(', ', $inProgress))); + } + if ($alreadyRequested != []) { + $this->addFlash('warning', sprintf('These judgings were already requested to be judged completely: %s', implode(', ', $alreadyRequested))); + } + if ($invalidJudgings !== []) { + $this->addFlash('warning', sprintf('These visualizations were skipped as the judgings were superseded by other judgings: %s', implode(', ', $invalidJudgings))); + } + } + if ($numRequested === 0) { + $this->addFlash('warning', 'No more remaining runs to be judged.'); + } else { + $this->addFlash('info', "Requested $numRequested to be visualized."); + } + } + + #[Route(path: '/visual/{visualId}', name: 'jury_submission_visual')] + public function visualAction( + Request $request, + ?string $visualId = null, + ): StreamedResponse { + + $visualization = $this->em->getRepository(Visualization::class)->findOneBy(['visualization_id' => $visualId]); + $name = 'visual.j' . $visualization->getJudging()->getJudgingid() + . '.png'; + return Utils::streamAsBinaryFile(file_get_contents($visualization->getFilename()), $name); + } } diff --git a/webapp/src/Controller/Jury/UserController.php b/webapp/src/Controller/Jury/UserController.php index 3000e5650f..cbcee2bbde 100644 --- a/webapp/src/Controller/Jury/UserController.php +++ b/webapp/src/Controller/Jury/UserController.php @@ -2,6 +2,12 @@ namespace App\Controller\Jury; +use App\Entity\Judgehost; +use App\Entity\JudgeTask; +use App\Entity\Testcase; +use App\Doctrine\DBAL\Types\JudgeTaskType; +use Doctrine\DBAL\ArrayParameterType; + use App\Controller\BaseController; use App\DataTransferObject\SubmissionRestriction; use App\Entity\Role; diff --git a/webapp/src/Doctrine/DBAL/Types/JudgeTaskType.php b/webapp/src/Doctrine/DBAL/Types/JudgeTaskType.php index b9c2575113..3fb086456f 100644 --- a/webapp/src/Doctrine/DBAL/Types/JudgeTaskType.php +++ b/webapp/src/Doctrine/DBAL/Types/JudgeTaskType.php @@ -13,12 +13,14 @@ class JudgeTaskType extends Type final public const GENERIC_TASK = 'generic_task'; final public const JUDGING_RUN = 'judging_run'; final public const PREFETCH = 'prefetch'; + final public const OUTPUT_VISUALIZATION = 'output_visualization'; final public const ALL_TYPES = [ self::CONFIG_CHECK, self::DEBUG_INFO, self::GENERIC_TASK, self::JUDGING_RUN, self::PREFETCH, + self::OUTPUT_VISUALIZATION, ]; public function getSQLDeclaration(array $column, AbstractPlatform $platform): string diff --git a/webapp/src/Entity/Executable.php b/webapp/src/Entity/Executable.php index 2beff3a635..bb8e8868d1 100644 --- a/webapp/src/Entity/Executable.php +++ b/webapp/src/Entity/Executable.php @@ -10,13 +10,13 @@ use ZipArchive; /** - * Compile, compare, and run script executable bundles. + * Compile, compare, run, debug and output visualizer script executable bundles. */ #[ORM\Entity] #[ORM\Table(options: [ 'collation' => 'utf8mb4_unicode_ci', 'charset' => 'utf8mb4', - 'comment' => 'Compile, compare, and run script executable bundles', + 'comment' => 'Compile, compare, debug, output visualizer and run script executable bundles', ])] class Executable { @@ -31,7 +31,7 @@ class Executable private ?string $description = null; #[ORM\Column(length: 32, options: ['comment' => 'Type of executable'])] - #[Assert\Choice(['compare', 'compile', 'debug', 'run'])] + #[Assert\Choice(['compare', 'compile', 'debug', 'output_visualizer', 'run'])] private string $type; #[ORM\OneToOne(targetEntity: ImmutableExecutable::class)] @@ -56,11 +56,18 @@ class Executable #[ORM\OneToMany(mappedBy: 'run_executable', targetEntity: Problem::class)] private Collection $problems_run; + /** + * @var Collection + */ + #[ORM\OneToMany(mappedBy: 'output_visualizer_executable', targetEntity: Problem::class)] + private Collection $problems_output_visualizer; + public function __construct() { - $this->languages = new ArrayCollection(); - $this->problems_compare = new ArrayCollection(); - $this->problems_run = new ArrayCollection(); + $this->languages = new ArrayCollection(); + $this->problems_compare = new ArrayCollection(); + $this->problems_run = new ArrayCollection(); + $this->problems_output_visualizer = new ArrayCollection(); } public function setExecid(string $execid): Executable @@ -143,6 +150,20 @@ public function getProblemsRun(): Collection return $this->problems_run; } + public function addProblemsOutputVisualizer(Problem $problemsOutputVisualizer): Executable + { + $this->problems_output_visualizer[] = $problemsOutputVisualizer; + return $this; + } + + /** + * @return Collection + */ + public function getProblemsOutputVisualizer(): Collection + { + return $this->problems_output_visualizer; + } + public function setImmutableExecutable(ImmutableExecutable $immutableExecutable): Executable { $this->immutableExecutable = $immutableExecutable; @@ -190,7 +211,7 @@ public function checkUsed(array $configScripts): bool if (in_array($this->execid, $configScripts, true)) { return true; } - if (count($this->problems_compare) || count($this->problems_run)) { + if (count($this->problems_compare) || count($this->problems_run) || count($this->problems_output_visualizer)) { return true; } foreach ($this->languages as $lang) { diff --git a/webapp/src/Entity/JudgeTask.php b/webapp/src/Entity/JudgeTask.php index 513efebee1..b00820a405 100644 --- a/webapp/src/Entity/JudgeTask.php +++ b/webapp/src/Entity/JudgeTask.php @@ -99,6 +99,10 @@ public function getSubmitid(): ?int #[Serializer\Type('string')] private ?int $compare_script_id = null; + #[ORM\Column(nullable: true, options: ['comment' => 'Output visualizer script ID', 'unsigned' => true])] + #[Serializer\Type('string')] + private ?int $output_visualizer_script_id = null; + #[ORM\Column(nullable: true, options: ['comment' => 'Testcase ID', 'unsigned' => true])] #[Serializer\Type('string')] private ?int $testcase_id = null; @@ -275,6 +279,17 @@ public function getCompareScriptId(): int return $this->compare_script_id; } + public function setOutputVisualizerScriptId(int $output_visualizer_script_id): JudgeTask + { + $this->output_visualizer_script_id = $output_visualizer_script_id; + return $this; + } + + public function getOutputVisualizerScriptId(): int + { + return $this->output_visualizer_script_id; + } + public function setTestcaseId(int $testcase_id): JudgeTask { $this->testcase_id = $testcase_id; diff --git a/webapp/src/Entity/Judging.php b/webapp/src/Entity/Judging.php index 2526b48c6a..a310a83c20 100644 --- a/webapp/src/Entity/Judging.php +++ b/webapp/src/Entity/Judging.php @@ -113,6 +113,10 @@ class Judging extends BaseApiEntity #[Serializer\Exclude] private bool $judgeCompletely = false; + #[ORM\Column(options: ['comment' => 'Explicitly requested to visualize the output.', 'default' => 0])] + #[Serializer\Exclude] + private bool $visualization = false; + #[ORM\Column(options: ['comment' => 'UUID, to make caching of compilation results safe.'])] #[Serializer\Exclude] private string $uuid; @@ -350,6 +354,17 @@ public function getJudgeCompletely(): bool return $this->judgeCompletely; } + public function setVisualization(bool $visualization): Judging + { + $this->visualization = $visualization; + return $this; + } + + public function getVisualization(): bool + { + return $this->visualization; + } + public function setSubmission(?Submission $submission = null): Judging { $this->submission = $submission; diff --git a/webapp/src/Entity/Problem.php b/webapp/src/Entity/Problem.php index 0d58aa6679..cd413ce37b 100644 --- a/webapp/src/Entity/Problem.php +++ b/webapp/src/Entity/Problem.php @@ -27,6 +27,7 @@ #[ORM\UniqueConstraint(columns: ['externalid'], name: 'externalid', options: ['lengths' => [190]])] #[ORM\Index(columns: ['special_run'], name: 'special_run')] #[ORM\Index(columns: ['special_compare'], name: 'special_compare')] +#[ORM\Index(columns: ['special_output_visualizer'], name: 'special_output_visualizer')] #[ORM\HasLifecycleCallbacks] #[UniqueEntity(fields: 'externalid')] class Problem extends BaseApiEntity implements @@ -154,6 +155,11 @@ class Problem extends BaseApiEntity implements #[Serializer\Exclude] private ?Executable $run_executable = null; + #[ORM\ManyToOne(inversedBy: 'problems_output_visualizer')] + #[ORM\JoinColumn(name: 'special_output_visualizer', referencedColumnName: 'execid', onDelete: 'SET NULL')] + #[Serializer\Exclude] + private ?Executable $output_visualizer_executable = null; + /** * @var Collection */ @@ -378,6 +384,17 @@ public function getRunExecutable(): ?Executable return $this->run_executable; } + public function setOutputVisualizerExecutable(?Executable $outputVisualizerExecutable = null): Problem + { + $this->output_visualizer_executable = $outputVisualizerExecutable; + return $this; + } + + public function getOutputVisualizerExecutable(): ?Executable + { + return $this->output_visualizer_executable; + } + public function __construct() { $this->testcases = new ArrayCollection(); diff --git a/webapp/src/Entity/Visualization.php b/webapp/src/Entity/Visualization.php new file mode 100644 index 0000000000..a6057b6e33 --- /dev/null +++ b/webapp/src/Entity/Visualization.php @@ -0,0 +1,86 @@ + 'utf8mb4_unicode_ci', + 'charset' => 'utf8mb4', + 'comment' => 'Team output visualization.', +])] +#[ORM\Index(columns: ['judgingid'], name: 'judgingid')] +class Visualization +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column(options: ['comment' => 'Visualization ID', 'unsigned' => true])] + private int $visualization_id; + + #[ORM\ManyToOne(inversedBy: 'visualizations')] + #[ORM\JoinColumn(name: 'judgingid', referencedColumnName: 'judgingid', onDelete: 'CASCADE')] + private Judging $judging; + + #[ORM\Column(options: ['comment' => 'Name of the file where we stored the visualization.'])] + private string $filename; + + #[ORM\ManyToOne] + #[ORM\JoinColumn(name: 'judgehostid', referencedColumnName: 'judgehostid', onDelete: 'SET NULL')] + private Judgehost $judgehost; + + #[ORM\ManyToOne(inversedBy: 'visualizations')] + #[ORM\JoinColumn(name: 'testcaseid', referencedColumnName: 'testcaseid', onDelete: 'CASCADE')] + private Testcase $testcase; + + public function getVisualizationId(): int + { + return $this->visualization_id; + } + + public function getJudging(): Judging + { + return $this->judging; + } + + public function setJudging(Judging $judging): Visualization + { + $this->judging = $judging; + return $this; + } + + public function getFilename(): string + { + return $this->filename; + } + + public function setFilename(string $filename): Visualization + { + $this->filename = $filename; + return $this; + } + + public function getJudgehost(): Judgehost + { + return $this->judgehost; + } + + public function setJudgehost(Judgehost $judgehost): Visualization + { + $this->judgehost = $judgehost; + return $this; + } + + public function getTestcase(): Testcase + { + return $this->testcase; + } + + public function setTestcase(Testcase $testcase): Visualization + { + $this->testcase = $testcase; + return $this; + } +} diff --git a/webapp/src/NelmioApiDocBundle/ExternalDocDescriber.php b/webapp/src/NelmioApiDocBundle/ExternalDocDescriber.php index 2805d51b55..ff073d4f0d 100644 --- a/webapp/src/NelmioApiDocBundle/ExternalDocDescriber.php +++ b/webapp/src/NelmioApiDocBundle/ExternalDocDescriber.php @@ -24,6 +24,6 @@ public function describe(OpenApi $api): void // Inject the correct server for the API docs $request = $this->requestStack->getCurrentRequest(); $this->decorated->describe($api); - Util::merge($api->servers[0], ['url' => $request->getSchemeAndHttpHost(),], true); + Util::merge($api->servers[0], ['url' => $request->getSchemeAndHttpHost().$request->getBaseUrl(),], true); } } diff --git a/webapp/src/Service/ImportProblemService.php b/webapp/src/Service/ImportProblemService.php index 26474e5a00..f7cedc19d9 100644 --- a/webapp/src/Service/ImportProblemService.php +++ b/webapp/src/Service/ImportProblemService.php @@ -72,7 +72,7 @@ public function importZippedProblem( $submission_file = 'submissions.json'; $problemIsNew = $problem === null; - $iniKeysProblem = ['name', 'timelimit', 'special_run', 'special_compare', 'externalid']; + $iniKeysProblem = ['name', 'timelimit', 'special_run', 'special_compare', 'special_output_visualizer', 'externalid']; $iniKeysContestProblem = ['allow_submit', 'allow_judge', 'points', 'color', 'short-name']; $defaultTimelimit = 10; @@ -144,6 +144,11 @@ public function importZippedProblem( $this->em->getRepository(Executable::class)->find($problemProperties['special_run']); unset($problemProperties['special_run']); } + if (isset($problemProperties['special_output_visualizer'])) { + $problemProperties['output_visualizer_executable'] = + $this->em->getRepository(Executable::class)->find($problemProperties['special_output_visualizer']); + unset($problemProperties['special_output_visualizer']); + } /** @var ContestProblem|null $contestProblem */ $contestProblem = null; @@ -208,6 +213,7 @@ public function importZippedProblem( ->setSpecialCompareArgs('') ->setRunExecutable() ->setCombinedRunCompare(false) + ->setOutputVisualizerExecutable() ->setMemlimit(null) ->setOutputlimit(null) ->setProblemStatementContent(null) @@ -306,6 +312,12 @@ public function importZippedProblem( } } + if (isset($yamlData['visualization'])) { + if (!$this->searchAndAddOutputVisualizer($zip, $messages, $externalId, $yamlData['visualization'], $problem)) { + return null; + } + } + foreach ($yamlProblemProperties as $key => $value) { $propertyAccessor->setValue($problem, $key, $value); } @@ -926,36 +938,61 @@ public function importProblemFromRequest(Request $request, ?int $contestId = nul ]; } + /** + * @param array{danger: string[], info: string[]} $messages + */ + private function searchAndAddOutputVisualizer(ZipArchive $zip, ?array &$messages, string $externalId, string $visualizerMode, ?Problem $problem): bool + { + $programStrings = []; + $programStrings['package_dir'] = 'output_visualizer/'; + $programStrings['type'] = 'output visualizer'; + $programStrings['clash'] = 'visual'; + return self::helperSearchAndAddProgram($zip, $messages, $externalId, $visualizerMode, $problem, $programStrings); + } + /** * @param array{danger: string[], info: string[]} $messages */ private function searchAndAddValidator(ZipArchive $zip, ?array &$messages, string $externalId, string $validationMode, ?Problem $problem): bool { - $validatorFiles = []; + $programStrings = []; + $programStrings['package_dir'] = 'output_validators/'; + $programStrings['type'] = 'output validator'; + $programStrings['clash'] = 'cmp'; + return self::helperSearchAndAddProgram($zip, $messages, $externalId, $validationMode, $problem, $programStrings); + } + + /** + * @param array{danger: string[], info: string[]} $messages + * @param array $programStrings + */ + private function helperSearchAndAddProgram(ZipArchive $zip, ?array &$messages, string $externalId, string $programMode, ?Problem $problem, array $programStrings): bool + { + $programFiles = []; for ($i = 0; $i < $zip->numFiles; $i++) { $filename = $zip->getNameIndex($i); - if (Utils::startsWith($filename, 'output_validators/') && + if (Utils::startsWith($filename, $programStrings['package_dir']) && !Utils::endsWith($filename, '/')) { - $validatorFiles[] = $filename; + $programFiles[] = $filename; } } - if (sizeof($validatorFiles) == 0) { - $messages['danger'][] = 'Custom validator specified but not found.'; + if (sizeof($programFiles) == 0) { + $messages['danger'][] = 'Custom ' . $programStrings['type'] . ' specified but not found.'; return false; } else { // File(s) have to share common directory. - $validatorDir = mb_substr($validatorFiles[0], 0, mb_strrpos($validatorFiles[0], '/')) . '/'; + $programDir = mb_substr($programFiles[0], 0, mb_strrpos($programFiles[0], '/')) . '/'; $sameDir = true; - foreach ($validatorFiles as $validatorFile) { - if (!Utils::startsWith($validatorFile, $validatorDir)) { + foreach ($programFiles as $programFile) { + if (!Utils::startsWith($programFile, $programDir)) { $sameDir = false; $messages['warning'][] = sprintf('%s does not start with %s.', - $validatorFile, $validatorDir); + $programFile, $programDir); break; } } if (!$sameDir) { - $messages['danger'][] = 'Found multiple custom output validators.'; + $messages['danger'][] = 'Found multiple custom ' . $programStrings['type'] . 's.'; return false; } else { $tmpzipfiledir = exec("mktemp -d --tmpdir=" . @@ -967,9 +1004,9 @@ private function searchAndAddValidator(ZipArchive $zip, ?array &$messages, strin ); } chmod($tmpzipfiledir, 0700); - foreach ($validatorFiles as $validatorFile) { - $content = $zip->getFromName($validatorFile); - $filebase = basename($validatorFile); + foreach ($programFiles as $programFile) { + $content = $zip->getFromName($programFile); + $filebase = basename($programFile); $newfilename = $tmpzipfiledir . "/" . $filebase; file_put_contents($newfilename, $content); if ($filebase === 'build' || $filebase === 'run') { @@ -978,51 +1015,65 @@ private function searchAndAddValidator(ZipArchive $zip, ?array &$messages, strin } } - exec("zip -r -j '$tmpzipfiledir/outputvalidator.zip' '$tmpzipfiledir'", + $newZipFilename = $tmpzipfiledir . '/' . str_replace(' ', '', $programStrings['type']) . '.zip'; + exec("zip -r -j '$newZipFilename' '$tmpzipfiledir'", $dontcare, $retval); if ($retval != 0) { throw new ServiceUnavailableHttpException( - null, 'Failed to create ZIP file for output validator.' + null, 'Failed to create ZIP file for ' . $programStrings['type'] . '.' ); } - $outputValidatorZip = file_get_contents($tmpzipfiledir . '/outputvalidator.zip'); - $outputValidatorName = substr($externalId, 0, 20) . '_cmp'; - if ($this->em->getRepository(Executable::class)->find($outputValidatorName)) { + $programZip = file_get_contents($newZipFilename); + $programName = substr($externalId, 0, 20) . '_' . $programStrings['clash']; + if ($this->em->getRepository(Executable::class)->find($programName)) { // Avoid name clash. $clashCount = 2; while ($this->em->getRepository(Executable::class)->find( - $outputValidatorName . '_' . $clashCount)) { + $programName . '_' . $clashCount)) { $clashCount++; } - $outputValidatorName = $outputValidatorName . "_" . $clashCount; + $programName = $programName . "_" . $clashCount; } - $combinedRunCompare = $validationMode == 'custom interactive'; - if (!($tempzipFile = tempnam($this->dj->getDomjudgeTmpDir(), "/executable-"))) { throw new ServiceUnavailableHttpException(null, 'Failed to create temporary file.'); } - file_put_contents($tempzipFile, $outputValidatorZip); + file_put_contents($tempzipFile, $programZip); $zipArchive = new ZipArchive(); $zipArchive->open($tempzipFile, ZipArchive::CREATE); $executable = new Executable(); $executable - ->setExecid($outputValidatorName) + ->setExecid($programName) ->setImmutableExecutable($this->dj->createImmutableExecutable($zipArchive)) - ->setDescription(sprintf('output validator for %s', $problem->getName())) - ->setType($combinedRunCompare ? 'run' : 'compare'); + ->setDescription(sprintf('%s for %s', $programStrings['type'], $problem->getName())); + + $combinedRunCompare = false; + if ($programStrings['type'] === 'output validator') { + $combinedRunCompare = $programMode == 'custom interactive'; + $executable->setType($combinedRunCompare ? 'run' : 'compare'); + } else { + $executable->setType(str_replace(' ', '_', $programStrings['type'])); + } $this->em->persist($executable); - if ($combinedRunCompare) { - $problem->setCombinedRunCompare(true); - $problem->setRunExecutable($executable); + if ($programStrings['type'] === 'output validator') { + if ($combinedRunCompare) { + $problem->setCombinedRunCompare(true); + $problem->setRunExecutable($executable); + } else { + $problem->setCompareExecutable($executable); + } + } elseif ($programStrings['type'] === 'output visualizer') { + $problem->setOutputVisualizerExecutable($executable); } else { - $problem->setCompareExecutable($executable); + $messages['danger'][] = "Unknown type '" . $programStrings['type'] . "'."; + return false; } - $messages['info'][] = "Added output validator '$outputValidatorName'."; + $newMessage = "Added " . $programStrings['type'] . " '$programName'."; + $messages['info'][] = $newMessage; } } return true; diff --git a/webapp/templates/jury/executable.html.twig b/webapp/templates/jury/executable.html.twig index 94569d1e4e..6c905432a7 100644 --- a/webapp/templates/jury/executable.html.twig +++ b/webapp/templates/jury/executable.html.twig @@ -59,6 +59,13 @@ {% set used = true %} {% endfor %} + {% elseif executable.type == 'output_visualizer' %} + {% for problem in executable.problemsOutputVisualizer %} + + p{{ problem.probid }} {{ problem | problemBadgeForContest }} + + {% set used = true %} + {% endfor %} {% elseif executable.type == 'compile' %} {% for language in executable.languages %} diff --git a/webapp/templates/jury/problem.html.twig b/webapp/templates/jury/problem.html.twig index 70de500b0f..57881c0497 100644 --- a/webapp/templates/jury/problem.html.twig +++ b/webapp/templates/jury/problem.html.twig @@ -109,6 +109,14 @@ {{ problem.specialCompareArgs }} {% endif %} + {% if problem.getOutputVisualizerExecutable is not empty %} + + Output visualizer + + {{ problem.getOutputVisualizerExecutable.execid }} + + + {% endif %} {% if type is not empty %} Type diff --git a/webapp/templates/jury/submission.html.twig b/webapp/templates/jury/submission.html.twig index 589c6dde95..bcccd7bdff 100644 --- a/webapp/templates/jury/submission.html.twig +++ b/webapp/templates/jury/submission.html.twig @@ -532,6 +532,12 @@ {{ runs | displayTestcaseResults(judgingDone) }} + {% if hasOutputVisualizer and judgingDone %} +
+ +
+ {% endif %} {% if selectedJudging is not null and runsOutstanding %} {% if selectedJudging.judgeCompletely %} @@ -835,6 +841,11 @@ {% endif %} {# selectedJudging.result != 'compiler-error' #} + {% if visualization is defined and visualization %} +
Team answer visualization
+ + {% endif %} + {% endif %} {# selectedJudging is not null or externalJudgement is not null #} {% endblock %}