mirror of
https://github.com/MarijnDoeve/TijdVoorDeTest.git
synced 2026-07-05 15:10:16 +02:00
feat: add question bank management, quiz finalization, and related backend/frontend functionality
This commit is contained in:
Generated
+162
-162
@@ -41,169 +41,169 @@
|
||||
</component>
|
||||
<component name="PhpIncludePathManager">
|
||||
<include_path>
|
||||
<path value="$PROJECT_DIR$/vendor/psr/cache" />
|
||||
<path value="$PROJECT_DIR$/vendor/psr/container" />
|
||||
<path value="$PROJECT_DIR$/vendor/psr/log" />
|
||||
<path value="$PROJECT_DIR$/vendor/psr/event-dispatcher" />
|
||||
<path value="$PROJECT_DIR$/vendor/psr/http-message" />
|
||||
<path value="$PROJECT_DIR$/vendor/gedmo/doctrine-extensions" />
|
||||
<path value="$PROJECT_DIR$/vendor/nikic/php-parser" />
|
||||
<path value="$PROJECT_DIR$/vendor/twig/twig" />
|
||||
<path value="$PROJECT_DIR$/vendor/fidry/cpu-core-counter" />
|
||||
<path value="$PROJECT_DIR$/vendor/react/stream" />
|
||||
<path value="$PROJECT_DIR$/vendor/react/promise" />
|
||||
<path value="$PROJECT_DIR$/vendor/react/event-loop" />
|
||||
<path value="$PROJECT_DIR$/vendor/react/cache" />
|
||||
<path value="$PROJECT_DIR$/vendor/psr/http-factory" />
|
||||
<path value="$PROJECT_DIR$/vendor/clue/ndjson-react" />
|
||||
<path value="$PROJECT_DIR$/vendor/psr/simple-cache" />
|
||||
<path value="$PROJECT_DIR$/vendor/psr/clock" />
|
||||
<path value="$PROJECT_DIR$/vendor/twig/extra-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/twig/intl-extra" />
|
||||
<path value="$PROJECT_DIR$/vendor/dama/doctrine-test-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/stof/doctrine-extensions-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/phar-io/manifest" />
|
||||
<path value="$PROJECT_DIR$/vendor/phar-io/version" />
|
||||
<path value="$PROJECT_DIR$/vendor/egulias/email-validator" />
|
||||
<path value="$PROJECT_DIR$/vendor/myclabs/deep-copy" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpstan/phpstan-symfony" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpstan/phpstan-phpunit" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpstan/phpstan" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpstan/phpstan-doctrine" />
|
||||
<path value="$PROJECT_DIR$/vendor/react/child-process" />
|
||||
<path value="$PROJECT_DIR$/vendor/jean85/pretty-package-versions" />
|
||||
<path value="$PROJECT_DIR$/vendor/react/dns" />
|
||||
<path value="$PROJECT_DIR$/vendor/react/socket" />
|
||||
<path value="$PROJECT_DIR$/vendor/sentry/sentry" />
|
||||
<path value="$PROJECT_DIR$/vendor/staabm/side-effects-detector" />
|
||||
<path value="$PROJECT_DIR$/vendor/rector/rector" />
|
||||
<path value="$PROJECT_DIR$/vendor/sentry/sentry-symfony" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-icu" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/form" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/framework-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-deepclone" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/dotenv" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/validator" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/ux-turbo" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/serializer" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpunit/php-timer" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpunit/phpunit" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpstan/extension-installer" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpstan/phpdoc-parser" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpunit/php-invoker" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpunit/php-file-iterator" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpunit/php-text-template" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpunit/php-code-coverage" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/asset-mapper" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/event-dispatcher" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/security-http" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/uid" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/var-dumper" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/brevo-mailer" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/runtime" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/phpunit-bridge" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/browser-kit" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/security-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/cache" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/asset" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/translation-contracts" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/config" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/intl" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/translation" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/css-selector" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/event-dispatcher-contracts" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/web-profiler-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-normalizer" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/twig-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/finder" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/dom-crawler" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/property-access" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/options-resolver" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/security-csrf" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/mailer" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/filesystem" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/twig-bridge" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-uuid" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-idn" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/maker-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/service-contracts" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/http-client-contracts" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/deprecation-contracts" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/routing" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/http-foundation" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/doctrine-bridge" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/property-info" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/type-info" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/psr-http-message-bridge" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/http-kernel" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/http-client" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/stopwatch" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/string" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/security-core" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/yaml" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/mime" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-grapheme" />
|
||||
<path value="$PROJECT_DIR$/vendor/theseer/tokenizer" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/process" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/console" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/error-handler" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/cache-contracts" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/var-exporter" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/flex" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/clock" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/password-hasher" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/dependency-injection" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/stimulus-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/doctrine-migrations-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/lexer" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/dbal" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/migrations" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/data-fixtures" />
|
||||
<path value="$PROJECT_DIR$/vendor/ergebnis/agent-detector" />
|
||||
<path value="$PROJECT_DIR$/vendor/evenement/evenement" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/deprecations" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/doctrine-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/markbaker/matrix" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpoffice/phpspreadsheet" />
|
||||
<path value="$PROJECT_DIR$/vendor/maennchen/zipstream-php" />
|
||||
<path value="$PROJECT_DIR$/vendor/markbaker/complex" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/doctrine-fixtures-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/instantiator" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/inflector" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/persistence" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/collections" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/sql-formatter" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/orm" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/event-manager" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/type" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/version" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/lines-of-code" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/file-filter" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/exporter" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/git-state" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/cli-parser" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/environment" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/global-state" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/object-enumerator" />
|
||||
<path value="$PROJECT_DIR$/vendor/ralouphie/getallheaders" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/comparator" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/diff" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/recursion-context" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/object-reflector" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/complexity" />
|
||||
<path value="$PROJECT_DIR$/vendor/thecodingmachine/safe" />
|
||||
<path value="$PROJECT_DIR$/vendor/thecodingmachine/phpstan-safe-rule" />
|
||||
<path value="$PROJECT_DIR$/vendor/vincentlanglet/twig-cs-fixer" />
|
||||
<path value="$PROJECT_DIR$/vendor/martin-georgiev/postgresql-for-doctrine" />
|
||||
<path value="$PROJECT_DIR$/vendor/friendsofphp/php-cs-fixer" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfonycasts/verify-email-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/webmozart/assert" />
|
||||
<path value="$PROJECT_DIR$/vendor/guzzlehttp/psr7" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpdocumentor/type-resolver" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpdocumentor/reflection-docblock" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfonycasts/sass-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpdocumentor/reflection-common" />
|
||||
<path value="$PROJECT_DIR$/vendor/composer" />
|
||||
<path value="$PROJECT_DIR$/vendor/dama/doctrine-test-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/clue/ndjson-react" />
|
||||
<path value="$PROJECT_DIR$/vendor/psr/http-factory" />
|
||||
<path value="$PROJECT_DIR$/vendor/psr/clock" />
|
||||
<path value="$PROJECT_DIR$/vendor/twig/twig" />
|
||||
<path value="$PROJECT_DIR$/vendor/twig/intl-extra" />
|
||||
<path value="$PROJECT_DIR$/vendor/twig/extra-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/stof/doctrine-extensions-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/psr/event-dispatcher" />
|
||||
<path value="$PROJECT_DIR$/vendor/psr/cache" />
|
||||
<path value="$PROJECT_DIR$/vendor/psr/simple-cache" />
|
||||
<path value="$PROJECT_DIR$/vendor/psr/log" />
|
||||
<path value="$PROJECT_DIR$/vendor/psr/container" />
|
||||
<path value="$PROJECT_DIR$/vendor/psr/http-message" />
|
||||
<path value="$PROJECT_DIR$/vendor/rector/rector" />
|
||||
<path value="$PROJECT_DIR$/vendor/jean85/pretty-package-versions" />
|
||||
<path value="$PROJECT_DIR$/vendor/react/child-process" />
|
||||
<path value="$PROJECT_DIR$/vendor/react/socket" />
|
||||
<path value="$PROJECT_DIR$/vendor/egulias/email-validator" />
|
||||
<path value="$PROJECT_DIR$/vendor/staabm/side-effects-detector" />
|
||||
<path value="$PROJECT_DIR$/vendor/sentry/sentry" />
|
||||
<path value="$PROJECT_DIR$/vendor/sentry/sentry-symfony" />
|
||||
<path value="$PROJECT_DIR$/vendor/react/event-loop" />
|
||||
<path value="$PROJECT_DIR$/vendor/nikic/php-parser" />
|
||||
<path value="$PROJECT_DIR$/vendor/gedmo/doctrine-extensions" />
|
||||
<path value="$PROJECT_DIR$/vendor/fidry/cpu-core-counter" />
|
||||
<path value="$PROJECT_DIR$/vendor/react/dns" />
|
||||
<path value="$PROJECT_DIR$/vendor/react/promise" />
|
||||
<path value="$PROJECT_DIR$/vendor/react/stream" />
|
||||
<path value="$PROJECT_DIR$/vendor/react/cache" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpunit/php-text-template" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpunit/phpunit" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpunit/php-timer" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpstan/phpdoc-parser" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/framework-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpunit/php-file-iterator" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpunit/php-invoker" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpunit/php-code-coverage" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpstan/phpstan" />
|
||||
<path value="$PROJECT_DIR$/vendor/phar-io/version" />
|
||||
<path value="$PROJECT_DIR$/vendor/phar-io/manifest" />
|
||||
<path value="$PROJECT_DIR$/vendor/myclabs/deep-copy" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpstan/extension-installer" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpstan/phpstan-phpunit" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpstan/phpstan-symfony" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpstan/phpstan-doctrine" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/intl" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/security-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/browser-kit" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/asset" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/security-http" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/config" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/translation-contracts" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/translation" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/ux-turbo" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/form" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-icu" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-deepclone" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/cache" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/validator" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/dotenv" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/serializer" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-idn" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/security-csrf" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/options-resolver" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/filesystem" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/web-profiler-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-uuid" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/twig-bridge" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/maker-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/runtime" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/event-dispatcher" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/asset-mapper" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/uid" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/mailer" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/brevo-mailer" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/var-dumper" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/phpunit-bridge" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/yaml" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/http-kernel" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/psr-http-message-bridge" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/stopwatch" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/deprecation-contracts" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/security-core" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/string" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/mime" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/dom-crawler" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/event-dispatcher-contracts" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/css-selector" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-normalizer" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/http-client" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/finder" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/twig-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/property-access" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/dependency-injection" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/cache-contracts" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/error-handler" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/flex" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/process" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/password-hasher" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/clock" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/stimulus-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/property-info" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/http-client-contracts" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/service-contracts" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/routing" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/var-exporter" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/doctrine-bridge" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/http-foundation" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/type-info" />
|
||||
<path value="$PROJECT_DIR$/vendor/theseer/tokenizer" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-grapheme" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/console" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/orm" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/instantiator" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/doctrine-fixtures-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/persistence" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/deprecations" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/sql-formatter" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/collections" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/event-manager" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/migrations" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/doctrine-migrations-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/inflector" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/dbal" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/lexer" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/data-fixtures" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/object-reflector" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/object-enumerator" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/global-state" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/comparator" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/lines-of-code" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/recursion-context" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/diff" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/complexity" />
|
||||
<path value="$PROJECT_DIR$/vendor/maennchen/zipstream-php" />
|
||||
<path value="$PROJECT_DIR$/vendor/evenement/evenement" />
|
||||
<path value="$PROJECT_DIR$/vendor/ergebnis/agent-detector" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/doctrine-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/ralouphie/getallheaders" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpoffice/phpspreadsheet" />
|
||||
<path value="$PROJECT_DIR$/vendor/markbaker/matrix" />
|
||||
<path value="$PROJECT_DIR$/vendor/markbaker/complex" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfonycasts/sass-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfonycasts/verify-email-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/friendsofphp/php-cs-fixer" />
|
||||
<path value="$PROJECT_DIR$/vendor/guzzlehttp/psr7" />
|
||||
<path value="$PROJECT_DIR$/vendor/vincentlanglet/twig-cs-fixer" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpdocumentor/reflection-docblock" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpdocumentor/type-resolver" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpdocumentor/reflection-common" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/cli-parser" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/version" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/type" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/file-filter" />
|
||||
<path value="$PROJECT_DIR$/vendor/webmozart/assert" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/git-state" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/exporter" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/environment" />
|
||||
<path value="$PROJECT_DIR$/vendor/thecodingmachine/phpstan-safe-rule" />
|
||||
<path value="$PROJECT_DIR$/vendor/thecodingmachine/safe" />
|
||||
<path value="$PROJECT_DIR$/vendor/martin-georgiev/postgresql-for-doctrine" />
|
||||
</include_path>
|
||||
</component>
|
||||
<component name="PhpInterpreters">
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import {Controller} from '@hotwired/stimulus';
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ['collection'];
|
||||
static values = {prototype: String};
|
||||
|
||||
connect() {
|
||||
this.index = this.collectionTarget.children.length;
|
||||
}
|
||||
|
||||
addItem() {
|
||||
const item = document.createElement('div');
|
||||
item.innerHTML = this.prototypeValue.replace(/__name__/g, this.index);
|
||||
this.collectionTarget.appendChild(item.firstElementChild);
|
||||
this.index++;
|
||||
}
|
||||
|
||||
removeItem(event) {
|
||||
event.target.closest('[data-collection-item]').remove();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?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 Version20260704151112 extends AbstractMigration
|
||||
{
|
||||
#[\Override]
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add season question bank (bank_question, bank_answer, question_label, bank_question_usage) and quiz finalization';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('CREATE TABLE bank_answer (id UUID NOT NULL, ordering SMALLINT DEFAULT 0 NOT NULL, text VARCHAR(255) NOT NULL, is_right_answer BOOLEAN NOT NULL, bank_question_id UUID NOT NULL, PRIMARY KEY (id))');
|
||||
$this->addSql('CREATE INDEX IDX_FAB865583CAC40C0 ON bank_answer (bank_question_id)');
|
||||
$this->addSql('CREATE TABLE bank_question (id UUID NOT NULL, question VARCHAR(255) NOT NULL, reusable BOOLEAN DEFAULT false NOT NULL, season_id UUID NOT NULL, PRIMARY KEY (id))');
|
||||
$this->addSql('CREATE INDEX IDX_87B753C94EC001D1 ON bank_question (season_id)');
|
||||
$this->addSql('CREATE TABLE bank_question_question_label (bank_question_id UUID NOT NULL, question_label_id UUID NOT NULL, PRIMARY KEY (bank_question_id, question_label_id))');
|
||||
$this->addSql('CREATE INDEX IDX_856E26833CAC40C0 ON bank_question_question_label (bank_question_id)');
|
||||
$this->addSql('CREATE INDEX IDX_856E268350B19F35 ON bank_question_question_label (question_label_id)');
|
||||
$this->addSql('CREATE TABLE bank_question_usage (id UUID NOT NULL, created TIMESTAMP(0) WITH TIME ZONE NOT NULL, bank_question_id UUID NOT NULL, quiz_id UUID NOT NULL, PRIMARY KEY (id))');
|
||||
$this->addSql('CREATE INDEX IDX_775833AD3CAC40C0 ON bank_question_usage (bank_question_id)');
|
||||
$this->addSql('CREATE INDEX IDX_775833AD853CD175 ON bank_question_usage (quiz_id)');
|
||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_775833AD3CAC40C0853CD175 ON bank_question_usage (bank_question_id, quiz_id)');
|
||||
$this->addSql('CREATE TABLE question_label (id UUID NOT NULL, name VARCHAR(64) NOT NULL, season_id UUID NOT NULL, PRIMARY KEY (id))');
|
||||
$this->addSql('CREATE INDEX IDX_3E4C41EC4EC001D1 ON question_label (season_id)');
|
||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_3E4C41EC5E237E064EC001D1 ON question_label (name, season_id)');
|
||||
$this->addSql('ALTER TABLE bank_answer ADD CONSTRAINT FK_FAB865583CAC40C0 FOREIGN KEY (bank_question_id) REFERENCES bank_question (id) NOT DEFERRABLE');
|
||||
$this->addSql('ALTER TABLE bank_question ADD CONSTRAINT FK_87B753C94EC001D1 FOREIGN KEY (season_id) REFERENCES season (id) NOT DEFERRABLE');
|
||||
$this->addSql('ALTER TABLE bank_question_question_label ADD CONSTRAINT FK_856E26833CAC40C0 FOREIGN KEY (bank_question_id) REFERENCES bank_question (id) ON DELETE CASCADE');
|
||||
$this->addSql('ALTER TABLE bank_question_question_label ADD CONSTRAINT FK_856E268350B19F35 FOREIGN KEY (question_label_id) REFERENCES question_label (id) ON DELETE CASCADE');
|
||||
$this->addSql('ALTER TABLE bank_question_usage ADD CONSTRAINT FK_775833AD3CAC40C0 FOREIGN KEY (bank_question_id) REFERENCES bank_question (id) NOT DEFERRABLE');
|
||||
$this->addSql('ALTER TABLE bank_question_usage ADD CONSTRAINT FK_775833AD853CD175 FOREIGN KEY (quiz_id) REFERENCES quiz (id) ON DELETE CASCADE NOT DEFERRABLE');
|
||||
$this->addSql('ALTER TABLE question_label ADD CONSTRAINT FK_3E4C41EC4EC001D1 FOREIGN KEY (season_id) REFERENCES season (id) NOT DEFERRABLE');
|
||||
$this->addSql('ALTER TABLE quiz ADD finalized_at TIMESTAMP(0) WITH TIME ZONE DEFAULT NULL');
|
||||
// Backfill: quizzes that are currently active must stay valid under the new "finalized before activation" rule
|
||||
$this->addSql('UPDATE quiz SET finalized_at = NOW() WHERE id IN (SELECT active_quiz_id FROM season WHERE active_quiz_id IS NOT NULL)');
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE bank_answer DROP CONSTRAINT FK_FAB865583CAC40C0');
|
||||
$this->addSql('ALTER TABLE bank_question DROP CONSTRAINT FK_87B753C94EC001D1');
|
||||
$this->addSql('ALTER TABLE bank_question_question_label DROP CONSTRAINT FK_856E26833CAC40C0');
|
||||
$this->addSql('ALTER TABLE bank_question_question_label DROP CONSTRAINT FK_856E268350B19F35');
|
||||
$this->addSql('ALTER TABLE bank_question_usage DROP CONSTRAINT FK_775833AD3CAC40C0');
|
||||
$this->addSql('ALTER TABLE bank_question_usage DROP CONSTRAINT FK_775833AD853CD175');
|
||||
$this->addSql('ALTER TABLE question_label DROP CONSTRAINT FK_3E4C41EC4EC001D1');
|
||||
$this->addSql('DROP TABLE bank_answer');
|
||||
$this->addSql('DROP TABLE bank_question');
|
||||
$this->addSql('DROP TABLE bank_question_question_label');
|
||||
$this->addSql('DROP TABLE bank_question_usage');
|
||||
$this->addSql('DROP TABLE question_label');
|
||||
$this->addSql('ALTER TABLE quiz DROP finalized_at');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Controller\Backoffice;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Attribute\AsController;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Routing\Requirement\Requirement;
|
||||
use Symfony\Component\Security\Http\Attribute\IsCsrfTokenValid;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
use Tvdt\Controller\AbstractController;
|
||||
use Tvdt\Entity\BankQuestion;
|
||||
use Tvdt\Entity\QuestionLabel;
|
||||
use Tvdt\Entity\Quiz;
|
||||
use Tvdt\Entity\Season;
|
||||
use Tvdt\Enum\FlashType;
|
||||
use Tvdt\Exception\BankQuestionAlreadyUsedException;
|
||||
use Tvdt\Exception\QuizLockedException;
|
||||
use Tvdt\Form\BankQuestionFormType;
|
||||
use Tvdt\Repository\BankQuestionRepository;
|
||||
use Tvdt\Repository\QuizRepository;
|
||||
use Tvdt\Security\Voter\SeasonVoter;
|
||||
use Tvdt\Service\QuestionBankService;
|
||||
|
||||
#[AsController]
|
||||
#[IsGranted('ROLE_USER')]
|
||||
class QuestionBankController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TranslatorInterface $translator,
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly BankQuestionRepository $bankQuestionRepository,
|
||||
private readonly QuizRepository $quizRepository,
|
||||
private readonly QuestionBankService $questionBankService,
|
||||
) {}
|
||||
|
||||
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
|
||||
#[Route(
|
||||
'/backoffice/season/{seasonCode:season}/question-bank',
|
||||
name: 'tvdt_backoffice_question_bank',
|
||||
requirements: ['seasonCode' => self::SEASON_CODE_REGEX],
|
||||
priority: 10,
|
||||
)]
|
||||
public function index(Season $season, Request $request): Response
|
||||
{
|
||||
$label = null;
|
||||
$labelId = $request->query->getString('label');
|
||||
if ('' !== $labelId && Uuid::isValid($labelId)) {
|
||||
$label = $this->em->getRepository(QuestionLabel::class)->find($labelId);
|
||||
if ($label instanceof QuestionLabel && $label->season !== $season) {
|
||||
$label = null;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->render('backoffice/season.html.twig', [
|
||||
'season' => $season,
|
||||
'bankQuestions' => $this->bankQuestionRepository->findBySeason($season, $label),
|
||||
'assignableQuizzes' => $this->quizRepository->findAssignableForSeason($season),
|
||||
'activeLabel' => $label,
|
||||
'activeTab' => 'question-bank',
|
||||
'template' => 'backoffice/season/tab_question_bank.html.twig',
|
||||
]);
|
||||
}
|
||||
|
||||
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
|
||||
#[Route(
|
||||
'/backoffice/season/{seasonCode:season}/question-bank/new',
|
||||
name: 'tvdt_backoffice_question_bank_new',
|
||||
requirements: ['seasonCode' => self::SEASON_CODE_REGEX],
|
||||
priority: 10,
|
||||
)]
|
||||
public function new(Season $season, Request $request): Response
|
||||
{
|
||||
$bankQuestion = new BankQuestion();
|
||||
|
||||
$form = $this->createForm(BankQuestionFormType::class, $bankQuestion, ['season' => $season]);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$this->applyAnswerOrdering($bankQuestion);
|
||||
$season->addBankQuestion($bankQuestion);
|
||||
$this->em->persist($bankQuestion);
|
||||
$this->em->flush();
|
||||
|
||||
$this->addFlash(FlashType::Success, $this->translator->trans('Question added to the question bank'));
|
||||
|
||||
return $this->redirectToRoute('tvdt_backoffice_question_bank', ['seasonCode' => $season->seasonCode]);
|
||||
}
|
||||
|
||||
return $this->render('backoffice/question_bank/form.html.twig', [
|
||||
'season' => $season,
|
||||
'form' => $form,
|
||||
'bankQuestion' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
|
||||
#[Route(
|
||||
'/backoffice/season/{seasonCode:season}/question-bank/{bankQuestion}/edit',
|
||||
name: 'tvdt_backoffice_question_bank_edit',
|
||||
requirements: ['seasonCode' => self::SEASON_CODE_REGEX, 'bankQuestion' => Requirement::UUID],
|
||||
priority: 10,
|
||||
)]
|
||||
public function edit(Season $season, BankQuestion $bankQuestion, Request $request): Response
|
||||
{
|
||||
$this->assertSameSeason($season, $bankQuestion->season);
|
||||
|
||||
$form = $this->createForm(BankQuestionFormType::class, $bankQuestion, ['season' => $season]);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$this->applyAnswerOrdering($bankQuestion);
|
||||
$this->em->flush();
|
||||
|
||||
$this->addFlash(FlashType::Success, $this->translator->trans('Question updated'));
|
||||
|
||||
return $this->redirectToRoute('tvdt_backoffice_question_bank', ['seasonCode' => $season->seasonCode]);
|
||||
}
|
||||
|
||||
return $this->render('backoffice/question_bank/form.html.twig', [
|
||||
'season' => $season,
|
||||
'form' => $form,
|
||||
'bankQuestion' => $bankQuestion,
|
||||
]);
|
||||
}
|
||||
|
||||
#[IsCsrfTokenValid('delete_bank_question')]
|
||||
#[IsGranted(SeasonVoter::DELETE, subject: 'season')]
|
||||
#[Route(
|
||||
'/backoffice/season/{seasonCode:season}/question-bank/{bankQuestion}/delete',
|
||||
name: 'tvdt_backoffice_question_bank_delete',
|
||||
requirements: ['seasonCode' => self::SEASON_CODE_REGEX, 'bankQuestion' => Requirement::UUID],
|
||||
methods: ['POST'],
|
||||
priority: 10,
|
||||
)]
|
||||
public function delete(Season $season, BankQuestion $bankQuestion): RedirectResponse
|
||||
{
|
||||
$this->assertSameSeason($season, $bankQuestion->season);
|
||||
|
||||
$this->em->remove($bankQuestion);
|
||||
$this->em->flush();
|
||||
|
||||
$this->addFlash(FlashType::Success, $this->translator->trans('Question removed from the question bank'));
|
||||
|
||||
return $this->redirectToRoute('tvdt_backoffice_question_bank', ['seasonCode' => $season->seasonCode]);
|
||||
}
|
||||
|
||||
#[IsCsrfTokenValid('assign_bank_question')]
|
||||
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
|
||||
#[Route(
|
||||
'/backoffice/season/{seasonCode:season}/question-bank/{bankQuestion}/assign',
|
||||
name: 'tvdt_backoffice_question_bank_assign',
|
||||
requirements: ['seasonCode' => self::SEASON_CODE_REGEX, 'bankQuestion' => Requirement::UUID],
|
||||
methods: ['POST'],
|
||||
priority: 10,
|
||||
)]
|
||||
public function assign(Season $season, BankQuestion $bankQuestion, Request $request): RedirectResponse
|
||||
{
|
||||
$this->assertSameSeason($season, $bankQuestion->season);
|
||||
|
||||
$quizId = $request->request->getString('quiz');
|
||||
if (!Uuid::isValid($quizId)) {
|
||||
throw new BadRequestHttpException('Invalid quiz');
|
||||
}
|
||||
|
||||
$quiz = $this->em->getRepository(Quiz::class)->find($quizId);
|
||||
if (!$quiz instanceof Quiz || $quiz->season !== $season) {
|
||||
throw new BadRequestHttpException('Invalid quiz');
|
||||
}
|
||||
|
||||
$this->denyAccessUnlessGranted(SeasonVoter::MODIFY_QUIZ_CONTENT, $quiz);
|
||||
|
||||
try {
|
||||
$this->questionBankService->assignToQuiz($bankQuestion, $quiz);
|
||||
$this->addFlash(FlashType::Success, $this->translator->trans('Question added to quiz %quiz%', ['%quiz%' => $quiz->name]));
|
||||
} catch (QuizLockedException) {
|
||||
$this->addFlash(FlashType::Danger, $this->translator->trans('This quiz can no longer be altered'));
|
||||
} catch (BankQuestionAlreadyUsedException) {
|
||||
$this->addFlash(FlashType::Danger, $this->translator->trans('This question has already been used'));
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('tvdt_backoffice_question_bank', ['seasonCode' => $season->seasonCode]);
|
||||
}
|
||||
|
||||
#[IsCsrfTokenValid('add_question_label')]
|
||||
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
|
||||
#[Route(
|
||||
'/backoffice/season/{seasonCode:season}/question-bank/labels',
|
||||
name: 'tvdt_backoffice_question_bank_labels',
|
||||
requirements: ['seasonCode' => self::SEASON_CODE_REGEX],
|
||||
methods: ['POST'],
|
||||
priority: 15,
|
||||
)]
|
||||
public function addLabel(Season $season, Request $request): RedirectResponse
|
||||
{
|
||||
$name = mb_trim($request->request->getString('name'));
|
||||
|
||||
if ('' === $name || mb_strlen($name) > 64) {
|
||||
$this->addFlash(FlashType::Danger, $this->translator->trans('Invalid label name'));
|
||||
|
||||
return $this->redirectToRoute('tvdt_backoffice_question_bank', ['seasonCode' => $season->seasonCode]);
|
||||
}
|
||||
|
||||
$exists = $season->questionLabels->exists(static fn (int $key, QuestionLabel $label): bool => $label->name === $name);
|
||||
if (!$exists) {
|
||||
$season->addQuestionLabel(new QuestionLabel($name));
|
||||
$this->em->flush();
|
||||
$this->addFlash(FlashType::Success, $this->translator->trans('Label added'));
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('tvdt_backoffice_question_bank', ['seasonCode' => $season->seasonCode]);
|
||||
}
|
||||
|
||||
#[IsCsrfTokenValid('delete_question_label')]
|
||||
#[IsGranted(SeasonVoter::DELETE, subject: 'season')]
|
||||
#[Route(
|
||||
'/backoffice/season/{seasonCode:season}/question-bank/labels/{label}/delete',
|
||||
name: 'tvdt_backoffice_question_bank_label_delete',
|
||||
requirements: ['seasonCode' => self::SEASON_CODE_REGEX, 'label' => Requirement::UUID],
|
||||
methods: ['POST'],
|
||||
priority: 15,
|
||||
)]
|
||||
public function deleteLabel(Season $season, QuestionLabel $label): RedirectResponse
|
||||
{
|
||||
$this->assertSameSeason($season, $label->season);
|
||||
|
||||
foreach ($label->bankQuestions as $bankQuestion) {
|
||||
$bankQuestion->removeLabel($label);
|
||||
}
|
||||
|
||||
$this->em->remove($label);
|
||||
$this->em->flush();
|
||||
|
||||
$this->addFlash(FlashType::Success, $this->translator->trans('Label removed'));
|
||||
|
||||
return $this->redirectToRoute('tvdt_backoffice_question_bank', ['seasonCode' => $season->seasonCode]);
|
||||
}
|
||||
|
||||
private function assertSameSeason(Season $season, Season $subjectSeason): void
|
||||
{
|
||||
if ($season !== $subjectSeason) {
|
||||
throw new NotFoundHttpException();
|
||||
}
|
||||
}
|
||||
|
||||
private function applyAnswerOrdering(BankQuestion $bankQuestion): void
|
||||
{
|
||||
$ordering = 1;
|
||||
foreach ($bankQuestion->answers as $answer) {
|
||||
$answer->ordering = $ordering++;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace Tvdt\Controller\Backoffice;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Safe\DateTimeImmutable;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
@@ -22,6 +23,7 @@ use Tvdt\Entity\Question;
|
||||
use Tvdt\Entity\Quiz;
|
||||
use Tvdt\Entity\QuizCandidate;
|
||||
use Tvdt\Entity\Season;
|
||||
use Tvdt\Enum\FlashType;
|
||||
use Tvdt\Exception\ErrorClearingQuizException;
|
||||
use Tvdt\Repository\QuizCandidateRepository;
|
||||
use Tvdt\Repository\QuizRepository;
|
||||
@@ -252,6 +254,12 @@ class QuizController extends AbstractController
|
||||
)]
|
||||
public function enableQuiz(Season $season, ?Quiz $quiz): 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]);
|
||||
}
|
||||
|
||||
$season->activeQuiz = $quiz;
|
||||
$this->em->flush();
|
||||
|
||||
@@ -274,7 +282,9 @@ class QuizController extends AbstractController
|
||||
{
|
||||
try {
|
||||
$this->quizRepository->clearQuiz($quiz);
|
||||
$this->addFlash('success', $this->translator->trans('Quiz cleared'));
|
||||
$quiz->finalizedAt = null;
|
||||
$this->em->flush();
|
||||
$this->addFlash('success', $this->translator->trans('Quiz cleared and no longer finalized'));
|
||||
} catch (ErrorClearingQuizException) {
|
||||
$this->addFlash('error', $this->translator->trans('Error clearing quiz'));
|
||||
}
|
||||
@@ -282,6 +292,50 @@ class QuizController extends AbstractController
|
||||
return $this->redirectToRoute('tvdt_backoffice_quiz', ['seasonCode' => $quiz->season->seasonCode, 'quiz' => $quiz->id]);
|
||||
}
|
||||
|
||||
#[IsCsrfTokenValid('finalize_quiz')]
|
||||
#[IsGranted(SeasonVoter::EDIT, subject: 'quiz')]
|
||||
#[Route(
|
||||
'/backoffice/quiz/{quiz}/finalize',
|
||||
name: 'tvdt_backoffice_quiz_finalize',
|
||||
requirements: ['quiz' => Requirement::UUID],
|
||||
methods: ['POST'],
|
||||
)]
|
||||
public function finalizeQuiz(Quiz $quiz): RedirectResponse
|
||||
{
|
||||
if ($quiz->questions->isEmpty() || [] !== $quiz->getQuestionErrors()) {
|
||||
$this->addFlash(FlashType::Warning, $this->translator->trans('The quiz cannot be finalized while it has errors'));
|
||||
} elseif (!$quiz->isFinalized()) {
|
||||
$quiz->finalizedAt = new DateTimeImmutable();
|
||||
$this->em->flush();
|
||||
$this->addFlash('success', $this->translator->trans('Quiz finalized'));
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('tvdt_backoffice_quiz', ['seasonCode' => $quiz->season->seasonCode, 'quiz' => $quiz->id]);
|
||||
}
|
||||
|
||||
#[IsCsrfTokenValid('unfinalize_quiz')]
|
||||
#[IsGranted(SeasonVoter::EDIT, subject: 'quiz')]
|
||||
#[Route(
|
||||
'/backoffice/quiz/{quiz}/unfinalize',
|
||||
name: 'tvdt_backoffice_quiz_unfinalize',
|
||||
requirements: ['quiz' => Requirement::UUID],
|
||||
methods: ['POST'],
|
||||
)]
|
||||
public function unfinalizeQuiz(Quiz $quiz): RedirectResponse
|
||||
{
|
||||
if ($quiz->hasStartedCandidates()) {
|
||||
$this->addFlash(FlashType::Danger, $this->translator->trans('The quiz has already been filled in and can no longer be altered'));
|
||||
} elseif ($quiz->season->activeQuiz === $quiz) {
|
||||
$this->addFlash(FlashType::Danger, $this->translator->trans('Deactivate the quiz before undoing the finalization'));
|
||||
} else {
|
||||
$quiz->finalizedAt = null;
|
||||
$this->em->flush();
|
||||
$this->addFlash('success', $this->translator->trans('Quiz is no longer finalized'));
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('tvdt_backoffice_quiz', ['seasonCode' => $quiz->season->seasonCode, 'quiz' => $quiz->id]);
|
||||
}
|
||||
|
||||
#[IsCsrfTokenValid('delete_quiz')]
|
||||
#[IsGranted(SeasonVoter::DELETE, subject: 'quiz')]
|
||||
#[Route(
|
||||
|
||||
@@ -39,7 +39,39 @@ class SeasonController extends AbstractController
|
||||
name: 'tvdt_backoffice_season',
|
||||
requirements: ['seasonCode' => self::SEASON_CODE_REGEX],
|
||||
)]
|
||||
public function index(Season $season, Request $request): Response
|
||||
public function index(Season $season): Response
|
||||
{
|
||||
return $this->render('backoffice/season.html.twig', [
|
||||
'season' => $season,
|
||||
'activeTab' => 'tests',
|
||||
'template' => 'backoffice/season/tab_tests.html.twig',
|
||||
]);
|
||||
}
|
||||
|
||||
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
|
||||
#[Route(
|
||||
'/backoffice/season/{seasonCode:season}/candidates',
|
||||
name: 'tvdt_backoffice_season_candidates',
|
||||
requirements: ['seasonCode' => self::SEASON_CODE_REGEX],
|
||||
priority: 10,
|
||||
)]
|
||||
public function candidatesTab(Season $season): Response
|
||||
{
|
||||
return $this->render('backoffice/season.html.twig', [
|
||||
'season' => $season,
|
||||
'activeTab' => 'candidates',
|
||||
'template' => 'backoffice/season/tab_candidates.html.twig',
|
||||
]);
|
||||
}
|
||||
|
||||
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
|
||||
#[Route(
|
||||
'/backoffice/season/{seasonCode:season}/settings',
|
||||
name: 'tvdt_backoffice_season_settings',
|
||||
requirements: ['seasonCode' => self::SEASON_CODE_REGEX],
|
||||
priority: 10,
|
||||
)]
|
||||
public function settingsTab(Season $season, Request $request): Response
|
||||
{
|
||||
$form = $this->createForm(SettingsForm::class, $season->settings);
|
||||
|
||||
@@ -47,11 +79,15 @@ class SeasonController extends AbstractController
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$this->em->flush();
|
||||
|
||||
return $this->redirectToRoute('tvdt_backoffice_season_settings', ['seasonCode' => $season->seasonCode]);
|
||||
}
|
||||
|
||||
return $this->render('backoffice/season.html.twig', [
|
||||
'season' => $season,
|
||||
'form' => $form,
|
||||
'activeTab' => 'settings',
|
||||
'template' => 'backoffice/season/tab_settings.html.twig',
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\DataFixtures;
|
||||
|
||||
use Doctrine\Bundle\FixturesBundle\Fixture;
|
||||
use Doctrine\Bundle\FixturesBundle\FixtureGroupInterface;
|
||||
use Doctrine\Persistence\ObjectManager;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
use Tvdt\Entity\User;
|
||||
|
||||
final class DevFixtures extends Fixture implements FixtureGroupInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UserPasswordHasherInterface $passwordHasher,
|
||||
) {}
|
||||
|
||||
public static function getGroups(): array
|
||||
{
|
||||
return ['dev'];
|
||||
}
|
||||
|
||||
public function load(ObjectManager $manager): void
|
||||
{
|
||||
$user = new User();
|
||||
$user->email = 'admin@tijdvoordetest.nl';
|
||||
$user->password = $this->passwordHasher->hashPassword($user, '12345678');
|
||||
$user->roles = ['ROLE_ADMIN'];
|
||||
|
||||
$manager->persist($user);
|
||||
|
||||
$manager->flush();
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,14 @@ namespace Tvdt\DataFixtures;
|
||||
use Doctrine\Bundle\FixturesBundle\Fixture;
|
||||
use Doctrine\Bundle\FixturesBundle\FixtureGroupInterface;
|
||||
use Doctrine\Persistence\ObjectManager;
|
||||
use Safe\DateTimeImmutable;
|
||||
use Tvdt\Entity\Answer;
|
||||
use Tvdt\Entity\BankAnswer;
|
||||
use Tvdt\Entity\BankQuestion;
|
||||
use Tvdt\Entity\BankQuestionUsage;
|
||||
use Tvdt\Entity\Candidate;
|
||||
use Tvdt\Entity\Question;
|
||||
use Tvdt\Entity\QuestionLabel;
|
||||
use Tvdt\Entity\Quiz;
|
||||
use Tvdt\Entity\Season;
|
||||
use Tvdt\Entity\SeasonSettings;
|
||||
@@ -18,6 +23,16 @@ final class KrtekFixtures extends Fixture implements FixtureGroupInterface
|
||||
{
|
||||
public const string KRTEK_SEASON = 'krtek-seaspm';
|
||||
|
||||
public const string KRTEK_QUIZ_1 = 'krtek-quiz-1';
|
||||
|
||||
public const string KRTEK_QUIZ_2 = 'krtek-quiz-2';
|
||||
|
||||
public const string BANK_QUESTION_REUSABLE = 'bank-question-reusable';
|
||||
|
||||
public const string BANK_QUESTION_USED = 'bank-question-used';
|
||||
|
||||
public const string BANK_QUESTION_UNUSED = 'bank-question-unused';
|
||||
|
||||
public static function getGroups(): array
|
||||
{
|
||||
return ['test', 'dev'];
|
||||
@@ -47,16 +62,64 @@ final class KrtekFixtures extends Fixture implements FixtureGroupInterface
|
||||
$quiz1 = $this->createQuiz1($season);
|
||||
$season->addQuiz($quiz1);
|
||||
$season->activeQuiz = $quiz1;
|
||||
$season->addQuiz($this->createQuiz2($season));
|
||||
|
||||
$quiz1->finalizedAt = new DateTimeImmutable();
|
||||
$quiz2 = $this->createQuiz2($season);
|
||||
$season->addQuiz($quiz2);
|
||||
|
||||
\assert($season->settings instanceof SeasonSettings);
|
||||
|
||||
$season->settings->confirmAnswers = true;
|
||||
$season->settings->showNumbers = true;
|
||||
|
||||
$this->createQuestionBank($season, $quiz2);
|
||||
|
||||
$manager->flush();
|
||||
|
||||
$this->addReference(self::KRTEK_SEASON, $season);
|
||||
$this->addReference(self::KRTEK_QUIZ_1, $quiz1);
|
||||
$this->addReference(self::KRTEK_QUIZ_2, $quiz2);
|
||||
}
|
||||
|
||||
private function createQuestionBank(Season $season, Quiz $usedInQuiz): void
|
||||
{
|
||||
$location = new QuestionLabel('Locatie');
|
||||
$season->addQuestionLabel($location);
|
||||
$finale = new QuestionLabel('Finale');
|
||||
$season->addQuestionLabel($finale);
|
||||
|
||||
$reusable = new BankQuestion();
|
||||
$reusable->question = 'Wie is de Krtek?';
|
||||
$reusable->reusable = true;
|
||||
$reusable->addLabel($finale);
|
||||
$reusable->addAnswer(new BankAnswer('Claudia', true));
|
||||
$reusable->addAnswer(new BankAnswer('Eelco'));
|
||||
$reusable->addAnswer(new BankAnswer('Elise'));
|
||||
|
||||
$season->addBankQuestion($reusable);
|
||||
|
||||
$used = new BankQuestion();
|
||||
$used->question = 'Waar sliep de Krtek?';
|
||||
$used->addLabel($location);
|
||||
$used->addAnswer(new BankAnswer('Boven', true));
|
||||
$used->addAnswer(new BankAnswer('Beneden'));
|
||||
$used->addUsage(new BankQuestionUsage($used, $usedInQuiz));
|
||||
|
||||
$season->addBankQuestion($used);
|
||||
|
||||
$unused = new BankQuestion();
|
||||
$unused->question = 'Wat at de Krtek als ontbijt?';
|
||||
$unused->addLabel($location);
|
||||
$unused->addLabel($finale);
|
||||
$unused->addAnswer(new BankAnswer('Brood', true));
|
||||
$unused->addAnswer(new BankAnswer('Yoghurt'));
|
||||
$unused->addAnswer(new BankAnswer('Niks'));
|
||||
|
||||
$season->addBankQuestion($unused);
|
||||
|
||||
$this->addReference(self::BANK_QUESTION_REUSABLE, $reusable);
|
||||
$this->addReference(self::BANK_QUESTION_USED, $used);
|
||||
$this->addReference(self::BANK_QUESTION_UNUSED, $unused);
|
||||
}
|
||||
|
||||
private function createQuiz1(Season $season): Quiz
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Entity;
|
||||
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Bridge\Doctrine\Types\UuidType;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
#[ORM\Entity]
|
||||
class BankAnswer implements \Stringable
|
||||
{
|
||||
#[ORM\Column(type: UuidType::NAME)]
|
||||
#[ORM\CustomIdGenerator(class: 'doctrine.uuid_generator')]
|
||||
#[ORM\GeneratedValue(strategy: 'CUSTOM')]
|
||||
#[ORM\Id]
|
||||
public private(set) Uuid $id;
|
||||
|
||||
#[ORM\Column(type: Types::SMALLINT, options: ['default' => 0])]
|
||||
public int $ordering = 0;
|
||||
|
||||
#[ORM\JoinColumn(nullable: false)]
|
||||
#[ORM\ManyToOne(inversedBy: 'answers')]
|
||||
public BankQuestion $bankQuestion;
|
||||
|
||||
public function __construct(
|
||||
#[ORM\Column(length: 255)]
|
||||
public string $text,
|
||||
#[ORM\Column]
|
||||
public bool $isRightAnswer = false,
|
||||
) {}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->text;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Entity;
|
||||
|
||||
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\Uid\Uuid;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
use Tvdt\Repository\BankQuestionRepository;
|
||||
|
||||
#[ORM\Entity(repositoryClass: BankQuestionRepository::class)]
|
||||
class BankQuestion implements \Stringable
|
||||
{
|
||||
#[ORM\Column(type: UuidType::NAME)]
|
||||
#[ORM\CustomIdGenerator(class: 'doctrine.uuid_generator')]
|
||||
#[ORM\GeneratedValue(strategy: 'CUSTOM')]
|
||||
#[ORM\Id]
|
||||
public private(set) Uuid $id;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
public string $question;
|
||||
|
||||
#[ORM\JoinColumn(nullable: false)]
|
||||
#[ORM\ManyToOne(inversedBy: 'bankQuestions')]
|
||||
public Season $season;
|
||||
|
||||
#[ORM\Column(options: ['default' => false])]
|
||||
public bool $reusable = false;
|
||||
|
||||
/** @var Collection<int, QuestionLabel> */
|
||||
#[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')]
|
||||
#[ORM\OneToMany(targetEntity: BankAnswer::class, mappedBy: 'bankQuestion', cascade: ['persist'], orphanRemoval: true)]
|
||||
#[ORM\OrderBy(['ordering' => 'ASC'])]
|
||||
public private(set) Collection $answers;
|
||||
|
||||
/** @var Collection<int, BankQuestionUsage> */
|
||||
#[ORM\OneToMany(targetEntity: BankQuestionUsage::class, mappedBy: 'bankQuestion', cascade: ['persist'], orphanRemoval: true)]
|
||||
public private(set) Collection $usages;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->labels = new ArrayCollection();
|
||||
$this->answers = new ArrayCollection();
|
||||
$this->usages = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function addAnswer(BankAnswer $answer): static
|
||||
{
|
||||
if (!$this->answers->contains($answer)) {
|
||||
$this->answers->add($answer);
|
||||
$answer->bankQuestion = $this;
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeAnswer(BankAnswer $answer): static
|
||||
{
|
||||
$this->answers->removeElement($answer);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function addLabel(QuestionLabel $label): static
|
||||
{
|
||||
if (!$this->labels->contains($label)) {
|
||||
$this->labels->add($label);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeLabel(QuestionLabel $label): static
|
||||
{
|
||||
$this->labels->removeElement($label);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function addUsage(BankQuestionUsage $usage): static
|
||||
{
|
||||
if (!$this->usages->contains($usage)) {
|
||||
$this->usages->add($usage);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isUsed(): bool
|
||||
{
|
||||
return !$this->usages->isEmpty();
|
||||
}
|
||||
|
||||
public function canBeAssigned(): bool
|
||||
{
|
||||
return $this->reusable || !$this->isUsed();
|
||||
}
|
||||
|
||||
public function isUsedInQuiz(Quiz $quiz): bool
|
||||
{
|
||||
return $this->usages->exists(static fn (int $key, BankQuestionUsage $usage): bool => $usage->quiz === $quiz);
|
||||
}
|
||||
|
||||
#[Assert\Callback]
|
||||
public function validateAnswers(ExecutionContextInterface $context): void
|
||||
{
|
||||
$correctAnswers = $this->answers->filter(static fn (BankAnswer $answer): bool => $answer->isRightAnswer)->count();
|
||||
|
||||
if (1 !== $correctAnswers) {
|
||||
$context->buildViolation('A question must have exactly one correct answer')
|
||||
->atPath('answers')
|
||||
->addViolation();
|
||||
}
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->question ?? '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Entity;
|
||||
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Gedmo\Mapping\Annotation as Gedmo;
|
||||
use Symfony\Bridge\Doctrine\Types\UuidType;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\UniqueConstraint(fields: ['bankQuestion', 'quiz'])]
|
||||
class BankQuestionUsage
|
||||
{
|
||||
#[ORM\Column(type: UuidType::NAME)]
|
||||
#[ORM\CustomIdGenerator(class: 'doctrine.uuid_generator')]
|
||||
#[ORM\GeneratedValue(strategy: 'CUSTOM')]
|
||||
#[ORM\Id]
|
||||
public private(set) Uuid $id;
|
||||
|
||||
#[Gedmo\Timestampable(on: 'create')]
|
||||
#[ORM\Column(type: Types::DATETIMETZ_IMMUTABLE, nullable: false)]
|
||||
public private(set) \DateTimeImmutable $created;
|
||||
|
||||
public function __construct(
|
||||
#[ORM\JoinColumn(nullable: false)]
|
||||
#[ORM\ManyToOne(inversedBy: 'usages')]
|
||||
public private(set) BankQuestion $bankQuestion,
|
||||
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
#[ORM\ManyToOne]
|
||||
public private(set) Quiz $quiz,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Entity;
|
||||
|
||||
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\Uid\Uuid;
|
||||
use Tvdt\Repository\QuestionLabelRepository;
|
||||
|
||||
#[ORM\Entity(repositoryClass: QuestionLabelRepository::class)]
|
||||
#[ORM\UniqueConstraint(fields: ['name', 'season'])]
|
||||
class QuestionLabel implements \Stringable
|
||||
{
|
||||
#[ORM\Column(type: UuidType::NAME)]
|
||||
#[ORM\CustomIdGenerator(class: 'doctrine.uuid_generator')]
|
||||
#[ORM\GeneratedValue(strategy: 'CUSTOM')]
|
||||
#[ORM\Id]
|
||||
public private(set) Uuid $id;
|
||||
|
||||
#[ORM\JoinColumn(nullable: false)]
|
||||
#[ORM\ManyToOne(inversedBy: 'questionLabels')]
|
||||
public Season $season;
|
||||
|
||||
/** @var Collection<int, BankQuestion> */
|
||||
#[ORM\ManyToMany(targetEntity: BankQuestion::class, mappedBy: 'labels')]
|
||||
public private(set) Collection $bankQuestions;
|
||||
|
||||
public function __construct(
|
||||
#[ORM\Column(length: 64)]
|
||||
public string $name,
|
||||
) {
|
||||
$this->bankQuestions = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ namespace Tvdt\Entity;
|
||||
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Bridge\Doctrine\Types\UuidType;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
@@ -40,6 +41,9 @@ class Quiz
|
||||
#[ORM\Column(nullable: false, options: ['default' => 1])]
|
||||
public int $dropouts = 1;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIMETZ_IMMUTABLE, nullable: true)]
|
||||
public ?\DateTimeImmutable $finalizedAt = null;
|
||||
|
||||
/** @var Collection<int, Elimination> */
|
||||
#[ORM\OneToMany(targetEntity: Elimination::class, mappedBy: 'quiz', cascade: ['persist'], orphanRemoval: true)]
|
||||
#[ORM\OrderBy(['createdAt' => 'DESC'])]
|
||||
@@ -62,6 +66,29 @@ class Quiz
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isFinalized(): bool
|
||||
{
|
||||
return $this->finalizedAt instanceof \DateTimeImmutable;
|
||||
}
|
||||
|
||||
public function hasStartedCandidates(): bool
|
||||
{
|
||||
return $this->candidateData->exists(static fn (int $key, QuizCandidate $quizCandidate): bool => $quizCandidate->started instanceof \DateTimeImmutable);
|
||||
}
|
||||
|
||||
/**
|
||||
* A locked quiz can no longer be altered: it is either explicitly
|
||||
* finalized or a candidate has already started filling it in.
|
||||
*/
|
||||
public function isLocked(): bool
|
||||
{
|
||||
if ($this->isFinalized()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->hasStartedCandidates();
|
||||
}
|
||||
|
||||
public function addElimination(Elimination $elimination): self
|
||||
{
|
||||
$this->eliminations->add($elimination);
|
||||
|
||||
@@ -51,12 +51,24 @@ class Season
|
||||
#[ORM\OneToOne(cascade: ['persist', 'remove'])]
|
||||
public ?SeasonSettings $settings = null;
|
||||
|
||||
/** @var Collection<int, BankQuestion> */
|
||||
#[ORM\OneToMany(targetEntity: BankQuestion::class, mappedBy: 'season', cascade: ['persist'], orphanRemoval: true)]
|
||||
#[ORM\OrderBy(['question' => 'ASC'])]
|
||||
public private(set) Collection $bankQuestions;
|
||||
|
||||
/** @var Collection<int, QuestionLabel> */
|
||||
#[ORM\OneToMany(targetEntity: QuestionLabel::class, mappedBy: 'season', cascade: ['persist'], orphanRemoval: true)]
|
||||
#[ORM\OrderBy(['name' => 'ASC'])]
|
||||
public private(set) Collection $questionLabels;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->settings = new SeasonSettings();
|
||||
$this->quizzes = new ArrayCollection();
|
||||
$this->candidates = new ArrayCollection();
|
||||
$this->owners = new ArrayCollection();
|
||||
$this->bankQuestions = new ArrayCollection();
|
||||
$this->questionLabels = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function addQuiz(Quiz $quiz): static
|
||||
@@ -79,6 +91,26 @@ class Season
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function addBankQuestion(BankQuestion $bankQuestion): static
|
||||
{
|
||||
if (!$this->bankQuestions->contains($bankQuestion)) {
|
||||
$this->bankQuestions->add($bankQuestion);
|
||||
$bankQuestion->season = $this;
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function addQuestionLabel(QuestionLabel $questionLabel): static
|
||||
{
|
||||
if (!$this->questionLabels->contains($questionLabel)) {
|
||||
$this->questionLabels->add($questionLabel);
|
||||
$questionLabel->season = $this;
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function addOwner(User $owner): static
|
||||
{
|
||||
if (!$this->owners->contains($owner)) {
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Exception;
|
||||
|
||||
class BankQuestionAlreadyUsedException extends \Exception {}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Exception;
|
||||
|
||||
class QuizLockedException extends \Exception {}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Form;
|
||||
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
use Tvdt\Entity\BankAnswer;
|
||||
|
||||
/** @extends AbstractType<BankAnswer> */
|
||||
class BankAnswerFormType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
->add('text', TextType::class, [
|
||||
'label' => false,
|
||||
'attr' => ['placeholder' => 'Answer', 'maxlength' => 255],
|
||||
])
|
||||
->add('isRightAnswer', CheckboxType::class, [
|
||||
'label' => 'Correct',
|
||||
'required' => false,
|
||||
])
|
||||
;
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => BankAnswer::class,
|
||||
'empty_data' => static fn (): BankAnswer => new BankAnswer(''),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Form;
|
||||
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
use Tvdt\Entity\BankQuestion;
|
||||
use Tvdt\Entity\QuestionLabel;
|
||||
use Tvdt\Entity\Season;
|
||||
use Tvdt\Repository\QuestionLabelRepository;
|
||||
|
||||
/** @extends AbstractType<BankQuestion> */
|
||||
class BankQuestionFormType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
/** @var Season $season */
|
||||
$season = $options['season'];
|
||||
|
||||
$builder
|
||||
->add('question', TextType::class, [
|
||||
'label' => 'Question',
|
||||
'attr' => ['maxlength' => 255],
|
||||
])
|
||||
->add('reusable', CheckboxType::class, [
|
||||
'label' => 'Reusable',
|
||||
'required' => false,
|
||||
'label_attr' => ['class' => 'checkbox-switch'],
|
||||
'attr' => ['role' => 'switch', 'switch' => null],
|
||||
])
|
||||
->add('labels', EntityType::class, [
|
||||
'label' => 'Labels',
|
||||
'class' => QuestionLabel::class,
|
||||
'multiple' => true,
|
||||
'expanded' => true,
|
||||
'required' => false,
|
||||
'query_builder' => static fn (QuestionLabelRepository $repository): QueryBuilder => $repository
|
||||
->createQueryBuilder('l')
|
||||
->where('l.season = :season')
|
||||
->orderBy('l.name', 'ASC')
|
||||
->setParameter('season', $season),
|
||||
])
|
||||
->add('answers', CollectionType::class, [
|
||||
'label' => 'Answers',
|
||||
'entry_type' => BankAnswerFormType::class,
|
||||
'entry_options' => ['label' => false],
|
||||
'allow_add' => true,
|
||||
'allow_delete' => true,
|
||||
'by_reference' => false,
|
||||
'prototype' => true,
|
||||
])
|
||||
->add('save', SubmitType::class, [
|
||||
'label' => 'Save',
|
||||
])
|
||||
;
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => BankQuestion::class,
|
||||
]);
|
||||
$resolver->setRequired('season');
|
||||
$resolver->setAllowedTypes('season', Season::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Repository;
|
||||
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
use Tvdt\Entity\BankQuestion;
|
||||
use Tvdt\Entity\QuestionLabel;
|
||||
use Tvdt\Entity\Season;
|
||||
|
||||
/** @extends ServiceEntityRepository<BankQuestion> */
|
||||
class BankQuestionRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, BankQuestion::class);
|
||||
}
|
||||
|
||||
/** @return list<BankQuestion> */
|
||||
public function findBySeason(Season $season, ?QuestionLabel $label = null): array
|
||||
{
|
||||
$queryBuilder = $this->createQueryBuilder('bq')
|
||||
->select('bq', 'ba', 'l', 'u', 'uq')
|
||||
->leftJoin('bq.answers', 'ba')
|
||||
->leftJoin('bq.labels', 'l')
|
||||
->leftJoin('bq.usages', 'u')
|
||||
->leftJoin('u.quiz', 'uq')
|
||||
->where('bq.season = :season')
|
||||
->orderBy('bq.question', 'ASC')
|
||||
->setParameter('season', $season);
|
||||
|
||||
if ($label instanceof QuestionLabel) {
|
||||
$queryBuilder
|
||||
->andWhere(':label member of bq.labels')
|
||||
->setParameter('label', $label);
|
||||
}
|
||||
|
||||
/* @var list<BankQuestion> */
|
||||
return $queryBuilder->getQuery()->getResult();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Repository;
|
||||
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
use Tvdt\Entity\QuestionLabel;
|
||||
|
||||
/** @extends ServiceEntityRepository<QuestionLabel> */
|
||||
class QuestionLabelRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, QuestionLabel::class);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ use Safe\Exceptions\DatetimeException;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
use Tvdt\Dto\Result;
|
||||
use Tvdt\Entity\Quiz;
|
||||
use Tvdt\Entity\Season;
|
||||
use Tvdt\Exception\ErrorClearingQuizException;
|
||||
|
||||
/** @extends ServiceEntityRepository<Quiz> */
|
||||
@@ -22,6 +23,29 @@ class QuizRepository extends ServiceEntityRepository
|
||||
parent::__construct($registry, Quiz::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Quizzes of the season that can still receive bank questions:
|
||||
* not finalized and not started by any candidate.
|
||||
*
|
||||
* @return list<Quiz>
|
||||
*/
|
||||
public function findAssignableForSeason(Season $season): array
|
||||
{
|
||||
/* @var list<Quiz> */
|
||||
return $this->getEntityManager()->createQuery(<<<DQL
|
||||
select q from Tvdt\Entity\Quiz q
|
||||
where q.season = :season
|
||||
and q.finalizedAt is null
|
||||
and not exists (
|
||||
select 1 from Tvdt\Entity\QuizCandidate qc
|
||||
where qc.quiz = q and qc.started is not null
|
||||
)
|
||||
order by q.id asc
|
||||
DQL)
|
||||
->setParameter('season', $season)
|
||||
->getResult();
|
||||
}
|
||||
|
||||
/** @throws ErrorClearingQuizException */
|
||||
public function clearQuiz(Quiz $quiz): void
|
||||
{
|
||||
|
||||
@@ -8,14 +8,16 @@ use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||||
use Tvdt\Entity\Answer;
|
||||
use Tvdt\Entity\BankQuestion;
|
||||
use Tvdt\Entity\Candidate;
|
||||
use Tvdt\Entity\Elimination;
|
||||
use Tvdt\Entity\Question;
|
||||
use Tvdt\Entity\QuestionLabel;
|
||||
use Tvdt\Entity\Quiz;
|
||||
use Tvdt\Entity\Season;
|
||||
use Tvdt\Entity\User;
|
||||
|
||||
/** @extends Voter<string, Season|Elimination|Quiz|Candidate|Answer|Question> */
|
||||
/** @extends Voter<string, Season|Elimination|Quiz|Candidate|Answer|Question|BankQuestion|QuestionLabel> */
|
||||
final class SeasonVoter extends Voter
|
||||
{
|
||||
public const string EDIT = 'SEASON_EDIT';
|
||||
@@ -24,15 +26,19 @@ final class SeasonVoter extends Voter
|
||||
|
||||
public const string DELETE = 'SEASON_DELETE';
|
||||
|
||||
public const string MODIFY_QUIZ_CONTENT = 'QUIZ_MODIFY_CONTENT';
|
||||
|
||||
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, self::MODIFY_QUIZ_CONTENT], true)
|
||||
&& (
|
||||
$subject instanceof Answer
|
||||
|| $subject instanceof BankQuestion
|
||||
|| $subject instanceof Candidate
|
||||
|| $subject instanceof Elimination
|
||||
|| $subject instanceof Season
|
||||
|| $subject instanceof Question
|
||||
|| $subject instanceof QuestionLabel
|
||||
|| $subject instanceof Quiz
|
||||
);
|
||||
}
|
||||
@@ -44,19 +50,36 @@ final class SeasonVoter extends Voter
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($user->isAdmin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$season = match (true) {
|
||||
$subject instanceof Answer => $subject->question->quiz->season,
|
||||
$subject instanceof Elimination,
|
||||
$subject instanceof Question => $subject->quiz->season,
|
||||
$subject instanceof BankQuestion,
|
||||
$subject instanceof Candidate,
|
||||
$subject instanceof QuestionLabel,
|
||||
$subject instanceof Quiz => $subject->season,
|
||||
$subject instanceof Season => $subject,
|
||||
};
|
||||
|
||||
if (self::MODIFY_QUIZ_CONTENT === $attribute) {
|
||||
$quiz = match (true) {
|
||||
$subject instanceof Answer => $subject->question->quiz,
|
||||
$subject instanceof Question => $subject->quiz,
|
||||
$subject instanceof Quiz => $subject,
|
||||
default => null,
|
||||
};
|
||||
|
||||
if (!$quiz instanceof Quiz || $quiz->isLocked()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->isAdmin || $season->isOwner($user);
|
||||
}
|
||||
|
||||
if ($user->isAdmin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return match ($attribute) {
|
||||
self::EDIT, self::DELETE, self::ELIMINATION => $season->isOwner($user),
|
||||
default => false,
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Service;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Tvdt\Entity\Answer;
|
||||
use Tvdt\Entity\BankQuestion;
|
||||
use Tvdt\Entity\BankQuestionUsage;
|
||||
use Tvdt\Entity\Question;
|
||||
use Tvdt\Entity\Quiz;
|
||||
use Tvdt\Exception\BankQuestionAlreadyUsedException;
|
||||
use Tvdt\Exception\QuizLockedException;
|
||||
|
||||
final readonly class QuestionBankService
|
||||
{
|
||||
public function __construct(private EntityManagerInterface $entityManager) {}
|
||||
|
||||
/**
|
||||
* Copy a bank question (with its answers) into a quiz and record the usage.
|
||||
*
|
||||
* @throws QuizLockedException when the quiz is finalized or already filled in
|
||||
* @throws BankQuestionAlreadyUsedException when the question is single-use and used, or already in this quiz
|
||||
*/
|
||||
public function assignToQuiz(BankQuestion $bankQuestion, Quiz $quiz): void
|
||||
{
|
||||
if ($bankQuestion->season !== $quiz->season) {
|
||||
throw new \InvalidArgumentException('Bank question and quiz belong to different seasons');
|
||||
}
|
||||
|
||||
if ($quiz->isLocked()) {
|
||||
throw new QuizLockedException();
|
||||
}
|
||||
|
||||
if (!$bankQuestion->canBeAssigned() || $bankQuestion->isUsedInQuiz($quiz)) {
|
||||
throw new BankQuestionAlreadyUsedException();
|
||||
}
|
||||
|
||||
$maxOrdering = 0;
|
||||
foreach ($quiz->questions as $existingQuestion) {
|
||||
$maxOrdering = max($maxOrdering, $existingQuestion->ordering);
|
||||
}
|
||||
|
||||
$question = new Question();
|
||||
$question->question = $bankQuestion->question;
|
||||
$question->ordering = $maxOrdering + 1;
|
||||
|
||||
foreach ($bankQuestion->answers as $bankAnswer) {
|
||||
$answer = new Answer($bankAnswer->text, $bankAnswer->isRightAnswer);
|
||||
$answer->ordering = $bankAnswer->ordering;
|
||||
$question->addAnswer($answer);
|
||||
}
|
||||
|
||||
$quiz->addQuestion($question);
|
||||
$bankQuestion->addUsage(new BankQuestionUsage($bankQuestion, $quiz));
|
||||
|
||||
$this->entityManager->persist($question);
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
{% extends 'backoffice/base.html.twig' %}
|
||||
|
||||
{% macro answer_row(answerForm) %}
|
||||
<div class="d-flex align-items-center gap-2 mb-2" data-collection-item>
|
||||
<div class="flex-grow-1">{{ form_widget(answerForm.text) }}</div>
|
||||
<div class="form-check">
|
||||
{{ form_widget(answerForm.isRightAnswer, {attr: {class: 'form-check-input'}}) }}
|
||||
{{ form_label(answerForm.isRightAnswer, null, {label_attr: {class: 'form-check-label'}}) }}
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger"
|
||||
data-action="bo--form-collection#removeItem">×</button>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% block title %}{{ parent() }}{{ 'Question bank'|trans }}{% endblock %}
|
||||
|
||||
{% 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"><a href="{{ path('tvdt_backoffice_question_bank', {seasonCode: season.seasonCode}) }}">{{ 'Question bank'|trans }}</a></li>
|
||||
<li class="breadcrumb-item active" aria-current="page">{{ bankQuestion is null ? 'Add question'|trans : 'Edit question'|trans }}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="row">
|
||||
<div class="col-md-6 col-12">
|
||||
<h2 class="mb-3">{{ bankQuestion is null ? 'Add question'|trans : 'Edit question'|trans }}</h2>
|
||||
|
||||
{{ form_start(form) }}
|
||||
{{ form_row(form.question) }}
|
||||
{{ form_row(form.reusable) }}
|
||||
{{ form_row(form.labels) }}
|
||||
|
||||
<div data-controller="bo--form-collection"
|
||||
data-bo--form-collection-prototype-value="{{ _self.answer_row(form.answers.vars.prototype)|e('html_attr') }}">
|
||||
{{ form_label(form.answers) }}
|
||||
{{ form_errors(form.answers) }}
|
||||
<div data-bo--form-collection-target="collection">
|
||||
{% for answerForm in form.answers %}
|
||||
{{ _self.answer_row(answerForm) }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% do form.answers.setRendered %}
|
||||
<button type="button" class="btn btn-sm btn-outline-primary mb-3"
|
||||
data-action="bo--form-collection#addItem">{{ 'Add answer'|trans }}</button>
|
||||
</div>
|
||||
|
||||
{{ form_end(form) }}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock body %}
|
||||
@@ -23,7 +23,16 @@
|
||||
{% endmacro %}
|
||||
|
||||
<div data-controller="bo--quiz">
|
||||
<h4 class="mb-3">{{ 'Quick actions'|trans }}</h4>
|
||||
<h4 class="mb-3">
|
||||
{{ 'Quick actions'|trans }}
|
||||
{% if quiz.isFinalized %}
|
||||
<span class="badge text-bg-success">{{ 'Finalized'|trans }}</span>
|
||||
{% elseif quiz.isLocked %}
|
||||
<span class="badge text-bg-warning">{{ 'Locked (answers given)'|trans }}</span>
|
||||
{% else %}
|
||||
<span class="badge text-bg-secondary">{{ 'Draft'|trans }}</span>
|
||||
{% endif %}
|
||||
</h4>
|
||||
<div class="mb-3 btn-group">
|
||||
|
||||
{% if quiz is same as (season.activeQuiz) %}
|
||||
@@ -36,11 +45,28 @@
|
||||
{% else %}
|
||||
<form action="{{ path('tvdt_backoffice_enable', {seasonCode: season.seasonCode, quiz: quiz.id}) }}" method="POST">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('enable_quiz') }}">
|
||||
<button type="submit" class="btn btn-primary rounded-0 rounded-start">
|
||||
<button type="submit" class="btn btn-primary rounded-0 rounded-start"
|
||||
{% if not quiz.isFinalized %}disabled data-bs-toggle="tooltip"
|
||||
title="{{ 'The quiz must be finalized before it can be activated'|trans }}"{% endif %}>
|
||||
{{ 'Make active'|trans }}
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if not quiz.isFinalized %}
|
||||
<form action="{{ path('tvdt_backoffice_quiz_finalize', {quiz: quiz.id}) }}" method="POST">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('finalize_quiz') }}">
|
||||
<button type="submit" class="btn btn-success rounded-0">
|
||||
{{ 'Finalize'|trans }}
|
||||
</button>
|
||||
</form>
|
||||
{% elseif not quiz.hasStartedCandidates and quiz is not same as (season.activeQuiz) %}
|
||||
<form action="{{ path('tvdt_backoffice_quiz_unfinalize', {quiz: quiz.id}) }}" method="POST">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('unfinalize_quiz') }}">
|
||||
<button type="submit" class="btn btn-outline-success rounded-0">
|
||||
{{ 'Undo finalization'|trans }}
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<button class="btn btn-danger" data-action="click->bo--quiz#clearQuiz">
|
||||
{{ 'Clear Quiz...'|trans }}
|
||||
</button>
|
||||
|
||||
@@ -11,39 +11,24 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<h2 class="mb-3">{{ 'Season'|trans }}: {{ season.name }}</h2>
|
||||
<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>
|
||||
<div class="list-group mb-3">
|
||||
{% for quiz in season.quizzes %}
|
||||
<a class="list-group-item list-group-item-action{% if season.activeQuiz == quiz %} active{% endif %}"
|
||||
href="{{ path('tvdt_backoffice_quiz', {seasonCode: season.seasonCode, quiz: quiz.id}) }}">{{ quiz.name }}</a>
|
||||
{% else %}
|
||||
{{ 'No quizzes'|trans }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 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>
|
||||
<ul class="mb-3">
|
||||
{% for candidate in season.candidates %}
|
||||
<li>{{ candidate.name }}</li>{% endfor %}
|
||||
</ul>
|
||||
{% set tabs = [
|
||||
{id: 'tests', label: 'Quizzes'|trans, route: 'tvdt_backoffice_season'},
|
||||
{id: 'question-bank', label: 'Question bank'|trans, route: 'tvdt_backoffice_question_bank'},
|
||||
{id: 'candidates', label: 'Candidates'|trans, route: 'tvdt_backoffice_season_candidates'},
|
||||
{id: 'settings', label: 'Settings'|trans, route: 'tvdt_backoffice_season_settings'},
|
||||
] %}
|
||||
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<h4 class="mb-0 pe-2">{{ 'Settings'|trans }}</h4>
|
||||
</div>
|
||||
{{ form(form) }}
|
||||
</div>
|
||||
<h2 class="mb-3">{{ 'Season'|trans }}: {{ season.name }}</h2>
|
||||
<ul class="nav nav-tabs mb-3">
|
||||
{% for tab in tabs %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link{{ activeTab == tab.id ? ' active' }}" href="{{ path(tab.route, {seasonCode: season.seasonCode}) }}">
|
||||
{{ tab.label }}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<div class="pt-3">
|
||||
{{ include(template) }}
|
||||
</div>
|
||||
{% endblock body %}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<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>
|
||||
<ul class="mb-3">
|
||||
{% for candidate in season.candidates %}
|
||||
<li>{{ candidate.name }}</li>
|
||||
{% else %}
|
||||
{{ 'No candidates'|trans }}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,109 @@
|
||||
<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>
|
||||
|
||||
<div class="d-flex align-items-center flex-wrap gap-2 mb-3">
|
||||
<a class="badge rounded-pill text-decoration-none{% if activeLabel is null %} text-bg-primary{% else %} text-bg-secondary{% endif %}"
|
||||
href="{{ path('tvdt_backoffice_question_bank', {seasonCode: season.seasonCode}) }}">{{ 'All'|trans }}</a>
|
||||
{% for label in season.questionLabels %}
|
||||
<span class="d-inline-flex align-items-center gap-1">
|
||||
<a class="badge rounded-pill text-decoration-none{% if activeLabel is same as (label) %} text-bg-primary{% else %} text-bg-secondary{% endif %}"
|
||||
href="{{ path('tvdt_backoffice_question_bank', {seasonCode: season.seasonCode, label: label.id}) }}">{{ label.name }}</a>
|
||||
<form action="{{ path('tvdt_backoffice_question_bank_label_delete', {seasonCode: season.seasonCode, label: label.id}) }}"
|
||||
method="POST" class="d-inline">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('delete_question_label') }}">
|
||||
<button type="submit" class="btn btn-sm btn-link p-0 text-danger" aria-label="{{ 'Remove label'|trans }}">×</button>
|
||||
</form>
|
||||
</span>
|
||||
{% endfor %}
|
||||
<form action="{{ path('tvdt_backoffice_question_bank_labels', {seasonCode: season.seasonCode}) }}"
|
||||
method="POST" class="d-inline-flex align-items-center gap-1">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('add_question_label') }}">
|
||||
<input type="text" class="form-control form-control-sm" name="name" maxlength="64"
|
||||
placeholder="{{ 'New label'|trans }}" required>
|
||||
<button type="submit" class="btn btn-sm btn-outline-primary">{{ 'Add label'|trans }}</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<table class="table align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ 'Question'|trans }}</th>
|
||||
<th>{{ 'Labels'|trans }}</th>
|
||||
<th>{{ 'Reusable'|trans }}</th>
|
||||
<th>{{ 'Used in'|trans }}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for bankQuestion in bankQuestions %}
|
||||
<tr>
|
||||
<td>{{ bankQuestion.question }}</td>
|
||||
<td>
|
||||
{% for label in bankQuestion.labels %}
|
||||
<span class="badge rounded-pill text-bg-secondary">{{ label.name }}</span>
|
||||
{% endfor %}
|
||||
</td>
|
||||
<td>
|
||||
{% if bankQuestion.reusable %}
|
||||
<span class="badge text-bg-info">{{ 'Reusable'|trans }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{{ bankQuestion.usages|map(usage => usage.quiz.name)|join(', ') }}
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<div class="d-inline-flex align-items-center gap-2">
|
||||
{% if bankQuestion.canBeAssigned and assignableQuizzes|length > 0 %}
|
||||
<form action="{{ path('tvdt_backoffice_question_bank_assign', {seasonCode: season.seasonCode, bankQuestion: bankQuestion.id}) }}"
|
||||
method="POST" class="d-inline-flex align-items-center gap-1">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('assign_bank_question') }}">
|
||||
<select class="form-select form-select-sm" name="quiz" required>
|
||||
{% for quiz in assignableQuizzes %}
|
||||
<option value="{{ quiz.id }}">{{ quiz.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="submit" class="btn btn-sm btn-outline-primary">{{ 'Assign'|trans }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<a class="btn btn-sm btn-outline-secondary"
|
||||
href="{{ path('tvdt_backoffice_question_bank_edit', {seasonCode: season.seasonCode, bankQuestion: bankQuestion.id}) }}">{{ 'Edit'|trans }}</a>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger" data-bs-toggle="modal"
|
||||
data-bs-target="#deleteBankQuestion-{{ bankQuestion.id }}">{{ 'Delete'|trans }}</button>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="deleteBankQuestion-{{ bankQuestion.id }}" data-bs-backdrop="static"
|
||||
tabindex="-1" aria-labelledby="deleteBankQuestion-{{ bankQuestion.id }}Label" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h1 class="modal-title fs-5" id="deleteBankQuestion-{{ bankQuestion.id }}Label">{{ 'Please Confirm'|trans }}</h1>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body text-start">
|
||||
{{ 'Are you sure you want to delete this question from the question bank?'|trans }}
|
||||
{% if bankQuestion.isUsed %}
|
||||
<br><strong>{{ 'This question has been used in a quiz. The copy in the quiz will not be affected.'|trans }}</strong>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ 'No'|trans }}</button>
|
||||
<form action="{{ path('tvdt_backoffice_question_bank_delete', {seasonCode: season.seasonCode, bankQuestion: bankQuestion.id}) }}"
|
||||
method="POST">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('delete_bank_question') }}">
|
||||
<button type="submit" class="btn btn-danger">{{ 'Yes'|trans }}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="5">{{ 'No questions in the question bank yet'|trans }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -0,0 +1,6 @@
|
||||
<div class="row">
|
||||
<div class="col-md-6 col-12">
|
||||
<h4 class="mb-3">{{ 'Settings'|trans }}</h4>
|
||||
{{ form(form) }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,22 @@
|
||||
<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>
|
||||
<div class="list-group mb-3">
|
||||
{% for quiz in season.quizzes %}
|
||||
<a class="list-group-item list-group-item-action{% if season.activeQuiz == quiz %} active{% endif %}"
|
||||
href="{{ path('tvdt_backoffice_quiz', {seasonCode: season.seasonCode, quiz: quiz.id}) }}">
|
||||
{{ quiz.name }}
|
||||
{% if quiz.isFinalized %}
|
||||
<span class="badge text-bg-success">{{ 'Finalized'|trans }}</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% else %}
|
||||
{{ 'No quizzes'|trans }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Tests\Controller\Backoffice;
|
||||
|
||||
use Doctrine\Bundle\DoctrineBundle\DataCollector\DoctrineDataCollector;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PHPUnit\Framework\Attributes\CoversNothing;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpKernel\Profiler\Profile;
|
||||
use Tvdt\Entity\Quiz;
|
||||
use Tvdt\Entity\User;
|
||||
|
||||
/**
|
||||
* Guards against N+1 regressions: every backoffice page must run a
|
||||
* constant number of queries, independent of the amount of data.
|
||||
*/
|
||||
#[CoversNothing]
|
||||
final class QueryCountTest extends WebTestCase
|
||||
{
|
||||
private KernelBrowser $client;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->client = self::createClient();
|
||||
$entityManager = self::getContainer()->get(EntityManagerInterface::class);
|
||||
|
||||
$user = $entityManager->getRepository(User::class)->findOneBy(['email' => 'krtek-admin@example.org']);
|
||||
$this->assertInstanceOf(User::class, $user);
|
||||
$this->client->loginUser($user);
|
||||
}
|
||||
|
||||
/** @return \Generator<string, array{string, int}> */
|
||||
public static function pageProvider(): \Generator
|
||||
{
|
||||
yield 'season tests tab' => ['/backoffice/season/krtek', 4];
|
||||
yield 'question bank tab' => ['/backoffice/season/krtek/question-bank', 6];
|
||||
yield 'candidates tab' => ['/backoffice/season/krtek/candidates', 4];
|
||||
yield 'settings tab' => ['/backoffice/season/krtek/settings', 4];
|
||||
yield 'question bank new' => ['/backoffice/season/krtek/question-bank/new', 4];
|
||||
yield 'quiz overview' => ['/backoffice/season/krtek/quiz/%quiz%/overview', 5];
|
||||
yield 'quiz result' => ['/backoffice/season/krtek/quiz/%quiz%/result', 6];
|
||||
yield 'quiz candidates list' => ['/backoffice/season/krtek/quiz/%quiz%/candidates-list', 7];
|
||||
}
|
||||
|
||||
#[DataProvider('pageProvider')]
|
||||
public function testPageStaysWithinQueryBudget(string $url, int $maxQueries): void
|
||||
{
|
||||
if (str_contains($url, '%quiz%')) {
|
||||
$entityManager = self::getContainer()->get(EntityManagerInterface::class);
|
||||
$quiz = $entityManager->getRepository(Quiz::class)->findOneBy(['name' => 'Quiz 1']);
|
||||
$this->assertInstanceOf(Quiz::class, $quiz);
|
||||
$url = str_replace('%quiz%', (string) $quiz->id, $url);
|
||||
}
|
||||
|
||||
// Warm up so boot/setup queries do not pollute the profiled request
|
||||
$this->client->request(Request::METHOD_GET, '/backoffice');
|
||||
|
||||
$this->client->enableProfiler();
|
||||
$this->client->request(Request::METHOD_GET, $url);
|
||||
$this->assertResponseIsSuccessful();
|
||||
|
||||
$profile = $this->client->getProfile();
|
||||
$this->assertInstanceOf(Profile::class, $profile);
|
||||
|
||||
$collector = $profile->getCollector('db');
|
||||
$this->assertInstanceOf(DoctrineDataCollector::class, $collector);
|
||||
$queries = $collector->getQueries()['default'] ?? [];
|
||||
|
||||
$sql = implode("\n", array_map(static fn (array $query): string => $query['sql'], $queries));
|
||||
$this->assertLessThanOrEqual($maxQueries, \count($queries), \sprintf("Query budget exceeded for %s:\n%s", $url, $sql));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Tests\Controller\Backoffice;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Tvdt\Controller\Backoffice\QuestionBankController;
|
||||
use Tvdt\Entity\BankQuestion;
|
||||
use Tvdt\Entity\Question;
|
||||
use Tvdt\Entity\QuestionLabel;
|
||||
use Tvdt\Entity\Quiz;
|
||||
use Tvdt\Entity\User;
|
||||
|
||||
#[CoversClass(QuestionBankController::class)]
|
||||
final class QuestionBankControllerTest extends WebTestCase
|
||||
{
|
||||
private KernelBrowser $client;
|
||||
|
||||
private EntityManagerInterface $entityManager;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->client = self::createClient();
|
||||
$this->entityManager = self::getContainer()->get(EntityManagerInterface::class);
|
||||
}
|
||||
|
||||
private function loginAsOwner(): void
|
||||
{
|
||||
$user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'krtek-admin@example.org']);
|
||||
$this->assertInstanceOf(User::class, $user);
|
||||
$this->client->loginUser($user);
|
||||
}
|
||||
|
||||
private function getBankQuestion(string $question): BankQuestion
|
||||
{
|
||||
$bankQuestion = $this->entityManager->getRepository(BankQuestion::class)->findOneBy(['question' => $question]);
|
||||
$this->assertInstanceOf(BankQuestion::class, $bankQuestion);
|
||||
|
||||
return $bankQuestion;
|
||||
}
|
||||
|
||||
private function getQuizByName(string $name): Quiz
|
||||
{
|
||||
$quiz = $this->entityManager->getRepository(Quiz::class)->findOneBy(['name' => $name]);
|
||||
$this->assertInstanceOf(Quiz::class, $quiz);
|
||||
|
||||
return $quiz;
|
||||
}
|
||||
|
||||
private function getCsrfToken(string $formActionContains): string
|
||||
{
|
||||
$crawler = $this->client->getCrawler();
|
||||
$input = $crawler->filter(\sprintf('form[action*="%s"] input[name="_token"]', $formActionContains));
|
||||
$this->assertGreaterThan(0, $input->count(), \sprintf('No form found with action containing "%s"', $formActionContains));
|
||||
|
||||
return (string) $input->first()->attr('value');
|
||||
}
|
||||
|
||||
public function testIndexListsBankQuestions(): void
|
||||
{
|
||||
$this->loginAsOwner();
|
||||
$this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank');
|
||||
|
||||
$this->assertResponseIsSuccessful();
|
||||
$this->assertSelectorTextContains('body', 'Wie is de Krtek?');
|
||||
$this->assertSelectorTextContains('body', 'Waar sliep de Krtek?');
|
||||
$this->assertSelectorTextContains('body', 'Wat at de Krtek als ontbijt?');
|
||||
}
|
||||
|
||||
public function testIndexFiltersByLabel(): void
|
||||
{
|
||||
$this->loginAsOwner();
|
||||
$label = $this->entityManager->getRepository(QuestionLabel::class)->findOneBy(['name' => 'Locatie']);
|
||||
$this->assertInstanceOf(QuestionLabel::class, $label);
|
||||
|
||||
$crawler = $this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank?label='.$label->id);
|
||||
|
||||
$this->assertResponseIsSuccessful();
|
||||
$body = $crawler->filter('tbody')->text();
|
||||
$this->assertStringContainsString('Waar sliep de Krtek?', $body);
|
||||
$this->assertStringNotContainsString('Wie is de Krtek?', $body);
|
||||
}
|
||||
|
||||
public function testNonOwnerIsDenied(): void
|
||||
{
|
||||
$user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'test@example.org']);
|
||||
$this->assertInstanceOf(User::class, $user);
|
||||
$this->client->loginUser($user);
|
||||
|
||||
$this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank');
|
||||
|
||||
$this->assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
public function testCreateBankQuestion(): void
|
||||
{
|
||||
$this->loginAsOwner();
|
||||
$crawler = $this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank/new');
|
||||
$this->assertResponseIsSuccessful();
|
||||
|
||||
$token = (string) $crawler->filter('input[name="bank_question_form[_token]"]')->attr('value');
|
||||
|
||||
$this->client->request(Request::METHOD_POST, '/backoffice/season/krtek/question-bank/new', [
|
||||
'bank_question_form' => [
|
||||
'question' => 'Wat is de lievelingskleur van de Krtek?',
|
||||
'reusable' => '1',
|
||||
'answers' => [
|
||||
['text' => 'Rood', 'isRightAnswer' => '1'],
|
||||
['text' => 'Blauw'],
|
||||
],
|
||||
'_token' => $token,
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertResponseRedirects('/backoffice/season/krtek/question-bank');
|
||||
|
||||
$this->entityManager->clear();
|
||||
$bankQuestion = $this->getBankQuestion('Wat is de lievelingskleur van de Krtek?');
|
||||
$this->assertTrue($bankQuestion->reusable);
|
||||
$this->assertCount(2, $bankQuestion->answers);
|
||||
$this->assertSame('Rood', (string) $bankQuestion->answers->first());
|
||||
}
|
||||
|
||||
public function testCreateRefusedWithoutCorrectAnswer(): void
|
||||
{
|
||||
$this->loginAsOwner();
|
||||
$crawler = $this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank/new');
|
||||
$token = (string) $crawler->filter('input[name="bank_question_form[_token]"]')->attr('value');
|
||||
|
||||
$this->client->request(Request::METHOD_POST, '/backoffice/season/krtek/question-bank/new', [
|
||||
'bank_question_form' => [
|
||||
'question' => 'Vraag zonder goed antwoord',
|
||||
'answers' => [
|
||||
['text' => 'Een'],
|
||||
['text' => 'Twee'],
|
||||
],
|
||||
'_token' => $token,
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertResponseIsUnprocessable();
|
||||
$this->assertNotInstanceOf(BankQuestion::class, $this->entityManager->getRepository(BankQuestion::class)->findOneBy(['question' => 'Vraag zonder goed antwoord']));
|
||||
}
|
||||
|
||||
public function testEditBankQuestion(): void
|
||||
{
|
||||
$this->loginAsOwner();
|
||||
$bankQuestion = $this->getBankQuestion('Wat at de Krtek als ontbijt?');
|
||||
|
||||
$url = \sprintf('/backoffice/season/krtek/question-bank/%s/edit', $bankQuestion->id);
|
||||
$crawler = $this->client->request(Request::METHOD_GET, $url);
|
||||
$this->assertResponseIsSuccessful();
|
||||
$token = (string) $crawler->filter('input[name="bank_question_form[_token]"]')->attr('value');
|
||||
|
||||
$this->client->request(Request::METHOD_POST, $url, [
|
||||
'bank_question_form' => [
|
||||
'question' => 'Wat dronk de Krtek als ontbijt?',
|
||||
'answers' => [
|
||||
['text' => 'Koffie', 'isRightAnswer' => '1'],
|
||||
['text' => 'Thee'],
|
||||
],
|
||||
'_token' => $token,
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertResponseRedirects('/backoffice/season/krtek/question-bank');
|
||||
|
||||
$this->entityManager->clear();
|
||||
$bankQuestion = $this->getBankQuestion('Wat dronk de Krtek als ontbijt?');
|
||||
$this->assertFalse($bankQuestion->reusable);
|
||||
$this->assertCount(2, $bankQuestion->answers);
|
||||
}
|
||||
|
||||
public function testDeleteUsedBankQuestionLeavesQuizIntact(): void
|
||||
{
|
||||
$this->loginAsOwner();
|
||||
$bankQuestion = $this->getBankQuestion('Waar sliep de Krtek?');
|
||||
$quiz2QuestionCount = $this->getQuizByName('Quiz 2')->questions->count();
|
||||
|
||||
$this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank');
|
||||
$token = $this->getCsrfToken(\sprintf('%s/delete', $bankQuestion->id));
|
||||
|
||||
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/question-bank/%s/delete', $bankQuestion->id), [
|
||||
'_token' => $token,
|
||||
]);
|
||||
|
||||
$this->assertResponseRedirects('/backoffice/season/krtek/question-bank');
|
||||
|
||||
$this->entityManager->clear();
|
||||
$this->assertNotInstanceOf(BankQuestion::class, $this->entityManager->getRepository(BankQuestion::class)->findOneBy(['question' => 'Waar sliep de Krtek?']));
|
||||
$this->assertCount($quiz2QuestionCount, $this->getQuizByName('Quiz 2')->questions);
|
||||
}
|
||||
|
||||
public function testAssignCopiesQuestionIntoQuiz(): void
|
||||
{
|
||||
$this->loginAsOwner();
|
||||
$bankQuestion = $this->getBankQuestion('Wat at de Krtek als ontbijt?');
|
||||
$quiz = $this->getQuizByName('Quiz 2');
|
||||
$questionCount = $quiz->questions->count();
|
||||
|
||||
$this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank');
|
||||
$token = $this->getCsrfToken(\sprintf('%s/assign', $bankQuestion->id));
|
||||
|
||||
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/question-bank/%s/assign', $bankQuestion->id), [
|
||||
'_token' => $token,
|
||||
'quiz' => (string) $quiz->id,
|
||||
]);
|
||||
|
||||
$this->assertResponseRedirects('/backoffice/season/krtek/question-bank');
|
||||
|
||||
$this->entityManager->clear();
|
||||
$quiz = $this->getQuizByName('Quiz 2');
|
||||
$this->assertCount($questionCount + 1, $quiz->questions);
|
||||
|
||||
$copiedQuestion = null;
|
||||
$maxOrdering = 0;
|
||||
foreach ($quiz->questions as $question) {
|
||||
$maxOrdering = max($maxOrdering, $question->ordering);
|
||||
if ('Wat at de Krtek als ontbijt?' === $question->question) {
|
||||
$copiedQuestion = $question;
|
||||
}
|
||||
}
|
||||
|
||||
$this->assertInstanceOf(Question::class, $copiedQuestion);
|
||||
$this->assertSame($maxOrdering, $copiedQuestion->ordering);
|
||||
$this->assertCount(3, $copiedQuestion->answers);
|
||||
|
||||
$bankQuestion = $this->getBankQuestion('Wat at de Krtek als ontbijt?');
|
||||
$this->assertTrue($bankQuestion->isUsed());
|
||||
$this->assertFalse($bankQuestion->canBeAssigned());
|
||||
}
|
||||
|
||||
public function testAssignUsedNonReusableQuestionIsRefused(): void
|
||||
{
|
||||
$this->loginAsOwner();
|
||||
$bankQuestion = $this->getBankQuestion('Waar sliep de Krtek?');
|
||||
$quiz = $this->getQuizByName('Quiz 2');
|
||||
$questionCount = $quiz->questions->count();
|
||||
|
||||
$this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank');
|
||||
|
||||
// The assign form is not rendered for used questions, so post with another form's token
|
||||
$token = $this->getCsrfToken('/assign');
|
||||
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/question-bank/%s/assign', $bankQuestion->id), [
|
||||
'_token' => $token,
|
||||
'quiz' => (string) $quiz->id,
|
||||
]);
|
||||
|
||||
$this->assertResponseRedirects('/backoffice/season/krtek/question-bank');
|
||||
|
||||
$this->entityManager->clear();
|
||||
$this->assertCount($questionCount, $this->getQuizByName('Quiz 2')->questions);
|
||||
}
|
||||
|
||||
public function testAssignSameReusableQuestionTwiceToSameQuizIsRefused(): void
|
||||
{
|
||||
$this->loginAsOwner();
|
||||
$bankQuestion = $this->getBankQuestion('Wie is de Krtek?');
|
||||
$quiz = $this->getQuizByName('Quiz 2');
|
||||
$questionCount = $quiz->questions->count();
|
||||
|
||||
$this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank');
|
||||
$token = $this->getCsrfToken(\sprintf('%s/assign', $bankQuestion->id));
|
||||
|
||||
$url = \sprintf('/backoffice/season/krtek/question-bank/%s/assign', $bankQuestion->id);
|
||||
$this->client->request(Request::METHOD_POST, $url, ['_token' => $token, 'quiz' => (string) $quiz->id]);
|
||||
$this->assertResponseRedirects('/backoffice/season/krtek/question-bank');
|
||||
|
||||
$this->client->request(Request::METHOD_POST, $url, ['_token' => $token, 'quiz' => (string) $quiz->id]);
|
||||
$this->assertResponseRedirects('/backoffice/season/krtek/question-bank');
|
||||
|
||||
$this->entityManager->clear();
|
||||
$this->assertCount($questionCount + 1, $this->getQuizByName('Quiz 2')->questions);
|
||||
}
|
||||
|
||||
public function testAssignIntoFinalizedQuizIsDenied(): void
|
||||
{
|
||||
$this->loginAsOwner();
|
||||
$bankQuestion = $this->getBankQuestion('Wie is de Krtek?');
|
||||
$finalizedQuiz = $this->getQuizByName('Quiz 1');
|
||||
$this->assertTrue($finalizedQuiz->isFinalized());
|
||||
|
||||
$this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank');
|
||||
$token = $this->getCsrfToken(\sprintf('%s/assign', $bankQuestion->id));
|
||||
|
||||
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/question-bank/%s/assign', $bankQuestion->id), [
|
||||
'_token' => $token,
|
||||
'quiz' => (string) $finalizedQuiz->id,
|
||||
]);
|
||||
|
||||
$this->assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
public function testAddAndDeleteLabel(): void
|
||||
{
|
||||
$this->loginAsOwner();
|
||||
$crawler = $this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank');
|
||||
$token = (string) $crawler->filter('form[action$="/question-bank/labels"] input[name="_token"]')->attr('value');
|
||||
|
||||
$this->client->request(Request::METHOD_POST, '/backoffice/season/krtek/question-bank/labels', [
|
||||
'_token' => $token,
|
||||
'name' => 'Opdracht',
|
||||
]);
|
||||
$this->assertResponseRedirects('/backoffice/season/krtek/question-bank');
|
||||
|
||||
$this->entityManager->clear();
|
||||
$label = $this->entityManager->getRepository(QuestionLabel::class)->findOneBy(['name' => 'Opdracht']);
|
||||
$this->assertInstanceOf(QuestionLabel::class, $label);
|
||||
|
||||
$this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank');
|
||||
$deleteToken = $this->getCsrfToken(\sprintf('labels/%s/delete', $label->id));
|
||||
|
||||
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/question-bank/labels/%s/delete', $label->id), [
|
||||
'_token' => $deleteToken,
|
||||
]);
|
||||
$this->assertResponseRedirects('/backoffice/season/krtek/question-bank');
|
||||
|
||||
$this->entityManager->clear();
|
||||
$this->assertNotInstanceOf(QuestionLabel::class, $this->entityManager->getRepository(QuestionLabel::class)->findOneBy(['name' => 'Opdracht']));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Tests\Controller\Backoffice;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use Safe\DateTimeImmutable;
|
||||
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Tvdt\Controller\Backoffice\QuizController;
|
||||
use Tvdt\Entity\Answer;
|
||||
use Tvdt\Entity\Candidate;
|
||||
use Tvdt\Entity\Question;
|
||||
use Tvdt\Entity\Quiz;
|
||||
use Tvdt\Entity\QuizCandidate;
|
||||
use Tvdt\Entity\Season;
|
||||
use Tvdt\Entity\User;
|
||||
|
||||
#[CoversClass(QuizController::class)]
|
||||
final class QuizFinalizeTest extends WebTestCase
|
||||
{
|
||||
private KernelBrowser $client;
|
||||
|
||||
private EntityManagerInterface $entityManager;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->client = self::createClient();
|
||||
$this->entityManager = self::getContainer()->get(EntityManagerInterface::class);
|
||||
|
||||
$user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'krtek-admin@example.org']);
|
||||
$this->assertInstanceOf(User::class, $user);
|
||||
$this->client->loginUser($user);
|
||||
}
|
||||
|
||||
private function getQuizByName(string $name): Quiz
|
||||
{
|
||||
$quiz = $this->entityManager->getRepository(Quiz::class)->findOneBy(['name' => $name]);
|
||||
$this->assertInstanceOf(Quiz::class, $quiz);
|
||||
|
||||
return $quiz;
|
||||
}
|
||||
|
||||
private function getKrtekSeason(): Season
|
||||
{
|
||||
$season = $this->entityManager->getRepository(Season::class)->findOneBy(['seasonCode' => 'krtek']);
|
||||
$this->assertInstanceOf(Season::class, $season);
|
||||
|
||||
return $season;
|
||||
}
|
||||
|
||||
private function getCsrfTokenFromOverview(Quiz $quiz, string $formActionContains): string
|
||||
{
|
||||
$crawler = $this->client->request(Request::METHOD_GET, \sprintf('/backoffice/season/krtek/quiz/%s/overview', $quiz->id));
|
||||
$this->assertResponseIsSuccessful();
|
||||
|
||||
$input = $crawler->filter(\sprintf('form[action*="%s"] input[name="_token"]', $formActionContains));
|
||||
$this->assertGreaterThan(0, $input->count(), \sprintf('No form found with action containing "%s"', $formActionContains));
|
||||
|
||||
return (string) $input->first()->attr('value');
|
||||
}
|
||||
|
||||
public function testFinalizeSetsFinalizedAt(): void
|
||||
{
|
||||
$quiz = $this->getQuizByName('Quiz 2');
|
||||
$this->assertFalse($quiz->isFinalized());
|
||||
|
||||
$token = $this->getCsrfTokenFromOverview($quiz, '/finalize');
|
||||
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/quiz/%s/finalize', $quiz->id), ['_token' => $token]);
|
||||
|
||||
$this->assertResponseRedirects();
|
||||
|
||||
$this->entityManager->clear();
|
||||
$this->assertTrue($this->getQuizByName('Quiz 2')->isFinalized());
|
||||
}
|
||||
|
||||
public function testFinalizeRefusedWhenQuizHasErrors(): void
|
||||
{
|
||||
$season = $this->getKrtekSeason();
|
||||
|
||||
$invalidQuiz = new Quiz();
|
||||
$invalidQuiz->name = 'Invalid Quiz';
|
||||
|
||||
$question = new Question();
|
||||
$question->question = 'Vraag zonder goed antwoord';
|
||||
$question->ordering = 1;
|
||||
$question->addAnswer(new Answer('Fout'));
|
||||
$question->addAnswer(new Answer('Ook fout'));
|
||||
|
||||
$invalidQuiz->addQuestion($question);
|
||||
$season->addQuiz($invalidQuiz);
|
||||
$this->entityManager->persist($invalidQuiz);
|
||||
$this->entityManager->flush();
|
||||
|
||||
// Token intention is shared, so any quiz overview provides it
|
||||
$token = $this->getCsrfTokenFromOverview($invalidQuiz, '/finalize');
|
||||
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/quiz/%s/finalize', $invalidQuiz->id), ['_token' => $token]);
|
||||
|
||||
$this->assertResponseRedirects();
|
||||
|
||||
$this->entityManager->clear();
|
||||
$this->assertFalse($this->getQuizByName('Invalid Quiz')->isFinalized());
|
||||
}
|
||||
|
||||
public function testEnableRefusedWhenNotFinalized(): void
|
||||
{
|
||||
$quiz = $this->getQuizByName('Quiz 2');
|
||||
$this->assertFalse($quiz->isFinalized());
|
||||
|
||||
$token = $this->getCsrfTokenFromOverview($quiz, '/enable');
|
||||
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/quiz/%s/enable', $quiz->id), ['_token' => $token]);
|
||||
|
||||
$this->assertResponseRedirects();
|
||||
|
||||
$this->entityManager->clear();
|
||||
$season = $this->getKrtekSeason();
|
||||
$this->assertInstanceOf(Quiz::class, $season->activeQuiz);
|
||||
$this->assertSame('Quiz 1', $season->activeQuiz->name);
|
||||
}
|
||||
|
||||
public function testEnableAllowedWhenFinalized(): void
|
||||
{
|
||||
$quiz = $this->getQuizByName('Quiz 2');
|
||||
$quiz->finalizedAt = new DateTimeImmutable();
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
$token = $this->getCsrfTokenFromOverview($quiz, '/enable');
|
||||
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/quiz/%s/enable', $quiz->id), ['_token' => $token]);
|
||||
|
||||
$this->assertResponseRedirects();
|
||||
|
||||
$this->entityManager->clear();
|
||||
$season = $this->getKrtekSeason();
|
||||
$this->assertInstanceOf(Quiz::class, $season->activeQuiz);
|
||||
$this->assertSame('Quiz 2', $season->activeQuiz->name);
|
||||
}
|
||||
|
||||
public function testUnfinalize(): void
|
||||
{
|
||||
$quiz = $this->getQuizByName('Quiz 2');
|
||||
$quiz->finalizedAt = new DateTimeImmutable();
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
$token = $this->getCsrfTokenFromOverview($quiz, '/unfinalize');
|
||||
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/quiz/%s/unfinalize', $quiz->id), ['_token' => $token]);
|
||||
|
||||
$this->assertResponseRedirects();
|
||||
|
||||
$this->entityManager->clear();
|
||||
$this->assertFalse($this->getQuizByName('Quiz 2')->isFinalized());
|
||||
}
|
||||
|
||||
public function testUnfinalizeRefusedWhenQuizIsActive(): void
|
||||
{
|
||||
// Quiz 1 is finalized and active in the fixtures; scrape a token from Quiz 2 (same intention)
|
||||
$quiz2 = $this->getQuizByName('Quiz 2');
|
||||
$quiz2->finalizedAt = new DateTimeImmutable();
|
||||
|
||||
$this->entityManager->flush();
|
||||
$token = $this->getCsrfTokenFromOverview($quiz2, '/unfinalize');
|
||||
|
||||
$quiz1 = $this->getQuizByName('Quiz 1');
|
||||
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/quiz/%s/unfinalize', $quiz1->id), ['_token' => $token]);
|
||||
|
||||
$this->assertResponseRedirects();
|
||||
|
||||
$this->entityManager->clear();
|
||||
$this->assertTrue($this->getQuizByName('Quiz 1')->isFinalized());
|
||||
}
|
||||
|
||||
public function testUnfinalizeRefusedWhenCandidatesStarted(): void
|
||||
{
|
||||
$quiz = $this->getQuizByName('Quiz 2');
|
||||
$quiz->finalizedAt = new DateTimeImmutable();
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
// Scrape the token before a candidate starts, since the button disappears afterwards
|
||||
$token = $this->getCsrfTokenFromOverview($quiz, '/unfinalize');
|
||||
|
||||
$candidate = $this->entityManager->getRepository(Candidate::class)->findOneBy(['name' => 'Tom']);
|
||||
$this->assertInstanceOf(Candidate::class, $candidate);
|
||||
$quizCandidate = new QuizCandidate($quiz, $candidate);
|
||||
$quizCandidate->started = new DateTimeImmutable();
|
||||
|
||||
$this->entityManager->persist($quizCandidate);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/quiz/%s/unfinalize', $quiz->id), ['_token' => $token]);
|
||||
|
||||
$this->assertResponseRedirects();
|
||||
|
||||
$this->entityManager->clear();
|
||||
$this->assertTrue($this->getQuizByName('Quiz 2')->isFinalized());
|
||||
}
|
||||
|
||||
public function testClearQuizResetsFinalization(): void
|
||||
{
|
||||
$quiz = $this->getQuizByName('Quiz 1');
|
||||
$this->assertTrue($quiz->isFinalized());
|
||||
|
||||
$token = $this->getCsrfTokenFromOverview($quiz, '/clear');
|
||||
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/quiz/%s/clear', $quiz->id), ['_token' => $token]);
|
||||
|
||||
$this->assertResponseRedirects();
|
||||
|
||||
$this->entityManager->clear();
|
||||
$this->assertFalse($this->getQuizByName('Quiz 1')->isFinalized());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Tests\Repository;
|
||||
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use Tvdt\Entity\QuestionLabel;
|
||||
use Tvdt\Repository\BankQuestionRepository;
|
||||
|
||||
#[CoversClass(BankQuestionRepository::class)]
|
||||
final class BankQuestionRepositoryTest extends DatabaseTestCase
|
||||
{
|
||||
private BankQuestionRepository $bankQuestionRepository;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->bankQuestionRepository = self::getContainer()->get(BankQuestionRepository::class);
|
||||
}
|
||||
|
||||
public function testFindBySeasonReturnsAllQuestions(): void
|
||||
{
|
||||
$season = $this->getSeasonByCode('krtek');
|
||||
|
||||
$bankQuestions = $this->bankQuestionRepository->findBySeason($season);
|
||||
|
||||
$this->assertCount(3, $bankQuestions);
|
||||
}
|
||||
|
||||
public function testFindBySeasonFiltersByLabel(): void
|
||||
{
|
||||
$season = $this->getSeasonByCode('krtek');
|
||||
$label = $this->entityManager->getRepository(QuestionLabel::class)
|
||||
->findOneBy(['season' => $season, 'name' => 'Locatie']);
|
||||
$this->assertInstanceOf(QuestionLabel::class, $label);
|
||||
|
||||
$bankQuestions = $this->bankQuestionRepository->findBySeason($season, $label);
|
||||
|
||||
$this->assertCount(2, $bankQuestions);
|
||||
foreach ($bankQuestions as $bankQuestion) {
|
||||
$this->assertTrue($bankQuestion->labels->contains($label));
|
||||
}
|
||||
}
|
||||
|
||||
public function testFindBySeasonIgnoresOtherSeasons(): void
|
||||
{
|
||||
$season = $this->getSeasonByCode('bbbbb');
|
||||
|
||||
$this->assertCount(0, $this->bankQuestionRepository->findBySeason($season));
|
||||
}
|
||||
}
|
||||
@@ -12,9 +12,11 @@ use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
use Tvdt\Entity\Answer;
|
||||
use Tvdt\Entity\BankQuestion;
|
||||
use Tvdt\Entity\Candidate;
|
||||
use Tvdt\Entity\Elimination;
|
||||
use Tvdt\Entity\Question;
|
||||
use Tvdt\Entity\QuestionLabel;
|
||||
use Tvdt\Entity\Quiz;
|
||||
use Tvdt\Entity\Season;
|
||||
use Tvdt\Entity\User;
|
||||
@@ -75,6 +77,61 @@ final class SeasonVoterTest extends TestCase
|
||||
$answer = self::createStub(Answer::class);
|
||||
$answer->question = $question;
|
||||
yield 'Answer' => [$answer];
|
||||
|
||||
$bankQuestion = self::createStub(BankQuestion::class);
|
||||
$bankQuestion->season = $season;
|
||||
yield 'BankQuestion' => [$bankQuestion];
|
||||
|
||||
$questionLabel = self::createStub(QuestionLabel::class);
|
||||
$questionLabel->season = $season;
|
||||
yield 'QuestionLabel' => [$questionLabel];
|
||||
}
|
||||
|
||||
public function testModifyQuizContentGrantedOnUnlockedQuiz(): void
|
||||
{
|
||||
$season = self::createStub(Season::class);
|
||||
$season->method('isOwner')->willReturn(true);
|
||||
|
||||
$quiz = self::createStub(Quiz::class);
|
||||
$quiz->season = $season;
|
||||
$quiz->method('isLocked')->willReturn(false);
|
||||
|
||||
$this->assertSame(VoterInterface::ACCESS_GRANTED, $this->seasonVoter->vote($this->token, $quiz, [SeasonVoter::MODIFY_QUIZ_CONTENT]));
|
||||
}
|
||||
|
||||
public function testModifyQuizContentDeniedOnLockedQuiz(): void
|
||||
{
|
||||
$season = self::createStub(Season::class);
|
||||
$season->method('isOwner')->willReturn(true);
|
||||
|
||||
$quiz = self::createStub(Quiz::class);
|
||||
$quiz->season = $season;
|
||||
$quiz->method('isLocked')->willReturn(true);
|
||||
|
||||
$this->assertSame(VoterInterface::ACCESS_DENIED, $this->seasonVoter->vote($this->token, $quiz, [SeasonVoter::MODIFY_QUIZ_CONTENT]));
|
||||
}
|
||||
|
||||
public function testModifyQuizContentDeniedOnLockedQuizForAdmin(): void
|
||||
{
|
||||
$user = new User();
|
||||
$user->roles = ['ROLE_ADMIN'];
|
||||
|
||||
$token = $this->createStub(TokenInterface::class);
|
||||
$token->method('getUser')->willReturn($user);
|
||||
|
||||
$quiz = self::createStub(Quiz::class);
|
||||
$quiz->season = self::createStub(Season::class);
|
||||
$quiz->method('isLocked')->willReturn(true);
|
||||
|
||||
$this->assertSame(VoterInterface::ACCESS_DENIED, $this->seasonVoter->vote($token, $quiz, [SeasonVoter::MODIFY_QUIZ_CONTENT]));
|
||||
}
|
||||
|
||||
public function testModifyQuizContentDeniedOnNonQuizSubject(): void
|
||||
{
|
||||
$season = self::createStub(Season::class);
|
||||
$season->method('isOwner')->willReturn(true);
|
||||
|
||||
$this->assertSame(VoterInterface::ACCESS_DENIED, $this->seasonVoter->vote($this->token, $season, [SeasonVoter::MODIFY_QUIZ_CONTENT]));
|
||||
}
|
||||
|
||||
public function testWrongUserTypeReturnFalse(): void
|
||||
|
||||
@@ -41,6 +41,22 @@
|
||||
<source>Add a quiz to {name}</source>
|
||||
<target>Voeg een test toe aan {name}</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="3AXboIn" resname="Add answer">
|
||||
<source>Add answer</source>
|
||||
<target>Antwoord toevoegen</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="MJD3m3Q" resname="Add label">
|
||||
<source>Add label</source>
|
||||
<target>Label toevoegen</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="gjGGGsg" resname="Add question">
|
||||
<source>Add question</source>
|
||||
<target>Vraag toevoegen</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="tHdA52O" resname="All">
|
||||
<source>All</source>
|
||||
<target>Alle</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="qiXD5ve" resname="All Seasons">
|
||||
<source>All Seasons</source>
|
||||
<target>Alle seizoenen</target>
|
||||
@@ -57,14 +73,26 @@
|
||||
<source>Are you sure you want to clear all the results? This will also delete all the eliminations.</source>
|
||||
<target>Weet je zeker dat je de resultaten wilt leegmaken? Dit gooit ook alle eliminaties weg.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="8HZ5s3T" resname="Are you sure you want to delete this question from the question bank?">
|
||||
<source>Are you sure you want to delete this question from the question bank?</source>
|
||||
<target>Weet je zeker dat je deze vraag uit de vragenbank wilt verwijderen?</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="Ec4twG8" resname="Are you sure you want to delete this quiz?">
|
||||
<source>Are you sure you want to delete this quiz?</source>
|
||||
<target>Weet je zeker dat je deze test wilt verwijderen?</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="4bcq6sL" resname="Assign">
|
||||
<source>Assign</source>
|
||||
<target>Toewijzen</target>
|
||||
</trans-unit>
|
||||
<trans-unit id=".QFPbFe" resname="Back">
|
||||
<source>Back</source>
|
||||
<target>Terug</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="JUdglpF" resname="Backoffice">
|
||||
<source>Backoffice</source>
|
||||
<target>Backoffice</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="T6TIfj7" resname="Candidate">
|
||||
<source>Candidate</source>
|
||||
<target>Kandidaat</target>
|
||||
@@ -133,6 +161,14 @@
|
||||
<source>Deactivate Quiz</source>
|
||||
<target>Deactiveer test</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="tOdAxXK" resname="Deactivate the quiz before undoing the finalization">
|
||||
<source>Deactivate the quiz before undoing the finalization</source>
|
||||
<target>Deactiveer de test voordat je de finalisatie ongedaan maakt</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="Z_crX_u" resname="Delete">
|
||||
<source>Delete</source>
|
||||
<target>Verwijderen</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="p9GNNI3" resname="Delete Quiz...">
|
||||
<source>Delete Quiz...</source>
|
||||
<target>Test verwijderen...</target>
|
||||
@@ -141,10 +177,22 @@
|
||||
<source>Download Template</source>
|
||||
<target>Download sjabloon</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="dwUtS3b" resname="Draft">
|
||||
<source>Draft</source>
|
||||
<target>Concept</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="FfYlwX8" resname="EMPTY">
|
||||
<source>EMPTY</source>
|
||||
<target>LEEG</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="M.l1CPU" resname="Edit">
|
||||
<source>Edit</source>
|
||||
<target>Bewerken</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="6RmXg4t" resname="Edit question">
|
||||
<source>Edit question</source>
|
||||
<target>Vraag bewerken</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="JZi_tm0" resname="Email">
|
||||
<source>Email</source>
|
||||
<target>E-mail</target>
|
||||
@@ -161,6 +209,18 @@
|
||||
<source>Error clearing quiz</source>
|
||||
<target>Fout bij het leegmaken van de test</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="bgWPQMg" resname="Export to XLSX">
|
||||
<source>Export to XLSX</source>
|
||||
<target>Exporteren naar XLSX</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="6rPqY9p" resname="Finalize">
|
||||
<source>Finalize</source>
|
||||
<target>Finaliseren</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="c_5RxsX" resname="Finalized">
|
||||
<source>Finalized</source>
|
||||
<target>Gefinaliseerd</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="OGiIhMH" resname="Green">
|
||||
<source>Green</source>
|
||||
<target>Groen</target>
|
||||
@@ -193,14 +253,34 @@
|
||||
<source>Inactive</source>
|
||||
<target>Inactief</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="0GRwjA_" resname="Invalid label name">
|
||||
<source>Invalid label name</source>
|
||||
<target>Ongeldige labelnaam</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="k1X7w12" resname="Invalid season code">
|
||||
<source>Invalid season code</source>
|
||||
<target>Ongeldige seizoencode</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="OoJeYtt" resname="Label added">
|
||||
<source>Label added</source>
|
||||
<target>Label toegevoegd</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="HWV7sHP" resname="Label removed">
|
||||
<source>Label removed</source>
|
||||
<target>Label verwijderd</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="YqI5TFM" resname="Labels">
|
||||
<source>Labels</source>
|
||||
<target>Labels</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="q0FeoCr" resname="Load Prepared Elimination">
|
||||
<source>Load Prepared Elimination</source>
|
||||
<target>Laad voorbereide eliminatie</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="M2ELPzt" resname="Locked (answers given)">
|
||||
<source>Locked (answers given)</source>
|
||||
<target>Vergrendeld (antwoorden gegeven)</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="JKl2Twv" resname="Logout">
|
||||
<source>Logout</source>
|
||||
<target>Uitloggen</target>
|
||||
@@ -221,6 +301,10 @@
|
||||
<source>Name</source>
|
||||
<target>Naam</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="lqTjJ4a" resname="New label">
|
||||
<source>New label</source>
|
||||
<target>Nieuw label</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="gefhnBC" resname="Next">
|
||||
<source>Next</source>
|
||||
<target>Volgende</target>
|
||||
@@ -233,6 +317,14 @@
|
||||
<source>No active quiz</source>
|
||||
<target>Geen actieve test</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="lwoek_H" resname="No candidates">
|
||||
<source>No candidates</source>
|
||||
<target>Geen kandidaten</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="IsJa5UL" resname="No questions in the question bank yet">
|
||||
<source>No questions in the question bank yet</source>
|
||||
<target>Nog geen vragen in de vragenbank</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="oNXT2zu" resname="No quizzes">
|
||||
<source>No quizzes</source>
|
||||
<target>Geen tests</target>
|
||||
@@ -249,6 +341,10 @@
|
||||
<source>Number of dropouts:</source>
|
||||
<target>Aantal afvallers:</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="_SqArFZ" resname="Open">
|
||||
<source>Open</source>
|
||||
<target>Openen</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="HmgPmMV" resname="Overview">
|
||||
<source>Overview</source>
|
||||
<target>Overzicht</target>
|
||||
@@ -297,6 +393,30 @@
|
||||
<source>Previous</source>
|
||||
<target>Vorige</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="W1WJHfF" resname="Question">
|
||||
<source>Question</source>
|
||||
<target>Vraag</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="lxvgioH" resname="Question added to quiz %quiz%">
|
||||
<source>Question added to quiz %quiz%</source>
|
||||
<target>Vraag toegevoegd aan test %quiz%</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="hMGFgEZ" resname="Question added to the question bank">
|
||||
<source>Question added to the question bank</source>
|
||||
<target>Vraag toegevoegd aan de vragenbank</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="katmLq0" resname="Question bank">
|
||||
<source>Question bank</source>
|
||||
<target>Vragenbank</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="MNSmL.W" resname="Question updated">
|
||||
<source>Question updated</source>
|
||||
<target>Vraag bijgewerkt</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="Rx5irUP" resname="Questions">
|
||||
<source>Questions</source>
|
||||
<target>Vragen</target>
|
||||
@@ -325,6 +445,10 @@
|
||||
<source>Quiz cleared</source>
|
||||
<target>Test leeggemaakt</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="cyXkBo4" resname="Quiz cleared and no longer finalized">
|
||||
<source>Quiz cleared and no longer finalized</source>
|
||||
<target>Test leeggemaakt en niet langer gefinaliseerd</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="LbVe.2c" resname="Quiz completed">
|
||||
<source>Quiz completed</source>
|
||||
<target>Test voltooid</target>
|
||||
@@ -333,6 +457,14 @@
|
||||
<source>Quiz deleted</source>
|
||||
<target>Test verwijderd</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="GPFvyrm" resname="Quiz finalized">
|
||||
<source>Quiz finalized</source>
|
||||
<target>Test gefinaliseerd</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="BAp8lIR" resname="Quiz is no longer finalized">
|
||||
<source>Quiz is no longer finalized</source>
|
||||
<target>Test is niet langer gefinaliseerd</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="frxoIkW" resname="Quiz name">
|
||||
<source>Quiz name</source>
|
||||
<target>Testnaam</target>
|
||||
@@ -353,6 +485,10 @@
|
||||
<source>Remember me</source>
|
||||
<target>Onthoud mij</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="zy7f1zh" resname="Remove label">
|
||||
<source>Remove label</source>
|
||||
<target>Label verwijderen</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="Z9CSKpk" resname="Repeat Password">
|
||||
<source>Repeat Password</source>
|
||||
<target>Herhaal wachtwoord</target>
|
||||
@@ -361,6 +497,10 @@
|
||||
<source>Results & Elimination</source>
|
||||
<target><![CDATA[Resultaat & Eliminatie]]></target>
|
||||
</trans-unit>
|
||||
<trans-unit id="uFRq6ud" resname="Reusable">
|
||||
<source>Reusable</source>
|
||||
<target>Herbruikbaar</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="z9OKodR" resname="Save">
|
||||
<source>Save</source>
|
||||
<target>Opslaan</target>
|
||||
@@ -413,6 +553,18 @@
|
||||
<source>The password fields must match.</source>
|
||||
<target>De wachtwoorden moeten overeen komen.</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>
|
||||
</trans-unit>
|
||||
<trans-unit id="B7wNGHP" resname="The quiz has already been filled in and can no longer be altered">
|
||||
<source>The quiz has already been filled in and can no longer be altered</source>
|
||||
<target>De test is al ingevuld en kan niet meer aangepast worden</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="Z3xaLuk" resname="The quiz must be finalized before it can be activated">
|
||||
<source>The quiz must be finalized before it can be activated</source>
|
||||
<target>De test moet gefinaliseerd zijn voordat deze geactiveerd kan worden</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="HuzRgeN" resname="There are no answers for this question">
|
||||
<source>There are no answers for this question</source>
|
||||
<target>Er zijn geen antwoorden voor deze vraag</target>
|
||||
@@ -421,10 +573,30 @@
|
||||
<source>There is no active quiz</source>
|
||||
<target>Er is geen test actief</target>
|
||||
</trans-unit>
|
||||
<trans-unit id=".WkwBH8" resname="This question has already been used">
|
||||
<source>This question has already been used</source>
|
||||
<target>Deze vraag is al gebruikt</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="zkt9PBS" resname="This question has been used in a quiz. The copy in the quiz will not be affected.">
|
||||
<source>This question has been used in a quiz. The copy in the quiz will not be affected.</source>
|
||||
<target>Deze vraag is gebruikt in een test. De kopie in de test blijft ongewijzigd.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="nFSq59J" resname="This quiz can no longer be altered">
|
||||
<source>This quiz can no longer be altered</source>
|
||||
<target>Deze test kan niet meer aangepast worden</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="Dptvysv" resname="Time">
|
||||
<source>Time</source>
|
||||
<target>Tijd</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="A_XdODo" resname="Undo finalization">
|
||||
<source>Undo finalization</source>
|
||||
<target>Finalisatie ongedaan maken</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="Bdt8q.S" resname="Used in">
|
||||
<source>Used in</source>
|
||||
<target>Gebruikt in</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="pRCwpOT" resname="Yes">
|
||||
<source>Yes</source>
|
||||
<target>Ja</target>
|
||||
|
||||
@@ -9,6 +9,14 @@
|
||||
<source>A PHP extension caused the upload to fail.</source>
|
||||
<target>De upload is mislukt vanwege een PHP-extensie.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="H5aPWKx" resname="A question must have exactly one correct answer">
|
||||
<source>A question must have exactly one correct answer</source>
|
||||
<target>Een vraag moet precies één goed antwoord hebben</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="urKst95" resname="A question needs at least two answers">
|
||||
<source>A question needs at least two answers</source>
|
||||
<target>Een vraag heeft minstens twee antwoorden nodig</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="L9tKe2y" resname="An empty file is not allowed.">
|
||||
<source>An empty file is not allowed.</source>
|
||||
<target>Lege bestanden zijn niet toegestaan.</target>
|
||||
@@ -65,6 +73,10 @@
|
||||
<source>Please enter a valid URL.</source>
|
||||
<target>Vul een geldige URL in.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="_vG7Dae" resname="Please enter a valid UUID.">
|
||||
<source>Please enter a valid UUID.</source>
|
||||
<target state="needs-review-translation">Vul een geldige UUID in.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="e_2ZUCK" resname="Please enter a valid birthdate.">
|
||||
<source>Please enter a valid birthdate.</source>
|
||||
<target>Vul een geldige geboortedatum in.</target>
|
||||
@@ -361,6 +373,10 @@
|
||||
<source>This URL is missing a top-level domain.</source>
|
||||
<target>Deze URL mist een top-level domein.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="1yeK8aU" resname="This XML payload is too large ({{ size }} bytes): it exceeds the limit of {{ limit }} bytes.">
|
||||
<source>This XML payload is too large ({{ size }} bytes): it exceeds the limit of {{ limit }} bytes.</source>
|
||||
<target state="needs-review-translation">Deze XML-payload is te groot ({{ size }} bytes): deze overschrijdt de limiet van {{ limit }} bytes.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="d4HuxpA" resname="This collection should contain exactly {{ limit }} element.|This collection should contain exactly {{ limit }} elements.">
|
||||
<source>This collection should contain exactly {{ limit }} element.|This collection should contain exactly {{ limit }} elements.</source>
|
||||
<target>Deze collectie moet exact één element bevatten.|Deze collectie moet exact {{ limit }} elementen bevatten.</target>
|
||||
@@ -425,6 +441,10 @@
|
||||
<source>This value contains characters that are not allowed by the current restriction-level.</source>
|
||||
<target>Deze waarde bevat tekens die niet zijn toegestaan volgens het huidige beperkingsniveau.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="hueZAUr" resname="This value does not conform to the expected XSD schema.">
|
||||
<source>This value does not conform to the expected XSD schema.</source>
|
||||
<target state="needs-review-translation">Deze waarde voldoet niet aan het verwachte XSD-schema.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="tD.RkRe" resname="This value does not match the expected {{ charset }} charset.">
|
||||
<source>This value does not match the expected {{ charset }} charset.</source>
|
||||
<target>Deze waarde is niet in de verwachte tekencodering {{ charset }}.</target>
|
||||
@@ -485,6 +505,10 @@
|
||||
<source>This value is not a valid country.</source>
|
||||
<target>Deze waarde is geen geldig land.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="ZXF_Jfm" resname="This value is not a valid cron expression.">
|
||||
<source>This value is not a valid cron expression.</source>
|
||||
<target state="needs-review-translation">Deze waarde is geen geldige cron-expressie.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="OeFZFII" resname="This value is not a valid currency.">
|
||||
<source>This value is not a valid currency.</source>
|
||||
<target>Deze waarde is geen geldige valuta.</target>
|
||||
@@ -525,6 +549,10 @@
|
||||
<source>This value is not a valid week.</source>
|
||||
<target>Deze waarde is geen geldige week.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="iTwuWRs" resname="This value is not valid XML.">
|
||||
<source>This value is not valid XML.</source>
|
||||
<target state="needs-review-translation">Deze waarde is geen geldige XML.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="TZg2uqx" resname="This value is not valid.">
|
||||
<source>This value is not valid.</source>
|
||||
<target>Deze waarde is niet geldig.</target>
|
||||
|
||||
Reference in New Issue
Block a user