Skip to content

Allow setting activation time in contest.yaml #1807

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
May 7, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 85 additions & 25 deletions webapp/src/Service/ImportExportService.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
use Collator;
use DateTime;
use DateTimeImmutable;
use DateTimeInterface;
use DateTimeZone;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\NonUniqueResultException;
Expand Down Expand Up @@ -47,19 +48,34 @@ public function getContestYamlData(Contest $contest, bool $includeProblems = tru
// We expect contest.yaml and problemset.yaml combined into one file here.

$data = [
'id' => $contest->getExternalid(),
'formal_name' => $contest->getName(),
'name' => $contest->getShortname(),
'start_time' => Utils::absTime($contest->getStarttime(), true),
'end_time' => Utils::absTime($contest->getEndtime(), true),
'duration' => Utils::relTime($contest->getContestTime((float)$contest->getEndtime())),
'penalty_time' => $this->config->get('penalty_time'),
'activation_time' => Utils::absTime($contest->getActivatetime(), true),
];
if ($warnMsg = $contest->getWarningMessage()) {
$data['warning_message'] = $warnMsg;
}
if ($contest->getFreezetime() !== null) {
$data['scoreboard_freeze_time'] = Utils::absTime($contest->getFreezetime(), true);
$data['scoreboard_freeze_duration'] = Utils::relTime(
$contest->getContestTime((float)$contest->getEndtime()) - $contest->getContestTime((float)$contest->getFreezetime()),
true);
true,
);
if ($contest->getUnfreezetime() !== null) {
$data['scoreboard_thaw_time'] = Utils::absTime($contest->getUnfreezetime(), true);
}
}
if ($contest->getFinalizetime() !== null) {
$data['finalize_time'] = Utils::absTime($contest->getFinalizetime(), true);
}

if ($contest->getDeactivatetime() !== null) {
$data['deactivate_time'] = Utils::absTime($contest->getDeactivatetime(), true);
}

if ($includeProblems) {
Expand All @@ -83,15 +99,55 @@ public function getContestYamlData(Contest $contest, bool $includeProblems = tru
return $data;
}

public function importContestData($data, ?string &$message = null, string &$cid = null): bool
/**
* Finds the first set field from $fields in $data and parse it as a date.
*
* To verify that everything works as expected the $errorMessage needs to be checked
* for parsing errors.
*/
protected function convertImportedTime(array $fields, array $data, ?string &$errorMessage = null): ?DateTimeImmutable
{
$timeValue = null;
$usedField = null;
foreach ($fields as $field) {
$timeValue = $data[$field] ?? null;
$usedField = $field;
// We need to check as the value for the key can be null
if ($timeValue) {
break;
}
}

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);
} else {
/** @var DateTime $time */
$time = $timeValue;
}
// If/When parsing fails we get a false instead of a null
if ($time === false) {
$errorMessage = 'Can not parse '.$usedField;
return null;
} elseif ($time) {
$time = $time->setTimezone(new DateTimeZone(date_default_timezone_get()));
}
return $time instanceof DateTime ? DateTimeImmutable::createFromMutable($time) : $time;
}

public function importContestData($data, ?string &$errorMessage = null, string &$cid = null): bool
{
if (empty($data) || !is_array($data)) {
$message = 'Error parsing YAML file.';
$errorMessage = 'Error parsing YAML file.';
return false;
}

$requiredFields = [['start_time', 'start-time'], 'name', ['id', 'short-name'], 'duration'];
$missingFields = [];
$activateTimeFields = ['activation_time','activate_time','activation-time', 'activate-time'];
$deactivateTimeFields = preg_filter('/^/', 'de', $activateTimeFields);
$startTimeFields = ['start_time', 'start-time'];
$requiredFields = [$startTimeFields, ['name', 'formal_name'], ['id', 'short_name', 'short-name'], 'duration'];
$missingFields = [];
foreach ($requiredFields as $field) {
if (is_array($field)) {
$present = false;
Expand All @@ -111,45 +167,49 @@ public function importContestData($data, ?string &$message = null, string &$cid
}

if (!empty($missingFields)) {
$message = sprintf('Missing fields: %s', implode(', ', $missingFields));
$errorMessage = sprintf('Missing fields: %s', implode(', ', $missingFields));
return false;
}

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

$starttimeValue = $data['start-time'] ?? $data['start_time'];

if (is_string($starttimeValue)) {
$starttime = date_create_from_format(DateTime::ATOM, $starttimeValue) ?:
// Make sure ISO 8601 but with the T replaced with a space also works.
date_create_from_format('Y-m-d H:i:sO', $starttimeValue);
} else {
/** @var DateTimeImmutable $starttimeValue */
$starttime = $starttimeValue;
$startTime = $this->convertImportedTime($startTimeFields, $data, $errorMessage);
if ($errorMessage) {
return false;
}
if ($starttime === false) {
$message = 'Can not parse start time';

// 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);
if ($errorMessage) {
return false;
} elseif (!$activateTime) {
$activateTime = new DateTime();
if ($activateTime > $startTime) {
$activateTime = $startTime;
}
}

$starttime = $starttime->setTimezone(new DateTimeZone(date_default_timezone_get()));
$activateTime = new DateTime();
if ($activateTime > $starttime) {
$activateTime = $starttime;
$deactivateTime = $this->convertImportedTime($deactivateTimeFields, $data, $errorMessage);
if ($errorMessage) {
return false;
}

$contest = new Contest();
$contest
->setName($data['name'])
->setName($data['name'] ?? $data['formal_name'] )
->setShortname(preg_replace(
$invalid_regex,
'_',
$data['shortname'] ?? $data['short-name'] ?? $data['id']
))
->setExternalid($contest->getShortname())
->setWarningMessage($data['warning-message'] ?? null)
->setStarttimeString(date_format($starttime, 'Y-m-d H:i:s e'))
->setStarttimeString(date_format($startTime, 'Y-m-d H:i:s e'))
->setActivatetimeString(date_format($activateTime, 'Y-m-d H:i:s e'))
->setEndtimeString(sprintf('+%s', $data['duration']));
if ($deactivateTime) {
$contest->setDeactivatetimeString(date_format($deactivateTime, 'Y-m-d H:i:s e'));
}

// Get all visible categories. For now, we assume these are the ones getting awards
$visibleCategories = $this->em->getRepository(TeamCategory::class)->findBy(['visible' => true]);
Expand All @@ -170,7 +230,7 @@ public function importContestData($data, ?string &$message = null, string &$cid
if ($freezeDuration !== null) {
$freezeDurationDiff = Utils::timeStringDiff($data['duration'], $freezeDuration);
if (str_starts_with($freezeDurationDiff, '-')) {
$message = 'Freeze duration is longer than contest length';
$errorMessage = 'Freeze duration is longer than contest length';
return false;
}
$contest->setFreezetimeString(sprintf('+%s', $freezeDurationDiff));
Expand All @@ -186,7 +246,7 @@ public function importContestData($data, ?string &$message = null, string &$cid
$messages[] = sprintf('%s: %s', $error->getPropertyPath(), $error->getMessage());
}

$message = sprintf("Contest has errors:\n\n%s", implode("\n", $messages));
$errorMessage = sprintf("Contest has errors:\n\n%s", implode("\n", $messages));
return false;
}

Expand Down
121 changes: 113 additions & 8 deletions webapp/tests/Unit/Controller/API/ContestControllerAdminTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,11 @@ public function testAddYaml(): void
{
$yaml = <<<EOF
duration: 2:00:00
name: NWERC 2020 Practice Session
penalty-time: 20
scoreboard-freeze-length: 30:00
short-name: practice
start-time: 2021-03-27 09:00:00+00:00
formal_name: NWERC 2020 Practice Session
penalty_time: 20
scoreboard_freeze_duration: 30:00
id: practice
start_time: 2021-03-27 09:00:00+00:00
problems:
- color: '#FE9DAF'
letter: A
Expand All @@ -52,12 +52,16 @@ public function testAddYaml(): void
short-name: cheating
EOF;
$expectedYaml = <<<EOF
duration: 2:00:00.000
id: practice
formal_name: NWERC 2020 Practice Session
penalty_time: 20
scoreboard_freeze_duration: 0:30:00
name: practice
activation_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'
duration: 2:00:00.000
penalty_time: 20
scoreboard_freeze_time: '2021-03-27T10:30:00+00:00'
scoreboard_freeze_duration: 0:30:00
EOF;

$url = $this->helperGetEndpointURL($this->apiEndpoint);
Expand All @@ -76,6 +80,8 @@ public function testAddYaml(): void
$expected = $this->parseSortYaml($expectedYaml);
$actual = $this->parseSortYaml($exportContestYaml);
self::assertSame($expected, $actual);
self::assertSame($this->getContest($cid)->getActivatetime(), $this->getContest($cid)->getStarttime());
self::assertNull($this->getContest($cid)->getDeactivatetime());
}

public function testAddJson(): void
Expand Down Expand Up @@ -234,4 +240,103 @@ public function provideChangeTimes(): Generator
yield [['id' => 1, 'scoreboard_thaw_time' => '+15 seconds'], 204, null, [], false, true];
yield [['id' => 1, 'scoreboard_thaw_time' => '-15 seconds'], 200, 'Demo contest', [], true, true];
}

/**
* @dataProvider provideNewContest
*/
public function testActivationTimeContestYaml(
string $activationTime, string $startTime, ?string $deactivationTime,
bool $setActivation, bool $setDeactivation
): void {
$yaml = <<<EOF
duration: 2:00:00
name: New Contest to check Activation
penalty-time: 20
scoreboard_freeze_duration: 30:00
id: activation
start_time: {$startTime}
problems:
- color: '#FE9DAF'
letter: A
rgb: '#FE9DAF'
id: anothereruption
EOF;

if ($setActivation) {
$yaml = "activation_time: ".$activationTime."\n".$yaml;
}
if ($setDeactivation) {
$yaml = "deactivation_time: ".$deactivationTime."\n".$yaml;
}
$url = $this->helperGetEndpointURL($this->apiEndpoint);
$tempYamlFile = tempnam(sys_get_temp_dir(), "/contest-yaml-");
file_put_contents($tempYamlFile, $yaml);
$yamlFile = new UploadedFile($tempYamlFile, 'contest.yaml');
$cid = $this->verifyApiJsonResponse('POST', $url, 200, $this->apiUser, [], ['yaml' => $yamlFile]);
self::assertIsString($cid);
unlink($tempYamlFile);

$now = Utils::now();
$nowTime = Utils::printtime($now, 'Y-m-d H:i:s');
$activation = Utils::toEpochFloat($activationTime);
$start = Utils::toEpochFloat($startTime);

self::assertIsString($cid);
self::assertSame('New Contest to check Activation', $this->getContest($cid)->getName());
self::assertSame($start, $this->getContest($cid)->getStarttime());

if ($setActivation) {
self::assertSame($activationTime, Utils::printtime($this->getContest($cid)->getActivatetime(), 'Y-m-d H:i:s'));
self::assertSame($activation, $this->getContest($cid)->getActivatetime());
} else {
// Contest uploaded starts in the past
if (Utils::printtime(Utils::now(), 'Y-m-d H:i:s')>=$startTime) {
self::assertSame($this->getContest($cid)->getActivatetime(), $this->getContest($cid)->getStarttime());
} else {
self::assertTrue($this->getContest($cid)->getActivatetime() <= $now);
}
}
if ($deactivationTime) {
self::assertSame($deactivationTime, Utils::printtime($this->getContest($cid)->getDeactivatetime(), 'Y-m-d H:i:s'));
} else {
self::assertNull($this->getContest($cid)->getDeactivatetime());
}
}

public function provideNewContest(): Generator
{
// Test Activation in past, present & future
yield [Utils::printtime(Utils::now()-14*24*60*60, 'Y-m-d H:i:s'), Utils::printtime(Utils::now()-7*24*60*60, 'Y-m-d H:i:s'), null, True, False];
yield [Utils::printtime(Utils::now()-7*24*60*60, 'Y-m-d H:i:s'), Utils::printtime(Utils::now(), 'Y-m-d H:i:s'), null, True, False];
yield [Utils::printtime(Utils::now()-1, 'Y-m-d H:i:s'), Utils::printtime(Utils::now(), 'Y-m-d H:i:s'), null, True, False];
yield [Utils::printtime(Utils::now(), 'Y-m-d H:i:s'), Utils::printtime(Utils::now(), 'Y-m-d H:i:s'), null, True, False];
yield [Utils::printtime(Utils::now(), 'Y-m-d H:i:s'), Utils::printtime(Utils::now()+1, 'Y-m-d H:i:s'), null, True, False];
yield [Utils::printtime(Utils::now(), 'Y-m-d H:i:s'), Utils::printtime(Utils::now()+7*24*60*60, 'Y-m-d H:i:s'), null, True, False];
yield [Utils::printtime(Utils::now()+1, 'Y-m-d H:i:s'), Utils::printtime(Utils::now()+7*24*60*60, 'Y-m-d H:i:s'), null, True, False];
yield [Utils::printtime(Utils::now()+7*24*60*60, 'Y-m-d H:i:s'), Utils::printtime(Utils::now()+14*24*60*60, 'Y-m-d H:i:s'), null, True, False];
yield [Utils::printtime(Utils::now()-7*24*60*60, 'Y-m-d H:i:s'), Utils::printtime(Utils::now()+7*24*60*60, 'Y-m-d H:i:s'), null, True, False];
// Test Deactivation in past, present & future
yield [Utils::printtime(Utils::now()-14*24*60*60, 'Y-m-d H:i:s'), Utils::printtime(Utils::now()-14*24*60*60, 'Y-m-d H:i:s'), Utils::printtime(Utils::now()-7*24*60*60, 'Y-m-d H:i:s'), False, True];
yield [Utils::printtime(Utils::now()-7*24*60*60, 'Y-m-d H:i:s'), Utils::printtime(Utils::now()-7*24*60*60, 'Y-m-d H:i:s'), Utils::printtime(Utils::now()-1, 'Y-m-d H:i:s'), False, True];
yield [Utils::printtime(Utils::now()-7*24*60*60, 'Y-m-d H:i:s'), Utils::printtime(Utils::now()-7*24*60*60, 'Y-m-d H:i:s'), Utils::printtime(Utils::now(), 'Y-m-d H:i:s'), False, True];
yield [Utils::printtime(Utils::now()-7*24*60*60, 'Y-m-d H:i:s'), Utils::printtime(Utils::now()-7*24*60*60, 'Y-m-d H:i:s'), Utils::printtime(Utils::now()+1, 'Y-m-d H:i:s'), False, True];
yield [Utils::printtime(Utils::now()-7*24*60*60, 'Y-m-d H:i:s'), Utils::printtime(Utils::now()-7*24*60*60, 'Y-m-d H:i:s'), Utils::printtime(Utils::now()+7*24*60*60, 'Y-m-d H:i:s'), False, True];
yield [Utils::printtime(Utils::now(), 'Y-m-d H:i:s'), Utils::printtime(Utils::now(), 'Y-m-d H:i:s'), Utils::printtime(Utils::now()+7*24*60*60, 'Y-m-d H:i:s'), False, True];
yield [Utils::printtime(Utils::now()+1, 'Y-m-d H:i:s'), Utils::printtime(Utils::now()+1, 'Y-m-d H:i:s'), Utils::printtime(Utils::now()+7*24*60*60, 'Y-m-d H:i:s'), False, True];
yield [Utils::printtime(Utils::now()+7*24*60*60, 'Y-m-d H:i:s'), Utils::printtime(Utils::now()+7*24*60*60, 'Y-m-d H:i:s'), Utils::printtime(Utils::now()+14*24*60*60, 'Y-m-d H:i:s'), False, True];
// Only activate during the contest
foreach ([True, False] as $explicitSet) {
yield [Utils::printtime(Utils::now()-7*24*60*60, 'Y-m-d H:i:s'), Utils::printtime(Utils::now()-7*24*60*60, 'Y-m-d H:i:s'), Utils::printtime(Utils::now()-(7*24-2)*60*60, 'Y-m-d H:i:s'), $explicitSet, True];
yield [Utils::printtime(Utils::now(), 'Y-m-d H:i:s'), Utils::printtime(Utils::now(), 'Y-m-d H:i:s'), Utils::printtime(Utils::now()+2*60*60, 'Y-m-d H:i:s'), $explicitSet, True];
yield [Utils::printtime(Utils::now()+7*24*60*60, 'Y-m-d H:i:s'), Utils::printtime(Utils::now()+7*24*60*60, 'Y-m-d H:i:s'), Utils::printtime(Utils::now()+(7*24+2)*60*60, 'Y-m-d H:i:s'), $explicitSet, True];
}
// Pick hardcoded times
yield ["2000-01-01 10:10:10", "2000-01-01 10:10:10", "2002-01-01 10:10:10", False, True];
yield ["2000-01-01 10:10:10", "2001-01-01 10:10:10", null, True, False];
yield ["2077-01-01 10:10:10", "2099-01-01 10:10:10", null, True, False];
yield ["2077-01-01 10:10:10", "2099-01-01 10:10:10", "2100-01-01 10:10:10", True, True];
yield ["2000-01-01 10:10:10", "2000-01-01 10:10:10", null, False, False];
yield [Utils::printtime(Utils::now(), 'Y-m-d H:i:s'), "2099-01-01 10:10:10", null, False, False];
yield [Utils::printtime(Utils::now(), 'Y-m-d H:i:s'), "2077-01-01 10:10:10", "2099-01-01 10:10:10", False, True];
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -91,12 +91,17 @@ public function testContestExport(string $cid, string $expectedYaml): void
public function provideContestYamlContents(): Generator
{
$year = date('Y')+1;
$pastYear = date('Y');
$yaml =<<<HEREDOC
id: demo
formal_name: 'Demo contest'
name: demo
start_time: '{$year}-01-01T08:00:00+00:00'
end_time: '{$year}-01-01T13:00:00+00:00'
duration: '5:00:00.000'
penalty_time: 20
activation_time: '{$pastYear}-01-01T08:00:00+00:00'
scoreboard_freeze_time: '{$year}-01-01T12:00:00+00:00'
scoreboard_freeze_duration: '1:00:00'
problems:
-
Expand Down
8 changes: 4 additions & 4 deletions webapp/tests/Unit/Service/ImportExportServiceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,16 +37,16 @@ public function testImportContestDataErrors($data, string $expectedMessage): voi
public function provideImportContestDataErrors(): Generator
{
yield [[], 'Error parsing YAML file.'];
yield [['name' => 'Some name'], 'Missing fields: one of (start_time, start-time), one of (id, short-name), duration'];
yield [['short-name' => 'somename', 'start-time' => '2020-01-01 12:34:56'], 'Missing fields: name, duration'];
yield [['name' => 'Some name'], 'Missing fields: one of (start_time, start-time), one of (id, short_name, short-name), duration'];
yield [['short-name' => 'somename', 'start-time' => '2020-01-01 12:34:56'], 'Missing fields: one of (name, formal_name), duration'];
yield [
[
'name' => 'Test contest',
'short-name' => 'test',
'duration' => '5:00:00',
'start-time' => 'Invalid start time here',
],
'Can not parse start time'
'Can not parse start-time'
];
yield [
[
Expand All @@ -55,7 +55,7 @@ public function provideImportContestDataErrors(): Generator
'duration' => '5:00:00',
'start_time' => 'Invalid start time here',
],
'Can not parse start time'
'Can not parse start_time'
];
yield [
[
Expand Down