diff --git a/etc/db-config.yaml b/etc/db-config.yaml index 665a48a6e8..7da81711d8 100644 --- a/etc/db-config.yaml +++ b/etc/db-config.yaml @@ -349,12 +349,12 @@ public: true description: If set, enable teams and jury to send source code to this command. See admin manual for allowed arguments. docdescription: See :ref:`printing` for more information. - - name: event_feed_format + - name: ccs_api_version type: enum - default_value: 2022-07 - enum_class: App\Utils\EventFeedFormat + default_value: 2025-draft + enum_class: App\Utils\CcsApiVersion public: false - description: Format of the event feed to use. See [current draft](https://ccs-specs.icpc.io/draft/contest_api#event-feed) and [versions available](https://ccs-specs.icpc.io/). + description: Version of the CCS API to use for the API and event feed. See [current draft](https://ccs-specs.icpc.io/draft/contest_api#event-feed) and [versions available](https://ccs-specs.icpc.io/). - name: shadow_mode type: bool default_value: false diff --git a/webapp/migrations/Version20250706121413.php b/webapp/migrations/Version20250706121413.php new file mode 100644 index 0000000000..8c929a98d5 --- /dev/null +++ b/webapp/migrations/Version20250706121413.php @@ -0,0 +1,40 @@ +addSql('UPDATE configuration SET name = \'ccs_api_version\' WHERE name = \'event_feed_format\' AND value = \'"2020-03"\''); + $this->addSql('UPDATE configuration SET name = \'ccs_api_version\', value = \'"2023-06"\' WHERE name = \'event_feed_format\' AND value = \'"2022-07"\''); + } + + public function down(Schema $schema): void + { + // Change key and values back to the old format + $this->addSql('UPDATE configuration SET name = \'event_feed_format\' WHERE name = \'ccs_api_version\' AND value = \'"2020-03"\''); + $this->addSql('UPDATE configuration SET name = \'event_feed_format\', value = \'"2022-07"\' WHERE name = \'ccs_api_version\' AND value = \'"2023-06"\''); + // Delete it if we have a non-supported version + $this->addSql('DELETE FROM configuration WHERE name = \'ccs_api_version\''); + } + + public function isTransactional(): bool + { + return false; + } +} diff --git a/webapp/src/Command/ImportEventFeedCommand.php b/webapp/src/Command/ImportEventFeedCommand.php index eee70cc16e..bc3b3a4959 100644 --- a/webapp/src/Command/ImportEventFeedCommand.php +++ b/webapp/src/Command/ImportEventFeedCommand.php @@ -2,7 +2,6 @@ namespace App\Command; -use App\Controller\API\GeneralInfoController as GI; use App\Entity\Contest; use App\Entity\ExternalContestSource; use App\Entity\User; @@ -53,7 +52,6 @@ protected function configure(): void $this ->setHelp( 'Import contest data from an event feed following the Contest API specification:' . PHP_EOL . - GI::CCS_SPEC_API_URL . ' or any version starting from "2021-11"' . PHP_EOL . PHP_EOL . 'Note the following assumptions and caveats:' . PHP_EOL . '- Configuration data will only be verified.' . PHP_EOL . '- Team members will not be imported.' . PHP_EOL . diff --git a/webapp/src/Controller/API/AbstractRestController.php b/webapp/src/Controller/API/AbstractRestController.php index ba52dd120f..8d7cb12da3 100644 --- a/webapp/src/Controller/API/AbstractRestController.php +++ b/webapp/src/Controller/API/AbstractRestController.php @@ -3,6 +3,7 @@ namespace App\Controller\API; use App\Entity\BaseApiEntity; +use App\Utils\CcsApiVersion; use Doctrine\ORM\NonUniqueResultException; use Doctrine\ORM\QueryBuilder; use Symfony\Component\HttpFoundation\BinaryFileResponse; @@ -73,7 +74,9 @@ protected function renderData( $view->getContext()->setAttribute('domjudge_service', $this->dj); $view->getContext()->setAttribute('config_service', $this->config); - $groups = [static::GROUP_DEFAULT]; + /** @var CcsApiVersion $ccsApiVersion */ + $ccsApiVersion = $this->config->get('ccs_api_version'); + $groups = [static::GROUP_DEFAULT, $ccsApiVersion->value]; if (!$request->query->has('strict') || !$request->query->getBoolean('strict')) { $groups[] = static::GROUP_NONSTRICT; } diff --git a/webapp/src/Controller/API/AccessController.php b/webapp/src/Controller/API/AccessController.php index 8f4196983e..c6ec068ce2 100644 --- a/webapp/src/Controller/API/AccessController.php +++ b/webapp/src/Controller/API/AccessController.php @@ -4,6 +4,7 @@ use App\DataTransferObject\Access; use App\DataTransferObject\AccessEndpoint; +use App\Utils\CcsApiVersion; use Doctrine\ORM\NonUniqueResultException; use Doctrine\ORM\NoResultException; use FOS\RestBundle\Controller\Annotations as Rest; @@ -81,6 +82,9 @@ public function getStatusAction(Request $request): Access $submissionsProperties[] = 'files'; } + /** @var CcsApiVersion $ccsApiVersion */ + $ccsApiVersion = $this->config->get('ccs_api_version'); + $capabilities = []; // Add capabilities @@ -90,7 +94,11 @@ public function getStatusAction(Request $request): Access } if ($this->dj->checkrole('team') && $this->dj->getUser()->getTeam()) { $capabilities[] = 'team_submit'; - $capabilities[] = 'team_clar'; + if ($ccsApiVersion->usePostClar()) { + $capabilities[] = 'post_clar'; + } else { + $capabilities[] = 'team_clar'; + } } if ($this->dj->checkrole('api_writer')) { $capabilities[] = 'proxy_submit'; @@ -161,6 +169,9 @@ public function getStatusAction(Request $request): Access 'rgb', 'color', 'time_limit', + 'memory_limit', + 'output_limit', + 'code_limit', 'test_data_count', 'statement', // DOMjudge specific properties: @@ -252,6 +263,7 @@ public function getStatusAction(Request $request): Access 'end_time', 'end_contest_time', 'max_run_time', + 'current', // DOMjudge specific properties: 'valid', ], diff --git a/webapp/src/Controller/API/ContestController.php b/webapp/src/Controller/API/ContestController.php index 8565c31589..ebf0e13826 100644 --- a/webapp/src/Controller/API/ContestController.php +++ b/webapp/src/Controller/API/ContestController.php @@ -13,7 +13,7 @@ use App\Service\DOMJudgeService; use App\Service\EventLogService; use App\Service\ImportExportService; -use App\Utils\EventFeedFormat; +use App\Utils\CcsApiVersion; use App\Utils\Utils; use BadMethodCallException; use Doctrine\Inflector\InflectorFactory; @@ -648,7 +648,8 @@ public function getEventFeedAction( $since_id = -1; } - $format = $this->config->get('event_feed_format'); + /** @var CcsApiVersion $format */ + $format = $this->config->get('ccs_api_version'); $response = new StreamedResponse(); $response->headers->set('X-Accel-Buffering', 'no'); @@ -696,12 +697,16 @@ public function getEventFeedAction( unset($toCheck['teamaffiliations']); unset($toCheck['contestproblems']); + /** @var CcsApiVersion $ccsApiVersion */ + $ccsApiVersion = $this->config->get('ccs_api_version'); + foreach ($toCheck as $plural => $class) { $serializerMetadata = $metadataFactory->getMetadataForClass($class); /** @var PropertyMetadata $propertyMetadata */ foreach ($serializerMetadata->propertyMetadata as $propertyMetadata) { if (is_array($propertyMetadata->groups) && - !in_array('Default', $propertyMetadata->groups)) { + !in_array('Default', $propertyMetadata->groups) && + !in_array($ccsApiVersion->value, $propertyMetadata->groups)) { $skippedProperties[$plural][] = $propertyMetadata->serializedName; } } @@ -835,21 +840,25 @@ public function getEventFeedAction( unset($data[$property]); } } + + $data = $this->eventLogService->applyCcsVersionChanges($event->getEndpointtype(), $data); + switch ($format) { - case EventFeedFormat::Format_2020_03: + case CcsApiVersion::Format_2020_03: $result = [ 'id' => (string)$event->getEventid(), - 'type' => (string)$event->getEndpointtype(), - 'op' => (string)$event->getAction(), + 'type' => $event->getEndpointtype(), + 'op' => $event->getAction(), 'data' => $data, ]; break; - case EventFeedFormat::Format_2022_07: + case CcsApiVersion::Format_2023_06: + case CcsApiVersion::Format_2025_DRAFT: if ($event->getAction() === EventLogService::ACTION_DELETE) { $data = null; } - $id = (string)$event->getEndpointid() ?: null; - $type = (string)$event->getEndpointtype(); + $id = $event->getEndpointid() ?: null; + $type = $event->getEndpointtype(); if ($type === 'contests') { // Special case: the type for a contest is singular and the ID must not be set $id = null; diff --git a/webapp/src/Controller/API/GeneralInfoController.php b/webapp/src/Controller/API/GeneralInfoController.php index f792ece94c..5b0b4b9ddc 100644 --- a/webapp/src/Controller/API/GeneralInfoController.php +++ b/webapp/src/Controller/API/GeneralInfoController.php @@ -13,6 +13,7 @@ use App\Service\DOMJudgeService; use App\Service\EventLogService; use App\Service\ImportProblemService; +use App\Utils\CcsApiVersion; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\NonUniqueResultException; use Doctrine\ORM\NoResultException; @@ -42,8 +43,6 @@ class GeneralInfoController extends AbstractFOSRestController { protected const API_VERSION = 4; - final public const CCS_SPEC_API_VERSION = '2023-06'; - final public const CCS_SPEC_API_URL = 'https://ccs-specs.icpc.io/2023-06/contest_api'; public function __construct( protected readonly EntityManagerInterface $em, @@ -94,9 +93,12 @@ public function getInfoAction( ); } + /** @var CcsApiVersion $ccsApiVersion */ + $ccsApiVersion = $this->config->get('ccs_api_version'); + return new ApiInfo( - version: self::CCS_SPEC_API_VERSION, - versionUrl: self::CCS_SPEC_API_URL, + version: $ccsApiVersion->value, + versionUrl: $ccsApiVersion->getCcsSpecsApiUrl(), name: 'DOMjudge', //TODO: Add DOMjudge logo provider: new ApiInfoProvider( diff --git a/webapp/src/Controller/API/ProblemController.php b/webapp/src/Controller/API/ProblemController.php index 61e2ccb39f..162e89d7d6 100644 --- a/webapp/src/Controller/API/ProblemController.php +++ b/webapp/src/Controller/API/ProblemController.php @@ -476,7 +476,13 @@ public function transformObject($object): ContestProblem|ContestProblemWrapper $problem = $object[0]; $testDataCount = (int)$object['testdatacount']; if ($this->dj->checkrole('jury')) { - return new ContestProblemWrapper($problem, $testDataCount); + return new ContestProblemWrapper( + $problem, + (int)round(($problem->getProblem()->getMemlimit() === null ? $this->config->get('memory_limit') : $problem->getProblem()->getMemlimit()) / 1024), + (int)round(($problem->getProblem()->getOutputlimit() === null ? $this->config->get('output_limit') : $problem->getProblem()->getOutputlimit()) / 1024), + $this->config->get('sourcesize_limit'), + $testDataCount, + ); } else { return $problem; } diff --git a/webapp/src/Controller/API/ScoreboardController.php b/webapp/src/Controller/API/ScoreboardController.php index 51f0c23a3c..7b06f23569 100644 --- a/webapp/src/Controller/API/ScoreboardController.php +++ b/webapp/src/Controller/API/ScoreboardController.php @@ -13,6 +13,7 @@ use App\Service\DOMJudgeService; use App\Service\EventLogService; use App\Service\ScoreboardService; +use App\Utils\CcsApiVersion; use App\Utils\Scoreboard\Filter; use App\Utils\Utils; use Doctrine\ORM\EntityManagerInterface; @@ -20,6 +21,7 @@ use FOS\RestBundle\Controller\Annotations as Rest; use Nelmio\ApiDocBundle\Attribute\Model; use OpenApi\Attributes as OA; +use Symfony\Component\Form\FormFactoryInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Attribute\MapQueryParameter; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; @@ -39,7 +41,7 @@ public function __construct( DOMJudgeService $DOMJudgeService, ConfigurationService $config, EventLogService $eventLogService, - protected readonly ScoreboardService $scoreboardService + protected readonly ScoreboardService $scoreboardService, ) { parent::__construct($entityManager, $DOMJudgeService, $config, $eventLogService); } @@ -169,6 +171,8 @@ public function getScoreboardAction( } $scoreIsInSeconds = (bool)$this->config->get('score_in_seconds'); + /** @var CcsApiVersion $ccsApiVersion */ + $ccsApiVersion = $this->config->get('ccs_api_version'); foreach ($scoreboard->getScores() as $teamScore) { if ($teamScore->team->getCategory()->getSortorder() !== $sortorder) { @@ -183,10 +187,12 @@ public function getScoreboardAction( } else { $score = new Score( numSolved: $teamScore->numPoints, - totalTime: $teamScore->totalTime, + totalTime: $this->formatTime($teamScore->totalTime, $ccsApiVersion, $scoreIsInSeconds), ); } + $lastProblemTime = null; + $problems = []; foreach ($scoreboard->getMatrix()[$teamScore->team->getTeamid()] as $problemId => $matrixItem) { $contestProblem = $scoreboard->getProblems()[$problemId]; @@ -206,13 +212,19 @@ public function getScoreboardAction( } else { $problem->firstToSolve = $matrixItem->isCorrect && $scoreboard->solvedFirst($teamScore->team, $contestProblem); if ($matrixItem->isCorrect) { - $problem->time = Utils::scoretime($matrixItem->time, $scoreIsInSeconds); + $problemTime = Utils::scoretime($matrixItem->time, $scoreIsInSeconds); + $problem->time = $this->formatTime($problemTime, $ccsApiVersion, $scoreIsInSeconds); + $lastProblemTime = max($lastProblemTime, $problemTime); } } $problems[] = $problem; } + if ($lastProblemTime !== null) { + $score->time = $this->formatTime($lastProblemTime, $ccsApiVersion, $scoreIsInSeconds); + } + usort($problems, fn(Problem $a, Problem $b) => $a->label <=> $b->label); $row = new Row( @@ -227,4 +239,16 @@ public function getScoreboardAction( return $results; } + + protected function formatTime( + int $time, + CcsApiVersion $ccsApiVersion, + bool $scoreIsInSeconds + ): int|string { + if ($ccsApiVersion->useRelTimes()) { + return $scoreIsInSeconds ? Utils::relTime($time) : Utils::relTime($time * 60); + } else { + return $time; + } + } } diff --git a/webapp/src/Controller/Jury/JuryMiscController.php b/webapp/src/Controller/Jury/JuryMiscController.php index 8d3913989f..6b845f1742 100644 --- a/webapp/src/Controller/Jury/JuryMiscController.php +++ b/webapp/src/Controller/Jury/JuryMiscController.php @@ -15,6 +15,7 @@ use App\Service\DOMJudgeService; use App\Service\EventLogService; use App\Service\ScoreboardService; +use App\Utils\CcsApiVersion; use App\Utils\Utils; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Query\Expr\Join; @@ -39,6 +40,7 @@ class JuryMiscController extends BaseController public function __construct( EntityManagerInterface $em, DOMJudgeService $dj, + protected readonly ConfigurationService $config, protected readonly EventLogService $eventLogService, protected readonly RequestStack $requestStack, KernelInterface $kernel, @@ -57,9 +59,12 @@ public function indexAction(ConfigurationService $config): Response } } + /** @var CcsApiVersion $ccsApiVersion */ + $ccsApiVersion = $this->config->get('ccs_api_version'); + return $this->render('jury/index.html.twig', [ 'adminer_enabled' => $config->get('adminer_enabled'), - 'CCS_SPEC_API_URL' => GI::CCS_SPEC_API_URL, + 'CCS_SPEC_API_URL' => $ccsApiVersion->getCcsSpecsApiUrl(), ]); } diff --git a/webapp/src/DataTransferObject/ContestProblemWrapper.php b/webapp/src/DataTransferObject/ContestProblemWrapper.php index 177b5c06f9..6bc47597e3 100644 --- a/webapp/src/DataTransferObject/ContestProblemWrapper.php +++ b/webapp/src/DataTransferObject/ContestProblemWrapper.php @@ -2,6 +2,7 @@ namespace App\DataTransferObject; +use App\Controller\API\AbstractRestController as ARC; use App\Entity\ContestProblem; use JMS\Serializer\Annotation as Serializer; @@ -10,6 +11,12 @@ class ContestProblemWrapper public function __construct( #[Serializer\Inline] protected readonly ContestProblem $contestProblem, + #[Serializer\Groups([ARC::GROUP_NONSTRICT, '2025-draft'])] + protected readonly int $memoryLimit, + #[Serializer\Groups([ARC::GROUP_NONSTRICT, '2025-draft'])] + protected readonly int $outputLimit, + #[Serializer\Groups([ARC::GROUP_NONSTRICT, '2025-draft'])] + protected readonly int $codeLimit, #[Serializer\SerializedName('test_data_count')] protected readonly int $testDataCount ) {} diff --git a/webapp/src/DataTransferObject/Scoreboard/Problem.php b/webapp/src/DataTransferObject/Scoreboard/Problem.php index b83613aa06..a75f50e6b0 100644 --- a/webapp/src/DataTransferObject/Scoreboard/Problem.php +++ b/webapp/src/DataTransferObject/Scoreboard/Problem.php @@ -15,7 +15,7 @@ public function __construct( public readonly int $numPending, public readonly bool $solved, #[Serializer\Exclude(if: 'object.time === null')] - public ?int $time = null, + public int|string|null $time = null, #[Serializer\Groups([ARC::GROUP_NONSTRICT])] #[Serializer\Exclude(if: 'object.firstToSolve === null')] public ?bool $firstToSolve = null, diff --git a/webapp/src/DataTransferObject/Scoreboard/Score.php b/webapp/src/DataTransferObject/Scoreboard/Score.php index aa5aadddd5..dc3b4d16f5 100644 --- a/webapp/src/DataTransferObject/Scoreboard/Score.php +++ b/webapp/src/DataTransferObject/Scoreboard/Score.php @@ -10,7 +10,9 @@ class Score public function __construct( public readonly int $numSolved, #[Serializer\Exclude(if: 'object.totalTime === null')] - public readonly ?int $totalTime = null, + public readonly int|string|null $totalTime = null, + #[Serializer\Exclude(if: 'object.time === null')] + public int|string|null $time = null, #[Serializer\Exclude(if: 'object.totalRuntime === null')] #[Serializer\Groups([ARC::GROUP_NONSTRICT])] public readonly ?int $totalRuntime = null, diff --git a/webapp/src/DataTransferObject/Shadowing/ContestData.php b/webapp/src/DataTransferObject/Shadowing/ContestData.php index 8d817d15f2..f4c89da0f9 100644 --- a/webapp/src/DataTransferObject/Shadowing/ContestData.php +++ b/webapp/src/DataTransferObject/Shadowing/ContestData.php @@ -9,7 +9,7 @@ public function __construct( public readonly string $name, public readonly string $duration, public readonly ?string $scoreboardFreezeDuration, - public readonly int $penaltyTime, + public readonly int|string $penaltyTime, public readonly ?string $startTime, // TODO: check for end time and scoreboard thaw time ) {} diff --git a/webapp/src/DataTransferObject/Shadowing/ContestEvent.php b/webapp/src/DataTransferObject/Shadowing/ContestEvent.php index 6f8dbabbe2..0f22c4fe46 100644 --- a/webapp/src/DataTransferObject/Shadowing/ContestEvent.php +++ b/webapp/src/DataTransferObject/Shadowing/ContestEvent.php @@ -9,7 +9,7 @@ public function __construct( public readonly string $name, public readonly string $duration, public readonly ?string $scoreboardType, - public readonly int $penaltyTime, + public readonly int|string $penaltyTime, public readonly ?string $formalName, public readonly ?string $startTime, public readonly ?string $countdownPauseTime, diff --git a/webapp/src/Entity/Contest.php b/webapp/src/Entity/Contest.php index 03a7207594..52c57c0994 100644 --- a/webapp/src/Entity/Contest.php +++ b/webapp/src/Entity/Contest.php @@ -422,7 +422,7 @@ public function getScoreboardTypeString(): string private Collection $externalContestSources; #[Serializer\SerializedName('penalty_time')] - private ?int $penaltyTimeForApi = null; + private int|string|null $penaltyTimeForApi = null; // This field gets filled by the contest visitor with a data transfer // object that represents the banner @@ -1583,13 +1583,13 @@ public function getContestProblemsetType(): ?string return $this->contestProblemsetType; } - public function setPenaltyTimeForApi(?int $penaltyTimeForApi): Contest + public function setPenaltyTimeForApi(int|string|null $penaltyTimeForApi): Contest { $this->penaltyTimeForApi = $penaltyTimeForApi; return $this; } - public function getPenaltyTimeForApi(): ?int + public function getPenaltyTimeForApi(): int|string|null { return $this->penaltyTimeForApi; } diff --git a/webapp/src/Entity/Judging.php b/webapp/src/Entity/Judging.php index da14eff906..9bf5ea4f29 100644 --- a/webapp/src/Entity/Judging.php +++ b/webapp/src/Entity/Judging.php @@ -335,6 +335,14 @@ public function getValid(): bool return $this->valid; } + // Note: we can't use CCSApiVersion::Format_2025_DRAFT->value in PHP 8.1, only in 8.2+ + #[Serializer\Groups([ARC::GROUP_NONSTRICT, '2025-draft'])] + #[Serializer\VirtualProperty] + public function getCurrent(): bool + { + return $this->getValid(); + } + /** * @param resource|string $outputCompile */ diff --git a/webapp/src/NelmioApiDocBundle/JMSModelDescriber.php b/webapp/src/NelmioApiDocBundle/JMSModelDescriber.php new file mode 100644 index 0000000000..2629e62c5e --- /dev/null +++ b/webapp/src/NelmioApiDocBundle/JMSModelDescriber.php @@ -0,0 +1,79 @@ +decorated->describe($model, $schema); + + /** @var CcsApiVersion $ccsApiVersion */ + $ccsApiVersion = $this->config->get('ccs_api_version'); + + if ($model->getType()->getClassName() === Contest::class) { + $this->setRelTimeProperty($schema, ['penalty_time'], $ccsApiVersion); + } elseif ($model->getType()->getClassName() === Score::class) { + $this->setRelTimeProperty($schema, ['total_time', 'time'], $ccsApiVersion); + } elseif ($model->getType()->getClassName() === Problem::class) { + $this->setRelTimeProperty($schema, ['time'], $ccsApiVersion); + } + } + + public function supports(Model $model): bool + { + return $this->decorated->supports($model); + } + + public function setModelRegistry(ModelRegistry $modelRegistry): void + { + $this->decorated->setModelRegistry($modelRegistry); + } + + /** + * @param list $propertiesToSet + */ + protected function setRelTimeProperty( + Schema $schema, + array $propertiesToSet, + CcsApiVersion $ccsApiVersion, + ): void { + foreach ($schema->properties as $property) { + if (!in_array($property->property, $propertiesToSet, true)) { + continue; + } + + if ($ccsApiVersion->useRelTimes()) { + $property->type = ' string'; + } else { + $property->type = ' integer'; + } + $property->nullable = true; + $property->ref = Generator::UNDEFINED; + /** @phpstan-ignore assign.propertyType */ + $property->oneOf = Generator::UNDEFINED; + } + } +} diff --git a/webapp/src/Serializer/ContestVisitor.php b/webapp/src/Serializer/ContestVisitor.php index b39e3a7fae..fff2485151 100644 --- a/webapp/src/Serializer/ContestVisitor.php +++ b/webapp/src/Serializer/ContestVisitor.php @@ -7,6 +7,7 @@ use App\Entity\Contest; use App\Service\ConfigurationService; use App\Service\DOMJudgeService; +use App\Utils\CcsApiVersion; use App\Utils\Utils; use JMS\Serializer\EventDispatcher\Events; use JMS\Serializer\EventDispatcher\EventSubscriberInterface; @@ -41,12 +42,14 @@ public function onPreSerialize(ObjectEvent $event): void /** @var Contest $contest */ $contest = $event->getObject(); - $property = new StaticPropertyMetadata( - Contest::class, - 'penalty_time', - null - ); - $contest->setPenaltyTimeForApi((int)$this->config->get('penalty_time')); + $penaltyTime = $this->config->get('penalty_time'); + /** @var CcsApiVersion $ccsApiVersion */ + $ccsApiVersion = $this->config->get('ccs_api_version'); + if ($ccsApiVersion->useRelTimes()) { + $contest->setPenaltyTimeForApi(Utils::relTime((int)$penaltyTime * 60, true)); + } else { + $contest->setPenaltyTimeForApi((int)$penaltyTime); + } $id = $contest->getExternalid(); diff --git a/webapp/src/Serializer/Shadowing/EventDenormalizer.php b/webapp/src/Serializer/Shadowing/EventDenormalizer.php index 1d8eeb8d31..27f4e2d2f3 100644 --- a/webapp/src/Serializer/Shadowing/EventDenormalizer.php +++ b/webapp/src/Serializer/Shadowing/EventDenormalizer.php @@ -6,7 +6,7 @@ use App\DataTransferObject\Shadowing\EventData; use App\DataTransferObject\Shadowing\EventType; use App\DataTransferObject\Shadowing\Operation; -use App\Utils\EventFeedFormat; +use App\Utils\CcsApiVersion; use Symfony\Component\Serializer\Exception\ExceptionInterface; use Symfony\Component\Serializer\Exception\InvalidArgumentException; use Symfony\Component\Serializer\Exception\LogicException; @@ -50,7 +50,23 @@ public function denormalize( } $eventType = EventType::fromString($data['type']); - if ($this->getEventFeedFormat($data, $context) === EventFeedFormat::Format_2022_07) { + if ($this->getCcsApiVersion($data, $context) === CcsApiVersion::Format_2020_03) { + $operation = Operation::from($data['op']); + if ($operation === Operation::DELETE) { + $eventData = []; + } elseif ($eventType->getEventClass() === null) { + $eventData = []; + } else { + $eventData = [$this->serializer->denormalize($data['data'], EventData::class, $format, $context + ['event_type' => $eventType])]; + } + return new Event( + $data['id'] ?? null, + $eventType, + $operation, + $data['data']['id'] ?? null, + $eventData, + ); + } else { $operation = isset($data['data']) ? Operation::CREATE : Operation::DELETE; if (isset($data['data']) && !isset($data['data'][0])) { $data['data'] = [$data['data']]; @@ -74,22 +90,6 @@ public function denormalize( $id, $eventData, ); - } else { - $operation = Operation::from($data['op']); - if ($operation === Operation::DELETE) { - $eventData = []; - } elseif ($eventType->getEventClass() === null) { - $eventData = []; - } else { - $eventData = [$this->serializer->denormalize($data['data'], EventData::class, $format, $context + ['event_type' => $eventType])]; - } - return new Event( - $data['id'] ?? null, - $eventType, - $operation, - $data['data']['id'] ?? null, - $eventData, - ); } } @@ -124,12 +124,13 @@ public function getSupportedTypes(?string $format): array * @param array{op?: string} $event * @param array{api_version?: string} $context */ - protected function getEventFeedFormat(array $event, array $context): EventFeedFormat + protected function getCcsApiVersion(array $event, array $context): CcsApiVersion { return match ($context['api_version']) { - '2020-03', '2021-11' => EventFeedFormat::Format_2020_03, - '2022-07', '2023-06' => EventFeedFormat::Format_2022_07, - default => isset($event['op']) ? EventFeedFormat::Format_2020_03 : EventFeedFormat::Format_2022_07, + '2020-03' => CcsApiVersion::Format_2020_03, + '2021-11', '2022-07', '2023-06' => CcsApiVersion::Format_2023_06, + '2025-draft' => CcsApiVersion::Format_2025_DRAFT, + default => isset($event['op']) ? CcsApiVersion::Format_2020_03 : CcsApiVersion::Format_2025_DRAFT, }; } } diff --git a/webapp/src/Serializer/SubmissionVisitor.php b/webapp/src/Serializer/SubmissionVisitor.php index e7f935ada1..573ccab229 100644 --- a/webapp/src/Serializer/SubmissionVisitor.php +++ b/webapp/src/Serializer/SubmissionVisitor.php @@ -45,11 +45,6 @@ public function onPreSerialize(ObjectEvent $event): void 'id' => $submission->getExternalid(), ] ); - $property = new StaticPropertyMetadata( - Submission::class, - 'files', - null - ); $submission->setFileForApi(new FileWithName( href: $route, mime: 'application/zip', diff --git a/webapp/src/Service/EventLogService.php b/webapp/src/Service/EventLogService.php index e400701c01..270c7deb25 100644 --- a/webapp/src/Service/EventLogService.php +++ b/webapp/src/Service/EventLogService.php @@ -12,6 +12,7 @@ use App\Entity\TeamAffiliation; use App\Entity\TeamCategory; use App\Entity\User; +use App\Utils\CcsApiVersion; use App\Utils\Utils; use BadMethodCallException; use Doctrine\Inflector\InflectorFactory; @@ -123,7 +124,7 @@ public function __construct( } if (!array_key_exists(self::KEY_TABLES, $data)) { $this->apiEndpoints[$endpoint][self::KEY_TABLES] = [ - preg_replace('/s$/', '', $endpoint) + preg_replace('/s$/', '', $endpoint), ]; } @@ -557,7 +558,7 @@ protected function insertEvents( $existingEvent = $existingEvents[$event->getEndpointid()] ?? null; $existingData = $existingEvent === null ? null : - Utils::jsonEncode($existingEvent->getContent()); + Utils::jsonEncode($this->applyCcsVersionChanges($endpointType, $existingEvent->getContent())); $data = Utils::jsonEncode($event->getContent()); if ($existingEvent === null || $existingData !== $data) { // Special case for state: this is always an update event @@ -849,4 +850,27 @@ public function endpointForEntity($entity): ?string return null; } + + /** + * @param array $event + * + * @return array + */ + public function applyCcsVersionChanges(string $endpointType, array $event): array + { + /** @var CcsApiVersion $ccsApiVersion */ + $ccsApiVersion = $this->config->get('ccs_api_version'); + + if ($endpointType == 'contests') { + $penaltyTime = $event['penalty_time']; + $penaltyTimeIsRelative = is_string($penaltyTime) && Utils::isRelTime($penaltyTime); + if ($ccsApiVersion->useRelTimes() && !$penaltyTimeIsRelative) { + $event['penalty_time'] = Utils::relTime($penaltyTime * 60, true); + } elseif (!$ccsApiVersion->useRelTimes() && $penaltyTimeIsRelative) { + $event['penalty_time'] = (int)floor(Utils::relTimeToSeconds($penaltyTime) / 60); + } + } + + return $event; + } } diff --git a/webapp/src/Service/ExternalContestSourceService.php b/webapp/src/Service/ExternalContestSourceService.php index 7970b6b134..051cf5f948 100644 --- a/webapp/src/Service/ExternalContestSourceService.php +++ b/webapp/src/Service/ExternalContestSourceService.php @@ -760,6 +760,9 @@ protected function validateAndUpdateContest(Event $event, EventData $data): void // Also compare the penalty time $penaltyTime = $data->penaltyTime; + if (is_string($penaltyTime) && Utils::isRelTime($penaltyTime)) { + $penaltyTime = (int)floor(Utils::relTimeToSeconds($penaltyTime) / 60); + } if ($this->config->get('penalty_time') != $penaltyTime) { $this->logger->warning( 'Penalty time does not match between feed (%d) and local (%d)', diff --git a/webapp/src/Utils/CcsApiVersion.php b/webapp/src/Utils/CcsApiVersion.php new file mode 100644 index 0000000000..70455a2ac0 --- /dev/null +++ b/webapp/src/Utils/CcsApiVersion.php @@ -0,0 +1,43 @@ + '`2020-03` version', + self::Format_2023_06 => '`2023-06` version, backwards compatible with `2022-07`', + self::Format_2025_DRAFT => '`2025-draft` version', + }; + } + + public function getCcsSpecsApiUrl(): string + { + return match ($this) { + self::Format_2025_DRAFT => 'https://ccs-specs.icpc.io/draft/contest_api', + default => sprintf('https://ccs-specs.icpc.io/%s/contest_api', $this->value) + }; + } + + public function useRelTimes(): bool + { + return match ($this) { + self::Format_2020_03, self::Format_2023_06 => false, + default => true, + }; + } + + public function usePostClar(): bool + { + return match ($this) { + self::Format_2020_03, self::Format_2023_06 => false, + default => true, + }; + } +} diff --git a/webapp/src/Utils/EventFeedFormat.php b/webapp/src/Utils/EventFeedFormat.php deleted file mode 100644 index 8d12460b73..0000000000 --- a/webapp/src/Utils/EventFeedFormat.php +++ /dev/null @@ -1,17 +0,0 @@ - 'Legacy format in use until the `2020-03` version', - self::Format_2022_07 => 'New format in use since the `2022-07` version', - }; - } -} diff --git a/webapp/tests/Unit/Controller/API/AccessControllerTest.php b/webapp/tests/Unit/Controller/API/AccessControllerTest.php index 3831faa8a9..5f10bdf774 100644 --- a/webapp/tests/Unit/Controller/API/AccessControllerTest.php +++ b/webapp/tests/Unit/Controller/API/AccessControllerTest.php @@ -22,7 +22,7 @@ public function testAccessAsAdmin(): void $access = $this->verifyApiJsonResponse('GET', $url, 200, 'admin'); self::assertArrayHasKey('capabilities', $access); self::assertSame( - ['contest_start', 'contest_thaw', 'team_submit', 'team_clar', 'proxy_submit', 'proxy_clar', 'admin_submit', 'admin_clar'], + ['contest_start', 'contest_thaw', 'team_submit', 'post_clar', 'proxy_submit', 'proxy_clar', 'admin_submit', 'admin_clar'], $access['capabilities'] ); @@ -32,14 +32,14 @@ public function testAccessAsAdmin(): void 'contest' => ['id', 'name', 'formal_name', 'start_time', 'duration', 'scoreboard_freeze_duration', 'penalty_time'], 'judgement-types' => ['id', 'name', 'penalty', 'solved'], 'languages' => ['id', 'name', 'entry_point_required', 'entry_point_name', 'extensions'], - 'problems' => ['id', 'label', 'name', 'ordinal', 'rgb', 'color', 'time_limit', 'test_data_count', 'statement'], + 'problems' => ['id', 'label', 'name', 'ordinal', 'rgb', 'color', 'time_limit', 'memory_limit', 'output_limit', 'code_limit', 'test_data_count', 'statement'], 'groups' => ['id', 'icpc_id', 'name', 'hidden'], 'organizations' => ['id', 'icpc_id', 'name', 'formal_name', 'country', 'country_flag'], 'teams' => ['id', 'icpc_id', 'name', 'display_name', 'organization_id', 'group_ids'], 'accounts' => ['id', 'username', 'name', 'type'], 'state' => ['started', 'frozen', 'ended', 'thawed', 'finalized', 'end_of_updates'], 'submissions' => ['id', 'language_id', 'problem_id', 'team_id', 'time', 'contest_time', 'entry_point', 'files'], - 'judgements' => ['id', 'submission_id', 'judgement_type_id', 'start_time', 'start_contest_time', 'end_time', 'end_contest_time', 'max_run_time'], + 'judgements' => ['id', 'submission_id', 'judgement_type_id', 'start_time', 'start_contest_time', 'end_time', 'end_contest_time', 'max_run_time', 'current'], 'runs' => ['id', 'judgement_id', 'ordinal', 'judgement_type_id', 'time', 'contest_time', 'run_time'], 'clarifications' => ['id', 'from_team_id', 'problem_id', 'text', 'time', 'contest_time'], 'awards' => ['id', 'citation', 'team_ids'], diff --git a/webapp/tests/Unit/Controller/API/ContestControllerTest.php b/webapp/tests/Unit/Controller/API/ContestControllerTest.php index ae937d858e..29d05eb4ca 100644 --- a/webapp/tests/Unit/Controller/API/ContestControllerTest.php +++ b/webapp/tests/Unit/Controller/API/ContestControllerTest.php @@ -11,7 +11,7 @@ class ContestControllerTest extends BaseTestCase protected array $expectedObjects = [ 'demo' => [ 'formal_name' => 'Demo contest', - 'penalty_time' => 20, + 'penalty_time' => '0:20:00', // 'start_time' => '2021-01-01T11:00:00+00:00', // 'end_time' => '2024-01-01T16:00:00+00:00', 'duration' => '5:00:00.000', diff --git a/webapp/tests/Unit/Controller/API/GeneralInfoControllerTest.php b/webapp/tests/Unit/Controller/API/GeneralInfoControllerTest.php index 42a64aead5..eb170df75c 100644 --- a/webapp/tests/Unit/Controller/API/GeneralInfoControllerTest.php +++ b/webapp/tests/Unit/Controller/API/GeneralInfoControllerTest.php @@ -34,8 +34,8 @@ public function testInfoReturnsVariables(): void static::assertIsArray($response); static::assertCount(5, $response); - static::assertEquals(GeneralInfoController::CCS_SPEC_API_VERSION, $response['version']); - static::assertEquals(GeneralInfoController::CCS_SPEC_API_URL, $response['version_url']); + static::assertEquals('2025-draft', $response['version']); + static::assertEquals('https://ccs-specs.icpc.io/draft/contest_api', $response['version_url']); static::assertEquals('DOMjudge', $response['name']); static::assertMatchesRegularExpression('/^\d+\.\d+\.\d+/', $response['domjudge']['version']); static::assertEquals('test', $response['domjudge']['environment']); diff --git a/webapp/tests/Unit/Controller/API/ProblemControllerAdminTest.php b/webapp/tests/Unit/Controller/API/ProblemControllerAdminTest.php index f9f2da9a96..1c11c5594b 100644 --- a/webapp/tests/Unit/Controller/API/ProblemControllerAdminTest.php +++ b/webapp/tests/Unit/Controller/API/ProblemControllerAdminTest.php @@ -18,6 +18,11 @@ protected function setUp(): void $this->expectedObjects['boolfind']['test_data_count'] = 10; $this->expectedObjects['fltcmp']['test_data_count'] = 1+3; // 1 sample, 3 secret cases $this->expectedObjects['hello']['test_data_count'] = 1; + foreach (array_keys($this->expectedObjects) as $problemId) { + $this->expectedObjects[$problemId]['memory_limit'] = 2048; + $this->expectedObjects[$problemId]['output_limit'] = 8; + $this->expectedObjects[$problemId]['code_limit'] = 256; + } parent::setUp(); } diff --git a/webapp/tests/Unit/Serializer/Shadowing/EventDenormalizerTest.php b/webapp/tests/Unit/Serializer/Shadowing/EventDenormalizerTest.php index dba6baeb90..7c2339e2bb 100644 --- a/webapp/tests/Unit/Serializer/Shadowing/EventDenormalizerTest.php +++ b/webapp/tests/Unit/Serializer/Shadowing/EventDenormalizerTest.php @@ -7,6 +7,7 @@ use App\DataTransferObject\Shadowing\LanguageEvent; use App\DataTransferObject\Shadowing\Operation; use App\DataTransferObject\Shadowing\SubmissionEvent; +use App\Utils\CcsApiVersion; use Generator; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Symfony\Component\Serializer\SerializerInterface; @@ -57,84 +58,86 @@ public function testDenormalizeDoNotUseContext( public function provideDenormalize(): Generator { - yield '2022-07 format, create/update single' => [ - [ - 'type' => 'submissions', - 'token' => 'sometoken', - 'data' => [ - 'id' => '123', - 'language_id' => 'cpp', - 'problem_id' => 'A', - 'team_id' => '1', - 'time' => '456', - 'files' => [], + foreach ([CcsApiVersion::Format_2023_06, CcsApiVersion::Format_2025_DRAFT] as $version) { + yield $version->value . ' format, create/update single' => [ + [ + 'type' => 'submissions', + 'token' => 'sometoken', + 'data' => [ + 'id' => '123', + 'language_id' => 'cpp', + 'problem_id' => 'A', + 'team_id' => '1', + 'time' => '456', + 'files' => [], + ], ], - ], - ['api_version' => '2022-07'], - 'sometoken', - EventType::SUBMISSIONS, - Operation::CREATE, - '123', - [ - new SubmissionEvent( - id: '123', - languageId: 'cpp', - problemId: 'A', - teamId: '1', - time: '456', - entryPoint: null, - files: [] - ), - ], - ]; - yield '2022-07 format, create/update unknown class' => [ - [ - 'type' => 'team-members', - 'token' => 'sometoken', - 'data' => [ - ['id' => '123'], + ['api_version' => $version], + 'sometoken', + EventType::SUBMISSIONS, + Operation::CREATE, + '123', + [ + new SubmissionEvent( + id: '123', + languageId: 'cpp', + problemId: 'A', + teamId: '1', + time: '456', + entryPoint: null, + files: [] + ), ], - ], - ['api_version' => '2022-07'], - 'sometoken', - EventType::TEAM_MEMBERS, - Operation::CREATE, - '123', - [], - ]; - yield '2022-07 format, create/update multiple' => [ - [ - 'type' => 'languages', - 'token' => 'anothertoken', - 'data' => [ - ['id' => 'cpp'], - ['id' => 'java'], + ]; + yield $version->value . ' format, create/update unknown class' => [ + [ + 'type' => 'team-members', + 'token' => 'sometoken', + 'data' => [ + ['id' => '123'], + ], ], - ], - ['api_version' => '2022-07'], - 'anothertoken', - EventType::LANGUAGES, - Operation::CREATE, - null, - [ - new LanguageEvent(id: 'cpp'), - new LanguageEvent(id: 'java'), - ], - ]; - yield '2022-07 format, delete' => [ - [ - 'type' => 'problems', - 'id' => '987', - 'token' => 'yetanothertoken', - 'data' => null, - ], - ['api_version' => '2022-07'], - 'yetanothertoken', - EventType::PROBLEMS, - Operation::DELETE, - '987', - [], - ]; + ['api_version' => $version], + 'sometoken', + EventType::TEAM_MEMBERS, + Operation::CREATE, + '123', + [], + ]; + yield $version->value . ' format, create/update multiple' => [ + [ + 'type' => 'languages', + 'token' => 'anothertoken', + 'data' => [ + ['id' => 'cpp'], + ['id' => 'java'], + ], + ], + ['api_version' => $version], + 'anothertoken', + EventType::LANGUAGES, + Operation::CREATE, + null, + [ + new LanguageEvent(id: 'cpp'), + new LanguageEvent(id: 'java'), + ], + ]; + yield $version->value . ' format, delete' => [ + [ + 'type' => 'problems', + 'id' => '987', + 'token' => 'yetanothertoken', + 'data' => null, + ], + ['api_version' => $version->value], + 'yetanothertoken', + EventType::PROBLEMS, + Operation::DELETE, + '987', + [], + ]; + } yield '2020-03 format, create' => [ [ 'id' => 'sometoken', @@ -149,7 +152,7 @@ public function provideDenormalize(): Generator 'files' => [], ], ], - ['api_version' => '2020-03'], + ['api_version' => CcsApiVersion::Format_2020_03->value], 'sometoken', EventType::SUBMISSIONS, Operation::CREATE, @@ -175,7 +178,7 @@ public function provideDenormalize(): Generator 'id' => 'cpp', ], ], - ['api_version' => '2020-03'], + ['api_version' => CcsApiVersion::Format_2020_03->value], 'anothertoken', EventType::LANGUAGES, Operation::UPDATE, @@ -193,7 +196,7 @@ public function provideDenormalize(): Generator 'id' => '987', ], ], - ['api_version' => '2020-03'], + ['api_version' => CcsApiVersion::Format_2020_03->value], 'yetanothertoken', EventType::PROBLEMS, Operation::DELETE,