diff --git a/.github/jobs/fix_pipelinecomponents_image.sh b/.github/jobs/fix_pipelinecomponents_image.sh index a34e917b3a..078dba6caf 100755 --- a/.github/jobs/fix_pipelinecomponents_image.sh +++ b/.github/jobs/fix_pipelinecomponents_image.sh @@ -8,10 +8,11 @@ set -eux echo "Set plugin config for version detection" phpcs --config-set installed_paths /app/vendor/phpcompatibility/php-compatibility - + # Current released version does not know enums echo "Upgrade the compatibility for PHP versions" mydir=$(pwd) sed -i 's/"phpcompatibility\/php-compatibility": "9.3.5"/"phpcompatibility\/php-compatibility": "dev-develop"/g' /app/composer.json +sed -i 's/"squizlabs\/php_codesniffer": "3.11.3"/"squizlabs\/php_codesniffer": "^3.13.0"/g' /app/composer.json cd /app; composer update cd $mydir diff --git a/webapp/src/Entity/Contest.php b/webapp/src/Entity/Contest.php index 7cb98b5e3c..03a7207594 100644 --- a/webapp/src/Entity/Contest.php +++ b/webapp/src/Entity/Contest.php @@ -1049,20 +1049,8 @@ public function getAbsoluteTime(?string $time_string): float|int|string|null { if ($time_string === null) { return null; - } elseif (preg_match('/^[+-][0-9]+:[0-9]{2}(:[0-9]{2}(\.[0-9]{0,6})?)?$/', $time_string)) { - $sign = ($time_string[0] == '-' ? -1 : +1); - $time_string[0] = 0; - $times = explode(':', $time_string, 3); - $hours = (int)$times[0]; - $minutes = (int)$times[1]; - if (count($times) == 2) { - $seconds = 0; - } else { - $seconds = (float)$times[2]; - } - $seconds = $seconds + 60 * ($minutes + 60 * $hours); - $seconds *= $sign; - $absoluteTime = $this->starttime + $seconds; + } elseif (Utils::isRelTime($time_string)) { + $absoluteTime = $this->starttime + Utils::relTimeToSeconds($time_string); // Take into account the removed intervals. /** @var RemovedInterval[] $removedIntervals */ diff --git a/webapp/src/Entity/SubmissionSource.php b/webapp/src/Entity/SubmissionSource.php index 7e699ace19..f52dc0540c 100644 --- a/webapp/src/Entity/SubmissionSource.php +++ b/webapp/src/Entity/SubmissionSource.php @@ -10,5 +10,4 @@ enum SubmissionSource: string case SHADOWING = 'shadowing'; case TEAM_PAGE = 'team page'; case UNKNOWN = 'unknown'; - } diff --git a/webapp/src/Service/ImportExportService.php b/webapp/src/Service/ImportExportService.php index dd2de043dc..2b2dcc217b 100644 --- a/webapp/src/Service/ImportExportService.php +++ b/webapp/src/Service/ImportExportService.php @@ -16,8 +16,10 @@ use App\Utils\Scoreboard\Filter; use App\Utils\Utils; use Collator; +use DateInterval; use DateTime; use DateTimeImmutable; +use DateTimeInterface; use DateTimeZone; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\NonUniqueResultException; @@ -83,10 +85,14 @@ public function getContestYamlData(Contest $contest, bool $includeProblems = tru 'formal_name' => $contest->getName(), 'name' => $contest->getShortname(), 'start_time' => Utils::absTime($contest->getStarttime(), true), - 'end_time' => Utils::absTime($contest->getEndtime(), true), + 'end_time' => Utils::isRelTime($contest->getEndtimeString()) + ? $contest->getEndtimeString() + : Utils::absTime($contest->getEndtime(), true), 'duration' => Utils::relTime($contest->getContestTime((float)$contest->getEndtime())), 'penalty_time' => $this->config->get('penalty_time'), - 'activate_time' => Utils::absTime($contest->getActivatetime(), true), + 'activate_time' => Utils::isRelTime($contest->getActivatetimeString()) + ? $contest->getActivatetimeString() + : Utils::absTime($contest->getActivatetime(), true), ]; if ($warnMsg = $contest->getWarningMessage()) { $data['warning_message'] = $warnMsg; @@ -100,13 +106,17 @@ public function getContestYamlData(Contest $contest, bool $includeProblems = tru } if ($contest->getFreezetime() !== null) { - $data['scoreboard_freeze_time'] = Utils::absTime($contest->getFreezetime(), true); + $data['scoreboard_freeze_time'] = Utils::isRelTime($contest->getFreezetimeString()) + ? $contest->getFreezetimeString() + : Utils::absTime($contest->getFreezetime(), true); $data['scoreboard_freeze_duration'] = Utils::relTime( $contest->getContestTime((float)$contest->getEndtime()) - $contest->getContestTime((float)$contest->getFreezetime()), true, ); if ($contest->getUnfreezetime() !== null) { - $data['scoreboard_thaw_time'] = Utils::absTime($contest->getUnfreezetime(), true); + $data['scoreboard_thaw_time'] = Utils::isRelTime($contest->getUnfreezetimeString()) + ? $contest->getUnfreezetimeString() + : Utils::absTime($contest->getUnfreezetime(), true); } } if ($contest->getFinalizetime() !== null) { @@ -114,7 +124,9 @@ public function getContestYamlData(Contest $contest, bool $includeProblems = tru } if ($contest->getDeactivatetime() !== null) { - $data['deactivate_time'] = Utils::absTime($contest->getDeactivatetime(), true); + $data['deactivate_time'] = Utils::isRelTime($contest->getDeactivatetimeString()) + ? $contest->getDeactivatetimeString() + : Utils::absTime($contest->getDeactivatetime(), true); } if ($includeProblems) { @@ -146,9 +158,15 @@ public function getContestYamlData(Contest $contest, bool $includeProblems = tru * * @param array $fields * @param array $data + * @return array{time: DateTimeImmutable|null, isRelative: bool|null, relativeTime: string|null} */ - protected function convertImportedTime(array $fields, array $data, ?string &$errorMessage = null): ?DateTimeImmutable - { + protected function convertImportedTime( + array $fields, + array $data, + bool $allowRelative = true, + ?DateTimeInterface $startTime = null, + ?string &$errorMessage = null + ): array { $timeValue = null; $usedField = null; foreach ($fields as $field) { @@ -160,10 +178,22 @@ protected function convertImportedTime(array $fields, array $data, ?string &$err } } + $isRelative = false; + if (is_string($timeValue)) { - $time = date_create_from_format(DateTime::ISO8601, $timeValue) ?: - // Make sure ISO 8601 but with the T replaced with a space also works. - date_create_from_format('Y-m-d H:i:sO', $timeValue); + if ($allowRelative && ($isRelative = Utils::isRelTime($timeValue)) && $startTime) { + $time = new DateTimeImmutable($startTime->format('Y-m-d H:i:s'), $startTime->getTimezone()); + $seconds = Utils::relTimeToSeconds($timeValue); + if ($seconds < 0) { + $time = $time->sub(new DateInterval(sprintf('PT%sS', abs($seconds)))); + } else { + $time = $time->add(new DateInterval(sprintf('PT%sS', $seconds))); + } + } else { + $time = date_create_from_format(DateTime::ISO8601, $timeValue) ?: + // Make sure ISO 8601 but with the T replaced with a space also works. + date_create_from_format('Y-m-d H:i:sO', $timeValue); + } } else { /** @var DateTime|DateTimeImmutable $time */ $time = $timeValue; @@ -171,11 +201,19 @@ protected function convertImportedTime(array $fields, array $data, ?string &$err // If/When parsing fails we get a false instead of a null if ($time === false) { $errorMessage = 'Can not parse '.$usedField; - return null; + return [ + 'time' => null, + 'isRelative' => null, + 'relativeTime' => null, + ]; } elseif ($time) { $time = $time->setTimezone(new DateTimeZone(date_default_timezone_get())); } - return $time instanceof DateTime ? DateTimeImmutable::createFromMutable($time) : $time; + return [ + 'time' => $time instanceof DateTime ? DateTimeImmutable::createFromMutable($time) : $time, + 'isRelative' => $isRelative, + 'relativeTime' => $isRelative ? $timeValue : null, + ]; } public function importContestData(mixed $data, ?string &$errorMessage = null, ?string &$cid = null): bool @@ -215,13 +253,17 @@ public function importContestData(mixed $data, ?string &$errorMessage = null, ?s $invalid_regex = str_replace(['/^[', '+$/'], ['/[^', '/'], DOMJudgeService::EXTERNAL_IDENTIFIER_REGEX); - $startTime = $this->convertImportedTime($startTimeFields, $data, $errorMessage); + ['time' => $startTime] = $this->convertImportedTime($startTimeFields, $data, false, errorMessage: $errorMessage); if ($errorMessage) { return false; } // Activate time is special, it can return non empty message for parsing error or null if no field was provided - $activateTime = $this->convertImportedTime($activateTimeFields, $data, $errorMessage); + [ + 'time' => $activateTime, + 'isRelative' => $activateTimeIsRelative, + 'relativeTime' => $activateRelativeTime, + ] = $this->convertImportedTime($activateTimeFields, $data, startTime: $startTime, errorMessage: $errorMessage); if ($errorMessage) { return false; } elseif (!$activateTime) { @@ -231,7 +273,11 @@ public function importContestData(mixed $data, ?string &$errorMessage = null, ?s } } - $deactivateTime = $this->convertImportedTime($deactivateTimeFields, $data, $errorMessage); + [ + 'time' => $deactivateTime, + 'isRelative' => $deactivateTimeIsRelative, + 'relativeTime' => $deactivateRelativeTime, + ] = $this->convertImportedTime($deactivateTimeFields, $data, startTime: $startTime, errorMessage: $errorMessage); if ($errorMessage) { return false; } @@ -247,11 +293,11 @@ public function importContestData(mixed $data, ?string &$errorMessage = null, ?s ->setExternalid($contest->getShortname()) ->setWarningMessage($data['warning_message'] ?? $data['warning-message'] ?? null) ->setStarttimeString(date_format($startTime, 'Y-m-d H:i:s e')) - ->setActivatetimeString(date_format($activateTime, 'Y-m-d H:i:s e')) + ->setActivatetimeString($activateTimeIsRelative ? $activateRelativeTime : date_format($activateTime, 'Y-m-d H:i:s e')) ->setEndtimeString(sprintf('+%s', $data['duration'])) ->setPublic($data['public'] ?? true); if ($deactivateTime) { - $contest->setDeactivatetimeString(date_format($deactivateTime, 'Y-m-d H:i:s e')); + $contest->setDeactivatetimeString($deactivateTimeIsRelative ? $deactivateRelativeTime : date_format($deactivateTime, 'Y-m-d H:i:s e')); } // Get all visible categories. For now, we assume these are the ones getting awards diff --git a/webapp/src/Utils/Utils.php b/webapp/src/Utils/Utils.php index 20823b8184..7d0a191a02 100644 --- a/webapp/src/Utils/Utils.php +++ b/webapp/src/Utils/Utils.php @@ -167,7 +167,9 @@ class Utils final public const DAY_IN_SECONDS = 60*60*24; - final public const RELTIME_REGEX = '/^(-)?(\d+):(\d{2}):(\d{2})(?:\.(\d{3}))?$/'; + // Regex to parse relative times. Note that these are our own relative times, which allows + // more than the CLICS spec does + final public const RELTIME_REGEX = '/^([+-])?(\d+):(\d{2})(?::(\d{2})(?:\.(\d+))?)?$/'; /** * Returns the milliseconds part of a time stamp truncated at three digits. @@ -192,6 +194,11 @@ public static function absTime(mixed $epoch, bool $floored = false): ?string . date("P", (int) $epoch); } + public static function isRelTime(string $time): bool + { + return preg_match(self::RELTIME_REGEX, $time) === 1; + } + /** * Prints a time diff as relative time as ([-+])?(h)*h:mm:ss(.uuu)? * (with millis if $floored is false and with + sign only if $includePlus is true). @@ -216,7 +223,7 @@ public static function relTimeToSeconds(string $reltime): float $seconds = $modifier * ( (int)$data[2] * 3600 + (int)$data[3] * 60 - + (float)sprintf('%d.%03d', $data[4], $data[5] ?? 0)); + + (float)sprintf('%d.%03d', $data[4] ?? 0, $data[5] ?? 0)); return $seconds; } diff --git a/webapp/tests/Unit/Controller/API/ContestControllerAdminTest.php b/webapp/tests/Unit/Controller/API/ContestControllerAdminTest.php index 98991795cd..556a107221 100644 --- a/webapp/tests/Unit/Controller/API/ContestControllerAdminTest.php +++ b/webapp/tests/Unit/Controller/API/ContestControllerAdminTest.php @@ -58,14 +58,14 @@ public function testAddYaml(): void name: practice activate_time: '2021-03-27T09:00:00+00:00' start_time: '2021-03-27T09:00:00+00:00' -end_time: '2021-03-27T11:00:00+00:00' +end_time: '+2:00:00' duration: 2:00:00.000 penalty_time: 20 medals: gold: 4 silver: 4 bronze: 4 -scoreboard_freeze_time: '2021-03-27T10:30:00+00:00' +scoreboard_freeze_time: '+01:30:00' scoreboard_freeze_duration: 0:30:00 EOF; diff --git a/webapp/tests/Unit/Controller/Jury/ImportExportControllerTest.php b/webapp/tests/Unit/Controller/Jury/ImportExportControllerTest.php index c81d532efa..0083f5e9ab 100644 --- a/webapp/tests/Unit/Controller/Jury/ImportExportControllerTest.php +++ b/webapp/tests/Unit/Controller/Jury/ImportExportControllerTest.php @@ -82,7 +82,7 @@ public function provideContestYamlContents(): Generator formal_name: 'Demo contest' name: demo start_time: '{$year}-01-01T08:00:00+00:00' -end_time: '{$year}-01-01T13:00:00+00:00' +end_time: '+5:00:00' duration: '5:00:00.000' penalty_time: 20 activate_time: '{$pastYear}-01-01T08:00:00+00:00' @@ -90,7 +90,7 @@ public function provideContestYamlContents(): Generator gold: 4 silver: 4 bronze: 4 -scoreboard_freeze_time: '{$year}-01-01T12:00:00+00:00' +scoreboard_freeze_time: '+04:00:00' scoreboard_freeze_duration: '1:00:00' problems: - @@ -218,25 +218,13 @@ public function testResultsTsvExport( public function provideResultsTsvExport(): Generator { - yield [0, true, true, 'results 1 -exteam Honorable 0 0 0 -']; - yield [0, true, false, 'results 1 -exteam Honorable 0 0 0 -']; - yield [0, false, true, 'results 1 -exteam Honorable 0 0 0 -']; - yield [0, false, true, 'results 1 -exteam Honorable 0 0 0 -']; - yield [1, true, true, 'results 1 -']; - yield [1, true, false, 'results 1 -']; - yield [1, false, true, 'results 1 -']; - yield [1, false, true, 'results 1 -']; + yield [0, true, true, "results 1\nexteam Honorable 0 0 0 \n"]; + yield [0, true, false, "results 1\nexteam Honorable 0 0 0 \n"]; + yield [0, false, true, "results 1\nexteam Honorable 0 0 0 \n"]; + yield [0, false, true, "results 1\nexteam Honorable 0 0 0 \n"]; + yield [1, true, true, "results 1\n"]; + yield [1, true, false, "results 1\n"]; + yield [1, false, true, "results 1\n"]; + yield [1, false, true, "results 1\n"]; } } diff --git a/webapp/tests/Unit/Service/ImportExportServiceTest.php b/webapp/tests/Unit/Service/ImportExportServiceTest.php index e7cc24a9e5..004346df75 100644 --- a/webapp/tests/Unit/Service/ImportExportServiceTest.php +++ b/webapp/tests/Unit/Service/ImportExportServiceTest.php @@ -112,8 +112,13 @@ public function provideImportContestDataErrors(): Generator /** * @dataProvider provideImportContestDataSuccess */ - public function testImportContestDataSuccess(mixed $data, string $expectedShortName, array $expectedProblems = []): void - { + public function testImportContestDataSuccess( + mixed $data, + string $expectedShortName, + string $expectedActivateTimeString, + ?string $expectedDeactivateTimeString, + array $expectedProblems = [] + ): void { /** @var ImportExportService $importExportService */ $importExportService = static::getContainer()->get(ImportExportService::class); self::assertTrue($importExportService->importContestData($data, $message, $cid), 'Importing failed: ' . $message); @@ -125,6 +130,8 @@ public function testImportContestDataSuccess(mixed $data, string $expectedShortN self::assertEquals($data['name'], $contest->getName()); self::assertEquals($data['public'] ?? true, $contest->getPublic()); self::assertEquals($expectedShortName, $contest->getShortname()); + self::assertEquals($expectedActivateTimeString, $contest->getActivatetimeString()); + self::assertEquals($expectedDeactivateTimeString, $contest->getDeactivatetimeString()); $problems = []; /** @var ContestProblem $problem */ @@ -149,6 +156,38 @@ public function provideImportContestDataSuccess(): Generator 'scoreboard-freeze-length' => '1:00:00', ], 'test-contest', + '2020-01-01 10:34:56 UTC', + null, + ]; + // Adding absolute activate and deactivate time + yield [ + [ + 'name' => 'Some test contest', + 'short-name' => 'test-contest', + 'duration' => '5:00:00', + 'start-time' => '2020-01-01T12:34:56+02:00', + 'activate_time' => '2020-01-01T06:34:56+02:00', + 'deactivate_time' => '2020-01-01T18:34:56+02:00', + 'scoreboard-freeze-length' => '1:00:00', + ], + 'test-contest', + '2020-01-01 04:34:56 UTC', + '2020-01-01 16:34:56 UTC', + ]; + // Adding relative activate and deactivate time + yield [ + [ + 'name' => 'Some test contest', + 'short-name' => 'test-contest', + 'duration' => '5:00:00', + 'start-time' => '2020-01-01T12:34:56+02:00', + 'activate_time' => '-6:00', + 'deactivate_time' => '+06:00:00', + 'scoreboard-freeze-length' => '1:00:00', + ], + 'test-contest', + '-6:00', + '+06:00:00', ]; // - Freeze length without hours // - Set a short name with invalid characters @@ -162,6 +201,8 @@ public function provideImportContestDataSuccess(): Generator 'scoreboard-freeze-length' => '30:00', ], 'test-contest__-__test', + '2020-01-01 10:34:56 UTC', + null, ]; // Real life example from NWERC 2020 practice session, including problems. yield [ @@ -195,6 +236,8 @@ public function provideImportContestDataSuccess(): Generator ], ], 'practice', + '2021-03-27 09:00:00 UTC', + null, ['A' => 'anothereruption', 'B' => 'brokengears', 'C' => 'cheating'], ]; @@ -209,6 +252,8 @@ public function provideImportContestDataSuccess(): Generator 'public' => false, ], 'test-contest', + '2020-01-01 10:34:56 UTC', + null, ]; }