diff --git a/etc/db-config.yaml b/etc/db-config.yaml index 44eac76bc9f..303e37f1153 100644 --- a/etc/db-config.yaml +++ b/etc/db-config.yaml @@ -111,16 +111,9 @@ type: int default_value: 50000 public: false - description: Maximum size of error/system output stored in the database (in bytes); use `-1` to disable any limits. - regex: /^[1-9]\d*$/ - error_message: A positive number is required. - - name: output_display_limit - type: int - default_value: 2000 - public: false - description: Maximum size of run/diff/error/system output shown in the jury interface (in bytes); use `-1` to disable any limits. - regex: /^[1-9]\d*$/ - error_message: A positive number is required. + description: Maximum size of error/system output stored in the database (in bytes); use `-1` to disable any limits. See `Display` / `output_display_limit` for how to control the output *shown*. + regex: /^([1-9]\d*|-1)$/ + error_message: A positive number or -1 is required. - name: lazy_eval_results type: int default_value: 1 @@ -208,6 +201,13 @@ - category: Display description: Options related to the DOMjudge user interface. items: + - name: output_display_limit + type: int + default_value: 2000 + public: false + description: Maximum size of run/diff/error/system output shown in the jury interface (in bytes); use `-1` to disable any limits. + regex: /^([1-9]\d*|-1)$/ + error_message: A positive number or -1 is required. - name: show_pending type: bool default_value: true diff --git a/gitlab/integration.sh b/gitlab/integration.sh index 2ee36099ec8..946123bd581 100755 --- a/gitlab/integration.sh +++ b/gitlab/integration.sh @@ -239,8 +239,8 @@ set -x # Finalize contest so that awards appear in the feed; first freeze and end the # contest if that has not already been done. export CURLOPTS="--fail -m 30 -b $COOKIEJAR" -curl $CURLOPTS -X POST -d 'contest=1&donow[freeze]=freeze now' http://localhost/domjudge/jury/contests || true -curl $CURLOPTS -X POST -d 'contest=1&donow[end]=end now' http://localhost/domjudge/jury/contests || true +curl $CURLOPTS http://localhost/domjudge/jury/contests/1/freeze/doNow || true +curl $CURLOPTS http://localhost/domjudge/jury/contests/1/end/doNow || true curl $CURLOPTS -X POST -d 'finalize_contest[b]=0&finalize_contest[finalizecomment]=gitlab&finalize_contest[finalize]=' http://localhost/domjudge/jury/contests/1/finalize # shellcheck disable=SC2002,SC2196 diff --git a/gitlab/unit-tests.sh b/gitlab/unit-tests.sh index 579af80f1d9..6713d5d60bb 100755 --- a/gitlab/unit-tests.sh +++ b/gitlab/unit-tests.sh @@ -59,6 +59,8 @@ if [ $UNITSUCCESS -eq 0 ]; then else STATE=failure fi +cp webapp/var/log/test.log "$GITLABARTIFACTS"/test.log + curl https://api.github.com/repos/domjudge/domjudge/statuses/$CI_COMMIT_SHA \ -X POST \ -H "Authorization: token $GH_BOT_TOKEN_OBSCURED" \ diff --git a/judge/runpipe.cc b/judge/runpipe.cc index 06e44098a68..17a0b8cb647 100644 --- a/judge/runpipe.cc +++ b/judge/runpipe.cc @@ -777,7 +777,7 @@ struct state_t { int time_millis = time % 1000; char direction = from.index == 0 ? ']' : '['; char eofbuf[128]; - sprintf(eofbuf, "[%3d.%03ds/%ld]%c", time_sec, time_millis, 0LL, direction); + sprintf(eofbuf, "[%3d.%03ds/%ld]%c", time_sec, time_millis, 0L, direction); write_all(output_file.output_file, eofbuf, strlen(eofbuf)); warning(0, "EOF from process #%ld", from.index); diff --git a/misc-tools/configure-domjudge.in b/misc-tools/configure-domjudge.in index 9f649686e84..756453e4973 100755 --- a/misc-tools/configure-domjudge.in +++ b/misc-tools/configure-domjudge.in @@ -130,7 +130,7 @@ if os.path.exists('languages.json'): dj_utils.upload_file(f'languages/{langid}/executable', 'executable', f'executables/{langid}.zip') for langid in executables: os.remove(f'executables/{langid}.zip') - if dj_utils.confirm(' - Upload configuration changes?', True): + if dj_utils.confirm(' - Upload language configuration changes?', True): actual_config = _keyify_list(dj_utils.upload_file(f'languages', 'json', f'languages.json')) diffs, new_keys, missing_keys = compare_configs( actual_config=actual_config, diff --git a/misc-tools/dj_utils.py b/misc-tools/dj_utils.py index 0d63cbd7142..c37a3508f45 100644 --- a/misc-tools/dj_utils.py +++ b/misc-tools/dj_utils.py @@ -11,6 +11,7 @@ import requests.utils import subprocess import sys +from urllib.parse import urlparse _myself = os.path.basename(sys.argv[0]) _default_user_agent = requests.utils.default_user_agent() @@ -76,12 +77,16 @@ def do_api_request(name: str, method: str = 'GET', jsonData: dict = {}): else: global ca_check url = f'{domjudge_webapp_folder_or_api_url}/{name}' + parsed = urlparse(domjudge_webapp_folder_or_api_url) + auth = None + if parsed.username or parsed.password: + auth = (parsed.username, parsed.password) try: if method == 'GET': - response = requests.get(url, headers=headers, verify=ca_check) + response = requests.get(url, headers=headers, verify=ca_check, auth=auth) elif method == 'PUT': - response = requests.put(url, headers=headers, verify=ca_check, json=jsonData) + response = requests.put(url, headers=headers, verify=ca_check, json=jsonData, auth=auth) except requests.exceptions.SSLError as e: ca_check = not confirm( "Can not verify certificate, ignore certificate check?", False) diff --git a/misc-tools/import-contest.in b/misc-tools/import-contest.in index 60b55d066da..dabefbb4a71 100755 --- a/misc-tools/import-contest.in +++ b/misc-tools/import-contest.in @@ -114,6 +114,24 @@ def import_contest_banner(cid: str): else: print('Skipping contest banner import.') +def import_contest_text(cid: str): + """Import the contest text""" + + files = ['contest.pdf', 'contest-web.pdf', 'contest.html', 'contest.txt'] + + text_file = None + for file in files: + if os.path.isfile(file): + text_file = file + break + + if text_file: + if dj_utils.confirm(f'Import {text_file} for contest?', False): + dj_utils.upload_file(f'contests/{cid}/text', 'text', text_file) + print('Contest text imported.') + else: + print('Skipping contest text import.') + if len(sys.argv) == 1: dj_utils.domjudge_webapp_folder_or_api_url = webappdir elif len(sys.argv) == 2: @@ -154,6 +172,7 @@ else: if cid is not None: print(f' -> cid={cid}') import_contest_banner(cid) + import_contest_text(cid) # Problem import is also special: we need to upload each individual problem and detect what they are if os.path.exists('problems.yaml') or os.path.exists('problems.json') or os.path.exists('problemset.yaml'): diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon deleted file mode 100644 index 475ff17ecc0..00000000000 --- a/phpstan-baseline.neon +++ /dev/null @@ -1,70 +0,0 @@ -parameters: - ignoreErrors: - - - message: "#^Method App\\\\Controller\\\\API\\\\AbstractRestController\\:\\:listActionHelper\\(\\) return type has no value type specified in iterable type array\\.$#" - count: 1 - path: webapp/src/Controller/API/AbstractRestController.php - - - - message: "#^PHPDoc tag @var for variable \\$objects has no value type specified in iterable type array\\.$#" - count: 1 - path: webapp/src/Controller/API/AbstractRestController.php - - - - message: "#^Method App\\\\Controller\\\\API\\\\GeneralInfoController\\:\\:addProblemAction\\(\\) return type has no value type specified in iterable type array\\.$#" - count: 1 - path: webapp/src/Controller/API/GeneralInfoController.php - - - - message: "#^Method App\\\\Controller\\\\API\\\\GeneralInfoController\\:\\:getDatabaseConfigurationAction\\(\\) return type has no value type specified in iterable type array\\.$#" - count: 1 - path: webapp/src/Controller/API/GeneralInfoController.php - - - - message: "#^Method App\\\\Controller\\\\API\\\\GeneralInfoController\\:\\:updateConfigurationAction\\(\\) return type has no value type specified in iterable type array\\.$#" - count: 1 - path: webapp/src/Controller/API/GeneralInfoController.php - - - - message: "#^Method App\\\\Controller\\\\API\\\\JudgehostController\\:\\:checkVersions\\(\\) return type has no value type specified in iterable type array\\.$#" - count: 1 - path: webapp/src/Controller/API/JudgehostController.php - - - - message: "#^Method App\\\\Controller\\\\API\\\\JudgehostController\\:\\:createJudgehostAction\\(\\) return type has no value type specified in iterable type array\\.$#" - count: 1 - path: webapp/src/Controller/API/JudgehostController.php - - - - message: "#^Method App\\\\Controller\\\\API\\\\JudgehostController\\:\\:getVersionCommands\\(\\) return type has no value type specified in iterable type array\\.$#" - count: 1 - path: webapp/src/Controller/API/JudgehostController.php - - - - message: "#^Method App\\\\Controller\\\\API\\\\JudgehostController\\:\\:updateJudgeHostAction\\(\\) return type has no value type specified in iterable type array\\.$#" - count: 1 - path: webapp/src/Controller/API/JudgehostController.php - - - - message: "#^Method App\\\\Controller\\\\API\\\\ProblemController\\:\\:addProblemAction\\(\\) return type has no value type specified in iterable type array\\.$#" - count: 1 - path: webapp/src/Controller/API/ProblemController.php - - - - message: "#^Method App\\\\Controller\\\\API\\\\ProblemController\\:\\:addProblemsAction\\(\\) return type has no value type specified in iterable type array\\.$#" - count: 1 - path: webapp/src/Controller/API/ProblemController.php - - - - message: "#^Method App\\\\Controller\\\\API\\\\ProblemController\\:\\:transformObject\\(\\) has parameter \\$object with no value type specified in iterable type array\\.$#" - count: 1 - path: webapp/src/Controller/API/ProblemController.php - - - message: "#^Method App\\\\FosRestBundle\\\\FlattenExceptionHandler\\:\\:serializeToJson\\(\\) has parameter \\$type with no value type specified in iterable type array\\.$#" - count: 1 - path: webapp/src/FosRestBundle/FlattenExceptionHandler.php - - - - message: "#^Method App\\\\FosRestBundle\\\\FlattenExceptionHandler\\:\\:serializeToJson\\(\\) return type has no value type specified in iterable type array\\.$#" - count: 1 - path: webapp/src/FosRestBundle/FlattenExceptionHandler.php diff --git a/phpstan.dist.neon b/phpstan.dist.neon index 8552bfc348f..531ebea2c11 100644 --- a/phpstan.dist.neon +++ b/phpstan.dist.neon @@ -22,5 +22,4 @@ parameters: message: "#Method .* return type has no value type specified in iterable type array#" path: webapp/src/DataFixtures/Test includes: - - phpstan-baseline.neon - lib/vendor/phpstan/phpstan-doctrine/extension.neon diff --git a/webapp/migrations/Version20240322100827.php b/webapp/migrations/Version20240322100827.php new file mode 100644 index 00000000000..9847a00172d --- /dev/null +++ b/webapp/migrations/Version20240322100827.php @@ -0,0 +1,40 @@ +addSql('CREATE TABLE contest_text_content (cid INT UNSIGNED NOT NULL COMMENT \'Contest ID\', content LONGBLOB NOT NULL COMMENT \'Text content(DC2Type:blobtext)\', PRIMARY KEY(cid)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB COMMENT = \'Stores contents of contest texts\' '); + $this->addSql('ALTER TABLE contest_text_content ADD CONSTRAINT FK_6680FE6A4B30D9C4 FOREIGN KEY (cid) REFERENCES contest (cid) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE contest ADD contest_text_type VARCHAR(4) DEFAULT NULL COMMENT \'File type of contest text\''); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE contest_text_content DROP FOREIGN KEY FK_6680FE6A4B30D9C4'); + $this->addSql('DROP TABLE contest_text_content'); + $this->addSql('ALTER TABLE contest DROP contest_text_type'); + } + + public function isTransactional(): bool + { + return false; + } +} diff --git a/webapp/public/style_domjudge.css b/webapp/public/style_domjudge.css index da398639711..c9f9bf861ce 100644 --- a/webapp/public/style_domjudge.css +++ b/webapp/public/style_domjudge.css @@ -659,6 +659,9 @@ blockquote { font-size: smaller; } +.right { + text-align: right; +} /* Disable the sticky footer on mobile */ @media only screen and (min-width: 600px) { diff --git a/webapp/src/Command/CheckDatabaseConfigurationDefaultValuesCommand.php b/webapp/src/Command/CheckDatabaseConfigurationDefaultValuesCommand.php index ffbe6dc0cff..5fe5e243bca 100644 --- a/webapp/src/Command/CheckDatabaseConfigurationDefaultValuesCommand.php +++ b/webapp/src/Command/CheckDatabaseConfigurationDefaultValuesCommand.php @@ -27,38 +27,38 @@ protected function execute(InputInterface $input, OutputInterface $output): int foreach ($this->config->getConfigSpecification() as $specification) { $message = sprintf( 'Configuration %s (in category %s) is of type %s but has wrong type for default_value (%s)', - $specification['name'], - $specification['category'], - $specification['type'], - json_encode($specification['default_value'], JSON_THROW_ON_ERROR) + $specification->name, + $specification->category, + $specification->type, + json_encode($specification->defaultValue, JSON_THROW_ON_ERROR) ); - switch ($specification['type']) { + switch ($specification->type) { case 'bool': - if (!is_bool($specification['default_value'])) { + if (!is_bool($specification->defaultValue)) { $messages[] = $message; } break; case 'int': - if (!is_int($specification['default_value'])) { + if (!is_int($specification->defaultValue)) { $messages[] = $message; } break; case 'string': - if (!is_string($specification['default_value'])) { + if (!is_string($specification->defaultValue)) { $messages[] = $message; } break; case 'array_val': - if (!(empty($specification['default_value']) || ( - is_array($specification['default_value']) && - is_int(key($specification['default_value']))))) { + if (!(empty($specification->defaultValue) || ( + is_array($specification->defaultValue) && + is_int(key($specification->defaultValue))))) { $messages[] = $message; } break; case 'array_keyval': - if (!(empty($specification['default_value']) || ( - is_array($specification['default_value']) && - is_string(key($specification['default_value']))))) { + if (!(empty($specification->defaultValue) || ( + is_array($specification->defaultValue) && + is_string(key($specification->defaultValue))))) { $messages[] = $message; } break; diff --git a/webapp/src/Command/ImportEventFeedCommand.php b/webapp/src/Command/ImportEventFeedCommand.php index 594a6fb6118..1b44a3f27a7 100644 --- a/webapp/src/Command/ImportEventFeedCommand.php +++ b/webapp/src/Command/ImportEventFeedCommand.php @@ -105,7 +105,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $dataSource = (int)$this->config->get('data_source'); $importDataSource = DOMJudgeService::DATA_SOURCE_CONFIGURATION_AND_LIVE_EXTERNAL; if ($dataSource !== $importDataSource) { - $dataSourceOptions = $this->config->getConfigSpecification()['data_source']['options']; + $dataSourceOptions = $this->config->getConfigSpecification()['data_source']->options; $this->style->error(sprintf( "data_source configuration setting is set to '%s' but should be '%s'.", $dataSourceOptions[$dataSource], diff --git a/webapp/src/Controller/API/AbstractApiController.php b/webapp/src/Controller/API/AbstractApiController.php new file mode 100644 index 00000000000..163f000bba0 --- /dev/null +++ b/webapp/src/Controller/API/AbstractApiController.php @@ -0,0 +1,103 @@ +em->createQueryBuilder(); + $qb + ->from(Contest::class, 'c') + ->select('c') + ->andWhere('c.enabled = 1') + ->orderBy('c.activatetime'); + + if ($onlyActive || !$this->dj->checkrole('api_reader')) { + $qb + ->andWhere('c.activatetime <= :now') + ->andWhere('c.deactivatetime IS NULL OR c.deactivatetime > :now') + ->setParameter('now', $now); + } + + // Filter on contests this user has access to + if (!$this->dj->checkrole('api_reader') && !$this->dj->checkrole('judgehost')) { + if ($this->dj->checkrole('team') && $this->dj->getUser()->getTeam()) { + $qb->leftJoin('c.teams', 'ct') + ->leftJoin('c.team_categories', 'tc') + ->leftJoin('tc.teams', 'tct') + ->andWhere('ct.teamid = :teamid OR tct.teamid = :teamid OR c.openToAllTeams = 1') + ->setParameter('teamid', $this->dj->getUser()->getTeam()); + } else { + $qb->andWhere('c.public = 1'); + } + } + + return $qb; + } + + /** + * @throws NonUniqueResultException + */ + protected function getContestId(Request $request): int + { + if (!$request->attributes->has('cid')) { + throw new BadRequestHttpException('cid parameter missing'); + } + + $qb = $this->getContestQueryBuilder($request->query->getBoolean('onlyActive', false)); + $qb + ->andWhere(sprintf('c.%s = :cid', $this->getContestIdField())) + ->setParameter('cid', $request->attributes->get('cid')); + + /** @var Contest|null $contest */ + $contest = $qb->getQuery()->getOneOrNullResult(); + + if ($contest === null) { + throw new NotFoundHttpException(sprintf('Contest with ID \'%s\' not found', $request->attributes->get('cid'))); + } + + return $contest->getCid(); + } + + protected function getContestIdField(): string + { + try { + return $this->eventLogService->externalIdFieldForEntity(Contest::class) ?? 'cid'; + } catch (Exception) { + return 'cid'; + } + } +} diff --git a/webapp/src/Controller/API/AbstractRestController.php b/webapp/src/Controller/API/AbstractRestController.php index 114aebd1c1c..e59da07a477 100644 --- a/webapp/src/Controller/API/AbstractRestController.php +++ b/webapp/src/Controller/API/AbstractRestController.php @@ -2,40 +2,24 @@ namespace App\Controller\API; -use App\Entity\Contest; -use App\Service\ConfigurationService; -use App\Service\DOMJudgeService; -use App\Service\EventLogService; -use App\Utils\Utils; -use Doctrine\ORM\EntityManagerInterface; +use App\Entity\BaseApiEntity; use Doctrine\ORM\NonUniqueResultException; use Doctrine\ORM\QueryBuilder; -use Exception; -use FOS\RestBundle\Controller\AbstractFOSRestController; use Symfony\Component\HttpFoundation\BinaryFileResponse; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Component\Validator\ConstraintViolationInterface; use Symfony\Component\Validator\ConstraintViolationListInterface; -abstract class AbstractRestController extends AbstractFOSRestController +/** + * @template T of BaseApiEntity + * @template U + */ +abstract class AbstractRestController extends AbstractApiController { - final public const GROUP_DEFAULT = 'Default'; - final public const GROUP_NONSTRICT = 'Nonstrict'; - final public const GROUP_RESTRICTED = 'Restricted'; - final public const GROUP_RESTRICTED_NONSTRICT = 'RestrictedNonstrict'; - - public function __construct( - protected readonly EntityManagerInterface $em, - protected readonly DOMJudgeService $dj, - protected readonly ConfigurationService $config, - protected readonly EventLogService $eventLogService - ) {} - /** * Get all objects for this endpoint. * @throws NonUniqueResultException @@ -153,76 +137,6 @@ protected function renderCreateData( $headers); } - /** - * Get the query builder used for getting contests. - * @param bool $onlyActive return only contests that are active - */ - protected function getContestQueryBuilder(bool $onlyActive = false): QueryBuilder - { - $now = Utils::now(); - $qb = $this->em->createQueryBuilder(); - $qb - ->from(Contest::class, 'c') - ->select('c') - ->andWhere('c.enabled = 1') - ->orderBy('c.activatetime'); - - if ($onlyActive || !$this->dj->checkrole('api_reader')) { - $qb - ->andWhere('c.activatetime <= :now') - ->andWhere('c.deactivatetime IS NULL OR c.deactivatetime > :now') - ->setParameter('now', $now); - } - - // Filter on contests this user has access to - if (!$this->dj->checkrole('api_reader') && !$this->dj->checkrole('judgehost')) { - if ($this->dj->checkrole('team') && $this->dj->getUser()->getTeam()) { - $qb->leftJoin('c.teams', 'ct') - ->leftJoin('c.team_categories', 'tc') - ->leftJoin('tc.teams', 'tct') - ->andWhere('ct.teamid = :teamid OR tct.teamid = :teamid OR c.openToAllTeams = 1') - ->setParameter('teamid', $this->dj->getUser()->getTeam()); - } else { - $qb->andWhere('c.public = 1'); - } - } - - return $qb; - } - - /** - * @throws NonUniqueResultException - */ - protected function getContestId(Request $request): int - { - if (!$request->attributes->has('cid')) { - throw new BadRequestHttpException('cid parameter missing'); - } - - $qb = $this->getContestQueryBuilder($request->query->getBoolean('onlyActive', false)); - $qb - ->andWhere(sprintf('c.%s = :cid', $this->getContestIdField())) - ->setParameter('cid', $request->attributes->get('cid')); - - /** @var Contest|null $contest */ - $contest = $qb->getQuery()->getOneOrNullResult(); - - if ($contest === null) { - throw new NotFoundHttpException(sprintf('Contest with ID \'%s\' not found', $request->attributes->get('cid'))); - } - - return $contest->getCid(); - } - - protected function getContestIdField(): string - { - try { - return $this->eventLogService->externalIdFieldForEntity(Contest::class) ?? 'cid'; - } catch (Exception) { - return 'cid'; - } - } - /** * Get the query builder to use for request for this REST endpoint. * @throws NonUniqueResultException @@ -235,6 +149,7 @@ abstract protected function getQueryBuilder(Request $request): QueryBuilder; abstract protected function getIdField(): string; /** + * @return array * @throws NonUniqueResultException */ protected function listActionHelper(Request $request): array @@ -271,7 +186,7 @@ protected function listActionHelper(Request $request): array } } - /** @var array $objects */ + /** @var array $objects */ $objects = $queryBuilder ->getQuery() ->getResult(); diff --git a/webapp/src/Controller/API/AccessController.php b/webapp/src/Controller/API/AccessController.php index 4cd8203b87a..68b8c5d2f2d 100644 --- a/webapp/src/Controller/API/AccessController.php +++ b/webapp/src/Controller/API/AccessController.php @@ -6,8 +6,6 @@ use App\DataTransferObject\AccessEndpoint; use Doctrine\ORM\NonUniqueResultException; use Doctrine\ORM\NoResultException; -use Doctrine\ORM\QueryBuilder; -use Exception; use FOS\RestBundle\Controller\Annotations as Rest; use Nelmio\ApiDocBundle\Annotation\Model; use OpenApi\Attributes as OA; @@ -22,7 +20,7 @@ #[OA\Response(ref: '#/components/responses/Unauthenticated', response: 401)] #[OA\Response(ref: '#/components/responses/Unauthorized', response: 403)] #[OA\Response(ref: '#/components/responses/NotFound', response: 404)] -class AccessController extends AbstractRestController +class AccessController extends AbstractApiController { /** * Get access information @@ -218,14 +216,4 @@ public function getStatusAction(Request $request): Access ], ); } - - protected function getQueryBuilder(Request $request): QueryBuilder - { - throw new Exception('Not implemented'); - } - - protected function getIdField(): string - { - throw new Exception('Not implemented'); - } } diff --git a/webapp/src/Controller/API/AccountController.php b/webapp/src/Controller/API/AccountController.php index 6ed27308f06..a10e4189f16 100644 --- a/webapp/src/Controller/API/AccountController.php +++ b/webapp/src/Controller/API/AccountController.php @@ -14,6 +14,9 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\Security\Http\Attribute\IsGranted; +/** + * @extends AbstractRestController + */ #[Rest\Route('/contests/{cid}')] #[OA\Tag(name: 'Accounts')] #[OA\Parameter(ref: '#/components/parameters/cid')] diff --git a/webapp/src/Controller/API/AwardsController.php b/webapp/src/Controller/API/AwardsController.php index 45c90b5a57c..3dde1cea2de 100644 --- a/webapp/src/Controller/API/AwardsController.php +++ b/webapp/src/Controller/API/AwardsController.php @@ -11,7 +11,6 @@ use App\Service\ScoreboardService; use App\Utils\Scoreboard\Scoreboard; use Doctrine\ORM\EntityManagerInterface; -use Doctrine\ORM\QueryBuilder; use Exception; use FOS\RestBundle\Controller\Annotations as Rest; use Nelmio\ApiDocBundle\Annotation\Model; @@ -27,7 +26,7 @@ #[OA\Response(ref: '#/components/responses/NotFound', response: 404)] #[OA\Response(ref: '#/components/responses/Unauthenticated', response: 401)] #[OA\Response(ref: '#/components/responses/InvalidResponse', response: 400)] -class AwardsController extends AbstractRestController +class AwardsController extends AbstractApiController { public function __construct( EntityManagerInterface $entityManager, @@ -106,14 +105,4 @@ protected function getContestAndScoreboard(Request $request): array return [$contest, $scoreboard]; } - - protected function getQueryBuilder(Request $request): QueryBuilder - { - throw new Exception('Not implemented'); - } - - protected function getIdField(): string - { - throw new Exception('Not implemented'); - } } diff --git a/webapp/src/Controller/API/BalloonController.php b/webapp/src/Controller/API/BalloonController.php index 1c5d1ad4e1b..02c4f780253 100644 --- a/webapp/src/Controller/API/BalloonController.php +++ b/webapp/src/Controller/API/BalloonController.php @@ -7,8 +7,6 @@ use App\Entity\Team; use App\Service\BalloonService; use Doctrine\ORM\NonUniqueResultException; -use Doctrine\ORM\QueryBuilder; -use Exception; use FOS\RestBundle\Controller\Annotations as Rest; use Nelmio\ApiDocBundle\Annotation\Model; use OpenApi\Attributes as OA; @@ -25,7 +23,7 @@ #[OA\Response(ref: '#/components/responses/InvalidResponse', response: 400)] #[OA\Response(ref: '#/components/responses/Unauthenticated', response: 401)] #[OA\Response(ref: '#/components/responses/Unauthorized', response: 403)] -class BalloonController extends AbstractRestController +class BalloonController extends AbstractApiController { /** * Get all the balloons for this contest. @@ -97,14 +95,4 @@ public function markDoneAction(int $balloonId, BalloonService $balloonService): { $balloonService->setDone($balloonId); } - - protected function getQueryBuilder(Request $request): QueryBuilder - { - throw new Exception('Not implemented'); - } - - protected function getIdField(): string - { - throw new Exception('Not implemented'); - } } diff --git a/webapp/src/Controller/API/ClarificationController.php b/webapp/src/Controller/API/ClarificationController.php index a9151cb2239..3d2f9daadc8 100644 --- a/webapp/src/Controller/API/ClarificationController.php +++ b/webapp/src/Controller/API/ClarificationController.php @@ -22,6 +22,9 @@ use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\Security\Http\Attribute\IsGranted; +/** + * @extends AbstractRestController + */ #[Rest\Route('/contests/{cid}/clarifications')] #[OA\Tag(name: 'Clarifications')] #[OA\Parameter(ref: '#/components/parameters/cid')] diff --git a/webapp/src/Controller/API/ContestController.php b/webapp/src/Controller/API/ContestController.php index f2de4dfcbe2..c1d49183b04 100644 --- a/webapp/src/Controller/API/ContestController.php +++ b/webapp/src/Controller/API/ContestController.php @@ -45,6 +45,9 @@ use Symfony\Component\Yaml\Yaml; use TypeError; +/** + * @extends AbstractRestController + */ #[Rest\Route('/contests')] #[OA\Tag(name: 'Contests')] #[OA\Parameter(ref: '#/components/parameters/strict')] @@ -260,6 +263,121 @@ public function setBannerAction(Request $request, string $cid, ValidatorInterfac return new Response('', Response::HTTP_NO_CONTENT); } + /** + * Delete the text for the given contest. + */ + #[IsGranted('ROLE_ADMIN')] + #[Rest\Delete('/{cid}/text', name: 'delete_contest_text')] + #[OA\Response(response: 204, description: 'Deleting text succeeded')] + #[OA\Parameter(ref: '#/components/parameters/cid')] + public function deleteTextAction(Request $request, string $cid): Response + { + $contest = $this->getContestAndCheckIfLocked($request, $cid); + $contest->setClearContestText(true); + $contest->processContestText(); + $this->em->flush(); + + $this->eventLogService->log('contests', $contest->getCid(), EventLogService::ACTION_UPDATE, + $contest->getCid()); + + return new Response('', Response::HTTP_NO_CONTENT); + } + + /** + * Set the text for the given contest. + */ + #[IsGranted('ROLE_ADMIN')] + #[Rest\Post("/{cid}/text", name: 'post_contest_text')] + #[Rest\Put("/{cid}/text", name: 'put_contest_text')] + #[OA\RequestBody( + required: true, + content: new OA\MediaType( + mediaType: 'multipart/form-data', + schema: new OA\Schema( + required: ['text'], + properties: [ + new OA\Property( + property: 'text', + description: 'The text to use, as either text/html, text/plain or application/pdf.', + type: 'string', + format: 'binary' + ), + ] + ) + ) + )] + #[OA\Response(response: 204, description: 'Setting text succeeded')] + #[OA\Parameter(ref: '#/components/parameters/cid')] + public function setTextAction(Request $request, string $cid, ValidatorInterface $validator): Response + { + $contest = $this->getContestAndCheckIfLocked($request, $cid); + + /** @var UploadedFile|null $text */ + $text = $request->files->get('text'); + if (!$text) { + return new JsonResponse(['title' => 'Validation failed', 'errors' => ['Please supply a text']], Response::HTTP_BAD_REQUEST); + } + if (!in_array($text->getMimeType(), ['text/html', 'text/plain', 'application/pdf'])) { + return new JsonResponse(['title' => 'Validation failed', 'errors' => ['Invalid text type']], Response::HTTP_BAD_REQUEST); + } + + $contest->setContestTextFile($text); + + if ($errorResponse = $this->responseForErrors($validator->validate($contest), true)) { + return $errorResponse; + } + + $contest->processContestText(); + $this->em->flush(); + + $this->eventLogService->log('contests', $contest->getCid(), EventLogService::ACTION_UPDATE, + $contest->getCid()); + + return new Response('', Response::HTTP_NO_CONTENT); + } + + /** + * Get the text for the given contest. + */ + #[Rest\Get('/{cid}/text', name: 'contest_text')] + #[OA\Response( + response: 200, + description: 'Returns the given contest text in PDF, HTML or TXT format', + content: [ + new OA\MediaType(mediaType: 'application/pdf'), + new OA\MediaType(mediaType: 'text/plain'), + new OA\MediaType(mediaType: 'text/html'), + ] + )] + #[OA\Parameter(ref: '#/components/parameters/cid')] + public function textAction(Request $request, string $cid): Response + { + /** @var Contest|null $contest */ + $contest = $this->getQueryBuilder($request) + ->andWhere(sprintf('%s = :id', $this->getIdField())) + ->setParameter('id', $cid) + ->getQuery() + ->getOneOrNullResult(); + + $hasAccess = $this->dj->checkrole('jury') || + $this->dj->checkrole('api_reader') || + $contest->getFreezeData()->started(); + + if (!$hasAccess) { + throw new AccessDeniedHttpException(); + } + + if ($contest === null) { + throw new NotFoundHttpException(sprintf('Object with ID \'%s\' not found', $cid)); + } + + if (!$contest->getContestTextType()) { + throw new NotFoundHttpException(sprintf('Contest with ID \'%s\' has no text', $cid)); + } + + return $contest->getContestTextStreamedResponse(); + } + /** * Change the start time or unfreeze (thaw) time of the given contest. * @throws NonUniqueResultException diff --git a/webapp/src/Controller/API/GeneralInfoController.php b/webapp/src/Controller/API/GeneralInfoController.php index d243b249f42..f47ea0c8009 100644 --- a/webapp/src/Controller/API/GeneralInfoController.php +++ b/webapp/src/Controller/API/GeneralInfoController.php @@ -163,6 +163,8 @@ public function getUserAction(): User /** * Get configuration variables. + * + * @return array> */ #[Rest\Get('/config')] #[OA\Response( @@ -202,6 +204,8 @@ public function getDatabaseConfigurationAction( /** * Update configuration variables. + * @return JsonResponse|array|string> + * * @throws NonUniqueResultException */ #[IsGranted('ROLE_ADMIN')] @@ -328,6 +332,8 @@ public function countryFlagAction( /** * Add a problem without linking it to a contest. + * + * @return array{problem_id: string, messages: array} */ #[IsGranted('ROLE_ADMIN')] #[Rest\Post('/problems')] diff --git a/webapp/src/Controller/API/GroupController.php b/webapp/src/Controller/API/GroupController.php index 93cbd76f04b..712ee90c8f0 100644 --- a/webapp/src/Controller/API/GroupController.php +++ b/webapp/src/Controller/API/GroupController.php @@ -3,6 +3,7 @@ namespace App\Controller\API; use App\DataTransferObject\TeamCategoryPost; +use App\DataTransferObject\TeamCategoryPut; use App\Entity\TeamCategory; use App\Service\ImportExportService; use Doctrine\ORM\NonUniqueResultException; @@ -16,6 +17,9 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; +/** + * @extends AbstractRestController + */ #[Rest\Route('/contests/{cid}/groups')] #[OA\Tag(name: 'Groups')] #[OA\Parameter(ref: '#/components/parameters/cid')] @@ -91,19 +95,70 @@ public function addAction( #[MapRequestPayload(validationFailedStatusCode: Response::HTTP_BAD_REQUEST)] TeamCategoryPost $teamCategoryPost, Request $request, - ImportExportService $importExport + ImportExportService $importExport, ): Response { $saved = []; - $importExport->importGroupsJson([ - [ - 'name' => $teamCategoryPost->name, - 'hidden' => $teamCategoryPost->hidden, - 'icpc_id' => $teamCategoryPost->icpcId, - 'sortorder' => $teamCategoryPost->sortorder, - 'color' => $teamCategoryPost->color, - 'allow_self_registration' => $teamCategoryPost->allowSelfRegistration, - ], - ], $message, $saved); + $groupData = [ + 'name' => $teamCategoryPost->name, + 'hidden' => $teamCategoryPost->hidden, + 'icpc_id' => $teamCategoryPost->icpcId, + 'sortorder' => $teamCategoryPost->sortorder, + 'color' => $teamCategoryPost->color, + 'allow_self_registration' => $teamCategoryPost->allowSelfRegistration, + ]; + $importExport->importGroupsJson([$groupData], $message, $saved); + if (!empty($message)) { + throw new BadRequestHttpException("Error while adding group: $message"); + } + + $group = $saved[0]; + $idField = $this->eventLogService->externalIdFieldForEntity(TeamCategory::class) ?? 'categoryid'; + $method = sprintf('get%s', ucfirst($idField)); + $id = call_user_func([$group, $method]); + + return $this->renderCreateData($request, $saved[0], 'group', $id); + } + + /** + * Update an existing group or create one with the given ID + */ + #[IsGranted('ROLE_API_WRITER')] + #[Rest\Put('/{id}')] + #[OA\RequestBody( + required: true, + content: [ + new OA\MediaType( + mediaType: 'multipart/form-data', + schema: new OA\Schema(ref: new Model(type: TeamCategoryPut::class)) + ), + ] + )] + #[OA\Response( + response: 201, + description: 'Returns the updated / added group', + content: new Model(type: TeamCategory::class) + )] + public function updateAction( + #[MapRequestPayload(validationFailedStatusCode: Response::HTTP_BAD_REQUEST)] + TeamCategoryPut $teamCategoryPut, + Request $request, + ImportExportService $importExport, + string $id, + ): Response { + $saved = []; + $groupData = [ + 'id' => $teamCategoryPut->id, + 'name' => $teamCategoryPut->name, + 'hidden' => $teamCategoryPut->hidden, + 'icpc_id' => $teamCategoryPut->icpcId, + 'sortorder' => $teamCategoryPut->sortorder, + 'color' => $teamCategoryPut->color, + 'allow_self_registration' => $teamCategoryPut->allowSelfRegistration, + ]; + if ($id !== $teamCategoryPut->id) { + throw new BadRequestHttpException('ID in URL does not match ID in payload'); + } + $importExport->importGroupsJson([$groupData], $message, $saved); if (!empty($message)) { throw new BadRequestHttpException("Error while adding group: $message"); } diff --git a/webapp/src/Controller/API/JudgehostController.php b/webapp/src/Controller/API/JudgehostController.php index 5115f69c7f5..77f5a3fafef 100644 --- a/webapp/src/Controller/API/JudgehostController.php +++ b/webapp/src/Controller/API/JudgehostController.php @@ -113,6 +113,8 @@ public function getJudgehostsAction( /** * Add a new judgehost to the list of judgehosts. * Also restarts (and returns) unfinished judgings. + * + * @return array * @throws NonUniqueResultException */ #[IsGranted('ROLE_JUDGEHOST')] @@ -192,6 +194,8 @@ public function createJudgehostAction(Request $request): array /** * Update the configuration of the given judgehost. + * + * @return Judgehost[] */ #[IsGranted('ROLE_JUDGEHOST')] #[Rest\Put('/{hostname}')] @@ -1184,6 +1188,8 @@ public function getFilesAction( /** * Get version commands for a given compile script. + * + * @return array{compiler_version_command?: string, runner_version_command?: string} */ #[IsGranted(new Expression("is_granted('ROLE_JURY') or is_granted('ROLE_JUDGEHOST')"))] #[Rest\Get('/get_version_commands/{judgetaskid<\d+>}')] @@ -1224,6 +1230,9 @@ public function getVersionCommands(string $judgetaskid): array return $ret; } + /** + * @return array{} + */ #[IsGranted(new Expression("is_granted('ROLE_JURY') or is_granted('ROLE_JUDGEHOST')"))] #[Rest\Put('/check_versions/{judgetaskid}')] #[OA\Response( diff --git a/webapp/src/Controller/API/JudgementController.php b/webapp/src/Controller/API/JudgementController.php index a0f3cd7b883..ca6530efbc8 100644 --- a/webapp/src/Controller/API/JudgementController.php +++ b/webapp/src/Controller/API/JudgementController.php @@ -18,6 +18,9 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Security\Http\Attribute\IsGranted; +/** + * @extends AbstractRestController + */ #[Rest\Route('/')] #[OA\Tag(name: 'Judgements')] #[OA\Parameter(ref: '#/components/parameters/cid')] diff --git a/webapp/src/Controller/API/JudgementTypeController.php b/webapp/src/Controller/API/JudgementTypeController.php index c8b02b54d1c..fe8588c7335 100644 --- a/webapp/src/Controller/API/JudgementTypeController.php +++ b/webapp/src/Controller/API/JudgementTypeController.php @@ -17,7 +17,7 @@ #[OA\Parameter(ref: '#/components/parameters/strict')] #[OA\Response(ref: '#/components/responses/InvalidResponse', response: 400)] #[OA\Response(ref: '#/components/responses/Unauthenticated', response: 401)] -class JudgementTypeController extends AbstractRestController +class JudgementTypeController extends AbstractApiController { /** * Get all the judgement types for this contest. @@ -112,14 +112,4 @@ protected function getJudgementTypes(array $filteredOn = null): array } return $result; } - - protected function getQueryBuilder(Request $request): QueryBuilder - { - throw new Exception('Not implemented'); - } - - protected function getIdField(): string - { - throw new Exception('Not implemented'); - } } diff --git a/webapp/src/Controller/API/LanguageController.php b/webapp/src/Controller/API/LanguageController.php index 3128113d5c9..e84be740b04 100644 --- a/webapp/src/Controller/API/LanguageController.php +++ b/webapp/src/Controller/API/LanguageController.php @@ -17,6 +17,9 @@ use Symfony\Component\Security\Http\Attribute\IsGranted; use ZipArchive; +/** + * @extends AbstractRestController + */ #[Rest\Route('/')] #[OA\Tag(name: 'Languages')] #[OA\Parameter(ref: '#/components/parameters/cid')] diff --git a/webapp/src/Controller/API/OrganizationController.php b/webapp/src/Controller/API/OrganizationController.php index 757afb72491..1269cb44b40 100644 --- a/webapp/src/Controller/API/OrganizationController.php +++ b/webapp/src/Controller/API/OrganizationController.php @@ -25,6 +25,9 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\Validator\Validator\ValidatorInterface; +/** + * @extends AbstractRestController + */ #[Rest\Route('/')] #[OA\Tag(name: 'Organizations')] #[OA\Parameter(ref: '#/components/parameters/cid')] diff --git a/webapp/src/Controller/API/ProblemController.php b/webapp/src/Controller/API/ProblemController.php index 31922076b12..8ed73d50fb7 100644 --- a/webapp/src/Controller/API/ProblemController.php +++ b/webapp/src/Controller/API/ProblemController.php @@ -30,6 +30,9 @@ use Symfony\Component\Security\Http\Attribute\IsGranted; use Symfony\Component\Yaml\Yaml; +/** + * @extends AbstractRestController + */ #[Rest\Route('/contests/{cid}/problems')] #[OA\Tag(name: 'Problems')] #[OA\Parameter(ref: '#/components/parameters/cid')] @@ -54,6 +57,7 @@ public function __construct( /** * Add one or more problems. * + * @return int[] * @throws BadRequestHttpException * @throws NonUniqueResultException */ @@ -166,6 +170,7 @@ public function listAction(Request $request): Response /** * Add a problem to this contest. + * @return array{problem_id: string, messages: array} * @throws NonUniqueResultException */ #[IsGranted('ROLE_ADMIN')] @@ -459,7 +464,7 @@ protected function getIdField(): string /** * Transform the given object before returning it from the API. - * @param array $object + * @param array{0: ContestProblem, testdatacount: int} $object */ public function transformObject($object): ContestProblem|ContestProblemWrapper { diff --git a/webapp/src/Controller/API/RunController.php b/webapp/src/Controller/API/RunController.php index 9d9d503aac5..c73ff8780d3 100644 --- a/webapp/src/Controller/API/RunController.php +++ b/webapp/src/Controller/API/RunController.php @@ -19,6 +19,9 @@ use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\Security\Http\Attribute\IsGranted; +/** + * @extends AbstractRestController + */ #[Rest\Route('/contests/{cid}/runs')] #[OA\Tag(name: 'Runs')] #[OA\Parameter(ref: '#/components/parameters/cid')] diff --git a/webapp/src/Controller/API/ScoreboardController.php b/webapp/src/Controller/API/ScoreboardController.php index 2d6f3162aa8..9cf171add81 100644 --- a/webapp/src/Controller/API/ScoreboardController.php +++ b/webapp/src/Controller/API/ScoreboardController.php @@ -17,8 +17,6 @@ use App\Utils\Utils; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\NonUniqueResultException; -use Doctrine\ORM\QueryBuilder; -use Exception; use FOS\RestBundle\Controller\Annotations as Rest; use Nelmio\ApiDocBundle\Annotation\Model; use OpenApi\Attributes as OA; @@ -33,7 +31,7 @@ #[OA\Response(ref: '#/components/responses/Unauthenticated', response: 401)] #[OA\Response(ref: '#/components/responses/Unauthorized', response: 403)] #[OA\Response(ref: '#/components/responses/NotFound', response: 404)] -class ScoreboardController extends AbstractRestController +class ScoreboardController extends AbstractApiController { public function __construct( EntityManagerInterface $entityManager, @@ -224,14 +222,4 @@ public function getScoreboardAction( return $results; } - - protected function getQueryBuilder(Request $request): QueryBuilder - { - throw new Exception('Not implemented'); - } - - protected function getIdField(): string - { - throw new Exception('Not implemented'); - } } diff --git a/webapp/src/Controller/API/SubmissionController.php b/webapp/src/Controller/API/SubmissionController.php index e8ba3c8e283..1d7564056fb 100644 --- a/webapp/src/Controller/API/SubmissionController.php +++ b/webapp/src/Controller/API/SubmissionController.php @@ -34,6 +34,9 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException; +/** + * @extends AbstractRestController + */ #[Rest\Route('/')] #[OA\Tag(name: 'Submissions')] #[OA\Parameter(ref: '#/components/parameters/cid')] diff --git a/webapp/src/Controller/API/TeamController.php b/webapp/src/Controller/API/TeamController.php index 63374228e6d..eff212911ec 100644 --- a/webapp/src/Controller/API/TeamController.php +++ b/webapp/src/Controller/API/TeamController.php @@ -26,6 +26,9 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\Validator\Validator\ValidatorInterface; +/** + * @extends AbstractRestController + */ #[Rest\Route('/')] #[OA\Tag(name: 'Teams')] #[OA\Parameter(ref: '#/components/parameters/cid')] diff --git a/webapp/src/Controller/API/UserController.php b/webapp/src/Controller/API/UserController.php index 2e3dc496a04..00d3a70c5fa 100644 --- a/webapp/src/Controller/API/UserController.php +++ b/webapp/src/Controller/API/UserController.php @@ -3,6 +3,7 @@ namespace App\Controller\API; use App\DataTransferObject\AddUser; +use App\DataTransferObject\UpdateUser; use App\Entity\Role; use App\Entity\Team; use App\Entity\User; @@ -24,6 +25,9 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; +/** + * @extends AbstractRestController + */ #[Rest\Route('/users', defaults: ['_format' => 'json'])] #[OA\Tag(name: 'Users')] #[OA\Response(ref: '#/components/responses/InvalidResponse', response: 400)] @@ -64,7 +68,7 @@ public function __construct( description: 'The groups.json files to import.', type: 'string', format: 'binary' - ) + ), ] ) ) @@ -106,7 +110,7 @@ public function addGroupsAction(Request $request): string property: 'json', description: 'The organizations.json files to import.', type: 'string', - format: 'binary') + format: 'binary'), ] ) ) @@ -150,7 +154,7 @@ public function addOrganizationsAction(Request $request): string description: 'The teams.json files to import.', type: 'string', format: 'binary' - ) + ), ] ) ) @@ -205,7 +209,7 @@ public function addTeamsAction(Request $request): string description: 'The accounts.yaml files to import.', type: 'string', format: 'binary' - ) + ), ] ) ) @@ -294,7 +298,7 @@ public function singleAction(Request $request, string $id): Response new OA\MediaType( mediaType: 'multipart/form-data', schema: new OA\Schema(ref: new Model(type: AddUser::class)) - ) + ), ] )] #[OA\Response( @@ -307,11 +311,53 @@ public function addAction( AddUser $addUser, Request $request ): Response { + return $this->addOrUpdateUser($addUser, $request); + } + + /** + * Update an existing User or create one with the given ID + */ + #[IsGranted('ROLE_API_WRITER')] + #[Rest\Put('/{id}')] + #[OA\RequestBody( + required: true, + content: [ + new OA\MediaType( + mediaType: 'multipart/form-data', + schema: new OA\Schema(ref: new Model(type: UpdateUser::class)) + ), + ] + )] + #[OA\Response( + response: 201, + description: 'Returns the added user', + content: new Model(type: User::class) + )] + public function updateAction( + #[MapRequestPayload(validationFailedStatusCode: Response::HTTP_BAD_REQUEST)] + UpdateUser $updateUser, + Request $request + ): Response { + return $this->addOrUpdateUser($updateUser, $request); + } + + protected function addOrUpdateUser(AddUser $addUser, Request $request): Response + { + if ($addUser instanceof UpdateUser && $this->eventLogService->externalIdFieldForEntity(User::class) && !$addUser->id) { + throw new BadRequestHttpException('`id` field is required'); + } + if ($this->em->getRepository(User::class)->findOneBy(['username' => $addUser->username])) { throw new BadRequestHttpException(sprintf("User %s already exists", $addUser->username)); } $user = new User(); + if ($addUser instanceof UpdateUser) { + $existingUser = $this->em->getRepository(User::class)->findOneBy([$this->eventLogService->externalIdFieldForEntity(User::class) => $addUser->id]); + if ($existingUser) { + $user = $existingUser; + } + } $user ->setUsername($addUser->username) ->setName($addUser->name) @@ -320,6 +366,10 @@ public function addAction( ->setPlainPassword($addUser->password) ->setEnabled($addUser->enabled ?? true); + if ($addUser instanceof UpdateUser) { + $user->setExternalid($addUser->id); + } + if ($addUser->teamId) { /** @var Team|null $team */ $team = $this->em->createQueryBuilder() diff --git a/webapp/src/Controller/BaseController.php b/webapp/src/Controller/BaseController.php index f206204d91f..64ee36b090e 100644 --- a/webapp/src/Controller/BaseController.php +++ b/webapp/src/Controller/BaseController.php @@ -101,6 +101,14 @@ protected function saveEntity( ): void { $auditLogType = Utils::tableForEntity($entity); + // Call the prePersist lifecycle callbacks. + // This used to work in preUpdate, but Doctrine has deprecated that feature. + // See https://www.doctrine-project.org/projects/doctrine-orm/en/3.1/reference/events.html#events-overview. + $metadata = $entityManager->getClassMetadata($entity::class); + foreach ($metadata->lifecycleCallbacks['prePersist'] ?? [] as $prePersistMethod) { + $entity->$prePersistMethod(); + } + $entityManager->persist($entity); $entityManager->flush(); diff --git a/webapp/src/Controller/Jury/ConfigController.php b/webapp/src/Controller/Jury/ConfigController.php index b9c34dbb113..bbddc76be73 100644 --- a/webapp/src/Controller/Jury/ConfigController.php +++ b/webapp/src/Controller/Jury/ConfigController.php @@ -65,13 +65,48 @@ public function indexAction(EventLogService $eventLogService, Request $request): } } } + $before = $this->config->all(); $errors = $this->config->saveChanges($data, $eventLogService, $this->dj, $options); + $after = $this->config->all(); + + // Compile a list of differences. + $diffs = []; + foreach ($before as $key => $value) { + if (!array_key_exists($key, $after)) { + $diffs[$key] = ['before' => $value, 'after' => null]; + } elseif ($value !== $after[$key]) { + $diffs[$key] = ['before' => $value, 'after' => $after[$key]]; + } + } + foreach ($after as $key => $value) { + if (!array_key_exists($key, $before)) { + $diffs[$key] = ['before' => null, 'after' => $value]; + } + } if (empty($errors)) { - $this->addFlash('scoreboard_refresh', 'After changing specific ' . - 'settings, you might need to refresh the scoreboard.'); + $needsRefresh = false; + $needsRejudging = false; + foreach ($diffs as $key => $diff) { + $category = $this->config->getCategory($key); + if ($category === 'Scoring') { + $needsRefresh = true; + } + if ($category === 'Judging') { + $needsRejudging = true; + } + } + + if ($needsRefresh) { + $this->addFlash('scoreboard_refresh', 'After changing specific ' . + 'scoring related settings, you might need to refresh the scoreboard (cache).'); + } + if ($needsRejudging) { + $this->addFlash('danger', 'After changing specific ' . + 'judging related settings, you might need to rejudge affected submissions.'); + } - return $this->redirectToRoute('jury_config'); + return $this->redirectToRoute('jury_config', ['diffs' => json_encode($diffs)]); } else { $this->addFlash('danger', 'Some errors occurred while saving configuration, ' . 'please check the data you entered.'); @@ -114,10 +149,15 @@ public function indexAction(EventLogService $eventLogService, Request $request): 'data' => $data ]; } + $diffs = $request->query->get('diffs'); + if ($diffs !== null) { + $diffs = json_decode($diffs, true); + } return $this->render('jury/config.html.twig', [ 'options' => $allData, 'errors' => $errors ?? [], 'activeCategory' => $activeCategory ?? 'Scoring', + 'diffs' => $diffs, ]); } diff --git a/webapp/src/Controller/Jury/ContestController.php b/webapp/src/Controller/Jury/ContestController.php index ad41392bbd8..6f0750f9fc9 100644 --- a/webapp/src/Controller/Jury/ContestController.php +++ b/webapp/src/Controller/Jury/ContestController.php @@ -37,6 +37,7 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpFoundation\StreamedResponse; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; @@ -69,106 +70,7 @@ public function indexAction(Request $request): Response { $em = $this->em; - if ($doNow = $request->request->all('donow')) { - $times = ['activate', 'start', 'freeze', 'end', - 'unfreeze', 'finalize', 'deactivate']; - $start_actions = ['delay_start', 'resume_start']; - $actions = array_merge($times, $start_actions); - - if (!$this->isGranted('ROLE_ADMIN')) { - throw new AccessDeniedHttpException(); - } - $contest = $em->getRepository(Contest::class)->find($request->request->get('contest')); - if (!$contest) { - throw new NotFoundHttpException('Contest not found'); - } - - $time = key($doNow); - if (!in_array($time, $actions, true)) { - throw new BadRequestHttpException( - sprintf("Unknown value '%s' for timetype", $time) - ); - } - - if ($time === 'finalize') { - return $this->redirectToRoute( - 'jury_contest_finalize', - ['contestId' => $contest->getCid()] - ); - } - - $now = (int)floor(Utils::now()); - $nowstring = date('Y-m-d H:i:s ', $now) . date_default_timezone_get(); - $this->dj->auditlog('contest', $contest->getCid(), $time . ' now', $nowstring); - - // Special case delay/resume start (only sets/unsets starttime_undefined). - $maxSeconds = Contest::STARTTIME_UPDATE_MIN_SECONDS_BEFORE; - if (in_array($time, $start_actions, true)) { - $enabled = $time !== 'delay_start'; - if (Utils::difftime((float)$contest->getStarttime(false), $now) <= $maxSeconds) { - $this->addFlash( - 'error', - sprintf("Cannot %s less than %d seconds before contest start.", - $time, $maxSeconds) - ); - return $this->redirectToRoute('jury_contests'); - } - $contest->setStarttimeEnabled($enabled); - $em->flush(); - $this->eventLogService->log( - 'contest', - $contest->getCid(), - EventLogService::ACTION_UPDATE, - $contest->getCid() - ); - $this->addFlash('scoreboard_refresh', - 'After changing the contest start time, it may be ' . - 'necessary to recalculate any cached scoreboards.'); - return $this->redirectToRoute('jury_contests'); - } - - $juryTimeData = $contest->getDataForJuryInterface(); - if (!$juryTimeData[$time]['show_button']) { - throw new BadRequestHttpException( - sprintf('Cannot update %s time at this moment', $time) - ); - } - - // starttime is special because other, relative times depend on it. - if ($time == 'start') { - if ($contest->getStarttimeEnabled() && - Utils::difftime((float)$contest->getStarttime(false), - $now) <= $maxSeconds) { - $this->addFlash( - 'danger', - sprintf("Cannot update starttime less than %d seconds before contest start.", - $maxSeconds) - ); - return $this->redirectToRoute('jury_contests'); - } - $contest - ->setStarttime($now) - ->setStarttimeString($nowstring) - ->setStarttimeEnabled(true); - $em->flush(); - - $this->addFlash('scoreboard_refresh', - 'After changing the contest start time, it may be ' . - 'necessary to recalculate any cached scoreboards.'); - } else { - $method = sprintf('set%stimeString', $time); - $contest->{$method}($nowstring); - $em->flush(); - } - $this->eventLogService->log( - 'contest', - $contest->getCid(), - EventLogService::ACTION_UPDATE, - $contest->getCid() - ); - return $this->redirectToRoute('jury_contests'); - } - + /** @var Contest[] $contests */ $contests = $em->createQueryBuilder() ->select('c') ->from(Contest::class, 'c') @@ -244,22 +146,43 @@ public function indexAction(Request $request): Response } } - if ($this->isGranted('ROLE_ADMIN') && !$contest->isLocked()) { + // Create action links + if ($contest->getContestTextType()) { $contestactions[] = [ - 'icon' => 'edit', - 'title' => 'edit this contest', - 'link' => $this->generateUrl('jury_contest_edit', [ - 'contestId' => $contest->getCid(), + 'icon' => 'file-' . $contest->getContestTextType(), + 'title' => 'view contest description', + 'link' => $this->generateUrl('jury_contest_text', [ + 'cid' => $contest->getCid(), ]) ]; - $contestactions[] = [ - 'icon' => 'trash-alt', - 'title' => 'delete this contest', - 'link' => $this->generateUrl('jury_contest_delete', [ - 'contestId' => $contest->getCid(), - ]), - 'ajaxModal' => true, - ]; + } else { + $contestactions[] = []; + } + if ($this->isGranted('ROLE_ADMIN')) { + if ($contest->isLocked()) { + // The number of table columns and thus the number of actions need + // to match for all rows to not get DataTables errors. + // Since we add two actions for non-locked contests, we need to add + // two empty actions for locked contests. + $contestactions[] = []; + $contestactions[] = []; + } else { + $contestactions[] = [ + 'icon' => 'edit', + 'title' => 'edit this contest', + 'link' => $this->generateUrl('jury_contest_edit', [ + 'contestId' => $contest->getCid(), + ]) + ]; + $contestactions[] = [ + 'icon' => 'trash-alt', + 'title' => 'delete this contest', + 'link' => $this->generateUrl('jury_contest_delete', [ + 'contestId' => $contest->getCid(), + ]), + 'ajaxModal' => true, + ]; + } } $contestdata['process_balloons'] = [ @@ -419,19 +342,43 @@ public function viewAction(Request $request, int $contestId): Response ]); } - #[Route(path: '/{contestId}/toggle-submit', name: 'jury_contest_toggle_submit')] - public function toggleSubmitAction(Request $request, string $contestId): Response + #[Route(path: '/{contestId}/toggle/{type}', name: 'jury_contest_toggle')] + public function toggleSubmitAction(Request $request, string $contestId, string $type): Response { $contest = $this->em->getRepository(Contest::class)->find($contestId); if (!$contest) { throw new NotFoundHttpException(sprintf('Contest with ID %s not found', $contestId)); } - $contest->setAllowSubmit($request->request->getBoolean('allow_submit')); + $value = $request->request->getBoolean('value'); + + switch ($type) { + case 'submit': + $contest->setAllowSubmit($value); + $label = 'set allow submit'; + break; + case 'balloons': + $contest->setProcessBalloons($value); + $label = 'set process balloons'; + break; + case 'tiebreaker': + $contest->setRuntimeAsScoreTiebreaker($value); + $label = 'set runtime as tiebreaker'; + break; + case 'medals': + $contest->setMedalsEnabled($value); + $label = 'set medal processing'; + break; + case 'public': + $contest->setPublic($value); + $label = 'set publicly visible'; + break; + default: + throw new BadRequestHttpException('Unknown toggle type'); + } $this->em->flush(); - $this->dj->auditlog('contest', $contestId, 'set allow submit', - $request->request->getBoolean('allow_submit') ? 'yes' : 'no'); + $this->dj->auditlog('contest', $contestId, $label, $value ? 'yes' : 'no'); return $this->redirectToRoute('jury_contest', ['contestId' => $contestId]); } @@ -849,6 +796,102 @@ public function finalizeAction(Request $request, int $contestId): Response ]); } + #[IsGranted('ROLE_ADMIN')] + #[Route(path: '/{contestId<\d+>}/{time}/doNow', name: 'jury_contest_donow')] + public function doNowAction(Request $request, int $contestId, string $time): Response + { + $times = ['activate', 'start', 'freeze', 'end', 'unfreeze', 'finalize', 'deactivate']; + $start_actions = ['delay_start', 'resume_start']; + $actions = array_merge($times, $start_actions); + + $contest = $this->em->getRepository(Contest::class)->find($contestId); + if (!$contest) { + throw new NotFoundHttpException(sprintf('Contest with ID %s not found', $contestId)); + } + + if (!in_array($time, $actions, true)) { + throw new BadRequestHttpException(sprintf("Unknown value '%s' for timetype", $time)); + } + + if ($time === 'finalize') { + return $this->redirectToRoute('jury_contest_finalize', ['contestId' => $contest->getCid()]); + } + + $now = (int)floor(Utils::now()); + $nowstring = date('Y-m-d H:i:s ', $now) . date_default_timezone_get(); + $this->dj->auditlog('contest', $contest->getCid(), $time . ' now', $nowstring); + + // Special case delay/resume start (only sets/unsets starttime_undefined). + $maxSeconds = Contest::STARTTIME_UPDATE_MIN_SECONDS_BEFORE; + if (in_array($time, $start_actions, true)) { + $enabled = $time !== 'delay_start'; + if (Utils::difftime((float)$contest->getStarttime(false), $now) <= $maxSeconds) { + $this->addFlash( + 'error', + sprintf("Cannot '%s' less than %d seconds before contest start.", + $time, $maxSeconds) + ); + return $this->redirectToRoute('jury_contests'); + } + $contest->setStarttimeEnabled($enabled); + $this->em->flush(); + $this->eventLogService->log( + 'contest', + $contest->getCid(), + EventLogService::ACTION_UPDATE, + $contest->getCid() + ); + $this->addFlash('scoreboard_refresh', 'After changing the contest start time, it may be ' + . 'necessary to recalculate any cached scoreboards.'); + return $this->redirectToRoute('jury_contests'); + } + + $juryTimeData = $contest->getDataForJuryInterface(); + if (!$juryTimeData[$time]['show_button']) { + throw new BadRequestHttpException( + sprintf("Cannot update '%s' time at this moment", $time) + ); + } + + // starttime is special because other, relative times depend on it. + if ($time == 'start') { + if ($contest->getStarttimeEnabled() && + Utils::difftime((float)$contest->getStarttime(false), + $now) <= $maxSeconds) { + $this->addFlash( + 'danger', + sprintf("Cannot update starttime less than %d seconds before contest start.", + $maxSeconds) + ); + return $this->redirectToRoute('jury_contests'); + } + $contest + ->setStarttime($now) + ->setStarttimeString($nowstring) + ->setStarttimeEnabled(true); + $this->em->flush(); + + $this->addFlash('scoreboard_refresh', 'After changing the contest start time, it may be ' + . 'necessary to recalculate any cached scoreboards.'); + } else { + $method = sprintf('set%stimeString', $time); + $contest->{$method}($nowstring); + $this->em->flush(); + } + $this->eventLogService->log( + 'contest', + $contest->getCid(), + EventLogService::ACTION_UPDATE, + $contest->getCid() + ); + + $referer = $request->headers->get('referer'); + if ($referer) { + return $this->redirect($referer); + } + return $this->redirectToRoute('jury_contests'); + } + #[Route(path: '/{contestId<\d+>}/request-remaining', name: 'jury_contest_request_remaining')] public function requestRemainingRunsWholeContestAction(int $contestId): RedirectResponse { @@ -987,4 +1030,15 @@ public function publicScoreboardDataZipAction( } return $this->dj->getScoreboardZip($request, $requestStack, $contest, $scoreboardService, $type === 'unfrozen'); } + + #[Route(path: '/{cid<\d+>}/text', name: 'jury_contest_text')] + public function viewTextAction(int $cid): StreamedResponse + { + $contest = $this->em->getRepository(Contest::class)->find($cid); + if (!$contest) { + throw new NotFoundHttpException(sprintf('Contest with ID %s not found', $cid)); + } + + return $contest->getContestTextStreamedResponse(); + } } diff --git a/webapp/src/Controller/Jury/ProblemController.php b/webapp/src/Controller/Jury/ProblemController.php index 81d913060d9..baa1e198134 100644 --- a/webapp/src/Controller/Jury/ProblemController.php +++ b/webapp/src/Controller/Jury/ProblemController.php @@ -70,9 +70,15 @@ public function indexAction(): Response ->groupBy('p.probid') ->getQuery()->getResult(); + $badgeTitle = ''; + $currentContest = $this->dj->getCurrentContest(); + if ($currentContest !== null) { + $badgeTitle = 'in ' . $currentContest->getShortname(); + } $table_fields = [ 'probid' => ['title' => 'ID', 'sort' => true, 'default_sort' => true], 'name' => ['title' => 'name', 'sort' => true], + 'badges' => ['title' => $badgeTitle, 'sort' => false], 'num_contests' => ['title' => '# contests', 'sort' => true], 'timelimit' => ['title' => 'time limit', 'sort' => true], 'memlimit' => ['title' => 'memory limit', 'sort' => true], @@ -118,7 +124,7 @@ public function indexAction(): Response if ($p->getProblemtextType()) { $problemactions[] = [ 'icon' => 'file-' . $p->getProblemtextType(), - 'title' => 'view problem description', + 'title' => 'view all problem statements of the contest', 'link' => $this->generateUrl('jury_problem_text', [ 'probId' => $p->getProbid(), ]) @@ -165,22 +171,41 @@ public function indexAction(): Response } $problemactions[] = $deleteAction; } + $default_memlimit = $this->config->get('memory_limit'); + $default_output_limit = $this->config->get('output_limit'); // Add formatted {mem,output}limit row data for the table. foreach (['memlimit', 'outputlimit'] as $col) { $orig_value = @$problemdata[$col]['value']; if (!isset($orig_value)) { + $value = 'default'; + if ($col == 'memlimit' && !empty($default_memlimit)) { + $value .= ' (' . Utils::printsize(1024 * $default_memlimit) . ')'; + } + if ($col == 'outputlimit' && !empty($default_output_limit)) { + $value .= ' (' . Utils::printsize(1024 * $default_output_limit) . ')'; + } $problemdata[$col] = [ - 'value' => 'default', + 'value' => $value, 'cssclass' => 'disabled', ]; } else { $problemdata[$col] = [ 'value' => Utils::printsize(1024 * $orig_value), 'sortvalue' => $orig_value, + 'cssclass' => 'right', ]; } } + $problemdata['timelimit']['value'] = @$problemdata['timelimit']['value'] . 's'; + $problemdata['timelimit']['cssclass'] = 'right'; + + $contestProblems = $p->getContestProblems()->toArray(); + $badges = []; + if ($this->dj->getCurrentContest() !== null) { + $badges = array_filter($contestProblems, fn($cp) => $cp->getCid() === $this->dj->getCurrentContest()->getCid()); + } + $problemdata['badges'] = ['value' => $badges]; // merge in the rest of the data $problemdata = array_merge($problemdata, [ diff --git a/webapp/src/Controller/Jury/SubmissionController.php b/webapp/src/Controller/Jury/SubmissionController.php index 590079e06aa..4b56ca30ec9 100644 --- a/webapp/src/Controller/Jury/SubmissionController.php +++ b/webapp/src/Controller/Jury/SubmissionController.php @@ -390,7 +390,7 @@ public function viewAction( $runResult['hostname'] = $firstJudgingRun->getJudgeTask()->getJudgehost()->getHostname(); $runResult['judgehostid'] = $firstJudgingRun->getJudgeTask()->getJudgehost()->getJudgehostid(); } - $runResult['is_output_run_truncated'] = preg_match( + $runResult['is_output_run_truncated'] = $outputDisplayLimit >= 0 && preg_match( '/\[output storage truncated after \d* B\]/', (string)$runResult['output_run_last_bytes'] ); diff --git a/webapp/src/Controller/PublicController.php b/webapp/src/Controller/PublicController.php index fcc6966bb84..b884113d24d 100644 --- a/webapp/src/Controller/PublicController.php +++ b/webapp/src/Controller/PublicController.php @@ -187,6 +187,16 @@ public function problemTextAction(int $probId): StreamedResponse }); } + #[Route(path: '/contest-text', name: 'public_contest_text')] + public function contestTextAction(): StreamedResponse + { + $contest = $this->dj->getCurrentContest(onlyPublic: true); + if (!$contest->getFreezeData()->started()) { + throw new NotFoundHttpException('Contest text not found or not available'); + } + return $contest->getContestTextStreamedResponse(); + } + /** * @throws NonUniqueResultException */ diff --git a/webapp/src/Controller/Team/MiscController.php b/webapp/src/Controller/Team/MiscController.php index 59d1709155a..727be9c8096 100644 --- a/webapp/src/Controller/Team/MiscController.php +++ b/webapp/src/Controller/Team/MiscController.php @@ -5,6 +5,8 @@ use App\Controller\BaseController; use App\DataTransferObject\SubmissionRestriction; use App\Entity\Clarification; +use App\Entity\Contest; +use App\Entity\ContestProblem; use App\Entity\Language; use App\Form\Type\PrintType; use App\Service\ConfigurationService; @@ -16,6 +18,9 @@ use Doctrine\ORM\NonUniqueResultException; use Doctrine\ORM\NoResultException; use Symfony\Component\ExpressionLanguage\Expression; +use Symfony\Component\HttpFoundation\StreamedResponse; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\PropertyAccess\PropertyAccess; use Symfony\Component\Security\Http\Attribute\IsGranted; use Symfony\Component\HttpFoundation\File\UploadedFile; @@ -211,4 +216,15 @@ public function docsAction(): Response { return $this->render('team/docs.html.twig'); } + + #[Route(path: '/contest-text', name: 'team_contest_text')] + public function contestTextAction(): StreamedResponse + { + $user = $this->dj->getUser(); + $contest = $this->dj->getCurrentContest($user->getTeam()->getTeamid()); + if (!$contest->getFreezeData()->started()) { + throw new NotFoundHttpException('Contest text not found or not available'); + } + return $contest->getContestTextStreamedResponse(); + } } diff --git a/webapp/src/DataTransferObject/TeamCategoryPut.php b/webapp/src/DataTransferObject/TeamCategoryPut.php new file mode 100644 index 00000000000..982f584e256 --- /dev/null +++ b/webapp/src/DataTransferObject/TeamCategoryPut.php @@ -0,0 +1,22 @@ + 'File type of contest text'] + )] + #[Serializer\Exclude] + private ?string $contestTextType = null; + /** * @var Collection */ @@ -392,6 +410,27 @@ class Contest extends BaseApiEntity implements AssetEntityInterface #[Serializer\Exclude] private ?ImageFile $bannerForApi = null; + /** + * @var Collection + * + * We use a OneToMany instead of a OneToOne here, because otherwise this + * relation will always be loaded. See the commit message of commit + * 9e421f96691ec67ed62767fe465a6d8751edd884 for a more elaborate explanation + */ + #[ORM\OneToMany( + mappedBy: 'contest', + targetEntity: ContestTextContent::class, + cascade: ['persist'], + orphanRemoval: true + )] + #[Serializer\Exclude] + private Collection $contestTextContent; + + // This field gets filled by the contest visitor with a data transfer + // object that represents the contest text. + #[Serializer\Exclude] + private ?FileWithName $textForApi = null; + public function __construct() { $this->problems = new ArrayCollection(); @@ -403,6 +442,7 @@ public function __construct() $this->team_categories = new ArrayCollection(); $this->medal_categories = new ArrayCollection(); $this->externalContestSources = new ArrayCollection(); + $this->contestTextContent = new ArrayCollection(); } public function getCid(): ?int @@ -1348,6 +1388,57 @@ public function addExternalContestSource(ExternalContestSource $externalContestS return $this; } + public function setContestTextContent(?ContestTextContent $content): self + { + $this->contestTextContent->clear(); + if ($content) { + $this->contestTextContent->add($content); + $content->setContest($this); + } + + return $this; + } + + public function getContestTextContent(): ?ContestTextContent + { + return $this->contestTextContent->first() ?: null; + } + + #[ORM\PrePersist] + #[ORM\PreUpdate] + public function processContestText(): void + { + if ($this->isClearContestText()) { + $this + ->setContestTextContent(null) + ->setContestTextType(null); + } elseif ($this->getContestTextFile()) { + $content = file_get_contents($this->getContestTextFile()->getRealPath()); + $clientName = $this->getContestTextFile()->getClientOriginalName(); + $contestTextType = Utils::getTextType($clientName, $this->getContestTextFile()->getRealPath()); + + if (!isset($contestTextType)) { + throw new Exception('Contest statement has unknown file type.'); + } + + $contestTextContent = (new ContestTextContent()) + ->setContent($content); + $this + ->setContestTextContent($contestTextContent) + ->setContestTextType($contestTextType); + } + } + + public function getContestTextStreamedResponse(): StreamedResponse + { + return Utils::getTextStreamedResponse( + $this->getContestTextType(), + new BadRequestHttpException(sprintf('Contest c%d text has unknown type', $this->getCid())), + sprintf('contest-%s.%s', $this->getShortname(), $this->getContestTextType()), + $this->getContestText() + ); + } + public function getBannerFile(): ?UploadedFile { return $this->bannerFile; @@ -1391,6 +1482,50 @@ public function isClearAsset(string $property): ?bool }; } + public function setContestTextFile(?UploadedFile $contestTextFile): Contest + { + $this->contestTextFile = $contestTextFile; + + // Clear the contest text to make sure the entity is modified. + $this->setContestTextContent(null); + + return $this; + } + + public function setClearContestText(bool $clearContestText): Contest + { + $this->clearContestText = $clearContestText; + $this->setContestTextContent(null); + + return $this; + } + + public function getContestText(): ?string + { + return $this->getContestTextContent()?->getContent(); + } + + public function getContestTextFile(): ?UploadedFile + { + return $this->contestTextFile; + } + + public function isClearContestText(): bool + { + return $this->clearContestText; + } + + public function setContestTextType(?string $contestTextType): Contest + { + $this->contestTextType = $contestTextType; + return $this; + } + + public function getContestTextType(): ?string + { + return $this->contestTextType; + } + public function setPenaltyTimeForApi(?int $penaltyTimeForApi): Contest { $this->penaltyTimeForApi = $penaltyTimeForApi; @@ -1419,4 +1554,21 @@ public function getBannerForApi(): array { return array_filter([$this->bannerForApi]); } + + public function setTextForApi(?FileWithName $textForApi = null): void + { + $this->textForApi = $textForApi; + } + + /** + * @return FileWithName[] + */ + #[Serializer\VirtualProperty] + #[Serializer\SerializedName('text')] + #[Serializer\Type('array')] + #[Serializer\Exclude(if: 'object.getTextForApi() === []')] + public function getTextForApi(): array + { + return array_filter([$this->textForApi]); + } } diff --git a/webapp/src/Entity/ContestProblem.php b/webapp/src/Entity/ContestProblem.php index 74591a1a961..8d966ce35be 100644 --- a/webapp/src/Entity/ContestProblem.php +++ b/webapp/src/Entity/ContestProblem.php @@ -37,7 +37,7 @@ exp: 'object.getShortname()', options: [new Serializer\Groups(['Nonstrict']), new Serializer\Type('string')] )] -class ContestProblem +class ContestProblem extends BaseApiEntity { #[ORM\Column(options: [ 'comment' => 'Unique problem ID within contest, used to sort problems in the scoreboard and typically a single letter', diff --git a/webapp/src/Entity/ContestTextContent.php b/webapp/src/Entity/ContestTextContent.php new file mode 100644 index 00000000000..7d2d0929522 --- /dev/null +++ b/webapp/src/Entity/ContestTextContent.php @@ -0,0 +1,51 @@ + 'utf8mb4_unicode_ci', + 'charset' => 'utf8mb4', + 'comment' => 'Stores contents of contest texts', +])] +class ContestTextContent +{ + /** + * We use a ManyToOne instead of a OneToOne here, because otherwise the + * reverse of this relation will always be loaded. See the commit message of commit + * 9e421f96691ec67ed62767fe465a6d8751edd884 for a more elaborate explanation. + */ + #[ORM\Id] + #[ORM\ManyToOne(inversedBy: 'contestTextContent')] + #[ORM\JoinColumn(name: 'cid', referencedColumnName: 'cid', onDelete: 'CASCADE')] + private Contest $contest; + + #[ORM\Column(type: 'blobtext', options: ['comment' => 'Text content'])] + private string $content; + + public function getContest(): Contest + { + return $this->contest; + } + + public function setContest(Contest $contest): self + { + $this->contest = $contest; + + return $this; + } + + public function getContent(): string + { + return $this->content; + } + + public function setContent(string $content): self + { + $this->content = $content; + + return $this; + } +} diff --git a/webapp/src/Entity/Problem.php b/webapp/src/Entity/Problem.php index e6ce64d5405..63964280ead 100644 --- a/webapp/src/Entity/Problem.php +++ b/webapp/src/Entity/Problem.php @@ -460,33 +460,7 @@ public function processProblemText(): void } elseif ($this->getProblemtextFile()) { $content = file_get_contents($this->getProblemtextFile()->getRealPath()); $clientName = $this->getProblemtextFile()->getClientOriginalName(); - $problemTextType = null; - - if (strrpos($clientName, '.') !== false) { - $ext = substr($clientName, strrpos($clientName, '.') + 1); - if (in_array($ext, ['txt', 'html', 'pdf'])) { - $problemTextType = $ext; - } - } - if (!isset($problemTextType)) { - $finfo = finfo_open(FILEINFO_MIME); - - [$type] = explode('; ', finfo_file($finfo, $this->getProblemtextFile()->getRealPath())); - - finfo_close($finfo); - - switch ($type) { - case 'application/pdf': - $problemTextType = 'pdf'; - break; - case 'text/html': - $problemTextType = 'html'; - break; - case 'text/plain': - $problemTextType = 'txt'; - break; - } - } + $problemTextType = Utils::getTextType($clientName, $this->getProblemtextFile()->getRealPath()); if (!isset($problemTextType)) { throw new Exception('Problem statement has unknown file type.'); @@ -502,25 +476,12 @@ public function processProblemText(): void public function getProblemTextStreamedResponse(): StreamedResponse { - $mimetype = match ($this->getProblemtextType()) { - 'pdf' => 'application/pdf', - 'html' => 'text/html', - 'txt' => 'text/plain', - default => throw new BadRequestHttpException(sprintf('Problem p%d text has unknown type', $this->getProbid())), - }; - - $filename = sprintf('prob-%s.%s', $this->getName(), $this->getProblemtextType()); - $problemText = $this->getProblemtext(); - - $response = new StreamedResponse(); - $response->setCallback(function () use ($problemText) { - echo $problemText; - }); - $response->headers->set('Content-Type', sprintf('%s; name="%s"', $mimetype, $filename)); - $response->headers->set('Content-Disposition', sprintf('inline; filename="%s"', $filename)); - $response->headers->set('Content-Length', (string)strlen($problemText)); - - return $response; + return Utils::getTextStreamedResponse( + $this->getProblemtextType(), + new BadRequestHttpException(sprintf('Problem p%d text has unknown type', $this->getProbid())), + sprintf('prob-%s.%s', $this->getName(), $this->getProblemtextType()), + $this->getProblemtext() + ); } public function setStatementForApi(?FileWithName $statementForApi = null): void diff --git a/webapp/src/Form/Type/ContestType.php b/webapp/src/Form/Type/ContestType.php index bce28faf742..8cfd815151f 100644 --- a/webapp/src/Form/Type/ContestType.php +++ b/webapp/src/Form/Type/ContestType.php @@ -175,6 +175,17 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'label' => 'Delete banner', 'required' => false, ]); + $builder->add('contestTextFile', FileType::class, [ + 'label' => 'Contest text', + 'required' => false, + 'attr' => [ + 'accept' => 'text/html,text/plain,application/pdf', + ], + ]); + $builder->add('clearContestText', CheckboxType::class, [ + 'label' => 'Delete contest text', + 'required' => false, + ]); $builder->add('warningMessage', TextType::class, [ 'required' => false, 'label' => 'Scoreboard warning message', @@ -202,6 +213,10 @@ public function buildForm(FormBuilderInterface $builder, array $options): void if (!$contest || !$this->dj->assetPath($id, 'contest')) { $form->remove('clearBanner'); } + + if ($contest && !$contest->getContestText()) { + $form->remove('clearContestText'); + } }); } diff --git a/webapp/src/FosRestBundle/FlattenExceptionHandler.php b/webapp/src/FosRestBundle/FlattenExceptionHandler.php index 18889307815..d0fc918a78d 100644 --- a/webapp/src/FosRestBundle/FlattenExceptionHandler.php +++ b/webapp/src/FosRestBundle/FlattenExceptionHandler.php @@ -38,6 +38,11 @@ public static function getSubscribingMethods(): array ]; } + /** + * @param array{params: string[]} $type + * + * @return array{code: int, message: string} + */ public function serializeToJson( JsonSerializationVisitor $visitor, FlattenException $exception, diff --git a/webapp/src/Serializer/ContestVisitor.php b/webapp/src/Serializer/ContestVisitor.php index 6614b1c7248..25432d259cc 100644 --- a/webapp/src/Serializer/ContestVisitor.php +++ b/webapp/src/Serializer/ContestVisitor.php @@ -2,6 +2,7 @@ namespace App\Serializer; +use App\DataTransferObject\FileWithName; use App\DataTransferObject\ImageFile; use App\Entity\Contest; use App\Service\ConfigurationService; @@ -12,6 +13,7 @@ use JMS\Serializer\EventDispatcher\EventSubscriberInterface; use JMS\Serializer\EventDispatcher\ObjectEvent; use JMS\Serializer\Metadata\StaticPropertyMetadata; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; class ContestVisitor implements EventSubscriberInterface { @@ -69,5 +71,32 @@ public function onPreSerialize(ObjectEvent $event): void } else { $contest->setBannerForApi(); } + + $hasAccess = $this->dj->checkrole('jury') || + $this->dj->checkrole('api_reader') || + $contest->getFreezeData()->started(); + + // Problem statement + if ($contest->getContestTextType() && $hasAccess) { + $route = $this->dj->apiRelativeUrl( + 'v4_contest_text', + [ + 'cid' => $contest->getApiId($this->eventLogService), + ] + ); + $mimeType = match ($contest->getContestTextType()) { + 'pdf' => 'application/pdf', + 'html' => 'text/html', + 'txt' => 'text/plain', + default => throw new BadRequestHttpException(sprintf('Contest c%d text has unknown type', $contest->getCid())), + }; + $contest->setTextForApi(new FileWithName( + $route, + $mimeType, + 'text.' . $contest->getContestTextType() + )); + } else { + $contest->setTextForApi(); + } } } diff --git a/webapp/src/Service/ConfigurationService.php b/webapp/src/Service/ConfigurationService.php index 65457f168a0..1eae90956ed 100644 --- a/webapp/src/Service/ConfigurationService.php +++ b/webapp/src/Service/ConfigurationService.php @@ -70,6 +70,11 @@ public function get(string $name, bool $onlyIfPublic = false) return $value; } + public function getCategory(string $name): string + { + return $this->getConfigSpecification()[$name]->category; + } + /** * Get all the configuration values, indexed by name. * diff --git a/webapp/src/Service/DOMJudgeService.php b/webapp/src/Service/DOMJudgeService.php index 08a1a2430fe..f4b632e6626 100644 --- a/webapp/src/Service/DOMJudgeService.php +++ b/webapp/src/Service/DOMJudgeService.php @@ -894,6 +894,11 @@ public function getSamplesZipForContest(Contest $contest): StreamedResponse } } + if ($contest->getContestTextType()) { + $filename = sprintf('contest.%s', $contest->getContestTextType()); + $zip->addFromString($filename, $contest->getContestText()); + } + $zip->close(); $zipFileContents = file_get_contents($tempFilename); unlink($tempFilename); diff --git a/webapp/src/Service/ImportExportService.php b/webapp/src/Service/ImportExportService.php index b99c6189bf1..e75a7142f78 100644 --- a/webapp/src/Service/ImportExportService.php +++ b/webapp/src/Service/ImportExportService.php @@ -209,10 +209,11 @@ public function importContestData(mixed $data, ?string &$errorMessage = null, st $data['short_name'] ?? $data['shortname'] ?? $data['short-name'] ?? $data['id'] )) ->setExternalid($contest->getShortname()) - ->setWarningMessage($data['warning-message'] ?? null) + ->setWarningMessage($data['warning_message'] ?? $data['warning-message'] ?? null) ->setStarttimeString(date_format($startTime, 'Y-m-d H:i:s e')) ->setActivatetimeString(date_format($activateTime, 'Y-m-d H:i:s e')) - ->setEndtimeString(sprintf('+%s', $data['duration'])); + ->setEndtimeString(sprintf('+%s', $data['duration'])) + ->setPublic($data['public'] ?? true); if ($deactivateTime) { $contest->setDeactivatetimeString(date_format($deactivateTime, 'Y-m-d H:i:s e')); } diff --git a/webapp/src/Twig/TwigExtension.php b/webapp/src/Twig/TwigExtension.php index fb8dfd965df..70f67578a3c 100644 --- a/webapp/src/Twig/TwigExtension.php +++ b/webapp/src/Twig/TwigExtension.php @@ -11,6 +11,7 @@ use App\Entity\Judging; use App\Entity\JudgingRun; use App\Entity\Language; +use App\Entity\Problem; use App\Entity\Submission; use App\Entity\SubmissionFile; use App\Entity\TeamCategory; @@ -107,6 +108,7 @@ public function getFilters(): array new TwigFilter('tsvField', $this->toTsvField(...)), new TwigFilter('fileTypeIcon', $this->fileTypeIcon(...)), new TwigFilter('problemBadge', $this->problemBadge(...), ['is_safe' => ['html']]), + new TwigFilter('problemBadgeForProblemAndContest', $this->problemBadgeForProblemAndContest(...), ['is_safe' => ['html']]), new TwigFilter('printMetadata', $this->printMetadata(...), ['is_safe' => ['html']]), new TwigFilter('printWarningContent', $this->printWarningContent(...), ['is_safe' => ['html']]), new TwigFilter('entityIdBadge', $this->entityIdBadge(...), ['is_safe' => ['html']]), @@ -1079,6 +1081,16 @@ public function problemBadge(ContestProblem $problem): string ); } + public function problemBadgeForProblemAndContest(Problem $problem, ?Contest $contest): string + { + foreach ($problem->getContestProblems() as $contestProblem) { + if ($contestProblem->getContest() === $contest) { + return $this->problemBadge($contestProblem); + } + } + return ''; + } + public function printMetadata(?string $metadata): string { if ($metadata === null) { diff --git a/webapp/src/Utils/Utils.php b/webapp/src/Utils/Utils.php index 0478c561733..0c7033f1af0 100644 --- a/webapp/src/Utils/Utils.php +++ b/webapp/src/Utils/Utils.php @@ -4,6 +4,7 @@ use DateTime; use Doctrine\Inflector\InflectorFactory; use Symfony\Component\HttpFoundation\StreamedResponse; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; /** * Generic utility class. @@ -429,18 +430,18 @@ public static function printhost(string $hostname, bool $full = false): string } /** - * Print (file) size in human readable format by using B,KB,MB,GB suffixes. - * Input is a integer (the size in bytes), output a string with suffix. + * Print (file) size in human-readable format by using B,KB,MB,GB suffixes. + * Input is an integer (the size in bytes), output a string with suffix. */ public static function printsize(int $size, int $decimals = 1): string { $factor = 1024; - $units = ['B', 'KB', 'MB', 'GB']; - $display = (int)$size; + $units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; + $display = $size; $exact = true; - for ($i = 0; $i < count($units) && $display > $factor; $i++) { - if (((int)$display % $factor)!=0) { + for ($i = 0; $i < count($units) && $display >= $factor; $i++) { + if (($display % $factor)!=0) { $exact = false; } $display /= $factor; @@ -891,4 +892,61 @@ public static function reindex(array $array, callable $callback): array }); return $reindexed; } + + public static function getTextType(string $clientName, string $realPath): ?string + { + $textType = null; + + if (strrpos($clientName, '.') !== false) { + $ext = substr($clientName, strrpos($clientName, '.') + 1); + if (in_array($ext, ['txt', 'html', 'pdf'])) { + $textType = $ext; + } + } + if (!isset($textType)) { + $finfo = finfo_open(FILEINFO_MIME); + + [$type] = explode('; ', finfo_file($finfo, $realPath)); + + finfo_close($finfo); + + switch ($type) { + case 'application/pdf': + $textType = 'pdf'; + break; + case 'text/html': + $textType = 'html'; + break; + case 'text/plain': + $textType = 'txt'; + break; + } + } + + return $textType; + } + + public static function getTextStreamedResponse( + ?string $textType, + BadRequestHttpException $exceptionMessage, + string $filename, + ?string $text + ): StreamedResponse { + $mimetype = match ($textType) { + 'pdf' => 'application/pdf', + 'html' => 'text/html', + 'txt' => 'text/plain', + default => throw $exceptionMessage, + }; + + $response = new StreamedResponse(); + $response->setCallback(function () use ($text) { + echo $text; + }); + $response->headers->set('Content-Type', sprintf('%s; name="%s"', $mimetype, $filename)); + $response->headers->set('Content-Disposition', sprintf('inline; filename="%s"', $filename)); + $response->headers->set('Content-Length', (string)strlen($text)); + + return $response; + } } diff --git a/webapp/templates/jury/clarification.html.twig b/webapp/templates/jury/clarification.html.twig index 01b1ad80ab0..72feccb3fd7 100644 --- a/webapp/templates/jury/clarification.html.twig +++ b/webapp/templates/jury/clarification.html.twig @@ -159,7 +159,7 @@ -
+
Send clarification
diff --git a/webapp/templates/jury/config.html.twig b/webapp/templates/jury/config.html.twig index adfaf9767e1..d9f41c83572 100644 --- a/webapp/templates/jury/config.html.twig +++ b/webapp/templates/jury/config.html.twig @@ -15,6 +15,28 @@ {% endblock %} {% block content %} + {% if diffs is not null %} + {% if diffs %} + + {% else %} + + {% endif %} + + {% endif %}

Configuration

diff --git a/webapp/templates/jury/contest.html.twig b/webapp/templates/jury/contest.html.twig index 7a69b58aa69..3ae68a03ae3 100644 --- a/webapp/templates/jury/contest.html.twig +++ b/webapp/templates/jury/contest.html.twig @@ -107,23 +107,37 @@ Allow submit - - -
+ {% include 'jury/partials/contest_toggle.html.twig' with {type: 'submit', enabled: contest.allowSubmit} %} + {% if contest.contestTextType is not empty %} + + Contest text + + + + + + + {% endif %} Process balloons - {{ contest.processBalloons | printYesNo }} + + {% include 'jury/partials/contest_toggle.html.twig' with {type: 'balloons', enabled: contest.processBalloons} %} + Runtime as tiebreaker - {{ contest.runtimeAsScoreTiebreaker | printYesNo }} + + {% include 'jury/partials/contest_toggle.html.twig' with {type: 'tiebreaker', enabled: contest.runtimeAsScoreTiebreaker} %} + Process medals - {{ contest.medalsEnabled | printYesNo }} + + {% include 'jury/partials/contest_toggle.html.twig' with {type: 'medals', enabled: contest.medalsEnabled} %} + Medals @@ -164,7 +178,9 @@ Publicly visible - {{ contest.public | printYesNo }} + + {% include 'jury/partials/contest_toggle.html.twig' with {type: 'public', enabled: contest.public} %} + Open to all teams @@ -395,7 +411,7 @@ {% if problem.problem.problemtextType %} - + {% endif %} diff --git a/webapp/templates/jury/contests.html.twig b/webapp/templates/jury/contests.html.twig index 04cc260fc86..d4fe9dbc220 100644 --- a/webapp/templates/jury/contests.html.twig +++ b/webapp/templates/jury/contests.html.twig @@ -14,57 +14,51 @@

Current contests

{% for contest in current_contests %} - {# TODO: at some point use real Symfony forms here? Is maybe hard because of all the submit buttons... #} -
- -
-
-
-
- {{ contest.name }} ({{ contest.shortname }} - c{{ contest.cid }}) - {% if contest.locked %} - - {% endif %} -
-
- {% if not contest.starttimeEnabled and contest.finalizetime is not empty %} -
- Warning: start time is undefined, but contest is finalized! -
- {% endif %} - - - {% for type, data in contest.dataForJuryInterface %} - - + {% endfor %} + +
- {% if data.icon is defined %} - +
+
+
+
+ {{ contest.name }} ({{ contest.shortname }} - c{{ contest.cid }}) + {% if contest.locked %} + + {% endif %} +
+
+ {% if not contest.starttimeEnabled and contest.finalizetime is not empty %} +
+ Warning: start time is undefined, but contest is finalized! +
+ {% endif %} + + + {% for type, data in contest.dataForJuryInterface %} + + + + + {% if is_granted('ROLE_ADMIN') %} + - - - {% if is_granted('ROLE_ADMIN') %} - - {% endif %} - - {% endfor %} - -
+ {% if data.icon is defined %} + + {% endif %} + {{ data.label }}:{{ data.time }} + {% if data.show_button %} + {% set button_label = type ~ " now" %} + {{ button(path('jury_contest_donow', {'contestId': contest.cid, 'time': type}), button_label, 'primary btn-sm') }} + {% endif %} + {% if data.extra_button is defined %} + {{ button(path('jury_contest_donow', {'contestId': contest.cid, 'time': data.extra_button.type}), data.extra_button.label, 'primary btn-sm') }} {% endif %} {{ data.label }}:{{ data.time }} - {% if data.show_button %} - - {% endif %} - {% if data.extra_button is defined %} - - {% endif %} -
-
+ {% endif %} +
- +
{% else %} {% if upcoming_contest is empty %}
@@ -77,10 +71,7 @@ {{ upcoming_contest.name }} ({{ upcoming_contest.shortname }}); active from {{ upcoming_contest.activatetime | printtime('D d M Y H:i:s T') }}

-
- - -
+ {{ button(path('jury_contest_donow', {'contestId': upcoming_contest.cid, 'time': 'activate'}), 'Activate now', 'primary') }}
{% endif %} {% endfor %} diff --git a/webapp/templates/jury/executable.html.twig b/webapp/templates/jury/executable.html.twig index 883dde50020..95cbe3956eb 100644 --- a/webapp/templates/jury/executable.html.twig +++ b/webapp/templates/jury/executable.html.twig @@ -48,21 +48,21 @@ {% if executable.type == 'compare' %} {% for problem in executable.problemsCompare %} - p{{ problem.probid }} + p{{ problem.probid }} {{ problem | problemBadgeForProblemAndContest(current_contest) }} {% set used = true %} {% endfor %} {% elseif executable.type == 'run' %} {% for problem in executable.problemsRun %} - p{{ problem.probid }} + p{{ problem.probid }} {{ problem | problemBadgeForProblemAndContest(current_contest) }} {% set used = true %} {% endfor %} {% elseif executable.type == 'compile' %} {% for language in executable.languages %} - {{ language.langid }} + {{ language | entityIdBadge }} {% set used = true %} {% endfor %} diff --git a/webapp/templates/jury/export/clarifications.html.twig b/webapp/templates/jury/export/clarifications.html.twig index fccf0b69eb9..0ee58eacb3f 100644 --- a/webapp/templates/jury/export/clarifications.html.twig +++ b/webapp/templates/jury/export/clarifications.html.twig @@ -94,7 +94,7 @@ Content -
{{ clarification.body | wrapUnquoted(80) }}
+
{{ clarification.body | markdown_to_html | sanitize_html('app.clarification_sanitizer') }}
{% if clarification.replies is not empty %} @@ -110,7 +110,7 @@ -
{{ reply.body | wrapUnquoted(80) }}
+
{{ reply.body | markdown_to_html | sanitize_html('app.clarification_sanitizer') }}
{% endfor %} diff --git a/webapp/templates/jury/export/layout.html.twig b/webapp/templates/jury/export/layout.html.twig index 22bf7d84619..4e465a2aab8 100644 --- a/webapp/templates/jury/export/layout.html.twig +++ b/webapp/templates/jury/export/layout.html.twig @@ -82,9 +82,30 @@ padding-top: 2rem; } + code { + font-size: .875em; + color: rgb(214, 51, 132); + word-wrap: break-word; + } + pre { + border-top: 1px dotted #C0C0C0; + border-bottom: 1px dotted #C0C0C0; + background-color: #FAFAFA; margin: 0; - white-space: pre-wrap; + padding: 5px; + font-family: monospace; + white-space: pre; + } + + pre > code { + color: inherit; + } + + blockquote { + border-left: darkgrey solid .2em; + padding-left: .5em; + color: darkgrey; } diff --git a/webapp/templates/jury/partials/contest_form.html.twig b/webapp/templates/jury/partials/contest_form.html.twig index 83aaf2c9b2b..bbadd194f81 100644 --- a/webapp/templates/jury/partials/contest_form.html.twig +++ b/webapp/templates/jury/partials/contest_form.html.twig @@ -38,6 +38,10 @@ {% if form.offsetExists('clearBanner') %} {{ form_row(form.clearBanner) }} {% endif %} + {{ form_row(form.contestTextFile) }} + {% if form.offsetExists('clearContestText') %} + {{ form_row(form.clearContestText) }} + {% endif %} {{ form_row(form.warningMessage) }}
diff --git a/webapp/templates/jury/partials/contest_toggle.html.twig b/webapp/templates/jury/partials/contest_toggle.html.twig new file mode 100644 index 00000000000..8d23d4b9eaf --- /dev/null +++ b/webapp/templates/jury/partials/contest_toggle.html.twig @@ -0,0 +1,4 @@ +
+ +
diff --git a/webapp/templates/jury/partials/submission_list.html.twig b/webapp/templates/jury/partials/submission_list.html.twig index a2d6b914c85..522d2b55527 100644 --- a/webapp/templates/jury/partials/submission_list.html.twig +++ b/webapp/templates/jury/partials/submission_list.html.twig @@ -135,7 +135,7 @@ {{ submission.language.langid }} + title="{{ submission.language.name }}">{{ submission.language | entityIdBadge }} {% if showExternalResult and showExternalTestcases %} diff --git a/webapp/templates/jury/problem.html.twig b/webapp/templates/jury/problem.html.twig index d2407a8b5d6..ba8a9ea6b16 100644 --- a/webapp/templates/jury/problem.html.twig +++ b/webapp/templates/jury/problem.html.twig @@ -69,7 +69,7 @@ Problem text - diff --git a/webapp/templates/jury/problemset.html.twig b/webapp/templates/jury/problemset.html.twig index 50a6daf85b0..fd80937eecd 100644 --- a/webapp/templates/jury/problemset.html.twig +++ b/webapp/templates/jury/problemset.html.twig @@ -1,10 +1,13 @@ {% extends "jury/base.html.twig" %} -{% block title %}Contest problems {{ current_jury_contest.shortname | default('') }} - {{ parent() }}{% endblock %} +{% block title %}Contest problems {{ current_contest.shortname | default('') }} - {{ parent() }}{% endblock %} {% block content %} {% include 'partials/problem_list.html.twig' with { - contest: current_team_contest, + contest: current_contest, + show_contest_text: true, + contest_text_path: 'jury_contest_text', + contest_text_add_cid: true, problem_text_path: 'jury_problem_text', problem_attachment_path: 'jury_attachment_fetch', problem_sample_zip_path: 'jury_problem_sample_zip', diff --git a/webapp/templates/jury/submission.html.twig b/webapp/templates/jury/submission.html.twig index 8dc109b7af4..e6e2bf741cb 100644 --- a/webapp/templates/jury/submission.html.twig +++ b/webapp/templates/jury/submission.html.twig @@ -158,19 +158,21 @@ {% endif %} - - - - {{ submission.contest.shortname }} - {{ submission.contest | entityIdBadge('c') }} - - + {% if current_contest.cid != submission.contest.cid %} + + + + {{ submission.contest.shortname }} + {{ submission.contest | entityIdBadge('c') }} + + + {% endif %} {% if submission.contestProblem %} - {{ submission.contestProblem.shortname }}: {{ submission.problem.name }} + {{ submission.contestProblem | problemBadge }}: {{ submission.problem.name }} {% else %} {{ submission.problem.name }} {% endif %} @@ -442,15 +444,19 @@ {% set judgehostLink = path('jury_judgehost', {judgehostid: judgehostid}) %} {{ hostname | printHost }} {% endfor %} - - Judging started: {{ selectedJudging.starttime | printtime('H:i:s') }} - {%- if selectedJudging.endtime -%} - , finished in {{ selectedJudging.starttime | printtimediff(selectedJudging.endtime) }}s - {%- elseif selectedJudging.valid or selectedJudging.rejudging -%} -  [still judging - busy {{ selectedJudging.starttime | printtimediff }}] - {%- else -%} -  [aborted] - {%- endif -%} - + {%- if selectedJudging.starttime -%} + Judging started: {{ selectedJudging.starttime | printtime('H:i:s') }} + {%- if selectedJudging.endtime -%} + , finished in {{ selectedJudging.starttime | printtimediff(selectedJudging.endtime) }}s + {%- elseif selectedJudging.valid or selectedJudging.rejudging -%} +  [still judging - busy {{ selectedJudging.starttime | printtimediff }}] + {%- else -%} +  [aborted] + {%- endif -%} + + {% else %} + Judging not started yet + {%- endif -%} {% endif -%} {%- if externalJudgement is not null %} (external judging started: {{ externalJudgement.starttime | printtime('H:i:s') }} diff --git a/webapp/templates/partials/problem_list.html.twig b/webapp/templates/partials/problem_list.html.twig index 11a8712c337..4885dafdc7c 100644 --- a/webapp/templates/partials/problem_list.html.twig +++ b/webapp/templates/partials/problem_list.html.twig @@ -1,6 +1,22 @@ {# problem \App\Entity\ContestProblem #} -

{{ contest.name | default('Contest') }} problems

+{% set contest_text_add_cid = contest_text_add_cid | default(false) %} + +

+ {{ contest.name | default('Contest') }} problems + {% if contest and show_contest_text and contest.contestTextType is not empty %} + {% if contest_text_add_cid %} + {% set contest_text_url = path(contest_text_path, {'cid': contest.cid}) %} + {% else %} + {% set contest_text_url = path(contest_text_path) %} + {% endif %} + + + text + + {% endif %} +

{% if problems is empty %}
No problem texts available at this point.
diff --git a/webapp/templates/public/problems.html.twig b/webapp/templates/public/problems.html.twig index 45ccf701c13..9779c6fc15f 100644 --- a/webapp/templates/public/problems.html.twig +++ b/webapp/templates/public/problems.html.twig @@ -5,6 +5,8 @@ {% block content %} {% include 'partials/problem_list.html.twig' with { contest: current_public_contest, + show_contest_text: current_public_contest and current_public_contest.freezeData.started, + contest_text_path: 'public_contest_text', problem_text_path: 'public_problem_text', problem_attachment_path: 'public_problem_attachment', problem_sample_zip_path: 'public_problem_sample_zip' diff --git a/webapp/templates/team/languages.html.twig b/webapp/templates/team/languages.html.twig index c4f7b4e5aee..57ce0d361f1 100644 --- a/webapp/templates/team/languages.html.twig +++ b/webapp/templates/team/languages.html.twig @@ -12,7 +12,12 @@

- {{ lang.langid }} + {{ lang.name }} + with + extension{% if lang.extensions|length > 1 %}s{% endif %}: + {% for ext in lang.extensions %} + .{{ ext }}{% if not loop.last %}, {% endif %} + {% endfor %}

{% if lang.compilerVersion and lang.compilerVersionCommand %} diff --git a/webapp/templates/team/partials/clarification_content.html.twig b/webapp/templates/team/partials/clarification_content.html.twig index 9536f8e70e3..7e38c1e53cd 100644 --- a/webapp/templates/team/partials/clarification_content.html.twig +++ b/webapp/templates/team/partials/clarification_content.html.twig @@ -5,7 +5,7 @@ {% endfor %}
-
+
{{ form_start(form) }} {{ form_row(form.recipient) }} diff --git a/webapp/templates/team/problems.html.twig b/webapp/templates/team/problems.html.twig index 5c436b84bd0..02bee80262a 100644 --- a/webapp/templates/team/problems.html.twig +++ b/webapp/templates/team/problems.html.twig @@ -5,6 +5,8 @@ {% block content %} {% include 'partials/problem_list.html.twig' with { contest: current_team_contest, + show_contest_text: current_team_contest and current_team_contest.freezeData.started, + contest_text_path: 'team_contest_text', problem_text_path: 'team_problem_text', problem_attachment_path: 'team_problem_attachment', problem_sample_zip_path: 'team_problem_sample_zip', diff --git a/webapp/tests/Unit/Controller/API/ContestControllerAdminTest.php b/webapp/tests/Unit/Controller/API/ContestControllerAdminTest.php index 751eb3f5248..26f6a63e0ef 100644 --- a/webapp/tests/Unit/Controller/API/ContestControllerAdminTest.php +++ b/webapp/tests/Unit/Controller/API/ContestControllerAdminTest.php @@ -15,6 +15,7 @@ use Generator; use Symfony\Component\HttpFoundation\BinaryFileResponse; use Symfony\Component\HttpFoundation\File\UploadedFile; +use Symfony\Component\HttpFoundation\StreamedResponse; use Symfony\Component\Yaml\Yaml; class ContestControllerAdminTest extends ContestControllerTest @@ -164,6 +165,49 @@ public function testBannerManagement(): void self::assertArrayNotHasKey('banner', $object); } + public function testTextManagement(): void + { + // First, make sure we have no text + $id = 1; + if ($this->objectClassForExternalId !== null) { + $id = $this->resolveEntityId($this->objectClassForExternalId, (string)$id); + } + $url = $this->helperGetEndpointURL($this->apiEndpoint, (string)$id); + $object = $this->verifyApiJsonResponse('GET', $url, 200, $this->apiUser); + self::assertArrayNotHasKey('text', $object); + + // Now upload a banner + $textFile = __DIR__ . '/../../../../../webapp/public/doc/logos/DOMjudgelogo.pdf'; + $text = new UploadedFile($textFile, 'DOMjudgelogo.pdf'); + $this->verifyApiJsonResponse('POST', $url . '/text', 204, $this->apiUser, null, ['text' => $text]); + + // Verify we do have a banner now + $object = $this->verifyApiJsonResponse('GET', $url, 200, $this->apiUser); + $textConfig = [ + [ + 'href' => "contests/$id/text", + 'mime' => 'application/pdf', + 'filename' => 'text.pdf', + ], + ]; + self::assertSame($textConfig, $object['text']); + + $this->client->request('GET', '/api' . $url . '/text'); + /** @var StreamedResponse $response */ + $response = $this->client->getResponse(); + ob_start(); + $response->getCallback()(); + $callbackData = ob_get_clean(); + self::assertEquals(file_get_contents($textFile), $callbackData); + + // Delete the text again + $this->verifyApiJsonResponse('DELETE', $url . '/text', 204, $this->apiUser); + + // Verify we have no text anymore + $object = $this->verifyApiJsonResponse('GET', $url, 200, $this->apiUser); + self::assertArrayNotHasKey('text', $object); + } + /** * @dataProvider provideChangeTimes */ diff --git a/webapp/tests/Unit/Controller/API/GroupControllerTest.php b/webapp/tests/Unit/Controller/API/GroupControllerTest.php index 6b9053fd375..cf900382741 100644 --- a/webapp/tests/Unit/Controller/API/GroupControllerTest.php +++ b/webapp/tests/Unit/Controller/API/GroupControllerTest.php @@ -2,6 +2,7 @@ namespace App\Tests\Unit\Controller\API; +use App\Service\DOMJudgeService; use Generator; class GroupControllerTest extends BaseTestCase @@ -85,6 +86,60 @@ public function testNewAddedGroupPostWithId(): void self::assertNotEquals($returnedObject['id'], $postWithId['id']); } + /** + * @dataProvider provideNewAddedGroup + */ + public function testNewAddedGroupPut(array $newGroupPostData): void + { + // This only works for non-local data sources + $this->setupDataSource(DOMJudgeService::DATA_SOURCE_CONFIGURATION_EXTERNAL); + + $url = $this->helperGetEndpointURL($this->apiEndpoint); + $objectsBeforeTest = $this->verifyApiJsonResponse('GET', $url, 200, $this->apiUser); + + $newGroupPostData['id'] = 'someid'; + + $returnedObject = $this->verifyApiJsonResponse('PUT', $url . '/someid', 201, 'admin', $newGroupPostData); + foreach ($newGroupPostData as $key => $value) { + self::assertEquals($value, $returnedObject[$key]); + } + + $objectsAfterTest = $this->verifyApiJsonResponse('GET', $url, 200, $this->apiUser); + $newItems = array_map('unserialize', array_diff(array_map('serialize', $objectsAfterTest), array_map('serialize', $objectsBeforeTest))); + self::assertEquals(1, count($newItems)); + $listKey = array_keys($newItems)[0]; + foreach ($newGroupPostData as $key => $value) { + self::assertEquals($value, $newItems[$listKey][$key]); + } + } + + /** + * @dataProvider provideNewAddedGroup + */ + public function testNewAddedGroupPutWithoutId(array $newGroupPostData): void + { + // This only works for non-local data sources + $this->setupDataSource(DOMJudgeService::DATA_SOURCE_CONFIGURATION_EXTERNAL); + + $url = $this->helperGetEndpointURL($this->apiEndpoint); + $returnedObject = $this->verifyApiJsonResponse('PUT', $url . '/someid', 400, 'admin', $newGroupPostData); + self::assertStringContainsString('ID in URL does not match ID in payload', $returnedObject['message']); + } + + /** + * @dataProvider provideNewAddedGroup + */ + public function testNewAddedGroupPutWithDifferentId(array $newGroupPostData): void + { + // This only works for non-local data sources + $this->setupDataSource(DOMJudgeService::DATA_SOURCE_CONFIGURATION_EXTERNAL); + + $newGroupPostData['id'] = 'someotherid'; + $url = $this->helperGetEndpointURL($this->apiEndpoint); + $returnedObject = $this->verifyApiJsonResponse('PUT', $url . '/someid', 400, 'admin', $newGroupPostData); + self::assertStringContainsString('ID in URL does not match ID in payload', $returnedObject['message']); + } + public function provideNewAddedGroup(): Generator { foreach ($this->newGroupsPostData as $group) { diff --git a/webapp/tests/Unit/Controller/API/UserControllerTest.php b/webapp/tests/Unit/Controller/API/UserControllerTest.php index 0a7d930daa6..1676b44223d 100644 --- a/webapp/tests/Unit/Controller/API/UserControllerTest.php +++ b/webapp/tests/Unit/Controller/API/UserControllerTest.php @@ -2,6 +2,8 @@ namespace App\Tests\Unit\Controller\API; +use App\Service\DOMJudgeService; + class UserControllerTest extends AccountBaseTestCase { protected ?string $apiEndpoint = 'users'; @@ -45,4 +47,52 @@ class UserControllerTest extends AccountBaseTestCase "enabled" => true ], ]; + + public function testAddLocal(): void + { + $data = [ + 'username' => 'testuser', + 'name' => 'Test User', + 'roles' => ['team'], + 'password' => 'testpassword', + ]; + + $response = $this->verifyApiJsonResponse('POST', $this->helperGetEndpointURL($this->apiEndpoint), 201, 'admin', $data); + static::assertArrayHasKey('id', $response); + static::assertEquals('testuser', $response['username']); + static::assertEquals('Test User', $response['name']); + static::assertEquals(['team'], $response['roles']); + } + + public function testUpdateNonLocal(): void + { + $this->setupDataSource(DOMJudgeService::DATA_SOURCE_CONFIGURATION_EXTERNAL); + $data = [ + 'id' => 'someid', + 'username' => 'testuser', + 'name' => 'Test User', + 'roles' => ['team'], + 'password' => 'testpassword', + ]; + + $response = $this->verifyApiJsonResponse('PUT', $this->helperGetEndpointURL($this->apiEndpoint) . '/someid', 201, 'admin', $data); + static::assertEquals('someid', $response['id']); + static::assertEquals('testuser', $response['username']); + static::assertEquals('Test User', $response['name']); + static::assertEquals(['team'], $response['roles']); + } + + public function testUpdateNonLocalNoId(): void + { + $this->setupDataSource(DOMJudgeService::DATA_SOURCE_CONFIGURATION_EXTERNAL); + $data = [ + 'username' => 'testuser', + 'name' => 'Test User', + 'roles' => ['team'], + 'password' => 'testpassword', + ]; + + $response = $this->verifyApiJsonResponse('PUT', $this->helperGetEndpointURL($this->apiEndpoint) . '/someid', 400, 'admin', $data); + static::assertMatchesRegularExpression('/id:\n.*This value should be of type unknown./', $response['message']); + } } diff --git a/webapp/tests/Unit/Controller/Jury/ImportExportControllerTest.php b/webapp/tests/Unit/Controller/Jury/ImportExportControllerTest.php index 0591f7ccfab..d061d4f517f 100644 --- a/webapp/tests/Unit/Controller/Jury/ImportExportControllerTest.php +++ b/webapp/tests/Unit/Controller/Jury/ImportExportControllerTest.php @@ -179,8 +179,8 @@ public function testClarificationsHtmlExport(): void self::assertSelectorExists('h1:contains("Clarifications for Demo contest")'); self::assertSelectorExists('td:contains("Example teamname")'); self::assertSelectorExists('td:contains("A: Hello World")'); - self::assertSelectorExists('pre:contains("Is it necessary to read the problem statement carefully?")'); - self::assertSelectorExists('pre:contains("Lunch is served")'); + self::assertSelectorExists('div:contains("Is it necessary to read the problem statement carefully?")'); + self::assertSelectorExists('div:contains("Lunch is served")'); } /** diff --git a/webapp/tests/Unit/Service/ImportExportServiceTest.php b/webapp/tests/Unit/Service/ImportExportServiceTest.php index 1794fa5f025..1c478db4f3b 100644 --- a/webapp/tests/Unit/Service/ImportExportServiceTest.php +++ b/webapp/tests/Unit/Service/ImportExportServiceTest.php @@ -106,6 +106,7 @@ public function testImportContestDataSuccess(mixed $data, string $expectedShortN $contest = $this->getContest($cid); self::assertEquals($data['name'], $contest->getName()); + self::assertEquals($data['public'] ?? true, $contest->getPublic()); self::assertEquals($expectedShortName, $contest->getShortname()); $problems = []; @@ -154,6 +155,7 @@ public function provideImportContestDataSuccess(): Generator 'scoreboard-freeze-length' => '30:00', 'short-name' => 'practice', 'start-time' => '2021-03-27 09:00:00+00:00', + 'public' => true, 'problems' => [ [ 'color' => '#FE9DAF', @@ -187,6 +189,7 @@ public function provideImportContestDataSuccess(): Generator 'duration' => '5:00:00', 'start_time' => '2020-01-01T12:34:56+02:00', 'scoreboard_freeze_duration' => '1:00:00', + 'public' => false, ], 'test-contest', ]; diff --git a/webapp/tests/Unit/Utils/UtilsTest.php b/webapp/tests/Unit/Utils/UtilsTest.php index c6e61c9335d..15d60d8efb9 100644 --- a/webapp/tests/Unit/Utils/UtilsTest.php +++ b/webapp/tests/Unit/Utils/UtilsTest.php @@ -505,8 +505,11 @@ public function testPrintsize(): void { self::assertEquals("0 B", Utils::printsize(0)); self::assertEquals("1000 B", Utils::printsize(1000)); - self::assertEquals("1024 B", Utils::printsize(1024)); + self::assertEquals("1023 B", Utils::printsize(1023)); + self::assertEquals("1 KB", Utils::printsize(1024)); self::assertEquals("1.0 KB", Utils::printsize(1025)); + self::assertEquals("1.0 KB", Utils::printsize(1075)); + self::assertEquals("1.1 KB", Utils::printsize(1076)); self::assertEquals("2 KB", Utils::printsize(2048)); self::assertEquals("2.5 KB", Utils::printsize(2560)); self::assertEquals("5 MB", Utils::printsize(5242880));