diff --git a/webapp/migrations/Version20241122144232.php b/webapp/migrations/Version20241122144232.php new file mode 100644 index 0000000000..19beb5d8d1 --- /dev/null +++ b/webapp/migrations/Version20241122144232.php @@ -0,0 +1,40 @@ +addSql('CREATE TABLE contestlanguage (cid INT UNSIGNED NOT NULL COMMENT \'Contest ID\', langid VARCHAR(32) NOT NULL COMMENT \'Language ID (string)\', INDEX IDX_ADCB43234B30D9C4 (cid), INDEX IDX_ADCB43232271845 (langid), PRIMARY KEY(cid, langid)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('ALTER TABLE contestlanguage ADD CONSTRAINT FK_ADCB43234B30D9C4 FOREIGN KEY (cid) REFERENCES contest (cid) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE contestlanguage ADD CONSTRAINT FK_ADCB43232271845 FOREIGN KEY (langid) REFERENCES language (langid) ON DELETE CASCADE'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE contestlanguage DROP FOREIGN KEY FK_ADCB43234B30D9C4'); + $this->addSql('ALTER TABLE contestlanguage DROP FOREIGN KEY FK_ADCB43232271845'); + $this->addSql('DROP TABLE contestlanguage'); + } + + public function isTransactional(): bool + { + return false; + } +} diff --git a/webapp/src/Controller/BaseController.php b/webapp/src/Controller/BaseController.php index 4a75c61a6f..e03c2eb910 100644 --- a/webapp/src/Controller/BaseController.php +++ b/webapp/src/Controller/BaseController.php @@ -8,6 +8,7 @@ use App\Entity\Contest; use App\Entity\ContestProblem; use App\Entity\ExternalIdFromInternalIdInterface; +use App\Entity\Language; use App\Entity\Problem; use App\Entity\RankCache; use App\Entity\ScoreCache; @@ -16,6 +17,7 @@ use App\Service\DOMJudgeService; use App\Service\EventLogService; use App\Utils\Utils; +use Doctrine\Common\Collections\Collection; use Doctrine\DBAL\Exception as DBALException; use Doctrine\Inflector\InflectorFactory; use Doctrine\ORM\EntityManagerInterface; @@ -522,6 +524,10 @@ protected function contestsForEntity(mixed $entity): array $contests = $this->dj->getCurrentContests(); } + if ($contests instanceof Collection) { + $contests = $contests->toArray(); + } + return $contests; } diff --git a/webapp/src/Controller/Jury/ContestController.php b/webapp/src/Controller/Jury/ContestController.php index a43db7aaaa..aafed2ecfa 100644 --- a/webapp/src/Controller/Jury/ContestController.php +++ b/webapp/src/Controller/Jury/ContestController.php @@ -346,12 +346,15 @@ public function viewAction(Request $request, int $contestId): Response ->getQuery() ->getResult(); + $languages = $this->dj->getAllowedLanguagesForContest($contest); + return $this->render('jury/contest.html.twig', [ 'contest' => $contest, 'allowRemovedIntervals' => $this->getParameter('removed_intervals'), 'removedIntervalForm' => $form, 'removedIntervals' => $removedIntervals, 'problems' => $problems, + 'languages' => $languages, ]); } diff --git a/webapp/src/Controller/Team/LanguageController.php b/webapp/src/Controller/Team/LanguageController.php index 1eb7b2e7b8..a03bc4e8fe 100644 --- a/webapp/src/Controller/Team/LanguageController.php +++ b/webapp/src/Controller/Team/LanguageController.php @@ -40,13 +40,9 @@ public function languagesAction(): Response if (!$languagesEnabled) { throw new BadRequestHttpException("You are not allowed to view this page."); } + $currentContest = $this->dj->getCurrentContest(); /** @var Language[] $languages */ - $languages = $this->em->createQueryBuilder() - ->select('l') - ->from(Language::class, 'l') - ->andWhere('l.allowSubmit = 1') - ->orderBy('l.langid') - ->getQuery()->getResult(); + $languages = $this->dj->getAllowedLanguagesForContest($currentContest); return $this->render('team/languages.html.twig', ['languages' => $languages]); } } diff --git a/webapp/src/Controller/Team/MiscController.php b/webapp/src/Controller/Team/MiscController.php index aae0630121..d0040b02e5 100644 --- a/webapp/src/Controller/Team/MiscController.php +++ b/webapp/src/Controller/Team/MiscController.php @@ -195,13 +195,9 @@ public function printAction(Request $request): Response ]); } + $currentContest = $this->dj->getCurrentContest(); /** @var Language[] $languages */ - $languages = $this->em->createQueryBuilder() - ->from(Language::class, 'l') - ->select('l') - ->andWhere('l.allowSubmit = 1') - ->getQuery() - ->getResult(); + $languages = $this->dj->getAllowedLanguagesForContest($currentContest); return $this->render('team/print.html.twig', [ 'form' => $form, diff --git a/webapp/src/Entity/Contest.php b/webapp/src/Entity/Contest.php index 936c005629..53f1cb4f63 100644 --- a/webapp/src/Entity/Contest.php +++ b/webapp/src/Entity/Contest.php @@ -343,6 +343,16 @@ class Contest extends BaseApiEntity implements #[Serializer\Exclude] private Collection $teams; + /** + * @var Collection + */ + #[ORM\ManyToMany(targetEntity: Language::class, inversedBy: 'contests')] + #[ORM\JoinTable(name: 'contestlanguage')] + #[ORM\JoinColumn(name: 'cid', referencedColumnName: 'cid', onDelete: 'CASCADE')] + #[ORM\InverseJoinColumn(name: 'langid', referencedColumnName: 'langid', onDelete: 'CASCADE')] + #[Serializer\Exclude] + private Collection $languages; + /** * @var Collection */ @@ -437,6 +447,7 @@ public function __construct() { $this->problems = new ArrayCollection(); $this->teams = new ArrayCollection(); + $this->languages = new ArrayCollection(); $this->removedIntervals = new ArrayCollection(); $this->clarifications = new ArrayCollection(); $this->submissions = new ArrayCollection(); @@ -896,6 +907,22 @@ public function getTeams(): Collection return $this->teams; } + public function addLanguage(Language $language): Contest + { + $this->languages[] = $language; + return $this; + } + + public function removeLanguage(Language $language): void + { + $this->languages->removeElement($language); + } + + public function getLanguages(): Collection + { + return $this->languages; + } + public function addProblem(ContestProblem $problem): Contest { $this->problems[] = $problem; @@ -1591,4 +1618,5 @@ public function getProblemsetForApi(): array { return array_filter([$this->problemsetForApi]); } + } diff --git a/webapp/src/Entity/Language.php b/webapp/src/Entity/Language.php index 2b28f2de18..26c6b69cf2 100644 --- a/webapp/src/Entity/Language.php +++ b/webapp/src/Entity/Language.php @@ -149,6 +149,13 @@ class Language extends BaseApiEntity implements #[Serializer\Exclude] private ?string $runnerVersionCommand = null; + /** + * @var Collection + */ + #[ORM\ManyToMany(targetEntity: Contest::class, mappedBy: 'languages')] + #[Serializer\Exclude] + private Collection $contests; + /** * @param Collection $versions */ @@ -386,6 +393,7 @@ public function __construct() { $this->submissions = new ArrayCollection(); $this->versions = new ArrayCollection(); + $this->contests = new ArrayCollection(); } public function addSubmission(Submission $submission): Language @@ -419,4 +427,23 @@ public function getAceLanguage(): string default => $this->getLangid(), }; } + + public function addContest(Contest $contest): Language + { + $this->contests[] = $contest; + $contest->addLanguage($this); + return $this; + } + + public function removeContest(Contest $contest): Language + { + $this->contests->removeElement($contest); + $contest->removeLanguage($this); + return $this; + } + + public function getContests(): Collection + { + return $this->contests; + } } diff --git a/webapp/src/Form/Type/ContestType.php b/webapp/src/Form/Type/ContestType.php index c1dabd5fe9..cfb1508cdd 100644 --- a/webapp/src/Form/Type/ContestType.php +++ b/webapp/src/Form/Type/ContestType.php @@ -4,6 +4,7 @@ use App\Entity\Contest; use App\Entity\ContestProblem; +use App\Entity\Language; use App\Entity\Team; use App\Entity\TeamCategory; use App\Service\DOMJudgeService; @@ -200,6 +201,13 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'allow_delete' => true, 'label' => false, ]); + $builder->add('languages', EntityType::class, [ + 'required' => false, + 'class' => Language::class, + 'multiple' => true, + 'choice_label' => fn(Language $language) => sprintf('%s (%s)', $language->getName(), $language->getExternalid()), + 'help' => 'List of languages that can be used in this contest. Leave empty to allow all languages that are enabled globally.', + ]); $builder->add('save', SubmitType::class); diff --git a/webapp/src/Form/Type/LanguageType.php b/webapp/src/Form/Type/LanguageType.php index 13419b7af4..5c4aad7977 100644 --- a/webapp/src/Form/Type/LanguageType.php +++ b/webapp/src/Form/Type/LanguageType.php @@ -2,6 +2,7 @@ namespace App\Form\Type; +use App\Entity\Contest; use App\Entity\Executable; use App\Entity\Language; use Doctrine\ORM\EntityRepository; @@ -88,6 +89,16 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'label' => 'Runner version command', 'required' => false, ]); + $builder->add('contests', EntityType::class, [ + 'class' => Contest::class, + 'required' => false, + 'choice_label' => 'name', + 'multiple' => true, + 'by_reference' => false, + 'query_builder' => fn(EntityRepository $er) => $er + ->createQueryBuilder('c') + ->orderBy('c.name'), + ]); $builder->add('save', SubmitType::class); // Remove ID field when doing an edit. diff --git a/webapp/src/Form/Type/SubmitProblemType.php b/webapp/src/Form/Type/SubmitProblemType.php index 9344700050..1e7fa12bd1 100644 --- a/webapp/src/Form/Type/SubmitProblemType.php +++ b/webapp/src/Form/Type/SubmitProblemType.php @@ -54,11 +54,10 @@ public function buildForm(FormBuilderInterface $builder, array $options): void ]; $builder->add('problem', EntityType::class, $problemConfig); + $languages = $this->dj->getAllowedLanguagesForContest($contest); $builder->add('language', EntityType::class, [ 'class' => Language::class, - 'query_builder' => fn(EntityRepository $er) => $er - ->createQueryBuilder('l') - ->andWhere('l.allowSubmit = 1'), + 'choices' => $languages, 'choice_label' => 'name', 'placeholder' => 'Select a language', ]); diff --git a/webapp/src/Service/DOMJudgeService.php b/webapp/src/Service/DOMJudgeService.php index 55c1bcbc39..a723cf2550 100644 --- a/webapp/src/Service/DOMJudgeService.php +++ b/webapp/src/Service/DOMJudgeService.php @@ -1000,11 +1000,13 @@ public function getTwigDataForProblemsAction( $defaultMemoryLimit = (int)$this->config->get('memory_limit'); $timeFactorDiffers = false; if ($showLimits) { + $languages = $this->getAllowedLanguagesForContest($contest); $timeFactorDiffers = $this->em->createQueryBuilder() ->from(Language::class, 'l') ->select('COUNT(l)') - ->andWhere('l.allowSubmit = true') ->andWhere('l.timeFactor <> 1') + ->andWhere('l IN (:languages)') + ->setParameter('languages', $languages) ->getQuery() ->getSingleScalarResult() > 0; } @@ -1682,4 +1684,20 @@ public function shadowMode(): bool { return (bool)$this->config->get('shadow_mode'); } + + /** @return Language[] */ + public function getAllowedLanguagesForContest(?Contest $contest) : array { + if ($contest) { + $languages = $contest->getLanguages(); + if (!$languages->isEmpty()) { + return $languages->toArray(); + } + } + return $this->em->createQueryBuilder(Language::class) + ->select('l') + ->from(Language::class, 'l') + ->where('l.allowSubmit = 1') + ->getQuery() + ->getResult(); + } } diff --git a/webapp/src/Service/ImportProblemService.php b/webapp/src/Service/ImportProblemService.php index 26474e5a00..7f3a503022 100644 --- a/webapp/src/Service/ImportProblemService.php +++ b/webapp/src/Service/ImportProblemService.php @@ -653,12 +653,7 @@ public function importZippedProblem( // First find all submittable languages: /** @var Language[] $allowedLanguages */ - $allowedLanguages = $this->em->createQueryBuilder() - ->from(Language::class, 'l', 'l.langid') - ->select('l') - ->andWhere('l.allowSubmit = true') - ->getQuery() - ->getResult(); + $allowedLanguages = $this->dj->getAllowedLanguagesForContest($contest); // Read submission details from optional file. $submission_file_string = $zip->getFromName($submission_file); @@ -708,9 +703,9 @@ public function importZippedProblem( continue; } $extension = end($parts); - foreach ($allowedLanguages as $key => $language) { + foreach ($allowedLanguages as $language) { if (in_array($extension, $language->getExtensions())) { - $languageToUse = $key; + $languageToUse = $language->getLangid(); break 2; } } diff --git a/webapp/src/Service/StatisticsService.php b/webapp/src/Service/StatisticsService.php index 15ff41ce32..4b026a2d6d 100644 --- a/webapp/src/Service/StatisticsService.php +++ b/webapp/src/Service/StatisticsService.php @@ -25,7 +25,7 @@ class StatisticsService 'all' => 'All teams', ]; - public function __construct(private readonly EntityManagerInterface $em) + public function __construct(private readonly EntityManagerInterface $em, private readonly DOMJudgeService $dj) { } @@ -544,15 +544,9 @@ public function getGroupedProblemsStats( public function getLanguagesStats(Contest $contest, string $view): array { /** @var Language[] $languages */ - $languages = $this->em->getRepository(Language::class) - ->createQueryBuilder('l') - ->andWhere('l.allowSubmit = 1') - ->orderBy('l.name') - ->getQuery() - ->getResult(); + $languages = $this->dj->getAllowedLanguagesForContest($contest); $languageStats = []; - foreach ($languages as $language) { $languageStats[$language->getLangid()] = [ 'name' => $language->getName(), diff --git a/webapp/src/Service/SubmissionService.php b/webapp/src/Service/SubmissionService.php index b46a65b2df..b800049a1a 100644 --- a/webapp/src/Service/SubmissionService.php +++ b/webapp/src/Service/SubmissionService.php @@ -453,9 +453,10 @@ public function submitSolution( throw new BadRequestHttpException('Submissions for contest (temporarily) disabled'); } - if (!$language->getAllowSubmit()) { + $allowedLanguages = $this->dj->getAllowedLanguagesForContest($contest); + if (!in_array($language, $allowedLanguages, true)) { throw new BadRequestHttpException( - sprintf("Language '%s' not found in database or not submittable.", $language->getLangid())); + sprintf("Language '%s' not allowed for contest [c%d].", $language->getLangid(), $contest->getCid())); } if ($language->getRequireEntryPoint() && empty($entryPoint)) { @@ -781,7 +782,7 @@ public function getSubmissionFileResponse(Submission $submission): StreamedRespo { /** @var SubmissionFile[] $files */ $files = $submission->getFiles(); - + if (count($files) !== 1) { throw new ServiceUnavailableHttpException(null, 'Submission does not contain exactly one file.'); } diff --git a/webapp/src/Twig/TwigExtension.php b/webapp/src/Twig/TwigExtension.php index 2a73e324e4..28029a4ae0 100644 --- a/webapp/src/Twig/TwigExtension.php +++ b/webapp/src/Twig/TwigExtension.php @@ -128,9 +128,10 @@ public function getGlobals(): array $selfRegistrationCategoriesCount = $this->em->getRepository(TeamCategory::class)->count(['allow_self_registration' => 1]); // These variables mostly exist for the header template. + $currentContest = $this->dj->getCurrentContest(); return [ 'current_contest_id' => $this->dj->getCurrentContestCookie(), - 'current_contest' => $this->dj->getCurrentContest(), + 'current_contest' => $currentContest, 'current_contests' => $this->dj->getCurrentContests(), 'current_public_contest' => $this->dj->getCurrentContest(onlyPublic: true), 'current_public_contests' => $this->dj->getCurrentContests(onlyPublic: true), @@ -141,12 +142,7 @@ public function getGlobals(): array 'external_ccs_submission_url' => $this->config->get('external_ccs_submission_url'), 'current_team_contest' => $team ? $this->dj->getCurrentContest($team->getTeamid()) : null, 'current_team_contests' => $team ? $this->dj->getCurrentContests($team->getTeamid()) : null, - 'submission_languages' => $this->em->createQueryBuilder() - ->from(Language::class, 'l') - ->select('l') - ->andWhere('l.allowSubmit = 1') - ->getQuery() - ->getResult(), + 'submission_languages' => $this->dj->getAllowedLanguagesForContest($currentContest), 'alpha3_countries' => Countries::getAlpha3Names(), 'alpha3_alpha2_country_mapping' => array_combine( Countries::getAlpha3Codes(), diff --git a/webapp/templates/jury/contest.html.twig b/webapp/templates/jury/contest.html.twig index a62eb9c3f1..d90cec1c09 100644 --- a/webapp/templates/jury/contest.html.twig +++ b/webapp/templates/jury/contest.html.twig @@ -193,6 +193,23 @@ + + Languages + + {% if contest.languages is empty %} + all globally enabled languages: + {% set allowedLanguages = languages %} + {% else %} + {% set allowedLanguages = contest.languages %} + {% endif %} +
    + {% for language in allowedLanguages %} +
  • {{ language.name }}
  • + {% endfor %} +
+ + + Public static scoreboard ZIP diff --git a/webapp/templates/jury/partials/contest_form.html.twig b/webapp/templates/jury/partials/contest_form.html.twig index 9e30edaff0..9581ebdb7a 100644 --- a/webapp/templates/jury/partials/contest_form.html.twig +++ b/webapp/templates/jury/partials/contest_form.html.twig @@ -31,6 +31,7 @@ {{ form_row(form.teams) }} {{ form_row(form.teamCategories) }} + {{ form_row(form.languages) }} {{ form_row(form.enabled) }} {{ form_row(form.bannerFile) }} {% if form.offsetExists('clearBanner') %}