Skip to content

Commit fae9071

Browse files
Allow relative times in contest.yaml
Fixes #2072
1 parent 8932cd1 commit fae9071

File tree

6 files changed

+129
-46
lines changed

6 files changed

+129
-46
lines changed

webapp/src/Entity/SubmissionSource.php

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,4 @@ enum SubmissionSource: string
1010
case SHADOWING = 'shadowing';
1111
case TEAM_PAGE = 'team page';
1212
case UNKNOWN = 'unknown';
13-
1413
}

webapp/src/Service/ImportExportService.php

Lines changed: 63 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,10 @@
1616
use App\Utils\Scoreboard\Filter;
1717
use App\Utils\Utils;
1818
use Collator;
19+
use DateInterval;
1920
use DateTime;
2021
use DateTimeImmutable;
22+
use DateTimeInterface;
2123
use DateTimeZone;
2224
use Doctrine\ORM\EntityManagerInterface;
2325
use Doctrine\ORM\NonUniqueResultException;
@@ -83,10 +85,14 @@ public function getContestYamlData(Contest $contest, bool $includeProblems = tru
8385
'formal_name' => $contest->getName(),
8486
'name' => $contest->getShortname(),
8587
'start_time' => Utils::absTime($contest->getStarttime(), true),
86-
'end_time' => Utils::absTime($contest->getEndtime(), true),
88+
'end_time' => Utils::isRelTime($contest->getEndtimeString())
89+
? $contest->getEndtimeString()
90+
: Utils::absTime($contest->getEndtime(), true),
8791
'duration' => Utils::relTime($contest->getContestTime((float)$contest->getEndtime())),
8892
'penalty_time' => $this->config->get('penalty_time'),
89-
'activate_time' => Utils::absTime($contest->getActivatetime(), true),
93+
'activate_time' => Utils::isRelTime($contest->getActivatetimeString())
94+
? $contest->getActivatetimeString()
95+
: Utils::absTime($contest->getActivatetime(), true),
9096
];
9197
if ($warnMsg = $contest->getWarningMessage()) {
9298
$data['warning_message'] = $warnMsg;
@@ -100,21 +106,27 @@ public function getContestYamlData(Contest $contest, bool $includeProblems = tru
100106
}
101107

102108
if ($contest->getFreezetime() !== null) {
103-
$data['scoreboard_freeze_time'] = Utils::absTime($contest->getFreezetime(), true);
109+
$data['scoreboard_freeze_time'] = Utils::isRelTime($contest->getFreezetimeString())
110+
? $contest->getFreezetimeString()
111+
: Utils::absTime($contest->getFreezetime(), true);
104112
$data['scoreboard_freeze_duration'] = Utils::relTime(
105113
$contest->getContestTime((float)$contest->getEndtime()) - $contest->getContestTime((float)$contest->getFreezetime()),
106114
true,
107115
);
108116
if ($contest->getUnfreezetime() !== null) {
109-
$data['scoreboard_thaw_time'] = Utils::absTime($contest->getUnfreezetime(), true);
117+
$data['scoreboard_thaw_time'] = Utils::isRelTime($contest->getUnfreezetimeString())
118+
? $contest->getUnfreezetimeString()
119+
: Utils::absTime($contest->getUnfreezetime(), true);
110120
}
111121
}
112122
if ($contest->getFinalizetime() !== null) {
113123
$data['finalize_time'] = Utils::absTime($contest->getFinalizetime(), true);
114124
}
115125

116126
if ($contest->getDeactivatetime() !== null) {
117-
$data['deactivate_time'] = Utils::absTime($contest->getDeactivatetime(), true);
127+
$data['deactivate_time'] = Utils::isRelTime($contest->getDeactivatetimeString())
128+
? $contest->getDeactivatetimeString()
129+
: Utils::absTime($contest->getDeactivatetime(), true);
118130
}
119131

120132
if ($includeProblems) {
@@ -146,9 +158,15 @@ public function getContestYamlData(Contest $contest, bool $includeProblems = tru
146158
*
147159
* @param array<string> $fields
148160
* @param array<string, string|DateTime|DateTimeImmutable> $data
161+
* @return array{time: DateTimeImmutable|null, isRelative: bool|null, relativeTime: string|null}
149162
*/
150-
protected function convertImportedTime(array $fields, array $data, ?string &$errorMessage = null): ?DateTimeImmutable
151-
{
163+
protected function convertImportedTime(
164+
array $fields,
165+
array $data,
166+
bool $allowRelative = true,
167+
?DateTimeInterface $startTime = null,
168+
?string &$errorMessage = null
169+
): array {
152170
$timeValue = null;
153171
$usedField = null;
154172
foreach ($fields as $field) {
@@ -160,22 +178,42 @@ protected function convertImportedTime(array $fields, array $data, ?string &$err
160178
}
161179
}
162180

181+
$isRelative = false;
182+
163183
if (is_string($timeValue)) {
164-
$time = date_create_from_format(DateTime::ISO8601, $timeValue) ?:
165-
// Make sure ISO 8601 but with the T replaced with a space also works.
166-
date_create_from_format('Y-m-d H:i:sO', $timeValue);
184+
if ($allowRelative && ($isRelative = Utils::isRelTime($timeValue)) && $startTime) {
185+
$time = new DateTimeImmutable($startTime->format('Y-m-d H:i:s'), $startTime->getTimezone());
186+
$seconds = Utils::relTimeToSeconds($timeValue);
187+
if ($seconds < 0) {
188+
$time = $time->sub(new DateInterval(sprintf('PT%sS', abs($seconds))));
189+
} else {
190+
$time = $time->add(new DateInterval(sprintf('PT%sS', $seconds)));
191+
}
192+
} else {
193+
$time = date_create_from_format(DateTime::ISO8601, $timeValue) ?:
194+
// Make sure ISO 8601 but with the T replaced with a space also works.
195+
date_create_from_format('Y-m-d H:i:sO', $timeValue);
196+
}
167197
} else {
168198
/** @var DateTime|DateTimeImmutable $time */
169199
$time = $timeValue;
170200
}
171201
// If/When parsing fails we get a false instead of a null
172202
if ($time === false) {
173203
$errorMessage = 'Can not parse '.$usedField;
174-
return null;
204+
return [
205+
'time' => null,
206+
'isRelative' => null,
207+
'relativeTime' => null,
208+
];
175209
} elseif ($time) {
176210
$time = $time->setTimezone(new DateTimeZone(date_default_timezone_get()));
177211
}
178-
return $time instanceof DateTime ? DateTimeImmutable::createFromMutable($time) : $time;
212+
return [
213+
'time' => $time instanceof DateTime ? DateTimeImmutable::createFromMutable($time) : $time,
214+
'isRelative' => $isRelative,
215+
'relativeTime' => $isRelative ? $timeValue : null,
216+
];
179217
}
180218

181219
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
215253

216254
$invalid_regex = str_replace(['/^[', '+$/'], ['/[^', '/'], DOMJudgeService::EXTERNAL_IDENTIFIER_REGEX);
217255

218-
$startTime = $this->convertImportedTime($startTimeFields, $data, $errorMessage);
256+
['time' => $startTime] = $this->convertImportedTime($startTimeFields, $data, false, errorMessage: $errorMessage);
219257
if ($errorMessage) {
220258
return false;
221259
}
222260

223261
// Activate time is special, it can return non empty message for parsing error or null if no field was provided
224-
$activateTime = $this->convertImportedTime($activateTimeFields, $data, $errorMessage);
262+
[
263+
'time' => $activateTime,
264+
'isRelative' => $activateTimeIsRelative,
265+
'relativeTime' => $activateRelativeTime,
266+
] = $this->convertImportedTime($activateTimeFields, $data, startTime: $startTime, errorMessage: $errorMessage);
225267
if ($errorMessage) {
226268
return false;
227269
} elseif (!$activateTime) {
@@ -231,7 +273,11 @@ public function importContestData(mixed $data, ?string &$errorMessage = null, ?s
231273
}
232274
}
233275

234-
$deactivateTime = $this->convertImportedTime($deactivateTimeFields, $data, $errorMessage);
276+
[
277+
'time' => $deactivateTime,
278+
'isRelative' => $deactivateTimeIsRelative,
279+
'relativeTime' => $deactivateRelativeTime,
280+
] = $this->convertImportedTime($deactivateTimeFields, $data, startTime: $startTime, errorMessage: $errorMessage);
235281
if ($errorMessage) {
236282
return false;
237283
}
@@ -247,11 +293,11 @@ public function importContestData(mixed $data, ?string &$errorMessage = null, ?s
247293
->setExternalid($contest->getShortname())
248294
->setWarningMessage($data['warning_message'] ?? $data['warning-message'] ?? null)
249295
->setStarttimeString(date_format($startTime, 'Y-m-d H:i:s e'))
250-
->setActivatetimeString(date_format($activateTime, 'Y-m-d H:i:s e'))
296+
->setActivatetimeString($activateTimeIsRelative ? $activateRelativeTime : date_format($activateTime, 'Y-m-d H:i:s e'))
251297
->setEndtimeString(sprintf('+%s', $data['duration']))
252298
->setPublic($data['public'] ?? true);
253299
if ($deactivateTime) {
254-
$contest->setDeactivatetimeString(date_format($deactivateTime, 'Y-m-d H:i:s e'));
300+
$contest->setDeactivatetimeString($deactivateTimeIsRelative ? $deactivateRelativeTime : date_format($deactivateTime, 'Y-m-d H:i:s e'));
255301
}
256302

257303
// Get all visible categories. For now, we assume these are the ones getting awards

webapp/src/Utils/Utils.php

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@ class Utils
167167

168168
final public const DAY_IN_SECONDS = 60*60*24;
169169

170-
final public const RELTIME_REGEX = '/^(-)?(\d+):(\d{2}):(\d{2})(?:\.(\d{3}))?$/';
170+
final public const RELTIME_REGEX = '/^([+-])?(\d+):(\d{2})(?::(\d{2})(?:\.(\d{3}))?)?$/';
171171

172172
/**
173173
* Returns the milliseconds part of a time stamp truncated at three digits.
@@ -192,6 +192,11 @@ public static function absTime(mixed $epoch, bool $floored = false): ?string
192192
. date("P", (int) $epoch);
193193
}
194194

195+
public static function isRelTime(string $time): bool
196+
{
197+
return preg_match(self::RELTIME_REGEX, $time) === 1;
198+
}
199+
195200
/**
196201
* Prints a time diff as relative time as ([-+])?(h)*h:mm:ss(.uuu)?
197202
* (with millis if $floored is false and with + sign only if $includePlus is true).
@@ -216,7 +221,7 @@ public static function relTimeToSeconds(string $reltime): float
216221
$seconds = $modifier * (
217222
(int)$data[2] * 3600
218223
+ (int)$data[3] * 60
219-
+ (float)sprintf('%d.%03d', $data[4], $data[5] ?? 0));
224+
+ (float)sprintf('%d.%03d', $data[4] ?? 0, $data[5] ?? 0));
220225
return $seconds;
221226
}
222227

webapp/tests/Unit/Controller/API/ContestControllerAdminTest.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,14 +58,14 @@ public function testAddYaml(): void
5858
name: practice
5959
activate_time: '2021-03-27T09:00:00+00:00'
6060
start_time: '2021-03-27T09:00:00+00:00'
61-
end_time: '2021-03-27T11:00:00+00:00'
61+
end_time: '+2:00:00'
6262
duration: 2:00:00.000
6363
penalty_time: 20
6464
medals:
6565
gold: 4
6666
silver: 4
6767
bronze: 4
68-
scoreboard_freeze_time: '2021-03-27T10:30:00+00:00'
68+
scoreboard_freeze_time: '+01:30:00'
6969
scoreboard_freeze_duration: 0:30:00
7070
EOF;
7171

webapp/tests/Unit/Controller/Jury/ImportExportControllerTest.php

Lines changed: 10 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -82,15 +82,15 @@ public function provideContestYamlContents(): Generator
8282
formal_name: 'Demo contest'
8383
name: demo
8484
start_time: '{$year}-01-01T08:00:00+00:00'
85-
end_time: '{$year}-01-01T13:00:00+00:00'
85+
end_time: '+5:00:00'
8686
duration: '5:00:00.000'
8787
penalty_time: 20
8888
activate_time: '{$pastYear}-01-01T08:00:00+00:00'
8989
medals:
9090
gold: 4
9191
silver: 4
9292
bronze: 4
93-
scoreboard_freeze_time: '{$year}-01-01T12:00:00+00:00'
93+
scoreboard_freeze_time: '+04:00:00'
9494
scoreboard_freeze_duration: '1:00:00'
9595
problems:
9696
-
@@ -218,25 +218,13 @@ public function testResultsTsvExport(
218218

219219
public function provideResultsTsvExport(): Generator
220220
{
221-
yield [0, true, true, 'results 1
222-
exteam Honorable 0 0 0
223-
'];
224-
yield [0, true, false, 'results 1
225-
exteam Honorable 0 0 0
226-
'];
227-
yield [0, false, true, 'results 1
228-
exteam Honorable 0 0 0
229-
'];
230-
yield [0, false, true, 'results 1
231-
exteam Honorable 0 0 0
232-
'];
233-
yield [1, true, true, 'results 1
234-
'];
235-
yield [1, true, false, 'results 1
236-
'];
237-
yield [1, false, true, 'results 1
238-
'];
239-
yield [1, false, true, 'results 1
240-
'];
221+
yield [0, true, true, "results 1\nexteam Honorable 0 0 0 \n"];
222+
yield [0, true, false, "results 1\nexteam Honorable 0 0 0 \n"];
223+
yield [0, false, true, "results 1\nexteam Honorable 0 0 0 \n"];
224+
yield [0, false, true, "results 1\nexteam Honorable 0 0 0 \n"];
225+
yield [1, true, true, "results 1\n"];
226+
yield [1, true, false, "results 1\n"];
227+
yield [1, false, true, "results 1\n"];
228+
yield [1, false, true, "results 1\n"];
241229
}
242230
}

webapp/tests/Unit/Service/ImportExportServiceTest.php

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -112,8 +112,13 @@ public function provideImportContestDataErrors(): Generator
112112
/**
113113
* @dataProvider provideImportContestDataSuccess
114114
*/
115-
public function testImportContestDataSuccess(mixed $data, string $expectedShortName, array $expectedProblems = []): void
116-
{
115+
public function testImportContestDataSuccess(
116+
mixed $data,
117+
string $expectedShortName,
118+
string $expectedActivateTimeString,
119+
?string $expectedDeactivateTimeString,
120+
array $expectedProblems = []
121+
): void {
117122
/** @var ImportExportService $importExportService */
118123
$importExportService = static::getContainer()->get(ImportExportService::class);
119124
self::assertTrue($importExportService->importContestData($data, $message, $cid), 'Importing failed: ' . $message);
@@ -125,6 +130,8 @@ public function testImportContestDataSuccess(mixed $data, string $expectedShortN
125130
self::assertEquals($data['name'], $contest->getName());
126131
self::assertEquals($data['public'] ?? true, $contest->getPublic());
127132
self::assertEquals($expectedShortName, $contest->getShortname());
133+
self::assertEquals($expectedActivateTimeString, $contest->getActivatetimeString());
134+
self::assertEquals($expectedDeactivateTimeString, $contest->getDeactivatetimeString());
128135

129136
$problems = [];
130137
/** @var ContestProblem $problem */
@@ -149,6 +156,38 @@ public function provideImportContestDataSuccess(): Generator
149156
'scoreboard-freeze-length' => '1:00:00',
150157
],
151158
'test-contest',
159+
'2020-01-01 10:34:56 UTC',
160+
null,
161+
];
162+
// Adding absolute activate and deactivate time
163+
yield [
164+
[
165+
'name' => 'Some test contest',
166+
'short-name' => 'test-contest',
167+
'duration' => '5:00:00',
168+
'start-time' => '2020-01-01T12:34:56+02:00',
169+
'activate_time' => '2020-01-01T06:34:56+02:00',
170+
'deactivate_time' => '2020-01-01T18:34:56+02:00',
171+
'scoreboard-freeze-length' => '1:00:00',
172+
],
173+
'test-contest',
174+
'2020-01-01 04:34:56 UTC',
175+
'2020-01-01 16:34:56 UTC',
176+
];
177+
// Adding relative activate and deactivate time
178+
yield [
179+
[
180+
'name' => 'Some test contest',
181+
'short-name' => 'test-contest',
182+
'duration' => '5:00:00',
183+
'start-time' => '2020-01-01T12:34:56+02:00',
184+
'activate_time' => '-6:00',
185+
'deactivate_time' => '+06:00:00',
186+
'scoreboard-freeze-length' => '1:00:00',
187+
],
188+
'test-contest',
189+
'-6:00',
190+
'+06:00:00',
152191
];
153192
// - Freeze length without hours
154193
// - Set a short name with invalid characters
@@ -162,6 +201,8 @@ public function provideImportContestDataSuccess(): Generator
162201
'scoreboard-freeze-length' => '30:00',
163202
],
164203
'test-contest__-__test',
204+
'2020-01-01 10:34:56 UTC',
205+
null,
165206
];
166207
// Real life example from NWERC 2020 practice session, including problems.
167208
yield [
@@ -195,6 +236,8 @@ public function provideImportContestDataSuccess(): Generator
195236
],
196237
],
197238
'practice',
239+
'2021-03-27 09:00:00 UTC',
240+
null,
198241
['A' => 'anothereruption', 'B' => 'brokengears', 'C' => 'cheating'],
199242
];
200243

@@ -209,6 +252,8 @@ public function provideImportContestDataSuccess(): Generator
209252
'public' => false,
210253
],
211254
'test-contest',
255+
'2020-01-01 10:34:56 UTC',
256+
null,
212257
];
213258
}
214259

0 commit comments

Comments
 (0)