mirror of
https://github.com/MarijnDoeve/TijdVoorDeTest.git
synced 2026-03-05 20:44:19 +01:00
Add correction management to backoffice, refactor security voter logic, and enhance candidate scoring
This commit introduces functionality to manage candidate corrections in the backoffice, with updated templates and a new route handler. The SeasonVoter is refactored to support additional entities, and scoring logic is updated to incorporate corrections consistently. Includes test coverage for voter logic and UI improvements for score tables.
This commit is contained in:
1
.github/workflows/ci.yml
vendored
1
.github/workflows/ci.yml
vendored
@@ -50,7 +50,6 @@ jobs:
|
|||||||
- name: Run migrations
|
- name: Run migrations
|
||||||
run: docker compose exec -T php bin/console -e test doctrine:migrations:migrate --no-interaction
|
run: docker compose exec -T php bin/console -e test doctrine:migrations:migrate --no-interaction
|
||||||
- name: Run PHPUnit
|
- name: Run PHPUnit
|
||||||
if: false # Remove this line when the tests are ready
|
|
||||||
run: docker compose exec -T php vendor/bin/phpunit
|
run: docker compose exec -T php vendor/bin/phpunit
|
||||||
- name: Doctrine Schema Validator
|
- name: Doctrine Schema Validator
|
||||||
run: docker compose exec -T php bin/console -e test doctrine:schema:validate
|
run: docker compose exec -T php bin/console -e test doctrine:schema:validate
|
||||||
|
|||||||
@@ -7,3 +7,4 @@ parameters:
|
|||||||
- public/
|
- public/
|
||||||
- src/
|
- src/
|
||||||
- tests/
|
- tests/
|
||||||
|
treatPhpDocTypesAsCertain: false
|
||||||
|
|||||||
@@ -5,13 +5,18 @@ declare(strict_types=1);
|
|||||||
namespace App\Controller\Backoffice;
|
namespace App\Controller\Backoffice;
|
||||||
|
|
||||||
use App\Controller\AbstractController;
|
use App\Controller\AbstractController;
|
||||||
|
use App\Entity\Candidate;
|
||||||
use App\Entity\Quiz;
|
use App\Entity\Quiz;
|
||||||
use App\Entity\Season;
|
use App\Entity\Season;
|
||||||
use App\Repository\CandidateRepository;
|
use App\Repository\CandidateRepository;
|
||||||
|
use App\Repository\QuizCandidateRepository;
|
||||||
use App\Security\Voter\SeasonVoter;
|
use App\Security\Voter\SeasonVoter;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
use Symfony\Component\HttpKernel\Attribute\AsController;
|
use Symfony\Component\HttpKernel\Attribute\AsController;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException;
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
@@ -23,7 +28,9 @@ class QuizController extends AbstractController
|
|||||||
private readonly CandidateRepository $candidateRepository,
|
private readonly CandidateRepository $candidateRepository,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
#[Route('/backoffice/season/{seasonCode}/quiz/{quiz}', name: 'app_backoffice_quiz',
|
#[Route(
|
||||||
|
'/backoffice/season/{seasonCode}/quiz/{quiz}',
|
||||||
|
name: 'app_backoffice_quiz',
|
||||||
requirements: ['seasonCode' => self::SEASON_CODE_REGEX],
|
requirements: ['seasonCode' => self::SEASON_CODE_REGEX],
|
||||||
)]
|
)]
|
||||||
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
|
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
|
||||||
@@ -36,11 +43,13 @@ class QuizController extends AbstractController
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Route('/backoffice/season/{seasonCode}/quiz/{quiz}/enable', name: 'app_backoffice_enable',
|
#[Route(
|
||||||
|
'/backoffice/season/{seasonCode}/quiz/{quiz}/enable',
|
||||||
|
name: 'app_backoffice_enable',
|
||||||
requirements: ['seasonCode' => self::SEASON_CODE_REGEX],
|
requirements: ['seasonCode' => self::SEASON_CODE_REGEX],
|
||||||
)]
|
)]
|
||||||
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
|
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
|
||||||
public function enableQuiz(Season $season, ?Quiz $quiz, EntityManagerInterface $em): Response
|
public function enableQuiz(Season $season, ?Quiz $quiz, EntityManagerInterface $em): RedirectResponse
|
||||||
{
|
{
|
||||||
$season->setActiveQuiz($quiz);
|
$season->setActiveQuiz($quiz);
|
||||||
$em->flush();
|
$em->flush();
|
||||||
@@ -51,4 +60,22 @@ class QuizController extends AbstractController
|
|||||||
|
|
||||||
return $this->redirectToRoute('app_backoffice_season', ['seasonCode' => $season->getSeasonCode()]);
|
return $this->redirectToRoute('app_backoffice_season', ['seasonCode' => $season->getSeasonCode()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Route(
|
||||||
|
'/backoffice/quiz/{quiz}/modify_correction/{candidate}',
|
||||||
|
name: 'app_backoffice_modify_correction',
|
||||||
|
)]
|
||||||
|
#[IsGranted(SeasonVoter::EDIT, subject: 'quiz')]
|
||||||
|
public function modifyCorrection(Quiz $quiz, Candidate $candidate, QuizCandidateRepository $quizCandidateRepository, Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
if (!$request->isMethod('POST')) {
|
||||||
|
throw new MethodNotAllowedHttpException(['POST']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$corrections = (float) $request->request->get('corrections');
|
||||||
|
|
||||||
|
$quizCandidateRepository->setCorrectionsForCandidate($quiz, $candidate, $corrections);
|
||||||
|
|
||||||
|
return $this->redirectToRoute('app_backoffice_quiz', ['seasonCode' => $quiz->getSeason()->getSeasonCode(), 'quiz' => $quiz->getId()]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,10 +21,10 @@ use App\Repository\QuestionRepository;
|
|||||||
use App\Repository\QuizCandidateRepository;
|
use App\Repository\QuizCandidateRepository;
|
||||||
use App\Repository\SeasonRepository;
|
use App\Repository\SeasonRepository;
|
||||||
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
|
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
|
||||||
use Symfony\Component\HttpFoundation\Exception\BadRequestException;
|
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
use Symfony\Component\HttpKernel\Attribute\AsController;
|
use Symfony\Component\HttpKernel\Attribute\AsController;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||||
|
|
||||||
@@ -109,7 +109,7 @@ final class QuizController extends AbstractController
|
|||||||
$answer = $answerRepository->findOneBy(['id' => $request->request->get('answer')]);
|
$answer = $answerRepository->findOneBy(['id' => $request->request->get('answer')]);
|
||||||
|
|
||||||
if (!$answer instanceof Answer) {
|
if (!$answer instanceof Answer) {
|
||||||
throw new BadRequestException('Invalid Answer ID');
|
throw new BadRequestHttpException('Invalid Answer ID');
|
||||||
}
|
}
|
||||||
|
|
||||||
$givenAnswer = new GivenAnswer($candidate, $answer->getQuestion()->getQuiz(), $answer);
|
$givenAnswer = new GivenAnswer($candidate, $answer->getQuestion()->getQuiz(), $answer);
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ use Symfony\Component\Uid\Uuid;
|
|||||||
/**
|
/**
|
||||||
* @extends ServiceEntityRepository<Candidate>
|
* @extends ServiceEntityRepository<Candidate>
|
||||||
*
|
*
|
||||||
* @phpstan-type Result array{id: Uuid, name: string, correct: int, time: \DateInterval, corrections?: float, score: float}
|
* @phpstan-type Result array{id: Uuid, name: string, correct: int, time: \DateInterval, corrections: float, score: float}
|
||||||
* @phpstan-type ResultList list<Result>
|
* @phpstan-type ResultList list<Result>
|
||||||
*/
|
*/
|
||||||
class CandidateRepository extends ServiceEntityRepository
|
class CandidateRepository extends ServiceEntityRepository
|
||||||
@@ -54,40 +54,32 @@ class CandidateRepository extends ServiceEntityRepository
|
|||||||
/** @return ResultList */
|
/** @return ResultList */
|
||||||
public function getScores(Quiz $quiz): array
|
public function getScores(Quiz $quiz): array
|
||||||
{
|
{
|
||||||
$scoreQb = $this->createQueryBuilder('c', 'c.id')
|
$qb = $this->createQueryBuilder('c', 'c.id')
|
||||||
->select('c.id', 'c.name', 'sum(case when a.isRightAnswer = true then 1 else 0 end) as correct')
|
->select('c.id', 'c.name', 'sum(case when a.isRightAnswer = true then 1 else 0 end) as correct', 'qc.corrections', 'max(ga.created) - qc.created as time')
|
||||||
->join('c.givenAnswers', 'ga')
|
->join('c.givenAnswers', 'ga')
|
||||||
->join('ga.answer', 'a')
|
->join('ga.answer', 'a')
|
||||||
->where('ga.quiz = :quiz')
|
|
||||||
->groupBy('c.id')
|
|
||||||
->setParameter('quiz', $quiz);
|
|
||||||
|
|
||||||
$startTimeCorrectionQb = $this->createQueryBuilder('c', 'c.id')
|
|
||||||
->select('c.id', 'qc.corrections', 'max(ga.created) - qc.created as time')
|
|
||||||
->join('c.quizData', 'qc')
|
->join('c.quizData', 'qc')
|
||||||
->join('c.givenAnswers', 'ga')
|
|
||||||
->where('qc.quiz = :quiz')
|
->where('qc.quiz = :quiz')
|
||||||
->groupBy('ga.quiz', 'c.id', 'qc.id')
|
->groupBy('ga.quiz', 'c.id', 'qc.id')
|
||||||
->setParameter('quiz', $quiz);
|
->setParameter('quiz', $quiz);
|
||||||
|
|
||||||
$merged = array_merge_recursive(
|
return $this->sortResults(
|
||||||
$scoreQb->getQuery()->getArrayResult(),
|
$this->calculateScore(
|
||||||
$startTimeCorrectionQb->getQuery()->getArrayResult(),
|
$qb->getQuery()->getResult(),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
return $this->sortResults($this->calculateScore($merged));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, array{id: Uuid, name: string, correct: int, time: \DateInterval, corrections?: float}> $in
|
* @param array<string, array{id: Uuid, name: string, correct: int, time: \DateInterval, corrections: float}> $in
|
||||||
*
|
*
|
||||||
* @return array<string, Result>
|
* @return array<string, Result>
|
||||||
* */
|
*/
|
||||||
private function calculateScore(array $in): array
|
private function calculateScore(array $in): array
|
||||||
{
|
{
|
||||||
return array_map(static fn ($candidate): array => [
|
return array_map(static fn ($candidate): array => [
|
||||||
...$candidate,
|
...$candidate,
|
||||||
'score' => $candidate['correct'] + ($candidate['corrections'] ?? 0.0),
|
'score' => $candidate['correct'] + $candidate['corrections'],
|
||||||
], $in);
|
], $in);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,4 +33,15 @@ class QuizCandidateRepository extends ServiceEntityRepository
|
|||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function setCorrectionsForCandidate(Quiz $quiz, Candidate $candidate, float $corrections): void
|
||||||
|
{
|
||||||
|
$quizCandidate = $this->findOneBy(['candidate' => $candidate, 'quiz' => $quiz]);
|
||||||
|
if (!$quizCandidate instanceof QuizCandidate) {
|
||||||
|
throw new \InvalidArgumentException('Quiz candidate not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
$quizCandidate->setCorrections($corrections);
|
||||||
|
$this->getEntityManager()->flush();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Security\Voter;
|
namespace App\Security\Voter;
|
||||||
|
|
||||||
|
use App\Entity\Answer;
|
||||||
|
use App\Entity\Candidate;
|
||||||
use App\Entity\Elimination;
|
use App\Entity\Elimination;
|
||||||
|
use App\Entity\Question;
|
||||||
|
use App\Entity\Quiz;
|
||||||
use App\Entity\Season;
|
use App\Entity\Season;
|
||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||||
@@ -22,10 +26,17 @@ final class SeasonVoter extends Voter
|
|||||||
protected function supports(string $attribute, mixed $subject): bool
|
protected function supports(string $attribute, mixed $subject): bool
|
||||||
{
|
{
|
||||||
return \in_array($attribute, [self::EDIT, self::DELETE, self::ELIMINATION], true)
|
return \in_array($attribute, [self::EDIT, self::DELETE, self::ELIMINATION], true)
|
||||||
&& ($subject instanceof Season || $subject instanceof Elimination);
|
&& (
|
||||||
|
$subject instanceof Season
|
||||||
|
|| $subject instanceof Elimination
|
||||||
|
|| $subject instanceof Quiz
|
||||||
|
|| $subject instanceof Candidate
|
||||||
|
|| $subject instanceof Answer
|
||||||
|
|| $subject instanceof Question
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param Season|Elimination $subject */
|
/** @param Season|Elimination|Quiz|Candidate|Answer|Question $subject */
|
||||||
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
|
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
|
||||||
{
|
{
|
||||||
$user = $token->getUser();
|
$user = $token->getUser();
|
||||||
@@ -37,7 +48,24 @@ final class SeasonVoter extends Voter
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
$season = $subject instanceof Season ? $subject : $subject->getQuiz()->getSeason();
|
switch (true) {
|
||||||
|
case $subject instanceof Answer:
|
||||||
|
$season = $subject->getQuestion()->getQuiz()->getSeason();
|
||||||
|
break;
|
||||||
|
case $subject instanceof Elimination:
|
||||||
|
case $subject instanceof Question:
|
||||||
|
$season = $subject->getQuiz()->getSeason();
|
||||||
|
break;
|
||||||
|
case $subject instanceof Candidate:
|
||||||
|
case $subject instanceof Quiz:
|
||||||
|
$season = $subject->getSeason();
|
||||||
|
break;
|
||||||
|
case $subject instanceof Season:
|
||||||
|
$season = $subject;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
return match ($attribute) {
|
return match ($attribute) {
|
||||||
self::EDIT, self::DELETE, self::ELIMINATION => $season->isOwner($user),
|
self::EDIT, self::DELETE, self::ELIMINATION => $season->isOwner($user),
|
||||||
|
|||||||
@@ -74,10 +74,10 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col">{{ 'Candidate'|trans }}</th>
|
<th scope="col">{{ 'Candidate'|trans }}</th>
|
||||||
<th scope="col">{{ 'Correct Answers'|trans }}</th>
|
<th style="width: 15%" scope="col">{{ 'Correct Answers'|trans }}</th>
|
||||||
<th scope="col">{{ 'Corrections'|trans }}</th>
|
<th style="width: 20%" scope="col">{{ 'Corrections'|trans }}</th>
|
||||||
<th scope="col">{{ 'Score'|trans }}</th>
|
<th style="width: 10%" scope="col">{{ 'Score'|trans }}</th>
|
||||||
<th scope="col">{{ 'Time'|trans }}</th>
|
<th style="width: 20%" scope="col">{{ 'Time'|trans }}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -85,7 +85,21 @@
|
|||||||
<tr class="table-{% if loop.revindex > quiz.dropouts %}success{% else %}danger{% endif %}">
|
<tr class="table-{% if loop.revindex > quiz.dropouts %}success{% else %}danger{% endif %}">
|
||||||
<td>{{ candidate.name }}</td>
|
<td>{{ candidate.name }}</td>
|
||||||
<td>{{ candidate.correct|default('0') }}</td>
|
<td>{{ candidate.correct|default('0') }}</td>
|
||||||
<td>{{ candidate.corrections|default('0') }}</td>
|
<td>
|
||||||
|
<form method="post"
|
||||||
|
action="{{ path('app_backoffice_modify_correction', {quiz: quiz.id, candidate: candidate.id}) }}">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-8">
|
||||||
|
<input class="form-control form-control-sm" type="number"
|
||||||
|
value="{{ candidate.corrections }}" step="0.5"
|
||||||
|
name="corrections">
|
||||||
|
</div>
|
||||||
|
<div class="col-2">
|
||||||
|
<button class="btn btn-sm btn-primary" type="submit">{{ 'Save'|trans }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
<td>{{ candidate.score|default('x') }}</td>
|
<td>{{ candidate.score|default('x') }}</td>
|
||||||
<td>{{ candidate.time }}</td>
|
<td>{{ candidate.time }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
class="elimination-screen" id="{{ colour }}"
|
class="elimination-screen" id="{{ colour }}"
|
||||||
alt="Screen with colour {{ colour }}"
|
alt="Screen with colour {{ colour }}"
|
||||||
data-controller="elimination"
|
data-controller="elimination"
|
||||||
data-action="click->elimination#next"
|
data-action="click->elimination#next keydown@document->elimination#next"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
>
|
>
|
||||||
|
|
||||||
|
|||||||
77
tests/Security/Voter/SeasonVoterTest.php
Normal file
77
tests/Security/Voter/SeasonVoterTest.php
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Security\Voter;
|
||||||
|
|
||||||
|
use App\Entity\Answer;
|
||||||
|
use App\Entity\Candidate;
|
||||||
|
use App\Entity\Elimination;
|
||||||
|
use App\Entity\Question;
|
||||||
|
use App\Entity\Quiz;
|
||||||
|
use App\Entity\Season;
|
||||||
|
use App\Entity\User;
|
||||||
|
use App\Security\Voter\SeasonVoter;
|
||||||
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
|
use PHPUnit\Framework\MockObject\Stub;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||||
|
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('typesProvider')]
|
||||||
|
public function testWithTypes(mixed $subject): void
|
||||||
|
{
|
||||||
|
$this->assertSame(VoterInterface::ACCESS_GRANTED, $this->seasonVoter->vote($this->token, $subject, ['SEASON_EDIT']));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNotOwnerWillReturnDenied(): void
|
||||||
|
{
|
||||||
|
$season = self::createStub(Season::class);
|
||||||
|
$season->method('isOwner')->willReturn(false);
|
||||||
|
|
||||||
|
$this->assertSame(VoterInterface::ACCESS_DENIED, $this->seasonVoter->vote($this->token, $season, ['SEASON_EDIT']));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function typesProvider(): \Generator
|
||||||
|
{
|
||||||
|
$season = self::createStub(Season::class);
|
||||||
|
$season->method('isOwner')->willReturn(true);
|
||||||
|
|
||||||
|
$quiz = self::createStub(Quiz::class);
|
||||||
|
$quiz->method('getSeason')->willReturn($season);
|
||||||
|
|
||||||
|
$elimination = self::createStub(Elimination::class);
|
||||||
|
$elimination->method('getQuiz')->willReturn($quiz);
|
||||||
|
|
||||||
|
$candidate = self::createStub(Candidate::class);
|
||||||
|
$candidate->method('getSeason')->willReturn($season);
|
||||||
|
|
||||||
|
$question = self::createStub(Question::class);
|
||||||
|
$question->method('getQuiz')->willReturn($quiz);
|
||||||
|
|
||||||
|
$answer = self::createStub(Answer::class);
|
||||||
|
$answer->method('getQuestion')->willReturn($question);
|
||||||
|
|
||||||
|
yield 'Season' => [$season];
|
||||||
|
yield 'Elimination' => [$elimination];
|
||||||
|
yield 'Quiz' => [$quiz];
|
||||||
|
yield 'Candidate' => [$candidate];
|
||||||
|
yield 'Question' => [$question];
|
||||||
|
yield 'Answer' => [$answer];
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user