diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml index 500999d..077b6dc 100644 --- a/.github/workflows/docker.yaml +++ b/.github/workflows/docker.yaml @@ -52,6 +52,7 @@ jobs: labels: ${{ steps.meta.outputs.labels }} - name: Conditional push for main branch + id: push if: startsWith(github.ref, 'refs/heads/master') || startsWith(github.ref, 'refs/heads/distro/') || startsWith(github.ref, 'refs/tags/v') uses: docker/build-push-action@master with: @@ -102,6 +103,7 @@ jobs: labels: ${{ steps.meta.outputs.labels }} - name: Conditional push for main branch + id: push if: startsWith(github.ref, 'refs/heads/master') || startsWith(github.ref, 'refs/heads/distro/') || startsWith(github.ref, 'refs/tags/v') uses: docker/build-push-action@master with: diff --git a/.idea/php.xml b/.idea/php.xml index 56839c4..71f5d1b 100644 --- a/.idea/php.xml +++ b/.idea/php.xml @@ -222,6 +222,7 @@ + diff --git a/composer.json b/composer.json index 2c5f0ba..af8e5d4 100644 --- a/composer.json +++ b/composer.json @@ -98,8 +98,7 @@ "symfony/polyfill-php80": "*", "symfony/polyfill-php81": "*", "symfony/polyfill-php82": "*", - "symfony/polyfill-php83": "*", - "symfony/polyfill-php84": "*" + "symfony/polyfill-php83": "*" }, "scripts": { "lint": [ diff --git a/composer.lock b/composer.lock index 0998723..cdf6212 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "144a7186cc53f1b1d649dd70a9072b03", + "content-hash": "410a4fbdac52136dd4a56f4be4ccb880", "packages": [ { "name": "async-aws/core", @@ -1522,12 +1522,12 @@ "source": { "type": "git", "url": "https://github.com/EasyCorp/EasyAdminBundle.git", - "reference": "8d6b02d39da311f6ca1e56a2ec5328062bcba891" + "reference": "ecf581d1a124f1ab97f1f4dd10c7a4bc633a67e4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/EasyCorp/EasyAdminBundle/zipball/8d6b02d39da311f6ca1e56a2ec5328062bcba891", - "reference": "8d6b02d39da311f6ca1e56a2ec5328062bcba891", + "url": "https://api.github.com/repos/EasyCorp/EasyAdminBundle/zipball/ecf581d1a124f1ab97f1f4dd10c7a4bc633a67e4", + "reference": "ecf581d1a124f1ab97f1f4dd10c7a4bc633a67e4", "shasum": "" }, "require": { @@ -1613,7 +1613,7 @@ "type": "github" } ], - "time": "2024-12-07T16:15:10+00:00" + "time": "2024-12-09T19:05:00+00:00" }, { "name": "egulias/email-validator", @@ -6936,6 +6936,83 @@ ], "time": "2024-09-10T14:38:51+00:00" }, + { + "name": "symfony/polyfill-php84", + "version": "1.x-dev", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php84.git", + "reference": "48e55b8ecb3a52432be17bcac66eaaa3c3336f68" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/48e55b8ecb3a52432be17bcac66eaaa3c3336f68", + "reference": "48e55b8ecb3a52432be17bcac66eaaa3c3336f68", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "default-branch": true, + "type": "library", + "extra": { + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php84\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php84/tree/1.x" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-10T14:38:51+00:00" + }, { "name": "symfony/polyfill-uuid", "version": "1.x-dev", @@ -8048,7 +8125,7 @@ "symfony-ux" ], "support": { - "source": "https://github.com/symfony/stimulus-bundle/tree/2.x" + "source": "https://github.com/symfony/stimulus-bundle/tree/v2.22.1" }, "funding": [ { @@ -8800,7 +8877,7 @@ "symfony-ux" ], "support": { - "source": "https://github.com/symfony/ux-chartjs/tree/2.x" + "source": "https://github.com/symfony/ux-chartjs/tree/v2.22.1" }, "funding": [ { @@ -8895,7 +8972,7 @@ "twig" ], "support": { - "source": "https://github.com/symfony/ux-live-component/tree/2.x" + "source": "https://github.com/symfony/ux-live-component/tree/v2.22.1" }, "funding": [ { @@ -8994,7 +9071,7 @@ "turbo-stream" ], "support": { - "source": "https://github.com/symfony/ux-turbo/tree/2.x" + "source": "https://github.com/symfony/ux-turbo/tree/v2.22.1" }, "funding": [ { @@ -9078,7 +9155,7 @@ "twig" ], "support": { - "source": "https://github.com/symfony/ux-twig-component/tree/2.x" + "source": "https://github.com/symfony/ux-twig-component/tree/v2.22.1" }, "funding": [ { @@ -10297,12 +10374,12 @@ "source": { "type": "git", "url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git", - "reference": "0fa6fe639a961a0765d4bc8fb341731cf1c4e03f" + "reference": "368c69911508013afeeac541325c5e8f5c72d0a7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/0fa6fe639a961a0765d4bc8fb341731cf1c4e03f", - "reference": "0fa6fe639a961a0765d4bc8fb341731cf1c4e03f", + "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/368c69911508013afeeac541325c5e8f5c72d0a7", + "reference": "368c69911508013afeeac541325c5e8f5c72d0a7", "shasum": "" }, "require": { @@ -10393,7 +10470,7 @@ "type": "github" } ], - "time": "2024-12-04T01:12:42+00:00" + "time": "2024-12-09T23:12:05+00:00" }, { "name": "masterminds/html5", diff --git a/src/Command/CompletedQuestionStatCommand.php b/src/Command/CompletedQuestionStatCommand.php deleted file mode 100644 index 06599d5..0000000 --- a/src/Command/CompletedQuestionStatCommand.php +++ /dev/null @@ -1,65 +0,0 @@ -setHeaderTitle('Solved questions'); - $table->setHeaders(['Email', 'Passed', 'Total', 'Percent']); - - $totalQuestions = $this->questionRepository->count(); - if (0 === $totalQuestions) { - $io->error('No questions found.'); - - return Command::FAILURE; - } - - /** - * @var list}> $solvedQuestions - */ - $solvedQuestions = $this->solutionEventRepository->createQueryBuilder('se') - ->select('u.email', 'COUNT(DISTINCT q) as solved_questions') - ->join('se.question', 'q') - ->join('se.submitter', 'u') - ->where('se.status = :status') - ->groupBy('u.email') - ->orderBy('solved_questions', 'DESC') - ->setParameter('status', SolutionEventStatus::Passed) - ->getQuery() - ->getResult(); - - foreach ($solvedQuestions as $row) { - $solvedQuestions = $row['solved_questions']; - $table->addRow([$row['email'], $solvedQuestions, $totalQuestions, round($solvedQuestions / $totalQuestions * 100, 2).'%']); - } - - $table->render(); - - return Command::SUCCESS; - } -} diff --git a/src/Command/LastLoginStatCommand.php b/src/Command/LastLoginStatCommand.php deleted file mode 100644 index d946b77..0000000 --- a/src/Command/LastLoginStatCommand.php +++ /dev/null @@ -1,79 +0,0 @@ -addOption( - 'moreThan', - 'm', - InputOption::VALUE_OPTIONAL, - 'Only show users who have not logged in for more than this number of days', - ); - } - - protected function execute(InputInterface $input, OutputInterface $output): int - { - $moreThan = ($moreThan_ = $input->getOption('moreThan')) !== null - ? (int) $moreThan_ - : null; - - /** - * @var list $results - */ - $results = $this->userRepository->createQueryBuilder('user') - ->leftJoin('user.loginEvents', 'loginEvent') - ->select('user.email', 'MAX(loginEvent.createdAt) as last_login_at') - ->groupBy('user.email') - ->orderBy('last_login_at', 'DESC') - ->getQuery() - ->getResult(); - - $table = new Table($output); - $table->setHeaderTitle('Last login date of users'); - $table->setHeaders(['Email', 'Last login', 'Recency']); - foreach ($results as $result) { - $lastLoginAt = ($lastLoginAt = $result['last_login_at']) !== null - ? new \DateTime($lastLoginAt) - : null; - - if (null !== $lastLoginAt) { - $lastLoginAtString = $lastLoginAt->format('Y-m-d H:i:s'); - $recency = $lastLoginAt->diff(new \DateTime()); - - if (null !== $moreThan && $recency->days < $moreThan) { - continue; - } - - $recencyString = $recency->format('%a days %h hours'); - - $table->addRow([$result['email'], $lastLoginAtString, $recencyString]); - } else { - $table->addRow([$result['email'], 'Never logged in', 'N/A']); - } - } - - $table->render(); - - return Command::SUCCESS; - } -} diff --git a/src/Controller/Admin/DashboardController.php b/src/Controller/Admin/DashboardController.php index da15f5d..9c1a8ae 100644 --- a/src/Controller/Admin/DashboardController.php +++ b/src/Controller/Admin/DashboardController.php @@ -50,6 +50,11 @@ public function configureMenuItems(): iterable { yield MenuItem::linkToRoute('Back to App', 'fa fa-arrow-left', 'app_home'); + yield MenuItem::section('Statistics'); + yield MenuItem::linkToRoute('Last login at', 'fa fa-sign-in-alt', 'admin_statistic_last_login_at'); + yield MenuItem::linkToRoute('Completed Questions', 'fa fa-trophy', 'admin_statistic_completed_questions'); + yield MenuItem::linkToRoute('Experience Points', 'fa fa-coins', 'admin_statistic_experience_points'); + yield MenuItem::section('User management'); yield MenuItem::linkToCrud('User', 'fa fa-user', User::class); yield MenuItem::linkToCrud('Group', 'fa fa-users', Group::class); @@ -66,7 +71,7 @@ public function configureMenuItems(): iterable yield MenuItem::linkToCrud('CommentLikeEvent', 'fa fa-thumbs-up', CommentLikeEvent::class); yield MenuItem::section('Mails'); - yield MenuItem::linkToRoute('EmailTemplates', 'fa fa-layer-group', 'app_admin_emailtemplate_index'); + yield MenuItem::linkToRoute('EmailTemplates', 'fa fa-layer-group', 'admin_emailtemplate_index'); yield MenuItem::linkToCrud('Email', 'fa fa-envelope', Email::class); yield MenuItem::linkToCrud('EmailDeliveryEvent', 'fa fa-paper-plane', EmailDeliveryEvent::class); diff --git a/src/Controller/Admin/EmailTemplateController.php b/src/Controller/Admin/EmailTemplateController.php index 4be03f3..515d80f 100644 --- a/src/Controller/Admin/EmailTemplateController.php +++ b/src/Controller/Admin/EmailTemplateController.php @@ -21,7 +21,7 @@ public function __construct( $this->templateDir = $this->projectDir.'/templates/email/mjml'; } - #[Route('/admin/email-template', name: 'app_admin_emailtemplate_index')] + #[Route('/admin/email-template', name: 'admin_emailtemplate_index')] public function index(): Response { $templateFiles = glob($this->templateDir.'/*.mjml.twig'); @@ -34,12 +34,12 @@ public function index(): Response $templateFiles ); - return $this->render('admin/email-template/index.twig', [ + return $this->render('admin/email_template/index.html.twig', [ 'templates' => $templateFiles, ]); } - #[Route('/admin/email-template/{name}', name: 'app_admin_emailtemplate_details')] + #[Route('/admin/email-template/{name}', name: 'admin_emailtemplate_details')] public function details(string $name, Request $request): Response { $parametersJSON = $request->query->get('parameters', '{}'); @@ -57,7 +57,7 @@ public function details(string $name, Request $request): Response $error = $e->getMessage(); } - return $this->render('admin/email-template/details.twig', [ + return $this->render('admin/email_template/details.html.twig', [ 'name' => $name, 'parameters' => $parameters, 'content' => $content, diff --git a/src/Controller/Admin/StatisticController.php b/src/Controller/Admin/StatisticController.php new file mode 100644 index 0000000..be479c4 --- /dev/null +++ b/src/Controller/Admin/StatisticController.php @@ -0,0 +1,123 @@ + $results + */ + $results = $userRepository->createQueryBuilder('user') + ->leftJoin('user.loginEvents', 'loginEvent') + ->select( + 'user.id', + 'user.email', + 'MAX(loginEvent.createdAt) AS last_login_at', + ) + ->groupBy('user.id', 'user.email') + ->orderBy('last_login_at', 'DESC') + ->getQuery() + ->getResult(); + + /** + * @var list $resultsWithRecency + */ + $resultsWithRecency = []; + /** + * @var list $resultsThatNeverLogin + */ + $resultsThatNeverLogin = []; + + foreach ($results as $result) { + $lastLoginAt = ($lastLoginAt = $result['last_login_at']) !== null + ? new \DateTime($lastLoginAt) + : null; + + if (null !== $lastLoginAt) { + $lastLoginAtString = $lastLoginAt->format('Y-m-d H:i:s'); + $recency = $lastLoginAt->diff(new \DateTime())->format('%a 天'); + + $resultsWithRecency[] = [ + 'id' => $result['id'], + 'email' => $result['email'], + 'last_login_at' => $lastLoginAtString, + 'recency' => $recency, + ]; + } else { + $resultsThatNeverLogin[] = [ + 'id' => $result['id'], + 'email' => $result['email'], + ]; + } + } + + return $this->render('admin/statistics/last_login_at.html.twig', [ + 'results' => $resultsWithRecency + $resultsThatNeverLogin, + ]); + } + + #[Route('/admin/statistic/completed-questions', name: 'admin_statistic_completed_questions')] + public function completedQuestions(UserRepository $userRepository, QuestionRepository $questionRepository): Response + { + $totalQuestions = $questionRepository->count(); + + /** + * @var list}> $userSolvedQuestionsCount + */ + $userSolvedQuestionsCount = $userRepository->createQueryBuilder('u') + ->select('u.id', 'u.email', 'COUNT(DISTINCT q) as solved_questions') + ->leftJoin('u.solutionEvents', 'se') + ->leftJoin('se.question', 'q') + ->where('se.status = :status or se is NULL') + ->groupBy('u.id', 'u.email') + ->orderBy('solved_questions', 'DESC') + ->setParameter('status', SolutionEventStatus::Passed) + ->getQuery() + ->getResult(); + + return $this->render('admin/statistics/completed_questions.html.twig', [ + 'totalQuestions' => $totalQuestions, + 'userSolvedQuestionsCount' => $userSolvedQuestionsCount, + ]); + } + + #[Route('/admin/statistic/experience-points', name: 'admin_statistic_experience_points')] + public function experiencePoint(PointCalculationService $pointCalculationService, UserRepository $userRepository): Response + { + $users = $userRepository->findAll(); + + /** + * @var list $usersWithPoints + */ + $usersWithPoints = []; + + foreach ($users as $user) { + $point = $pointCalculationService->calculate($user); + + $usersWithPoints[] = [ + 'id' => $user->getId(), + 'email' => $user->getEmail(), + 'points' => $point, + ]; + } + + usort($usersWithPoints, fn (array $a, array $b) => $b['points'] <=> $a['points']); + + return $this->render('admin/statistics/experience_points.html.twig', [ + 'usersWithPoints' => $usersWithPoints, + ]); + } +} diff --git a/src/Controller/Admin/UserCrudController.php b/src/Controller/Admin/UserCrudController.php index 7648ee5..c246800 100644 --- a/src/Controller/Admin/UserCrudController.php +++ b/src/Controller/Admin/UserCrudController.php @@ -55,6 +55,7 @@ public function configureFields(string $pageName): iterable AssociationField::new('group', 'Group'), DateTimeField::new('created_at', 'Created at')->hideOnForm(), DateTimeField::new('updated_at', 'Updated at')->hideOnForm(), + DateTimeField::new('last_login_at', 'Last login at'), ]; } diff --git a/src/Entity/User.php b/src/Entity/User.php index 3755ff9..c79c19c 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -217,6 +217,16 @@ public function setName(string $name): static return $this; } + public function getLastLoginAt(): ?\DateTimeInterface + { + $lastLoginEvent = $this->loginEvents->last(); + if (false === $lastLoginEvent) { + return null; + } + + return $lastLoginEvent->getCreatedAt(); + } + /** * @return Collection */ diff --git a/templates/admin/email-template/details.twig b/templates/admin/email_template/details.html.twig similarity index 91% rename from templates/admin/email-template/details.twig rename to templates/admin/email_template/details.html.twig index d0d4cbc..adb07d8 100644 --- a/templates/admin/email-template/details.twig +++ b/templates/admin/email_template/details.html.twig @@ -2,7 +2,7 @@ {% block content_title %}郵件範本 – 預覽 {{ name }}{% endblock %} {% block page_actions %} - 回到範本列表 + 回到範本列表 {% endblock %} {% block main %} diff --git a/templates/admin/email-template/index.twig b/templates/admin/email_template/index.html.twig similarity index 71% rename from templates/admin/email-template/index.twig rename to templates/admin/email_template/index.html.twig index 3ddd99d..d0c40c9 100644 --- a/templates/admin/email-template/index.twig +++ b/templates/admin/email_template/index.html.twig @@ -3,18 +3,18 @@ {% block content_title %}郵件範本{% endblock %} {% block main %} - +
- - + + {% for template in templates %} - + {% endfor %} diff --git a/templates/admin/statistics/completed_questions.html.twig b/templates/admin/statistics/completed_questions.html.twig new file mode 100644 index 0000000..9668865 --- /dev/null +++ b/templates/admin/statistics/completed_questions.html.twig @@ -0,0 +1,27 @@ +{% extends '@EasyAdmin/page/content.html.twig' %} + +{% block content_title %}統計資料 – 完成題數{% endblock %} + +{% block main %} +
模板檔案路徑功能範本名稱動作
{{ template }}預覽預覽
+ + + + + + + + + {% for user in userSolvedQuestionsCount %} + + + + + + {% endfor %} + +
帳號完成題數進度
{{ user.email }}{{ user.solved_questions }}{{ totalQuestions > 0 ? (user.solved_questions / totalQuestions * 100)|round(2) : 0 }}%
+{% endblock %} diff --git a/templates/admin/statistics/experience_points.html.twig b/templates/admin/statistics/experience_points.html.twig new file mode 100644 index 0000000..2f84374 --- /dev/null +++ b/templates/admin/statistics/experience_points.html.twig @@ -0,0 +1,25 @@ +{% extends '@EasyAdmin/page/content.html.twig' %} + +{% block content_title %}統計資料 – 上次登入時間{% endblock %} + +{% block main %} + + + + + + + + + {% for user in usersWithPoints %} + + + + + {% endfor %} + +
帳號經驗值
{{ user.email }}{{ user.points }}
+{% endblock %} diff --git a/templates/admin/statistics/last_login_at.html.twig b/templates/admin/statistics/last_login_at.html.twig new file mode 100644 index 0000000..7091d94 --- /dev/null +++ b/templates/admin/statistics/last_login_at.html.twig @@ -0,0 +1,27 @@ +{% extends '@EasyAdmin/page/content.html.twig' %} + +{% block content_title %}統計資料 – 上次登入時間{% endblock %} + +{% block main %} + + + + + + + + + + {% for result in results %} + + + + + + {% endfor %} + +
帳號上次登入時間距今天數
{{ result.email }}{{ result.last_login_at ?? '沒登入過' }}{{ result.recency ?? 'N/A' }}
+{% endblock %} diff --git a/translations/messages.zh_TW.yaml b/translations/messages.zh_TW.yaml index eee286c..7a026ff 100644 --- a/translations/messages.zh_TW.yaml +++ b/translations/messages.zh_TW.yaml @@ -1,5 +1,4 @@ User: 使用者 -Dashboard: 儀表板 User management: 使用者管理 Question management: 題庫管理 Schema: 結構 @@ -69,6 +68,10 @@ Kind: 種類 Text Content: 文字內容 HTML Content: HTML 內容 EmailTemplates: 郵件範本 +Last login at: 最後登入時間 +Statistics: 統計資料 +Completed Questions: 完成題數 +Experience Points: 經驗值 result_presenter.tabs.result: 執行結果 result_presenter.tabs.answer: 正確答案