From dbeba29cc5ee2794f800eb7b97a9cf210034b35a Mon Sep 17 00:00:00 2001 From: Yi-Jyun Pan Date: Fri, 4 Oct 2024 00:30:08 +0800 Subject: [PATCH 1/9] feat(entity): Login Event --- migrations/Version20241003162915.php | 37 +++++++++++++++++++++++++ src/Entity/LoginEvent.php | 28 +++++++++++++++++++ src/Entity/User.php | 37 +++++++++++++++++++++++++ src/Repository/LoginEventRepository.php | 35 +++++++++++++++++++++++ 4 files changed, 137 insertions(+) create mode 100644 migrations/Version20241003162915.php create mode 100644 src/Entity/LoginEvent.php create mode 100644 src/Repository/LoginEventRepository.php diff --git a/migrations/Version20241003162915.php b/migrations/Version20241003162915.php new file mode 100644 index 0000000..e0c4f5d --- /dev/null +++ b/migrations/Version20241003162915.php @@ -0,0 +1,37 @@ +addSql('CREATE TABLE login_event (id UUID NOT NULL, account_id INT NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX IDX_3DDECD339B6B5FBA ON login_event (account_id)'); + $this->addSql('COMMENT ON COLUMN login_event.id IS \'(DC2Type:ulid)\''); + $this->addSql('COMMENT ON COLUMN login_event.created_at IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('ALTER TABLE login_event ADD CONSTRAINT FK_3DDECD339B6B5FBA FOREIGN KEY (account_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('CREATE SCHEMA public'); + $this->addSql('ALTER TABLE login_event DROP CONSTRAINT FK_3DDECD339B6B5FBA'); + $this->addSql('DROP TABLE login_event'); + } +} diff --git a/src/Entity/LoginEvent.php b/src/Entity/LoginEvent.php new file mode 100644 index 0000000..a006274 --- /dev/null +++ b/src/Entity/LoginEvent.php @@ -0,0 +1,28 @@ +account; + } + + public function setAccount(?User $account): static + { + $this->account = $account; + + return $this; + } +} diff --git a/src/Entity/User.php b/src/Entity/User.php index 0447e39..8d0f586 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -76,6 +76,12 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface #[ORM\OneToMany(targetEntity: HintOpenEvent::class, mappedBy: 'opener', orphanRemoval: true)] private Collection $hintOpenEvents; + /** + * @var Collection + */ + #[ORM\OneToMany(targetEntity: LoginEvent::class, mappedBy: 'account', orphanRemoval: true)] + private Collection $loginEvents; + public function __construct() { $this->solutionEvents = new ArrayCollection(); @@ -83,6 +89,7 @@ public function __construct() $this->comments = new ArrayCollection(); $this->commentLikeEvents = new ArrayCollection(); $this->hintOpenEvents = new ArrayCollection(); + $this->loginEvents = new ArrayCollection(); } public function getId(): ?int @@ -343,4 +350,34 @@ public function removeHintOpenEvent(HintOpenEvent $hintOpenEvent): static return $this; } + + /** + * @return Collection + */ + public function getLoginEvents(): Collection + { + return $this->loginEvents; + } + + public function addLoginEvent(LoginEvent $loginEvent): static + { + if (!$this->loginEvents->contains($loginEvent)) { + $this->loginEvents->add($loginEvent); + $loginEvent->setAccount($this); + } + + return $this; + } + + public function removeLoginEvent(LoginEvent $loginEvent): static + { + if ($this->loginEvents->removeElement($loginEvent)) { + // set the owning side to null (unless already changed) + if ($loginEvent->getAccount() === $this) { + $loginEvent->setAccount(null); + } + } + + return $this; + } } diff --git a/src/Repository/LoginEventRepository.php b/src/Repository/LoginEventRepository.php new file mode 100644 index 0000000..a95feb0 --- /dev/null +++ b/src/Repository/LoginEventRepository.php @@ -0,0 +1,35 @@ + + */ +class LoginEventRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, LoginEvent::class); + } + + public function getLoginCount(User $user): int + { + $loginCount = $this->createQueryBuilder('l') + ->select('COUNT(l.id)') + ->andWhere('l.account = :user') + ->setParameter('user', $user) + ->getQuery() + ->getSingleScalarResult(); + + \assert(\is_int($loginCount)); + + return $loginCount; + } +} From 2cd23dd27040590fb4dbba56f9084a5c07da819b Mon Sep 17 00:00:00 2001 From: Yi-Jyun Pan Date: Fri, 4 Oct 2024 00:36:44 +0800 Subject: [PATCH 2/9] feat(admin): LoginEvent --- src/Controller/Admin/DashboardController.php | 2 + .../Admin/LoginEventCrudController.php | 44 +++++++++++++++++++ translations/messages.zh_TW.yaml | 2 + 3 files changed, 48 insertions(+) create mode 100644 src/Controller/Admin/LoginEventCrudController.php diff --git a/src/Controller/Admin/DashboardController.php b/src/Controller/Admin/DashboardController.php index 811055c..c6c8eb1 100644 --- a/src/Controller/Admin/DashboardController.php +++ b/src/Controller/Admin/DashboardController.php @@ -8,6 +8,7 @@ use App\Entity\CommentLikeEvent; use App\Entity\Group; use App\Entity\HintOpenEvent; +use App\Entity\LoginEvent; use App\Entity\Question; use App\Entity\Schema; use App\Entity\SolutionEvent; @@ -54,5 +55,6 @@ public function configureMenuItems(): iterable yield MenuItem::linkToCrud('SolutionEvent', 'fa fa-check', SolutionEvent::class); yield MenuItem::linkToCrud('SolutionVideoEvent', 'fa fa-video', SolutionVideoEvent::class); yield MenuItem::linkToCrud('HintOpenEvent', 'fa fa-lightbulb', HintOpenEvent::class); + yield MenuItem::linkToCrud('LoginEvent', 'fa fa-sign-in', LoginEvent::class); } } diff --git a/src/Controller/Admin/LoginEventCrudController.php b/src/Controller/Admin/LoginEventCrudController.php new file mode 100644 index 0000000..2d84e3e --- /dev/null +++ b/src/Controller/Admin/LoginEventCrudController.php @@ -0,0 +1,44 @@ +add('account'); + } + + public function configureActions(Actions $actions): Actions + { + return $actions + ->disable(Action::DELETE, Action::EDIT, Action::NEW) + ->add(Crud::PAGE_INDEX, Action::DETAIL); + } +} diff --git a/translations/messages.zh_TW.yaml b/translations/messages.zh_TW.yaml index 4641c2d..56fd815 100644 --- a/translations/messages.zh_TW.yaml +++ b/translations/messages.zh_TW.yaml @@ -44,6 +44,8 @@ Back to App: 返回 App Reindex: 進行搜尋索引 HintOpenEvent: 提示打開事件 Response: 回應 +LoginEvent: 登入事件 +Account: 帳號 challenge.error-type.user: 輸入錯誤 challenge.error-type.server: 伺服器錯誤 From 133f8d92b8f33e58981b230784b770babe005e47 Mon Sep 17 00:00:00 2001 From: Yi-Jyun Pan Date: Fri, 4 Oct 2024 00:37:03 +0800 Subject: [PATCH 3/9] feat(subscriber): Write login event when logging in --- src/EventSubscriber/LoginSubscriber.php | 38 +++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 src/EventSubscriber/LoginSubscriber.php diff --git a/src/EventSubscriber/LoginSubscriber.php b/src/EventSubscriber/LoginSubscriber.php new file mode 100644 index 0000000..d3d3040 --- /dev/null +++ b/src/EventSubscriber/LoginSubscriber.php @@ -0,0 +1,38 @@ +getAuthenticationToken()->getUser(); + \assert($user instanceof User); + + $loginEvent = (new LoginEvent()) + ->setAccount($user); + + $this->entityManager->persist($loginEvent); + $this->entityManager->flush(); + } + + public static function getSubscribedEvents(): array + { + return [ + 'security.authentication.success' => 'onSecurityAuthenticationSuccess', + ]; + } +} From f2d5f186d820f85d21d573f4d9901f5061b8419b Mon Sep 17 00:00:00 2001 From: Yi-Jyun Pan Date: Fri, 4 Oct 2024 00:48:04 +0800 Subject: [PATCH 4/9] feat(profile): Show login events count in profile --- templates/profile/index.html.twig | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/templates/profile/index.html.twig b/templates/profile/index.html.twig index 60841a7..ba73f51 100644 --- a/templates/profile/index.html.twig +++ b/templates/profile/index.html.twig @@ -40,6 +40,10 @@ 觀看影片次數:{{ user.solutionVideoEvents.count }} +
  • + + 登入次數:{{ user.loginEvents.count }} +
  • From 8b3e469caf33b69654be572ac78f5611edbfb327 Mon Sep 17 00:00:00 2001 From: Yi-Jyun Pan Date: Fri, 4 Oct 2024 01:02:59 +0800 Subject: [PATCH 5/9] feat(questions): Implement pass rate --- assets/styles/app.scss | 14 ++++- src/Twig/Components/Questions/Card.php | 58 +++++++++++++++++++ templates/components/Questions/Card.html.twig | 1 + 3 files changed, 72 insertions(+), 1 deletion(-) diff --git a/assets/styles/app.scss b/assets/styles/app.scss index 294eaa5..2fd92eb 100644 --- a/assets/styles/app.scss +++ b/assets/styles/app.scss @@ -120,7 +120,19 @@ ul.credit { } &__operations { - @extend .d-flex, .gap-2, .justify-content-start, .align-items-center; + @extend .d-flex, .gap-3, .justify-content-start, .align-items-center; + } + + .question-card__pass-rate { + &[data-pass-rate~="high"] { + @extend .text-success; + } + &[data-pass-rate~="medium"] { + @extend .text-warning; + } + &[data-pass-rate~="low"] { + @extend .text-danger; + } } } diff --git a/src/Twig/Components/Questions/Card.php b/src/Twig/Components/Questions/Card.php index 860d3ca..34bdfe6 100644 --- a/src/Twig/Components/Questions/Card.php +++ b/src/Twig/Components/Questions/Card.php @@ -5,10 +5,68 @@ namespace App\Twig\Components\Questions; use App\Entity\Question; +use App\Entity\SolutionEvent; +use App\Entity\SolutionEventStatus; use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; #[AsTwigComponent] final class Card { public Question $question; + + /** + * Get the pass rate of the question. + * + * @return float the pass rate of the question + */ + public function getPassRate(): float + { + $totalAttemptCount = $this->getTotalAttemptCount(); + if (0 === $totalAttemptCount) { + return 0; + } + + return round($this->getTotalSolvedCount() / $totalAttemptCount * 100, 2); + } + + /** + * Get the pass rate level of the question. + * + * Low: 0% - 40% + * Medium: 41 – 70% + * High: 71% - 100% + */ + public function getPassRateLevel(): string + { + $passRate = $this->getPassRate(); + + return match (true) { + $passRate <= 40 => 'low', + $passRate <= 70 => 'medium', + default => 'high', + }; + } + + /** + * Get the total number of attempts made on the question. + * + * @return int the total number of attempts made on the question + */ + private function getTotalAttemptCount(): int + { + return $this->question->getSolutionEvents()->count(); + } + + /** + * Get the total number of times the question has been solved. + * + * @return int the total number of times the question has been solved + */ + private function getTotalSolvedCount(): int + { + return $this->question->getSolutionEvents() + ->filter( + fn (SolutionEvent $solutionEvent) => SolutionEventStatus::Passed === $solutionEvent->getStatus() + )->count(); + } } diff --git a/templates/components/Questions/Card.html.twig b/templates/components/Questions/Card.html.twig index d006cca..4dcbc20 100644 --- a/templates/components/Questions/Card.html.twig +++ b/templates/components/Questions/Card.html.twig @@ -11,6 +11,7 @@
    進行測驗 +
    通過率 {{ this.passRate }}%
    From 6b541909e8a7b7941e4cd32567d8f05571125246 Mon Sep 17 00:00:00 2001 From: Yi-Jyun Pan Date: Fri, 4 Oct 2024 01:08:27 +0800 Subject: [PATCH 6/9] refactor(questions): Move pass rate logic to Question --- src/Entity/Question.php | 38 +++++++++++++++++ src/Twig/Components/Questions/Card.php | 42 +------------------ templates/components/Questions/Card.html.twig | 2 +- 3 files changed, 40 insertions(+), 42 deletions(-) diff --git a/src/Entity/Question.php b/src/Entity/Question.php index 3474022..2c085f5 100644 --- a/src/Entity/Question.php +++ b/src/Entity/Question.php @@ -176,6 +176,44 @@ public function setSolutionVideo(?string $solution_video): static return $this; } + /** + * Get the pass rate of the question. + * + * @return float the pass rate of the question + */ + public function getPassRate(): float + { + $totalAttemptCount = $this->getTotalAttemptCount(); + if (0 === $totalAttemptCount) { + return 0; + } + + return round($this->getTotalSolvedCount() / $totalAttemptCount * 100, 2); + } + + /** + * Get the total number of attempts made on the question. + * + * @return int the total number of attempts made on the question + */ + public function getTotalAttemptCount(): int + { + return $this->getSolutionEvents()->count(); + } + + /** + * Get the total number of times the question has been solved. + * + * @return int the total number of times the question has been solved + */ + public function getTotalSolvedCount(): int + { + return $this->getSolutionEvents() + ->filter( + fn (SolutionEvent $solutionEvent) => SolutionEventStatus::Passed === $solutionEvent->getStatus() + )->count(); + } + /** * @return Collection */ diff --git a/src/Twig/Components/Questions/Card.php b/src/Twig/Components/Questions/Card.php index 34bdfe6..0b64468 100644 --- a/src/Twig/Components/Questions/Card.php +++ b/src/Twig/Components/Questions/Card.php @@ -5,8 +5,6 @@ namespace App\Twig\Components\Questions; use App\Entity\Question; -use App\Entity\SolutionEvent; -use App\Entity\SolutionEventStatus; use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; #[AsTwigComponent] @@ -14,21 +12,6 @@ final class Card { public Question $question; - /** - * Get the pass rate of the question. - * - * @return float the pass rate of the question - */ - public function getPassRate(): float - { - $totalAttemptCount = $this->getTotalAttemptCount(); - if (0 === $totalAttemptCount) { - return 0; - } - - return round($this->getTotalSolvedCount() / $totalAttemptCount * 100, 2); - } - /** * Get the pass rate level of the question. * @@ -38,7 +21,7 @@ public function getPassRate(): float */ public function getPassRateLevel(): string { - $passRate = $this->getPassRate(); + $passRate = $this->question->getPassRate(); return match (true) { $passRate <= 40 => 'low', @@ -46,27 +29,4 @@ public function getPassRateLevel(): string default => 'high', }; } - - /** - * Get the total number of attempts made on the question. - * - * @return int the total number of attempts made on the question - */ - private function getTotalAttemptCount(): int - { - return $this->question->getSolutionEvents()->count(); - } - - /** - * Get the total number of times the question has been solved. - * - * @return int the total number of times the question has been solved - */ - private function getTotalSolvedCount(): int - { - return $this->question->getSolutionEvents() - ->filter( - fn (SolutionEvent $solutionEvent) => SolutionEventStatus::Passed === $solutionEvent->getStatus() - )->count(); - } } diff --git a/templates/components/Questions/Card.html.twig b/templates/components/Questions/Card.html.twig index 4dcbc20..be75378 100644 --- a/templates/components/Questions/Card.html.twig +++ b/templates/components/Questions/Card.html.twig @@ -11,7 +11,7 @@
    進行測驗 -
    通過率 {{ this.passRate }}%
    +
    通過率 {{ question.passRate }}%
    From c7d9fc20c95920efd087b48d422259d728bf0ce2 Mon Sep 17 00:00:00 2001 From: Yi-Jyun Pan Date: Fri, 4 Oct 2024 01:08:50 +0800 Subject: [PATCH 7/9] feat(challenge): Show pass rate --- templates/components/Challenge/Header.html.twig | 1 + 1 file changed, 1 insertion(+) diff --git a/templates/components/Challenge/Header.html.twig b/templates/components/Challenge/Header.html.twig index 3c95f65..f2275d3 100644 --- a/templates/components/Challenge/Header.html.twig +++ b/templates/components/Challenge/Header.html.twig @@ -3,6 +3,7 @@
    • {{ question.difficulty|trans }}
    • {{ question.type }}
    • +
    • 通過率 {{ question.passRate }}%
    From a8a3fb17e45a1c55f716eb68ae3bd6f9f2ca8d98 Mon Sep 17 00:00:00 2001 From: Yi-Jyun Pan Date: Fri, 4 Oct 2024 01:29:01 +0800 Subject: [PATCH 8/9] feat(app): De-/Initialize tooltips for page navigation --- assets/app/index.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/assets/app/index.ts b/assets/app/index.ts index 0897364..b998955 100644 --- a/assets/app/index.ts +++ b/assets/app/index.ts @@ -1,2 +1,19 @@ -import "bootstrap"; +import * as bootstrap from "bootstrap"; import "./bootstrap.ts"; + +/** + * Initialize tooltips of Bootstrap + */ +document.addEventListener("turbo:load", () => { + const tooltipTriggerList = document.querySelectorAll("[data-bs-toggle=\"tooltip\"]"); + const tooltipList = [...tooltipTriggerList].map(tooltipTriggerEl => new bootstrap.Tooltip(tooltipTriggerEl)); + + // Destroy tooltips on navigating to a new page + document.addEventListener("turbo:before-visit", () => { + for (const tooltip of tooltipList) { + tooltip.dispose(); + } + }, { + once: true, + }); +}); From ec67dffea0457149d8215cbaa608b53cb59351b4 Mon Sep 17 00:00:00 2001 From: Yi-Jyun Pan Date: Fri, 4 Oct 2024 01:29:53 +0800 Subject: [PATCH 9/9] feat(challenge): Implement details of pass rate --- assets/styles/app.scss | 9 +++++++++ templates/components/Challenge/Header.html.twig | 9 ++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/assets/styles/app.scss b/assets/styles/app.scss index 2fd92eb..c8d6ace 100644 --- a/assets/styles/app.scss +++ b/assets/styles/app.scss @@ -160,6 +160,15 @@ ul.credit { li { @extend .list-inline-item; } + + &__pass_rate { + &__value { + @extend .text-body; + + border-bottom: 1px dotted black; + text-decoration: none; + } + } } } diff --git a/templates/components/Challenge/Header.html.twig b/templates/components/Challenge/Header.html.twig index f2275d3..576cc33 100644 --- a/templates/components/Challenge/Header.html.twig +++ b/templates/components/Challenge/Header.html.twig @@ -3,7 +3,14 @@