feat: Provisionner automatiquement un nouvel établissement
Lorsqu'un super-admin crée un établissement via l'interface, le système doit automatiquement créer la base tenant, exécuter les migrations, créer le premier utilisateur admin et envoyer l'invitation — le tout de manière asynchrone pour ne pas bloquer la réponse HTTP. Ce mécanisme rend chaque établissement opérationnel dès sa création sans intervention manuelle sur l'infrastructure.
This commit is contained in:
@@ -89,6 +89,14 @@ TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
|
||||
TURNSTILE_FAIL_OPEN=true
|
||||
###< cloudflare/turnstile ###
|
||||
|
||||
###> s3/minio ###
|
||||
S3_ENDPOINT=http://minio:9000
|
||||
S3_BUCKET=classeo
|
||||
S3_KEY=classeo
|
||||
S3_SECRET=classeo_secret
|
||||
S3_REGION=us-east-1
|
||||
###< s3/minio ###
|
||||
|
||||
###> symfony/lock ###
|
||||
# Choose one of the stores below
|
||||
# postgresql+advisory://db_user:db_password@localhost/db_name
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"doctrine/doctrine-bundle": "^2.13 || ^3.0@dev",
|
||||
"doctrine/doctrine-migrations-bundle": "^3.4",
|
||||
"doctrine/orm": "^3.3",
|
||||
"league/flysystem-aws-s3-v3": "^3.32",
|
||||
"lexik/jwt-authentication-bundle": "^3.2",
|
||||
"nelmio/cors-bundle": "^2.6",
|
||||
"phpoffice/phpspreadsheet": "^5.4",
|
||||
@@ -26,6 +27,7 @@
|
||||
"symfony/console": "^8.0",
|
||||
"symfony/doctrine-messenger": "^8.0",
|
||||
"symfony/dotenv": "^8.0",
|
||||
"symfony/expression-language": "8.0.*",
|
||||
"symfony/flex": "^2",
|
||||
"symfony/framework-bundle": "^8.0",
|
||||
"symfony/html-sanitizer": "8.0.*",
|
||||
|
||||
790
backend/composer.lock
generated
790
backend/composer.lock
generated
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "92b9472c96a59c314d96372c4094f185",
|
||||
"content-hash": "851abcf008c69423a69ad329ae88a255",
|
||||
"packages": [
|
||||
{
|
||||
"name": "api-platform/core",
|
||||
@@ -224,6 +224,157 @@
|
||||
},
|
||||
"time": "2026-01-23T15:23:18+00:00"
|
||||
},
|
||||
{
|
||||
"name": "aws/aws-crt-php",
|
||||
"version": "v1.2.7",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/awslabs/aws-crt-php.git",
|
||||
"reference": "d71d9906c7bb63a28295447ba12e74723bd3730e"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/awslabs/aws-crt-php/zipball/d71d9906c7bb63a28295447ba12e74723bd3730e",
|
||||
"reference": "d71d9906c7bb63a28295447ba12e74723bd3730e",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=5.5"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^4.8.35||^5.6.3||^9.5",
|
||||
"yoast/phpunit-polyfills": "^1.0"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-awscrt": "Make sure you install awscrt native extension to use any of the functionality."
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"classmap": [
|
||||
"src/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"Apache-2.0"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "AWS SDK Common Runtime Team",
|
||||
"email": "aws-sdk-common-runtime@amazon.com"
|
||||
}
|
||||
],
|
||||
"description": "AWS Common Runtime for PHP",
|
||||
"homepage": "https://github.com/awslabs/aws-crt-php",
|
||||
"keywords": [
|
||||
"amazon",
|
||||
"aws",
|
||||
"crt",
|
||||
"sdk"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/awslabs/aws-crt-php/issues",
|
||||
"source": "https://github.com/awslabs/aws-crt-php/tree/v1.2.7"
|
||||
},
|
||||
"time": "2024-10-18T22:15:13+00:00"
|
||||
},
|
||||
{
|
||||
"name": "aws/aws-sdk-php",
|
||||
"version": "3.378.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/aws/aws-sdk-php.git",
|
||||
"reference": "7a95e0665ad13c2cb8999d64439cf969c86724dd"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/7a95e0665ad13c2cb8999d64439cf969c86724dd",
|
||||
"reference": "7a95e0665ad13c2cb8999d64439cf969c86724dd",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"aws/aws-crt-php": "^1.2.3",
|
||||
"ext-json": "*",
|
||||
"ext-pcre": "*",
|
||||
"ext-simplexml": "*",
|
||||
"guzzlehttp/guzzle": "^7.4.5",
|
||||
"guzzlehttp/promises": "^2.0",
|
||||
"guzzlehttp/psr7": "^2.4.5",
|
||||
"mtdowling/jmespath.php": "^2.8.0",
|
||||
"php": ">=8.1",
|
||||
"psr/http-message": "^1.0 || ^2.0",
|
||||
"symfony/filesystem": "^v5.4.45 || ^v6.4.3 || ^v7.1.0 || ^v8.0.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"andrewsville/php-token-reflection": "^1.4",
|
||||
"aws/aws-php-sns-message-validator": "~1.0",
|
||||
"behat/behat": "~3.0",
|
||||
"composer/composer": "^2.7.8",
|
||||
"dms/phpunit-arraysubset-asserts": "^v0.5.0",
|
||||
"doctrine/cache": "~1.4",
|
||||
"ext-dom": "*",
|
||||
"ext-openssl": "*",
|
||||
"ext-sockets": "*",
|
||||
"phpunit/phpunit": "^10.0",
|
||||
"psr/cache": "^2.0 || ^3.0",
|
||||
"psr/simple-cache": "^2.0 || ^3.0",
|
||||
"sebastian/comparator": "^1.2.3 || ^4.0 || ^5.0",
|
||||
"yoast/phpunit-polyfills": "^2.0"
|
||||
},
|
||||
"suggest": {
|
||||
"aws/aws-php-sns-message-validator": "To validate incoming SNS notifications",
|
||||
"doctrine/cache": "To use the DoctrineCacheAdapter",
|
||||
"ext-curl": "To send requests using cURL",
|
||||
"ext-openssl": "Allows working with CloudFront private distributions and verifying received SNS messages",
|
||||
"ext-pcntl": "To use client-side monitoring",
|
||||
"ext-sockets": "To use client-side monitoring"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "3.0-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"files": [
|
||||
"src/functions.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"Aws\\": "src/"
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"src/data/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"Apache-2.0"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Amazon Web Services",
|
||||
"homepage": "https://aws.amazon.com"
|
||||
}
|
||||
],
|
||||
"description": "AWS SDK for PHP - Use Amazon Web Services in your PHP project",
|
||||
"homepage": "https://aws.amazon.com/sdk-for-php",
|
||||
"keywords": [
|
||||
"amazon",
|
||||
"aws",
|
||||
"cloud",
|
||||
"dynamodb",
|
||||
"ec2",
|
||||
"glacier",
|
||||
"s3",
|
||||
"sdk"
|
||||
],
|
||||
"support": {
|
||||
"forum": "https://github.com/aws/aws-sdk-php/discussions",
|
||||
"issues": "https://github.com/aws/aws-sdk-php/issues",
|
||||
"source": "https://github.com/aws/aws-sdk-php/tree/3.378.0"
|
||||
},
|
||||
"time": "2026-04-08T18:13:19+00:00"
|
||||
},
|
||||
{
|
||||
"name": "brick/math",
|
||||
"version": "0.14.1",
|
||||
@@ -1536,6 +1687,215 @@
|
||||
],
|
||||
"time": "2025-03-06T22:45:56+00:00"
|
||||
},
|
||||
{
|
||||
"name": "guzzlehttp/guzzle",
|
||||
"version": "7.10.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/guzzle/guzzle.git",
|
||||
"reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/guzzle/guzzle/zipball/b51ac707cfa420b7bfd4e4d5e510ba8008e822b4",
|
||||
"reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-json": "*",
|
||||
"guzzlehttp/promises": "^2.3",
|
||||
"guzzlehttp/psr7": "^2.8",
|
||||
"php": "^7.2.5 || ^8.0",
|
||||
"psr/http-client": "^1.0",
|
||||
"symfony/deprecation-contracts": "^2.2 || ^3.0"
|
||||
},
|
||||
"provide": {
|
||||
"psr/http-client-implementation": "1.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"bamarni/composer-bin-plugin": "^1.8.2",
|
||||
"ext-curl": "*",
|
||||
"guzzle/client-integration-tests": "3.0.2",
|
||||
"php-http/message-factory": "^1.1",
|
||||
"phpunit/phpunit": "^8.5.39 || ^9.6.20",
|
||||
"psr/log": "^1.1 || ^2.0 || ^3.0"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-curl": "Required for CURL handler support",
|
||||
"ext-intl": "Required for Internationalized Domain Name (IDN) support",
|
||||
"psr/log": "Required for using the Log middleware"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"bamarni-bin": {
|
||||
"bin-links": true,
|
||||
"forward-command": false
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"files": [
|
||||
"src/functions_include.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"GuzzleHttp\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Graham Campbell",
|
||||
"email": "hello@gjcampbell.co.uk",
|
||||
"homepage": "https://github.com/GrahamCampbell"
|
||||
},
|
||||
{
|
||||
"name": "Michael Dowling",
|
||||
"email": "mtdowling@gmail.com",
|
||||
"homepage": "https://github.com/mtdowling"
|
||||
},
|
||||
{
|
||||
"name": "Jeremy Lindblom",
|
||||
"email": "jeremeamia@gmail.com",
|
||||
"homepage": "https://github.com/jeremeamia"
|
||||
},
|
||||
{
|
||||
"name": "George Mponos",
|
||||
"email": "gmponos@gmail.com",
|
||||
"homepage": "https://github.com/gmponos"
|
||||
},
|
||||
{
|
||||
"name": "Tobias Nyholm",
|
||||
"email": "tobias.nyholm@gmail.com",
|
||||
"homepage": "https://github.com/Nyholm"
|
||||
},
|
||||
{
|
||||
"name": "Márk Sági-Kazár",
|
||||
"email": "mark.sagikazar@gmail.com",
|
||||
"homepage": "https://github.com/sagikazarmark"
|
||||
},
|
||||
{
|
||||
"name": "Tobias Schultze",
|
||||
"email": "webmaster@tubo-world.de",
|
||||
"homepage": "https://github.com/Tobion"
|
||||
}
|
||||
],
|
||||
"description": "Guzzle is a PHP HTTP client library",
|
||||
"keywords": [
|
||||
"client",
|
||||
"curl",
|
||||
"framework",
|
||||
"http",
|
||||
"http client",
|
||||
"psr-18",
|
||||
"psr-7",
|
||||
"rest",
|
||||
"web service"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/guzzle/guzzle/issues",
|
||||
"source": "https://github.com/guzzle/guzzle/tree/7.10.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/GrahamCampbell",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/Nyholm",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-08-23T22:36:01+00:00"
|
||||
},
|
||||
{
|
||||
"name": "guzzlehttp/promises",
|
||||
"version": "2.3.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/guzzle/promises.git",
|
||||
"reference": "481557b130ef3790cf82b713667b43030dc9c957"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/guzzle/promises/zipball/481557b130ef3790cf82b713667b43030dc9c957",
|
||||
"reference": "481557b130ef3790cf82b713667b43030dc9c957",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.2.5 || ^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"bamarni/composer-bin-plugin": "^1.8.2",
|
||||
"phpunit/phpunit": "^8.5.44 || ^9.6.25"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"bamarni-bin": {
|
||||
"bin-links": true,
|
||||
"forward-command": false
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"GuzzleHttp\\Promise\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Graham Campbell",
|
||||
"email": "hello@gjcampbell.co.uk",
|
||||
"homepage": "https://github.com/GrahamCampbell"
|
||||
},
|
||||
{
|
||||
"name": "Michael Dowling",
|
||||
"email": "mtdowling@gmail.com",
|
||||
"homepage": "https://github.com/mtdowling"
|
||||
},
|
||||
{
|
||||
"name": "Tobias Nyholm",
|
||||
"email": "tobias.nyholm@gmail.com",
|
||||
"homepage": "https://github.com/Nyholm"
|
||||
},
|
||||
{
|
||||
"name": "Tobias Schultze",
|
||||
"email": "webmaster@tubo-world.de",
|
||||
"homepage": "https://github.com/Tobion"
|
||||
}
|
||||
],
|
||||
"description": "Guzzle promises library",
|
||||
"keywords": [
|
||||
"promise"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/guzzle/promises/issues",
|
||||
"source": "https://github.com/guzzle/promises/tree/2.3.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/GrahamCampbell",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/Nyholm",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-08-22T14:34:08+00:00"
|
||||
},
|
||||
{
|
||||
"name": "guzzlehttp/psr7",
|
||||
"version": "2.8.0",
|
||||
@@ -1785,6 +2145,249 @@
|
||||
],
|
||||
"time": "2025-10-17T11:30:53+00:00"
|
||||
},
|
||||
{
|
||||
"name": "league/flysystem",
|
||||
"version": "3.33.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/thephpleague/flysystem.git",
|
||||
"reference": "570b8871e0ce693764434b29154c54b434905350"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/thephpleague/flysystem/zipball/570b8871e0ce693764434b29154c54b434905350",
|
||||
"reference": "570b8871e0ce693764434b29154c54b434905350",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"league/flysystem-local": "^3.0.0",
|
||||
"league/mime-type-detection": "^1.0.0",
|
||||
"php": "^8.0.2"
|
||||
},
|
||||
"conflict": {
|
||||
"async-aws/core": "<1.19.0",
|
||||
"async-aws/s3": "<1.14.0",
|
||||
"aws/aws-sdk-php": "3.209.31 || 3.210.0",
|
||||
"guzzlehttp/guzzle": "<7.0",
|
||||
"guzzlehttp/ringphp": "<1.1.1",
|
||||
"phpseclib/phpseclib": "3.0.15",
|
||||
"symfony/http-client": "<5.2"
|
||||
},
|
||||
"require-dev": {
|
||||
"async-aws/s3": "^1.5 || ^2.0",
|
||||
"async-aws/simple-s3": "^1.1 || ^2.0",
|
||||
"aws/aws-sdk-php": "^3.295.10",
|
||||
"composer/semver": "^3.0",
|
||||
"ext-fileinfo": "*",
|
||||
"ext-ftp": "*",
|
||||
"ext-mongodb": "^1.3|^2",
|
||||
"ext-zip": "*",
|
||||
"friendsofphp/php-cs-fixer": "^3.5",
|
||||
"google/cloud-storage": "^1.23",
|
||||
"guzzlehttp/psr7": "^2.6",
|
||||
"microsoft/azure-storage-blob": "^1.1",
|
||||
"mongodb/mongodb": "^1.2|^2",
|
||||
"phpseclib/phpseclib": "^3.0.36",
|
||||
"phpstan/phpstan": "^1.10",
|
||||
"phpunit/phpunit": "^9.5.11|^10.0",
|
||||
"sabre/dav": "^4.6.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"League\\Flysystem\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Frank de Jonge",
|
||||
"email": "info@frankdejonge.nl"
|
||||
}
|
||||
],
|
||||
"description": "File storage abstraction for PHP",
|
||||
"keywords": [
|
||||
"WebDAV",
|
||||
"aws",
|
||||
"cloud",
|
||||
"file",
|
||||
"files",
|
||||
"filesystem",
|
||||
"filesystems",
|
||||
"ftp",
|
||||
"s3",
|
||||
"sftp",
|
||||
"storage"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/thephpleague/flysystem/issues",
|
||||
"source": "https://github.com/thephpleague/flysystem/tree/3.33.0"
|
||||
},
|
||||
"time": "2026-03-25T07:59:30+00:00"
|
||||
},
|
||||
{
|
||||
"name": "league/flysystem-aws-s3-v3",
|
||||
"version": "3.32.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/thephpleague/flysystem-aws-s3-v3.git",
|
||||
"reference": "a1979df7c9784d334ea6df356aed3d18ac6673d0"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/a1979df7c9784d334ea6df356aed3d18ac6673d0",
|
||||
"reference": "a1979df7c9784d334ea6df356aed3d18ac6673d0",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"aws/aws-sdk-php": "^3.295.10",
|
||||
"league/flysystem": "^3.10.0",
|
||||
"league/mime-type-detection": "^1.0.0",
|
||||
"php": "^8.0.2"
|
||||
},
|
||||
"conflict": {
|
||||
"guzzlehttp/guzzle": "<7.0",
|
||||
"guzzlehttp/ringphp": "<1.1.1"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"League\\Flysystem\\AwsS3V3\\": ""
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Frank de Jonge",
|
||||
"email": "info@frankdejonge.nl"
|
||||
}
|
||||
],
|
||||
"description": "AWS S3 filesystem adapter for Flysystem.",
|
||||
"keywords": [
|
||||
"Flysystem",
|
||||
"aws",
|
||||
"file",
|
||||
"files",
|
||||
"filesystem",
|
||||
"s3",
|
||||
"storage"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/3.32.0"
|
||||
},
|
||||
"time": "2026-02-25T16:46:44+00:00"
|
||||
},
|
||||
{
|
||||
"name": "league/flysystem-local",
|
||||
"version": "3.31.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/thephpleague/flysystem-local.git",
|
||||
"reference": "2f669db18a4c20c755c2bb7d3a7b0b2340488079"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/2f669db18a4c20c755c2bb7d3a7b0b2340488079",
|
||||
"reference": "2f669db18a4c20c755c2bb7d3a7b0b2340488079",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-fileinfo": "*",
|
||||
"league/flysystem": "^3.0.0",
|
||||
"league/mime-type-detection": "^1.0.0",
|
||||
"php": "^8.0.2"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"League\\Flysystem\\Local\\": ""
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Frank de Jonge",
|
||||
"email": "info@frankdejonge.nl"
|
||||
}
|
||||
],
|
||||
"description": "Local filesystem adapter for Flysystem.",
|
||||
"keywords": [
|
||||
"Flysystem",
|
||||
"file",
|
||||
"files",
|
||||
"filesystem",
|
||||
"local"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/thephpleague/flysystem-local/tree/3.31.0"
|
||||
},
|
||||
"time": "2026-01-23T15:30:45+00:00"
|
||||
},
|
||||
{
|
||||
"name": "league/mime-type-detection",
|
||||
"version": "1.16.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/thephpleague/mime-type-detection.git",
|
||||
"reference": "2d6702ff215bf922936ccc1ad31007edc76451b9"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/2d6702ff215bf922936ccc1ad31007edc76451b9",
|
||||
"reference": "2d6702ff215bf922936ccc1ad31007edc76451b9",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-fileinfo": "*",
|
||||
"php": "^7.4 || ^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"friendsofphp/php-cs-fixer": "^3.2",
|
||||
"phpstan/phpstan": "^0.12.68",
|
||||
"phpunit/phpunit": "^8.5.8 || ^9.3 || ^10.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"League\\MimeTypeDetection\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Frank de Jonge",
|
||||
"email": "info@frankdejonge.nl"
|
||||
}
|
||||
],
|
||||
"description": "Mime-type detection for Flysystem",
|
||||
"support": {
|
||||
"issues": "https://github.com/thephpleague/mime-type-detection/issues",
|
||||
"source": "https://github.com/thephpleague/mime-type-detection/tree/1.16.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/frankdejonge",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/league/flysystem",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2024-09-21T08:32:55+00:00"
|
||||
},
|
||||
{
|
||||
"name": "league/uri",
|
||||
"version": "7.8.1",
|
||||
@@ -2371,6 +2974,72 @@
|
||||
],
|
||||
"time": "2026-01-02T08:56:05+00:00"
|
||||
},
|
||||
{
|
||||
"name": "mtdowling/jmespath.php",
|
||||
"version": "2.8.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/jmespath/jmespath.php.git",
|
||||
"reference": "a2a865e05d5f420b50cc2f85bb78d565db12a6bc"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/jmespath/jmespath.php/zipball/a2a865e05d5f420b50cc2f85bb78d565db12a6bc",
|
||||
"reference": "a2a865e05d5f420b50cc2f85bb78d565db12a6bc",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.2.5 || ^8.0",
|
||||
"symfony/polyfill-mbstring": "^1.17"
|
||||
},
|
||||
"require-dev": {
|
||||
"composer/xdebug-handler": "^3.0.3",
|
||||
"phpunit/phpunit": "^8.5.33"
|
||||
},
|
||||
"bin": [
|
||||
"bin/jp.php"
|
||||
],
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "2.8-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"files": [
|
||||
"src/JmesPath.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"JmesPath\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Graham Campbell",
|
||||
"email": "hello@gjcampbell.co.uk",
|
||||
"homepage": "https://github.com/GrahamCampbell"
|
||||
},
|
||||
{
|
||||
"name": "Michael Dowling",
|
||||
"email": "mtdowling@gmail.com",
|
||||
"homepage": "https://github.com/mtdowling"
|
||||
}
|
||||
],
|
||||
"description": "Declaratively specify how to extract elements from a JSON document",
|
||||
"keywords": [
|
||||
"json",
|
||||
"jsonpath"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/jmespath/jmespath.php/issues",
|
||||
"source": "https://github.com/jmespath/jmespath.php/tree/2.8.0"
|
||||
},
|
||||
"time": "2024-09-04T18:46:31+00:00"
|
||||
},
|
||||
{
|
||||
"name": "nelmio/cors-bundle",
|
||||
"version": "2.6.1",
|
||||
@@ -2813,6 +3482,58 @@
|
||||
},
|
||||
"time": "2019-01-08T18:20:26+00:00"
|
||||
},
|
||||
{
|
||||
"name": "psr/http-client",
|
||||
"version": "1.0.3",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/php-fig/http-client.git",
|
||||
"reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90",
|
||||
"reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.0 || ^8.0",
|
||||
"psr/http-message": "^1.0 || ^2.0"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "1.0.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Psr\\Http\\Client\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "PHP-FIG",
|
||||
"homepage": "https://www.php-fig.org/"
|
||||
}
|
||||
],
|
||||
"description": "Common interface for HTTP clients",
|
||||
"homepage": "https://github.com/php-fig/http-client",
|
||||
"keywords": [
|
||||
"http",
|
||||
"http-client",
|
||||
"psr",
|
||||
"psr-18"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/php-fig/http-client"
|
||||
},
|
||||
"time": "2023-09-23T14:17:50+00:00"
|
||||
},
|
||||
{
|
||||
"name": "psr/http-factory",
|
||||
"version": "1.1.0",
|
||||
@@ -4672,6 +5393,73 @@
|
||||
],
|
||||
"time": "2024-09-25T14:21:43+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/expression-language",
|
||||
"version": "v8.0.8",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/expression-language.git",
|
||||
"reference": "b2a5fd3b7331ae10cc0ed75a28d64b25b67d2c7b"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/expression-language/zipball/b2a5fd3b7331ae10cc0ed75a28d64b25b67d2c7b",
|
||||
"reference": "b2a5fd3b7331ae10cc0ed75a28d64b25b67d2c7b",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.4",
|
||||
"symfony/cache": "^7.4|^8.0",
|
||||
"symfony/service-contracts": "^2.5|^3"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Component\\ExpressionLanguage\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Tests/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Fabien Potencier",
|
||||
"email": "fabien@symfony.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Provides an engine that can compile and evaluate expressions",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/expression-language/tree/v8.0.8"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-03-30T15:14:47+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/filesystem",
|
||||
"version": "v8.0.1",
|
||||
|
||||
@@ -64,3 +64,5 @@ framework:
|
||||
# Import élèves/enseignants → async (batch processing, peut être long)
|
||||
App\Administration\Application\Command\ImportStudents\ImportStudentsCommand: async
|
||||
App\Administration\Application\Command\ImportTeachers\ImportTeachersCommand: async
|
||||
# Provisioning établissement → async (création BDD, migrations, premier admin)
|
||||
App\SuperAdmin\Application\Command\ProvisionEstablishment\ProvisionEstablishmentCommand: async
|
||||
|
||||
@@ -1,19 +1,14 @@
|
||||
# Configuration des tenants en production
|
||||
# Tenants en production : résolution dynamique depuis la base establishments
|
||||
#
|
||||
# En production, les tenants peuvent être configurés de deux façons :
|
||||
# 1. Via la variable d'environnement TENANT_CONFIGS (JSON)
|
||||
# 2. Via une implémentation DatabaseTenantRegistry (à implémenter)
|
||||
#
|
||||
# Pour l'instant, on utilise InMemoryTenantRegistry avec configuration env.
|
||||
# Si aucun tenant n'est configuré, toutes les requêtes retourneront 404.
|
||||
|
||||
parameters:
|
||||
# Format JSON attendu: [{"tenantId":"uuid","subdomain":"ecole","databaseUrl":"postgres://..."}]
|
||||
tenant.prod_configs_json: '%env(default::TENANT_CONFIGS)%'
|
||||
# Le DoctrineTenantRegistry interroge la table establishments sur la base master.
|
||||
# Les nouveaux établissements sont immédiatement accessibles via leur sous-domaine
|
||||
# sans redémarrage de l'application.
|
||||
|
||||
services:
|
||||
App\Shared\Infrastructure\Tenant\TenantRegistry:
|
||||
class: App\Shared\Infrastructure\Tenant\InMemoryTenantRegistry
|
||||
factory: ['@App\Shared\Infrastructure\Tenant\TenantRegistryFactory', 'createFromEnv']
|
||||
App\Shared\Infrastructure\Tenant\DoctrineTenantRegistry:
|
||||
arguments:
|
||||
$configsJson: '%tenant.prod_configs_json%'
|
||||
$connection: '@doctrine.dbal.master_connection'
|
||||
$masterDatabaseUrl: '%env(DATABASE_URL)%'
|
||||
|
||||
App\Shared\Infrastructure\Tenant\TenantRegistry:
|
||||
alias: App\Shared\Infrastructure\Tenant\DoctrineTenantRegistry
|
||||
|
||||
@@ -247,12 +247,20 @@ services:
|
||||
$homeworkSanitizer: '@html_sanitizer.sanitizer.homework_sanitizer'
|
||||
|
||||
App\Scolarite\Application\Port\FileStorage:
|
||||
alias: App\Scolarite\Infrastructure\Storage\LocalFileStorage
|
||||
alias: App\Scolarite\Infrastructure\Storage\S3FileStorage
|
||||
|
||||
App\Scolarite\Infrastructure\Storage\LocalFileStorage:
|
||||
arguments:
|
||||
$storagePath: '%kernel.project_dir%/var/storage'
|
||||
|
||||
App\Scolarite\Infrastructure\Storage\S3FileStorage:
|
||||
arguments:
|
||||
$endpoint: '%env(S3_ENDPOINT)%'
|
||||
$bucket: '%env(S3_BUCKET)%'
|
||||
$key: '%env(S3_KEY)%'
|
||||
$secret: '%env(S3_SECRET)%'
|
||||
$region: '%env(S3_REGION)%'
|
||||
|
||||
# Schedule (Story 4.1 - Emploi du temps)
|
||||
App\Scolarite\Domain\Repository\ScheduleSlotRepository:
|
||||
alias: App\Scolarite\Infrastructure\Persistence\Doctrine\DoctrineScheduleSlotRepository
|
||||
@@ -298,12 +306,18 @@ services:
|
||||
App\Scolarite\Domain\Service\AverageCalculator:
|
||||
autowire: true
|
||||
|
||||
App\Scolarite\Domain\Service\TeacherStatisticsCalculator:
|
||||
autowire: true
|
||||
|
||||
App\Scolarite\Application\Service\RecalculerMoyennesService:
|
||||
autowire: true
|
||||
|
||||
App\Scolarite\Application\Port\PeriodFinder:
|
||||
alias: App\Scolarite\Infrastructure\Service\DoctrinePeriodFinder
|
||||
|
||||
App\Scolarite\Application\Port\TeacherStatisticsReader:
|
||||
alias: App\Scolarite\Infrastructure\ReadModel\DbalTeacherStatisticsReader
|
||||
|
||||
App\Scolarite\Infrastructure\Persistence\Doctrine\DoctrineEvaluationStatisticsRepository:
|
||||
autowire: true
|
||||
|
||||
@@ -333,6 +347,23 @@ services:
|
||||
App\SuperAdmin\Domain\Repository\EstablishmentRepository:
|
||||
alias: App\SuperAdmin\Infrastructure\Persistence\Doctrine\DoctrineEstablishmentRepository
|
||||
|
||||
# Provisioning (Story 2.17 - Provisioning automatique)
|
||||
App\SuperAdmin\Infrastructure\Provisioning\TenantDatabaseCreator:
|
||||
arguments:
|
||||
$connection: '@doctrine.dbal.master_connection'
|
||||
|
||||
App\SuperAdmin\Infrastructure\Provisioning\TenantMigrator:
|
||||
arguments:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
$masterDatabaseUrl: '%env(DATABASE_URL)%'
|
||||
|
||||
App\SuperAdmin\Application\Port\TenantProvisioner:
|
||||
alias: App\SuperAdmin\Infrastructure\Provisioning\DatabaseTenantProvisioner
|
||||
|
||||
App\SuperAdmin\Infrastructure\Provisioning\ProvisionEstablishmentHandler:
|
||||
arguments:
|
||||
$masterDatabaseUrl: '%env(DATABASE_URL)%'
|
||||
|
||||
# School Calendar Repository (Story 2.11 - Calendrier scolaire)
|
||||
App\Administration\Domain\Model\SchoolCalendar\SchoolCalendarRepository:
|
||||
alias: App\Administration\Infrastructure\Persistence\Doctrine\DoctrineSchoolCalendarRepository
|
||||
|
||||
@@ -12,4 +12,9 @@ interface FileStorage
|
||||
public function upload(string $path, mixed $content, string $mimeType): string;
|
||||
|
||||
public function delete(string $path): void;
|
||||
|
||||
/**
|
||||
* @return resource
|
||||
*/
|
||||
public function readStream(string $path): mixed;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Port;
|
||||
|
||||
/**
|
||||
* Port pour lire les données statistiques d'un enseignant.
|
||||
*
|
||||
* L'implémentation utilise des requêtes SQL optimisées pour agréger
|
||||
* les notes, moyennes et évaluations nécessaires aux dashboards statistiques.
|
||||
*/
|
||||
interface TeacherStatisticsReader
|
||||
{
|
||||
/**
|
||||
* Résumé par classe/matière : nombre d'évaluations, d'élèves notés, moyenne et taux de réussite.
|
||||
*
|
||||
* @return list<array{
|
||||
* classId: string,
|
||||
* className: string,
|
||||
* subjectId: string,
|
||||
* subjectName: string,
|
||||
* evaluationCount: int,
|
||||
* studentCount: int,
|
||||
* average: float|null,
|
||||
* successRate: float|null,
|
||||
* }>
|
||||
*/
|
||||
public function teacherClassesSummary(
|
||||
string $teacherId,
|
||||
string $tenantId,
|
||||
string $periodStartDate,
|
||||
string $periodEndDate,
|
||||
): array;
|
||||
|
||||
/**
|
||||
* Toutes les notes normalisées sur /20 pour une classe/matière/période.
|
||||
*
|
||||
* @return list<float>
|
||||
*/
|
||||
public function classGradesNormalized(
|
||||
string $teacherId,
|
||||
string $classId,
|
||||
string $subjectId,
|
||||
string $tenantId,
|
||||
string $periodStartDate,
|
||||
string $periodEndDate,
|
||||
): array;
|
||||
|
||||
/**
|
||||
* Moyennes mensuelles de la classe pour le graphique d'évolution.
|
||||
*
|
||||
* @return list<array{month: string, average: float}>
|
||||
*/
|
||||
public function classMonthlyAverages(
|
||||
string $teacherId,
|
||||
string $classId,
|
||||
string $subjectId,
|
||||
string $tenantId,
|
||||
string $academicYearStart,
|
||||
string $academicYearEnd,
|
||||
): array;
|
||||
|
||||
/**
|
||||
* Moyennes par élève pour une classe/matière/période.
|
||||
*
|
||||
* @return list<array{studentId: string, studentName: string, average: float|null}>
|
||||
*/
|
||||
public function studentAveragesForClass(
|
||||
string $teacherId,
|
||||
string $classId,
|
||||
string $subjectId,
|
||||
string $periodId,
|
||||
string $tenantId,
|
||||
): array;
|
||||
|
||||
/**
|
||||
* Moyennes mensuelles par élève pour une classe/matière.
|
||||
* Clé = studentId, valeur = liste de moyennes mensuelles chronologiques.
|
||||
*
|
||||
* @return array<string, list<float>>
|
||||
*/
|
||||
public function studentMonthlyAveragesForClass(
|
||||
string $classId,
|
||||
string $subjectId,
|
||||
string $tenantId,
|
||||
string $academicYearStart,
|
||||
string $academicYearEnd,
|
||||
): array;
|
||||
|
||||
/**
|
||||
* Historique des notes d'un élève pour une matière, avec dates et valeurs normalisées.
|
||||
*
|
||||
* @return list<array{date: string, value: float, evaluationTitle: string}>
|
||||
*/
|
||||
public function studentGradeHistory(
|
||||
string $studentId,
|
||||
string $subjectId,
|
||||
string $classId,
|
||||
string $teacherId,
|
||||
string $tenantId,
|
||||
string $academicYearStart,
|
||||
string $academicYearEnd,
|
||||
): array;
|
||||
|
||||
/**
|
||||
* Difficulté de chaque évaluation d'un enseignant : moyenne obtenue.
|
||||
*
|
||||
* @return list<array{
|
||||
* evaluationId: string,
|
||||
* title: string,
|
||||
* classId: string,
|
||||
* className: string,
|
||||
* subjectId: string,
|
||||
* subjectName: string,
|
||||
* date: string,
|
||||
* average: float|null,
|
||||
* gradedCount: int,
|
||||
* }>
|
||||
*/
|
||||
public function teacherEvaluationDifficulties(string $teacherId, string $tenantId): array;
|
||||
|
||||
/**
|
||||
* Moyennes des évaluations des autres enseignants pour une matière (anonymisé).
|
||||
*
|
||||
* @return list<float> Moyennes des évaluations des autres enseignants
|
||||
*/
|
||||
public function subjectAveragesForOtherTeachers(
|
||||
string $teacherId,
|
||||
string $subjectId,
|
||||
string $tenantId,
|
||||
): array;
|
||||
}
|
||||
@@ -6,22 +6,26 @@ namespace App\Scolarite\Application\Query\GetBlockedDates;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendarRepository;
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
use App\Scolarite\Application\Port\HomeworkRulesChecker;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateInterval;
|
||||
use DateTimeImmutable;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
/**
|
||||
* Retourne les dates bloquées (jours fériés, vacances, journées pédagogiques, weekends)
|
||||
* pour une plage de dates donnée.
|
||||
* Retourne les dates bloquées (jours fériés, vacances, journées pédagogiques, weekends,
|
||||
* et dates non conformes aux règles de devoirs) pour une plage de dates donnée.
|
||||
*
|
||||
* Utilisé par le frontend pour griser les jours non modifiables dans la grille EDT.
|
||||
* Utilisé par le frontend pour griser les jours non disponibles dans le calendrier.
|
||||
*/
|
||||
#[AsMessageHandler(bus: 'query.bus')]
|
||||
final readonly class GetBlockedDatesHandler
|
||||
{
|
||||
public function __construct(
|
||||
private SchoolCalendarRepository $calendarRepository,
|
||||
private HomeworkRulesChecker $rulesChecker,
|
||||
private Clock $clock,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -37,6 +41,7 @@ final readonly class GetBlockedDatesHandler
|
||||
$endDate = new DateTimeImmutable($query->endDate);
|
||||
$oneDay = new DateInterval('P1D');
|
||||
|
||||
$now = $this->clock->now();
|
||||
$blockedDates = [];
|
||||
$current = $startDate;
|
||||
|
||||
@@ -50,14 +55,21 @@ final readonly class GetBlockedDatesHandler
|
||||
reason: $dayOfWeek === 6 ? 'Samedi' : 'Dimanche',
|
||||
type: 'weekend',
|
||||
);
|
||||
} elseif ($calendar !== null) {
|
||||
$entry = $calendar->trouverEntreePourDate($current);
|
||||
} elseif ($calendar !== null && ($entry = $calendar->trouverEntreePourDate($current)) !== null) {
|
||||
$blockedDates[] = new BlockedDateDto(
|
||||
date: $dateStr,
|
||||
reason: $entry->label,
|
||||
type: $entry->type->value,
|
||||
);
|
||||
} else {
|
||||
$dueDate = new DateTimeImmutable($dateStr);
|
||||
$result = $this->rulesChecker->verifier($tenantId, $dueDate, $now);
|
||||
|
||||
if ($entry !== null) {
|
||||
if (!$result->estValide()) {
|
||||
$blockedDates[] = new BlockedDateDto(
|
||||
date: $dateStr,
|
||||
reason: $entry->label,
|
||||
type: $entry->type->value,
|
||||
reason: $result->messages()[0] ?? 'Règle de devoirs',
|
||||
type: $result->estBloquant() ? 'rule_hard' : 'rule_soft',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Query\GetClassStatisticsDetail;
|
||||
|
||||
final readonly class ClassStatisticsDetailDto
|
||||
{
|
||||
/**
|
||||
* @param list<int> $distribution
|
||||
* @param list<array{month: string, average: float}> $evolution
|
||||
* @param list<StudentAverageDto> $students
|
||||
*/
|
||||
public function __construct(
|
||||
public ?float $average,
|
||||
public float $successRate,
|
||||
public array $distribution,
|
||||
public array $evolution,
|
||||
public array $students,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Query\GetClassStatisticsDetail;
|
||||
|
||||
use App\Scolarite\Application\Port\PeriodFinder;
|
||||
use App\Scolarite\Application\Port\TeacherStatisticsReader;
|
||||
use App\Scolarite\Domain\Service\AverageCalculator;
|
||||
use App\Scolarite\Domain\Service\TeacherStatisticsCalculator;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
use function array_map;
|
||||
use function count;
|
||||
|
||||
use DateTimeImmutable;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
#[AsMessageHandler(bus: 'query.bus')]
|
||||
final readonly class GetClassStatisticsDetailHandler
|
||||
{
|
||||
public function __construct(
|
||||
private TeacherStatisticsReader $reader,
|
||||
private PeriodFinder $periodFinder,
|
||||
private TeacherStatisticsCalculator $statisticsCalculator,
|
||||
private AverageCalculator $averageCalculator,
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(GetClassStatisticsDetailQuery $query): ClassStatisticsDetailDto
|
||||
{
|
||||
$tenantId = TenantId::fromString($query->tenantId);
|
||||
$period = $this->periodFinder->findForDate(new DateTimeImmutable(), $tenantId);
|
||||
|
||||
if ($period === null) {
|
||||
return new ClassStatisticsDetailDto(
|
||||
average: null,
|
||||
successRate: 0.0,
|
||||
distribution: [0, 0, 0, 0, 0, 0, 0, 0],
|
||||
evolution: [],
|
||||
students: [],
|
||||
);
|
||||
}
|
||||
|
||||
$normalizedGrades = $this->reader->classGradesNormalized(
|
||||
$query->teacherId,
|
||||
$query->classId,
|
||||
$query->subjectId,
|
||||
$query->tenantId,
|
||||
$period->startDate->format('Y-m-d'),
|
||||
$period->endDate->format('Y-m-d'),
|
||||
);
|
||||
|
||||
$classStats = $this->averageCalculator->calculateClassStatistics($normalizedGrades);
|
||||
$distribution = $this->statisticsCalculator->calculateDistribution($normalizedGrades);
|
||||
$successRate = $this->statisticsCalculator->calculateSuccessRate($normalizedGrades);
|
||||
|
||||
$now = new DateTimeImmutable();
|
||||
$month = (int) $now->format('n');
|
||||
$yearStart = $month >= 9 ? (int) $now->format('Y') : (int) $now->format('Y') - 1;
|
||||
$academicYearStart = sprintf('%d-09-01', $yearStart);
|
||||
$academicYearEnd = sprintf('%d-08-31', $yearStart + 1);
|
||||
|
||||
$evolution = $this->reader->classMonthlyAverages(
|
||||
$query->teacherId,
|
||||
$query->classId,
|
||||
$query->subjectId,
|
||||
$query->tenantId,
|
||||
$academicYearStart,
|
||||
$academicYearEnd,
|
||||
);
|
||||
|
||||
$studentAverages = $this->reader->studentAveragesForClass(
|
||||
$query->teacherId,
|
||||
$query->classId,
|
||||
$query->subjectId,
|
||||
$period->periodId,
|
||||
$query->tenantId,
|
||||
);
|
||||
|
||||
$studentTrends = $this->reader->studentMonthlyAveragesForClass(
|
||||
$query->classId,
|
||||
$query->subjectId,
|
||||
$query->tenantId,
|
||||
$academicYearStart,
|
||||
$academicYearEnd,
|
||||
);
|
||||
|
||||
$students = array_map(
|
||||
fn (array $s) => new StudentAverageDto(
|
||||
studentId: $s['studentId'],
|
||||
studentName: $s['studentName'],
|
||||
average: $s['average'],
|
||||
inDifficulty: $s['average'] !== null && $s['average'] < $query->threshold,
|
||||
trend: isset($studentTrends[$s['studentId']]) && count($studentTrends[$s['studentId']]) >= 2
|
||||
? $this->statisticsCalculator->detectTrend($studentTrends[$s['studentId']])
|
||||
: 'stable',
|
||||
),
|
||||
$studentAverages,
|
||||
);
|
||||
|
||||
return new ClassStatisticsDetailDto(
|
||||
average: $classStats->average,
|
||||
successRate: $successRate,
|
||||
distribution: $distribution,
|
||||
evolution: $evolution,
|
||||
students: $students,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Query\GetClassStatisticsDetail;
|
||||
|
||||
final readonly class GetClassStatisticsDetailQuery
|
||||
{
|
||||
public function __construct(
|
||||
public string $teacherId,
|
||||
public string $classId,
|
||||
public string $subjectId,
|
||||
public string $tenantId,
|
||||
public float $threshold = 8.0,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Query\GetClassStatisticsDetail;
|
||||
|
||||
final readonly class StudentAverageDto
|
||||
{
|
||||
public function __construct(
|
||||
public string $studentId,
|
||||
public string $studentName,
|
||||
public ?float $average,
|
||||
public bool $inDifficulty,
|
||||
public string $trend,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Query\GetEvaluationDifficulty;
|
||||
|
||||
final readonly class EvaluationDifficultyDto
|
||||
{
|
||||
public function __construct(
|
||||
public string $evaluationId,
|
||||
public string $title,
|
||||
public string $classId,
|
||||
public string $className,
|
||||
public string $subjectId,
|
||||
public string $subjectName,
|
||||
public string $date,
|
||||
public ?float $average,
|
||||
public int $gradedCount,
|
||||
public ?float $subjectAverage,
|
||||
public ?float $percentile,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Query\GetEvaluationDifficulty;
|
||||
|
||||
use App\Scolarite\Application\Port\TeacherStatisticsReader;
|
||||
use App\Scolarite\Domain\Service\TeacherStatisticsCalculator;
|
||||
|
||||
use function array_sum;
|
||||
use function count;
|
||||
use function round;
|
||||
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
#[AsMessageHandler(bus: 'query.bus')]
|
||||
final readonly class GetEvaluationDifficultyHandler
|
||||
{
|
||||
public function __construct(
|
||||
private TeacherStatisticsReader $reader,
|
||||
private TeacherStatisticsCalculator $calculator,
|
||||
) {
|
||||
}
|
||||
|
||||
/** @return list<EvaluationDifficultyDto> */
|
||||
public function __invoke(GetEvaluationDifficultyQuery $query): array
|
||||
{
|
||||
$evaluations = $this->reader->teacherEvaluationDifficulties(
|
||||
$query->teacherId,
|
||||
$query->tenantId,
|
||||
);
|
||||
|
||||
/** @var array<string, list<float>> $otherAveragesCache */
|
||||
$otherAveragesCache = [];
|
||||
|
||||
$results = [];
|
||||
|
||||
foreach ($evaluations as $eval) {
|
||||
/** @var string $subjectId */
|
||||
$subjectId = $eval['subjectId'];
|
||||
|
||||
if (!isset($otherAveragesCache[$subjectId])) {
|
||||
$otherAveragesCache[$subjectId] = $this->reader->subjectAveragesForOtherTeachers(
|
||||
$query->teacherId,
|
||||
$subjectId,
|
||||
$query->tenantId,
|
||||
);
|
||||
}
|
||||
|
||||
$otherAvgs = $otherAveragesCache[$subjectId];
|
||||
$subjectAverage = $otherAvgs !== [] ? round(array_sum($otherAvgs) / count($otherAvgs), 2) : null;
|
||||
$percentile = $eval['average'] !== null && $otherAvgs !== []
|
||||
? $this->calculator->calculatePercentile($eval['average'], $otherAvgs)
|
||||
: null;
|
||||
|
||||
$results[] = new EvaluationDifficultyDto(
|
||||
evaluationId: $eval['evaluationId'],
|
||||
title: $eval['title'],
|
||||
classId: $eval['classId'],
|
||||
className: $eval['className'],
|
||||
subjectId: $subjectId,
|
||||
subjectName: $eval['subjectName'],
|
||||
date: $eval['date'],
|
||||
average: $eval['average'],
|
||||
gradedCount: $eval['gradedCount'],
|
||||
subjectAverage: $subjectAverage,
|
||||
percentile: $percentile,
|
||||
);
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Query\GetEvaluationDifficulty;
|
||||
|
||||
final readonly class GetEvaluationDifficultyQuery
|
||||
{
|
||||
public function __construct(
|
||||
public string $teacherId,
|
||||
public string $tenantId,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Query\GetStudentProgression;
|
||||
|
||||
use App\Scolarite\Application\Port\TeacherStatisticsReader;
|
||||
use App\Scolarite\Domain\Service\TeacherStatisticsCalculator;
|
||||
|
||||
use function array_map;
|
||||
use function array_values;
|
||||
|
||||
use DateTimeImmutable;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
#[AsMessageHandler(bus: 'query.bus')]
|
||||
final readonly class GetStudentProgressionHandler
|
||||
{
|
||||
public function __construct(
|
||||
private TeacherStatisticsReader $reader,
|
||||
private TeacherStatisticsCalculator $calculator,
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(GetStudentProgressionQuery $query): StudentProgressionDto
|
||||
{
|
||||
$now = new DateTimeImmutable();
|
||||
$month = (int) $now->format('n');
|
||||
$yearStart = $month >= 9 ? (int) $now->format('Y') : (int) $now->format('Y') - 1;
|
||||
$academicYearStart = sprintf('%d-09-01', $yearStart);
|
||||
$academicYearEnd = sprintf('%d-08-31', $yearStart + 1);
|
||||
|
||||
$history = $this->reader->studentGradeHistory(
|
||||
$query->studentId,
|
||||
$query->subjectId,
|
||||
$query->classId,
|
||||
$query->teacherId,
|
||||
$query->tenantId,
|
||||
$academicYearStart,
|
||||
$academicYearEnd,
|
||||
);
|
||||
|
||||
$grades = array_map(
|
||||
static fn (array $row) => new GradePointDto(
|
||||
date: $row['date'],
|
||||
value: $row['value'],
|
||||
evaluationTitle: $row['evaluationTitle'],
|
||||
),
|
||||
$history,
|
||||
);
|
||||
|
||||
$points = array_values(array_map(
|
||||
static fn (int $i, GradePointDto $g) => [$i + 1, $g->value],
|
||||
array_keys($grades),
|
||||
$grades,
|
||||
));
|
||||
|
||||
$trendLine = $this->calculator->calculateTrendLine($points);
|
||||
|
||||
return new StudentProgressionDto(
|
||||
grades: $grades,
|
||||
trendLine: $trendLine,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Query\GetStudentProgression;
|
||||
|
||||
final readonly class GetStudentProgressionQuery
|
||||
{
|
||||
public function __construct(
|
||||
public string $studentId,
|
||||
public string $subjectId,
|
||||
public string $classId,
|
||||
public string $teacherId,
|
||||
public string $tenantId,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Query\GetStudentProgression;
|
||||
|
||||
final readonly class GradePointDto
|
||||
{
|
||||
public function __construct(
|
||||
public string $date,
|
||||
public float $value,
|
||||
public string $evaluationTitle,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Query\GetStudentProgression;
|
||||
|
||||
use App\Scolarite\Domain\Service\TrendResult;
|
||||
|
||||
final readonly class StudentProgressionDto
|
||||
{
|
||||
/**
|
||||
* @param list<GradePointDto> $grades
|
||||
*/
|
||||
public function __construct(
|
||||
public array $grades,
|
||||
public ?TrendResult $trendLine,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Query\GetTeacherStatisticsOverview;
|
||||
|
||||
final readonly class ClassOverviewDto
|
||||
{
|
||||
public function __construct(
|
||||
public string $classId,
|
||||
public string $className,
|
||||
public string $subjectId,
|
||||
public string $subjectName,
|
||||
public int $evaluationCount,
|
||||
public int $studentCount,
|
||||
public ?float $average,
|
||||
public ?float $successRate,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Query\GetTeacherStatisticsOverview;
|
||||
|
||||
use App\Scolarite\Application\Port\PeriodFinder;
|
||||
use App\Scolarite\Application\Port\TeacherStatisticsReader;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
use function array_map;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
#[AsMessageHandler(bus: 'query.bus')]
|
||||
final readonly class GetTeacherStatisticsOverviewHandler
|
||||
{
|
||||
public function __construct(
|
||||
private TeacherStatisticsReader $reader,
|
||||
private PeriodFinder $periodFinder,
|
||||
) {
|
||||
}
|
||||
|
||||
/** @return list<ClassOverviewDto> */
|
||||
public function __invoke(GetTeacherStatisticsOverviewQuery $query): array
|
||||
{
|
||||
$period = $this->periodFinder->findForDate(
|
||||
new DateTimeImmutable(),
|
||||
TenantId::fromString($query->tenantId),
|
||||
);
|
||||
|
||||
if ($period === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = $this->reader->teacherClassesSummary(
|
||||
$query->teacherId,
|
||||
$query->tenantId,
|
||||
$period->startDate->format('Y-m-d'),
|
||||
$period->endDate->format('Y-m-d'),
|
||||
);
|
||||
|
||||
return array_map(
|
||||
static fn (array $row) => new ClassOverviewDto(
|
||||
classId: $row['classId'],
|
||||
className: $row['className'],
|
||||
subjectId: $row['subjectId'],
|
||||
subjectName: $row['subjectName'],
|
||||
evaluationCount: $row['evaluationCount'],
|
||||
studentCount: $row['studentCount'],
|
||||
average: $row['average'],
|
||||
successRate: $row['successRate'],
|
||||
),
|
||||
$rows,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Query\GetTeacherStatisticsOverview;
|
||||
|
||||
final readonly class GetTeacherStatisticsOverviewQuery
|
||||
{
|
||||
public function __construct(
|
||||
public string $teacherId,
|
||||
public string $tenantId,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Service;
|
||||
|
||||
use App\Scolarite\Application\Query\GetClassStatisticsDetail\ClassStatisticsDetailDto;
|
||||
|
||||
use function fclose;
|
||||
use function fopen;
|
||||
use function fputcsv;
|
||||
use function fwrite;
|
||||
use function rewind;
|
||||
use function stream_get_contents;
|
||||
|
||||
final class StatisticsExporter
|
||||
{
|
||||
/**
|
||||
* Exporte les statistiques d'une classe au format CSV.
|
||||
*
|
||||
* Structure : Nom, Moyenne, En difficulté, Tendance
|
||||
* En-tête avec résumé : Moyenne de classe, Taux de réussite
|
||||
*/
|
||||
public function exportClassToCsv(
|
||||
ClassStatisticsDetailDto $stats,
|
||||
string $className,
|
||||
string $subjectName,
|
||||
): string {
|
||||
$handle = fopen('php://temp', 'r+');
|
||||
|
||||
if ($handle === false) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// BOM UTF-8 pour compatibilité Excel sur Windows
|
||||
fwrite($handle, "\xEF\xBB\xBF");
|
||||
|
||||
fputcsv($handle, ['Statistiques - ' . $className . ' - ' . $subjectName], separator: ';');
|
||||
fputcsv($handle, [], separator: ';');
|
||||
fputcsv($handle, ['Moyenne de classe', $stats->average !== null ? (string) $stats->average : 'N/A'], separator: ';');
|
||||
fputcsv($handle, ['Taux de réussite', $stats->successRate . '%'], separator: ';');
|
||||
fputcsv($handle, [], separator: ';');
|
||||
fputcsv($handle, ['Élève', 'Moyenne', 'En difficulté', 'Tendance'], separator: ';');
|
||||
|
||||
foreach ($stats->students as $student) {
|
||||
fputcsv($handle, [
|
||||
$student->studentName,
|
||||
$student->average !== null ? (string) $student->average : 'N/A',
|
||||
$student->inDifficulty ? 'Oui' : 'Non',
|
||||
match ($student->trend) {
|
||||
'improving' => 'Progression',
|
||||
'declining' => 'Régression',
|
||||
default => 'Stable',
|
||||
},
|
||||
], separator: ';');
|
||||
}
|
||||
|
||||
rewind($handle);
|
||||
$csv = stream_get_contents($handle);
|
||||
fclose($handle);
|
||||
|
||||
return $csv !== false ? $csv : '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Domain\Service;
|
||||
|
||||
use function abs;
|
||||
use function array_fill;
|
||||
use function array_values;
|
||||
use function count;
|
||||
use function floor;
|
||||
use function max;
|
||||
use function min;
|
||||
use function round;
|
||||
|
||||
final class TeacherStatisticsCalculator
|
||||
{
|
||||
/**
|
||||
* Répartition des notes en 8 bins de largeur 2.5 points (sur /20).
|
||||
* Bins : [0–2.5[, [2.5–5[, [5–7.5[, [7.5–10[, [10–12.5[, [12.5–15[, [15–17.5[, [17.5–20].
|
||||
*
|
||||
* @param list<float> $normalizedValues Notes normalisées sur /20
|
||||
*
|
||||
* @return list<int> 8 éléments
|
||||
*/
|
||||
public function calculateDistribution(array $normalizedValues): array
|
||||
{
|
||||
/** @var list<int> $bins */
|
||||
$bins = array_fill(0, 8, 0);
|
||||
|
||||
foreach ($normalizedValues as $value) {
|
||||
$binIndex = min(7, max(0, (int) floor($value / 2.5)));
|
||||
++$bins[$binIndex];
|
||||
}
|
||||
|
||||
return array_values($bins);
|
||||
}
|
||||
|
||||
/**
|
||||
* Taux de réussite : pourcentage de notes >= seuil.
|
||||
*
|
||||
* @param list<float> $normalizedValues Notes normalisées sur /20
|
||||
* @param float $threshold Seuil de réussite (défaut : 10.0)
|
||||
*/
|
||||
public function calculateSuccessRate(array $normalizedValues, float $threshold = 10.0): float
|
||||
{
|
||||
if ($normalizedValues === []) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$count = count($normalizedValues);
|
||||
$above = 0;
|
||||
|
||||
foreach ($normalizedValues as $value) {
|
||||
if ($value >= $threshold) {
|
||||
++$above;
|
||||
}
|
||||
}
|
||||
|
||||
return round($above * 100.0 / $count, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Régression linéaire simple (moindres carrés) pour la ligne de tendance.
|
||||
*
|
||||
* @param list<array{0: int|float, 1: float}> $points [[x, y], ...]
|
||||
*/
|
||||
public function calculateTrendLine(array $points): ?TrendResult
|
||||
{
|
||||
$n = count($points);
|
||||
|
||||
if ($n < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$sumX = 0.0;
|
||||
$sumY = 0.0;
|
||||
$sumXY = 0.0;
|
||||
$sumX2 = 0.0;
|
||||
|
||||
foreach ($points as [$x, $y]) {
|
||||
$sumX += $x;
|
||||
$sumY += $y;
|
||||
$sumXY += $x * $y;
|
||||
$sumX2 += $x * $x;
|
||||
}
|
||||
|
||||
$denominator = $n * $sumX2 - $sumX * $sumX;
|
||||
|
||||
if (abs($denominator) < 1e-10) {
|
||||
return new TrendResult(slope: 0.0, intercept: $sumY / $n);
|
||||
}
|
||||
|
||||
$slope = ($n * $sumXY - $sumX * $sumY) / $denominator;
|
||||
$intercept = ($sumY - $slope * $sumX) / $n;
|
||||
|
||||
return new TrendResult(
|
||||
slope: round($slope, 4),
|
||||
intercept: round($intercept, 4),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Détecte la tendance à partir de moyennes périodiques (trimestres).
|
||||
* Compare la dernière moyenne à la première avec un seuil de 1 point.
|
||||
*
|
||||
* @param list<float> $periodicAverages Moyennes par période chronologique
|
||||
*
|
||||
* @return 'improving'|'stable'|'declining'
|
||||
*/
|
||||
public function detectTrend(array $periodicAverages): string
|
||||
{
|
||||
if (count($periodicAverages) < 2) {
|
||||
return 'stable';
|
||||
}
|
||||
|
||||
$first = $periodicAverages[0];
|
||||
$last = $periodicAverages[count($periodicAverages) - 1];
|
||||
$diff = $last - $first;
|
||||
|
||||
if ($diff > 1.0) {
|
||||
return 'improving';
|
||||
}
|
||||
|
||||
if ($diff < -1.0) {
|
||||
return 'declining';
|
||||
}
|
||||
|
||||
return 'stable';
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcule le percentile d'une valeur parmi un ensemble d'autres valeurs.
|
||||
* Indique le % de valeurs inférieures à la valeur donnée.
|
||||
*
|
||||
* @param list<float> $otherValues
|
||||
*/
|
||||
public function calculatePercentile(float $value, array $otherValues): float
|
||||
{
|
||||
if ($otherValues === []) {
|
||||
return 100.0;
|
||||
}
|
||||
|
||||
$below = 0;
|
||||
|
||||
foreach ($otherValues as $other) {
|
||||
if ($other < $value) {
|
||||
++$below;
|
||||
}
|
||||
}
|
||||
|
||||
return round($below * 100.0 / count($otherValues), 1);
|
||||
}
|
||||
}
|
||||
14
backend/src/Scolarite/Domain/Service/TrendResult.php
Normal file
14
backend/src/Scolarite/Domain/Service/TrendResult.php
Normal file
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Domain\Service;
|
||||
|
||||
final readonly class TrendResult
|
||||
{
|
||||
public function __construct(
|
||||
public float $slope,
|
||||
public float $intercept,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -16,16 +16,16 @@ use App\Scolarite\Domain\Repository\HomeworkRepository;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
use function array_map;
|
||||
use function realpath;
|
||||
use function str_starts_with;
|
||||
use function fclose;
|
||||
use function fpassthru;
|
||||
|
||||
use RuntimeException;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpFoundation\HeaderUtils;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
@@ -39,8 +39,6 @@ final readonly class HomeworkAttachmentController
|
||||
private HomeworkAttachmentRepository $attachmentRepository,
|
||||
private UploadHomeworkAttachmentHandler $uploadHandler,
|
||||
private FileStorage $fileStorage,
|
||||
#[Autowire('%kernel.project_dir%/var/storage')]
|
||||
private string $storageDir,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -124,7 +122,7 @@ final readonly class HomeworkAttachmentController
|
||||
}
|
||||
|
||||
#[Route('/api/homework/{id}/attachments/{attachmentId}', name: 'api_homework_attachment_download', methods: ['GET'])]
|
||||
public function download(string $id, string $attachmentId): BinaryFileResponse
|
||||
public function download(string $id, string $attachmentId): StreamedResponse
|
||||
{
|
||||
$user = $this->getSecurityUser();
|
||||
$tenantId = TenantId::fromString($user->tenantId());
|
||||
@@ -143,20 +141,29 @@ final readonly class HomeworkAttachmentController
|
||||
|
||||
foreach ($attachments as $attachment) {
|
||||
if ((string) $attachment->id === $attachmentId) {
|
||||
$fullPath = $this->storageDir . '/' . $attachment->filePath;
|
||||
$realPath = realpath($fullPath);
|
||||
$realStorageDir = realpath($this->storageDir);
|
||||
|
||||
if ($realPath === false || $realStorageDir === false || !str_starts_with($realPath, $realStorageDir)) {
|
||||
try {
|
||||
$stream = $this->fileStorage->readStream($attachment->filePath);
|
||||
} catch (RuntimeException) {
|
||||
throw new NotFoundHttpException('Pièce jointe non trouvée.');
|
||||
}
|
||||
|
||||
$response = new BinaryFileResponse($realPath);
|
||||
$response->setContentDisposition(
|
||||
ResponseHeaderBag::DISPOSITION_INLINE,
|
||||
$response = new StreamedResponse(static function () use ($stream): void {
|
||||
try {
|
||||
fpassthru($stream);
|
||||
} finally {
|
||||
fclose($stream);
|
||||
}
|
||||
});
|
||||
|
||||
$disposition = HeaderUtils::makeDisposition(
|
||||
HeaderUtils::DISPOSITION_INLINE,
|
||||
$attachment->filename,
|
||||
);
|
||||
|
||||
$response->headers->set('Content-Type', $attachment->mimeType);
|
||||
$response->headers->set('Content-Disposition', $disposition);
|
||||
$response->headers->set('Content-Length', (string) $attachment->fileSize);
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Scolarite\Infrastructure\Api\Controller;
|
||||
|
||||
use App\Administration\Infrastructure\Security\SecurityUser;
|
||||
use App\Scolarite\Application\Port\FileStorage;
|
||||
use App\Scolarite\Application\Query\GetChildrenHomework\ChildHomeworkDto;
|
||||
use App\Scolarite\Application\Query\GetChildrenHomework\GetChildrenHomeworkDetailHandler;
|
||||
use App\Scolarite\Application\Query\GetChildrenHomework\GetChildrenHomeworkHandler;
|
||||
@@ -18,16 +19,16 @@ use App\Scolarite\Infrastructure\Security\HomeworkParentVoter;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
use function array_map;
|
||||
use function fclose;
|
||||
use function fpassthru;
|
||||
use function is_string;
|
||||
use function realpath;
|
||||
use function str_starts_with;
|
||||
|
||||
use RuntimeException;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpFoundation\HeaderUtils;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
@@ -45,8 +46,7 @@ final readonly class ParentHomeworkController
|
||||
private GetChildrenHomeworkDetailHandler $detailHandler,
|
||||
private HomeworkRepository $homeworkRepository,
|
||||
private HomeworkAttachmentRepository $attachmentRepository,
|
||||
#[Autowire('%kernel.project_dir%/var/storage')]
|
||||
private string $uploadsDir,
|
||||
private FileStorage $fileStorage,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -116,7 +116,7 @@ final readonly class ParentHomeworkController
|
||||
* Téléchargement d'une pièce jointe (parent).
|
||||
*/
|
||||
#[Route('/api/me/children/homework/{homeworkId}/attachments/{attachmentId}', name: 'api_parent_child_homework_attachment', methods: ['GET'])]
|
||||
public function downloadAttachment(string $homeworkId, string $attachmentId): BinaryFileResponse
|
||||
public function downloadAttachment(string $homeworkId, string $attachmentId): StreamedResponse
|
||||
{
|
||||
$user = $this->getSecurityUser();
|
||||
$tenantId = TenantId::fromString($user->tenantId());
|
||||
@@ -138,20 +138,29 @@ final readonly class ParentHomeworkController
|
||||
|
||||
foreach ($attachments as $attachment) {
|
||||
if ((string) $attachment->id === $attachmentId) {
|
||||
$fullPath = $this->uploadsDir . '/' . $attachment->filePath;
|
||||
$realPath = realpath($fullPath);
|
||||
$realUploadsDir = realpath($this->uploadsDir);
|
||||
|
||||
if ($realPath === false || $realUploadsDir === false || !str_starts_with($realPath, $realUploadsDir)) {
|
||||
try {
|
||||
$stream = $this->fileStorage->readStream($attachment->filePath);
|
||||
} catch (RuntimeException) {
|
||||
throw new NotFoundHttpException('Pièce jointe non trouvée.');
|
||||
}
|
||||
|
||||
$response = new BinaryFileResponse($realPath);
|
||||
$response->setContentDisposition(
|
||||
ResponseHeaderBag::DISPOSITION_INLINE,
|
||||
$response = new StreamedResponse(static function () use ($stream): void {
|
||||
try {
|
||||
fpassthru($stream);
|
||||
} finally {
|
||||
fclose($stream);
|
||||
}
|
||||
});
|
||||
|
||||
$disposition = HeaderUtils::makeDisposition(
|
||||
HeaderUtils::DISPOSITION_INLINE,
|
||||
$attachment->filename,
|
||||
);
|
||||
|
||||
$response->headers->set('Content-Type', $attachment->mimeType);
|
||||
$response->headers->set('Content-Disposition', $disposition);
|
||||
$response->headers->set('Content-Length', (string) $attachment->fileSize);
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Scolarite\Infrastructure\Api\Controller;
|
||||
|
||||
use App\Administration\Infrastructure\Security\SecurityUser;
|
||||
use App\Scolarite\Application\Port\FileStorage;
|
||||
use App\Scolarite\Application\Port\ScheduleDisplayReader;
|
||||
use App\Scolarite\Application\Port\StudentClassReader;
|
||||
use App\Scolarite\Application\Query\GetStudentHomework\GetStudentHomeworkHandler;
|
||||
@@ -19,16 +20,16 @@ use App\Scolarite\Infrastructure\Security\HomeworkStudentVoter;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
use function array_map;
|
||||
use function fclose;
|
||||
use function fpassthru;
|
||||
use function is_string;
|
||||
use function realpath;
|
||||
use function str_starts_with;
|
||||
|
||||
use RuntimeException;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpFoundation\HeaderUtils;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
@@ -44,8 +45,7 @@ final readonly class StudentHomeworkController
|
||||
private HomeworkAttachmentRepository $attachmentRepository,
|
||||
private ScheduleDisplayReader $displayReader,
|
||||
private StudentClassReader $studentClassReader,
|
||||
#[Autowire('%kernel.project_dir%/var/storage')]
|
||||
private string $uploadsDir,
|
||||
private FileStorage $fileStorage,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ final readonly class StudentHomeworkController
|
||||
}
|
||||
|
||||
#[Route('/api/me/homework/{homeworkId}/attachments/{attachmentId}', name: 'api_student_homework_attachment', methods: ['GET'])]
|
||||
public function downloadAttachment(string $homeworkId, string $attachmentId): BinaryFileResponse
|
||||
public function downloadAttachment(string $homeworkId, string $attachmentId): StreamedResponse
|
||||
{
|
||||
$user = $this->getSecurityUser();
|
||||
$tenantId = TenantId::fromString($user->tenantId());
|
||||
@@ -115,20 +115,29 @@ final readonly class StudentHomeworkController
|
||||
|
||||
foreach ($attachments as $attachment) {
|
||||
if ((string) $attachment->id === $attachmentId) {
|
||||
$fullPath = $this->uploadsDir . '/' . $attachment->filePath;
|
||||
$realPath = realpath($fullPath);
|
||||
$realUploadsDir = realpath($this->uploadsDir);
|
||||
|
||||
if ($realPath === false || $realUploadsDir === false || !str_starts_with($realPath, $realUploadsDir)) {
|
||||
try {
|
||||
$stream = $this->fileStorage->readStream($attachment->filePath);
|
||||
} catch (RuntimeException) {
|
||||
throw new NotFoundHttpException('Pièce jointe non trouvée.');
|
||||
}
|
||||
|
||||
$response = new BinaryFileResponse($realPath);
|
||||
$response->setContentDisposition(
|
||||
ResponseHeaderBag::DISPOSITION_INLINE,
|
||||
$response = new StreamedResponse(static function () use ($stream): void {
|
||||
try {
|
||||
fpassthru($stream);
|
||||
} finally {
|
||||
fclose($stream);
|
||||
}
|
||||
});
|
||||
|
||||
$disposition = HeaderUtils::makeDisposition(
|
||||
HeaderUtils::DISPOSITION_INLINE,
|
||||
$attachment->filename,
|
||||
);
|
||||
|
||||
$response->headers->set('Content-Type', $attachment->mimeType);
|
||||
$response->headers->set('Content-Disposition', $disposition);
|
||||
$response->headers->set('Content-Length', (string) $attachment->fileSize);
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace App\Scolarite\Infrastructure\Api\Controller;
|
||||
|
||||
use App\Administration\Infrastructure\Security\SecurityUser;
|
||||
use App\Scolarite\Application\Port\ClassStudentsReader;
|
||||
use App\Scolarite\Application\Port\FileStorage;
|
||||
use App\Scolarite\Domain\Model\Homework\HomeworkId;
|
||||
use App\Scolarite\Domain\Model\HomeworkSubmission\HomeworkSubmission;
|
||||
use App\Scolarite\Domain\Model\HomeworkSubmission\HomeworkSubmissionId;
|
||||
@@ -24,15 +25,15 @@ use function count;
|
||||
|
||||
use DateTimeImmutable;
|
||||
|
||||
use function fclose;
|
||||
use function fpassthru;
|
||||
use function in_array;
|
||||
use function realpath;
|
||||
use function str_starts_with;
|
||||
|
||||
use RuntimeException;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpFoundation\HeaderUtils;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
@@ -45,8 +46,7 @@ final readonly class TeacherSubmissionController
|
||||
private HomeworkSubmissionRepository $submissionRepository,
|
||||
private SubmissionAttachmentRepository $attachmentRepository,
|
||||
private ClassStudentsReader $classStudentsReader,
|
||||
#[Autowire('%kernel.project_dir%/var/storage')]
|
||||
private string $storageDir,
|
||||
private FileStorage $fileStorage,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -240,7 +240,7 @@ final readonly class TeacherSubmissionController
|
||||
}
|
||||
|
||||
#[Route('/api/homework/{homeworkId}/submissions/{submissionId}/attachments/{attachmentId}', name: 'api_teacher_submission_attachment_download', methods: ['GET'])]
|
||||
public function downloadAttachment(string $homeworkId, string $submissionId, string $attachmentId): BinaryFileResponse
|
||||
public function downloadAttachment(string $homeworkId, string $submissionId, string $attachmentId): StreamedResponse
|
||||
{
|
||||
$user = $this->getSecurityUser();
|
||||
$tenantId = TenantId::fromString($user->tenantId());
|
||||
@@ -268,20 +268,29 @@ final readonly class TeacherSubmissionController
|
||||
|
||||
foreach ($attachments as $attachment) {
|
||||
if ((string) $attachment->id === $attachmentId) {
|
||||
$fullPath = $this->storageDir . '/' . $attachment->filePath;
|
||||
$realPath = realpath($fullPath);
|
||||
$realStorageDir = realpath($this->storageDir);
|
||||
|
||||
if ($realPath === false || $realStorageDir === false || !str_starts_with($realPath, $realStorageDir)) {
|
||||
try {
|
||||
$stream = $this->fileStorage->readStream($attachment->filePath);
|
||||
} catch (RuntimeException) {
|
||||
throw new NotFoundHttpException('Pièce jointe non trouvée.');
|
||||
}
|
||||
|
||||
$response = new BinaryFileResponse($realPath);
|
||||
$response->setContentDisposition(
|
||||
ResponseHeaderBag::DISPOSITION_INLINE,
|
||||
$response = new StreamedResponse(static function () use ($stream): void {
|
||||
try {
|
||||
fpassthru($stream);
|
||||
} finally {
|
||||
fclose($stream);
|
||||
}
|
||||
});
|
||||
|
||||
$disposition = HeaderUtils::makeDisposition(
|
||||
HeaderUtils::DISPOSITION_INLINE,
|
||||
$attachment->filename,
|
||||
);
|
||||
|
||||
$response->headers->set('Content-Type', $attachment->mimeType);
|
||||
$response->headers->set('Content-Disposition', $disposition);
|
||||
$response->headers->set('Content-Length', (string) $attachment->fileSize);
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Api\Provider;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Administration\Domain\Model\User\Role;
|
||||
use App\Administration\Infrastructure\Security\SecurityUser;
|
||||
use App\Scolarite\Application\Query\GetEvaluationDifficulty\EvaluationDifficultyDto;
|
||||
use App\Scolarite\Application\Query\GetEvaluationDifficulty\GetEvaluationDifficultyQuery;
|
||||
use App\Scolarite\Infrastructure\Api\Resource\EvaluationDifficultyResource;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
|
||||
use function in_array;
|
||||
|
||||
use Override;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||
use Symfony\Component\Messenger\HandleTrait;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
|
||||
/**
|
||||
* @implements ProviderInterface<EvaluationDifficultyResource>
|
||||
*/
|
||||
final class EvaluationDifficultyProvider implements ProviderInterface
|
||||
{
|
||||
use HandleTrait;
|
||||
|
||||
public function __construct(
|
||||
MessageBusInterface $queryBus,
|
||||
private readonly TenantContext $tenantContext,
|
||||
private readonly Security $security,
|
||||
) {
|
||||
$this->messageBus = $queryBus;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): EvaluationDifficultyResource
|
||||
{
|
||||
if (!$this->tenantContext->hasTenant()) {
|
||||
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
|
||||
}
|
||||
|
||||
$user = $this->security->getUser();
|
||||
|
||||
if (!$user instanceof SecurityUser) {
|
||||
throw new UnauthorizedHttpException('Bearer', 'Authentification requise.');
|
||||
}
|
||||
|
||||
if (!in_array(Role::PROF->value, $user->getRoles(), true)) {
|
||||
throw new AccessDeniedHttpException('Accès réservé aux enseignants.');
|
||||
}
|
||||
|
||||
/** @var list<EvaluationDifficultyDto> $results */
|
||||
$results = $this->handle(new GetEvaluationDifficultyQuery(
|
||||
teacherId: $user->userId(),
|
||||
tenantId: (string) $this->tenantContext->getCurrentTenantId(),
|
||||
));
|
||||
|
||||
$resource = new EvaluationDifficultyResource();
|
||||
$resource->teacherId = $user->userId();
|
||||
|
||||
foreach ($results as $dto) {
|
||||
$resource->evaluations[] = [
|
||||
'evaluationId' => $dto->evaluationId,
|
||||
'title' => $dto->title,
|
||||
'classId' => $dto->classId,
|
||||
'className' => $dto->className,
|
||||
'subjectId' => $dto->subjectId,
|
||||
'subjectName' => $dto->subjectName,
|
||||
'date' => $dto->date,
|
||||
'average' => $dto->average,
|
||||
'gradedCount' => $dto->gradedCount,
|
||||
'subjectAverage' => $dto->subjectAverage,
|
||||
'percentile' => $dto->percentile,
|
||||
];
|
||||
}
|
||||
|
||||
return $resource;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Api\Provider;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Administration\Domain\Model\User\Role;
|
||||
use App\Administration\Infrastructure\Security\SecurityUser;
|
||||
use App\Scolarite\Application\Query\GetStudentProgression\GetStudentProgressionQuery;
|
||||
use App\Scolarite\Infrastructure\Api\Resource\StudentProgressionResource;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
|
||||
use function in_array;
|
||||
use function is_string;
|
||||
|
||||
use Override;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||
use Symfony\Component\Messenger\HandleTrait;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
|
||||
/**
|
||||
* @implements ProviderInterface<StudentProgressionResource>
|
||||
*/
|
||||
final class StudentProgressionProvider implements ProviderInterface
|
||||
{
|
||||
use HandleTrait;
|
||||
|
||||
public function __construct(
|
||||
MessageBusInterface $queryBus,
|
||||
private readonly TenantContext $tenantContext,
|
||||
private readonly Security $security,
|
||||
) {
|
||||
$this->messageBus = $queryBus;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): StudentProgressionResource
|
||||
{
|
||||
if (!$this->tenantContext->hasTenant()) {
|
||||
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
|
||||
}
|
||||
|
||||
$user = $this->security->getUser();
|
||||
|
||||
if (!$user instanceof SecurityUser) {
|
||||
throw new UnauthorizedHttpException('Bearer', 'Authentification requise.');
|
||||
}
|
||||
|
||||
if (!in_array(Role::PROF->value, $user->getRoles(), true)) {
|
||||
throw new AccessDeniedHttpException('Accès réservé aux enseignants.');
|
||||
}
|
||||
|
||||
/** @var string $studentId */
|
||||
$studentId = $uriVariables['studentId'];
|
||||
|
||||
/** @var array<string, mixed> $filters */
|
||||
$filters = $context['filters'] ?? [];
|
||||
/** @var string|null $subjectId */
|
||||
$subjectId = $filters['subjectId'] ?? null;
|
||||
/** @var string|null $classId */
|
||||
$classId = $filters['classId'] ?? null;
|
||||
|
||||
if (!is_string($subjectId) || $subjectId === '') {
|
||||
throw new BadRequestHttpException('Le paramètre subjectId est requis.');
|
||||
}
|
||||
|
||||
if (!is_string($classId) || $classId === '') {
|
||||
throw new BadRequestHttpException('Le paramètre classId est requis.');
|
||||
}
|
||||
|
||||
/** @var \App\Scolarite\Application\Query\GetStudentProgression\StudentProgressionDto $dto */
|
||||
$dto = $this->handle(new GetStudentProgressionQuery(
|
||||
studentId: $studentId,
|
||||
subjectId: $subjectId,
|
||||
classId: $classId,
|
||||
teacherId: $user->userId(),
|
||||
tenantId: (string) $this->tenantContext->getCurrentTenantId(),
|
||||
));
|
||||
|
||||
$resource = new StudentProgressionResource();
|
||||
$resource->studentId = $studentId;
|
||||
$resource->subjectId = $subjectId;
|
||||
$resource->classId = $classId;
|
||||
|
||||
foreach ($dto->grades as $grade) {
|
||||
$resource->grades[] = [
|
||||
'date' => $grade->date,
|
||||
'value' => $grade->value,
|
||||
'evaluationTitle' => $grade->evaluationTitle,
|
||||
];
|
||||
}
|
||||
|
||||
if ($dto->trendLine !== null) {
|
||||
$resource->trendLine = [
|
||||
'slope' => $dto->trendLine->slope,
|
||||
'intercept' => $dto->trendLine->intercept,
|
||||
];
|
||||
}
|
||||
|
||||
return $resource;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Api\Provider;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Administration\Domain\Model\User\Role;
|
||||
use App\Administration\Infrastructure\Security\SecurityUser;
|
||||
use App\Scolarite\Application\Query\GetClassStatisticsDetail\GetClassStatisticsDetailQuery;
|
||||
use App\Scolarite\Infrastructure\Api\Resource\TeacherClassStatisticsResource;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
|
||||
use function in_array;
|
||||
use function is_string;
|
||||
|
||||
use Override;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||
use Symfony\Component\Messenger\HandleTrait;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
|
||||
/**
|
||||
* @implements ProviderInterface<TeacherClassStatisticsResource>
|
||||
*/
|
||||
final class TeacherClassStatisticsProvider implements ProviderInterface
|
||||
{
|
||||
use HandleTrait;
|
||||
|
||||
public function __construct(
|
||||
MessageBusInterface $queryBus,
|
||||
private readonly TenantContext $tenantContext,
|
||||
private readonly Security $security,
|
||||
) {
|
||||
$this->messageBus = $queryBus;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): TeacherClassStatisticsResource
|
||||
{
|
||||
if (!$this->tenantContext->hasTenant()) {
|
||||
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
|
||||
}
|
||||
|
||||
$user = $this->security->getUser();
|
||||
|
||||
if (!$user instanceof SecurityUser) {
|
||||
throw new UnauthorizedHttpException('Bearer', 'Authentification requise.');
|
||||
}
|
||||
|
||||
if (!in_array(Role::PROF->value, $user->getRoles(), true)) {
|
||||
throw new AccessDeniedHttpException('Accès réservé aux enseignants.');
|
||||
}
|
||||
|
||||
/** @var string $classId */
|
||||
$classId = $uriVariables['classId'];
|
||||
|
||||
/** @var array<string, mixed> $filters */
|
||||
$filters = $context['filters'] ?? [];
|
||||
/** @var string|null $subjectId */
|
||||
$subjectId = $filters['subjectId'] ?? null;
|
||||
|
||||
if (!is_string($subjectId) || $subjectId === '') {
|
||||
throw new BadRequestHttpException('Le paramètre subjectId est requis.');
|
||||
}
|
||||
|
||||
$threshold = 8.0;
|
||||
$rawThreshold = $filters['threshold'] ?? null;
|
||||
|
||||
if (is_string($rawThreshold) && $rawThreshold !== '') {
|
||||
$parsed = (float) $rawThreshold;
|
||||
|
||||
if ($parsed < 0 || $parsed > 20) {
|
||||
throw new BadRequestHttpException('Le seuil doit être compris entre 0 et 20.');
|
||||
}
|
||||
|
||||
$threshold = $parsed;
|
||||
}
|
||||
|
||||
/** @var \App\Scolarite\Application\Query\GetClassStatisticsDetail\ClassStatisticsDetailDto $dto */
|
||||
$dto = $this->handle(new GetClassStatisticsDetailQuery(
|
||||
teacherId: $user->userId(),
|
||||
classId: $classId,
|
||||
subjectId: $subjectId,
|
||||
tenantId: (string) $this->tenantContext->getCurrentTenantId(),
|
||||
threshold: $threshold,
|
||||
));
|
||||
|
||||
$resource = new TeacherClassStatisticsResource();
|
||||
$resource->classId = $classId;
|
||||
$resource->subjectId = $subjectId;
|
||||
$resource->average = $dto->average;
|
||||
$resource->successRate = $dto->successRate;
|
||||
$resource->distribution = $dto->distribution;
|
||||
$resource->evolution = $dto->evolution;
|
||||
|
||||
foreach ($dto->students as $s) {
|
||||
$resource->students[] = [
|
||||
'studentId' => $s->studentId,
|
||||
'studentName' => $s->studentName,
|
||||
'average' => $s->average,
|
||||
'inDifficulty' => $s->inDifficulty,
|
||||
'trend' => $s->trend,
|
||||
];
|
||||
}
|
||||
|
||||
return $resource;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Api\Provider;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Administration\Domain\Model\User\Role;
|
||||
use App\Administration\Infrastructure\Security\SecurityUser;
|
||||
use App\Scolarite\Application\Query\GetClassStatisticsDetail\ClassStatisticsDetailDto;
|
||||
use App\Scolarite\Application\Query\GetClassStatisticsDetail\GetClassStatisticsDetailQuery;
|
||||
use App\Scolarite\Application\Service\StatisticsExporter;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
|
||||
use function in_array;
|
||||
use function is_string;
|
||||
|
||||
use Override;
|
||||
|
||||
use function sprintf;
|
||||
use function str_replace;
|
||||
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||
use Symfony\Component\Messenger\HandleTrait;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
|
||||
/**
|
||||
* @implements ProviderInterface<Response>
|
||||
*/
|
||||
final class TeacherStatisticsExportProvider implements ProviderInterface
|
||||
{
|
||||
use HandleTrait;
|
||||
|
||||
public function __construct(
|
||||
MessageBusInterface $queryBus,
|
||||
private readonly TenantContext $tenantContext,
|
||||
private readonly Security $security,
|
||||
private readonly StatisticsExporter $exporter,
|
||||
) {
|
||||
$this->messageBus = $queryBus;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Response
|
||||
{
|
||||
if (!$this->tenantContext->hasTenant()) {
|
||||
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
|
||||
}
|
||||
|
||||
$user = $this->security->getUser();
|
||||
|
||||
if (!$user instanceof SecurityUser) {
|
||||
throw new UnauthorizedHttpException('Bearer', 'Authentification requise.');
|
||||
}
|
||||
|
||||
if (!in_array(Role::PROF->value, $user->getRoles(), true)) {
|
||||
throw new AccessDeniedHttpException('Accès réservé aux enseignants.');
|
||||
}
|
||||
|
||||
/** @var array<string, mixed> $filters */
|
||||
$filters = $context['filters'] ?? [];
|
||||
/** @var string|null $classId */
|
||||
$classId = $filters['classId'] ?? null;
|
||||
/** @var string|null $subjectId */
|
||||
$subjectId = $filters['subjectId'] ?? null;
|
||||
/** @var string|null $className */
|
||||
$className = $filters['className'] ?? 'Classe';
|
||||
/** @var string|null $subjectName */
|
||||
$subjectName = $filters['subjectName'] ?? 'Matière';
|
||||
|
||||
if (!is_string($classId) || $classId === '') {
|
||||
throw new BadRequestHttpException('Le paramètre classId est requis.');
|
||||
}
|
||||
|
||||
if (!is_string($subjectId) || $subjectId === '') {
|
||||
throw new BadRequestHttpException('Le paramètre subjectId est requis.');
|
||||
}
|
||||
|
||||
/** @var ClassStatisticsDetailDto $dto */
|
||||
$dto = $this->handle(new GetClassStatisticsDetailQuery(
|
||||
teacherId: $user->userId(),
|
||||
classId: $classId,
|
||||
subjectId: $subjectId,
|
||||
tenantId: (string) $this->tenantContext->getCurrentTenantId(),
|
||||
));
|
||||
|
||||
$csv = $this->exporter->exportClassToCsv($dto, (string) $className, (string) $subjectName);
|
||||
|
||||
// Sanitize filename: remove characters dangerous in Content-Disposition
|
||||
$safeFilename = str_replace(['"', "\r", "\n", '/', '\\'], '', sprintf('statistiques-%s-%s.csv', $className, $subjectName));
|
||||
|
||||
return new Response(
|
||||
content: $csv,
|
||||
headers: [
|
||||
'Content-Type' => 'text/csv; charset=UTF-8',
|
||||
'Content-Disposition' => sprintf('attachment; filename="%s"', $safeFilename),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Api\Provider;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Administration\Domain\Model\User\Role;
|
||||
use App\Administration\Infrastructure\Security\SecurityUser;
|
||||
use App\Scolarite\Application\Query\GetTeacherStatisticsOverview\GetTeacherStatisticsOverviewQuery;
|
||||
use App\Scolarite\Infrastructure\Api\Resource\TeacherStatisticsOverviewResource;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
|
||||
use function in_array;
|
||||
|
||||
use Override;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||
use Symfony\Component\Messenger\HandleTrait;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
|
||||
/**
|
||||
* @implements ProviderInterface<TeacherStatisticsOverviewResource>
|
||||
*/
|
||||
final class TeacherStatisticsOverviewProvider implements ProviderInterface
|
||||
{
|
||||
use HandleTrait;
|
||||
|
||||
public function __construct(
|
||||
MessageBusInterface $queryBus,
|
||||
private readonly TenantContext $tenantContext,
|
||||
private readonly Security $security,
|
||||
) {
|
||||
$this->messageBus = $queryBus;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): TeacherStatisticsOverviewResource
|
||||
{
|
||||
if (!$this->tenantContext->hasTenant()) {
|
||||
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
|
||||
}
|
||||
|
||||
$user = $this->security->getUser();
|
||||
|
||||
if (!$user instanceof SecurityUser) {
|
||||
throw new UnauthorizedHttpException('Bearer', 'Authentification requise.');
|
||||
}
|
||||
|
||||
if (!in_array(Role::PROF->value, $user->getRoles(), true)) {
|
||||
throw new AccessDeniedHttpException('Accès réservé aux enseignants.');
|
||||
}
|
||||
|
||||
/** @var list<\App\Scolarite\Application\Query\GetTeacherStatisticsOverview\ClassOverviewDto> $results */
|
||||
$results = $this->handle(new GetTeacherStatisticsOverviewQuery(
|
||||
teacherId: $user->userId(),
|
||||
tenantId: (string) $this->tenantContext->getCurrentTenantId(),
|
||||
));
|
||||
|
||||
$resource = new TeacherStatisticsOverviewResource();
|
||||
$resource->teacherId = $user->userId();
|
||||
|
||||
foreach ($results as $dto) {
|
||||
$resource->classes[] = [
|
||||
'classId' => $dto->classId,
|
||||
'className' => $dto->className,
|
||||
'subjectId' => $dto->subjectId,
|
||||
'subjectName' => $dto->subjectName,
|
||||
'evaluationCount' => $dto->evaluationCount,
|
||||
'studentCount' => $dto->studentCount,
|
||||
'average' => $dto->average,
|
||||
'successRate' => $dto->successRate,
|
||||
];
|
||||
}
|
||||
|
||||
return $resource;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Api\Resource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiProperty;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use App\Scolarite\Infrastructure\Api\Provider\EvaluationDifficultyProvider;
|
||||
|
||||
#[ApiResource(
|
||||
shortName: 'EvaluationDifficulty',
|
||||
operations: [
|
||||
new Get(
|
||||
uriTemplate: '/me/statistics/evaluations',
|
||||
provider: EvaluationDifficultyProvider::class,
|
||||
name: 'get_evaluation_difficulty',
|
||||
),
|
||||
],
|
||||
)]
|
||||
final class EvaluationDifficultyResource
|
||||
{
|
||||
#[ApiProperty(identifier: true)]
|
||||
public string $teacherId = 'me';
|
||||
|
||||
/** @var list<array{evaluationId: string, title: string, classId: string, className: string, subjectId: string, subjectName: string, date: string, average: float|null, gradedCount: int, subjectAverage: float|null, percentile: float|null}> */
|
||||
public array $evaluations = [];
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Api\Resource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiProperty;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use App\Scolarite\Infrastructure\Api\Provider\StudentProgressionProvider;
|
||||
|
||||
#[ApiResource(
|
||||
shortName: 'StudentProgression',
|
||||
operations: [
|
||||
new Get(
|
||||
uriTemplate: '/me/statistics/students/{studentId}',
|
||||
provider: StudentProgressionProvider::class,
|
||||
name: 'get_student_progression',
|
||||
),
|
||||
],
|
||||
)]
|
||||
final class StudentProgressionResource
|
||||
{
|
||||
#[ApiProperty(identifier: true)]
|
||||
public ?string $studentId = null;
|
||||
|
||||
public ?string $subjectId = null;
|
||||
|
||||
public ?string $classId = null;
|
||||
|
||||
/** @var list<array{date: string, value: float, evaluationTitle: string}> */
|
||||
public array $grades = [];
|
||||
|
||||
/** @var array{slope: float, intercept: float}|null */
|
||||
public ?array $trendLine = null;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Api\Resource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiProperty;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use App\Scolarite\Infrastructure\Api\Provider\TeacherClassStatisticsProvider;
|
||||
|
||||
#[ApiResource(
|
||||
shortName: 'TeacherClassStatistics',
|
||||
operations: [
|
||||
new Get(
|
||||
uriTemplate: '/me/statistics/classes/{classId}',
|
||||
provider: TeacherClassStatisticsProvider::class,
|
||||
name: 'get_teacher_class_statistics',
|
||||
),
|
||||
],
|
||||
)]
|
||||
final class TeacherClassStatisticsResource
|
||||
{
|
||||
#[ApiProperty(identifier: true)]
|
||||
public ?string $classId = null;
|
||||
|
||||
public ?string $subjectId = null;
|
||||
|
||||
public ?float $average = null;
|
||||
|
||||
public float $successRate = 0.0;
|
||||
|
||||
/** @var list<int> */
|
||||
public array $distribution = [];
|
||||
|
||||
/** @var list<array{month: string, average: float}> */
|
||||
public array $evolution = [];
|
||||
|
||||
/** @var list<array{studentId: string, studentName: string, average: float|null, inDifficulty: bool, trend: string}> */
|
||||
public array $students = [];
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Api\Resource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiProperty;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use App\Scolarite\Infrastructure\Api\Provider\TeacherStatisticsExportProvider;
|
||||
|
||||
#[ApiResource(
|
||||
shortName: 'TeacherStatisticsExport',
|
||||
operations: [
|
||||
new Get(
|
||||
uriTemplate: '/me/statistics/export',
|
||||
provider: TeacherStatisticsExportProvider::class,
|
||||
name: 'get_teacher_statistics_export',
|
||||
),
|
||||
],
|
||||
)]
|
||||
final class TeacherStatisticsExportResource
|
||||
{
|
||||
#[ApiProperty(identifier: true)]
|
||||
public string $id = 'export';
|
||||
|
||||
public string $format = 'csv';
|
||||
|
||||
public string $content = '';
|
||||
|
||||
public string $filename = '';
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Api\Resource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiProperty;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use App\Scolarite\Infrastructure\Api\Provider\TeacherStatisticsOverviewProvider;
|
||||
|
||||
#[ApiResource(
|
||||
shortName: 'TeacherStatisticsOverview',
|
||||
operations: [
|
||||
new Get(
|
||||
uriTemplate: '/me/statistics',
|
||||
provider: TeacherStatisticsOverviewProvider::class,
|
||||
name: 'get_teacher_statistics_overview',
|
||||
),
|
||||
],
|
||||
)]
|
||||
final class TeacherStatisticsOverviewResource
|
||||
{
|
||||
#[ApiProperty(identifier: true)]
|
||||
public string $teacherId = 'me';
|
||||
|
||||
public ?string $periodId = null;
|
||||
|
||||
/** @var list<array{classId: string, className: string, subjectId: string, subjectName: string, evaluationCount: int, studentCount: int, average: float|null, successRate: float|null}> */
|
||||
public array $classes = [];
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\ReadModel;
|
||||
|
||||
use App\Scolarite\Application\Port\TeacherStatisticsReader;
|
||||
use Doctrine\DBAL\Connection;
|
||||
|
||||
use function is_numeric;
|
||||
use function round;
|
||||
|
||||
final readonly class DbalTeacherStatisticsReader implements TeacherStatisticsReader
|
||||
{
|
||||
public function __construct(
|
||||
private Connection $connection,
|
||||
) {
|
||||
}
|
||||
|
||||
public function teacherClassesSummary(
|
||||
string $teacherId,
|
||||
string $tenantId,
|
||||
string $periodStartDate,
|
||||
string $periodEndDate,
|
||||
): array {
|
||||
$rows = $this->connection->fetchAllAssociative(
|
||||
<<<'SQL'
|
||||
SELECT
|
||||
ta.school_class_id AS class_id,
|
||||
sc.name AS class_name,
|
||||
ta.subject_id,
|
||||
s.name AS subject_name,
|
||||
COUNT(DISTINCT e.id) AS evaluation_count,
|
||||
COUNT(DISTINCT g.student_id) AS student_count,
|
||||
CASE WHEN COUNT(g.id) FILTER (WHERE g.status = 'graded') > 0
|
||||
THEN ROUND(AVG(g.value * 20.0 / NULLIF(e.grade_scale, 0)) FILTER (WHERE g.status = 'graded'), 2)
|
||||
ELSE NULL
|
||||
END AS average,
|
||||
CASE WHEN COUNT(g.id) FILTER (WHERE g.status = 'graded') > 0
|
||||
THEN ROUND(
|
||||
COUNT(g.id) FILTER (WHERE g.status = 'graded' AND g.value * 20.0 / NULLIF(e.grade_scale, 0) >= 10) * 100.0
|
||||
/ COUNT(g.id) FILTER (WHERE g.status = 'graded'),
|
||||
1
|
||||
)
|
||||
ELSE NULL
|
||||
END AS success_rate
|
||||
FROM teacher_assignments ta
|
||||
JOIN school_classes sc ON sc.id = ta.school_class_id
|
||||
JOIN subjects s ON s.id = ta.subject_id
|
||||
LEFT JOIN evaluations e ON e.class_id = ta.school_class_id
|
||||
AND e.subject_id = ta.subject_id
|
||||
AND e.teacher_id = ta.teacher_id
|
||||
AND e.tenant_id = ta.tenant_id
|
||||
AND e.status != 'deleted'
|
||||
AND e.grades_published_at IS NOT NULL
|
||||
AND e.evaluation_date BETWEEN :period_start AND :period_end
|
||||
LEFT JOIN grades g ON g.evaluation_id = e.id AND g.tenant_id = ta.tenant_id
|
||||
WHERE ta.teacher_id = :teacher_id
|
||||
AND ta.tenant_id = :tenant_id
|
||||
AND ta.status = 'active'
|
||||
GROUP BY ta.school_class_id, sc.name, ta.subject_id, s.name
|
||||
ORDER BY sc.name, s.name
|
||||
SQL,
|
||||
[
|
||||
'teacher_id' => $teacherId,
|
||||
'tenant_id' => $tenantId,
|
||||
'period_start' => $periodStartDate,
|
||||
'period_end' => $periodEndDate,
|
||||
],
|
||||
);
|
||||
|
||||
$result = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
/** @var string $classId */
|
||||
$classId = $row['class_id'];
|
||||
/** @var string $className */
|
||||
$className = $row['class_name'];
|
||||
/** @var string $subjectId */
|
||||
$subjectId = $row['subject_id'];
|
||||
/** @var string $subjectName */
|
||||
$subjectName = $row['subject_name'];
|
||||
/** @var string|int $evaluationCount */
|
||||
$evaluationCount = $row['evaluation_count'];
|
||||
/** @var string|int $studentCount */
|
||||
$studentCount = $row['student_count'];
|
||||
|
||||
/** @var string|float|null $average */
|
||||
$average = $row['average'];
|
||||
/** @var string|float|null $successRate */
|
||||
$successRate = $row['success_rate'];
|
||||
|
||||
$result[] = [
|
||||
'classId' => $classId,
|
||||
'className' => $className,
|
||||
'subjectId' => $subjectId,
|
||||
'subjectName' => $subjectName,
|
||||
'evaluationCount' => (int) $evaluationCount,
|
||||
'studentCount' => (int) $studentCount,
|
||||
'average' => $average !== null ? (float) $average : null,
|
||||
'successRate' => $successRate !== null ? (float) $successRate : null,
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function classGradesNormalized(
|
||||
string $teacherId,
|
||||
string $classId,
|
||||
string $subjectId,
|
||||
string $tenantId,
|
||||
string $periodStartDate,
|
||||
string $periodEndDate,
|
||||
): array {
|
||||
$rows = $this->connection->fetchFirstColumn(
|
||||
<<<'SQL'
|
||||
SELECT ROUND(g.value * 20.0 / NULLIF(e.grade_scale, 0), 2)
|
||||
FROM grades g
|
||||
JOIN evaluations e ON e.id = g.evaluation_id
|
||||
WHERE e.teacher_id = :teacher_id
|
||||
AND e.class_id = :class_id
|
||||
AND e.subject_id = :subject_id
|
||||
AND e.tenant_id = :tenant_id
|
||||
AND e.status != 'deleted'
|
||||
AND e.grades_published_at IS NOT NULL
|
||||
AND e.evaluation_date BETWEEN :period_start AND :period_end
|
||||
AND g.status = 'graded'
|
||||
AND g.tenant_id = :tenant_id
|
||||
ORDER BY g.value
|
||||
SQL,
|
||||
[
|
||||
'teacher_id' => $teacherId,
|
||||
'class_id' => $classId,
|
||||
'subject_id' => $subjectId,
|
||||
'tenant_id' => $tenantId,
|
||||
'period_start' => $periodStartDate,
|
||||
'period_end' => $periodEndDate,
|
||||
],
|
||||
);
|
||||
|
||||
$result = [];
|
||||
|
||||
foreach ($rows as $v) {
|
||||
if (is_numeric($v)) {
|
||||
$result[] = (float) $v;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function classMonthlyAverages(
|
||||
string $teacherId,
|
||||
string $classId,
|
||||
string $subjectId,
|
||||
string $tenantId,
|
||||
string $academicYearStart,
|
||||
string $academicYearEnd,
|
||||
): array {
|
||||
$rows = $this->connection->fetchAllAssociative(
|
||||
<<<'SQL'
|
||||
SELECT
|
||||
TO_CHAR(e.evaluation_date, 'YYYY-MM') AS month,
|
||||
ROUND(AVG(g.value * 20.0 / NULLIF(e.grade_scale, 0)), 2) AS average
|
||||
FROM grades g
|
||||
JOIN evaluations e ON e.id = g.evaluation_id
|
||||
WHERE e.teacher_id = :teacher_id
|
||||
AND e.class_id = :class_id
|
||||
AND e.subject_id = :subject_id
|
||||
AND e.tenant_id = :tenant_id
|
||||
AND e.status != 'deleted'
|
||||
AND e.grades_published_at IS NOT NULL
|
||||
AND g.status = 'graded'
|
||||
AND g.tenant_id = :tenant_id
|
||||
AND e.evaluation_date BETWEEN :year_start AND :year_end
|
||||
GROUP BY TO_CHAR(e.evaluation_date, 'YYYY-MM')
|
||||
ORDER BY month
|
||||
SQL,
|
||||
[
|
||||
'teacher_id' => $teacherId,
|
||||
'class_id' => $classId,
|
||||
'subject_id' => $subjectId,
|
||||
'tenant_id' => $tenantId,
|
||||
'year_start' => $academicYearStart,
|
||||
'year_end' => $academicYearEnd,
|
||||
],
|
||||
);
|
||||
|
||||
$result = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
/** @var string $month */
|
||||
$month = $row['month'];
|
||||
/** @var string|float $average */
|
||||
$average = $row['average'];
|
||||
|
||||
$result[] = [
|
||||
'month' => $month,
|
||||
'average' => (float) $average,
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Les moyennes lues depuis student_averages incluent toutes les évaluations
|
||||
* de la matière (tous enseignants confondus) — c'est le même agrégat que
|
||||
* celui du bulletin scolaire. Le JOIN teacher_assignments contrôle l'accès
|
||||
* (seul un enseignant affecté à la classe peut appeler cette méthode).
|
||||
*/
|
||||
public function studentAveragesForClass(
|
||||
string $teacherId,
|
||||
string $classId,
|
||||
string $subjectId,
|
||||
string $periodId,
|
||||
string $tenantId,
|
||||
): array {
|
||||
$rows = $this->connection->fetchAllAssociative(
|
||||
<<<'SQL'
|
||||
SELECT
|
||||
ca.user_id AS student_id,
|
||||
CONCAT(u.first_name, ' ', u.last_name) AS student_name,
|
||||
sa.average
|
||||
FROM class_assignments ca
|
||||
JOIN users u ON u.id = ca.user_id
|
||||
JOIN teacher_assignments ta ON ta.school_class_id = ca.school_class_id
|
||||
AND ta.subject_id = :subject_id
|
||||
AND ta.teacher_id = :teacher_id
|
||||
AND ta.tenant_id = :tenant_id
|
||||
AND ta.status = 'active'
|
||||
LEFT JOIN student_averages sa ON sa.student_id = ca.user_id
|
||||
AND sa.subject_id = :subject_id
|
||||
AND sa.period_id = :period_id
|
||||
AND sa.tenant_id = :tenant_id
|
||||
WHERE ca.school_class_id = :class_id
|
||||
AND ca.tenant_id = :tenant_id
|
||||
ORDER BY u.last_name, u.first_name
|
||||
SQL,
|
||||
[
|
||||
'teacher_id' => $teacherId,
|
||||
'class_id' => $classId,
|
||||
'subject_id' => $subjectId,
|
||||
'period_id' => $periodId,
|
||||
'tenant_id' => $tenantId,
|
||||
],
|
||||
);
|
||||
|
||||
$result = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
/** @var string $studentId */
|
||||
$studentId = $row['student_id'];
|
||||
/** @var string $studentName */
|
||||
$studentName = $row['student_name'];
|
||||
/** @var string|float|null $studentAvg */
|
||||
$studentAvg = $row['average'];
|
||||
|
||||
$result[] = [
|
||||
'studentId' => $studentId,
|
||||
'studentName' => $studentName,
|
||||
'average' => $studentAvg !== null ? (float) $studentAvg : null,
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function studentMonthlyAveragesForClass(
|
||||
string $classId,
|
||||
string $subjectId,
|
||||
string $tenantId,
|
||||
string $academicYearStart,
|
||||
string $academicYearEnd,
|
||||
): array {
|
||||
$rows = $this->connection->fetchAllAssociative(
|
||||
<<<'SQL'
|
||||
SELECT
|
||||
g.student_id,
|
||||
TO_CHAR(e.evaluation_date, 'YYYY-MM') AS month,
|
||||
ROUND(AVG(g.value * 20.0 / NULLIF(e.grade_scale, 0)), 2) AS average
|
||||
FROM grades g
|
||||
JOIN evaluations e ON e.id = g.evaluation_id
|
||||
WHERE e.class_id = :class_id
|
||||
AND e.subject_id = :subject_id
|
||||
AND e.tenant_id = :tenant_id
|
||||
AND e.status != 'deleted'
|
||||
AND e.grades_published_at IS NOT NULL
|
||||
AND g.status = 'graded'
|
||||
AND g.tenant_id = :tenant_id
|
||||
AND e.evaluation_date BETWEEN :year_start AND :year_end
|
||||
GROUP BY g.student_id, TO_CHAR(e.evaluation_date, 'YYYY-MM')
|
||||
ORDER BY g.student_id, month
|
||||
SQL,
|
||||
[
|
||||
'class_id' => $classId,
|
||||
'subject_id' => $subjectId,
|
||||
'tenant_id' => $tenantId,
|
||||
'year_start' => $academicYearStart,
|
||||
'year_end' => $academicYearEnd,
|
||||
],
|
||||
);
|
||||
|
||||
/** @var array<string, list<float>> $result */
|
||||
$result = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
/** @var string $sid */
|
||||
$sid = $row['student_id'];
|
||||
/** @var string|float $avg */
|
||||
$avg = $row['average'];
|
||||
|
||||
$result[$sid][] = (float) $avg;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function studentGradeHistory(
|
||||
string $studentId,
|
||||
string $subjectId,
|
||||
string $classId,
|
||||
string $teacherId,
|
||||
string $tenantId,
|
||||
string $academicYearStart,
|
||||
string $academicYearEnd,
|
||||
): array {
|
||||
$rows = $this->connection->fetchAllAssociative(
|
||||
<<<'SQL'
|
||||
SELECT
|
||||
e.evaluation_date::date AS date,
|
||||
ROUND(g.value * 20.0 / NULLIF(e.grade_scale, 0), 2) AS value,
|
||||
e.title AS evaluation_title
|
||||
FROM grades g
|
||||
JOIN evaluations e ON e.id = g.evaluation_id
|
||||
JOIN teacher_assignments ta ON ta.school_class_id = e.class_id
|
||||
AND ta.subject_id = e.subject_id
|
||||
AND ta.teacher_id = :teacher_id
|
||||
AND ta.tenant_id = :tenant_id
|
||||
AND ta.status = 'active'
|
||||
WHERE g.student_id = :student_id
|
||||
AND e.subject_id = :subject_id
|
||||
AND e.class_id = :class_id
|
||||
AND e.tenant_id = :tenant_id
|
||||
AND e.status != 'deleted'
|
||||
AND e.grades_published_at IS NOT NULL
|
||||
AND g.status = 'graded'
|
||||
AND g.tenant_id = :tenant_id
|
||||
AND e.evaluation_date BETWEEN :year_start AND :year_end
|
||||
ORDER BY e.evaluation_date
|
||||
SQL,
|
||||
[
|
||||
'student_id' => $studentId,
|
||||
'subject_id' => $subjectId,
|
||||
'class_id' => $classId,
|
||||
'teacher_id' => $teacherId,
|
||||
'tenant_id' => $tenantId,
|
||||
'year_start' => $academicYearStart,
|
||||
'year_end' => $academicYearEnd,
|
||||
],
|
||||
);
|
||||
|
||||
$result = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
/** @var string $date */
|
||||
$date = $row['date'];
|
||||
/** @var string|float $value */
|
||||
$value = $row['value'];
|
||||
/** @var string $evaluationTitle */
|
||||
$evaluationTitle = $row['evaluation_title'];
|
||||
|
||||
$result[] = [
|
||||
'date' => $date,
|
||||
'value' => (float) $value,
|
||||
'evaluationTitle' => $evaluationTitle,
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function teacherEvaluationDifficulties(string $teacherId, string $tenantId): array
|
||||
{
|
||||
$rows = $this->connection->fetchAllAssociative(
|
||||
<<<'SQL'
|
||||
SELECT
|
||||
e.id AS evaluation_id,
|
||||
e.title,
|
||||
e.class_id,
|
||||
sc.name AS class_name,
|
||||
e.subject_id,
|
||||
s.name AS subject_name,
|
||||
e.evaluation_date::date AS date,
|
||||
es.average,
|
||||
COALESCE(es.graded_count, 0) AS graded_count
|
||||
FROM evaluations e
|
||||
JOIN school_classes sc ON sc.id = e.class_id
|
||||
JOIN subjects s ON s.id = e.subject_id
|
||||
LEFT JOIN evaluation_statistics es ON es.evaluation_id = e.id
|
||||
WHERE e.teacher_id = :teacher_id
|
||||
AND e.tenant_id = :tenant_id
|
||||
AND e.status != 'deleted'
|
||||
AND e.grades_published_at IS NOT NULL
|
||||
ORDER BY e.evaluation_date DESC
|
||||
SQL,
|
||||
[
|
||||
'teacher_id' => $teacherId,
|
||||
'tenant_id' => $tenantId,
|
||||
],
|
||||
);
|
||||
|
||||
$result = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
/** @var string $evaluationId */
|
||||
$evaluationId = $row['evaluation_id'];
|
||||
/** @var string $title */
|
||||
$title = $row['title'];
|
||||
/** @var string $rowClassId */
|
||||
$rowClassId = $row['class_id'];
|
||||
/** @var string $className */
|
||||
$className = $row['class_name'];
|
||||
/** @var string $rowSubjectId */
|
||||
$rowSubjectId = $row['subject_id'];
|
||||
/** @var string $subjectName */
|
||||
$subjectName = $row['subject_name'];
|
||||
/** @var string $date */
|
||||
$date = $row['date'];
|
||||
/** @var string|int $gradedCount */
|
||||
$gradedCount = $row['graded_count'];
|
||||
/** @var string|float|null $evalAvg */
|
||||
$evalAvg = $row['average'];
|
||||
|
||||
$result[] = [
|
||||
'evaluationId' => $evaluationId,
|
||||
'title' => $title,
|
||||
'classId' => $rowClassId,
|
||||
'className' => $className,
|
||||
'subjectId' => $rowSubjectId,
|
||||
'subjectName' => $subjectName,
|
||||
'date' => $date,
|
||||
'average' => $evalAvg !== null ? round((float) $evalAvg, 2) : null,
|
||||
'gradedCount' => (int) $gradedCount,
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function subjectAveragesForOtherTeachers(
|
||||
string $teacherId,
|
||||
string $subjectId,
|
||||
string $tenantId,
|
||||
): array {
|
||||
$rows = $this->connection->fetchFirstColumn(
|
||||
<<<'SQL'
|
||||
SELECT ROUND(AVG(es.average), 2)
|
||||
FROM evaluations e
|
||||
JOIN evaluation_statistics es ON es.evaluation_id = e.id
|
||||
WHERE e.subject_id = :subject_id
|
||||
AND e.tenant_id = :tenant_id
|
||||
AND e.teacher_id != :teacher_id
|
||||
AND e.status != 'deleted'
|
||||
AND e.grades_published_at IS NOT NULL
|
||||
AND es.average IS NOT NULL
|
||||
GROUP BY e.teacher_id
|
||||
SQL,
|
||||
[
|
||||
'teacher_id' => $teacherId,
|
||||
'subject_id' => $subjectId,
|
||||
'tenant_id' => $tenantId,
|
||||
],
|
||||
);
|
||||
|
||||
$result = [];
|
||||
|
||||
foreach ($rows as $v) {
|
||||
if (is_numeric($v)) {
|
||||
$result[] = (float) $v;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\ReadModel;
|
||||
|
||||
use App\Scolarite\Application\Port\TeacherStatisticsReader;
|
||||
|
||||
final class InMemoryTeacherStatisticsReader implements TeacherStatisticsReader
|
||||
{
|
||||
/** @var list<array{classId: string, className: string, subjectId: string, subjectName: string, evaluationCount: int, studentCount: int, average: float|null, successRate: float|null}> */
|
||||
private array $classesSummary = [];
|
||||
|
||||
/** @var list<float> */
|
||||
private array $classGrades = [];
|
||||
|
||||
/** @var list<array{month: string, average: float}> */
|
||||
private array $monthlyAverages = [];
|
||||
|
||||
/** @var list<array{studentId: string, studentName: string, average: float|null}> */
|
||||
private array $studentAverages = [];
|
||||
|
||||
/** @var list<array{date: string, value: float, evaluationTitle: string}> */
|
||||
private array $gradeHistory = [];
|
||||
|
||||
/** @var list<array{evaluationId: string, title: string, classId: string, className: string, subjectId: string, subjectName: string, date: string, average: float|null, gradedCount: int}> */
|
||||
private array $evaluationDifficulties = [];
|
||||
|
||||
/** @var array<string, list<float>> */
|
||||
private array $studentMonthlyAverages = [];
|
||||
|
||||
/** @var list<float> */
|
||||
private array $otherTeachersAverages = [];
|
||||
|
||||
/** @param list<array{classId: string, className: string, subjectId: string, subjectName: string, evaluationCount: int, studentCount: int, average: float|null, successRate: float|null}> $data */
|
||||
public function feedClassesSummary(array $data): void
|
||||
{
|
||||
$this->classesSummary = $data;
|
||||
}
|
||||
|
||||
/** @param list<float> $grades */
|
||||
public function feedClassGrades(array $grades): void
|
||||
{
|
||||
$this->classGrades = $grades;
|
||||
}
|
||||
|
||||
/** @param list<array{month: string, average: float}> $averages */
|
||||
public function feedMonthlyAverages(array $averages): void
|
||||
{
|
||||
$this->monthlyAverages = $averages;
|
||||
}
|
||||
|
||||
/** @param list<array{studentId: string, studentName: string, average: float|null}> $averages */
|
||||
public function feedStudentAverages(array $averages): void
|
||||
{
|
||||
$this->studentAverages = $averages;
|
||||
}
|
||||
|
||||
/** @param list<array{date: string, value: float, evaluationTitle: string}> $history */
|
||||
public function feedGradeHistory(array $history): void
|
||||
{
|
||||
$this->gradeHistory = $history;
|
||||
}
|
||||
|
||||
/** @param list<array{evaluationId: string, title: string, classId: string, className: string, subjectId: string, subjectName: string, date: string, average: float|null, gradedCount: int}> $difficulties */
|
||||
public function feedEvaluationDifficulties(array $difficulties): void
|
||||
{
|
||||
$this->evaluationDifficulties = $difficulties;
|
||||
}
|
||||
|
||||
/** @param array<string, list<float>> $averages */
|
||||
public function feedStudentMonthlyAverages(array $averages): void
|
||||
{
|
||||
$this->studentMonthlyAverages = $averages;
|
||||
}
|
||||
|
||||
/** @param list<float> $averages */
|
||||
public function feedOtherTeachersAverages(array $averages): void
|
||||
{
|
||||
$this->otherTeachersAverages = $averages;
|
||||
}
|
||||
|
||||
public function teacherClassesSummary(string $teacherId, string $tenantId, string $periodStartDate, string $periodEndDate): array
|
||||
{
|
||||
return $this->classesSummary;
|
||||
}
|
||||
|
||||
public function classGradesNormalized(string $teacherId, string $classId, string $subjectId, string $tenantId, string $periodStartDate, string $periodEndDate): array
|
||||
{
|
||||
return $this->classGrades;
|
||||
}
|
||||
|
||||
public function classMonthlyAverages(string $teacherId, string $classId, string $subjectId, string $tenantId, string $academicYearStart, string $academicYearEnd): array
|
||||
{
|
||||
return $this->monthlyAverages;
|
||||
}
|
||||
|
||||
public function studentAveragesForClass(string $teacherId, string $classId, string $subjectId, string $periodId, string $tenantId): array
|
||||
{
|
||||
return $this->studentAverages;
|
||||
}
|
||||
|
||||
public function studentMonthlyAveragesForClass(string $classId, string $subjectId, string $tenantId, string $academicYearStart, string $academicYearEnd): array
|
||||
{
|
||||
return $this->studentMonthlyAverages;
|
||||
}
|
||||
|
||||
public function studentGradeHistory(string $studentId, string $subjectId, string $classId, string $teacherId, string $tenantId, string $academicYearStart, string $academicYearEnd): array
|
||||
{
|
||||
return $this->gradeHistory;
|
||||
}
|
||||
|
||||
public function teacherEvaluationDifficulties(string $teacherId, string $tenantId): array
|
||||
{
|
||||
return $this->evaluationDifficulties;
|
||||
}
|
||||
|
||||
public function subjectAveragesForOtherTeachers(string $teacherId, string $subjectId, string $tenantId): array
|
||||
{
|
||||
return $this->otherTeachersAverages;
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ use App\Scolarite\Application\Port\FileStorage;
|
||||
|
||||
use function dirname;
|
||||
use function file_put_contents;
|
||||
use function fopen;
|
||||
use function is_dir;
|
||||
use function is_file;
|
||||
use function is_string;
|
||||
@@ -15,6 +16,12 @@ use function mkdir;
|
||||
|
||||
use Override;
|
||||
|
||||
use function realpath;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
use function sprintf;
|
||||
use function str_starts_with;
|
||||
use function unlink;
|
||||
|
||||
final readonly class LocalFileStorage implements FileStorage
|
||||
@@ -50,4 +57,24 @@ final readonly class LocalFileStorage implements FileStorage
|
||||
unlink($fullPath);
|
||||
}
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function readStream(string $path): mixed
|
||||
{
|
||||
$fullPath = $this->storagePath . '/' . $path;
|
||||
$realPath = realpath($fullPath);
|
||||
$realStoragePath = realpath($this->storagePath);
|
||||
|
||||
if ($realPath === false || $realStoragePath === false || !str_starts_with($realPath, $realStoragePath)) {
|
||||
throw new RuntimeException(sprintf('Impossible de lire le fichier : %s', $path));
|
||||
}
|
||||
|
||||
$stream = fopen($realPath, 'r');
|
||||
|
||||
if ($stream === false) {
|
||||
throw new RuntimeException(sprintf('Impossible de lire le fichier : %s', $path));
|
||||
}
|
||||
|
||||
return $stream;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Storage;
|
||||
|
||||
use App\Scolarite\Application\Port\FileStorage;
|
||||
use Aws\S3\S3Client;
|
||||
|
||||
use function is_resource;
|
||||
|
||||
use League\Flysystem\AwsS3V3\AwsS3V3Adapter;
|
||||
use League\Flysystem\Filesystem;
|
||||
use League\Flysystem\UnableToDeleteFile;
|
||||
use League\Flysystem\UnableToReadFile;
|
||||
use Override;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Psr\Log\NullLogger;
|
||||
use RuntimeException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
final readonly class S3FileStorage implements FileStorage
|
||||
{
|
||||
private Filesystem $filesystem;
|
||||
private LoggerInterface $logger;
|
||||
|
||||
public function __construct(
|
||||
string $endpoint,
|
||||
string $bucket,
|
||||
string $key,
|
||||
string $secret,
|
||||
string $region,
|
||||
?LoggerInterface $logger = null,
|
||||
) {
|
||||
$this->logger = $logger ?? new NullLogger();
|
||||
$client = new S3Client([
|
||||
'endpoint' => $endpoint,
|
||||
'credentials' => [
|
||||
'key' => $key,
|
||||
'secret' => $secret,
|
||||
],
|
||||
'region' => $region,
|
||||
'version' => 'latest',
|
||||
'use_path_style_endpoint' => true,
|
||||
]);
|
||||
|
||||
$this->filesystem = new Filesystem(
|
||||
new AwsS3V3Adapter($client, $bucket),
|
||||
);
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function upload(string $path, mixed $content, string $mimeType): string
|
||||
{
|
||||
$config = [
|
||||
'ContentType' => $mimeType,
|
||||
];
|
||||
|
||||
if (is_resource($content)) {
|
||||
$this->filesystem->writeStream($path, $content, $config);
|
||||
} else {
|
||||
$this->filesystem->write($path, (string) $content, $config);
|
||||
}
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function delete(string $path): void
|
||||
{
|
||||
try {
|
||||
$this->filesystem->delete($path);
|
||||
} catch (UnableToDeleteFile $e) {
|
||||
$this->logger->warning('S3 delete failed, possible orphan blob: {path}', [
|
||||
'path' => $path,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function readStream(string $path): mixed
|
||||
{
|
||||
try {
|
||||
return $this->filesystem->readStream($path);
|
||||
} catch (UnableToReadFile $e) {
|
||||
throw new RuntimeException(sprintf('Impossible de lire le fichier : %s', $path), 0, $e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Infrastructure\Tenant;
|
||||
|
||||
use function array_values;
|
||||
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Override;
|
||||
|
||||
use function parse_url;
|
||||
use function sprintf;
|
||||
|
||||
use Symfony\Contracts\Service\ResetInterface;
|
||||
|
||||
/**
|
||||
* Resolves tenants dynamically from the establishments table in the master database.
|
||||
*
|
||||
* Unlike InMemoryTenantRegistry (loaded from static config), this implementation
|
||||
* makes newly created establishments immediately accessible via their subdomain
|
||||
* without restarting the application.
|
||||
*
|
||||
* Results are lazy-loaded and cached in memory for the duration of the request.
|
||||
* Implements ResetInterface so long-running workers invalidate the cache between messages.
|
||||
*/
|
||||
final class DoctrineTenantRegistry implements TenantRegistry, ResetInterface
|
||||
{
|
||||
/** @var array<string, TenantConfig>|null Indexed by tenant ID */
|
||||
private ?array $byId = null;
|
||||
|
||||
/** @var array<string, TenantConfig>|null Indexed by subdomain */
|
||||
private ?array $bySubdomain = null;
|
||||
|
||||
public function __construct(
|
||||
private readonly Connection $connection,
|
||||
private readonly string $masterDatabaseUrl,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function getConfig(TenantId $tenantId): TenantConfig
|
||||
{
|
||||
$this->ensureLoaded();
|
||||
|
||||
$key = (string) $tenantId;
|
||||
|
||||
if (!isset($this->byId[$key])) {
|
||||
throw TenantNotFoundException::withId($tenantId);
|
||||
}
|
||||
|
||||
return $this->byId[$key];
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function getBySubdomain(string $subdomain): TenantConfig
|
||||
{
|
||||
$this->ensureLoaded();
|
||||
|
||||
if (!isset($this->bySubdomain[$subdomain])) {
|
||||
throw TenantNotFoundException::withSubdomain($subdomain);
|
||||
}
|
||||
|
||||
return $this->bySubdomain[$subdomain];
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function exists(string $subdomain): bool
|
||||
{
|
||||
$this->ensureLoaded();
|
||||
|
||||
return isset($this->bySubdomain[$subdomain]);
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function getAllConfigs(): array
|
||||
{
|
||||
$this->ensureLoaded();
|
||||
|
||||
/** @var array<string, TenantConfig> $byId */
|
||||
$byId = $this->byId;
|
||||
|
||||
return array_values($byId);
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function reset(): void
|
||||
{
|
||||
$this->byId = null;
|
||||
$this->bySubdomain = null;
|
||||
}
|
||||
|
||||
private function ensureLoaded(): void
|
||||
{
|
||||
if ($this->byId !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->byId = [];
|
||||
$this->bySubdomain = [];
|
||||
|
||||
/** @var array<array{tenant_id: string, subdomain: string, database_name: string}> $rows */
|
||||
$rows = $this->connection->fetchAllAssociative(
|
||||
"SELECT tenant_id, subdomain, database_name FROM establishments WHERE status = 'active'",
|
||||
);
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$config = new TenantConfig(
|
||||
tenantId: TenantId::fromString($row['tenant_id']),
|
||||
subdomain: $row['subdomain'],
|
||||
databaseUrl: $this->buildDatabaseUrl($row['database_name']),
|
||||
);
|
||||
|
||||
$this->byId[$row['tenant_id']] = $config;
|
||||
$this->bySubdomain[$row['subdomain']] = $config;
|
||||
}
|
||||
}
|
||||
|
||||
private function buildDatabaseUrl(string $databaseName): string
|
||||
{
|
||||
$parsed = parse_url($this->masterDatabaseUrl);
|
||||
|
||||
$scheme = $parsed['scheme'] ?? 'postgresql';
|
||||
$user = $parsed['user'] ?? '';
|
||||
$pass = isset($parsed['pass']) ? ':' . $parsed['pass'] : '';
|
||||
$host = $parsed['host'] ?? 'localhost';
|
||||
$port = isset($parsed['port']) ? ':' . $parsed['port'] : '';
|
||||
$query = isset($parsed['query']) ? '?' . $parsed['query'] : '';
|
||||
|
||||
return sprintf('%s://%s%s@%s%s/%s%s', $scheme, $user, $pass, $host, $port, $databaseName, $query);
|
||||
}
|
||||
}
|
||||
@@ -17,23 +17,18 @@ final readonly class CreateEstablishmentHandler
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(CreateEstablishmentCommand $command): CreateEstablishmentResult
|
||||
public function __invoke(CreateEstablishmentCommand $command): Establishment
|
||||
{
|
||||
$establishment = Establishment::creer(
|
||||
name: $command->name,
|
||||
subdomain: $command->subdomain,
|
||||
adminEmail: $command->adminEmail,
|
||||
createdBy: SuperAdminId::fromString($command->superAdminId),
|
||||
createdAt: $this->clock->now(),
|
||||
);
|
||||
|
||||
$this->establishmentRepository->save($establishment);
|
||||
|
||||
return new CreateEstablishmentResult(
|
||||
establishmentId: (string) $establishment->id,
|
||||
tenantId: (string) $establishment->tenantId,
|
||||
name: $establishment->name,
|
||||
subdomain: $establishment->subdomain,
|
||||
databaseName: $establishment->databaseName,
|
||||
);
|
||||
return $establishment;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\SuperAdmin\Application\Command\CreateEstablishment;
|
||||
|
||||
final readonly class CreateEstablishmentResult
|
||||
{
|
||||
public function __construct(
|
||||
public string $establishmentId,
|
||||
public string $tenantId,
|
||||
public string $name,
|
||||
public string $subdomain,
|
||||
public string $databaseName,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\SuperAdmin\Application\Command\ProvisionEstablishment;
|
||||
|
||||
/**
|
||||
* Triggers async provisioning of a newly created establishment.
|
||||
*
|
||||
* Property names intentionally avoid "tenantId" to prevent the
|
||||
* TenantDatabaseMiddleware from trying to switch to a database
|
||||
* that doesn't exist yet.
|
||||
*/
|
||||
final readonly class ProvisionEstablishmentCommand
|
||||
{
|
||||
public function __construct(
|
||||
public string $establishmentId,
|
||||
public string $establishmentTenantId,
|
||||
public string $databaseName,
|
||||
public string $subdomain,
|
||||
public string $adminEmail,
|
||||
public string $establishmentName,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\SuperAdmin\Application\Port;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Creates a tenant database and runs migrations.
|
||||
*/
|
||||
interface TenantProvisioner
|
||||
{
|
||||
/**
|
||||
* Creates the tenant database and applies the schema.
|
||||
*
|
||||
* @throws RuntimeException if provisioning fails
|
||||
*/
|
||||
public function provision(string $databaseName): void;
|
||||
}
|
||||
@@ -18,6 +18,7 @@ final readonly class EtablissementCree implements DomainEvent
|
||||
public TenantId $tenantId,
|
||||
public string $name,
|
||||
public string $subdomain,
|
||||
public string $adminEmail,
|
||||
private DateTimeImmutable $occurredOn,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ final class Establishment extends AggregateRoot
|
||||
public static function creer(
|
||||
string $name,
|
||||
string $subdomain,
|
||||
string $adminEmail,
|
||||
SuperAdminId $createdBy,
|
||||
DateTimeImmutable $createdAt,
|
||||
): self {
|
||||
@@ -49,7 +50,7 @@ final class Establishment extends AggregateRoot
|
||||
name: $name,
|
||||
subdomain: $subdomain,
|
||||
databaseName: sprintf('classeo_tenant_%s', str_replace('-', '', (string) $tenantId)),
|
||||
status: EstablishmentStatus::ACTIF,
|
||||
status: EstablishmentStatus::PROVISIONING,
|
||||
createdAt: $createdAt,
|
||||
createdBy: $createdBy,
|
||||
);
|
||||
@@ -59,12 +60,18 @@ final class Establishment extends AggregateRoot
|
||||
tenantId: $establishment->tenantId,
|
||||
name: $name,
|
||||
subdomain: $subdomain,
|
||||
adminEmail: $adminEmail,
|
||||
occurredOn: $createdAt,
|
||||
));
|
||||
|
||||
return $establishment;
|
||||
}
|
||||
|
||||
public function activer(): void
|
||||
{
|
||||
$this->status = EstablishmentStatus::ACTIF;
|
||||
}
|
||||
|
||||
public function desactiver(DateTimeImmutable $at): void
|
||||
{
|
||||
if ($this->status !== EstablishmentStatus::ACTIF) {
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace App\SuperAdmin\Domain\Model\Establishment;
|
||||
|
||||
enum EstablishmentStatus: string
|
||||
{
|
||||
case PROVISIONING = 'provisioning';
|
||||
case ACTIF = 'active';
|
||||
case INACTIF = 'inactive';
|
||||
}
|
||||
|
||||
@@ -8,10 +8,12 @@ use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\SuperAdmin\Application\Command\CreateEstablishment\CreateEstablishmentCommand;
|
||||
use App\SuperAdmin\Application\Command\CreateEstablishment\CreateEstablishmentHandler;
|
||||
use App\SuperAdmin\Application\Command\ProvisionEstablishment\ProvisionEstablishmentCommand;
|
||||
use App\SuperAdmin\Infrastructure\Api\Resource\EstablishmentResource;
|
||||
use App\SuperAdmin\Infrastructure\Security\SecuritySuperAdmin;
|
||||
use Override;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
|
||||
/**
|
||||
* @implements ProcessorInterface<EstablishmentResource, EstablishmentResource>
|
||||
@@ -21,6 +23,7 @@ final readonly class CreateEstablishmentProcessor implements ProcessorInterface
|
||||
public function __construct(
|
||||
private CreateEstablishmentHandler $handler,
|
||||
private Security $security,
|
||||
private MessageBusInterface $commandBus,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -33,20 +36,29 @@ final readonly class CreateEstablishmentProcessor implements ProcessorInterface
|
||||
/** @var SecuritySuperAdmin $user */
|
||||
$user = $this->security->getUser();
|
||||
|
||||
$result = ($this->handler)(new CreateEstablishmentCommand(
|
||||
$establishment = ($this->handler)(new CreateEstablishmentCommand(
|
||||
name: $data->name,
|
||||
subdomain: $data->subdomain,
|
||||
adminEmail: $data->adminEmail,
|
||||
superAdminId: $user->superAdminId(),
|
||||
));
|
||||
|
||||
$this->commandBus->dispatch(new ProvisionEstablishmentCommand(
|
||||
establishmentId: (string) $establishment->id,
|
||||
establishmentTenantId: (string) $establishment->tenantId,
|
||||
databaseName: $establishment->databaseName,
|
||||
subdomain: $establishment->subdomain,
|
||||
adminEmail: $data->adminEmail,
|
||||
establishmentName: $establishment->name,
|
||||
));
|
||||
|
||||
$resource = new EstablishmentResource();
|
||||
$resource->id = $result->establishmentId;
|
||||
$resource->tenantId = $result->tenantId;
|
||||
$resource->name = $result->name;
|
||||
$resource->subdomain = $result->subdomain;
|
||||
$resource->databaseName = $result->databaseName;
|
||||
$resource->status = 'active';
|
||||
$resource->id = (string) $establishment->id;
|
||||
$resource->tenantId = (string) $establishment->tenantId;
|
||||
$resource->name = $establishment->name;
|
||||
$resource->subdomain = $establishment->subdomain;
|
||||
$resource->databaseName = $establishment->databaseName;
|
||||
$resource->status = $establishment->status->value;
|
||||
|
||||
return $resource;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\SuperAdmin\Infrastructure\Provisioning;
|
||||
|
||||
use App\SuperAdmin\Application\Port\TenantProvisioner;
|
||||
use Override;
|
||||
|
||||
/**
|
||||
* Provisions a tenant by creating the database and running migrations.
|
||||
*/
|
||||
final readonly class DatabaseTenantProvisioner implements TenantProvisioner
|
||||
{
|
||||
public function __construct(
|
||||
private TenantDatabaseCreator $databaseCreator,
|
||||
private TenantMigrator $migrator,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function provision(string $databaseName): void
|
||||
{
|
||||
$this->databaseCreator->create($databaseName);
|
||||
$this->migrator->migrate($databaseName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\SuperAdmin\Infrastructure\Provisioning;
|
||||
|
||||
use App\Administration\Application\Command\InviteUser\InviteUserCommand;
|
||||
use App\Administration\Application\Command\InviteUser\InviteUserHandler;
|
||||
use App\Administration\Domain\Exception\EmailDejaUtiliseeException;
|
||||
use App\Administration\Domain\Model\User\Email;
|
||||
use App\Administration\Domain\Model\User\Role;
|
||||
use App\Administration\Domain\Model\User\User;
|
||||
use App\Administration\Domain\Repository\UserRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\DomainEvent;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use App\Shared\Infrastructure\Tenant\TenantDatabaseSwitcher;
|
||||
use App\SuperAdmin\Application\Command\ProvisionEstablishment\ProvisionEstablishmentCommand;
|
||||
use App\SuperAdmin\Application\Port\TenantProvisioner;
|
||||
use App\SuperAdmin\Domain\Model\Establishment\EstablishmentId;
|
||||
use App\SuperAdmin\Domain\Repository\EstablishmentRepository;
|
||||
|
||||
use function parse_url;
|
||||
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Handles the complete provisioning of a new establishment:
|
||||
* 1. Creates the tenant database and runs migrations
|
||||
* 2. Creates the first admin user (idempotent)
|
||||
* 3. Activates the establishment
|
||||
* 4. Dispatches invitation events (after activation so the tenant is resolvable)
|
||||
*/
|
||||
#[AsMessageHandler(bus: 'command.bus')]
|
||||
final readonly class ProvisionEstablishmentHandler
|
||||
{
|
||||
public function __construct(
|
||||
private TenantProvisioner $tenantProvisioner,
|
||||
private InviteUserHandler $inviteUserHandler,
|
||||
private UserRepository $userRepository,
|
||||
private Clock $clock,
|
||||
private TenantDatabaseSwitcher $databaseSwitcher,
|
||||
private EstablishmentRepository $establishmentRepository,
|
||||
private MessageBusInterface $eventBus,
|
||||
private LoggerInterface $logger,
|
||||
private string $masterDatabaseUrl,
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(ProvisionEstablishmentCommand $command): void
|
||||
{
|
||||
$this->logger->info('Starting establishment provisioning.', [
|
||||
'establishment' => $command->establishmentId,
|
||||
'subdomain' => $command->subdomain,
|
||||
]);
|
||||
|
||||
$this->tenantProvisioner->provision($command->databaseName);
|
||||
|
||||
// Create admin user on the tenant database, collect events without dispatching
|
||||
$pendingEvents = $this->createFirstAdminOnTenantDb($command);
|
||||
|
||||
// Activate establishment on master DB so the tenant becomes resolvable
|
||||
$this->activateEstablishment($command->establishmentId);
|
||||
|
||||
// Now dispatch events — the tenant is active and resolvable by the middleware
|
||||
foreach ($pendingEvents as $event) {
|
||||
$this->eventBus->dispatch($event);
|
||||
}
|
||||
|
||||
$this->logger->info('Establishment provisioning completed.', [
|
||||
'establishment' => $command->establishmentId,
|
||||
'subdomain' => $command->subdomain,
|
||||
'adminEmail' => $command->adminEmail,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return DomainEvent[]
|
||||
*/
|
||||
private function createFirstAdminOnTenantDb(ProvisionEstablishmentCommand $command): array
|
||||
{
|
||||
$tenantDatabaseUrl = $this->buildTenantDatabaseUrl($command->databaseName);
|
||||
$this->databaseSwitcher->useTenantDatabase($tenantDatabaseUrl);
|
||||
|
||||
try {
|
||||
return $this->createFirstAdmin($command);
|
||||
} catch (Throwable $e) {
|
||||
$this->restoreDefaultDatabase();
|
||||
|
||||
throw $e;
|
||||
} finally {
|
||||
$this->restoreDefaultDatabase();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return DomainEvent[]
|
||||
*/
|
||||
private function createFirstAdmin(ProvisionEstablishmentCommand $command): array
|
||||
{
|
||||
try {
|
||||
$user = ($this->inviteUserHandler)(new InviteUserCommand(
|
||||
tenantId: $command->establishmentTenantId,
|
||||
schoolName: $command->establishmentName,
|
||||
email: $command->adminEmail,
|
||||
role: Role::ADMIN->value,
|
||||
firstName: 'Administrateur',
|
||||
lastName: $command->establishmentName,
|
||||
));
|
||||
|
||||
return $user->pullDomainEvents();
|
||||
} catch (EmailDejaUtiliseeException) {
|
||||
$this->logger->info('Admin already exists, re-sending invitation.', [
|
||||
'email' => $command->adminEmail,
|
||||
]);
|
||||
|
||||
return $this->resendInvitation($command);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return DomainEvent[]
|
||||
*/
|
||||
private function resendInvitation(ProvisionEstablishmentCommand $command): array
|
||||
{
|
||||
$existingUser = $this->userRepository->findByEmail(
|
||||
new Email($command->adminEmail),
|
||||
TenantId::fromString($command->establishmentTenantId),
|
||||
);
|
||||
|
||||
if ($existingUser === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$existingUser->renvoyerInvitation($this->clock->now());
|
||||
$this->userRepository->save($existingUser);
|
||||
|
||||
return $existingUser->pullDomainEvents();
|
||||
}
|
||||
|
||||
private function activateEstablishment(string $establishmentId): void
|
||||
{
|
||||
$establishment = $this->establishmentRepository->get(
|
||||
EstablishmentId::fromString($establishmentId),
|
||||
);
|
||||
$establishment->activer();
|
||||
$this->establishmentRepository->save($establishment);
|
||||
}
|
||||
|
||||
private function restoreDefaultDatabase(): void
|
||||
{
|
||||
try {
|
||||
$this->databaseSwitcher->useDefaultDatabase();
|
||||
} catch (Throwable $e) {
|
||||
$this->logger->error('Failed to restore default database connection.', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function buildTenantDatabaseUrl(string $databaseName): string
|
||||
{
|
||||
$parsed = parse_url($this->masterDatabaseUrl);
|
||||
|
||||
$scheme = $parsed['scheme'] ?? 'postgresql';
|
||||
$user = $parsed['user'] ?? '';
|
||||
$pass = isset($parsed['pass']) ? ':' . $parsed['pass'] : '';
|
||||
$host = $parsed['host'] ?? 'localhost';
|
||||
$port = isset($parsed['port']) ? ':' . $parsed['port'] : '';
|
||||
$query = isset($parsed['query']) ? '?' . $parsed['query'] : '';
|
||||
|
||||
return sprintf('%s://%s%s@%s%s/%s%s', $scheme, $user, $pass, $host, $port, $databaseName, $query);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\SuperAdmin\Infrastructure\Provisioning;
|
||||
|
||||
use Doctrine\DBAL\Connection;
|
||||
|
||||
use function preg_match;
|
||||
|
||||
use Psr\Log\LoggerInterface;
|
||||
use RuntimeException;
|
||||
|
||||
use function sprintf;
|
||||
use function str_replace;
|
||||
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Creates a PostgreSQL database for a new tenant.
|
||||
*
|
||||
* Extracted from the tenant:database:create console command
|
||||
* to be usable programmatically during provisioning.
|
||||
*/
|
||||
final readonly class TenantDatabaseCreator
|
||||
{
|
||||
public function __construct(
|
||||
private Connection $connection,
|
||||
private LoggerInterface $logger,
|
||||
private string $databaseUser = 'classeo',
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws RuntimeException if database name is invalid or creation fails
|
||||
*/
|
||||
public function create(string $databaseName): void
|
||||
{
|
||||
if (!preg_match('/^classeo_tenant_[a-z0-9_]+$/', $databaseName)) {
|
||||
throw new RuntimeException(sprintf('Invalid tenant database name: "%s"', $databaseName));
|
||||
}
|
||||
|
||||
try {
|
||||
$exists = $this->connection->fetchOne(
|
||||
'SELECT 1 FROM pg_database WHERE datname = :name',
|
||||
['name' => $databaseName],
|
||||
);
|
||||
|
||||
if ($exists !== false) {
|
||||
$this->logger->info('Tenant database already exists, skipping creation.', [
|
||||
'database' => $databaseName,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->connection->executeStatement(sprintf(
|
||||
"CREATE DATABASE %s WITH OWNER = %s ENCODING = 'UTF8' LC_COLLATE = 'en_US.utf8' LC_CTYPE = 'en_US.utf8'",
|
||||
$this->quoteIdentifier($databaseName),
|
||||
$this->quoteIdentifier($this->databaseUser),
|
||||
));
|
||||
|
||||
$this->logger->info('Tenant database created.', ['database' => $databaseName]);
|
||||
} catch (Throwable $e) {
|
||||
throw new RuntimeException(
|
||||
sprintf('Failed to create tenant database "%s": %s', $databaseName, $e->getMessage()),
|
||||
previous: $e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private function quoteIdentifier(string $identifier): string
|
||||
{
|
||||
return '"' . str_replace('"', '""', $identifier) . '"';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\SuperAdmin\Infrastructure\Provisioning;
|
||||
|
||||
use function getenv;
|
||||
use function parse_url;
|
||||
|
||||
use Psr\Log\LoggerInterface;
|
||||
use RuntimeException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
/**
|
||||
* Runs Doctrine migrations for a tenant database.
|
||||
*
|
||||
* Spawns a subprocess with DATABASE_URL pointing to the tenant database,
|
||||
* so Doctrine connects to the correct database before the kernel boots.
|
||||
*/
|
||||
final readonly class TenantMigrator
|
||||
{
|
||||
public function __construct(
|
||||
private string $projectDir,
|
||||
private string $masterDatabaseUrl,
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws RuntimeException if migration fails
|
||||
*/
|
||||
public function migrate(string $databaseName): void
|
||||
{
|
||||
$databaseUrl = $this->buildDatabaseUrl($databaseName);
|
||||
|
||||
$process = new Process(
|
||||
command: ['php', 'bin/console', 'doctrine:migrations:migrate', '--no-interaction'],
|
||||
cwd: $this->projectDir,
|
||||
env: [
|
||||
...getenv(),
|
||||
'DATABASE_URL' => $databaseUrl,
|
||||
],
|
||||
timeout: 300,
|
||||
);
|
||||
|
||||
$this->logger->info('Running migrations for tenant database.', ['database' => $databaseName]);
|
||||
|
||||
$process->run();
|
||||
|
||||
if (!$process->isSuccessful()) {
|
||||
throw new RuntimeException(sprintf(
|
||||
'Migration failed for tenant database "%s": %s',
|
||||
$databaseName,
|
||||
$process->getErrorOutput(),
|
||||
));
|
||||
}
|
||||
|
||||
$this->logger->info('Migrations completed for tenant database.', ['database' => $databaseName]);
|
||||
}
|
||||
|
||||
private function buildDatabaseUrl(string $databaseName): string
|
||||
{
|
||||
$parsed = parse_url($this->masterDatabaseUrl);
|
||||
|
||||
$scheme = $parsed['scheme'] ?? 'postgresql';
|
||||
$user = $parsed['user'] ?? '';
|
||||
$pass = isset($parsed['pass']) ? ':' . $parsed['pass'] : '';
|
||||
$host = $parsed['host'] ?? 'localhost';
|
||||
$port = isset($parsed['port']) ? ':' . $parsed['port'] : '';
|
||||
|
||||
$query = isset($parsed['query']) ? '?' . $parsed['query'] : '';
|
||||
|
||||
return sprintf('%s://%s%s@%s%s/%s%s', $scheme, $user, $pass, $host, $port, $databaseName, $query);
|
||||
}
|
||||
}
|
||||
@@ -36,11 +36,9 @@ final class PasswordResetEndpointsTest extends ApiTestCase
|
||||
|
||||
// Should NOT return 401 Unauthorized
|
||||
// It should return 200 (success) or 429 (rate limited), but never 401
|
||||
self::assertNotEquals(401, $response->getStatusCode(), 'Password forgot endpoint should be accessible without JWT');
|
||||
|
||||
// The endpoint always returns success to prevent email enumeration
|
||||
// Even for non-existent emails
|
||||
self::assertResponseIsSuccessful();
|
||||
$status = $response->getStatusCode();
|
||||
self::assertNotEquals(401, $status, 'Password forgot endpoint should be accessible without JWT');
|
||||
self::assertContains($status, [200, 201, 429], 'Expected 200/201 (success) or 429 (rate limited)');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
|
||||
@@ -0,0 +1,527 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Functional\Scolarite\Api;
|
||||
|
||||
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Domain\Model\Subject\SubjectId;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Administration\Infrastructure\Security\SecurityUser;
|
||||
use App\Scolarite\Domain\Model\Evaluation\Coefficient;
|
||||
use App\Scolarite\Domain\Model\Evaluation\Evaluation;
|
||||
use App\Scolarite\Domain\Model\Evaluation\GradeScale;
|
||||
use App\Scolarite\Domain\Model\Grade\Grade;
|
||||
use App\Scolarite\Domain\Model\Grade\GradeStatus;
|
||||
use App\Scolarite\Domain\Model\Grade\GradeValue;
|
||||
use App\Scolarite\Domain\Repository\EvaluationRepository;
|
||||
use App\Scolarite\Domain\Repository\GradeRepository;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Connection;
|
||||
|
||||
use const JSON_THROW_ON_ERROR;
|
||||
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
|
||||
final class TeacherStatisticsEndpointsTest extends ApiTestCase
|
||||
{
|
||||
protected static ?bool $alwaysBootKernel = true;
|
||||
|
||||
private const string TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
||||
private const string TEACHER_ID = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
|
||||
private const string STUDENT_ID = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb';
|
||||
private const string STUDENT2_ID = 'cccccccc-cccc-cccc-cccc-cccccccccccc';
|
||||
private const string CLASS_ID = 'dddddddd-dddd-dddd-dddd-dddddddddddd';
|
||||
private const string SUBJECT_ID = 'eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee';
|
||||
private const string PERIOD_ID = 'ffffffff-ffff-ffff-ffff-ffffffffffff';
|
||||
private const string BASE_URL = 'http://ecole-alpha.classeo.local/api';
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->seedFixtures();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
/** @var Connection $connection */
|
||||
$connection = static::getContainer()->get(Connection::class);
|
||||
$connection->executeStatement('DELETE FROM evaluation_statistics WHERE evaluation_id IN (SELECT id FROM evaluations WHERE tenant_id = :tid AND teacher_id = :teach)', ['tid' => self::TENANT_ID, 'teach' => self::TEACHER_ID]);
|
||||
$connection->executeStatement('DELETE FROM student_averages WHERE tenant_id = :tid', ['tid' => self::TENANT_ID]);
|
||||
$connection->executeStatement('DELETE FROM student_general_averages WHERE tenant_id = :tid', ['tid' => self::TENANT_ID]);
|
||||
$connection->executeStatement('DELETE FROM grade_events WHERE grade_id IN (SELECT id FROM grades WHERE tenant_id = :tid AND created_by = :teach)', ['tid' => self::TENANT_ID, 'teach' => self::TEACHER_ID]);
|
||||
$connection->executeStatement('DELETE FROM grades WHERE tenant_id = :tid AND created_by = :teach', ['tid' => self::TENANT_ID, 'teach' => self::TEACHER_ID]);
|
||||
$connection->executeStatement('DELETE FROM evaluations WHERE tenant_id = :tid AND teacher_id = :teach', ['tid' => self::TENANT_ID, 'teach' => self::TEACHER_ID]);
|
||||
$connection->executeStatement('DELETE FROM teacher_assignments WHERE tenant_id = :tid AND teacher_id = :teach', ['tid' => self::TENANT_ID, 'teach' => self::TEACHER_ID]);
|
||||
$connection->executeStatement('DELETE FROM class_assignments WHERE tenant_id = :tid AND school_class_id = :class', ['tid' => self::TENANT_ID, 'class' => self::CLASS_ID]);
|
||||
$connection->executeStatement('DELETE FROM academic_periods WHERE id = :id', ['id' => self::PERIOD_ID]);
|
||||
$connection->executeStatement('DELETE FROM users WHERE id = :id', ['id' => self::TEACHER_ID]);
|
||||
$connection->executeStatement('DELETE FROM users WHERE id = :id', ['id' => self::STUDENT_ID]);
|
||||
$connection->executeStatement('DELETE FROM users WHERE id = :id', ['id' => self::STUDENT2_ID]);
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// GET /me/statistics — Auth & Access
|
||||
// =========================================================================
|
||||
|
||||
#[Test]
|
||||
public function overviewReturns401WithoutAuthentication(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$client->request('GET', self::BASE_URL . '/me/statistics', [
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(401);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function overviewReturns403ForStudent(): void
|
||||
{
|
||||
$client = $this->createAuthenticatedClient(self::STUDENT_ID, ['ROLE_ELEVE']);
|
||||
$client->request('GET', self::BASE_URL . '/me/statistics', [
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function overviewReturns403ForParent(): void
|
||||
{
|
||||
$parentId = '99999999-9999-9999-9999-999999999999';
|
||||
$client = $this->createAuthenticatedClient($parentId, ['ROLE_PARENT']);
|
||||
$client->request('GET', self::BASE_URL . '/me/statistics', [
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// GET /me/statistics — Happy path
|
||||
// =========================================================================
|
||||
|
||||
#[Test]
|
||||
public function overviewReturnsClassSummaryForTeacher(): void
|
||||
{
|
||||
$client = $this->createAuthenticatedClient(self::TEACHER_ID, ['ROLE_PROF']);
|
||||
$client->request('GET', self::BASE_URL . '/me/statistics', [
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
/** @var string $content */
|
||||
$content = $client->getResponse()->getContent();
|
||||
/** @var array<string, mixed> $data */
|
||||
$data = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
|
||||
|
||||
self::assertArrayHasKey('teacherId', $data);
|
||||
self::assertSame(self::TEACHER_ID, $data['teacherId']);
|
||||
self::assertArrayHasKey('classes', $data);
|
||||
self::assertNotEmpty($data['classes']);
|
||||
|
||||
$class = $data['classes'][0];
|
||||
self::assertSame(self::CLASS_ID, $class['classId']);
|
||||
self::assertSame(self::SUBJECT_ID, $class['subjectId']);
|
||||
self::assertArrayHasKey('evaluationCount', $class);
|
||||
self::assertArrayHasKey('studentCount', $class);
|
||||
self::assertArrayHasKey('average', $class);
|
||||
self::assertArrayHasKey('successRate', $class);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// GET /me/statistics/classes/{classId} — Auth & Validation
|
||||
// =========================================================================
|
||||
|
||||
#[Test]
|
||||
public function classDetailReturns401WithoutAuthentication(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$client->request('GET', self::BASE_URL . '/me/statistics/classes/' . self::CLASS_ID . '?subjectId=' . self::SUBJECT_ID, [
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(401);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function classDetailReturns403ForStudent(): void
|
||||
{
|
||||
$client = $this->createAuthenticatedClient(self::STUDENT_ID, ['ROLE_ELEVE']);
|
||||
$client->request('GET', self::BASE_URL . '/me/statistics/classes/' . self::CLASS_ID . '?subjectId=' . self::SUBJECT_ID, [
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function classDetailReturns400WithoutSubjectId(): void
|
||||
{
|
||||
$client = $this->createAuthenticatedClient(self::TEACHER_ID, ['ROLE_PROF']);
|
||||
$client->request('GET', self::BASE_URL . '/me/statistics/classes/' . self::CLASS_ID, [
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(400);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function classDetailReturns400WithInvalidThreshold(): void
|
||||
{
|
||||
$client = $this->createAuthenticatedClient(self::TEACHER_ID, ['ROLE_PROF']);
|
||||
$client->request('GET', self::BASE_URL . '/me/statistics/classes/' . self::CLASS_ID . '?subjectId=' . self::SUBJECT_ID . '&threshold=25', [
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(400);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// GET /me/statistics/classes/{classId} — Happy path
|
||||
// =========================================================================
|
||||
|
||||
#[Test]
|
||||
public function classDetailReturnsStatisticsForTeacher(): void
|
||||
{
|
||||
$client = $this->createAuthenticatedClient(self::TEACHER_ID, ['ROLE_PROF']);
|
||||
$client->request('GET', self::BASE_URL . '/me/statistics/classes/' . self::CLASS_ID . '?subjectId=' . self::SUBJECT_ID, [
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
/** @var string $content */
|
||||
$content = $client->getResponse()->getContent();
|
||||
/** @var array<string, mixed> $data */
|
||||
$data = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
|
||||
|
||||
self::assertSame(self::CLASS_ID, $data['classId']);
|
||||
self::assertSame(self::SUBJECT_ID, $data['subjectId']);
|
||||
self::assertArrayHasKey('average', $data);
|
||||
self::assertArrayHasKey('successRate', $data);
|
||||
self::assertArrayHasKey('distribution', $data);
|
||||
self::assertCount(8, $data['distribution']);
|
||||
self::assertArrayHasKey('evolution', $data);
|
||||
self::assertArrayHasKey('students', $data);
|
||||
self::assertNotEmpty($data['students']);
|
||||
|
||||
$student = $data['students'][0];
|
||||
self::assertArrayHasKey('studentId', $student);
|
||||
self::assertArrayHasKey('studentName', $student);
|
||||
self::assertArrayHasKey('average', $student);
|
||||
self::assertArrayHasKey('inDifficulty', $student);
|
||||
self::assertArrayHasKey('trend', $student);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// GET /me/statistics/export — Auth & Validation
|
||||
// =========================================================================
|
||||
|
||||
#[Test]
|
||||
public function exportReturns401WithoutAuthentication(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$client->request('GET', self::BASE_URL . '/me/statistics/export?classId=' . self::CLASS_ID . '&subjectId=' . self::SUBJECT_ID);
|
||||
|
||||
self::assertResponseStatusCodeSame(401);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function exportReturns403ForStudent(): void
|
||||
{
|
||||
$client = $this->createAuthenticatedClient(self::STUDENT_ID, ['ROLE_ELEVE']);
|
||||
$client->request('GET', self::BASE_URL . '/me/statistics/export?classId=' . self::CLASS_ID . '&subjectId=' . self::SUBJECT_ID);
|
||||
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function exportReturns400WithoutClassId(): void
|
||||
{
|
||||
$client = $this->createAuthenticatedClient(self::TEACHER_ID, ['ROLE_PROF']);
|
||||
$client->request('GET', self::BASE_URL . '/me/statistics/export?subjectId=' . self::SUBJECT_ID);
|
||||
|
||||
self::assertResponseStatusCodeSame(400);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function exportReturns400WithoutSubjectId(): void
|
||||
{
|
||||
$client = $this->createAuthenticatedClient(self::TEACHER_ID, ['ROLE_PROF']);
|
||||
$client->request('GET', self::BASE_URL . '/me/statistics/export?classId=' . self::CLASS_ID);
|
||||
|
||||
self::assertResponseStatusCodeSame(400);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// GET /me/statistics/export — Happy path
|
||||
// =========================================================================
|
||||
|
||||
#[Test]
|
||||
public function exportReturnsCsvWithCorrectHeaders(): void
|
||||
{
|
||||
$client = $this->createAuthenticatedClient(self::TEACHER_ID, ['ROLE_PROF']);
|
||||
$client->request('GET', self::BASE_URL . '/me/statistics/export?classId=' . self::CLASS_ID . '&subjectId=' . self::SUBJECT_ID . '&className=6eB&subjectName=Math%C3%A9matiques');
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
self::assertResponseHeaderSame('content-type', 'text/csv; charset=UTF-8');
|
||||
|
||||
/** @var string $csv */
|
||||
$csv = $client->getResponse()->getContent();
|
||||
self::assertNotEmpty($csv);
|
||||
self::assertStringContainsString('Moyenne', $csv);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// GET /me/statistics/evaluations — Auth & Access
|
||||
// =========================================================================
|
||||
|
||||
#[Test]
|
||||
public function evaluationDifficultyReturns401WithoutAuthentication(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$client->request('GET', self::BASE_URL . '/me/statistics/evaluations', [
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(401);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function evaluationDifficultyReturns403ForStudent(): void
|
||||
{
|
||||
$client = $this->createAuthenticatedClient(self::STUDENT_ID, ['ROLE_ELEVE']);
|
||||
$client->request('GET', self::BASE_URL . '/me/statistics/evaluations', [
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function evaluationDifficultyReturnsDataForTeacher(): void
|
||||
{
|
||||
$client = $this->createAuthenticatedClient(self::TEACHER_ID, ['ROLE_PROF']);
|
||||
$client->request('GET', self::BASE_URL . '/me/statistics/evaluations', [
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
/** @var string $content */
|
||||
$content = $client->getResponse()->getContent();
|
||||
/** @var array<string, mixed> $payload */
|
||||
$payload = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
|
||||
|
||||
self::assertArrayHasKey('evaluations', $payload);
|
||||
/** @var list<array<string, mixed>> $evaluations */
|
||||
$evaluations = $payload['evaluations'];
|
||||
self::assertIsArray($evaluations);
|
||||
self::assertNotEmpty($evaluations);
|
||||
|
||||
$eval = $evaluations[0];
|
||||
self::assertArrayHasKey('evaluationId', $eval);
|
||||
self::assertArrayHasKey('title', $eval);
|
||||
self::assertArrayHasKey('gradedCount', $eval);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// GET /me/statistics/students/{studentId} — Auth & Validation
|
||||
// =========================================================================
|
||||
|
||||
#[Test]
|
||||
public function studentProgressionReturns401WithoutAuthentication(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$client->request('GET', self::BASE_URL . '/me/statistics/students/' . self::STUDENT_ID . '?subjectId=' . self::SUBJECT_ID . '&classId=' . self::CLASS_ID, [
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(401);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function studentProgressionReturns403ForStudent(): void
|
||||
{
|
||||
$client = $this->createAuthenticatedClient(self::STUDENT_ID, ['ROLE_ELEVE']);
|
||||
$client->request('GET', self::BASE_URL . '/me/statistics/students/' . self::STUDENT_ID . '?subjectId=' . self::SUBJECT_ID . '&classId=' . self::CLASS_ID, [
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function studentProgressionReturnsDataForTeacher(): void
|
||||
{
|
||||
$client = $this->createAuthenticatedClient(self::TEACHER_ID, ['ROLE_PROF']);
|
||||
$client->request('GET', self::BASE_URL . '/me/statistics/students/' . self::STUDENT_ID . '?subjectId=' . self::SUBJECT_ID . '&classId=' . self::CLASS_ID, [
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
/** @var string $content */
|
||||
$content = $client->getResponse()->getContent();
|
||||
/** @var array<string, mixed> $data */
|
||||
$data = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
|
||||
|
||||
self::assertArrayHasKey('grades', $data);
|
||||
self::assertIsArray($data['grades']);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Helpers
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* @param list<string> $roles
|
||||
*/
|
||||
private function createAuthenticatedClient(string $userId, array $roles): \ApiPlatform\Symfony\Bundle\Test\Client
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
$user = new SecurityUser(
|
||||
userId: UserId::fromString($userId),
|
||||
email: 'test-stats@classeo.local',
|
||||
hashedPassword: '',
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
roles: $roles,
|
||||
);
|
||||
|
||||
$client->loginUser($user, 'api');
|
||||
|
||||
return $client;
|
||||
}
|
||||
|
||||
private function seedFixtures(): void
|
||||
{
|
||||
$container = static::getContainer();
|
||||
/** @var Connection $connection */
|
||||
$connection = $container->get(Connection::class);
|
||||
$tenantId = TenantId::fromString(self::TENANT_ID);
|
||||
$now = new DateTimeImmutable();
|
||||
|
||||
$schoolId = '550e8400-e29b-41d4-a716-ff6655440001';
|
||||
$academicYearId = '550e8400-e29b-41d4-a716-ff6655440002';
|
||||
|
||||
// Seed users
|
||||
$connection->executeStatement(
|
||||
"INSERT INTO users (id, tenant_id, email, hashed_password, first_name, last_name, roles, statut, created_at, updated_at)
|
||||
VALUES (:id, :tid, 'teacher-stats@test.local', '', 'Marc', 'Dupont', '[\"ROLE_PROF\"]', 'active', NOW(), NOW())
|
||||
ON CONFLICT (id) DO NOTHING",
|
||||
['id' => self::TEACHER_ID, 'tid' => self::TENANT_ID],
|
||||
);
|
||||
$connection->executeStatement(
|
||||
"INSERT INTO users (id, tenant_id, email, hashed_password, first_name, last_name, roles, statut, created_at, updated_at)
|
||||
VALUES (:id, :tid, 'student-stats1@test.local', '', 'Alice', 'Durand', '[\"ROLE_ELEVE\"]', 'active', NOW(), NOW())
|
||||
ON CONFLICT (id) DO NOTHING",
|
||||
['id' => self::STUDENT_ID, 'tid' => self::TENANT_ID],
|
||||
);
|
||||
$connection->executeStatement(
|
||||
"INSERT INTO users (id, tenant_id, email, hashed_password, first_name, last_name, roles, statut, created_at, updated_at)
|
||||
VALUES (:id, :tid, 'student-stats2@test.local', '', 'Bob', 'Martin', '[\"ROLE_ELEVE\"]', 'active', NOW(), NOW())
|
||||
ON CONFLICT (id) DO NOTHING",
|
||||
['id' => self::STUDENT2_ID, 'tid' => self::TENANT_ID],
|
||||
);
|
||||
|
||||
// Seed class and subject
|
||||
$connection->executeStatement(
|
||||
"INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, status, created_at, updated_at)
|
||||
VALUES (:id, :tid, :sid, :ayid, 'Stats-6eB', 'active', NOW(), NOW())
|
||||
ON CONFLICT (id) DO NOTHING",
|
||||
['id' => self::CLASS_ID, 'tid' => self::TENANT_ID, 'sid' => $schoolId, 'ayid' => $academicYearId],
|
||||
);
|
||||
$connection->executeStatement(
|
||||
"INSERT INTO subjects (id, tenant_id, school_id, name, code, status, created_at, updated_at)
|
||||
VALUES (:id, :tid, :sid, 'Mathématiques', 'MATH', 'active', NOW(), NOW())
|
||||
ON CONFLICT (id) DO NOTHING",
|
||||
['id' => self::SUBJECT_ID, 'tid' => self::TENANT_ID, 'sid' => $schoolId],
|
||||
);
|
||||
|
||||
// Seed academic period (must cover current date for queries to return data)
|
||||
// Clean up any conflicting rows first (unique constraint on tenant_id, academic_year_id, sequence)
|
||||
$connection->executeStatement(
|
||||
'DELETE FROM academic_periods WHERE tenant_id = :tid AND academic_year_id = :ayid AND sequence = 2',
|
||||
['tid' => self::TENANT_ID, 'ayid' => $academicYearId],
|
||||
);
|
||||
$connection->executeStatement(
|
||||
'DELETE FROM academic_periods WHERE id = :id',
|
||||
['id' => self::PERIOD_ID],
|
||||
);
|
||||
$connection->executeStatement(
|
||||
"INSERT INTO academic_periods (id, tenant_id, academic_year_id, period_type, sequence, label, start_date, end_date)
|
||||
VALUES (:id, :tid, :ayid, 'trimester', 2, 'Trimestre 2', '2026-01-01', '2026-06-30')",
|
||||
['id' => self::PERIOD_ID, 'tid' => self::TENANT_ID, 'ayid' => $academicYearId],
|
||||
);
|
||||
|
||||
// Seed teacher assignment (required for statistics reader queries)
|
||||
$connection->executeStatement(
|
||||
"INSERT INTO teacher_assignments (id, tenant_id, teacher_id, school_class_id, subject_id, academic_year_id, status, start_date, created_at, updated_at)
|
||||
VALUES (gen_random_uuid(), :tid, :teach, :class, :subj, :ayid, 'active', NOW(), NOW(), NOW())
|
||||
ON CONFLICT DO NOTHING",
|
||||
['tid' => self::TENANT_ID, 'teach' => self::TEACHER_ID, 'class' => self::CLASS_ID, 'subj' => self::SUBJECT_ID, 'ayid' => $academicYearId],
|
||||
);
|
||||
|
||||
// Seed student class assignments (class_assignments links students to classes)
|
||||
$connection->executeStatement(
|
||||
'INSERT INTO class_assignments (id, tenant_id, user_id, school_class_id, academic_year_id, created_at, updated_at)
|
||||
VALUES (gen_random_uuid(), :tid, :sid, :class, :ayid, NOW(), NOW())
|
||||
ON CONFLICT DO NOTHING',
|
||||
['tid' => self::TENANT_ID, 'sid' => self::STUDENT_ID, 'class' => self::CLASS_ID, 'ayid' => $academicYearId],
|
||||
);
|
||||
$connection->executeStatement(
|
||||
'INSERT INTO class_assignments (id, tenant_id, user_id, school_class_id, academic_year_id, created_at, updated_at)
|
||||
VALUES (gen_random_uuid(), :tid, :sid, :class, :ayid, NOW(), NOW())
|
||||
ON CONFLICT DO NOTHING',
|
||||
['tid' => self::TENANT_ID, 'sid' => self::STUDENT2_ID, 'class' => self::CLASS_ID, 'ayid' => $academicYearId],
|
||||
);
|
||||
|
||||
// Create and publish evaluations with grades
|
||||
/** @var EvaluationRepository $evalRepo */
|
||||
$evalRepo = $container->get(EvaluationRepository::class);
|
||||
/** @var GradeRepository $gradeRepo */
|
||||
$gradeRepo = $container->get(GradeRepository::class);
|
||||
|
||||
$eval = Evaluation::creer(
|
||||
tenantId: $tenantId,
|
||||
classId: ClassId::fromString(self::CLASS_ID),
|
||||
subjectId: SubjectId::fromString(self::SUBJECT_ID),
|
||||
teacherId: UserId::fromString(self::TEACHER_ID),
|
||||
title: 'DS Maths Stats',
|
||||
description: null,
|
||||
evaluationDate: new DateTimeImmutable('2026-03-15'),
|
||||
gradeScale: new GradeScale(20),
|
||||
coefficient: new Coefficient(1.0),
|
||||
now: $now,
|
||||
);
|
||||
$eval->publierNotes($now);
|
||||
$eval->pullDomainEvents();
|
||||
$evalRepo->save($eval);
|
||||
|
||||
foreach ([
|
||||
[self::STUDENT_ID, 15.0],
|
||||
[self::STUDENT2_ID, 8.0],
|
||||
] as [$studentId, $value]) {
|
||||
$grade = Grade::saisir(
|
||||
tenantId: $tenantId,
|
||||
evaluationId: $eval->id,
|
||||
studentId: UserId::fromString($studentId),
|
||||
value: new GradeValue($value),
|
||||
status: GradeStatus::GRADED,
|
||||
gradeScale: new GradeScale(20),
|
||||
createdBy: UserId::fromString(self::TEACHER_ID),
|
||||
now: $now,
|
||||
);
|
||||
$grade->pullDomainEvents();
|
||||
$gradeRepo->save($grade);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Functional\Shared\Infrastructure\Audit;
|
||||
|
||||
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
|
||||
use App\Shared\Application\Port\AuditLogger;
|
||||
use Doctrine\DBAL\Connection;
|
||||
|
||||
use const JSON_THROW_ON_ERROR;
|
||||
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
|
||||
/**
|
||||
* [P1] Functional tests for audit trail infrastructure.
|
||||
*
|
||||
* Verifies that the AuditLogger writes to the real audit_log table
|
||||
* and that entries contain correct metadata.
|
||||
*
|
||||
* @see NFR-S7: Audit trail immutable (qui, quoi, quand)
|
||||
* @see FR90: Tracage actions sensibles
|
||||
*/
|
||||
final class AuditTrailFunctionalTest extends ApiTestCase
|
||||
{
|
||||
protected static ?bool $alwaysBootKernel = true;
|
||||
|
||||
private Connection $connection;
|
||||
private AuditLogger $auditLogger;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
static::bootKernel();
|
||||
$container = static::getContainer();
|
||||
|
||||
/* @var Connection $connection */
|
||||
$this->connection = $container->get(Connection::class);
|
||||
|
||||
/* @var AuditLogger $auditLogger */
|
||||
$this->auditLogger = $container->get(AuditLogger::class);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function logAuthenticationWritesEntryToAuditLogTable(): void
|
||||
{
|
||||
$userId = Uuid::uuid4();
|
||||
|
||||
$this->auditLogger->logAuthentication(
|
||||
eventType: 'ConnexionReussie',
|
||||
userId: $userId,
|
||||
payload: [
|
||||
'email_hash' => hash('sha256', 'test@example.com'),
|
||||
'result' => 'success',
|
||||
'method' => 'password',
|
||||
],
|
||||
);
|
||||
|
||||
$entry = $this->connection->fetchAssociative(
|
||||
'SELECT * FROM audit_log WHERE aggregate_id = ? AND event_type = ? ORDER BY occurred_at DESC LIMIT 1',
|
||||
[$userId->toString(), 'ConnexionReussie'],
|
||||
);
|
||||
|
||||
self::assertNotFalse($entry, 'Audit log entry should exist after logAuthentication');
|
||||
self::assertSame('User', $entry['aggregate_type']);
|
||||
self::assertSame($userId->toString(), $entry['aggregate_id']);
|
||||
self::assertSame('ConnexionReussie', $entry['event_type']);
|
||||
|
||||
$payload = json_decode($entry['payload'], true, 512, JSON_THROW_ON_ERROR);
|
||||
self::assertSame('success', $payload['result']);
|
||||
self::assertSame('password', $payload['method']);
|
||||
self::assertArrayHasKey('email_hash', $payload);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function logAuthenticationIncludesMetadataWithTimestamp(): void
|
||||
{
|
||||
$userId = Uuid::uuid4();
|
||||
|
||||
$this->auditLogger->logAuthentication(
|
||||
eventType: 'ConnexionReussie',
|
||||
userId: $userId,
|
||||
payload: ['result' => 'success'],
|
||||
);
|
||||
|
||||
$entry = $this->connection->fetchAssociative(
|
||||
'SELECT * FROM audit_log WHERE aggregate_id = ? ORDER BY occurred_at DESC LIMIT 1',
|
||||
[$userId->toString()],
|
||||
);
|
||||
|
||||
self::assertNotFalse($entry);
|
||||
self::assertNotEmpty($entry['occurred_at'], 'Audit entry must have a timestamp');
|
||||
|
||||
$metadata = json_decode($entry['metadata'], true, 512, JSON_THROW_ON_ERROR);
|
||||
self::assertIsArray($metadata);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function logFailedAuthenticationWritesWithNullUserId(): void
|
||||
{
|
||||
$this->auditLogger->logAuthentication(
|
||||
eventType: 'ConnexionEchouee',
|
||||
userId: null,
|
||||
payload: [
|
||||
'email_hash' => hash('sha256', 'unknown@example.com'),
|
||||
'result' => 'failure',
|
||||
'reason' => 'invalid_credentials',
|
||||
],
|
||||
);
|
||||
|
||||
$entry = $this->connection->fetchAssociative(
|
||||
"SELECT * FROM audit_log WHERE event_type = 'ConnexionEchouee' ORDER BY occurred_at DESC LIMIT 1",
|
||||
);
|
||||
|
||||
self::assertNotFalse($entry, 'Failed login audit entry should exist');
|
||||
self::assertNull($entry['aggregate_id'], 'Failed login should have null user ID');
|
||||
self::assertSame('User', $entry['aggregate_type']);
|
||||
|
||||
$payload = json_decode($entry['payload'], true, 512, JSON_THROW_ON_ERROR);
|
||||
self::assertSame('failure', $payload['result']);
|
||||
self::assertSame('invalid_credentials', $payload['reason']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function logDataChangeWritesOldAndNewValues(): void
|
||||
{
|
||||
$aggregateId = Uuid::uuid4();
|
||||
|
||||
$this->auditLogger->logDataChange(
|
||||
aggregateType: 'Grade',
|
||||
aggregateId: $aggregateId,
|
||||
eventType: 'GradeModified',
|
||||
oldValues: ['value' => 14.0],
|
||||
newValues: ['value' => 16.0],
|
||||
reason: 'Correction erreur de saisie',
|
||||
);
|
||||
|
||||
$entry = $this->connection->fetchAssociative(
|
||||
'SELECT * FROM audit_log WHERE aggregate_id = ? AND event_type = ? ORDER BY occurred_at DESC LIMIT 1',
|
||||
[$aggregateId->toString(), 'GradeModified'],
|
||||
);
|
||||
|
||||
self::assertNotFalse($entry);
|
||||
self::assertSame('Grade', $entry['aggregate_type']);
|
||||
|
||||
$payload = json_decode($entry['payload'], true, 512, JSON_THROW_ON_ERROR);
|
||||
self::assertSame(['value' => 14.0], $payload['old_values']);
|
||||
self::assertSame(['value' => 16.0], $payload['new_values']);
|
||||
self::assertSame('Correction erreur de saisie', $payload['reason']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function auditLogEntriesAreAppendOnly(): void
|
||||
{
|
||||
$userId = Uuid::uuid4();
|
||||
|
||||
$this->auditLogger->logAuthentication(
|
||||
eventType: 'ConnexionReussie',
|
||||
userId: $userId,
|
||||
payload: ['result' => 'success'],
|
||||
);
|
||||
|
||||
$countBefore = (int) $this->connection->fetchOne(
|
||||
'SELECT COUNT(*) FROM audit_log WHERE aggregate_id = ?',
|
||||
[$userId->toString()],
|
||||
);
|
||||
|
||||
self::assertSame(1, $countBefore);
|
||||
|
||||
// Log a second event for the same user
|
||||
$this->auditLogger->logAuthentication(
|
||||
eventType: 'ConnexionReussie',
|
||||
userId: $userId,
|
||||
payload: ['result' => 'success'],
|
||||
);
|
||||
|
||||
$countAfter = (int) $this->connection->fetchOne(
|
||||
'SELECT COUNT(*) FROM audit_log WHERE aggregate_id = ?',
|
||||
[$userId->toString()],
|
||||
);
|
||||
|
||||
// Both entries should exist (append-only, no overwrite)
|
||||
self::assertSame(2, $countAfter, 'Audit log must be append-only — both entries should exist');
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,9 @@ use function sprintf;
|
||||
use Symfony\Component\HttpClient\HttpClient;
|
||||
|
||||
use function sys_get_temp_dir;
|
||||
|
||||
use Throwable;
|
||||
|
||||
use function unlink;
|
||||
|
||||
/**
|
||||
@@ -42,6 +45,16 @@ final class GouvFrCalendarApiTest extends TestCase
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
// Skip si l'API externe est injoignable (timeout réseau, DNS, etc.)
|
||||
try {
|
||||
$check = HttpClient::create()->request('GET', 'https://data.education.gouv.fr', [
|
||||
'timeout' => 5,
|
||||
]);
|
||||
$check->getStatusCode();
|
||||
} catch (Throwable) {
|
||||
self::markTestSkipped('API data.education.gouv.fr injoignable — test ignoré.');
|
||||
}
|
||||
|
||||
$this->tempDir = sys_get_temp_dir() . '/classeo-calendar-test-' . uniqid();
|
||||
mkdir($this->tempDir);
|
||||
|
||||
@@ -55,6 +68,10 @@ final class GouvFrCalendarApiTest extends TestCase
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
if (!isset($this->tempDir) || !is_dir($this->tempDir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Supprimer les fichiers générés
|
||||
$files = glob($this->tempDir . '/*.json');
|
||||
foreach ($files as $file) {
|
||||
|
||||
@@ -112,6 +112,14 @@ final class UploadSubmissionAttachmentHandlerTest extends TestCase
|
||||
public function delete(string $path): void
|
||||
{
|
||||
}
|
||||
|
||||
public function readStream(string $path): mixed
|
||||
{
|
||||
/** @var resource $stream */
|
||||
$stream = fopen('php://memory', 'r+');
|
||||
|
||||
return $stream;
|
||||
}
|
||||
};
|
||||
|
||||
$clock = new class implements Clock {
|
||||
|
||||
@@ -10,8 +10,11 @@ use App\Administration\Domain\Model\SchoolCalendar\CalendarEntryType;
|
||||
use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendar;
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemorySchoolCalendarRepository;
|
||||
use App\Scolarite\Application\Port\HomeworkRulesChecker;
|
||||
use App\Scolarite\Application\Port\HomeworkRulesCheckResult;
|
||||
use App\Scolarite\Application\Query\GetBlockedDates\GetBlockedDatesHandler;
|
||||
use App\Scolarite\Application\Query\GetBlockedDates\GetBlockedDatesQuery;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
@@ -28,7 +31,29 @@ final class GetBlockedDatesHandlerTest extends TestCase
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->calendarRepository = new InMemorySchoolCalendarRepository();
|
||||
$this->handler = new GetBlockedDatesHandler($this->calendarRepository);
|
||||
|
||||
$rulesChecker = new class implements HomeworkRulesChecker {
|
||||
public function verifier(
|
||||
TenantId $tenantId,
|
||||
DateTimeImmutable $dueDate,
|
||||
DateTimeImmutable $creationDate,
|
||||
): HomeworkRulesCheckResult {
|
||||
return HomeworkRulesCheckResult::ok();
|
||||
}
|
||||
};
|
||||
|
||||
$clock = new class implements Clock {
|
||||
public function now(): DateTimeImmutable
|
||||
{
|
||||
return new DateTimeImmutable('2026-03-01 10:00:00');
|
||||
}
|
||||
};
|
||||
|
||||
$this->handler = new GetBlockedDatesHandler(
|
||||
$this->calendarRepository,
|
||||
$rulesChecker,
|
||||
$clock,
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
@@ -110,6 +135,93 @@ final class GetBlockedDatesHandlerTest extends TestCase
|
||||
self::assertCount(5, $vacations);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function returnsRuleHardBlockedDates(): void
|
||||
{
|
||||
$rulesChecker = new class implements HomeworkRulesChecker {
|
||||
public function verifier(
|
||||
TenantId $tenantId,
|
||||
DateTimeImmutable $dueDate,
|
||||
DateTimeImmutable $creationDate,
|
||||
): HomeworkRulesCheckResult {
|
||||
// Block Tuesday March 3
|
||||
if ($dueDate->format('Y-m-d') === '2026-03-03') {
|
||||
return new HomeworkRulesCheckResult(
|
||||
warnings: [new \App\Scolarite\Application\Port\RuleWarning('minimum_delay', 'Délai minimum non respecté')],
|
||||
bloquant: true,
|
||||
);
|
||||
}
|
||||
|
||||
return HomeworkRulesCheckResult::ok();
|
||||
}
|
||||
};
|
||||
|
||||
$clock = new class implements Clock {
|
||||
public function now(): DateTimeImmutable
|
||||
{
|
||||
return new DateTimeImmutable('2026-03-01 10:00:00');
|
||||
}
|
||||
};
|
||||
|
||||
$handler = new GetBlockedDatesHandler($this->calendarRepository, $rulesChecker, $clock);
|
||||
|
||||
$result = ($handler)(new GetBlockedDatesQuery(
|
||||
tenantId: self::TENANT_ID,
|
||||
academicYearId: self::ACADEMIC_YEAR_ID,
|
||||
startDate: '2026-03-02',
|
||||
endDate: '2026-03-06',
|
||||
));
|
||||
|
||||
$ruleBlocked = array_filter($result, static fn ($d) => $d->type === 'rule_hard');
|
||||
self::assertCount(1, $ruleBlocked);
|
||||
$blocked = array_values($ruleBlocked)[0];
|
||||
self::assertSame('2026-03-03', $blocked->date);
|
||||
self::assertSame('Délai minimum non respecté', $blocked->reason);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function returnsRuleSoftWarningDates(): void
|
||||
{
|
||||
$rulesChecker = new class implements HomeworkRulesChecker {
|
||||
public function verifier(
|
||||
TenantId $tenantId,
|
||||
DateTimeImmutable $dueDate,
|
||||
DateTimeImmutable $creationDate,
|
||||
): HomeworkRulesCheckResult {
|
||||
if ($dueDate->format('Y-m-d') === '2026-03-04') {
|
||||
return new HomeworkRulesCheckResult(
|
||||
warnings: [new \App\Scolarite\Application\Port\RuleWarning('no_monday_after', 'Devoirs pour lundi déconseillés')],
|
||||
bloquant: false,
|
||||
);
|
||||
}
|
||||
|
||||
return HomeworkRulesCheckResult::ok();
|
||||
}
|
||||
};
|
||||
|
||||
$clock = new class implements Clock {
|
||||
public function now(): DateTimeImmutable
|
||||
{
|
||||
return new DateTimeImmutable('2026-03-01 10:00:00');
|
||||
}
|
||||
};
|
||||
|
||||
$handler = new GetBlockedDatesHandler($this->calendarRepository, $rulesChecker, $clock);
|
||||
|
||||
$result = ($handler)(new GetBlockedDatesQuery(
|
||||
tenantId: self::TENANT_ID,
|
||||
academicYearId: self::ACADEMIC_YEAR_ID,
|
||||
startDate: '2026-03-02',
|
||||
endDate: '2026-03-06',
|
||||
));
|
||||
|
||||
$ruleSoft = array_filter($result, static fn ($d) => $d->type === 'rule_soft');
|
||||
self::assertCount(1, $ruleSoft);
|
||||
$soft = array_values($ruleSoft)[0];
|
||||
self::assertSame('2026-03-04', $soft->date);
|
||||
self::assertSame('rule_soft', $soft->type);
|
||||
}
|
||||
|
||||
private function createCalendarWithHoliday(DateTimeImmutable $date, string $label): SchoolCalendar
|
||||
{
|
||||
$tenantId = TenantId::fromString(self::TENANT_ID);
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Scolarite\Application\Query\GetClassStatisticsDetail;
|
||||
|
||||
use App\Scolarite\Application\Port\PeriodFinder;
|
||||
use App\Scolarite\Application\Port\PeriodInfo;
|
||||
use App\Scolarite\Application\Query\GetClassStatisticsDetail\GetClassStatisticsDetailHandler;
|
||||
use App\Scolarite\Application\Query\GetClassStatisticsDetail\GetClassStatisticsDetailQuery;
|
||||
use App\Scolarite\Domain\Service\AverageCalculator;
|
||||
use App\Scolarite\Domain\Service\TeacherStatisticsCalculator;
|
||||
use App\Scolarite\Infrastructure\ReadModel\InMemoryTeacherStatisticsReader;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class GetClassStatisticsDetailHandlerTest extends TestCase
|
||||
{
|
||||
private const string TEACHER_ID = '550e8400-e29b-41d4-a716-446655440010';
|
||||
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||
private const string CLASS_ID = '550e8400-e29b-41d4-a716-446655440020';
|
||||
private const string SUBJECT_ID = '550e8400-e29b-41d4-a716-446655440030';
|
||||
|
||||
private InMemoryTeacherStatisticsReader $reader;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->reader = new InMemoryTeacherStatisticsReader();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsEmptyWhenNoPeriodFound(): void
|
||||
{
|
||||
$handler = $this->createHandler(periodInfo: null);
|
||||
|
||||
$result = $handler($this->query());
|
||||
|
||||
self::assertNull($result->average);
|
||||
self::assertSame(0.0, $result->successRate);
|
||||
self::assertSame([], $result->students);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itComputesClassStatisticsFromGrades(): void
|
||||
{
|
||||
$this->reader->feedClassGrades([8.0, 10.0, 12.0, 14.0, 16.0]);
|
||||
$this->reader->feedMonthlyAverages([
|
||||
['month' => '2026-01', 'average' => 11.0],
|
||||
['month' => '2026-02', 'average' => 12.5],
|
||||
]);
|
||||
$this->reader->feedStudentAverages([
|
||||
['studentId' => 's1', 'studentName' => 'Alice Dupont', 'average' => 14.0],
|
||||
['studentId' => 's2', 'studentName' => 'Bob Martin', 'average' => 7.0],
|
||||
]);
|
||||
|
||||
$handler = $this->createHandler(periodInfo: $this->currentPeriod());
|
||||
$result = $handler($this->query());
|
||||
|
||||
self::assertSame(12.0, $result->average);
|
||||
self::assertSame(80.0, $result->successRate); // 4/5 >= 10
|
||||
self::assertSame([0, 0, 0, 1, 2, 1, 1, 0], $result->distribution);
|
||||
self::assertCount(2, $result->evolution);
|
||||
self::assertCount(2, $result->students);
|
||||
self::assertFalse($result->students[0]->inDifficulty); // Alice 14.0 >= 8.0
|
||||
self::assertTrue($result->students[1]->inDifficulty); // Bob 7.0 < 8.0
|
||||
}
|
||||
|
||||
private function query(): GetClassStatisticsDetailQuery
|
||||
{
|
||||
return new GetClassStatisticsDetailQuery(
|
||||
teacherId: self::TEACHER_ID,
|
||||
classId: self::CLASS_ID,
|
||||
subjectId: self::SUBJECT_ID,
|
||||
tenantId: self::TENANT_ID,
|
||||
);
|
||||
}
|
||||
|
||||
private function createHandler(?PeriodInfo $periodInfo): GetClassStatisticsDetailHandler
|
||||
{
|
||||
$periodFinder = new class($periodInfo) implements PeriodFinder {
|
||||
public function __construct(private readonly ?PeriodInfo $info)
|
||||
{
|
||||
}
|
||||
|
||||
public function findForDate(DateTimeImmutable $date, TenantId $tenantId): ?PeriodInfo
|
||||
{
|
||||
return $this->info;
|
||||
}
|
||||
};
|
||||
|
||||
return new GetClassStatisticsDetailHandler(
|
||||
$this->reader,
|
||||
$periodFinder,
|
||||
new TeacherStatisticsCalculator(),
|
||||
new AverageCalculator(),
|
||||
);
|
||||
}
|
||||
|
||||
private function currentPeriod(): PeriodInfo
|
||||
{
|
||||
return new PeriodInfo(
|
||||
periodId: 'period-1',
|
||||
startDate: new DateTimeImmutable('2026-01-05'),
|
||||
endDate: new DateTimeImmutable('2026-03-31'),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Scolarite\Application\Query\GetEvaluationDifficulty;
|
||||
|
||||
use App\Scolarite\Application\Query\GetEvaluationDifficulty\GetEvaluationDifficultyHandler;
|
||||
use App\Scolarite\Application\Query\GetEvaluationDifficulty\GetEvaluationDifficultyQuery;
|
||||
use App\Scolarite\Domain\Service\TeacherStatisticsCalculator;
|
||||
use App\Scolarite\Infrastructure\ReadModel\InMemoryTeacherStatisticsReader;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class GetEvaluationDifficultyHandlerTest extends TestCase
|
||||
{
|
||||
private const string TEACHER_ID = '550e8400-e29b-41d4-a716-446655440010';
|
||||
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||
|
||||
private InMemoryTeacherStatisticsReader $reader;
|
||||
private GetEvaluationDifficultyHandler $handler;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->reader = new InMemoryTeacherStatisticsReader();
|
||||
$this->handler = new GetEvaluationDifficultyHandler(
|
||||
$this->reader,
|
||||
new TeacherStatisticsCalculator(),
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsEmptyWhenNoEvaluations(): void
|
||||
{
|
||||
$result = ($this->handler)($this->query());
|
||||
|
||||
self::assertSame([], $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsEvaluationDifficultyWithComparison(): void
|
||||
{
|
||||
$this->reader->feedEvaluationDifficulties([
|
||||
[
|
||||
'evaluationId' => 'eval-1',
|
||||
'title' => 'Contrôle chapitre 5',
|
||||
'classId' => 'class-1',
|
||||
'className' => '6ème A',
|
||||
'subjectId' => 'subject-1',
|
||||
'subjectName' => 'Mathématiques',
|
||||
'date' => '2026-03-15',
|
||||
'average' => 12.0,
|
||||
'gradedCount' => 25,
|
||||
],
|
||||
]);
|
||||
|
||||
// Other teachers' averages for same subject
|
||||
$this->reader->feedOtherTeachersAverages([10.0, 11.0, 13.0]);
|
||||
|
||||
$result = ($this->handler)($this->query());
|
||||
|
||||
self::assertCount(1, $result);
|
||||
self::assertSame('Contrôle chapitre 5', $result[0]->title);
|
||||
self::assertSame(12.0, $result[0]->average);
|
||||
self::assertEqualsWithDelta(11.33, $result[0]->subjectAverage, 0.01);
|
||||
self::assertNotNull($result[0]->percentile);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itHandlesNoOtherTeachersForComparison(): void
|
||||
{
|
||||
$this->reader->feedEvaluationDifficulties([
|
||||
[
|
||||
'evaluationId' => 'eval-1',
|
||||
'title' => 'Test unique',
|
||||
'classId' => 'class-1',
|
||||
'className' => '6ème A',
|
||||
'subjectId' => 'subject-1',
|
||||
'subjectName' => 'Musique',
|
||||
'date' => '2026-03-15',
|
||||
'average' => 14.0,
|
||||
'gradedCount' => 20,
|
||||
],
|
||||
]);
|
||||
|
||||
$this->reader->feedOtherTeachersAverages([]);
|
||||
|
||||
$result = ($this->handler)($this->query());
|
||||
|
||||
self::assertCount(1, $result);
|
||||
self::assertNull($result[0]->subjectAverage);
|
||||
self::assertNull($result[0]->percentile);
|
||||
}
|
||||
|
||||
private function query(): GetEvaluationDifficultyQuery
|
||||
{
|
||||
return new GetEvaluationDifficultyQuery(
|
||||
teacherId: self::TEACHER_ID,
|
||||
tenantId: self::TENANT_ID,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Scolarite\Application\Query\GetStudentProgression;
|
||||
|
||||
use App\Scolarite\Application\Query\GetStudentProgression\GetStudentProgressionHandler;
|
||||
use App\Scolarite\Application\Query\GetStudentProgression\GetStudentProgressionQuery;
|
||||
use App\Scolarite\Domain\Service\TeacherStatisticsCalculator;
|
||||
use App\Scolarite\Infrastructure\ReadModel\InMemoryTeacherStatisticsReader;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class GetStudentProgressionHandlerTest extends TestCase
|
||||
{
|
||||
private InMemoryTeacherStatisticsReader $reader;
|
||||
private GetStudentProgressionHandler $handler;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->reader = new InMemoryTeacherStatisticsReader();
|
||||
$this->handler = new GetStudentProgressionHandler(
|
||||
$this->reader,
|
||||
new TeacherStatisticsCalculator(),
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsEmptyProgressionWhenNoGrades(): void
|
||||
{
|
||||
$result = ($this->handler)($this->query());
|
||||
|
||||
self::assertSame([], $result->grades);
|
||||
self::assertNull($result->trendLine);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsSingleGradeWithNoTrendLine(): void
|
||||
{
|
||||
$this->reader->feedGradeHistory([
|
||||
['date' => '2026-01-15', 'value' => 12.0, 'evaluationTitle' => 'Contrôle 1'],
|
||||
]);
|
||||
|
||||
$result = ($this->handler)($this->query());
|
||||
|
||||
self::assertCount(1, $result->grades);
|
||||
self::assertSame('2026-01-15', $result->grades[0]->date);
|
||||
self::assertSame(12.0, $result->grades[0]->value);
|
||||
self::assertNull($result->trendLine);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itComputesTrendLineFromMultipleGrades(): void
|
||||
{
|
||||
$this->reader->feedGradeHistory([
|
||||
['date' => '2026-01-15', 'value' => 10.0, 'evaluationTitle' => 'Contrôle 1'],
|
||||
['date' => '2026-02-10', 'value' => 12.0, 'evaluationTitle' => 'Contrôle 2'],
|
||||
['date' => '2026-03-05', 'value' => 14.0, 'evaluationTitle' => 'Contrôle 3'],
|
||||
]);
|
||||
|
||||
$result = ($this->handler)($this->query());
|
||||
|
||||
self::assertCount(3, $result->grades);
|
||||
self::assertNotNull($result->trendLine);
|
||||
self::assertGreaterThan(0, $result->trendLine->slope); // Positive trend
|
||||
}
|
||||
|
||||
private function query(): GetStudentProgressionQuery
|
||||
{
|
||||
return new GetStudentProgressionQuery(
|
||||
studentId: '550e8400-e29b-41d4-a716-446655440050',
|
||||
subjectId: '550e8400-e29b-41d4-a716-446655440030',
|
||||
classId: '550e8400-e29b-41d4-a716-446655440020',
|
||||
teacherId: '550e8400-e29b-41d4-a716-446655440010',
|
||||
tenantId: '550e8400-e29b-41d4-a716-446655440001',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Scolarite\Application\Query\GetTeacherStatisticsOverview;
|
||||
|
||||
use App\Scolarite\Application\Port\PeriodFinder;
|
||||
use App\Scolarite\Application\Port\PeriodInfo;
|
||||
use App\Scolarite\Application\Query\GetTeacherStatisticsOverview\GetTeacherStatisticsOverviewHandler;
|
||||
use App\Scolarite\Application\Query\GetTeacherStatisticsOverview\GetTeacherStatisticsOverviewQuery;
|
||||
use App\Scolarite\Infrastructure\ReadModel\InMemoryTeacherStatisticsReader;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class GetTeacherStatisticsOverviewHandlerTest extends TestCase
|
||||
{
|
||||
private const string TEACHER_ID = '550e8400-e29b-41d4-a716-446655440010';
|
||||
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||
|
||||
private InMemoryTeacherStatisticsReader $reader;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->reader = new InMemoryTeacherStatisticsReader();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsEmptyWhenNoPeriodFound(): void
|
||||
{
|
||||
$handler = $this->createHandler(periodInfo: null);
|
||||
|
||||
$result = $handler(new GetTeacherStatisticsOverviewQuery(
|
||||
teacherId: self::TEACHER_ID,
|
||||
tenantId: self::TENANT_ID,
|
||||
));
|
||||
|
||||
self::assertSame([], $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsClassOverviewDtos(): void
|
||||
{
|
||||
$this->reader->feedClassesSummary([
|
||||
[
|
||||
'classId' => 'class-1',
|
||||
'className' => '6ème A',
|
||||
'subjectId' => 'subject-1',
|
||||
'subjectName' => 'Mathématiques',
|
||||
'evaluationCount' => 3,
|
||||
'studentCount' => 25,
|
||||
'average' => 12.5,
|
||||
'successRate' => 72.0,
|
||||
],
|
||||
[
|
||||
'classId' => 'class-2',
|
||||
'className' => '5ème B',
|
||||
'subjectId' => 'subject-1',
|
||||
'subjectName' => 'Mathématiques',
|
||||
'evaluationCount' => 2,
|
||||
'studentCount' => 28,
|
||||
'average' => 10.8,
|
||||
'successRate' => 57.0,
|
||||
],
|
||||
]);
|
||||
|
||||
$handler = $this->createHandler(periodInfo: $this->currentPeriod());
|
||||
|
||||
$result = $handler(new GetTeacherStatisticsOverviewQuery(
|
||||
teacherId: self::TEACHER_ID,
|
||||
tenantId: self::TENANT_ID,
|
||||
));
|
||||
|
||||
self::assertCount(2, $result);
|
||||
self::assertSame('6ème A', $result[0]->className);
|
||||
self::assertSame(12.5, $result[0]->average);
|
||||
self::assertSame(72.0, $result[0]->successRate);
|
||||
self::assertSame('5ème B', $result[1]->className);
|
||||
}
|
||||
|
||||
private function createHandler(?PeriodInfo $periodInfo): GetTeacherStatisticsOverviewHandler
|
||||
{
|
||||
$periodFinder = new class($periodInfo) implements PeriodFinder {
|
||||
public function __construct(private readonly ?PeriodInfo $info)
|
||||
{
|
||||
}
|
||||
|
||||
public function findForDate(DateTimeImmutable $date, TenantId $tenantId): ?PeriodInfo
|
||||
{
|
||||
return $this->info;
|
||||
}
|
||||
};
|
||||
|
||||
return new GetTeacherStatisticsOverviewHandler($this->reader, $periodFinder);
|
||||
}
|
||||
|
||||
private function currentPeriod(): PeriodInfo
|
||||
{
|
||||
return new PeriodInfo(
|
||||
periodId: 'period-1',
|
||||
startDate: new DateTimeImmutable('2026-01-05'),
|
||||
endDate: new DateTimeImmutable('2026-03-31'),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Scolarite\Application\Service;
|
||||
|
||||
use App\Scolarite\Application\Query\GetClassStatisticsDetail\ClassStatisticsDetailDto;
|
||||
use App\Scolarite\Application\Query\GetClassStatisticsDetail\StudentAverageDto;
|
||||
use App\Scolarite\Application\Service\StatisticsExporter;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
use function str_contains;
|
||||
|
||||
final class StatisticsExporterTest extends TestCase
|
||||
{
|
||||
private StatisticsExporter $exporter;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->exporter = new StatisticsExporter();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itExportsClassStatisticsToCsv(): void
|
||||
{
|
||||
$stats = new ClassStatisticsDetailDto(
|
||||
average: 12.5,
|
||||
successRate: 72.0,
|
||||
distribution: [0, 0, 1, 2, 3, 2, 1, 0],
|
||||
evolution: [],
|
||||
students: [
|
||||
new StudentAverageDto(
|
||||
studentId: 's1',
|
||||
studentName: 'Alice Dupont',
|
||||
average: 14.0,
|
||||
inDifficulty: false,
|
||||
trend: 'improving',
|
||||
),
|
||||
new StudentAverageDto(
|
||||
studentId: 's2',
|
||||
studentName: 'Bob Martin',
|
||||
average: 7.0,
|
||||
inDifficulty: true,
|
||||
trend: 'declining',
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
$csv = $this->exporter->exportClassToCsv($stats, '6ème A', 'Mathématiques');
|
||||
|
||||
self::assertNotSame('', $csv);
|
||||
self::assertTrue(str_contains($csv, '6ème A'));
|
||||
self::assertTrue(str_contains($csv, 'Mathématiques'));
|
||||
self::assertTrue(str_contains($csv, '12.5'));
|
||||
self::assertTrue(str_contains($csv, '72%'));
|
||||
self::assertTrue(str_contains($csv, 'Alice Dupont'));
|
||||
self::assertTrue(str_contains($csv, '14'));
|
||||
self::assertTrue(str_contains($csv, 'Progression'));
|
||||
self::assertTrue(str_contains($csv, 'Bob Martin'));
|
||||
self::assertTrue(str_contains($csv, 'Oui'));
|
||||
self::assertTrue(str_contains($csv, 'Régression'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itHandlesEmptyStudentList(): void
|
||||
{
|
||||
$stats = new ClassStatisticsDetailDto(
|
||||
average: null,
|
||||
successRate: 0.0,
|
||||
distribution: [0, 0, 0, 0, 0, 0, 0, 0],
|
||||
evolution: [],
|
||||
students: [],
|
||||
);
|
||||
|
||||
$csv = $this->exporter->exportClassToCsv($stats, '5ème B', 'Français');
|
||||
|
||||
self::assertNotSame('', $csv);
|
||||
self::assertTrue(str_contains($csv, '5ème B'));
|
||||
self::assertTrue(str_contains($csv, 'N/A'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Scolarite\Domain\Service;
|
||||
|
||||
use App\Scolarite\Domain\Service\TeacherStatisticsCalculator;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class TeacherStatisticsCalculatorTest extends TestCase
|
||||
{
|
||||
private TeacherStatisticsCalculator $calculator;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->calculator = new TeacherStatisticsCalculator();
|
||||
}
|
||||
|
||||
// --- Distribution ---
|
||||
|
||||
#[Test]
|
||||
public function distributionReturnsEmptyBinsWhenNoValues(): void
|
||||
{
|
||||
$bins = $this->calculator->calculateDistribution([]);
|
||||
|
||||
self::assertSame([0, 0, 0, 0, 0, 0, 0, 0], $bins);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function distributionPlacesValuesInCorrectBins(): void
|
||||
{
|
||||
// Bins: [0-2.5[, [2.5-5[, [5-7.5[, [7.5-10[, [10-12.5[, [12.5-15[, [15-17.5[, [17.5-20]
|
||||
$values = [1.0, 3.0, 6.0, 9.0, 11.0, 14.0, 16.0, 19.0];
|
||||
$bins = $this->calculator->calculateDistribution($values);
|
||||
|
||||
self::assertSame([1, 1, 1, 1, 1, 1, 1, 1], $bins);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function distributionHandlesMaxValue20InLastBin(): void
|
||||
{
|
||||
$bins = $this->calculator->calculateDistribution([20.0]);
|
||||
|
||||
self::assertSame([0, 0, 0, 0, 0, 0, 0, 1], $bins);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function distributionHandlesMultipleValuesInSameBin(): void
|
||||
{
|
||||
$values = [10.0, 10.5, 11.0, 12.0];
|
||||
$bins = $this->calculator->calculateDistribution($values);
|
||||
|
||||
// All in bin [10-12.5[
|
||||
self::assertSame([0, 0, 0, 0, 4, 0, 0, 0], $bins);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function distributionHandlesBoundaryValues(): void
|
||||
{
|
||||
$values = [0.0, 2.5, 5.0, 7.5, 10.0, 12.5, 15.0, 17.5];
|
||||
$bins = $this->calculator->calculateDistribution($values);
|
||||
|
||||
// 0.0 → bin 0, 2.5 → bin 1, 5.0 → bin 2, 7.5 → bin 3
|
||||
// 10.0 → bin 4, 12.5 → bin 5, 15.0 → bin 6, 17.5 → bin 7
|
||||
self::assertSame([1, 1, 1, 1, 1, 1, 1, 1], $bins);
|
||||
}
|
||||
|
||||
// --- Success Rate ---
|
||||
|
||||
#[Test]
|
||||
public function successRateReturnsZeroWhenNoValues(): void
|
||||
{
|
||||
self::assertSame(0.0, $this->calculator->calculateSuccessRate([]));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function successRateCountsValuesAboveThreshold(): void
|
||||
{
|
||||
// Threshold defaults to 10.0
|
||||
$values = [8.0, 10.0, 12.0, 14.0, 6.0];
|
||||
|
||||
// 10.0, 12.0, 14.0 are >= 10 → 3/5 = 60%
|
||||
self::assertSame(60.0, $this->calculator->calculateSuccessRate($values));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function successRateWithCustomThreshold(): void
|
||||
{
|
||||
$values = [8.0, 10.0, 12.0, 14.0, 6.0];
|
||||
|
||||
// >= 12: 12.0, 14.0 → 2/5 = 40%
|
||||
self::assertSame(40.0, $this->calculator->calculateSuccessRate($values, 12.0));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function successRateWithAllAbove(): void
|
||||
{
|
||||
$values = [15.0, 18.0, 12.0];
|
||||
|
||||
self::assertSame(100.0, $this->calculator->calculateSuccessRate($values));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function successRateWithNoneAbove(): void
|
||||
{
|
||||
$values = [4.0, 6.0, 8.0];
|
||||
|
||||
self::assertSame(0.0, $this->calculator->calculateSuccessRate($values));
|
||||
}
|
||||
|
||||
// --- Trend Line ---
|
||||
|
||||
#[Test]
|
||||
public function trendLineReturnsNullWhenLessThanTwoPoints(): void
|
||||
{
|
||||
self::assertNull($this->calculator->calculateTrendLine([]));
|
||||
self::assertNull($this->calculator->calculateTrendLine([[1, 10.0]]));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function trendLineCalculatesLinearRegression(): void
|
||||
{
|
||||
// Perfectly increasing line: y = 2x + 8
|
||||
$points = [[1, 10.0], [2, 12.0], [3, 14.0]];
|
||||
|
||||
$result = $this->calculator->calculateTrendLine($points);
|
||||
|
||||
self::assertNotNull($result);
|
||||
self::assertEqualsWithDelta(2.0, $result->slope, 0.01);
|
||||
self::assertEqualsWithDelta(8.0, $result->intercept, 0.01);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function trendLineWithFlatData(): void
|
||||
{
|
||||
$points = [[1, 12.0], [2, 12.0], [3, 12.0]];
|
||||
|
||||
$result = $this->calculator->calculateTrendLine($points);
|
||||
|
||||
self::assertNotNull($result);
|
||||
self::assertEqualsWithDelta(0.0, $result->slope, 0.01);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function trendLineWithDecreasingData(): void
|
||||
{
|
||||
$points = [[1, 16.0], [2, 14.0], [3, 12.0]];
|
||||
|
||||
$result = $this->calculator->calculateTrendLine($points);
|
||||
|
||||
self::assertNotNull($result);
|
||||
self::assertLessThan(0, $result->slope);
|
||||
}
|
||||
|
||||
// --- Detect Trend ---
|
||||
|
||||
#[Test]
|
||||
public function detectTrendReturnsStableWhenLessThanTwoAverages(): void
|
||||
{
|
||||
self::assertSame('stable', $this->calculator->detectTrend([]));
|
||||
self::assertSame('stable', $this->calculator->detectTrend([12.0]));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function detectTrendReturnsImprovingWhenLastIsHigherByThreshold(): void
|
||||
{
|
||||
// Last - first > 1.0 (default threshold)
|
||||
self::assertSame('improving', $this->calculator->detectTrend([10.0, 11.5, 13.0]));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function detectTrendReturnsDecliningWhenLastIsLowerByThreshold(): void
|
||||
{
|
||||
self::assertSame('declining', $this->calculator->detectTrend([14.0, 12.0, 10.0]));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function detectTrendReturnsStableWhenDifferenceIsBelowThreshold(): void
|
||||
{
|
||||
self::assertSame('stable', $this->calculator->detectTrend([12.0, 12.5, 12.8]));
|
||||
}
|
||||
|
||||
// --- Percentile ---
|
||||
|
||||
#[Test]
|
||||
public function percentileReturnsHundredWhenNoOtherValues(): void
|
||||
{
|
||||
self::assertSame(100.0, $this->calculator->calculatePercentile(12.0, []));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function percentileCalculatesCorrectly(): void
|
||||
{
|
||||
// My value: 14.0, others: [10, 12, 16, 18]
|
||||
// 2 out of 4 are below → 50th percentile
|
||||
self::assertSame(50.0, $this->calculator->calculatePercentile(14.0, [10.0, 12.0, 16.0, 18.0]));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function percentileAtBottom(): void
|
||||
{
|
||||
self::assertSame(0.0, $this->calculator->calculatePercentile(5.0, [10.0, 12.0, 14.0]));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function percentileAtTop(): void
|
||||
{
|
||||
self::assertSame(100.0, $this->calculator->calculatePercentile(20.0, [10.0, 12.0, 14.0]));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function percentileWithDuplicateValues(): void
|
||||
{
|
||||
// My value: 12.0, others: [12.0, 12.0, 12.0]
|
||||
// 0 strictly below → 0th percentile
|
||||
self::assertSame(0.0, $this->calculator->calculatePercentile(12.0, [12.0, 12.0, 12.0]));
|
||||
}
|
||||
|
||||
// --- Detect Trend edge cases ---
|
||||
|
||||
#[Test]
|
||||
public function detectTrendAtExactThresholdReturnsStable(): void
|
||||
{
|
||||
// Difference is exactly 1.0 → should be stable (not strictly above threshold)
|
||||
self::assertSame('stable', $this->calculator->detectTrend([10.0, 11.0]));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function detectTrendJustAboveThresholdReturnsImproving(): void
|
||||
{
|
||||
// Difference is 1.01 → should be improving
|
||||
self::assertSame('improving', $this->calculator->detectTrend([10.0, 11.01]));
|
||||
}
|
||||
|
||||
// --- Distribution edge cases ---
|
||||
|
||||
#[Test]
|
||||
public function distributionWithSingleValue(): void
|
||||
{
|
||||
$bins = $this->calculator->calculateDistribution([10.0]);
|
||||
|
||||
self::assertSame([0, 0, 0, 0, 1, 0, 0, 0], $bins);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function distributionWithAllSameValues(): void
|
||||
{
|
||||
$values = [12.0, 12.0, 12.0, 12.0, 12.0];
|
||||
$bins = $this->calculator->calculateDistribution($values);
|
||||
|
||||
// All in bin [10-12.5[
|
||||
self::assertSame([0, 0, 0, 0, 5, 0, 0, 0], $bins);
|
||||
}
|
||||
|
||||
// --- Trend Line edge cases ---
|
||||
|
||||
#[Test]
|
||||
public function trendLineWithTwoPointsExact(): void
|
||||
{
|
||||
$points = [[1, 10.0], [2, 14.0]];
|
||||
|
||||
$result = $this->calculator->calculateTrendLine($points);
|
||||
|
||||
self::assertNotNull($result);
|
||||
self::assertEqualsWithDelta(4.0, $result->slope, 0.01);
|
||||
self::assertEqualsWithDelta(6.0, $result->intercept, 0.01);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function trendLineWithNoisyData(): void
|
||||
{
|
||||
// Noisy but overall increasing
|
||||
$points = [[1, 8.0], [2, 12.0], [3, 10.0], [4, 14.0], [5, 13.0]];
|
||||
|
||||
$result = $this->calculator->calculateTrendLine($points);
|
||||
|
||||
self::assertNotNull($result);
|
||||
self::assertGreaterThan(0, $result->slope);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Scolarite\Infrastructure\Api\Controller;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Domain\Model\Subject\SubjectId;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Administration\Infrastructure\Security\SecurityUser;
|
||||
use App\Scolarite\Application\Command\UploadHomeworkAttachment\UploadHomeworkAttachmentHandler;
|
||||
use App\Scolarite\Application\Port\FileStorage;
|
||||
use App\Scolarite\Domain\Model\Homework\Homework;
|
||||
use App\Scolarite\Domain\Model\Homework\HomeworkAttachment;
|
||||
use App\Scolarite\Domain\Model\Homework\HomeworkAttachmentId;
|
||||
use App\Scolarite\Domain\Repository\HomeworkRepository;
|
||||
use App\Scolarite\Infrastructure\Api\Controller\HomeworkAttachmentController;
|
||||
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryHomeworkAttachmentRepository;
|
||||
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryHomeworkRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use App\Tests\Unit\Scolarite\Infrastructure\Storage\InMemoryFileStorage;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
final class HomeworkAttachmentControllerTest extends TestCase
|
||||
{
|
||||
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||
private const string TEACHER_ID = '550e8400-e29b-41d4-a716-446655440010';
|
||||
private const string OTHER_TEACHER_ID = '550e8400-e29b-41d4-a716-446655440099';
|
||||
|
||||
private InMemoryHomeworkRepository $homeworkRepository;
|
||||
private InMemoryHomeworkAttachmentRepository $attachmentRepository;
|
||||
private InMemoryFileStorage $fileStorage;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->homeworkRepository = new InMemoryHomeworkRepository();
|
||||
$this->attachmentRepository = new InMemoryHomeworkAttachmentRepository();
|
||||
$this->fileStorage = new InMemoryFileStorage();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function downloadReturnsStreamedResponseForExistingAttachment(): void
|
||||
{
|
||||
$homework = $this->createHomework();
|
||||
$this->homeworkRepository->save($homework);
|
||||
|
||||
$attachment = $this->createAttachment('exercices.pdf', 'homework/files/exercices.pdf');
|
||||
$this->attachmentRepository->save($homework->id, $attachment);
|
||||
$this->fileStorage->upload('homework/files/exercices.pdf', 'PDF content here', 'application/pdf');
|
||||
|
||||
$controller = $this->createController(self::TEACHER_ID);
|
||||
|
||||
$response = $controller->download((string) $homework->id, (string) $attachment->id);
|
||||
|
||||
self::assertInstanceOf(StreamedResponse::class, $response);
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
self::assertSame('application/pdf', $response->headers->get('Content-Type'));
|
||||
self::assertStringContainsString('exercices.pdf', $response->headers->get('Content-Disposition') ?? '');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function downloadReturns404ForNonExistentAttachment(): void
|
||||
{
|
||||
$homework = $this->createHomework();
|
||||
$this->homeworkRepository->save($homework);
|
||||
|
||||
$controller = $this->createController(self::TEACHER_ID);
|
||||
|
||||
$this->expectException(NotFoundHttpException::class);
|
||||
|
||||
$controller->download((string) $homework->id, 'non-existent-attachment-id');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function downloadReturns404WhenFileNotFoundInStorage(): void
|
||||
{
|
||||
$homework = $this->createHomework();
|
||||
$this->homeworkRepository->save($homework);
|
||||
|
||||
$attachment = $this->createAttachment('missing.pdf', 'homework/files/missing.pdf');
|
||||
$this->attachmentRepository->save($homework->id, $attachment);
|
||||
// File NOT uploaded to storage — simulates a missing blob
|
||||
|
||||
$controller = $this->createController(self::TEACHER_ID);
|
||||
|
||||
$this->expectException(NotFoundHttpException::class);
|
||||
|
||||
$controller->download((string) $homework->id, (string) $attachment->id);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function downloadDeniesAccessToNonOwnerTeacher(): void
|
||||
{
|
||||
$homework = $this->createHomework();
|
||||
$this->homeworkRepository->save($homework);
|
||||
|
||||
$attachment = $this->createAttachment('exercices.pdf', 'homework/files/exercices.pdf');
|
||||
$this->attachmentRepository->save($homework->id, $attachment);
|
||||
|
||||
$controller = $this->createController(self::OTHER_TEACHER_ID);
|
||||
|
||||
$this->expectException(AccessDeniedHttpException::class);
|
||||
|
||||
$controller->download((string) $homework->id, (string) $attachment->id);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function listDeniesAccessToNonOwnerTeacher(): void
|
||||
{
|
||||
$homework = $this->createHomework();
|
||||
$this->homeworkRepository->save($homework);
|
||||
|
||||
$controller = $this->createController(self::OTHER_TEACHER_ID);
|
||||
|
||||
$this->expectException(AccessDeniedHttpException::class);
|
||||
|
||||
$controller->list((string) $homework->id);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function deleteDeniesAccessToNonOwnerTeacher(): void
|
||||
{
|
||||
$homework = $this->createHomework();
|
||||
$this->homeworkRepository->save($homework);
|
||||
|
||||
$attachment = $this->createAttachment('exercices.pdf', 'homework/files/exercices.pdf');
|
||||
$this->attachmentRepository->save($homework->id, $attachment);
|
||||
|
||||
$controller = $this->createController(self::OTHER_TEACHER_ID);
|
||||
|
||||
$this->expectException(AccessDeniedHttpException::class);
|
||||
|
||||
$controller->delete((string) $homework->id, (string) $attachment->id);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function downloadDeniesAccessToUnauthenticatedUser(): void
|
||||
{
|
||||
$homework = $this->createHomework();
|
||||
$this->homeworkRepository->save($homework);
|
||||
|
||||
$controller = $this->createControllerWithoutUser();
|
||||
|
||||
$this->expectException(AccessDeniedHttpException::class);
|
||||
|
||||
$controller->download((string) $homework->id, 'any-attachment-id');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function listReturnsAttachmentsForOwner(): void
|
||||
{
|
||||
$homework = $this->createHomework();
|
||||
$this->homeworkRepository->save($homework);
|
||||
|
||||
$attachment = $this->createAttachment('exercices.pdf', 'homework/files/exercices.pdf');
|
||||
$this->attachmentRepository->save($homework->id, $attachment);
|
||||
|
||||
$controller = $this->createController(self::TEACHER_ID);
|
||||
|
||||
$response = $controller->list((string) $homework->id);
|
||||
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
/** @var array<array{id: string, filename: string}> $data */
|
||||
$data = json_decode((string) $response->getContent(), true);
|
||||
self::assertCount(1, $data);
|
||||
self::assertSame('exercices.pdf', $data[0]['filename']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function deleteRemovesAttachmentAndFile(): void
|
||||
{
|
||||
$homework = $this->createHomework();
|
||||
$this->homeworkRepository->save($homework);
|
||||
|
||||
$attachment = $this->createAttachment('exercices.pdf', 'homework/files/exercices.pdf');
|
||||
$this->attachmentRepository->save($homework->id, $attachment);
|
||||
$this->fileStorage->upload('homework/files/exercices.pdf', 'content', 'application/pdf');
|
||||
|
||||
$controller = $this->createController(self::TEACHER_ID);
|
||||
$response = $controller->delete((string) $homework->id, (string) $attachment->id);
|
||||
|
||||
self::assertSame(204, $response->getStatusCode());
|
||||
self::assertEmpty($this->attachmentRepository->findByHomeworkId($homework->id));
|
||||
self::assertFalse($this->fileStorage->has('homework/files/exercices.pdf'));
|
||||
}
|
||||
|
||||
private function createHomework(): Homework
|
||||
{
|
||||
return Homework::creer(
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
classId: ClassId::fromString('550e8400-e29b-41d4-a716-446655440020'),
|
||||
subjectId: SubjectId::fromString('550e8400-e29b-41d4-a716-446655440030'),
|
||||
teacherId: UserId::fromString(self::TEACHER_ID),
|
||||
title: 'Devoir test',
|
||||
description: 'Description',
|
||||
dueDate: new DateTimeImmutable('2026-05-01'),
|
||||
now: new DateTimeImmutable('2026-04-09'),
|
||||
);
|
||||
}
|
||||
|
||||
private function createAttachment(string $filename, string $filePath): HomeworkAttachment
|
||||
{
|
||||
return new HomeworkAttachment(
|
||||
id: HomeworkAttachmentId::generate(),
|
||||
filename: $filename,
|
||||
filePath: $filePath,
|
||||
fileSize: 5000,
|
||||
mimeType: 'application/pdf',
|
||||
uploadedAt: new DateTimeImmutable('2026-04-09'),
|
||||
);
|
||||
}
|
||||
|
||||
private function createController(string $teacherId): HomeworkAttachmentController
|
||||
{
|
||||
$securityUser = new SecurityUser(
|
||||
userId: UserId::fromString($teacherId),
|
||||
email: 'teacher@example.com',
|
||||
hashedPassword: 'hashed',
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
roles: ['ROLE_PROF'],
|
||||
);
|
||||
|
||||
$security = $this->createMock(Security::class);
|
||||
$security->method('getUser')->willReturn($securityUser);
|
||||
|
||||
$uploadHandler = $this->createUploadHandler($this->homeworkRepository, $this->fileStorage);
|
||||
|
||||
return new HomeworkAttachmentController(
|
||||
security: $security,
|
||||
homeworkRepository: $this->homeworkRepository,
|
||||
attachmentRepository: $this->attachmentRepository,
|
||||
uploadHandler: $uploadHandler,
|
||||
fileStorage: $this->fileStorage,
|
||||
);
|
||||
}
|
||||
|
||||
private function createControllerWithoutUser(): HomeworkAttachmentController
|
||||
{
|
||||
$security = $this->createMock(Security::class);
|
||||
$security->method('getUser')->willReturn(null);
|
||||
|
||||
$uploadHandler = $this->createUploadHandler($this->homeworkRepository, $this->fileStorage);
|
||||
|
||||
return new HomeworkAttachmentController(
|
||||
security: $security,
|
||||
homeworkRepository: $this->homeworkRepository,
|
||||
attachmentRepository: $this->attachmentRepository,
|
||||
uploadHandler: $uploadHandler,
|
||||
fileStorage: $this->fileStorage,
|
||||
);
|
||||
}
|
||||
|
||||
private function createUploadHandler(HomeworkRepository $homeworkRepository, FileStorage $fileStorage): UploadHomeworkAttachmentHandler
|
||||
{
|
||||
$clock = new class implements Clock {
|
||||
public function now(): DateTimeImmutable
|
||||
{
|
||||
return new DateTimeImmutable('2026-04-09 10:00:00');
|
||||
}
|
||||
};
|
||||
|
||||
return new UploadHomeworkAttachmentHandler($homeworkRepository, $fileStorage, $clock);
|
||||
}
|
||||
}
|
||||
@@ -6,10 +6,16 @@ namespace App\Tests\Unit\Scolarite\Infrastructure\Storage;
|
||||
|
||||
use App\Scolarite\Application\Port\FileStorage;
|
||||
|
||||
use function fopen;
|
||||
use function fwrite;
|
||||
use function is_string;
|
||||
|
||||
use Override;
|
||||
|
||||
use function rewind;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class InMemoryFileStorage implements FileStorage
|
||||
{
|
||||
/** @var array<string, string> */
|
||||
@@ -29,6 +35,21 @@ final class InMemoryFileStorage implements FileStorage
|
||||
unset($this->files[$path]);
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function readStream(string $path): mixed
|
||||
{
|
||||
if (!isset($this->files[$path])) {
|
||||
throw new RuntimeException("File not found: {$path}");
|
||||
}
|
||||
|
||||
/** @var resource $stream */
|
||||
$stream = fopen('php://memory', 'r+');
|
||||
fwrite($stream, $this->files[$path]);
|
||||
rewind($stream);
|
||||
|
||||
return $stream;
|
||||
}
|
||||
|
||||
public function has(string $path): bool
|
||||
{
|
||||
return isset($this->files[$path]);
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Scolarite\Infrastructure\Storage;
|
||||
|
||||
use App\Scolarite\Infrastructure\Storage\S3FileStorage;
|
||||
|
||||
use function fopen;
|
||||
|
||||
use League\Flysystem\Filesystem;
|
||||
use League\Flysystem\UnableToDeleteFile;
|
||||
use League\Flysystem\UnableToReadFile;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use ReflectionClass;
|
||||
use RuntimeException;
|
||||
|
||||
final class S3FileStorageTest extends TestCase
|
||||
{
|
||||
private Filesystem $filesystem;
|
||||
private LoggerInterface $logger;
|
||||
private S3FileStorage $storage;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->filesystem = $this->createMock(Filesystem::class);
|
||||
$this->logger = $this->createMock(LoggerInterface::class);
|
||||
|
||||
$this->storage = $this->createStorageWithMockedFilesystem($this->filesystem, $this->logger);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function uploadWritesStringContentToFilesystem(): void
|
||||
{
|
||||
$this->filesystem->expects(self::once())
|
||||
->method('write')
|
||||
->with('homework/abc/file.pdf', 'fake content', ['ContentType' => 'application/pdf']);
|
||||
|
||||
$result = $this->storage->upload('homework/abc/file.pdf', 'fake content', 'application/pdf');
|
||||
|
||||
self::assertSame('homework/abc/file.pdf', $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function uploadWritesStreamContentToFilesystem(): void
|
||||
{
|
||||
/** @var resource $stream */
|
||||
$stream = fopen('php://memory', 'r+');
|
||||
|
||||
$this->filesystem->expects(self::once())
|
||||
->method('writeStream')
|
||||
->with('homework/abc/file.pdf', $stream, ['ContentType' => 'application/pdf']);
|
||||
|
||||
$result = $this->storage->upload('homework/abc/file.pdf', $stream, 'application/pdf');
|
||||
|
||||
self::assertSame('homework/abc/file.pdf', $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function deleteRemovesFileFromFilesystem(): void
|
||||
{
|
||||
$this->filesystem->expects(self::once())
|
||||
->method('delete')
|
||||
->with('homework/abc/file.pdf');
|
||||
|
||||
$this->logger->expects(self::never())
|
||||
->method('warning');
|
||||
|
||||
$this->storage->delete('homework/abc/file.pdf');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function deleteLogsWarningOnFailure(): void
|
||||
{
|
||||
$this->filesystem->expects(self::once())
|
||||
->method('delete')
|
||||
->willThrowException(UnableToDeleteFile::atLocation('homework/abc/file.pdf'));
|
||||
|
||||
$this->logger->expects(self::once())
|
||||
->method('warning')
|
||||
->with(
|
||||
'S3 delete failed, possible orphan blob: {path}',
|
||||
self::callback(static fn (array $context): bool => $context['path'] === 'homework/abc/file.pdf'),
|
||||
);
|
||||
|
||||
$this->storage->delete('homework/abc/file.pdf');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function readStreamReturnsResourceFromFilesystem(): void
|
||||
{
|
||||
/** @var resource $expectedStream */
|
||||
$expectedStream = fopen('php://memory', 'r+');
|
||||
|
||||
$this->filesystem->expects(self::once())
|
||||
->method('readStream')
|
||||
->with('homework/abc/file.pdf')
|
||||
->willReturn($expectedStream);
|
||||
|
||||
$result = $this->storage->readStream('homework/abc/file.pdf');
|
||||
|
||||
self::assertSame($expectedStream, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function readStreamThrowsRuntimeExceptionOnMissingFile(): void
|
||||
{
|
||||
$this->filesystem->expects(self::once())
|
||||
->method('readStream')
|
||||
->with('homework/abc/missing.pdf')
|
||||
->willThrowException(UnableToReadFile::fromLocation('homework/abc/missing.pdf'));
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessage('Impossible de lire le fichier : homework/abc/missing.pdf');
|
||||
|
||||
$this->storage->readStream('homework/abc/missing.pdf');
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an S3FileStorage instance with a mocked Filesystem injected via reflection.
|
||||
*
|
||||
* S3FileStorage is `final readonly` and its constructor creates a real S3Client,
|
||||
* so we bypass it with newInstanceWithoutConstructor() and inject mocks directly.
|
||||
* If the class gains new properties, this method must be updated.
|
||||
*/
|
||||
private function createStorageWithMockedFilesystem(Filesystem $filesystem, LoggerInterface $logger): S3FileStorage
|
||||
{
|
||||
$reflection = new ReflectionClass(S3FileStorage::class);
|
||||
$storage = $reflection->newInstanceWithoutConstructor();
|
||||
|
||||
$fsProp = $reflection->getProperty('filesystem');
|
||||
$fsProp->setValue($storage, $filesystem);
|
||||
|
||||
$loggerProp = $reflection->getProperty('logger');
|
||||
$loggerProp->setValue($storage, $logger);
|
||||
|
||||
return $storage;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Shared\Infrastructure\Tenant;
|
||||
|
||||
use App\Shared\Infrastructure\Tenant\DoctrineTenantRegistry;
|
||||
use App\Shared\Infrastructure\Tenant\TenantId;
|
||||
use App\Shared\Infrastructure\Tenant\TenantNotFoundException;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
#[CoversClass(DoctrineTenantRegistry::class)]
|
||||
final class DoctrineTenantRegistryTest extends TestCase
|
||||
{
|
||||
private const string MASTER_URL = 'postgresql://classeo:secret@db:5432/classeo_master';
|
||||
private const string TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
||||
private const string SUBDOMAIN = 'ecole-alpha';
|
||||
private const string DB_NAME = 'classeo_tenant_a1b2c3d4e5f67890abcdef1234567890';
|
||||
|
||||
#[Test]
|
||||
public function itResolvesConfigBySubdomain(): void
|
||||
{
|
||||
$registry = $this->registryWith([
|
||||
['tenant_id' => self::TENANT_ID, 'subdomain' => self::SUBDOMAIN, 'database_name' => self::DB_NAME],
|
||||
]);
|
||||
|
||||
$config = $registry->getBySubdomain(self::SUBDOMAIN);
|
||||
|
||||
self::assertSame(self::SUBDOMAIN, $config->subdomain);
|
||||
self::assertSame(self::TENANT_ID, (string) $config->tenantId);
|
||||
self::assertSame('postgresql://classeo:secret@db:5432/' . self::DB_NAME, $config->databaseUrl);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itResolvesConfigByTenantId(): void
|
||||
{
|
||||
$registry = $this->registryWith([
|
||||
['tenant_id' => self::TENANT_ID, 'subdomain' => self::SUBDOMAIN, 'database_name' => self::DB_NAME],
|
||||
]);
|
||||
|
||||
$config = $registry->getConfig(TenantId::fromString(self::TENANT_ID));
|
||||
|
||||
self::assertSame(self::SUBDOMAIN, $config->subdomain);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itThrowsForUnknownSubdomain(): void
|
||||
{
|
||||
$registry = $this->registryWith([]);
|
||||
|
||||
$this->expectException(TenantNotFoundException::class);
|
||||
$registry->getBySubdomain('inexistant');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itThrowsForUnknownTenantId(): void
|
||||
{
|
||||
$registry = $this->registryWith([]);
|
||||
|
||||
$this->expectException(TenantNotFoundException::class);
|
||||
$registry->getConfig(TenantId::fromString(self::TENANT_ID));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itChecksExistence(): void
|
||||
{
|
||||
$registry = $this->registryWith([
|
||||
['tenant_id' => self::TENANT_ID, 'subdomain' => self::SUBDOMAIN, 'database_name' => self::DB_NAME],
|
||||
]);
|
||||
|
||||
self::assertTrue($registry->exists(self::SUBDOMAIN));
|
||||
self::assertFalse($registry->exists('inexistant'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsAllConfigs(): void
|
||||
{
|
||||
$registry = $this->registryWith([
|
||||
['tenant_id' => self::TENANT_ID, 'subdomain' => self::SUBDOMAIN, 'database_name' => self::DB_NAME],
|
||||
['tenant_id' => 'b2c3d4e5-f6a7-8901-bcde-f12345678901', 'subdomain' => 'ecole-beta', 'database_name' => 'classeo_tenant_beta'],
|
||||
]);
|
||||
|
||||
$configs = $registry->getAllConfigs();
|
||||
|
||||
self::assertCount(2, $configs);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itQueriesDatabaseOnlyOnce(): void
|
||||
{
|
||||
$connection = $this->createMock(Connection::class);
|
||||
$connection->expects(self::once())
|
||||
->method('fetchAllAssociative')
|
||||
->willReturn([
|
||||
['tenant_id' => self::TENANT_ID, 'subdomain' => self::SUBDOMAIN, 'database_name' => self::DB_NAME],
|
||||
]);
|
||||
|
||||
$registry = new DoctrineTenantRegistry($connection, self::MASTER_URL);
|
||||
|
||||
$registry->getBySubdomain(self::SUBDOMAIN);
|
||||
$registry->getConfig(TenantId::fromString(self::TENANT_ID));
|
||||
$registry->exists(self::SUBDOMAIN);
|
||||
$registry->getAllConfigs();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<array{tenant_id: string, subdomain: string, database_name: string}> $rows
|
||||
*/
|
||||
private function registryWith(array $rows): DoctrineTenantRegistry
|
||||
{
|
||||
$connection = $this->createMock(Connection::class);
|
||||
$connection->method('fetchAllAssociative')->willReturn($rows);
|
||||
|
||||
return new DoctrineTenantRegistry($connection, self::MASTER_URL);
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,7 @@ final class CreateEstablishmentHandlerTest extends TestCase
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function createsEstablishmentAndReturnsResult(): void
|
||||
public function createsEstablishmentAndReturnsIt(): void
|
||||
{
|
||||
$command = new CreateEstablishmentCommand(
|
||||
name: 'École Alpha',
|
||||
@@ -46,13 +46,13 @@ final class CreateEstablishmentHandlerTest extends TestCase
|
||||
superAdminId: self::SUPER_ADMIN_ID,
|
||||
);
|
||||
|
||||
$result = ($this->handler)($command);
|
||||
$establishment = ($this->handler)($command);
|
||||
|
||||
self::assertNotEmpty($result->establishmentId);
|
||||
self::assertNotEmpty($result->tenantId);
|
||||
self::assertSame('École Alpha', $result->name);
|
||||
self::assertSame('ecole-alpha', $result->subdomain);
|
||||
self::assertStringStartsWith('classeo_tenant_', $result->databaseName);
|
||||
self::assertNotEmpty((string) $establishment->id);
|
||||
self::assertNotEmpty((string) $establishment->tenantId);
|
||||
self::assertSame('École Alpha', $establishment->name);
|
||||
self::assertSame('ecole-alpha', $establishment->subdomain);
|
||||
self::assertStringStartsWith('classeo_tenant_', $establishment->databaseName);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
@@ -65,10 +65,10 @@ final class CreateEstablishmentHandlerTest extends TestCase
|
||||
superAdminId: self::SUPER_ADMIN_ID,
|
||||
);
|
||||
|
||||
$result = ($this->handler)($command);
|
||||
$establishment = ($this->handler)($command);
|
||||
|
||||
$establishments = $this->repository->findAll();
|
||||
self::assertCount(1, $establishments);
|
||||
self::assertSame($result->establishmentId, (string) $establishments[0]->id);
|
||||
self::assertSame((string) $establishment->id, (string) $establishments[0]->id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ final class GetEstablishmentsHandlerTest extends TestCase
|
||||
$this->repository->save(Establishment::creer(
|
||||
name: 'École Alpha',
|
||||
subdomain: 'ecole-alpha',
|
||||
adminEmail: 'admin@ecole-alpha.fr',
|
||||
createdBy: SuperAdminId::fromString(self::SUPER_ADMIN_ID),
|
||||
createdAt: new DateTimeImmutable('2026-02-16 10:00:00'),
|
||||
));
|
||||
@@ -47,6 +48,7 @@ final class GetEstablishmentsHandlerTest extends TestCase
|
||||
$this->repository->save(Establishment::creer(
|
||||
name: 'École Beta',
|
||||
subdomain: 'ecole-beta',
|
||||
adminEmail: 'admin@ecole-beta.fr',
|
||||
createdBy: SuperAdminId::fromString(self::SUPER_ADMIN_ID),
|
||||
createdAt: new DateTimeImmutable('2026-02-16 11:00:00'),
|
||||
));
|
||||
@@ -56,6 +58,6 @@ final class GetEstablishmentsHandlerTest extends TestCase
|
||||
self::assertCount(2, $result);
|
||||
self::assertSame('École Alpha', $result[0]->name);
|
||||
self::assertSame('ecole-alpha', $result[0]->subdomain);
|
||||
self::assertSame('active', $result[0]->status);
|
||||
self::assertSame('provisioning', $result[0]->status);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,11 +23,11 @@ final class EstablishmentTest extends TestCase
|
||||
private const string SUBDOMAIN = 'ecole-alpha';
|
||||
|
||||
#[Test]
|
||||
public function creerCreatesActiveEstablishment(): void
|
||||
public function creerCreatesProvisioningEstablishment(): void
|
||||
{
|
||||
$establishment = $this->createEstablishment();
|
||||
|
||||
self::assertSame(EstablishmentStatus::ACTIF, $establishment->status);
|
||||
self::assertSame(EstablishmentStatus::PROVISIONING, $establishment->status);
|
||||
self::assertSame(self::ESTABLISHMENT_NAME, $establishment->name);
|
||||
self::assertSame(self::SUBDOMAIN, $establishment->subdomain);
|
||||
self::assertNull($establishment->lastActivityAt);
|
||||
@@ -59,10 +59,21 @@ final class EstablishmentTest extends TestCase
|
||||
self::assertStringStartsWith('classeo_tenant_', $establishment->databaseName);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function activerChangesStatusToActif(): void
|
||||
{
|
||||
$establishment = $this->createEstablishment();
|
||||
|
||||
self::assertSame(EstablishmentStatus::PROVISIONING, $establishment->status);
|
||||
$establishment->activer();
|
||||
self::assertSame(EstablishmentStatus::ACTIF, $establishment->status);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function desactiverChangesStatusToInactif(): void
|
||||
{
|
||||
$establishment = $this->createEstablishment();
|
||||
$establishment->activer();
|
||||
|
||||
$establishment->desactiver(new DateTimeImmutable('2026-02-16 12:00:00'));
|
||||
|
||||
@@ -73,6 +84,7 @@ final class EstablishmentTest extends TestCase
|
||||
public function desactiverRecordsEtablissementDesactiveEvent(): void
|
||||
{
|
||||
$establishment = $this->createEstablishment();
|
||||
$establishment->activer();
|
||||
$establishment->pullDomainEvents(); // Clear creation event
|
||||
|
||||
$establishment->desactiver(new DateTimeImmutable('2026-02-16 12:00:00'));
|
||||
@@ -86,6 +98,7 @@ final class EstablishmentTest extends TestCase
|
||||
public function desactiverThrowsWhenAlreadyInactive(): void
|
||||
{
|
||||
$establishment = $this->createEstablishment();
|
||||
$establishment->activer();
|
||||
$establishment->desactiver(new DateTimeImmutable('2026-02-16 12:00:00'));
|
||||
|
||||
$this->expectException(EstablishmentDejaInactifException::class);
|
||||
@@ -141,6 +154,7 @@ final class EstablishmentTest extends TestCase
|
||||
return Establishment::creer(
|
||||
name: self::ESTABLISHMENT_NAME,
|
||||
subdomain: self::SUBDOMAIN,
|
||||
adminEmail: 'admin@ecole-alpha.fr',
|
||||
createdBy: SuperAdminId::fromString(self::SUPER_ADMIN_ID),
|
||||
createdAt: new DateTimeImmutable('2026-02-16 10:00:00'),
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace App\Tests\Unit\SuperAdmin\Infrastructure\Api\Processor;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\SuperAdmin\Application\Command\CreateEstablishment\CreateEstablishmentHandler;
|
||||
use App\SuperAdmin\Application\Command\ProvisionEstablishment\ProvisionEstablishmentCommand;
|
||||
use App\SuperAdmin\Domain\Model\SuperAdmin\SuperAdminId;
|
||||
use App\SuperAdmin\Infrastructure\Api\Processor\CreateEstablishmentProcessor;
|
||||
use App\SuperAdmin\Infrastructure\Api\Resource\EstablishmentResource;
|
||||
@@ -16,13 +17,15 @@ use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Messenger\Envelope;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
|
||||
final class CreateEstablishmentProcessorTest extends TestCase
|
||||
{
|
||||
private const string SUPER_ADMIN_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||
|
||||
#[Test]
|
||||
public function processCreatesEstablishmentAndReturnsResource(): void
|
||||
public function processCreatesEstablishmentAndDispatchesProvisioning(): void
|
||||
{
|
||||
$repository = new InMemoryEstablishmentRepository();
|
||||
$clock = new class implements Clock {
|
||||
@@ -42,7 +45,16 @@ final class CreateEstablishmentProcessorTest extends TestCase
|
||||
$security = $this->createMock(Security::class);
|
||||
$security->method('getUser')->willReturn($securityUser);
|
||||
|
||||
$processor = new CreateEstablishmentProcessor($handler, $security);
|
||||
$dispatched = [];
|
||||
$commandBus = $this->createMock(MessageBusInterface::class);
|
||||
$commandBus->method('dispatch')
|
||||
->willReturnCallback(static function (object $message) use (&$dispatched): Envelope {
|
||||
$dispatched[] = $message;
|
||||
|
||||
return new Envelope($message);
|
||||
});
|
||||
|
||||
$processor = new CreateEstablishmentProcessor($handler, $security, $commandBus);
|
||||
|
||||
$input = new EstablishmentResource();
|
||||
$input->name = 'École Gamma';
|
||||
@@ -55,6 +67,12 @@ final class CreateEstablishmentProcessorTest extends TestCase
|
||||
self::assertNotNull($result->tenantId);
|
||||
self::assertSame('École Gamma', $result->name);
|
||||
self::assertSame('ecole-gamma', $result->subdomain);
|
||||
self::assertSame('active', $result->status);
|
||||
self::assertSame('provisioning', $result->status);
|
||||
|
||||
self::assertCount(1, $dispatched);
|
||||
self::assertInstanceOf(ProvisionEstablishmentCommand::class, $dispatched[0]);
|
||||
self::assertSame('admin@ecole-gamma.fr', $dispatched[0]->adminEmail);
|
||||
self::assertSame('ecole-gamma', $dispatched[0]->subdomain);
|
||||
self::assertSame('École Gamma', $dispatched[0]->establishmentName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ final class EstablishmentCollectionProviderTest extends TestCase
|
||||
$repository->save(Establishment::creer(
|
||||
name: 'École Alpha',
|
||||
subdomain: 'ecole-alpha',
|
||||
adminEmail: 'admin@ecole-alpha.fr',
|
||||
createdBy: SuperAdminId::fromString(self::SUPER_ADMIN_ID),
|
||||
createdAt: new DateTimeImmutable('2026-02-16 10:00:00'),
|
||||
));
|
||||
@@ -49,6 +50,6 @@ final class EstablishmentCollectionProviderTest extends TestCase
|
||||
self::assertCount(1, $result);
|
||||
self::assertSame('École Alpha', $result[0]->name);
|
||||
self::assertSame('ecole-alpha', $result[0]->subdomain);
|
||||
self::assertSame('active', $result[0]->status);
|
||||
self::assertSame('provisioning', $result[0]->status);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\SuperAdmin\Infrastructure\Provisioning;
|
||||
|
||||
use App\SuperAdmin\Application\Port\TenantProvisioner;
|
||||
use App\SuperAdmin\Infrastructure\Provisioning\DatabaseTenantProvisioner;
|
||||
use App\SuperAdmin\Infrastructure\Provisioning\TenantDatabaseCreator;
|
||||
use App\SuperAdmin\Infrastructure\Provisioning\TenantMigrator;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\NullLogger;
|
||||
use RuntimeException;
|
||||
|
||||
final class DatabaseTenantProvisionerTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function itCallsCreatorThenMigratorInOrder(): void
|
||||
{
|
||||
$steps = [];
|
||||
|
||||
$connection = $this->createMock(Connection::class);
|
||||
$connection->method('fetchOne')->willReturn(false);
|
||||
$connection->method('executeStatement')->willReturnCallback(
|
||||
static function () use (&$steps): int {
|
||||
$steps[] = 'create';
|
||||
|
||||
return 1;
|
||||
},
|
||||
);
|
||||
|
||||
$creator = new TenantDatabaseCreator($connection, new NullLogger());
|
||||
|
||||
// TenantMigrator is final — we wrap via the TenantProvisioner interface
|
||||
// to verify the creator is called. Migration subprocess cannot be tested unitarily.
|
||||
$provisioner = new class($creator, $steps) implements TenantProvisioner {
|
||||
/** @param string[] $steps */
|
||||
public function __construct(
|
||||
private readonly TenantDatabaseCreator $creator,
|
||||
private array &$steps,
|
||||
) {
|
||||
}
|
||||
|
||||
public function provision(string $databaseName): void
|
||||
{
|
||||
$this->creator->create($databaseName);
|
||||
$this->steps[] = 'migrate';
|
||||
}
|
||||
};
|
||||
|
||||
$provisioner->provision('classeo_tenant_test');
|
||||
|
||||
self::assertSame(['create', 'migrate'], $steps);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itPropagatesCreationFailure(): void
|
||||
{
|
||||
$connection = $this->createMock(Connection::class);
|
||||
$connection->method('fetchOne')->willThrowException(new RuntimeException('Connection refused'));
|
||||
|
||||
$creator = new TenantDatabaseCreator($connection, new NullLogger());
|
||||
$migrator = new TenantMigrator('/tmp', 'postgresql://u:p@h/db', new NullLogger());
|
||||
|
||||
$provisioner = new DatabaseTenantProvisioner($creator, $migrator);
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
$provisioner->provision('classeo_tenant_test');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\SuperAdmin\Infrastructure\Provisioning;
|
||||
|
||||
use App\Administration\Application\Command\InviteUser\InviteUserHandler;
|
||||
use App\Administration\Domain\Event\InvitationRenvoyee;
|
||||
use App\Administration\Domain\Event\UtilisateurInvite;
|
||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryUserRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use App\SuperAdmin\Application\Command\ProvisionEstablishment\ProvisionEstablishmentCommand;
|
||||
use App\SuperAdmin\Application\Port\TenantProvisioner;
|
||||
use App\SuperAdmin\Domain\Model\Establishment\Establishment;
|
||||
use App\SuperAdmin\Domain\Model\Establishment\EstablishmentId;
|
||||
use App\SuperAdmin\Domain\Model\Establishment\EstablishmentStatus;
|
||||
use App\SuperAdmin\Domain\Model\SuperAdmin\SuperAdminId;
|
||||
use App\SuperAdmin\Infrastructure\Persistence\InMemory\InMemoryEstablishmentRepository;
|
||||
use App\SuperAdmin\Infrastructure\Provisioning\ProvisionEstablishmentHandler;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\NullLogger;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\Messenger\Envelope;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
|
||||
final class ProvisionEstablishmentHandlerTest extends TestCase
|
||||
{
|
||||
private const string MASTER_URL = 'postgresql://classeo:secret@db:5432/classeo_master?serverVersion=18';
|
||||
private const string ESTABLISHMENT_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||
private const string TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
||||
|
||||
#[Test]
|
||||
public function itProvisionsTenantDatabase(): void
|
||||
{
|
||||
$provisioner = $this->createMock(TenantProvisioner::class);
|
||||
$provisioner->expects(self::once())
|
||||
->method('provision')
|
||||
->with('classeo_tenant_abc123');
|
||||
|
||||
$handler = $this->buildHandler(provisioner: $provisioner);
|
||||
$handler($this->command());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itCreatesAdminUser(): void
|
||||
{
|
||||
$userRepository = new InMemoryUserRepository();
|
||||
|
||||
$handler = $this->buildHandler(userRepository: $userRepository);
|
||||
$handler($this->command());
|
||||
|
||||
$users = $userRepository->findAllByTenant(TenantId::fromString(self::TENANT_ID));
|
||||
self::assertCount(1, $users);
|
||||
self::assertSame('admin@ecole-gamma.fr', (string) $users[0]->email);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itDispatchesInvitationEvent(): void
|
||||
{
|
||||
$dispatched = [];
|
||||
$eventBus = $this->spyEventBus($dispatched);
|
||||
|
||||
$handler = $this->buildHandler(eventBus: $eventBus);
|
||||
$handler($this->command());
|
||||
|
||||
self::assertNotEmpty($dispatched);
|
||||
self::assertInstanceOf(UtilisateurInvite::class, $dispatched[0]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itActivatesEstablishmentAfterProvisioning(): void
|
||||
{
|
||||
$establishmentRepo = $this->establishmentRepoWithProvisioningEstablishment();
|
||||
|
||||
$handler = $this->buildHandler(establishmentRepository: $establishmentRepo);
|
||||
$handler($this->command());
|
||||
|
||||
$establishment = $establishmentRepo->get(
|
||||
EstablishmentId::fromString(self::ESTABLISHMENT_ID),
|
||||
);
|
||||
self::assertSame(EstablishmentStatus::ACTIF, $establishment->status);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itIsIdempotentWhenAdminAlreadyExists(): void
|
||||
{
|
||||
$userRepository = new InMemoryUserRepository();
|
||||
$dispatched = [];
|
||||
$eventBus = $this->spyEventBus($dispatched);
|
||||
|
||||
$handler = $this->buildHandler(userRepository: $userRepository, eventBus: $eventBus);
|
||||
|
||||
// First call creates the admin
|
||||
$handler($this->command());
|
||||
self::assertCount(1, $dispatched);
|
||||
self::assertInstanceOf(UtilisateurInvite::class, $dispatched[0]);
|
||||
|
||||
// Second call is idempotent — re-sends invitation
|
||||
$dispatched = [];
|
||||
$handler($this->command());
|
||||
self::assertCount(1, $dispatched);
|
||||
self::assertInstanceOf(InvitationRenvoyee::class, $dispatched[0]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itSwitchesDatabaseAndRestores(): void
|
||||
{
|
||||
$switcher = new SpyDatabaseSwitcher();
|
||||
|
||||
$handler = $this->buildHandler(databaseSwitcher: $switcher);
|
||||
$handler($this->command());
|
||||
|
||||
self::assertCount(1, $switcher->switchedTo);
|
||||
self::assertStringContainsString('classeo_tenant_abc123', $switcher->switchedTo[0]);
|
||||
self::assertTrue($switcher->restoredToDefault);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itPreservesQueryParametersInDatabaseUrl(): void
|
||||
{
|
||||
$switcher = new SpyDatabaseSwitcher();
|
||||
|
||||
$handler = $this->buildHandler(databaseSwitcher: $switcher);
|
||||
$handler($this->command());
|
||||
|
||||
self::assertStringContainsString('?serverVersion=18', $switcher->switchedTo[0]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itRestoresDatabaseEvenOnFailure(): void
|
||||
{
|
||||
$switcher = new SpyDatabaseSwitcher();
|
||||
|
||||
$eventBus = $this->createMock(MessageBusInterface::class);
|
||||
$eventBus->method('dispatch')
|
||||
->willThrowException(new RuntimeException('Event bus failure'));
|
||||
|
||||
$handler = $this->buildHandler(databaseSwitcher: $switcher, eventBus: $eventBus);
|
||||
|
||||
try {
|
||||
$handler($this->command());
|
||||
} catch (RuntimeException) {
|
||||
// Expected
|
||||
}
|
||||
|
||||
self::assertTrue($switcher->restoredToDefault);
|
||||
}
|
||||
|
||||
private function command(): ProvisionEstablishmentCommand
|
||||
{
|
||||
return new ProvisionEstablishmentCommand(
|
||||
establishmentId: self::ESTABLISHMENT_ID,
|
||||
establishmentTenantId: self::TENANT_ID,
|
||||
databaseName: 'classeo_tenant_abc123',
|
||||
subdomain: 'ecole-gamma',
|
||||
adminEmail: 'admin@ecole-gamma.fr',
|
||||
establishmentName: 'École Gamma',
|
||||
);
|
||||
}
|
||||
|
||||
private function establishmentRepoWithProvisioningEstablishment(): InMemoryEstablishmentRepository
|
||||
{
|
||||
$repo = new InMemoryEstablishmentRepository();
|
||||
$establishment = Establishment::reconstitute(
|
||||
id: EstablishmentId::fromString(self::ESTABLISHMENT_ID),
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
name: 'École Gamma',
|
||||
subdomain: 'ecole-gamma',
|
||||
databaseName: 'classeo_tenant_abc123',
|
||||
status: EstablishmentStatus::PROVISIONING,
|
||||
createdAt: new DateTimeImmutable('2026-04-07 10:00:00'),
|
||||
createdBy: SuperAdminId::fromString('550e8400-e29b-41d4-a716-446655440002'),
|
||||
);
|
||||
$repo->save($establishment);
|
||||
|
||||
return $repo;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param object[] $dispatched
|
||||
*/
|
||||
private function spyEventBus(array &$dispatched): MessageBusInterface
|
||||
{
|
||||
$eventBus = $this->createMock(MessageBusInterface::class);
|
||||
$eventBus->method('dispatch')
|
||||
->willReturnCallback(static function (object $message) use (&$dispatched): Envelope {
|
||||
$dispatched[] = $message;
|
||||
|
||||
return new Envelope($message);
|
||||
});
|
||||
|
||||
return $eventBus;
|
||||
}
|
||||
|
||||
private function buildHandler(
|
||||
?TenantProvisioner $provisioner = null,
|
||||
?InMemoryUserRepository $userRepository = null,
|
||||
?SpyDatabaseSwitcher $databaseSwitcher = null,
|
||||
?InMemoryEstablishmentRepository $establishmentRepository = null,
|
||||
?MessageBusInterface $eventBus = null,
|
||||
): ProvisionEstablishmentHandler {
|
||||
$provisioner ??= $this->createMock(TenantProvisioner::class);
|
||||
|
||||
$clock = new class implements Clock {
|
||||
public function now(): DateTimeImmutable
|
||||
{
|
||||
return new DateTimeImmutable('2026-04-07 10:00:00');
|
||||
}
|
||||
};
|
||||
|
||||
$userRepository ??= new InMemoryUserRepository();
|
||||
|
||||
$databaseSwitcher ??= new SpyDatabaseSwitcher();
|
||||
|
||||
$establishmentRepository ??= $this->establishmentRepoWithProvisioningEstablishment();
|
||||
|
||||
$eventBus ??= $this->createMock(MessageBusInterface::class);
|
||||
$eventBus->method('dispatch')
|
||||
->willReturnCallback(static fn (object $m): Envelope => new Envelope($m));
|
||||
|
||||
return new ProvisionEstablishmentHandler(
|
||||
tenantProvisioner: $provisioner,
|
||||
inviteUserHandler: new InviteUserHandler($userRepository, $clock),
|
||||
userRepository: $userRepository,
|
||||
clock: $clock,
|
||||
databaseSwitcher: $databaseSwitcher,
|
||||
establishmentRepository: $establishmentRepository,
|
||||
eventBus: $eventBus,
|
||||
logger: new NullLogger(),
|
||||
masterDatabaseUrl: self::MASTER_URL,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\SuperAdmin\Infrastructure\Provisioning;
|
||||
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Administration\Application\Command\InviteUser\InviteUserHandler;
|
||||
use App\Administration\Domain\Event\UtilisateurInvite;
|
||||
use App\Administration\Domain\Model\User\Role;
|
||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryUserRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use App\SuperAdmin\Application\Command\CreateEstablishment\CreateEstablishmentHandler;
|
||||
use App\SuperAdmin\Application\Command\ProvisionEstablishment\ProvisionEstablishmentCommand;
|
||||
use App\SuperAdmin\Application\Port\TenantProvisioner;
|
||||
use App\SuperAdmin\Domain\Model\Establishment\EstablishmentStatus;
|
||||
use App\SuperAdmin\Domain\Model\SuperAdmin\SuperAdminId;
|
||||
use App\SuperAdmin\Infrastructure\Api\Processor\CreateEstablishmentProcessor;
|
||||
use App\SuperAdmin\Infrastructure\Api\Resource\EstablishmentResource;
|
||||
use App\SuperAdmin\Infrastructure\Persistence\InMemory\InMemoryEstablishmentRepository;
|
||||
use App\SuperAdmin\Infrastructure\Provisioning\ProvisionEstablishmentHandler;
|
||||
use App\SuperAdmin\Infrastructure\Security\SecuritySuperAdmin;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\NullLogger;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Messenger\Envelope;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
|
||||
/**
|
||||
* Integration tests: verify the full provisioning flow from API request
|
||||
* through establishment creation to async provisioning and admin user creation.
|
||||
*
|
||||
* Split into focused tests that each verify one aspect of the flow.
|
||||
*/
|
||||
final class ProvisioningIntegrationTest extends TestCase
|
||||
{
|
||||
private const string SUPER_ADMIN_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||
private const string MASTER_URL = 'postgresql://classeo:secret@db:5432/classeo_master';
|
||||
|
||||
private InMemoryEstablishmentRepository $establishmentRepository;
|
||||
private InMemoryUserRepository $userRepository;
|
||||
private ?ProvisionEstablishmentCommand $provisionCommand;
|
||||
/** @var object[] */
|
||||
private array $dispatchedEvents;
|
||||
|
||||
private function runFullFlow(): void
|
||||
{
|
||||
$clock = new class implements Clock {
|
||||
public function now(): DateTimeImmutable
|
||||
{
|
||||
return new DateTimeImmutable('2026-04-07 10:00:00');
|
||||
}
|
||||
};
|
||||
|
||||
// Phase 1: API processor creates establishment
|
||||
$this->establishmentRepository = new InMemoryEstablishmentRepository();
|
||||
$createHandler = new CreateEstablishmentHandler($this->establishmentRepository, $clock);
|
||||
|
||||
$security = $this->createMock(Security::class);
|
||||
$security->method('getUser')->willReturn(new SecuritySuperAdmin(
|
||||
SuperAdminId::fromString(self::SUPER_ADMIN_ID),
|
||||
'superadmin@classeo.fr',
|
||||
'hashed',
|
||||
));
|
||||
|
||||
$this->provisionCommand = null;
|
||||
$commandBus = $this->createMock(MessageBusInterface::class);
|
||||
$commandBus->method('dispatch')
|
||||
->willReturnCallback(function (object $message): Envelope {
|
||||
if ($message instanceof ProvisionEstablishmentCommand) {
|
||||
$this->provisionCommand = $message;
|
||||
}
|
||||
|
||||
return new Envelope($message);
|
||||
});
|
||||
|
||||
$processor = new CreateEstablishmentProcessor($createHandler, $security, $commandBus);
|
||||
|
||||
$input = new EstablishmentResource();
|
||||
$input->name = 'École Test';
|
||||
$input->subdomain = 'ecole-test';
|
||||
$input->adminEmail = 'admin@ecole-test.fr';
|
||||
|
||||
$processor->process($input, new Post());
|
||||
|
||||
// Phase 2: Provisioning handler processes the command
|
||||
self::assertNotNull($this->provisionCommand);
|
||||
|
||||
$this->userRepository = new InMemoryUserRepository();
|
||||
$this->dispatchedEvents = [];
|
||||
|
||||
$eventBus = $this->createMock(MessageBusInterface::class);
|
||||
$eventBus->method('dispatch')
|
||||
->willReturnCallback(function (object $message): Envelope {
|
||||
$this->dispatchedEvents[] = $message;
|
||||
|
||||
return new Envelope($message);
|
||||
});
|
||||
|
||||
$provisioner = $this->createMock(TenantProvisioner::class);
|
||||
|
||||
$switcher = new SpyDatabaseSwitcher();
|
||||
|
||||
$provisionHandler = new ProvisionEstablishmentHandler(
|
||||
tenantProvisioner: $provisioner,
|
||||
inviteUserHandler: new InviteUserHandler($this->userRepository, $clock),
|
||||
userRepository: $this->userRepository,
|
||||
clock: $clock,
|
||||
databaseSwitcher: $switcher,
|
||||
establishmentRepository: $this->establishmentRepository,
|
||||
eventBus: $eventBus,
|
||||
logger: new NullLogger(),
|
||||
masterDatabaseUrl: self::MASTER_URL,
|
||||
);
|
||||
|
||||
$provisionHandler($this->provisionCommand);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function processorCreatesEstablishmentInProvisioningStatus(): void
|
||||
{
|
||||
$this->runFullFlow();
|
||||
|
||||
$establishments = $this->establishmentRepository->findAll();
|
||||
self::assertCount(1, $establishments);
|
||||
self::assertSame('École Test', $establishments[0]->name);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function processorDispatchesProvisioningCommandWithAdminEmail(): void
|
||||
{
|
||||
$this->runFullFlow();
|
||||
|
||||
self::assertNotNull($this->provisionCommand);
|
||||
self::assertSame('admin@ecole-test.fr', $this->provisionCommand->adminEmail);
|
||||
self::assertSame('ecole-test', $this->provisionCommand->subdomain);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function provisioningCreatesAdminUserWithCorrectRole(): void
|
||||
{
|
||||
$this->runFullFlow();
|
||||
|
||||
$users = $this->userRepository->findAllByTenant(
|
||||
TenantId::fromString($this->provisionCommand->establishmentTenantId),
|
||||
);
|
||||
self::assertCount(1, $users);
|
||||
self::assertSame('admin@ecole-test.fr', (string) $users[0]->email);
|
||||
self::assertSame(Role::ADMIN, $users[0]->role);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function provisioningActivatesEstablishmentAndDispatchesEvent(): void
|
||||
{
|
||||
$this->runFullFlow();
|
||||
|
||||
$establishments = $this->establishmentRepository->findAll();
|
||||
self::assertSame(EstablishmentStatus::ACTIF, $establishments[0]->status);
|
||||
|
||||
self::assertCount(1, $this->dispatchedEvents);
|
||||
self::assertInstanceOf(UtilisateurInvite::class, $this->dispatchedEvents[0]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\SuperAdmin\Infrastructure\Provisioning;
|
||||
|
||||
use App\Shared\Infrastructure\Tenant\TenantDatabaseSwitcher;
|
||||
|
||||
/**
|
||||
* Test double that records database switching operations.
|
||||
*/
|
||||
final class SpyDatabaseSwitcher implements TenantDatabaseSwitcher
|
||||
{
|
||||
/** @var string[] */
|
||||
public array $switchedTo = [];
|
||||
public bool $restoredToDefault = false;
|
||||
|
||||
public function useTenantDatabase(string $databaseUrl): void
|
||||
{
|
||||
$this->switchedTo[] = $databaseUrl;
|
||||
}
|
||||
|
||||
public function useDefaultDatabase(): void
|
||||
{
|
||||
$this->restoredToDefault = true;
|
||||
}
|
||||
|
||||
public function currentDatabaseUrl(): ?string
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user