feat: address PR review comments — unassign/sync bank questions, blank quiz creation, deactivate redirect, remove duplicate tab titles

- Use Symfony ObjectMapper for BankQuestion/BankAnswer → Question/Answer copy (#[Map(if: false)] on id, season, etc.)
- Track created Question on BankQuestionUsage (nullable FK, onDelete: SET NULL) for unassign/sync support
- Add unassign route: removes the Question copy + usage record
- Add sync route: pushes bank question edits to a finalized-not-started quiz copy
- Auto-sync non-finalized quiz copies on bank question edit; flash warning for finalized-not-started
- Add blank quiz creation (no XLSX required) with new route + template
- Deactivate quiz button now stays on the quiz overview page (redirect_quiz hidden field)
- Remove duplicate h4 titles below the tab bar on all season tabs
- Add migration for bank_question_usage.question_id
- Add Dutch translations for all new strings
This commit is contained in:
2026-07-04 21:54:44 +02:00
parent f05f88bcc5
commit 8304d8680b
15 changed files with 329 additions and 23 deletions
+32
View File
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260704200000 extends AbstractMigration
{
#[\Override]
public function getDescription(): string
{
return 'Add question_id FK to bank_question_usage for unassign and sync support';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE bank_question_usage ADD question_id UUID DEFAULT NULL');
$this->addSql('ALTER TABLE bank_question_usage ADD CONSTRAINT FK_BQU_QUESTION FOREIGN KEY (question_id) REFERENCES question (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('CREATE INDEX IDX_BQU_QUESTION ON bank_question_usage (question_id)');
}
#[\Override]
public function down(Schema $schema): void
{
$this->addSql('DROP INDEX IDX_BQU_QUESTION');
$this->addSql('ALTER TABLE bank_question_usage DROP CONSTRAINT FK_BQU_QUESTION');
$this->addSql('ALTER TABLE bank_question_usage DROP COLUMN question_id');
}
}
@@ -19,6 +19,7 @@ use Symfony\Component\Uid\Uuid;
use Symfony\Contracts\Translation\TranslatorInterface;
use Tvdt\Controller\AbstractController;
use Tvdt\Entity\BankQuestion;
use Tvdt\Entity\BankQuestionUsage;
use Tvdt\Entity\QuestionLabel;
use Tvdt\Entity\Quiz;
use Tvdt\Entity\Season;
@@ -121,6 +122,8 @@ class QuestionBankController extends AbstractController
$this->applyAnswerOrdering($bankQuestion);
$this->em->flush();
$this->syncUsagesAfterEdit($bankQuestion);
$this->addFlash(FlashType::Success, $this->translator->trans('Question updated'));
return $this->redirectToRoute('tvdt_backoffice_question_bank', ['seasonCode' => $season->seasonCode]);
@@ -245,6 +248,64 @@ class QuestionBankController extends AbstractController
return $this->redirectToRoute('tvdt_backoffice_question_bank', ['seasonCode' => $season->seasonCode]);
}
#[IsCsrfTokenValid('unassign_bank_question')]
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
#[Route(
'/backoffice/season/{seasonCode:season}/question-bank/{bankQuestion}/unassign/{usage}',
name: 'tvdt_backoffice_question_bank_unassign',
requirements: ['seasonCode' => self::SEASON_CODE_REGEX, 'bankQuestion' => Requirement::UUID, 'usage' => Requirement::UUID],
methods: ['POST'],
priority: 10,
)]
public function unassign(Season $season, BankQuestion $bankQuestion, BankQuestionUsage $usage): RedirectResponse
{
$this->assertSameSeason($season, $bankQuestion->season);
if ($usage->bankQuestion !== $bankQuestion) {
throw new NotFoundHttpException();
}
if ($usage->quiz->isLocked()) {
$this->addFlash(FlashType::Danger, $this->translator->trans('This quiz can no longer be altered'));
return $this->redirectToRoute('tvdt_backoffice_question_bank', ['seasonCode' => $season->seasonCode]);
}
$this->questionBankService->unassignFromQuiz($usage);
$this->addFlash(FlashType::Success, $this->translator->trans('Question removed from quiz %quiz%', ['%quiz%' => $usage->quiz->name]));
return $this->redirectToRoute('tvdt_backoffice_question_bank', ['seasonCode' => $season->seasonCode]);
}
#[IsCsrfTokenValid('sync_bank_question')]
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
#[Route(
'/backoffice/season/{seasonCode:season}/question-bank/{bankQuestion}/sync/{usage}',
name: 'tvdt_backoffice_question_bank_sync',
requirements: ['seasonCode' => self::SEASON_CODE_REGEX, 'bankQuestion' => Requirement::UUID, 'usage' => Requirement::UUID],
methods: ['POST'],
priority: 10,
)]
public function syncToQuiz(Season $season, BankQuestion $bankQuestion, BankQuestionUsage $usage): RedirectResponse
{
$this->assertSameSeason($season, $bankQuestion->season);
if ($usage->bankQuestion !== $bankQuestion) {
throw new NotFoundHttpException();
}
if ($usage->quiz->hasStartedCandidates()) {
$this->addFlash(FlashType::Danger, $this->translator->trans('This quiz has already been filled in and can no longer be altered'));
return $this->redirectToRoute('tvdt_backoffice_question_bank', ['seasonCode' => $season->seasonCode]);
}
$this->questionBankService->syncToQuiz($bankQuestion, $usage);
$this->addFlash(FlashType::Success, $this->translator->trans('Question synced to quiz %quiz%', ['%quiz%' => $usage->quiz->name]));
return $this->redirectToRoute('tvdt_backoffice_question_bank', ['seasonCode' => $season->seasonCode]);
}
private function assertSameSeason(Season $season, Season $subjectSeason): void
{
if ($season !== $subjectSeason) {
@@ -259,4 +320,26 @@ class QuestionBankController extends AbstractController
$answer->ordering = $ordering++;
}
}
private function syncUsagesAfterEdit(BankQuestion $bankQuestion): void
{
$pendingNames = [];
foreach ($bankQuestion->usages as $usage) {
if (!$usage->quiz->isFinalized()) {
$this->questionBankService->syncToQuiz($bankQuestion, $usage);
} elseif (!$usage->quiz->hasStartedCandidates()) {
$pendingNames[] = $usage->quiz->name;
}
}
if ([] !== $pendingNames) {
$this->addFlash(
FlashType::Warning,
$this->translator->trans(
'The question was not synced to finalized quiz(zes): %quizzes%. Use the Sync button to update them.',
['%quizzes%' => implode(', ', $pendingNames)],
),
);
}
}
}
+12 -3
View File
@@ -252,19 +252,28 @@ class QuizController extends AbstractController
requirements: ['seasonCode' => self::SEASON_CODE_REGEX, 'quiz' => Requirement::UUID.'|null'],
methods: ['POST'],
)]
public function enableQuiz(Season $season, ?Quiz $quiz): RedirectResponse
public function enableQuiz(Season $season, ?Quiz $quiz, Request $request): RedirectResponse
{
if ($quiz instanceof Quiz && !$quiz->isFinalized()) {
$this->addFlash(FlashType::Danger, $this->translator->trans('The quiz must be finalized before it can be activated'));
return $this->redirectToRoute('tvdt_backoffice_quiz', ['seasonCode' => $season->seasonCode, 'quiz' => $quiz->id]);
return $this->redirectToRoute('tvdt_backoffice_quiz_overview', ['seasonCode' => $season->seasonCode, 'quiz' => $quiz->id]);
}
$season->activeQuiz = $quiz;
$this->em->flush();
if ($quiz instanceof Quiz) {
return $this->redirectToRoute('tvdt_backoffice_quiz', ['seasonCode' => $season->seasonCode, 'quiz' => $quiz->id]);
return $this->redirectToRoute('tvdt_backoffice_quiz_overview', ['seasonCode' => $season->seasonCode, 'quiz' => $quiz->id]);
}
// When deactivating, stay on the quiz page if one was passed
$previousQuizId = $request->request->getString('redirect_quiz');
if ('' !== $previousQuizId) {
$previousQuiz = $this->em->getRepository(Quiz::class)->find($previousQuizId);
if ($previousQuiz instanceof Quiz && $previousQuiz->season === $season) {
return $this->redirectToRoute('tvdt_backoffice_quiz_overview', ['seasonCode' => $season->seasonCode, 'quiz' => $previousQuiz->id]);
}
}
return $this->redirectToRoute('tvdt_backoffice_season', ['seasonCode' => $season->seasonCode]);
@@ -5,6 +5,8 @@ declare(strict_types=1);
namespace Tvdt\Controller\Backoffice;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -148,4 +150,38 @@ class SeasonController extends AbstractController
return $this->render('/backoffice/quiz_add.html.twig', ['form' => $form, 'season' => $season]);
}
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
#[Route(
'/backoffice/season/{seasonCode:season}/add-blank-quiz',
name: 'tvdt_backoffice_quiz_add_blank',
requirements: ['seasonCode' => self::SEASON_CODE_REGEX],
priority: 10,
)]
public function addBlankQuiz(Request $request, Season $season): Response
{
$form = $this->createFormBuilder(new Quiz())
->add('name', TextType::class, ['label' => 'Quiz name'])
->add('save', SubmitType::class, ['label' => 'Create'])
->getForm();
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
/** @var Quiz $quiz */
$quiz = $form->getData();
$quiz->season = $season;
$this->em->persist($quiz);
$this->em->flush();
$this->addFlash(FlashType::Success, $this->translator->trans('Quiz Added!'));
return $this->redirectToRoute('tvdt_backoffice_quiz_overview', [
'seasonCode' => $season->seasonCode,
'quiz' => $quiz->id,
]);
}
return $this->render('/backoffice/quiz_add_blank.html.twig', ['form' => $form, 'season' => $season]);
}
}
+3
View File
@@ -7,11 +7,13 @@ namespace Tvdt\Entity;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Types\UuidType;
use Symfony\Component\ObjectMapper\Attribute\Map;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity]
class BankAnswer implements \Stringable
{
#[Map(if: false)]
#[ORM\Column(type: UuidType::NAME)]
#[ORM\CustomIdGenerator(class: 'doctrine.uuid_generator')]
#[ORM\GeneratedValue(strategy: 'CUSTOM')]
@@ -21,6 +23,7 @@ class BankAnswer implements \Stringable
#[ORM\Column(type: Types::SMALLINT, options: ['default' => 0])]
public int $ordering = 0;
#[Map(if: false)]
#[ORM\JoinColumn(nullable: false)]
#[ORM\ManyToOne(inversedBy: 'answers')]
public BankQuestion $bankQuestion;
+7
View File
@@ -8,6 +8,7 @@ use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Types\UuidType;
use Symfony\Component\ObjectMapper\Attribute\Map;
use Symfony\Component\Uid\Uuid;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
@@ -16,6 +17,7 @@ use Tvdt\Repository\BankQuestionRepository;
#[ORM\Entity(repositoryClass: BankQuestionRepository::class)]
class BankQuestion implements \Stringable
{
#[Map(if: false)]
#[ORM\Column(type: UuidType::NAME)]
#[ORM\CustomIdGenerator(class: 'doctrine.uuid_generator')]
#[ORM\GeneratedValue(strategy: 'CUSTOM')]
@@ -25,24 +27,29 @@ class BankQuestion implements \Stringable
#[ORM\Column(length: 255)]
public string $question;
#[Map(if: false)]
#[ORM\JoinColumn(nullable: false)]
#[ORM\ManyToOne(inversedBy: 'bankQuestions')]
public Season $season;
#[Map(if: false)]
#[ORM\Column(options: ['default' => false])]
public bool $reusable = false;
/** @var Collection<int, QuestionLabel> */
#[Map(if: false)]
#[ORM\ManyToMany(targetEntity: QuestionLabel::class, inversedBy: 'bankQuestions')]
public private(set) Collection $labels;
/** @var Collection<int, BankAnswer> */
#[Assert\Count(min: 2, minMessage: 'A question needs at least two answers')]
#[Map(if: false)]
#[ORM\OneToMany(targetEntity: BankAnswer::class, mappedBy: 'bankQuestion', cascade: ['persist'], orphanRemoval: true)]
#[ORM\OrderBy(['ordering' => 'ASC'])]
public private(set) Collection $answers;
/** @var Collection<int, BankQuestionUsage> */
#[Map(if: false)]
#[ORM\OneToMany(targetEntity: BankQuestionUsage::class, mappedBy: 'bankQuestion', cascade: ['persist'], orphanRemoval: true)]
public private(set) Collection $usages;
+4
View File
@@ -24,6 +24,10 @@ class BankQuestionUsage
#[ORM\Column(type: Types::DATETIMETZ_IMMUTABLE, nullable: false)]
public private(set) \DateTimeImmutable $created;
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
#[ORM\ManyToOne]
public ?Question $question = null;
public function __construct(
#[ORM\JoinColumn(nullable: false)]
#[ORM\ManyToOne(inversedBy: 'usages')]
+56 -6
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Tvdt\Service;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\ObjectMapper\ObjectMapperInterface;
use Tvdt\Entity\Answer;
use Tvdt\Entity\BankQuestion;
use Tvdt\Entity\BankQuestionUsage;
@@ -15,7 +16,10 @@ use Tvdt\Exception\QuizLockedException;
final readonly class QuestionBankService
{
public function __construct(private EntityManagerInterface $entityManager) {}
public function __construct(
private EntityManagerInterface $entityManager,
private ObjectMapperInterface $objectMapper,
) {}
/**
* Copy a bank question (with its answers) into a quiz and record the usage.
@@ -42,20 +46,66 @@ final readonly class QuestionBankService
$maxOrdering = max($maxOrdering, $existingQuestion->ordering);
}
$question = new Question();
$question->question = $bankQuestion->question;
/** @var Question $question */
$question = $this->objectMapper->map($bankQuestion, Question::class);
$question->ordering = $maxOrdering + 1;
foreach ($bankQuestion->answers as $bankAnswer) {
$answer = new Answer($bankAnswer->text, $bankAnswer->isRightAnswer);
$answer->ordering = $bankAnswer->ordering;
/** @var Answer $answer */
$answer = $this->objectMapper->map($bankAnswer, Answer::class);
$question->addAnswer($answer);
}
$quiz->addQuestion($question);
$bankQuestion->addUsage(new BankQuestionUsage($bankQuestion, $quiz));
$usage = new BankQuestionUsage($bankQuestion, $quiz);
$usage->question = $question;
$bankQuestion->addUsage($usage);
$this->entityManager->persist($question);
$this->entityManager->flush();
}
/**
* Propagate bank question edits to a quiz copy.
* Only safe on quizzes where no candidate has started (no GivenAnswers exist yet).
*/
public function syncToQuiz(BankQuestion $bankQuestion, BankQuestionUsage $usage): void
{
$question = $usage->question;
if (!$question instanceof Question) {
return;
}
$question->question = $bankQuestion->question;
// Replace answers (safe: no started candidates means no GivenAnswers)
foreach ($question->answers->toArray() as $existingAnswer) {
$question->answers->removeElement($existingAnswer);
$this->entityManager->remove($existingAnswer);
}
foreach ($bankQuestion->answers as $bankAnswer) {
/** @var Answer $answer */
$answer = $this->objectMapper->map($bankAnswer, Answer::class);
$question->addAnswer($answer);
}
$this->entityManager->flush();
}
/** Remove the quiz copy created by this usage and delete the usage record. */
public function unassignFromQuiz(BankQuestionUsage $usage): void
{
$question = $usage->question;
if ($question instanceof Question) {
$question->quiz->questions->removeElement($question);
$this->entityManager->remove($question);
}
$usage->bankQuestion->usages->removeElement($usage);
$this->entityManager->remove($usage);
$this->entityManager->flush();
}
}
@@ -38,6 +38,7 @@
{% if quiz is same as (season.activeQuiz) %}
<form action="{{ path('tvdt_backoffice_enable', {seasonCode: season.seasonCode, quiz: 'null'}) }}" method="POST">
<input type="hidden" name="_token" value="{{ csrf_token('enable_quiz') }}">
<input type="hidden" name="redirect_quiz" value="{{ quiz.id }}">
<button type="submit" class="btn btn-secondary rounded-0 rounded-start">
{{ 'Deactivate Quiz'|trans }}
</button>
@@ -0,0 +1,28 @@
{% extends 'backoffice/base.html.twig' %}
{% block breadcrumbs %}
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ path('tvdt_backoffice_index') }}">{{ 'Home'|trans }}</a></li>
<li class="breadcrumb-item"><a href="{{ path('tvdt_backoffice_season', {seasonCode: season.seasonCode}) }}">{{ season.name }}</a></li>
<li class="breadcrumb-item active" aria-current="page">{{ 'Add blank quiz'|trans }}</li>
</ol>
</nav>
{% endblock %}
{% block body %}
<div class="row">
<div class="col-md-6 col-12">
<h2 class="mb-3">{{ t('Add a quiz to {name}', {name: season.name})|trans }}</h2>
{{ form_start(form) }}
{{ form_row(form.name) }}
{{ form_widget(form.save, {attr: {class: 'btn btn-primary'}}) }}
{{ form_end(form) }}
</div>
<div class="col-md-6 col-12">
<p class="mb-3">{{ 'Create an empty quiz and add questions from the question bank.'|trans }}</p>
</div>
</div>
{% endblock %}
{% block title %}{{ parent() }}Backoffice{% endblock %}
@@ -1,10 +1,8 @@
<div class="row">
<div class="col-md-6 col-12">
<div class="d-flex align-items-center mb-3">
<h4 class="mb-0 pe-2">{{ 'Candidates'|trans }}</h4>
<a class="link"
href="{{ path('tvdt_backoffice_add_candidates', {seasonCode: season.seasonCode}) }}">{{ 'Add Candidate'|trans }}
</a>
<div class="mb-3">
<a class="btn btn-sm btn-outline-primary"
href="{{ path('tvdt_backoffice_add_candidates', {seasonCode: season.seasonCode}) }}">{{ 'Add Candidate'|trans }}</a>
</div>
<ul class="mb-3">
{% for candidate in season.candidates %}
@@ -1,6 +1,5 @@
<div class="d-flex align-items-center mb-3">
<h4 class="mb-0 pe-2">{{ 'Question bank'|trans }}</h4>
<a class="link" href="{{ path('tvdt_backoffice_question_bank_new', {seasonCode: season.seasonCode}) }}">{{ 'Add'|trans }}</a>
<div class="mb-3">
<a class="btn btn-sm btn-outline-primary" href="{{ path('tvdt_backoffice_question_bank_new', {seasonCode: season.seasonCode}) }}">{{ 'Add question'|trans }}</a>
</div>
<div class="d-flex align-items-center flex-wrap gap-2 mb-3">
@@ -51,7 +50,23 @@
{% endif %}
</td>
<td>
{{ bankQuestion.usages|map(usage => usage.quiz.name)|join(', ') }}
{% for usage in bankQuestion.usages %}
<div class="d-flex align-items-center gap-1">
<span>{{ usage.quiz.name }}</span>
<form action="{{ path('tvdt_backoffice_question_bank_unassign', {seasonCode: season.seasonCode, bankQuestion: bankQuestion.id, usage: usage.id}) }}"
method="POST" class="d-inline">
<input type="hidden" name="_token" value="{{ csrf_token('unassign_bank_question') }}">
<button type="submit" class="btn btn-sm btn-link p-0 text-danger" title="{{ 'Unassign'|trans }}">&times;</button>
</form>
{% if usage.quiz.isFinalized %}
<form action="{{ path('tvdt_backoffice_question_bank_sync', {seasonCode: season.seasonCode, bankQuestion: bankQuestion.id, usage: usage.id}) }}"
method="POST" class="d-inline">
<input type="hidden" name="_token" value="{{ csrf_token('sync_bank_question') }}">
<button type="submit" class="btn btn-sm btn-link p-0 text-primary" title="{{ 'Sync latest changes to this quiz'|trans }}">&#8635;</button>
</form>
{% endif %}
</div>
{% endfor %}
</td>
<td class="text-end">
<div class="d-inline-flex align-items-center gap-2">
@@ -1,6 +1,5 @@
<div class="row">
<div class="col-md-6 col-12">
<h4 class="mb-3">{{ 'Settings'|trans }}</h4>
{{ form(form) }}
</div>
</div>
@@ -1,9 +1,10 @@
<div class="row">
<div class="col-md-6 col-12">
<div class="d-flex align-items-center mb-3">
<h4 class="mb-0 pe-2">{{ 'Quizzes'|trans }}</h4>
<a class="link"
href="{{ path('tvdt_backoffice_quiz_add', {seasonCode: season.seasonCode}) }}">{{ 'Add'|trans }}</a>
<div class="d-flex align-items-center gap-2 mb-3">
<a class="btn btn-sm btn-outline-primary"
href="{{ path('tvdt_backoffice_quiz_add', {seasonCode: season.seasonCode}) }}">{{ 'Add from XLSX'|trans }}</a>
<a class="btn btn-sm btn-outline-secondary"
href="{{ path('tvdt_backoffice_quiz_add_blank', {seasonCode: season.seasonCode}) }}">{{ 'Add blank'|trans }}</a>
</div>
<div class="list-group mb-3">
{% for quiz in season.quizzes %}
+40
View File
@@ -45,6 +45,18 @@
<source>Add answer</source>
<target>Antwoord toevoegen</target>
</trans-unit>
<trans-unit id="hUN3NTt" resname="Add blank">
<source>Add blank</source>
<target>Leeg toevoegen</target>
</trans-unit>
<trans-unit id="nl03zPX" resname="Add blank quiz">
<source>Add blank quiz</source>
<target>Lege quiz toevoegen</target>
</trans-unit>
<trans-unit id="2Qmgp4S" resname="Add from XLSX">
<source>Add from XLSX</source>
<target>Toevoegen vanuit XLSX</target>
</trans-unit>
<trans-unit id="MJD3m3Q" resname="Add label">
<source>Add label</source>
<target>Label toevoegen</target>
@@ -153,6 +165,10 @@
<source>Create an account</source>
<target>Maak een account aan</target>
</trans-unit>
<trans-unit id="yBgKisV" resname="Create an empty quiz and add questions from the question bank.">
<source>Create an empty quiz and add questions from the question bank.</source>
<target>Maak een lege quiz aan en voeg vragen toe vanuit de vragenbank.</target>
</trans-unit>
<trans-unit id="S5P7nQd" resname="Deactivate">
<source>Deactivate</source>
<target>Deactiveren</target>
@@ -409,10 +425,18 @@
<source>Question bank</source>
<target>Vragenbank</target>
</trans-unit>
<trans-unit id="htaUa1k" resname="Question removed from quiz %quiz%">
<source>Question removed from quiz %quiz%</source>
<target>Vraag verwijderd uit quiz %quiz%</target>
</trans-unit>
<trans-unit id="KApairC" resname="Question removed from the question bank">
<source>Question removed from the question bank</source>
<target>Vraag verwijderd uit de vragenbank</target>
</trans-unit>
<trans-unit id="6rkejYf" resname="Question synced to quiz %quiz%">
<source>Question synced to quiz %quiz%</source>
<target>Vraag gesynchroniseerd naar quiz %quiz%</target>
</trans-unit>
<trans-unit id="MNSmL.W" resname="Question updated">
<source>Question updated</source>
<target>Vraag bijgewerkt</target>
@@ -549,10 +573,18 @@
<source>Submit</source>
<target>Verstuur</target>
</trans-unit>
<trans-unit id="yqJbSKu" resname="Sync latest changes to this quiz">
<source>Sync latest changes to this quiz</source>
<target>Laatste wijzigingen synchroniseren naar deze quiz</target>
</trans-unit>
<trans-unit id="_z4el3Z" resname="The password fields must match.">
<source>The password fields must match.</source>
<target>De wachtwoorden moeten overeen komen.</target>
</trans-unit>
<trans-unit id="1jF4vJ8" resname="The question was not synced to finalized quiz(zes): %quizzes%. Use the Sync button to update them.">
<source>The question was not synced to finalized quiz(zes): %quizzes%. Use the Sync button to update them.</source>
<target>De vraag is niet gesynchroniseerd naar gefinaliseerde quiz(zes): %quizzes%. Gebruik de Synchroniseren-knop om ze bij te werken.</target>
</trans-unit>
<trans-unit id="K3e_SRJ" resname="The quiz cannot be finalized while it has errors">
<source>The quiz cannot be finalized while it has errors</source>
<target>De test kan niet gefinaliseerd worden zolang er fouten zijn</target>
@@ -585,10 +617,18 @@
<source>This quiz can no longer be altered</source>
<target>Deze test kan niet meer aangepast worden</target>
</trans-unit>
<trans-unit id="uAJQGot" resname="This quiz has already been filled in and can no longer be altered">
<source>This quiz has already been filled in and can no longer be altered</source>
<target>Deze quiz is al ingevuld en kan niet meer worden gewijzigd</target>
</trans-unit>
<trans-unit id="Dptvysv" resname="Time">
<source>Time</source>
<target>Tijd</target>
</trans-unit>
<trans-unit id="XLYBGca" resname="Unassign">
<source>Unassign</source>
<target>Ontkoppelen</target>
</trans-unit>
<trans-unit id="A_XdODo" resname="Undo finalization">
<source>Undo finalization</source>
<target>Finalisatie ongedaan maken</target>