Add quiz clearing and deletion functionality with UI enhancements

This commit introduces the ability to clear quiz results and delete quizzes directly from the backoffice. It includes new routes, controllers, modals for user confirmation, and updates to translations. The `QuizRepository` now supports dedicated methods for clearing results and deleting quizzes along with error handling. Related database migrations and front-end adjustments are also included.
This commit is contained in:
2025-06-07 20:49:21 +02:00
parent 79236d84e9
commit 6a77df402d
18 changed files with 327 additions and 63 deletions

View File

@@ -1,5 +1,5 @@
import * as bootstrap from 'bootstrap'
import './bootstrap.js';
import 'bootstrap/dist/css/bootstrap.min.css'
import * as bootstrap from 'bootstrap'
import './styles/backoffice.scss';

View File

@@ -0,0 +1,17 @@
import {Controller} from '@hotwired/stimulus';
import * as bootstrap from 'bootstrap'
export default class extends Controller {
connect() {
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]')
const tooltipList = [...tooltipTriggerList].map(tooltipTriggerEl => new bootstrap.Tooltip(tooltipTriggerEl))
}
clearQuiz() {
new bootstrap.Modal('#clearQuizModal').show();
}
deleteQuiz() {
new bootstrap.Modal('#deleteQuizModal').show();
}
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250607154730 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE season DROP CONSTRAINT FK_F0E45BA96706D6B
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE season ADD CONSTRAINT FK_F0E45BA96706D6B FOREIGN KEY (active_quiz_id) REFERENCES quiz (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE
SQL);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE season DROP CONSTRAINT fk_f0e45ba96706d6b
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE season ADD CONSTRAINT fk_f0e45ba96706d6b FOREIGN KEY (active_quiz_id) REFERENCES quiz (id) NOT DEFERRABLE INITIALLY IMMEDIATE
SQL);
}
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250607184525 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE elimination DROP CONSTRAINT FK_5947284F853CD175
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE elimination ADD CONSTRAINT FK_5947284F853CD175 FOREIGN KEY (quiz_id) REFERENCES quiz (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE given_answer DROP CONSTRAINT FK_9AC61A30853CD175
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE given_answer ADD CONSTRAINT FK_9AC61A30853CD175 FOREIGN KEY (quiz_id) REFERENCES quiz (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE
SQL);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE given_answer DROP CONSTRAINT fk_9ac61a30853cd175
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE given_answer ADD CONSTRAINT fk_9ac61a30853cd175 FOREIGN KEY (quiz_id) REFERENCES quiz (id) NOT DEFERRABLE INITIALLY IMMEDIATE
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE elimination DROP CONSTRAINT fk_5947284f853cd175
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE elimination ADD CONSTRAINT fk_5947284f853cd175 FOREIGN KEY (quiz_id) REFERENCES quiz (id) NOT DEFERRABLE INITIALLY IMMEDIATE
SQL);
}
}

View File

@@ -27,6 +27,7 @@ return RectorConfig::configure()
phpunitCodeQuality: true,
doctrineCodeQuality: true,
symfonyCodeQuality: true,
// naming: true
)
->withAttributesSets(all: true)
->withComposerBased(twig: true, doctrine: true, phpunit: true, symfony: true)

View File

@@ -10,6 +10,7 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController as AbstractBase
abstract class AbstractController extends AbstractBaseController
{
protected const string SEASON_CODE_REGEX = '[A-Za-z\d]{5}';
protected const string CANDIDATE_HASH_REGEX = '[\w\-=]+';
#[\Override]

View File

@@ -39,9 +39,10 @@ final class PrepareEliminationController extends AbstractController
$elimination->updateFromInputBag($request->request);
$em->flush();
if (true === $request->request->getBoolean('start')) {
if ($request->request->getBoolean('start')) {
return $this->redirectToRoute('app_elimination', ['elimination' => $elimination->getId()]);
}
$this->addFlash('success', 'Elimination updated');
}

View File

@@ -8,8 +8,10 @@ use App\Controller\AbstractController;
use App\Entity\Candidate;
use App\Entity\Quiz;
use App\Entity\Season;
use App\Exception\ErrorClearingQuizException;
use App\Repository\CandidateRepository;
use App\Repository\QuizCandidateRepository;
use App\Repository\QuizRepository;
use App\Security\Voter\SeasonVoter;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
@@ -19,6 +21,7 @@ use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Contracts\Translation\TranslatorInterface;
#[AsController]
#[IsGranted('ROLE_USER')]
@@ -26,6 +29,7 @@ class QuizController extends AbstractController
{
public function __construct(
private readonly CandidateRepository $candidateRepository,
private readonly TranslatorInterface $translator,
) {}
#[Route(
@@ -61,6 +65,37 @@ class QuizController extends AbstractController
return $this->redirectToRoute('app_backoffice_season', ['seasonCode' => $season->getSeasonCode()]);
}
#[Route(
'/backoffice/quiz/{quiz}/clear',
name: 'app_backoffice_quiz_clear',
)]
#[IsGranted(SeasonVoter::EDIT, subject: 'quiz')]
public function clearQuiz(Quiz $quiz, QuizRepository $quizRepository): RedirectResponse
{
try {
$quizRepository->clearQuiz($quiz);
$this->addFlash('success', $this->translator->trans('Quiz cleared'));
} catch (ErrorClearingQuizException) {
$this->addFlash('error', $this->translator->trans('Error clearing quiz'));
}
return $this->redirectToRoute('app_backoffice_quiz', ['seasonCode' => $quiz->getSeason()->getSeasonCode(), 'quiz' => $quiz->getId()]);
}
#[Route(
'/backoffice/quiz/{quiz}/delete',
name: 'app_backoffice_quiz_delete',
)]
#[IsGranted(SeasonVoter::DELETE, subject: 'quiz')]
public function deleteQuiz(Quiz $quiz, QuizRepository $quizRepository): RedirectResponse
{
$quizRepository->deleteQuiz($quiz);
$this->addFlash('success', $this->translator->trans('Quiz deleted'));
return $this->redirectToRoute('app_backoffice_season', ['seasonCode' => $quiz->getSeason()->getSeasonCode()]);
}
#[Route(
'/backoffice/quiz/{quiz}/modify_correction/{candidate}',
name: 'app_backoffice_modify_correction',

View File

@@ -26,7 +26,7 @@ use Symfony\Contracts\Translation\TranslatorInterface;
#[IsGranted('ROLE_USER')]
class SeasonController extends AbstractController
{
public function __construct(private readonly TranslatorInterface $translator, private EntityManagerInterface $em,
public function __construct(private readonly TranslatorInterface $translator, private readonly EntityManagerInterface $em,
) {}
#[Route(

View File

@@ -18,6 +18,7 @@ use Symfony\Component\Uid\Uuid;
class Elimination
{
public const string SCREEN_GREEN = 'green';
public const string SCREEN_RED = 'red';
#[ORM\Id]
@@ -35,7 +36,7 @@ class Elimination
public function __construct(
#[ORM\ManyToOne(inversedBy: 'eliminations')]
#[ORM\JoinColumn(nullable: false)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private Quiz $quiz,
) {}
@@ -66,7 +67,7 @@ class Elimination
/** @param InputBag<bool|float|int|string> $inputBag */
public function updateFromInputBag(InputBag $inputBag): self
{
foreach ($this->data as $name => $screenColour) {
foreach (array_keys($this->data) as $name) {
$newColour = $inputBag->get('colour-'.mb_strtolower($name));
if (\is_string($newColour)) {
$this->data[$name] = $inputBag->get('colour-'.mb_strtolower($name));

View File

@@ -31,7 +31,7 @@ class GivenAnswer
private Candidate $candidate,
#[ORM\ManyToOne]
#[ORM\JoinColumn(nullable: false)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private Quiz $quiz,
#[ORM\ManyToOne(inversedBy: 'givenAnswers')]

View File

@@ -43,6 +43,7 @@ class Season
private Collection $owners;
#[ORM\ManyToOne]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
private ?Quiz $ActiveQuiz = null;
public function __construct()

View File

@@ -0,0 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Exception;
class ErrorClearingQuizException extends \Exception {}

View File

@@ -4,17 +4,59 @@ declare(strict_types=1);
namespace App\Repository;
use App\Entity\Elimination;
use App\Entity\GivenAnswer;
use App\Entity\Quiz;
use App\Entity\QuizCandidate;
use App\Exception\ErrorClearingQuizException;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
use Psr\Log\LoggerInterface;
/**
* @extends ServiceEntityRepository<Quiz>
*/
class QuizRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
public function __construct(ManagerRegistry $registry, private readonly LoggerInterface $logger)
{
parent::__construct($registry, Quiz::class);
}
/** @throws ErrorClearingQuizException */
public function clearQuiz(Quiz $quiz): void
{
$em = $this->getEntityManager();
$em->beginTransaction();
try {
$em->createQueryBuilder()
->delete()->from(QuizCandidate::class, 'qc')
->where('qc.quiz = :quiz')
->setParameter('quiz', $quiz)
->getQuery()->execute();
$em->createQueryBuilder()
->delete()->from(GivenAnswer::class, 'ga')
->where('ga.quiz = :quiz')
->setParameter('quiz', $quiz)
->getQuery()->execute();
$em->createQueryBuilder()
->delete()->from(Elimination::class, 'e')
->where('e.quiz = :quiz')
->setParameter('quiz', $quiz)
->getQuery()->execute();
} catch (\Throwable $throwable) {
$this->logger->error($throwable->getMessage());
$em->rollback();
throw new ErrorClearingQuizException(previous: $throwable);
}
$em->commit();
}
public function deleteQuiz(Quiz $quiz): void
{
$this->getEntityManager()->remove($quiz);
$this->getEntityManager()->flush();
}
}

View File

@@ -11,43 +11,45 @@
{{ 'Add'|trans }}
</a>
</div>
<table class="table table-hover">
<thead>
<tr>
{% if is_granted('ROLE_ADMIN') %}
<th scope="col">{{ 'Owner(s)'|trans }}</th>
{% endif %}
<th scope="col">{{ 'Name'|trans }}</th>
<th scope="col">{{ 'Active Quiz'|trans }}</th>
<th scope="col">{{ 'Season Code'|trans }}</th>
<th scope="col">{{ 'Manage'|trans }}</th>
</tr>
</thead>
<tbody>
{% for season in seasons %}
<tr class="align-middle">
{% if seasons %}
<table class="table table-hover">
<thead>
<tr>
{% if is_granted('ROLE_ADMIN') %}
<td>{{ season.owners|map(o => o.email)|join(', ') }}</td>
<th scope="col">{{ 'Owner(s)'|trans }}</th>
{% endif %}
<td>{{ season.name }}</td>
<td>
{% if season.activeQuiz %}
{{ season.activeQuiz.name }}
{% else %}
{{ 'No active quiz'|trans }}
{% endif %}
</td>
<td>
<a {% if season.activeQuiz %}href="{{ path('app_quiz_enter_name', {seasonCode: season.seasonCode}) }}"
{% else %}class="disabled" {% endif %}>{{ season.seasonCode }}</a>
</td>
<td>
<a href="{{ path('app_backoffice_season', {seasonCode: season.seasonCode}) }}">{{ 'Manage'|trans }}</a>
</td>
<th scope="col">{{ 'Name'|trans }}</th>
<th scope="col">{{ 'Active Quiz'|trans }}</th>
<th scope="col">{{ 'Season Code'|trans }}</th>
<th scope="col">{{ 'Manage'|trans }}</th>
</tr>
{% else %}
EMPTY
{% endfor %}
</tbody>
</table>
</thead>
<tbody>
{% for season in seasons %}
<tr class="align-middle">
{% if is_granted('ROLE_ADMIN') %}
<td>{{ season.owners|map(o => o.email)|join(', ') }}</td>
{% endif %}
<td>{{ season.name }}</td>
<td>
{% if season.activeQuiz %}
{{ season.activeQuiz.name }}
{% else %}
{{ 'No active quiz'|trans }}
{% endif %}
</td>
<td>
<a {% if season.activeQuiz %}href="{{ path('app_quiz_enter_name', {seasonCode: season.seasonCode}) }}"
{% else %}class="disabled" {% endif %}>{{ season.seasonCode }}</a>
</td>
<td>
<a href="{{ path('app_backoffice_season', {seasonCode: season.seasonCode}) }}">{{ 'Manage'|trans }}</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
{{ 'You have no seasons yet.'|trans }}
{% endif %}
{% endblock %}

View File

@@ -2,13 +2,19 @@
{% block body %}
<h2 class="py-2">{{ 'Quiz'|trans }}: {{ quiz.season.name }} - {{ quiz.name }}</h2>
<div class="py-2 btn-group">
<div class="py-2 btn-group" data-controller="bo--quiz">
<a class="btn btn-primary {% if quiz is same as(season.activeQuiz) %}disabled{% endif %}"
href="{{ path('app_backoffice_enable', {seasonCode: season.seasonCode, quiz: quiz.id}) }}">{{ 'Make active'|trans }}</a>
{% if quiz is same as (season.activeQuiz) %}
<a class="btn btn-secondary"
href="{{ path('app_backoffice_enable', {seasonCode: season.seasonCode, quiz: 'null'}) }}">{{ 'Deactivate Quiz'|trans }}</a>
{% endif %}
<button class="btn btn-danger" data-action="click->bo--quiz#clearQuiz">
{{ 'Clear quiz...'|trans }}
</button>
<button class="btn btn-danger" data-action="click->bo--quiz#deleteQuiz">
{{ 'Delete Quiz...'|trans }}
</button>
</div>
<div id="questions">
@@ -111,16 +117,48 @@
</tbody>
</table>
</div>
{% endblock %}
{% block javascripts %}
{{ parent() }}
<script>
document.addEventListener('DOMContentLoaded', function () {
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]')
const tooltipList = [...tooltipTriggerList].map(tooltipTriggerEl => new bootstrap.Tooltip(tooltipTriggerEl))
});
</script>
{% endblock javascripts %}
{% block title %}
{# Modal Clear #}
<div class="modal fade" id="clearQuizModal" data-bs-backdrop="static"
tabindex="-1"
aria-labelledby="staticBackdropLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="staticBackdropLabel">Please Confirm</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
Are you sure you want to clear all the results? This will also delete al the eliminations.
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">No</button>
<a href="{{ path('app_backoffice_quiz_clear', {quiz: quiz.id}) }}" class="btn btn-danger">Yes</a>
</div>
</div>
</div>
</div>
{# Modal Delete #}
<div class="modal fade" id="deleteQuizModal" data-bs-backdrop="static"
tabindex="-1"
aria-labelledby="staticBackdropLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="staticBackdropLabel">Please Confirm</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
Are you sure you want to delete this quiz?
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">No</button>
<a href="{{ path('app_backoffice_quiz_delete', {quiz: quiz.id}) }}" class="btn btn-danger">Yes</a>
</div>
</div>
</div>
</div>
{% endblock %}
{% block title %}
{% endblock %}

View File

@@ -18,19 +18,19 @@ use PHPUnit\Framework\TestCase;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
class SeasonVoterTest extends TestCase
final class SeasonVoterTest extends TestCase
{
private SeasonVoter $seasonVoter;
private TokenInterface&Stub $token;
private User&Stub $user;
protected function setUp(): void
{
$this->seasonVoter = new SeasonVoter();
$this->token = $this->createStub(TokenInterface::class);
$this->user = $this->createStub(User::class);
$this->token->method('getUser')->willReturn($this->user);
$user = $this->createStub(User::class);
$this->token->method('getUser')->willReturn($user);
}
#[DataProvider('typesProvider')]

View File

@@ -49,6 +49,10 @@
<source>Candidates</source>
<target>Kandidaten</target>
</trans-unit>
<trans-unit id="FNY513f" resname="Clear quiz...">
<source>Clear quiz...</source>
<target>Test leegmaken...</target>
</trans-unit>
<trans-unit id="sFpB4C2" resname="Correct Answers">
<source>Correct Answers</source>
<target>Goede antwoorden</target>
@@ -77,6 +81,10 @@
<source>Deactivate Quiz</source>
<target>Deactiveer test</target>
</trans-unit>
<trans-unit id="p9GNNI3" resname="Delete Quiz...">
<source>Delete Quiz...</source>
<target>Test verwijderen...</target>
</trans-unit>
<trans-unit id="R9yHzHv" resname="Download Template">
<source>Download Template</source>
<target>Download sjabloon</target>
@@ -93,6 +101,10 @@
<source>Enter your name</source>
<target>Voor je naam in</target>
</trans-unit>
<trans-unit id="HNMwvRn" resname="Error clearing quiz">
<source>Error clearing quiz</source>
<target>Fout bij leegmaken test</target>
</trans-unit>
<trans-unit id="OGiIhMH" resname="Green">
<source>Green</source>
<target>Groen</target>
@@ -177,10 +189,18 @@
<source>Quiz Added!</source>
<target>Test toegevoegd!</target>
</trans-unit>
<trans-unit id="vXN8b2w" resname="Quiz cleared">
<source>Quiz cleared</source>
<target>Test leeggemaakt</target>
</trans-unit>
<trans-unit id="LbVe.2c" resname="Quiz completed">
<source>Quiz completed</source>
<target>Test voltooid</target>
</trans-unit>
<trans-unit id="XdfTTMD" resname="Quiz deleted">
<source>Quiz deleted</source>
<target>Test verwijderd</target>
</trans-unit>
<trans-unit id="frxoIkW" resname="Quiz name">
<source>Quiz name</source>
<target>Testnaam</target>
@@ -237,10 +257,6 @@
<source>Sign in</source>
<target>Log in</target>
</trans-unit>
<trans-unit id="2QO7aYC" resname="Start Elimination">
<source>Start Elimination</source>
<target>Start eliminatie</target>
</trans-unit>
<trans-unit id="9m8DOBg" resname="Submit">
<source>Submit</source>
<target>Verstuur</target>
@@ -253,10 +269,18 @@
<source>There are no answers for this question</source>
<target>Er zijn geen antwoorden voor deze vraag</target>
</trans-unit>
<trans-unit id=".LrcTyU" resname="There is no active quiz">
<source>There is no active quiz</source>
<target>Er is geen test actief</target>
</trans-unit>
<trans-unit id="Dptvysv" resname="Time">
<source>Time</source>
<target>Tijd</target>
</trans-unit>
<trans-unit id="0afY1NF" resname="You have no seasons yet.">
<source>You have no seasons yet.</source>
<target>Je hebt nog geen seizoenen.</target>
</trans-unit>
<trans-unit id="vVQAP9A" resname="Your Seasons">
<source>Your Seasons</source>
<target>Jouw seizoenen</target>