mirror of
https://github.com/MarijnDoeve/TijdVoorDeTest.git
synced 2026-07-05 15:10:16 +02:00
Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
a0cb06ddb2
|
|||
|
5ecf56359c
|
|||
|
829028c807
|
|||
|
3d6bb88837
|
|||
|
dde1f9a0b1
|
|||
|
9008f128a6
|
|||
|
052268e042
|
|||
|
482ca8be7e
|
|||
|
f6988f4d77
|
|||
|
4c242b0980
|
|||
|
46e24c5035
|
|||
|
5d51885c82
|
|||
|
8304d8680b
|
|||
|
f05f88bcc5
|
|||
|
c34c25dff7
|
|||
| d1d1eb3a24 | |||
| 5ea7a636b8 | |||
| d37136be93 | |||
| 212401a97f | |||
| 7c74574d3c | |||
| b1f84d441f | |||
| 8c72b1b217 | |||
| 806cff8c0f |
Executable
+48
@@ -0,0 +1,48 @@
|
|||||||
|
#!/usr/bin/env zsh
|
||||||
|
setopt ERR_EXIT PIPE_FAIL NOUNSET
|
||||||
|
|
||||||
|
# Collect staged PHP and Twig files
|
||||||
|
STAGED_PHP=()
|
||||||
|
while IFS= read -r file; do
|
||||||
|
[[ -n "$file" ]] && STAGED_PHP+=("$file")
|
||||||
|
done < <(git diff --cached --name-only --diff-filter=ACMR | grep -E '\.php$' || true)
|
||||||
|
|
||||||
|
STAGED_TWIG=()
|
||||||
|
while IFS= read -r file; do
|
||||||
|
[[ -n "$file" ]] && STAGED_TWIG+=("$file")
|
||||||
|
done < <(git diff --cached --name-only --diff-filter=ACMR | grep -E '\.twig$' || true)
|
||||||
|
|
||||||
|
if [[ ${#STAGED_PHP[@]} -eq 0 && ${#STAGED_TWIG[@]} -eq 0 ]]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use exec if the service is up, otherwise spin up a one-off container
|
||||||
|
if docker compose exec -T php true 2>/dev/null; then
|
||||||
|
DOCKER_CMD=(docker compose exec -T php)
|
||||||
|
else
|
||||||
|
echo "PHP service not running — using docker compose run..."
|
||||||
|
DOCKER_CMD=(docker compose run --rm php)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ${#STAGED_PHP[@]} -gt 0 ]]; then
|
||||||
|
echo "PHP (${#STAGED_PHP[@]} file(s)): Rector → CS-Fixer → PHPStan"
|
||||||
|
|
||||||
|
echo " → Rector"
|
||||||
|
"${DOCKER_CMD[@]}" vendor/bin/rector process "${STAGED_PHP[@]}"
|
||||||
|
git add "${STAGED_PHP[@]}"
|
||||||
|
|
||||||
|
echo " → PHP-CS-Fixer"
|
||||||
|
"${DOCKER_CMD[@]}" vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php "${STAGED_PHP[@]}"
|
||||||
|
git add "${STAGED_PHP[@]}"
|
||||||
|
|
||||||
|
echo " → PHPStan"
|
||||||
|
"${DOCKER_CMD[@]}" vendor/bin/phpstan analyse "${STAGED_PHP[@]}" --no-progress
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ${#STAGED_TWIG[@]} -gt 0 ]]; then
|
||||||
|
echo "Twig (${#STAGED_TWIG[@]} file(s)): Twig-CS-Fixer"
|
||||||
|
|
||||||
|
echo " → Twig-CS-Fixer"
|
||||||
|
"${DOCKER_CMD[@]}" vendor/bin/twig-cs-fixer fix "${STAGED_TWIG[@]}"
|
||||||
|
git add "${STAGED_TWIG[@]}"
|
||||||
|
fi
|
||||||
@@ -28,3 +28,7 @@ updates:
|
|||||||
directory: "/"
|
directory: "/"
|
||||||
schedule:
|
schedule:
|
||||||
interval: "daily"
|
interval: "daily"
|
||||||
|
- package-ecosystem: "github-actions"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
|||||||
+120
-30
@@ -17,31 +17,58 @@ permissions:
|
|||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
quality:
|
build:
|
||||||
name: Code Quality
|
name: Build Dev Image
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 20
|
timeout-minutes: 15
|
||||||
|
if: "!startsWith(github.ref, 'refs/tags/')"
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
- name: Lint Dockerfile
|
- name: Lint Dockerfile
|
||||||
uses: hadolint/hadolint-action@v3.1.0
|
uses: hadolint/hadolint-action@2332a7b74a6de0dda2e2221d575162eba76ba5e5 # v3.3.0
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
|
||||||
- name: Build Docker images
|
- name: Build Docker images
|
||||||
uses: docker/bake-action@v5
|
uses: docker/bake-action@d3418bd7d0e9324001bca92fa8ba175ea7e6dc9b # v7
|
||||||
with:
|
with:
|
||||||
pull: true
|
pull: true
|
||||||
|
files: |
|
||||||
|
compose.yaml
|
||||||
|
compose.override.yaml
|
||||||
|
set: |
|
||||||
|
*.cache-from=type=gha,scope=${{github.ref}}-devbuild
|
||||||
|
*.cache-from=type=gha,scope=refs/heads/main-devbuild
|
||||||
|
*.cache-to=type=gha,scope=${{github.ref}}-devbuild,mode=${{ github.event_name == 'pull_request' && 'min' || 'max' }}
|
||||||
|
|
||||||
|
quality:
|
||||||
|
name: Code Quality
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
needs: build
|
||||||
|
if: "!startsWith(github.ref, 'refs/tags/')"
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
|
||||||
|
- name: Load Docker images
|
||||||
|
uses: docker/bake-action@d3418bd7d0e9324001bca92fa8ba175ea7e6dc9b # v7
|
||||||
|
with:
|
||||||
load: true
|
load: true
|
||||||
files: |
|
files: |
|
||||||
compose.yaml
|
compose.yaml
|
||||||
compose.override.yaml
|
compose.override.yaml
|
||||||
set: |
|
set: |
|
||||||
*.cache-from=type=gha,scope=${{github.ref}}-quality
|
*.cache-from=type=gha,scope=${{github.ref}}-devbuild
|
||||||
*.cache-from=type=gha,scope=refs/heads/main
|
|
||||||
*.cache-to=type=gha,scope=${{github.ref}}-quality,mode=${{ github.event_name == 'pull_request' && 'min' || 'max' }}
|
|
||||||
- name: Start services
|
- name: Start services
|
||||||
run: docker compose up php database --wait --no-build
|
run: docker compose up php database --wait --no-build
|
||||||
- name: Warm up dev cache
|
- name: Warm up dev cache
|
||||||
@@ -71,36 +98,51 @@ jobs:
|
|||||||
- name: Assert all checks passed
|
- name: Assert all checks passed
|
||||||
if: always()
|
if: always()
|
||||||
run: |
|
run: |
|
||||||
outcomes="${{ steps.twig_lint.outcome }} ${{ steps.cs.outcome }} ${{ steps.twig_cs.outcome }} ${{ steps.phpstan.outcome }} ${{ steps.rector.outcome }}"
|
failed=0
|
||||||
if echo "$outcomes" | grep -q "failure"; then exit 1; fi
|
check() {
|
||||||
|
local name="$1" outcome="$2"
|
||||||
|
if [[ "$outcome" == "failure" ]]; then
|
||||||
|
echo "::error::$name failed"
|
||||||
|
failed=1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
check "Twig Lint" "${{ steps.twig_lint.outcome }}"
|
||||||
|
check "Coding Style" "${{ steps.cs.outcome }}"
|
||||||
|
check "Twig Coding Style" "${{ steps.twig_cs.outcome }}"
|
||||||
|
check "PHPStan" "${{ steps.phpstan.outcome }}"
|
||||||
|
check "Rector" "${{ steps.rector.outcome }}"
|
||||||
|
exit $failed
|
||||||
|
|
||||||
tests:
|
tests:
|
||||||
name: Tests
|
name: Tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
|
needs: build
|
||||||
|
if: "!startsWith(github.ref, 'refs/tags/')"
|
||||||
permissions:
|
permissions:
|
||||||
checks: write
|
checks: write
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
- name: Set up Docker Buildx
|
with:
|
||||||
uses: docker/setup-buildx-action@v3
|
persist-credentials: false
|
||||||
- name: Build Docker images
|
- name: Set up Docker Buildx
|
||||||
uses: docker/bake-action@v5
|
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
|
||||||
|
- name: Load Docker images
|
||||||
|
uses: docker/bake-action@d3418bd7d0e9324001bca92fa8ba175ea7e6dc9b # v7
|
||||||
with:
|
with:
|
||||||
pull: true
|
|
||||||
load: true
|
load: true
|
||||||
files: |
|
files: |
|
||||||
compose.yaml
|
compose.yaml
|
||||||
compose.override.yaml
|
compose.override.yaml
|
||||||
set: |
|
set: |
|
||||||
*.cache-from=type=gha,scope=${{github.ref}}-tests
|
*.cache-from=type=gha,scope=${{github.ref}}-devbuild
|
||||||
*.cache-from=type=gha,scope=refs/heads/main
|
|
||||||
*.cache-to=type=gha,scope=${{github.ref}}-tests,mode=${{ github.event_name == 'pull_request' && 'min' || 'max' }}
|
|
||||||
- name: Start services
|
- name: Start services
|
||||||
run: docker compose up php database --wait --no-build
|
run: docker compose up php database --wait --no-build
|
||||||
|
- name: Build SCSS
|
||||||
|
run: docker compose exec -T php bin/console sass:build
|
||||||
- name: Create test database
|
- name: Create test database
|
||||||
run: docker compose exec -T php bin/console -e test doctrine:database:create
|
run: docker compose exec -T php bin/console -e test doctrine:database:create
|
||||||
- name: Run migrations
|
- name: Run migrations
|
||||||
@@ -111,13 +153,55 @@ jobs:
|
|||||||
run: docker compose exec -T php vendor/bin/phpunit --log-junit var/phpunit/junit.xml
|
run: docker compose exec -T php vendor/bin/phpunit --log-junit var/phpunit/junit.xml
|
||||||
- name: Publish PHPUnit test results
|
- name: Publish PHPUnit test results
|
||||||
if: always()
|
if: always()
|
||||||
uses: mikepenz/action-junit-report@v5
|
uses: mikepenz/action-junit-report@d9f48fc87bc235f7e214acf696ca5abc0a986f16 # v6
|
||||||
with:
|
with:
|
||||||
report_paths: var/phpunit/junit.xml
|
report_paths: var/phpunit/junit.xml
|
||||||
check_name: PHPUnit
|
check_name: PHPUnit
|
||||||
- name: Doctrine Schema Validator
|
- name: Doctrine Schema Validator
|
||||||
run: docker compose exec -T php bin/console -e test doctrine:schema:validate
|
run: docker compose exec -T php bin/console -e test doctrine:schema:validate
|
||||||
|
|
||||||
|
verify-prior-run:
|
||||||
|
name: Verify Prior CI Run
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
|
permissions:
|
||||||
|
actions: read
|
||||||
|
steps:
|
||||||
|
- name: Wait for and verify successful CI run on this commit
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
run: |
|
||||||
|
max_attempts=30
|
||||||
|
attempt=0
|
||||||
|
while [[ $attempt -lt $max_attempts ]]; do
|
||||||
|
attempt=$((attempt + 1))
|
||||||
|
|
||||||
|
success_count=$(gh api \
|
||||||
|
"repos/${{ github.repository }}/actions/workflows/ci.yml/runs?head_sha=${{ github.sha }}&status=success&per_page=5" \
|
||||||
|
--jq "[.workflow_runs[] | select(.id != ${{ github.run_id }})] | length")
|
||||||
|
|
||||||
|
if [[ "$success_count" -gt 0 ]]; then
|
||||||
|
echo "Found $success_count prior successful CI run(s) for ${{ github.sha }}."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
in_progress_count=$(gh api \
|
||||||
|
"repos/${{ github.repository }}/actions/workflows/ci.yml/runs?head_sha=${{ github.sha }}&per_page=10" \
|
||||||
|
--jq "[.workflow_runs[] | select(.id != ${{ github.run_id }}) | select(.status == \"in_progress\" or .status == \"queued\" or .status == \"waiting\" or .status == \"requested\" or .status == \"pending\")] | length")
|
||||||
|
|
||||||
|
if [[ "$in_progress_count" -gt 0 ]]; then
|
||||||
|
echo "CI still in progress (attempt $attempt/$max_attempts), waiting 30s..."
|
||||||
|
sleep 30
|
||||||
|
else
|
||||||
|
echo "::error::No prior successful CI run found for ${{ github.sha }}. Only tag commits that have passed CI on main."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "::error::Timed out waiting for CI run to complete for ${{ github.sha }}."
|
||||||
|
exit 1
|
||||||
|
|
||||||
build-deploy:
|
build-deploy:
|
||||||
name: Build and Deploy
|
name: Build and Deploy
|
||||||
permissions:
|
permissions:
|
||||||
@@ -126,17 +210,21 @@ jobs:
|
|||||||
environment:
|
environment:
|
||||||
name: ${{ startsWith(github.ref, 'refs/tags/') && 'production' || 'acceptance' }}
|
name: ${{ startsWith(github.ref, 'refs/tags/') && 'production' || 'acceptance' }}
|
||||||
url: ${{ vars.URL }}
|
url: ${{ vars.URL }}
|
||||||
needs: [quality, tests]
|
needs: [quality, tests, verify-prior-run]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
if: (github.ref == 'refs/heads/main' && false) || startsWith(github.ref, 'refs/tags/')
|
if: >-
|
||||||
|
always() && !cancelled() && !failure() &&
|
||||||
|
((github.ref == 'refs/heads/main' && false) || startsWith(github.ref, 'refs/tags/'))
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
|
||||||
- name: Log in to GitHub Container Registry
|
- name: Log in to GitHub Container Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
@@ -164,7 +252,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Build and Push Docker images
|
- name: Build and Push Docker images
|
||||||
uses: docker/bake-action@v5
|
uses: docker/bake-action@d3418bd7d0e9324001bca92fa8ba175ea7e6dc9b # v7
|
||||||
with:
|
with:
|
||||||
pull: true
|
pull: true
|
||||||
push: true
|
push: true
|
||||||
@@ -178,7 +266,7 @@ jobs:
|
|||||||
*.tags=${{ steps.meta.outputs.full_name }}
|
*.tags=${{ steps.meta.outputs.full_name }}
|
||||||
|
|
||||||
- name: Create Sentry release
|
- name: Create Sentry release
|
||||||
uses: getsentry/action-release@v3
|
uses: getsentry/action-release@ff07929a6537bac57790c3451cf4d364aca38528 # v3
|
||||||
env:
|
env:
|
||||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||||
@@ -191,5 +279,7 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
PORTAINER_WEBHOOK: ${{secrets.PORTAINER_WEBHOOK}}
|
PORTAINER_WEBHOOK: ${{secrets.PORTAINER_WEBHOOK}}
|
||||||
|
IMAGE_TAG: ${{steps.meta.outputs.tag}}
|
||||||
|
SENTRY_RELEASE: ${{steps.meta.outputs.sentry_version}}
|
||||||
run: |
|
run: |
|
||||||
curl -v -X POST "${PORTAINER_WEBHOOK}?IMAGE_TAG=${{steps.meta.outputs.tag}}" --fail-with-body
|
curl -v -X POST "${PORTAINER_WEBHOOK}?IMAGE_TAG=${IMAGE_TAG}&SENTRY_RELEASE=${SENTRY_RELEASE}" --fail-with-body
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Dependabot metadata
|
- name: Dependabot metadata
|
||||||
id: metadata
|
id: metadata
|
||||||
uses: dependabot/fetch-metadata@v2
|
uses: dependabot/fetch-metadata@v3
|
||||||
with:
|
with:
|
||||||
github-token: "${{ secrets.GITHUB_TOKEN }}"
|
github-token: "${{ secrets.GITHUB_TOKEN }}"
|
||||||
- name: Enable auto-merge for Dependabot PRs
|
- name: Enable auto-merge for Dependabot PRs
|
||||||
|
|||||||
Generated
+1
@@ -170,6 +170,7 @@
|
|||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/ergebnis/agent-detector" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/ergebnis/agent-detector" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/polyfill-deepclone" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/polyfill-deepclone" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/file-filter" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/file-filter" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/object-mapper" />
|
||||||
</content>
|
</content>
|
||||||
<orderEntry type="inheritedJdk" />
|
<orderEntry type="inheritedJdk" />
|
||||||
<orderEntry type="sourceFolder" forTests="false" />
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
|||||||
Generated
+163
-162
@@ -41,169 +41,170 @@
|
|||||||
</component>
|
</component>
|
||||||
<component name="PhpIncludePathManager">
|
<component name="PhpIncludePathManager">
|
||||||
<include_path>
|
<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/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" />
|
||||||
|
<path value="$PROJECT_DIR$/vendor/symfony/object-mapper" />
|
||||||
</include_path>
|
</include_path>
|
||||||
</component>
|
</component>
|
||||||
<component name="PhpInterpreters">
|
<component name="PhpInterpreters">
|
||||||
|
|||||||
@@ -224,6 +224,15 @@ Auto-executed scripts on install/update:
|
|||||||
- `assets:install` - Copy public assets
|
- `assets:install` - Copy public assets
|
||||||
- `importmap:install` - JS import map setup
|
- `importmap:install` - JS import map setup
|
||||||
|
|
||||||
|
## Writing Style (Help Content & UI Text)
|
||||||
|
|
||||||
|
When writing Dutch help content in `templates/backoffice/help/nl/`:
|
||||||
|
|
||||||
|
- **No em-dashes** (—): use a comma or restructure the sentence instead.
|
||||||
|
- **No semicolons** (;): use a comma. Semicolons are technically correct but read as AI-generated text.
|
||||||
|
- **Natural Dutch**: write the way a person would explain it to a colleague, not in formal documentation style.
|
||||||
|
- **Colons after bold labels** (e.g. `<strong>Label:</strong> description`) are fine and intentional.
|
||||||
|
|
||||||
## Notes for Future Work
|
## Notes for Future Work
|
||||||
|
|
||||||
- The backoffice elimination logic is in `Controller/Backoffice/PrepareEliminationController.php`
|
- The backoffice elimination logic is in `Controller/Backoffice/PrepareEliminationController.php`
|
||||||
|
|||||||
@@ -51,6 +51,11 @@ reload-tests:
|
|||||||
@docker compose exec php bin/console --env=test doctrine:migrations:migrate -n
|
@docker compose exec php bin/console --env=test doctrine:migrations:migrate -n
|
||||||
@docker compose exec php bin/console --env=test doctrine:fixtures:load -n --group=test
|
@docker compose exec php bin/console --env=test doctrine:fixtures:load -n --group=test
|
||||||
|
|
||||||
|
install-hooks:
|
||||||
|
git config core.hooksPath .githooks
|
||||||
|
chmod +x .githooks/pre-commit
|
||||||
|
@echo "Pre-commit hook installed."
|
||||||
|
|
||||||
trust-cert:
|
trust-cert:
|
||||||
sudo security add-trusted-cer -d \
|
sudo security add-trusted-cer -d \
|
||||||
-r trustRoot \
|
-r trustRoot \
|
||||||
|
|||||||
@@ -1,39 +1,162 @@
|
|||||||
# Tijd voor de test
|
# Tijd voor de test
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
PHP/Symfony application for WIDM-style quiz management.
|
||||||
|
Built with FrankenPHP, PostgreSQL, and Docker.
|
||||||
|
|
||||||
|
> **Disclaimer:** This is an unofficial, non-commercial, open-source fan
|
||||||
|
> project. It is not affiliated with, endorsed by, or associated with
|
||||||
|
> *Wie is de Mol?* (produced by IDTV, broadcast by AVROTROS/NPO) or
|
||||||
|
> *De Mol* (produced by Woestijnvis, broadcast by Play/De Vijver Media).
|
||||||
|
> *Wie is de Mol?* and *De Mol* are trademarks of their respective rights
|
||||||
|
> holders. No copyright infringement is intended.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
### Maken van de test
|
- Docker
|
||||||
|
- [Just](https://just.systems) (`brew install just`)
|
||||||
|
|
||||||
- WIDM-tests met een variabel aantal vragen.
|
## Local development
|
||||||
- Vragen in een vaste volgorde zijn samen één test (een vraag kan niet bij
|
|
||||||
meerdere tests horen).
|
|
||||||
- Vragen hebben 2 of meer antwoordmogelijkheden. Slechts één antwoord is correct.
|
|
||||||
- Meerdere test samen vormen een seizoen.
|
|
||||||
- Een seizoen heeft één of geen actieve tests, als er een test actief is kan
|
|
||||||
uitsluitend die test gemaakt worden.
|
|
||||||
- Kandidaten kunnen een test maximaal 1 keer invullen.
|
|
||||||
- Vanaf het moment dat de kandidaat op start klikt na het intypen van hun naam
|
|
||||||
gaat de tijd lopen. Deze stopt na het aanklikken van een antwoord op de laatste
|
|
||||||
vraag van de test.
|
|
||||||
- Achtergrondmuziek
|
|
||||||
|
|
||||||
### Schermen kijken
|
```bash
|
||||||
|
just up # Start PHP + PostgreSQL containers
|
||||||
|
just migrate # Run pending database migrations
|
||||||
|
just fixtures # Load dev fixtures (truncates first)
|
||||||
|
```
|
||||||
|
|
||||||
- Nadat een speler een test heeft gemaakt (of vooraf als de namen vooraf
|
The app is available at **https://localhost** (self-signed cert — run
|
||||||
ingevoerd zijn) kunnen jokers toegekend worden aan de test van kandidaat. Een
|
`just trust-cert` on macOS to trust it).
|
||||||
positief getal om antwoorden goed te rekenen, een negatief getal om
|
|
||||||
antwoorden fout te rekenen.
|
|
||||||
- Vooraf kan gekozen worden hoe veel afvallers er zijn.
|
|
||||||
- Bij het kijken naam rode en groene schermen wordt een naam ingevoerd. Er
|
|
||||||
wordt een rood of groen scherm getoond.
|
|
||||||
- Spelers kunnen geforceerd op groen of rood gezet worden, deze worden dan niet
|
|
||||||
meegenomen in de berekening van de slechtste speler.
|
|
||||||
|
|
||||||
### Statistieken
|
### Useful commands
|
||||||
|
|
||||||
TBD
|
```bash
|
||||||
|
just shell # Shell inside the running PHP container
|
||||||
|
just shell-run # Shell in a fresh one-off container
|
||||||
|
just stop # Stop containers (keep volumes)
|
||||||
|
just down # Stop and remove containers
|
||||||
|
just clean # Nuclear: remove containers + volumes + generated files
|
||||||
|
just exec <cmd> # Run any command inside the PHP container
|
||||||
|
```
|
||||||
|
|
||||||
## Nice to haves
|
### Environment
|
||||||
|
|
||||||
- Optie voor antwoord geven in twee klikken (selecteren en volgende).
|
Copy `.env` and override locally via `.env.local` (not committed):
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
|----------------|-------------------------------------|
|
||||||
|
| `APP_SECRET` | Symfony app secret |
|
||||||
|
| `DATABASE_URL` | PostgreSQL DSN (auto-set in Docker) |
|
||||||
|
| `SENTRY_DSN` | Sentry error tracking |
|
||||||
|
| `DEFAULT_URI` | Base URL for CLI-generated links |
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
just test # Full PHPUnit suite
|
||||||
|
just test tests/Path/To/TestFile.php # Single file
|
||||||
|
just test --coverage-html var/coverage # HTML coverage report
|
||||||
|
just reload-tests # Drop/recreate test DB + migrate + test fixtures
|
||||||
|
```
|
||||||
|
|
||||||
|
Tests use a separate database configured via `.env.test`. The DAMA
|
||||||
|
Doctrine bundle wraps each test in a transaction that is rolled back after.
|
||||||
|
`just reload-tests` loads the `--group=test` fixtures; `just fixtures`
|
||||||
|
loads the dev group and is unrelated to the test database.
|
||||||
|
|
||||||
|
## Code quality
|
||||||
|
|
||||||
|
All checks run in CI and must pass before merging.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
just fix-cs # Auto-fix PHP-CS-Fixer + Twig-CS-Fixer
|
||||||
|
just phpstan # PHPStan static analysis (level 8)
|
||||||
|
just rector # Apply Rector modernizations
|
||||||
|
just rector --dry-run # Preview Rector changes without applying
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database
|
||||||
|
|
||||||
|
```bash
|
||||||
|
just migrate # Run pending migrations
|
||||||
|
just fixtures # Load dev fixtures
|
||||||
|
bin/console make:migration # Generate a new migration (inside container)
|
||||||
|
```
|
||||||
|
|
||||||
|
Migrations live in `migrations/` (namespace `DoctrineMigrations`). Test
|
||||||
|
fixtures are in `src/DataFixtures/` loaded with `--group=test`.
|
||||||
|
|
||||||
|
## Translations
|
||||||
|
|
||||||
|
```bash
|
||||||
|
just translations # Extract/update nl translation strings into translations/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
1. Create a branch from `main` — use a prefix like `feat/`, `fix/`,
|
||||||
|
or `docs/`.
|
||||||
|
2. Open a pull request; CI must pass before merging.
|
||||||
|
3. Install the pre-commit hook (see below) to catch issues before pushing.
|
||||||
|
|
||||||
|
### Pre-commit hook
|
||||||
|
|
||||||
|
A pre-commit hook lives in `.githooks/pre-commit`. Install it once after cloning:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
just install-hooks
|
||||||
|
```
|
||||||
|
|
||||||
|
On every commit it runs automatically, **only on staged files**:
|
||||||
|
|
||||||
|
| Staged file type | Tools run |
|
||||||
|
|-------------------------|------------------------------------------------------------------------------|
|
||||||
|
| `.php` | Rector → PHP-CS-Fixer (auto-fix + re-stage), then PHPStan (blocks on errors) |
|
||||||
|
| `.twig` | Twig-CS-Fixer (auto-fix + re-stage) |
|
||||||
|
| Other (docs, config, …) | Nothing — commit proceeds immediately |
|
||||||
|
|
||||||
|
If the PHP container is not running, the hook falls back to
|
||||||
|
`docker compose run --rm` so checks still execute. PHPUnit is not
|
||||||
|
run in the hook; CI covers that.
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
Docker images are published to `ghcr.io/marijndoeve/tijdvoordetest`
|
||||||
|
for each tagged release.
|
||||||
|
|
||||||
|
### First-time setup
|
||||||
|
|
||||||
|
1. Copy `compose.yaml` and `compose.prod.yaml` to your server.
|
||||||
|
2. Create a `.env.prod.local` file with the required variables (see below).
|
||||||
|
3. Start the stack — migrations run automatically on container start:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
IMAGE_TAG=latest docker compose -f compose.yaml -f compose.prod.yaml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Updating to a new version
|
||||||
|
|
||||||
|
```bash
|
||||||
|
IMAGE_TAG=<tag> docker compose -f compose.yaml -f compose.prod.yaml pull
|
||||||
|
IMAGE_TAG=<tag> docker compose -f compose.yaml -f compose.prod.yaml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Required environment variables
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
|----------------------------|---------------------------------------------|
|
||||||
|
| `IMAGE_TAG` | Image tag to run (e.g. `1.2.3` or `latest`) |
|
||||||
|
| `APP_SECRET` | Random secret string for Symfony |
|
||||||
|
| `CADDY_MERCURE_JWT_SECRET` | JWT secret for the Mercure hub |
|
||||||
|
| `POSTGRES_PASSWORD` | PostgreSQL password |
|
||||||
|
| `MAILER_DSN` | Mailer transport DSN |
|
||||||
|
| `MAILER_SENDER` | From address for emails |
|
||||||
|
| `SENTRY_DSN` | Sentry project DSN (optional) |
|
||||||
|
|
||||||
|
The `compose.prod.yaml` configures Traefik labels for TLS termination at
|
||||||
|
`tijdvoordetest.nl`. Adjust the `traefik` labels in that file if you're
|
||||||
|
hosting on a different domain or using a different reverse proxy.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
[MIT](LICENSE)
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -10,7 +10,7 @@ services:
|
|||||||
MAILER_DSN: ${MAILER_DSN}
|
MAILER_DSN: ${MAILER_DSN}
|
||||||
MAILER_SENDER: ${MAILER_SENDER}
|
MAILER_SENDER: ${MAILER_SENDER}
|
||||||
SENTRY_DSN: ${SENTRY_DSN}
|
SENTRY_DSN: ${SENTRY_DSN}
|
||||||
SENTRY_RELEASE: ${IMAGE_TAG}
|
SENTRY_RELEASE: ${SENTRY_RELEASE}
|
||||||
SENTRY_ENVIRONMENT: ${SENTRY_ENVIRONMENT}
|
SENTRY_ENVIRONMENT: ${SENTRY_ENVIRONMENT}
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
"symfony/form": "8.1.*",
|
"symfony/form": "8.1.*",
|
||||||
"symfony/framework-bundle": "8.1.*",
|
"symfony/framework-bundle": "8.1.*",
|
||||||
"symfony/mailer": "8.1.*",
|
"symfony/mailer": "8.1.*",
|
||||||
|
"symfony/object-mapper": "8.1.*",
|
||||||
"symfony/property-access": "8.1.*",
|
"symfony/property-access": "8.1.*",
|
||||||
"symfony/property-info": "8.1.*",
|
"symfony/property-info": "8.1.*",
|
||||||
"symfony/runtime": "8.1.*",
|
"symfony/runtime": "8.1.*",
|
||||||
|
|||||||
Generated
+155
-80
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "f8e4107825dba89eaa38d067d47856ce",
|
"content-hash": "7171824ca13f4df0801dfa5d7f58d6a0",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "composer/pcre",
|
"name": "composer/pcre",
|
||||||
@@ -1839,16 +1839,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "martin-georgiev/postgresql-for-doctrine",
|
"name": "martin-georgiev/postgresql-for-doctrine",
|
||||||
"version": "v4.6.0",
|
"version": "v4.7.0",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/martin-georgiev/postgresql-for-doctrine.git",
|
"url": "https://github.com/martin-georgiev/postgresql-for-doctrine.git",
|
||||||
"reference": "59841c7e53f8339b13bc0cb0ee9931b7b9bbb139"
|
"reference": "23b5c2694083355ab87eaa913b43a0cddd8c64bb"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/martin-georgiev/postgresql-for-doctrine/zipball/59841c7e53f8339b13bc0cb0ee9931b7b9bbb139",
|
"url": "https://api.github.com/repos/martin-georgiev/postgresql-for-doctrine/zipball/23b5c2694083355ab87eaa913b43a0cddd8c64bb",
|
||||||
"reference": "59841c7e53f8339b13bc0cb0ee9931b7b9bbb139",
|
"reference": "23b5c2694083355ab87eaa913b43a0cddd8c64bb",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -1863,13 +1863,13 @@
|
|||||||
"deptrac/deptrac": "^4.0",
|
"deptrac/deptrac": "^4.0",
|
||||||
"doctrine/orm": "~2.14||~3.0",
|
"doctrine/orm": "~2.14||~3.0",
|
||||||
"ekino/phpstan-banned-code": "^3.2.0",
|
"ekino/phpstan-banned-code": "^3.2.0",
|
||||||
"friendsofphp/php-cs-fixer": "^3.95.2",
|
"friendsofphp/php-cs-fixer": "^3.95.11",
|
||||||
"phpstan/phpstan": "^2.1.55",
|
"phpstan/phpstan": "^2.2.2",
|
||||||
"phpstan/phpstan-deprecation-rules": "^2.0.4",
|
"phpstan/phpstan-deprecation-rules": "^2.0.4",
|
||||||
"phpstan/phpstan-doctrine": "^2.0.22",
|
"phpstan/phpstan-doctrine": "^2.0.27",
|
||||||
"phpstan/phpstan-phpunit": "^2.0.16",
|
"phpstan/phpstan-phpunit": "^2.0.16",
|
||||||
"phpunit/phpunit": "^10.5.63||^11.5",
|
"phpunit/phpunit": "^10.5.63||^11.5",
|
||||||
"rector/rector": "^2.4.4",
|
"rector/rector": "^2.5.2",
|
||||||
"symfony/cache": "^6.4||^7.0",
|
"symfony/cache": "^6.4||^7.0",
|
||||||
"symfony/var-exporter": "^6.4||^7.0"
|
"symfony/var-exporter": "^6.4||^7.0"
|
||||||
},
|
},
|
||||||
@@ -1952,7 +1952,7 @@
|
|||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/martin-georgiev/postgresql-for-doctrine/issues",
|
"issues": "https://github.com/martin-georgiev/postgresql-for-doctrine/issues",
|
||||||
"source": "https://github.com/martin-georgiev/postgresql-for-doctrine/tree/v4.6.0"
|
"source": "https://github.com/martin-georgiev/postgresql-for-doctrine/tree/v4.7.0"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -1964,7 +1964,7 @@
|
|||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-05-29T19:11:20+00:00"
|
"time": "2026-07-01T18:17:39+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "phpdocumentor/reflection-common",
|
"name": "phpdocumentor/reflection-common",
|
||||||
@@ -5337,6 +5337,79 @@
|
|||||||
],
|
],
|
||||||
"time": "2026-05-29T05:06:50+00:00"
|
"time": "2026-05-29T05:06:50+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "symfony/object-mapper",
|
||||||
|
"version": "v8.1.1",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/symfony/object-mapper.git",
|
||||||
|
"reference": "f2d118d3ced275117b83acc5b57f6611ab38cd14"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/symfony/object-mapper/zipball/f2d118d3ced275117b83acc5b57f6611ab38cd14",
|
||||||
|
"reference": "f2d118d3ced275117b83acc5b57f6611ab38cd14",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": ">=8.4.1",
|
||||||
|
"psr/container": "^2.0"
|
||||||
|
},
|
||||||
|
"conflict": {
|
||||||
|
"symfony/property-access": "<7.2"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"symfony/property-access": "^7.4|^8.0",
|
||||||
|
"symfony/var-exporter": "^7.4|^8.0"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Symfony\\Component\\ObjectMapper\\": ""
|
||||||
|
},
|
||||||
|
"exclude-from-classmap": [
|
||||||
|
"/Tests/"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Fabien Potencier",
|
||||||
|
"email": "fabien@symfony.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Symfony Community",
|
||||||
|
"homepage": "https://symfony.com/contributors"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Provides a way to map an object to another object",
|
||||||
|
"homepage": "https://symfony.com",
|
||||||
|
"support": {
|
||||||
|
"source": "https://github.com/symfony/object-mapper/tree/v8.1.1"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://symfony.com/sponsor",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/fabpot",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/nicolas-grekas",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||||
|
"type": "tidelift"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2026-06-17T10:12:54+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/options-resolver",
|
"name": "symfony/options-resolver",
|
||||||
"version": "v8.1.0",
|
"version": "v8.1.0",
|
||||||
@@ -9287,16 +9360,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "friendsofphp/php-cs-fixer",
|
"name": "friendsofphp/php-cs-fixer",
|
||||||
"version": "v3.95.8",
|
"version": "v3.95.11",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git",
|
"url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git",
|
||||||
"reference": "4140023f552ff02346df9b1329742532166f677f"
|
"reference": "35f98e1293283397824d7f349ce5afb8747c3cd5"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/4140023f552ff02346df9b1329742532166f677f",
|
"url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/35f98e1293283397824d7f349ce5afb8747c3cd5",
|
||||||
"reference": "4140023f552ff02346df9b1329742532166f677f",
|
"reference": "35f98e1293283397824d7f349ce5afb8747c3cd5",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -9330,7 +9403,7 @@
|
|||||||
"require-dev": {
|
"require-dev": {
|
||||||
"facile-it/paraunit": "^1.3.1 || ^2.11.0",
|
"facile-it/paraunit": "^1.3.1 || ^2.11.0",
|
||||||
"infection/infection": "^0.32.7",
|
"infection/infection": "^0.32.7",
|
||||||
"justinrainbow/json-schema": "^6.9.0",
|
"justinrainbow/json-schema": "^6.10.0",
|
||||||
"keradus/cli-executor": "^2.3",
|
"keradus/cli-executor": "^2.3",
|
||||||
"mikey179/vfsstream": "^1.6.12",
|
"mikey179/vfsstream": "^1.6.12",
|
||||||
"php-coveralls/php-coveralls": "^2.9.1",
|
"php-coveralls/php-coveralls": "^2.9.1",
|
||||||
@@ -9380,7 +9453,7 @@
|
|||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues",
|
"issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues",
|
||||||
"source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.8"
|
"source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.11"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -9388,7 +9461,7 @@
|
|||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-06-16T09:52:26+00:00"
|
"time": "2026-06-25T14:17:04+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "myclabs/deep-copy",
|
"name": "myclabs/deep-copy",
|
||||||
@@ -9676,11 +9749,11 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "phpstan/phpstan",
|
"name": "phpstan/phpstan",
|
||||||
"version": "2.2.2",
|
"version": "2.2.4",
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/e5cc34d491a90e79c216d824f60fe21fd4d93bd6",
|
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/f0fe3fb03bb53ce68cc2416785b260e62226ec27",
|
||||||
"reference": "e5cc34d491a90e79c216d824f60fe21fd4d93bd6",
|
"reference": "f0fe3fb03bb53ce68cc2416785b260e62226ec27",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -9736,7 +9809,7 @@
|
|||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-06-05T09:00:01+00:00"
|
"time": "2026-07-03T07:00:23+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "phpstan/phpstan-doctrine",
|
"name": "phpstan/phpstan-doctrine",
|
||||||
@@ -9817,21 +9890,22 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "phpstan/phpstan-phpunit",
|
"name": "phpstan/phpstan-phpunit",
|
||||||
"version": "2.0.16",
|
"version": "2.0.17",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/phpstan/phpstan-phpunit.git",
|
"url": "https://github.com/phpstan/phpstan-phpunit.git",
|
||||||
"reference": "6ab598e1bc106e6827fd346ae4a12b4a5d634c32"
|
"reference": "c2f977551f0736d60467b3d754b2e0cf4e337b3f"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/phpstan/phpstan-phpunit/zipball/6ab598e1bc106e6827fd346ae4a12b4a5d634c32",
|
"url": "https://api.github.com/repos/phpstan/phpstan-phpunit/zipball/c2f977551f0736d60467b3d754b2e0cf4e337b3f",
|
||||||
"reference": "6ab598e1bc106e6827fd346ae4a12b4a5d634c32",
|
"reference": "c2f977551f0736d60467b3d754b2e0cf4e337b3f",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
|
"phar-io/version": "^3.2",
|
||||||
"php": "^7.4 || ^8.0",
|
"php": "^7.4 || ^8.0",
|
||||||
"phpstan/phpstan": "^2.1.32"
|
"phpstan/phpstan": "^2.2.3"
|
||||||
},
|
},
|
||||||
"conflict": {
|
"conflict": {
|
||||||
"phpunit/phpunit": "<7.0"
|
"phpunit/phpunit": "<7.0"
|
||||||
@@ -9841,7 +9915,8 @@
|
|||||||
"php-parallel-lint/php-parallel-lint": "^1.2",
|
"php-parallel-lint/php-parallel-lint": "^1.2",
|
||||||
"phpstan/phpstan-deprecation-rules": "^2.0",
|
"phpstan/phpstan-deprecation-rules": "^2.0",
|
||||||
"phpstan/phpstan-strict-rules": "^2.0",
|
"phpstan/phpstan-strict-rules": "^2.0",
|
||||||
"phpunit/phpunit": "^9.6"
|
"phpunit/phpunit": "^9.6",
|
||||||
|
"shipmonk/name-collision-detector": "^2.1"
|
||||||
},
|
},
|
||||||
"type": "phpstan-extension",
|
"type": "phpstan-extension",
|
||||||
"extra": {
|
"extra": {
|
||||||
@@ -9867,9 +9942,9 @@
|
|||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/phpstan/phpstan-phpunit/issues",
|
"issues": "https://github.com/phpstan/phpstan-phpunit/issues",
|
||||||
"source": "https://github.com/phpstan/phpstan-phpunit/tree/2.0.16"
|
"source": "https://github.com/phpstan/phpstan-phpunit/tree/2.0.17"
|
||||||
},
|
},
|
||||||
"time": "2026-02-14T09:05:21+00:00"
|
"time": "2026-06-29T05:32:23+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "phpstan/phpstan-symfony",
|
"name": "phpstan/phpstan-symfony",
|
||||||
@@ -10330,16 +10405,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "phpunit/phpunit",
|
"name": "phpunit/phpunit",
|
||||||
"version": "13.2.1",
|
"version": "13.2.2",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/sebastianbergmann/phpunit.git",
|
"url": "https://github.com/sebastianbergmann/phpunit.git",
|
||||||
"reference": "60da0ff1e10a0f72ee18a24117ec3b613a346bba"
|
"reference": "492c067e618de7b3c76105082c90f9d2833401b7"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/60da0ff1e10a0f72ee18a24117ec3b613a346bba",
|
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/492c067e618de7b3c76105082c90f9d2833401b7",
|
||||||
"reference": "60da0ff1e10a0f72ee18a24117ec3b613a346bba",
|
"reference": "492c067e618de7b3c76105082c90f9d2833401b7",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -10410,7 +10485,7 @@
|
|||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/sebastianbergmann/phpunit/issues",
|
"issues": "https://github.com/sebastianbergmann/phpunit/issues",
|
||||||
"security": "https://github.com/sebastianbergmann/phpunit/security/policy",
|
"security": "https://github.com/sebastianbergmann/phpunit/security/policy",
|
||||||
"source": "https://github.com/sebastianbergmann/phpunit/tree/13.2.1"
|
"source": "https://github.com/sebastianbergmann/phpunit/tree/13.2.2"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -10418,7 +10493,7 @@
|
|||||||
"type": "other"
|
"type": "other"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-06-15T13:14:22+00:00"
|
"time": "2026-06-29T13:36:29+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "react/cache",
|
"name": "react/cache",
|
||||||
@@ -10948,16 +11023,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "rector/rector",
|
"name": "rector/rector",
|
||||||
"version": "2.4.6",
|
"version": "2.5.2",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/rectorphp/rector.git",
|
"url": "https://github.com/rectorphp/rector.git",
|
||||||
"reference": "9b9e5c76618e4d359f65b54ca2eabcad3d1761ee"
|
"reference": "49ff6339174bdbdf50b0b35ecbcff14a05ac9e24"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/rectorphp/rector/zipball/9b9e5c76618e4d359f65b54ca2eabcad3d1761ee",
|
"url": "https://api.github.com/repos/rectorphp/rector/zipball/49ff6339174bdbdf50b0b35ecbcff14a05ac9e24",
|
||||||
"reference": "9b9e5c76618e4d359f65b54ca2eabcad3d1761ee",
|
"reference": "49ff6339174bdbdf50b0b35ecbcff14a05ac9e24",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -10996,7 +11071,7 @@
|
|||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/rectorphp/rector/issues",
|
"issues": "https://github.com/rectorphp/rector/issues",
|
||||||
"source": "https://github.com/rectorphp/rector/tree/2.4.6"
|
"source": "https://github.com/rectorphp/rector/tree/2.5.2"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -11004,7 +11079,7 @@
|
|||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-06-17T11:56:28+00:00"
|
"time": "2026-06-22T11:39:33+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "sebastian/cli-parser",
|
"name": "sebastian/cli-parser",
|
||||||
@@ -12167,16 +12242,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/browser-kit",
|
"name": "symfony/browser-kit",
|
||||||
"version": "v8.1.0",
|
"version": "v8.1.1",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/symfony/browser-kit.git",
|
"url": "https://github.com/symfony/browser-kit.git",
|
||||||
"reference": "74e18e582cdda0eca35f7c74e1e48e62f0ede853"
|
"reference": "f2ac86001ca9f487e8c6d0e11c8e33e6a9b8b2d5"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/symfony/browser-kit/zipball/74e18e582cdda0eca35f7c74e1e48e62f0ede853",
|
"url": "https://api.github.com/repos/symfony/browser-kit/zipball/f2ac86001ca9f487e8c6d0e11c8e33e6a9b8b2d5",
|
||||||
"reference": "74e18e582cdda0eca35f7c74e1e48e62f0ede853",
|
"reference": "f2ac86001ca9f487e8c6d0e11c8e33e6a9b8b2d5",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -12215,7 +12290,7 @@
|
|||||||
"description": "Simulates the behavior of a web browser, allowing you to make requests, click on links and submit forms programmatically",
|
"description": "Simulates the behavior of a web browser, allowing you to make requests, click on links and submit forms programmatically",
|
||||||
"homepage": "https://symfony.com",
|
"homepage": "https://symfony.com",
|
||||||
"support": {
|
"support": {
|
||||||
"source": "https://github.com/symfony/browser-kit/tree/v8.1.0"
|
"source": "https://github.com/symfony/browser-kit/tree/v8.1.1"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -12235,7 +12310,7 @@
|
|||||||
"type": "tidelift"
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-05-29T05:06:50+00:00"
|
"time": "2026-06-09T10:54:51+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/css-selector",
|
"name": "symfony/css-selector",
|
||||||
@@ -12308,16 +12383,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/dom-crawler",
|
"name": "symfony/dom-crawler",
|
||||||
"version": "v8.1.0",
|
"version": "v8.1.1",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/symfony/dom-crawler.git",
|
"url": "https://github.com/symfony/dom-crawler.git",
|
||||||
"reference": "77ca351474ea018daba5f2e473cbf1b9b8e72ac6"
|
"reference": "1dfadd25537c8fcb6752cce5775f24647d976bdc"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/symfony/dom-crawler/zipball/77ca351474ea018daba5f2e473cbf1b9b8e72ac6",
|
"url": "https://api.github.com/repos/symfony/dom-crawler/zipball/1dfadd25537c8fcb6752cce5775f24647d976bdc",
|
||||||
"reference": "77ca351474ea018daba5f2e473cbf1b9b8e72ac6",
|
"reference": "1dfadd25537c8fcb6752cce5775f24647d976bdc",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -12354,7 +12429,7 @@
|
|||||||
"description": "Eases DOM navigation for HTML and XML documents",
|
"description": "Eases DOM navigation for HTML and XML documents",
|
||||||
"homepage": "https://symfony.com",
|
"homepage": "https://symfony.com",
|
||||||
"support": {
|
"support": {
|
||||||
"source": "https://github.com/symfony/dom-crawler/tree/v8.1.0"
|
"source": "https://github.com/symfony/dom-crawler/tree/v8.1.1"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -12374,7 +12449,7 @@
|
|||||||
"type": "tidelift"
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-05-29T05:06:50+00:00"
|
"time": "2026-06-05T06:23:12+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/maker-bundle",
|
"name": "symfony/maker-bundle",
|
||||||
@@ -12477,16 +12552,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/phpunit-bridge",
|
"name": "symfony/phpunit-bridge",
|
||||||
"version": "v8.1.0",
|
"version": "v8.1.1",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/symfony/phpunit-bridge.git",
|
"url": "https://github.com/symfony/phpunit-bridge.git",
|
||||||
"reference": "1fed488f8033f2dece371e60a1c66f2add274916"
|
"reference": "3e1c9a9167e07474ec115555b632f0ffadb0f94d"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/symfony/phpunit-bridge/zipball/1fed488f8033f2dece371e60a1c66f2add274916",
|
"url": "https://api.github.com/repos/symfony/phpunit-bridge/zipball/3e1c9a9167e07474ec115555b632f0ffadb0f94d",
|
||||||
"reference": "1fed488f8033f2dece371e60a1c66f2add274916",
|
"reference": "3e1c9a9167e07474ec115555b632f0ffadb0f94d",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -12538,7 +12613,7 @@
|
|||||||
"testing"
|
"testing"
|
||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"source": "https://github.com/symfony/phpunit-bridge/tree/v8.1.0"
|
"source": "https://github.com/symfony/phpunit-bridge/tree/v8.1.1"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -12558,20 +12633,20 @@
|
|||||||
"type": "tidelift"
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-05-29T05:06:50+00:00"
|
"time": "2026-06-09T10:54:51+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/web-profiler-bundle",
|
"name": "symfony/web-profiler-bundle",
|
||||||
"version": "v8.1.0",
|
"version": "v8.1.1",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/symfony/web-profiler-bundle.git",
|
"url": "https://github.com/symfony/web-profiler-bundle.git",
|
||||||
"reference": "f8ccea08797a511b85a698b0da40e1b9e6461086"
|
"reference": "eb4cf71d8fc496d790ec85b1b684a7ac30d57a96"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/symfony/web-profiler-bundle/zipball/f8ccea08797a511b85a698b0da40e1b9e6461086",
|
"url": "https://api.github.com/repos/symfony/web-profiler-bundle/zipball/eb4cf71d8fc496d790ec85b1b684a7ac30d57a96",
|
||||||
"reference": "f8ccea08797a511b85a698b0da40e1b9e6461086",
|
"reference": "eb4cf71d8fc496d790ec85b1b684a7ac30d57a96",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -12623,7 +12698,7 @@
|
|||||||
"dev"
|
"dev"
|
||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"source": "https://github.com/symfony/web-profiler-bundle/tree/v8.1.0"
|
"source": "https://github.com/symfony/web-profiler-bundle/tree/v8.1.1"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -12643,27 +12718,27 @@
|
|||||||
"type": "tidelift"
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-05-29T05:06:50+00:00"
|
"time": "2026-06-05T06:23:12+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "thecodingmachine/phpstan-safe-rule",
|
"name": "thecodingmachine/phpstan-safe-rule",
|
||||||
"version": "v1.4.3",
|
"version": "v1.4.7",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/thecodingmachine/phpstan-safe-rule.git",
|
"url": "https://github.com/thecodingmachine/phpstan-safe-rule.git",
|
||||||
"reference": "5c804889253ce9498ef185e108e9f94b6023208e"
|
"reference": "51fa2a35a270f683fc9ea53384a03e892b4d7b51"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/thecodingmachine/phpstan-safe-rule/zipball/5c804889253ce9498ef185e108e9f94b6023208e",
|
"url": "https://api.github.com/repos/thecodingmachine/phpstan-safe-rule/zipball/51fa2a35a270f683fc9ea53384a03e892b4d7b51",
|
||||||
"reference": "5c804889253ce9498ef185e108e9f94b6023208e",
|
"reference": "51fa2a35a270f683fc9ea53384a03e892b4d7b51",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
"nikic/php-parser": "^5",
|
"nikic/php-parser": "^5",
|
||||||
"php": "^8.1",
|
"php": "^8.1",
|
||||||
"phpstan/phpstan": "^2.1.11",
|
"phpstan/phpstan": "^2.2.2",
|
||||||
"thecodingmachine/safe": "^1.2 || ^2.0 || ^3.0"
|
"thecodingmachine/safe": "^3.1"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"php-coveralls/php-coveralls": "^2.1",
|
"php-coveralls/php-coveralls": "^2.1",
|
||||||
@@ -12699,9 +12774,9 @@
|
|||||||
"description": "A PHPStan rule to detect safety issues. Must be used in conjunction with thecodingmachine/safe",
|
"description": "A PHPStan rule to detect safety issues. Must be used in conjunction with thecodingmachine/safe",
|
||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/thecodingmachine/phpstan-safe-rule/issues",
|
"issues": "https://github.com/thecodingmachine/phpstan-safe-rule/issues",
|
||||||
"source": "https://github.com/thecodingmachine/phpstan-safe-rule/tree/v1.4.3"
|
"source": "https://github.com/thecodingmachine/phpstan-safe-rule/tree/v1.4.7"
|
||||||
},
|
},
|
||||||
"time": "2025-11-21T09:41:49+00:00"
|
"time": "2026-06-21T07:55:55+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "theseer/tokenizer",
|
"name": "theseer/tokenizer",
|
||||||
@@ -12755,16 +12830,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "vincentlanglet/twig-cs-fixer",
|
"name": "vincentlanglet/twig-cs-fixer",
|
||||||
"version": "4.0.1",
|
"version": "4.0.2",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/VincentLanglet/Twig-CS-Fixer.git",
|
"url": "https://github.com/VincentLanglet/Twig-CS-Fixer.git",
|
||||||
"reference": "366f7cca494a6f95c5f410ae542aef9c164d329e"
|
"reference": "1cb75618f7dd0f9bf51924aa6d3aa8c588f51d5a"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/VincentLanglet/Twig-CS-Fixer/zipball/366f7cca494a6f95c5f410ae542aef9c164d329e",
|
"url": "https://api.github.com/repos/VincentLanglet/Twig-CS-Fixer/zipball/1cb75618f7dd0f9bf51924aa6d3aa8c588f51d5a",
|
||||||
"reference": "366f7cca494a6f95c5f410ae542aef9c164d329e",
|
"reference": "1cb75618f7dd0f9bf51924aa6d3aa8c588f51d5a",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -12820,7 +12895,7 @@
|
|||||||
"homepage": "https://github.com/VincentLanglet/Twig-CS-Fixer",
|
"homepage": "https://github.com/VincentLanglet/Twig-CS-Fixer",
|
||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/VincentLanglet/Twig-CS-Fixer/issues",
|
"issues": "https://github.com/VincentLanglet/Twig-CS-Fixer/issues",
|
||||||
"source": "https://github.com/VincentLanglet/Twig-CS-Fixer/tree/4.0.1"
|
"source": "https://github.com/VincentLanglet/Twig-CS-Fixer/tree/4.0.2"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -12828,7 +12903,7 @@
|
|||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-06-18T15:31:27+00:00"
|
"time": "2026-06-29T15:22:14+00:00"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"aliases": [],
|
"aliases": [],
|
||||||
|
|||||||
@@ -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,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260704200000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
#[\Override]
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Add question_id FK to bank_question_usage for unassign and sync support';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE bank_question_usage ADD question_id UUID DEFAULT NULL');
|
||||||
|
$this->addSql('ALTER TABLE bank_question_usage ADD CONSTRAINT FK_BQU_QUESTION FOREIGN KEY (question_id) REFERENCES question (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE');
|
||||||
|
$this->addSql('CREATE INDEX IDX_BQU_QUESTION ON bank_question_usage (question_id)');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[\Override]
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('DROP INDEX IDX_BQU_QUESTION');
|
||||||
|
$this->addSql('ALTER TABLE bank_question_usage DROP CONSTRAINT FK_BQU_QUESTION');
|
||||||
|
$this->addSql('ALTER TABLE bank_question_usage DROP COLUMN question_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-1
@@ -3,6 +3,7 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
use Rector\Config\RectorConfig;
|
use Rector\Config\RectorConfig;
|
||||||
|
use Rector\PHPUnit\CodeQuality\Rector\Class_\AddSeeTestAnnotationRector;
|
||||||
use Rector\Symfony\Bridge\Symfony\Routing\SymfonyRoutesProvider;
|
use Rector\Symfony\Bridge\Symfony\Routing\SymfonyRoutesProvider;
|
||||||
use Rector\Symfony\Contract\Bridge\Symfony\Routing\SymfonyRoutesProviderInterface;
|
use Rector\Symfony\Contract\Bridge\Symfony\Routing\SymfonyRoutesProviderInterface;
|
||||||
|
|
||||||
@@ -13,7 +14,7 @@ return RectorConfig::configure()
|
|||||||
__DIR__.'/src',
|
__DIR__.'/src',
|
||||||
__DIR__.'/tests',
|
__DIR__.'/tests',
|
||||||
])
|
])
|
||||||
->withSkip([__DIR__.'/config/reference.php'])
|
->withSkipPath(__DIR__.'/config/reference.php')
|
||||||
->withSymfonyContainerXml(__DIR__.'/var/cache/dev/Tvdt_KernelDevDebugContainer.xml')
|
->withSymfonyContainerXml(__DIR__.'/var/cache/dev/Tvdt_KernelDevDebugContainer.xml')
|
||||||
->withSymfonyContainerPhp(__DIR__.'/tests/symfony-container.php')
|
->withSymfonyContainerPhp(__DIR__.'/tests/symfony-container.php')
|
||||||
->registerService(SymfonyRoutesProvider::class, SymfonyRoutesProviderInterface::class)
|
->registerService(SymfonyRoutesProvider::class, SymfonyRoutesProviderInterface::class)
|
||||||
@@ -34,4 +35,6 @@ return RectorConfig::configure()
|
|||||||
)
|
)
|
||||||
->withAttributesSets(all: true)
|
->withAttributesSets(all: true)
|
||||||
->withComposerBased(twig: true, doctrine: true, phpunit: true, symfony: true)
|
->withComposerBased(twig: true, doctrine: true, phpunit: true, symfony: true)
|
||||||
|
->withSkip([AddSeeTestAnnotationRector::class])
|
||||||
|
|
||||||
;
|
;
|
||||||
|
|||||||
@@ -0,0 +1,366 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tvdt\Controller\Backoffice;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
|
||||||
|
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\BankQuestionUsage;
|
||||||
|
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->syncUsagesAfterEdit($bankQuestion);
|
||||||
|
|
||||||
|
$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);
|
||||||
|
|
||||||
|
$hasLockedUsages = $bankQuestion->usages->exists(
|
||||||
|
static fn (int $key, BankQuestionUsage $usage): bool => $usage->quiz->isLocked(),
|
||||||
|
);
|
||||||
|
if ($hasLockedUsages) {
|
||||||
|
$this->addFlash(FlashType::Danger, $this->translator->trans('This question cannot be deleted because it is used in a locked or active quiz'));
|
||||||
|
|
||||||
|
return $this->redirectToRoute('tvdt_backoffice_question_bank', ['seasonCode' => $season->seasonCode]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$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) {
|
||||||
|
try {
|
||||||
|
$season->addQuestionLabel(new QuestionLabel($name));
|
||||||
|
$this->em->flush();
|
||||||
|
$this->addFlash(FlashType::Success, $this->translator->trans('Label added'));
|
||||||
|
} catch (UniqueConstraintViolationException) {
|
||||||
|
// Concurrent request already inserted the same label; treat as a no-op
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[IsCsrfTokenValid('unassign_bank_question')]
|
||||||
|
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
|
||||||
|
#[Route(
|
||||||
|
'/backoffice/season/{seasonCode:season}/question-bank/{bankQuestion}/unassign/{usage}',
|
||||||
|
name: 'tvdt_backoffice_question_bank_unassign',
|
||||||
|
requirements: ['seasonCode' => self::SEASON_CODE_REGEX, 'bankQuestion' => Requirement::UUID, 'usage' => Requirement::UUID],
|
||||||
|
methods: ['POST'],
|
||||||
|
priority: 10,
|
||||||
|
)]
|
||||||
|
public function unassign(Season $season, BankQuestion $bankQuestion, BankQuestionUsage $usage): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->assertSameSeason($season, $bankQuestion->season);
|
||||||
|
|
||||||
|
if ($usage->bankQuestion !== $bankQuestion) {
|
||||||
|
throw new NotFoundHttpException();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($usage->quiz->isLocked()) {
|
||||||
|
$this->addFlash(FlashType::Danger, $this->translator->trans('This quiz can no longer be altered'));
|
||||||
|
|
||||||
|
return $this->redirectToRoute('tvdt_backoffice_question_bank', ['seasonCode' => $season->seasonCode]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->questionBankService->unassignFromQuiz($usage);
|
||||||
|
$this->addFlash(FlashType::Success, $this->translator->trans('Question removed from quiz %quiz%', ['%quiz%' => $usage->quiz->name]));
|
||||||
|
|
||||||
|
return $this->redirectToRoute('tvdt_backoffice_question_bank', ['seasonCode' => $season->seasonCode]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[IsCsrfTokenValid('sync_bank_question')]
|
||||||
|
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
|
||||||
|
#[Route(
|
||||||
|
'/backoffice/season/{seasonCode:season}/question-bank/{bankQuestion}/sync/{usage}',
|
||||||
|
name: 'tvdt_backoffice_question_bank_sync',
|
||||||
|
requirements: ['seasonCode' => self::SEASON_CODE_REGEX, 'bankQuestion' => Requirement::UUID, 'usage' => Requirement::UUID],
|
||||||
|
methods: ['POST'],
|
||||||
|
priority: 10,
|
||||||
|
)]
|
||||||
|
public function syncToQuiz(Season $season, BankQuestion $bankQuestion, BankQuestionUsage $usage): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->assertSameSeason($season, $bankQuestion->season);
|
||||||
|
|
||||||
|
if ($usage->bankQuestion !== $bankQuestion) {
|
||||||
|
throw new NotFoundHttpException();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($usage->quiz->isLocked()) {
|
||||||
|
$this->addFlash(FlashType::Danger, $this->translator->trans('This quiz can no longer be altered'));
|
||||||
|
|
||||||
|
return $this->redirectToRoute('tvdt_backoffice_question_bank', ['seasonCode' => $season->seasonCode]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->questionBankService->syncToQuiz($bankQuestion, $usage);
|
||||||
|
$this->em->flush();
|
||||||
|
$this->addFlash(FlashType::Success, $this->translator->trans('Question synced to quiz %quiz%', ['%quiz%' => $usage->quiz->name]));
|
||||||
|
|
||||||
|
return $this->redirectToRoute('tvdt_backoffice_question_bank', ['seasonCode' => $season->seasonCode]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function assertSameSeason(Season $season, Season $subjectSeason): void
|
||||||
|
{
|
||||||
|
if ($season !== $subjectSeason) {
|
||||||
|
throw new NotFoundHttpException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function applyAnswerOrdering(BankQuestion $bankQuestion): void
|
||||||
|
{
|
||||||
|
$ordering = 1;
|
||||||
|
foreach ($bankQuestion->answers as $answer) {
|
||||||
|
$answer->ordering = $ordering++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function syncUsagesAfterEdit(BankQuestion $bankQuestion): void
|
||||||
|
{
|
||||||
|
$pendingNames = [];
|
||||||
|
$synced = false;
|
||||||
|
foreach ($bankQuestion->usages as $usage) {
|
||||||
|
if (!$usage->quiz->isLocked()) {
|
||||||
|
$this->questionBankService->syncToQuiz($bankQuestion, $usage);
|
||||||
|
$synced = true;
|
||||||
|
} else {
|
||||||
|
$pendingNames[] = $usage->quiz->name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($synced) {
|
||||||
|
$this->em->flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ([] !== $pendingNames) {
|
||||||
|
$this->addFlash(
|
||||||
|
FlashType::Warning,
|
||||||
|
$this->translator->trans(
|
||||||
|
'The question was not synced to finalized quiz(zes): %quizzes%. Use the Sync button to update them.',
|
||||||
|
['%quizzes%' => implode(', ', $pendingNames)],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace Tvdt\Controller\Backoffice;
|
namespace Tvdt\Controller\Backoffice;
|
||||||
|
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Safe\DateTimeImmutable;
|
||||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
@@ -22,6 +23,7 @@ use Tvdt\Entity\Question;
|
|||||||
use Tvdt\Entity\Quiz;
|
use Tvdt\Entity\Quiz;
|
||||||
use Tvdt\Entity\QuizCandidate;
|
use Tvdt\Entity\QuizCandidate;
|
||||||
use Tvdt\Entity\Season;
|
use Tvdt\Entity\Season;
|
||||||
|
use Tvdt\Enum\FlashType;
|
||||||
use Tvdt\Exception\ErrorClearingQuizException;
|
use Tvdt\Exception\ErrorClearingQuizException;
|
||||||
use Tvdt\Repository\QuizCandidateRepository;
|
use Tvdt\Repository\QuizCandidateRepository;
|
||||||
use Tvdt\Repository\QuizRepository;
|
use Tvdt\Repository\QuizRepository;
|
||||||
@@ -154,7 +156,13 @@ class QuizController extends AbstractController
|
|||||||
public function answerMapping(Season $season, Quiz $quiz): Response
|
public function answerMapping(Season $season, Quiz $quiz): Response
|
||||||
{
|
{
|
||||||
$fetchedQuiz = $this->quizRepository->fetchWithQuestions($quiz->id);
|
$fetchedQuiz = $this->quizRepository->fetchWithQuestions($quiz->id);
|
||||||
\assert($fetchedQuiz->questions->count() > 0);
|
|
||||||
|
if ($fetchedQuiz->questions->isEmpty()) {
|
||||||
|
$this->addFlash(FlashType::Warning, $this->translator->trans('This quiz has no questions yet'));
|
||||||
|
|
||||||
|
return $this->redirectToRoute('tvdt_backoffice_quiz_overview', ['seasonCode' => $season->seasonCode, 'quiz' => $quiz->id]);
|
||||||
|
}
|
||||||
|
|
||||||
$firstQuestion = $fetchedQuiz->questions->first();
|
$firstQuestion = $fetchedQuiz->questions->first();
|
||||||
\assert($firstQuestion instanceof Question);
|
\assert($firstQuestion instanceof Question);
|
||||||
|
|
||||||
@@ -233,7 +241,7 @@ class QuizController extends AbstractController
|
|||||||
|
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
||||||
$this->addFlash('success', $this->translator->trans('Candidate answers saved'));
|
$this->addFlash(FlashType::Success, $this->translator->trans('Candidate answers saved'));
|
||||||
|
|
||||||
return $this->redirectToRoute('tvdt_backoffice_quiz_candidates_question', [
|
return $this->redirectToRoute('tvdt_backoffice_quiz_candidates_question', [
|
||||||
'seasonCode' => $season->seasonCode,
|
'seasonCode' => $season->seasonCode,
|
||||||
@@ -250,13 +258,28 @@ class QuizController extends AbstractController
|
|||||||
requirements: ['seasonCode' => self::SEASON_CODE_REGEX, 'quiz' => Requirement::UUID.'|null'],
|
requirements: ['seasonCode' => self::SEASON_CODE_REGEX, 'quiz' => Requirement::UUID.'|null'],
|
||||||
methods: ['POST'],
|
methods: ['POST'],
|
||||||
)]
|
)]
|
||||||
public function enableQuiz(Season $season, ?Quiz $quiz): RedirectResponse
|
public function enableQuiz(Season $season, ?Quiz $quiz, Request $request): RedirectResponse
|
||||||
{
|
{
|
||||||
|
if ($quiz instanceof Quiz && !$quiz->isFinalized()) {
|
||||||
|
$this->addFlash(FlashType::Danger, $this->translator->trans('The quiz must be finalized before it can be activated'));
|
||||||
|
|
||||||
|
return $this->redirectToRoute('tvdt_backoffice_quiz_overview', ['seasonCode' => $season->seasonCode, 'quiz' => $quiz->id]);
|
||||||
|
}
|
||||||
|
|
||||||
$season->activeQuiz = $quiz;
|
$season->activeQuiz = $quiz;
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
||||||
if ($quiz instanceof Quiz) {
|
if ($quiz instanceof Quiz) {
|
||||||
return $this->redirectToRoute('tvdt_backoffice_quiz', ['seasonCode' => $season->seasonCode, 'quiz' => $quiz->id]);
|
return $this->redirectToRoute('tvdt_backoffice_quiz_overview', ['seasonCode' => $season->seasonCode, 'quiz' => $quiz->id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// When deactivating, stay on the quiz page if one was passed
|
||||||
|
$previousQuizId = $request->request->getString('redirect_quiz');
|
||||||
|
if ('' !== $previousQuizId) {
|
||||||
|
$previousQuiz = $this->em->getRepository(Quiz::class)->find($previousQuizId);
|
||||||
|
if ($previousQuiz instanceof Quiz && $previousQuiz->season === $season) {
|
||||||
|
return $this->redirectToRoute('tvdt_backoffice_quiz_overview', ['seasonCode' => $season->seasonCode, 'quiz' => $previousQuiz->id]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->redirectToRoute('tvdt_backoffice_season', ['seasonCode' => $season->seasonCode]);
|
return $this->redirectToRoute('tvdt_backoffice_season', ['seasonCode' => $season->seasonCode]);
|
||||||
@@ -274,9 +297,55 @@ class QuizController extends AbstractController
|
|||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$this->quizRepository->clearQuiz($quiz);
|
$this->quizRepository->clearQuiz($quiz);
|
||||||
$this->addFlash('success', $this->translator->trans('Quiz cleared'));
|
$this->addFlash(FlashType::Success, $this->translator->trans('Quiz cleared and no longer finalized'));
|
||||||
} catch (ErrorClearingQuizException) {
|
} catch (ErrorClearingQuizException) {
|
||||||
$this->addFlash('error', $this->translator->trans('Error clearing quiz'));
|
$this->addFlash(FlashType::Danger, $this->translator->trans('Error clearing quiz'));
|
||||||
|
}
|
||||||
|
|
||||||
|
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(FlashType::Success, $this->translator->trans('Quiz finalized'));
|
||||||
|
} else {
|
||||||
|
$this->addFlash(FlashType::Warning, $this->translator->trans('The quiz is already 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(FlashType::Success, $this->translator->trans('Quiz is no longer finalized'));
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->redirectToRoute('tvdt_backoffice_quiz', ['seasonCode' => $quiz->season->seasonCode, 'quiz' => $quiz->id]);
|
return $this->redirectToRoute('tvdt_backoffice_quiz', ['seasonCode' => $quiz->season->seasonCode, 'quiz' => $quiz->id]);
|
||||||
@@ -294,7 +363,7 @@ class QuizController extends AbstractController
|
|||||||
{
|
{
|
||||||
$this->quizRepository->deleteQuiz($quiz);
|
$this->quizRepository->deleteQuiz($quiz);
|
||||||
|
|
||||||
$this->addFlash('success', $this->translator->trans('Quiz deleted'));
|
$this->addFlash(FlashType::Success, $this->translator->trans('Quiz deleted'));
|
||||||
|
|
||||||
return $this->redirectToRoute('tvdt_backoffice_season', ['seasonCode' => $quiz->season->seasonCode]);
|
return $this->redirectToRoute('tvdt_backoffice_season', ['seasonCode' => $quiz->season->seasonCode]);
|
||||||
}
|
}
|
||||||
@@ -359,7 +428,7 @@ class QuizController extends AbstractController
|
|||||||
|
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
||||||
$this->addFlash('success', $this->translator->trans('Candidate status updated'));
|
$this->addFlash(FlashType::Success, $this->translator->trans('Candidate status updated'));
|
||||||
|
|
||||||
return $this->redirectToRoute('tvdt_backoffice_quiz_candidates_tab', ['seasonCode' => $quiz->season->seasonCode, 'quiz' => $quiz->id]);
|
return $this->redirectToRoute('tvdt_backoffice_quiz_candidates_tab', ['seasonCode' => $quiz->season->seasonCode, 'quiz' => $quiz->id]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ declare(strict_types=1);
|
|||||||
namespace Tvdt\Controller\Backoffice;
|
namespace Tvdt\Controller\Backoffice;
|
||||||
|
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
@@ -39,7 +41,39 @@ class SeasonController extends AbstractController
|
|||||||
name: 'tvdt_backoffice_season',
|
name: 'tvdt_backoffice_season',
|
||||||
requirements: ['seasonCode' => self::SEASON_CODE_REGEX],
|
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);
|
$form = $this->createForm(SettingsForm::class, $season->settings);
|
||||||
|
|
||||||
@@ -47,11 +81,15 @@ class SeasonController extends AbstractController
|
|||||||
|
|
||||||
if ($form->isSubmitted() && $form->isValid()) {
|
if ($form->isSubmitted() && $form->isValid()) {
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
||||||
|
return $this->redirectToRoute('tvdt_backoffice_season_settings', ['seasonCode' => $season->seasonCode]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->render('backoffice/season.html.twig', [
|
return $this->render('backoffice/season.html.twig', [
|
||||||
'season' => $season,
|
'season' => $season,
|
||||||
'form' => $form,
|
'form' => $form,
|
||||||
|
'activeTab' => 'settings',
|
||||||
|
'template' => 'backoffice/season/tab_settings.html.twig',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,7 +116,7 @@ class SeasonController extends AbstractController
|
|||||||
return $this->redirectToRoute('tvdt_backoffice_season', ['seasonCode' => $season->seasonCode]);
|
return $this->redirectToRoute('tvdt_backoffice_season', ['seasonCode' => $season->seasonCode]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->render('backoffice/season_add_candidates.html.twig', ['form' => $form]);
|
return $this->render('backoffice/season_add_candidates.html.twig', ['form' => $form, 'season' => $season]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
|
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
|
||||||
@@ -112,4 +150,38 @@ class SeasonController extends AbstractController
|
|||||||
|
|
||||||
return $this->render('/backoffice/quiz_add.html.twig', ['form' => $form, 'season' => $season]);
|
return $this->render('/backoffice/quiz_add.html.twig', ['form' => $form, 'season' => $season]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
|
||||||
|
#[Route(
|
||||||
|
'/backoffice/season/{seasonCode:season}/add-blank-quiz',
|
||||||
|
name: 'tvdt_backoffice_quiz_add_blank',
|
||||||
|
requirements: ['seasonCode' => self::SEASON_CODE_REGEX],
|
||||||
|
priority: 10,
|
||||||
|
)]
|
||||||
|
public function addBlankQuiz(Request $request, Season $season): Response
|
||||||
|
{
|
||||||
|
$form = $this->createFormBuilder(new Quiz())
|
||||||
|
->add('name', TextType::class, ['label' => $this->translator->trans('Quiz name'), 'translation_domain' => false])
|
||||||
|
->add('save', SubmitType::class, ['label' => 'Create'])
|
||||||
|
->getForm();
|
||||||
|
|
||||||
|
$form->handleRequest($request);
|
||||||
|
|
||||||
|
if ($form->isSubmitted() && $form->isValid()) {
|
||||||
|
/** @var Quiz $quiz */
|
||||||
|
$quiz = $form->getData();
|
||||||
|
$quiz->season = $season;
|
||||||
|
$this->em->persist($quiz);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
$this->addFlash(FlashType::Success, $this->translator->trans('Quiz Added!'));
|
||||||
|
|
||||||
|
return $this->redirectToRoute('tvdt_backoffice_quiz_overview', [
|
||||||
|
'seasonCode' => $season->seasonCode,
|
||||||
|
'quiz' => $quiz->id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->render('/backoffice/quiz_add_blank.html.twig', ['form' => $form, 'season' => $season]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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\Fixture;
|
||||||
use Doctrine\Bundle\FixturesBundle\FixtureGroupInterface;
|
use Doctrine\Bundle\FixturesBundle\FixtureGroupInterface;
|
||||||
use Doctrine\Persistence\ObjectManager;
|
use Doctrine\Persistence\ObjectManager;
|
||||||
|
use Safe\DateTimeImmutable;
|
||||||
use Tvdt\Entity\Answer;
|
use Tvdt\Entity\Answer;
|
||||||
|
use Tvdt\Entity\BankAnswer;
|
||||||
|
use Tvdt\Entity\BankQuestion;
|
||||||
|
use Tvdt\Entity\BankQuestionUsage;
|
||||||
use Tvdt\Entity\Candidate;
|
use Tvdt\Entity\Candidate;
|
||||||
use Tvdt\Entity\Question;
|
use Tvdt\Entity\Question;
|
||||||
|
use Tvdt\Entity\QuestionLabel;
|
||||||
use Tvdt\Entity\Quiz;
|
use Tvdt\Entity\Quiz;
|
||||||
use Tvdt\Entity\Season;
|
use Tvdt\Entity\Season;
|
||||||
use Tvdt\Entity\SeasonSettings;
|
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_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
|
public static function getGroups(): array
|
||||||
{
|
{
|
||||||
return ['test', 'dev'];
|
return ['test', 'dev'];
|
||||||
@@ -47,16 +62,64 @@ final class KrtekFixtures extends Fixture implements FixtureGroupInterface
|
|||||||
$quiz1 = $this->createQuiz1($season);
|
$quiz1 = $this->createQuiz1($season);
|
||||||
$season->addQuiz($quiz1);
|
$season->addQuiz($quiz1);
|
||||||
$season->activeQuiz = $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);
|
\assert($season->settings instanceof SeasonSettings);
|
||||||
|
|
||||||
$season->settings->confirmAnswers = true;
|
$season->settings->confirmAnswers = true;
|
||||||
$season->settings->showNumbers = true;
|
$season->settings->showNumbers = true;
|
||||||
|
|
||||||
|
$this->createQuestionBank($season, $quiz2);
|
||||||
|
|
||||||
$manager->flush();
|
$manager->flush();
|
||||||
|
|
||||||
$this->addReference(self::KRTEK_SEASON, $season);
|
$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
|
private function createQuiz1(Season $season): Quiz
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?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\ObjectMapper\Attribute\Map;
|
||||||
|
use Symfony\Component\Uid\Uuid;
|
||||||
|
|
||||||
|
#[ORM\Entity]
|
||||||
|
class BankAnswer implements \Stringable
|
||||||
|
{
|
||||||
|
#[Map(if: false)]
|
||||||
|
#[ORM\Column(type: UuidType::NAME)]
|
||||||
|
#[ORM\CustomIdGenerator(class: 'doctrine.uuid_generator')]
|
||||||
|
#[ORM\GeneratedValue(strategy: 'CUSTOM')]
|
||||||
|
#[ORM\Id]
|
||||||
|
public private(set) Uuid $id;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::SMALLINT, options: ['default' => 0])]
|
||||||
|
public int $ordering = 0;
|
||||||
|
|
||||||
|
#[Map(if: false)]
|
||||||
|
#[ORM\JoinColumn(nullable: false)]
|
||||||
|
#[ORM\ManyToOne(inversedBy: 'answers')]
|
||||||
|
public BankQuestion $bankQuestion;
|
||||||
|
|
||||||
|
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,136 @@
|
|||||||
|
<?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\ObjectMapper\Attribute\Map;
|
||||||
|
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
|
||||||
|
{
|
||||||
|
#[Map(if: false)]
|
||||||
|
#[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;
|
||||||
|
|
||||||
|
#[Map(if: false)]
|
||||||
|
#[ORM\JoinColumn(nullable: false)]
|
||||||
|
#[ORM\ManyToOne(inversedBy: 'bankQuestions')]
|
||||||
|
public Season $season;
|
||||||
|
|
||||||
|
#[Map(if: false)]
|
||||||
|
#[ORM\Column(options: ['default' => false])]
|
||||||
|
public bool $reusable = false;
|
||||||
|
|
||||||
|
/** @var Collection<int, QuestionLabel> */
|
||||||
|
#[Map(if: false)]
|
||||||
|
#[ORM\ManyToMany(targetEntity: QuestionLabel::class, inversedBy: 'bankQuestions')]
|
||||||
|
public private(set) Collection $labels;
|
||||||
|
|
||||||
|
/** @var Collection<int, BankAnswer> */
|
||||||
|
#[Assert\Count(min: 2, minMessage: 'A question needs at least two answers')]
|
||||||
|
#[Map(if: false)]
|
||||||
|
#[ORM\OneToMany(targetEntity: BankAnswer::class, mappedBy: 'bankQuestion', cascade: ['persist'], orphanRemoval: true)]
|
||||||
|
#[ORM\OrderBy(['ordering' => 'ASC'])]
|
||||||
|
public private(set) Collection $answers;
|
||||||
|
|
||||||
|
/** @var Collection<int, BankQuestionUsage> */
|
||||||
|
#[Map(if: false)]
|
||||||
|
#[ORM\OneToMany(targetEntity: BankQuestionUsage::class, mappedBy: 'bankQuestion', cascade: ['persist'], orphanRemoval: true)]
|
||||||
|
public private(set) Collection $usages;
|
||||||
|
|
||||||
|
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,40 @@
|
|||||||
|
<?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;
|
||||||
|
|
||||||
|
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
||||||
|
#[ORM\ManyToOne]
|
||||||
|
public ?Question $question = null;
|
||||||
|
|
||||||
|
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\ArrayCollection;
|
||||||
use Doctrine\Common\Collections\Collection;
|
use Doctrine\Common\Collections\Collection;
|
||||||
|
use Doctrine\DBAL\Types\Types;
|
||||||
use Doctrine\ORM\Mapping as ORM;
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
use Symfony\Bridge\Doctrine\Types\UuidType;
|
use Symfony\Bridge\Doctrine\Types\UuidType;
|
||||||
use Symfony\Component\Uid\Uuid;
|
use Symfony\Component\Uid\Uuid;
|
||||||
@@ -40,6 +41,9 @@ class Quiz
|
|||||||
#[ORM\Column(nullable: false, options: ['default' => 1])]
|
#[ORM\Column(nullable: false, options: ['default' => 1])]
|
||||||
public int $dropouts = 1;
|
public int $dropouts = 1;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::DATETIMETZ_IMMUTABLE, nullable: true)]
|
||||||
|
public ?\DateTimeImmutable $finalizedAt = null;
|
||||||
|
|
||||||
/** @var Collection<int, Elimination> */
|
/** @var Collection<int, Elimination> */
|
||||||
#[ORM\OneToMany(targetEntity: Elimination::class, mappedBy: 'quiz', cascade: ['persist'], orphanRemoval: true)]
|
#[ORM\OneToMany(targetEntity: Elimination::class, mappedBy: 'quiz', cascade: ['persist'], orphanRemoval: true)]
|
||||||
#[ORM\OrderBy(['createdAt' => 'DESC'])]
|
#[ORM\OrderBy(['createdAt' => 'DESC'])]
|
||||||
@@ -62,6 +66,29 @@ class Quiz
|
|||||||
return $this;
|
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
|
public function addElimination(Elimination $elimination): self
|
||||||
{
|
{
|
||||||
$this->eliminations->add($elimination);
|
$this->eliminations->add($elimination);
|
||||||
|
|||||||
@@ -51,12 +51,24 @@ class Season
|
|||||||
#[ORM\OneToOne(cascade: ['persist', 'remove'])]
|
#[ORM\OneToOne(cascade: ['persist', 'remove'])]
|
||||||
public ?SeasonSettings $settings = null;
|
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()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->settings = new SeasonSettings();
|
$this->settings = new SeasonSettings();
|
||||||
$this->quizzes = new ArrayCollection();
|
$this->quizzes = new ArrayCollection();
|
||||||
$this->candidates = new ArrayCollection();
|
$this->candidates = new ArrayCollection();
|
||||||
$this->owners = new ArrayCollection();
|
$this->owners = new ArrayCollection();
|
||||||
|
$this->bankQuestions = new ArrayCollection();
|
||||||
|
$this->questionLabels = new ArrayCollection();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function addQuiz(Quiz $quiz): static
|
public function addQuiz(Quiz $quiz): static
|
||||||
@@ -79,6 +91,26 @@ class Season
|
|||||||
return $this;
|
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
|
public function addOwner(User $owner): static
|
||||||
{
|
{
|
||||||
if (!$this->owners->contains($owner)) {
|
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 Symfony\Component\Uid\Uuid;
|
||||||
use Tvdt\Dto\Result;
|
use Tvdt\Dto\Result;
|
||||||
use Tvdt\Entity\Quiz;
|
use Tvdt\Entity\Quiz;
|
||||||
|
use Tvdt\Entity\Season;
|
||||||
use Tvdt\Exception\ErrorClearingQuizException;
|
use Tvdt\Exception\ErrorClearingQuizException;
|
||||||
|
|
||||||
/** @extends ServiceEntityRepository<Quiz> */
|
/** @extends ServiceEntityRepository<Quiz> */
|
||||||
@@ -22,6 +23,29 @@ class QuizRepository extends ServiceEntityRepository
|
|||||||
parent::__construct($registry, Quiz::class);
|
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 */
|
/** @throws ErrorClearingQuizException */
|
||||||
public function clearQuiz(Quiz $quiz): void
|
public function clearQuiz(Quiz $quiz): void
|
||||||
{
|
{
|
||||||
@@ -48,6 +72,20 @@ class QuizRepository extends ServiceEntityRepository
|
|||||||
DQL)
|
DQL)
|
||||||
->setParameter('quiz', $quiz)
|
->setParameter('quiz', $quiz)
|
||||||
->execute();
|
->execute();
|
||||||
|
|
||||||
|
$em->createQuery(<<<DQL
|
||||||
|
delete from Tvdt\Entity\BankQuestionUsage bqu
|
||||||
|
where bqu.quiz = :quiz
|
||||||
|
DQL)
|
||||||
|
->setParameter('quiz', $quiz)
|
||||||
|
->execute();
|
||||||
|
|
||||||
|
$em->createQuery(<<<DQL
|
||||||
|
update Tvdt\Entity\Quiz q set q.finalizedAt = null
|
||||||
|
where q = :quiz
|
||||||
|
DQL)
|
||||||
|
->setParameter('quiz', $quiz)
|
||||||
|
->execute();
|
||||||
}
|
}
|
||||||
// @codeCoverageIgnoreStart
|
// @codeCoverageIgnoreStart
|
||||||
catch (\Throwable $throwable) {
|
catch (\Throwable $throwable) {
|
||||||
@@ -113,8 +151,8 @@ class QuizRepository extends ServiceEntityRepository
|
|||||||
{
|
{
|
||||||
return $this->getEntityManager()->createQuery(<<<dql
|
return $this->getEntityManager()->createQuery(<<<dql
|
||||||
select q, qz, a from Tvdt\Entity\Quiz q
|
select q, qz, a from Tvdt\Entity\Quiz q
|
||||||
join q.questions qz
|
left join q.questions qz
|
||||||
join qz.answers a
|
left join qz.answers a
|
||||||
where q.id = :id
|
where q.id = :id
|
||||||
dql)->setParameter('id', $id)->getSingleResult();
|
dql)->setParameter('id', $id)->getSingleResult();
|
||||||
}
|
}
|
||||||
@@ -127,8 +165,8 @@ class QuizRepository extends ServiceEntityRepository
|
|||||||
{
|
{
|
||||||
return $this->getEntityManager()->createQuery(<<<dql
|
return $this->getEntityManager()->createQuery(<<<dql
|
||||||
select q, qz, a, ac, s, sc, qc from Tvdt\Entity\Quiz q
|
select q, qz, a, ac, s, sc, qc from Tvdt\Entity\Quiz q
|
||||||
join q.questions qz
|
left join q.questions qz
|
||||||
join qz.answers a
|
left join qz.answers a
|
||||||
left join a.candidates ac
|
left join a.candidates ac
|
||||||
join q.season s
|
join q.season s
|
||||||
left join s.candidates sc
|
left join s.candidates sc
|
||||||
|
|||||||
@@ -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\Vote;
|
||||||
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||||||
use Tvdt\Entity\Answer;
|
use Tvdt\Entity\Answer;
|
||||||
|
use Tvdt\Entity\BankQuestion;
|
||||||
use Tvdt\Entity\Candidate;
|
use Tvdt\Entity\Candidate;
|
||||||
use Tvdt\Entity\Elimination;
|
use Tvdt\Entity\Elimination;
|
||||||
use Tvdt\Entity\Question;
|
use Tvdt\Entity\Question;
|
||||||
|
use Tvdt\Entity\QuestionLabel;
|
||||||
use Tvdt\Entity\Quiz;
|
use Tvdt\Entity\Quiz;
|
||||||
use Tvdt\Entity\Season;
|
use Tvdt\Entity\Season;
|
||||||
use Tvdt\Entity\User;
|
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
|
final class SeasonVoter extends Voter
|
||||||
{
|
{
|
||||||
public const string EDIT = 'SEASON_EDIT';
|
public const string EDIT = 'SEASON_EDIT';
|
||||||
@@ -24,15 +26,19 @@ final class SeasonVoter extends Voter
|
|||||||
|
|
||||||
public const string DELETE = 'SEASON_DELETE';
|
public const string DELETE = 'SEASON_DELETE';
|
||||||
|
|
||||||
|
public const string MODIFY_QUIZ_CONTENT = 'QUIZ_MODIFY_CONTENT';
|
||||||
|
|
||||||
protected function supports(string $attribute, mixed $subject): bool
|
protected function supports(string $attribute, mixed $subject): bool
|
||||||
{
|
{
|
||||||
return \in_array($attribute, [self::EDIT, self::DELETE, self::ELIMINATION], true)
|
return \in_array($attribute, [self::EDIT, self::DELETE, self::ELIMINATION, self::MODIFY_QUIZ_CONTENT], true)
|
||||||
&& (
|
&& (
|
||||||
$subject instanceof Answer
|
$subject instanceof Answer
|
||||||
|
|| $subject instanceof BankQuestion
|
||||||
|| $subject instanceof Candidate
|
|| $subject instanceof Candidate
|
||||||
|| $subject instanceof Elimination
|
|| $subject instanceof Elimination
|
||||||
|| $subject instanceof Season
|
|| $subject instanceof Season
|
||||||
|| $subject instanceof Question
|
|| $subject instanceof Question
|
||||||
|
|| $subject instanceof QuestionLabel
|
||||||
|| $subject instanceof Quiz
|
|| $subject instanceof Quiz
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -44,19 +50,36 @@ final class SeasonVoter extends Voter
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($user->isAdmin) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
$season = match (true) {
|
$season = match (true) {
|
||||||
$subject instanceof Answer => $subject->question->quiz->season,
|
$subject instanceof Answer => $subject->question->quiz->season,
|
||||||
$subject instanceof Elimination,
|
$subject instanceof Elimination,
|
||||||
$subject instanceof Question => $subject->quiz->season,
|
$subject instanceof Question => $subject->quiz->season,
|
||||||
|
$subject instanceof BankQuestion,
|
||||||
$subject instanceof Candidate,
|
$subject instanceof Candidate,
|
||||||
|
$subject instanceof QuestionLabel,
|
||||||
$subject instanceof Quiz => $subject->season,
|
$subject instanceof Quiz => $subject->season,
|
||||||
$subject instanceof Season => $subject,
|
$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) {
|
return match ($attribute) {
|
||||||
self::EDIT, self::DELETE, self::ELIMINATION => $season->isOwner($user),
|
self::EDIT, self::DELETE, self::ELIMINATION => $season->isOwner($user),
|
||||||
default => false,
|
default => false,
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tvdt\Service;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\LockMode;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Component\ObjectMapper\ObjectMapperInterface;
|
||||||
|
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,
|
||||||
|
private ObjectMapperInterface $objectMapper,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->entityManager->wrapInTransaction(function () use ($bankQuestion, $quiz): void {
|
||||||
|
// Pessimistic write lock serialises concurrent assignment attempts for the same BankQuestion
|
||||||
|
$this->entityManager->lock($bankQuestion, LockMode::PESSIMISTIC_WRITE);
|
||||||
|
|
||||||
|
if (!$bankQuestion->canBeAssigned() || $bankQuestion->isUsedInQuiz($quiz)) {
|
||||||
|
throw new BankQuestionAlreadyUsedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
$maxOrdering = 0;
|
||||||
|
foreach ($quiz->questions as $existingQuestion) {
|
||||||
|
$maxOrdering = max($maxOrdering, $existingQuestion->ordering);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var Question $question */
|
||||||
|
$question = $this->objectMapper->map($bankQuestion, Question::class);
|
||||||
|
$question->ordering = $maxOrdering + 1;
|
||||||
|
|
||||||
|
foreach ($bankQuestion->answers as $bankAnswer) {
|
||||||
|
/** @var Answer $answer */
|
||||||
|
$answer = $this->objectMapper->map($bankAnswer, Answer::class);
|
||||||
|
$question->addAnswer($answer);
|
||||||
|
}
|
||||||
|
|
||||||
|
$quiz->addQuestion($question);
|
||||||
|
|
||||||
|
$usage = new BankQuestionUsage($bankQuestion, $quiz);
|
||||||
|
$usage->question = $question;
|
||||||
|
|
||||||
|
$bankQuestion->addUsage($usage);
|
||||||
|
|
||||||
|
$this->entityManager->persist($question);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Propagate bank question edits to a quiz copy.
|
||||||
|
* Only safe on quizzes where no candidate has started (no GivenAnswers exist yet).
|
||||||
|
*/
|
||||||
|
public function syncToQuiz(BankQuestion $bankQuestion, BankQuestionUsage $usage): void
|
||||||
|
{
|
||||||
|
$question = $usage->question;
|
||||||
|
if (!$question instanceof Question) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$question->question = $bankQuestion->question;
|
||||||
|
|
||||||
|
// Replace answers (safe: no started candidates means no GivenAnswers)
|
||||||
|
foreach ($question->answers->toArray() as $existingAnswer) {
|
||||||
|
$question->answers->removeElement($existingAnswer);
|
||||||
|
$this->entityManager->remove($existingAnswer);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($bankQuestion->answers as $bankAnswer) {
|
||||||
|
/** @var Answer $answer */
|
||||||
|
$answer = $this->objectMapper->map($bankAnswer, Answer::class);
|
||||||
|
$question->addAnswer($answer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove the quiz copy created by this usage and delete the usage record. */
|
||||||
|
public function unassignFromQuiz(BankQuestionUsage $usage): void
|
||||||
|
{
|
||||||
|
$question = $usage->question;
|
||||||
|
if ($question instanceof Question) {
|
||||||
|
$question->quiz->questions->removeElement($question);
|
||||||
|
$this->entityManager->remove($question);
|
||||||
|
}
|
||||||
|
|
||||||
|
$usage->bankQuestion->usages->removeElement($usage);
|
||||||
|
$this->entityManager->remove($usage);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{{ include([
|
||||||
|
'backoffice/help/' ~ app.request.locale ~ '/index.html.twig',
|
||||||
|
'backoffice/help/nl/index.html.twig',
|
||||||
|
]) }}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<h6>Aan de slag</h6>
|
||||||
|
<p>Elk seizoen groepeert één spel met alle bijbehorende testen en kandidaten. De seizoenscode is de link die kandidaten gebruiken om een test te starten, en is alleen actief als er een actieve test is.</p>
|
||||||
|
<h6>Globale werkwijze</h6>
|
||||||
|
<ol>
|
||||||
|
<li><strong>Seizoen aanmaken</strong> en kandidaten toevoegen</li>
|
||||||
|
<li><strong>Test aanmaken</strong> via Excel of de vragenbank</li>
|
||||||
|
<li>Test <strong>afronden</strong> en <strong>activeren</strong></li>
|
||||||
|
<li>Kandidaten laten <strong>deelnemen</strong> (eigen apparaat of gedeelde laptop)</li>
|
||||||
|
<li><strong>Resultaten</strong> bekijken en eliminatie starten</li>
|
||||||
|
</ol>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<h6>Eliminatie voorbereiden</h6>
|
||||||
|
<p>Kies voor elke kandidaat een kleur: <strong>groen</strong> betekent veilig, <strong>rood</strong> betekent geëlimineerd.</p>
|
||||||
|
<p>Gebruik <strong>Opslaan en starten</strong> om de eliminatie direct af te spelen, of sla eerst op en start later via het tabblad Resultaten.</p>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<h6>Test importeren via Excel</h6>
|
||||||
|
<p>Upload een Excel-bestand met de vragen voor deze test. Na het uploaden kun je de vragen bekijken, controleren en de test afronden.</p>
|
||||||
|
<h6>Verwacht formaat</h6>
|
||||||
|
<ul>
|
||||||
|
<li>Eerste kolom: de vraagtekst</li>
|
||||||
|
<li>Volgende kolommen: de antwoordopties</li>
|
||||||
|
<li>Markeer het juiste antwoord met <strong>WAAR</strong> (Nederlandstalige Excel) of <strong>TRUE</strong> (Engelstalige Excel), alle andere antwoorden zet je op ONWAAR/FALSE</li>
|
||||||
|
</ul>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<h6>Lege test aanmaken</h6>
|
||||||
|
<p>Maak een lege test aan en voeg vragen toe vanuit de vragenbank. Handig als je vragen hergebruikt of ze van tevoren in de bank hebt klaargezet.</p>
|
||||||
|
<p>Na het aanmaken open je de test, voeg je vragen toe via het tabblad Overzicht en ronde je de test af voordat je hem activeert.</p>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<h6>Antwoorden invullen</h6>
|
||||||
|
<p>Gebruik dit formulier om antwoorden aan kandidaten toe te wijzen. Op deze manier kunnen er statistieken gemaakt worden hoe verdacht kandidaten zijn.</p>
|
||||||
|
<p>Navigeer met de knoppen Vorige en Volgende tussen vragen. Vink per kandidaat het gegeven antwoord aan en sla op.</p>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<h6>Kandidaten laten deelnemen</h6>
|
||||||
|
<p>Kandidaten kunnen de test invullen via hun eigen apparaat, een of meerdere gedeelde laptops, of een mix daarvan.</p>
|
||||||
|
<p><strong>Eigen apparaat:</strong> Deel de seizoenscode. Elke kandidaat bezoekt de site op zijn of haar telefoon of laptop, voert de eigen naam in en start de test.</p>
|
||||||
|
<p><strong>Gedeelde laptop(s):</strong> Open de naamsinvoerpagina van tevoren op een of meerdere laptops. Elke kandidaat typt zijn of haar naam en start. Na afloop kan de volgende kandidaat hetzelfde doen op dezelfde of een andere laptop.</p>
|
||||||
|
<h6>Status</h6>
|
||||||
|
<p>Deactiveer een kandidaat als deze de test niet hoeft te maken, bijvoorbeeld na eerder uitgeschakeld zijn. Deactivering is per test en heeft geen invloed op andere testen.</p>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<h6>Overzicht & afronden</h6>
|
||||||
|
<p>Vragen met een rode markering in de lijst hiernaast bevatten een fout. Herstel deze vóór het afronden.</p>
|
||||||
|
<p><strong>Afronden</strong> vergrendelt de test voor bewerking en maakt hem klaar voor kandidaten. Daarna kun je hem activeren.</p>
|
||||||
|
<p><strong>Activeren</strong> stelt de test beschikbaar aan kandidaten. Er kan maar één test tegelijk actief zijn, activeer de volgende pas als iedereen de huidige heeft afgerond.</p>
|
||||||
|
<p><strong>Test wissen</strong> verwijdert alle gegeven antwoorden en heft het afronden op, zodat je de test opnieuw kunt bewerken en uitvoeren.</p>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<h6>Vraag toevoegen</h6>
|
||||||
|
<p>Voer de vraag in en voeg minimaal twee antwoordopties toe. Markeer precies één antwoord als correct.</p>
|
||||||
|
<p>Gebruik labels om vragen te organiseren in de vragenbank, bijvoorbeeld per aflevering of type vraag.</p>
|
||||||
|
<p>Markeer een vraag als <em>herbruikbaar</em> als deze in meerdere testen mag voorkomen, anders kan een vraag maar aan één test worden gekoppeld.</p>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<h6>Resultaten</h6>
|
||||||
|
<p>De tabel toont het eindresultaat per kandidaat gesorteerd op score. Rode rijen zijn de kandidaten met de laagste score die risico lopen op eliminatie.</p>
|
||||||
|
<p><strong>Jokers</strong> voeg je toe voor goede of foute vragen (halve punten zijn mogelijk).</p>
|
||||||
|
<p><strong>Straftijd</strong> is tijdstraf in seconden en wordt meegewogen bij gelijke score. Let op: Een positief getal is een straf en een negatief getal is een bonus.</p>
|
||||||
|
<p>Via <strong>Eliminatie voorbereiden</strong> stel je de schermkleuren handmatig in en start je de eliminatie.</p>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<h6>Nieuw seizoen</h6>
|
||||||
|
<p>Een seizoen groepeert alle testen en kandidaten voor één spel. Geef het seizoen een herkenbare naam, de seizoenscode wordt automatisch gegenereerd.</p>
|
||||||
|
<p>Na het aanmaken voeg je kandidaten toe en maak je testen aan via de seizoenpagina.</p>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<h6>Kandidaten toevoegen</h6>
|
||||||
|
<p>Voer één naam per regel in. Dit zijn de spelers die deelnemen aan dit seizoen.</p>
|
||||||
|
<p>Je kunt later altijd nog kandidaten toevoegen via het tabblad Kandidaten. Gebruik dezelfde schrijfwijze van namen die je in het spel gebruikt.</p>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<h6>Kandidaten</h6>
|
||||||
|
<p>Dit zijn de spelers van dit seizoen. Voeg alle deelnemers toe voordat je de eerste test start, kandidaten worden automatisch aan nieuwe testen gekoppeld.</p>
|
||||||
|
<p>Namen zijn vrij in te voeren, gebruik dezelfde schrijfwijze die je in het spel gebruikt.</p>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<h6>Vragenbank</h6>
|
||||||
|
<p>De vragenbank is een bibliotheek met vragen die aan meerdere testen kunnen worden gekoppeld. Markeer een vraag als <em>herbruikbaar</em> als deze in meerdere testen mag voorkomen (bijv. "Wie is de Mol?").</p>
|
||||||
|
<p>Na het bewerken van een vraag in de bank worden testen die de vraag al bevatten <strong>niet</strong> automatisch bijgewerkt, gebruik de synchronisatieknop (↻) naast een test om de meest recente versie door te zetten.</p>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<h6>Seizoensinstellingen</h6>
|
||||||
|
<p>Pas hier de weergave-instellingen van dit seizoen aan.</p>
|
||||||
|
<p><strong>Nummers tonen:</strong> toont vraagnummers tijdens de test. <strong>Antwoord bevestigen:</strong> vraagt kandidaten om hun antwoord te bevestigen voordat ze doorgaan.</p>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<h6>Testen beheren</h6>
|
||||||
|
<p>Voeg een test toe vanuit een Excel-bestand of maak een lege test aan en vul deze via de vragenbank. Open daarna de test om hem te bekijken en af te ronden.</p>
|
||||||
|
<p>Een test moet eerst <strong>afgerond</strong> zijn voordat je hem kunt activeren. Slechts één test kan tegelijk actief zijn, namelijk de test die kandidaten op dat moment kunnen invullen.</p>
|
||||||
|
<h6>Volgorde van werken</h6>
|
||||||
|
<ol>
|
||||||
|
<li>Test aanmaken (Excel of leeg)</li>
|
||||||
|
<li>Vragen controleren en test afronden</li>
|
||||||
|
<li>Test activeren</li>
|
||||||
|
<li>Kandidaten laten deelnemen</li>
|
||||||
|
<li>Resultaten bekijken en eliminatie voorbereiden</li>
|
||||||
|
</ol>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{{ include([
|
||||||
|
'backoffice/help/' ~ app.request.locale ~ '/prepare_elimination.html.twig',
|
||||||
|
'backoffice/help/nl/prepare_elimination.html.twig',
|
||||||
|
]) }}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{{ include([
|
||||||
|
'backoffice/help/' ~ app.request.locale ~ '/quiz_add.html.twig',
|
||||||
|
'backoffice/help/nl/quiz_add.html.twig',
|
||||||
|
]) }}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{{ include([
|
||||||
|
'backoffice/help/' ~ app.request.locale ~ '/quiz_add_blank.html.twig',
|
||||||
|
'backoffice/help/nl/quiz_add_blank.html.twig',
|
||||||
|
]) }}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{{ include([
|
||||||
|
'backoffice/help/' ~ app.request.locale ~ '/quiz_answer_mapping.html.twig',
|
||||||
|
'backoffice/help/nl/quiz_answer_mapping.html.twig',
|
||||||
|
]) }}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{{ include([
|
||||||
|
'backoffice/help/' ~ app.request.locale ~ '/quiz_candidates.html.twig',
|
||||||
|
'backoffice/help/nl/quiz_candidates.html.twig',
|
||||||
|
]) }}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{{ include([
|
||||||
|
'backoffice/help/' ~ app.request.locale ~ '/quiz_overview.html.twig',
|
||||||
|
'backoffice/help/nl/quiz_overview.html.twig',
|
||||||
|
]) }}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{{ include([
|
||||||
|
'backoffice/help/' ~ app.request.locale ~ '/quiz_question_bank_form.html.twig',
|
||||||
|
'backoffice/help/nl/quiz_question_bank_form.html.twig',
|
||||||
|
]) }}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{{ include([
|
||||||
|
'backoffice/help/' ~ app.request.locale ~ '/quiz_result.html.twig',
|
||||||
|
'backoffice/help/nl/quiz_result.html.twig',
|
||||||
|
]) }}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{{ include([
|
||||||
|
'backoffice/help/' ~ app.request.locale ~ '/season_add.html.twig',
|
||||||
|
'backoffice/help/nl/season_add.html.twig',
|
||||||
|
]) }}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{{ include([
|
||||||
|
'backoffice/help/' ~ app.request.locale ~ '/season_add_candidates.html.twig',
|
||||||
|
'backoffice/help/nl/season_add_candidates.html.twig',
|
||||||
|
]) }}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{{ include([
|
||||||
|
'backoffice/help/' ~ app.request.locale ~ '/season_candidates.html.twig',
|
||||||
|
'backoffice/help/nl/season_candidates.html.twig',
|
||||||
|
]) }}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{{ include([
|
||||||
|
'backoffice/help/' ~ app.request.locale ~ '/season_question_bank.html.twig',
|
||||||
|
'backoffice/help/nl/season_question_bank.html.twig',
|
||||||
|
]) }}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{{ include([
|
||||||
|
'backoffice/help/' ~ app.request.locale ~ '/season_settings.html.twig',
|
||||||
|
'backoffice/help/nl/season_settings.html.twig',
|
||||||
|
]) }}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{{ include([
|
||||||
|
'backoffice/help/' ~ app.request.locale ~ '/season_tests.html.twig',
|
||||||
|
'backoffice/help/nl/season_tests.html.twig',
|
||||||
|
]) }}
|
||||||
@@ -11,53 +11,60 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<div class="d-flex flex-row align-items-center mb-3">
|
<div class="row">
|
||||||
<h2 class="mb-0 pe-2">
|
<div class="col-md-8 col-12">
|
||||||
{{ is_granted('ROLE_ADMIN') ? 'All Seasons'|trans : 'Your Seasons'|trans }}
|
<div class="d-flex flex-row align-items-center mb-3">
|
||||||
</h2>
|
<h2 class="mb-0 pe-2">
|
||||||
<a class="link" href="{{ path('tvdt_backoffice_season_add') }}">
|
{{ is_granted('ROLE_ADMIN') ? 'All Seasons'|trans : 'Your Seasons'|trans }}
|
||||||
{{ 'Add'|trans }}
|
</h2>
|
||||||
</a>
|
<a class="link" href="{{ path('tvdt_backoffice_season_add') }}">
|
||||||
</div>
|
{{ 'Add'|trans }}
|
||||||
{% if seasons %}
|
</a>
|
||||||
<table class="table table-hover mb-3">
|
</div>
|
||||||
<thead>
|
{% if seasons %}
|
||||||
<tr>
|
<table class="table table-hover mb-3">
|
||||||
{% if is_granted('ROLE_ADMIN') %}
|
<thead>
|
||||||
<th scope="col">{{ 'Owner(s)'|trans }}</th>
|
<tr>
|
||||||
{% endif %}
|
{% if is_granted('ROLE_ADMIN') %}
|
||||||
<th scope="col">{{ 'Name'|trans }}</th>
|
<th scope="col">{{ 'Owner(s)'|trans }}</th>
|
||||||
<th scope="col">{{ 'Active Quiz'|trans }}</th>
|
|
||||||
<th scope="col">{{ 'Season Code'|trans }}</th>
|
|
||||||
<th scope="col">{{ 'Manage'|trans }}</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for season in seasons %}
|
|
||||||
<tr class="align-middle">
|
|
||||||
{% if is_granted('ROLE_ADMIN') %}
|
|
||||||
<td>{{ season.owners|map(o => o.email)|join(', ') }}</td>
|
|
||||||
{% endif %}
|
|
||||||
<td>{{ season.name }}</td>
|
|
||||||
<td>
|
|
||||||
{% if season.activeQuiz %}
|
|
||||||
{{ season.activeQuiz.name }}
|
|
||||||
{% else %}
|
|
||||||
{{ 'No active quiz'|trans }}
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
<th scope="col">{{ 'Name'|trans }}</th>
|
||||||
<td>
|
<th scope="col">{{ 'Active Quiz'|trans }}</th>
|
||||||
<a {% if season.activeQuiz %}href="{{ path('tvdt_quiz_enter_name', {seasonCode: season.seasonCode}) }}"
|
<th scope="col">{{ 'Season Code'|trans }}</th>
|
||||||
{% else %}class="disabled" {% endif %}>{{ season.seasonCode }}</a>
|
<th scope="col">{{ 'Manage'|trans }}</th>
|
||||||
</td>
|
</tr>
|
||||||
<td>
|
</thead>
|
||||||
<a href="{{ path('tvdt_backoffice_season', {seasonCode: season.seasonCode}) }}">{{ 'Manage'|trans }}</a>
|
<tbody>
|
||||||
</td>
|
{% for season in seasons %}
|
||||||
</tr>
|
<tr class="align-middle">
|
||||||
{% endfor %}
|
{% if is_granted('ROLE_ADMIN') %}
|
||||||
</tbody>
|
<td>{{ season.owners|map(o => o.email)|join(', ') }}</td>
|
||||||
</table>
|
{% endif %}
|
||||||
{% else %}
|
<td>{{ season.name }}</td>
|
||||||
{{ 'You have no seasons yet.'|trans }}
|
<td>
|
||||||
{% endif %}
|
{% if season.activeQuiz %}
|
||||||
|
{{ season.activeQuiz.name }}
|
||||||
|
{% else %}
|
||||||
|
{{ 'No active quiz'|trans }}
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a {% if season.activeQuiz %}href="{{ path('tvdt_quiz_enter_name', {seasonCode: season.seasonCode}) }}"
|
||||||
|
{% else %}class="disabled" {% endif %}>{{ season.seasonCode }}</a>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a href="{{ path('tvdt_backoffice_season', {seasonCode: season.seasonCode}) }}">{{ 'Manage'|trans }}</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
{{ 'You have no seasons yet.'|trans }}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 col-12">
|
||||||
|
{{ include('backoffice/help/index.html.twig') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-md-6">
|
<div class="col-12 col-md-6">
|
||||||
<p class="mb-3">{{ 'Help text for preparing elimination'|trans }}</p>
|
{{ include('backoffice/help/prepare_elimination.html.twig') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
{% 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 class="col-md-6 col-12">
|
||||||
|
{{ include('backoffice/help/quiz_question_bank_form.html.twig') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock body %}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
<div class="row">
|
||||||
|
<div class="col-xl-9 col-12">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
<div>
|
<div>
|
||||||
{% set questions = quiz.questions %}
|
{% set questions = quiz.questions %}
|
||||||
@@ -64,3 +66,8 @@
|
|||||||
</table>
|
</table>
|
||||||
<button type="submit" class="btn btn-primary">{{ 'Save'|trans }}</button>
|
<button type="submit" class="btn btn-primary">{{ 'Save'|trans }}</button>
|
||||||
</form>
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="col-xl-3 col-12">
|
||||||
|
{{ include('backoffice/help/quiz_answer_mapping.html.twig') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
<div class="row">
|
||||||
|
<div class="col-md-8 col-12">
|
||||||
<h4 class="mb-3">{{ 'Candidates'|trans }}</h4>
|
<h4 class="mb-3">{{ 'Candidates'|trans }}</h4>
|
||||||
<table class="table table-hover mb-3">
|
<table class="table table-hover mb-3">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -50,3 +52,8 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 col-12">
|
||||||
|
{{ include('backoffice/help/quiz_candidates.html.twig') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -22,13 +22,24 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
<div data-controller="bo--quiz">
|
<div class="row">
|
||||||
<h4 class="mb-3">{{ 'Quick actions'|trans }}</h4>
|
<div class="col-md-8 col-12" data-controller="bo--quiz">
|
||||||
|
<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">
|
<div class="mb-3 btn-group">
|
||||||
|
|
||||||
{% if quiz is same as (season.activeQuiz) %}
|
{% if quiz is same as (season.activeQuiz) %}
|
||||||
<form action="{{ path('tvdt_backoffice_enable', {seasonCode: season.seasonCode, quiz: 'null'}) }}" method="POST">
|
<form action="{{ path('tvdt_backoffice_enable', {seasonCode: season.seasonCode, quiz: 'null'}) }}" method="POST">
|
||||||
<input type="hidden" name="_token" value="{{ csrf_token('enable_quiz') }}">
|
<input type="hidden" name="_token" value="{{ csrf_token('enable_quiz') }}">
|
||||||
|
<input type="hidden" name="redirect_quiz" value="{{ quiz.id }}">
|
||||||
<button type="submit" class="btn btn-secondary rounded-0 rounded-start">
|
<button type="submit" class="btn btn-secondary rounded-0 rounded-start">
|
||||||
{{ 'Deactivate Quiz'|trans }}
|
{{ 'Deactivate Quiz'|trans }}
|
||||||
</button>
|
</button>
|
||||||
@@ -36,11 +47,32 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<form action="{{ path('tvdt_backoffice_enable', {seasonCode: season.seasonCode, quiz: quiz.id}) }}" method="POST">
|
<form action="{{ path('tvdt_backoffice_enable', {seasonCode: season.seasonCode, quiz: quiz.id}) }}" method="POST">
|
||||||
<input type="hidden" name="_token" value="{{ csrf_token('enable_quiz') }}">
|
<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 }}
|
{{ 'Make active'|trans }}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% 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"
|
||||||
|
data-bs-toggle="tooltip"
|
||||||
|
title="{{ 'Locks the quiz so it can no longer be edited and makes it ready for candidates to take.'|trans }}">
|
||||||
|
{{ '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"
|
||||||
|
data-bs-toggle="tooltip"
|
||||||
|
title="{{ 'Re-opens the quiz for editing. Candidates will no longer be able to take the quiz until it is finalized again.'|trans }}">
|
||||||
|
{{ 'Undo finalization'|trans }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
<button class="btn btn-danger" data-action="click->bo--quiz#clearQuiz">
|
<button class="btn btn-danger" data-action="click->bo--quiz#clearQuiz">
|
||||||
{{ 'Clear Quiz...'|trans }}
|
{{ 'Clear Quiz...'|trans }}
|
||||||
</button>
|
</button>
|
||||||
@@ -111,3 +143,7 @@
|
|||||||
csrf_token('delete_quiz'),
|
csrf_token('delete_quiz'),
|
||||||
) }}
|
) }}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-md-4 col-12">
|
||||||
|
{{ include('backoffice/help/quiz_overview.html.twig') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
<div class="row">
|
||||||
|
<div class="col-md-8 col-12">
|
||||||
<h4 class="mb-3">{{ 'Score'|trans }}</h4>
|
<h4 class="mb-3">{{ 'Score'|trans }}</h4>
|
||||||
<div class="btn-toolbar mb-3" role="toolbar">
|
<div class="btn-toolbar mb-3" role="toolbar">
|
||||||
<div class="btn-group me-2">
|
<div class="btn-group me-2">
|
||||||
@@ -69,3 +71,8 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 col-12">
|
||||||
|
{{ include('backoffice/help/quiz_result.html.twig') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -21,9 +21,7 @@
|
|||||||
{{ form_end(form) }}
|
{{ form_end(form) }}
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6 col-12">
|
<div class="col-md-6 col-12">
|
||||||
<p class="mb-3">
|
{{ include('backoffice/help/quiz_add.html.twig') }}
|
||||||
{{ 'Help text for adding a quiz'|trans }}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{% extends 'backoffice/base.html.twig' %}
|
||||||
|
|
||||||
|
{% block breadcrumbs %}
|
||||||
|
<nav aria-label="breadcrumb" class="mb-3">
|
||||||
|
<ol class="breadcrumb">
|
||||||
|
<li class="breadcrumb-item"><a href="{{ path('tvdt_backoffice_index') }}">{{ 'Home'|trans }}</a></li>
|
||||||
|
<li class="breadcrumb-item"><a href="{{ path('tvdt_backoffice_season', {seasonCode: season.seasonCode}) }}">{{ season.name }}</a></li>
|
||||||
|
<li class="breadcrumb-item active" aria-current="page">{{ 'Add blank quiz'|trans }}</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 col-12">
|
||||||
|
<h2 class="mb-3">{{ t('Add a quiz to {name}', {name: season.name})|trans }}</h2>
|
||||||
|
{{ form_start(form) }}
|
||||||
|
{{ form_row(form.name) }}
|
||||||
|
{{ form_widget(form.save, {attr: {class: 'btn btn-primary'}}) }}
|
||||||
|
{{ form_end(form) }}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 col-12">
|
||||||
|
{{ include('backoffice/help/quiz_add_blank.html.twig') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block title %}{{ parent() }}Backoffice{% endblock %}
|
||||||
@@ -11,39 +11,24 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<h2 class="mb-3">{{ 'Season'|trans }}: {{ season.name }}</h2>
|
{% set tabs = [
|
||||||
<div class="row">
|
{id: 'tests', label: 'Quizzes'|trans, route: 'tvdt_backoffice_season'},
|
||||||
<div class="col-md-6 col-12">
|
{id: 'question-bank', label: 'Question bank'|trans, route: 'tvdt_backoffice_question_bank'},
|
||||||
<div class="d-flex align-items-center mb-3">
|
{id: 'candidates', label: 'Candidates'|trans, route: 'tvdt_backoffice_season_candidates'},
|
||||||
<h4 class="mb-0 pe-2">{{ 'Quizzes'|trans }}</h4>
|
{id: 'settings', label: 'Settings'|trans, route: 'tvdt_backoffice_season_settings'},
|
||||||
<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>
|
|
||||||
|
|
||||||
<div class="d-flex align-items-center mb-3">
|
<h2 class="mb-3">{{ 'Season'|trans }}: {{ season.name }}</h2>
|
||||||
<h4 class="mb-0 pe-2">{{ 'Settings'|trans }}</h4>
|
<ul class="nav nav-tabs mb-3">
|
||||||
</div>
|
{% for tab in tabs %}
|
||||||
{{ form(form) }}
|
<li class="nav-item">
|
||||||
</div>
|
<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>
|
</div>
|
||||||
{% endblock body %}
|
{% endblock body %}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 col-12">
|
||||||
|
<div class="mb-3">
|
||||||
|
<a class="btn btn-sm btn-outline-primary"
|
||||||
|
href="{{ path('tvdt_backoffice_add_candidates', {seasonCode: season.seasonCode}) }}">{{ 'Add Candidate'|trans }}</a>
|
||||||
|
</div>
|
||||||
|
<ul class="mb-3">
|
||||||
|
{% for candidate in season.candidates %}
|
||||||
|
<li>{{ candidate.name }}</li>
|
||||||
|
{% else %}
|
||||||
|
{{ 'No candidates'|trans }}
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 col-12">
|
||||||
|
{{ include('backoffice/help/season_candidates.html.twig') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-8 col-12">
|
||||||
|
<div class="mb-3">
|
||||||
|
<a class="btn btn-sm btn-outline-primary" href="{{ path('tvdt_backoffice_question_bank_new', {seasonCode: season.seasonCode}) }}">{{ 'Add question'|trans }}</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 col-12">
|
||||||
|
{{ include('backoffice/help/season_question_bank.html.twig') }}
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
{% for usage in bankQuestion.usages %}
|
||||||
|
<div class="d-flex align-items-center gap-1">
|
||||||
|
<span>{{ usage.quiz.name }}</span>
|
||||||
|
<form action="{{ path('tvdt_backoffice_question_bank_unassign', {seasonCode: season.seasonCode, bankQuestion: bankQuestion.id, usage: usage.id}) }}"
|
||||||
|
method="POST" class="d-inline">
|
||||||
|
<input type="hidden" name="_token" value="{{ csrf_token('unassign_bank_question') }}">
|
||||||
|
<button type="submit" class="btn btn-sm btn-link p-0 text-danger" title="{{ 'Unassign'|trans }}">×</button>
|
||||||
|
</form>
|
||||||
|
{% if usage.quiz.isFinalized %}
|
||||||
|
<form action="{{ path('tvdt_backoffice_question_bank_sync', {seasonCode: season.seasonCode, bankQuestion: bankQuestion.id, usage: usage.id}) }}"
|
||||||
|
method="POST" class="d-inline">
|
||||||
|
<input type="hidden" name="_token" value="{{ csrf_token('sync_bank_question') }}">
|
||||||
|
<button type="submit" class="btn btn-sm btn-link p-0 text-primary" title="{{ 'Sync latest changes to this quiz'|trans }}">↻</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</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,8 @@
|
|||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 col-12">
|
||||||
|
{{ form(form) }}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 col-12">
|
||||||
|
{{ include('backoffice/help/season_settings.html.twig') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 col-12">
|
||||||
|
<div class="d-flex align-items-center gap-2 mb-3">
|
||||||
|
<a class="btn btn-sm btn-outline-primary"
|
||||||
|
href="{{ path('tvdt_backoffice_quiz_add', {seasonCode: season.seasonCode}) }}">{{ 'Import'|trans }}</a>
|
||||||
|
<a class="btn btn-sm btn-outline-secondary"
|
||||||
|
href="{{ path('tvdt_backoffice_quiz_add_blank', {seasonCode: season.seasonCode}) }}">{{ 'Add blank'|trans }}</a>
|
||||||
|
</div>
|
||||||
|
<div class="list-group mb-3">
|
||||||
|
{% for quiz in season.quizzes %}
|
||||||
|
<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 class="col-md-6 col-12">
|
||||||
|
{{ include('backoffice/help/season_tests.html.twig') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -19,9 +19,7 @@
|
|||||||
{{ form_end(form) }}
|
{{ form_end(form) }}
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6 col-12">
|
<div class="col-md-6 col-12">
|
||||||
<p class="mb-3">
|
{{ include('backoffice/help/season_add.html.twig') }}
|
||||||
{{ 'Help text for creating a season'|trans }}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -20,9 +20,7 @@
|
|||||||
{{ form_end(form) }}
|
{{ form_end(form) }}
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6 col-12">
|
<div class="col-md-6 col-12">
|
||||||
<p class="mb-3">
|
{{ include('backoffice/help/season_add_candidates.html.twig') }}
|
||||||
{{ 'Help text for adding candidates'|trans }}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -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,345 @@
|
|||||||
|
<?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\GivenAnswer;
|
||||||
|
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 QuizControllerTest 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 getCandidate(string $name): Candidate
|
||||||
|
{
|
||||||
|
$candidate = $this->entityManager->getRepository(Candidate::class)->findOneBy(['name' => $name]);
|
||||||
|
$this->assertInstanceOf(Candidate::class, $candidate);
|
||||||
|
|
||||||
|
return $candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getCsrfTokenFromOverview(Quiz $quiz, string $formActionContains): string
|
||||||
|
{
|
||||||
|
$crawler = $this->client->request(Request::METHOD_GET, \sprintf('/backoffice/season/krtek/quiz/%s/overview', $quiz->id));
|
||||||
|
self::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 testIndexRedirectsToOverview(): void
|
||||||
|
{
|
||||||
|
$quiz = $this->getQuizByName('Quiz 1');
|
||||||
|
|
||||||
|
$this->client->request(Request::METHOD_GET, \sprintf('/backoffice/season/krtek/quiz/%s', $quiz->id));
|
||||||
|
|
||||||
|
self::assertResponseRedirects(\sprintf('/backoffice/season/krtek/quiz/%s/overview', $quiz->id));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testOverviewLoadsSuccessfully(): void
|
||||||
|
{
|
||||||
|
$quiz = $this->getQuizByName('Quiz 1');
|
||||||
|
|
||||||
|
$this->client->request(Request::METHOD_GET, \sprintf('/backoffice/season/krtek/quiz/%s/overview', $quiz->id));
|
||||||
|
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
self::assertSelectorTextContains('body', 'Quiz 1');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testResultTabLoadsSuccessfully(): void
|
||||||
|
{
|
||||||
|
$quiz = $this->getQuizByName('Quiz 1');
|
||||||
|
|
||||||
|
$this->client->request(Request::METHOD_GET, \sprintf('/backoffice/season/krtek/quiz/%s/result', $quiz->id));
|
||||||
|
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCandidatesTabLoadsSuccessfully(): void
|
||||||
|
{
|
||||||
|
$quiz = $this->getQuizByName('Quiz 1');
|
||||||
|
|
||||||
|
$this->client->request(Request::METHOD_GET, \sprintf('/backoffice/season/krtek/quiz/%s/candidates-list', $quiz->id));
|
||||||
|
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAnswerMappingRedirectsToFirstQuestion(): void
|
||||||
|
{
|
||||||
|
$quiz = $this->getQuizByName('Quiz 1');
|
||||||
|
|
||||||
|
$this->client->request(Request::METHOD_GET, \sprintf('/backoffice/season/krtek/quiz/%s/answer-mapping', $quiz->id));
|
||||||
|
|
||||||
|
self::assertResponseRedirects();
|
||||||
|
$this->assertStringContainsString('/candidates/', (string) $this->client->getResponse()->headers->get('Location'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCandidatesQuestionTabLoadsSuccessfully(): void
|
||||||
|
{
|
||||||
|
$quiz = $this->getQuizByName('Quiz 1');
|
||||||
|
$question = $quiz->questions->first();
|
||||||
|
$this->assertInstanceOf(Question::class, $question);
|
||||||
|
|
||||||
|
$this->client->request(Request::METHOD_GET, \sprintf('/backoffice/season/krtek/quiz/%s/candidates/%s', $quiz->id, $question->id));
|
||||||
|
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSaveCandidateAnswersPersistsSelection(): void
|
||||||
|
{
|
||||||
|
$quiz = $this->getQuizByName('Quiz 1');
|
||||||
|
$question = $quiz->questions->first();
|
||||||
|
$this->assertInstanceOf(Question::class, $question);
|
||||||
|
$answer = $question->answers->first();
|
||||||
|
$this->assertInstanceOf(Answer::class, $answer);
|
||||||
|
$candidate = $this->getCandidate('Tom');
|
||||||
|
|
||||||
|
$url = \sprintf('/backoffice/season/krtek/quiz/%s/candidates/%s', $quiz->id, $question->id);
|
||||||
|
$crawler = $this->client->request(Request::METHOD_GET, $url);
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
$token = (string) $crawler->filter('input[name="_token"]')->first()->attr('value');
|
||||||
|
|
||||||
|
$this->client->request(Request::METHOD_POST, $url, [
|
||||||
|
'_token' => $token,
|
||||||
|
'candidate_answer' => [
|
||||||
|
(string) $candidate->id => [(string) $answer->id],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertResponseRedirects($url);
|
||||||
|
$this->entityManager->clear();
|
||||||
|
|
||||||
|
$savedAnswer = $this->entityManager->getRepository(Answer::class)->find($answer->id);
|
||||||
|
$this->assertInstanceOf(Answer::class, $savedAnswer);
|
||||||
|
$this->assertTrue($savedAnswer->candidates->exists(
|
||||||
|
static fn (int $key, Candidate $c): bool => $c->id->equals($candidate->id),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testToggleCandidateCreatesInactiveQuizCandidate(): void
|
||||||
|
{
|
||||||
|
$quiz = $this->getQuizByName('Quiz 1');
|
||||||
|
$candidate = $this->getCandidate('Tom');
|
||||||
|
|
||||||
|
$crawler = $this->client->request(Request::METHOD_GET, \sprintf('/backoffice/season/krtek/quiz/%s/candidates-list', $quiz->id));
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
$token = (string) $crawler->filter(\sprintf('form[action*="/%s/toggle"] input[name="_token"]', $candidate->id))->first()->attr('value');
|
||||||
|
|
||||||
|
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/quiz/%s/candidate/%s/toggle', $quiz->id, $candidate->id), [
|
||||||
|
'_token' => $token,
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseRedirects();
|
||||||
|
$this->entityManager->clear();
|
||||||
|
|
||||||
|
$quizCandidate = $this->entityManager->getRepository(QuizCandidate::class)->findOneBy([
|
||||||
|
'quiz' => $this->getQuizByName('Quiz 1'),
|
||||||
|
'candidate' => $this->getCandidate('Tom'),
|
||||||
|
]);
|
||||||
|
$this->assertInstanceOf(QuizCandidate::class, $quizCandidate);
|
||||||
|
$this->assertFalse($quizCandidate->active);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testToggleCandidateTogglesActiveState(): void
|
||||||
|
{
|
||||||
|
$quiz = $this->getQuizByName('Quiz 1');
|
||||||
|
$candidate = $this->getCandidate('Tom');
|
||||||
|
|
||||||
|
$quizCandidate = new QuizCandidate($quiz, $candidate);
|
||||||
|
$quizCandidate->active = false;
|
||||||
|
|
||||||
|
$this->entityManager->persist($quizCandidate);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
$crawler = $this->client->request(Request::METHOD_GET, \sprintf('/backoffice/season/krtek/quiz/%s/candidates-list', $quiz->id));
|
||||||
|
$token = (string) $crawler->filter(\sprintf('form[action*="/%s/toggle"] input[name="_token"]', $candidate->id))->first()->attr('value');
|
||||||
|
|
||||||
|
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/quiz/%s/candidate/%s/toggle', $quiz->id, $candidate->id), [
|
||||||
|
'_token' => $token,
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseRedirects();
|
||||||
|
$this->entityManager->clear();
|
||||||
|
|
||||||
|
$updated = $this->entityManager->getRepository(QuizCandidate::class)->findOneBy([
|
||||||
|
'quiz' => $this->getQuizByName('Quiz 1'),
|
||||||
|
'candidate' => $this->getCandidate('Tom'),
|
||||||
|
]);
|
||||||
|
$this->assertInstanceOf(QuizCandidate::class, $updated);
|
||||||
|
$this->assertTrue($updated->active);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testModifyCorrection(): void
|
||||||
|
{
|
||||||
|
$quiz = $this->getQuizByName('Quiz 1');
|
||||||
|
$candidate = $this->getCandidate('Tom');
|
||||||
|
|
||||||
|
// getScores() requires started IS NOT NULL and at least one GivenAnswer
|
||||||
|
$quizCandidate = new QuizCandidate($quiz, $candidate);
|
||||||
|
$quizCandidate->started = new DateTimeImmutable();
|
||||||
|
|
||||||
|
$this->entityManager->persist($quizCandidate);
|
||||||
|
$firstQuestion = $quiz->questions->first();
|
||||||
|
$this->assertInstanceOf(Question::class, $firstQuestion);
|
||||||
|
$answer = $firstQuestion->answers->first();
|
||||||
|
$this->assertInstanceOf(Answer::class, $answer);
|
||||||
|
$this->entityManager->persist(new GivenAnswer($candidate, $quiz, $answer));
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
$crawler = $this->client->request(Request::METHOD_GET, \sprintf('/backoffice/season/krtek/quiz/%s/result', $quiz->id));
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
$token = (string) $crawler->filter(\sprintf('form[action*="%s/modify_correction"] input[name="_token"]', $candidate->id))->first()->attr('value');
|
||||||
|
|
||||||
|
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/quiz/%s/candidate/%s/modify_correction', $quiz->id, $candidate->id), [
|
||||||
|
'_token' => $token,
|
||||||
|
'corrections' => '1.5',
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseRedirects();
|
||||||
|
$this->entityManager->clear();
|
||||||
|
|
||||||
|
$updated = $this->entityManager->getRepository(QuizCandidate::class)->findOneBy([
|
||||||
|
'quiz' => $this->getQuizByName('Quiz 1'),
|
||||||
|
'candidate' => $this->getCandidate('Tom'),
|
||||||
|
]);
|
||||||
|
$this->assertInstanceOf(QuizCandidate::class, $updated);
|
||||||
|
$this->assertEqualsWithDelta(1.5, $updated->corrections, \PHP_FLOAT_EPSILON);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testModifyPenalty(): void
|
||||||
|
{
|
||||||
|
$quiz = $this->getQuizByName('Quiz 1');
|
||||||
|
$candidate = $this->getCandidate('Claudia');
|
||||||
|
|
||||||
|
$quizCandidate = new QuizCandidate($quiz, $candidate);
|
||||||
|
$quizCandidate->started = new DateTimeImmutable();
|
||||||
|
|
||||||
|
$this->entityManager->persist($quizCandidate);
|
||||||
|
$firstQuestion = $quiz->questions->first();
|
||||||
|
$this->assertInstanceOf(Question::class, $firstQuestion);
|
||||||
|
$answer = $firstQuestion->answers->first();
|
||||||
|
$this->assertInstanceOf(Answer::class, $answer);
|
||||||
|
$this->entityManager->persist(new GivenAnswer($candidate, $quiz, $answer));
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
$crawler = $this->client->request(Request::METHOD_GET, \sprintf('/backoffice/season/krtek/quiz/%s/result', $quiz->id));
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
$token = (string) $crawler->filter(\sprintf('form[action*="%s/modify_penalty"] input[name="_token"]', $candidate->id))->first()->attr('value');
|
||||||
|
|
||||||
|
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/quiz/%s/candidate/%s/modify_penalty', $quiz->id, $candidate->id), [
|
||||||
|
'_token' => $token,
|
||||||
|
'penalty' => '30',
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseRedirects();
|
||||||
|
$this->entityManager->clear();
|
||||||
|
|
||||||
|
$updated = $this->entityManager->getRepository(QuizCandidate::class)->findOneBy([
|
||||||
|
'quiz' => $this->getQuizByName('Quiz 1'),
|
||||||
|
'candidate' => $this->getCandidate('Claudia'),
|
||||||
|
]);
|
||||||
|
$this->assertInstanceOf(QuizCandidate::class, $updated);
|
||||||
|
$this->assertSame(30, $updated->penaltySeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDeleteQuiz(): void
|
||||||
|
{
|
||||||
|
$quiz = $this->getQuizByName('Quiz 2');
|
||||||
|
$quizId = $quiz->id;
|
||||||
|
$token = $this->getCsrfTokenFromOverview($quiz, '/delete');
|
||||||
|
|
||||||
|
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/quiz/%s/delete', $quiz->id), [
|
||||||
|
'_token' => $token,
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseRedirects('/backoffice/season/krtek');
|
||||||
|
$this->entityManager->clear();
|
||||||
|
$this->assertNotInstanceOf(Quiz::class, $this->entityManager->getRepository(Quiz::class)->find($quizId));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNonOwnerIsDenied(): void
|
||||||
|
{
|
||||||
|
$user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'test@example.org']);
|
||||||
|
$this->assertInstanceOf(User::class, $user);
|
||||||
|
$this->client->loginUser($user);
|
||||||
|
|
||||||
|
$quiz = $this->getQuizByName('Quiz 1');
|
||||||
|
$this->client->request(Request::METHOD_GET, \sprintf('/backoffice/season/krtek/quiz/%s/overview', $quiz->id));
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testOverviewLoadsForEmptyQuiz(): void
|
||||||
|
{
|
||||||
|
$season = $this->entityManager->getRepository(Season::class)->findOneBy(['seasonCode' => 'krtek']);
|
||||||
|
$this->assertInstanceOf(Season::class, $season);
|
||||||
|
|
||||||
|
$emptyQuiz = new Quiz();
|
||||||
|
$emptyQuiz->name = 'Empty Quiz';
|
||||||
|
|
||||||
|
$season->addQuiz($emptyQuiz);
|
||||||
|
$this->entityManager->persist($emptyQuiz);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
$this->client->request(Request::METHOD_GET, \sprintf('/backoffice/season/krtek/quiz/%s/overview', $emptyQuiz->id));
|
||||||
|
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
self::assertSelectorTextContains('body', 'Empty Quiz');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAnswerMappingRedirectsWithFlashWhenNoQuestions(): void
|
||||||
|
{
|
||||||
|
$season = $this->entityManager->getRepository(Season::class)->findOneBy(['seasonCode' => 'krtek']);
|
||||||
|
$this->assertInstanceOf(Season::class, $season);
|
||||||
|
|
||||||
|
$emptyQuiz = new Quiz();
|
||||||
|
$emptyQuiz->name = 'Empty Quiz';
|
||||||
|
|
||||||
|
$season->addQuiz($emptyQuiz);
|
||||||
|
$this->entityManager->persist($emptyQuiz);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
$this->client->request(Request::METHOD_GET, \sprintf('/backoffice/season/krtek/quiz/%s/answer-mapping', $emptyQuiz->id));
|
||||||
|
|
||||||
|
self::assertResponseRedirects(\sprintf('/backoffice/season/krtek/quiz/%s/overview', $emptyQuiz->id));
|
||||||
|
$this->client->followRedirect();
|
||||||
|
self::assertSelectorTextContains('body', 'Deze test heeft nog geen vragen');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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\Authorization\Voter\VoterInterface;
|
||||||
use Symfony\Component\Security\Core\User\UserInterface;
|
use Symfony\Component\Security\Core\User\UserInterface;
|
||||||
use Tvdt\Entity\Answer;
|
use Tvdt\Entity\Answer;
|
||||||
|
use Tvdt\Entity\BankQuestion;
|
||||||
use Tvdt\Entity\Candidate;
|
use Tvdt\Entity\Candidate;
|
||||||
use Tvdt\Entity\Elimination;
|
use Tvdt\Entity\Elimination;
|
||||||
use Tvdt\Entity\Question;
|
use Tvdt\Entity\Question;
|
||||||
|
use Tvdt\Entity\QuestionLabel;
|
||||||
use Tvdt\Entity\Quiz;
|
use Tvdt\Entity\Quiz;
|
||||||
use Tvdt\Entity\Season;
|
use Tvdt\Entity\Season;
|
||||||
use Tvdt\Entity\User;
|
use Tvdt\Entity\User;
|
||||||
@@ -75,6 +77,61 @@ final class SeasonVoterTest extends TestCase
|
|||||||
$answer = self::createStub(Answer::class);
|
$answer = self::createStub(Answer::class);
|
||||||
$answer->question = $question;
|
$answer->question = $question;
|
||||||
yield 'Answer' => [$answer];
|
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
|
public function testWrongUserTypeReturnFalse(): void
|
||||||
|
|||||||
@@ -41,6 +41,34 @@
|
|||||||
<source>Add a quiz to {name}</source>
|
<source>Add a quiz to {name}</source>
|
||||||
<target>Voeg een test toe aan {name}</target>
|
<target>Voeg een test toe aan {name}</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="3AXboIn" resname="Add answer">
|
||||||
|
<source>Add answer</source>
|
||||||
|
<target>Antwoord toevoegen</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="hUN3NTt" resname="Add blank">
|
||||||
|
<source>Add blank</source>
|
||||||
|
<target>Leeg toevoegen</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="nl03zPX" resname="Add blank quiz">
|
||||||
|
<source>Add blank quiz</source>
|
||||||
|
<target>Lege quiz toevoegen</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="2Qmgp4S" resname="Import">
|
||||||
|
<source>Import</source>
|
||||||
|
<target>Importeren</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">
|
<trans-unit id="qiXD5ve" resname="All Seasons">
|
||||||
<source>All Seasons</source>
|
<source>All Seasons</source>
|
||||||
<target>Alle seizoenen</target>
|
<target>Alle seizoenen</target>
|
||||||
@@ -57,14 +85,26 @@
|
|||||||
<source>Are you sure you want to clear all the results? This will also delete all the eliminations.</source>
|
<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>
|
<target>Weet je zeker dat je de resultaten wilt leegmaken? Dit gooit ook alle eliminaties weg.</target>
|
||||||
</trans-unit>
|
</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?">
|
<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>
|
<source>Are you sure you want to delete this quiz?</source>
|
||||||
<target>Weet je zeker dat je deze test wilt verwijderen?</target>
|
<target>Weet je zeker dat je deze test wilt verwijderen?</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="4bcq6sL" resname="Assign">
|
||||||
|
<source>Assign</source>
|
||||||
|
<target>Toewijzen</target>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id=".QFPbFe" resname="Back">
|
<trans-unit id=".QFPbFe" resname="Back">
|
||||||
<source>Back</source>
|
<source>Back</source>
|
||||||
<target>Terug</target>
|
<target>Terug</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="JUdglpF" resname="Backoffice">
|
||||||
|
<source>Backoffice</source>
|
||||||
|
<target>Backoffice</target>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="T6TIfj7" resname="Candidate">
|
<trans-unit id="T6TIfj7" resname="Candidate">
|
||||||
<source>Candidate</source>
|
<source>Candidate</source>
|
||||||
<target>Kandidaat</target>
|
<target>Kandidaat</target>
|
||||||
@@ -125,6 +165,10 @@
|
|||||||
<source>Create an account</source>
|
<source>Create an account</source>
|
||||||
<target>Maak een account aan</target>
|
<target>Maak een account aan</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="yBgKisV" resname="Create an empty quiz and add questions from the question bank.">
|
||||||
|
<source>Create an empty quiz and add questions from the question bank.</source>
|
||||||
|
<target>Maak een lege quiz aan en voeg vragen toe vanuit de vragenbank.</target>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="S5P7nQd" resname="Deactivate">
|
<trans-unit id="S5P7nQd" resname="Deactivate">
|
||||||
<source>Deactivate</source>
|
<source>Deactivate</source>
|
||||||
<target>Deactiveren</target>
|
<target>Deactiveren</target>
|
||||||
@@ -133,6 +177,14 @@
|
|||||||
<source>Deactivate Quiz</source>
|
<source>Deactivate Quiz</source>
|
||||||
<target>Deactiveer test</target>
|
<target>Deactiveer test</target>
|
||||||
</trans-unit>
|
</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 het afronden 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...">
|
<trans-unit id="p9GNNI3" resname="Delete Quiz...">
|
||||||
<source>Delete Quiz...</source>
|
<source>Delete Quiz...</source>
|
||||||
<target>Test verwijderen...</target>
|
<target>Test verwijderen...</target>
|
||||||
@@ -141,10 +193,22 @@
|
|||||||
<source>Download Template</source>
|
<source>Download Template</source>
|
||||||
<target>Download sjabloon</target>
|
<target>Download sjabloon</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="dwUtS3b" resname="Draft">
|
||||||
|
<source>Draft</source>
|
||||||
|
<target>Concept</target>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="FfYlwX8" resname="EMPTY">
|
<trans-unit id="FfYlwX8" resname="EMPTY">
|
||||||
<source>EMPTY</source>
|
<source>EMPTY</source>
|
||||||
<target>LEEG</target>
|
<target>LEEG</target>
|
||||||
</trans-unit>
|
</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">
|
<trans-unit id="JZi_tm0" resname="Email">
|
||||||
<source>Email</source>
|
<source>Email</source>
|
||||||
<target>E-mail</target>
|
<target>E-mail</target>
|
||||||
@@ -161,6 +225,18 @@
|
|||||||
<source>Error clearing quiz</source>
|
<source>Error clearing quiz</source>
|
||||||
<target>Fout bij het leegmaken van de test</target>
|
<target>Fout bij het leegmaken van de test</target>
|
||||||
</trans-unit>
|
</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>Afronden</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="c_5RxsX" resname="Finalized">
|
||||||
|
<source>Finalized</source>
|
||||||
|
<target>Afgerond</target>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="OGiIhMH" resname="Green">
|
<trans-unit id="OGiIhMH" resname="Green">
|
||||||
<source>Green</source>
|
<source>Green</source>
|
||||||
<target>Groen</target>
|
<target>Groen</target>
|
||||||
@@ -193,14 +269,34 @@
|
|||||||
<source>Inactive</source>
|
<source>Inactive</source>
|
||||||
<target>Inactief</target>
|
<target>Inactief</target>
|
||||||
</trans-unit>
|
</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">
|
<trans-unit id="k1X7w12" resname="Invalid season code">
|
||||||
<source>Invalid season code</source>
|
<source>Invalid season code</source>
|
||||||
<target>Ongeldige seizoencode</target>
|
<target>Ongeldige seizoencode</target>
|
||||||
</trans-unit>
|
</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">
|
<trans-unit id="q0FeoCr" resname="Load Prepared Elimination">
|
||||||
<source>Load Prepared Elimination</source>
|
<source>Load Prepared Elimination</source>
|
||||||
<target>Laad voorbereide eliminatie</target>
|
<target>Laad voorbereide eliminatie</target>
|
||||||
</trans-unit>
|
</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">
|
<trans-unit id="JKl2Twv" resname="Logout">
|
||||||
<source>Logout</source>
|
<source>Logout</source>
|
||||||
<target>Uitloggen</target>
|
<target>Uitloggen</target>
|
||||||
@@ -221,6 +317,10 @@
|
|||||||
<source>Name</source>
|
<source>Name</source>
|
||||||
<target>Naam</target>
|
<target>Naam</target>
|
||||||
</trans-unit>
|
</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">
|
<trans-unit id="gefhnBC" resname="Next">
|
||||||
<source>Next</source>
|
<source>Next</source>
|
||||||
<target>Volgende</target>
|
<target>Volgende</target>
|
||||||
@@ -233,6 +333,14 @@
|
|||||||
<source>No active quiz</source>
|
<source>No active quiz</source>
|
||||||
<target>Geen actieve test</target>
|
<target>Geen actieve test</target>
|
||||||
</trans-unit>
|
</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">
|
<trans-unit id="oNXT2zu" resname="No quizzes">
|
||||||
<source>No quizzes</source>
|
<source>No quizzes</source>
|
||||||
<target>Geen tests</target>
|
<target>Geen tests</target>
|
||||||
@@ -249,6 +357,10 @@
|
|||||||
<source>Number of dropouts:</source>
|
<source>Number of dropouts:</source>
|
||||||
<target>Aantal afvallers:</target>
|
<target>Aantal afvallers:</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="_SqArFZ" resname="Open">
|
||||||
|
<source>Open</source>
|
||||||
|
<target>Openen</target>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="HmgPmMV" resname="Overview">
|
<trans-unit id="HmgPmMV" resname="Overview">
|
||||||
<source>Overview</source>
|
<source>Overview</source>
|
||||||
<target>Overzicht</target>
|
<target>Overzicht</target>
|
||||||
@@ -297,6 +409,38 @@
|
|||||||
<source>Previous</source>
|
<source>Previous</source>
|
||||||
<target>Vorige</target>
|
<target>Vorige</target>
|
||||||
</trans-unit>
|
</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="htaUa1k" resname="Question removed from quiz %quiz%">
|
||||||
|
<source>Question removed from quiz %quiz%</source>
|
||||||
|
<target>Vraag verwijderd uit quiz %quiz%</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="KApairC" resname="Question removed from the question bank">
|
||||||
|
<source>Question removed from the question bank</source>
|
||||||
|
<target>Vraag verwijderd uit de vragenbank</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="6rkejYf" resname="Question synced to quiz %quiz%">
|
||||||
|
<source>Question synced to quiz %quiz%</source>
|
||||||
|
<target>Vraag gesynchroniseerd naar quiz %quiz%</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="MNSmL.W" resname="Question updated">
|
||||||
|
<source>Question updated</source>
|
||||||
|
<target>Vraag bijgewerkt</target>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="Rx5irUP" resname="Questions">
|
<trans-unit id="Rx5irUP" resname="Questions">
|
||||||
<source>Questions</source>
|
<source>Questions</source>
|
||||||
<target>Vragen</target>
|
<target>Vragen</target>
|
||||||
@@ -325,6 +469,10 @@
|
|||||||
<source>Quiz cleared</source>
|
<source>Quiz cleared</source>
|
||||||
<target>Test leeggemaakt</target>
|
<target>Test leeggemaakt</target>
|
||||||
</trans-unit>
|
</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 afgerond</target>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="LbVe.2c" resname="Quiz completed">
|
<trans-unit id="LbVe.2c" resname="Quiz completed">
|
||||||
<source>Quiz completed</source>
|
<source>Quiz completed</source>
|
||||||
<target>Test voltooid</target>
|
<target>Test voltooid</target>
|
||||||
@@ -333,6 +481,14 @@
|
|||||||
<source>Quiz deleted</source>
|
<source>Quiz deleted</source>
|
||||||
<target>Test verwijderd</target>
|
<target>Test verwijderd</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="GPFvyrm" resname="Quiz finalized">
|
||||||
|
<source>Quiz finalized</source>
|
||||||
|
<target>Test afgerond</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 afgerond</target>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="frxoIkW" resname="Quiz name">
|
<trans-unit id="frxoIkW" resname="Quiz name">
|
||||||
<source>Quiz name</source>
|
<source>Quiz name</source>
|
||||||
<target>Testnaam</target>
|
<target>Testnaam</target>
|
||||||
@@ -353,6 +509,10 @@
|
|||||||
<source>Remember me</source>
|
<source>Remember me</source>
|
||||||
<target>Onthoud mij</target>
|
<target>Onthoud mij</target>
|
||||||
</trans-unit>
|
</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">
|
<trans-unit id="Z9CSKpk" resname="Repeat Password">
|
||||||
<source>Repeat Password</source>
|
<source>Repeat Password</source>
|
||||||
<target>Herhaal wachtwoord</target>
|
<target>Herhaal wachtwoord</target>
|
||||||
@@ -361,6 +521,10 @@
|
|||||||
<source>Results & Elimination</source>
|
<source>Results & Elimination</source>
|
||||||
<target><![CDATA[Resultaat & Eliminatie]]></target>
|
<target><![CDATA[Resultaat & Eliminatie]]></target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="uFRq6ud" resname="Reusable">
|
||||||
|
<source>Reusable</source>
|
||||||
|
<target>Herbruikbaar</target>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="z9OKodR" resname="Save">
|
<trans-unit id="z9OKodR" resname="Save">
|
||||||
<source>Save</source>
|
<source>Save</source>
|
||||||
<target>Opslaan</target>
|
<target>Opslaan</target>
|
||||||
@@ -409,10 +573,30 @@
|
|||||||
<source>Submit</source>
|
<source>Submit</source>
|
||||||
<target>Verstuur</target>
|
<target>Verstuur</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="yqJbSKu" resname="Sync latest changes to this quiz">
|
||||||
|
<source>Sync latest changes to this quiz</source>
|
||||||
|
<target>Laatste wijzigingen synchroniseren naar deze quiz</target>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="_z4el3Z" resname="The password fields must match.">
|
<trans-unit id="_z4el3Z" resname="The password fields must match.">
|
||||||
<source>The password fields must match.</source>
|
<source>The password fields must match.</source>
|
||||||
<target>De wachtwoorden moeten overeen komen.</target>
|
<target>De wachtwoorden moeten overeen komen.</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="1jF4vJ8" resname="The question was not synced to finalized quiz(zes): %quizzes%. Use the Sync button to update them.">
|
||||||
|
<source>The question was not synced to finalized quiz(zes): %quizzes%. Use the Sync button to update them.</source>
|
||||||
|
<target>De vraag is niet gesynchroniseerd naar afgeronde quiz(zes): %quizzes%. Gebruik de Synchroniseren-knop om ze bij te werken.</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="K3e_SRJ" resname="The quiz cannot be finalized while it has errors">
|
||||||
|
<source>The quiz cannot be finalized while it has errors</source>
|
||||||
|
<target>De test kan niet afgerond 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 afgerond zijn voordat deze geactiveerd kan worden</target>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="HuzRgeN" resname="There are no answers for this question">
|
<trans-unit id="HuzRgeN" resname="There are no answers for this question">
|
||||||
<source>There are no answers for this question</source>
|
<source>There are no answers for this question</source>
|
||||||
<target>Er zijn geen antwoorden voor deze vraag</target>
|
<target>Er zijn geen antwoorden voor deze vraag</target>
|
||||||
@@ -421,10 +605,50 @@
|
|||||||
<source>There is no active quiz</source>
|
<source>There is no active quiz</source>
|
||||||
<target>Er is geen test actief</target>
|
<target>Er is geen test actief</target>
|
||||||
</trans-unit>
|
</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="uAJQGot" resname="This quiz has already been filled in and can no longer be altered">
|
||||||
|
<source>This quiz has already been filled in and can no longer be altered</source>
|
||||||
|
<target>Deze quiz is al ingevuld en kan niet meer worden gewijzigd</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="Xq9rT2p" resname="This quiz has no questions yet">
|
||||||
|
<source>This quiz has no questions yet</source>
|
||||||
|
<target>Deze test heeft nog geen vragen</target>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="Dptvysv" resname="Time">
|
<trans-unit id="Dptvysv" resname="Time">
|
||||||
<source>Time</source>
|
<source>Time</source>
|
||||||
<target>Tijd</target>
|
<target>Tijd</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="XLYBGca" resname="Unassign">
|
||||||
|
<source>Unassign</source>
|
||||||
|
<target>Ontkoppelen</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="A_XdODo" resname="Undo finalization">
|
||||||
|
<source>Undo finalization</source>
|
||||||
|
<target>Afronden ongedaan maken</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="Lk7mP2q" resname="Locks the quiz so it can no longer be edited and makes it ready for candidates to take.">
|
||||||
|
<source>Locks the quiz so it can no longer be edited and makes it ready for candidates to take.</source>
|
||||||
|
<target>Vergrendelt de test zodat deze niet meer bewerkt kan worden en maakt deze klaar voor deelnemers.</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="Rn3xW8s" resname="Re-opens the quiz for editing. Candidates will no longer be able to take the quiz until it is finalized again.">
|
||||||
|
<source>Re-opens the quiz for editing. Candidates will no longer be able to take the quiz until it is finalized again.</source>
|
||||||
|
<target>Heropent de test voor bewerking. Deelnemers kunnen de test niet meer afnemen totdat deze opnieuw is afgerond.</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">
|
<trans-unit id="pRCwpOT" resname="Yes">
|
||||||
<source>Yes</source>
|
<source>Yes</source>
|
||||||
<target>Ja</target>
|
<target>Ja</target>
|
||||||
|
|||||||
@@ -9,6 +9,14 @@
|
|||||||
<source>A PHP extension caused the upload to fail.</source>
|
<source>A PHP extension caused the upload to fail.</source>
|
||||||
<target>De upload is mislukt vanwege een PHP-extensie.</target>
|
<target>De upload is mislukt vanwege een PHP-extensie.</target>
|
||||||
</trans-unit>
|
</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.">
|
<trans-unit id="L9tKe2y" resname="An empty file is not allowed.">
|
||||||
<source>An empty file is not allowed.</source>
|
<source>An empty file is not allowed.</source>
|
||||||
<target>Lege bestanden zijn niet toegestaan.</target>
|
<target>Lege bestanden zijn niet toegestaan.</target>
|
||||||
@@ -65,6 +73,10 @@
|
|||||||
<source>Please enter a valid URL.</source>
|
<source>Please enter a valid URL.</source>
|
||||||
<target>Vul een geldige URL in.</target>
|
<target>Vul een geldige URL in.</target>
|
||||||
</trans-unit>
|
</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.">
|
<trans-unit id="e_2ZUCK" resname="Please enter a valid birthdate.">
|
||||||
<source>Please enter a valid birthdate.</source>
|
<source>Please enter a valid birthdate.</source>
|
||||||
<target>Vul een geldige geboortedatum in.</target>
|
<target>Vul een geldige geboortedatum in.</target>
|
||||||
@@ -361,6 +373,10 @@
|
|||||||
<source>This URL is missing a top-level domain.</source>
|
<source>This URL is missing a top-level domain.</source>
|
||||||
<target>Deze URL mist een top-level domein.</target>
|
<target>Deze URL mist een top-level domein.</target>
|
||||||
</trans-unit>
|
</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.">
|
<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>
|
<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>
|
<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>
|
<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>
|
<target>Deze waarde bevat tekens die niet zijn toegestaan volgens het huidige beperkingsniveau.</target>
|
||||||
</trans-unit>
|
</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.">
|
<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>
|
<source>This value does not match the expected {{ charset }} charset.</source>
|
||||||
<target>Deze waarde is niet in de verwachte tekencodering {{ charset }}.</target>
|
<target>Deze waarde is niet in de verwachte tekencodering {{ charset }}.</target>
|
||||||
@@ -485,6 +505,10 @@
|
|||||||
<source>This value is not a valid country.</source>
|
<source>This value is not a valid country.</source>
|
||||||
<target>Deze waarde is geen geldig land.</target>
|
<target>Deze waarde is geen geldig land.</target>
|
||||||
</trans-unit>
|
</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.">
|
<trans-unit id="OeFZFII" resname="This value is not a valid currency.">
|
||||||
<source>This value is not a valid currency.</source>
|
<source>This value is not a valid currency.</source>
|
||||||
<target>Deze waarde is geen geldige valuta.</target>
|
<target>Deze waarde is geen geldige valuta.</target>
|
||||||
@@ -525,6 +549,10 @@
|
|||||||
<source>This value is not a valid week.</source>
|
<source>This value is not a valid week.</source>
|
||||||
<target>Deze waarde is geen geldige week.</target>
|
<target>Deze waarde is geen geldige week.</target>
|
||||||
</trans-unit>
|
</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.">
|
<trans-unit id="TZg2uqx" resname="This value is not valid.">
|
||||||
<source>This value is not valid.</source>
|
<source>This value is not valid.</source>
|
||||||
<target>Deze waarde is niet geldig.</target>
|
<target>Deze waarde is niet geldig.</target>
|
||||||
|
|||||||
Reference in New Issue
Block a user