mirror of
https://github.com/MarijnDoeve/TijdVoorDeTest.git
synced 2026-07-05 07:00:14 +02:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
052268e042
|
|||
|
482ca8be7e
|
|||
|
f6988f4d77
|
|||
|
4c242b0980
|
|||
|
46e24c5035
|
|||
|
5d51885c82
|
|||
|
8304d8680b
|
|||
|
f05f88bcc5
|
|||
|
c34c25dff7
|
|||
| d1d1eb3a24 | |||
| 5ea7a636b8 | |||
| d37136be93 | |||
| 212401a97f | |||
| 7c74574d3c | |||
| b1f84d441f | |||
| 8c72b1b217 | |||
| 806cff8c0f | |||
| 815e7b17be | |||
| 764f59e6a7 | |||
| 404c0dcc26 |
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: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
+175
-40
@@ -17,49 +17,132 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build Dev Image
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
if: "!startsWith(github.ref, 'refs/tags/')"
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Lint Dockerfile
|
||||
uses: hadolint/hadolint-action@2332a7b74a6de0dda2e2221d575162eba76ba5e5 # v3.3.0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
|
||||
- name: Build Docker images
|
||||
uses: docker/bake-action@d3418bd7d0e9324001bca92fa8ba175ea7e6dc9b # v7
|
||||
with:
|
||||
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
|
||||
files: |
|
||||
compose.yaml
|
||||
compose.override.yaml
|
||||
set: |
|
||||
*.cache-from=type=gha,scope=${{github.ref}}-devbuild
|
||||
- name: Start services
|
||||
run: docker compose up php database --wait --no-build
|
||||
- name: Warm up dev cache
|
||||
run: docker compose exec -T php bin/console cache:warmup --env=dev
|
||||
- name: Lint Twig templates
|
||||
id: twig_lint
|
||||
continue-on-error: true
|
||||
run: docker compose exec -T php bin/console lint:twig --format=github templates
|
||||
- name: Coding Style
|
||||
id: cs
|
||||
continue-on-error: true
|
||||
run: docker compose exec -T php vendor/bin/php-cs-fixer check --diff --show-progress=none
|
||||
- name: Twig Coding Style
|
||||
id: twig_cs
|
||||
continue-on-error: true
|
||||
run: docker compose exec -T php vendor/bin/twig-cs-fixer check
|
||||
- name: Static Analysis (PHPStan)
|
||||
id: phpstan
|
||||
continue-on-error: true
|
||||
run: docker compose exec -T php vendor/bin/phpstan analyse --no-progress --no-ansi --error-format=github
|
||||
- name: Rector
|
||||
id: rector
|
||||
continue-on-error: true
|
||||
run: docker compose exec -T php vendor/bin/rector process --dry-run --no-progress-bar --output-format=github
|
||||
- name: Check HTTP reachability
|
||||
run: curl -v --fail-with-body http://localhost
|
||||
- name: Assert all checks passed
|
||||
if: always()
|
||||
run: |
|
||||
failed=0
|
||||
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:
|
||||
name: Tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
needs: build
|
||||
if: "!startsWith(github.ref, 'refs/tags/')"
|
||||
permissions:
|
||||
checks: write
|
||||
pull-requests: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Lint Dockerfile
|
||||
uses: hadolint/hadolint-action@v3.1.0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Build Docker images
|
||||
uses: docker/bake-action@v5
|
||||
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:
|
||||
pull: true
|
||||
load: true
|
||||
files: |
|
||||
compose.yaml
|
||||
compose.override.yaml
|
||||
set: |
|
||||
*.cache-from=type=gha,scope=${{github.ref}}
|
||||
*.cache-from=type=gha,scope=refs/heads/main
|
||||
*.cache-to=type=gha,scope=${{github.ref}},mode=max
|
||||
*.cache-from=type=gha,scope=${{github.ref}}-devbuild
|
||||
- name: Start services
|
||||
run: docker compose up php database --wait --no-build
|
||||
- name: Lint Twig templates
|
||||
run: docker compose exec -T php bin/console lint:twig --format=github templates
|
||||
- name: Coding Style
|
||||
run: docker compose exec -T php vendor/bin/php-cs-fixer check --diff --show-progress=none
|
||||
- name: Twig Coding Style
|
||||
run: docker compose exec -T php vendor/bin/twig-cs-fixer check
|
||||
- name: Static Analysis (PHPStan)
|
||||
run: docker compose exec -T php vendor/bin/phpstan analyse --no-progress --no-ansi --error-format=github
|
||||
- name: Rector
|
||||
run: docker compose exec -T php vendor/bin/rector process --dry-run --no-progress-bar --output-format=github
|
||||
- name: Check HTTP reachability
|
||||
run: curl -v --fail-with-body http://localhost
|
||||
- name: Check Mercure reachability
|
||||
if: false
|
||||
run: curl -vkI --fail-with-body https://localhost/.well-known/mercure?topic=test
|
||||
- name: Build SCSS
|
||||
run: docker compose exec -T php bin/console sass:build
|
||||
- name: Create test database
|
||||
run: docker compose exec -T php bin/console -e test doctrine:database:create
|
||||
- name: Run migrations
|
||||
@@ -70,31 +153,78 @@ jobs:
|
||||
run: docker compose exec -T php vendor/bin/phpunit --log-junit var/phpunit/junit.xml
|
||||
- name: Publish PHPUnit test results
|
||||
if: always()
|
||||
uses: mikepenz/action-junit-report@v5
|
||||
uses: mikepenz/action-junit-report@d9f48fc87bc235f7e214acf696ca5abc0a986f16 # v6
|
||||
with:
|
||||
report_paths: var/phpunit/junit.xml
|
||||
check_name: PHPUnit
|
||||
- name: Doctrine Schema Validator
|
||||
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:
|
||||
name: Build and deploy to ${{ startsWith(github.ref, 'refs/tags/') && 'production' || (github.ref == 'refs/heads/main' && 'acceptance' || '') }}
|
||||
name: Build and Deploy
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
environment:
|
||||
name: ${{ startsWith(github.ref, 'refs/tags/') && 'production' || (github.ref == 'refs/heads/main' && 'acceptance' || '') }}
|
||||
name: ${{ startsWith(github.ref, 'refs/tags/') && 'production' || 'acceptance' }}
|
||||
url: ${{ vars.URL }}
|
||||
needs: tests
|
||||
needs: [quality, tests, verify-prior-run]
|
||||
runs-on: ubuntu-latest
|
||||
if: (github.ref == 'refs/heads/main' && false) || startsWith(github.ref, 'refs/tags/')
|
||||
timeout-minutes: 15
|
||||
if: >-
|
||||
always() && !cancelled() && !failure() &&
|
||||
((github.ref == 'refs/heads/main' && false) || startsWith(github.ref, 'refs/tags/'))
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- 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
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -106,20 +236,23 @@ jobs:
|
||||
REPO_LOWER=$(echo "${{ github.repository }}" | tr '[:upper:]' '[:lower:]')
|
||||
if [[ "${{ github.ref }}" == refs/tags/* ]]; then
|
||||
TAG="${GITHUB_REF#refs/tags/}"
|
||||
SENTRY_VERSION="${TAG#v}"
|
||||
{
|
||||
echo "tag=$TAG"
|
||||
echo "sentry_version=$SENTRY_VERSION"
|
||||
echo "full_name=ghcr.io/${REPO_LOWER}:$TAG"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
SHORT_SHA=$(git rev-parse --short HEAD)
|
||||
{
|
||||
echo "tag=$SHORT_SHA"
|
||||
echo "sentry_version=$SHORT_SHA"
|
||||
echo "full_name=ghcr.io/${REPO_LOWER}:$SHORT_SHA"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Build and Push Docker images
|
||||
uses: docker/bake-action@v5
|
||||
uses: docker/bake-action@d3418bd7d0e9324001bca92fa8ba175ea7e6dc9b # v7
|
||||
with:
|
||||
pull: true
|
||||
push: true
|
||||
@@ -133,18 +266,20 @@ jobs:
|
||||
*.tags=${{ steps.meta.outputs.full_name }}
|
||||
|
||||
- name: Create Sentry release
|
||||
uses: getsentry/action-release@v3
|
||||
uses: getsentry/action-release@ff07929a6537bac57790c3451cf4d364aca38528 # v3
|
||||
env:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||
with:
|
||||
release: ${{steps.meta.outputs.tag}}
|
||||
environment: ${{ startsWith(github.ref, 'refs/tags/') && 'production' || (github.ref == 'refs/heads/main' && 'acceptance' || '') }}
|
||||
release: ${{steps.meta.outputs.sentry_version}}
|
||||
environment: ${{ startsWith(github.ref, 'refs/tags/') && 'production' || 'acceptance' }}
|
||||
|
||||
- name: Trigger Portainer Deployment
|
||||
shell: bash
|
||||
env:
|
||||
PORTAINER_WEBHOOK: ${{secrets.PORTAINER_WEBHOOK}}
|
||||
IMAGE_TAG: ${{steps.meta.outputs.tag}}
|
||||
SENTRY_RELEASE: ${{steps.meta.outputs.sentry_version}}
|
||||
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:
|
||||
- name: Dependabot metadata
|
||||
id: metadata
|
||||
uses: dependabot/fetch-metadata@v2
|
||||
uses: dependabot/fetch-metadata@v3
|
||||
with:
|
||||
github-token: "${{ secrets.GITHUB_TOKEN }}"
|
||||
- name: Enable auto-merge for Dependabot PRs
|
||||
|
||||
+2
-4
@@ -72,10 +72,8 @@ Icon
|
||||
/.idea/
|
||||
/.vscode/
|
||||
|
||||
# Junie
|
||||
!/.junie/
|
||||
/.junie/memory/
|
||||
/.junie/plans/
|
||||
# Claude Code
|
||||
/.claude/settings.local.json
|
||||
|
||||
# Windows
|
||||
Thumbs.db
|
||||
|
||||
Generated
+2
@@ -169,6 +169,8 @@
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/git-state" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/ergebnis/agent-detector" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/polyfill-deepclone" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/file-filter" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/object-mapper" />
|
||||
</content>
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
|
||||
Generated
+107
-107
@@ -41,90 +41,134 @@
|
||||
</component>
|
||||
<component name="PhpIncludePathManager">
|
||||
<include_path>
|
||||
<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/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/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/phpunit/php-text-template" />
|
||||
<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/framework-bundle" />
|
||||
<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/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/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/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/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/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/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/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/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/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/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/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/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/inflector" />
|
||||
<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/orm" />
|
||||
<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/doctrine/migrations" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/object-reflector" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/object-enumerator" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/global-state" />
|
||||
@@ -135,76 +179,32 @@
|
||||
<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/doctrine/deprecations" />
|
||||
<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/phpdocumentor/type-resolver" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpdocumentor/reflection-common" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfonycasts/sass-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfonycasts/verify-email-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/thecodingmachine/safe" />
|
||||
<path value="$PROJECT_DIR$/vendor/martin-georgiev/postgresql-for-doctrine" />
|
||||
<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/sebastian/environment" />
|
||||
<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/friendsofphp/php-cs-fixer" />
|
||||
<path value="$PROJECT_DIR$/vendor/guzzlehttp/psr7" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/file-filter" />
|
||||
<path value="$PROJECT_DIR$/vendor/webmozart/assert" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/exporter" />
|
||||
<path value="$PROJECT_DIR$/vendor/thecodingmachine/phpstan-safe-rule" />
|
||||
<path value="$PROJECT_DIR$/vendor/composer" />
|
||||
<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/psr/event-dispatcher" />
|
||||
<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/psr/simple-cache" />
|
||||
<path value="$PROJECT_DIR$/vendor/psr/cache" />
|
||||
<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/react/event-loop" />
|
||||
<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/react/dns" />
|
||||
<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/dama/doctrine-test-bundle" />
|
||||
<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/twig/twig" />
|
||||
<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/phpstan/phpstan" />
|
||||
<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/phpstan/extension-installer" />
|
||||
<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/rector/rector" />
|
||||
<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/egulias/email-validator" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-php84" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-php85" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/git-state" />
|
||||
<path value="$PROJECT_DIR$/vendor/ergebnis/agent-detector" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-deepclone" />
|
||||
<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>
|
||||
</component>
|
||||
<component name="PhpInterpreters">
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
# Agent Guide: Tijd Voor De Test (Tvdt)
|
||||
|
||||
This document provides essential context and instructions for AI agents working on the **Tijd Voor De Test** project.
|
||||
|
||||
## Project Overview
|
||||
A web application for managing "Wie is de Mol?" style tests, including seasons, quizzes, candidates, and eliminations.
|
||||
|
||||
- **Namespace**: `Tvdt`
|
||||
- **PHP Version**: 8.5+
|
||||
- **Framework**: Symfony 8.0
|
||||
|
||||
## Tech Stack
|
||||
- **Server**: FrankenPHP (Caddy-based PHP server)
|
||||
- **Database**: PostgreSQL
|
||||
- **Frontend**: Symfony Asset Mapper (no Node.js/Webpack), Stimulus, Turbo
|
||||
- **Styling**: Sass (via `symfonycasts/sass-bundle`)
|
||||
- **Persistence**: Doctrine ORM 3.x
|
||||
|
||||
## Core Domain Entities
|
||||
- **Season**: Groups quizzes and candidates for a specific period.
|
||||
- **SeasonSettings**: Configuration for a season.
|
||||
- **Quiz**: A test within a season containing multiple questions.
|
||||
- **Question**: Questions belonging to a quiz.
|
||||
- **Answer**: Possible answers for a question.
|
||||
- **Candidate**: A participant in the season.
|
||||
- **QuizCandidate**: Represents a candidate's attempt at a specific quiz (tracking start/end time).
|
||||
- **GivenAnswer**: The specific answer a candidate selected during a quiz.
|
||||
- **Elimination**: Records of red/green screens and forced results.
|
||||
- **User**: Administrative accounts for managing the system.
|
||||
|
||||
## Development Workflow
|
||||
The project uses `just` as the primary task runner. Always prefer `just` commands over manual docker calls.
|
||||
|
||||
### Common Commands
|
||||
- `just up`: Start the environment.
|
||||
- `just down`: Stop the environment.
|
||||
- `just shell`: Enter the PHP container.
|
||||
- `just migrate`: Run database migrations.
|
||||
- `just fixtures`: Load development fixtures.
|
||||
- `just fix-cs`: Run `php-cs-fixer` and `twig-cs-fixer`.
|
||||
- `just phpstan`: Run static analysis.
|
||||
- `just rector`: Run Rector for automated refactorings.
|
||||
- `just reload-tests`: Reset the test database and load test fixtures.
|
||||
|
||||
## Coding Standards
|
||||
- **PSR-12**: Follow standard PHP coding styles.
|
||||
- **Strict Typing**: Use strict types in all PHP files.
|
||||
- **Doctrine ORM 3**: Be aware of ORM 3 changes (e.g., lazy loading behavior, attribute-based mapping).
|
||||
- **Symfony 8**: Use modern Symfony features (Attributes, Type-hinting).
|
||||
- **Safe Functions**: Use `thecodingmachine/safe` for standard PHP functions that throw exceptions instead of returning false.
|
||||
|
||||
## Testing
|
||||
- **Framework**: PHPUnit
|
||||
- **Bundle**: `dama/doctrine-test-bundle` is used to wrap tests in transactions.
|
||||
- **Location**: `tests/` directory mirroring `src/`.
|
||||
- **Execution**: Run via `bin/phpunit` inside the container or `just reload-tests` to prepare the environment.
|
||||
|
||||
## Frontend Development
|
||||
- JavaScript is managed via **Import Maps**.
|
||||
- Stimulus controllers are located in `assets/controllers/`.
|
||||
- CSS/Sass is in `assets/styles/`.
|
||||
- Assets are compiled on-the-fly or mapped; do not look for a `node_modules` folder.
|
||||
|
||||
## Key Components
|
||||
### Controllers
|
||||
- **Backoffice**: Located in `src/Controller/Backoffice`, handles season and quiz management.
|
||||
- **Quiz**: `src/Controller/QuizController` handles the candidate-facing side of quizzes.
|
||||
- **Elimination**: `src/Controller/EliminationController` handles elimination screens.
|
||||
|
||||
### Services
|
||||
- **QuizSpreadsheetService**: Handles importing quizzes from XLSX files.
|
||||
|
||||
### Base Classes & Enums
|
||||
- **AbstractController**: Base class for all controllers, containing common regexes and flash helpers.
|
||||
- **FlashType Enum**: Used for consistent flash messaging (`FlashType::Success`, `FlashType::Danger`, etc.).
|
||||
|
||||
## Key Files
|
||||
- `composer.json`: Dependency management.
|
||||
- `importmap.php`: JavaScript module mapping.
|
||||
- `Justfile`: Automation shortcuts.
|
||||
- `config/`: Application configuration.
|
||||
- `templates/`: Twig templates.
|
||||
@@ -0,0 +1,232 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
**Tijd voor de test** is a PHP/Symfony 8.1 application for managing quizzes in the style of **Wie is de Mol?** (WIDM) — a Dutch TV show where contestants try to identify a saboteur ("de Mol") among them. At the end of each episode, participants take a quiz about the Mol's identity and actions; the candidate with the least correct answers is eliminated. This app replicates that quiz format with:
|
||||
- Test creation with variable question counts
|
||||
- Season management with active test controls
|
||||
- Candidate answer tracking with automatic timing
|
||||
- Elimination tracking with joker adjustments
|
||||
- Backoffice management for quiz administration and statistics
|
||||
|
||||
Tech Stack:
|
||||
- **Framework**: Symfony 8.1
|
||||
- **PHP**: 8.5+
|
||||
- **Database**: PostgreSQL 16
|
||||
- **ORM**: Doctrine
|
||||
- **Server**: FrankenPHP with Caddy
|
||||
- **Container**: Docker Compose
|
||||
- **Frontend**: Twig templates with SASS (via asset mapper)
|
||||
- **Testing**: PHPUnit 13 with DAMA Doctrine test bundle
|
||||
|
||||
## Build & Development Commands
|
||||
|
||||
All commands assume Docker is running. The project uses a [Justfile](https://just.systems) as the primary interface.
|
||||
|
||||
### Essential Commands
|
||||
|
||||
```bash
|
||||
just up # Start Docker services (PHP, PostgreSQL)
|
||||
just stop # Stop services
|
||||
just down # Stop and remove containers/orphans
|
||||
just shell # Interactive shell inside the PHP container
|
||||
just shell-run # Shell in a fresh one-off container
|
||||
```
|
||||
|
||||
### Database
|
||||
|
||||
```bash
|
||||
just migrate # Run Doctrine migrations (starts services first)
|
||||
just fixtures # Load dev fixtures (truncates first)
|
||||
just reload-tests # Drop/recreate test DB, migrate, load test fixtures
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
```bash
|
||||
just test # Run full PHPUnit suite
|
||||
just test tests/Path/To/TestFile.php # Run a specific test file
|
||||
just test --coverage-html var/coverage # Generate HTML coverage report
|
||||
```
|
||||
|
||||
### Code Quality & Linting
|
||||
|
||||
```bash
|
||||
just fix-cs # Auto-fix PHP-CS-Fixer + Twig-CS-Fixer
|
||||
just phpstan # PHPStan static analysis (level 9)
|
||||
just phpstan --no-progress # Without progress output
|
||||
just rector # Apply Rector modernizations
|
||||
just rector --dry-run # Preview Rector changes
|
||||
```
|
||||
|
||||
### Other
|
||||
|
||||
```bash
|
||||
just translations # Extract/update nl translation strings
|
||||
just clean # Nuke containers (volumes) + all generated files (prompts for confirmation)
|
||||
just trust-cert # Trust the local Caddy TLS certificate (macOS)
|
||||
just exec <cmd> # Run any command inside the PHP container
|
||||
```
|
||||
|
||||
All code quality checks run in CI/CD (.github/workflows/ci.yml) and should pass before merging.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
Controller/ # HTTP request handlers (attribute-routed)
|
||||
Backoffice/ # Admin panel controllers
|
||||
Entity/ # Doctrine ORM entities
|
||||
Repository/ # Database queries
|
||||
Service/ # Business logic
|
||||
Command/ # CLI commands
|
||||
Form/ # Symfony form types
|
||||
Dto/ # Data transfer objects
|
||||
Enum/ # Enumerations (FlashType, etc.)
|
||||
Exception/ # Custom exceptions
|
||||
Factory/ # Object factories
|
||||
Helpers/ # Utility functions
|
||||
Security/ # Auth and voter classes
|
||||
Voter/ # Authorization voters
|
||||
DataFixtures/ # Test data loaders
|
||||
|
||||
config/
|
||||
packages/ # Symfony bundle configurations
|
||||
routes/ # Route definitions
|
||||
services.yaml # Service container configuration
|
||||
routes.yaml # Main route entry point
|
||||
|
||||
templates/
|
||||
backoffice/ # Admin UI templates
|
||||
quiz/ # Public quiz UI templates
|
||||
base.html.twig # Main layout
|
||||
|
||||
tests/
|
||||
Command/ # Command tests
|
||||
Controller/ # Controller/integration tests
|
||||
Repository/ # Repository tests
|
||||
Security/ # Auth tests
|
||||
Helpers/ # Utility tests
|
||||
bootstrap.php # PHPUnit bootstrap with test container setup
|
||||
```
|
||||
|
||||
## Core Domain Entities
|
||||
|
||||
- **Season**: Groups quizzes and candidates for a specific period, with a linked `SeasonSettings`.
|
||||
- **Quiz**: A test within a season containing multiple `Question`s, each with multiple `Answer`s.
|
||||
- **Candidate**: A participant in the season.
|
||||
- **QuizCandidate**: Represents a candidate's attempt at a specific quiz (tracks start/end time).
|
||||
- **GivenAnswer**: The specific answer a candidate selected during a quiz.
|
||||
- **Elimination**: Records red/green screens and forced results with joker adjustments.
|
||||
- **User**: Administrative accounts for managing the system.
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
### Routing
|
||||
- Routes are **attribute-based** (PHP 8 attributes in controller methods)
|
||||
- Configured in `config/routes/attributes.yaml` for automatic discovery
|
||||
- Main entry point: `config/routes.yaml`
|
||||
|
||||
### Service Container & Dependency Injection
|
||||
- Services in `src/` are automatically registered via PSR-4 namespace `Tvdt\`
|
||||
- Exclusions: Entity, DependencyInjection, Kernel classes
|
||||
- Autowiring and autoconfiguration enabled by default
|
||||
- Service definitions in `config/services.yaml`
|
||||
|
||||
### Database & Migrations
|
||||
- PostgreSQL-based with Doctrine ORM
|
||||
- Migrations in `migrations/` at project root, namespace `DoctrineMigrations` (intentionally not autoloaded); generate with `bin/console make:migration`
|
||||
- Test fixtures in `src/DataFixtures/` (loaded with `--group=test`)
|
||||
- Test database configured separately via `.env.test`
|
||||
|
||||
### Testing Infrastructure
|
||||
- **PHPUnit 13** with DAMA Doctrine Test Bundle for transaction rollback
|
||||
- Bootstrap: `tests/bootstrap.php` loads env vars and autoloader; `tests/symfony-container.php` boots the test kernel/container (used by Rector)
|
||||
- Symfony test utilities (BrowserKit, CSS selectors) available
|
||||
- Coverage excluded from: `src/DataFixtures/`
|
||||
- Test environment: `APP_ENV=test` (set in phpunit.dist.xml)
|
||||
|
||||
### Code Style & Standards
|
||||
- **PHP-CS-Fixer**: Symfony ruleset + risky rules enabled
|
||||
- Strict types declaration required
|
||||
- Trailing commas in multiline structures
|
||||
- No else-only blocks
|
||||
- **Rector**: Aggressive modernization with all attribute sets + prepared sets (dead code, code quality, Doctrine, Symfony, PHPUnit)
|
||||
- **PHPStan**: Level 8 with extensions for Doctrine and Symfony
|
||||
- **Twig-CS-Fixer**: Template style enforcement
|
||||
- **Safe functions**: Use `thecodingmachine/safe` wrappers for standard PHP functions that return `false` on failure — they throw exceptions instead
|
||||
|
||||
### Environment Configuration
|
||||
- `.env` - Local development defaults (uncommitted in .env.local)
|
||||
- `.env.dev` - Development overrides
|
||||
- `.env.test` - Test environment configuration
|
||||
- Production uses `composer dump-env prod` for compiled configuration
|
||||
- Key variables:
|
||||
- `APP_ENV` - Environment (dev/test/prod)
|
||||
- `DATABASE_URL` - PostgreSQL connection string
|
||||
- `MAILER_SENDER` - From address for emails
|
||||
|
||||
### Frontend Build
|
||||
- Asset mapper (no Node.js/Webpack) for JS/CSS bundling; JS modules declared in `importmap.php`
|
||||
- **Stimulus** controllers in `assets/controllers/`, **Turbo** for SPA-like navigation
|
||||
- Sass sources in `assets/styles/`, compiled via `bin/console sass:build`
|
||||
- Production: Assets precompiled during Docker build
|
||||
- Development: Watch mode enabled in FrankenPHP container
|
||||
|
||||
## CI/CD Pipeline
|
||||
|
||||
GitHub Actions workflow (`.github/workflows/ci.yml`):
|
||||
|
||||
1. **Linting**: Dockerfile (hadolint), Twig templates
|
||||
2. **Code Quality**:
|
||||
- PHP-CS-Fixer style check
|
||||
- Twig-CS-Fixer style check
|
||||
- PHPStan static analysis
|
||||
- Rector dry-run
|
||||
3. **Integration Tests**:
|
||||
- Docker image build and start services
|
||||
- Database creation and migration
|
||||
- Fixture loading
|
||||
- Full PHPUnit test suite with JUnit XML output
|
||||
- Doctrine schema validation
|
||||
4. **Build & Deploy** (on tags or main, disabled currently):
|
||||
- Docker image push to GitHub Container Registry
|
||||
- Sentry release creation
|
||||
- Portainer webhook trigger for production deployment
|
||||
|
||||
Runs on all pushes to main and pull requests. Concurrency cancels old runs on new commits.
|
||||
|
||||
## Important Files & Conventions
|
||||
|
||||
- **Kernel**: `src/Kernel.php` - Symfony kernel class
|
||||
- **AbstractController**: Base class for all controllers — defines route parameter regexes (`SEASON_CODE_REGEX`, `CANDIDATE_HASH_REGEX`) and flash helpers
|
||||
- **Flash Messages**: Use `FlashType` enum instead of string literals
|
||||
- **QuizSpreadsheetService**: Handles importing quizzes from XLSX files
|
||||
- **Rector container**: `tests/symfony-container.php` — boots a test kernel so Rector can resolve Symfony service types
|
||||
- **.gitignore**: Excludes var/, vendor/, .env.local, .phpunit.cache
|
||||
- **Dockerfile**: Multi-stage build with dev/prod separation, FrankenPHP-based
|
||||
- **Docker Compose**: PHP service with Caddy, PostgreSQL database, persistent volumes
|
||||
|
||||
## Security & Authorization
|
||||
|
||||
- Doctrine extensions enabled (timestamps, slugs, etc.)
|
||||
- Voter-based authorization in `src/Security/Voter/`
|
||||
- User entity with security encoding configured
|
||||
- CSRF protection enabled
|
||||
- Email verification available via SymfonyCasts bundle
|
||||
|
||||
## Composer Scripts
|
||||
|
||||
Auto-executed scripts on install/update:
|
||||
- `cache:clear` - Symfony cache clear
|
||||
- `assets:install` - Copy public assets
|
||||
- `importmap:install` - JS import map setup
|
||||
|
||||
## Notes for Future Work
|
||||
|
||||
- The backoffice elimination logic is in `Controller/Backoffice/PrepareEliminationController.php`
|
||||
- Quiz timing logic starts on candidate start click and stops on final answer selection
|
||||
- Background music feature noted but not yet implemented (requirements only)
|
||||
- Statistics functionality is marked TBD in README
|
||||
@@ -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:fixtures:load -n --group=test
|
||||
|
||||
install-hooks:
|
||||
git config core.hooksPath .githooks
|
||||
chmod +x .githooks/pre-commit
|
||||
@echo "Pre-commit hook installed."
|
||||
|
||||
trust-cert:
|
||||
sudo security add-trusted-cer -d \
|
||||
-r trustRoot \
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
.DEFAULT_GOAL := help
|
||||
|
||||
.PHONY: up
|
||||
up: ## Start application
|
||||
@docker compose up -d
|
||||
|
||||
stop: ## Stop application
|
||||
@docker compose stop
|
||||
|
||||
.PHONY: shell
|
||||
shell: ## Start a shell inside the container
|
||||
@docker compose exec php bash
|
||||
|
||||
.PHONY: help
|
||||
help:
|
||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-10s\033[0m %s\n", $$1, $$2}'
|
||||
@@ -1,39 +1,162 @@
|
||||
# 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
|
||||
|
||||
### Maken van de test
|
||||
- Docker
|
||||
- [Just](https://just.systems) (`brew install just`)
|
||||
|
||||
- WIDM-tests met een variabel aantal vragen.
|
||||
- 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
|
||||
## Local development
|
||||
|
||||
### 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
|
||||
ingevoerd zijn) kunnen jokers toegekend worden aan de test van kandidaat. Een
|
||||
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.
|
||||
The app is available at **https://localhost** (self-signed cert — run
|
||||
`just trust-cert` on macOS to trust it).
|
||||
|
||||
### 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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
.col-result-xs { width: 10%; }
|
||||
.col-result-sm { width: 15%; }
|
||||
.col-result-md { width: 20%; }
|
||||
|
||||
+87
-4
@@ -1,14 +1,97 @@
|
||||
html, body {
|
||||
html {
|
||||
height: 100%;
|
||||
background-image: url("../img/background.png");
|
||||
background-position: center center;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-color: black;
|
||||
color: white;
|
||||
|
||||
display: grid;
|
||||
align-items: center;
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
body {
|
||||
height: 100%;
|
||||
background-image: url("../img/background.png");
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-color: black;
|
||||
display: grid;
|
||||
align-content: center;
|
||||
}
|
||||
|
||||
.quiz-form-narrow {
|
||||
max-width: 320px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.quiz-content {
|
||||
width: fit-content;
|
||||
max-width: min(760px, 100%);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.quiz-answers-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, auto);
|
||||
column-gap: 2rem;
|
||||
|
||||
@media (max-width: 767px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.answer-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: white;
|
||||
font-size: 1.1rem;
|
||||
padding: 0.4rem 0;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
line-height: 1.3;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
background: radial-gradient(ellipse at 35% 30%, #6abf4b, #2d7a1f);
|
||||
border-radius: 4px;
|
||||
box-shadow: inset 0 1px 2px rgba(255, 255, 255, 0.25), 0 2px 6px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
color: #7ed45a;
|
||||
outline: none;
|
||||
|
||||
&::before {
|
||||
background: radial-gradient(ellipse at 35% 30%, #86d45f, #3d9c2a);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
input.btn-check:checked + label.answer-btn {
|
||||
color: #7ed45a;
|
||||
|
||||
&::before {
|
||||
background: radial-gradient(ellipse at 35% 30%, #86d45f, #3d9c2a);
|
||||
box-shadow: 0 0 10px rgba(106, 191, 75, 0.7), inset 0 1px 2px rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.quiz-topbar {
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
z-index: 1030;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.elimination-screen {
|
||||
|
||||
@@ -7,6 +7,7 @@ services:
|
||||
target: frankenphp_dev
|
||||
volumes:
|
||||
- ./:/app
|
||||
- ~/.composer/cache:/root/.composer/cache
|
||||
- ./frankenphp/Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- ./frankenphp/conf.d/20-app.dev.ini:/usr/local/etc/php/app.conf.d/20-app.dev.ini:ro
|
||||
- ./frankenphp/data:/data
|
||||
@@ -46,6 +47,8 @@ services:
|
||||
- sass:build
|
||||
- --watch
|
||||
- -v
|
||||
healthcheck:
|
||||
disable: true
|
||||
|
||||
###> symfony/mercure-bundle ###
|
||||
###< symfony/mercure-bundle ###
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ services:
|
||||
MAILER_DSN: ${MAILER_DSN}
|
||||
MAILER_SENDER: ${MAILER_SENDER}
|
||||
SENTRY_DSN: ${SENTRY_DSN}
|
||||
SENTRY_RELEASE: ${IMAGE_TAG}
|
||||
SENTRY_RELEASE: ${SENTRY_RELEASE}
|
||||
SENTRY_ENVIRONMENT: ${SENTRY_ENVIRONMENT}
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"symfony/form": "8.1.*",
|
||||
"symfony/framework-bundle": "8.1.*",
|
||||
"symfony/mailer": "8.1.*",
|
||||
"symfony/object-mapper": "8.1.*",
|
||||
"symfony/property-access": "8.1.*",
|
||||
"symfony/property-info": "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",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "f8e4107825dba89eaa38d067d47856ce",
|
||||
"content-hash": "7171824ca13f4df0801dfa5d7f58d6a0",
|
||||
"packages": [
|
||||
{
|
||||
"name": "composer/pcre",
|
||||
@@ -1839,16 +1839,16 @@
|
||||
},
|
||||
{
|
||||
"name": "martin-georgiev/postgresql-for-doctrine",
|
||||
"version": "v4.6.0",
|
||||
"version": "v4.7.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/martin-georgiev/postgresql-for-doctrine.git",
|
||||
"reference": "59841c7e53f8339b13bc0cb0ee9931b7b9bbb139"
|
||||
"reference": "23b5c2694083355ab87eaa913b43a0cddd8c64bb"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/martin-georgiev/postgresql-for-doctrine/zipball/59841c7e53f8339b13bc0cb0ee9931b7b9bbb139",
|
||||
"reference": "59841c7e53f8339b13bc0cb0ee9931b7b9bbb139",
|
||||
"url": "https://api.github.com/repos/martin-georgiev/postgresql-for-doctrine/zipball/23b5c2694083355ab87eaa913b43a0cddd8c64bb",
|
||||
"reference": "23b5c2694083355ab87eaa913b43a0cddd8c64bb",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -1863,13 +1863,13 @@
|
||||
"deptrac/deptrac": "^4.0",
|
||||
"doctrine/orm": "~2.14||~3.0",
|
||||
"ekino/phpstan-banned-code": "^3.2.0",
|
||||
"friendsofphp/php-cs-fixer": "^3.95.2",
|
||||
"phpstan/phpstan": "^2.1.55",
|
||||
"friendsofphp/php-cs-fixer": "^3.95.11",
|
||||
"phpstan/phpstan": "^2.2.2",
|
||||
"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",
|
||||
"phpunit/phpunit": "^10.5.63||^11.5",
|
||||
"rector/rector": "^2.4.4",
|
||||
"rector/rector": "^2.5.2",
|
||||
"symfony/cache": "^6.4||^7.0",
|
||||
"symfony/var-exporter": "^6.4||^7.0"
|
||||
},
|
||||
@@ -1952,7 +1952,7 @@
|
||||
],
|
||||
"support": {
|
||||
"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": [
|
||||
{
|
||||
@@ -1964,7 +1964,7 @@
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2026-05-29T19:11:20+00:00"
|
||||
"time": "2026-07-01T18:17:39+00:00"
|
||||
},
|
||||
{
|
||||
"name": "phpdocumentor/reflection-common",
|
||||
@@ -5337,6 +5337,79 @@
|
||||
],
|
||||
"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",
|
||||
"version": "v8.1.0",
|
||||
@@ -9287,16 +9360,16 @@
|
||||
},
|
||||
{
|
||||
"name": "friendsofphp/php-cs-fixer",
|
||||
"version": "v3.95.8",
|
||||
"version": "v3.95.11",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git",
|
||||
"reference": "4140023f552ff02346df9b1329742532166f677f"
|
||||
"reference": "35f98e1293283397824d7f349ce5afb8747c3cd5"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/4140023f552ff02346df9b1329742532166f677f",
|
||||
"reference": "4140023f552ff02346df9b1329742532166f677f",
|
||||
"url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/35f98e1293283397824d7f349ce5afb8747c3cd5",
|
||||
"reference": "35f98e1293283397824d7f349ce5afb8747c3cd5",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -9330,7 +9403,7 @@
|
||||
"require-dev": {
|
||||
"facile-it/paraunit": "^1.3.1 || ^2.11.0",
|
||||
"infection/infection": "^0.32.7",
|
||||
"justinrainbow/json-schema": "^6.9.0",
|
||||
"justinrainbow/json-schema": "^6.10.0",
|
||||
"keradus/cli-executor": "^2.3",
|
||||
"mikey179/vfsstream": "^1.6.12",
|
||||
"php-coveralls/php-coveralls": "^2.9.1",
|
||||
@@ -9380,7 +9453,7 @@
|
||||
],
|
||||
"support": {
|
||||
"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": [
|
||||
{
|
||||
@@ -9388,7 +9461,7 @@
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2026-06-16T09:52:26+00:00"
|
||||
"time": "2026-06-25T14:17:04+00:00"
|
||||
},
|
||||
{
|
||||
"name": "myclabs/deep-copy",
|
||||
@@ -9676,11 +9749,11 @@
|
||||
},
|
||||
{
|
||||
"name": "phpstan/phpstan",
|
||||
"version": "2.2.2",
|
||||
"version": "2.2.4",
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/e5cc34d491a90e79c216d824f60fe21fd4d93bd6",
|
||||
"reference": "e5cc34d491a90e79c216d824f60fe21fd4d93bd6",
|
||||
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/f0fe3fb03bb53ce68cc2416785b260e62226ec27",
|
||||
"reference": "f0fe3fb03bb53ce68cc2416785b260e62226ec27",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -9736,7 +9809,7 @@
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2026-06-05T09:00:01+00:00"
|
||||
"time": "2026-07-03T07:00:23+00:00"
|
||||
},
|
||||
{
|
||||
"name": "phpstan/phpstan-doctrine",
|
||||
@@ -9817,21 +9890,22 @@
|
||||
},
|
||||
{
|
||||
"name": "phpstan/phpstan-phpunit",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.17",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/phpstan/phpstan-phpunit.git",
|
||||
"reference": "6ab598e1bc106e6827fd346ae4a12b4a5d634c32"
|
||||
"reference": "c2f977551f0736d60467b3d754b2e0cf4e337b3f"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/phpstan/phpstan-phpunit/zipball/6ab598e1bc106e6827fd346ae4a12b4a5d634c32",
|
||||
"reference": "6ab598e1bc106e6827fd346ae4a12b4a5d634c32",
|
||||
"url": "https://api.github.com/repos/phpstan/phpstan-phpunit/zipball/c2f977551f0736d60467b3d754b2e0cf4e337b3f",
|
||||
"reference": "c2f977551f0736d60467b3d754b2e0cf4e337b3f",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"phar-io/version": "^3.2",
|
||||
"php": "^7.4 || ^8.0",
|
||||
"phpstan/phpstan": "^2.1.32"
|
||||
"phpstan/phpstan": "^2.2.3"
|
||||
},
|
||||
"conflict": {
|
||||
"phpunit/phpunit": "<7.0"
|
||||
@@ -9841,7 +9915,8 @@
|
||||
"php-parallel-lint/php-parallel-lint": "^1.2",
|
||||
"phpstan/phpstan-deprecation-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",
|
||||
"extra": {
|
||||
@@ -9867,9 +9942,9 @@
|
||||
],
|
||||
"support": {
|
||||
"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",
|
||||
@@ -10330,16 +10405,16 @@
|
||||
},
|
||||
{
|
||||
"name": "phpunit/phpunit",
|
||||
"version": "13.2.1",
|
||||
"version": "13.2.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/sebastianbergmann/phpunit.git",
|
||||
"reference": "60da0ff1e10a0f72ee18a24117ec3b613a346bba"
|
||||
"reference": "492c067e618de7b3c76105082c90f9d2833401b7"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/60da0ff1e10a0f72ee18a24117ec3b613a346bba",
|
||||
"reference": "60da0ff1e10a0f72ee18a24117ec3b613a346bba",
|
||||
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/492c067e618de7b3c76105082c90f9d2833401b7",
|
||||
"reference": "492c067e618de7b3c76105082c90f9d2833401b7",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -10410,7 +10485,7 @@
|
||||
"support": {
|
||||
"issues": "https://github.com/sebastianbergmann/phpunit/issues",
|
||||
"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": [
|
||||
{
|
||||
@@ -10418,7 +10493,7 @@
|
||||
"type": "other"
|
||||
}
|
||||
],
|
||||
"time": "2026-06-15T13:14:22+00:00"
|
||||
"time": "2026-06-29T13:36:29+00:00"
|
||||
},
|
||||
{
|
||||
"name": "react/cache",
|
||||
@@ -10948,16 +11023,16 @@
|
||||
},
|
||||
{
|
||||
"name": "rector/rector",
|
||||
"version": "2.4.6",
|
||||
"version": "2.5.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/rectorphp/rector.git",
|
||||
"reference": "9b9e5c76618e4d359f65b54ca2eabcad3d1761ee"
|
||||
"reference": "49ff6339174bdbdf50b0b35ecbcff14a05ac9e24"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/rectorphp/rector/zipball/9b9e5c76618e4d359f65b54ca2eabcad3d1761ee",
|
||||
"reference": "9b9e5c76618e4d359f65b54ca2eabcad3d1761ee",
|
||||
"url": "https://api.github.com/repos/rectorphp/rector/zipball/49ff6339174bdbdf50b0b35ecbcff14a05ac9e24",
|
||||
"reference": "49ff6339174bdbdf50b0b35ecbcff14a05ac9e24",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -10996,7 +11071,7 @@
|
||||
],
|
||||
"support": {
|
||||
"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": [
|
||||
{
|
||||
@@ -11004,7 +11079,7 @@
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2026-06-17T11:56:28+00:00"
|
||||
"time": "2026-06-22T11:39:33+00:00"
|
||||
},
|
||||
{
|
||||
"name": "sebastian/cli-parser",
|
||||
@@ -12167,16 +12242,16 @@
|
||||
},
|
||||
{
|
||||
"name": "symfony/browser-kit",
|
||||
"version": "v8.1.0",
|
||||
"version": "v8.1.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/browser-kit.git",
|
||||
"reference": "74e18e582cdda0eca35f7c74e1e48e62f0ede853"
|
||||
"reference": "f2ac86001ca9f487e8c6d0e11c8e33e6a9b8b2d5"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/browser-kit/zipball/74e18e582cdda0eca35f7c74e1e48e62f0ede853",
|
||||
"reference": "74e18e582cdda0eca35f7c74e1e48e62f0ede853",
|
||||
"url": "https://api.github.com/repos/symfony/browser-kit/zipball/f2ac86001ca9f487e8c6d0e11c8e33e6a9b8b2d5",
|
||||
"reference": "f2ac86001ca9f487e8c6d0e11c8e33e6a9b8b2d5",
|
||||
"shasum": ""
|
||||
},
|
||||
"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",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/browser-kit/tree/v8.1.0"
|
||||
"source": "https://github.com/symfony/browser-kit/tree/v8.1.1"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -12235,7 +12310,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-05-29T05:06:50+00:00"
|
||||
"time": "2026-06-09T10:54:51+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/css-selector",
|
||||
@@ -12308,16 +12383,16 @@
|
||||
},
|
||||
{
|
||||
"name": "symfony/dom-crawler",
|
||||
"version": "v8.1.0",
|
||||
"version": "v8.1.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/dom-crawler.git",
|
||||
"reference": "77ca351474ea018daba5f2e473cbf1b9b8e72ac6"
|
||||
"reference": "1dfadd25537c8fcb6752cce5775f24647d976bdc"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/dom-crawler/zipball/77ca351474ea018daba5f2e473cbf1b9b8e72ac6",
|
||||
"reference": "77ca351474ea018daba5f2e473cbf1b9b8e72ac6",
|
||||
"url": "https://api.github.com/repos/symfony/dom-crawler/zipball/1dfadd25537c8fcb6752cce5775f24647d976bdc",
|
||||
"reference": "1dfadd25537c8fcb6752cce5775f24647d976bdc",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -12354,7 +12429,7 @@
|
||||
"description": "Eases DOM navigation for HTML and XML documents",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/dom-crawler/tree/v8.1.0"
|
||||
"source": "https://github.com/symfony/dom-crawler/tree/v8.1.1"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -12374,7 +12449,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-05-29T05:06:50+00:00"
|
||||
"time": "2026-06-05T06:23:12+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/maker-bundle",
|
||||
@@ -12477,16 +12552,16 @@
|
||||
},
|
||||
{
|
||||
"name": "symfony/phpunit-bridge",
|
||||
"version": "v8.1.0",
|
||||
"version": "v8.1.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/phpunit-bridge.git",
|
||||
"reference": "1fed488f8033f2dece371e60a1c66f2add274916"
|
||||
"reference": "3e1c9a9167e07474ec115555b632f0ffadb0f94d"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/phpunit-bridge/zipball/1fed488f8033f2dece371e60a1c66f2add274916",
|
||||
"reference": "1fed488f8033f2dece371e60a1c66f2add274916",
|
||||
"url": "https://api.github.com/repos/symfony/phpunit-bridge/zipball/3e1c9a9167e07474ec115555b632f0ffadb0f94d",
|
||||
"reference": "3e1c9a9167e07474ec115555b632f0ffadb0f94d",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -12538,7 +12613,7 @@
|
||||
"testing"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/phpunit-bridge/tree/v8.1.0"
|
||||
"source": "https://github.com/symfony/phpunit-bridge/tree/v8.1.1"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -12558,20 +12633,20 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-05-29T05:06:50+00:00"
|
||||
"time": "2026-06-09T10:54:51+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/web-profiler-bundle",
|
||||
"version": "v8.1.0",
|
||||
"version": "v8.1.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/web-profiler-bundle.git",
|
||||
"reference": "f8ccea08797a511b85a698b0da40e1b9e6461086"
|
||||
"reference": "eb4cf71d8fc496d790ec85b1b684a7ac30d57a96"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/web-profiler-bundle/zipball/f8ccea08797a511b85a698b0da40e1b9e6461086",
|
||||
"reference": "f8ccea08797a511b85a698b0da40e1b9e6461086",
|
||||
"url": "https://api.github.com/repos/symfony/web-profiler-bundle/zipball/eb4cf71d8fc496d790ec85b1b684a7ac30d57a96",
|
||||
"reference": "eb4cf71d8fc496d790ec85b1b684a7ac30d57a96",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -12623,7 +12698,7 @@
|
||||
"dev"
|
||||
],
|
||||
"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": [
|
||||
{
|
||||
@@ -12643,27 +12718,27 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-05-29T05:06:50+00:00"
|
||||
"time": "2026-06-05T06:23:12+00:00"
|
||||
},
|
||||
{
|
||||
"name": "thecodingmachine/phpstan-safe-rule",
|
||||
"version": "v1.4.3",
|
||||
"version": "v1.4.7",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/thecodingmachine/phpstan-safe-rule.git",
|
||||
"reference": "5c804889253ce9498ef185e108e9f94b6023208e"
|
||||
"reference": "51fa2a35a270f683fc9ea53384a03e892b4d7b51"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/thecodingmachine/phpstan-safe-rule/zipball/5c804889253ce9498ef185e108e9f94b6023208e",
|
||||
"reference": "5c804889253ce9498ef185e108e9f94b6023208e",
|
||||
"url": "https://api.github.com/repos/thecodingmachine/phpstan-safe-rule/zipball/51fa2a35a270f683fc9ea53384a03e892b4d7b51",
|
||||
"reference": "51fa2a35a270f683fc9ea53384a03e892b4d7b51",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"nikic/php-parser": "^5",
|
||||
"php": "^8.1",
|
||||
"phpstan/phpstan": "^2.1.11",
|
||||
"thecodingmachine/safe": "^1.2 || ^2.0 || ^3.0"
|
||||
"phpstan/phpstan": "^2.2.2",
|
||||
"thecodingmachine/safe": "^3.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"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",
|
||||
"support": {
|
||||
"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",
|
||||
@@ -12755,16 +12830,16 @@
|
||||
},
|
||||
{
|
||||
"name": "vincentlanglet/twig-cs-fixer",
|
||||
"version": "4.0.1",
|
||||
"version": "4.0.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/VincentLanglet/Twig-CS-Fixer.git",
|
||||
"reference": "366f7cca494a6f95c5f410ae542aef9c164d329e"
|
||||
"reference": "1cb75618f7dd0f9bf51924aa6d3aa8c588f51d5a"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/VincentLanglet/Twig-CS-Fixer/zipball/366f7cca494a6f95c5f410ae542aef9c164d329e",
|
||||
"reference": "366f7cca494a6f95c5f410ae542aef9c164d329e",
|
||||
"url": "https://api.github.com/repos/VincentLanglet/Twig-CS-Fixer/zipball/1cb75618f7dd0f9bf51924aa6d3aa8c588f51d5a",
|
||||
"reference": "1cb75618f7dd0f9bf51924aa6d3aa8c588f51d5a",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -12820,7 +12895,7 @@
|
||||
"homepage": "https://github.com/VincentLanglet/Twig-CS-Fixer",
|
||||
"support": {
|
||||
"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": [
|
||||
{
|
||||
@@ -12828,7 +12903,7 @@
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2026-06-18T15:31:27+00:00"
|
||||
"time": "2026-06-29T15:22:14+00:00"
|
||||
}
|
||||
],
|
||||
"aliases": [],
|
||||
|
||||
@@ -6,7 +6,6 @@ when@dev:
|
||||
framework:
|
||||
profiler:
|
||||
only_exceptions: false
|
||||
collect_serializer_data: true
|
||||
|
||||
when@test:
|
||||
web_profiler:
|
||||
|
||||
Generated
+4
-4
@@ -127,7 +127,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* }
|
||||
* @psalm-type ServicesConfig = array{
|
||||
* _defaults?: DefaultsType,
|
||||
* _instanceof?: InstanceofType,
|
||||
* _instanceof?: array<class-string, InstanceofType>,
|
||||
* ...<string, DefinitionType|AliasType|PrototypeType|StackType|ArgumentsType|null>
|
||||
* }
|
||||
* @psalm-type ExtensionType = array<string, mixed>
|
||||
@@ -727,7 +727,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* servicename?: scalar|Param|null, // Overrules dbname parameter if given and used as SERVICE_NAME or SID connection parameter for Oracle depending on the service parameter.
|
||||
* sessionMode?: scalar|Param|null, // The session mode to use for the oci8 driver
|
||||
* server?: scalar|Param|null, // The name of a running database server to connect to for SQL Anywhere.
|
||||
* default_dbname?: scalar|Param|null, // Override the default database (postgres) to connect to for PostgreSQL connexion.
|
||||
* default_dbname?: scalar|Param|null, // Override the default database (postgres) to connect to for PostgreSQL connection.
|
||||
* sslmode?: scalar|Param|null, // Determines whether or with what priority a SSL TCP/IP connection will be negotiated with the server for PostgreSQL.
|
||||
* sslrootcert?: scalar|Param|null, // The name of a file containing SSL certificate authority (CA) certificate(s). If the file exists, the server's certificate will be verified to be signed by one of these authorities.
|
||||
* sslcert?: scalar|Param|null, // The path to the SSL client certificate file for PostgreSQL.
|
||||
@@ -773,7 +773,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* servicename?: scalar|Param|null, // Overrules dbname parameter if given and used as SERVICE_NAME or SID connection parameter for Oracle depending on the service parameter.
|
||||
* sessionMode?: scalar|Param|null, // The session mode to use for the oci8 driver
|
||||
* server?: scalar|Param|null, // The name of a running database server to connect to for SQL Anywhere.
|
||||
* default_dbname?: scalar|Param|null, // Override the default database (postgres) to connect to for PostgreSQL connexion.
|
||||
* default_dbname?: scalar|Param|null, // Override the default database (postgres) to connect to for PostgreSQL connection.
|
||||
* sslmode?: scalar|Param|null, // Determines whether or with what priority a SSL TCP/IP connection will be negotiated with the server for PostgreSQL.
|
||||
* sslrootcert?: scalar|Param|null, // The name of a file containing SSL certificate authority (CA) certificate(s). If the file exists, the server's certificate will be verified to be signed by one of these authorities.
|
||||
* sslcert?: scalar|Param|null, // The path to the SSL client certificate file for PostgreSQL.
|
||||
@@ -852,7 +852,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* lock_path?: scalar|Param|null, // Default: "%kernel.cache_dir%/doctrine/orm/slc/filelock"
|
||||
* lock_lifetime?: scalar|Param|null, // Default: 60
|
||||
* type?: scalar|Param|null, // Default: "default"
|
||||
* lifetime?: scalar|Param|null, // Default: 0
|
||||
* lifetime?: scalar|Param|null, // Default: null
|
||||
* service?: scalar|Param|null,
|
||||
* name?: scalar|Param|null,
|
||||
* }>,
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -8,7 +8,7 @@
|
||||
failOnNotice="true"
|
||||
failOnWarning="true"
|
||||
bootstrap="tests/bootstrap.php"
|
||||
cacheDirectory=".phpunit.cache"
|
||||
cacheDirectory="/tmp/phpunit.cache"
|
||||
>
|
||||
<php>
|
||||
<ini name="display_errors" value="1" />
|
||||
|
||||
+4
-1
@@ -3,6 +3,7 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
use Rector\Config\RectorConfig;
|
||||
use Rector\PHPUnit\CodeQuality\Rector\Class_\AddSeeTestAnnotationRector;
|
||||
use Rector\Symfony\Bridge\Symfony\Routing\SymfonyRoutesProvider;
|
||||
use Rector\Symfony\Contract\Bridge\Symfony\Routing\SymfonyRoutesProviderInterface;
|
||||
|
||||
@@ -13,7 +14,7 @@ return RectorConfig::configure()
|
||||
__DIR__.'/src',
|
||||
__DIR__.'/tests',
|
||||
])
|
||||
->withSkip([__DIR__.'/config/reference.php'])
|
||||
->withSkipPath(__DIR__.'/config/reference.php')
|
||||
->withSymfonyContainerXml(__DIR__.'/var/cache/dev/Tvdt_KernelDevDebugContainer.xml')
|
||||
->withSymfonyContainerPhp(__DIR__.'/tests/symfony-container.php')
|
||||
->registerService(SymfonyRoutesProvider::class, SymfonyRoutesProviderInterface::class)
|
||||
@@ -34,4 +35,6 @@ return RectorConfig::configure()
|
||||
)
|
||||
->withAttributesSets(all: true)
|
||||
->withComposerBased(twig: true, doctrine: true, phpunit: true, symfony: true)
|
||||
->withSkip([AddSeeTestAnnotationRector::class])
|
||||
|
||||
;
|
||||
|
||||
@@ -6,17 +6,21 @@ namespace Tvdt\Controller\Backoffice;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpFoundation\HeaderUtils;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
use Symfony\Component\HttpKernel\Attribute\AsController;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Routing\Requirement\Requirement;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
use Tvdt\Controller\AbstractController;
|
||||
use Tvdt\Entity\Quiz;
|
||||
use Tvdt\Entity\Season;
|
||||
use Tvdt\Entity\User;
|
||||
use Tvdt\Form\CreateSeasonFormType;
|
||||
use Tvdt\Repository\SeasonRepository;
|
||||
use Tvdt\Security\Voter\SeasonVoter;
|
||||
use Tvdt\Service\QuizSpreadsheetService;
|
||||
|
||||
#[AsController]
|
||||
@@ -78,4 +82,20 @@ final class BackofficeController extends AbstractController
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
#[IsGranted(SeasonVoter::EDIT, subject: 'quiz')]
|
||||
#[Route(
|
||||
'/backoffice/quiz/{quiz}/export',
|
||||
name: 'tvdt_backoffice_quiz_export',
|
||||
requirements: ['quiz' => Requirement::UUID],
|
||||
methods: ['GET'],
|
||||
)]
|
||||
public function exportQuiz(Quiz $quiz): StreamedResponse
|
||||
{
|
||||
$response = new StreamedResponse($this->excel->quizToXlsx($quiz));
|
||||
$response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||
$response->headers->set('Content-Disposition', HeaderUtils::makeDisposition(HeaderUtils::DISPOSITION_ATTACHMENT, $quiz->name.'.xlsx'));
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
<?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);
|
||||
|
||||
$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->hasStartedCandidates()) {
|
||||
$this->addFlash(FlashType::Danger, $this->translator->trans('This quiz has already been filled in and can no longer be altered'));
|
||||
|
||||
return $this->redirectToRoute('tvdt_backoffice_question_bank', ['seasonCode' => $season->seasonCode]);
|
||||
}
|
||||
|
||||
$this->questionBankService->syncToQuiz($bankQuestion, $usage);
|
||||
$this->addFlash(FlashType::Success, $this->translator->trans('Question synced to quiz %quiz%', ['%quiz%' => $usage->quiz->name]));
|
||||
|
||||
return $this->redirectToRoute('tvdt_backoffice_question_bank', ['seasonCode' => $season->seasonCode]);
|
||||
}
|
||||
|
||||
private function assertSameSeason(Season $season, Season $subjectSeason): void
|
||||
{
|
||||
if ($season !== $subjectSeason) {
|
||||
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 = [];
|
||||
foreach ($bankQuestion->usages as $usage) {
|
||||
if (!$usage->quiz->isFinalized()) {
|
||||
$this->questionBankService->syncToQuiz($bankQuestion, $usage);
|
||||
} elseif (!$usage->quiz->hasStartedCandidates()) {
|
||||
$pendingNames[] = $usage->quiz->name;
|
||||
}
|
||||
}
|
||||
|
||||
if ([] !== $pendingNames) {
|
||||
$this->addFlash(
|
||||
FlashType::Warning,
|
||||
$this->translator->trans(
|
||||
'The question was not synced to finalized quiz(zes): %quizzes%. Use the Sync button to update them.',
|
||||
['%quizzes%' => implode(', ', $pendingNames)],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace Tvdt\Controller\Backoffice;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Safe\DateTimeImmutable;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
@@ -22,6 +23,7 @@ use Tvdt\Entity\Question;
|
||||
use Tvdt\Entity\Quiz;
|
||||
use Tvdt\Entity\QuizCandidate;
|
||||
use Tvdt\Entity\Season;
|
||||
use Tvdt\Enum\FlashType;
|
||||
use Tvdt\Exception\ErrorClearingQuizException;
|
||||
use Tvdt\Repository\QuizCandidateRepository;
|
||||
use Tvdt\Repository\QuizRepository;
|
||||
@@ -154,7 +156,13 @@ class QuizController extends AbstractController
|
||||
public function answerMapping(Season $season, Quiz $quiz): Response
|
||||
{
|
||||
$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();
|
||||
\assert($firstQuestion instanceof Question);
|
||||
|
||||
@@ -233,7 +241,7 @@ class QuizController extends AbstractController
|
||||
|
||||
$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', [
|
||||
'seasonCode' => $season->seasonCode,
|
||||
@@ -250,13 +258,28 @@ class QuizController extends AbstractController
|
||||
requirements: ['seasonCode' => self::SEASON_CODE_REGEX, 'quiz' => Requirement::UUID.'|null'],
|
||||
methods: ['POST'],
|
||||
)]
|
||||
public function enableQuiz(Season $season, ?Quiz $quiz): RedirectResponse
|
||||
public function enableQuiz(Season $season, ?Quiz $quiz, Request $request): RedirectResponse
|
||||
{
|
||||
if ($quiz instanceof Quiz && !$quiz->isFinalized()) {
|
||||
$this->addFlash(FlashType::Danger, $this->translator->trans('The quiz must be finalized before it can be activated'));
|
||||
|
||||
return $this->redirectToRoute('tvdt_backoffice_quiz_overview', ['seasonCode' => $season->seasonCode, 'quiz' => $quiz->id]);
|
||||
}
|
||||
|
||||
$season->activeQuiz = $quiz;
|
||||
$this->em->flush();
|
||||
|
||||
if ($quiz instanceof Quiz) {
|
||||
return $this->redirectToRoute('tvdt_backoffice_quiz', ['seasonCode' => $season->seasonCode, 'quiz' => $quiz->id]);
|
||||
return $this->redirectToRoute('tvdt_backoffice_quiz_overview', ['seasonCode' => $season->seasonCode, 'quiz' => $quiz->id]);
|
||||
}
|
||||
|
||||
// When deactivating, stay on the quiz page if one was passed
|
||||
$previousQuizId = $request->request->getString('redirect_quiz');
|
||||
if ('' !== $previousQuizId) {
|
||||
$previousQuiz = $this->em->getRepository(Quiz::class)->find($previousQuizId);
|
||||
if ($previousQuiz instanceof Quiz && $previousQuiz->season === $season) {
|
||||
return $this->redirectToRoute('tvdt_backoffice_quiz_overview', ['seasonCode' => $season->seasonCode, 'quiz' => $previousQuiz->id]);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('tvdt_backoffice_season', ['seasonCode' => $season->seasonCode]);
|
||||
@@ -274,9 +297,55 @@ class QuizController extends AbstractController
|
||||
{
|
||||
try {
|
||||
$this->quizRepository->clearQuiz($quiz);
|
||||
$this->addFlash('success', $this->translator->trans('Quiz cleared'));
|
||||
$quiz->finalizedAt = null;
|
||||
$this->em->flush();
|
||||
$this->addFlash(FlashType::Success, $this->translator->trans('Quiz cleared and no longer finalized'));
|
||||
} 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'));
|
||||
}
|
||||
|
||||
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]);
|
||||
@@ -294,7 +363,7 @@ class QuizController extends AbstractController
|
||||
{
|
||||
$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]);
|
||||
}
|
||||
@@ -359,7 +428,7 @@ class QuizController extends AbstractController
|
||||
|
||||
$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]);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ declare(strict_types=1);
|
||||
namespace Tvdt\Controller\Backoffice;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
@@ -39,7 +41,39 @@ class SeasonController extends AbstractController
|
||||
name: 'tvdt_backoffice_season',
|
||||
requirements: ['seasonCode' => self::SEASON_CODE_REGEX],
|
||||
)]
|
||||
public function index(Season $season, Request $request): Response
|
||||
public function index(Season $season): Response
|
||||
{
|
||||
return $this->render('backoffice/season.html.twig', [
|
||||
'season' => $season,
|
||||
'activeTab' => 'tests',
|
||||
'template' => 'backoffice/season/tab_tests.html.twig',
|
||||
]);
|
||||
}
|
||||
|
||||
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
|
||||
#[Route(
|
||||
'/backoffice/season/{seasonCode:season}/candidates',
|
||||
name: 'tvdt_backoffice_season_candidates',
|
||||
requirements: ['seasonCode' => self::SEASON_CODE_REGEX],
|
||||
priority: 10,
|
||||
)]
|
||||
public function candidatesTab(Season $season): Response
|
||||
{
|
||||
return $this->render('backoffice/season.html.twig', [
|
||||
'season' => $season,
|
||||
'activeTab' => 'candidates',
|
||||
'template' => 'backoffice/season/tab_candidates.html.twig',
|
||||
]);
|
||||
}
|
||||
|
||||
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
|
||||
#[Route(
|
||||
'/backoffice/season/{seasonCode:season}/settings',
|
||||
name: 'tvdt_backoffice_season_settings',
|
||||
requirements: ['seasonCode' => self::SEASON_CODE_REGEX],
|
||||
priority: 10,
|
||||
)]
|
||||
public function settingsTab(Season $season, Request $request): Response
|
||||
{
|
||||
$form = $this->createForm(SettingsForm::class, $season->settings);
|
||||
|
||||
@@ -47,11 +81,15 @@ class SeasonController extends AbstractController
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$this->em->flush();
|
||||
|
||||
return $this->redirectToRoute('tvdt_backoffice_season_settings', ['seasonCode' => $season->seasonCode]);
|
||||
}
|
||||
|
||||
return $this->render('backoffice/season.html.twig', [
|
||||
'season' => $season,
|
||||
'form' => $form,
|
||||
'activeTab' => 'settings',
|
||||
'template' => 'backoffice/season/tab_settings.html.twig',
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -112,4 +150,38 @@ class SeasonController extends AbstractController
|
||||
|
||||
return $this->render('/backoffice/quiz_add.html.twig', ['form' => $form, 'season' => $season]);
|
||||
}
|
||||
|
||||
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
|
||||
#[Route(
|
||||
'/backoffice/season/{seasonCode:season}/add-blank-quiz',
|
||||
name: 'tvdt_backoffice_quiz_add_blank',
|
||||
requirements: ['seasonCode' => self::SEASON_CODE_REGEX],
|
||||
priority: 10,
|
||||
)]
|
||||
public function addBlankQuiz(Request $request, Season $season): Response
|
||||
{
|
||||
$form = $this->createFormBuilder(new Quiz())
|
||||
->add('name', TextType::class, ['label' => 'Quiz name'])
|
||||
->add('save', SubmitType::class, ['label' => 'Create'])
|
||||
->getForm();
|
||||
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
/** @var Quiz $quiz */
|
||||
$quiz = $form->getData();
|
||||
$quiz->season = $season;
|
||||
$this->em->persist($quiz);
|
||||
$this->em->flush();
|
||||
|
||||
$this->addFlash(FlashType::Success, $this->translator->trans('Quiz Added!'));
|
||||
|
||||
return $this->redirectToRoute('tvdt_backoffice_quiz_overview', [
|
||||
'seasonCode' => $season->seasonCode,
|
||||
'quiz' => $quiz->id,
|
||||
]);
|
||||
}
|
||||
|
||||
return $this->render('/backoffice/quiz_add_blank.html.twig', ['form' => $form, 'season' => $season]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\DataFixtures;
|
||||
|
||||
use Doctrine\Bundle\FixturesBundle\Fixture;
|
||||
use Doctrine\Bundle\FixturesBundle\FixtureGroupInterface;
|
||||
use Doctrine\Persistence\ObjectManager;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
use Tvdt\Entity\User;
|
||||
|
||||
final class DevFixtures extends Fixture implements FixtureGroupInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UserPasswordHasherInterface $passwordHasher,
|
||||
) {}
|
||||
|
||||
public static function getGroups(): array
|
||||
{
|
||||
return ['dev'];
|
||||
}
|
||||
|
||||
public function load(ObjectManager $manager): void
|
||||
{
|
||||
$user = new User();
|
||||
$user->email = 'admin@tijdvoordetest.nl';
|
||||
$user->password = $this->passwordHasher->hashPassword($user, '12345678');
|
||||
$user->roles = ['ROLE_ADMIN'];
|
||||
|
||||
$manager->persist($user);
|
||||
|
||||
$manager->flush();
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,14 @@ namespace Tvdt\DataFixtures;
|
||||
use Doctrine\Bundle\FixturesBundle\Fixture;
|
||||
use Doctrine\Bundle\FixturesBundle\FixtureGroupInterface;
|
||||
use Doctrine\Persistence\ObjectManager;
|
||||
use Safe\DateTimeImmutable;
|
||||
use Tvdt\Entity\Answer;
|
||||
use Tvdt\Entity\BankAnswer;
|
||||
use Tvdt\Entity\BankQuestion;
|
||||
use Tvdt\Entity\BankQuestionUsage;
|
||||
use Tvdt\Entity\Candidate;
|
||||
use Tvdt\Entity\Question;
|
||||
use Tvdt\Entity\QuestionLabel;
|
||||
use Tvdt\Entity\Quiz;
|
||||
use Tvdt\Entity\Season;
|
||||
use Tvdt\Entity\SeasonSettings;
|
||||
@@ -18,6 +23,16 @@ final class KrtekFixtures extends Fixture implements FixtureGroupInterface
|
||||
{
|
||||
public const string KRTEK_SEASON = 'krtek-seaspm';
|
||||
|
||||
public const string KRTEK_QUIZ_1 = 'krtek-quiz-1';
|
||||
|
||||
public const string KRTEK_QUIZ_2 = 'krtek-quiz-2';
|
||||
|
||||
public const string BANK_QUESTION_REUSABLE = 'bank-question-reusable';
|
||||
|
||||
public const string BANK_QUESTION_USED = 'bank-question-used';
|
||||
|
||||
public const string BANK_QUESTION_UNUSED = 'bank-question-unused';
|
||||
|
||||
public static function getGroups(): array
|
||||
{
|
||||
return ['test', 'dev'];
|
||||
@@ -47,16 +62,64 @@ final class KrtekFixtures extends Fixture implements FixtureGroupInterface
|
||||
$quiz1 = $this->createQuiz1($season);
|
||||
$season->addQuiz($quiz1);
|
||||
$season->activeQuiz = $quiz1;
|
||||
$season->addQuiz($this->createQuiz2($season));
|
||||
|
||||
$quiz1->finalizedAt = new DateTimeImmutable();
|
||||
$quiz2 = $this->createQuiz2($season);
|
||||
$season->addQuiz($quiz2);
|
||||
|
||||
\assert($season->settings instanceof SeasonSettings);
|
||||
|
||||
$season->settings->confirmAnswers = true;
|
||||
$season->settings->showNumbers = true;
|
||||
|
||||
$this->createQuestionBank($season, $quiz2);
|
||||
|
||||
$manager->flush();
|
||||
|
||||
$this->addReference(self::KRTEK_SEASON, $season);
|
||||
$this->addReference(self::KRTEK_QUIZ_1, $quiz1);
|
||||
$this->addReference(self::KRTEK_QUIZ_2, $quiz2);
|
||||
}
|
||||
|
||||
private function createQuestionBank(Season $season, Quiz $usedInQuiz): void
|
||||
{
|
||||
$location = new QuestionLabel('Locatie');
|
||||
$season->addQuestionLabel($location);
|
||||
$finale = new QuestionLabel('Finale');
|
||||
$season->addQuestionLabel($finale);
|
||||
|
||||
$reusable = new BankQuestion();
|
||||
$reusable->question = 'Wie is de Krtek?';
|
||||
$reusable->reusable = true;
|
||||
$reusable->addLabel($finale);
|
||||
$reusable->addAnswer(new BankAnswer('Claudia', true));
|
||||
$reusable->addAnswer(new BankAnswer('Eelco'));
|
||||
$reusable->addAnswer(new BankAnswer('Elise'));
|
||||
|
||||
$season->addBankQuestion($reusable);
|
||||
|
||||
$used = new BankQuestion();
|
||||
$used->question = 'Waar sliep de Krtek?';
|
||||
$used->addLabel($location);
|
||||
$used->addAnswer(new BankAnswer('Boven', true));
|
||||
$used->addAnswer(new BankAnswer('Beneden'));
|
||||
$used->addUsage(new BankQuestionUsage($used, $usedInQuiz));
|
||||
|
||||
$season->addBankQuestion($used);
|
||||
|
||||
$unused = new BankQuestion();
|
||||
$unused->question = 'Wat at de Krtek als ontbijt?';
|
||||
$unused->addLabel($location);
|
||||
$unused->addLabel($finale);
|
||||
$unused->addAnswer(new BankAnswer('Brood', true));
|
||||
$unused->addAnswer(new BankAnswer('Yoghurt'));
|
||||
$unused->addAnswer(new BankAnswer('Niks'));
|
||||
|
||||
$season->addBankQuestion($unused);
|
||||
|
||||
$this->addReference(self::BANK_QUESTION_REUSABLE, $reusable);
|
||||
$this->addReference(self::BANK_QUESTION_USED, $used);
|
||||
$this->addReference(self::BANK_QUESTION_UNUSED, $unused);
|
||||
}
|
||||
|
||||
private function createQuiz1(Season $season): Quiz
|
||||
|
||||
@@ -0,0 +1,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,
|
||||
) {}
|
||||
}
|
||||
@@ -31,14 +31,14 @@ class GivenAnswer
|
||||
public function __construct(
|
||||
#[ORM\JoinColumn(nullable: false)]
|
||||
#[ORM\ManyToOne(inversedBy: 'givenAnswers')]
|
||||
private(set) Candidate $candidate,
|
||||
public private(set) Candidate $candidate,
|
||||
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
#[ORM\ManyToOne]
|
||||
private(set) Quiz $quiz,
|
||||
public private(set) Quiz $quiz,
|
||||
|
||||
#[ORM\JoinColumn(nullable: false)]
|
||||
#[ORM\ManyToOne(inversedBy: 'givenAnswers')]
|
||||
private(set) Answer $answer,
|
||||
public private(set) Answer $answer,
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Entity;
|
||||
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Bridge\Doctrine\Types\UuidType;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
use Tvdt\Repository\QuestionLabelRepository;
|
||||
|
||||
#[ORM\Entity(repositoryClass: QuestionLabelRepository::class)]
|
||||
#[ORM\UniqueConstraint(fields: ['name', 'season'])]
|
||||
class QuestionLabel implements \Stringable
|
||||
{
|
||||
#[ORM\Column(type: UuidType::NAME)]
|
||||
#[ORM\CustomIdGenerator(class: 'doctrine.uuid_generator')]
|
||||
#[ORM\GeneratedValue(strategy: 'CUSTOM')]
|
||||
#[ORM\Id]
|
||||
public private(set) Uuid $id;
|
||||
|
||||
#[ORM\JoinColumn(nullable: false)]
|
||||
#[ORM\ManyToOne(inversedBy: 'questionLabels')]
|
||||
public Season $season;
|
||||
|
||||
/** @var Collection<int, BankQuestion> */
|
||||
#[ORM\ManyToMany(targetEntity: BankQuestion::class, mappedBy: 'labels')]
|
||||
public private(set) Collection $bankQuestions;
|
||||
|
||||
public function __construct(
|
||||
#[ORM\Column(length: 64)]
|
||||
public string $name,
|
||||
) {
|
||||
$this->bankQuestions = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ namespace Tvdt\Entity;
|
||||
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Bridge\Doctrine\Types\UuidType;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
@@ -40,6 +41,9 @@ class Quiz
|
||||
#[ORM\Column(nullable: false, options: ['default' => 1])]
|
||||
public int $dropouts = 1;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIMETZ_IMMUTABLE, nullable: true)]
|
||||
public ?\DateTimeImmutable $finalizedAt = null;
|
||||
|
||||
/** @var Collection<int, Elimination> */
|
||||
#[ORM\OneToMany(targetEntity: Elimination::class, mappedBy: 'quiz', cascade: ['persist'], orphanRemoval: true)]
|
||||
#[ORM\OrderBy(['createdAt' => 'DESC'])]
|
||||
@@ -62,6 +66,29 @@ class Quiz
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isFinalized(): bool
|
||||
{
|
||||
return $this->finalizedAt instanceof \DateTimeImmutable;
|
||||
}
|
||||
|
||||
public function hasStartedCandidates(): bool
|
||||
{
|
||||
return $this->candidateData->exists(static fn (int $key, QuizCandidate $quizCandidate): bool => $quizCandidate->started instanceof \DateTimeImmutable);
|
||||
}
|
||||
|
||||
/**
|
||||
* A locked quiz can no longer be altered: it is either explicitly
|
||||
* finalized or a candidate has already started filling it in.
|
||||
*/
|
||||
public function isLocked(): bool
|
||||
{
|
||||
if ($this->isFinalized()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->hasStartedCandidates();
|
||||
}
|
||||
|
||||
public function addElimination(Elimination $elimination): self
|
||||
{
|
||||
$this->eliminations->add($elimination);
|
||||
|
||||
@@ -51,12 +51,24 @@ class Season
|
||||
#[ORM\OneToOne(cascade: ['persist', 'remove'])]
|
||||
public ?SeasonSettings $settings = null;
|
||||
|
||||
/** @var Collection<int, BankQuestion> */
|
||||
#[ORM\OneToMany(targetEntity: BankQuestion::class, mappedBy: 'season', cascade: ['persist'], orphanRemoval: true)]
|
||||
#[ORM\OrderBy(['question' => 'ASC'])]
|
||||
public private(set) Collection $bankQuestions;
|
||||
|
||||
/** @var Collection<int, QuestionLabel> */
|
||||
#[ORM\OneToMany(targetEntity: QuestionLabel::class, mappedBy: 'season', cascade: ['persist'], orphanRemoval: true)]
|
||||
#[ORM\OrderBy(['name' => 'ASC'])]
|
||||
public private(set) Collection $questionLabels;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->settings = new SeasonSettings();
|
||||
$this->quizzes = new ArrayCollection();
|
||||
$this->candidates = new ArrayCollection();
|
||||
$this->owners = new ArrayCollection();
|
||||
$this->bankQuestions = new ArrayCollection();
|
||||
$this->questionLabels = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function addQuiz(Quiz $quiz): static
|
||||
@@ -79,6 +91,26 @@ class Season
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function addBankQuestion(BankQuestion $bankQuestion): static
|
||||
{
|
||||
if (!$this->bankQuestions->contains($bankQuestion)) {
|
||||
$this->bankQuestions->add($bankQuestion);
|
||||
$bankQuestion->season = $this;
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function addQuestionLabel(QuestionLabel $questionLabel): static
|
||||
{
|
||||
if (!$this->questionLabels->contains($questionLabel)) {
|
||||
$this->questionLabels->add($questionLabel);
|
||||
$questionLabel->season = $this;
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function addOwner(User $owner): static
|
||||
{
|
||||
if (!$this->owners->contains($owner)) {
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Exception;
|
||||
|
||||
class BankQuestionAlreadyUsedException extends \Exception {}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Exception;
|
||||
|
||||
class QuizLockedException extends \Exception {}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Form;
|
||||
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
use Tvdt\Entity\BankAnswer;
|
||||
|
||||
/** @extends AbstractType<BankAnswer> */
|
||||
class BankAnswerFormType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
->add('text', TextType::class, [
|
||||
'label' => false,
|
||||
'attr' => ['placeholder' => 'Answer', 'maxlength' => 255],
|
||||
])
|
||||
->add('isRightAnswer', CheckboxType::class, [
|
||||
'label' => 'Correct',
|
||||
'required' => false,
|
||||
])
|
||||
;
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => BankAnswer::class,
|
||||
'empty_data' => static fn (): BankAnswer => new BankAnswer(''),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Form;
|
||||
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
use Tvdt\Entity\BankQuestion;
|
||||
use Tvdt\Entity\QuestionLabel;
|
||||
use Tvdt\Entity\Season;
|
||||
use Tvdt\Repository\QuestionLabelRepository;
|
||||
|
||||
/** @extends AbstractType<BankQuestion> */
|
||||
class BankQuestionFormType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
/** @var Season $season */
|
||||
$season = $options['season'];
|
||||
|
||||
$builder
|
||||
->add('question', TextType::class, [
|
||||
'label' => 'Question',
|
||||
'attr' => ['maxlength' => 255],
|
||||
])
|
||||
->add('reusable', CheckboxType::class, [
|
||||
'label' => 'Reusable',
|
||||
'required' => false,
|
||||
'label_attr' => ['class' => 'checkbox-switch'],
|
||||
'attr' => ['role' => 'switch', 'switch' => null],
|
||||
])
|
||||
->add('labels', EntityType::class, [
|
||||
'label' => 'Labels',
|
||||
'class' => QuestionLabel::class,
|
||||
'multiple' => true,
|
||||
'expanded' => true,
|
||||
'required' => false,
|
||||
'query_builder' => static fn (QuestionLabelRepository $repository): QueryBuilder => $repository
|
||||
->createQueryBuilder('l')
|
||||
->where('l.season = :season')
|
||||
->orderBy('l.name', 'ASC')
|
||||
->setParameter('season', $season),
|
||||
])
|
||||
->add('answers', CollectionType::class, [
|
||||
'label' => 'Answers',
|
||||
'entry_type' => BankAnswerFormType::class,
|
||||
'entry_options' => ['label' => false],
|
||||
'allow_add' => true,
|
||||
'allow_delete' => true,
|
||||
'by_reference' => false,
|
||||
'prototype' => true,
|
||||
])
|
||||
->add('save', SubmitType::class, [
|
||||
'label' => 'Save',
|
||||
])
|
||||
;
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => BankQuestion::class,
|
||||
]);
|
||||
$resolver->setRequired('season');
|
||||
$resolver->setAllowedTypes('season', Season::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Repository;
|
||||
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
use Tvdt\Entity\BankQuestion;
|
||||
use Tvdt\Entity\QuestionLabel;
|
||||
use Tvdt\Entity\Season;
|
||||
|
||||
/** @extends ServiceEntityRepository<BankQuestion> */
|
||||
class BankQuestionRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, BankQuestion::class);
|
||||
}
|
||||
|
||||
/** @return list<BankQuestion> */
|
||||
public function findBySeason(Season $season, ?QuestionLabel $label = null): array
|
||||
{
|
||||
$queryBuilder = $this->createQueryBuilder('bq')
|
||||
->select('bq', 'ba', 'l', 'u', 'uq')
|
||||
->leftJoin('bq.answers', 'ba')
|
||||
->leftJoin('bq.labels', 'l')
|
||||
->leftJoin('bq.usages', 'u')
|
||||
->leftJoin('u.quiz', 'uq')
|
||||
->where('bq.season = :season')
|
||||
->orderBy('bq.question', 'ASC')
|
||||
->setParameter('season', $season);
|
||||
|
||||
if ($label instanceof QuestionLabel) {
|
||||
$queryBuilder
|
||||
->andWhere(':label member of bq.labels')
|
||||
->setParameter('label', $label);
|
||||
}
|
||||
|
||||
/* @var list<BankQuestion> */
|
||||
return $queryBuilder->getQuery()->getResult();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Repository;
|
||||
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
use Tvdt\Entity\QuestionLabel;
|
||||
|
||||
/** @extends ServiceEntityRepository<QuestionLabel> */
|
||||
class QuestionLabelRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, QuestionLabel::class);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ use Safe\Exceptions\DatetimeException;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
use Tvdt\Dto\Result;
|
||||
use Tvdt\Entity\Quiz;
|
||||
use Tvdt\Entity\Season;
|
||||
use Tvdt\Exception\ErrorClearingQuizException;
|
||||
|
||||
/** @extends ServiceEntityRepository<Quiz> */
|
||||
@@ -22,6 +23,29 @@ class QuizRepository extends ServiceEntityRepository
|
||||
parent::__construct($registry, Quiz::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Quizzes of the season that can still receive bank questions:
|
||||
* not finalized and not started by any candidate.
|
||||
*
|
||||
* @return list<Quiz>
|
||||
*/
|
||||
public function findAssignableForSeason(Season $season): array
|
||||
{
|
||||
/* @var list<Quiz> */
|
||||
return $this->getEntityManager()->createQuery(<<<DQL
|
||||
select q from Tvdt\Entity\Quiz q
|
||||
where q.season = :season
|
||||
and q.finalizedAt is null
|
||||
and not exists (
|
||||
select 1 from Tvdt\Entity\QuizCandidate qc
|
||||
where qc.quiz = q and qc.started is not null
|
||||
)
|
||||
order by q.id asc
|
||||
DQL)
|
||||
->setParameter('season', $season)
|
||||
->getResult();
|
||||
}
|
||||
|
||||
/** @throws ErrorClearingQuizException */
|
||||
public function clearQuiz(Quiz $quiz): void
|
||||
{
|
||||
@@ -113,8 +137,8 @@ class QuizRepository extends ServiceEntityRepository
|
||||
{
|
||||
return $this->getEntityManager()->createQuery(<<<dql
|
||||
select q, qz, a from Tvdt\Entity\Quiz q
|
||||
join q.questions qz
|
||||
join qz.answers a
|
||||
left join q.questions qz
|
||||
left join qz.answers a
|
||||
where q.id = :id
|
||||
dql)->setParameter('id', $id)->getSingleResult();
|
||||
}
|
||||
@@ -127,8 +151,8 @@ class QuizRepository extends ServiceEntityRepository
|
||||
{
|
||||
return $this->getEntityManager()->createQuery(<<<dql
|
||||
select q, qz, a, ac, s, sc, qc from Tvdt\Entity\Quiz q
|
||||
join q.questions qz
|
||||
join qz.answers a
|
||||
left join q.questions qz
|
||||
left join qz.answers a
|
||||
left join a.candidates ac
|
||||
join q.season s
|
||||
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\Voter;
|
||||
use Tvdt\Entity\Answer;
|
||||
use Tvdt\Entity\BankQuestion;
|
||||
use Tvdt\Entity\Candidate;
|
||||
use Tvdt\Entity\Elimination;
|
||||
use Tvdt\Entity\Question;
|
||||
use Tvdt\Entity\QuestionLabel;
|
||||
use Tvdt\Entity\Quiz;
|
||||
use Tvdt\Entity\Season;
|
||||
use Tvdt\Entity\User;
|
||||
|
||||
/** @extends Voter<string, Season|Elimination|Quiz|Candidate|Answer|Question> */
|
||||
/** @extends Voter<string, Season|Elimination|Quiz|Candidate|Answer|Question|BankQuestion|QuestionLabel> */
|
||||
final class SeasonVoter extends Voter
|
||||
{
|
||||
public const string EDIT = 'SEASON_EDIT';
|
||||
@@ -24,15 +26,19 @@ final class SeasonVoter extends Voter
|
||||
|
||||
public const string DELETE = 'SEASON_DELETE';
|
||||
|
||||
public const string MODIFY_QUIZ_CONTENT = 'QUIZ_MODIFY_CONTENT';
|
||||
|
||||
protected function supports(string $attribute, mixed $subject): bool
|
||||
{
|
||||
return \in_array($attribute, [self::EDIT, self::DELETE, self::ELIMINATION], true)
|
||||
return \in_array($attribute, [self::EDIT, self::DELETE, self::ELIMINATION, self::MODIFY_QUIZ_CONTENT], true)
|
||||
&& (
|
||||
$subject instanceof Answer
|
||||
|| $subject instanceof BankQuestion
|
||||
|| $subject instanceof Candidate
|
||||
|| $subject instanceof Elimination
|
||||
|| $subject instanceof Season
|
||||
|| $subject instanceof Question
|
||||
|| $subject instanceof QuestionLabel
|
||||
|| $subject instanceof Quiz
|
||||
);
|
||||
}
|
||||
@@ -44,19 +50,36 @@ final class SeasonVoter extends Voter
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($user->isAdmin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$season = match (true) {
|
||||
$subject instanceof Answer => $subject->question->quiz->season,
|
||||
$subject instanceof Elimination,
|
||||
$subject instanceof Question => $subject->quiz->season,
|
||||
$subject instanceof BankQuestion,
|
||||
$subject instanceof Candidate,
|
||||
$subject instanceof QuestionLabel,
|
||||
$subject instanceof Quiz => $subject->season,
|
||||
$subject instanceof Season => $subject,
|
||||
};
|
||||
|
||||
if (self::MODIFY_QUIZ_CONTENT === $attribute) {
|
||||
$quiz = match (true) {
|
||||
$subject instanceof Answer => $subject->question->quiz,
|
||||
$subject instanceof Question => $subject->quiz,
|
||||
$subject instanceof Quiz => $subject,
|
||||
default => null,
|
||||
};
|
||||
|
||||
if (!$quiz instanceof Quiz || $quiz->isLocked()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->isAdmin || $season->isOwner($user);
|
||||
}
|
||||
|
||||
if ($user->isAdmin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return match ($attribute) {
|
||||
self::EDIT, self::DELETE, self::ELIMINATION => $season->isOwner($user),
|
||||
default => false,
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
<?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);
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
|
||||
/** Remove the quiz copy created by this usage and delete the usage record. */
|
||||
public function unassignFromQuiz(BankQuestionUsage $usage): void
|
||||
{
|
||||
$question = $usage->question;
|
||||
if ($question instanceof Question) {
|
||||
$question->quiz->questions->removeElement($question);
|
||||
$this->entityManager->remove($question);
|
||||
}
|
||||
|
||||
$usage->bankQuestion->usages->removeElement($usage);
|
||||
$this->entityManager->remove($usage);
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Service;
|
||||
|
||||
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
|
||||
use PhpOffice\PhpSpreadsheet\Reader;
|
||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||
use PhpOffice\PhpSpreadsheet\Writer;
|
||||
@@ -17,39 +18,40 @@ class QuizSpreadsheetService
|
||||
{
|
||||
public function generateTemplate(bool $fillExample = true): \Closure
|
||||
{
|
||||
$spreadsheet = new Spreadsheet();
|
||||
|
||||
$sheet = $spreadsheet->getActiveSheet();
|
||||
|
||||
$sheet->getStyle('1:1')->getFont()->setBold(true);
|
||||
|
||||
$sheet->setCellValue('A1', 'Question');
|
||||
$sheet->getColumnDimension('A')->setWidth(30);
|
||||
$sheet->getStyle('A:A')->getAlignment()->setWrapText(true);
|
||||
|
||||
$counter = 1;
|
||||
foreach (range('B', 'L', 2) as $column) {
|
||||
$sheet->setCellValue($column.'1', 'Answer '.$counter++);
|
||||
$sheet->getColumnDimension($column)->setWidth(30);
|
||||
$sheet->getStyle($column.':'.$column)->getAlignment()->setWrapText(true);
|
||||
}
|
||||
|
||||
foreach (range('C', 'M', 2) as $column) {
|
||||
$sheet->setCellValue($column.'1', 'Correct');
|
||||
$sheet->getColumnDimension($column)->setAutoSize(true);
|
||||
}
|
||||
$quiz = new Quiz();
|
||||
|
||||
if ($fillExample) {
|
||||
$sheet->setCellValue('B2', 'Man');
|
||||
$sheet->setCellValue('C2', true);
|
||||
$geslacht = new Question();
|
||||
$geslacht->question = 'Is de mol een man of een vrouw?';
|
||||
$geslacht->ordering = 1;
|
||||
$geslacht->addAnswer(new Answer('Man', true));
|
||||
$geslacht->addAnswer(new Answer('Vrouw'));
|
||||
$quiz->addQuestion($geslacht);
|
||||
|
||||
$sheet->setCellValue('D2', 'Vrouw');
|
||||
$sheet->setCellValue('E2', false);
|
||||
$identiteit = new Question();
|
||||
$identiteit->question = 'Wie is de mol?';
|
||||
$identiteit->ordering = 2;
|
||||
foreach ([
|
||||
['Emma', false],
|
||||
['Jan', false],
|
||||
['Sara', false],
|
||||
['Piet', false],
|
||||
['Lisa', true],
|
||||
['Kees', false],
|
||||
['Anna', false],
|
||||
['Henk', false],
|
||||
['Nina', false],
|
||||
['Joost', false],
|
||||
] as $i => [$name, $correct]) {
|
||||
$answer = new Answer($name, $correct);
|
||||
$answer->ordering = $i + 1;
|
||||
$identiteit->addAnswer($answer);
|
||||
}
|
||||
|
||||
$sheet->setCellValue('A2', 'Is de mol een man of een vrouw?');
|
||||
$quiz->addQuestion($identiteit);
|
||||
}
|
||||
|
||||
return $this->toXlsx($spreadsheet);
|
||||
return $this->quizToXlsx($quiz);
|
||||
}
|
||||
|
||||
/** @throws SpreadsheetDataException */
|
||||
@@ -94,24 +96,16 @@ class QuizSpreadsheetService
|
||||
$answerCounter = 1;
|
||||
$arrCounter = 1;
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
if (null === $questionArr[$arrCounter]) {
|
||||
if (1 === $answerCounter) {
|
||||
$errors[] = \sprintf('Question %d has no answers', $answerCounter);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
} catch (\ErrorException) {
|
||||
break;
|
||||
}
|
||||
|
||||
while (\array_key_exists($arrCounter, $questionArr) && null !== $questionArr[$arrCounter]) {
|
||||
$answer = new Answer((string) $questionArr[$arrCounter++], (bool) $questionArr[$arrCounter++]);
|
||||
$answer->ordering = $answerCounter++;
|
||||
$question->addAnswer($answer);
|
||||
}
|
||||
|
||||
if (1 === $answerCounter) {
|
||||
$errors[] = \sprintf('Question %d has no answers', $question->ordering);
|
||||
}
|
||||
|
||||
$quiz->addQuestion($question);
|
||||
}
|
||||
|
||||
@@ -120,9 +114,47 @@ class QuizSpreadsheetService
|
||||
}
|
||||
}
|
||||
|
||||
public function quizToXlsx(Quiz $quiz): void
|
||||
public function quizToXlsx(Quiz $quiz): \Closure
|
||||
{
|
||||
throw new \Exception('Not implemented');
|
||||
$spreadsheet = new Spreadsheet();
|
||||
$sheet = $spreadsheet->getActiveSheet();
|
||||
|
||||
// Write data rows first so we know the maximum answer count.
|
||||
$maxAnswers = 0;
|
||||
$row = 2;
|
||||
foreach ($quiz->questions as $question) {
|
||||
$sheet->setCellValue('A'.$row, $question->question);
|
||||
|
||||
$col = 0;
|
||||
foreach ($question->answers as $answer) {
|
||||
$sheet->setCellValue(Coordinate::stringFromColumnIndex(2 + 2 * $col).$row, $answer->text);
|
||||
$sheet->setCellValue(Coordinate::stringFromColumnIndex(3 + 2 * $col).$row, $answer->isRightAnswer);
|
||||
++$col;
|
||||
}
|
||||
|
||||
$maxAnswers = max($maxAnswers, $col);
|
||||
++$row;
|
||||
}
|
||||
|
||||
// Write headers last, sized to the widest question.
|
||||
$sheet->getStyle('1:1')->getFont()->setBold(true);
|
||||
$sheet->setCellValue('A1', 'Question');
|
||||
$sheet->getColumnDimension('A')->setWidth(30);
|
||||
$sheet->getStyle('A:A')->getAlignment()->setWrapText(true);
|
||||
|
||||
for ($i = 0; $i < $maxAnswers; ++$i) {
|
||||
$answerCol = Coordinate::stringFromColumnIndex(2 + 2 * $i);
|
||||
$correctCol = Coordinate::stringFromColumnIndex(3 + 2 * $i);
|
||||
|
||||
$sheet->setCellValue($answerCol.'1', 'Answer '.($i + 1));
|
||||
$sheet->getColumnDimension($answerCol)->setWidth(30);
|
||||
$sheet->getStyle($answerCol.':'.$answerCol)->getAlignment()->setWrapText(true);
|
||||
|
||||
$sheet->setCellValue($correctCol.'1', 'Correct');
|
||||
$sheet->getColumnDimension($correctCol)->setAutoSize(true);
|
||||
}
|
||||
|
||||
return $this->toXlsx($spreadsheet);
|
||||
}
|
||||
|
||||
private function toXlsx(Spreadsheet $spreadsheet): \Closure
|
||||
|
||||
@@ -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 speelronde met alle bijbehorende testen en kandidaten. De seizoenscode is de link die kandidaten gebruiken om een test te starten — deze link 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 XLSX 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 uploaden via XLSX</h6>
|
||||
<p>Upload een XLSX-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 via de spreadsheet</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 overzicht om handmatig antwoorden toe te wijzen — bijvoorbeeld bij technische problemen of als iemand de test op papier heeft ingevuld.</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 op twee manieren invullen:</p>
|
||||
<p><strong>Optie 1 — eigen apparaat:</strong> Deel de seizoenscode. Elke kandidaat bezoekt de site op zijn of haar telefoon, voert de eigen naam in en start de test.</p>
|
||||
<p><strong>Optie 2 — gedeelde laptop:</strong> Open de naamsinvoerpagina van tevoren op een laptop. Elke kandidaat typt zijn of haar naam en start. Na afloop kan de volgende kandidaat hetzelfde doen.</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 test 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 thema.</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>Correcties</strong> voeg je toe voor bonuspunten of jokeraftrek (halve punten zijn mogelijk).</p>
|
||||
<p><strong>Straf</strong> is tijdstraf in seconden en wordt meegewogen bij gelijke score.</p>
|
||||
<p>Via <strong>Eliminatie voorbereiden</strong> stel je de schermkleuren handmatig in en start je de eliminatiereeks.</p>
|
||||
@@ -0,0 +1,3 @@
|
||||
<h6>Nieuw seizoen</h6>
|
||||
<p>Een seizoen groepeert alle testen en kandidaten voor één speelronde. 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 bankenvraag 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 naam en overige instellingen van dit seizoen aan.</p>
|
||||
<p>De seizoenscode staat vast na aanmaken en bepaalt de URL die kandidaten gebruiken om in te loggen.</p>
|
||||
@@ -0,0 +1,11 @@
|
||||
<h6>Testen beheren</h6>
|
||||
<p>Voeg een test toe vanuit een XLSX-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 — dit is de test die kandidaten op dat moment kunnen invullen.</p>
|
||||
<h6>Volgorde van werken</h6>
|
||||
<ol>
|
||||
<li>Test aanmaken (XLSX 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 %}
|
||||
|
||||
{% block body %}
|
||||
<div class="d-flex flex-row align-items-center mb-3">
|
||||
<h2 class="mb-0 pe-2">
|
||||
{{ is_granted('ROLE_ADMIN') ? 'All Seasons'|trans : 'Your Seasons'|trans }}
|
||||
</h2>
|
||||
<a class="link" href="{{ path('tvdt_backoffice_season_add') }}">
|
||||
{{ 'Add'|trans }}
|
||||
</a>
|
||||
</div>
|
||||
{% if seasons %}
|
||||
<table class="table table-hover mb-3">
|
||||
<thead>
|
||||
<tr>
|
||||
{% if is_granted('ROLE_ADMIN') %}
|
||||
<th scope="col">{{ 'Owner(s)'|trans }}</th>
|
||||
{% endif %}
|
||||
<th scope="col">{{ 'Name'|trans }}</th>
|
||||
<th scope="col">{{ 'Active Quiz'|trans }}</th>
|
||||
<th scope="col">{{ 'Season Code'|trans }}</th>
|
||||
<th scope="col">{{ 'Manage'|trans }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for season in seasons %}
|
||||
<tr class="align-middle">
|
||||
{% if 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 }}
|
||||
<div class="row">
|
||||
<div class="col-md-8 col-12">
|
||||
<div class="d-flex flex-row align-items-center mb-3">
|
||||
<h2 class="mb-0 pe-2">
|
||||
{{ is_granted('ROLE_ADMIN') ? 'All Seasons'|trans : 'Your Seasons'|trans }}
|
||||
</h2>
|
||||
<a class="link" href="{{ path('tvdt_backoffice_season_add') }}">
|
||||
{{ 'Add'|trans }}
|
||||
</a>
|
||||
</div>
|
||||
{% if seasons %}
|
||||
<table class="table table-hover mb-3">
|
||||
<thead>
|
||||
<tr>
|
||||
{% if is_granted('ROLE_ADMIN') %}
|
||||
<th scope="col">{{ 'Owner(s)'|trans }}</th>
|
||||
{% 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 %}
|
||||
<th scope="col">{{ 'Name'|trans }}</th>
|
||||
<th scope="col">{{ 'Active Quiz'|trans }}</th>
|
||||
<th scope="col">{{ 'Season Code'|trans }}</th>
|
||||
<th scope="col">{{ 'Manage'|trans }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for season in seasons %}
|
||||
<tr class="align-middle">
|
||||
{% if is_granted('ROLE_ADMIN') %}
|
||||
<td>{{ season.owners|map(o => o.email)|join(', ') }}</td>
|
||||
{% endif %}
|
||||
<td>{{ season.name }}</td>
|
||||
<td>
|
||||
{% if season.activeQuiz %}
|
||||
{{ season.activeQuiz.name }}
|
||||
{% else %}
|
||||
{{ 'No active quiz'|trans }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<a {% if season.activeQuiz %}href="{{ path('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 %}
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
</form>
|
||||
</div>
|
||||
<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>
|
||||
{% 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>
|
||||
{% set questions = quiz.questions %}
|
||||
@@ -64,3 +66,8 @@
|
||||
</table>
|
||||
<button type="submit" class="btn btn-primary">{{ 'Save'|trans }}</button>
|
||||
</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>
|
||||
<table class="table table-hover mb-3">
|
||||
<thead>
|
||||
@@ -50,3 +52,8 @@
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="col-md-4 col-12">
|
||||
{{ include('backoffice/help/quiz_candidates.html.twig') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,45 @@
|
||||
<div data-controller="bo--quiz">
|
||||
<h4 class="mb-3">{{ 'Quick actions'|trans }}</h4>
|
||||
{% macro confirm_modal(id, target, body, formAction, csrfToken) %}
|
||||
<div class="modal fade" id="{{ id }}" data-bs-backdrop="static"
|
||||
tabindex="-1"
|
||||
data-bo--quiz-target="{{ target }}"
|
||||
aria-labelledby="{{ id }}Label" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h1 class="modal-title fs-5" id="{{ 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">{{ body }}</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ 'No'|trans }}</button>
|
||||
<form action="{{ formAction }}" method="POST">
|
||||
<input type="hidden" name="_token" value="{{ csrfToken }}">
|
||||
<button type="submit" class="btn btn-danger">{{ 'Yes'|trans }}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
<div class="row">
|
||||
<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">
|
||||
|
||||
{% if quiz is same as (season.activeQuiz) %}
|
||||
<form action="{{ path('tvdt_backoffice_enable', {seasonCode: season.seasonCode, quiz: 'null'}) }}" method="POST">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('enable_quiz') }}">
|
||||
<input type="hidden" name="redirect_quiz" value="{{ quiz.id }}">
|
||||
<button type="submit" class="btn btn-secondary rounded-0 rounded-start">
|
||||
{{ 'Deactivate Quiz'|trans }}
|
||||
</button>
|
||||
@@ -12,11 +47,32 @@
|
||||
{% else %}
|
||||
<form action="{{ path('tvdt_backoffice_enable', {seasonCode: season.seasonCode, quiz: quiz.id}) }}" method="POST">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('enable_quiz') }}">
|
||||
<button type="submit" class="btn btn-primary rounded-0 rounded-start">
|
||||
<button type="submit" class="btn btn-primary rounded-0 rounded-start"
|
||||
{% if not quiz.isFinalized %}disabled data-bs-toggle="tooltip"
|
||||
title="{{ 'The quiz must be finalized before it can be activated'|trans }}"{% endif %}>
|
||||
{{ 'Make active'|trans }}
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if not quiz.isFinalized %}
|
||||
<form action="{{ path('tvdt_backoffice_quiz_finalize', {quiz: quiz.id}) }}" method="POST">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('finalize_quiz') }}">
|
||||
<button type="submit" class="btn btn-success rounded-0"
|
||||
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">
|
||||
{{ 'Clear Quiz...'|trans }}
|
||||
</button>
|
||||
@@ -25,6 +81,11 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<a class="btn btn-outline-secondary mb-3"
|
||||
href="{{ path('tvdt_backoffice_quiz_export', {quiz: quiz.id}) }}">
|
||||
{{ 'Export to XLSX'|trans }}
|
||||
</a>
|
||||
|
||||
<h4 class="mb-3">{{ 'Questions'|trans }}</h4>
|
||||
<div class="accordion">
|
||||
{%~ for question in quiz.questions ~%}
|
||||
@@ -66,53 +127,23 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{# Modal Clear #}
|
||||
<div class="modal fade" id="clearQuizModal" data-bs-backdrop="static"
|
||||
tabindex="-1"
|
||||
data-bo--quiz-target="clearModal"
|
||||
aria-labelledby="staticBackdropLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h1 class="modal-title fs-5" id="staticBackdropLabel">{{ 'Please Confirm'|trans }}</h1>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
{{ 'Are you sure you want to clear all the results? This will also delete all the eliminations.'|trans }}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ 'No'|trans }}</button>
|
||||
<form action="{{ path('tvdt_backoffice_quiz_clear', {quiz: quiz.id}) }}" method="POST">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('clear_quiz') }}">
|
||||
<button type="submit" class="btn btn-danger">{{ 'Yes'|trans }}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ _self.confirm_modal(
|
||||
'clearQuizModal',
|
||||
'clearModal',
|
||||
'Are you sure you want to clear all the results? This will also delete all the eliminations.'|trans,
|
||||
path('tvdt_backoffice_quiz_clear', {quiz: quiz.id}),
|
||||
csrf_token('clear_quiz'),
|
||||
) }}
|
||||
|
||||
{# Modal Delete #}
|
||||
<div class="modal fade" id="deleteQuizModal" data-bs-backdrop="static"
|
||||
tabindex="-1"
|
||||
data-bo--quiz-target="deleteModal"
|
||||
aria-labelledby="staticBackdropLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h1 class="modal-title fs-5" id="staticBackdropLabel">{{ 'Please Confirm'|trans }}</h1>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
{{ 'Are you sure you want to delete this quiz?'|trans }}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ 'No'|trans }}</button>
|
||||
<form action="{{ path('tvdt_backoffice_quiz_delete', {quiz: quiz.id}) }}" method="POST">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('delete_quiz') }}">
|
||||
<button type="submit" class="btn btn-danger">{{ 'Yes'|trans }}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ _self.confirm_modal(
|
||||
'deleteQuizModal',
|
||||
'deleteModal',
|
||||
'Are you sure you want to delete this quiz?'|trans,
|
||||
path('tvdt_backoffice_quiz_delete', {quiz: quiz.id}),
|
||||
csrf_token('delete_quiz'),
|
||||
) }}
|
||||
</div>
|
||||
<div class="col-md-4 col-12">
|
||||
{{ include('backoffice/help/quiz_overview.html.twig') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<div class="row">
|
||||
<div class="col-md-8 col-12">
|
||||
<h4 class="mb-3">{{ 'Score'|trans }}</h4>
|
||||
<div class="btn-toolbar mb-3" role="toolbar">
|
||||
<div class="btn-group me-2">
|
||||
{# <a class="btn btn-primary">{{ 'Start Elimination'|trans }}</a> #}
|
||||
<form action="{{ path('tvdt_prepare_elimination', {seasonCode: season.seasonCode, quiz: quiz.id}) }}" method="POST">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('prepare_elimination') }}">
|
||||
<button type="submit" class="btn btn-secondary rounded-0 rounded-start">{{ 'Prepare Custom Elimination'|trans }}</button>
|
||||
@@ -20,15 +21,15 @@
|
||||
</div>
|
||||
</div>
|
||||
<p class="mb-3">{{ 'Number of dropouts:'|trans }} {{ quiz.dropouts }} </p>
|
||||
<table class="table table-hover mb-3">
|
||||
<table class="table table-hover table-result mb-3">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{{ 'Candidate'|trans }}</th>
|
||||
<th style="width: 15%" scope="col">{{ 'Correct Answers'|trans }}</th>
|
||||
<th style="width: 20%" scope="col">{{ 'Corrections'|trans }}</th>
|
||||
<th style="width: 20%" scope="col">{{ 'Penalty'|trans }}</th>
|
||||
<th style="width: 10%" scope="col">{{ 'Score'|trans }}</th>
|
||||
<th style="width: 20%" scope="col">{{ 'Time'|trans }}</th>
|
||||
<th class="col-result-sm" scope="col">{{ 'Correct Answers'|trans }}</th>
|
||||
<th class="col-result-md" scope="col">{{ 'Corrections'|trans }}</th>
|
||||
<th class="col-result-md" scope="col">{{ 'Penalty'|trans }}</th>
|
||||
<th class="col-result-xs" scope="col">{{ 'Score'|trans }}</th>
|
||||
<th class="col-result-md" scope="col">{{ 'Time'|trans }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -40,15 +41,11 @@
|
||||
<form method="post"
|
||||
action="{{ path('tvdt_backoffice_modify_correction', {quiz: quiz.id, candidate: candidate.id}) }}">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('candidate_correction') }}">
|
||||
<div class="row">
|
||||
<div class="col-8">
|
||||
<input class="form-control form-control-sm" type="number"
|
||||
value="{{ candidate.corrections }}" step="0.5"
|
||||
name="corrections">
|
||||
</div>
|
||||
<div class="col-2">
|
||||
<button class="btn btn-sm btn-primary" type="submit">{{ 'Save'|trans }}</button>
|
||||
</div>
|
||||
<div class="d-flex gap-1">
|
||||
<input class="form-control form-control-sm" type="number"
|
||||
value="{{ candidate.corrections }}" step="0.5"
|
||||
name="corrections">
|
||||
<button class="btn btn-sm btn-primary" type="submit">{{ 'Save'|trans }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</td>
|
||||
@@ -56,15 +53,11 @@
|
||||
<form method="post"
|
||||
action="{{ path('tvdt_backoffice_modify_penalty', {quiz: quiz.id, candidate: candidate.id}) }}">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('candidate_penalty') }}">
|
||||
<div class="row">
|
||||
<div class="col-8">
|
||||
<input class="form-control form-control-sm" type="number"
|
||||
value="{{ candidate.penaltySeconds }}" step="1"
|
||||
name="penalty">
|
||||
</div>
|
||||
<div class="col-2">
|
||||
<button class="btn btn-sm btn-primary" type="submit">{{ 'Save'|trans }}</button>
|
||||
</div>
|
||||
<div class="d-flex gap-1">
|
||||
<input class="form-control form-control-sm" type="number"
|
||||
value="{{ candidate.penaltySeconds }}" step="1"
|
||||
name="penalty">
|
||||
<button class="btn btn-sm btn-primary" type="submit">{{ 'Save'|trans }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</td>
|
||||
@@ -78,3 +71,8 @@
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</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) }}
|
||||
</div>
|
||||
<div class="col-md-6 col-12">
|
||||
<p class="mb-3">
|
||||
{{ 'Help text for adding a quiz'|trans }}
|
||||
</p>
|
||||
{{ include('backoffice/help/quiz_add.html.twig') }}
|
||||
</div>
|
||||
</div>
|
||||
{% 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,40 +11,24 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<h2 class="mb-3">{{ 'Season'|trans }}: {{ season.name }}</h2>
|
||||
<div class="row">
|
||||
<div class="col-md-6 col-12">
|
||||
<div class="d-flex flex-row align-items-center mb-3">
|
||||
<h4 class="mb-0 pe-2">{{ 'Quizzes'|trans }}</h4>
|
||||
<a class="link"
|
||||
href="{{ path('tvdt_backoffice_quiz_add', {seasonCode: season.seasonCode}) }}">{{ 'Add'|trans }}</a>
|
||||
</div>
|
||||
<div class="list-group mb-3">
|
||||
{% for quiz in season.quizzes %}
|
||||
<a class="list-group-item list-group-item-action{% if season.activeQuiz == quiz %} active{% endif %}"
|
||||
href="{{ path('tvdt_backoffice_quiz', {seasonCode: season.seasonCode, quiz: quiz.id}) }}">{{ quiz.name }}</a>
|
||||
{% else %}
|
||||
{{ 'No quizzes'|trans }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 col-12">
|
||||
<div class="d-flex flex-row align-items-center mb-3">
|
||||
<h4 class="mb-0 pe-2">{{ 'Candidates'|trans }}</h4>
|
||||
<a class="link"
|
||||
href="{{ path('tvdt_backoffice_add_candidates', {seasonCode: season.seasonCode}) }}">{{ 'Add Candidate'|trans }}
|
||||
</a>
|
||||
</div>
|
||||
<ul class="mb-3">
|
||||
{% for candidate in season.candidates %}
|
||||
<li>{{ candidate.name }}</li>{% endfor %}
|
||||
</ul>
|
||||
{% set tabs = [
|
||||
{id: 'tests', label: 'Quizzes'|trans, route: 'tvdt_backoffice_season'},
|
||||
{id: 'question-bank', label: 'Question bank'|trans, route: 'tvdt_backoffice_question_bank'},
|
||||
{id: 'candidates', label: 'Candidates'|trans, route: 'tvdt_backoffice_season_candidates'},
|
||||
{id: 'settings', label: 'Settings'|trans, route: 'tvdt_backoffice_season_settings'},
|
||||
] %}
|
||||
|
||||
<div class="d-flex flex-row align-items-center mb-3">
|
||||
<h4 class="mb-0 pe-2">{{ 'Settings'|trans }}</h4>
|
||||
</div>
|
||||
{{ form(form) }}
|
||||
</div>
|
||||
<div class="col-12 col-md-3"></div>
|
||||
<h2 class="mb-3">{{ 'Season'|trans }}: {{ season.name }}</h2>
|
||||
<ul class="nav nav-tabs mb-3">
|
||||
{% for tab in tabs %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link{{ activeTab == tab.id ? ' active' }}" href="{{ path(tab.route, {seasonCode: season.seasonCode}) }}">
|
||||
{{ tab.label }}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<div class="pt-3">
|
||||
{{ include(template) }}
|
||||
</div>
|
||||
{% endblock body %}
|
||||
|
||||
@@ -0,0 +1,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}) }}">{{ 'Add from XLSX'|trans }}</a>
|
||||
<a class="btn btn-sm btn-outline-secondary"
|
||||
href="{{ path('tvdt_backoffice_quiz_add_blank', {seasonCode: season.seasonCode}) }}">{{ 'Add blank'|trans }}</a>
|
||||
</div>
|
||||
<div class="list-group mb-3">
|
||||
{% for quiz in season.quizzes %}
|
||||
<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) }}
|
||||
</div>
|
||||
<div class="col-md-6 col-12">
|
||||
<p class="mb-3">
|
||||
{{ 'Help text for creating a season'|trans }}
|
||||
</p>
|
||||
{{ include('backoffice/help/season_add.html.twig') }}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -20,9 +20,7 @@
|
||||
{{ form_end(form) }}
|
||||
</div>
|
||||
<div class="col-md-6 col-12">
|
||||
<p class="mb-3">
|
||||
{{ 'Help text for adding candidates'|trans }}
|
||||
</p>
|
||||
{{ include('backoffice/help/season_add_candidates.html.twig') }}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
{% extends 'base.html.twig' %}
|
||||
{% block importmap %}{{ importmap('quiz') }}{% endblock %}
|
||||
{% block nav %}{{ include('quiz/nav.html.twig') }}{% endblock %}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
{% extends 'quiz/base.html.twig' %}
|
||||
{% block body %}
|
||||
{{ form(form) }}
|
||||
<div class="quiz-form-narrow">
|
||||
{{ form(form) }}
|
||||
</div>
|
||||
{% endblock body %}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
{% if is_granted('IS_AUTHENTICATED') or app.current_route() == 'tvdt_quiz_select_season' %}
|
||||
<div class="quiz-topbar">
|
||||
{% if is_granted('IS_AUTHENTICATED') %}
|
||||
<a href="{{ path('tvdt_backoffice_index') }}" class="btn btn-outline-secondary btn-sm">
|
||||
{{ 'Backoffice'|trans }}
|
||||
</a>
|
||||
<a href="{{ path('tvdt_login_logout') }}" class="btn btn-outline-secondary btn-sm">
|
||||
{{ 'Logout'|trans }}
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="{{ path('tvdt_backoffice_index') }}" class="btn btn-outline-secondary btn-sm">
|
||||
{{ 'Manage Quiz'|trans }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -1,36 +1,43 @@
|
||||
{% extends 'quiz/base.html.twig' %}
|
||||
{% block body %}
|
||||
<h4>
|
||||
{% if season.settings.showNumbers %}
|
||||
({{ question.ordering }}/{{ question.quiz.questions.count }})
|
||||
{% endif %}{{ question.question }}<br/>
|
||||
</h4>
|
||||
<form method="post">
|
||||
<input type="hidden" name="token" value="{{ csrf_token('question') }}">
|
||||
{% if season.settings.confirmAnswers == false %}
|
||||
{% for answer in question.answers %}
|
||||
<div class="quiz-content">
|
||||
<h4>
|
||||
{% if season.settings.showNumbers %}
|
||||
({{ question.ordering }}/{{ question.quiz.questions.count }})
|
||||
{% endif %}{{ question.question }}<br/>
|
||||
</h4>
|
||||
<form method="post">
|
||||
<input type="hidden" name="token" value="{{ csrf_token('question') }}">
|
||||
{% set twoCol = question.answers|length >= 6 %}
|
||||
{% if season.settings.confirmAnswers == false %}
|
||||
<div class="{{ twoCol ? 'quiz-answers-grid' : '' }}">
|
||||
{% for answer in question.answers %}
|
||||
<div class="py-2">
|
||||
<button class="answer-btn"
|
||||
type="submit"
|
||||
name="answer"
|
||||
value="{{ answer.id }}">{{ answer.text }}</button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="{{ twoCol ? 'quiz-answers-grid' : '' }}">
|
||||
{% for answer in question.answers %}
|
||||
<div class="py-1">
|
||||
<input required="required" type="radio" class="btn-check" name="answer"
|
||||
id="answer-{{ loop.index0 }}" autocomplete="off"
|
||||
value="{{ answer.id }}">
|
||||
<label class="answer-btn" for="answer-{{ loop.index0 }}">{{ answer.text }}</label>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="py-2">
|
||||
<button class="btn btn-outline-success"
|
||||
<button class="btn btn-success"
|
||||
type="submit"
|
||||
name="answer"
|
||||
value="{{ answer.id }}">{{ answer.text }}</button>
|
||||
>{{ 'Next'|trans }}</button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
{% for answer in question.answers %}
|
||||
<div class="py-1">
|
||||
<input required="required" type="radio" class="btn-check" name="answer"
|
||||
id="answer-{{ loop.index0 }}" autocomplete="off"
|
||||
value="{{ answer.id }}">
|
||||
<label class="btn btn-outline-secondary" for="answer-{{ loop.index0 }}">{{ answer.text }}</label>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div class="py-2">
|
||||
<button class="btn btn-success"
|
||||
type="submit"
|
||||
>{{ 'Next'|trans }}</button>
|
||||
</div>
|
||||
|
||||
{% endif %}
|
||||
</form>
|
||||
{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
{% endblock body %}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{% extends 'quiz/base.html.twig' %}
|
||||
{% block body %}
|
||||
{{ form(form) }}
|
||||
<a href="{{ path('tvdt_backoffice_index') }}" class="btn btn-outline-secondary">{{ 'Manage Quiz'|trans }}</a>
|
||||
<div class="quiz-form-narrow">
|
||||
{{ form(form) }}
|
||||
</div>
|
||||
{% endblock body %}
|
||||
|
||||
@@ -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']));
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user