From 8f40818358598c6bc9e8ff8b9a76a0e99ed54a0a Mon Sep 17 00:00:00 2001 From: Nicky Gerritsen Date: Tue, 13 Feb 2024 19:49:49 +0100 Subject: [PATCH 01/20] Shadow differences: redirect to configure page if not set up yet. --- .../Jury/ShadowDifferencesController.php | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/webapp/src/Controller/Jury/ShadowDifferencesController.php b/webapp/src/Controller/Jury/ShadowDifferencesController.php index 0fe08bdfbb..ac9938ef98 100644 --- a/webapp/src/Controller/Jury/ShadowDifferencesController.php +++ b/webapp/src/Controller/Jury/ShadowDifferencesController.php @@ -3,6 +3,7 @@ namespace App\Controller\Jury; use App\DataTransferObject\SubmissionRestriction; +use App\Entity\ExternalContestSource; use App\Service\ConfigurationService; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\NonUniqueResultException; @@ -64,6 +65,19 @@ public function indexAction( return $this->redirectToRoute('jury_index'); } + /** @var ExternalContestSource|null $externalContestSource */ + $externalContestSource = $this->em->createQueryBuilder() + ->from(ExternalContestSource::class, 'ecs') + ->select('ecs') + ->andWhere('ecs.contest = :contest') + ->setParameter('contest', $this->dj->getCurrentContest()) + ->getQuery()->getOneOrNullResult(); + + if (!$externalContestSource) { + $this->addFlash('warning', 'No external contest present yet, please configure one first'); + return $this->redirectToRoute('jury_external_contest_manage'); + } + // Close the session, as this might take a while and we don't need the session below. $this->requestStack->getSession()->save(); From b35e8503f261514ccf9deacca2772002d891b0a6 Mon Sep 17 00:00:00 2001 From: Nicky Gerritsen Date: Tue, 13 Feb 2024 20:02:20 +0100 Subject: [PATCH 02/20] Also create submission if the source is not a ZIP. --- .../Service/ExternalContestSourceService.php | 134 +++++++++--------- 1 file changed, 67 insertions(+), 67 deletions(-) diff --git a/webapp/src/Service/ExternalContestSourceService.php b/webapp/src/Service/ExternalContestSourceService.php index 741862b8b8..aa183f6eea 100644 --- a/webapp/src/Service/ExternalContestSourceService.php +++ b/webapp/src/Service/ExternalContestSourceService.php @@ -1474,7 +1474,7 @@ protected function importSubmission(string $entityType, ?string $eventId, string // Check if we have a local file. if (file_exists($zipUrl)) { // Yes, use it directly - $zipFile = $zipUrl; + $zipFile = $zipUrl; $shouldUnlink = false; } else { // No, download the ZIP file. @@ -1516,86 +1516,86 @@ protected function importSubmission(string $entityType, ?string $eventId, string fclose($ziphandler); } } + } - if ($submissionDownloadSucceeded) { - // Open the ZIP file. - $zip = new ZipArchive(); - $zip->open($zipFile); + if ($submissionDownloadSucceeded && isset($zipFile, $tmpdir)) { + // Open the ZIP file. + $zip = new ZipArchive(); + $zip->open($zipFile); - // Determine the files to submit. - /** @var UploadedFile[] $filesToSubmit */ - $filesToSubmit = []; - for ($zipFileIdx = 0; $zipFileIdx < $zip->numFiles; $zipFileIdx++) { - $filename = $zip->getNameIndex($zipFileIdx); - $content = $zip->getFromName($filename); + // Determine the files to submit. + /** @var UploadedFile[] $filesToSubmit */ + $filesToSubmit = []; + for ($zipFileIdx = 0; $zipFileIdx < $zip->numFiles; $zipFileIdx++) { + $filename = $zip->getNameIndex($zipFileIdx); + $content = $zip->getFromName($filename); - if (!($tmpSubmissionFile = tempnam($tmpdir, "submission_source_"))) { - $this->addOrUpdateWarning($eventId, $entityType, $data['id'], ExternalSourceWarning::TYPE_SUBMISSION_ERROR, [ - 'message' => 'Cannot create temporary file to extract ZIP contents for file ' . $filename, - ]); - $submissionDownloadSucceeded = false; - continue; - } - file_put_contents($tmpSubmissionFile, $content); - $filesToSubmit[] = new UploadedFile( - $tmpSubmissionFile, $filename, - null, null, true - ); + if (!($tmpSubmissionFile = tempnam($tmpdir, "submission_source_"))) { + $this->addOrUpdateWarning($eventId, $entityType, $data['id'], ExternalSourceWarning::TYPE_SUBMISSION_ERROR, [ + 'message' => 'Cannot create temporary file to extract ZIP contents for file ' . $filename, + ]); + $submissionDownloadSucceeded = false; + continue; } - } else { - $filesToSubmit = []; + file_put_contents($tmpSubmissionFile, $content); + $filesToSubmit[] = new UploadedFile( + $tmpSubmissionFile, $filename, + null, null, true + ); } + } else { + $filesToSubmit = []; + } - // If the language requires an entry point but we do not have one, use automatic entry point detection. - if ($language->getRequireEntryPoint() && $entryPoint === null) { - $entryPoint = '__auto__'; - } + // If the language requires an entry point but we do not have one, use automatic entry point detection. + if ($language->getRequireEntryPoint() && $entryPoint === null) { + $entryPoint = '__auto__'; + } - // Submit the solution - $contest = $this->em->getRepository(Contest::class)->find($this->getSourceContestId()); - $submission = $this->submissionService->submitSolution( - team: $team, - user: null, - problem: $contestProblem, - contest: $contest, - language: $language, - files: $filesToSubmit, - source: 'shadowing', - entryPoint: $entryPoint, - externalId: $submissionId, - submitTime: $submitTime, - message: $message, - forceImportInvalid: !$submissionDownloadSucceeded - ); - if (!$submission) { - $this->addOrUpdateWarning($eventId, $entityType, $data['id'], ExternalSourceWarning::TYPE_SUBMISSION_ERROR, [ - 'message' => 'Cannot add submission: ' . $message, - ]); - // Clean up the temporary submission files. - foreach ($filesToSubmit as $file) { - unlink($file->getRealPath()); - } - if (isset($zip)) { - $zip->close(); - } - if ($shouldUnlink) { - unlink($zipFile); - } - return; + // Submit the solution + $contest = $this->em->getRepository(Contest::class)->find($this->getSourceContestId()); + $submission = $this->submissionService->submitSolution( + team: $team, + user: null, + problem: $contestProblem, + contest: $contest, + language: $language, + files: $filesToSubmit, + source: 'shadowing', + entryPoint: $entryPoint, + externalId: $submissionId, + submitTime: $submitTime, + message: $message, + forceImportInvalid: !$submissionDownloadSucceeded + ); + if (!$submission) { + $this->addOrUpdateWarning($eventId, $entityType, $data['id'], ExternalSourceWarning::TYPE_SUBMISSION_ERROR, [ + 'message' => 'Cannot add submission: ' . $message, + ]); + // Clean up the temporary submission files. + foreach ($filesToSubmit as $file) { + unlink($file->getRealPath()); } - - // Clean up the ZIP. if (isset($zip)) { $zip->close(); } - if ($shouldUnlink) { + if (isset($shouldUnlink) && $shouldUnlink && isset($zipFile)) { unlink($zipFile); } + return; + } - // Clean up the temporary submission files. - foreach ($filesToSubmit as $file) { - unlink($file->getRealPath()); - } + // Clean up the ZIP. + if (isset($zip)) { + $zip->close(); + } + if (isset($shouldUnlink) && $shouldUnlink && isset($zipFile)) { + unlink($zipFile); + } + + // Clean up the temporary submission files. + foreach ($filesToSubmit as $file) { + unlink($file->getRealPath()); } } From 948dae42e5bb474b4811e48d35e80cc123c1aa2c Mon Sep 17 00:00:00 2001 From: Nicky Gerritsen Date: Tue, 13 Feb 2024 20:02:30 +0100 Subject: [PATCH 03/20] Show import errors as shadow differences in menu count. --- webapp/src/Service/DOMJudgeService.php | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/webapp/src/Service/DOMJudgeService.php b/webapp/src/Service/DOMJudgeService.php index ff18d37463..7b14dd1f26 100644 --- a/webapp/src/Service/DOMJudgeService.php +++ b/webapp/src/Service/DOMJudgeService.php @@ -390,13 +390,12 @@ public function getUpdates(): array $shadow_difference_count = $this->em->createQueryBuilder() ->from(Submission::class, 's') ->innerJoin('s.external_judgements', 'ej', Join::WITH, 'ej.valid = 1') - ->innerJoin('s.judgings', 'j', Join::WITH, 'j.valid = 1') + ->leftJoin('s.judgings', 'j', Join::WITH, 'j.valid = 1') ->select('COUNT(s.submitid)') ->andWhere('s.contest = :contest') ->andWhere('s.externalid IS NOT NULL') ->andWhere('ej.result IS NOT NULL') - ->andWhere('j.result IS NOT NULL') - ->andWhere('ej.result != j.result') + ->andWhere('(j.result IS NOT NULL AND ej.result != j.result) OR s.importError IS NOT NULL') ->andWhere('ej.verified = false') ->setParameter('contest', $contest) ->getQuery() From 30a4448bf216bf768cd10191e492195b249ab955 Mon Sep 17 00:00:00 2001 From: Nicky Gerritsen Date: Tue, 13 Feb 2024 20:30:55 +0100 Subject: [PATCH 04/20] Install and enable serializer. --- composer.json | 5 ++ composer.lock | 102 +++++++++++++++++++++++++- webapp/config/packages/framework.yaml | 3 + 3 files changed, 108 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index e060075c08..05495bef82 100644 --- a/composer.json +++ b/composer.json @@ -71,6 +71,8 @@ "mbostock/d3": "^3.5", "nelmio/api-doc-bundle": "^4.11", "novus/nvd3": "^1.8", + "phpdocumentor/reflection-docblock": "^5.3", + "phpstan/phpdoc-parser": "^1.25", "promphp/prometheus_client_php": "^2.6", "ramsey/uuid": "^4.2", "select2/select2": "4.*", @@ -89,9 +91,12 @@ "symfony/intl": "6.4.*", "symfony/mime": "6.4.*", "symfony/monolog-bundle": "^3.8.0", + "symfony/property-access": "6.4.*", + "symfony/property-info": "6.4.*", "symfony/runtime": "6.4.*", "symfony/security-bundle": "6.4.*", "symfony/security-csrf": "6.4.*", + "symfony/serializer": "6.4.*", "symfony/stopwatch": "6.4.*", "symfony/twig-bundle": "6.4.*", "symfony/validator": "6.4.*", diff --git a/composer.lock b/composer.lock index cc13572f25..b4654dcf7a 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "f833613ad9885c067c7cc3a2e60b3a5c", + "content-hash": "46b6797a184b1ae6a2c1bf8f577fb445", "packages": [ { "name": "apalfrey/select2-bootstrap-5-theme", @@ -9280,6 +9280,104 @@ ], "time": "2024-01-23T14:51:35+00:00" }, + { + "name": "symfony/serializer", + "version": "v6.4.3", + "source": { + "type": "git", + "url": "https://github.com/symfony/serializer.git", + "reference": "51a06ee93c4d5ab5b9edaa0635d8b83953e3c14d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/serializer/zipball/51a06ee93c4d5ab5b9edaa0635d8b83953e3c14d", + "reference": "51a06ee93c4d5ab5b9edaa0635d8b83953e3c14d", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "~1.8" + }, + "conflict": { + "doctrine/annotations": "<1.12", + "phpdocumentor/reflection-docblock": "<3.2.2", + "phpdocumentor/type-resolver": "<1.4.0", + "symfony/dependency-injection": "<5.4", + "symfony/property-access": "<5.4", + "symfony/property-info": "<5.4.24|>=6,<6.2.11", + "symfony/uid": "<5.4", + "symfony/validator": "<6.4", + "symfony/yaml": "<5.4" + }, + "require-dev": { + "doctrine/annotations": "^1.12|^2", + "phpdocumentor/reflection-docblock": "^3.2|^4.0|^5.0", + "seld/jsonlint": "^1.10", + "symfony/cache": "^5.4|^6.0|^7.0", + "symfony/config": "^5.4|^6.0|^7.0", + "symfony/console": "^5.4|^6.0|^7.0", + "symfony/dependency-injection": "^5.4|^6.0|^7.0", + "symfony/error-handler": "^5.4|^6.0|^7.0", + "symfony/filesystem": "^5.4|^6.0|^7.0", + "symfony/form": "^5.4|^6.0|^7.0", + "symfony/http-foundation": "^5.4|^6.0|^7.0", + "symfony/http-kernel": "^5.4|^6.0|^7.0", + "symfony/messenger": "^5.4|^6.0|^7.0", + "symfony/mime": "^5.4|^6.0|^7.0", + "symfony/property-access": "^5.4.26|^6.3|^7.0", + "symfony/property-info": "^5.4.24|^6.2.11|^7.0", + "symfony/translation-contracts": "^2.5|^3", + "symfony/uid": "^5.4|^6.0|^7.0", + "symfony/validator": "^6.4|^7.0", + "symfony/var-dumper": "^5.4|^6.0|^7.0", + "symfony/var-exporter": "^5.4|^6.0|^7.0", + "symfony/yaml": "^5.4|^6.0|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Serializer\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Handles serializing and deserializing data structures, including object graphs, into array structures or other formats like XML and JSON.", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/serializer/tree/v6.4.3" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-01-30T08:32:12+00:00" + }, { "name": "symfony/service-contracts", "version": "v3.4.1", @@ -13043,5 +13141,5 @@ "platform-overrides": { "php": "8.1.0" }, - "plugin-api-version": "2.3.0" + "plugin-api-version": "2.6.0" } diff --git a/webapp/config/packages/framework.yaml b/webapp/config/packages/framework.yaml index deb14179c1..ed7df0ab36 100644 --- a/webapp/config/packages/framework.yaml +++ b/webapp/config/packages/framework.yaml @@ -6,6 +6,9 @@ framework: http_method_override: true annotations: true handle_all_throwables: true + serializer: + enabled: true + name_converter: serializer.name_converter.camel_case_to_snake_case # Enables session support. Note that the session will ONLY be started if you read or write from it. # Remove or comment this section to explicitly disable session support. From 69f46c3a779c572692509831e88f716c4bb3ca96 Mon Sep 17 00:00:00 2001 From: Nicky Gerritsen Date: Tue, 13 Feb 2024 20:30:29 +0100 Subject: [PATCH 05/20] Add provider data to API info. --- .../src/Controller/API/GeneralInfoController.php | 5 +++++ webapp/src/DataTransferObject/ApiInfo.php | 1 + webapp/src/DataTransferObject/ApiInfoProvider.php | 15 +++++++++++++++ 3 files changed, 21 insertions(+) create mode 100644 webapp/src/DataTransferObject/ApiInfoProvider.php diff --git a/webapp/src/Controller/API/GeneralInfoController.php b/webapp/src/Controller/API/GeneralInfoController.php index 838a3735aa..da0d5f55c0 100644 --- a/webapp/src/Controller/API/GeneralInfoController.php +++ b/webapp/src/Controller/API/GeneralInfoController.php @@ -3,6 +3,7 @@ namespace App\Controller\API; use App\DataTransferObject\ApiInfo; +use App\DataTransferObject\ApiInfoProvider; use App\DataTransferObject\ApiVersion; use App\DataTransferObject\DomJudgeApiInfo; use App\DataTransferObject\ExtendedContestStatus; @@ -99,6 +100,10 @@ public function getInfoAction( version: self::CCS_SPEC_API_VERSION, versionUrl: self::CCS_SPEC_API_URL, name: 'DOMjudge', + provider: new ApiInfoProvider( + name: 'DOMjudge', + version: $this->getParameter('domjudge.version'), + ), domjudge: $domjudge ); } diff --git a/webapp/src/DataTransferObject/ApiInfo.php b/webapp/src/DataTransferObject/ApiInfo.php index bc517b7900..17a8f5ed56 100644 --- a/webapp/src/DataTransferObject/ApiInfo.php +++ b/webapp/src/DataTransferObject/ApiInfo.php @@ -10,6 +10,7 @@ public function __construct( public readonly string $version, public readonly string $versionUrl, public readonly string $name, + public readonly ?ApiInfoProvider $provider, #[Serializer\Exclude(if: '!object.domjudge')] public readonly ?DomJudgeApiInfo $domjudge, ) {} diff --git a/webapp/src/DataTransferObject/ApiInfoProvider.php b/webapp/src/DataTransferObject/ApiInfoProvider.php new file mode 100644 index 0000000000..31335e07d4 --- /dev/null +++ b/webapp/src/DataTransferObject/ApiInfoProvider.php @@ -0,0 +1,15 @@ + Date: Tue, 13 Feb 2024 20:30:48 +0100 Subject: [PATCH 06/20] Use DTO's for cached contest and API info data in external contest service. --- .../Shadowing/ContestData.php | 23 ++++++++ .../Service/ExternalContestSourceService.php | 54 ++++++++----------- 2 files changed, 46 insertions(+), 31 deletions(-) create mode 100644 webapp/src/DataTransferObject/Shadowing/ContestData.php diff --git a/webapp/src/DataTransferObject/Shadowing/ContestData.php b/webapp/src/DataTransferObject/Shadowing/ContestData.php new file mode 100644 index 0000000000..859ec9c362 --- /dev/null +++ b/webapp/src/DataTransferObject/Shadowing/ContestData.php @@ -0,0 +1,23 @@ + $verdicts */ @@ -109,6 +101,7 @@ public function __construct( protected readonly EventLogService $eventLog, protected readonly SubmissionService $submissionService, protected readonly ScoreboardService $scoreboardService, + protected readonly SerializerInterface $serializer, #[Autowire('%domjudge.version%')] string $domjudgeVersion ) { @@ -159,7 +152,7 @@ public function getContestId(): string throw new LogicException('The contest source is not valid'); } - return $this->cachedContestData['id']; + return $this->cachedContestData->id; } public function getContestName(): string @@ -168,7 +161,7 @@ public function getContestName(): string throw new LogicException('The contest source is not valid'); } - return $this->cachedContestData['name']; + return $this->cachedContestData->name; } public function getContestStartTime(): ?float @@ -176,8 +169,8 @@ public function getContestStartTime(): ?float if (!$this->isValidContestSource()) { throw new LogicException('The contest source is not valid'); } - if (isset($this->cachedContestData['start_time'])) { - return Utils::toEpochFloat($this->cachedContestData['start_time']); + if (isset($this->cachedContestData->startTime)) { + return Utils::toEpochFloat($this->cachedContestData->startTime); } else { $this->logger->warning('Contest has no start time, is the contest paused?'); return null; @@ -190,7 +183,7 @@ public function getContestDuration(): string throw new LogicException('The contest source is not valid'); } - return $this->cachedContestData['duration']; + return $this->cachedContestData->duration; } public function getApiVersion(): ?string @@ -199,7 +192,7 @@ public function getApiVersion(): ?string throw new LogicException('The contest source is not valid'); } - return $this->cachedApiInfoData['version'] ?? null; + return $this->cachedApiInfoData->version ?? null; } public function getApiVersionUrl(): ?string @@ -208,7 +201,7 @@ public function getApiVersionUrl(): ?string throw new LogicException('The contest source is not valid'); } - return $this->cachedApiInfoData['version_url'] ?? null; + return $this->cachedApiInfoData->versionUrl ?? null; } public function getApiProviderName(): ?string @@ -217,7 +210,7 @@ public function getApiProviderName(): ?string throw new LogicException('The contest source is not valid'); } - return $this->cachedApiInfoData['provider']['name'] ?? $this->cachedApiInfoData['name'] ?? null; + return $this->cachedApiInfoData->provider?->name ?? $this->cachedApiInfoData->name; } public function getApiProviderVersion(): ?string @@ -226,7 +219,7 @@ public function getApiProviderVersion(): ?string throw new LogicException('The contest source is not valid'); } - return $this->cachedApiInfoData['provider']['version'] ?? null; + return $this->cachedApiInfoData->provider?->version ?? $this->cachedApiInfoData->domjudge?->version; } public function getApiProviderBuildDate(): ?string @@ -235,7 +228,7 @@ public function getApiProviderBuildDate(): ?string throw new LogicException('The contest source is not valid'); } - return $this->cachedApiInfoData['provider']['build_date'] ?? null; + return $this->cachedApiInfoData->provider?->buildDate; } public function getLoadingError(): string @@ -494,7 +487,6 @@ function ( * - A boolean that can be set to true (pass-by-reference) to stop processing * * @param resource $filePointer - * @throws JsonException */ protected function readEventsFromFile($filePointer, callable $callback): void { @@ -555,10 +547,10 @@ protected function loadContest(): void } $this->httpClient = $this->httpClient->withOptions($clientOptions); $contestResponse = $this->httpClient->request('GET', $this->source->getSource()); - $this->cachedContestData = $contestResponse->toArray(); + $this->cachedContestData = $this->serializer->deserialize($contestResponse->getContent(), ContestData::class, 'json'); $apiInfoResponse = $this->httpClient->request('GET', ''); - $this->cachedApiInfoData = $apiInfoResponse->toArray(); + $this->cachedApiInfoData = $this->serializer->deserialize($apiInfoResponse->getContent(), ApiInfo::class, 'json'); } } catch (HttpExceptionInterface|DecodingExceptionInterface|TransportExceptionInterface $e) { $this->cachedContestData = null; @@ -580,15 +572,15 @@ protected function loadContest(): void $this->loadingError = 'event-feed.ndjson not found in archive'; } else { try { - $this->cachedContestData = $this->dj->jsonDecode(file_get_contents($contestFile)); - } catch (JsonException $e) { + $this->cachedContestData = $this->serializer->deserialize(file_get_contents($contestFile), ContestData::class, 'json'); + } catch (Exception $e) { $this->loadingError = $e->getMessage(); } if (is_file($apiInfoFile)) { try { - $this->cachedApiInfoData = $this->dj->jsonDecode(file_get_contents($apiInfoFile)); - } catch (JsonException $e) { + $this->cachedApiInfoData = $this->serializer->deserialize(file_get_contents($apiInfoFile), ApiInfo::class, 'json'); + } catch (Exception $e) { $this->loadingError = $e->getMessage(); } } From 9ffb0bacb2659452e60f7c02f7a8b889b97a2440 Mon Sep 17 00:00:00 2001 From: Nicky Gerritsen Date: Wed, 14 Feb 2024 21:31:51 +0100 Subject: [PATCH 07/20] Use DTO's and the Symfony Serializer component for reading external contest sources. --- .../DataTransferObject/Shadowing/Account.php | 8 + .../DataTransferObject/Shadowing/Award.php | 8 + .../Shadowing/ClarificationEvent.php | 17 + .../Shadowing/ContestEvent.php | 19 + .../DataTransferObject/Shadowing/Event.php | 20 + .../Shadowing/EventData.php | 8 + .../Shadowing/EventType.php | 70 ++ .../Shadowing/GroupEvent.php | 17 + .../Shadowing/JudgementEvent.php | 17 + .../Shadowing/JudgementTypeEvent.php | 13 + .../Shadowing/LanguageEvent.php | 17 + .../Shadowing/Operation.php | 10 + .../Shadowing/OrganizationEvent.php | 17 + .../Shadowing/ProblemEvent.php | 14 + .../DataTransferObject/Shadowing/RunEvent.php | 16 + .../Shadowing/StateEvent.php | 15 + .../Shadowing/SubmissionEvent.php | 20 + .../Shadowing/SubmissionFile.php | 11 + .../Shadowing/TeamEvent.php | 20 + .../Shadowing/TeamMember.php | 8 + .../Shadowing/EventDataNormalizer.php | 82 ++ .../Shadowing/EventDenormalizer.php | 115 ++ .../Service/ExternalContestSourceService.php | 994 +++++++++--------- 23 files changed, 1029 insertions(+), 507 deletions(-) create mode 100644 webapp/src/DataTransferObject/Shadowing/Account.php create mode 100644 webapp/src/DataTransferObject/Shadowing/Award.php create mode 100644 webapp/src/DataTransferObject/Shadowing/ClarificationEvent.php create mode 100644 webapp/src/DataTransferObject/Shadowing/ContestEvent.php create mode 100644 webapp/src/DataTransferObject/Shadowing/Event.php create mode 100644 webapp/src/DataTransferObject/Shadowing/EventData.php create mode 100644 webapp/src/DataTransferObject/Shadowing/EventType.php create mode 100644 webapp/src/DataTransferObject/Shadowing/GroupEvent.php create mode 100644 webapp/src/DataTransferObject/Shadowing/JudgementEvent.php create mode 100644 webapp/src/DataTransferObject/Shadowing/JudgementTypeEvent.php create mode 100644 webapp/src/DataTransferObject/Shadowing/LanguageEvent.php create mode 100644 webapp/src/DataTransferObject/Shadowing/Operation.php create mode 100644 webapp/src/DataTransferObject/Shadowing/OrganizationEvent.php create mode 100644 webapp/src/DataTransferObject/Shadowing/ProblemEvent.php create mode 100644 webapp/src/DataTransferObject/Shadowing/RunEvent.php create mode 100644 webapp/src/DataTransferObject/Shadowing/StateEvent.php create mode 100644 webapp/src/DataTransferObject/Shadowing/SubmissionEvent.php create mode 100644 webapp/src/DataTransferObject/Shadowing/SubmissionFile.php create mode 100644 webapp/src/DataTransferObject/Shadowing/TeamEvent.php create mode 100644 webapp/src/DataTransferObject/Shadowing/TeamMember.php create mode 100644 webapp/src/Serializer/Shadowing/EventDataNormalizer.php create mode 100644 webapp/src/Serializer/Shadowing/EventDenormalizer.php diff --git a/webapp/src/DataTransferObject/Shadowing/Account.php b/webapp/src/DataTransferObject/Shadowing/Account.php new file mode 100644 index 0000000000..d7395933c6 --- /dev/null +++ b/webapp/src/DataTransferObject/Shadowing/Account.php @@ -0,0 +1,8 @@ + + */ + public function getEventClass(): string + { + switch ($this) { + case self::ACCOUNTS: + return Account::class; + case self::AWARDS: + return Award::class; + case self::CLARIFICATIONS: + return ClarificationEvent::class; + case self::CONTESTS: + return ContestEvent::class; + case self::GROUPS: + return GroupEvent::class; + case self::JUDGEMENTS: + return JudgementEvent::class; + case self::JUDGEMENT_TYPES: + return JudgementTypeEvent::class; + case self::LANGUAGES: + return LanguageEvent::class; + case self::ORGANIZATIONS: + return OrganizationEvent::class; + case self::PROBLEMS: + return ProblemEvent::class; + case self::RUNS: + return RunEvent::class; + case self::STATE: + return StateEvent::class; + case self::SUBMISSIONS: + return SubmissionEvent::class; + case self::TEAMS: + return TeamEvent::class; + case self::TEAM_MEMBERS: + return TeamMember::class; + } + } +} diff --git a/webapp/src/DataTransferObject/Shadowing/GroupEvent.php b/webapp/src/DataTransferObject/Shadowing/GroupEvent.php new file mode 100644 index 0000000000..efc7002236 --- /dev/null +++ b/webapp/src/DataTransferObject/Shadowing/GroupEvent.php @@ -0,0 +1,17 @@ +supportsDenormalization($data, $type, $format, $context)) { + throw new InvalidArgumentException('Unsupported data.'); + } + + if (!$this->serializer instanceof DenormalizerInterface) { + throw new LogicException('Cannot denormalize attribute "data" because the injected serializer is not a denormalizer.'); + } + + $eventType = $context['event_type']; + $eventClass = $eventType->getEventClass(); + + // Unset the event type, so we are not calling ourselves recursively + unset($context['event_type']); + return $this->serializer->denormalize($data, $eventClass, $format, $context); + } + + /** + * @param array{api_version?: string, event_type?: EventType} $context + */ + public function supportsDenormalization( + mixed $data, + string $type, + ?string $format = null, + array $context = [] + ): bool { + if (!is_array($data)) { + return false; + } + if ($type !== EventData::class) { + return false; + } + + if (!isset($context['event_type'])) { + return false; + } + + if (!$context['event_type'] instanceof EventType) { + return false; + } + + return true; + } + + /** + * @return array + */ + public function getSupportedTypes(?string $format): array + { + return [EventData::class => false, '*' => null]; + } +} diff --git a/webapp/src/Serializer/Shadowing/EventDenormalizer.php b/webapp/src/Serializer/Shadowing/EventDenormalizer.php new file mode 100644 index 0000000000..506a7b6032 --- /dev/null +++ b/webapp/src/Serializer/Shadowing/EventDenormalizer.php @@ -0,0 +1,115 @@ +|null + * } $data + * @param array{api_version?: string} $context + * @return Event + * + * @throws ExceptionInterface + */ + public function denormalize( + mixed $data, + string $type, + ?string $format = null, + array $context = [] + ): Event { + if (!$this->supportsDenormalization($data, $type, $format, $context)) { + throw new InvalidArgumentException('Unsupported data.'); + } + + if (!$this->serializer instanceof DenormalizerInterface) { + throw new LogicException('Cannot denormalize attribute "data" because the injected serializer is not a denormalizer.'); + } + + $eventType = EventType::fromString($data['type']); + if ($this->getEventFeedFormat($data, $context) === EventFeedFormat::Format_2022_07) { + $operation = !isset($data['data']) ? Operation::DELETE : Operation::CREATE; + if (!isset($data['data'][0])) { + $data['data'] = [$data['data']]; + } + $id = $operation === Operation::DELETE ? $data['id'] : $data['data'][0]['id'] ?? null; + return new Event( + $data['token'] ?? null, + $eventType, + $operation, + $id, + $this->serializer->denormalize($data['data'], EventData::class . '[]', $format, $context + ['event_type' => $eventType]), + ); + } else { + return new Event( + $data['id'] ?? null, + $eventType, + Operation::from($data['op']), + $data['data']['id'], + [$this->serializer->denormalize($data['data'], EventData::class, $format, $context + ['event_type' => $eventType])], + ); + } + } + + /** + * @param array{api_version?: string} $context + */ + public function supportsDenormalization( + mixed $data, + string $type, + ?string $format = null, + array $context = [] + ): bool { + if (!is_array($data)) { + return false; + } + if ($type !== Event::class) { + return false; + } + return true; + } + + /** + * @return array + */ + public function getSupportedTypes(?string $format): array + { + return [Event::class => false, '*' => null]; + } + + + /** + * @param array{op?: string} $event + * @param array{api_version?: string} $context + */ + protected function getEventFeedFormat(array $event, array $context): EventFeedFormat + { + 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, + }; + } +} diff --git a/webapp/src/Service/ExternalContestSourceService.php b/webapp/src/Service/ExternalContestSourceService.php index 6258c0bc1a..c73b295190 100644 --- a/webapp/src/Service/ExternalContestSourceService.php +++ b/webapp/src/Service/ExternalContestSourceService.php @@ -3,7 +3,22 @@ namespace App\Service; use App\DataTransferObject\ApiInfo; +use App\DataTransferObject\Shadowing\ClarificationEvent; use App\DataTransferObject\Shadowing\ContestData; +use App\DataTransferObject\Shadowing\ContestEvent; +use App\DataTransferObject\Shadowing\Event; +use App\DataTransferObject\Shadowing\EventData; +use App\DataTransferObject\Shadowing\EventType; +use App\DataTransferObject\Shadowing\GroupEvent; +use App\DataTransferObject\Shadowing\JudgementEvent; +use App\DataTransferObject\Shadowing\JudgementTypeEvent; +use App\DataTransferObject\Shadowing\LanguageEvent; +use App\DataTransferObject\Shadowing\OrganizationEvent; +use App\DataTransferObject\Shadowing\ProblemEvent; +use App\DataTransferObject\Shadowing\RunEvent; +use App\DataTransferObject\Shadowing\StateEvent; +use App\DataTransferObject\Shadowing\SubmissionEvent; +use App\DataTransferObject\Shadowing\TeamEvent; use App\Entity\BaseApiEntity; use App\Entity\Clarification; use App\Entity\Contest; @@ -19,7 +34,6 @@ use App\Entity\TeamAffiliation; use App\Entity\TeamCategory; use App\Entity\Testcase; -use App\Utils\EventFeedFormat; use App\Utils\Utils; use DateTime; use DateTimeZone; @@ -27,6 +41,7 @@ use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\NonUniqueResultException; use Exception; +use InvalidArgumentException; use LogicException; use Psr\Log\LoggerInterface; use Symfony\Component\DependencyInjection\Attribute\Autowire; @@ -64,31 +79,22 @@ class ExternalContestSourceService * this by storing these events here and checking whether there are any * after saving any dependent event. * - * This array is three dimensional: + * This array is three-dimensional: * - The first dimension is the type of the dependent event type * - The second dimension is the (external) ID of the dependent event * - The third dimension contains an array of all events that should be processed * - * @var array> $pendingEvents + * @var array>>> $pendingEvents */ protected array $pendingEvents = [ // Initialize it with all types that can be a dependent event. // Note that Language is not here, as they should exist already. - 'team' => [], - 'group' => [], - 'organization' => [], - 'problem' => [], + 'team' => [], + 'group' => [], + 'organization' => [], + 'problem' => [], 'clarification' => [], - 'submission' => [], + 'submission' => [], ]; public function __construct( @@ -192,7 +198,7 @@ public function getApiVersion(): ?string throw new LogicException('The contest source is not valid'); } - return $this->cachedApiInfoData->version ?? null; + return $this->cachedApiInfoData->version; } public function getApiVersionUrl(): ?string @@ -201,7 +207,7 @@ public function getApiVersionUrl(): ?string throw new LogicException('The contest source is not valid'); } - return $this->cachedApiInfoData->versionUrl ?? null; + return $this->cachedApiInfoData->versionUrl; } public function getApiProviderName(): ?string @@ -251,15 +257,18 @@ public function getLastReadEventId(): ?string 'SELECT ecs.lastEventId FROM App\Entity\ExternalContestSource ecs WHERE ecs.extsourceid = :extsourceid') - ->setParameter('extsourceid', $this->source->getExtsourceid()) - ->getSingleScalarResult(); + ->setParameter('extsourceid', $this->source->getExtsourceid()) + ->getSingleScalarResult(); } /** * @param string[] $eventsToSkip */ - public function import(bool $fromStart, array $eventsToSkip, ?callable $progressReporter = null): bool - { + public function import( + bool $fromStart, + array $eventsToSkip, + ?callable $progressReporter = null + ): bool { // We need the verdicts to validate judgement-types. $this->verdicts = $this->dj->getVerdicts(mergeExternal: true); @@ -311,8 +320,10 @@ protected function setLastEvent(?string $eventId): void /** * @param string[] $eventsToSkip */ - protected function importFromCcsApi(array $eventsToSkip, ?callable $progressReporter = null): bool - { + protected function importFromCcsApi( + array $eventsToSkip, + ?callable $progressReporter = null + ): bool { while (true) { // Check whether we have received an exit signal. if (function_exists('pcntl_signal_dispatch')) { @@ -346,18 +357,14 @@ protected function importFromCcsApi(array $eventsToSkip, ?callable $progressRepo $processBuffer = function () use ($eventsToSkip, &$buffer, $progressReporter) { while (($newlinePos = strpos($buffer, "\n")) !== false) { - $line = substr($buffer, 0, $newlinePos); + $line = substr($buffer, 0, $newlinePos); $buffer = substr($buffer, $newlinePos + 1); if (!empty($line)) { - $event = $this->dj->jsonDecode($line); + $event = $this->serializer->deserialize($line, Event::class, 'json', ['api_version' => $this->getApiVersion()]); $this->importEvent($event, $eventsToSkip); - if ($this->getEventFeedFormat($event) === EventFeedFormat::Format_2022_07) { - $eventId = $event['token'] ?? null; - } else { - $eventId = $event['id']; - } + $eventId = $event->id; $this->setLastEvent($eventId); $progressReporter(false); } @@ -429,15 +436,16 @@ protected function importFromCcsApi(array $eventsToSkip, ?callable $progressRepo /** * @param string[] $eventsToSkip */ - protected function importFromContestArchive(array $eventsToSkip, ?callable $progressReporter = null): bool - { + protected function importFromContestArchive( + array $eventsToSkip, + ?callable $progressReporter = null + ): bool { $file = fopen($this->source->getSource() . '/event-feed.ndjson', 'r'); $skipEventsUpTo = $this->getLastReadEventId(); $this->readEventsFromFile($file, function ( - array $event, string $line, &$shouldStop ) use ( @@ -445,19 +453,14 @@ function ( &$skipEventsUpTo, $progressReporter ) { - $lastEventId = $this->getLastReadEventId(); + $lastEventId = $this->getLastReadEventId(); $readingToLastEventId = false; - - if ($this->getEventFeedFormat($event) === EventFeedFormat::Format_2022_07) { - $eventId = $event['token'] ?? null; - } else { - $eventId = $event['id']; - } + $event = $this->serializer->deserialize($line, Event::class, 'json', ['api_version' => $this->getApiVersion()]); if ($skipEventsUpTo === null) { $this->importEvent($event, $eventsToSkip); - $lastEventId = $eventId; - } elseif ($eventId === $skipEventsUpTo) { + $lastEventId = $event->id; + } elseif ($event->id === $skipEventsUpTo) { $skipEventsUpTo = null; } else { $readingToLastEventId = true; @@ -482,11 +485,11 @@ function ( * * The callback will be called for every found event and will receive three * arguments: - * - The event to process - * - The line the event was on + * - The event line to process * - A boolean that can be set to true (pass-by-reference) to stop processing * - * @param resource $filePointer + * @param resource $filePointer + * @param callable(string, bool): void $callback */ protected function readEventsFromFile($filePointer, callable $callback): void { @@ -498,16 +501,15 @@ protected function readEventsFromFile($filePointer, callable $callback): void } $newlinePos = strpos($buffer, "\n"); if ($newlinePos === false) { - $line = $buffer; + $line = $buffer; $buffer = ''; } else { - $line = substr($buffer, 0, $newlinePos); + $line = substr($buffer, 0, $newlinePos); $buffer = substr($buffer, $newlinePos + 1); } - $event = $this->dj->jsonDecode($line); $shouldStop = false; - $callback($event, $line, $shouldStop); + $callback($line, $shouldStop); /** @phpstan-ignore-next-line The callable can modify $shouldStop but currently we can't indicate this */ if ($shouldStop) { return; @@ -528,8 +530,8 @@ protected function loadContest(): void try { // The base URL is the URL of the CCS API root. if (preg_match('/^(.*\/)contests\/.*/', - $this->source->getSource(), $matches) === 0) { - $this->loadingError = 'Cannot determine base URL. Did you pass a CCS API contest URL?'; + $this->source->getSource(), $matches) === 0) { + $this->loadingError = 'Cannot determine base URL. Did you pass a CCS API contest URL?'; $this->cachedContestData = null; } else { $clientOptions = [ @@ -538,7 +540,7 @@ protected function loadContest(): void $this->basePath = $matches[1]; if ($this->source->getUsername()) { $auth = [$this->source->getUsername()]; - if (is_string($this->source->getPassword() ?? null)) { + if (is_string($this->source->getPassword())) { $auth[] = $this->source->getPassword(); } $clientOptions['auth_basic'] = $auth; @@ -592,21 +594,14 @@ protected function loadContest(): void /** * Import the given event. * - * @param array{token?: string, id: string, type: string, time: string, op?: string, end_of_updates?: bool, - * data?: array{run_time?: float, time?: string, contest_time?: string, ordinal?: int, - * id: string, judgement_id?: string, judgement_type_id: string|null, - * max_run_time?: float|null, start_time: string, start_contest_time?: string, - * end_time?: string|null, end_contest_time?: string|null, submission_id: string, - * output_compile_as_string: null, language_id?: string, externalid?: string|null, - * team_id: string, problem_id?: string, entry_point?: string|null, old_result?: null, - * files?: array{href: string}}|mixed[] - * } $event - * @param string[] $eventsToSkip + * @param Event $event + * @param string[] $eventsToSkip + * * @throws DBALException * @throws NonUniqueResultException * @throws TransportExceptionInterface */ - public function importEvent(array $event, array $eventsToSkip): void + public function importEvent(Event $event, array $eventsToSkip): void { // Check whether we have received an exit signal. if (function_exists('pcntl_signal_dispatch')) { @@ -616,104 +611,76 @@ public function importEvent(array $event, array $eventsToSkip): void return; } - if ($this->getEventFeedFormat($event) === EventFeedFormat::Format_2022_07) { - $eventId = $event['token'] ?? null; - if (!isset($event['data'])) { - $operation = EventLogService::ACTION_DELETE; - $data = [['id' => $event['id']]]; - } else { - $operation = EventLogService::ACTION_CREATE; - $data = $event['data']; - if (!isset($data[0])) { - $data = [$data]; - } - } - } else { - $eventId = $event['id']; - $operation = $event['op']; - $data = [$event['data']]; - } - $entityType = $event['type']; - if ($entityType === 'contest') { - $entityType = 'contests'; - } - - if ($eventId !== null && in_array($eventId, $eventsToSkip)) { + if ($event->id !== null && in_array($event->id, $eventsToSkip)) { $this->logger->info("Skipping event with ID %s and type %s as requested", - [$eventId, $event['type']]); + [$event->id, $event->type->value]); return; } - if ($eventId !== null) { + if ($event->id !== null) { $this->logger->debug("Importing event with ID %s and type %s...", - [$eventId, $event['type']]); + [$event->id, $event->type->value]); } else { $this->logger->debug("Importing event with type %s...", - [$event['type']]); - } - - foreach ($data as $dataItem) { - switch ($entityType) { - case 'awards': - case 'team-members': - case 'accounts': - case 'state': - $this->logger->debug("Ignoring event of type %s", [$entityType]); - if (isset($event['end_of_updates'])) { - $this->logger->info('End of updates encountered'); - } - break; - case 'contests': - $this->validateAndUpdateContest($entityType, $eventId, $operation, $dataItem); - break; - case 'judgement-types': - $this->importJudgementType($entityType, $eventId, $operation, $dataItem); - break; - case 'languages': - $this->validateLanguage($entityType, $eventId, $operation, $dataItem); - break; - case 'groups': - $this->validateAndUpdateGroup($entityType, $eventId, $operation, $dataItem); - break; - case 'organizations': - $this->validateAndUpdateOrganization($entityType, $eventId, $operation, $dataItem); - break; - case 'problems': - $this->validateAndUpdateProblem($entityType, $eventId, $operation, $dataItem); - break; - case 'teams': - $this->validateAndUpdateTeam($entityType, $eventId, $operation, $dataItem); - break; - case 'clarifications': - $this->importClarification($entityType, $eventId, $operation, $dataItem); - break; - case 'submissions': - $this->importSubmission($entityType, $eventId, $operation, $dataItem); - break; - case 'judgements': - $this->importJudgement($entityType, $eventId, $operation, $dataItem); - break; - case 'runs': - $this->importRun($entityType, $eventId, $operation, $dataItem); - break; - } + [$event->type->value]); + } + + // Note the @vars here are to make PHPStan understand the correct types. + $method = match ($event->type) { + EventType::AWARDS, EventType::TEAM_MEMBERS, EventType::ACCOUNTS => $this->ignoreEvent(...), + EventType::STATE => $this->validateState(...), + EventType::CONTESTS => $this->validateAndUpdateContest(...), + EventType::JUDGEMENT_TYPES => $this->importJudgementType(...), + EventType::LANGUAGES => $this->validateLanguage(...), + EventType::GROUPS => $this->validateAndUpdateGroup(...), + EventType::ORGANIZATIONS => $this->validateAndUpdateOrganization(...), + EventType::PROBLEMS => $this->validateAndUpdateProblem(...), + EventType::TEAMS => $this->validateAndUpdateTeam(...), + EventType::CLARIFICATIONS => $this->importClarification(...), + EventType::SUBMISSIONS => $this->importSubmission(...), + EventType::JUDGEMENTS => $this->importJudgement(...), + EventType::RUNS => $this->importRun(...), + }; + + foreach ($event->data as $eventData) { + $method($event, $eventData); } } /** - * @param array{id: string, name: string, duration: string, scoreboard_type: string, penalty_time: int, - * formal_name?: string, start_time?: string|null, countdown_pause_time?: int|null, - * scoreboard_freeze_duration: string|null, scoreboard_thaw_time?: string|null, - * banner: array{0: array{href: string, filename: string, mime: string, width: int, height: int}}} $data + * @param Event $event + */ + protected function ignoreEvent(Event $event, EventData $data): void + { + $this->logger->debug("Ignoring event of type %s", [$event->type->value]); + } + + /** + * @param Event $event + */ + protected function validateState(Event $event, EventData $data): void + { + if (!$data instanceof StateEvent) { + throw new InvalidArgumentException('Invalid event data type'); + } + if ($data->endOfUpdates) { + $this->logger->info('End of updates encountered'); + } + } + + /** + * @param Event $event + * * @throws NonUniqueResultException */ - protected function validateAndUpdateContest(string $entityType, ?string $eventId, string $operation, array $data): void + protected function validateAndUpdateContest(Event $event, EventData $data): void { + if (!$data instanceof ContestEvent) { + throw new InvalidArgumentException('Invalid event data type'); + } if (!$this->warningIfUnsupported( - $operation, - $eventId, - $entityType, - $data['id'], + $event, + $data->id, [EventLogService::ACTION_CREATE, EventLogService::ACTION_UPDATE]) ) { return; @@ -727,30 +694,30 @@ protected function validateAndUpdateContest(string $entityType, ?string $eventId // We need to convert the freeze to a value from the start instead of // the end so perform some regex magic. - $duration = $data['duration']; - $freeze = $data['scoreboard_freeze_duration']; + $duration = $data->duration; + $freeze = $data->scoreboardFreezeDuration; $reltimeRegex = '/^(-)?(\d+):(\d{2}):(\d{2})(?:\.(\d{3}))?$/'; preg_match($reltimeRegex, $duration, $durationData); $durationNegative = ($durationData[1] === '-'); - $fullDuration = $durationNegative ? $duration : ('+' . $duration); + $fullDuration = $durationNegative ? $duration : ('+' . $duration); if ($freeze !== null) { preg_match($reltimeRegex, $freeze, $freezeData); - $freezeNegative = ($freezeData[1] === '-'); - $freezeHourModifier = $freezeNegative ? -1 : 1; - $freezeInSeconds = $freezeHourModifier * (int)$freezeData[2] * 3600 - + 60 * (int)$freezeData[3] - + (double)sprintf('%d.%03d', $freezeData[4], $freezeData[5]); + $freezeNegative = ($freezeData[1] === '-'); + $freezeHourModifier = $freezeNegative ? -1 : 1; + $freezeInSeconds = $freezeHourModifier * (int)$freezeData[2] * 3600 + + 60 * (int)$freezeData[3] + + (double)sprintf('%d.%03d', $freezeData[4], $freezeData[5]); $durationHourModifier = $durationNegative ? -1 : 1; - $durationInSeconds = $durationHourModifier * (int)$durationData[2] * 3600 - + 60 * (int)$durationData[3] - + (double)sprintf('%d.%03d', $durationData[4], $durationData[5]); - $freezeStartSeconds = $durationInSeconds - $freezeInSeconds; - $freezeHour = floor($freezeStartSeconds / 3600); - $freezeMinutes = floor(($freezeStartSeconds % 3600) / 60); - $freezeSeconds = floor(($freezeStartSeconds % 60) / 60); - $freezeMilliseconds = $freezeStartSeconds - floor($freezeStartSeconds); + $durationInSeconds = $durationHourModifier * (int)$durationData[2] * 3600 + + 60 * (int)$durationData[3] + + (double)sprintf('%d.%03d', $durationData[4], $durationData[5]); + $freezeStartSeconds = $durationInSeconds - $freezeInSeconds; + $freezeHour = floor($freezeStartSeconds / 3600); + $freezeMinutes = floor(($freezeStartSeconds % 3600) / 60); + $freezeSeconds = floor(($freezeStartSeconds % 60) / 60); + $freezeMilliseconds = $freezeStartSeconds - floor($freezeStartSeconds); $fullFreeze = sprintf( '%s%d:%02d:%02d.%03d', @@ -766,13 +733,13 @@ protected function validateAndUpdateContest(string $entityType, ?string $eventId // The timezones are given in ISO 8601 and we only support names. // This is why we will use the platform default timezone and just verify it matches. - $startTime = isset($data['start_time']) ? new DateTime($data['start_time']) : null; + $startTime = $data->startTime ? new DateTime($data->startTime) : null; if ($startTime !== null) { // We prefer to use our default timezone, since that is a timezone name // The feed only has timezone offset, so we will only use it if the offset // differs from our local timezone offset $timezoneToUse = date_default_timezone_get(); - $feedTimezone = new DateTimeZone($startTime->format('e')); + $feedTimezone = new DateTimeZone($startTime->format('e')); if ($contest->getStartTimeObject()) { $ourTimezone = new DateTimeZone($contest->getStartTimeObject()->format('e')); } else { @@ -787,9 +754,9 @@ protected function validateAndUpdateContest(string $entityType, ?string $eventId } $toCheck = [ 'start_time_enabled' => true, - 'start_time_string' => $startTime->format('Y-m-d H:i:s ') . $timezoneToUse, - 'end_time' => $contest->getAbsoluteTime($fullDuration), - 'freeze_time' => $contest->getAbsoluteTime($fullFreeze), + 'start_time_string' => $startTime->format('Y-m-d H:i:s ') . $timezoneToUse, + 'end_time' => $contest->getAbsoluteTime($fullDuration), + 'freeze_time' => $contest->getAbsoluteTime($fullFreeze), ]; } else { $toCheck = [ @@ -797,10 +764,10 @@ protected function validateAndUpdateContest(string $entityType, ?string $eventId ]; } - $toCheck['name'] = $data['name']; + $toCheck['name'] = $data->name; // Also compare the penalty time - $penaltyTime = (int)$data['penalty_time']; + $penaltyTime = $data->penaltyTime; if ($this->config->get('penalty_time') != $penaltyTime) { $this->logger->warning( 'Penalty time does not match between feed (%d) and local (%d)', @@ -808,34 +775,38 @@ protected function validateAndUpdateContest(string $entityType, ?string $eventId ); } - $this->compareOrCreateValues($eventId, $entityType, $data['id'], $contest, $toCheck); + $this->compareOrCreateValues($event, $data->id, $contest, $toCheck); $this->em->flush(); $this->eventLog->log('contests', $contest->getCid(), EventLogService::ACTION_UPDATE, $this->getSourceContestId()); } /** - * @param array{id: string, name: string, penalty: bool, solved: bool} $data description + * @param Event $event */ - protected function importJudgementType(string $entityType, ?string $eventId, string $operation, array $data): void + protected function importJudgementType(Event $event, EventData $data): void { - if (!$this->warningIfUnsupported($operation, $eventId, $entityType, $data['id'], [EventLogService::ACTION_CREATE])) { + if (!$data instanceof JudgementTypeEvent) { + throw new InvalidArgumentException('Invalid event data type'); + } + + if (!$this->warningIfUnsupported($event, $data->id, [EventLogService::ACTION_CREATE])) { return; } - $verdict = $data['id']; + $verdict = $data->id; $verdictsFlipped = array_flip($this->verdicts); if (!isset($verdictsFlipped[$verdict])) { // Verdict not found, import it as a custom verdict; assume it has a penalty. $customVerdicts = $this->config->get('external_judgement_types'); - $customVerdicts[$verdict] = str_replace(' ', '-', $data['name']); + $customVerdicts[$verdict] = str_replace(' ', '-', $data->name); $this->config->saveChanges(['external_judgement_types' => $customVerdicts], $this->eventLog, $this->dj); $this->verdicts = $this->dj->getVerdicts(mergeExternal: true); $penalty = true; $solved = false; $this->logger->warning('Judgement type %s not found locally, importing as external verdict', [$verdict]); } else { - $this->removeWarning($entityType, $data['id'], ExternalSourceWarning::TYPE_ENTITY_NOT_FOUND); + $this->removeWarning($event->type, $data->id, ExternalSourceWarning::TYPE_ENTITY_NOT_FOUND); $penalty = true; $solved = false; if ($verdict === 'AC') { @@ -848,63 +819,67 @@ protected function importJudgementType(string $entityType, ?string $eventId, str $extraDiff = []; - if ($penalty !== $data['penalty']) { - $extraDiff['penalty'] = [$penalty, $data['penalty']]; + if ($penalty !== $data->penalty) { + $extraDiff['penalty'] = [$penalty, $data->penalty]; } - if ($solved !== $data['solved']) { - $extraDiff['solved'] = [$solved, $data['solved']]; + if ($solved !== $data->solved) { + $extraDiff['solved'] = [$solved, $data->solved]; } // Entity doesn't matter, since we do not compare anything besides the extra data - $this->compareOrCreateValues($eventId, $entityType, $data['id'], $this->source->getContest(), [], $extraDiff, false); + $this->compareOrCreateValues($event, $data->id, $this->source->getContest(), [], $extraDiff, false); } /** - * @param array{id: string, name: string, entry_point_required: true, entry_point_name?: string|null, - * extensions: string[], - * compiler?: array{command: string, args?: string, version?: string, version_command?: string}, - * runner?: array{command: string, args?: string, version?: string, version_command?: string}} $data + * @param Event $event */ - protected function validateLanguage(string $entityType, ?string $eventId, string $operation, array $data): void + protected function validateLanguage(Event $event, EventData $data): void { - if (!$this->warningIfUnsupported($operation, $eventId, $entityType, $data['id'], [EventLogService::ACTION_CREATE])) { + if (!$data instanceof LanguageEvent) { + throw new InvalidArgumentException('Invalid event data type'); + } + + if (!$this->warningIfUnsupported($event, $data->id, [EventLogService::ACTION_CREATE])) { return; } - $extId = $data['id']; + $extId = $data->id; $language = $this->em ->getRepository(Language::class) ->findOneBy(['externalid' => $extId]); if (!$language) { - $this->addOrUpdateWarning($eventId, $entityType, $data['id'], ExternalSourceWarning::TYPE_ENTITY_NOT_FOUND); + $this->addOrUpdateWarning($event, $data->id, ExternalSourceWarning::TYPE_ENTITY_NOT_FOUND); } elseif (!$language->getAllowSubmit()) { - $this->addOrUpdateWarning($eventId, $entityType, $data['id'], ExternalSourceWarning::TYPE_DATA_MISMATCH, [ + $this->addOrUpdateWarning($event, $data->id, ExternalSourceWarning::TYPE_DATA_MISMATCH, [ 'diff' => [ 'allow_submit' => [ - 'us' => false, + 'us' => false, 'external' => true, - ] - ] + ], + ], ]); } else { - $this->removeWarning($entityType, $data['id'], ExternalSourceWarning::TYPE_DATA_MISMATCH); + $this->removeWarning($event->type, $data->id, ExternalSourceWarning::TYPE_DATA_MISMATCH); } } /** - * @param array{id: string, name: string, icpc_id?: string|null, type?: string|null, location?: string|null, - * hidden?: bool, sortorder?: int|null, color?: string|null} $data + * @param Event $event */ - protected function validateAndUpdateGroup(string $entityType, ?string $eventId, string $operation, array $data): void + protected function validateAndUpdateGroup(Event $event, EventData $data): void { - $groupId = $data['id']; + if (!$data instanceof GroupEvent) { + throw new InvalidArgumentException('Invalid event data type'); + } + + $groupId = $data->id; /** @var TeamCategory|null $category */ $category = $this->em ->getRepository(TeamCategory::class) ->findOneBy(['externalid' => $groupId]); - if ($operation === EventLogService::ACTION_DELETE) { + if ($event->operation->value === EventLogService::ACTION_DELETE) { // Delete category if we still have it if ($category) { $this->logger->warning( @@ -922,7 +897,7 @@ protected function validateAndUpdateGroup(string $entityType, ?string $eventId, if (!$category) { $this->logger->warning( 'Category with name %s should exist, creating', - [$data['name']] + [$data->name] ); $category = new TeamCategory(); $this->em->persist($category); @@ -930,21 +905,21 @@ protected function validateAndUpdateGroup(string $entityType, ?string $eventId, } $toCheck = [ - 'externalid' => $data['id'], - 'name' => $data['name'], - 'visible' => !($data['hidden'] ?? false), - 'icpcid' => $data['icpc_id'] ?? null, + 'externalid' => $data->id, + 'name' => $data->name, + 'visible' => !($data->hidden ?? false), + 'icpcid' => $data->icpcId, ]; // Add DOMjudge specific fields that might be useful to import - if (isset($data['sortorder'])) { - $toCheck['sortorder'] = $data['sortorder']; + if (isset($data->sortorder)) { + $toCheck['sortorder'] = $data->sortorder; } - if (isset($data['color'])) { - $toCheck['color'] = $data['color']; + if (isset($data->color)) { + $toCheck['color'] = $data->color; } - $this->compareOrCreateValues($eventId, $entityType, $data['id'], $category, $toCheck); + $this->compareOrCreateValues($event, $data->id, $category, $toCheck); $this->em->flush(); $this->eventLog->log('groups', $category->getCategoryid(), $action, $this->getSourceContestId()); @@ -953,20 +928,22 @@ protected function validateAndUpdateGroup(string $entityType, ?string $eventId, } /** - * @param array{id: string, name: string, icpc_id?: string|null, formal_name?: string|null, country?: string, - * country_flag?: array{0: array}, url?: string, twitter_hashtag?: string, - * twitter_account?: string, location?: array{0: array}, logo?: array{0: array}} $data + * @param Event $event */ - protected function validateAndUpdateOrganization(string $entityType, ?string $eventId, string $operation, array $data): void + protected function validateAndUpdateOrganization(Event $event, EventData $data): void { - $organizationId = $data['id']; + if (!$data instanceof OrganizationEvent) { + throw new InvalidArgumentException('Invalid event data type'); + } + + $organizationId = $data->id; /** @var TeamAffiliation|null $affiliation */ $affiliation = $this->em ->getRepository(TeamAffiliation::class) ->findOneBy(['externalid' => $organizationId]); - if ($operation === EventLogService::ACTION_DELETE) { + if ($event->operation->value === EventLogService::ACTION_DELETE) { // Delete affiliation if we still have it if ($affiliation) { $this->logger->warning( @@ -984,26 +961,26 @@ protected function validateAndUpdateOrganization(string $entityType, ?string $ev if (!$affiliation) { $this->logger->warning( 'Affiliation with name %s should exist, creating', - [$data['formal_name'] ?? $data['name']] + [$data->formalName ?? $data->name] ); $affiliation = new TeamAffiliation(); $this->em->persist($affiliation); $action = EventLogService::ACTION_CREATE; } - $this->removeWarning($entityType, $data['id'], ExternalSourceWarning::TYPE_ENTITY_NOT_FOUND); + $this->removeWarning($event->type, $data->id, ExternalSourceWarning::TYPE_ENTITY_NOT_FOUND); $toCheck = [ - 'externalid' => $data['id'], - 'name' => $data['formal_name'] ?? $data['name'], - 'shortname' => $data['name'], - 'icpcid' => $data['icpc_id'] ?? null, + 'externalid' => $data->id, + 'name' => $data->formalName ?? $data->name, + 'shortname' => $data->name, + 'icpcid' => $data->icpcId, ]; - if (isset($data['country'])) { - $toCheck['country'] = $data['country']; + if (isset($data->country)) { + $toCheck['country'] = $data->country; } - $this->compareOrCreateValues($eventId, $entityType, $data['id'], $affiliation, $toCheck); + $this->compareOrCreateValues($event, $data->id, $affiliation, $toCheck); $this->em->flush(); $this->eventLog->log('organizations', $affiliation->getAffilid(), $action, $this->getSourceContestId()); @@ -1012,20 +989,27 @@ protected function validateAndUpdateOrganization(string $entityType, ?string $ev } /** - * @param array{id: string, name: string, time_limit: int, label?: string|null, rgb?: string|null} $data + * @param Event $event */ - protected function validateAndUpdateProblem(string $entityType, ?string $eventId, string $operation, array $data): void + protected function validateAndUpdateProblem(Event $event, EventData $data): void { - if (!$this->warningIfUnsupported($operation, $eventId, $entityType, $data['id'], [EventLogService::ACTION_CREATE, EventLogService::ACTION_UPDATE])) { + if (!$data instanceof ProblemEvent) { + throw new InvalidArgumentException('Invalid event data type'); + } + + if (!$this->warningIfUnsupported($event, $data->id, [ + EventLogService::ACTION_CREATE, + EventLogService::ACTION_UPDATE, + ])) { return; } - $problemId = $data['id']; + $problemId = $data->id; // First, load the problem. $problem = $this->em->getRepository(Problem::class)->findOneBy(['externalid' => $problemId]); if (!$problem) { - $this->addOrUpdateWarning($eventId, $entityType, $data['id'], ExternalSourceWarning::TYPE_ENTITY_NOT_FOUND); + $this->addOrUpdateWarning($event, $data->id, ExternalSourceWarning::TYPE_ENTITY_NOT_FOUND); return; } @@ -1033,39 +1017,39 @@ protected function validateAndUpdateProblem(string $entityType, ?string $eventId $contestProblem = $this->em ->getRepository(ContestProblem::class) ->find([ - 'contest' => $this->getSourceContest(), - 'problem' => $problem, - ]); + 'contest' => $this->getSourceContest(), + 'problem' => $problem, + ]); if (!$contestProblem) { // Note: we can't handle updates to non-existing problems, since we require things // like the testcases - $this->addOrUpdateWarning($eventId, $entityType, $data['id'], ExternalSourceWarning::TYPE_ENTITY_NOT_FOUND); + $this->addOrUpdateWarning($event, $data->id, ExternalSourceWarning::TYPE_ENTITY_NOT_FOUND); return; } - $this->removeWarning($entityType, $data['id'], ExternalSourceWarning::TYPE_ENTITY_NOT_FOUND); + $this->removeWarning($event->type, $data->id, ExternalSourceWarning::TYPE_ENTITY_NOT_FOUND); $toCheckProblem = [ - 'name' => $data['name'], - 'timelimit' => $data['time_limit'], + 'name' => $data->name, + 'timelimit' => $data->timeLimit, ]; - if ($contestProblem->getShortname() !== $data['label']) { + if ($contestProblem->getShortname() !== $data->label) { $this->logger->warning( 'Contest problem short name does not match between feed (%s) and local (%s), updating', - [$data['label'], $contestProblem->getShortname()] + [$data->label, $contestProblem->getShortname()] ); - $contestProblem->setShortname($data['label']); + $contestProblem->setShortname($data->label); } - if ($contestProblem->getColor() !== ($data['rgb'] ?? null)) { + if ($contestProblem->getColor() !== ($data->rgb)) { $this->logger->warning( 'Contest problem color does not match between feed (%s) and local (%s), updating', - [$data['rgb'] ?? null, $contestProblem->getColor()] + [$data->rgb, $contestProblem->getColor()] ); - $contestProblem->setColor($data['rgb'] ?? null); + $contestProblem->setColor($data->rgb); } - $this->compareOrCreateValues($eventId, $entityType, $data['id'], $problem, $toCheckProblem); + $this->compareOrCreateValues($event, $data->id, $problem, $toCheckProblem); $this->em->flush(); $this->eventLog->log('problems', $problem->getProbid(), EventLogService::ACTION_UPDATE, $this->getSourceContestId()); @@ -1074,19 +1058,22 @@ protected function validateAndUpdateProblem(string $entityType, ?string $eventId } /** - * @param array{id: string, name: string, formal_name?: string|null, icpc_id?: string|null, country?: string|null, - * organization_id?: string|null, group_ids?: string[], display_name?: string|null, country?: string|null} $data + * @param Event $event */ - protected function validateAndUpdateTeam(string $entityType, ?string $eventId, string $operation, array $data): void + protected function validateAndUpdateTeam(Event $event, EventData $data): void { - $teamId = $data['id']; + if (!$data instanceof TeamEvent) { + throw new InvalidArgumentException('Invalid event data type'); + } + + $teamId = $data->id; /** @var Team|null $team */ $team = $this->em ->getRepository(Team::class) ->findOneBy(['externalid' => $teamId]); - if ($operation === EventLogService::ACTION_DELETE) { + if ($event->operation->value === EventLogService::ACTION_DELETE) { // Delete team if we still have it if ($team) { $this->logger->warning( @@ -1104,15 +1091,15 @@ protected function validateAndUpdateTeam(string $entityType, ?string $eventId, s if (!$team) { $this->logger->warning( 'Team with name %s should exist, creating', - [$data['formal_name'] ?? $data['name']] + [$data->formalName ?? $data->name] ); $team = new Team(); $this->em->persist($team); $action = EventLogService::ACTION_CREATE; } - if (!empty($data['organization_id'])) { - $affiliation = $this->em->getRepository(TeamAffiliation::class)->findOneBy(['externalid' => $data['organization_id']]); + if (!empty($data->organizationId)) { + $affiliation = $this->em->getRepository(TeamAffiliation::class)->findOneBy(['externalid' => $data->organizationId]); if (!$affiliation) { $affiliation = new TeamAffiliation(); $this->em->persist($affiliation); @@ -1120,8 +1107,8 @@ protected function validateAndUpdateTeam(string $entityType, ?string $eventId, s $team->setAffiliation($affiliation); } - if (!empty($data['group_ids'][0])) { - $category = $this->em->getRepository(TeamCategory::class)->findOneBy(['externalid' => $data['group_ids'][0]]); + if (!empty($data->groupIds[0])) { + $category = $this->em->getRepository(TeamCategory::class)->findOneBy(['externalid' => $data->groupIds[0]]); if (!$category) { $category = new TeamCategory(); $this->em->persist($category); @@ -1129,21 +1116,21 @@ protected function validateAndUpdateTeam(string $entityType, ?string $eventId, s $team->setCategory($category); } - $this->removeWarning($entityType, $data['id'], ExternalSourceWarning::TYPE_ENTITY_NOT_FOUND); + $this->removeWarning($event->type, $data->id, ExternalSourceWarning::TYPE_ENTITY_NOT_FOUND); $toCheck = [ - 'externalid' => $data['id'], - 'name' => $data['formal_name'] ?? $data['name'], - 'display_name' => $data['display_name'] ?? null, - 'affiliation.externalid' => $data['organization_id'] ?? null, - 'category.externalid' => $data['group_ids'][0] ?? null, - 'icpcid' => $data['icpc_id'] ?? null, + 'externalid' => $data->id, + 'name' => $data->formalName ?? $data->name, + 'display_name' => $data->displayName, + 'affiliation.externalid' => $data->organizationId, + 'category.externalid' => $data->groupIds[0] ?? null, + 'icpcid' => $data->icpcId, ]; - if (isset($data['country'])) { - $toCheck['country'] = $data['country']; + if (isset($data->country)) { + $toCheck['country'] = $data->country; } - $this->compareOrCreateValues($eventId, $entityType, $data['id'], $team, $toCheck); + $this->compareOrCreateValues($event, $data->id, $team, $toCheck); $this->em->flush(); $this->eventLog->log('teams', $team->getTeamid(), $action, $this->getSourceContestId()); @@ -1152,36 +1139,40 @@ protected function validateAndUpdateTeam(string $entityType, ?string $eventId, s } /** - * @param array{id: string, text: string, time: string, contest_time: string, from_team_id?: string|null, - * to_team_id?: string|null, reply_to_id: string|null, problem_id?: string|null} $data + * @param Event $event + * * @throws NonUniqueResultException */ - protected function importClarification(string $entityType, ?string $eventId, string $operation, array $data): void + protected function importClarification(Event $event, EventData $data): void { - $clarificationId = $data['id']; + if (!$data instanceof ClarificationEvent) { + throw new InvalidArgumentException('Invalid event data type'); + } + + $clarificationId = $data->id; - if ($operation === EventLogService::ACTION_DELETE) { + if ($event->operation->value === EventLogService::ACTION_DELETE) { // We need to delete the team $clarification = $this->em ->getRepository(Clarification::class) ->findOneBy([ - 'contest' => $this->getSourceContest(), - 'externalid' => $clarificationId - ]); + 'contest' => $this->getSourceContest(), + 'externalid' => $clarificationId, + ]); if ($clarification) { $this->em->remove($clarification); $this->em->flush(); $this->eventLog->log('clarifications', $clarification->getClarid(), - EventLogService::ACTION_DELETE, - $this->getSourceContestId(), null, - $clarification->getExternalid()); + EventLogService::ACTION_DELETE, + $this->getSourceContestId(), null, + $clarification->getExternalid()); return; } else { - $this->addOrUpdateWarning($eventId, $entityType, $data['id'], ExternalSourceWarning::TYPE_ENTITY_NOT_FOUND); + $this->addOrUpdateWarning($event, $data->id, ExternalSourceWarning::TYPE_ENTITY_NOT_FOUND); } - $this->removeWarning($entityType, $data['id'], ExternalSourceWarning::TYPE_ENTITY_NOT_FOUND); + $this->removeWarning($event->type, $data->id, ExternalSourceWarning::TYPE_ENTITY_NOT_FOUND); return; } @@ -1189,9 +1180,9 @@ protected function importClarification(string $entityType, ?string $eventId, str $clarification = $this->em ->getRepository(Clarification::class) ->findOneBy([ - 'contest' => $this->getSourceContest(), - 'externalid' => $clarificationId - ]); + 'contest' => $this->getSourceContest(), + 'externalid' => $clarificationId, + ]); if ($clarification) { $action = EventLogService::ACTION_UPDATE; } else { @@ -1201,64 +1192,64 @@ protected function importClarification(string $entityType, ?string $eventId, str } // Now check if we have all dependent data. - $fromTeamId = $data['from_team_id'] ?? null; - $fromTeam = null; + $fromTeamId = $data->fromTeamId; + $fromTeam = null; if ($fromTeamId !== null) { $fromTeam = $this->em ->getRepository(Team::class) ->findOneBy(['externalid' => $fromTeamId]); if (!$fromTeam) { - $this->addPendingEvent('team', $fromTeamId, $operation, $entityType, $eventId, $data); + $this->addPendingEvent('team', $fromTeamId, $event, $data); return; } } - $toTeamId = $data['to_team_id'] ?? null; - $toTeam = null; + $toTeamId = $data->toTeamId; + $toTeam = null; if ($toTeamId !== null) { $toTeam = $this->em ->getRepository(Team::class) ->findOneBy(['externalid' => $toTeamId]); if (!$toTeam) { - $this->addPendingEvent('team', $toTeamId, $operation, $entityType, $eventId, $data); + $this->addPendingEvent('team', $toTeamId, $event, $data); return; } } - $inReplyToId = $data['reply_to_id'] ?? null; - $inReplyTo = null; + $inReplyToId = $data->replyToId; + $inReplyTo = null; if ($inReplyToId !== null) { $inReplyTo = $this->em ->getRepository(Clarification::class) ->findOneBy([ - 'contest' => $this->getSourceContest(), - 'externalid' => $inReplyToId - ]); + 'contest' => $this->getSourceContest(), + 'externalid' => $inReplyToId, + ]); if (!$inReplyTo) { - $this->addPendingEvent('clarification', $inReplyToId, $operation, $entityType, $eventId, $data); + $this->addPendingEvent('clarification', $inReplyToId, $event, $data); return; } } - $problemId = $data['problem_id'] ?? null; - $problem = null; + $problemId = $data->problemId; + $problem = null; if ($problemId !== null) { $problem = $this->em ->getRepository(Problem::class) ->findOneBy(['externalid' => $problemId]); if (!$problem) { - $this->addPendingEvent('problem', $problemId, $operation, $entityType, $eventId, $data); + $this->addPendingEvent('problem', $problemId, $event, $data); return; } } - $this->removeWarning($entityType, $data['id'], ExternalSourceWarning::TYPE_DEPENDENCY_MISSING); + $this->removeWarning($event->type, $data->id, ExternalSourceWarning::TYPE_DEPENDENCY_MISSING); $contest = $this->em ->getRepository(Contest::class) ->find($this->getSourceContestId()); - $submitTime = Utils::toEpochFloat($data['time']); + $submitTime = Utils::toEpochFloat($data->time); $clarification ->setInReplyTo($inReplyTo) @@ -1266,7 +1257,7 @@ protected function importClarification(string $entityType, ?string $eventId, str ->setRecipient($toTeam) ->setProblem($problem) ->setContest($contest) - ->setBody($data['text']) + ->setBody($data->text) ->setSubmittime($submitTime); if ($inReplyTo) { @@ -1287,40 +1278,42 @@ protected function importClarification(string $entityType, ?string $eventId, str } $this->em->flush(); $this->eventLog->log('clarifications', $clarification->getClarid(), $action, - $this->getSourceContestId()); + $this->getSourceContestId()); $this->processPendingEvents('clarification', $clarification->getExternalid()); } /** - * @param array{id: string, language_id: string, problem_id: string, team_id: string, - * time: string, contest_time: string, entry_point?: string|null, - * files: array, - * reaction?: array>} $data + * @param Event $event + * * @throws TransportExceptionInterface * @throws DBALException * @throws NonUniqueResultException */ - protected function importSubmission(string $entityType, ?string $eventId, string $operation, array $data): void + protected function importSubmission(Event $event, EventData $data): void { - $submissionId = $data['id']; + if (!$data instanceof SubmissionEvent) { + throw new InvalidArgumentException('Invalid event data type'); + } + + $submissionId = $data->id; - if ($operation === EventLogService::ACTION_DELETE) { + if ($event->operation->value === EventLogService::ACTION_DELETE) { // We need to mark the submission as not valid and then emit a delete event. $submission = $this->em ->getRepository(Submission::class) ->findOneBy([ - 'contest' => $this->getSourceContest(), - 'externalid' => $submissionId - ]); + 'contest' => $this->getSourceContest(), + 'externalid' => $submissionId, + ]); if ($submission) { $this->markSubmissionAsValidAndRecalcScore($submission, false); return; } else { - $this->addOrUpdateWarning($eventId, $entityType, $data['id'], ExternalSourceWarning::TYPE_ENTITY_NOT_FOUND); + $this->addOrUpdateWarning($event, $data->id, ExternalSourceWarning::TYPE_ENTITY_NOT_FOUND); } - $this->removeWarning($entityType, $data['id'], ExternalSourceWarning::TYPE_ENTITY_NOT_FOUND); + $this->removeWarning($event->type, $data->id, ExternalSourceWarning::TYPE_ENTITY_NOT_FOUND); return; } @@ -1328,14 +1321,14 @@ protected function importSubmission(string $entityType, ?string $eventId, string $submission = $this->em ->getRepository(Submission::class) ->findOneBy([ - 'contest' => $this->getSourceContest(), - 'externalid' => $submissionId - ]); + 'contest' => $this->getSourceContest(), + 'externalid' => $submissionId, + ]); - $languageId = $data['language_id']; + $languageId = $data->languageId; $language = $this->em->getRepository(Language::class)->findOneBy(['externalid' => $languageId]); if (!$language) { - $this->addOrUpdateWarning($eventId, $entityType, $data['id'], ExternalSourceWarning::TYPE_DEPENDENCY_MISSING, [ + $this->addOrUpdateWarning($event, $data->id, ExternalSourceWarning::TYPE_DEPENDENCY_MISSING, [ 'dependencies' => [ ['type' => 'language', 'id' => $languageId], ], @@ -1343,12 +1336,12 @@ protected function importSubmission(string $entityType, ?string $eventId, string return; } - $this->removeWarning($entityType, $data['id'], ExternalSourceWarning::TYPE_DEPENDENCY_MISSING); + $this->removeWarning($event->type, $data->id, ExternalSourceWarning::TYPE_DEPENDENCY_MISSING); - $problemId = $data['problem_id']; + $problemId = $data->problemId; $problem = $this->em->getRepository(Problem::class)->findOneBy(['externalid' => $problemId]); if (!$problem) { - $this->addPendingEvent('problem', $problemId, $operation, $entityType, $eventId, $data); + $this->addPendingEvent('problem', $problemId, $event, $data); return; } @@ -1356,12 +1349,12 @@ protected function importSubmission(string $entityType, ?string $eventId, string $contestProblem = $this->em ->getRepository(ContestProblem::class) ->findOneBy([ - 'contest' => $this->getSourceContest(), - 'problem' => $problem, - ]); + 'contest' => $this->getSourceContest(), + 'problem' => $problem, + ]); if (!$contestProblem) { - $this->addOrUpdateWarning($eventId, $entityType, $data['id'], ExternalSourceWarning::TYPE_DEPENDENCY_MISSING, [ + $this->addOrUpdateWarning($event, $data->id, ExternalSourceWarning::TYPE_DEPENDENCY_MISSING, [ 'dependencies' => [ ['type' => 'contest-problem', 'id' => $problem->getExternalid()], ], @@ -1369,18 +1362,18 @@ protected function importSubmission(string $entityType, ?string $eventId, string return; } - $teamId = $data['team_id']; + $teamId = $data->teamId; $team = $this->em->getRepository(Team::class)->findOneBy(['externalid' => $teamId]); if (!$team) { - $this->addPendingEvent('team', $teamId, $operation, $entityType, $eventId, $data); + $this->addPendingEvent('team', $teamId, $event, $data); return; } - $this->removeWarning($entityType, $data['id'], ExternalSourceWarning::TYPE_DEPENDENCY_MISSING); + $this->removeWarning($event->type, $data->id, ExternalSourceWarning::TYPE_DEPENDENCY_MISSING); - $submitTime = Utils::toEpochFloat($data['time']); + $submitTime = Utils::toEpochFloat($data->time); - $entryPoint = $data['entry_point'] ?? null; + $entryPoint = $data->entryPoint; if (empty($entryPoint)) { $entryPoint = null; } @@ -1393,26 +1386,26 @@ protected function importSubmission(string $entityType, ?string $eventId, string $diff = []; if ($submission->getTeam()->getTeamid() !== $team->getTeamid()) { $diff['team_id'] = [ - 'us' => $submission->getTeam()->getExternalid(), - 'external' => $team->getExternalid() + 'us' => $submission->getTeam()->getExternalid(), + 'external' => $team->getExternalid(), ]; } if ($submission->getProblem()->getExternalid() !== $problem->getExternalid()) { $diff['problem_id'] = [ - 'us' => $submission->getProblem()->getExternalid(), - 'external' => $problem->getExternalid() + 'us' => $submission->getProblem()->getExternalid(), + 'external' => $problem->getExternalid(), ]; } if ($submission->getLanguage()->getExternalid() !== $language->getExternalid()) { $diff['language_id'] = [ - 'us' => $submission->getLanguage()->getExternalid(), - 'external' => $language->getExternalid() + 'us' => $submission->getLanguage()->getExternalid(), + 'external' => $language->getExternalid(), ]; } if (abs(Utils::difftime((float)$submission->getSubmittime(), $submitTime)) >= 1) { $diff['time'] = [ - 'us' => $submission->getAbsoluteSubmitTime(), - 'external' => $data['time'] + 'us' => $submission->getAbsoluteSubmitTime(), + 'external' => $data->time, ]; } if ($entryPoint !== $submission->getEntryPoint()) { @@ -1421,22 +1414,22 @@ protected function importSubmission(string $entityType, ?string $eventId, string $submission->setEntryPoint($entryPoint); $this->em->flush(); $this->eventLog->log('submissions', $submission->getSubmitid(), - EventLogService::ACTION_UPDATE, $this->getSourceContestId()); + EventLogService::ACTION_UPDATE, $this->getSourceContestId()); $this->processPendingEvents('submission', $submission->getExternalid()); return; } elseif ($entryPoint !== null) { $diff['entry_point'] = [ - 'us' => $submission->getEntryPoint(), - 'external' => $entryPoint + 'us' => $submission->getEntryPoint(), + 'external' => $entryPoint, ]; } } if (!empty($diff)) { - $this->addOrUpdateWarning($eventId, $entityType, $data['id'], ExternalSourceWarning::TYPE_DATA_MISMATCH, ['diff' => $diff]); + $this->addOrUpdateWarning($event, $data->id, ExternalSourceWarning::TYPE_DATA_MISMATCH, ['diff' => $diff]); return; } - $this->removeWarning($entityType, $data['id'], ExternalSourceWarning::TYPE_DATA_MISMATCH); + $this->removeWarning($event->type, $data->id, ExternalSourceWarning::TYPE_DATA_MISMATCH); // If the submission was not valid before, mark it valid now and recalculate the scoreboard. if (!$submission->getValid()) { @@ -1444,18 +1437,18 @@ protected function importSubmission(string $entityType, ?string $eventId, string } } else { // First, check if we actually have the source for this submission in the data. - if (empty($data['files'][0]['href'])) { - $this->addOrUpdateWarning($eventId, $entityType, $data['id'], ExternalSourceWarning::TYPE_SUBMISSION_ERROR, [ + if (empty($data->files[0]?->href)) { + $this->addOrUpdateWarning($event, $data->id, ExternalSourceWarning::TYPE_SUBMISSION_ERROR, [ 'message' => 'No source files in event', ]); $submissionDownloadSucceeded = false; - } elseif (($data['files'][0]['mime'] ?? null) !== 'application/zip') { - $this->addOrUpdateWarning($eventId, $entityType, $data['id'], ExternalSourceWarning::TYPE_SUBMISSION_ERROR, [ + } elseif (($data->files[0]->mime ?? null) !== 'application/zip') { + $this->addOrUpdateWarning($event, $data->id, ExternalSourceWarning::TYPE_SUBMISSION_ERROR, [ 'message' => 'Non-ZIP source files in event', ]); $submissionDownloadSucceeded = false; } else { - $zipUrl = $data['files'][0]['href']; + $zipUrl = $data->files[0]->href; if (preg_match('/^https?:\/\//', $zipUrl) === 0) { // Relative URL, prepend the base URL. $zipUrl = ($this->basePath ?? '') . $zipUrl; @@ -1472,7 +1465,7 @@ protected function importSubmission(string $entityType, ?string $eventId, string // No, download the ZIP file. $shouldUnlink = true; if (!($zipFile = tempnam($tmpdir, "submission_zip_"))) { - $this->addOrUpdateWarning($eventId, $entityType, $data['id'], ExternalSourceWarning::TYPE_SUBMISSION_ERROR, [ + $this->addOrUpdateWarning($event, $data->id, ExternalSourceWarning::TYPE_SUBMISSION_ERROR, [ 'message' => 'Cannot create temporary file to download ZIP', ]); $submissionDownloadSucceeded = false; @@ -1484,13 +1477,13 @@ protected function importSubmission(string $entityType, ?string $eventId, string $ziphandler = fopen($zipFile, 'w'); if ($response->getStatusCode() !== 200) { // TODO: Retry a couple of times. - $this->addOrUpdateWarning($eventId, $entityType, $data['id'], ExternalSourceWarning::TYPE_SUBMISSION_ERROR, [ + $this->addOrUpdateWarning($event, $data->id, ExternalSourceWarning::TYPE_SUBMISSION_ERROR, [ 'message' => 'Cannot download ZIP from ' . $zipUrl, ]); $submissionDownloadSucceeded = false; } } catch (TransportExceptionInterface $e) { - $this->addOrUpdateWarning($eventId, $entityType, $data['id'], ExternalSourceWarning::TYPE_SUBMISSION_ERROR, [ + $this->addOrUpdateWarning($event, $data->id, ExternalSourceWarning::TYPE_SUBMISSION_ERROR, [ 'message' => 'Cannot download ZIP from ' . $zipUrl . ': ' . $e->getMessage(), ]); if (isset($ziphandler)) { @@ -1523,7 +1516,7 @@ protected function importSubmission(string $entityType, ?string $eventId, string $content = $zip->getFromName($filename); if (!($tmpSubmissionFile = tempnam($tmpdir, "submission_source_"))) { - $this->addOrUpdateWarning($eventId, $entityType, $data['id'], ExternalSourceWarning::TYPE_SUBMISSION_ERROR, [ + $this->addOrUpdateWarning($event, $data->id, ExternalSourceWarning::TYPE_SUBMISSION_ERROR, [ 'message' => 'Cannot create temporary file to extract ZIP contents for file ' . $filename, ]); $submissionDownloadSucceeded = false; @@ -1545,7 +1538,7 @@ protected function importSubmission(string $entityType, ?string $eventId, string } // Submit the solution - $contest = $this->em->getRepository(Contest::class)->find($this->getSourceContestId()); + $contest = $this->em->getRepository(Contest::class)->find($this->getSourceContestId()); $submission = $this->submissionService->submitSolution( team: $team, user: null, @@ -1561,7 +1554,7 @@ protected function importSubmission(string $entityType, ?string $eventId, string forceImportInvalid: !$submissionDownloadSucceeded ); if (!$submission) { - $this->addOrUpdateWarning($eventId, $entityType, $data['id'], ExternalSourceWarning::TYPE_SUBMISSION_ERROR, [ + $this->addOrUpdateWarning($event, $data->id, ExternalSourceWarning::TYPE_SUBMISSION_ERROR, [ 'message' => 'Cannot add submission: ' . $message, ]); // Clean up the temporary submission files. @@ -1592,39 +1585,43 @@ protected function importSubmission(string $entityType, ?string $eventId, string } if ($submissionDownloadSucceeded) { - $this->removeWarning($entityType, $data['id'], ExternalSourceWarning::TYPE_SUBMISSION_ERROR); + $this->removeWarning($event->type, $data->id, ExternalSourceWarning::TYPE_SUBMISSION_ERROR); } $this->processPendingEvents('submission', $submission->getExternalid()); } /** - * @param array{start_time: string, start_contest_time: string, id: string, submission_id: string, - * max_run_time?: int|null, end_time?: string|null, output_compile_as_string?: string|null, judgement_type_id?: string|null} $data + * @param Event $event + * * @throws DBALException */ - protected function importJudgement(string $entityType, ?string $eventId, string $operation, array $data): void + protected function importJudgement(Event $event, EventData $data): void { + if (!$data instanceof JudgementEvent) { + throw new InvalidArgumentException('Invalid event data type'); + } + // Note that we do not emit events for imported judgements, as we will generate our own. - $judgementId = $data['id']; + $judgementId = $data->id; - if ($operation === EventLogService::ACTION_DELETE) { + if ($event->operation->value === EventLogService::ACTION_DELETE) { // We need to delete the judgement. $judgement = $this->em ->getRepository(ExternalJudgement::class) ->findOneBy([ - 'contest' => $this->getSourceContestId(), - 'externalid' => $judgementId - ]); + 'contest' => $this->getSourceContestId(), + 'externalid' => $judgementId, + ]); if ($judgement) { $this->em->remove($judgement); $this->em->flush(); return; } else { - $this->addOrUpdateWarning($eventId, $entityType, $data['id'], ExternalSourceWarning::TYPE_ENTITY_NOT_FOUND); + $this->addOrUpdateWarning($event, $data->id, ExternalSourceWarning::TYPE_ENTITY_NOT_FOUND); } - $this->removeWarning($entityType, $data['id'], ExternalSourceWarning::TYPE_ENTITY_NOT_FOUND); + $this->removeWarning($event->type, $data->id, ExternalSourceWarning::TYPE_ENTITY_NOT_FOUND); return; } @@ -1632,10 +1629,10 @@ protected function importJudgement(string $entityType, ?string $eventId, string $judgement = $this->em ->getRepository(ExternalJudgement::class) ->findOneBy([ - 'contest' => $this->getSourceContestId(), - 'externalid' => $judgementId - ]); - $persist = false; + 'contest' => $this->getSourceContestId(), + 'externalid' => $judgementId, + ]); + $persist = false; if (!$judgement) { $judgement = new ExternalJudgement(); $judgement @@ -1645,29 +1642,29 @@ protected function importJudgement(string $entityType, ?string $eventId, string } // Now check if we have all dependent data. - $submissionId = $data['submission_id']; + $submissionId = $data->submissionId; $submission = $this->em ->getRepository(Submission::class) ->findOneBy([ - 'contest' => $this->getSourceContestId(), - 'externalid' => $submissionId - ]); + 'contest' => $this->getSourceContestId(), + 'externalid' => $submissionId, + ]); if (!$submission) { - $this->addPendingEvent('submission', $submissionId, $operation, $entityType, $eventId, $data); + $this->addPendingEvent('submission', $submissionId, $event, $data); return; } - $startTime = Utils::toEpochFloat($data['start_time']); + $startTime = Utils::toEpochFloat($data->startTime); $endTime = null; - if (isset($data['end_time'])) { - $endTime = Utils::toEpochFloat($data['end_time']); + if (isset($data->endTime)) { + $endTime = Utils::toEpochFloat($data->endTime); } - $judgementTypeId = $data['judgement_type_id'] ?? null; + $judgementTypeId = $data->judgementTypeId; $verdictsFlipped = array_flip($this->verdicts); // Set the result based on the judgement type ID. if ($judgementTypeId !== null && !isset($verdictsFlipped[$judgementTypeId])) { - $this->addOrUpdateWarning($eventId, $entityType, $data['id'], ExternalSourceWarning::TYPE_DEPENDENCY_MISSING, [ + $this->addOrUpdateWarning($event, $data->id, ExternalSourceWarning::TYPE_DEPENDENCY_MISSING, [ 'dependencies' => [ ['type' => 'judgement-type', 'id' => $judgementTypeId], ], @@ -1675,7 +1672,7 @@ protected function importJudgement(string $entityType, ?string $eventId, string return; } - $this->removeWarning($entityType, $data['id'], ExternalSourceWarning::TYPE_DEPENDENCY_MISSING); + $this->removeWarning($event->type, $data->id, ExternalSourceWarning::TYPE_DEPENDENCY_MISSING); $judgement ->setSubmission($submission) @@ -1694,13 +1691,13 @@ protected function importJudgement(string $entityType, ?string $eventId, string // starttime and update them. /** @var ExternalJudgement[] $externalJudgements */ $externalJudgements = $this->em->createQueryBuilder() - ->from(ExternalJudgement::class, 'ej') - ->select('ej') - ->andWhere('ej.submission = :submission') - ->setParameter('submission', $submission) - ->orderBy('ej.starttime', 'DESC') - ->getQuery() - ->getResult(); + ->from(ExternalJudgement::class, 'ej') + ->select('ej') + ->andWhere('ej.submission = :submission') + ->setParameter('submission', $submission) + ->orderBy('ej.starttime', 'DESC') + ->getQuery() + ->getResult(); foreach ($externalJudgements as $idx => $externalJudgement) { $externalJudgement->setValid($idx === 0); @@ -1709,13 +1706,13 @@ protected function importJudgement(string $entityType, ?string $eventId, string $this->em->flush(); $contestId = $submission->getContest()->getCid(); - $teamId = $submission->getTeam()->getTeamid(); + $teamId = $submission->getTeam()->getTeamid(); $problemId = $submission->getProblem()->getProbid(); // Now we need to update the scoreboard cache for this cell to get this judgement result in. $this->em->clear(); $contest = $this->em->getRepository(Contest::class)->find($contestId); - $team = $this->em->getRepository(Team::class)->find($teamId); + $team = $this->em->getRepository(Team::class)->find($teamId); $problem = $this->em->getRepository(Problem::class)->find($problemId); $this->scoreboardService->calculateScoreRow($contest, $team, $problem); @@ -1723,41 +1720,44 @@ protected function importJudgement(string $entityType, ?string $eventId, string } /** - * @param array{id: string, judgement_id: string, ordinal: int, judgement_type_id?: string|null, - * time?: string|null, contest_time?: string|null, run_time?: int|null} $data + * @param Event $event */ - protected function importRun(string $entityType, ?string $eventId, string $operation, array $data): void + protected function importRun(Event $event, EventData $data): void { + if (!$data instanceof RunEvent) { + throw new InvalidArgumentException('Invalid event data type'); + } + // Note that we do not emit events for imported runs, as we will generate our own. - $runId = $data['id']; + $runId = $data->id; - if ($operation === EventLogService::ACTION_DELETE) { + if ($event->operation->value === EventLogService::ACTION_DELETE) { // We need to delete the run. $run = $this->em ->getRepository(ExternalRun::class) ->findOneBy([ - 'contest' => $this->getSourceContest(), - 'externalid' => $runId - ]); + 'contest' => $this->getSourceContest(), + 'externalid' => $runId, + ]); if ($run) { $this->em->remove($run); $this->em->flush(); return; } else { - $this->addOrUpdateWarning($eventId, $entityType, $data['id'], ExternalSourceWarning::TYPE_ENTITY_NOT_FOUND); + $this->addOrUpdateWarning($event, $data->id, ExternalSourceWarning::TYPE_ENTITY_NOT_FOUND); } - $this->removeWarning($entityType, $data['id'], ExternalSourceWarning::TYPE_ENTITY_NOT_FOUND); + $this->removeWarning($event->type, $data->id, ExternalSourceWarning::TYPE_ENTITY_NOT_FOUND); return; } // First, load the external run. - $run = $this->em + $run = $this->em ->getRepository(ExternalRun::class) ->findOneBy([ - 'contest' => $this->getSourceContest(), - 'externalid' => $runId - ]); + 'contest' => $this->getSourceContest(), + 'externalid' => $runId, + ]); $persist = false; if (!$run) { $run = new ExternalRun(); @@ -1768,26 +1768,26 @@ protected function importRun(string $entityType, ?string $eventId, string $opera } // Now check if we have all dependent data. - $judgementId = $data['judgement_id']; + $judgementId = $data->judgementId; $externalJudgement = $this->em ->getRepository(ExternalJudgement::class) ->findOneBy([ - 'contest' => $this->getSourceContest(), - 'externalid' => $judgementId - ]); + 'contest' => $this->getSourceContest(), + 'externalid' => $judgementId, + ]); if (!$externalJudgement) { - $this->addPendingEvent('judgement', $judgementId, $operation, $entityType, $eventId, $data); + $this->addPendingEvent('judgement', $judgementId, $event, $data); return; } - $time = Utils::toEpochFloat($data['time']); - $runTime = $data['run_time'] ?? 0.0; + $time = Utils::toEpochFloat($data->time); + $runTime = $data->runTime ?? 0.0; - $judgementTypeId = $data['judgement_type_id'] ?? null; + $judgementTypeId = $data->judgementTypeId; $verdictsFlipped = array_flip($this->verdicts); // Set the result based on the judgement type ID. if (!isset($verdictsFlipped[$judgementTypeId])) { - $this->addOrUpdateWarning($eventId, $entityType, $data['id'], ExternalSourceWarning::TYPE_DEPENDENCY_MISSING, [ + $this->addOrUpdateWarning($event, $data->id, ExternalSourceWarning::TYPE_DEPENDENCY_MISSING, [ 'dependencies' => [ ['type' => 'judgement-type', 'id' => $judgementTypeId], ], @@ -1795,25 +1795,25 @@ protected function importRun(string $entityType, ?string $eventId, string $opera return; } - $this->removeWarning($entityType, $data['id'], ExternalSourceWarning::TYPE_DEPENDENCY_MISSING); + $this->removeWarning($event->type, $data->id, ExternalSourceWarning::TYPE_DEPENDENCY_MISSING); - $rank = $data['ordinal']; + $rank = $data->ordinal; $problem = $externalJudgement->getSubmission()->getContestProblem(); // Find the testcase belonging to this run. /** @var Testcase|null $testcase */ $testcase = $this->em->createQueryBuilder() - ->from(Testcase::class, 't') - ->select('t') - ->andWhere('t.problem = :problem') - ->andWhere('t.ranknumber = :ranknumber') - ->setParameter('problem', $problem->getProblem()) - ->setParameter('ranknumber', $rank) - ->getQuery() - ->getOneOrNullResult(); + ->from(Testcase::class, 't') + ->select('t') + ->andWhere('t.problem = :problem') + ->andWhere('t.ranknumber = :ranknumber') + ->setParameter('problem', $problem->getProblem()) + ->setParameter('ranknumber', $rank) + ->getQuery() + ->getOneOrNullResult(); if ($testcase === null) { - $this->addOrUpdateWarning($eventId, $entityType, $data['id'], ExternalSourceWarning::TYPE_DEPENDENCY_MISSING, [ + $this->addOrUpdateWarning($event, $data->id, ExternalSourceWarning::TYPE_DEPENDENCY_MISSING, [ 'dependencies' => [ ['type' => 'testcase', 'id' => $rank], ], @@ -1821,7 +1821,7 @@ protected function importRun(string $entityType, ?string $eventId, string $opera return; } - $this->removeWarning($entityType, $data['id'], ExternalSourceWarning::TYPE_DEPENDENCY_MISSING); + $this->removeWarning($event->type, $data->id, ExternalSourceWarning::TYPE_DEPENDENCY_MISSING); $run ->setExternalJudgement($externalJudgement) @@ -1852,43 +1852,42 @@ protected function processPendingEvents(string $type, string|int $id): void } /** - * @param array{run_time?: float, time?: string, contest_time?: string, ordinal?: int, - * id: string, judgement_id?: string, judgement_type_id?: string|null, - * max_run_time?: float|null, start_time?: string, start_contest_time?: string, - * end_time?: string|null, end_contest_time?: string|null, submission_id?: string, - * output_compile_as_string?: string|null, language_id?: string, externalid?: string|null, - * team_id?: string, problem_id?: string, entry_point?: string|null, old_result?: null, - * files?: array} $data + * @param Event $event */ - protected function addPendingEvent(string $type, string|int $id, string $operation, string $entityType, ?string $eventId, array $data): void - { + protected function addPendingEvent( + string $type, + string|int $id, + Event $event, + ClarificationEvent|SubmissionEvent|JudgementEvent|RunEvent $data + ): void { // First, check if we already have pending events for this event. // We do this by loading the warnings with the correct hash. $hash = ExternalSourceWarning::calculateHash( ExternalSourceWarning::TYPE_DEPENDENCY_MISSING, - $entityType, - $data['id'] + $event->type->value, + $data->id ); $warning = $this->em ->getRepository(ExternalSourceWarning::class) ->findOneBy([ - 'externalContestSource' => $this->source, - 'hash' => $hash - ]); + 'externalContestSource' => $this->source, + 'hash' => $hash, + ]); $dependencies = []; if ($warning) { $dependencies = $warning->getContent()['dependencies']; } - $event = [ - 'op' => $operation, - 'type' => $entityType, - 'id' => $eventId, - 'data' => $data, - ]; + $event = new Event( + id: $event->id, + type: $event->type, + operation: $event->operation, + objectId: $id, + data: [$data], + ); $dependencies[$type . '-' . $id] = ['type' => $type, 'id' => $id, 'event' => $event]; - $this->addOrUpdateWarning($eventId, $entityType, $data['id'], ExternalSourceWarning::TYPE_DEPENDENCY_MISSING, [ + $this->addOrUpdateWarning($event, $data->id, ExternalSourceWarning::TYPE_DEPENDENCY_MISSING, [ 'dependencies' => $dependencies, ]); @@ -1906,9 +1905,9 @@ protected function loadPendingEvents(): void $warnings = $this->em ->getRepository(ExternalSourceWarning::class) ->findBy([ - 'externalContestSource' => $this->source, - 'type' => ExternalSourceWarning::TYPE_DEPENDENCY_MISSING, - ]); + 'externalContestSource' => $this->source, + 'type' => ExternalSourceWarning::TYPE_DEPENDENCY_MISSING, + ]); foreach ($warnings as $warning) { $dependencies = $warning->getContent()['dependencies']; foreach ($dependencies as $dependency) { @@ -1916,8 +1915,8 @@ protected function loadPendingEvents(): void continue; } - $type = $dependency['type']; - $id = $dependency['id']; + $type = $dependency['type']; + $id = $dependency['id']; $event = $dependency['event']; if (!isset($this->pendingEvents[$type][$id])) { @@ -1938,39 +1937,35 @@ private function markSubmissionAsValidAndRecalcScore(Submission $submission, boo $submission->setValid($valid); $contestId = $submission->getContest()->getCid(); - $teamId = $submission->getTeam()->getTeamid(); + $teamId = $submission->getTeam()->getTeamid(); $problemId = $submission->getProblem()->getProbid(); $this->em->flush(); $this->eventLog->log('submissions', $submission->getSubmitid(), - $valid ? EventLogService::ACTION_CREATE : EventLogService::ACTION_DELETE, - $this->getSourceContestId()); + $valid ? EventLogService::ACTION_CREATE : EventLogService::ACTION_DELETE, + $this->getSourceContestId()); $contest = $this->em->getRepository(Contest::class)->find($contestId); - $team = $this->em->getRepository(Team::class)->find($teamId); + $team = $this->em->getRepository(Team::class)->find($teamId); $problem = $this->em->getRepository(Problem::class)->find($problemId); $this->scoreboardService->calculateScoreRow($contest, $team, $problem); } /** - * @param array{'affiliation.externalid'?: string|null, 'category.externalid'?: string|null, color?: string|null, - * country?: string|null, display_name?: string|null, end_time?: string, externalid?: string, - * freeze_time?: string|null, icpc_id?: string|null, label?: string, name?: string, rgb?: string|null, - * shortname?: string, sortorder?: int, start_time_enabled?: bool, start_time_string?: string, - * timelimit?: int, visible?: bool} $values + * @param Event $event + * @param array $values * @param array $extraDiff */ private function compareOrCreateValues( - ?string $eventId, - string $entityType, - ?string $entityId, + Event $event, + ?string $entityId, BaseApiEntity $entity, - array $values, - array $extraDiff = [], - bool $updateEntity = true + array $values, + array $extraDiff = [], + bool $updateEntity = true ): void { $propertyAccessor = PropertyAccess::createPropertyAccessor(); - $diff = []; + $diff = []; foreach ($values as $field => $value) { try { $ourValue = $propertyAccessor->getValue($entity, $field); @@ -1990,13 +1985,13 @@ private function compareOrCreateValues( $fullDiff = []; foreach ($diff as $field => $ourValue) { $fullDiff[$field] = [ - 'us' => $ourValue, + 'us' => $ourValue, 'external' => $values[$field], ]; } foreach ($extraDiff as $field => $diffValues) { $fullDiff[$field] = [ - 'us' => $diffValues[0], + 'us' => $diffValues[0], 'external' => $diffValues[1], ]; } @@ -2012,46 +2007,51 @@ private function compareOrCreateValues( } } } else { - $this->addOrUpdateWarning($eventId, $entityType, $entityId, ExternalSourceWarning::TYPE_DATA_MISMATCH, [ - 'diff' => $fullDiff + $this->addOrUpdateWarning($event, $entityId, ExternalSourceWarning::TYPE_DATA_MISMATCH, [ + 'diff' => $fullDiff, ]); } } else { - $this->removeWarning($entityType, $entityId, ExternalSourceWarning::TYPE_DATA_MISMATCH); + $this->removeWarning($event->type, $entityId, ExternalSourceWarning::TYPE_DATA_MISMATCH); } } /** - * @param string[] $supportedActions + * @param Event $event + * @param string[] $supportedActions + * * @return bool True iff supported */ - protected function warningIfUnsupported(string $operation, ?string $eventId, string $entityType, ?string $entityId, array $supportedActions): bool - { - if (!in_array($operation, $supportedActions)) { - $this->addOrUpdateWarning($eventId, $entityType, $entityId, ExternalSourceWarning::TYPE_UNSUPORTED_ACTION, [ - 'action' => $operation + protected function warningIfUnsupported( + Event $event, + ?string $entityId, + array $supportedActions + ): bool { + if (!in_array($event->operation->value, $supportedActions)) { + $this->addOrUpdateWarning($event, $entityId, ExternalSourceWarning::TYPE_UNSUPORTED_ACTION, [ + 'action' => $event->operation->value, ]); return false; } // Clear warnings since this action is supported. - $this->removeWarning($entityType, $entityId, ExternalSourceWarning::TYPE_UNSUPORTED_ACTION); + $this->removeWarning($event->type, $event->id, ExternalSourceWarning::TYPE_UNSUPORTED_ACTION); return true; } /** + * @param Event $event * @param array $content */ protected function addOrUpdateWarning( - ?string $eventId, - string $entityType, + Event $event, ?string $entityId, string $type, - array $content = [] + array $content = [] ): void { - $hash = ExternalSourceWarning::calculateHash($type, $entityType, $entityId); - $warning = $this->em + $hash = ExternalSourceWarning::calculateHash($type, $event->type->value, $entityId); + $warning = $this->em ->getRepository(ExternalSourceWarning::class) ->findOneBy(['externalContestSource' => $this->source, 'hash' => $hash]); if (!$warning) { @@ -2063,23 +2063,23 @@ protected function addOrUpdateWarning( $warning ->setExternalContestSource($this->source) ->setType($type) - ->setEntityType($entityType) + ->setEntityType($event->type->value) ->setEntityId($entityId); $this->em->persist($warning); } $warning - ->setLastEventId($eventId) + ->setLastEventId($event->id) ->setLastTime(Utils::now()) ->setContent($content); $this->em->flush(); } - protected function removeWarning(string $entityType, ?string $entityId, string $type): void + protected function removeWarning(EventType $eventType, ?string $entityId, string $type): void { - $hash = ExternalSourceWarning::calculateHash($type, $entityType, $entityId); - $warning = $this->em + $hash = ExternalSourceWarning::calculateHash($type, $eventType->value, $entityId); + $warning = $this->em ->getRepository(ExternalSourceWarning::class) ->findOneBy(['externalContestSource' => $this->source, 'hash' => $hash]); if ($warning) { @@ -2087,24 +2087,4 @@ protected function removeWarning(string $entityType, ?string $entityId, string $ $this->em->flush(); } } - - /** - * @param array{token?: string, id: string, type: string, time: string, op?: string, end_of_updates?: bool, - * data?: array{run_time?: float, time?: string, contest_time?: string, ordinal?: int, - * id: string, judgement_id?: string, judgement_type_id: string|null, - * max_run_time?: float|null, start_time: string, start_contest_time?: string, - * end_time?: string|null, end_contest_time?: string|null, submission_id?: string, - * output_compile_as_string: null, language_id?: string, externalid?: string|null, - * team_id: string, problem_id?: string, entry_point?: string|null, old_result?: null, - * files?: array{href: string}}|mixed[] - * } $event - */ - protected function getEventFeedFormat(array $event): EventFeedFormat - { - return match ($this->getApiVersion()) { - '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, - }; - } } From 7f883d8bce9ad7606110a21248644261b6ac512e Mon Sep 17 00:00:00 2001 From: Nicky Gerritsen Date: Thu, 15 Feb 2024 08:27:13 +0100 Subject: [PATCH 08/20] Fix test for general info API. --- .../tests/Unit/Controller/API/GeneralInfoControllerTest.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/webapp/tests/Unit/Controller/API/GeneralInfoControllerTest.php b/webapp/tests/Unit/Controller/API/GeneralInfoControllerTest.php index a01488dd02..42a64aead5 100644 --- a/webapp/tests/Unit/Controller/API/GeneralInfoControllerTest.php +++ b/webapp/tests/Unit/Controller/API/GeneralInfoControllerTest.php @@ -33,13 +33,15 @@ public function testInfoReturnsVariables(): void $response = $this->verifyApiJsonResponse('GET', $endpoint, 200); static::assertIsArray($response); - static::assertCount(4, $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('DOMjudge', $response['name']); static::assertMatchesRegularExpression('/^\d+\.\d+\.\d+/', $response['domjudge']['version']); static::assertEquals('test', $response['domjudge']['environment']); static::assertStringStartsWith('http', $response['domjudge']['doc_url']); + static::assertMatchesRegularExpression('/^\d+\.\d+\.\d+/', $response['provider']['version']); + static::assertEquals('DOMjudge', $response['provider']['name']); } } From 30032d42d50dc1a3f8bf2c0af0e86f90acc7f860 Mon Sep 17 00:00:00 2001 From: Nicky Gerritsen Date: Thu, 15 Feb 2024 17:27:52 +0100 Subject: [PATCH 09/20] Update webapp/src/Controller/API/GeneralInfoController.php Co-authored-by: MCJ Vasseur <14887731+vmcj@users.noreply.github.com> --- webapp/src/Controller/API/GeneralInfoController.php | 1 + 1 file changed, 1 insertion(+) diff --git a/webapp/src/Controller/API/GeneralInfoController.php b/webapp/src/Controller/API/GeneralInfoController.php index da0d5f55c0..d243b249f4 100644 --- a/webapp/src/Controller/API/GeneralInfoController.php +++ b/webapp/src/Controller/API/GeneralInfoController.php @@ -100,6 +100,7 @@ public function getInfoAction( version: self::CCS_SPEC_API_VERSION, versionUrl: self::CCS_SPEC_API_URL, name: 'DOMjudge', + //TODO: Add DOMjudge logo provider: new ApiInfoProvider( name: 'DOMjudge', version: $this->getParameter('domjudge.version'), From 2b9b9302e965f6283902b33ff60f460046b43110 Mon Sep 17 00:00:00 2001 From: Nicky Gerritsen Date: Thu, 15 Feb 2024 18:15:13 +0100 Subject: [PATCH 10/20] Update webapp/src/Service/ExternalContestSourceService.php Co-authored-by: MCJ Vasseur <14887731+vmcj@users.noreply.github.com> --- webapp/src/Service/ExternalContestSourceService.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webapp/src/Service/ExternalContestSourceService.php b/webapp/src/Service/ExternalContestSourceService.php index c73b295190..cddee74791 100644 --- a/webapp/src/Service/ExternalContestSourceService.php +++ b/webapp/src/Service/ExternalContestSourceService.php @@ -483,7 +483,7 @@ function ( /** * Read events from the given file. * - * The callback will be called for every found event and will receive three + * The callback will be called for every found event and will receive two * arguments: * - The event line to process * - A boolean that can be set to true (pass-by-reference) to stop processing From 34c2cfe21d3dbab2e4e025cc185e792ca861a679 Mon Sep 17 00:00:00 2001 From: Nicky Gerritsen Date: Thu, 15 Feb 2024 20:43:49 +0100 Subject: [PATCH 11/20] Get rid of all fields from event DTO's we don't (currently) use. Also add comment to event classes that are unused. --- webapp/src/DataTransferObject/Shadowing/Account.php | 2 +- webapp/src/DataTransferObject/Shadowing/Award.php | 3 ++- .../DataTransferObject/Shadowing/ClarificationEvent.php | 1 - webapp/src/DataTransferObject/Shadowing/ContestData.php | 8 -------- webapp/src/DataTransferObject/Shadowing/GroupEvent.php | 2 -- .../src/DataTransferObject/Shadowing/JudgementEvent.php | 3 --- webapp/src/DataTransferObject/Shadowing/LanguageEvent.php | 7 ------- .../DataTransferObject/Shadowing/OrganizationEvent.php | 3 --- webapp/src/DataTransferObject/Shadowing/RunEvent.php | 1 - webapp/src/DataTransferObject/Shadowing/StateEvent.php | 5 ----- .../src/DataTransferObject/Shadowing/SubmissionEvent.php | 1 - webapp/src/DataTransferObject/Shadowing/TeamMember.php | 2 +- 12 files changed, 4 insertions(+), 34 deletions(-) diff --git a/webapp/src/DataTransferObject/Shadowing/Account.php b/webapp/src/DataTransferObject/Shadowing/Account.php index d7395933c6..6ec12ebbbb 100644 --- a/webapp/src/DataTransferObject/Shadowing/Account.php +++ b/webapp/src/DataTransferObject/Shadowing/Account.php @@ -4,5 +4,5 @@ class Account implements EventData { - + // Not used currently, so no fields } diff --git a/webapp/src/DataTransferObject/Shadowing/Award.php b/webapp/src/DataTransferObject/Shadowing/Award.php index 5fc695c501..a461b70bdc 100644 --- a/webapp/src/DataTransferObject/Shadowing/Award.php +++ b/webapp/src/DataTransferObject/Shadowing/Award.php @@ -4,5 +4,6 @@ class Award implements EventData { - + // Not used currently, so no fields + // TODO: verify awards? } diff --git a/webapp/src/DataTransferObject/Shadowing/ClarificationEvent.php b/webapp/src/DataTransferObject/Shadowing/ClarificationEvent.php index f60c58600b..474cd178b9 100644 --- a/webapp/src/DataTransferObject/Shadowing/ClarificationEvent.php +++ b/webapp/src/DataTransferObject/Shadowing/ClarificationEvent.php @@ -8,7 +8,6 @@ public function __construct( public readonly string $id, public readonly string $text, public readonly string $time, - public readonly string $contestTime, public readonly ?string $fromTeamId, public readonly ?string $toTeamId, public readonly ?string $replyToId, diff --git a/webapp/src/DataTransferObject/Shadowing/ContestData.php b/webapp/src/DataTransferObject/Shadowing/ContestData.php index 859ec9c362..92aaeeb2bf 100644 --- a/webapp/src/DataTransferObject/Shadowing/ContestData.php +++ b/webapp/src/DataTransferObject/Shadowing/ContestData.php @@ -6,18 +6,10 @@ class ContestData { public function __construct( public readonly string $id, - public readonly ?string $externalId, public readonly string $name, - public readonly ?string $formalName, - public readonly ?string $shortname, public readonly string $duration, public readonly ?string $scoreboardFreezeDuration, - public readonly ?string $scoreboardThawTime, - public readonly ?string $scoreboardType, public readonly int $penaltyTime, public readonly ?string $startTime, - public readonly ?string $endTime, - public readonly ?bool $allowSubmit, - public readonly ?string $warningMessage, ) {} } diff --git a/webapp/src/DataTransferObject/Shadowing/GroupEvent.php b/webapp/src/DataTransferObject/Shadowing/GroupEvent.php index efc7002236..8d5d5b3814 100644 --- a/webapp/src/DataTransferObject/Shadowing/GroupEvent.php +++ b/webapp/src/DataTransferObject/Shadowing/GroupEvent.php @@ -8,8 +8,6 @@ public function __construct( public readonly string $id, public readonly string $name, public readonly ?string $icpcId, - public readonly ?string $type, - public readonly ?string $location, public readonly ?bool $hidden, public readonly ?int $sortorder, public readonly ?string $color, diff --git a/webapp/src/DataTransferObject/Shadowing/JudgementEvent.php b/webapp/src/DataTransferObject/Shadowing/JudgementEvent.php index 47d8e39062..5f94cf9140 100644 --- a/webapp/src/DataTransferObject/Shadowing/JudgementEvent.php +++ b/webapp/src/DataTransferObject/Shadowing/JudgementEvent.php @@ -6,12 +6,9 @@ class JudgementEvent implements EventData { public function __construct( public readonly string $startTime, - public readonly string $startContestTime, public readonly string $id, public readonly string $submissionId, - public readonly ?float $maxRunTime, public readonly ?string $endTime, - public readonly ?string $outputCompileAsString, public readonly ?string $judgementTypeId, ) {} } diff --git a/webapp/src/DataTransferObject/Shadowing/LanguageEvent.php b/webapp/src/DataTransferObject/Shadowing/LanguageEvent.php index ef7d6c2302..aa5e799f4b 100644 --- a/webapp/src/DataTransferObject/Shadowing/LanguageEvent.php +++ b/webapp/src/DataTransferObject/Shadowing/LanguageEvent.php @@ -4,14 +4,7 @@ class LanguageEvent implements EventData { - /** - * @param string[] $extensions - */ public function __construct( public readonly string $id, - public readonly string $name, - public readonly ?bool $entryPointRequired, - public readonly ?string $entryPointName, - public readonly array $extensions, ) {} } diff --git a/webapp/src/DataTransferObject/Shadowing/OrganizationEvent.php b/webapp/src/DataTransferObject/Shadowing/OrganizationEvent.php index abe9bd32fe..6b645867b7 100644 --- a/webapp/src/DataTransferObject/Shadowing/OrganizationEvent.php +++ b/webapp/src/DataTransferObject/Shadowing/OrganizationEvent.php @@ -10,8 +10,5 @@ public function __construct( public readonly ?string $icpcId, public readonly ?string $formalName, public readonly ?string $country, - public readonly ?string $url, - public readonly ?string $twitterHashtag, - public readonly ?string $twitterAccount, ) {} } diff --git a/webapp/src/DataTransferObject/Shadowing/RunEvent.php b/webapp/src/DataTransferObject/Shadowing/RunEvent.php index 6f990e7bb6..c476963144 100644 --- a/webapp/src/DataTransferObject/Shadowing/RunEvent.php +++ b/webapp/src/DataTransferObject/Shadowing/RunEvent.php @@ -10,7 +10,6 @@ public function __construct( public readonly int $ordinal, public readonly ?string $judgementTypeId, public readonly ?string $time, - public readonly ?string $contestTime, public readonly ?float $runTime, ) {} } diff --git a/webapp/src/DataTransferObject/Shadowing/StateEvent.php b/webapp/src/DataTransferObject/Shadowing/StateEvent.php index ee54c69638..b0ce8b6af6 100644 --- a/webapp/src/DataTransferObject/Shadowing/StateEvent.php +++ b/webapp/src/DataTransferObject/Shadowing/StateEvent.php @@ -5,11 +5,6 @@ class StateEvent implements EventData { public function __construct( - public readonly ?string $started, - public readonly ?string $ended, - public readonly ?string $frozen, - public readonly ?string $thawed, - public readonly ?string $finalized, public readonly ?string $endOfUpdates, ) {} } diff --git a/webapp/src/DataTransferObject/Shadowing/SubmissionEvent.php b/webapp/src/DataTransferObject/Shadowing/SubmissionEvent.php index 5c765b1395..d334a72264 100644 --- a/webapp/src/DataTransferObject/Shadowing/SubmissionEvent.php +++ b/webapp/src/DataTransferObject/Shadowing/SubmissionEvent.php @@ -13,7 +13,6 @@ public function __construct( public readonly string $problemId, public readonly string $teamId, public readonly string $time, - public readonly string $contestTime, public readonly ?string $entryPoint, public readonly array $files, ) {} diff --git a/webapp/src/DataTransferObject/Shadowing/TeamMember.php b/webapp/src/DataTransferObject/Shadowing/TeamMember.php index bd7214c21f..f5add03996 100644 --- a/webapp/src/DataTransferObject/Shadowing/TeamMember.php +++ b/webapp/src/DataTransferObject/Shadowing/TeamMember.php @@ -4,5 +4,5 @@ class TeamMember implements EventData { - + // Not used currently, so no fields } From 6eb57098e7a1a906967c723c490bd9a841f2bd0e Mon Sep 17 00:00:00 2001 From: Nicky Gerritsen Date: Fri, 16 Feb 2024 10:47:17 +0100 Subject: [PATCH 12/20] Add todo for checking some contest times. --- webapp/src/DataTransferObject/Shadowing/ContestData.php | 1 + 1 file changed, 1 insertion(+) diff --git a/webapp/src/DataTransferObject/Shadowing/ContestData.php b/webapp/src/DataTransferObject/Shadowing/ContestData.php index 92aaeeb2bf..8d817d15f2 100644 --- a/webapp/src/DataTransferObject/Shadowing/ContestData.php +++ b/webapp/src/DataTransferObject/Shadowing/ContestData.php @@ -11,5 +11,6 @@ public function __construct( public readonly ?string $scoreboardFreezeDuration, public readonly int $penaltyTime, public readonly ?string $startTime, + // TODO: check for end time and scoreboard thaw time ) {} } From d10ca547ef0d8a486964aa3d8026eb5324a2fad7 Mon Sep 17 00:00:00 2001 From: Nicky Gerritsen Date: Mon, 26 Feb 2024 20:03:54 +0100 Subject: [PATCH 13/20] Don't use negation in ternary. --- webapp/src/Serializer/Shadowing/EventDenormalizer.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webapp/src/Serializer/Shadowing/EventDenormalizer.php b/webapp/src/Serializer/Shadowing/EventDenormalizer.php index 506a7b6032..853d5917d4 100644 --- a/webapp/src/Serializer/Shadowing/EventDenormalizer.php +++ b/webapp/src/Serializer/Shadowing/EventDenormalizer.php @@ -50,7 +50,7 @@ public function denormalize( $eventType = EventType::fromString($data['type']); if ($this->getEventFeedFormat($data, $context) === EventFeedFormat::Format_2022_07) { - $operation = !isset($data['data']) ? Operation::DELETE : Operation::CREATE; + $operation = isset($data['data']) ? Operation::CREATE : Operation::DELETE; if (!isset($data['data'][0])) { $data['data'] = [$data['data']]; } From cbd96f658d07e4cd384890eaaa7195a4bcb2f674 Mon Sep 17 00:00:00 2001 From: Nicky Gerritsen Date: Mon, 26 Feb 2024 20:18:46 +0100 Subject: [PATCH 14/20] Restore formatting of file to not have a big diff. --- .../Service/ExternalContestSourceService.php | 310 +++++++++--------- 1 file changed, 148 insertions(+), 162 deletions(-) diff --git a/webapp/src/Service/ExternalContestSourceService.php b/webapp/src/Service/ExternalContestSourceService.php index cddee74791..31922a1b2f 100644 --- a/webapp/src/Service/ExternalContestSourceService.php +++ b/webapp/src/Service/ExternalContestSourceService.php @@ -89,12 +89,12 @@ class ExternalContestSourceService protected array $pendingEvents = [ // Initialize it with all types that can be a dependent event. // Note that Language is not here, as they should exist already. - 'team' => [], - 'group' => [], - 'organization' => [], - 'problem' => [], + 'team' => [], + 'group' => [], + 'organization' => [], + 'problem' => [], 'clarification' => [], - 'submission' => [], + 'submission' => [], ]; public function __construct( @@ -257,18 +257,15 @@ public function getLastReadEventId(): ?string 'SELECT ecs.lastEventId FROM App\Entity\ExternalContestSource ecs WHERE ecs.extsourceid = :extsourceid') - ->setParameter('extsourceid', $this->source->getExtsourceid()) - ->getSingleScalarResult(); + ->setParameter('extsourceid', $this->source->getExtsourceid()) + ->getSingleScalarResult(); } /** * @param string[] $eventsToSkip */ - public function import( - bool $fromStart, - array $eventsToSkip, - ?callable $progressReporter = null - ): bool { + public function import(bool $fromStart, array $eventsToSkip, ?callable $progressReporter = null): bool + { // We need the verdicts to validate judgement-types. $this->verdicts = $this->dj->getVerdicts(mergeExternal: true); @@ -320,10 +317,8 @@ protected function setLastEvent(?string $eventId): void /** * @param string[] $eventsToSkip */ - protected function importFromCcsApi( - array $eventsToSkip, - ?callable $progressReporter = null - ): bool { + protected function importFromCcsApi(array $eventsToSkip, ?callable $progressReporter = null): bool + { while (true) { // Check whether we have received an exit signal. if (function_exists('pcntl_signal_dispatch')) { @@ -357,7 +352,7 @@ protected function importFromCcsApi( $processBuffer = function () use ($eventsToSkip, &$buffer, $progressReporter) { while (($newlinePos = strpos($buffer, "\n")) !== false) { - $line = substr($buffer, 0, $newlinePos); + $line = substr($buffer, 0, $newlinePos); $buffer = substr($buffer, $newlinePos + 1); if (!empty($line)) { @@ -436,10 +431,8 @@ protected function importFromCcsApi( /** * @param string[] $eventsToSkip */ - protected function importFromContestArchive( - array $eventsToSkip, - ?callable $progressReporter = null - ): bool { + protected function importFromContestArchive(array $eventsToSkip, ?callable $progressReporter = null): bool + { $file = fopen($this->source->getSource() . '/event-feed.ndjson', 'r'); $skipEventsUpTo = $this->getLastReadEventId(); @@ -453,7 +446,7 @@ function ( &$skipEventsUpTo, $progressReporter ) { - $lastEventId = $this->getLastReadEventId(); + $lastEventId = $this->getLastReadEventId(); $readingToLastEventId = false; $event = $this->serializer->deserialize($line, Event::class, 'json', ['api_version' => $this->getApiVersion()]); @@ -501,10 +494,10 @@ protected function readEventsFromFile($filePointer, callable $callback): void } $newlinePos = strpos($buffer, "\n"); if ($newlinePos === false) { - $line = $buffer; + $line = $buffer; $buffer = ''; } else { - $line = substr($buffer, 0, $newlinePos); + $line = substr($buffer, 0, $newlinePos); $buffer = substr($buffer, $newlinePos + 1); } @@ -530,8 +523,8 @@ protected function loadContest(): void try { // The base URL is the URL of the CCS API root. if (preg_match('/^(.*\/)contests\/.*/', - $this->source->getSource(), $matches) === 0) { - $this->loadingError = 'Cannot determine base URL. Did you pass a CCS API contest URL?'; + $this->source->getSource(), $matches) === 0) { + $this->loadingError = 'Cannot determine base URL. Did you pass a CCS API contest URL?'; $this->cachedContestData = null; } else { $clientOptions = [ @@ -613,16 +606,16 @@ public function importEvent(Event $event, array $eventsToSkip): void if ($event->id !== null && in_array($event->id, $eventsToSkip)) { $this->logger->info("Skipping event with ID %s and type %s as requested", - [$event->id, $event->type->value]); + [$event->id, $event->type->value]); return; } if ($event->id !== null) { $this->logger->debug("Importing event with ID %s and type %s...", - [$event->id, $event->type->value]); + [$event->id, $event->type->value]); } else { $this->logger->debug("Importing event with type %s...", - [$event->type->value]); + [$event->type->value]); } // Note the @vars here are to make PHPStan understand the correct types. @@ -694,30 +687,30 @@ protected function validateAndUpdateContest(Event $event, EventData $data): void // We need to convert the freeze to a value from the start instead of // the end so perform some regex magic. - $duration = $data->duration; - $freeze = $data->scoreboardFreezeDuration; + $duration = $data->duration; + $freeze = $data->scoreboardFreezeDuration; $reltimeRegex = '/^(-)?(\d+):(\d{2}):(\d{2})(?:\.(\d{3}))?$/'; preg_match($reltimeRegex, $duration, $durationData); $durationNegative = ($durationData[1] === '-'); - $fullDuration = $durationNegative ? $duration : ('+' . $duration); + $fullDuration = $durationNegative ? $duration : ('+' . $duration); if ($freeze !== null) { preg_match($reltimeRegex, $freeze, $freezeData); - $freezeNegative = ($freezeData[1] === '-'); + $freezeNegative = ($freezeData[1] === '-'); $freezeHourModifier = $freezeNegative ? -1 : 1; - $freezeInSeconds = $freezeHourModifier * (int)$freezeData[2] * 3600 + $freezeInSeconds = $freezeHourModifier * (int)$freezeData[2] * 3600 + 60 * (int)$freezeData[3] + (double)sprintf('%d.%03d', $freezeData[4], $freezeData[5]); $durationHourModifier = $durationNegative ? -1 : 1; - $durationInSeconds = $durationHourModifier * (int)$durationData[2] * 3600 - + 60 * (int)$durationData[3] - + (double)sprintf('%d.%03d', $durationData[4], $durationData[5]); - $freezeStartSeconds = $durationInSeconds - $freezeInSeconds; - $freezeHour = floor($freezeStartSeconds / 3600); - $freezeMinutes = floor(($freezeStartSeconds % 3600) / 60); - $freezeSeconds = floor(($freezeStartSeconds % 60) / 60); - $freezeMilliseconds = $freezeStartSeconds - floor($freezeStartSeconds); + $durationInSeconds = $durationHourModifier * (int)$durationData[2] * 3600 + + 60 * (int)$durationData[3] + + (double)sprintf('%d.%03d', $durationData[4], $durationData[5]); + $freezeStartSeconds = $durationInSeconds - $freezeInSeconds; + $freezeHour = floor($freezeStartSeconds / 3600); + $freezeMinutes = floor(($freezeStartSeconds % 3600) / 60); + $freezeSeconds = floor(($freezeStartSeconds % 60) / 60); + $freezeMilliseconds = $freezeStartSeconds - floor($freezeStartSeconds); $fullFreeze = sprintf( '%s%d:%02d:%02d.%03d', @@ -739,7 +732,7 @@ protected function validateAndUpdateContest(Event $event, EventData $data): void // The feed only has timezone offset, so we will only use it if the offset // differs from our local timezone offset $timezoneToUse = date_default_timezone_get(); - $feedTimezone = new DateTimeZone($startTime->format('e')); + $feedTimezone = new DateTimeZone($startTime->format('e')); if ($contest->getStartTimeObject()) { $ourTimezone = new DateTimeZone($contest->getStartTimeObject()->format('e')); } else { @@ -754,9 +747,9 @@ protected function validateAndUpdateContest(Event $event, EventData $data): void } $toCheck = [ 'start_time_enabled' => true, - 'start_time_string' => $startTime->format('Y-m-d H:i:s ') . $timezoneToUse, - 'end_time' => $contest->getAbsoluteTime($fullDuration), - 'freeze_time' => $contest->getAbsoluteTime($fullFreeze), + 'start_time_string' => $startTime->format('Y-m-d H:i:s ') . $timezoneToUse, + 'end_time' => $contest->getAbsoluteTime($fullDuration), + 'freeze_time' => $contest->getAbsoluteTime($fullFreeze), ]; } else { $toCheck = [ @@ -794,7 +787,7 @@ protected function importJudgementType(Event $event, EventData $data): void return; } - $verdict = $data->id; + $verdict = $data->id; $verdictsFlipped = array_flip($this->verdicts); if (!isset($verdictsFlipped[$verdict])) { // Verdict not found, import it as a custom verdict; assume it has a penalty. @@ -853,7 +846,7 @@ protected function validateLanguage(Event $event, EventData $data): void $this->addOrUpdateWarning($event, $data->id, ExternalSourceWarning::TYPE_DATA_MISMATCH, [ 'diff' => [ 'allow_submit' => [ - 'us' => false, + 'us' => false, 'external' => true, ], ], @@ -906,9 +899,9 @@ protected function validateAndUpdateGroup(Event $event, EventData $data): void $toCheck = [ 'externalid' => $data->id, - 'name' => $data->name, - 'visible' => !($data->hidden ?? false), - 'icpcid' => $data->icpcId, + 'name' => $data->name, + 'visible' => !($data->hidden ?? false), + 'icpcid' => $data->icpcId, ]; // Add DOMjudge specific fields that might be useful to import @@ -972,9 +965,9 @@ protected function validateAndUpdateOrganization(Event $event, EventData $data): $toCheck = [ 'externalid' => $data->id, - 'name' => $data->formalName ?? $data->name, - 'shortname' => $data->name, - 'icpcid' => $data->icpcId, + 'name' => $data->formalName ?? $data->name, + 'shortname' => $data->name, + 'icpcid' => $data->icpcId, ]; if (isset($data->country)) { $toCheck['country'] = $data->country; @@ -1017,9 +1010,9 @@ protected function validateAndUpdateProblem(Event $event, EventData $data): void $contestProblem = $this->em ->getRepository(ContestProblem::class) ->find([ - 'contest' => $this->getSourceContest(), - 'problem' => $problem, - ]); + 'contest' => $this->getSourceContest(), + 'problem' => $problem, + ]); if (!$contestProblem) { // Note: we can't handle updates to non-existing problems, since we require things // like the testcases @@ -1030,7 +1023,7 @@ protected function validateAndUpdateProblem(Event $event, EventData $data): void $this->removeWarning($event->type, $data->id, ExternalSourceWarning::TYPE_ENTITY_NOT_FOUND); $toCheckProblem = [ - 'name' => $data->name, + 'name' => $data->name, 'timelimit' => $data->timeLimit, ]; @@ -1119,12 +1112,12 @@ protected function validateAndUpdateTeam(Event $event, EventData $data): void $this->removeWarning($event->type, $data->id, ExternalSourceWarning::TYPE_ENTITY_NOT_FOUND); $toCheck = [ - 'externalid' => $data->id, - 'name' => $data->formalName ?? $data->name, - 'display_name' => $data->displayName, + 'externalid' => $data->id, + 'name' => $data->formalName ?? $data->name, + 'display_name' => $data->displayName, 'affiliation.externalid' => $data->organizationId, - 'category.externalid' => $data->groupIds[0] ?? null, - 'icpcid' => $data->icpcId, + 'category.externalid' => $data->groupIds[0] ?? null, + 'icpcid' => $data->icpcId, ]; if (isset($data->country)) { $toCheck['country'] = $data->country; @@ -1157,16 +1150,16 @@ protected function importClarification(Event $event, EventData $data): void $clarification = $this->em ->getRepository(Clarification::class) ->findOneBy([ - 'contest' => $this->getSourceContest(), - 'externalid' => $clarificationId, - ]); + 'contest' => $this->getSourceContest(), + 'externalid' => $clarificationId, + ]); if ($clarification) { $this->em->remove($clarification); $this->em->flush(); $this->eventLog->log('clarifications', $clarification->getClarid(), - EventLogService::ACTION_DELETE, - $this->getSourceContestId(), null, - $clarification->getExternalid()); + EventLogService::ACTION_DELETE, + $this->getSourceContestId(), null, + $clarification->getExternalid()); return; } else { $this->addOrUpdateWarning($event, $data->id, ExternalSourceWarning::TYPE_ENTITY_NOT_FOUND); @@ -1180,9 +1173,9 @@ protected function importClarification(Event $event, EventData $data): void $clarification = $this->em ->getRepository(Clarification::class) ->findOneBy([ - 'contest' => $this->getSourceContest(), - 'externalid' => $clarificationId, - ]); + 'contest' => $this->getSourceContest(), + 'externalid' => $clarificationId, + ]); if ($clarification) { $action = EventLogService::ACTION_UPDATE; } else { @@ -1193,7 +1186,7 @@ protected function importClarification(Event $event, EventData $data): void // Now check if we have all dependent data. $fromTeamId = $data->fromTeamId; - $fromTeam = null; + $fromTeam = null; if ($fromTeamId !== null) { $fromTeam = $this->em ->getRepository(Team::class) @@ -1205,7 +1198,7 @@ protected function importClarification(Event $event, EventData $data): void } $toTeamId = $data->toTeamId; - $toTeam = null; + $toTeam = null; if ($toTeamId !== null) { $toTeam = $this->em ->getRepository(Team::class) @@ -1217,14 +1210,14 @@ protected function importClarification(Event $event, EventData $data): void } $inReplyToId = $data->replyToId; - $inReplyTo = null; + $inReplyTo = null; if ($inReplyToId !== null) { $inReplyTo = $this->em ->getRepository(Clarification::class) ->findOneBy([ - 'contest' => $this->getSourceContest(), - 'externalid' => $inReplyToId, - ]); + 'contest' => $this->getSourceContest(), + 'externalid' => $inReplyToId, + ]); if (!$inReplyTo) { $this->addPendingEvent('clarification', $inReplyToId, $event, $data); return; @@ -1232,7 +1225,7 @@ protected function importClarification(Event $event, EventData $data): void } $problemId = $data->problemId; - $problem = null; + $problem = null; if ($problemId !== null) { $problem = $this->em ->getRepository(Problem::class) @@ -1278,7 +1271,7 @@ protected function importClarification(Event $event, EventData $data): void } $this->em->flush(); $this->eventLog->log('clarifications', $clarification->getClarid(), $action, - $this->getSourceContestId()); + $this->getSourceContestId()); $this->processPendingEvents('clarification', $clarification->getExternalid()); } @@ -1304,9 +1297,9 @@ protected function importSubmission(Event $event, EventData $data): void $submission = $this->em ->getRepository(Submission::class) ->findOneBy([ - 'contest' => $this->getSourceContest(), - 'externalid' => $submissionId, - ]); + 'contest' => $this->getSourceContest(), + 'externalid' => $submissionId, + ]); if ($submission) { $this->markSubmissionAsValidAndRecalcScore($submission, false); return; @@ -1321,9 +1314,9 @@ protected function importSubmission(Event $event, EventData $data): void $submission = $this->em ->getRepository(Submission::class) ->findOneBy([ - 'contest' => $this->getSourceContest(), - 'externalid' => $submissionId, - ]); + 'contest' => $this->getSourceContest(), + 'externalid' => $submissionId, + ]); $languageId = $data->languageId; $language = $this->em->getRepository(Language::class)->findOneBy(['externalid' => $languageId]); @@ -1349,9 +1342,9 @@ protected function importSubmission(Event $event, EventData $data): void $contestProblem = $this->em ->getRepository(ContestProblem::class) ->findOneBy([ - 'contest' => $this->getSourceContest(), - 'problem' => $problem, - ]); + 'contest' => $this->getSourceContest(), + 'problem' => $problem, + ]); if (!$contestProblem) { $this->addOrUpdateWarning($event, $data->id, ExternalSourceWarning::TYPE_DEPENDENCY_MISSING, [ @@ -1386,25 +1379,25 @@ protected function importSubmission(Event $event, EventData $data): void $diff = []; if ($submission->getTeam()->getTeamid() !== $team->getTeamid()) { $diff['team_id'] = [ - 'us' => $submission->getTeam()->getExternalid(), + 'us' => $submission->getTeam()->getExternalid(), 'external' => $team->getExternalid(), ]; } if ($submission->getProblem()->getExternalid() !== $problem->getExternalid()) { $diff['problem_id'] = [ - 'us' => $submission->getProblem()->getExternalid(), + 'us' => $submission->getProblem()->getExternalid(), 'external' => $problem->getExternalid(), ]; } if ($submission->getLanguage()->getExternalid() !== $language->getExternalid()) { $diff['language_id'] = [ - 'us' => $submission->getLanguage()->getExternalid(), + 'us' => $submission->getLanguage()->getExternalid(), 'external' => $language->getExternalid(), ]; } if (abs(Utils::difftime((float)$submission->getSubmittime(), $submitTime)) >= 1) { $diff['time'] = [ - 'us' => $submission->getAbsoluteSubmitTime(), + 'us' => $submission->getAbsoluteSubmitTime(), 'external' => $data->time, ]; } @@ -1414,12 +1407,12 @@ protected function importSubmission(Event $event, EventData $data): void $submission->setEntryPoint($entryPoint); $this->em->flush(); $this->eventLog->log('submissions', $submission->getSubmitid(), - EventLogService::ACTION_UPDATE, $this->getSourceContestId()); + EventLogService::ACTION_UPDATE, $this->getSourceContestId()); $this->processPendingEvents('submission', $submission->getExternalid()); return; } elseif ($entryPoint !== null) { $diff['entry_point'] = [ - 'us' => $submission->getEntryPoint(), + 'us' => $submission->getEntryPoint(), 'external' => $entryPoint, ]; } @@ -1459,7 +1452,7 @@ protected function importSubmission(Event $event, EventData $data): void // Check if we have a local file. if (file_exists($zipUrl)) { // Yes, use it directly - $zipFile = $zipUrl; + $zipFile = $zipUrl; $shouldUnlink = false; } else { // No, download the ZIP file. @@ -1611,9 +1604,9 @@ protected function importJudgement(Event $event, EventData $data): void $judgement = $this->em ->getRepository(ExternalJudgement::class) ->findOneBy([ - 'contest' => $this->getSourceContestId(), - 'externalid' => $judgementId, - ]); + 'contest' => $this->getSourceContestId(), + 'externalid' => $judgementId, + ]); if ($judgement) { $this->em->remove($judgement); $this->em->flush(); @@ -1629,10 +1622,10 @@ protected function importJudgement(Event $event, EventData $data): void $judgement = $this->em ->getRepository(ExternalJudgement::class) ->findOneBy([ - 'contest' => $this->getSourceContestId(), - 'externalid' => $judgementId, - ]); - $persist = false; + 'contest' => $this->getSourceContestId(), + 'externalid' => $judgementId, + ]); + $persist = false; if (!$judgement) { $judgement = new ExternalJudgement(); $judgement @@ -1646,9 +1639,9 @@ protected function importJudgement(Event $event, EventData $data): void $submission = $this->em ->getRepository(Submission::class) ->findOneBy([ - 'contest' => $this->getSourceContestId(), - 'externalid' => $submissionId, - ]); + 'contest' => $this->getSourceContestId(), + 'externalid' => $submissionId, + ]); if (!$submission) { $this->addPendingEvent('submission', $submissionId, $event, $data); return; @@ -1691,13 +1684,13 @@ protected function importJudgement(Event $event, EventData $data): void // starttime and update them. /** @var ExternalJudgement[] $externalJudgements */ $externalJudgements = $this->em->createQueryBuilder() - ->from(ExternalJudgement::class, 'ej') - ->select('ej') - ->andWhere('ej.submission = :submission') - ->setParameter('submission', $submission) - ->orderBy('ej.starttime', 'DESC') - ->getQuery() - ->getResult(); + ->from(ExternalJudgement::class, 'ej') + ->select('ej') + ->andWhere('ej.submission = :submission') + ->setParameter('submission', $submission) + ->orderBy('ej.starttime', 'DESC') + ->getQuery() + ->getResult(); foreach ($externalJudgements as $idx => $externalJudgement) { $externalJudgement->setValid($idx === 0); @@ -1706,13 +1699,13 @@ protected function importJudgement(Event $event, EventData $data): void $this->em->flush(); $contestId = $submission->getContest()->getCid(); - $teamId = $submission->getTeam()->getTeamid(); + $teamId = $submission->getTeam()->getTeamid(); $problemId = $submission->getProblem()->getProbid(); // Now we need to update the scoreboard cache for this cell to get this judgement result in. $this->em->clear(); $contest = $this->em->getRepository(Contest::class)->find($contestId); - $team = $this->em->getRepository(Team::class)->find($teamId); + $team = $this->em->getRepository(Team::class)->find($teamId); $problem = $this->em->getRepository(Problem::class)->find($problemId); $this->scoreboardService->calculateScoreRow($contest, $team, $problem); @@ -1737,9 +1730,9 @@ protected function importRun(Event $event, EventData $data): void $run = $this->em ->getRepository(ExternalRun::class) ->findOneBy([ - 'contest' => $this->getSourceContest(), - 'externalid' => $runId, - ]); + 'contest' => $this->getSourceContest(), + 'externalid' => $runId, + ]); if ($run) { $this->em->remove($run); $this->em->flush(); @@ -1752,12 +1745,12 @@ protected function importRun(Event $event, EventData $data): void } // First, load the external run. - $run = $this->em + $run = $this->em ->getRepository(ExternalRun::class) ->findOneBy([ - 'contest' => $this->getSourceContest(), - 'externalid' => $runId, - ]); + 'contest' => $this->getSourceContest(), + 'externalid' => $runId, + ]); $persist = false; if (!$run) { $run = new ExternalRun(); @@ -1772,15 +1765,15 @@ protected function importRun(Event $event, EventData $data): void $externalJudgement = $this->em ->getRepository(ExternalJudgement::class) ->findOneBy([ - 'contest' => $this->getSourceContest(), - 'externalid' => $judgementId, - ]); + 'contest' => $this->getSourceContest(), + 'externalid' => $judgementId, + ]); if (!$externalJudgement) { $this->addPendingEvent('judgement', $judgementId, $event, $data); return; } - $time = Utils::toEpochFloat($data->time); + $time = Utils::toEpochFloat($data->time); $runTime = $data->runTime ?? 0.0; $judgementTypeId = $data->judgementTypeId; @@ -1797,20 +1790,20 @@ protected function importRun(Event $event, EventData $data): void $this->removeWarning($event->type, $data->id, ExternalSourceWarning::TYPE_DEPENDENCY_MISSING); - $rank = $data->ordinal; + $rank = $data->ordinal; $problem = $externalJudgement->getSubmission()->getContestProblem(); // Find the testcase belonging to this run. /** @var Testcase|null $testcase */ $testcase = $this->em->createQueryBuilder() - ->from(Testcase::class, 't') - ->select('t') - ->andWhere('t.problem = :problem') - ->andWhere('t.ranknumber = :ranknumber') - ->setParameter('problem', $problem->getProblem()) - ->setParameter('ranknumber', $rank) - ->getQuery() - ->getOneOrNullResult(); + ->from(Testcase::class, 't') + ->select('t') + ->andWhere('t.problem = :problem') + ->andWhere('t.ranknumber = :ranknumber') + ->setParameter('problem', $problem->getProblem()) + ->setParameter('ranknumber', $rank) + ->getQuery() + ->getOneOrNullResult(); if ($testcase === null) { $this->addOrUpdateWarning($event, $data->id, ExternalSourceWarning::TYPE_DEPENDENCY_MISSING, [ @@ -1854,12 +1847,8 @@ protected function processPendingEvents(string $type, string|int $id): void /** * @param Event $event */ - protected function addPendingEvent( - string $type, - string|int $id, - Event $event, - ClarificationEvent|SubmissionEvent|JudgementEvent|RunEvent $data - ): void { + protected function addPendingEvent(string $type, string|int $id, Event $event, ClarificationEvent|SubmissionEvent|JudgementEvent|RunEvent $data): void + { // First, check if we already have pending events for this event. // We do this by loading the warnings with the correct hash. $hash = ExternalSourceWarning::calculateHash( @@ -1870,9 +1859,9 @@ protected function addPendingEvent( $warning = $this->em ->getRepository(ExternalSourceWarning::class) ->findOneBy([ - 'externalContestSource' => $this->source, - 'hash' => $hash, - ]); + 'externalContestSource' => $this->source, + 'hash' => $hash, + ]); $dependencies = []; if ($warning) { @@ -1905,9 +1894,9 @@ protected function loadPendingEvents(): void $warnings = $this->em ->getRepository(ExternalSourceWarning::class) ->findBy([ - 'externalContestSource' => $this->source, - 'type' => ExternalSourceWarning::TYPE_DEPENDENCY_MISSING, - ]); + 'externalContestSource' => $this->source, + 'type' => ExternalSourceWarning::TYPE_DEPENDENCY_MISSING, + ]); foreach ($warnings as $warning) { $dependencies = $warning->getContent()['dependencies']; foreach ($dependencies as $dependency) { @@ -1915,8 +1904,8 @@ protected function loadPendingEvents(): void continue; } - $type = $dependency['type']; - $id = $dependency['id']; + $type = $dependency['type']; + $id = $dependency['id']; $event = $dependency['event']; if (!isset($this->pendingEvents[$type][$id])) { @@ -1937,16 +1926,16 @@ private function markSubmissionAsValidAndRecalcScore(Submission $submission, boo $submission->setValid($valid); $contestId = $submission->getContest()->getCid(); - $teamId = $submission->getTeam()->getTeamid(); + $teamId = $submission->getTeam()->getTeamid(); $problemId = $submission->getProblem()->getProbid(); $this->em->flush(); $this->eventLog->log('submissions', $submission->getSubmitid(), - $valid ? EventLogService::ACTION_CREATE : EventLogService::ACTION_DELETE, - $this->getSourceContestId()); + $valid ? EventLogService::ACTION_CREATE : EventLogService::ACTION_DELETE, + $this->getSourceContestId()); $contest = $this->em->getRepository(Contest::class)->find($contestId); - $team = $this->em->getRepository(Team::class)->find($teamId); + $team = $this->em->getRepository(Team::class)->find($teamId); $problem = $this->em->getRepository(Problem::class)->find($problemId); $this->scoreboardService->calculateScoreRow($contest, $team, $problem); } @@ -1965,7 +1954,7 @@ private function compareOrCreateValues( bool $updateEntity = true ): void { $propertyAccessor = PropertyAccess::createPropertyAccessor(); - $diff = []; + $diff = []; foreach ($values as $field => $value) { try { $ourValue = $propertyAccessor->getValue($entity, $field); @@ -1985,13 +1974,13 @@ private function compareOrCreateValues( $fullDiff = []; foreach ($diff as $field => $ourValue) { $fullDiff[$field] = [ - 'us' => $ourValue, + 'us' => $ourValue, 'external' => $values[$field], ]; } foreach ($extraDiff as $field => $diffValues) { $fullDiff[$field] = [ - 'us' => $diffValues[0], + 'us' => $diffValues[0], 'external' => $diffValues[1], ]; } @@ -2022,11 +2011,8 @@ private function compareOrCreateValues( * * @return bool True iff supported */ - protected function warningIfUnsupported( - Event $event, - ?string $entityId, - array $supportedActions - ): bool { + protected function warningIfUnsupported(Event $event, ?string $entityId, array $supportedActions): bool + { if (!in_array($event->operation->value, $supportedActions)) { $this->addOrUpdateWarning($event, $entityId, ExternalSourceWarning::TYPE_UNSUPORTED_ACTION, [ 'action' => $event->operation->value, @@ -2050,7 +2036,7 @@ protected function addOrUpdateWarning( string $type, array $content = [] ): void { - $hash = ExternalSourceWarning::calculateHash($type, $event->type->value, $entityId); + $hash = ExternalSourceWarning::calculateHash($type, $event->type->value, $entityId); $warning = $this->em ->getRepository(ExternalSourceWarning::class) ->findOneBy(['externalContestSource' => $this->source, 'hash' => $hash]); From 23e772613cc9b5c9d5f63dbbdbc6c40cf9ad8a23 Mon Sep 17 00:00:00 2001 From: Nicky Gerritsen Date: Mon, 26 Feb 2024 20:24:33 +0100 Subject: [PATCH 15/20] Rename event data normalizer to denormalizer since that is all it does. --- .../{EventDataNormalizer.php => EventDataDenormalizer.php} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename webapp/src/Serializer/Shadowing/{EventDataNormalizer.php => EventDataDenormalizer.php} (96%) diff --git a/webapp/src/Serializer/Shadowing/EventDataNormalizer.php b/webapp/src/Serializer/Shadowing/EventDataDenormalizer.php similarity index 96% rename from webapp/src/Serializer/Shadowing/EventDataNormalizer.php rename to webapp/src/Serializer/Shadowing/EventDataDenormalizer.php index 2f515a80bb..78a78dd6bf 100644 --- a/webapp/src/Serializer/Shadowing/EventDataNormalizer.php +++ b/webapp/src/Serializer/Shadowing/EventDataDenormalizer.php @@ -14,7 +14,7 @@ /** * This class converts the data of an event into the correct event data object */ -class EventDataNormalizer implements DenormalizerInterface, SerializerAwareInterface +class EventDataDenormalizer implements DenormalizerInterface, SerializerAwareInterface { use SerializerAwareTrait; From 78c1edcf5d23b2a13e73c5b3e9de885459f9a492 Mon Sep 17 00:00:00 2001 From: Nicky Gerritsen Date: Mon, 26 Feb 2024 20:55:39 +0100 Subject: [PATCH 16/20] Add tests for event parsing. Also fix some logic errors. --- .../Shadowing/EventDenormalizer.php | 23 ++- .../Shadowing/EventDenormalizerTest.php | 189 ++++++++++++++++++ 2 files changed, 207 insertions(+), 5 deletions(-) create mode 100644 webapp/tests/Unit/Serializer/Shadowing/EventDenormalizerTest.php diff --git a/webapp/src/Serializer/Shadowing/EventDenormalizer.php b/webapp/src/Serializer/Shadowing/EventDenormalizer.php index 853d5917d4..148a7dcd36 100644 --- a/webapp/src/Serializer/Shadowing/EventDenormalizer.php +++ b/webapp/src/Serializer/Shadowing/EventDenormalizer.php @@ -30,6 +30,7 @@ class EventDenormalizer implements DenormalizerInterface, SerializerAwareInterfa * data: array{id: string}|array|null * } $data * @param array{api_version?: string} $context + * * @return Event * * @throws ExceptionInterface @@ -51,24 +52,36 @@ public function denormalize( $eventType = EventType::fromString($data['type']); if ($this->getEventFeedFormat($data, $context) === EventFeedFormat::Format_2022_07) { $operation = isset($data['data']) ? Operation::CREATE : Operation::DELETE; - if (!isset($data['data'][0])) { + if (isset($data['data']) && !isset($data['data'][0])) { $data['data'] = [$data['data']]; } - $id = $operation === Operation::DELETE ? $data['id'] : $data['data'][0]['id'] ?? null; + if ($operation === Operation::CREATE && count($data['data']) === 1) { + $id = $data['data'][0]['id']; + } elseif ($operation === Operation::DELETE) { + $id = $data['id']; + } else { + $id = null; + } return new Event( $data['token'] ?? null, $eventType, $operation, $id, - $this->serializer->denormalize($data['data'], EventData::class . '[]', $format, $context + ['event_type' => $eventType]), + isset($data['data']) ? $this->serializer->denormalize($data['data'], EventData::class . '[]', $format, $context + ['event_type' => $eventType]) : [], ); } else { + $operation = Operation::from($data['op']); + if ($operation === Operation::DELETE) { + $eventData = []; + } else { + $eventData = [$this->serializer->denormalize($data['data'], EventData::class, $format, $context + ['event_type' => $eventType])]; + } return new Event( $data['id'] ?? null, $eventType, - Operation::from($data['op']), + $operation, $data['data']['id'], - [$this->serializer->denormalize($data['data'], EventData::class, $format, $context + ['event_type' => $eventType])], + $eventData, ); } } diff --git a/webapp/tests/Unit/Serializer/Shadowing/EventDenormalizerTest.php b/webapp/tests/Unit/Serializer/Shadowing/EventDenormalizerTest.php new file mode 100644 index 0000000000..d2173cd5b1 --- /dev/null +++ b/webapp/tests/Unit/Serializer/Shadowing/EventDenormalizerTest.php @@ -0,0 +1,189 @@ +getcontainer()->get(SerializerInterface::class); + $event = $serializer->denormalize($data, Event::class, 'json', $context); + self::assertEquals($expectedId, $event->id); + self::assertEquals($expectedType, $event->type); + self::assertEquals($expectedOperation, $event->operation); + self::assertEquals($expectedObjectId, $event->objectId); + self::assertEquals($expectedData, $event->data); + } + + /** + * @dataProvider provideDenormalize + */ + public function testDenormalizeDoNotUseContext( + mixed $data, + array $context, + ?string $expectedId, + EventType $expectedType, + Operation $expectedOperation, + ?string $expectedObjectId, + array $expectedData + ) { + $serializer = $this->getcontainer()->get(SerializerInterface::class); + $event = $serializer->denormalize($data, Event::class, 'json', ['api_version' => null]); + self::assertEquals($expectedId, $event->id); + self::assertEquals($expectedType, $event->type); + self::assertEquals($expectedOperation, $event->operation); + self::assertEquals($expectedObjectId, $event->objectId); + self::assertEquals($expectedData, $event->data); + } + + 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' => [], + ], + ], + ['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 multiple' => [ + [ + 'type' => 'languages', + 'token' => 'anothertoken', + 'data' => [ + ['id' => 'cpp'], + ['id' => 'java'], + ], + ], + ['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', + [], + ]; + yield '2020-03 format, create' => [ + [ + 'id' => 'sometoken', + 'type' => 'submissions', + 'op' => 'create', + 'data' => [ + 'id' => '123', + 'language_id' => 'cpp', + 'problem_id' => 'A', + 'team_id' => '1', + 'time' => '456', + 'files' => [], + ], + ], + ['api_version' => '2020-03'], + 'sometoken', + EventType::SUBMISSIONS, + Operation::CREATE, + '123', + [ + new SubmissionEvent( + id: '123', + languageId: 'cpp', + problemId: 'A', + teamId: '1', + time: '456', + entryPoint: null, + files: [] + ), + ], + ]; + yield '2020-03 format, update' => [ + [ + 'id' => 'anothertoken', + 'type' => 'languages', + 'op' => 'update', + 'data' => [ + 'id' => 'cpp', + ], + ], + ['api_version' => '2020-03'], + 'anothertoken', + EventType::LANGUAGES, + Operation::UPDATE, + 'cpp', + [ + new LanguageEvent(id: 'cpp'), + ], + ]; + yield '2020-03 format, delete' => [ + [ + 'id' => 'yetanothertoken', + 'type' => 'problems', + 'op' => 'delete', + 'data' => [ + 'id' => '987', + ], + ], + ['api_version' => '2020-03'], + 'yetanothertoken', + EventType::PROBLEMS, + Operation::DELETE, + '987', + [], + ]; + } +} From 7570245606df2dba41e6c8807d23ff77806f76e2 Mon Sep 17 00:00:00 2001 From: Nicky Gerritsen Date: Mon, 26 Feb 2024 21:01:50 +0100 Subject: [PATCH 17/20] Get rid of unused event classes. --- .../DataTransferObject/Shadowing/Account.php | 8 -------- .../DataTransferObject/Shadowing/Award.php | 9 --------- .../Shadowing/EventType.php | 11 +++-------- .../Shadowing/TeamMember.php | 8 -------- .../Shadowing/EventDataDenormalizer.php | 3 +++ .../Shadowing/EventDenormalizer.php | 9 ++++++++- .../Shadowing/EventDenormalizerTest.php | 19 +++++++++++++++++-- 7 files changed, 31 insertions(+), 36 deletions(-) delete mode 100644 webapp/src/DataTransferObject/Shadowing/Account.php delete mode 100644 webapp/src/DataTransferObject/Shadowing/Award.php delete mode 100644 webapp/src/DataTransferObject/Shadowing/TeamMember.php diff --git a/webapp/src/DataTransferObject/Shadowing/Account.php b/webapp/src/DataTransferObject/Shadowing/Account.php deleted file mode 100644 index 6ec12ebbbb..0000000000 --- a/webapp/src/DataTransferObject/Shadowing/Account.php +++ /dev/null @@ -1,8 +0,0 @@ - + * @return class-string|null */ - public function getEventClass(): string + public function getEventClass(): ?string { switch ($this) { - case self::ACCOUNTS: - return Account::class; - case self::AWARDS: - return Award::class; case self::CLARIFICATIONS: return ClarificationEvent::class; case self::CONTESTS: @@ -63,8 +59,7 @@ public function getEventClass(): string return SubmissionEvent::class; case self::TEAMS: return TeamEvent::class; - case self::TEAM_MEMBERS: - return TeamMember::class; } + return null; } } diff --git a/webapp/src/DataTransferObject/Shadowing/TeamMember.php b/webapp/src/DataTransferObject/Shadowing/TeamMember.php deleted file mode 100644 index f5add03996..0000000000 --- a/webapp/src/DataTransferObject/Shadowing/TeamMember.php +++ /dev/null @@ -1,8 +0,0 @@ -getEventClass(); + if ($eventClass === null) { + return null; + } // Unset the event type, so we are not calling ourselves recursively unset($context['event_type']); diff --git a/webapp/src/Serializer/Shadowing/EventDenormalizer.php b/webapp/src/Serializer/Shadowing/EventDenormalizer.php index 148a7dcd36..7caedaaaef 100644 --- a/webapp/src/Serializer/Shadowing/EventDenormalizer.php +++ b/webapp/src/Serializer/Shadowing/EventDenormalizer.php @@ -62,17 +62,24 @@ public function denormalize( } else { $id = null; } + if ($eventType->getEventClass() === null) { + $eventData = []; + } else { + $eventData = isset($data['data']) ? $this->serializer->denormalize($data['data'], EventData::class . '[]', $format, $context + ['event_type' => $eventType]) : []; + } return new Event( $data['token'] ?? null, $eventType, $operation, $id, - isset($data['data']) ? $this->serializer->denormalize($data['data'], EventData::class . '[]', $format, $context + ['event_type' => $eventType]) : [], + $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])]; } diff --git a/webapp/tests/Unit/Serializer/Shadowing/EventDenormalizerTest.php b/webapp/tests/Unit/Serializer/Shadowing/EventDenormalizerTest.php index d2173cd5b1..dba6baeb90 100644 --- a/webapp/tests/Unit/Serializer/Shadowing/EventDenormalizerTest.php +++ b/webapp/tests/Unit/Serializer/Shadowing/EventDenormalizerTest.php @@ -24,7 +24,7 @@ public function testDenormalizeUseContext( Operation $expectedOperation, ?string $expectedObjectId, array $expectedData - ) { + ): void { $serializer = $this->getcontainer()->get(SerializerInterface::class); $event = $serializer->denormalize($data, Event::class, 'json', $context); self::assertEquals($expectedId, $event->id); @@ -45,7 +45,7 @@ public function testDenormalizeDoNotUseContext( Operation $expectedOperation, ?string $expectedObjectId, array $expectedData - ) { + ): void { $serializer = $this->getcontainer()->get(SerializerInterface::class); $event = $serializer->denormalize($data, Event::class, 'json', ['api_version' => null]); self::assertEquals($expectedId, $event->id); @@ -87,6 +87,21 @@ public function provideDenormalize(): Generator ), ], ]; + yield '2022-07 format, create/update unknown class' => [ + [ + 'type' => 'team-members', + 'token' => 'sometoken', + 'data' => [ + ['id' => '123'], + ], + ], + ['api_version' => '2022-07'], + 'sometoken', + EventType::TEAM_MEMBERS, + Operation::CREATE, + '123', + [], + ]; yield '2022-07 format, create/update multiple' => [ [ 'type' => 'languages', From 356e8ee8d461aa44666e6ebf0dac3ff005848395 Mon Sep 17 00:00:00 2001 From: Nicky Gerritsen Date: Tue, 27 Feb 2024 19:10:01 +0100 Subject: [PATCH 18/20] When calculating external source warning, the entity ID might be null. --- webapp/src/Entity/ExternalSourceWarning.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webapp/src/Entity/ExternalSourceWarning.php b/webapp/src/Entity/ExternalSourceWarning.php index 5445376783..b223b6277b 100644 --- a/webapp/src/Entity/ExternalSourceWarning.php +++ b/webapp/src/Entity/ExternalSourceWarning.php @@ -170,7 +170,7 @@ public function fillhash(): void $this->setHash(static::calculateHash($this->getType(), $this->getEntityType(), $this->getEntityId())); } - public static function calculateHash(string $type, string $entityType, string $enttiyId): string + public static function calculateHash(string $type, string $entityType, ?string $enttiyId): string { return "$entityType-$enttiyId-$type"; } From 59719ffd19a007b504bd7f00d9f9959653e0c3ba Mon Sep 17 00:00:00 2001 From: Nicky Gerritsen Date: Tue, 27 Feb 2024 19:10:23 +0100 Subject: [PATCH 19/20] State events do no not have an ID. --- webapp/src/Serializer/Shadowing/EventDenormalizer.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/webapp/src/Serializer/Shadowing/EventDenormalizer.php b/webapp/src/Serializer/Shadowing/EventDenormalizer.php index 7caedaaaef..1d8eeb8d31 100644 --- a/webapp/src/Serializer/Shadowing/EventDenormalizer.php +++ b/webapp/src/Serializer/Shadowing/EventDenormalizer.php @@ -56,7 +56,7 @@ public function denormalize( $data['data'] = [$data['data']]; } if ($operation === Operation::CREATE && count($data['data']) === 1) { - $id = $data['data'][0]['id']; + $id = $data['data'][0]['id'] ?? null; } elseif ($operation === Operation::DELETE) { $id = $data['id']; } else { @@ -87,7 +87,7 @@ public function denormalize( $data['id'] ?? null, $eventType, $operation, - $data['data']['id'], + $data['data']['id'] ?? null, $eventData, ); } From 7b13e518c32dc71266abe7d77d53e7b609f6297b Mon Sep 17 00:00:00 2001 From: Nicky Gerritsen Date: Tue, 27 Feb 2024 20:01:10 +0100 Subject: [PATCH 20/20] Fix missing change from array to DTO. --- webapp/src/Service/SubmissionService.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webapp/src/Service/SubmissionService.php b/webapp/src/Service/SubmissionService.php index bc9e7a02ba..43453b3e1a 100644 --- a/webapp/src/Service/SubmissionService.php +++ b/webapp/src/Service/SubmissionService.php @@ -175,7 +175,7 @@ public function getSubmissionList( } else { $queryBuilder ->andWhere('ej.result = :externalresult') - ->setParameter('externalresult', $restrictions['external_result']); + ->setParameter('externalresult', $restrictions->externalResult); } }