Start of Symfony

This commit is contained in:
2024-12-29 14:55:07 +01:00
parent 3258700efd
commit 628bfa22f0
100 changed files with 3312 additions and 5643 deletions

View File

@@ -1,4 +0,0 @@
/.idea/
/containers/
.gitignore
compose.*

34
.dockerignore Normal file
View File

@@ -0,0 +1,34 @@
**/*.log
**/*.md
**/*.php~
**/*.dist.php
**/*.dist
**/*.cache
**/._*
**/.dockerignore
**/.DS_Store
**/.git/
**/.gitattributes
**/.gitignore
**/.gitmodules
**/compose.*.yaml
**/compose.*.yml
**/compose.yaml
**/compose.yml
**/docker-compose.*.yaml
**/docker-compose.*.yml
**/docker-compose.yaml
**/docker-compose.yml
**/Dockerfile
**/Thumbs.db
.github/
docs/
public/bundles/
tests/
var/
vendor/
.editorconfig
.env.*.local
.env.local
.env.local.php
.env.test

57
.editorconfig Normal file
View File

@@ -0,0 +1,57 @@
# EditorConfig helps developers define and maintain consistent
# coding styles between different editors and IDEs
# editorconfig.org
root = true
[*]
# Change these settings to your own preference
indent_style = space
indent_size = 4
# We recommend you to keep these unchanged
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.{js,html,ts,tsx}]
indent_size = 2
[*.json]
indent_size = 2
[*.md]
trim_trailing_whitespace = false
[*.sh]
indent_style = tab
[*.xml.dist]
indent_style = space
indent_size = 4
[*.{yaml,yml}]
trim_trailing_whitespace = false
[.github/workflows/*.yml]
indent_size = 2
[.gitmodules]
indent_style = tab
[.php_cs.dist]
indent_style = space
indent_size = 4
[composer.json]
indent_size = 4
[{compose,docker-compose}.*.{yaml,yml}]
indent_size = 2
[*.*Dockerfile]
indent_style = tab
[*.*Caddyfile]
indent_style = tab

20
.env Normal file
View File

@@ -0,0 +1,20 @@
# In all environments, the following files are loaded if they exist,
# the latter taking precedence over the former:
#
# * .env contains default values for the environment variables needed by the app
# * .env.local uncommitted file with local overrides
# * .env.$APP_ENV committed environment-specific defaults
# * .env.$APP_ENV.local uncommitted environment-specific overrides
#
# Real environment variables win over .env files.
#
# DO NOT DEFINE PRODUCTION SECRETS IN THIS FILE NOR IN ANY OTHER COMMITTED FILES.
# https://symfony.com/doc/current/configuration/secrets.html
#
# Run "composer dump-env prod" to compile .env files for production use (requires symfony/flex >=1.2).
# https://symfony.com/doc/current/best_practices.html#use-environment-variables-for-infrastructure-configuration
###> symfony/framework-bundle ###
APP_ENV=dev
APP_SECRET=
###< symfony/framework-bundle ###

4
.env.dev Normal file
View File

@@ -0,0 +1,4 @@
###> symfony/framework-bundle ###
APP_SECRET=e26b9552d9e7f969b160373effaa7690
###< symfony/framework-bundle ###

17
.gitattributes vendored Normal file
View File

@@ -0,0 +1,17 @@
* text=auto eol=lf
*.conf text eol=lf
*.html text eol=lf
*.ini text eol=lf
*.js text eol=lf
*.json text eol=lf
*.md text eol=lf
*.php text eol=lf
*.sh text eol=lf
*.yaml text eol=lf
*.yml text eol=lf
bin/console text eol=lf
composer.lock text eol=lf merge=ours
*.ico binary
*.png binary

76
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,76 @@
name: CI
on:
push:
branches:
- main
pull_request: ~
workflow_dispatch: ~
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
tests:
name: Tests
runs-on: ubuntu-latest
steps:
-
name: Checkout
uses: actions/checkout@v4
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
-
name: Build Docker images
uses: docker/bake-action@v4
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
-
name: Start services
run: docker compose up --wait --no-build
-
name: Check HTTP reachability
run: curl -v --fail-with-body http://localhost
-
name: Check HTTPS reachability
if: false # Remove this line when the homepage will be configured, or change the path to check
run: curl -vk --fail-with-body https://localhost
-
name: Check Mercure reachability
run: curl -vkI --fail-with-body https://localhost/.well-known/mercure?topic=test
-
name: Create test database
if: false # Remove this line if Doctrine ORM is installed
run: docker compose exec -T php bin/console -e test doctrine:database:create
-
name: Run migrations
if: false # Remove this line if Doctrine Migrations is installed
run: docker compose exec -T php bin/console -e test doctrine:migrations:migrate --no-interaction
-
name: Run PHPUnit
if: false # Remove this line if PHPUnit is installed
run: docker compose exec -T php bin/phpunit
-
name: Doctrine Schema Validator
if: false # Remove this line if Doctrine ORM is installed
run: docker compose exec -T php bin/console -e test doctrine:schema:validate
lint:
name: Docker Lint
runs-on: ubuntu-latest
steps:
-
name: Checkout
uses: actions/checkout@v4
-
name: Lint Dockerfile
uses: hadolint/hadolint-action@v3.1.0

210
.gitignore vendored
View File

@@ -1,174 +1,60 @@
compose.override.yaml
/tvdt/staticfiles/
.idea/
### Generated by gibo (https://github.com/simonwhitaker/gibo)
### https://raw.github.com/github/gitignore/76739a38b56907118c5a880d63250c99d5690a5a/Python.gitignore
### https://raw.github.com/github/gitignore/6eeebe6f49678aacd8311ce079842c971b3ebe96/Symfony.gitignore
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# Cache and logs (Symfony2)
/app/cache/*
/app/logs/*
!app/cache/.gitkeep
!app/logs/.gitkeep
# C extensions
*.so
# Email spool folder
/app/spool/*
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# Cache, session files and logs (Symfony3)
/var/cache/*
/var/logs/*
/var/sessions/*
!var/cache/.gitkeep
!var/logs/.gitkeep
!var/sessions/.gitkeep
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Logs (Symfony4)
/var/log/*
!var/log/.gitkeep
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Parameters
/app/config/parameters.yml
/app/config/parameters.ini
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Managed by Composer
/app/bootstrap.php.cache
/var/bootstrap.php.cache
/bin/*
!bin/console
!bin/symfony_requirements
/vendor/
# Translations
*.mo
*.pot
# Assets and user uploads
/web/bundles/
/web/uploads/
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# PHPUnit
/app/phpunit.xml
/phpunit.xml
# Flask stuff:
instance/
.webassets-cache
# Build data
/build/
# Scrapy stuff:
.scrapy
# Composer PHAR
/composer.phar
# Sphinx documentation
docs/_build/
# Backup entities generated with doctrine:generate:entities command
**/Entity/*~
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml
.pdm-python
.pdm-build/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# Embedded web-server pid file
/.web-server-pid
### Generated by gibo (https://github.com/simonwhitaker/gibo)
### https://raw.github.com/github/gitignore/76739a38b56907118c5a880d63250c99d5690a5a/Global/macOS.gitignore
### https://raw.github.com/github/gitignore/6eeebe6f49678aacd8311ce079842c971b3ebe96/Global/macOS.gitignore
# General
.DS_Store
@@ -197,3 +83,13 @@ Icon
Network Trash Folder
Temporary Items
.apdisk
###> symfony/framework-bundle ###
/.env.local
/.env.local.php
/.env.*.local
/config/secrets/prod/prod.decrypt.private.php
/public/bundles/
/var/
/vendor/
###< symfony/framework-bundle ###

8
.idea/.gitignore generated vendored
View File

@@ -1,8 +0,0 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

94
Dockerfile Normal file
View File

@@ -0,0 +1,94 @@
#syntax=docker/dockerfile:1
# Versions
FROM dunglas/frankenphp:1-php8.3 AS frankenphp_upstream
# The different stages of this Dockerfile are meant to be built into separate images
# https://docs.docker.com/develop/develop-images/multistage-build/#stop-at-a-specific-build-stage
# https://docs.docker.com/compose/compose-file/#target
# Base FrankenPHP image
FROM frankenphp_upstream AS frankenphp_base
WORKDIR /app
VOLUME /app/var/
# persistent / runtime deps
# hadolint ignore=DL3008
RUN apt-get update && apt-get install -y --no-install-recommends \
acl \
file \
gettext \
git \
&& rm -rf /var/lib/apt/lists/*
RUN set -eux; \
install-php-extensions \
@composer \
apcu \
intl \
opcache \
zip \
;
# https://getcomposer.org/doc/03-cli.md#composer-allow-superuser
ENV COMPOSER_ALLOW_SUPERUSER=1
ENV PHP_INI_SCAN_DIR=":$PHP_INI_DIR/app.conf.d"
###> recipes ###
###< recipes ###
COPY --link frankenphp/conf.d/10-app.ini $PHP_INI_DIR/app.conf.d/
COPY --link --chmod=755 frankenphp/docker-entrypoint.sh /usr/local/bin/docker-entrypoint
COPY --link frankenphp/Caddyfile /etc/caddy/Caddyfile
ENTRYPOINT ["docker-entrypoint"]
HEALTHCHECK --start-period=60s CMD curl -f http://localhost:2019/metrics || exit 1
CMD [ "frankenphp", "run", "--config", "/etc/caddy/Caddyfile" ]
# Dev FrankenPHP image
FROM frankenphp_base AS frankenphp_dev
ENV APP_ENV=dev XDEBUG_MODE=off
RUN mv "$PHP_INI_DIR/php.ini-development" "$PHP_INI_DIR/php.ini"
RUN set -eux; \
install-php-extensions \
xdebug \
;
COPY --link frankenphp/conf.d/20-app.dev.ini $PHP_INI_DIR/app.conf.d/
CMD [ "frankenphp", "run", "--config", "/etc/caddy/Caddyfile", "--watch" ]
# Prod FrankenPHP image
FROM frankenphp_base AS frankenphp_prod
ENV APP_ENV=prod
ENV FRANKENPHP_CONFIG="import worker.Caddyfile"
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
COPY --link frankenphp/conf.d/20-app.prod.ini $PHP_INI_DIR/app.conf.d/
COPY --link frankenphp/worker.Caddyfile /etc/caddy/worker.Caddyfile
# prevent the reinstallation of vendors at every changes in the source code
COPY --link composer.* symfony.* ./
RUN set -eux; \
composer install --no-cache --prefer-dist --no-dev --no-autoloader --no-scripts --no-progress
# copy sources
COPY --link . ./
RUN rm -Rf frankenphp/
RUN set -eux; \
mkdir -p var/cache var/log; \
composer dump-autoload --classmap-authoritative --no-dev; \
composer dump-env prod; \
composer run-script --no-dev post-install-cmd; \
chmod +x bin/console; sync;

View File

@@ -1,59 +0,0 @@
DOCKER_EXEC=docker compose exec app
.DEFAULT_GOAL := help
.PHONY: help
help:
@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf "\033[36m%-10s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
.PHONY: init
init: install up migrate fixtures ## setup the application from scratch
.PHONY: up
up: ## Starts the app
@docker compose up -d --force-recreate
.PHONY: down
down: ## Stops the app
@docker compose down --remove-orphans
.PHONY: build
build: ## (Re)build the containers
@echo ✨ Building container
@docker compose build
.PHONY: shell
shell: ## Opens a shell in the app container
@${DOCKER_EXEC} bash
.PHONY: migrate
migrate: ## Migrate the database to the latest version
@echo ✨ Appling migrations
@${DOCKER_EXEC} python manage.py migrate
.PHONY: compilemessages
messages: ## Compile translations
@echo ✨ Finding translations
@${DOCKER_EXEC} python manage.py makemessages -l nl
@echo ✨ Compiling translations
@${DOCKER_EXEC} python manage.py compilemessages --ignore .venv
.PHONY: install
install: ## Install dependencies in container
@echo ✨ Installing dependencies
@docker compose run --rm --entrypoint="" app poetry install --without prod --sync
.PHONY: fixtures
fixtures:
@echo ✨ Loading fixtures
@${DOCKER_EXEC} python manage.py loaddata krtek
.PHONY: _clean
_clean:
@echo ✨ Stopping containers
@docker compose down -v
@echo ✨ Removing compiled files
@rm -f tvdt/*/locale/*/LC_MESSAGES/django.mo tvdt/locale/*/LC_MESSAGES/django.mo
.PHONY: clean
clean: _clean init

21
bin/console Executable file
View File

@@ -0,0 +1,21 @@
#!/usr/bin/env php
<?php
use App\Kernel;
use Symfony\Bundle\FrameworkBundle\Console\Application;
if (!is_dir(dirname(__DIR__).'/vendor')) {
throw new LogicException('Dependencies are missing. Try running "composer install".');
}
if (!is_file(dirname(__DIR__).'/vendor/autoload_runtime.php')) {
throw new LogicException('Symfony Runtime is missing. Try running "composer require symfony/runtime".');
}
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
return function (array $context) {
$kernel = new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
return new Application($kernel);
};

24
compose.override.yaml Normal file
View File

@@ -0,0 +1,24 @@
# Development environment override
services:
php:
build:
context: .
target: frankenphp_dev
volumes:
- ./:/app
- ./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
# If you develop on Mac or Windows you can remove the vendor/ directory
# from the bind-mount for better performance by enabling the next line:
#- /app/vendor
environment:
MERCURE_EXTRA_DIRECTIVES: demo
# See https://xdebug.org/docs/all_settings#mode
XDEBUG_MODE: "${XDEBUG_MODE:-off}"
extra_hosts:
# Ensure that host.docker.internal is correctly defined on Linux
- host.docker.internal:host-gateway
tty: true
###> symfony/mercure-bundle ###
###< symfony/mercure-bundle ###

View File

@@ -1,5 +1,10 @@
# Production environment override
services:
app:
image: tvdt/app:prod
php:
build:
dockerfile: containers/python/Containerfile.prod
context: .
target: frankenphp_prod
environment:
APP_SECRET: ${APP_SECRET}
MERCURE_PUBLISHER_JWT_KEY: ${CADDY_MERCURE_JWT_SECRET}
MERCURE_SUBSCRIBER_JWT_KEY: ${CADDY_MERCURE_JWT_SECRET}

View File

@@ -1,25 +1,43 @@
services:
app:
build:
dockerfile: containers/python/Containerfile.dev
ports:
- "8000:8000"
php:
image: ${IMAGES_PREFIX:-}app-php
restart: unless-stopped
environment:
DATABASE_URL: postgres://tvdt:tvdt@db:5432/tvdt
DEBUG: true
depends_on:
- db
db:
image: postgres:17.2
environment:
POSTGRES_PASSWORD: tvdt
POSTGRES_USER: tvdt
POSTGRES_DB: tvdt
SERVER_NAME: ${SERVER_NAME:-localhost}, php:80
MERCURE_PUBLISHER_JWT_KEY: ${CADDY_MERCURE_JWT_SECRET:-!ChangeThisMercureHubJWTSecretKey!}
MERCURE_SUBSCRIBER_JWT_KEY: ${CADDY_MERCURE_JWT_SECRET:-!ChangeThisMercureHubJWTSecretKey!}
# Run "composer require symfony/orm-pack" to install and configure Doctrine ORM
DATABASE_URL: postgresql://${POSTGRES_USER:-app}:${POSTGRES_PASSWORD:-!ChangeMe!}@database:5432/${POSTGRES_DB:-app}?serverVersion=${POSTGRES_VERSION:-15}&charset=${POSTGRES_CHARSET:-utf8}
# Run "composer require symfony/mercure-bundle" to install and configure the Mercure integration
MERCURE_URL: ${CADDY_MERCURE_URL:-http://php/.well-known/mercure}
MERCURE_PUBLIC_URL: ${CADDY_MERCURE_PUBLIC_URL:-https://${SERVER_NAME:-localhost}/.well-known/mercure}
MERCURE_JWT_SECRET: ${CADDY_MERCURE_JWT_SECRET:-!ChangeThisMercureHubJWTSecretKey!}
# The two next lines can be removed after initial installation
SYMFONY_VERSION: ${SYMFONY_VERSION:-}
STABILITY: ${STABILITY:-stable}
volumes:
- data:/var/lib/postgresql/data
- caddy_data:/data
- caddy_config:/config
ports:
- "5432:5432"
# HTTP
- target: 80
published: ${HTTP_PORT:-80}
protocol: tcp
# HTTPS
- target: 443
published: ${HTTPS_PORT:-443}
protocol: tcp
# HTTP/3
- target: 443
published: ${HTTP3_PORT:-443}
protocol: udp
# Mercure is installed as a Caddy module, prevent the Flex recipe from installing another service
###> symfony/mercure-bundle ###
###< symfony/mercure-bundle ###
volumes:
data:
caddy_data:
caddy_config:
###> symfony/mercure-bundle ###
###< symfony/mercure-bundle ###

71
composer.json Normal file
View File

@@ -0,0 +1,71 @@
{
"name": "symfony/skeleton",
"type": "project",
"license": "MIT",
"description": "A minimal Symfony project recommended to create bare bones applications",
"minimum-stability": "stable",
"prefer-stable": true,
"require": {
"php": ">=8.3.15",
"ext-ctype": "*",
"ext-iconv": "*",
"runtime/frankenphp-symfony": "^0.2.0",
"symfony/console": "7.2.*",
"symfony/dotenv": "7.2.*",
"symfony/flex": "^2",
"symfony/framework-bundle": "7.2.*",
"symfony/runtime": "7.2.*",
"symfony/yaml": "7.2.*"
},
"config": {
"allow-plugins": {
"php-http/discovery": true,
"symfony/flex": true,
"symfony/runtime": true
},
"bump-after-update": true,
"sort-packages": true
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"App\\Tests\\": "tests/"
}
},
"replace": {
"symfony/polyfill-ctype": "*",
"symfony/polyfill-iconv": "*",
"symfony/polyfill-php72": "*",
"symfony/polyfill-php73": "*",
"symfony/polyfill-php74": "*",
"symfony/polyfill-php80": "*",
"symfony/polyfill-php81": "*",
"symfony/polyfill-php82": "*"
},
"scripts": {
"auto-scripts": {
"cache:clear": "symfony-cmd",
"assets:install %PUBLIC_DIR%": "symfony-cmd"
},
"post-install-cmd": [
"@auto-scripts"
],
"post-update-cmd": [
"@auto-scripts"
]
},
"conflict": {
"symfony/symfony": "*"
},
"extra": {
"symfony": {
"allow-contrib": false,
"require": "7.2.*",
"docker": true
}
}
}

2487
composer.lock generated Normal file

File diff suppressed because it is too large Load Diff

5
config/bundles.php Normal file
View File

@@ -0,0 +1,5 @@
<?php
return [
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
];

View File

@@ -0,0 +1,19 @@
framework:
cache:
# Unique name of your app: used to compute stable namespaces for cache keys.
#prefix_seed: your_vendor_name/app_name
# The "app" cache stores to the filesystem by default.
# The data in this cache should persist between deploys.
# Other options include:
# Redis
#app: cache.adapter.redis
#default_redis_provider: redis://localhost
# APCu (not recommended with heavy random-write workloads as memory fragmentation can cause perf issues)
#app: cache.adapter.apcu
# Namespaced pools use the above "app" backend by default
#pools:
#my.dedicated.cache: null

View File

@@ -0,0 +1,15 @@
# see https://symfony.com/doc/current/reference/configuration/framework.html
framework:
secret: '%env(APP_SECRET)%'
# Note that the session will be started ONLY if you read or write from it.
session: true
#esi: true
#fragments: true
when@test:
framework:
test: true
session:
storage_factory_id: session.storage.factory.mock_file

View File

@@ -0,0 +1,10 @@
framework:
router:
# Configure how to generate URLs in non-HTTP contexts, such as CLI commands.
# See https://symfony.com/doc/current/routing.html#generating-urls-in-commands
#default_uri: http://localhost
when@prod:
framework:
router:
strict_requirements: null

5
config/preload.php Normal file
View File

@@ -0,0 +1,5 @@
<?php
if (file_exists(dirname(__DIR__).'/var/cache/prod/App_KernelProdContainer.preload.php')) {
require dirname(__DIR__).'/var/cache/prod/App_KernelProdContainer.preload.php';
}

5
config/routes.yaml Normal file
View File

@@ -0,0 +1,5 @@
controllers:
resource:
path: ../src/Controller/
namespace: App\Controller
type: attribute

View File

@@ -0,0 +1,4 @@
when@dev:
_errors:
resource: '@FrameworkBundle/Resources/config/routing/errors.xml'
prefix: /_error

24
config/services.yaml Normal file
View File

@@ -0,0 +1,24 @@
# This file is the entry point to configure your own services.
# Files in the packages/ subdirectory configure your dependencies.
# Put parameters here that don't need to change on each machine where the app is deployed
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
parameters:
services:
# default configuration for services in *this* file
_defaults:
autowire: true # Automatically injects dependencies in your services.
autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.
# makes classes in src/ available to be used as services
# this creates a service per class whose id is the fully-qualified class name
App\:
resource: '../src/'
exclude:
- '../src/DependencyInjection/'
- '../src/Entity/'
- '../src/Kernel.php'
# add more service definitions when explicit configuration is needed
# please note that last definitions always *replace* previous ones

View File

@@ -1,16 +0,0 @@
FROM python:3.13 AS dev
RUN apt-get update && apt-get install -y \
gettext
RUN pip install poetry~=1.8
WORKDIR /app
ENV POETRY_VIRTUALENVS_IN_PROJECT=1 \
POETRY_VIRTUALENVS_CREATE=1
ENV VIRTUAL_ENV=/app/.venv \
PATH="/app/.venv/bin:$PATH"
ENTRYPOINT ["python","manage.py", "runserver", "0.0.0.0:8000"]

View File

@@ -1,36 +0,0 @@
FROM python:3.13 AS builder
RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends \
gettext
RUN pip install --no-cache-dir poetry==1.8
WORKDIR /app
ENV POETRY_NO_INTERACTION=1 \
POETRY_VIRTUALENVS_IN_PROJECT=1 \
POETRY_VIRTUALENVS_CREATE=1 \
POETRY_CACHE_DIR=/tmp/poetry_cache
COPY tvdt/pyproject.toml tvdt/poetry.lock ./
RUN poetry install --without dev
COPY ./tvdt/ .
ENV VIRTUAL_ENV=/app/.venv \
PATH="/app/.venv/bin:$PATH"
RUN python manage.py compilemessages --ignore .venv \
&& python manage.py collectstatic
FROM python:3.13 AS runtime
WORKDIR /app
ENV VIRTUAL_ENV=/app/.venv \
PATH="/app/.venv/bin:$PATH"
COPY --from=builder /app /app
ENTRYPOINT ["gunicorn", "-b", "0.0.0.0:8000", "tvdt.wsgi", ""]

57
frankenphp/Caddyfile Normal file
View File

@@ -0,0 +1,57 @@
{
{$CADDY_GLOBAL_OPTIONS}
frankenphp {
{$FRANKENPHP_CONFIG}
}
}
{$CADDY_EXTRA_CONFIG}
{$SERVER_NAME:localhost} {
log {
{$CADDY_SERVER_LOG_OPTIONS}
# Redact the authorization query parameter that can be set by Mercure
format filter {
request>uri query {
replace authorization REDACTED
}
}
}
root /app/public
encode zstd br gzip
mercure {
# Transport to use (default to Bolt)
transport_url {$MERCURE_TRANSPORT_URL:bolt:///data/mercure.db}
# Publisher JWT key
publisher_jwt {env.MERCURE_PUBLISHER_JWT_KEY} {env.MERCURE_PUBLISHER_JWT_ALG}
# Subscriber JWT key
subscriber_jwt {env.MERCURE_SUBSCRIBER_JWT_KEY} {env.MERCURE_SUBSCRIBER_JWT_ALG}
# Allow anonymous subscribers (double-check that it's what you want)
anonymous
# Enable the subscription API (double-check that it's what you want)
subscriptions
# Extra directives
{$MERCURE_EXTRA_DIRECTIVES}
}
vulcain
{$CADDY_SERVER_EXTRA_DIRECTIVES}
# Disable Topics tracking if not enabled explicitly: https://github.com/jkarlin/topics
header ?Permissions-Policy "browsing-topics=()"
@phpRoute {
not path /.well-known/mercure*
not file {path}
}
rewrite @phpRoute index.php
@frontController path index.php
php @frontController
file_server
}

View File

@@ -0,0 +1,13 @@
expose_php = 0
date.timezone = UTC
apc.enable_cli = 1
session.use_strict_mode = 1
zend.detect_unicode = 0
; https://symfony.com/doc/current/performance.html
realpath_cache_size = 4096K
realpath_cache_ttl = 600
opcache.interned_strings_buffer = 16
opcache.max_accelerated_files = 20000
opcache.memory_consumption = 256
opcache.enable_file_override = 1

View File

@@ -0,0 +1,5 @@
; See https://docs.docker.com/desktop/networking/#i-want-to-connect-from-a-container-to-a-service-on-the-host
; See https://github.com/docker/for-linux/issues/264
; The `client_host` below may optionally be replaced with `discover_client_host=yes`
; Add `start_with_request=yes` to start debug session on each request
xdebug.client_host = host.docker.internal

View File

@@ -0,0 +1,2 @@
opcache.preload_user = root
opcache.preload = /app/config/preload.php

62
frankenphp/docker-entrypoint.sh Executable file
View File

@@ -0,0 +1,62 @@
#!/bin/sh
set -e
if [ "$1" = 'frankenphp' ] || [ "$1" = 'php' ] || [ "$1" = 'bin/console' ]; then
# Install the project the first time PHP is started
# After the installation, the following block can be deleted
if [ ! -f composer.json ]; then
rm -Rf tmp/
composer create-project "symfony/skeleton $SYMFONY_VERSION" tmp --stability="$STABILITY" --prefer-dist --no-progress --no-interaction --no-install
cd tmp
cp -Rp . ..
cd -
rm -Rf tmp/
composer require "php:>=$PHP_VERSION" runtime/frankenphp-symfony
composer config --json extra.symfony.docker 'true'
if grep -q ^DATABASE_URL= .env; then
echo 'To finish the installation please press Ctrl+C to stop Docker Compose and run: docker compose up --build -d --wait'
sleep infinity
fi
fi
if [ -z "$(ls -A 'vendor/' 2>/dev/null)" ]; then
composer install --prefer-dist --no-progress --no-interaction
fi
if grep -q ^DATABASE_URL= .env; then
echo 'Waiting for database to be ready...'
ATTEMPTS_LEFT_TO_REACH_DATABASE=60
until [ $ATTEMPTS_LEFT_TO_REACH_DATABASE -eq 0 ] || DATABASE_ERROR=$(php bin/console dbal:run-sql -q "SELECT 1" 2>&1); do
if [ $? -eq 255 ]; then
# If the Doctrine command exits with 255, an unrecoverable error occurred
ATTEMPTS_LEFT_TO_REACH_DATABASE=0
break
fi
sleep 1
ATTEMPTS_LEFT_TO_REACH_DATABASE=$((ATTEMPTS_LEFT_TO_REACH_DATABASE - 1))
echo "Still waiting for database to be ready... Or maybe the database is not reachable. $ATTEMPTS_LEFT_TO_REACH_DATABASE attempts left."
done
if [ $ATTEMPTS_LEFT_TO_REACH_DATABASE -eq 0 ]; then
echo 'The database is not up or not reachable:'
echo "$DATABASE_ERROR"
exit 1
else
echo 'The database is now ready and reachable'
fi
if [ "$( find ./migrations -iname '*.php' -print -quit )" ]; then
php bin/console doctrine:migrations:migrate --no-interaction --all-or-nothing
fi
fi
setfacl -R -m u:www-data:rwX -m u:"$(whoami)":rwX var
setfacl -dR -m u:www-data:rwX -m u:"$(whoami)":rwX var
echo 'PHP app ready!'
fi
exec docker-php-entrypoint "$@"

View File

@@ -0,0 +1,4 @@
worker {
file ./public/index.php
env APP_RUNTIME Runtime\FrankenPhpSymfony\Runtime
}

9
public/index.php Normal file
View File

@@ -0,0 +1,9 @@
<?php
use App\Kernel;
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
return function (array $context) {
return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
};

11
src/Kernel.php Normal file
View File

@@ -0,0 +1,11 @@
<?php
namespace App;
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
class Kernel extends BaseKernel
{
use MicroKernelTrait;
}

59
symfony.lock Normal file
View File

@@ -0,0 +1,59 @@
{
"symfony/console": {
"version": "7.2",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "5.3",
"ref": "1781ff40d8a17d87cf53f8d4cf0c8346ed2bb461"
},
"files": [
"bin/console"
]
},
"symfony/flex": {
"version": "2.4",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "2.4",
"ref": "52e9754527a15e2b79d9a610f98185a1fe46622a"
},
"files": [
".env",
".env.dev"
]
},
"symfony/framework-bundle": {
"version": "7.2",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.2",
"ref": "87bcf6f7c55201f345d8895deda46d2adbdbaa89"
},
"files": [
"config/packages/cache.yaml",
"config/packages/framework.yaml",
"config/preload.php",
"config/routes/framework.yaml",
"config/services.yaml",
"public/index.php",
"src/Controller/.gitignore",
"src/Kernel.php"
]
},
"symfony/routing": {
"version": "7.2",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.0",
"ref": "21b72649d5622d8f7da329ffb5afb232a023619d"
},
"files": [
"config/packages/routing.yaml",
"config/routes.yaml"
]
}
}

View File

@@ -1,6 +0,0 @@
from django.apps import AppConfig
class BackofficeConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "backoffice"

View File

@@ -1,14 +0,0 @@
from quiz.models import Quiz
class QuizConverter:
regex = r"\d+"
def to_python(self, value: str) -> Quiz:
try:
return Quiz.objects.get(id=value)
except Quiz.DoesNotExist:
raise ValueError
def to_url(self, value: Quiz | int) -> str:
return str(value.id) if isinstance(value, Quiz) else value

View File

@@ -1,36 +0,0 @@
{% load i18n %}
{% load static %}
<!DOCTYPE html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
rel="stylesheet"
integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH"
crossorigin="anonymous">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz"
crossorigin="anonymous"></script>
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<title>
{% block title %}
{% translate "Tijd voor de test" %}
{% endblock title %}
</title>
</head>
<body>
{% block nav %}
{% include "backoffice/nav.html" %}
{% endblock nav %}
<main>
<div class="container">
{% include "messages.html" %}
{% block body %}
{% endblock body %}
</div>
</main>
</body>
{% block script %}
{% endblock script %}
</html>

View File

@@ -1,45 +0,0 @@
{% extends "backoffice/base.html" %}
{% load i18n %}
{% block body %}
<h2>{% translate "Your Seasons" %}</h2>
<table class="table table-hover">
<thead>
<tr>
<th scope="col">{% translate "Name" %}</th>
<th scope="col">{% translate "Active Quiz" %}</th>
<th scope="col">{% translate "Season Code" %}</th>
<th scope="col">{% translate "Preregister?" %}</th>
<th scope="col">{% translate "Manage" %}</th>
</tr>
</thead>
<tbody>
{% for season in seasons %}
<tr class="align-middle">
<td>{{ season.name }}</td>
<td>
{% if season.active_quiz %}
{{ season.active_quiz.name }}
{% else %}
{% translate "No active quiz" %}
{% endif %}
</td>
<td>
<a {% if season.active_quiz %}href="{% url "enter_name" season %}"{% else %}class="disabled" {% endif %}>{{ season.season_code }}</a>
</td>
<td>
<input class="form-check-input"
type="checkbox"
disabled
{% if season.preregister_candidates %}checked{% endif %}
aria-label="Preregister Enabled">
</td>
<td>
<a href="{% url "backoffice:season" season %}">{% translate "Manage" %}</a>
</td>
</tr>
{% empty %}
EMPTY
{% endfor %}
</tbody>
</table>
{% endblock body %}

View File

@@ -1,51 +0,0 @@
{% load i18n %}
<nav class="navbar navbar-expand-lg bg-body-tertiary">
<div class="container-fluid">
<a class="navbar-brand" href="#">Tijd voor de test</a>
<button class="navbar-toggler"
type="button"
data-bs-toggle="collapse"
data-bs-target="#navbarSupportedContent"
aria-controls="navbarSupportedContent"
aria-expanded="false"
aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item">
{% url "backoffice:index" as expected %}
<a class="nav-link{% if expected == request.path %} active{% endif %}"
href="{{ expected }}">Seizoenen</a>
</li>
</ul>
<ul class="navbar-nav ml-auto mb-e mb-lg-0">
<li class="nav-item">
<a class="nav-link" href="{% url 'admin:index' %}">Django Admin</a>
</li>
<li class="nav-item py-2 py-lg-1 col-12 col-lg-auto">
<div class="vr d-none d-lg-flex h-100 mx-lg-2 text-white"></div>
<hr class="d-lg-none my-2 text-white-50">
</li>
<li>
<form class="d-flex" action="{% url 'set_language' %}" method="post">
{% csrf_token %}
<input name="next" type="hidden" value="{{ redirect_to }}" />
<select name="language" class="form-select me-2">
{% get_current_language as LANGUAGE_CODE %}
{% get_available_languages as LANGUAGES %}
{% get_language_info_list for LANGUAGES as languages %}
{% for language in languages %}
<option value="{{ language.code }}"
{% if language.code == LANGUAGE_CODE %}selected="selected"{% endif %}>
{{ language.name_local }} ({{ language.code }})
</option>
{% endfor %}
</select>
<button class="btn btn-outline-secondary" type="submit">Go</button>
</form>
</li>
</ul>
</div>
</div>
</nav>

View File

@@ -1,92 +0,0 @@
{% extends "backoffice/base.html" %}
{% load i18n %}
{% block body %}
<p>
<h2>{% translate "Quiz" %}: {{ quiz.season.name }} - {{ quiz.name }}</h2>
</p>
<div id="questions">
<p>
<h4>{% translate "Questions" %}</h4>
</p>
<div class="accordion">
{% for question in quiz.questions.all %}
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button collapsed"
type="button"
data-bs-toggle="collapse"
data-bs-target="#question-{{ forloop.counter0 }}"
aria-controls="question-{{ forloop.counter0 }}">
{% with question_error=question.errors %}
{% if question_error %}
<span data-bs-toggle="tooltip"
title="{{ question_error }}"
class="badge text-bg-danger rounded-pill me-2">!</span>
{% endif %}
{% endwith %}
{{ forloop.counter }}. {{ question.question }}
</button>
</h2>
<div id="question-{{ forloop.counter0 }}"
class="accordion-collapse collapse">
<div class="accordion-body">
{% for answer in question.answers.all %}
<li {% if answer.is_right_answer %}class="text-decoration-underline"{% endif %}>{{ answer.text }}</li>
{% empty %}
{% translate "There are no answers for this question" %}
{% endfor %}
</div>
</div>
</div>
{% empty %}
EMPTY
{% endfor %}
</div>
</div>
<div class="scores">
<p>
<h4>{% translate "Score" %}</h4>
</p>
<div class="btn-toolbar" role="toolbar">
<div class="btn-group btn-group-lg me-2">
<a class="btn btn-primary">{% translate "Start Elimination" %}</a>
</div>
<div class="btn-group btn-group-lg">
<a class="btn btn-secondary">{% translate "Prepare Custom Elimination" %}</a>
<a class="btn btn-secondary">{% translate "Load Prepared Elimination" %}</a>
</div>
</div>
<p>{% translate "Number of dropouts:" %} {{ quiz.dropouts }}</p>
<table class="table table-hover">
<thead>
<tr>
<th scope="col">{% translate "Candidate" %}</th>
<th scope="col">{% translate "Correct Answers" %}</th>
<th scope="col">{% translate "Corrections" %}</th>
<th scope="col">{% translate "Score" %}</th>
<th scope="col">{% translate "Time" %}</th>
</tr>
</thead>
<tbody>
{% with result=quiz.get_score %}
{% for candidate in result %}
<tr class="table-{% if forloop.revcounter > quiz.dropouts %}success{% else %}danger{% endif %}">
<td>{{ candidate.name }}</td>
<td>{{ candidate.correct }}</td>
<td>{{ candidate.corrections }}</td>
<td>{{ candidate.score }}</td>
<td>{{ candidate.time }}</td>
</tr>
{% empty %}
{% endfor %}
</tbody>
</table>
{% endwith %}
</div>
{% endblock body %}
{% block script %}
<script>
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]')
const tooltipList = [...tooltipTriggerList].map(tooltipTriggerEl => new bootstrap.Tooltip(tooltipTriggerEl))
</script>
{% endblock script %}

View File

@@ -1,25 +0,0 @@
{% extends "backoffice/base.html" %}
{% load i18n %}
{% block body %}
<p>
<h2>{% translate "Season" %}: {{ season.name }}</h2>
</p>
<div class="row">
<div class="col-md-6 col-12">
<h4>{% translate "Quizzes" %}</h4>
<div class="list-group">
{% for quiz in season.quizzes.all %}
<a class="list-group-item list-group-item-action{% if season.active_quiz == quiz %} active{% endif %}"
href="{% url "backoffice:quiz" quiz %}">{{ quiz.name }}</a>
{% empty %}
{% endfor %}
</div>
</div>
<div class="col-md-6 col-12">
<h4>{% translate "Candidates" %}</h4>
<ul>
{% for candidate in season.candidates.all %}<li>{{ candidate.name }}</li>{% endfor %}
</ul>
</div>
</div>
{% endblock body %}

View File

@@ -1,3 +0,0 @@
from django.test import TestCase
# Create your tests here.

View File

@@ -1,21 +0,0 @@
from django.contrib.auth.decorators import login_required
from django.urls import path, register_converter
from tvdt.converters import SeasonCodeConverter
from .converters import QuizConverter
from .views import BackofficeIndexView, QuizView, SeasonView
register_converter(SeasonCodeConverter, "season")
register_converter(QuizConverter, "quiz")
app_name = "backoffice"
urlpatterns = [
path("", login_required(BackofficeIndexView.as_view()), name="index"),
path(
"<season:season>/",
login_required(SeasonView.as_view()),
name="season",
),
path("<quiz:quiz>/", login_required(QuizView.as_view()), name="quiz"),
]

View File

@@ -1,3 +0,0 @@
from .home import BackofficeIndexView
from .quiz import QuizView
from .season import SeasonView

View File

@@ -1,10 +0,0 @@
from django.http import HttpRequest
from django.views.generic import TemplateView
class BackofficeIndexView(TemplateView):
template_name = "backoffice/index.html"
def get(self, request: HttpRequest, *args, **kwargs):
seasons = request.user.seasons.all()
return self.render_to_response({"seasons": seasons})

View File

@@ -1,13 +0,0 @@
from django.http import HttpRequest
from django.views import View
from django.views.generic.base import TemplateResponseMixin
from quiz.models import Quiz
class QuizView(View, TemplateResponseMixin):
template_name = "backoffice/quiz.html"
def get(self, request: HttpRequest, quiz: Quiz, *args, **kwargs):
return self.render_to_response({"quiz": quiz})

View File

@@ -1,12 +0,0 @@
from django.http import HttpRequest
from django.views import View
from django.views.generic.base import TemplateResponseMixin
from quiz.models import Season
class SeasonView(View, TemplateResponseMixin):
template_name = "backoffice/season.html"
def get(self, request: HttpRequest, season: Season, *args, **kwargs):
return self.render_to_response({"season": season})

View File

@@ -1,122 +0,0 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-12-11 23:22+0100\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: backoffice/templates/backoffice/base.html:18
msgid "Tijd voor de test"
msgstr "Tijd voor de test"
#: backoffice/templates/backoffice/index.html:4
msgid "Your Seasons"
msgstr "Jouw seizoenen"
#: backoffice/templates/backoffice/index.html:8
msgid "Name"
msgstr "Naam"
#: backoffice/templates/backoffice/index.html:9
msgid "Active Quiz"
msgstr "Actieve test"
#: backoffice/templates/backoffice/index.html:10
msgid "Season Code"
msgstr "Seizoenscode"
#: backoffice/templates/backoffice/index.html:11
msgid "Preregister?"
msgstr "Voorregistreren?"
#: backoffice/templates/backoffice/index.html:12
#: backoffice/templates/backoffice/index.html:37
msgid "Manage"
msgstr "Beheer"
#: backoffice/templates/backoffice/index.html:23
msgid "No active quiz"
msgstr "Geen actieve test"
#: backoffice/templates/backoffice/quiz.html:5
msgid "Quiz"
msgstr "Test"
#: backoffice/templates/backoffice/quiz.html:9
msgid "Questions"
msgstr "Vragen"
#: backoffice/templates/backoffice/quiz.html:36
msgid "There are no answers for this question"
msgstr "Er zijn geen antwoorden voor deze vraag"
#: backoffice/templates/backoffice/quiz.html:48
#: backoffice/templates/backoffice/quiz.html:66
msgid "Score"
msgstr "Score"
#: backoffice/templates/backoffice/quiz.html:52
msgid "Start Elimination"
msgstr "Start eliminatie"
#: backoffice/templates/backoffice/quiz.html:55
msgid "Prepare Custom Elimination"
msgstr "Aangepaste eliminatie voorbereiden"
#: backoffice/templates/backoffice/quiz.html:56
msgid "Load Prepared Elimination"
msgstr "Aangepaste eliminatie inladen"
#: backoffice/templates/backoffice/quiz.html:59
msgid "Number of dropouts:"
msgstr "Aantal afvallers:"
#: backoffice/templates/backoffice/quiz.html:63
msgid "Candidate"
msgstr "Kandidaat"
#: backoffice/templates/backoffice/quiz.html:64
msgid "Correct Answers"
msgstr "Goede antwoorden"
#: backoffice/templates/backoffice/quiz.html:65
msgid "Corrections"
msgstr "Jokers"
#: backoffice/templates/backoffice/quiz.html:67
msgid "Time"
msgstr "Tijd"
#: backoffice/templates/backoffice/season.html:5
msgid "Season"
msgstr "Seizoen"
#: backoffice/templates/backoffice/season.html:9
msgid "Quizzes"
msgstr "Tests"
#: backoffice/templates/backoffice/season.html:21
msgid "Candidates"
msgstr "Kandidaten"
#: tvdt/settings.py:173
msgid "Dutch"
msgstr "Nederlands"
#: tvdt/settings.py:173
msgid "English"
msgstr "Engels"

View File

@@ -1,22 +0,0 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main() -> None:
"""Run administrative tasks."""
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tvdt.settings")
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == "__main__":
main()

1163
tvdt/poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,34 +0,0 @@
[tool.poetry]
name = "tvdt"
package-mode = false
[tool.poetry.dependencies]
python = "^3.12"
Django = "^5.1.2"
django-crispy-forms = "^2.3"
crispy-bootstrap5 = "^2024.10"
django-allauth = {extras = ["socialaccount"], version = "^65.2.0"}
django-stubs = {extras = ["compatible-mypy"], version = "^5.1.0"}
environs = {extras = ["django"], version = "^11.2.1"}
psycopg2 = "^2.9.10"
[tool.poetry.group.dev.dependencies]
mypy = "^1.11.0"
black = "^24.10.0"
isort = "^5.13.2"
djlint = "^1.36.3"
[tool.poetry.group.prod.dependencies]
gunicorn = "^23.0.0"
[tool.isort]
profile = "black"
[tool.mypy]
plugins = ["mypy_django_plugin.main"]
[tool.django-stubs]
django_settings_module = "tvdt.settings"
[tool.djlint]
profile="django"

View File

View File

@@ -1,50 +0,0 @@
from django.contrib import admin
from .models import Answer, Candidate, Correction, GivenAnswer, Question, Quiz, Season
class CandidatesAdmin(admin.StackedInline):
model = Candidate
extra = 1
@admin.register(Season)
class SeasonAdmin(admin.ModelAdmin):
inlines = [CandidatesAdmin]
class QuestionInline(admin.TabularInline):
model = Question
extra = 0
@admin.register(Quiz)
class QuizAdmin(admin.ModelAdmin):
inlines = [QuestionInline]
class AnswerInline(admin.TabularInline):
model = Answer
extra = 0
@admin.register(Question)
class QuestionAdmin(admin.ModelAdmin):
list_display = ["question", "quiz__season__name", "quiz__name", "_order"]
ordering = ["quiz__season", "quiz", "_order"]
inlines = [AnswerInline]
@admin.register(Candidate)
class CandidateAdmin(admin.ModelAdmin):
pass
@admin.register(GivenAnswer)
class GivenAnswerAdmin(admin.ModelAdmin):
pass
@admin.register(Correction)
class CorrextionAdmin(admin.ModelAdmin):
pass

View File

@@ -1,8 +0,0 @@
from django.apps import AppConfig
from django.utils.translation import gettext_lazy as _
class QuizConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "quiz"
verbose_name = _("quiz")

View File

@@ -1,2 +0,0 @@
def get_theme(request) -> dict:
return {"theme": "wie_is_de_mol"}

View File

@@ -1,28 +0,0 @@
import base64
import binascii
from .models import Candidate, Season
class CandidateConverter:
regex = r"[A-Za-z\d]{5}\/[\w\-=]+"
def to_python(self, value: str) -> Candidate:
season_code, base64_name = value.split("/")
try:
name = base64.urlsafe_b64decode(base64_name).decode()
except binascii.Error:
raise ValueError
try:
season = Season.objects.get(season_code=season_code)
candidate = Candidate.objects.get(name=name, season=season)
return candidate
except [Season.DoesNotExist, Candidate.DoesNotExist]:
raise ValueError
def to_url(self, candidate: Candidate) -> str:
base64_candidate = base64.urlsafe_b64encode(candidate.name.encode()).decode()
return f"{candidate.season.season_code}/{base64_candidate}"

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +0,0 @@
import random
import string
def generate_season_code(length: int = 5) -> str:
return "".join(
random.choice(string.ascii_uppercase + string.digits) for _ in range(length)
)

View File

@@ -1,162 +0,0 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-12-11 23:22+0100\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: quiz/apps.py:8 quiz/models/correction.py:17 quiz/models/given_answer.py:19
#: quiz/models/question.py:23 quiz/models/quiz.py:60
msgid "quiz"
msgstr "test"
#: quiz/models/answer.py:10
msgid "text"
msgstr "tekst"
#: quiz/models/answer.py:15 quiz/models/question.py:18
#: quiz/models/question.py:58
msgid "question"
msgstr "vraag"
#: quiz/models/answer.py:17
msgid "is right answer"
msgstr "is goede antwoord"
#: quiz/models/answer.py:19 quiz/models/candidate.py:50
msgid "candidates"
msgstr "kandidaten"
#: quiz/models/answer.py:23 quiz/models/given_answer.py:25
msgid "answer"
msgstr "antwoord"
#: quiz/models/answer.py:24
msgid "answers"
msgstr "antwoorden"
#: quiz/models/candidate.py:18 quiz/models/quiz.py:12 quiz/models/season.py:12
msgid "name"
msgstr "naam"
#: quiz/models/candidate.py:49 quiz/models/correction.py:11
#: quiz/models/given_answer.py:11
msgid "candidate"
msgstr "kandidaat"
#: quiz/models/correction.py:19
msgid "amount"
msgstr "aantal"
#: quiz/models/correction.py:23
msgid "correction"
msgstr "joker"
#: quiz/models/correction.py:24
msgid "corrections"
msgstr "jokers"
#: quiz/models/given_answer.py:36
msgid "given answer"
msgstr "gegeven antwoord"
#: quiz/models/given_answer.py:37
msgid "given answers"
msgstr "gegeven antwoorden"
#: quiz/models/question.py:25
msgid "enabled"
msgstr "actief"
#: quiz/models/question.py:42
msgid "Error: Question has no answers"
msgstr "Fout: Raar genoeg heeft deze vraag geen antwoorden..."
#: quiz/models/question.py:47
msgid "Error: This question has no right answer!"
msgstr "Fout: Raar genoeg heeft deze vraag geen antwoorden..."
#: quiz/models/question.py:50
msgid "Warning: This question has multiple correct answers"
msgstr "Waarschuwing: Raar genoeg heeft deze vraag geen antwoorden..."
#: quiz/models/question.py:59
msgid "questions"
msgstr "vraag"
#: quiz/models/quiz.py:17 quiz/models/season.py:43
msgid "season"
msgstr "seizoen"
#: quiz/models/quiz.py:21
msgid "dropouts"
msgstr "afvallers"
#: quiz/models/quiz.py:61
msgid "quizzes"
msgstr "tests"
#: quiz/models/season.py:19
msgid "active quiz"
msgstr "actieve test"
#: quiz/models/season.py:23
msgid "season code"
msgstr "seizoencode"
#: quiz/models/season.py:26
msgid "preregister candidates"
msgstr "kandidaten voorregistreren"
#: quiz/models/season.py:30
msgid "owners"
msgstr "eigenaren"
#: quiz/models/season.py:44
msgid "seasons"
msgstr "seizoenen"
#: quiz/templates/quiz/base.html:16
msgid "Tijd voor de test"
msgstr "Tijd voor de test"
#: quiz/templates/quiz/question.html:15
msgid "Weirdly enough this question has no answers..."
msgstr "Raar genoeg heeft deze vraag geen antwoorden..."
#: quiz/views/enternameview.py:15
msgid "Name"
msgstr "Naam"
#: quiz/views/enternameview.py:28
msgid "This season has no active quiz."
msgstr "Dit seizoen heeft geen actieve test."
#: quiz/views/enternameview.py:40
msgid "Candidate does not exist"
msgstr "Kandidaat bestaat niet"
#: quiz/views/questionview.py:23
msgid "No active quiz for season"
msgstr "Geen active test voor seizoen"
#: quiz/views/questionview.py:27
msgid "Quiz done"
msgstr "Test klaar"
#: quiz/views/selectseasonview.py:30
msgid "Invalid season code"
msgstr "Ongeldige seizoencode"

View File

@@ -1,286 +0,0 @@
# Generated by Django 5.1.3 on 2024-11-25 18:17
import django.db.models.deletion
from django.db import migrations, models
import quiz.helpers
class Migration(migrations.Migration):
initial = True
dependencies = []
operations = [
migrations.CreateModel(
name="Candidate",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=16, verbose_name="name")),
],
options={
"verbose_name": "candidate",
"verbose_name_plural": "candidates",
},
),
migrations.CreateModel(
name="Question",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("question", models.CharField(max_length=256, verbose_name="question")),
("enabled", models.BooleanField(default=True, verbose_name="enabled")),
],
options={
"verbose_name": "question",
"verbose_name_plural": "questions",
},
),
migrations.CreateModel(
name="Quiz",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=64, verbose_name="name")),
],
options={
"verbose_name": "quiz",
"verbose_name_plural": "quizzes",
},
),
migrations.CreateModel(
name="Answer",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("text", models.CharField(max_length=64, verbose_name="text")),
(
"is_right_answer",
models.BooleanField(verbose_name="is right answer"),
),
(
"candidates",
models.ManyToManyField(
blank=True, to="quiz.candidate", verbose_name="candidates"
),
),
(
"question",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="answers",
to="quiz.question",
verbose_name="question",
),
),
],
options={
"verbose_name": "answer",
"verbose_name_plural": "answers",
"order_with_respect_to": "question",
},
),
migrations.AddField(
model_name="question",
name="quiz",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="questions",
to="quiz.quiz",
verbose_name="quiz",
),
),
migrations.CreateModel(
name="GivenAnswer",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("created", models.DateTimeField(auto_now_add=True)),
(
"answer",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="quiz.answer",
verbose_name="answer",
),
),
(
"candidate",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="answers",
to="quiz.candidate",
verbose_name="candidate",
),
),
(
"quiz",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="+",
to="quiz.quiz",
verbose_name="quiz",
),
),
],
options={
"verbose_name": "given answer",
"verbose_name_plural": "given answers",
"ordering": ("quiz", "candidate"),
},
),
migrations.CreateModel(
name="Season",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=64, verbose_name="name")),
(
"season_code",
models.CharField(
default=quiz.helpers.generate_season_code,
max_length=5,
verbose_name="season code",
),
),
(
"preregister_candidates",
models.BooleanField(
default=True, verbose_name="preregister candidates"
),
),
(
"active_quiz",
models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="+",
to="quiz.quiz",
verbose_name="active quiz",
),
),
],
options={
"verbose_name": "season",
"verbose_name_plural": "seasons",
},
),
migrations.AddField(
model_name="quiz",
name="season",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="quizzes",
to="quiz.season",
verbose_name="season",
),
),
migrations.AddField(
model_name="candidate",
name="season",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="candidates",
to="quiz.season",
verbose_name="season",
),
),
migrations.AlterOrderWithRespectTo(
name="question",
order_with_respect_to="quiz",
),
migrations.CreateModel(
name="Correction",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"candidate",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="corrections_used",
to="quiz.candidate",
verbose_name="candidate",
),
),
(
"quiz",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="corrections_used",
to="quiz.quiz",
verbose_name="quiz",
),
),
],
options={
"verbose_name": "correction",
"verbose_name_plural": "corrections",
"unique_together": {("candidate", "quiz")},
},
),
migrations.AddIndex(
model_name="candidate",
index=models.Index(
fields=["season", "name"], name="quiz_candid_season__d83118_idx"
),
),
migrations.AlterUniqueTogether(
name="candidate",
unique_together={("season", "name")},
),
]

View File

@@ -1,24 +0,0 @@
# Generated by Django 5.1.3 on 2024-11-30 18:21
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("quiz", "0001_initial"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name="season",
name="owner",
field=models.ManyToManyField(
related_name="seasons",
to=settings.AUTH_USER_MODEL,
verbose_name="owners",
),
),
]

View File

@@ -1,39 +0,0 @@
# Generated by Django 5.1.3 on 2024-12-01 14:23
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("quiz", "0002_season_owner"),
]
operations = [
migrations.AddField(
model_name="correction",
name="amount",
field=models.FloatField(default=1, verbose_name="amount"),
),
migrations.AlterField(
model_name="correction",
name="candidate",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="corrections",
to="quiz.candidate",
verbose_name="candidate",
),
),
migrations.AlterField(
model_name="correction",
name="quiz",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="corrections",
to="quiz.quiz",
verbose_name="quiz",
),
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 5.1.3 on 2024-12-01 16:57
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("quiz", "0003_correction_amount_alter_correction_candidate_and_more"),
]
operations = [
migrations.AddField(
model_name="quiz",
name="dropouts",
field=models.PositiveSmallIntegerField(default=1, verbose_name="dropouts"),
),
]

View File

@@ -1,7 +0,0 @@
from .answer import Answer
from .candidate import Candidate
from .correction import Correction
from .given_answer import GivenAnswer
from .question import Question
from .quiz import Quiz
from .season import Season

View File

@@ -1,26 +0,0 @@
from typing import final
from django.db import models
from django.utils.translation import gettext_lazy as _
from django_stubs_ext.db.models import TypedModelMeta
@final
class Answer(models.Model):
text = models.CharField(max_length=64, verbose_name=_("text"))
question = models.ForeignKey(
"Question",
on_delete=models.CASCADE,
related_name="answers",
verbose_name=_("question"),
)
is_right_answer = models.BooleanField(verbose_name=_("is right answer"))
candidates = models.ManyToManyField(
"Candidate", verbose_name=_("candidates"), blank=True
)
class Meta(TypedModelMeta):
verbose_name = _("answer")
verbose_name_plural = _("answers")
order_with_respect_to = "question"

View File

@@ -1,50 +0,0 @@
from typing import Self
from django.db import models
from django.utils.translation import gettext_lazy as _
from django_stubs_ext.db.models import TypedModelMeta
from .given_answer import GivenAnswer
from .question import NoActiveTestForSeason, Question, QuizAlreadyFinished
class Candidate(models.Model):
season = models.ForeignKey(
"Season",
on_delete=models.CASCADE,
related_name="candidates",
verbose_name="season",
)
name = models.CharField(max_length=16, verbose_name=_("name"))
def get_next_question(self, candidate: Self) -> "Question":
quiz = candidate.season.active_quiz
if quiz is None:
raise NoActiveTestForSeason()
question = (
Question.objects.filter(quiz=quiz, enabled=True)
.exclude(
id__in=GivenAnswer.objects.filter(
candidate=candidate,
quiz=quiz,
answer__isnull=False,
).values_list("answer__question_id", flat=True)
)
.first()
)
if question is None:
raise QuizAlreadyFinished()
return question
def __str__(self) -> str:
return f"{self.name} ({self.season})"
class Meta(TypedModelMeta):
unique_together = ["season", "name"]
indexes = [models.Index(fields=["season", "name"])]
verbose_name = _("candidate")
verbose_name_plural = _("candidates")

View File

@@ -1,24 +0,0 @@
from django.db import models
from django.utils.translation import gettext_lazy as _
from django_stubs_ext.db.models import TypedModelMeta
class Correction(models.Model):
candidate = models.ForeignKey(
"Candidate",
on_delete=models.CASCADE,
related_name="corrections",
verbose_name=_("candidate"),
)
quiz = models.ForeignKey(
"Quiz",
on_delete=models.CASCADE,
related_name="corrections",
verbose_name=_("quiz"),
)
amount = models.FloatField(verbose_name=_("amount"), default=1)
class Meta(TypedModelMeta):
unique_together = ("candidate", "quiz")
verbose_name = _("correction")
verbose_name_plural = _("corrections")

View File

@@ -1,37 +0,0 @@
from django.db import models
from django.utils.translation import gettext_lazy as _
from django_stubs_ext.db.models import TypedModelMeta
class GivenAnswer(models.Model):
candidate = models.ForeignKey(
"Candidate",
on_delete=models.CASCADE,
related_name="answers",
verbose_name=_("candidate"),
)
quiz = models.ForeignKey(
"Quiz",
on_delete=models.CASCADE,
null=False,
related_name="+",
verbose_name=_("quiz"),
)
answer = models.ForeignKey(
"Answer",
on_delete=models.CASCADE,
verbose_name=_("answer"),
null=True,
)
created = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"{self.quiz} - {self.candidate.name} {self.answer}"
class Meta(TypedModelMeta):
ordering = ("quiz", "candidate")
verbose_name = _("given answer")
verbose_name_plural = _("given answers")

View File

@@ -1,61 +0,0 @@
from django.db import models
from django.db.models import QuerySet
from django.utils.translation import gettext_lazy as _
from django_stubs_ext.db.models import TypedModelMeta
from quiz.models import Answer
class NoActiveTestForSeason(Exception):
pass
class QuizAlreadyFinished(Exception):
pass
class Question(models.Model):
question = models.CharField(max_length=256, verbose_name=_("question"))
quiz = models.ForeignKey(
"Quiz",
on_delete=models.CASCADE,
related_name="questions",
verbose_name=_("quiz"),
)
enabled = models.BooleanField(default=True, verbose_name=_("enabled"))
@property
def order(self):
return self._order
@property
def right_answer(self) -> QuerySet[Answer]:
return self.answers.filter(is_right_answer=True)
@property
def has_right_answer(self) -> bool:
return self.answers.filter(is_right_answer=True).count() > 0
@property
def errors(self) -> str | None:
if self.answers.count() == 0:
return _("Error: Question has no answers")
n_correct_answers = self.answers.filter(is_right_answer=True).count()
if n_correct_answers == 0:
return _("Error: This question has no right answer!")
if n_correct_answers > 1:
return _("Warning: This question has multiple correct answers")
return None
def __str__(self) -> str:
return f"{self._order + 1}. {self.question} ({self.quiz}) ({self.answers.count()} answers, {self.answers.filter(is_right_answer=True).count()} correct)"
class Meta(TypedModelMeta):
verbose_name = _("question")
verbose_name_plural = _("questions")
order_with_respect_to = "quiz"

View File

@@ -1,61 +0,0 @@
from django.db import models
from django.db.models import F, OuterRef, Subquery
from django.db.models.aggregates import Count, Max, Min
from django.db.models.functions import Coalesce
from django.utils.translation import gettext_lazy as _
from django_stubs_ext.db.models import TypedModelMeta
from quiz.models import Candidate, Correction
class Quiz(models.Model):
name = models.CharField(max_length=64, verbose_name=_("name"))
season = models.ForeignKey(
"Season",
on_delete=models.CASCADE,
related_name="quizzes",
verbose_name=_("season"),
)
dropouts = models.PositiveSmallIntegerField(
verbose_name=_("dropouts"),
default=1,
)
def is_valid_quiz(self) -> bool:
return True
# Check > 0 active questions
# Check every question 1 right answer
def get_score(self):
time_query = (
Candidate.objects.filter(id=OuterRef("id"), answers__quiz=self)
.annotate(time=Max("answers__created") - Min("answers__created"))
.values("time")
)
corrections = Correction.objects.filter(
quiz=self, candidate=OuterRef("id")
).values("amount")
scores = (
Candidate.objects.filter(
answers__answer__is_right_answer=True,
answers__quiz=self,
)
.values("id", "name")
.annotate(
correct=Count("answers"),
corrections=Coalesce(Subquery(corrections), 0.0),
score=F("correct") + F("corrections"),
time=Subquery(time_query),
)
.order_by("-score", "time")
)
return scores
def __str__(self) -> str:
return f"{self.season.name} - {self.name}"
class Meta(TypedModelMeta):
verbose_name = _("quiz")
verbose_name_plural = _("quizzes")

View File

@@ -1,44 +0,0 @@
from django.contrib.auth import get_user_model
from django.db import models
from django.utils.translation import gettext_lazy as _
from django_stubs_ext.db.models import TypedModelMeta
from ..helpers import generate_season_code
User = get_user_model()
class Season(models.Model):
name = models.CharField(max_length=64, verbose_name=_("name"))
active_quiz = models.ForeignKey(
"Quiz",
on_delete=models.SET_NULL,
null=True,
blank=True,
default=None,
verbose_name=_("active quiz"),
related_name="+",
)
season_code = models.CharField(
max_length=5, default=generate_season_code, verbose_name=_("season code")
)
preregister_candidates = models.BooleanField(
default=True, verbose_name=_("preregister candidates")
)
owner = models.ManyToManyField(
User,
verbose_name=_("owners"),
related_name="seasons",
)
def renew_season_code(self) -> str:
self.season_code = generate_season_code()
self.save()
return self.season_code
def __str__(self) -> str:
return self.name
class Meta(TypedModelMeta):
verbose_name = _("season")
verbose_name_plural = _("seasons")

Binary file not shown.

Before

Width:  |  Height:  |  Size: 322 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 397 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 402 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 445 KiB

View File

@@ -1,51 +0,0 @@
{% load i18n %}
{% load static %}
<!DOCTYPE html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
rel="stylesheet"
integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH"
crossorigin="anonymous">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz"
crossorigin="anonymous"></script>
<title>
{% block title %}
{% translate "Tijd voor de test" %}
{% endblock title %}
</title>
<style>
html, body {
height: 100%;
{% with "quiz/"|add:theme|add:"/background.png" as background %}
background-image: url("{% static background %}");
{% endwith %}
background-position: center center;
background-repeat: no-repeat;
background-color: black;
color: white;
display: grid;
align-items: center;
justify-self: center;
}
.asteriskField {
display: none;
}
</style>
</head>
<body>
<main>
<div class="container">
{% include "messages.html" %}
{% block body %}
{% endblock body %}
</div>
{% block script %}
{% endblock script %}
</main>
</body>
</html>

View File

@@ -1,5 +0,0 @@
{% extends "quiz/base.html" %}
{% load crispy_forms_tags %}
{% block body %}
{% crispy form %}
{% endblock body %}

View File

@@ -1,18 +0,0 @@
{% extends "quiz/base.html" %}
{% load i18n %}
{% block body %}
<h2>{{ question.question }}</h2>
<form method="post">
{% csrf_token %}
{% for answer in question.answers.all %}
<div>
<button class="btn btn-outline-success"
type="submit"
name="answer"
value="{{ answer.id }}">{{ answer.text }}</button>
</div>
{% empty %}
{% translate "Weirdly enough this question has no answers..." %}
{% endfor %}
</form>
{% endblock body %}

View File

@@ -1,6 +0,0 @@
{% extends "quiz/base.html" %}
{% load crispy_forms_tags %}
{% load i18n %}
{% block body %}
{% crispy form %}
{% endblock body %}

View File

@@ -1,3 +0,0 @@
from django.test import TestCase
# Create your tests here.

View File

@@ -1,17 +0,0 @@
from django.urls import path, register_converter
from tvdt.converters import SeasonCodeConverter
from .converters import CandidateConverter
from .views import SelectSeasonView
from .views.enternameview import EnterNameView
from .views.questionview import QuestionView
register_converter(SeasonCodeConverter, "season")
register_converter(CandidateConverter, "candidate")
urlpatterns = [
path("", SelectSeasonView.as_view(), name="index"),
path("<season:season>/", EnterNameView.as_view(), name="enter_name"),
path("<candidate:candidate>/", QuestionView.as_view(), name="question"),
# path("<>")
]

View File

@@ -1 +0,0 @@
from .selectseasonview import SelectSeasonView

View File

@@ -1,51 +0,0 @@
from crispy_forms.helper import FormHelper
from django import forms
from django.contrib import messages
from django.shortcuts import redirect
from django.urls import reverse
from django.utils.translation import gettext as _
from django.utils.translation import gettext_lazy
from django.views import View
from django.views.generic.base import TemplateResponseMixin
from ..models import Candidate, Season
class EnterNameForm(forms.Form):
name = forms.CharField(label=_("Name"))
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
class EnterNameView(View, TemplateResponseMixin):
template_name = "quiz/enter_name.html"
forms_class = EnterNameForm
def get(self, request, season: Season, *args, **kwargs):
if season.active_quiz == None:
messages.info(request, _("This season has no active quiz."))
return redirect("home")
return self.render_to_response({"form": self.forms_class()})
def post(self, request, season: Season, *args, **kwargs):
name = request.POST.get("name")
try:
candidate = Candidate.objects.get(season=season, name__iexact=name)
except Candidate.DoesNotExist:
if season.preregister_candidates:
messages.warning(request, _("Candidate does not exist"))
return redirect(reverse("quiz", kwargs={"season": season}))
candidate = Candidate.objects.create(season=season, name=name)
return redirect(
reverse(
"question",
kwargs={"candidate": candidate},
)
)

View File

@@ -1,59 +0,0 @@
from django.contrib import messages
from django.core.exceptions import BadRequest
from django.http import Http404, HttpRequest, HttpResponse
from django.shortcuts import redirect
from django.urls import reverse
from django.utils.translation import gettext as _
from django.views import View
from django.views.generic.base import TemplateResponseMixin
from ..models import Answer, Candidate, GivenAnswer
from ..models.question import NoActiveTestForSeason, QuizAlreadyFinished
class QuestionView(View, TemplateResponseMixin):
template_name = "quiz/question.html"
def get(
self, request: HttpRequest, candidate: Candidate, *args, **kwargs
) -> HttpResponse:
try:
question = candidate.get_next_question(candidate)
except NoActiveTestForSeason:
messages.error(request, _("No active quiz for season"))
return redirect("home")
except QuizAlreadyFinished:
if not kwargs.get("from_post"):
messages.error(request, _("Quiz done"))
return redirect(reverse("enter_name", kwargs={"season": candidate.season}))
# TODO: On first question -> record time
if (
GivenAnswer.objects.filter(
candidate=candidate, quiz=candidate.season.active_quiz
).count()
== 0
):
GivenAnswer.objects.create(
candidate=candidate, quiz=question.quiz, answer=None
)
return self.render_to_response({"candidate": candidate, "question": question})
def post(self, request: HttpRequest, candidate: Candidate, *args, **kwargs):
answer_id = request.POST.get("answer")
if answer_id == None:
raise BadRequest
try:
answer = Answer.objects.get(id=answer_id)
except Answer.DoesNotExist:
raise BadRequest
GivenAnswer.objects.create(
candidate=candidate,
quiz=answer.question.quiz,
answer=answer,
)
return self.get(request, candidate, from_post=True)

View File

@@ -1,39 +0,0 @@
from crispy_forms.helper import FormHelper
from django import forms
from django.contrib import messages
from django.http import Http404
from django.shortcuts import redirect
from django.urls import reverse
from django.utils.translation import gettext as _
from django.views.generic.edit import FormView
from mypy.dmypy.client import request
from ..models import Season
class SelectSeasonForm(forms.Form):
code = forms.CharField(max_length=5)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
class SelectSeasonView(FormView):
form_class = SelectSeasonForm
template_name = "quiz/select_season.html"
def form_valid(self, form):
try:
season = Season.objects.get(season_code=form.cleaned_data["code"].upper())
except Season.DoesNotExist:
messages.warning(self.request, _("Invalid season code"))
return redirect("home")
from environs import Env
env = Env()
env.read_env()
print(env.dump())
return redirect(reverse("enter_name", kwargs={"season": season}))

View File

@@ -1,13 +0,0 @@
{% if messages %}
{% for message in messages %}
<div class="alert alert-dismissible fade show {{ message.tags }}"
role="alert">
{% if message.level == DEFAULT_MESSAGE_LEVELS.DEBUG %}<strong>Debug:</strong>{% endif %}
{{ message }}
<button type="button"
class="btn-close"
data-bs-dismiss="alert"
aria-label="Close"></button>
</div>
{% endfor %}
{% endif %}

View File

View File

@@ -1,16 +0,0 @@
"""
ASGI config for tvdt project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/5.1/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tvdt.settings")
application = get_asgi_application()

View File

@@ -1,14 +0,0 @@
from quiz.models import Season
class SeasonCodeConverter:
regex = r"[A-Za-z\d]{5}"
def to_python(self, value: str) -> Season:
try:
return Season.objects.get(season_code=value.upper())
except Season.DoesNotExist:
raise ValueError
def to_url(self, value: Season) -> str:
return value.season_code

View File

@@ -1,198 +0,0 @@
"""
Django settings for tvdt project.
Generated by 'django-backoffice startproject' using Django 5.1.2.
For more information on this file, see
https://docs.djangoproject.com/en/5.1/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/5.1/ref/settings/
"""
from pathlib import Path
from django.contrib import messages
from django.utils.translation import gettext_lazy as _
from environs import Env
env = Env()
env.read_env()
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/5.1/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = env.str("SECRET_KEY")
# SECURITY WARNING: don't run with debug turned on in production!
# Override in .env for local development
DEBUG = env.bool("DEBUG", default=False)
ALLOWED_HOSTS: list[str] = ["*"]
# Application definition
INSTALLED_APPS = [
"quiz.apps.QuizConfig",
"backoffice.apps.BackofficeConfig",
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
]
# crispy
INSTALLED_APPS += [
"crispy_forms",
"crispy_bootstrap5",
]
# allauth
INSTALLED_APPS += [
"allauth",
"allauth.account",
"allauth.socialaccount",
"allauth.socialaccount.providers.google",
"allauth.socialaccount.providers.github",
]
SOCIALACCOUNT_PROVIDERS = {
"google": {
"APP": {
"client_id": env.str("GOOGLE_CLIENT_ID"),
"secret": env.str("GOOGLE_SECRET"),
}
},
"github": {
"VERIFIED_EMAIL": True,
"APP": {
"client_id": env.str("GITHUB_CLIENT_ID"),
"secret": env.str("GITHUB_SECRET"),
},
},
}
CRISPY_ALLOWED_TEMPLATE_PACKS = "bootstrap5"
CRISPY_TEMPLATE_PACK = "bootstrap5"
MIDDLEWARE = [
"allauth.account.middleware.AccountMiddleware",
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.locale.LocaleMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
ROOT_URLCONF = "tvdt.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [
BASE_DIR / "templates",
],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
"quiz.context_processors.get_theme",
],
},
},
]
AUTHENTICATION_BACKENDS = [
# Needed to login by username in Django backoffice, regardless of `allauth`
"django.contrib.auth.backends.ModelBackend",
# `allauth` specific authentication methods, such as login by email
"allauth.account.auth_backends.AuthenticationBackend",
]
WSGI_APPLICATION = "tvdt.wsgi.application"
# Database
# https://docs.djangoproject.com/en/5.1/ref/settings/#databases
DATABASES = {"default": env.dj_db_url("DATABASE_URL")}
# Password validation
# https://docs.djangoproject.com/en/5.1/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = (
[
{
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
},
{
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
},
{
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
},
{
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
},
]
if not DEBUG
else []
)
MESSAGE_TAGS = {
messages.DEBUG: "alert-info",
messages.INFO: "alert-info",
messages.SUCCESS: "alert-success",
messages.WARNING: "alert-warning",
messages.ERROR: "alert-danger",
100: "alert-primary",
110: "alert-secondary",
120: "alert-light",
130: "alert-dark",
}
# Internationalization
# https://docs.djangoproject.com/en/5.1/topics/i18n/
LANGUAGE_CODE = "nl"
LANGUAGES = [("nl", _("Dutch")), ("en", _("English"))]
TIME_ZONE = "Europe/Amsterdam"
USE_I18N = True
USE_TZ = True
LOCALE_PATHS = [BASE_DIR / "locale"]
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/5.1/howto/static-files/
STATIC_URL = "static/"
STATIC_ROOT = BASE_DIR / "staticfiles"
# Default primary key field type
# https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
# email is the new username
ACCOUNT_USER_MODEL_USERNAME_FIELD = None
ACCOUNT_EMAIL_REQUIRED = True
ACCOUNT_USERNAME_REQUIRED = False
ACCOUNT_AUTHENTICATION_METHOD = "email"

View File

@@ -1,29 +0,0 @@
"""
URL configuration for tvdt project.
The `urlpatterns` list routes URLs to views. For more information please see:
https://docs.djangoproject.com/en/5.1/topics/http/urls/
Examples:
Function views
1. Add an import: from my_app import views
2. Add a URL to urlpatterns: path('', views.home, name='home')
Class-based views
1. Add an import: from other_app.views import Home
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.contrib import admin
from django.contrib.staticfiles.urls import urlpatterns as static_urlpatterns
from django.urls import include, path
urlpatterns = [
path("", include("quiz.urls")),
path("backoffice/", include("backoffice.urls")),
path("admin/", admin.site.urls),
path("accounts/", include("allauth.urls")),
path("i18n/", include("django.conf.urls.i18n")),
]
urlpatterns += static_urlpatterns

View File

@@ -1,16 +0,0 @@
"""
WSGI config for tvdt project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/5.1/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tvdt.settings")
application = get_wsgi_application()