mirror of
https://github.com/MarijnDoeve/TijdVoorDeTest.git
synced 2026-07-05 23:20:18 +02:00
Compare commits
29 Commits
54668521f0
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 135e4f0ae5 | |||
| 696537cf35 | |||
| c2dbb9f309 | |||
| d1d1eb3a24 | |||
| 5ea7a636b8 | |||
| d37136be93 | |||
| 212401a97f | |||
| 7c74574d3c | |||
| b1f84d441f | |||
| 8c72b1b217 | |||
| 806cff8c0f | |||
| 815e7b17be | |||
| 764f59e6a7 | |||
| 404c0dcc26 | |||
| 685544ffff | |||
| 6b616465b2 | |||
| 381c2119e7 | |||
| 228a36be95 | |||
| 05ad1ebf2e | |||
| 102b894134 | |||
| 8609fc3552 | |||
| 8cec9faec0 | |||
| bf36b1a6ac | |||
| 0b5c535ed9 | |||
| dcb1f40b7e | |||
| 368790c073 | |||
| 078f838700 | |||
| 841bd5b00d | |||
| 2aea98a839 |
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@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # 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,241 @@
|
||||
# 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
|
||||
|
||||
## Writing Style (Help Content & UI Text)
|
||||
|
||||
When writing Dutch help content in `templates/backoffice/help/nl/`:
|
||||
|
||||
- **No em-dashes** (—): use a comma or restructure the sentence instead.
|
||||
- **No semicolons** (;): use a comma. Semicolons are technically correct but read as AI-generated text.
|
||||
- **Natural Dutch**: write the way a person would explain it to a colleague, not in formal documentation style.
|
||||
- **Colons after bold labels** (e.g. `<strong>Label:</strong> description`) are fine and intentional.
|
||||
|
||||
## Notes for Future Work
|
||||
|
||||
- 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)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'bootstrap/dist/css/bootstrap.min.css';
|
||||
import 'bootstrap-icons/font/bootstrap-icons.min.css';
|
||||
import './styles/backoffice.scss';
|
||||
import './stimulus.js';
|
||||
import './bootstrap.js';
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import {Controller} from '@hotwired/stimulus';
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ['collection'];
|
||||
static values = {prototype: String};
|
||||
|
||||
connect() {
|
||||
this.index = this.collectionTarget.children.length;
|
||||
this._setupDrag();
|
||||
this._syncOrdering();
|
||||
}
|
||||
|
||||
addItem() {
|
||||
const item = document.createElement('div');
|
||||
item.innerHTML = this.prototypeValue.replace(/__name__/g, this.index);
|
||||
const el = item.firstElementChild;
|
||||
this.collectionTarget.appendChild(el);
|
||||
this._makeDraggable(el);
|
||||
this.index++;
|
||||
this._syncOrdering();
|
||||
}
|
||||
|
||||
removeItem(event) {
|
||||
event.target.closest('[data-collection-item]').remove();
|
||||
}
|
||||
|
||||
sortAlphabetically() {
|
||||
const items = [...this.collectionTarget.children];
|
||||
items.sort((a, b) => {
|
||||
const textA = (a.querySelector('input[type="text"]')?.value ?? '').toLowerCase();
|
||||
const textB = (b.querySelector('input[type="text"]')?.value ?? '').toLowerCase();
|
||||
return textA.localeCompare(textB);
|
||||
});
|
||||
items.forEach(item => this.collectionTarget.appendChild(item));
|
||||
this._syncOrdering();
|
||||
}
|
||||
|
||||
randomize() {
|
||||
const items = [...this.collectionTarget.children];
|
||||
for (let i = items.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[items[i], items[j]] = [items[j], items[i]];
|
||||
}
|
||||
items.forEach(item => this.collectionTarget.appendChild(item));
|
||||
this._syncOrdering();
|
||||
}
|
||||
|
||||
// — drag-and-drop —
|
||||
|
||||
_setupDrag() {
|
||||
[...this.collectionTarget.children].forEach(el => this._makeDraggable(el));
|
||||
}
|
||||
|
||||
_makeDraggable(el) {
|
||||
const handle = el.querySelector('[data-drag-handle]');
|
||||
if (!handle) return;
|
||||
|
||||
handle.setAttribute('draggable', 'true');
|
||||
|
||||
handle.addEventListener('dragstart', (e) => {
|
||||
this._dragging = el;
|
||||
el.classList.add('opacity-50');
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
});
|
||||
|
||||
handle.addEventListener('dragend', () => {
|
||||
this._dragging = null;
|
||||
el.classList.remove('opacity-50');
|
||||
this.collectionTarget.querySelectorAll('[data-collection-item]').forEach(i => i.classList.remove('border-top', 'border-bottom', 'border-primary'));
|
||||
});
|
||||
|
||||
el.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
if (!this._dragging || this._dragging === el) return;
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
const rect = el.getBoundingClientRect();
|
||||
const isBottom = e.clientY > rect.top + rect.height / 2;
|
||||
el.classList.toggle('border-top', !isBottom);
|
||||
el.classList.toggle('border-bottom', isBottom);
|
||||
el.classList.add('border-primary');
|
||||
});
|
||||
|
||||
el.addEventListener('dragleave', () => {
|
||||
el.classList.remove('border-top', 'border-bottom', 'border-primary');
|
||||
});
|
||||
|
||||
el.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
el.classList.remove('border-top', 'border-bottom', 'border-primary');
|
||||
if (!this._dragging || this._dragging === el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const isBottom = e.clientY > rect.top + rect.height / 2;
|
||||
this.collectionTarget.insertBefore(this._dragging, isBottom ? el.nextSibling : el);
|
||||
this._syncOrdering();
|
||||
});
|
||||
}
|
||||
|
||||
_syncOrdering() {
|
||||
[...this.collectionTarget.children].forEach((el, i) => {
|
||||
const input = el.querySelector('input[name*="[ordering]"]');
|
||||
if (input) input.value = i;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
+2
-1
@@ -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.*",
|
||||
@@ -65,7 +66,7 @@
|
||||
"symfony/stopwatch": "8.1.*",
|
||||
"symfony/web-profiler-bundle": "8.1.*",
|
||||
"thecodingmachine/phpstan-safe-rule": "^1.4.3",
|
||||
"vincentlanglet/twig-cs-fixer": "^3.14.0"
|
||||
"vincentlanglet/twig-cs-fixer": "^4.0.0"
|
||||
},
|
||||
"config": {
|
||||
"allow-plugins": {
|
||||
|
||||
Generated
+523
-379
File diff suppressed because it is too large
Load Diff
@@ -6,3 +6,4 @@ stof_doctrine_extensions:
|
||||
default:
|
||||
timestampable: true
|
||||
softdeleteable: true
|
||||
loggable: true
|
||||
|
||||
@@ -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,
|
||||
* }>,
|
||||
|
||||
+20
-27
@@ -12,33 +12,26 @@ declare(strict_types=1);
|
||||
* be used as an "entrypoint" (and passed to the importmap() Twig function).
|
||||
*
|
||||
* The "importmap:require" command can be used to add new entries to this file.
|
||||
*
|
||||
* @return array<string, array{ // Import name as key, description of the imported file as value
|
||||
* path: string, // Logical, relative or absolute path to the file
|
||||
* type?: 'js'|'css'|'json', // Type of the file, defaults to 'js'
|
||||
* entrypoint?: bool, // Whether the file is an entrypoint, for 'js' only
|
||||
* }|array{
|
||||
* version: string, // Version of the remote package
|
||||
* package_specifier?: string, // Remote "package-name/path" specifier, defaults to the import name
|
||||
* type?: 'js'|'css'|'json',
|
||||
* entrypoint?: bool,
|
||||
* }>
|
||||
*/
|
||||
return [
|
||||
'quiz' => [
|
||||
'path' => './assets/quiz.js',
|
||||
'entrypoint' => true,
|
||||
],
|
||||
'backoffice' => [
|
||||
'path' => './assets/backoffice.js',
|
||||
'entrypoint' => true,
|
||||
],
|
||||
'@symfony/stimulus-bundle' => [
|
||||
'path' => './vendor/symfony/stimulus-bundle/assets/dist/loader.js',
|
||||
],
|
||||
'bootstrap' => [
|
||||
'version' => '5.3.8',
|
||||
],
|
||||
'@popperjs/core' => [
|
||||
'version' => '2.11.8',
|
||||
],
|
||||
'bootstrap/dist/css/bootstrap.min.css' => [
|
||||
'version' => '5.3.8',
|
||||
'type' => 'css',
|
||||
],
|
||||
'@hotwired/stimulus' => [
|
||||
'version' => '3.2.2',
|
||||
],
|
||||
'@hotwired/turbo' => [
|
||||
'version' => '8.0.23',
|
||||
],
|
||||
'quiz' => ['path' => './assets/quiz.js', 'entrypoint' => true],
|
||||
'backoffice' => ['path' => './assets/backoffice.js', 'entrypoint' => true],
|
||||
'@symfony/stimulus-bundle' => ['path' => './vendor/symfony/stimulus-bundle/assets/dist/loader.js'],
|
||||
'bootstrap' => ['version' => '5.3.8'],
|
||||
'@popperjs/core' => ['version' => '2.11.8'],
|
||||
'bootstrap/dist/css/bootstrap.min.css' => ['version' => '5.3.8', 'type' => 'css'],
|
||||
'@hotwired/stimulus' => ['version' => '3.2.2'],
|
||||
'@hotwired/turbo' => ['version' => '8.0.23'],
|
||||
'bootstrap-icons/font/bootstrap-icons.min.css' => ['version' => '1.13.1', 'type' => 'css'],
|
||||
];
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
<?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 Version20260705142647 extends AbstractMigration
|
||||
{
|
||||
#[\Override]
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add season question bank tables, quiz finalization, Gedmo Loggable log entries, and label colour/slug';
|
||||
}
|
||||
|
||||
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, question_id UUID DEFAULT NULL, bank_question_id UUID NOT NULL, quiz_id UUID NOT NULL, PRIMARY KEY (id))');
|
||||
$this->addSql('CREATE INDEX IDX_775833AD1E27F6BF ON bank_question_usage (question_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 ext_log_entries (data JSON DEFAULT NULL, id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, action VARCHAR(8) NOT NULL, logged_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, object_id VARCHAR(64) DEFAULT NULL, object_class VARCHAR(191) NOT NULL, version INT NOT NULL, username VARCHAR(191) DEFAULT NULL, PRIMARY KEY (id))');
|
||||
$this->addSql('CREATE INDEX log_class_lookup_idx ON ext_log_entries (object_class)');
|
||||
$this->addSql('CREATE INDEX log_date_lookup_idx ON ext_log_entries (logged_at)');
|
||||
$this->addSql('CREATE INDEX log_user_lookup_idx ON ext_log_entries (username)');
|
||||
$this->addSql('CREATE INDEX log_version_lookup_idx ON ext_log_entries (object_id, object_class, version)');
|
||||
$this->addSql("CREATE TABLE question_label (id UUID NOT NULL, colour VARCHAR(16) DEFAULT 'secondary' NOT NULL, slug VARCHAR(64) 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('CREATE UNIQUE INDEX uq_question_label_slug_season ON question_label (slug, 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_775833AD1E27F6BF FOREIGN KEY (question_id) REFERENCES question (id) ON DELETE SET NULL NOT DEFERRABLE');
|
||||
$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');
|
||||
}
|
||||
|
||||
#[\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_775833AD1E27F6BF');
|
||||
$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 ext_log_entries');
|
||||
$this->addSql('DROP TABLE question_label');
|
||||
$this->addSql('ALTER TABLE quiz DROP finalized_at');
|
||||
}
|
||||
}
|
||||
+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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ use Tvdt\Controller\AbstractController;
|
||||
use Tvdt\Entity\Elimination;
|
||||
use Tvdt\Entity\Quiz;
|
||||
use Tvdt\Entity\Season;
|
||||
use Tvdt\Enum\FlashType;
|
||||
use Tvdt\Factory\EliminationFactory;
|
||||
|
||||
final class PrepareEliminationController extends AbstractController
|
||||
@@ -52,7 +53,7 @@ final class PrepareEliminationController extends AbstractController
|
||||
return $this->redirectToRoute('tvdt_elimination', ['elimination' => $elimination->id]);
|
||||
}
|
||||
|
||||
$this->addFlash('success', 'Elimination updated');
|
||||
$this->addFlash(FlashType::Success, 'Elimination updated');
|
||||
|
||||
return $this->redirectToRoute('tvdt_prepare_elimination_view', ['elimination' => $elimination->id]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
<?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\String\Slugger\SluggerInterface;
|
||||
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\Enum\LabelColour;
|
||||
use Tvdt\Exception\BankQuestionAlreadyUsedException;
|
||||
use Tvdt\Exception\BankQuestionIncompleteException;
|
||||
use Tvdt\Exception\QuizLockedException;
|
||||
use Tvdt\Form\BankQuestionFormType;
|
||||
use Tvdt\Repository\BankQuestionRepository;
|
||||
use Tvdt\Repository\QuestionLabelRepository;
|
||||
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 QuestionLabelRepository $questionLabelRepository,
|
||||
private readonly QuizRepository $quizRepository,
|
||||
private readonly QuestionBankService $questionBankService,
|
||||
private readonly SluggerInterface $slugger,
|
||||
) {}
|
||||
|
||||
#[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;
|
||||
$labelSlug = $request->query->getString('label');
|
||||
if ('' !== $labelSlug) {
|
||||
$label = $this->questionLabelRepository->findBySlugAndSeason($labelSlug, $season);
|
||||
}
|
||||
|
||||
return $this->render('backoffice/season.html.twig', [
|
||||
'season' => $season,
|
||||
'bankQuestions' => $this->bankQuestionRepository->findBySeason($season, $label),
|
||||
'assignableQuizzes' => $this->quizRepository->findAssignableForSeason($season),
|
||||
'activeLabel' => $label,
|
||||
'labelColours' => LabelColour::cases(),
|
||||
'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->em->flush();
|
||||
|
||||
$this->syncUsagesAfterEdit($bankQuestion);
|
||||
|
||||
$this->addFlash(FlashType::Success, $this->translator->trans('Question updated'));
|
||||
|
||||
return $this->redirectToRoute('tvdt_backoffice_question_bank', ['seasonCode' => $season->seasonCode]);
|
||||
}
|
||||
|
||||
return $this->render('backoffice/question_bank/form.html.twig', [
|
||||
'season' => $season,
|
||||
'form' => $form,
|
||||
'bankQuestion' => $bankQuestion,
|
||||
]);
|
||||
}
|
||||
|
||||
#[IsCsrfTokenValid('delete_bank_question')]
|
||||
#[IsGranted(SeasonVoter::DELETE, subject: 'season')]
|
||||
#[Route(
|
||||
'/backoffice/season/{seasonCode:season}/question-bank/{bankQuestion}/delete',
|
||||
name: 'tvdt_backoffice_question_bank_delete',
|
||||
requirements: ['seasonCode' => self::SEASON_CODE_REGEX, 'bankQuestion' => Requirement::UUID],
|
||||
methods: ['POST'],
|
||||
priority: 10,
|
||||
)]
|
||||
public function delete(Season $season, BankQuestion $bankQuestion): RedirectResponse
|
||||
{
|
||||
$this->assertSameSeason($season, $bankQuestion->season);
|
||||
|
||||
$hasLockedUsages = $bankQuestion->usages->exists(
|
||||
static fn (int $key, BankQuestionUsage $usage): bool => $usage->quiz->isLocked,
|
||||
);
|
||||
if ($hasLockedUsages) {
|
||||
$this->addFlash(FlashType::Danger, $this->translator->trans('This question cannot be deleted because it is used in a locked or active quiz'));
|
||||
|
||||
return $this->redirectToRoute('tvdt_backoffice_question_bank', ['seasonCode' => $season->seasonCode]);
|
||||
}
|
||||
|
||||
$this->em->remove($bankQuestion);
|
||||
$this->em->flush();
|
||||
|
||||
$this->addFlash(FlashType::Success, $this->translator->trans('Question removed from the question bank'));
|
||||
|
||||
return $this->redirectToRoute('tvdt_backoffice_question_bank', ['seasonCode' => $season->seasonCode]);
|
||||
}
|
||||
|
||||
#[IsCsrfTokenValid('assign_bank_question')]
|
||||
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
|
||||
#[Route(
|
||||
'/backoffice/season/{seasonCode:season}/question-bank/{bankQuestion}/assign',
|
||||
name: 'tvdt_backoffice_question_bank_assign',
|
||||
requirements: ['seasonCode' => self::SEASON_CODE_REGEX, 'bankQuestion' => Requirement::UUID],
|
||||
methods: ['POST'],
|
||||
priority: 10,
|
||||
)]
|
||||
public function assign(Season $season, BankQuestion $bankQuestion, Request $request): RedirectResponse
|
||||
{
|
||||
$this->assertSameSeason($season, $bankQuestion->season);
|
||||
|
||||
$quizId = $request->request->getString('quiz');
|
||||
if (!Uuid::isValid($quizId)) {
|
||||
throw new BadRequestHttpException('Invalid quiz');
|
||||
}
|
||||
|
||||
$quiz = $this->em->getRepository(Quiz::class)->find($quizId);
|
||||
if (!$quiz instanceof Quiz || $quiz->season !== $season) {
|
||||
throw new BadRequestHttpException('Invalid quiz');
|
||||
}
|
||||
|
||||
$this->denyAccessUnlessGranted(SeasonVoter::MODIFY_QUIZ_CONTENT, $quiz);
|
||||
|
||||
try {
|
||||
$this->questionBankService->assignToQuiz($bankQuestion, $quiz);
|
||||
$this->addFlash(FlashType::Success, $this->translator->trans('Question added to quiz %quiz%', ['%quiz%' => $quiz->name]));
|
||||
} catch (QuizLockedException) {
|
||||
$this->addFlash(FlashType::Danger, $this->translator->trans('This quiz can no longer be altered'));
|
||||
} catch (BankQuestionAlreadyUsedException) {
|
||||
$this->addFlash(FlashType::Danger, $this->translator->trans('This question has already been used'));
|
||||
} catch (BankQuestionIncompleteException) {
|
||||
$this->addFlash(FlashType::Warning, $this->translator->trans('This question is incomplete: it needs at least two answers and exactly one correct answer'));
|
||||
}
|
||||
|
||||
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]);
|
||||
}
|
||||
|
||||
$slug = mb_strtolower($this->slugger->slug($name)->toString());
|
||||
|
||||
$colour = LabelColour::tryFrom($request->request->getString('colour')) ?? LabelColour::Gray;
|
||||
|
||||
$exists = $season->questionLabels->exists(static fn (int $key, QuestionLabel $label): bool => $label->name === $name);
|
||||
if (!$exists) {
|
||||
if ($this->questionLabelRepository->slugExistsForSeason($slug, $season)) {
|
||||
$this->addFlash(FlashType::Danger, $this->translator->trans('A label with a similar name already exists'));
|
||||
|
||||
return $this->redirectToRoute('tvdt_backoffice_question_bank', ['seasonCode' => $season->seasonCode]);
|
||||
}
|
||||
|
||||
try {
|
||||
$newLabel = new QuestionLabel($name);
|
||||
$newLabel->slug = $slug;
|
||||
$newLabel->colour = $colour;
|
||||
$season->addQuestionLabel($newLabel);
|
||||
$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/{labelSlug}/delete',
|
||||
name: 'tvdt_backoffice_question_bank_label_delete',
|
||||
requirements: ['seasonCode' => self::SEASON_CODE_REGEX, 'labelSlug' => '[a-z0-9-]+'],
|
||||
methods: ['POST'],
|
||||
priority: 15,
|
||||
)]
|
||||
public function deleteLabel(Season $season, string $labelSlug): RedirectResponse
|
||||
{
|
||||
$label = $this->questionLabelRepository->findBySlugAndSeason($labelSlug, $season);
|
||||
if (!$label instanceof QuestionLabel) {
|
||||
throw $this->createNotFoundException();
|
||||
}
|
||||
|
||||
foreach ($label->bankQuestions as $bankQuestion) {
|
||||
$bankQuestion->removeLabel($label);
|
||||
}
|
||||
|
||||
$this->em->remove($label);
|
||||
$this->em->flush();
|
||||
|
||||
$this->addFlash(FlashType::Success, $this->translator->trans('Label removed'));
|
||||
|
||||
return $this->redirectToRoute('tvdt_backoffice_question_bank', ['seasonCode' => $season->seasonCode]);
|
||||
}
|
||||
|
||||
#[IsCsrfTokenValid('unassign_bank_question')]
|
||||
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
|
||||
#[Route(
|
||||
'/backoffice/season/{seasonCode:season}/question-bank/{bankQuestion}/unassign/{usage}',
|
||||
name: 'tvdt_backoffice_question_bank_unassign',
|
||||
requirements: ['seasonCode' => self::SEASON_CODE_REGEX, 'bankQuestion' => Requirement::UUID, 'usage' => Requirement::UUID],
|
||||
methods: ['POST'],
|
||||
priority: 10,
|
||||
)]
|
||||
public function unassign(Season $season, BankQuestion $bankQuestion, BankQuestionUsage $usage): RedirectResponse
|
||||
{
|
||||
$this->assertSameSeason($season, $bankQuestion->season);
|
||||
|
||||
if ($usage->bankQuestion !== $bankQuestion) {
|
||||
throw new NotFoundHttpException();
|
||||
}
|
||||
|
||||
if ($usage->quiz->isLocked) {
|
||||
$this->addFlash(FlashType::Danger, $this->translator->trans('This quiz can no longer be altered'));
|
||||
|
||||
return $this->redirectToRoute('tvdt_backoffice_question_bank', ['seasonCode' => $season->seasonCode]);
|
||||
}
|
||||
|
||||
$this->questionBankService->unassignFromQuiz($usage);
|
||||
$this->addFlash(FlashType::Success, $this->translator->trans('Question removed from quiz %quiz%', ['%quiz%' => $usage->quiz->name]));
|
||||
|
||||
return $this->redirectToRoute('tvdt_backoffice_question_bank', ['seasonCode' => $season->seasonCode]);
|
||||
}
|
||||
|
||||
#[IsCsrfTokenValid('sync_bank_question')]
|
||||
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
|
||||
#[Route(
|
||||
'/backoffice/season/{seasonCode:season}/question-bank/{bankQuestion}/sync/{usage}',
|
||||
name: 'tvdt_backoffice_question_bank_sync',
|
||||
requirements: ['seasonCode' => self::SEASON_CODE_REGEX, 'bankQuestion' => Requirement::UUID, 'usage' => Requirement::UUID],
|
||||
methods: ['POST'],
|
||||
priority: 10,
|
||||
)]
|
||||
public function syncToQuiz(Season $season, BankQuestion $bankQuestion, BankQuestionUsage $usage): RedirectResponse
|
||||
{
|
||||
$this->assertSameSeason($season, $bankQuestion->season);
|
||||
|
||||
if ($usage->bankQuestion !== $bankQuestion) {
|
||||
throw new NotFoundHttpException();
|
||||
}
|
||||
|
||||
if ($usage->quiz->isLocked) {
|
||||
$this->addFlash(FlashType::Danger, $this->translator->trans('This quiz can no longer be altered'));
|
||||
|
||||
return $this->redirectToRoute('tvdt_backoffice_question_bank', ['seasonCode' => $season->seasonCode]);
|
||||
}
|
||||
|
||||
$this->questionBankService->syncToQuiz($bankQuestion, $usage);
|
||||
$this->em->flush();
|
||||
$this->addFlash(FlashType::Success, $this->translator->trans('Question synced to quiz %quiz%', ['%quiz%' => $usage->quiz->name]));
|
||||
|
||||
return $this->redirectToRoute('tvdt_backoffice_question_bank', ['seasonCode' => $season->seasonCode]);
|
||||
}
|
||||
|
||||
private function assertSameSeason(Season $season, Season $subjectSeason): void
|
||||
{
|
||||
if ($season !== $subjectSeason) {
|
||||
throw new NotFoundHttpException();
|
||||
}
|
||||
}
|
||||
|
||||
private function applyAnswerOrdering(BankQuestion $bankQuestion): void
|
||||
{
|
||||
$ordering = 1;
|
||||
foreach ($bankQuestion->answers as $answer) {
|
||||
$answer->ordering = $ordering++;
|
||||
}
|
||||
}
|
||||
|
||||
private function syncUsagesAfterEdit(BankQuestion $bankQuestion): void
|
||||
{
|
||||
$pendingNames = [];
|
||||
$synced = false;
|
||||
foreach ($bankQuestion->usages as $usage) {
|
||||
if (!$usage->quiz->isLocked) {
|
||||
$this->questionBankService->syncToQuiz($bankQuestion, $usage);
|
||||
$synced = true;
|
||||
} else {
|
||||
$pendingNames[] = $usage->quiz->name;
|
||||
}
|
||||
}
|
||||
|
||||
if ($synced) {
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
if ([] !== $pendingNames) {
|
||||
$this->addFlash(
|
||||
FlashType::Warning,
|
||||
$this->translator->trans(
|
||||
'The question was not synced to finalized quiz(zes): %quizzes%. Use the Sync button to update them.',
|
||||
['%quizzes%' => implode(', ', $pendingNames)],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace Tvdt\Controller\Backoffice;
|
||||
|
||||
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'));
|
||||
$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'));
|
||||
} else {
|
||||
$this->addFlash(FlashType::Warning, $this->translator->trans('The quiz is already finalized'));
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('tvdt_backoffice_quiz', ['seasonCode' => $quiz->season->seasonCode, 'quiz' => $quiz->id]);
|
||||
}
|
||||
|
||||
#[IsCsrfTokenValid('unfinalize_quiz')]
|
||||
#[IsGranted(SeasonVoter::EDIT, subject: 'quiz')]
|
||||
#[Route(
|
||||
'/backoffice/quiz/{quiz}/unfinalize',
|
||||
name: 'tvdt_backoffice_quiz_unfinalize',
|
||||
requirements: ['quiz' => Requirement::UUID],
|
||||
methods: ['POST'],
|
||||
)]
|
||||
public function unfinalizeQuiz(Quiz $quiz): RedirectResponse
|
||||
{
|
||||
if ($quiz->hasStartedCandidates) {
|
||||
$this->addFlash(FlashType::Danger, $this->translator->trans('The quiz has already been filled in and can no longer be altered'));
|
||||
} elseif ($quiz->season->activeQuiz === $quiz) {
|
||||
$this->addFlash(FlashType::Danger, $this->translator->trans('Deactivate the quiz before undoing the finalization'));
|
||||
} else {
|
||||
$quiz->finalizedAt = null;
|
||||
$this->em->flush();
|
||||
$this->addFlash(FlashType::Success, $this->translator->trans('Quiz is no longer finalized'));
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('tvdt_backoffice_quiz', ['seasonCode' => $quiz->season->seasonCode, 'quiz' => $quiz->id]);
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Controller\Backoffice;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Attribute\AsController;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Routing\Requirement\Requirement;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
use Tvdt\Controller\AbstractController;
|
||||
use Tvdt\Entity\Question;
|
||||
use Tvdt\Entity\Quiz;
|
||||
use Tvdt\Entity\Season;
|
||||
use Tvdt\Enum\FlashType;
|
||||
use Tvdt\Form\QuestionFormType;
|
||||
use Tvdt\Security\Voter\SeasonVoter;
|
||||
|
||||
#[AsController]
|
||||
#[IsGranted('ROLE_USER')]
|
||||
class QuizQuestionController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly TranslatorInterface $translator,
|
||||
) {}
|
||||
|
||||
#[IsGranted(SeasonVoter::MODIFY_QUIZ_CONTENT, subject: 'question')]
|
||||
#[Route(
|
||||
'/backoffice/season/{seasonCode:season}/quiz/{quiz}/question/{question}/edit',
|
||||
name: 'tvdt_backoffice_quiz_question_edit',
|
||||
requirements: ['seasonCode' => self::SEASON_CODE_REGEX, 'quiz' => Requirement::UUID, 'question' => Requirement::UUID],
|
||||
)]
|
||||
public function edit(Season $season, Quiz $quiz, Question $question, Request $request): Response
|
||||
{
|
||||
if ($question->quiz !== $quiz || $quiz->season !== $season) {
|
||||
throw new NotFoundHttpException();
|
||||
}
|
||||
|
||||
$form = $this->createForm(QuestionFormType::class, $question);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$this->applyAnswerOrdering($question);
|
||||
$this->em->flush();
|
||||
|
||||
$this->addFlash(FlashType::Success, $this->translator->trans('Question updated'));
|
||||
|
||||
return $this->redirectToRoute('tvdt_backoffice_quiz_overview', [
|
||||
'seasonCode' => $season->seasonCode,
|
||||
'quiz' => $quiz->id,
|
||||
]);
|
||||
}
|
||||
|
||||
return $this->render('backoffice/quiz/question_form.html.twig', [
|
||||
'season' => $season,
|
||||
'quiz' => $quiz,
|
||||
'question' => $question,
|
||||
'form' => $form,
|
||||
]);
|
||||
}
|
||||
|
||||
private function applyAnswerOrdering(Question $question): void
|
||||
{
|
||||
$ordering = 1;
|
||||
foreach ($question->answers as $answer) {
|
||||
$answer->ordering = $ordering++;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,13 +4,19 @@ declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Controller\Backoffice;
|
||||
|
||||
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\FormError;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Attribute\AsController;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
use Symfony\Component\Validator\Constraints\Length;
|
||||
use Symfony\Component\Validator\Constraints\NotBlank;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
use Tvdt\Controller\AbstractController;
|
||||
use Tvdt\Entity\Candidate;
|
||||
@@ -39,7 +45,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 +85,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',
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -78,7 +120,7 @@ class SeasonController extends AbstractController
|
||||
return $this->redirectToRoute('tvdt_backoffice_season', ['seasonCode' => $season->seasonCode]);
|
||||
}
|
||||
|
||||
return $this->render('backoffice/season_add_candidates.html.twig', ['form' => $form]);
|
||||
return $this->render('backoffice/season_add_candidates.html.twig', ['form' => $form, 'season' => $season]);
|
||||
}
|
||||
|
||||
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
|
||||
@@ -112,4 +154,52 @@ 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' => $this->translator->trans('Quiz name'),
|
||||
'translation_domain' => false,
|
||||
'constraints' => [
|
||||
new NotBlank(),
|
||||
new Length(max: 64),
|
||||
],
|
||||
])
|
||||
->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);
|
||||
|
||||
try {
|
||||
$this->em->flush();
|
||||
} catch (UniqueConstraintViolationException) {
|
||||
$form->get('name')->addError(new FormError($this->translator->trans('A quiz with this name already exists in this season')));
|
||||
|
||||
return $this->render('/backoffice/quiz_add_blank.html.twig', ['form' => $form, 'season' => $season]);
|
||||
}
|
||||
|
||||
$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]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ use Symfony\Component\Security\Core\User\UserInterface;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
use SymfonyCasts\Bundle\VerifyEmail\Exception\VerifyEmailExceptionInterface;
|
||||
use Tvdt\Entity\User;
|
||||
use Tvdt\Enum\FlashType;
|
||||
use Tvdt\Form\RegistrationFormType;
|
||||
use Tvdt\Repository\UserRepository;
|
||||
use Tvdt\Security\EmailVerifier;
|
||||
@@ -95,7 +96,7 @@ final class RegistrationController extends AbstractController
|
||||
return $this->redirectToRoute('tvdt_register');
|
||||
}
|
||||
|
||||
$this->addFlash('success', $this->translator->trans('Your email address has been verified.'));
|
||||
$this->addFlash(FlashType::Success->value, $this->translator->trans('Your email address has been verified.'));
|
||||
|
||||
return $this->redirectToRoute('tvdt_backoffice_index');
|
||||
}
|
||||
|
||||
@@ -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,68 @@ 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');
|
||||
$location->slug = 'locatie';
|
||||
|
||||
$season->addQuestionLabel($location);
|
||||
$finale = new QuestionLabel('Finale');
|
||||
$finale->slug = '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,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Entity;
|
||||
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\MappedSuperclass]
|
||||
abstract class AbstractBaseAnswer implements \Stringable
|
||||
{
|
||||
#[ORM\Column(type: Types::SMALLINT, options: ['default' => 0])]
|
||||
public int $ordering = 0;
|
||||
|
||||
public function __construct(
|
||||
#[ORM\Column(length: 255)]
|
||||
public string $text,
|
||||
#[ORM\Column]
|
||||
public bool $isRightAnswer = false,
|
||||
) {}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->text;
|
||||
}
|
||||
}
|
||||
+4
-16
@@ -6,14 +6,13 @@ 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;
|
||||
use Tvdt\Repository\AnswerRepository;
|
||||
|
||||
#[ORM\Entity(repositoryClass: AnswerRepository::class)]
|
||||
class Answer implements \Stringable
|
||||
class Answer extends AbstractBaseAnswer
|
||||
{
|
||||
#[ORM\Column(type: UuidType::NAME)]
|
||||
#[ORM\CustomIdGenerator(class: 'doctrine.uuid_generator')]
|
||||
@@ -21,9 +20,6 @@ class Answer implements \Stringable
|
||||
#[ORM\Id]
|
||||
public private(set) Uuid $id;
|
||||
|
||||
#[ORM\Column(type: Types::SMALLINT, options: ['default' => 0])]
|
||||
public int $ordering = 0;
|
||||
|
||||
#[ORM\JoinColumn(nullable: false)]
|
||||
#[ORM\ManyToOne(inversedBy: 'answers')]
|
||||
public Question $question;
|
||||
@@ -36,12 +32,9 @@ class Answer implements \Stringable
|
||||
#[ORM\OneToMany(targetEntity: GivenAnswer::class, mappedBy: 'answer', orphanRemoval: true)]
|
||||
public private(set) Collection $givenAnswers;
|
||||
|
||||
public function __construct(
|
||||
#[ORM\Column(length: 255)]
|
||||
public string $text,
|
||||
#[ORM\Column]
|
||||
public bool $isRightAnswer = false,
|
||||
) {
|
||||
public function __construct(string $text, bool $isRightAnswer = false)
|
||||
{
|
||||
parent::__construct($text, $isRightAnswer);
|
||||
$this->candidates = new ArrayCollection();
|
||||
$this->givenAnswers = new ArrayCollection();
|
||||
}
|
||||
@@ -57,9 +50,4 @@ class Answer implements \Stringable
|
||||
{
|
||||
$this->candidates->removeElement($candidate);
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->text;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Entity;
|
||||
|
||||
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 extends AbstractBaseAnswer
|
||||
{
|
||||
#[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;
|
||||
|
||||
#[Map(if: false)]
|
||||
#[ORM\JoinColumn(nullable: false)]
|
||||
#[ORM\ManyToOne(inversedBy: 'answers')]
|
||||
public BankQuestion $bankQuestion;
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
<?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 Gedmo\Mapping\Annotation as Gedmo;
|
||||
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;
|
||||
|
||||
#[Gedmo\Loggable(logEntryClass: LogEntry::class)]
|
||||
#[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;
|
||||
|
||||
#[Gedmo\Versioned]
|
||||
#[ORM\Column(length: 255)]
|
||||
public string $question;
|
||||
|
||||
#[Map(if: false)]
|
||||
#[ORM\JoinColumn(nullable: false)]
|
||||
#[ORM\ManyToOne(inversedBy: 'bankQuestions')]
|
||||
public Season $season;
|
||||
|
||||
#[Gedmo\Versioned]
|
||||
#[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> */
|
||||
#[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 bool $isUsed {
|
||||
get => !$this->usages->isEmpty();
|
||||
}
|
||||
|
||||
public bool $canBeAssigned {
|
||||
get => $this->reusable || !$this->isUsed;
|
||||
}
|
||||
|
||||
/** True when the question is fully complete and can be assigned to a quiz. */
|
||||
public bool $isCompleteForQuiz {
|
||||
get => $this->answers->count() >= 2
|
||||
&& 1 === $this->answers->filter(static fn (BankAnswer $answer): bool => $answer->isRightAnswer)->count();
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
if ($this->answers->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->answers->filter(static fn (BankAnswer $answer): bool => $answer->isRightAnswer)->count();
|
||||
|
||||
if ($this->answers->count() < 2) {
|
||||
$context->buildViolation('A question needs at least two answers')
|
||||
->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,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Entity;
|
||||
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Gedmo\Loggable\Entity\MappedSuperclass\AbstractLogEntry;
|
||||
use Gedmo\Loggable\Entity\Repository\LogEntryRepository;
|
||||
|
||||
/**
|
||||
* Custom LogEntry that stores change data as JSON (instead of serialized `array`)
|
||||
* so it works with the PostgreSQL-only DBAL setup in this project.
|
||||
*
|
||||
* @extends AbstractLogEntry<object>
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: LogEntryRepository::class)]
|
||||
#[ORM\Index(name: 'log_class_lookup_idx', columns: ['object_class'])]
|
||||
#[ORM\Index(name: 'log_date_lookup_idx', columns: ['logged_at'])]
|
||||
#[ORM\Index(name: 'log_user_lookup_idx', columns: ['username'])]
|
||||
#[ORM\Index(name: 'log_version_lookup_idx', columns: ['object_id', 'object_class', 'version'])]
|
||||
#[ORM\Table(name: 'ext_log_entries')]
|
||||
class LogEntry extends AbstractLogEntry
|
||||
{
|
||||
#[ORM\Column(type: Types::JSON, nullable: true)]
|
||||
#[\Override]
|
||||
protected $data;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?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\Enum\LabelColour;
|
||||
use Tvdt\Repository\QuestionLabelRepository;
|
||||
|
||||
#[ORM\Entity(repositoryClass: QuestionLabelRepository::class)]
|
||||
#[ORM\UniqueConstraint(fields: ['name', 'season'])]
|
||||
#[ORM\UniqueConstraint(name: 'uq_question_label_slug_season', fields: ['slug', '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;
|
||||
|
||||
#[ORM\Column(length: 16, enumType: LabelColour::class, options: ['default' => 'secondary'])]
|
||||
public LabelColour $colour = LabelColour::Gray;
|
||||
|
||||
#[ORM\Column(length: 64)]
|
||||
public string $slug = '';
|
||||
|
||||
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,19 @@ class Quiz
|
||||
return $this;
|
||||
}
|
||||
|
||||
public bool $isFinalized {
|
||||
get => $this->finalizedAt instanceof \DateTimeImmutable;
|
||||
}
|
||||
|
||||
public bool $hasStartedCandidates {
|
||||
get => $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 bool $isLocked {
|
||||
get => $this->isFinalized || $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,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Enum;
|
||||
|
||||
use Symfony\Component\Translation\TranslatableMessage;
|
||||
|
||||
enum LabelColour: string
|
||||
{
|
||||
case Blue = 'primary';
|
||||
case Gray = 'secondary';
|
||||
case Green = 'success';
|
||||
case Red = 'danger';
|
||||
case Yellow = 'warning';
|
||||
case Cyan = 'info';
|
||||
case White = 'light';
|
||||
|
||||
public function label(): TranslatableMessage
|
||||
{
|
||||
return match ($this) {
|
||||
self::Blue => new TranslatableMessage('Blue'),
|
||||
self::Gray => new TranslatableMessage('Gray'),
|
||||
self::Green => new TranslatableMessage('Green'),
|
||||
self::Red => new TranslatableMessage('Red'),
|
||||
self::Yellow => new TranslatableMessage('Yellow'),
|
||||
self::Cyan => new TranslatableMessage('Cyan'),
|
||||
self::White => new TranslatableMessage('White'),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 BankQuestionIncompleteException extends \Exception {}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Exception;
|
||||
|
||||
class QuizLockedException extends \Exception {}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?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\HiddenType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
|
||||
/**
|
||||
* @template TAnswer of object
|
||||
*
|
||||
* @extends AbstractType<TAnswer>
|
||||
*/
|
||||
abstract class AbstractBaseAnswerFormType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
->add('ordering', HiddenType::class, ['empty_data' => '0'])
|
||||
->add('text', TextType::class, [
|
||||
'label' => false,
|
||||
'attr' => ['placeholder' => 'Answer', 'maxlength' => 255],
|
||||
])
|
||||
->add('isRightAnswer', CheckboxType::class, [
|
||||
'label' => 'Correct',
|
||||
'required' => false,
|
||||
])
|
||||
;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Form;
|
||||
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
use Tvdt\Entity\Answer;
|
||||
|
||||
/** @extends AbstractBaseAnswerFormType<Answer> */
|
||||
class AnswerFormType extends AbstractBaseAnswerFormType
|
||||
{
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => Answer::class,
|
||||
'empty_data' => static fn (): Answer => new Answer(''),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Form;
|
||||
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
use Tvdt\Entity\BankAnswer;
|
||||
|
||||
/** @extends AbstractBaseAnswerFormType<BankAnswer> */
|
||||
class BankAnswerFormType extends AbstractBaseAnswerFormType
|
||||
{
|
||||
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,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Form;
|
||||
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
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\Question;
|
||||
|
||||
/** @extends AbstractType<Question> */
|
||||
class QuestionFormType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
->add('question', TextType::class, [
|
||||
'label' => 'Question',
|
||||
'attr' => ['maxlength' => 255],
|
||||
])
|
||||
->add('answers', CollectionType::class, [
|
||||
'label' => 'Answers',
|
||||
'entry_type' => AnswerFormType::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' => Question::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?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')
|
||||
->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> $questions */
|
||||
$questions = $queryBuilder->getQuery()->getResult();
|
||||
|
||||
if ([] === $questions) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Load each many-to-many/one-to-many collection in a separate query to avoid
|
||||
// the Cartesian-product row explosion that occurs when joining multiple collections at once.
|
||||
$this->createQueryBuilder('bq')
|
||||
->select('partial bq.{id}', 'ba')
|
||||
->leftJoin('bq.answers', 'ba')
|
||||
->where('bq.season = :season')
|
||||
->setParameter('season', $season)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
|
||||
$this->createQueryBuilder('bq')
|
||||
->select('partial bq.{id}', 'l')
|
||||
->leftJoin('bq.labels', 'l')
|
||||
->where('bq.season = :season')
|
||||
->setParameter('season', $season)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
|
||||
$this->createQueryBuilder('bq')
|
||||
->select('partial bq.{id}', 'u', 'uq')
|
||||
->leftJoin('bq.usages', 'u')
|
||||
->leftJoin('u.quiz', 'uq')
|
||||
->where('bq.season = :season')
|
||||
->setParameter('season', $season)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
|
||||
return $questions;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Repository;
|
||||
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
use Tvdt\Entity\QuestionLabel;
|
||||
use Tvdt\Entity\Season;
|
||||
|
||||
/** @extends ServiceEntityRepository<QuestionLabel> */
|
||||
class QuestionLabelRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, QuestionLabel::class);
|
||||
}
|
||||
|
||||
public function findBySlugAndSeason(string $slug, Season $season): ?QuestionLabel
|
||||
{
|
||||
return $this->findOneBy(['slug' => $slug, 'season' => $season]);
|
||||
}
|
||||
|
||||
public function slugExistsForSeason(string $slug, Season $season, ?QuestionLabel $excluding = null): bool
|
||||
{
|
||||
$qb = $this->createQueryBuilder('l')
|
||||
->where('l.slug = :slug')
|
||||
->andWhere('l.season = :season')
|
||||
->setParameter('slug', $slug)
|
||||
->setParameter('season', $season);
|
||||
|
||||
if ($excluding instanceof QuestionLabel) {
|
||||
$qb->andWhere('l.id != :id')->setParameter('id', $excluding->id);
|
||||
}
|
||||
|
||||
return (int) $qb->select('COUNT(l.id)')->getQuery()->getSingleScalarResult() > 0;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
@@ -48,6 +72,20 @@ class QuizRepository extends ServiceEntityRepository
|
||||
DQL)
|
||||
->setParameter('quiz', $quiz)
|
||||
->execute();
|
||||
|
||||
$em->createQuery(<<<DQL
|
||||
delete from Tvdt\Entity\BankQuestionUsage bqu
|
||||
where bqu.quiz = :quiz
|
||||
DQL)
|
||||
->setParameter('quiz', $quiz)
|
||||
->execute();
|
||||
|
||||
$em->createQuery(<<<DQL
|
||||
update Tvdt\Entity\Quiz q set q.finalizedAt = null
|
||||
where q = :quiz
|
||||
DQL)
|
||||
->setParameter('quiz', $quiz)
|
||||
->execute();
|
||||
}
|
||||
// @codeCoverageIgnoreStart
|
||||
catch (\Throwable $throwable) {
|
||||
@@ -113,8 +151,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 +165,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,121 @@
|
||||
<?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\BankQuestionIncompleteException;
|
||||
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
|
||||
* @throws BankQuestionIncompleteException when the question lacks ≥2 answers or a correct answer
|
||||
*/
|
||||
public function assignToQuiz(BankQuestion $bankQuestion, Quiz $quiz): void
|
||||
{
|
||||
if ($bankQuestion->season !== $quiz->season) {
|
||||
throw new \InvalidArgumentException('Bank question and quiz belong to different seasons');
|
||||
}
|
||||
|
||||
if ($quiz->isLocked) {
|
||||
throw new QuizLockedException();
|
||||
}
|
||||
|
||||
if (!$bankQuestion->isCompleteForQuiz) {
|
||||
throw new BankQuestionIncompleteException();
|
||||
}
|
||||
|
||||
$this->entityManager->wrapInTransaction(function () use ($bankQuestion, $quiz): void {
|
||||
// Pessimistic write lock serialises concurrent assignment attempts for the same BankQuestion
|
||||
$this->entityManager->lock($bankQuestion, LockMode::PESSIMISTIC_WRITE);
|
||||
|
||||
if (!$bankQuestion->canBeAssigned || $bankQuestion->isUsedInQuiz($quiz)) {
|
||||
throw new BankQuestionAlreadyUsedException();
|
||||
}
|
||||
|
||||
$maxOrdering = 0;
|
||||
foreach ($quiz->questions as $existingQuestion) {
|
||||
$maxOrdering = max($maxOrdering, $existingQuestion->ordering);
|
||||
}
|
||||
|
||||
/** @var Question $question */
|
||||
$question = $this->objectMapper->map($bankQuestion, Question::class);
|
||||
$question->ordering = $maxOrdering + 1;
|
||||
|
||||
foreach ($bankQuestion->answers as $bankAnswer) {
|
||||
/** @var Answer $answer */
|
||||
$answer = $this->objectMapper->map($bankAnswer, Answer::class);
|
||||
$question->addAnswer($answer);
|
||||
}
|
||||
|
||||
$quiz->addQuestion($question);
|
||||
|
||||
$usage = new BankQuestionUsage($bankQuestion, $quiz);
|
||||
$usage->question = $question;
|
||||
|
||||
$bankQuestion->addUsage($usage);
|
||||
|
||||
$this->entityManager->persist($question);
|
||||
$this->entityManager->flush();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Propagate bank question edits to a quiz copy.
|
||||
* Only safe on quizzes where no candidate has started (no GivenAnswers exist yet).
|
||||
*/
|
||||
public function syncToQuiz(BankQuestion $bankQuestion, BankQuestionUsage $usage): void
|
||||
{
|
||||
$question = $usage->question;
|
||||
if (!$question instanceof Question) {
|
||||
return;
|
||||
}
|
||||
|
||||
$question->question = $bankQuestion->question;
|
||||
|
||||
// Replace answers (safe: no started candidates means no GivenAnswers)
|
||||
foreach ($question->answers->toArray() as $existingAnswer) {
|
||||
$question->answers->removeElement($existingAnswer);
|
||||
$this->entityManager->remove($existingAnswer);
|
||||
}
|
||||
|
||||
foreach ($bankQuestion->answers as $bankAnswer) {
|
||||
/** @var Answer $answer */
|
||||
$answer = $this->objectMapper->map($bankAnswer, Answer::class);
|
||||
$question->addAnswer($answer);
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove the quiz copy created by this usage and delete the usage record. */
|
||||
public function unassignFromQuiz(BankQuestionUsage $usage): void
|
||||
{
|
||||
$question = $usage->question;
|
||||
if ($question instanceof Question) {
|
||||
$question->quiz->questions->removeElement($question);
|
||||
$this->entityManager->remove($question);
|
||||
}
|
||||
|
||||
$usage->bankQuestion->usages->removeElement($usage);
|
||||
$this->entityManager->remove($usage);
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
}
|
||||
@@ -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 spel met alle bijbehorende testen en kandidaten. De seizoenscode is de link die kandidaten gebruiken om een test te starten, en is alleen actief als er een actieve test is.</p>
|
||||
<h6>Globale werkwijze</h6>
|
||||
<ol>
|
||||
<li><strong>Seizoen aanmaken</strong> en kandidaten toevoegen</li>
|
||||
<li><strong>Test aanmaken</strong> via Excel of de vragenbank</li>
|
||||
<li>Test <strong>afronden</strong> en <strong>activeren</strong></li>
|
||||
<li>Kandidaten laten <strong>deelnemen</strong> (eigen apparaat of gedeelde laptop)</li>
|
||||
<li><strong>Resultaten</strong> bekijken en eliminatie starten</li>
|
||||
</ol>
|
||||
@@ -0,0 +1,3 @@
|
||||
<h6>Eliminatie voorbereiden</h6>
|
||||
<p>Kies voor elke kandidaat een kleur: <strong>groen</strong> betekent veilig, <strong>rood</strong> betekent geëlimineerd.</p>
|
||||
<p>Gebruik <strong>Opslaan en starten</strong> om de eliminatie direct af te spelen, of sla eerst op en start later via het tabblad Resultaten.</p>
|
||||
@@ -0,0 +1,8 @@
|
||||
<h6>Test importeren via Excel</h6>
|
||||
<p>Upload een Excel-bestand met de vragen voor deze test. Na het uploaden kun je de vragen bekijken, controleren en de test afronden.</p>
|
||||
<h6>Verwacht formaat</h6>
|
||||
<ul>
|
||||
<li>Eerste kolom: de vraagtekst</li>
|
||||
<li>Volgende kolommen: de antwoordopties</li>
|
||||
<li>Markeer het juiste antwoord met <strong>WAAR</strong> (Nederlandstalige Excel) of <strong>TRUE</strong> (Engelstalige Excel), alle andere antwoorden zet je op ONWAAR/FALSE</li>
|
||||
</ul>
|
||||
@@ -0,0 +1,3 @@
|
||||
<h6>Lege test aanmaken</h6>
|
||||
<p>Maak een lege test aan en voeg vragen toe vanuit de vragenbank. Handig als je vragen hergebruikt of ze van tevoren in de bank hebt klaargezet.</p>
|
||||
<p>Na het aanmaken open je de test, voeg je vragen toe via het tabblad Overzicht en ronde je de test af voordat je hem activeert.</p>
|
||||
@@ -0,0 +1,3 @@
|
||||
<h6>Antwoorden invullen</h6>
|
||||
<p>Gebruik dit formulier om antwoorden aan kandidaten toe te wijzen. Op deze manier kunnen er statistieken gemaakt worden hoe verdacht kandidaten zijn.</p>
|
||||
<p>Navigeer met de knoppen Vorige en Volgende tussen vragen. Vink per kandidaat het gegeven antwoord aan en sla op.</p>
|
||||
@@ -0,0 +1,6 @@
|
||||
<h6>Kandidaten laten deelnemen</h6>
|
||||
<p>Kandidaten kunnen de test invullen via hun eigen apparaat, een of meerdere gedeelde laptops, of een mix daarvan.</p>
|
||||
<p><strong>Eigen apparaat:</strong> Deel de seizoenscode. Elke kandidaat bezoekt de site op zijn of haar telefoon of laptop, voert de eigen naam in en start de test.</p>
|
||||
<p><strong>Gedeelde laptop(s):</strong> Open de naamsinvoerpagina van tevoren op een of meerdere laptops. Elke kandidaat typt zijn of haar naam en start. Na afloop kan de volgende kandidaat hetzelfde doen op dezelfde of een andere laptop.</p>
|
||||
<h6>Status</h6>
|
||||
<p>Deactiveer een kandidaat als deze de test niet hoeft te maken, bijvoorbeeld na eerder uitgeschakeld zijn. Deactivering is per test en heeft geen invloed op andere testen.</p>
|
||||
@@ -0,0 +1,5 @@
|
||||
<h6>Overzicht & afronden</h6>
|
||||
<p>Vragen met een rode markering in de lijst hiernaast bevatten een fout. Herstel deze vóór het afronden.</p>
|
||||
<p><strong>Afronden</strong> vergrendelt de test voor bewerking en maakt hem klaar voor kandidaten. Daarna kun je hem activeren.</p>
|
||||
<p><strong>Activeren</strong> stelt de test beschikbaar aan kandidaten. Er kan maar één test tegelijk actief zijn, activeer de volgende pas als iedereen de huidige heeft afgerond.</p>
|
||||
<p><strong>Test wissen</strong> verwijdert alle gegeven antwoorden en heft het afronden op, zodat je de test opnieuw kunt bewerken en uitvoeren.</p>
|
||||
@@ -0,0 +1,4 @@
|
||||
<h6>Vraag toevoegen</h6>
|
||||
<p>Voer de vraag in en voeg minimaal twee antwoordopties toe. Markeer precies één antwoord als correct.</p>
|
||||
<p>Gebruik labels om vragen te organiseren in de vragenbank, bijvoorbeeld per aflevering of type vraag.</p>
|
||||
<p>Markeer een vraag als <em>herbruikbaar</em> als deze in meerdere testen mag voorkomen, anders kan een vraag maar aan één test worden gekoppeld.</p>
|
||||
@@ -0,0 +1,5 @@
|
||||
<h6>Resultaten</h6>
|
||||
<p>De tabel toont het eindresultaat per kandidaat gesorteerd op score. Rode rijen zijn de kandidaten met de laagste score die risico lopen op eliminatie.</p>
|
||||
<p><strong>Jokers</strong> voeg je toe voor goede of foute vragen (halve punten zijn mogelijk).</p>
|
||||
<p><strong>Straftijd</strong> is tijdstraf in seconden en wordt meegewogen bij gelijke score. Let op: Een positief getal is een straf en een negatief getal is een bonus.</p>
|
||||
<p>Via <strong>Eliminatie voorbereiden</strong> stel je de schermkleuren handmatig in en start je de eliminatie.</p>
|
||||
@@ -0,0 +1,3 @@
|
||||
<h6>Nieuw seizoen</h6>
|
||||
<p>Een seizoen groepeert alle testen en kandidaten voor één spel. Geef het seizoen een herkenbare naam, de seizoenscode wordt automatisch gegenereerd.</p>
|
||||
<p>Na het aanmaken voeg je kandidaten toe en maak je testen aan via de seizoenpagina.</p>
|
||||
@@ -0,0 +1,3 @@
|
||||
<h6>Kandidaten toevoegen</h6>
|
||||
<p>Voer één naam per regel in. Dit zijn de spelers die deelnemen aan dit seizoen.</p>
|
||||
<p>Je kunt later altijd nog kandidaten toevoegen via het tabblad Kandidaten. Gebruik dezelfde schrijfwijze van namen die je in het spel gebruikt.</p>
|
||||
@@ -0,0 +1,3 @@
|
||||
<h6>Kandidaten</h6>
|
||||
<p>Dit zijn de spelers van dit seizoen. Voeg alle deelnemers toe voordat je de eerste test start, kandidaten worden automatisch aan nieuwe testen gekoppeld.</p>
|
||||
<p>Namen zijn vrij in te voeren, gebruik dezelfde schrijfwijze die je in het spel gebruikt.</p>
|
||||
@@ -0,0 +1,3 @@
|
||||
<h6>Vragenbank</h6>
|
||||
<p>De vragenbank is een bibliotheek met vragen die aan meerdere testen kunnen worden gekoppeld. Markeer een vraag als <em>herbruikbaar</em> als deze in meerdere testen mag voorkomen (bijv. "Wie is de Mol?").</p>
|
||||
<p>Na het bewerken van een vraag in de bank worden testen die de vraag al bevatten <strong>niet</strong> automatisch bijgewerkt, gebruik de synchronisatieknop (↻) naast een test om de meest recente versie door te zetten.</p>
|
||||
@@ -0,0 +1,3 @@
|
||||
<h6>Seizoensinstellingen</h6>
|
||||
<p>Pas hier de weergave-instellingen van dit seizoen aan.</p>
|
||||
<p><strong>Nummers tonen:</strong> toont vraagnummers tijdens de test. <strong>Antwoord bevestigen:</strong> vraagt kandidaten om hun antwoord te bevestigen voordat ze doorgaan.</p>
|
||||
@@ -0,0 +1,11 @@
|
||||
<h6>Testen beheren</h6>
|
||||
<p>Voeg een test toe vanuit een Excel-bestand of maak een lege test aan en vul deze via de vragenbank. Open daarna de test om hem te bekijken en af te ronden.</p>
|
||||
<p>Een test moet eerst <strong>afgerond</strong> zijn voordat je hem kunt activeren. Slechts één test kan tegelijk actief zijn, namelijk de test die kandidaten op dat moment kunnen invullen.</p>
|
||||
<h6>Volgorde van werken</h6>
|
||||
<ol>
|
||||
<li>Test aanmaken (Excel of leeg)</li>
|
||||
<li>Vragen controleren en test afronden</li>
|
||||
<li>Test activeren</li>
|
||||
<li>Kandidaten laten deelnemen</li>
|
||||
<li>Resultaten bekijken en eliminatie voorbereiden</li>
|
||||
</ol>
|
||||
@@ -0,0 +1,4 @@
|
||||
{{ include([
|
||||
'backoffice/help/' ~ app.request.locale ~ '/prepare_elimination.html.twig',
|
||||
'backoffice/help/nl/prepare_elimination.html.twig',
|
||||
]) }}
|
||||
@@ -0,0 +1,4 @@
|
||||
{{ include([
|
||||
'backoffice/help/' ~ app.request.locale ~ '/quiz_add.html.twig',
|
||||
'backoffice/help/nl/quiz_add.html.twig',
|
||||
]) }}
|
||||
@@ -0,0 +1,4 @@
|
||||
{{ include([
|
||||
'backoffice/help/' ~ app.request.locale ~ '/quiz_add_blank.html.twig',
|
||||
'backoffice/help/nl/quiz_add_blank.html.twig',
|
||||
]) }}
|
||||
@@ -0,0 +1,4 @@
|
||||
{{ include([
|
||||
'backoffice/help/' ~ app.request.locale ~ '/quiz_answer_mapping.html.twig',
|
||||
'backoffice/help/nl/quiz_answer_mapping.html.twig',
|
||||
]) }}
|
||||
@@ -0,0 +1,4 @@
|
||||
{{ include([
|
||||
'backoffice/help/' ~ app.request.locale ~ '/quiz_candidates.html.twig',
|
||||
'backoffice/help/nl/quiz_candidates.html.twig',
|
||||
]) }}
|
||||
@@ -0,0 +1,4 @@
|
||||
{{ include([
|
||||
'backoffice/help/' ~ app.request.locale ~ '/quiz_overview.html.twig',
|
||||
'backoffice/help/nl/quiz_overview.html.twig',
|
||||
]) }}
|
||||
@@ -0,0 +1,4 @@
|
||||
{{ include([
|
||||
'backoffice/help/' ~ app.request.locale ~ '/quiz_question_bank_form.html.twig',
|
||||
'backoffice/help/nl/quiz_question_bank_form.html.twig',
|
||||
]) }}
|
||||
@@ -0,0 +1,4 @@
|
||||
{{ include([
|
||||
'backoffice/help/' ~ app.request.locale ~ '/quiz_result.html.twig',
|
||||
'backoffice/help/nl/quiz_result.html.twig',
|
||||
]) }}
|
||||
@@ -0,0 +1,4 @@
|
||||
{{ include([
|
||||
'backoffice/help/' ~ app.request.locale ~ '/season_add.html.twig',
|
||||
'backoffice/help/nl/season_add.html.twig',
|
||||
]) }}
|
||||
@@ -0,0 +1,4 @@
|
||||
{{ include([
|
||||
'backoffice/help/' ~ app.request.locale ~ '/season_add_candidates.html.twig',
|
||||
'backoffice/help/nl/season_add_candidates.html.twig',
|
||||
]) }}
|
||||
@@ -0,0 +1,4 @@
|
||||
{{ include([
|
||||
'backoffice/help/' ~ app.request.locale ~ '/season_candidates.html.twig',
|
||||
'backoffice/help/nl/season_candidates.html.twig',
|
||||
]) }}
|
||||
@@ -0,0 +1,4 @@
|
||||
{{ include([
|
||||
'backoffice/help/' ~ app.request.locale ~ '/season_question_bank.html.twig',
|
||||
'backoffice/help/nl/season_question_bank.html.twig',
|
||||
]) }}
|
||||
@@ -0,0 +1,4 @@
|
||||
{{ include([
|
||||
'backoffice/help/' ~ app.request.locale ~ '/season_settings.html.twig',
|
||||
'backoffice/help/nl/season_settings.html.twig',
|
||||
]) }}
|
||||
@@ -0,0 +1,4 @@
|
||||
{{ include([
|
||||
'backoffice/help/' ~ app.request.locale ~ '/season_tests.html.twig',
|
||||
'backoffice/help/nl/season_tests.html.twig',
|
||||
]) }}
|
||||
@@ -11,53 +11,60 @@
|
||||
{% endblock %}
|
||||
|
||||
{% 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 %}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
{% macro answer_row(answerForm) %}
|
||||
<div class="d-flex align-items-center gap-2 mb-2" data-collection-item>
|
||||
{{ form_widget(answerForm.ordering) }}
|
||||
<span class="text-muted" data-drag-handle style="cursor: grab" title="{{ 'Drag to reorder'|trans }}"><i class="bi bi-grip-vertical"></i></span>
|
||||
<div class="flex-grow-1">{{ form_widget(answerForm.text) }}</div>
|
||||
<div class="d-none">{{ form_widget(answerForm.isRightAnswer) }}</div>
|
||||
<button type="button"
|
||||
class="btn btn-sm {{ answerForm.isRightAnswer.vars.checked ? 'btn-success' : 'btn-danger' }}"
|
||||
title="{{ 'Toggle correct answer'|trans }}"
|
||||
onclick="var cb=this.closest('[data-collection-item]').querySelector('input[type=checkbox]');cb.checked=!cb.checked;this.classList.toggle('btn-success',cb.checked);this.classList.toggle('btn-danger',!cb.checked);this.querySelector('i').className=cb.checked?'bi bi-check-lg':'bi bi-x-lg'">
|
||||
<i class="{{ answerForm.isRightAnswer.vars.checked ? 'bi bi-check-lg' : 'bi bi-x-lg' }}"></i>
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger"
|
||||
data-action="bo--form-collection#removeItem"><i class="bi bi-trash"></i></button>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
@@ -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,53 @@
|
||||
{% extends 'backoffice/base.html.twig' %}
|
||||
{% import 'backoffice/partials/answer_row.html.twig' as macros %}
|
||||
|
||||
{% 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="{{ macros.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 %}
|
||||
{{ macros.answer_row(answerForm) }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% do form.answers.setRendered %}
|
||||
<div class="d-flex gap-2 mb-3">
|
||||
<button type="button" class="btn btn-sm btn-outline-primary"
|
||||
data-action="bo--form-collection#addItem">{{ 'Add answer'|trans }}</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||
data-action="bo--form-collection#sortAlphabetically">{{ 'Sort A–Z'|trans }}</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||
data-action="bo--form-collection#randomize">{{ 'Randomize'|trans }}</button>
|
||||
</div>
|
||||
</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 %}
|
||||
@@ -0,0 +1,51 @@
|
||||
{% extends 'backoffice/base.html.twig' %}
|
||||
{% import 'backoffice/partials/answer_row.html.twig' as macros %}
|
||||
|
||||
{% block title %}{{ parent() }}{{ 'Edit question'|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_quiz_overview', {seasonCode: season.seasonCode, quiz: quiz.id}) }}">{{ quiz.name }}</a></li>
|
||||
<li class="breadcrumb-item active" aria-current="page">{{ 'Edit question'|trans }}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="row">
|
||||
<div class="col-md-6 col-12">
|
||||
<h2 class="mb-3">{{ 'Edit question'|trans }}</h2>
|
||||
|
||||
{{ form_start(form) }}
|
||||
{{ form_row(form.question) }}
|
||||
|
||||
<div data-controller="bo--form-collection"
|
||||
data-bo--form-collection-prototype-value="{{ macros.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 %}
|
||||
{{ macros.answer_row(answerForm) }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% do form.answers.setRendered %}
|
||||
<div class="d-flex gap-2 mb-3">
|
||||
<button type="button" class="btn btn-sm btn-outline-primary"
|
||||
data-action="bo--form-collection#addItem">{{ 'Add answer'|trans }}</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||
data-action="bo--form-collection#sortAlphabetically">{{ 'Sort A–Z'|trans }}</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||
data-action="bo--form-collection#randomize">{{ 'Randomize'|trans }}</button>
|
||||
</div>
|
||||
</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,17 +47,42 @@
|
||||
{% 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>
|
||||
<button class="btn btn-danger rounded-0 rounded-end" data-action="click->bo--quiz#deleteQuiz">
|
||||
<button class="btn btn-danger rounded-0 " data-action="click->bo--quiz#deleteQuiz">
|
||||
{{ 'Delete Quiz...'|trans }}
|
||||
</button>
|
||||
<a class="btn btn-secondary rounded-0 rounded-end"
|
||||
href="{{ path('tvdt_backoffice_quiz_export', {quiz: quiz.id}) }}">
|
||||
{{ 'Export to XLSX'|trans }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<h4 class="mb-3">{{ 'Questions'|trans }}</h4>
|
||||
@@ -44,6 +104,12 @@
|
||||
<div id="question-{{ loop.index0 }}"
|
||||
class="accordion-collapse collapse">
|
||||
<div class="accordion-body">
|
||||
{% if is_granted('QUIZ_MODIFY_CONTENT', question) %}
|
||||
<a class="btn btn-sm btn-outline-secondary mb-2"
|
||||
href="{{ path('tvdt_backoffice_quiz_question_edit', {seasonCode: season.seasonCode, quiz: quiz.id, question: question.id}) }}">
|
||||
<i class="bi bi-pencil"></i> {{ 'Edit'|trans }}
|
||||
</a>
|
||||
{% endif %}
|
||||
<ul>
|
||||
{%~ for answer in question.answers %}
|
||||
<li{% if answer.isRightAnswer %} class="text-decoration-underline"{% endif %}>
|
||||
@@ -66,53 +132,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>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user