Skip to content

Commit

Permalink
Add option to restrict a problem to a set of languages.
Browse files Browse the repository at this point in the history
This is especially useful for output-only problems (part of #2521).
  • Loading branch information
meisterT committed Feb 28, 2025
1 parent 78e48af commit 40847af
Show file tree
Hide file tree
Showing 7 changed files with 148 additions and 4 deletions.
40 changes: 40 additions & 0 deletions webapp/migrations/Version20250228123717.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

declare(strict_types=1);

namespace DoctrineMigrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250228123717 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add option to restrict a problem to a set of languages.';
}

public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->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;
}
}
27 changes: 27 additions & 0 deletions webapp/src/Entity/Language.php
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,13 @@ class Language extends BaseApiEntity implements
#[Serializer\Exclude]
private Collection $contests;

/**
* @var Collection<int, Problem>
*/
#[ORM\ManyToMany(targetEntity: Problem::class, mappedBy: 'languages')]
#[Serializer\Exclude]
private Collection $problems;

/**
* @param Collection<int, Version> $versions
*/
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
}
32 changes: 32 additions & 0 deletions webapp/src/Entity/Problem.php
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,17 @@ class Problem extends BaseApiEntity implements
#[Serializer\Exclude]
private ?FileWithName $statementForApi = null;


/**
* @var Collection<int, Language>
*/
#[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;
Expand Down Expand Up @@ -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();
}

Expand Down Expand Up @@ -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<int, Language>
*/
public function getLanguages(): Collection
{
return $this->languages;
}

public function removeLanguage(Language $language): Problem
{
$this->languages->removeElement($language);
return $this;
}
}
11 changes: 11 additions & 0 deletions webapp/src/Form/Type/LanguageType.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down
8 changes: 8 additions & 0 deletions webapp/src/Form/Type/ProblemType.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down
20 changes: 16 additions & 4 deletions webapp/src/Service/SubmissionService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
14 changes: 14 additions & 0 deletions webapp/templates/jury/problem.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,20 @@
<td>{{ type }}</td>
</tr>
{% endif %}
<tr>
<th>Languages</th>
<td>
{% if problem.languages is empty %}
<em>all languages enabled for the corresponding contest</em>
{% else %}
<ul>
{% for language in problem.languages %}
<li><a href="{{ path('jury_language', {'langId': language.langid}) }}">{{ language.name }}</a></li>
{% endfor %}
</ul>
{% endif %}
</td>
</tr>
</table>
</div>
</div>
Expand Down

0 comments on commit 40847af

Please sign in to comment.