diff --git a/webapp/migrations/Version20250323190305.php b/webapp/migrations/Version20250323190305.php index 126980b53d..edab8b5662 100644 --- a/webapp/migrations/Version20250323190305.php +++ b/webapp/migrations/Version20250323190305.php @@ -14,7 +14,7 @@ final class Version20250323190305 extends AbstractMigration { public function getDescription(): string { - return ''; + return 'Add problem types'; } public function up(Schema $schema): void diff --git a/webapp/migrations/Version20250620082406.php b/webapp/migrations/Version20250620082406.php new file mode 100644 index 0000000000..07c52b9347 --- /dev/null +++ b/webapp/migrations/Version20250620082406.php @@ -0,0 +1,38 @@ +addSql('ALTER TABLE judging CHANGE max_runtime_for_verdict max_runtime_for_verdict NUMERIC(32, 9) UNSIGNED DEFAULT NULL COMMENT \'The maximum runtime for all runs that resulted in the verdict\''); + $this->addSql('ALTER TABLE problem CHANGE types types INT NOT NULL COMMENT \'Bitmask of problem types, default is pass-fail.\''); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE problem CHANGE types types INT NOT NULL COMMENT \'Bitset of problem types, default is pass-fail.\''); + $this->addSql('ALTER TABLE judging CHANGE max_runtime_for_verdict max_runtime_for_verdict NUMERIC(32, 9) UNSIGNED DEFAULT NULL COMMENT \'The maximum run time for all runs that resulted in the verdict\''); + } + + public function isTransactional(): bool + { + return false; + } +} diff --git a/webapp/migrations/Version20250620090108.php b/webapp/migrations/Version20250620090108.php new file mode 100644 index 0000000000..a7e5f3242c --- /dev/null +++ b/webapp/migrations/Version20250620090108.php @@ -0,0 +1,48 @@ +addSql('CREATE TABLE team_category_team (categoryid INT UNSIGNED NOT NULL COMMENT \'Team category ID\', teamid INT UNSIGNED NOT NULL COMMENT \'Team ID\', INDEX IDX_3A19F9C99B32FD3 (categoryid), INDEX IDX_3A19F9C94DD6ABF3 (teamid), PRIMARY KEY(categoryid, teamid)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('ALTER TABLE team_category_team ADD CONSTRAINT FK_3A19F9C99B32FD3 FOREIGN KEY (categoryid) REFERENCES team_category (categoryid) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE team_category_team ADD CONSTRAINT FK_3A19F9C94DD6ABF3 FOREIGN KEY (teamid) REFERENCES team (teamid) ON DELETE CASCADE'); + $this->addSql('INSERT INTO team_category_team (categoryid, teamid) SELECT categoryid, teamid FROM team'); + $this->addSql('ALTER TABLE team DROP FOREIGN KEY team_ibfk_1'); + $this->addSql('DROP INDEX categoryid ON team'); + $this->addSql('ALTER TABLE team DROP categoryid'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE team ADD categoryid INT UNSIGNED DEFAULT NULL COMMENT \'Team category ID\''); + $this->addSql('ALTER TABLE team ADD CONSTRAINT team_ibfk_1 FOREIGN KEY (categoryid) REFERENCES team_category (categoryid) ON DELETE CASCADE'); + $this->addSql('CREATE INDEX categoryid ON team (categoryid)'); + $this->addSql('UPDATE team SET categoryid = (SELECT MIN(categoryid) from team_category_team WHERE team_category_team.teamid = team.teamid)'); + $this->addSql('ALTER TABLE team_category_team DROP FOREIGN KEY FK_3A19F9C99B32FD3'); + $this->addSql('ALTER TABLE team_category_team DROP FOREIGN KEY FK_3A19F9C94DD6ABF3'); + $this->addSql('DROP TABLE team_category_team'); + } + + public function isTransactional(): bool + { + return false; + } +} diff --git a/webapp/migrations/Version20250801124024.php b/webapp/migrations/Version20250801124024.php new file mode 100644 index 0000000000..3458b55f96 --- /dev/null +++ b/webapp/migrations/Version20250801124024.php @@ -0,0 +1,46 @@ +addSql(<<addSql(<<setAffiliation($affiliations[$organizationName]); } - $teamObj->setCategory($category); + $teamObj->addCategory($category); $oldid = $team['id']; $newid = $nextTeamId++; $teamObj->setTeamid($newid); diff --git a/webapp/src/Controller/API/MetricsController.php b/webapp/src/Controller/API/MetricsController.php index 0b9e53da0d..7649f4255b 100644 --- a/webapp/src/Controller/API/MetricsController.php +++ b/webapp/src/Controller/API/MetricsController.php @@ -7,11 +7,13 @@ use App\Entity\QueueTask; use App\Entity\Submission; use App\Entity\Team; +use App\Entity\TeamCategory; use App\Entity\User; use App\Service\DOMJudgeService; use App\Service\SubmissionService; use App\Utils\Utils; use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\Query\Expr\Join; use FOS\RestBundle\Controller\AbstractFOSRestController; use FOS\RestBundle\Controller\Annotations as Rest; use OpenApi\Attributes as OA; @@ -79,8 +81,9 @@ public function prometheusAction(): Response ->select('t', 'u') ->from(Team::class, 't') ->leftJoin('t.users', 'u') - ->join('t.category', 'cat') + ->join('t.categories', 'cat', Join::WITH, 'BIT_AND(cat.types, :scoring) = :scoring') ->andWhere('cat.visible = true') + ->setParameter('scoring', TeamCategory::TYPE_SCORING) ->getQuery() ->getResult(); @@ -89,8 +92,9 @@ public function prometheusAction(): Response ->select('u') ->from(User::class, 'u') ->leftJoin('u.team', 't') - ->join('t.category', 'cat') + ->join('t.categories', 'cat', Join::WITH, 'BIT_AND(cat.types, :scoring) = :scoring') ->andWhere('cat.visible = true') + ->setParameter('scoring', TeamCategory::TYPE_SCORING) ->getQuery() ->getResult(); @@ -134,10 +138,11 @@ public function prometheusAction(): Response ->from(Team::class, 't') ->leftJoin('t.users', 'u') ->leftJoin('t.contests', 'c') - ->join('t.category', 'cat') + ->join('t.categories', 'cat', Join::WITH, 'BIT_AND(cat.types, :scoring) = :scoring') ->leftJoin('cat.contests', 'cc') ->andWhere('c.cid = :cid OR cc.cid = :cid') ->andWhere('cat.visible = true') + ->setParameter('scoring', TeamCategory::TYPE_SCORING) ->setParameter('cid', $contest->getCid()) ->getQuery() ->getResult(); @@ -154,10 +159,11 @@ public function prometheusAction(): Response ->from(User::class, 'u') ->leftJoin('u.team', 't') ->leftJoin('t.contests', 'c') - ->join('t.category', 'cat') + ->join('t.categories', 'cat', Join::WITH, 'BIT_AND(cat.types, :scoring) = :scoring') ->leftJoin('cat.contests', 'cc') ->andWhere('c.cid = :cid OR cc.cid = :cid') ->andWhere('cat.visible = true') + ->setParameter('scoring', TeamCategory::TYPE_SCORING) ->setParameter('cid', $contest->getCid()) ->getQuery() ->getResult(); @@ -227,10 +233,11 @@ public function prometheusAction(): Response ->join('b.submission', 's') ->join('s.contest', 'c') ->join('s.team', 't') - ->join('t.category', 'cat') + ->join('t.categories', 'cat', Join::WITH, 'BIT_AND(cat.types, :scoring) = :scoring') ->andWhere('b.done = false') ->andWhere('c.cid = :cid') ->andWhere('cat.visible = true') + ->setParameter('scoring', TeamCategory::TYPE_SCORING) ->setParameter('cid', $contest->getCid()) ->getQuery() ->getResult(); diff --git a/webapp/src/Controller/API/ScoreboardController.php b/webapp/src/Controller/API/ScoreboardController.php index 51f0c23a3c..63029e30e7 100644 --- a/webapp/src/Controller/API/ScoreboardController.php +++ b/webapp/src/Controller/API/ScoreboardController.php @@ -171,7 +171,7 @@ public function getScoreboardAction( $scoreIsInSeconds = (bool)$this->config->get('score_in_seconds'); foreach ($scoreboard->getScores() as $teamScore) { - if ($teamScore->team->getCategory()->getSortorder() !== $sortorder) { + if ($teamScore->team->getSortorder() !== $sortorder) { continue; } diff --git a/webapp/src/Controller/API/SubmissionController.php b/webapp/src/Controller/API/SubmissionController.php index 419c1b12df..bdacecc372 100644 --- a/webapp/src/Controller/API/SubmissionController.php +++ b/webapp/src/Controller/API/SubmissionController.php @@ -11,6 +11,7 @@ use App\Entity\SubmissionFile; use App\Entity\SubmissionSource; use App\Entity\Team; +use App\Entity\TeamCategory; use App\Entity\User; use App\Service\ConfigurationService; use App\Service\DOMJudgeService; @@ -19,6 +20,7 @@ use App\Utils\Utils; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\NonUniqueResultException; +use Doctrine\ORM\Query\Expr\Join; use Doctrine\ORM\QueryBuilder; use Exception; use FOS\RestBundle\Controller\Annotations as Rest; @@ -462,7 +464,8 @@ protected function getQueryBuilder(Request $request): QueryBuilder if (!$this->dj->checkrole('api_reader') && !$this->dj->checkrole('judgehost')) { $queryBuilder - ->join('t.category', 'cat'); + ->join('t.categories', 'cat', Join::WITH, 'BIT_AND(cat.types, :scoring) = :scoring') + ->setParameter('scoring', TeamCategory::TYPE_SCORING); if ($this->dj->checkrole('team')) { $queryBuilder ->andWhere('cat.visible = 1 OR s.team = :team') diff --git a/webapp/src/Controller/API/TeamController.php b/webapp/src/Controller/API/TeamController.php index db5e8f017e..7b15b95684 100644 --- a/webapp/src/Controller/API/TeamController.php +++ b/webapp/src/Controller/API/TeamController.php @@ -5,6 +5,7 @@ use App\DataTransferObject\AddTeam; use App\Entity\Contest; use App\Entity\Team; +use App\Entity\TeamCategory; use App\Service\AssetUpdateService; use App\Service\ConfigurationService; use App\Service\DOMJudgeService; @@ -12,6 +13,7 @@ use App\Service\ImportExportService; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\NonUniqueResultException; +use Doctrine\ORM\Query\Expr\Join; use Doctrine\ORM\QueryBuilder; use FOS\RestBundle\Controller\Annotations as Rest; use Nelmio\ApiDocBundle\Attribute\Model; @@ -305,14 +307,16 @@ protected function getQueryBuilder(Request $request): QueryBuilder $queryBuilder = $this->em->createQueryBuilder() ->from(Team::class, 't') ->leftJoin('t.affiliation', 'ta') - ->leftJoin('t.category', 'tc') + ->leftJoin('t.categories', 'tc') + ->leftJoin('t.categories', 'tcc', Join::WITH, 'BIT_AND(tcc.types, :scoring) = :scoring') ->leftJoin('t.contests', 'c') ->leftJoin('tc.contests', 'cc') - ->select('t, ta'); + ->setParameter('scoring', TeamCategory::TYPE_SCORING) + ->select('t, ta, tc'); if ($request->query->has('category')) { $queryBuilder - ->andWhere('t.category = :category') + ->andWhere('tc.categoryid = :category') ->setParameter('category', $request->query->get('category')); } @@ -324,6 +328,7 @@ protected function getQueryBuilder(Request $request): QueryBuilder if (!$this->dj->checkrole('api_reader') || $request->query->getBoolean('public')) { $queryBuilder->andWhere('tc.visible = 1'); + $queryBuilder->andWhere('tcc.visible = 1'); } if ($request->attributes->has('cid')) { diff --git a/webapp/src/Controller/Jury/ContestController.php b/webapp/src/Controller/Jury/ContestController.php index 4716857940..62709b92aa 100644 --- a/webapp/src/Controller/Jury/ContestController.php +++ b/webapp/src/Controller/Jury/ContestController.php @@ -216,7 +216,7 @@ public function indexAction(Request $request): Response ->select('COUNT(DISTINCT t.teamid)') ->from(Team::class, 't') ->leftJoin('t.contests', 'c') - ->join('t.category', 'cat') + ->join('t.categories', 'cat') ->leftJoin('cat.contests', 'cc') ->andWhere('c.cid = :cid OR cc.cid = :cid') ->setParameter('cid', $contest->getCid()) diff --git a/webapp/src/Controller/Jury/ImportExportController.php b/webapp/src/Controller/Jury/ImportExportController.php index 6f07dfd71a..64246fc16a 100644 --- a/webapp/src/Controller/Jury/ImportExportController.php +++ b/webapp/src/Controller/Jury/ImportExportController.php @@ -487,7 +487,7 @@ protected function getResultsHtml( 'rank' => null, ]; foreach ($teams as $team) { - if (!isset($categories[$team->getCategory()->getCategoryid()]) || $team->getCategory()->getSortorder() !== $sortOrder) { + if ($team->getHidden() || $team->getSortorder() !== $sortOrder) { continue; } diff --git a/webapp/src/Controller/Jury/JudgeRemainingTrait.php b/webapp/src/Controller/Jury/JudgeRemainingTrait.php index 466a652e26..8600006586 100644 --- a/webapp/src/Controller/Jury/JudgeRemainingTrait.php +++ b/webapp/src/Controller/Jury/JudgeRemainingTrait.php @@ -80,7 +80,7 @@ public function judgeRemaining(int $contestId = -1, string $categoryId = '', str ->select('j') ->join('j.submission', 's') ->join('s.team', 't') - ->join('t.category', 'tc') + ->join('t.categories', 'tc') ->andWhere('j.valid = true') ->andWhere('j.result != :compiler_error') ->setParameter('compiler_error', 'compiler-error'); diff --git a/webapp/src/Controller/Jury/SubmissionController.php b/webapp/src/Controller/Jury/SubmissionController.php index 6c0bd7d4cd..d2d23fd4ee 100644 --- a/webapp/src/Controller/Jury/SubmissionController.php +++ b/webapp/src/Controller/Jury/SubmissionController.php @@ -1145,7 +1145,7 @@ public function verifyAction( if (!$judging->getContest()->isOpenToAllTeams()) { $teamsQueryBuilder ->leftJoin('t.contests', 'c') - ->join('t.category', 'cat') + ->join('t.categories', 'cat') ->leftJoin('cat.contests', 'cc') ->andWhere('c.cid = :cid OR cc.cid = :cid') ->setParameter('cid', $judging->getContest()->getCid()); diff --git a/webapp/src/Controller/Jury/TeamCategoryController.php b/webapp/src/Controller/Jury/TeamCategoryController.php index dc56442b4a..bc72ed1471 100644 --- a/webapp/src/Controller/Jury/TeamCategoryController.php +++ b/webapp/src/Controller/Jury/TeamCategoryController.php @@ -55,11 +55,12 @@ public function indexAction(): Response ->groupBy('c.categoryid') ->getQuery()->getResult(); $table_fields = [ - 'categoryid' => ['title' => 'ID', 'sort' => true], + 'categoryid' => ['title' => 'ID', 'sort' => true, 'default_sort' => true], 'externalid' => ['title' => 'external ID', 'sort' => true], 'icpcid' => ['title' => 'ICPC ID', 'sort' => true], - 'sortorder' => ['title' => 'sort', 'sort' => true, 'default_sort' => true], + 'sortorder' => ['title' => 'sort', 'sort' => true], 'name' => ['title' => 'name', 'sort' => true], + 'types' => ['title' => 'types', 'sort' => false], 'num_teams' => ['title' => '# teams', 'sort' => true], 'visible' => ['title' => 'visible', 'sort' => true], 'allow_self_registration' => ['title' => 'self-registration', 'sort' => true], @@ -97,6 +98,7 @@ public function indexAction(): Response ]; } + $categorydata['types'] = ['value' => implode(', ', $teamCategory->getTypeHumanNames()) ?: '-']; $categorydata['num_teams'] = ['value' => $teamCategoryData['num_teams']]; $categorydata['visible'] = ['value' => $teamCategory->getVisible() ? 'yes' : 'no']; $categorydata['allow_self_registration'] = ['value' => $teamCategory->getAllowSelfRegistration() ? 'yes' : 'no']; diff --git a/webapp/src/Controller/Jury/TeamController.php b/webapp/src/Controller/Jury/TeamController.php index 87399655e9..a1f51ebe74 100644 --- a/webapp/src/Controller/Jury/TeamController.php +++ b/webapp/src/Controller/Jury/TeamController.php @@ -51,7 +51,7 @@ public function indexAction(): Response ->from(Team::class, 't') ->leftJoin('t.contests', 'c') ->leftJoin('t.affiliation', 'a') - ->leftJoin('t.category', 'cat') + ->leftJoin('t.categories', 'cat') ->leftJoin('cat.contests', 'cc') ->orderBy('cat.sortorder', 'ASC') ->addOrderBy('t.name', 'ASC') @@ -93,7 +93,8 @@ public function indexAction(): Response 'externalid' => ['title' => 'external ID', 'sort' => true], 'label' => ['title' => 'label', 'sort' => true,], 'effective_name' => ['title' => 'name', 'sort' => true,], - 'category' => ['title' => 'category', 'sort' => true,], + 'category' => ['title' => 'sort order category', 'sort' => true,], + 'num_categories' => ['title' => '# categories', 'sort' => true,], 'affiliation' => ['title' => 'affiliation', 'sort' => true,], 'num_contests' => ['title' => '# contests', 'sort' => true,], 'ip_address' => ['title' => 'last IP', 'sort' => true,], @@ -122,6 +123,9 @@ public function indexAction(): Response } } + $teamdata['category'] = ['value' => $t->getScoringCategory()]; + $teamdata['num_categories'] = ['value' => $t->getCategories()->count()]; + // Add some elements for the solved status. $num_solved = 0; $num_submitted = 0; @@ -189,8 +193,8 @@ public function indexAction(): Response foreach ($t->getContests() as $c) { $teamContests[$c->getCid()] = true; } - if ($t->getCategory()) { - foreach ($t->getCategory()->getContests() as $c) { + foreach ($t->getCategories() as $category) { + foreach ($category->getContests() as $c) { $teamContests[$c->getCid()] = true; } } @@ -212,8 +216,7 @@ public function indexAction(): Response 'data' => $teamdata, 'actions' => $teamactions, 'link' => $this->generateUrl('jury_team', ['teamId' => $t->getTeamId()]), - 'cssclass' => ($t->getCategory() ? ("category" . $t->getCategory()->getCategoryId()) : '') . - ($t->getEnabled() ? '' : ' disabled'), + 'cssclass' => ($t->getEnabled() ? '' : ' disabled'), ]; } return $this->render('jury/teams.html.twig', [ diff --git a/webapp/src/Controller/PublicController.php b/webapp/src/Controller/PublicController.php index d9ee48b02c..9856685997 100644 --- a/webapp/src/Controller/PublicController.php +++ b/webapp/src/Controller/PublicController.php @@ -186,8 +186,16 @@ public function changeContestAction(Request $request, RouterInterface $router, i public function teamAction(Request $request, int $teamId): Response { /** @var Team|null $team */ - $team = $this->em->getRepository(Team::class)->find($teamId); - if ($team && $team->getCategory() && !$team->getCategory()->getVisible()) { + $team = $this->em->createQueryBuilder() + ->from(Team::class, 't') + ->innerJoin('t.categories', 'tc') + ->select('t, tc') + ->andWhere('tc.visible = 1') + ->andWhere('t.teamid = :teamId') + ->setParameter('teamId', $teamId) + ->getQuery() + ->getOneOrNullResult(); + if ($team?->getHidden()) { $team = null; } $showFlags = (bool)$this->config->get('show_flags'); @@ -298,7 +306,7 @@ public function submissionsAction(Request $request, string $teamId, string $prob /** @var Team|null $team */ $team = $this->em->getRepository(Team::class)->findOneBy(['externalid' => $teamId]); - if ($team && $team->getCategory() && !$team->getCategory()->getVisible()) { + if ($team && $team->getScoringCategory() && !$team->getScoringCategory()->getVisible()) { $team = null; } diff --git a/webapp/src/Controller/SecurityController.php b/webapp/src/Controller/SecurityController.php index 15bc302dff..bb7534dbb9 100644 --- a/webapp/src/Controller/SecurityController.php +++ b/webapp/src/Controller/SecurityController.php @@ -133,7 +133,7 @@ public function registerAction( ->setExternalid(Uuid::uuid4()->toString()) ->addUser($user) ->setName($teamName) - ->setCategory($teamCategory) + ->addCategory($teamCategory) ->setInternalComments('Registered by ' . $this->dj->getClientIp() . ' on ' . date('r')); if ($this->config->get('show_affiliations')) { diff --git a/webapp/src/Controller/Team/ScoreboardController.php b/webapp/src/Controller/Team/ScoreboardController.php index 069e5db60c..0985b16f46 100644 --- a/webapp/src/Controller/Team/ScoreboardController.php +++ b/webapp/src/Controller/Team/ScoreboardController.php @@ -67,8 +67,16 @@ public function teamAction(Request $request, int $teamId): Response } /** @var Team|null $team */ - $team = $this->em->getRepository(Team::class)->find($teamId); - if ($team && $team->getCategory() && !$team->getCategory()->getVisible() && $teamId !== $this->dj->getUser()->getTeamId()) { + $team = $this->em->createQueryBuilder() + ->from(Team::class, 't') + ->innerJoin('t.categories', 'tc') + ->select('t, tc') + ->andWhere('tc.visible = 1') + ->andWhere('t.teamid = :teamId') + ->setParameter('teamId', $teamId) + ->getQuery() + ->getOneOrNullResult(); + if ($team?->getHidden() && $teamId !== $this->dj->getUser()->getTeamId()) { $team = null; } $showFlags = (bool)$this->config->get('show_flags'); diff --git a/webapp/src/DataFixtures/DefaultData/TeamCategoryFixture.php b/webapp/src/DataFixtures/DefaultData/TeamCategoryFixture.php index 630ba2f216..eaa187ba46 100644 --- a/webapp/src/DataFixtures/DefaultData/TeamCategoryFixture.php +++ b/webapp/src/DataFixtures/DefaultData/TeamCategoryFixture.php @@ -26,6 +26,7 @@ public function load(ObjectManager $manager): void if (!($category = $manager->getRepository(TeamCategory::class)->findOneBy(['externalid' => $item[4]]))) { $category = (new TeamCategory()) ->setName($item[0]) + ->setTypes([TeamCategory::TYPE_SCORING, TeamCategory::TYPE_BADGE_TOP, TeamCategory::TYPE_BACKGROUND]) ->setSortorder($item[1]) ->setColor($item[2]) ->setVisible($item[3]) diff --git a/webapp/src/DataFixtures/DefaultData/TeamFixture.php b/webapp/src/DataFixtures/DefaultData/TeamFixture.php index 7b2fc0fda5..05b50604e3 100644 --- a/webapp/src/DataFixtures/DefaultData/TeamFixture.php +++ b/webapp/src/DataFixtures/DefaultData/TeamFixture.php @@ -23,7 +23,7 @@ public function load(ObjectManager $manager): void ->setName('DOMjudge') ->setExternalid('domjudge') ->setLabel('domjudge') - ->setCategory($this->getReference(TeamCategoryFixture::SYSTEM_REFERENCE, TeamCategory::class)); + ->addCategory($this->getReference(TeamCategoryFixture::SYSTEM_REFERENCE, TeamCategory::class)); $manager->persist($team); } else { $this->logger->info('Team DOMjudge already exists, not created'); diff --git a/webapp/src/DataFixtures/ExampleData/TeamCategoryFixture.php b/webapp/src/DataFixtures/ExampleData/TeamCategoryFixture.php index 8895c01b4a..bb1c7a1c22 100644 --- a/webapp/src/DataFixtures/ExampleData/TeamCategoryFixture.php +++ b/webapp/src/DataFixtures/ExampleData/TeamCategoryFixture.php @@ -13,12 +13,15 @@ public function load(ObjectManager $manager): void { $participants = new TeamCategory(); $participants + ->setTypes([TeamCategory::TYPE_SCORING, TeamCategory::TYPE_BADGE_TOP]) + ->setSortorder(0) ->setName('Participants') ->setExternalid('participants'); $observers = new TeamCategory(); $observers ->setName('Observers') + ->setTypes([TeamCategory::TYPE_SCORING, TeamCategory::TYPE_BADGE_TOP, TeamCategory::TYPE_BACKGROUND]) ->setSortorder(1) ->setColor('#ffcc33') ->setExternalid('observers'); @@ -26,6 +29,7 @@ public function load(ObjectManager $manager): void $organisation = new TeamCategory(); $organisation ->setName('Organisation') + ->setTypes([TeamCategory::TYPE_SCORING, TeamCategory::TYPE_BADGE_TOP, TeamCategory::TYPE_BACKGROUND]) ->setSortorder(1) ->setColor('#ff99cc') ->setVisible(false) diff --git a/webapp/src/DataFixtures/ExampleData/TeamFixture.php b/webapp/src/DataFixtures/ExampleData/TeamFixture.php index 122fc9fdc4..e7048333dc 100644 --- a/webapp/src/DataFixtures/ExampleData/TeamFixture.php +++ b/webapp/src/DataFixtures/ExampleData/TeamFixture.php @@ -21,7 +21,7 @@ public function load(ObjectManager $manager): void ->setLabel('exteam') ->setName('Example teamname') ->setAffiliation($this->getReference(TeamAffiliationFixture::AFFILIATION_REFERENCE, TeamAffiliation::class)) - ->setCategory($this->getReference(TeamCategoryFixture::PARTICIPANTS_REFERENCE, TeamCategory::class)); + ->addCategory($this->getReference(TeamCategoryFixture::PARTICIPANTS_REFERENCE, TeamCategory::class)); $manager->persist($team); $manager->flush(); diff --git a/webapp/src/DataFixtures/Test/CreateTeamWithTwoTeamAffiliationsFixture.php b/webapp/src/DataFixtures/Test/CreateTeamWithTwoTeamAffiliationsFixture.php new file mode 100644 index 0000000000..6da99e8081 --- /dev/null +++ b/webapp/src/DataFixtures/Test/CreateTeamWithTwoTeamAffiliationsFixture.php @@ -0,0 +1,28 @@ +setExternalid('teamwithtwogroups') + ->setIcpcid('teamwithtwogroups') + ->setLabel('teamwithtwogroups') + ->setName('Team with two groups') + ->setAffiliation($manager->getRepository(TeamAffiliation::class)->findOneBy(['externalid' => 'utrecht'])) + ->addCategory($manager->getRepository(TeamCategory::class)->findOneBy(['externalid' => 'participants'])) + ->addCategory($manager->getRepository(TeamCategory::class)->findOneBy(['externalid' => 'observers'])); + + $manager->persist($team); + $manager->flush(); + } +} diff --git a/webapp/src/DataFixtures/Test/NonSortOrderTeamCategoryFixture.php b/webapp/src/DataFixtures/Test/NonSortOrderTeamCategoryFixture.php new file mode 100644 index 0000000000..e38e7a7928 --- /dev/null +++ b/webapp/src/DataFixtures/Test/NonSortOrderTeamCategoryFixture.php @@ -0,0 +1,22 @@ +setName('Category for color') + ->setExternalid('colorcat') + ->setTypes([TeamCategory::TYPE_BACKGROUND]) + ->setColor('#123123'); + $manager->persist($category); + $manager->flush(); + + $this->addReference(sprintf('%s:%d', static::class, 0), $category); + } +} diff --git a/webapp/src/DataFixtures/Test/RejudgingFirstToSolveFixture.php b/webapp/src/DataFixtures/Test/RejudgingFirstToSolveFixture.php index a5fb6c9f06..8dc42bfcdf 100644 --- a/webapp/src/DataFixtures/Test/RejudgingFirstToSolveFixture.php +++ b/webapp/src/DataFixtures/Test/RejudgingFirstToSolveFixture.php @@ -17,8 +17,10 @@ public function load(ObjectManager $manager): void { $team1 = $manager->getRepository(Team::class)->findOneBy(['name' => 'Example teamname']); $team2 = (new Team()) - ->setName('Another team') - ->setCategory($team1->getCategory()); + ->setName('Another team'); + foreach ($team1->getCategories() as $category) { + $team2->addCategory($category); + } $manager->persist($team2); diff --git a/webapp/src/Entity/ExternalSourceWarning.php b/webapp/src/Entity/ExternalSourceWarning.php index b223b6277b..b46bd123ee 100644 --- a/webapp/src/Entity/ExternalSourceWarning.php +++ b/webapp/src/Entity/ExternalSourceWarning.php @@ -40,7 +40,7 @@ class ExternalSourceWarning scale: 9, options: ['comment' => 'Time this warning happened last', 'unsigned' => true] )] - private float $lastTime; + private string|float $lastTime; #[ORM\Column(options: ['comment' => 'Type of the entity for this warning'])] private string $entityType; @@ -81,12 +81,12 @@ public function setLastEventId(?string $lastEventId): ExternalSourceWarning return $this; } - public function getLastTime(): float + public function getLastTime(): string|float { return $this->lastTime; } - public function setLastTime(float $lastTime): ExternalSourceWarning + public function setLastTime(string|float $lastTime): ExternalSourceWarning { $this->lastTime = $lastTime; return $this; diff --git a/webapp/src/Entity/RankCache.php b/webapp/src/Entity/RankCache.php index d1f25640eb..0971575ca4 100644 --- a/webapp/src/Entity/RankCache.php +++ b/webapp/src/Entity/RankCache.php @@ -19,8 +19,8 @@ #[ORM\Index(columns: ['cid', 'points_public', 'totaltime_public', 'totalruntime_public'], name: 'order_public')] #[ORM\Index(columns: ['cid'], name: 'cid')] #[ORM\Index(columns: ['teamid'], name: 'teamid')] -#[ORM\Index(columns: ['sort_key_public'], name: 'sortKeyPublic')] -#[ORM\Index(columns: ['sort_key_restricted'], name: 'sortKeyRestricted')] +#[ORM\Index(columns: ['sort_key_public'], name: 'sortKeyPublic', options: ['lengths' => [768]])] +#[ORM\Index(columns: ['sort_key_restricted'], name: 'sortKeyRestricted', options: ['lengths' => [768]])] class RankCache { #[ORM\Column(options: [ diff --git a/webapp/src/Entity/Team.php b/webapp/src/Entity/Team.php index b08b485446..708dee82ac 100644 --- a/webapp/src/Entity/Team.php +++ b/webapp/src/Entity/Team.php @@ -21,7 +21,6 @@ #[ORM\Entity] #[ORM\Table(options: ['collation' => 'utf8mb4_unicode_ci', 'charset' => 'utf8mb4'])] #[ORM\Index(columns: ['affilid'], name: 'affilid')] -#[ORM\Index(columns: ['categoryid'], name: 'categoryid')] #[ORM\UniqueConstraint(name: 'externalid', columns: ['externalid'], options: ['lengths' => [190]])] #[ORM\UniqueConstraint(name: 'label', columns: ['label'])] #[UniqueEntity(fields: 'externalid')] @@ -142,10 +141,12 @@ class Team extends BaseApiEntity implements #[Serializer\Exclude] private ?TeamAffiliation $affiliation = null; - #[ORM\ManyToOne(inversedBy: 'teams')] - #[ORM\JoinColumn(name: 'categoryid', referencedColumnName: 'categoryid', onDelete: 'CASCADE')] + /** + * @var Collection + */ + #[ORM\ManyToMany(targetEntity: TeamCategory::class, mappedBy: 'teams', cascade: ['persist'])] #[Serializer\Exclude] - private ?TeamCategory $category = null; + private Collection $categories; /** * @var Collection @@ -436,15 +437,25 @@ public function getAffiliationId(): ?string return $this->getAffiliation()?->getExternalid(); } - public function setCategory(?TeamCategory $category = null): Team + public function addCategory(TeamCategory $category): Team { - $this->category = $category; + $this->categories[] = $category; + $category->addTeam($this); return $this; } - public function getCategory(): ?TeamCategory + public function removeCategory(TeamCategory $category): void + { + $this->categories->removeElement($category); + $category->removeTeam($this); + } + + /** + * @return Collection + */ + public function getCategories(): Collection { - return $this->category; + return $this->categories; } #[Serializer\VirtualProperty] @@ -452,12 +463,18 @@ public function getCategory(): ?TeamCategory #[Serializer\Type('bool')] public function getHidden(): bool { - return !$this->getCategory() || !$this->getCategory()->getVisible(); + foreach ($this->getCategories() as $category) { + if ($category->getVisible()) { + return false; + } + } + return true; } public function __construct() { $this->contests = new ArrayCollection(); + $this->categories = new ArrayCollection(); $this->users = new ArrayCollection(); $this->submissions = new ArrayCollection(); $this->sent_clarifications = new ArrayCollection(); @@ -569,7 +586,7 @@ public function getUnreadClarifications(): Collection #[Serializer\Type('array')] public function getGroupIds(): array { - return $this->getCategory() ? [$this->getCategory()->getExternalid()] : []; + return $this->categories->map(fn(TeamCategory $category) => $category->getExternalid())->toArray(); } #[OA\Property(nullable: true)] @@ -601,6 +618,12 @@ public function canViewClarification(Clarification $clarification): bool #[Assert\Callback] public function validate(ExecutionContextInterface $context): void + { + $this->validateUserCreation($context); + $this->validateCategoryTypes($context); + } + + private function validateUserCreation(ExecutionContextInterface $context): void { if ($this->getAddUserForTeam() === static::CREATE_NEW_USER) { if (empty($this->getNewUsername())) { @@ -617,11 +640,49 @@ public function validate(ExecutionContextInterface $context): void } } + private function validateCategoryTypes(ExecutionContextInterface $context): void + { + $exclusiveTypes = [ + TeamCategory::TYPE_SCORING => 'scoring', + TeamCategory::TYPE_BACKGROUND => 'background', + ]; + + foreach ($exclusiveTypes as $typeFlag => $typeName) { + $categoriesWithType = []; + foreach ($this->getCategories() as $category) { + if ($category->hasType($typeFlag)) { + $categoriesWithType[] = $category->getName(); + } + } + + if (count($categoriesWithType) > 1) { + $message = sprintf( + 'A team can be in at most one %s category. Found: %s.', + $typeName, + implode(', ', $categoriesWithType) + ); + $context + ->buildViolation($message) + ->atPath('categories') + ->addViolation(); + } + } + } + public function inContest(Contest $contest): bool { - return $contest->isOpenToAllTeams() || - $this->getContests()->contains($contest) || - ($this->getCategory() !== null && $this->getCategory()->inContest($contest)); + if ($contest->isOpenToAllTeams()) { + return true; + } + if ($this->getContests()->contains($contest)) { + return true; + } + foreach ($this->getCategories() as $category) { + if ($category->inContest($contest)) { + return true; + } + } + return false; } public function getAssetProperties(): array @@ -661,4 +722,56 @@ public function getPhotoForApi(): array { return array_filter([$this->photoForApi]); } + + public function getCategoryOfType(int $type): ?TeamCategory + { + return $this->categories->findFirst(fn(int $key, TeamCategory $category) => $category->hasType($type)); + } + + /** + * @return Collection + */ + public function getCategoriesOfType(int $type): Collection + { + return $this->categories->filter(fn(TeamCategory $category) => $category->hasType($type)); + } + + public function getScoringCategory(): ?TeamCategory + { + return $this->getCategoryOfType(TeamCategory::TYPE_SCORING); + } + + public function getBackgroundColorCategory(): ?TeamCategory + { + return $this->getCategoryOfType(TeamCategory::TYPE_BACKGROUND); + } + + /** + * @return Collection + */ + public function getCssClassCategories(): Collection + { + return $this->getCategoriesOfType(TeamCategory::TYPE_CSS_CLASS); + } + + /** + * @return Collection + */ + public function getTopBadgeCategories(): Collection + { + return $this->getCategoriesOfType(TeamCategory::TYPE_BADGE_TOP); + } + + /** + * @return Collection + */ + public function getBadgeCategories(): Collection + { + return $this->getCategoriesOfType(TeamCategory::TYPE_BADGE_ALL); + } + + public function getSortOrder(): ?int + { + return $this->getScoringCategory()?->getSortorder(); + } } diff --git a/webapp/src/Entity/TeamAffiliation.php b/webapp/src/Entity/TeamAffiliation.php index d77958d864..7676a62f5b 100644 --- a/webapp/src/Entity/TeamAffiliation.php +++ b/webapp/src/Entity/TeamAffiliation.php @@ -302,4 +302,9 @@ public function getLogoForApi(): array { return array_filter([$this->logoForApi]); } + + public function __toString(): string + { + return $this->getName() ?? $this->getShortname(); + } } diff --git a/webapp/src/Entity/TeamCategory.php b/webapp/src/Entity/TeamCategory.php index 0ae161ff87..c3273af228 100644 --- a/webapp/src/Entity/TeamCategory.php +++ b/webapp/src/Entity/TeamCategory.php @@ -10,6 +10,7 @@ use Stringable; use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; use Symfony\Component\Validator\Constraints as Assert; +use Symfony\Component\Validator\Context\ExecutionContextInterface; /** * Categories for teams (e.g.: participants, observers, ...). @@ -60,13 +61,48 @@ class TeamCategory extends BaseApiEntity implements #[Assert\NotBlank] private string $name; + // These types are encoded as bitset - if you add a new type, use the next power of 2. + public const TYPE_SCORING = 1; + public const TYPE_BACKGROUND = 2; + public const TYPE_BADGE_TOP = 4; + public const TYPE_BADGE_ALL = 8; + public const TYPE_CSS_CLASS = 16; + + /** + * @var array + */ + public const TYPES_TO_STRING = [ + self::TYPE_SCORING => 'scoring', + self::TYPE_BACKGROUND => 'background', + self::TYPE_BADGE_TOP => 'badge-top', + self::TYPE_BADGE_ALL => 'badge-all', + self::TYPE_CSS_CLASS => 'css-class', + ]; + + /** + * @var array + */ + public const TYPES_TO_HUMAN_STRING = [ + self::TYPE_SCORING => 'Scoring', + self::TYPE_BACKGROUND => 'Background color', + self::TYPE_BADGE_TOP => 'Badge for top team', + self::TYPE_BADGE_ALL => 'Badge for all teams', + self::TYPE_CSS_CLASS => 'CSS class', + ]; + + #[ORM\Column(options: ['comment' => 'Bitmask of category types, default is scoring.'])] + #[Serializer\Exclude] + private int $types = self::TYPE_SCORING; + #[ORM\Column( type: 'tinyint', - options: ['comment' => 'Where to sort this category on the scoreboard', 'unsigned' => true, 'default' => 0] + nullable: true, + options: ['comment' => 'Where to sort this category on the scoreboard', 'unsigned' => true] )] #[Assert\GreaterThanOrEqual(0, message: 'Only non-negative sortorders are supported')] #[Serializer\Groups([ARC::GROUP_NONSTRICT])] - private int $sortorder = 0; + #[Serializer\Exclude(if: 'object.getSortorder() === null')] + private ?int $sortorder = 0; #[ORM\Column( length: 32, @@ -75,6 +111,7 @@ class TeamCategory extends BaseApiEntity implements )] #[OA\Property(nullable: true)] #[Serializer\Groups([ARC::GROUP_NONSTRICT])] + #[Serializer\Exclude(if: 'object.getColor() === null')] private ?string $color = null; #[ORM\Column(options: ['comment' => 'Are teams in this category visible?', 'default' => 1])] @@ -88,10 +125,21 @@ class TeamCategory extends BaseApiEntity implements #[Serializer\Groups([ARC::GROUP_NONSTRICT])] private bool $allow_self_registration = false; + #[ORM\Column( + nullable: true, + options: ['comment' => 'CSS class to apply to scoreboard rows (only for TYPE_CSS_CLASS)'] + )] + #[Serializer\Groups([ARC::GROUP_NONSTRICT])] + #[Serializer\Exclude(if: 'object.getCssClass() === null')] + private ?string $css_class = null; + /** * @var Collection */ - #[ORM\OneToMany(mappedBy: 'category', targetEntity: Team::class)] + #[ORM\ManyToMany(targetEntity: Team::class, inversedBy: 'categories', cascade: ['persist'])] + #[ORM\JoinColumn(name: 'categoryid', referencedColumnName: 'categoryid', onDelete: 'CASCADE')] + #[ORM\InverseJoinColumn(name: 'teamid', referencedColumnName: 'teamid', onDelete: 'CASCADE')] + #[Assert\Valid] #[Serializer\Exclude] private Collection $teams; @@ -170,13 +218,13 @@ public function getShortDescription(): ?string return $this->getName(); } - public function setSortorder(int $sortorder): TeamCategory + public function setSortorder(?int $sortorder): TeamCategory { $this->sortorder = $sortorder; return $this; } - public function getSortorder(): int + public function getSortorder(): ?int { return $this->sortorder; } @@ -214,12 +262,105 @@ public function getAllowSelfRegistration(): bool return $this->allow_self_registration; } + + public function hasType(int $type): bool + { + return ($this->types & $type) !== 0; + } + + public function addType(int $type): TeamCategory + { + $this->types |= $type; + return $this; + } + + public function removeType(int $type): TeamCategory + { + $this->types &= ~$type; + return $this; + } + + /** + * @return list + */ + public function getTypes(): array + { + $ret = []; + foreach (array_keys(self::TYPES_TO_STRING) as $type) { + if ($this->types & $type) { + $ret[] = $type; + } + } + return $ret; + } + + /** + * @param array $types + */ + public function setTypes(array $types): TeamCategory + { + $types = array_unique($types); + $this->types = 0; + foreach ($types as $type) { + $this->types |= $type; + } + return $this; + } + + /** + * @return string[] + */ + #[Serializer\VirtualProperty] + #[Serializer\SerializedName('types')] + #[Serializer\Type('array')] + #[Serializer\Groups([ARC::GROUP_NONSTRICT])] + public function getTypeNames(): array + { + $names = []; + foreach (self::TYPES_TO_STRING as $typeValue => $typeName) { + if ($this->hasType($typeValue)) { + $names[] = $typeName; + } + } + return $names; + } + + /** + * @return string[] + */ + public function getTypeHumanNames(): array + { + $names = []; + foreach (self::TYPES_TO_HUMAN_STRING as $typeValue => $typeName) { + if ($this->hasType($typeValue)) { + $names[] = $typeName; + } + } + return $names; + } + + public function setCssClass(?string $cssClass): TeamCategory + { + $this->css_class = $cssClass; + return $this; + } + + public function getCssClass(): ?string + { + return $this->css_class; + } + public function addTeam(Team $team): TeamCategory { $this->teams[] = $team; return $this; } + public function removeTeam(Team $team): void + { + $this->teams->removeElement($team); + } + /** * @return Collection */ @@ -268,4 +409,62 @@ public function inContest(Contest $contest): bool { return $contest->isOpenToAllTeams() || $this->getContests()->contains($contest); } + + #[Assert\Callback] + public function validate(ExecutionContextInterface $context): void + { + // Types field must only contain valid type bits + $validTypes = array_reduce(array_keys(self::TYPES_TO_STRING), fn($carry, $type) => $carry | $type, 0); + if (($this->types & ~$validTypes) !== 0) { + $context + ->buildViolation('Invalid category type combination.') + ->atPath('types') + ->addViolation(); + } + + // Badge types are mutually exclusive + if ($this->hasType(self::TYPE_BADGE_TOP) && $this->hasType(self::TYPE_BADGE_ALL)) { + $message = sprintf( + 'A category cannot be both "%s" and "%s".', + self::TYPES_TO_HUMAN_STRING[self::TYPE_BADGE_TOP], + self::TYPES_TO_HUMAN_STRING[self::TYPE_BADGE_ALL] + ); + $context + ->buildViolation($message) + ->atPath('types') + ->addViolation(); + } + + // Validate type-specific field requirements + $typeFieldRequirements = [ + self::TYPE_SCORING => ['field' => 'sortorder', 'value' => $this->sortorder, 'name' => 'Sort order'], + self::TYPE_BACKGROUND => ['field' => 'color', 'value' => $this->color, 'name' => 'Color'], + self::TYPE_CSS_CLASS => ['field' => 'css_class', 'value' => $this->css_class, 'name' => 'CSS class'], + ]; + + foreach ($typeFieldRequirements as $type => $fieldInfo) { + $hasType = $this->hasType($type); + $hasValue = $fieldInfo['value'] !== null; + + if ($hasType && !$hasValue) { + $context + ->buildViolation(sprintf('%s is required for %s categories.', + $fieldInfo['name'], + strtolower(self::TYPES_TO_HUMAN_STRING[$type]) + )) + ->atPath($fieldInfo['field']) + ->addViolation(); + } + + if (!$hasType && $hasValue) { + $context + ->buildViolation(sprintf('%s should only be set for %s categories.', + $fieldInfo['name'], + strtolower(self::TYPES_TO_HUMAN_STRING[$type]) + )) + ->atPath($fieldInfo['field']) + ->addViolation(); + } + } + } } diff --git a/webapp/src/Form/Type/ContestType.php b/webapp/src/Form/Type/ContestType.php index bcf1769bcc..c0cd6418bf 100644 --- a/webapp/src/Form/Type/ContestType.php +++ b/webapp/src/Form/Type/ContestType.php @@ -10,6 +10,7 @@ use App\Entity\TeamCategory; use App\Service\DOMJudgeService; use App\Service\EventLogService; +use Doctrine\ORM\EntityRepository; use Symfony\Bridge\Doctrine\Form\Type\EntityType; use Symfony\Component\Form\Extension\Core\Type\CheckboxType; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; @@ -125,6 +126,11 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'multiple' => true, 'choice_label' => fn(TeamCategory $category) => $category->getName(), 'help' => 'List of team categories that will receive medals for this contest.', + 'query_builder' => function(EntityRepository $er) { + return $er->createQueryBuilder('c') + ->andWhere('BIT_AND(c.types, :scoring) = :scoring') + ->setParameter('scoring', TeamCategory::TYPE_SCORING); + } ]); foreach (['gold', 'silver', 'bronze'] as $medalType) { $help = "The number of $medalType medals for this contest."; diff --git a/webapp/src/Form/Type/ExportResultsType.php b/webapp/src/Form/Type/ExportResultsType.php index 15b4541798..538846ecc8 100644 --- a/webapp/src/Form/Type/ExportResultsType.php +++ b/webapp/src/Form/Type/ExportResultsType.php @@ -21,6 +21,8 @@ public function buildForm(FormBuilderInterface $builder, array $options): void ->from(TeamCategory::class, 'c', 'c.categoryid') ->select('c.sortorder, c.name') ->where('c.visible = 1') + ->andWhere('BIT_AND(c.types, :scoring) = :scoring') + ->setParameter('scoring', TeamCategory::TYPE_SCORING) ->orderBy('c.sortorder') ->getQuery() ->getResult(); diff --git a/webapp/src/Form/Type/RejudgingType.php b/webapp/src/Form/Type/RejudgingType.php index 5afcda07c6..6050cddfd0 100644 --- a/webapp/src/Form/Type/RejudgingType.php +++ b/webapp/src/Form/Type/RejudgingType.php @@ -181,7 +181,7 @@ public function buildForm(FormBuilderInterface $builder, array $options): void if (!$selectAllTeams) { $teamsQueryBuilder ->leftJoin('t.contests', 'c') - ->join('t.category', 'cat') + ->join('t.categories', 'cat') ->leftJoin('cat.contests', 'cc') ->andWhere('c IN (:contests) OR cc IN (:contests)') ->setParameter('contests', $contests); diff --git a/webapp/src/Form/Type/SubmissionsFilterType.php b/webapp/src/Form/Type/SubmissionsFilterType.php index f962bb7501..74951f4f43 100644 --- a/webapp/src/Form/Type/SubmissionsFilterType.php +++ b/webapp/src/Form/Type/SubmissionsFilterType.php @@ -101,7 +101,7 @@ public function buildForm(FormBuilderInterface $builder, array $options): void if (!$selectAllTeams) { $teamsQueryBuilder ->leftJoin("t.contests", "c") - ->join("t.category", "cat") + ->join("t.categories", "cat") ->leftJoin("cat.contests", "cc") ->andWhere("c IN (:contests) OR cc IN (:contests)") ->setParameter(":contests", $contests); diff --git a/webapp/src/Form/Type/TeamCategoryType.php b/webapp/src/Form/Type/TeamCategoryType.php index 2752962253..c5c65ec818 100644 --- a/webapp/src/Form/Type/TeamCategoryType.php +++ b/webapp/src/Form/Type/TeamCategoryType.php @@ -30,15 +30,44 @@ public function buildForm(FormBuilderInterface $builder, array $options): void ] ]); $builder->add('name', null, ['empty_data' => '']); - $builder->add('sortorder', IntegerType::class); + + $builder->add('types', ChoiceType::class, [ + 'label' => 'Category Types', + 'choices' => array_flip(TeamCategory::TYPES_TO_HUMAN_STRING), + 'multiple' => true, + 'expanded' => false, + 'required' => false, + 'help' => 'Leave empty to use only for categorization.' + ]); + + $builder->add('sortorder', IntegerType::class, [ + 'required' => false, + 'attr' => [ + 'data-conditional-field' => 'types', + 'data-conditional-field-value' => TeamCategory::TYPE_SCORING, + ], + ]); + $builder->add('color', TextType::class, [ 'required' => false, 'attr' => [ 'data-color-picker' => '', + 'data-conditional-field' => 'types', + 'data-conditional-field-value' => TeamCategory::TYPE_BACKGROUND, ], 'help' => '', 'help_html' => true, ]); + + $builder->add('css_class', TextType::class, [ + 'label' => 'CSS Class', + 'required' => false, + 'attr' => [ + 'data-conditional-field' => 'types', + 'data-conditional-field-value' => TeamCategory::TYPE_CSS_CLASS, + ], + 'help' => 'CSS class to apply to scoreboard rows for teams in this category.', + ]); $builder->add('visible', ChoiceType::class, [ 'expanded' => true, 'choices' => [ diff --git a/webapp/src/Form/Type/TeamType.php b/webapp/src/Form/Type/TeamType.php index 5a5cdde162..eaddc686ef 100644 --- a/webapp/src/Form/Type/TeamType.php +++ b/webapp/src/Form/Type/TeamType.php @@ -65,8 +65,12 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'required' => false, 'help' => 'If provided, will display this instead of the team name in certain places, like the scoreboard.', ]); - $builder->add('category', EntityType::class, [ - 'class' => TeamCategory::class, + $builder->add('categories', EntityType::class, [ + 'class' => TeamCategory::class, + 'required' => false, + 'choice_label' => 'name', + 'multiple' => true, + 'by_reference' => false, ]); $builder->add('publicdescription', TextareaType::class, [ 'label' => 'Public description', diff --git a/webapp/src/Service/AwardService.php b/webapp/src/Service/AwardService.php index 4dd15073f7..4d4a9c14d8 100644 --- a/webapp/src/Service/AwardService.php +++ b/webapp/src/Service/AwardService.php @@ -18,10 +18,10 @@ protected function loadAwards(Contest $contest, Scoreboard $scoreboard): void $groups = []; foreach ($scoreboard->getTeamsInDescendingOrder() as $team) { $teamid = $team->getExternalid(); - if ($scoreboard->isBestInCategory($team)) { - $catId = $team->getCategory()->getExternalid(); + if ($scoreboard->isBestInCategory($team, $team->getScoringCategory())) { + $catId = $team->getScoringCategory()?->getExternalid(); $group_winners[$catId][] = $teamid; - $groups[$catId] = $team->getCategory()->getName(); + $groups[$catId] = $team->getScoringCategory()?->getName(); } foreach ($scoreboard->getProblems() as $problem) { $shortname = $problem->getShortname(); @@ -65,8 +65,8 @@ protected function loadAwards(Contest $contest, Scoreboard $scoreboard): void foreach ($scoreboard->getScores() as $teamScore) { // If we are checking a new sort order, reset the number of skipped teams - if ($teamScore->team->getCategory()->getSortorder() !== $currentSortOrder) { - $currentSortOrder = $teamScore->team->getCategory()->getSortorder(); + if ($teamScore->team->getSortOrder() !== $currentSortOrder) { + $currentSortOrder = $teamScore->team->getSortorder(); $skippedTeams = 0; } @@ -79,7 +79,7 @@ protected function loadAwards(Contest $contest, Scoreboard $scoreboard): void $overall_winners[] = $teamid; } if ($contest->getMedalsEnabled()) { - if ($contest->getMedalCategories()->contains($teamScore->team->getCategory())) { + if ($teamScore->team->getScoringCategory() && $contest->getMedalCategories()->contains($teamScore->team->getScoringCategory())) { if ($rank - $skippedTeams <= $contest->getGoldMedals()) { $medal_winners['gold'][] = $teamid; } elseif ($rank - $skippedTeams <= $contest->getGoldMedals() + $contest->getSilverMedals()) { diff --git a/webapp/src/Service/BalloonService.php b/webapp/src/Service/BalloonService.php index ff93e2fddf..cbdc9804ed 100644 --- a/webapp/src/Service/BalloonService.php +++ b/webapp/src/Service/BalloonService.php @@ -10,6 +10,7 @@ use App\Entity\ScoreCache; use App\Entity\Submission; use App\Entity\Team; +use App\Entity\TeamCategory; use Doctrine\DBAL\Exception\UniqueConstraintViolationException; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Exception\ORMException; @@ -117,10 +118,11 @@ public function collectBalloonTable(Contest $contest, bool $todo = false): array ->leftJoin('b.contest', 'co') ->leftJoin('p.contest_problems', 'cp', Join::WITH, 'co.cid = cp.contest AND p.probid = cp.problem') ->leftJoin('b.team', 't') - ->leftJoin('t.category', 'c') + ->leftJoin('t.categories', 'c', Join::WITH, 'BIT_AND(c.types, :scoring) = :scoring') ->leftJoin('t.affiliation', 'a') ->andWhere('co.cid = :cid') ->setParameter('cid', $contest->getCid()) + ->setParameter('scoring', TeamCategory::TYPE_SCORING) ->orderBy('b.done', 'ASC') ->addOrderBy('s.submittime', 'DESC'); diff --git a/webapp/src/Service/CheckConfigService.php b/webapp/src/Service/CheckConfigService.php index 3c2544bbe5..92ed01ea71 100644 --- a/webapp/src/Service/CheckConfigService.php +++ b/webapp/src/Service/CheckConfigService.php @@ -94,6 +94,7 @@ public function runAll(): array $teams = [ 'photos' => $this->checkTeamPhotos(), 'affiliations' => $this->checkAffiliations(), + 'categories' => $this->checkCategories(), 'teamdupenames' => $this->checkTeamDuplicateNames(), 'selfregistration' => $this->checkSelfRegistration(), ]; @@ -790,6 +791,34 @@ public function checkAffiliations(): ConfigCheckItem ); } + public function checkCategories(): ConfigCheckItem + { + $this->stopwatch->start(__FUNCTION__); + /** @var Team[] $teams */ + $teams = $this->em->getRepository(Team::class) + ->createQueryBuilder('t') + ->innerJoin('t.categories', 'c') + ->select('t, c'); + + $desc = ''; + $result = 'O'; + foreach ($teams as $team) { + if (!$team->getScoringCategory()) { + $result = 'W'; + $desc .= sprintf(" - Team `t%d` (%s) does not belong to any scoring category\n", $team->getTeamid(), $team->getName()); + } + } + + $desc = $desc ?: 'Everything OK'; + + $this->stopwatch->stop(__FUNCTION__); + return new ConfigCheckItem( + caption: 'Team categories', + result: $result, + desc: $desc + ); + } + public function checkTeamDuplicateNames(): ConfigCheckItem { $this->stopwatch->start(__FUNCTION__); diff --git a/webapp/src/Service/DOMJudgeService.php b/webapp/src/Service/DOMJudgeService.php index 8571dfa81f..6fcb6a7609 100644 --- a/webapp/src/Service/DOMJudgeService.php +++ b/webapp/src/Service/DOMJudgeService.php @@ -1396,7 +1396,7 @@ public function loadTeam(string $teamId, Contest $contest): Team $queryBuilder = $this->em->createQueryBuilder() ->from(Team::class, 't') ->select('t') - ->leftJoin('t.category', 'tc') + ->leftJoin('t.categories', 'tc') ->leftJoin('t.contests', 'c') ->leftJoin('tc.contests', 'cc') ->andWhere('t.externalid = :team') diff --git a/webapp/src/Service/ExternalContestSourceService.php b/webapp/src/Service/ExternalContestSourceService.php index 7970b6b134..ed57a06c7c 100644 --- a/webapp/src/Service/ExternalContestSourceService.php +++ b/webapp/src/Service/ExternalContestSourceService.php @@ -1121,7 +1121,7 @@ protected function validateAndUpdateTeam(Event $event, EventData $data): void $category->setName($data->groupIds[0]); $this->em->persist($category); } - $team->setCategory($category); + $team->addCategory($category); } $this->removeWarning($event->type, $data->id, ExternalSourceWarning::TYPE_ENTITY_NOT_FOUND); diff --git a/webapp/src/Service/ICPCCmsService.php b/webapp/src/Service/ICPCCmsService.php index da28564035..7f2beefd33 100644 --- a/webapp/src/Service/ICPCCmsService.php +++ b/webapp/src/Service/ICPCCmsService.php @@ -110,7 +110,7 @@ public function importTeams(string $token, string $contest, ?string &$message = $team = new Team(); $team ->setName($teamData['teamName']) - ->setCategory($participants) + ->addCategory($participants) ->setAffiliation($affiliation) ->setEnabled($enabled) ->setInternalComments('Status: ' . $teamData['status']) @@ -132,7 +132,7 @@ public function importTeams(string $token, string $contest, ?string &$message = $username = sprintf("team%04d", $team->getTeamid()); $team ->setName($teamData['teamName']) - ->setCategory($participants) + ->addCategory($participants) ->setAffiliation($affiliation) ->setEnabled($enabled) ->setInternalComments('Status: ' . $teamData['status']) diff --git a/webapp/src/Service/ImportExportService.php b/webapp/src/Service/ImportExportService.php index 2b2dcc217b..e03fc3d8a3 100644 --- a/webapp/src/Service/ImportExportService.php +++ b/webapp/src/Service/ImportExportService.php @@ -23,6 +23,7 @@ use DateTimeZone; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\NonUniqueResultException; +use Doctrine\ORM\Query\Expr\Join; use JsonException; use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; @@ -506,9 +507,10 @@ public function getTeamData(): array /** @var Team[] $teams */ $teams = $this->em->createQueryBuilder() ->from(Team::class, 't') - ->join('t.category', 'c') + ->join('t.categories', 'c', Join::WITH, 'BIT_AND(c.types, :scoring) = :scoring') ->select('t') ->where('c.visible = 1') + ->setParameter('scoring', TeamCategory::TYPE_SCORING) ->getQuery() ->getResult(); @@ -517,7 +519,7 @@ public function getTeamData(): array $data[] = [ $team->getExternalid(), $team->getIcpcId(), - $team->getCategory()->getExternalid(), + $team->getScoringCategory()?->getExternalid(), $team->getEffectiveName(), $team->getAffiliation() ? $team->getAffiliation()->getName() : '', $team->getAffiliation() ? $team->getAffiliation()->getShortname() : '', @@ -590,7 +592,7 @@ public function getResultsData( $skippedTeams = 0; foreach ($scoreboard->getScores() as $teamScore) { - if ($teamScore->team->getCategory()->getSortorder() !== $sortOrder) { + if ($teamScore->team->getSortorder() !== $sortOrder) { continue; } $maxTime = -1; @@ -603,7 +605,7 @@ public function getResultsData( $numPoints = $teamScore->numPoints; $skip = false; - if (!$contest->getMedalCategories()->contains($teamScore->team->getCategory())) { + if (!$teamScore->team->getScoringCategory() || !$contest->getMedalCategories()->contains($teamScore->team->getScoringCategory())) { $skip = true; $skippedTeams++; } @@ -658,12 +660,12 @@ public function getResultsData( $rank = null; } - $categoryId = $teamScore->team->getCategory()->getCategoryid(); - if (isset($groupWinners[$categoryId])) { + $categoryId = $teamScore->team->getScoringCategory()?->getCategoryid(); + if ($categoryId && isset($groupWinners[$categoryId])) { $groupWinner = null; } else { $groupWinners[$categoryId] = true; - $groupWinner = $teamScore->team->getCategory()->getName(); + $groupWinner = $teamScore->team->getScoringCategory()?->getName(); } $data[] = new ResultRow( @@ -794,22 +796,37 @@ protected function importGroupsTsv(array $content, ?string &$message = null): in /** * Import groups JSON * - * @param array $data + * @param array $data * @param TeamCategory[]|null $saved The saved groups */ public function importGroupsJson(array $data, ?string &$message = null, ?array &$saved = null): int { // TODO: can we have this use the DTO? $groupData = []; - foreach ($data as $group) { + foreach ($data as $index => $group) { + if (isset($group['types'])) { + $types = []; + $typeMapping = array_flip(TeamCategory::TYPES_TO_STRING); + foreach ($group['types'] as $type) { + if (!isset($typeMapping[$type])) { + $message = sprintf('Invalid group type at index %d: %s', $index, $type); + return -1; + } + $types[] = $typeMapping[$type]; + } + } else { + $types = [TeamCategory::TYPE_SCORING, TeamCategory::TYPE_BADGE_TOP]; + } $groupData[] = [ 'categoryid' => @$group['id'], 'icpc_id' => @$group['icpc_id'], 'name' => $group['name'] ?? '', 'visible' => !($group['hidden'] ?? false), + 'types' => $types, 'sortorder' => @$group['sortorder'], 'color' => @$group['color'], + 'css_class' => @$group['css_class'], 'allow_self_registration' => $group['allow_self_registration'] ?? false, ]; } @@ -821,7 +838,8 @@ public function importGroupsJson(array $data, ?string &$message = null, ?array & * Import group data from the given array * * @param array $groupData + * types?: int[]|null, sortorder?: int|null, color?: string|null, css_class?: string|null, + * allow_self_registration: bool}> $groupData * @param TeamCategory[]|null $saved The saved groups * * @throws NonUniqueResultException @@ -853,11 +871,29 @@ protected function importGroupData( } $added = true; } + /** @var list $types */ + $types = $groupItem['types'] ?? [TeamCategory::TYPE_SCORING, TeamCategory::TYPE_BADGE_TOP]; + $sortOrder = $groupItem['sortorder'] ?? null; + if (in_array(TeamCategory::TYPE_SCORING, $types, true) && $sortOrder === null) { + $sortOrder = 0; + } + if ($sortOrder !== null && !in_array(TeamCategory::TYPE_SCORING, $types, true)) { + $types = [TeamCategory::TYPE_SCORING]; + } + if (isset($groupItem['color']) && !in_array(TeamCategory::TYPE_BACKGROUND, $types, true)) { + $types[] = TeamCategory::TYPE_BACKGROUND; + } + if (isset($groupItem['css_class']) && !in_array(TeamCategory::TYPE_CSS_CLASS, $types, true)) { + $types[] = TeamCategory::TYPE_CSS_CLASS; + } + $teamCategory ->setName($groupItem['name']) ->setVisible($groupItem['visible'] ?? true) - ->setSortorder($groupItem['sortorder'] ?? 0) + ->setTypes($types) + ->setSortorder($sortOrder) ->setColor($groupItem['color'] ?? null) + ->setCssClass($groupItem['css_class'] ?? null) ->setIcpcid($groupItem['icpc_id'] ?? null); $teamCategory->setAllowSelfRegistration($groupItem['allow_self_registration']); @@ -1060,7 +1096,7 @@ protected function importTeamsTsv(array $content, ?string &$message = null): int 'team' => [ 'teamid' => $teamId, 'icpcid' => $teamIcpcId, - 'categoryid' => @$line[2], + 'categoryids' => isset($line[2]) ? [$line[2]] : [], 'name' => @$line[3], ], 'team_affiliation' => [ @@ -1091,7 +1127,7 @@ public function importTeamsJson(array $data, ?string &$message = null, ?array &$ 'teamid' => $team['id'] ?? null, 'icpcid' => $team['icpc_id'] ?? null, 'label' => $team['label'] ?? null, - 'categoryid' => $team['group_ids'][0] ?? null, + 'categoryids' => $team['group_ids'] ?? [], 'name' => $team['name'] ?? '', 'display_name' => $team['display_name'] ?? null, 'publicdescription' => $team['public_description'] ?? $team['members'] ?? '', @@ -1192,7 +1228,7 @@ public function importAccountsJson(array $data, ?string &$message = null, ?array * Import team data from the given array. * * @param array $teamData @@ -1265,11 +1301,12 @@ protected function importTeamData(array $teamData, ?string &$message, ?array &$s $teamItem['team']['affiliation'] = $teamAffiliation; unset($teamItem['team']['affilid']); - if (!empty($teamItem['team']['categoryid'])) { - $teamCategory = $this->em->getRepository(TeamCategory::class)->findOneBy(['externalid' => $teamItem['team']['categoryid']]); + $teamCategories = []; + foreach ($teamItem['team']['categoryids'] ?? [] as $categoryid) { + $teamCategory = $this->em->getRepository(TeamCategory::class)->findOneBy(['externalid' => $categoryid]); if (!$teamCategory) { foreach ($createdCategories as $createdCategory) { - if ($createdCategory->getExternalid() === $teamItem['team']['categoryid']) { + if ($createdCategory->getExternalid() === $categoryid) { $teamCategory = $createdCategory; break; } @@ -1278,8 +1315,10 @@ protected function importTeamData(array $teamData, ?string &$message, ?array &$s if (!$teamCategory) { $teamCategory = new TeamCategory(); $teamCategory - ->setExternalid($teamItem['team']['categoryid']) - ->setName($teamItem['team']['categoryid'] . ' - auto-create during import'); + ->setTypes([TeamCategory::TYPE_SCORING, TeamCategory::TYPE_BADGE_TOP]) + ->setSortorder(0) + ->setExternalid($categoryid) + ->setName($categoryid . ' - auto-create during import'); $errors = $this->validator->validate($teamCategory); if ($errors->count()) { @@ -1298,9 +1337,10 @@ protected function importTeamData(array $teamData, ?string &$message, ?array &$s $createdCategories[] = $teamCategory; } } + $teamCategories[] = $teamCategory; } - $teamItem['team']['category'] = $teamCategory; - unset($teamItem['team']['categoryid']); + $teamItem['team']['categories'] = $teamCategories; + unset($teamItem['team']['categoryids']); // Determine if we need to set the team ID manually or automatically if (empty($teamItem['team']['teamid'])) { @@ -1368,15 +1408,21 @@ protected function importTeamData(array $teamData, ?string &$message, ?array &$s foreach ($createdAffiliations as $affiliation) { $this->em->persist($affiliation); - $this->em->flush(); + } + + foreach ($createdCategories as $category) { + $this->em->persist($category); + } + + $this->em->flush(); + + foreach ($createdAffiliations as $affiliation) { $this->dj->auditlog('team_affiliation', $affiliation->getAffilid(), 'added', 'imported from tsv / json'); } foreach ($createdCategories as $category) { - $this->em->persist($category); - $this->em->flush(); $this->dj->auditlog('team_category', $category->getCategoryid(), 'added', 'imported from tsv'); } @@ -1426,15 +1472,21 @@ protected function importAccountData( $allUsers = []; foreach ($accountData as $index => $accountItem) { if (!empty($accountItem['team'])) { - $team = $this->em->getRepository(Team::class)->findOneBy([ - 'name' => $accountItem['team']['name'], - 'category' => $accountItem['team']['category'], - ]); + $team = $this->em->createQueryBuilder() + ->select('t') + ->from(Team::class, 't') + ->join('t.categories', 'c') + ->andWhere('t.name = :name') + ->andWhere('c.categoryid = :category') + ->setParameter('name', $accountItem['team']['name']) + ->setParameter('category', $accountItem['team']['category']) + ->getQuery() + ->getOneOrNullResult(); if ($team === null) { $team = new Team(); $team ->setName($accountItem['team']['name']) - ->setCategory($accountItem['team']['category']) + ->addCategory($accountItem['team']['category']) ->setExternalid($accountItem['team']['externalid']) ->setPublicDescription($accountItem['team']['publicdescription'] ?? null); $action = EventLogService::ACTION_CREATE; diff --git a/webapp/src/Service/RejudgingService.php b/webapp/src/Service/RejudgingService.php index 629dfc3418..56bc9528f7 100644 --- a/webapp/src/Service/RejudgingService.php +++ b/webapp/src/Service/RejudgingService.php @@ -369,7 +369,7 @@ public function finishRejudging(Rejudging $rejudging, string $action, ?callable if (!$contest->isOpenToAllTeams()) { $queryBuilder ->leftJoin('t.contests', 'c') - ->join('t.category', 'cat') + ->join('t.categories', 'cat') ->leftJoin('cat.contests', 'cc') ->andWhere('c.cid = :cid OR cc.cid = :cid') ->setParameter('cid', $contest->getCid()); diff --git a/webapp/src/Service/ScoreboardService.php b/webapp/src/Service/ScoreboardService.php index e0c65e8684..cd949cb442 100644 --- a/webapp/src/Service/ScoreboardService.php +++ b/webapp/src/Service/ScoreboardService.php @@ -131,7 +131,7 @@ public function calculateTeamRank( } $restricted = ($jury || $freezeData->showFinal(false)); $variant = $restricted ? 'Restricted' : 'Public'; - $sortOrder = $team->getCategory()->getSortorder(); + $sortOrder = $team->getSortOrder(); $sortKey = $this->em->createQueryBuilder() ->from(RankCache::class, 'r') @@ -152,11 +152,12 @@ public function calculateTeamRank( $better = $this->em->createQueryBuilder() ->from(RankCache::class, 'r') ->join('r.team', 't') - ->join('t.category', 'tc') + ->join('t.categories', 'tc', Join::WITH, 'BIT_AND(tc.types, :scoring) = :scoring') ->select('COUNT(t.teamid)') ->andWhere('r.sortKey'.$variant.' > :sortKey') ->andWhere('r.contest = :contest') ->andWhere('tc.sortorder = :sortorder') + ->setParameter('scoring', TeamCategory::TYPE_SCORING) ->setParameter('sortKey', $sortKey) ->setParameter('contest', $contest) ->setParameter('sortorder', $sortOrder) @@ -190,7 +191,7 @@ public function calculateScoreRow( [ $contest->getCid(), $team->getTeamid(), $problem->getProbid() ] ); - if (!$team->getCategory()) { + if (!$team->getScoringCategory()) { $this->logger->warning( "Team '%d' has no category, skipping", [ $team->getTeamid() ] @@ -354,10 +355,11 @@ public function calculateScoreRow( $params = [ 'cid' => $contest->getCid(), 'probid' => $problem->getProbid(), - 'teamSortOrder' => $team->getCategory()->getSortorder(), + 'teamSortOrder' => $team->getSortorder(), /** @phpstan-ignore-next-line $absSubmitTime is always set when $correctJury is true */ 'submitTime' => $absSubmitTime, 'correctResult' => Judging::RESULT_CORRECT, + 'scoring' => TeamCategory::TYPE_SCORING, ]; // Find out how many valid submissions were submitted earlier @@ -377,7 +379,9 @@ public function calculateScoreRow( LEFT JOIN external_judgement ej USING (submitid) LEFT JOIN external_judgement ej2 ON ej2.submitid = s.submitid AND ej2.starttime > ej.starttime LEFT JOIN team t USING(teamid) - LEFT JOIN team_category tc USING (categoryid) + # TODO: category type + LEFT JOIN team_category_team tcc USING (teamid) + LEFT JOIN team_category tc ON tc.categoryid = tcc.categoryid AND (tc.types & :scoring) = :scoring WHERE s.valid = 1 AND (ej.result IS NULL OR ej.result = :correctResult '. $verificationRequiredExtra.') AND @@ -389,7 +393,9 @@ public function calculateScoreRow( SELECT count(*) FROM submission s LEFT JOIN judging j ON (s.submitid=j.submitid AND j.valid=1) LEFT JOIN team t USING (teamid) - LEFT JOIN team_category tc USING (categoryid) + # TODO: category type + LEFT JOIN team_category_team tcc USING (teamid) + LEFT JOIN team_category tc ON tc.categoryid = tcc.categoryid AND (tc.types & :scoring) = :scoring WHERE s.valid = 1 AND (j.judgingid IS NULL OR j.result IS NULL OR j.result = :correctResult '. $verificationRequiredExtra.') AND @@ -621,7 +627,7 @@ public function refreshCache(Contest $contest, ?callable $progressReporter = nul if (!$contest->isOpenToAllTeams()) { $queryBuilder ->leftJoin('t.contests', 'c') - ->join('t.category', 'cat') + ->join('t.categories', 'cat') ->leftJoin('cat.contests', 'cc') ->andWhere('c.cid = :cid OR cc.cid = :cid') ->setParameter('cid', $contest->getCid()); @@ -831,12 +837,13 @@ public function getFilterValues(Contest $contest, bool $jury): array ->from(TeamAffiliation::class, 'a') ->select('a') ->join('a.teams', 't') - ->andWhere('t.category IN (:categories)') + ->join('t.categories', 'tc') + ->andWhere('tc.categoryid IN (:categories)') ->setParameter('categories', $categories); if (!$contest->isOpenToAllTeams()) { $queryBuilder ->leftJoin('t.contests', 'c') - ->join('t.category', 'cat') + ->join('t.categories', 'cat') ->leftJoin('cat.contests', 'cc') ->andWhere('c = :contest OR cc = :contest') ->setParameter('contest', $contest); @@ -951,17 +958,20 @@ protected function getTeamsInOrder(Contest $contest, bool $jury = false, ?Filter { $queryBuilder = $this->em->createQueryBuilder() ->from(Team::class, 't', 't.teamid') - ->innerJoin('t.category', 'tc') + // Join on categories twice: once to determine the sort order (tc) and once to get all categories the team belongs to (tcc) + ->innerJoin('t.categories', 'tc', Join::WITH, 'BIT_AND(tc.types, :scoring) = :scoring') + ->innerJoin('t.categories', 'tcc') ->leftJoin(RankCache::class, 'r', Join::WITH, 'r.team = t AND r.contest = :rcid') ->leftJoin('t.affiliation', 'ta') - ->select('t, tc, ta', 'COALESCE(t.display_name, t.name) AS HIDDEN effectivename') + ->select('t, tcc, ta', 'COALESCE(t.display_name, t.name) AS HIDDEN effectivename') ->andWhere('t.enabled = 1') - ->setParameter('rcid', $contest->getCid()); + ->setParameter('rcid', $contest->getCid()) + ->setParameter('scoring', TeamCategory::TYPE_SCORING); if (!$contest->isOpenToAllTeams()) { $queryBuilder ->leftJoin('t.contests', 'c') - ->join('t.category', 'cat') + ->join('t.categories', 'cat') ->leftJoin('cat.contests', 'cc') ->andWhere('c.cid = :cid OR cc.cid = :cid') ->setParameter('cid', $contest->getCid()); @@ -970,6 +980,7 @@ protected function getTeamsInOrder(Contest $contest, bool $jury = false, ?Filter $show_filter = $this->config->get('show_teams_on_scoreboard'); if (!$jury) { $queryBuilder->andWhere('tc.visible = 1'); + $queryBuilder->andWhere('tcc.visible = 1'); if ($show_filter === self::SHOW_TEAM_AFTER_LOGIN) { $queryBuilder ->join('t.users', 'u', Join::WITH, 'u.last_login IS NOT NULL OR u.last_api_login IS NOT NULL'); @@ -988,9 +999,14 @@ protected function getTeamsInOrder(Contest $contest, bool $jury = false, ?Filter } if ($filter->categories) { + // Use a new join, since we need both the other two category joins for other logic already $queryBuilder - ->andWhere('t.category IN (:categories)') + ->innerJoin('t.categories', 'tccc') + ->andWhere('tccc.categoryid IN (:categories)') ->setParameter('categories', $filter->categories); + if (!$jury) { + $queryBuilder->andWhere('tccc.visible = 1'); + } } if ($filter->countries) { diff --git a/webapp/src/Service/StatisticsService.php b/webapp/src/Service/StatisticsService.php index 7dcf9f6181..e3646737ec 100644 --- a/webapp/src/Service/StatisticsService.php +++ b/webapp/src/Service/StatisticsService.php @@ -56,7 +56,7 @@ public function getTeams(Contest $contest, string $filter): array return $this->applyFilter($this->em->createQueryBuilder() ->select('t', 'ts', 'j', 'lang', 'a') ->from(Team::class, 't') - ->join('t.category', 'tc') + ->join('t.categories', 'tc') ->leftJoin('t.affiliation', 'a') ->join('t.submissions', 'ts') ->join('ts.language', 'l') @@ -71,7 +71,7 @@ public function getTeams(Contest $contest, string $filter): array ->from(Team::class, 't') ->leftJoin('t.contests', 'c') ->leftJoin('t.affiliation', 'a') - ->join('t.category', 'tc') + ->join('t.categories', 'tc') ->leftJoin('tc.contests', 'cc') ->join('t.submissions', 'ts') ->join('ts.language', 'l') @@ -257,7 +257,7 @@ public function getTeamStats(Contest $contest, Team $team): array ->join('s.problem', 'p') ->join('j.runs', 'jr') ->join('s.team', 'team') - ->join('team.category', 'tc') + ->join('team.categories', 'tc') // ->andWhere('j.valid = true') ->andWhere('s.contest = :contest') ->andWhere('s.team = :team') @@ -342,7 +342,7 @@ public function getProblemStats( ->join('s.judgings', 'sj') ->join('j.runs', 'jr') ->join('s.team', 'team') - ->join('team.category', 'tc') + ->join('team.categories', 'tc') ->andWhere('j.valid = true') ->andWhere('j.result IS NOT NULL') ->andWhere('s.contest = :contest') @@ -430,7 +430,7 @@ public function getGroupedProblemsStats( ->join('j.submission', 's') ->join('s.problem', 'p') ->join('s.team', 'team') - ->join('team.category', 'tc') + ->join('team.categories', 'tc') ->andWhere('j.valid = true') ->andWhere('j.result IS NOT NULL') ->andWhere('s.contest = :contest') @@ -685,7 +685,7 @@ protected function getTeamNumSubmissions(Contest $contest, string $filter): arra ->select('t.teamid as teamid, count(t.teamid) as num_submissions') ->from(Submission::class, 's') ->join('s.team', 't') - ->join('t.category', 'tc') + ->join('t.categories', 'tc') ->andWhere('s.contest = :contest'), $filter) ->groupBy('t.teamid') ->setParameter('contest', $contest) diff --git a/webapp/src/Service/SubmissionService.php b/webapp/src/Service/SubmissionService.php index 792038885d..066f9e9451 100644 --- a/webapp/src/Service/SubmissionService.php +++ b/webapp/src/Service/SubmissionService.php @@ -86,6 +86,7 @@ public function getSubmissionList( ->from(Submission::class, 's') ->select('s', 'j', 'cp') ->join('s.team', 't') + ->join('t.categories', 'tc') ->join('s.contest_problem', 'cp') ->andWhere('s.contest IN (:contests)') ->setParameter('contests', array_keys($contests)) @@ -214,13 +215,13 @@ public function getSubmissionList( if (isset($restrictions->categoryId)) { $queryBuilder - ->andWhere('t.category = :categoryid') + ->andWhere('tc.categoryid = :categoryid') ->setParameter('categoryid', $restrictions->categoryId); } if (!empty($restrictions->categoryIds)) { $queryBuilder - ->andWhere('t.category IN (:categoryids)') + ->andWhere('tc.categoryid IN (:categoryids)') ->setParameter('categoryids', $restrictions->categoryIds); } @@ -238,7 +239,7 @@ public function getSubmissionList( if (isset($restrictions->visible)) { $queryBuilder - ->innerJoin('t.category', 'cat') + ->innerJoin('t.categories', 'cat') ->andWhere('cat.visible = true'); } @@ -374,7 +375,6 @@ public function getSubmissionList( $counts['inContest'] = (clone $queryBuilder) ->select('COUNT(s.submitid)') ->join('s.contest', 'c') - ->join('t.category', 'tc') ->andWhere('s.submittime BETWEEN c.starttime AND c.endtime') ->andWhere('tc.visible = true') ->getQuery() diff --git a/webapp/src/Utils/Scoreboard/Scoreboard.php b/webapp/src/Utils/Scoreboard/Scoreboard.php index aaea69c269..67f3ac2217 100644 --- a/webapp/src/Utils/Scoreboard/Scoreboard.php +++ b/webapp/src/Utils/Scoreboard/Scoreboard.php @@ -11,7 +11,9 @@ use App\Utils\FreezeData; use App\Utils\Utils; use Collator; +use Doctrine\Common\Collections\ArrayCollection; use Exception; +use Ramsey\Collection\Set; class Scoreboard { @@ -75,6 +77,27 @@ public function getCategories(): array return $this->categories; } + /** + * @param int[] $limitToTeamIds + * + * @return list + */ + public function getColorCategories(?array $limitToTeamIds = null): array + { + $categories = []; + foreach ($this->scores as $score) { + // Skip if we have limitteams and the team is not listed. + if (!empty($limitToTeamIds) && + !in_array($score->team->getTeamid(), $limitToTeamIds)) { + continue; + } + if ($category = $score->team->getBackgroundColorCategory()) { + $categories[$category->getCategoryid()] = $category; + } + } + return array_values($categories); + } + /** * @return ContestProblem[] */ @@ -178,7 +201,7 @@ protected function calculateScoreboard(): void $previousTeamId = null; foreach ($this->scores as $teamScore) { $teamId = $teamScore->team->getTeamid(); - $teamSortOrder = $teamScore->team->getCategory()->getSortorder(); + $teamSortOrder = $teamScore->team->getSortorder(); // rank, team name, total correct, total time if ($teamSortOrder != $prevSortOrder) { $prevSortOrder = $teamSortOrder; @@ -198,7 +221,7 @@ protected function calculateScoreboard(): void // Keep summary statistics for the bottom row of our table. // The numberOfPoints summary is useful only if they're all 1-point problems. - $sortOrder = $teamScore->team->getCategory()->getSortorder(); + $sortOrder = $teamScore->team->getSortorder(); $this->summary->addNumberOfPoints($sortOrder, $teamScore->numPoints); $teamAffiliation = $teamScore->team->getAffiliation(); if ($teamAffiliation) { @@ -259,32 +282,7 @@ public function showPoints(): bool } /** - * Return the used team categories for this scoreboard. - * - * @param int[] $limitToTeamIds - * @return TeamCategory[] - */ - public function getUsedCategories(?array $limitToTeamIds = null): array - { - $usedCategories = []; - foreach ($this->scores as $score) { - // Skip if we have limitteams and the team is not listed. - if (!empty($limitToTeamIds) && - !in_array($score->team->getTeamid(), $limitToTeamIds)) { - continue; - } - - $category = $score->team->getCategory(); - if ($category) { - $usedCategories[$category->getCategoryid()] = $category; - } - } - - return $usedCategories; - } - - /** - * Return whether this scoreboard has multiple category colors. + * Return whether this scoreboard has at least one non default category color. * * @param int[] $limitToTeamIds */ @@ -298,15 +296,12 @@ public function hasCategoryColors(?array $limitToTeamIds = null): bool continue; } - if ($score->team->getCategory() && - $score->team->getCategory()->getColor()) { - $colors[$score->team->getCategory()->getColor()] = 1; - } else { - $colors['transparent'] = 1; + if ($score->team->getBackgroundColorCategory()?->getColor()) { + $colors[$score->team->getBackgroundColorCategory()->getColor()] = 1; } } - return count($colors) > 1; + return count($colors) > 0; } /** @@ -314,7 +309,7 @@ public function hasCategoryColors(?array $limitToTeamIds = null): bool * * @param int[] $limitToTeamIds */ - public function isBestInCategory(Team $team, ?array $limitToTeamIds = null): bool + public function isBestInCategory(Team $team, TeamCategory $category, ?array $limitToTeamIds = null): bool { if ($this->bestInCategoryData === null) { $this->bestInCategoryData = []; @@ -325,14 +320,21 @@ public function isBestInCategory(Team $team, ?array $limitToTeamIds = null): boo continue; } - $categoryId = $score->team->getCategory()->getCategoryid(); + $categoryId = $score->team->getScoringCategory()->getCategoryid(); if (!isset($this->bestInCategoryData[$categoryId])) { $this->bestInCategoryData[$categoryId] = $score->team->getTeamid(); } + + foreach ($score->team->getTopBadgeCategories() as $badgeCategory) { + $categoryId = $badgeCategory->getCategoryid(); + if (!isset($this->bestInCategoryData[$categoryId])) { + $this->bestInCategoryData[$categoryId] = $score->team->getTeamid(); + } + } } } - $categoryId = $team->getCategory()->getCategoryid(); + $categoryId = $category->getCategoryid(); // Only check the scores when the team has points. if ($this->scores[$team->getTeamid()]->numPoints > 0) { // If the rank of this team is equal to the best team for this @@ -362,7 +364,7 @@ public function isFastestSubmission(Team $team, ContestProblem $problem): bool if (!$item->isCorrect) { return false; } - $sortorder = $team->getCategory()->getSortorder(); + $sortorder = $team->getSortorder(); $bestTime = $this->summary->getProblem($problem->getProbid())->getBestRuntime($sortorder); return $item->runtime == $bestTime; } diff --git a/webapp/templates/jury/base.html.twig b/webapp/templates/jury/base.html.twig index 91775be24c..c3e988ec97 100644 --- a/webapp/templates/jury/base.html.twig +++ b/webapp/templates/jury/base.html.twig @@ -55,6 +55,42 @@ $('[data-bs-toggle="tooltip"]').tooltip(); applyEditorTheme(); + + $('[data-conditional-field]').each(function () { + const $field = $(this); + const $wrapper = $field.closest('div.mb-3'); + const conditionalFieldName = $field.data('conditional-field'); + const conditionalFieldRequiredValue = $field.data('conditional-field-value').toString(); + const $form = $field.closest('form'); + const formName = $form.attr('name'); + const fullConditionalFieldName = `${formName}_${conditionalFieldName}`; + const $conditionalField = $field.closest('form').find(`#${fullConditionalFieldName}`); + + const toggleBasedOnConditional = function($field, $wrapper, $conditionalField, conditionalFieldRequiredValue) { + // Check if the conditional field has the value + const conditionalFieldValue = $conditionalField.val(); + let matches; + // Check if the conditionalFieldValue is an array, for multi selects + if (conditionalFieldValue instanceof Array) { + matches = conditionalFieldValue.indexOf(conditionalFieldRequiredValue) !== -1; + } else { + matches = conditionalFieldValue === conditionalFieldRequiredValue; + } + + if (matches) { + $wrapper.removeClass('d-none'); + $field.attr('disabled', false); + } else { + $wrapper.addClass('d-none'); + $field.attr('disabled', true); + } + }; + + toggleBasedOnConditional($field, $wrapper, $conditionalField, conditionalFieldRequiredValue); + $conditionalField.on('change', () => { + toggleBasedOnConditional($field, $wrapper, $conditionalField, conditionalFieldRequiredValue); + }); + }); }); initializeKeyboardShortcuts(); diff --git a/webapp/templates/jury/partials/team_category_form.html.twig b/webapp/templates/jury/partials/team_category_form.html.twig index b20e6dd7ea..29deb93220 100644 --- a/webapp/templates/jury/partials/team_category_form.html.twig +++ b/webapp/templates/jury/partials/team_category_form.html.twig @@ -1,12 +1,5 @@
- {{ form_start(form) }} - {{ form_row(form.externalid) }} - {{ form_row(form.icpcid) }} - {{ form_row(form.name) }} - {{ form_row(form.sortorder) }} - {{ form_row(form.color) }} - {{ form_row(form.visible) }} - {{ form_end(form) }} + {{ form(form) }}
diff --git a/webapp/templates/jury/partials/team_form.html.twig b/webapp/templates/jury/partials/team_form.html.twig index 81d3f9e508..fe29a7e3c8 100644 --- a/webapp/templates/jury/partials/team_form.html.twig +++ b/webapp/templates/jury/partials/team_form.html.twig @@ -6,7 +6,7 @@ {{ form_row(form.label) }} {{ form_row(form.name) }} {{ form_row(form.displayName) }} - {{ form_row(form.category) }} + {{ form_row(form.categories) }} {{ form_row(form.publicdescription) }} {{ form_row(form.affiliation) }} {{ form_row(form.penalty) }} diff --git a/webapp/templates/jury/team.html.twig b/webapp/templates/jury/team.html.twig index 8db6ef1f1b..8ced325188 100644 --- a/webapp/templates/jury/team.html.twig +++ b/webapp/templates/jury/team.html.twig @@ -110,14 +110,20 @@
- + diff --git a/webapp/templates/jury/team_category.html.twig b/webapp/templates/jury/team_category.html.twig index 22b2790610..614bd40ccb 100644 --- a/webapp/templates/jury/team_category.html.twig +++ b/webapp/templates/jury/team_category.html.twig @@ -35,15 +35,37 @@ - - + + + {% if teamCategory.sortorder is not null %} + + + + + {% endif %} {% if teamCategory.color %} {% endif %} + {% if teamCategory.cssClass %} + + + + + {% endif %} diff --git a/webapp/templates/jury/team_category_add.html.twig b/webapp/templates/jury/team_category_add.html.twig index 11159d9866..a5dd42c00d 100644 --- a/webapp/templates/jury/team_category_add.html.twig +++ b/webapp/templates/jury/team_category_add.html.twig @@ -6,6 +6,7 @@ {% block extrahead %} {{ parent() }} {{ macros.coloris_extrahead() }} + {{ macros.select2_extrahead() }} {% endblock %} {% block content %} diff --git a/webapp/templates/jury/team_category_edit.html.twig b/webapp/templates/jury/team_category_edit.html.twig index 6e5b839143..e4710aa578 100644 --- a/webapp/templates/jury/team_category_edit.html.twig +++ b/webapp/templates/jury/team_category_edit.html.twig @@ -6,6 +6,7 @@ {% block extrahead %} {{ parent() }} {{ macros.coloris_extrahead() }} + {{ macros.select2_extrahead() }} {% endblock %} {% block content %} diff --git a/webapp/templates/partials/scoreboard_table.html.twig b/webapp/templates/partials/scoreboard_table.html.twig index 7b8139be70..509531c302 100644 --- a/webapp/templates/partials/scoreboard_table.html.twig +++ b/webapp/templates/partials/scoreboard_table.html.twig @@ -14,7 +14,7 @@ {% set static = false %} {% endif %} {% set showPoints = scoreboard.showPoints %} -{% set usedCategories = scoreboard.usedCategories(limitToTeamIds) %} +{% set colorCategories = scoreboard.colorCategories(limitToTeamIds) %} {% set hasDifferentCategoryColors = scoreboard.categoryColors(limitToTeamIds) %} {% set scores = scoreboard.scores | filter(score => limitToTeams is null or score.team.teamid in limitToTeamIds) %} {% set problems = scoreboard.problems %} @@ -125,13 +125,13 @@ {% set medalCount = 0 %} {% for score in scores %} {% set classes = [] %} - {% if score.team.category.sortorder != previousSortOrder %} + {% if score.team.sortorder != previousSortOrder %} {% if previousSortOrder != -1 %} {# Output summary of previous sort order #} {% include 'partials/scoreboard_summary.html.twig' with {sortOrder: previousSortOrder} %} {% endif %} {% set classes = classes | merge(['sortorderswitch']) %} - {% set previousSortOrder = score.team.category.sortorder %} + {% set previousSortOrder = score.team.sortorder %} {% set previousTeam = null %} {% endif %} @@ -146,8 +146,11 @@ {% set classes = classes | merge(['scorethisisme']) %} {% set color = '#FFFF99' %} {% else %} - {% set color = score.team.category.color %} + {% set color = score.team.backgroundColorCategory.color|default(null) %} {% endif %} + {% for category in score.team.cssClassCategories %} + {% set classes = classes | merge([category.cssClass]) %} + {% endfor %} {% if enable_ranking %} {% if medalsEnabled %} @@ -231,11 +234,26 @@ {% endif %} - {% if usedCategories | length > 1 and scoreboard.bestInCategory(score.team, limitToTeamIds) %} - - {{ score.team.category.name }} - - {% endif %} + {% set hasBadges = score.team.badgeCategories | length > 0 %} + {% for category in score.team.topBadgeCategories %} + {% if scoreboard.bestInCategory(score.team, category, limitToTeamIds) %} + {% set hasBadges = true %} + {% endif %} + {% endfor %} + {% if hasBadges %}{% endif %} + {% for category in score.team.badgeCategories %} + + {{ category.name }} + + {% endfor %} + {% for category in score.team.topBadgeCategories %} + {% if scoreboard.bestInCategory(score.team, category, limitToTeamIds) %} + + {{ category.name }} + + {% endif %} + {% endfor %} + {% if hasBadges %}{% endif %} {{ score.team.effectiveName }} {% if showAffiliations %} @@ -401,9 +419,9 @@ {% set medalCount = 0 %} {% for score in scores %} {% set classes = [] %} - {% if score.team.category.sortorder != previousSortOrder %} + {% if score.team.sortorder != previousSortOrder %} {% set classes = classes | merge(['sortorderswitch']) %} - {% set previousSortOrder = score.team.category.sortorder %} + {% set previousSortOrder = score.team.sortorder %} {% set previousTeam = null %} {% endif %} @@ -418,8 +436,11 @@ {% set classes = classes | merge(['scorethisisme']) %} {% set color = '#FFFF99' %} {% else %} - {% set color = score.team.category.color %} + {% set color = score.team.backgroundColorCategory.color|default(null) %} {% endif %} + {% for category in score.team.cssClassCategories %} + {% set classes = classes | merge([category.cssClass]) %} + {% endfor %} - {% if false and usedCategories | length > 1 and scoreboard.bestInCategory(score.team, limitToTeamIds) %} - - {{ score.team.category.name }} - + {# TODO: how to display badges on mobile scorebaord #} + {% if false %} + {% set hasBadges = score.team.badgeCategories | length > 0 %} + {% for category in score.team.topBadgeCategories %} + {% if scoreboard.bestInCategory(score.team, category, limitToTeamIds) %} + {% set hasBadges = true %} + {% endif %} + {% endfor %} + {% if hasBadges %}{% endif %} + {% for category in score.team.badgeCategories %} + + {{ category.name }} + + {% endfor %} + {% for category in score.team.topBadgeCategories %} + {% if scoreboard.bestInCategory(score.team, category, limitToTeamIds) %} + + {{ category.name }} + + {% endif %} + {% endfor %} + {% if hasBadges %}{% endif %} {% endif %} {{ score.team.effectiveName }} @@ -567,7 +606,7 @@



{# only print legend when there's more than one category #} - {% if limitToTeamIds is null and usedCategories | length > 1 and hasDifferentCategoryColors %} + {% if limitToTeamIds is null and colorCategories | length > 0 and hasDifferentCategoryColors %}
CategoryCategories - {% if team.category %} - - {{ team.category.name }} - - {% else %} + {% if team.categories.empty %} - + {% else %} + {% endif %}
Sortorder{{ teamCategory.sortorder }}Types + {% if teamCategory.typeHumanNames %} +
    + {% for typeName in teamCategory.typeHumanNames %} +
  • {{ typeName }}
  • + {% endfor %} +
+ {% else %} + - + {% endif %} +
Sortorder{{ teamCategory.sortorder }}
Color {{ teamCategory.color }}
CSS Class{{ teamCategory.cssClass }}
Visible {{ teamCategory.visible | printYesNo }}
@@ -581,7 +620,7 @@ - {% for category in scoreboard.categories | filter(category => usedCategories[category.categoryid] is defined) %} + {% for category in colorCategories %} - - + + {% if team.publicdescription is not empty %} diff --git a/webapp/tests/Unit/Controller/API/TeamControllerTest.php b/webapp/tests/Unit/Controller/API/TeamControllerTest.php index 9fb775e9ba..ba036af6e4 100644 --- a/webapp/tests/Unit/Controller/API/TeamControllerTest.php +++ b/webapp/tests/Unit/Controller/API/TeamControllerTest.php @@ -3,6 +3,7 @@ namespace App\Tests\Unit\Controller\API; use App\DataFixtures\Test\AddLocationToTeamFixture; +use App\DataFixtures\Test\CreateTeamWithTwoTeamAffiliationsFixture; use App\Entity\Team; use Symfony\Component\HttpFoundation\BinaryFileResponse; use Symfony\Component\HttpFoundation\File\UploadedFile; @@ -25,9 +26,23 @@ class TeamControllerTest extends BaseTestCase 'photo' => null, 'location' => ['description' => 'Utrecht'], ], + 'teamwithtwogroups' => [ + 'group_ids' => ['participants', 'observers'], + 'affiliation' => 'Utrecht University', + 'nationality' => 'NLD', + 'id' => 'teamwithtwogroups', + 'icpc_id' => 'teamwithtwogroups', + 'name' => 'Team with two groups', + 'display_name' => null, + 'members' => null, + 'photo' => null, + ], ]; - protected static array $fixtures = [AddLocationToTeamFixture::class]; + protected static array $fixtures = [ + AddLocationToTeamFixture::class, + CreateTeamWithTwoTeamAffiliationsFixture::class, + ]; protected array $expectedAbsent = ['4242', 'nonexistent']; diff --git a/webapp/tests/Unit/Controller/Jury/TeamCategoryControllerTest.php b/webapp/tests/Unit/Controller/Jury/TeamCategoryControllerTest.php index b2478852c9..1719d6c238 100644 --- a/webapp/tests/Unit/Controller/Jury/TeamCategoryControllerTest.php +++ b/webapp/tests/Unit/Controller/Jury/TeamCategoryControllerTest.php @@ -19,6 +19,7 @@ class TeamCategoryControllerTest extends JuryControllerTestCase protected static string $addForm = 'team_category['; protected static array $addEntitiesShown = ['name', 'sortorder']; protected static array $addEntities = [['name' => 'New Category', + 'types' => [TeamCategory::TYPE_SCORING, TeamCategory::TYPE_BACKGROUND], 'sortorder' => '1', 'color' => '#123456', 'visible' => '1', @@ -30,11 +31,11 @@ class TeamCategoryControllerTest extends JuryControllerTestCase 'sortorder' => '0'], ['name' => 'Large', 'sortorder' => '128'], - ['name' => 'Colorless', - 'color' => ''], ['name' => 'FutureColor', + 'types' => [TeamCategory::TYPE_SCORING, TeamCategory::TYPE_BACKGROUND], 'color' => 'UnknownColor'], ['name' => 'NameColor', + 'types' => [TeamCategory::TYPE_SCORING, TeamCategory::TYPE_BACKGROUND], 'color' => 'yellow'], ['name' => 'Invisible', 'visible' => '0'], diff --git a/webapp/tests/Unit/Controller/Jury/TeamControllerTest.php b/webapp/tests/Unit/Controller/Jury/TeamControllerTest.php index 89d30a05fd..bdb972848e 100644 --- a/webapp/tests/Unit/Controller/Jury/TeamControllerTest.php +++ b/webapp/tests/Unit/Controller/Jury/TeamControllerTest.php @@ -2,7 +2,9 @@ namespace App\Tests\Unit\Controller\Jury; +use App\DataFixtures\Test\NonSortOrderTeamCategoryFixture; use App\Entity\Team; +use App\Entity\TeamCategory; use App\Entity\User; use Doctrine\ORM\EntityManagerInterface; @@ -26,7 +28,7 @@ class TeamControllerTest extends JuryControllerTestCase protected static array $addEntitiesCount = ['contests']; protected static array $addEntities = [['name' => 'New Team', 'displayName' => 'New Team Display Name', - 'category' => '3', + 'categories' => ['3'], 'publicdescription' => 'Some members', 'penalty' => '0', 'location' => 'The first room', @@ -37,7 +39,7 @@ class TeamControllerTest extends JuryControllerTestCase 'icpcid' => ''], ['name' => 'Another Team', 'displayName' => 'Another Team Display Name', - 'category' => '1', + 'categories' => ['1'], 'publicdescription' => 'More members', 'penalty' => '20', 'location' => 'Another room', @@ -47,7 +49,7 @@ class TeamControllerTest extends JuryControllerTestCase 'newUsername' => 'linkeduser'], ['name' => 'Team linked to existing user', 'displayName' => 'Third team display name', - 'category' => '1', + 'categories' => ['1'], 'publicdescription' => 'Members of this team', 'penalty' => '0', 'enabled' => '1', @@ -124,4 +126,17 @@ public function testAddWithoutUserThenEdit(): void static::assertNotNull($user); static::assertEquals('New Team', $user->getTeam()->getName()); } + + /** + * Test that adding a team with multiple categories works. + */ + public function testAddMultipleCategories(): void + { + $this->loadFixture(NonSortOrderTeamCategoryFixture::class); + $teamToAdd = static::$addEntities[0]; + $teamToAdd['categories'][] = $this->resolveReference(NonSortOrderTeamCategoryFixture::class . ':0', TeamCategory::class); + [$combinedValues, $element] = $this->helperProvideMergeAddEntity($teamToAdd); + [$combinedValues, $element] = $this->helperProvideTranslateAddEntity($combinedValues, $element); + $this->testCheckAddEntityAdmin($combinedValues, $element); + } } diff --git a/webapp/tests/Unit/Integration/QueuetaskIntegrationTest.php b/webapp/tests/Unit/Integration/QueuetaskIntegrationTest.php index e5c98344b4..f777c9e377 100644 --- a/webapp/tests/Unit/Integration/QueuetaskIntegrationTest.php +++ b/webapp/tests/Unit/Integration/QueuetaskIntegrationTest.php @@ -105,7 +105,7 @@ protected function setUp(): void $this->teams[$i] = new Team(); $this->teams[$i] ->setName(self::CONTEST_NAME . ' team ' . $i) - ->setCategory($category); + ->addCategory($category); $this->em->persist($this->teams[$i]); } $this->em->flush(); diff --git a/webapp/tests/Unit/Integration/ScoreboardIntegrationTest.php b/webapp/tests/Unit/Integration/ScoreboardIntegrationTest.php index 8ff63433d4..c26f6e840c 100644 --- a/webapp/tests/Unit/Integration/ScoreboardIntegrationTest.php +++ b/webapp/tests/Unit/Integration/ScoreboardIntegrationTest.php @@ -121,7 +121,7 @@ protected function setUp(): void $this->teams[$i] = new Team(); $this->teams[$i] ->setName(self::CONTEST_NAME.' team '.$i) - ->setCategory($category); + ->addCategory($category); $this->em->persist($this->teams[$i]); } diff --git a/webapp/tests/Unit/Service/AwardServiceTest.php b/webapp/tests/Unit/Service/AwardServiceTest.php index b4a33fd7e9..51c6737bf2 100644 --- a/webapp/tests/Unit/Service/AwardServiceTest.php +++ b/webapp/tests/Unit/Service/AwardServiceTest.php @@ -63,7 +63,7 @@ protected function setUp(): void $team = (new Team()) ->setName('Team ' . $teamLetter) ->setExternalid('team_' . $teamLetter) - ->setCategory($category) + ->addCategory($category) ->setAffiliation(); // No affiliation needed $reflectedProblem = new ReflectionClass(Team::class); $teamIdProperty = $reflectedProblem->getProperty('teamid'); diff --git a/webapp/tests/Unit/Service/ImportExportServiceTest.php b/webapp/tests/Unit/Service/ImportExportServiceTest.php index 004346df75..6be8f3706f 100644 --- a/webapp/tests/Unit/Service/ImportExportServiceTest.php +++ b/webapp/tests/Unit/Service/ImportExportServiceTest.php @@ -2,6 +2,7 @@ namespace App\Tests\Unit\Service; +use App\DataFixtures\Test\NonSortOrderTeamCategoryFixture; use App\DataFixtures\Test\TeamWithExternalIdEqualsOneFixture; use App\DataFixtures\Test\TeamWithExternalIdEqualsTwoFixture; use App\DataTransferObject\ResultRow; @@ -699,8 +700,8 @@ protected function testImportAccounts(int $importCount, ?string $message, bool $ self::assertEquals($data['team']['name'], $team->getName()); } if (isset($data['team']['category'])) { - self::assertNotNull($team->getCategory()); - self::assertEquals($data['team']['category'], $team->getCategory()->getName()); + self::assertNotFalse($team->getCategories()->first()); + self::assertEquals($data['team']['category'], $team->getCategories()->first()->getName()); } if (isset($data['team']['members'])) { self::assertEquals($data['team']['members'], $team->getPublicDescription()); @@ -795,7 +796,7 @@ public function testImportTeamsTsv(): void self::assertEquals($data['label'], $team->getLabel()); self::assertEquals($data['name'], $team->getName()); self::assertNull($team->getLocation()); - self::assertEquals($data['category']['externalid'], $team->getCategory()->getExternalid()); + self::assertEquals($data['category']['externalid'], $team->getCategories()->first()->getExternalid()); self::assertEquals($data['affiliation']['externalid'], $team->getAffiliation()->getExternalid()); self::assertEquals($data['affiliation']['shortname'], $team->getAffiliation()->getShortname()); self::assertEquals($data['affiliation']['name'], $team->getAffiliation()->getName()); @@ -805,6 +806,8 @@ public function testImportTeamsTsv(): void public function testImportTeamsJson(): void { + $this->loadFixture(NonSortOrderTeamCategoryFixture::class); + // Example from the manual, but we have changed the ID's to not mix them with fixtures and // we explicitly use a different label for the first team and no label for the second // Also we explicitly test for the label '0', since that is a special case @@ -827,9 +830,20 @@ public function testImportTeamsJson(): void "id": "13", "icpc_id": "123456", "label": "0", - "group_ids": ["26"], + "group_ids": ["24", "colorcat"], "name": "Team with label 0", "organization_id": "INST-44" +}, { + "id": "14", + "icpc_id": "112233", + "group_ids": [], + "name": "Team with empty groups", + "organization_id": "INST-45" +}, { + "id": "15", + "icpc_id": "445566", + "name": "Team with no groups", + "organization_id": "INST-46" }] EOF; @@ -840,36 +854,40 @@ public function testImportTeamsJson(): void 'label' => 'team1', 'name' => '¡i¡i¡', 'location' => 'AUD 10', - 'category' => [ - 'externalid' => '24', - ], - 'affiliation' => [ - 'externalid' => 'INST-42', - ], + 'categories' => ['24'], + 'affiliation' => 'INST-42', ], [ 'externalid' => '12', 'icpcid' => '447837', 'label' => null, 'name' => 'Pleading not FAUlty', 'location' => null, - 'category' => [ - 'externalid' => '25', - ], - 'affiliation' => [ - 'externalid' => 'INST-43', - ], + 'categories' => ['25'], + 'affiliation' => 'INST-43', ], [ 'externalid' => '13', 'icpcid' => '123456', 'label' => '0', 'name' => 'Team with label 0', 'location' => null, - 'category' => [ - 'externalid' => '26', - ], - 'affiliation' => [ - 'externalid' => 'INST-44', - ], + 'categories' => ['24', 'colorcat'], + 'affiliation' => 'INST-44', + ], [ + 'externalid' => '14', + 'icpcid' => '112233', + 'label' => null, + 'name' => 'Team with empty groups', + 'location' => null, + 'categories' => [], + 'affiliation' => 'INST-45', + ], [ + 'externalid' => '15', + 'icpcid' => '445566', + 'label' => null, + 'name' => 'Team with no groups', + 'location' => null, + 'categories' => [], + 'affiliation' => 'INST-46', ], ]; @@ -895,8 +913,9 @@ public function testImportTeamsJson(): void self::assertEquals($data['label'], $team->getLabel()); self::assertEquals($data['location'], $team->getLocation()); self::assertEquals($data['name'], $team->getName()); - self::assertEquals($data['category']['externalid'], $team->getCategory()->getExternalid()); - self::assertEquals($data['affiliation']['externalid'], $team->getAffiliation()->getExternalid()); + $categoryIds = $team->getCategories()->map(fn(TeamCategory $category) => $category->getExternalid())->toArray(); + self::assertEqualsCanonicalizing($data['categories'], $categoryIds); + self::assertEquals($data['affiliation'], $team->getAffiliation()->getExternalid()); } } @@ -1039,6 +1058,16 @@ public function testImportGroupsJson(): void }, { "id": "23", "name": "Spectators" +}, { + "id": "24", + "name": "Color", + "types": ["background"], + "color": "#123123" +}, { + "id": "25", + "name": "CSS", + "types": ["css-class"], + "css_class": "test" }] EOF; @@ -1047,15 +1076,32 @@ public function testImportGroupsJson(): void 'externalid' => '13337', 'name' => 'Companies', 'icpcid' => '123', + 'sortorder' => 0, 'visible' => false, ], [ 'externalid' => '47', 'name' => 'Participants', + 'sortorder' => 0, 'visible' => true, ], [ 'externalid' => '23', 'name' => 'Spectators', + 'sortorder' => 0, + 'visible' => true, + ], [ + 'externalid' => '24', + 'name' => 'Color', + 'types' => [TeamCategory::TYPE_BACKGROUND], + 'sortorder' => null, + 'visible' => true, + 'color' => '#123123', + ], [ + 'externalid' => '25', + 'name' => 'CSS', + 'types' => [TeamCategory::TYPE_CSS_CLASS], + 'sortorder' => null, 'visible' => true, + 'cssClass' => 'test', ], ]; @@ -1079,7 +1125,11 @@ public function testImportGroupsJson(): void self::assertNotNull($category, "Team cagegory $data[name] does not exist"); self::assertEquals($data['icpcid'] ?? null, $category->getIcpcId()); self::assertEquals($data['name'], $category->getName()); + self::assertEquals($data['types'] ?? [TeamCategory::TYPE_SCORING, TeamCategory::TYPE_BADGE_TOP], $category->getTypes()); self::assertEquals($data['visible'], $category->getVisible()); + self::assertEquals($data['sortorder'] ?? null, $category->getSortorder()); + self::assertEquals($data['color'] ?? null, $category->getColor()); + self::assertEquals($data['cssClass'] ?? null, $category->getCssClass()); } } @@ -1277,7 +1327,7 @@ public function testGetResultsData(bool $full, bool $honors, string $dataSet, st ->setIcpcid($teamData['icpc_id']) ->setName($teamData['name']) ->setDisplayName($teamData['display_name']) - ->setCategory($groupsById[$teamData['group_ids'][0]]); + ->addCategory($groupsById[$teamData['group_ids'][0]]); $em->persist($team); $em->flush(); $teamsById[$team->getExternalid()] = $team;
{% set link = null %} diff --git a/webapp/templates/partials/team.html.twig b/webapp/templates/partials/team.html.twig index 6bb371d8a0..8e6620aa92 100644 --- a/webapp/templates/partials/team.html.twig +++ b/webapp/templates/partials/team.html.twig @@ -12,8 +12,18 @@ {{ team.effectiveName }}
Category{{ team.category.name }}Categories + {% if team.categories.empty %} + - + {% else %} +
    + {% for category in team.categories %} +
  • {{ category.name }}
  • + {% endfor %} +
+ {% endif %} +