diff --git a/webapp/migrations/Version20250228123717.php b/webapp/migrations/Version20250228123717.php new file mode 100644 index 0000000000..d0ca95f2c2 --- /dev/null +++ b/webapp/migrations/Version20250228123717.php @@ -0,0 +1,40 @@ +addSql('CREATE TABLE problemlanguage (probid INT UNSIGNED NOT NULL COMMENT \'Problem ID\', langid VARCHAR(32) NOT NULL COMMENT \'Language ID (string)\', INDEX IDX_46B150BBEF049279 (probid), INDEX IDX_46B150BB2271845 (langid), PRIMARY KEY(probid, langid)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('ALTER TABLE problemlanguage ADD CONSTRAINT FK_46B150BBEF049279 FOREIGN KEY (probid) REFERENCES problem (probid) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE problemlanguage ADD CONSTRAINT FK_46B150BB2271845 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 problemlanguage DROP FOREIGN KEY FK_46B150BBEF049279'); + $this->addSql('ALTER TABLE problemlanguage DROP FOREIGN KEY FK_46B150BB2271845'); + $this->addSql('DROP TABLE problemlanguage'); + } + + public function isTransactional(): bool + { + return false; + } +} diff --git a/webapp/src/Entity/Language.php b/webapp/src/Entity/Language.php index 959a46eea9..9cb78d3b41 100644 --- a/webapp/src/Entity/Language.php +++ b/webapp/src/Entity/Language.php @@ -156,6 +156,13 @@ class Language extends BaseApiEntity implements #[Serializer\Exclude] private Collection $contests; + /** + * @var Collection + */ + #[ORM\ManyToMany(targetEntity: Problem::class, mappedBy: 'languages')] + #[Serializer\Exclude] + private Collection $problems; + /** * @param Collection $versions */ @@ -394,6 +401,7 @@ public function __construct() $this->submissions = new ArrayCollection(); $this->versions = new ArrayCollection(); $this->contests = new ArrayCollection(); + $this->problems = new ArrayCollection(); } public function addSubmission(Submission $submission): Language @@ -443,4 +451,23 @@ public function getContests(): Collection { return $this->contests; } + + public function addProblem(Problem $problem): Language + { + $this->problems[] = $problem; + $problem->addLanguage($this); + return $this; + } + + public function removeProblem(Problem $problem): Language + { + $this->problems->removeElement($problem); + $problem->removeLanguage($this); + return $this; + } + + public function getProblems(): Collection + { + return $this->problems; + } } diff --git a/webapp/src/Entity/Problem.php b/webapp/src/Entity/Problem.php index 0d58aa6679..c108c83180 100644 --- a/webapp/src/Entity/Problem.php +++ b/webapp/src/Entity/Problem.php @@ -191,6 +191,17 @@ class Problem extends BaseApiEntity implements #[Serializer\Exclude] private ?FileWithName $statementForApi = null; + + /** + * @var Collection + */ + #[ORM\ManyToMany(targetEntity: Language::class, inversedBy: 'problems')] + #[ORM\JoinTable(name: 'problemlanguage')] + #[ORM\JoinColumn(name: 'probid', referencedColumnName: 'probid', onDelete: 'CASCADE')] + #[ORM\InverseJoinColumn(name: 'langid', referencedColumnName: 'langid', onDelete: 'CASCADE')] + #[Serializer\Exclude] + private Collection $languages; + public function setProbid(int $probid): Problem { $this->probid = $probid; @@ -385,6 +396,7 @@ public function __construct() $this->clarifications = new ArrayCollection(); $this->contest_problems = new ArrayCollection(); $this->attachments = new ArrayCollection(); + $this->languages = new ArrayCollection(); $this->problemStatementContent = new ArrayCollection(); } @@ -541,4 +553,24 @@ public function getStatementForApi(): array { return array_filter([$this->statementForApi]); } + + public function addLanguage(Language $language): Problem + { + $this->languages[] = $language; + return $this; + } + + /** + * @return Collection + */ + public function getLanguages(): Collection + { + return $this->languages; + } + + public function removeLanguage(Language $language): Problem + { + $this->languages->removeElement($language); + return $this; + } } diff --git a/webapp/src/Form/Type/LanguageType.php b/webapp/src/Form/Type/LanguageType.php index 5c4aad7977..2f0c70864f 100644 --- a/webapp/src/Form/Type/LanguageType.php +++ b/webapp/src/Form/Type/LanguageType.php @@ -5,6 +5,7 @@ use App\Entity\Contest; use App\Entity\Executable; use App\Entity\Language; +use App\Entity\Problem; use Doctrine\ORM\EntityRepository; use Symfony\Bridge\Doctrine\Form\Type\EntityType; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; @@ -99,6 +100,16 @@ public function buildForm(FormBuilderInterface $builder, array $options): void ->createQueryBuilder('c') ->orderBy('c.name'), ]); + $builder->add('problems', EntityType::class, [ + 'class' => Problem::class, + 'required' => false, + 'choice_label' => 'name', + 'multiple' => true, + 'by_reference' => false, + 'query_builder' => fn(EntityRepository $er) => $er + ->createQueryBuilder('p') + ->orderBy('p.name'), + ]); $builder->add('save', SubmitType::class); // Remove ID field when doing an edit. diff --git a/webapp/src/Form/Type/ProblemType.php b/webapp/src/Form/Type/ProblemType.php index 96d2f18075..c17cfb4ec4 100644 --- a/webapp/src/Form/Type/ProblemType.php +++ b/webapp/src/Form/Type/ProblemType.php @@ -3,6 +3,7 @@ namespace App\Form\Type; use App\Entity\Executable; +use App\Entity\Language; use App\Entity\Problem; use Doctrine\ORM\EntityRepository; use Symfony\Bridge\Doctrine\Form\Type\EntityType; @@ -90,6 +91,13 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'required' => false, 'help' => 'leave empty for default', ]); + $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 for this problem. Leave empty to allow all languages that are enabled for this contest.', + ]); $builder->add('save', SubmitType::class); // Remove clearProblemstatement field when we do not have a problem text. diff --git a/webapp/src/Service/SubmissionService.php b/webapp/src/Service/SubmissionService.php index 52d95befa0..04410c3c25 100644 --- a/webapp/src/Service/SubmissionService.php +++ b/webapp/src/Service/SubmissionService.php @@ -535,10 +535,22 @@ public function submitSolution( throw new BadRequestHttpException('Submissions for contest (temporarily) disabled'); } - $allowedLanguages = $this->dj->getAllowedLanguagesForContest($contest); - if (!in_array($language, $allowedLanguages, true)) { - throw new BadRequestHttpException( - sprintf("Language '%s' not allowed for contest [c%d].", $language->getLangid(), $contest->getCid())); + // If there is a set of languages configured for the problem, it overrides the languages configured for the + // contest / globally. This is useful for restricting problems to be solved in specific languages, e.g. + // output-only problems. + $allowedLanguages = $problem->getProblem()->getLanguages(); + if ($allowedLanguages->isEmpty()) { + $allowedLanguages = $this->dj->getAllowedLanguagesForContest($contest); + if (!in_array($language, $allowedLanguages, strict: true)) { + throw new BadRequestHttpException( + sprintf("Language '%s' not allowed for contest [c%d].", $language->getLangid(), $contest->getCid())); + } + } else { + $allowedLanguages = $allowedLanguages->toArray(); + if (!in_array($language, $allowedLanguages, strict: true)) { + throw new BadRequestHttpException( + sprintf("Language '%s' not allowed for problem [p%d].", $language->getLangid(), $problem->getProbid())); + } } if ($language->getRequireEntryPoint() && empty($entryPoint)) { diff --git a/webapp/templates/jury/problem.html.twig b/webapp/templates/jury/problem.html.twig index a58f7cc4d2..fd1b73dfc3 100644 --- a/webapp/templates/jury/problem.html.twig +++ b/webapp/templates/jury/problem.html.twig @@ -116,6 +116,20 @@ {{ type }} {% endif %} + + Languages + + {% if problem.languages is empty %} + all languages enabled for the corresponding contest + {% else %} + + {% endif %} + +