From 901e21bad54d6d6dfef72ef99266cbb47f6728a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Sat, 16 Nov 2024 09:55:46 +0100 Subject: [PATCH 1/5] Add support for /awards page --- .castor/database.php | 15 + .castor/docker.php | 10 +- .castor/qa.php | 19 +- phpstan-baseline.neon | 74 +- phpstan.neon | 4 + src/Command/QotdRunCommand.php | 10 +- src/Controller/AwardsController.php | 24 + .../Slack/Command/QotdController.php | 4 +- src/Entity/Qotd.php | 4 +- src/EventSubscriber/SignatureSubscriber.php | 4 +- src/Repository/QotdRepository.php | 119 +- src/Twig/Extension/AppExtension.php | 2 +- templates/awards/index.html.twig | 65 ++ templates/base.html.twig | 5 +- templates/random/index.html.twig | 2 +- tests/Controller/AwardsControllerTest.php | 29 + tests/Controller/QotdControllerTest.php | 2 +- tests/Controller/RandomControllerTest.php | 29 + tools/php-cs-fixer/composer.lock | 1029 ++++++++++++++--- tools/phpstan/composer.lock | 46 +- 20 files changed, 1203 insertions(+), 293 deletions(-) create mode 100644 .castor/database.php create mode 100644 src/Controller/AwardsController.php create mode 100644 templates/awards/index.html.twig create mode 100644 tests/Controller/AwardsControllerTest.php create mode 100644 tests/Controller/RandomControllerTest.php diff --git a/.castor/database.php b/.castor/database.php new file mode 100644 index 0000000..eba7c1a --- /dev/null +++ b/.castor/database.php @@ -0,0 +1,15 @@ +title('Connecting to the PostgreSQL database'); + + docker_compose(['exec', 'postgres', 'psql', '-U', 'app', 'app'], context()->toInteractive()); +} diff --git a/.castor/docker.php b/.castor/docker.php index c28e0d9..67c6500 100644 --- a/.castor/docker.php +++ b/.castor/docker.php @@ -39,7 +39,7 @@ function about(): void try { $routers = http_client() - ->request('GET', sprintf('http://%s:8080/api/http/routers', variable('root_domain'))) + ->request('GET', \sprintf('http://%s:8080/api/http/routers', variable('root_domain'))) ->toArray() ; $projectName = variable('project_name'); @@ -50,7 +50,7 @@ function about(): void if ("frontend-{$projectName}" === $router['service']) { continue; } - if (!preg_match('{^Host\\(`(?P.*)`\\)$}', $router['rule'], $matches)) { + if (!preg_match('{^Host\(`(?P.*)`\)$}', $router['rule'], $matches)) { continue; } $hosts = explode('`) || Host(`', $matches['hosts']); @@ -342,19 +342,19 @@ function push(): void } } - $content = sprintf(<<<'EOHCL' + $content = \sprintf(<<<'EOHCL' group "default" { targets = [%s] } EOHCL - , implode(', ', array_map(fn ($target) => sprintf('"%s"', $target['target']), $targets))); + , implode(', ', array_map(fn ($target) => \sprintf('"%s"', $target['target']), $targets))); foreach ($targets as $target) { $reference = str_replace('${REGISTRY:-}', $registry, $target['reference'] ?? ''); - $content .= sprintf(<<<'EOHCL' + $content .= \sprintf(<<<'EOHCL' target "%s" { context = "infrastructure/docker/%s" dockerfile = "%s" diff --git a/.castor/qa.php b/.castor/qa.php index 56b6e11..261466e 100644 --- a/.castor/qa.php +++ b/.castor/qa.php @@ -29,6 +29,15 @@ function install(): void docker_compose_run('composer install -o', workDir: '/var/www/tools/phpstan'); } +#[AsTask(description: 'Updates tooling')] +function update(): void +{ + io()->title('Updating QA tooling'); + + docker_compose_run('composer update -o', workDir: '/var/www/tools/php-cs-fixer'); + docker_compose_run('composer update -o', workDir: '/var/www/tools/phpstan'); +} + #[AsTask(description: 'Runs PHPUnit', aliases: ['phpunit'])] function phpunit(): int { @@ -39,21 +48,17 @@ function phpunit(): int function phpstan(bool $generateBaseline = false): int { if (!is_dir(variable('root_dir') . '/tools/phpstan/vendor')) { - io()->error('PHPStan is not installed. Run `castor qa:install` first.'); - - return 1; + install(); } - return docker_exit_code('phpstan' . ($generateBaseline ? ' -b' : ''), workDir: '/var/www'); + return docker_exit_code('phpstan -v' . ($generateBaseline ? ' -b' : ''), workDir: '/var/www'); } #[AsTask(description: 'Fixes Coding Style', aliases: ['cs'])] function cs(bool $dryRun = false): int { if (!is_dir(variable('root_dir') . '/tools/php-cs-fixer/vendor')) { - io()->error('PHP-CS-Fixer is not installed. Run `castor qa:install` first.'); - - return 1; + install(); } if ($dryRun) { diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 22fac7c..1e6e88b 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1,81 +1,11 @@ parameters: ignoreErrors: - - message: "#^Method App\\\\Command\\\\QotdRunCommand\\:\\:canUseMessage\\(\\) has parameter \\$message with no value type specified in iterable type array\\.$#" - count: 1 - path: src/Command/QotdRunCommand.php - - - - message: "#^Method App\\\\Entity\\\\Qotd\\:\\:getImageUrls\\(\\) return type has no value type specified in iterable type array\\.$#" - count: 1 - path: src/Entity/Qotd.php - - - - message: "#^Method App\\\\Entity\\\\Qotd\\:\\:getVideoUrls\\(\\) return type has no value type specified in iterable type array\\.$#" - count: 1 - path: src/Entity/Qotd.php - - - - message: "#^Method App\\\\Paginator\\\\Mode\\\\NativeQuery\\:\\:__construct\\(\\) has parameter \\$parameters with no value type specified in iterable type array\\.$#" - count: 1 - path: src/Paginator/Mode/NativeQuery.php - - - - message: "#^Method App\\\\Repository\\\\QotdRepository\\:\\:countBiggestVotingUsers\\(\\) return type has no value type specified in iterable type array\\.$#" + message: "#^Method App\\\\Repository\\\\QotdRepository\\:\\:findForHomepage\\(\\) should return Knp\\\\Component\\\\Pager\\\\Pagination\\\\PaginationInterface\\ but returns Knp\\\\Component\\\\Pager\\\\Pagination\\\\PaginationInterface\\\\.$#" count: 1 path: src/Repository/QotdRepository.php - - message: "#^Method App\\\\Repository\\\\QotdRepository\\:\\:countMostQuotedUsers\\(\\) return type has no value type specified in iterable type array\\.$#" + message: "#^Method App\\\\Repository\\\\QotdRepository\\:\\:findForHomepageNotVoted\\(\\) should return Knp\\\\Component\\\\Pager\\\\Pagination\\\\PaginationInterface\\ but returns Knp\\\\Component\\\\Pager\\\\Pagination\\\\PaginationInterface\\\\.$#" count: 1 path: src/Repository/QotdRepository.php - - - - message: "#^Method App\\\\Repository\\\\QotdRepository\\:\\:countMostUpVotedUsers\\(\\) return type has no value type specified in iterable type array\\.$#" - count: 1 - path: src/Repository/QotdRepository.php - - - - message: "#^Method App\\\\Repository\\\\QotdRepository\\:\\:countOver\\(\\) return type has no value type specified in iterable type array\\.$#" - count: 1 - path: src/Repository/QotdRepository.php - - - - message: "#^Method App\\\\Repository\\\\QotdRepository\\:\\:findBestsOver\\(\\) return type has no value type specified in iterable type array\\.$#" - count: 1 - path: src/Repository/QotdRepository.php - - - - message: "#^Method App\\\\Repository\\\\QotdRepository\\:\\:getAuthors\\(\\) return type has no value type specified in iterable type array\\.$#" - count: 1 - path: src/Repository/QotdRepository.php - - - - message: "#^Method App\\\\Repository\\\\QotdRepository\\:\\:search\\(\\) return type has no value type specified in iterable type array\\.$#" - count: 1 - path: src/Repository/QotdRepository.php - - - - message: "#^Method App\\\\Stats\\\\ChartBuilder\\:\\:getColors\\(\\) has parameter \\$counts with no value type specified in iterable type array\\.$#" - count: 1 - path: src/Stats/ChartBuilder.php - - - - message: "#^Method App\\\\Stats\\\\ChartBuilder\\:\\:getColors\\(\\) return type has no value type specified in iterable type array\\.$#" - count: 1 - path: src/Stats/ChartBuilder.php - - - - message: "#^Call to an undefined method App\\\\Tests\\\\Controller\\\\QotdControllerTest\\:\\:assertSame\\(\\)\\.$#" - count: 1 - path: tests/Controller/QotdControllerTest.php - - - - message: "#^Call to an undefined static method App\\\\Tests\\\\Controller\\\\QotdControllerTest\\:\\:assertCount\\(\\)\\.$#" - count: 4 - path: tests/Controller/QotdControllerTest.php - - - - message: "#^Call to an undefined static method App\\\\Tests\\\\Controller\\\\QotdControllerTest\\:\\:assertSame\\(\\)\\.$#" - count: 6 - path: tests/Controller/QotdControllerTest.php diff --git a/phpstan.neon b/phpstan.neon index 59fd009..7f5658d 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -15,6 +15,10 @@ parameters: tmpDir: tools/phpstan/var inferPrivatePropertyTypeFromConstructor: true + ignoreErrors: + - + identifier: missingType.iterableValue + # symfony: # container_xml_path: 'var/cache/dev/App_KernelDevDebugContainer.xml' diff --git a/src/Command/QotdRunCommand.php b/src/Command/QotdRunCommand.php index 5d1774f..f940e32 100644 --- a/src/Command/QotdRunCommand.php +++ b/src/Command/QotdRunCommand.php @@ -67,12 +67,12 @@ protected function execute(InputInterface $input, OutputInterface $output): int $lowerDate = $date->setTime(0, 0, 0); $upperDate = $date->setTime(23, 59, 59); - $io->comment(sprintf('Looking between %s and %s', $lowerDate->format('Y-m-d H:i:s'), $upperDate->format('Y-m-d H:i:s'))); + $io->comment(\sprintf('Looking between %s and %s', $lowerDate->format('Y-m-d H:i:s'), $upperDate->format('Y-m-d H:i:s'))); if (!$dryRun && !$input->getOption('force')) { $qotd = $this->qotdRepository->findOneBy(['date' => $date]); if ($qotd) { - $io->error(sprintf('Qotd for %s already exists', $date->format('Y-m-d'))); + $io->error(\sprintf('Qotd for %s already exists', $date->format('Y-m-d'))); return Command::FAILURE; } @@ -149,8 +149,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int 'auth_bearer' => $this->slackBotToken, ]); - $mediaSuffix = sprintf('%s---%s', uuid_create(), $this->slugger->slug($file['name'])); - $mediaPath = sprintf('%s/%s---%s', $this->uploadDirectory, $qotd->id, $mediaSuffix); + $mediaSuffix = \sprintf('%s---%s', uuid_create(), $this->slugger->slug($file['name'])); + $mediaPath = \sprintf('%s/%s---%s', $this->uploadDirectory, $qotd->id, $mediaSuffix); $this->fs->dumpFile($mediaPath, $response->getContent()); @@ -166,7 +166,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->botClient->request('POST', 'chat.postMessage', [ 'json' => [ 'channel' => $this->channelIdForSummary, - 'text' => sprintf( + 'text' => \sprintf( "%s's QOTD was: %s\nYou can vote for it on %s", ucfirst($input->getArgument('date')), $bestMessage['permalink'], diff --git a/src/Controller/AwardsController.php b/src/Controller/AwardsController.php new file mode 100644 index 0000000..0ede01b --- /dev/null +++ b/src/Controller/AwardsController.php @@ -0,0 +1,24 @@ +render('awards/index.html.twig', [ + 'awards' => $this->qotdRepository->computeAwards(), + ]); + } +} diff --git a/src/Controller/Slack/Command/QotdController.php b/src/Controller/Slack/Command/QotdController.php index c4f2a62..e9ef032 100644 --- a/src/Controller/Slack/Command/QotdController.php +++ b/src/Controller/Slack/Command/QotdController.php @@ -27,9 +27,9 @@ public function __invoke(Request $request): Response $qotd = $this->qotdRepository->findOneBy(['date' => $date]); if (!$qotd) { - return new Response(sprintf('There was not QOTD on %s.', $date->format('Y-m-d'))); + return new Response(\sprintf('There was not QOTD on %s.', $date->format('Y-m-d'))); } - return new Response(sprintf('%s\'s QOTD was: %s', $qotd->date->format('Y-m-d'), $qotd->permalink), 200); + return new Response(\sprintf('%s\'s QOTD was: %s', $qotd->date->format('Y-m-d'), $qotd->permalink), 200); } } diff --git a/src/Entity/Qotd.php b/src/Entity/Qotd.php index 692afa5..b578ec5 100644 --- a/src/Entity/Qotd.php +++ b/src/Entity/Qotd.php @@ -99,13 +99,13 @@ public function getVote(UserInterface $user): QotdVote #[Groups(['qotd:read'])] public function getImageUrls(): array { - return array_map(fn (string $image) => sprintf('uploads/%s---%s', $this->id, $image), $this->images); + return array_map(fn (string $image) => \sprintf('uploads/%s---%s', $this->id, $image), $this->images); } #[Groups(['qotd:read'])] public function getVideoUrls(): array { - return array_map(fn (string $video) => sprintf('uploads/%s---%s', $this->id, $video), $this->videos); + return array_map(fn (string $video) => \sprintf('uploads/%s---%s', $this->id, $video), $this->videos); } #[ORM\PreUpdate] diff --git a/src/EventSubscriber/SignatureSubscriber.php b/src/EventSubscriber/SignatureSubscriber.php index 03bb75a..beb44d2 100644 --- a/src/EventSubscriber/SignatureSubscriber.php +++ b/src/EventSubscriber/SignatureSubscriber.php @@ -35,9 +35,9 @@ public function verifySignature(RequestEvent $event): void $signature = $request->headers->get('X-Slack-Signature'); $timestamp = $request->headers->get('X-Slack-Request-Timestamp'); - $payload = sprintf('v0:%s:%s', $timestamp, $body); + $payload = \sprintf('v0:%s:%s', $timestamp, $body); - $signatureTmp = sprintf('v0=%s', hash_hmac('sha256', $payload, $this->signinSecret)); + $signatureTmp = \sprintf('v0=%s', hash_hmac('sha256', $payload, $this->signinSecret)); if ($signatureTmp !== $signature) { $event->setResponse(new Response('You are not slack', 401)); diff --git a/src/Repository/QotdRepository.php b/src/Repository/QotdRepository.php index 9ed34c2..30e1bad 100644 --- a/src/Repository/QotdRepository.php +++ b/src/Repository/QotdRepository.php @@ -95,7 +95,7 @@ public function findForHomepage(int $page, QotdDirection $direction, ?QotdFilter */ public function findForHomepageNotVoted(int $page, UserInterface $user): PaginationInterface { - $rsm = new ResultSetMappingBuilder($this->_em); + $rsm = new ResultSetMappingBuilder($this->getEntityManager()); $rsm->addRootEntityFromClassMetadata(Qotd::class, 'q'); $select = $rsm->generateSelectClause(); @@ -122,7 +122,7 @@ public function search(string $query): array return []; } - $rsm = new ResultSetMappingBuilder($this->_em); + $rsm = new ResultSetMappingBuilder($this->getEntityManager()); $rsm->addRootEntityFromClassMetadata(Qotd::class, 'q'); $select = $rsm->generateSelectClause(); @@ -136,7 +136,7 @@ public function search(string $query): array EOSQL; $results = $this - ->_em + ->getEntityManager() ->createNativeQuery($sql, $rsm) ->execute([ 'query' => $query, @@ -156,7 +156,7 @@ public function search(string $query): array EOSQL; return $this - ->_em + ->getEntityManager() ->createNativeQuery($sql, $rsm) ->execute([ 'query' => $query, @@ -190,12 +190,12 @@ public function countMostQuotedUsers(): array ORDER BY count DESC, username ASC EOSQL; - $rsm = new ResultSetMappingBuilder($this->_em); + $rsm = new ResultSetMappingBuilder($this->getEntityManager()); $rsm->addScalarResult('username', 'username', 'string'); $rsm->addScalarResult('count', 'count', 'integer'); return $this - ->_em + ->getEntityManager() ->createNativeQuery($sql, $rsm) ->getResult() ; @@ -226,12 +226,12 @@ public function countMostUpVotedUsers(): array ORDER BY vote DESC, username ASC EOSQL; - $rsm = new ResultSetMappingBuilder($this->_em); + $rsm = new ResultSetMappingBuilder($this->getEntityManager()); $rsm->addScalarResult('username', 'username', 'string'); $rsm->addScalarResult('vote', 'vote', 'integer'); return $this - ->_em + ->getEntityManager() ->createNativeQuery($sql, $rsm) ->getResult() ; @@ -265,12 +265,12 @@ public function countBiggestVotingUsers(): array ORDER BY vote DESC, voter_username ASC EOSQL; - $rsm = new ResultSetMappingBuilder($this->_em); + $rsm = new ResultSetMappingBuilder($this->getEntityManager()); $rsm->addScalarResult('voter_username', 'username', 'string'); $rsm->addScalarResult('vote', 'vote', 'integer'); return $this - ->_em + ->getEntityManager() ->createNativeQuery($sql, $rsm) ->getResult() ; @@ -286,13 +286,13 @@ public function countOver(string $period): array limit 100 EOSQL; - $rsm = new ResultSetMappingBuilder($this->_em); + $rsm = new ResultSetMappingBuilder($this->getEntityManager()); $rsm->addScalarResult('period', 'period', 'datetime_immutable'); $rsm->addScalarResult('count', 'count', 'integer'); $rsm->addScalarResult('vote', 'vote', 'integer'); return $this - ->_em + ->getEntityManager() ->createNativeQuery($sql, $rsm) ->setParameters([ 'period' => $period, @@ -301,15 +301,95 @@ public function countOver(string $period): array ; } + public function computeAwards(): array + { + $rsm = new ResultSetMappingBuilder($this->getEntityManager()); + $rsm->addScalarResult('rank', 'rank', 'integer'); + $rsm->addScalarResult('username', 'username', 'string'); + $rsm->addScalarResult('score', 'score', 'integer'); + $rsm->addScalarResult('awards', 'awards', 'json'); + + $sql = <<<'SQL' + WITH + raw_periods AS ( + select column1 as period, column2 as factor from (values + ('day', 1 ^ 2), + ('week', 2 ^ 2), + ('month', 3 ^ 2), + ('year', 4 ^ 2) + ) as x + ), + date_boundary AS ( + SELECT + min(date_trunc(p.period, date)) AS startp, + max(date_trunc(p.period, date)) AS endp, + p.* + FROM qotd + FULL OUTER JOIN raw_periods p on true + GROUP BY p.period, p.factor + ), + periods AS ( + SELECT + generate_series(startp, endp, ('1 ' || period)::interval) AS start_of_period, + period, + factor + FROM date_boundary + ), + qotd AS ( + SELECT + p.*, + rank() OVER w AS rank, + q.* + FROM periods p + INNER JOIN qotd q on date_trunc(p.period, q.date) = p.start_of_period + WINDOW w AS ( + PARTITION BY p.period, p.start_of_period + ORDER BY q.vote DESC, q.date DESC + ) + ), + aggregation AS ( + SELECT + period, + username, + factor, + count(1) as count + FROM qotd + WHERE rank = 1 + GROUP BY username, period, factor + order by username asc, factor desc + ), + final AS ( + SELECT + rank() over (order by sum(count * factor) desc, username asc) as rank, + username, + sum(count * factor) as score, + json_object_agg( + period, count + ) as awards + FROM aggregation + GROUP BY username + ) + SELECT * + FROM final + ORDER BY rank ASC + SQL; + + return $this + ->getEntityManager() + ->createNativeQuery($sql, $rsm) + ->getResult() + ; + } + public function findBestsOver(string $period): array { - $rsm = new ResultSetMappingBuilder($this->_em); + $rsm = new ResultSetMappingBuilder($this->getEntityManager()); $rsm->addRootEntityFromClassMetadata(Qotd::class, 'q'); $rsm->addScalarResult('start_of_period', 'start_of_period', 'datetime_immutable'); $select = $rsm->generateSelectClause(); - $sql = <<_em + ->getEntityManager() ->createNativeQuery($sql, $rsm) ->setParameters([ 'period' => $period, diff --git a/src/Twig/Extension/AppExtension.php b/src/Twig/Extension/AppExtension.php index 7736dd4..f97fd77 100644 --- a/src/Twig/Extension/AppExtension.php +++ b/src/Twig/Extension/AppExtension.php @@ -50,7 +50,7 @@ public function whenTest(string $name): string return ''; } - return sprintf('data-test=%s', $name); + return \sprintf('data-test=%s', $name); } public function getTurboFrame(): ?string diff --git a/templates/awards/index.html.twig b/templates/awards/index.html.twig new file mode 100644 index 0000000..97e2b7c --- /dev/null +++ b/templates/awards/index.html.twig @@ -0,0 +1,65 @@ +{% extends 'base.html.twig' %} + +{% set active = 'awards' %} + +{% block title %}Awards{% endblock %} + +{% set period_to_badges = { + 'year': 'danger', + 'month': 'warning', + 'week': 'info', + 'day': 'primary', +} %} + +{% set rank_to_emoji = { + 1: '🥇', + 2: '🥈', + 3: '🥉', +} %} + +{% block body %} +

Awards

+ + + + + + + + + + + + + + + + {% for award in awards %} + + + + + + {% for period, badge in period_to_badges %} + + + {% endfor %} + + {% else %} + + + + {% endfor %} + +
RanksUsernameScoreYearMonthWeekDay
+ {{ attribute(rank_to_emoji, award.rank) ?? '' }} + + #{{ award.rank }} + {{ award.username }}{{ award.score }} + {% if attribute(award.awards, period) ?? false %} + + {{ attribute(award.awards, period) }} + + {% endif %} +
No awards found.
+{% endblock %} diff --git a/templates/base.html.twig b/templates/base.html.twig index 6bff55c..fda94f2 100644 --- a/templates/base.html.twig +++ b/templates/base.html.twig @@ -42,11 +42,14 @@ +