feat: Provisionner automatiquement un nouvel établissement
Some checks failed
CI / Backend Tests (push) Has been cancelled
CI / Frontend Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Naming Conventions (push) Has been cancelled
CI / Build Check (push) Has been cancelled

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:
2026-04-08 13:55:41 +02:00
parent bec211ebf0
commit dc2be898d5
171 changed files with 11703 additions and 700 deletions

View File

@@ -74,7 +74,7 @@ development_status:
2-12b-optimistic-update-pages-admin: done 2-12b-optimistic-update-pages-admin: done
2-13-personnalisation-visuelle-etablissement: done 2-13-personnalisation-visuelle-etablissement: done
2-15-organisation-sections-dashboard-admin: done 2-15-organisation-sections-dashboard-admin: done
2-17-provisioning-automatique-etablissements: ready-for-dev # Tâches post-MVP différées de 2-10 2-17-provisioning-automatique-etablissements: done # Tâches post-MVP différées de 2-10
epic-2-retrospective: done epic-2-retrospective: done
# Epic 3: Import & Onboarding (5 stories) # Epic 3: Import & Onboarding (5 stories)
@@ -108,7 +108,7 @@ development_status:
5-8-consultation-des-devoirs-par-le-parent: done 5-8-consultation-des-devoirs-par-le-parent: done
5-9-description-enrichie-et-pieces-jointes-enseignant: done 5-9-description-enrichie-et-pieces-jointes-enseignant: done
5-10-rendu-de-devoir-par-leleve: done 5-10-rendu-de-devoir-par-leleve: done
5-11-description-enrichie-upload-calendrier-devoirs: ready-for-dev # Tâches UX différées de 5-1 5-11-description-enrichie-upload-calendrier-devoirs: done # Tâches UX différées de 5-1
epic-5-retrospective: optional epic-5-retrospective: optional
# Epic 6: Notes & Évaluations (12 stories) # Epic 6: Notes & Évaluations (12 stories)
@@ -120,11 +120,12 @@ development_status:
6-5-mode-competences: done 6-5-mode-competences: done
6-6-consultation-notes-par-leleve: done 6-6-consultation-notes-par-leleve: done
6-7-consultation-notes-par-le-parent: done 6-7-consultation-notes-par-le-parent: done
6-8-statistiques-enseignant: ready-for-dev 6-8-statistiques-enseignant: done
6-9-grade-voter-et-acces-notes-affectations: ready-for-dev # Débloque tâches différées de 2-6, 2-8, 2-9 6-9-grade-voter-et-acces-notes-affectations: review # Débloque tâches différées de 2-6, 2-8, 2-9
6-10-statistiques-notes-par-matiere-admin: ready-for-dev # Débloque tâches différées de 2-2 6-10-statistiques-notes-par-matiere-admin: ready-for-dev # Débloque tâches différées de 2-2
6-11-audit-trail-evenements-notes: ready-for-dev # Débloque tâches différées de 1-7 6-11-audit-trail-evenements-notes: ready-for-dev # Débloque tâches différées de 1-7
6-12-correctifs-mode-competences: ready-for-dev # Patches critiques review 6-5 6-12-correctifs-mode-competences: ready-for-dev # Patches critiques review 6-5
6-13-acces-evaluations-remplacant: ready-for-dev # UX : navigation évaluations pour le remplaçant (identifié en 6-9)
epic-6-retrospective: optional epic-6-retrospective: optional
# Epic 7: Vie Scolaire (8 stories) # Epic 7: Vie Scolaire (8 stories)

View File

@@ -89,6 +89,14 @@ TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
TURNSTILE_FAIL_OPEN=true TURNSTILE_FAIL_OPEN=true
###< cloudflare/turnstile ### ###< 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 ### ###> symfony/lock ###
# Choose one of the stores below # Choose one of the stores below
# postgresql+advisory://db_user:db_password@localhost/db_name # postgresql+advisory://db_user:db_password@localhost/db_name

View File

@@ -15,6 +15,7 @@
"doctrine/doctrine-bundle": "^2.13 || ^3.0@dev", "doctrine/doctrine-bundle": "^2.13 || ^3.0@dev",
"doctrine/doctrine-migrations-bundle": "^3.4", "doctrine/doctrine-migrations-bundle": "^3.4",
"doctrine/orm": "^3.3", "doctrine/orm": "^3.3",
"league/flysystem-aws-s3-v3": "^3.32",
"lexik/jwt-authentication-bundle": "^3.2", "lexik/jwt-authentication-bundle": "^3.2",
"nelmio/cors-bundle": "^2.6", "nelmio/cors-bundle": "^2.6",
"phpoffice/phpspreadsheet": "^5.4", "phpoffice/phpspreadsheet": "^5.4",
@@ -26,6 +27,7 @@
"symfony/console": "^8.0", "symfony/console": "^8.0",
"symfony/doctrine-messenger": "^8.0", "symfony/doctrine-messenger": "^8.0",
"symfony/dotenv": "^8.0", "symfony/dotenv": "^8.0",
"symfony/expression-language": "8.0.*",
"symfony/flex": "^2", "symfony/flex": "^2",
"symfony/framework-bundle": "^8.0", "symfony/framework-bundle": "^8.0",
"symfony/html-sanitizer": "8.0.*", "symfony/html-sanitizer": "8.0.*",

790
backend/composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "92b9472c96a59c314d96372c4094f185", "content-hash": "851abcf008c69423a69ad329ae88a255",
"packages": [ "packages": [
{ {
"name": "api-platform/core", "name": "api-platform/core",
@@ -224,6 +224,157 @@
}, },
"time": "2026-01-23T15:23:18+00:00" "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", "name": "brick/math",
"version": "0.14.1", "version": "0.14.1",
@@ -1536,6 +1687,215 @@
], ],
"time": "2025-03-06T22:45:56+00:00" "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", "name": "guzzlehttp/psr7",
"version": "2.8.0", "version": "2.8.0",
@@ -1785,6 +2145,249 @@
], ],
"time": "2025-10-17T11:30:53+00:00" "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", "name": "league/uri",
"version": "7.8.1", "version": "7.8.1",
@@ -2371,6 +2974,72 @@
], ],
"time": "2026-01-02T08:56:05+00:00" "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", "name": "nelmio/cors-bundle",
"version": "2.6.1", "version": "2.6.1",
@@ -2813,6 +3482,58 @@
}, },
"time": "2019-01-08T18:20:26+00:00" "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", "name": "psr/http-factory",
"version": "1.1.0", "version": "1.1.0",
@@ -4672,6 +5393,73 @@
], ],
"time": "2024-09-25T14:21:43+00:00" "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", "name": "symfony/filesystem",
"version": "v8.0.1", "version": "v8.0.1",

View File

@@ -64,3 +64,5 @@ framework:
# Import élèves/enseignants → async (batch processing, peut être long) # Import élèves/enseignants → async (batch processing, peut être long)
App\Administration\Application\Command\ImportStudents\ImportStudentsCommand: async App\Administration\Application\Command\ImportStudents\ImportStudentsCommand: async
App\Administration\Application\Command\ImportTeachers\ImportTeachersCommand: async App\Administration\Application\Command\ImportTeachers\ImportTeachersCommand: async
# Provisioning établissement → async (création BDD, migrations, premier admin)
App\SuperAdmin\Application\Command\ProvisionEstablishment\ProvisionEstablishmentCommand: async

View File

@@ -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 : # Le DoctrineTenantRegistry interroge la table establishments sur la base master.
# 1. Via la variable d'environnement TENANT_CONFIGS (JSON) # Les nouveaux établissements sont immédiatement accessibles via leur sous-domaine
# 2. Via une implémentation DatabaseTenantRegistry (à implémenter) # sans redémarrage de l'application.
#
# 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)%'
services: services:
App\Shared\Infrastructure\Tenant\TenantRegistry: App\Shared\Infrastructure\Tenant\DoctrineTenantRegistry:
class: App\Shared\Infrastructure\Tenant\InMemoryTenantRegistry
factory: ['@App\Shared\Infrastructure\Tenant\TenantRegistryFactory', 'createFromEnv']
arguments: 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

View File

@@ -247,12 +247,20 @@ services:
$homeworkSanitizer: '@html_sanitizer.sanitizer.homework_sanitizer' $homeworkSanitizer: '@html_sanitizer.sanitizer.homework_sanitizer'
App\Scolarite\Application\Port\FileStorage: App\Scolarite\Application\Port\FileStorage:
alias: App\Scolarite\Infrastructure\Storage\LocalFileStorage alias: App\Scolarite\Infrastructure\Storage\S3FileStorage
App\Scolarite\Infrastructure\Storage\LocalFileStorage: App\Scolarite\Infrastructure\Storage\LocalFileStorage:
arguments: arguments:
$storagePath: '%kernel.project_dir%/var/storage' $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) # Schedule (Story 4.1 - Emploi du temps)
App\Scolarite\Domain\Repository\ScheduleSlotRepository: App\Scolarite\Domain\Repository\ScheduleSlotRepository:
alias: App\Scolarite\Infrastructure\Persistence\Doctrine\DoctrineScheduleSlotRepository alias: App\Scolarite\Infrastructure\Persistence\Doctrine\DoctrineScheduleSlotRepository
@@ -298,12 +306,18 @@ services:
App\Scolarite\Domain\Service\AverageCalculator: App\Scolarite\Domain\Service\AverageCalculator:
autowire: true autowire: true
App\Scolarite\Domain\Service\TeacherStatisticsCalculator:
autowire: true
App\Scolarite\Application\Service\RecalculerMoyennesService: App\Scolarite\Application\Service\RecalculerMoyennesService:
autowire: true autowire: true
App\Scolarite\Application\Port\PeriodFinder: App\Scolarite\Application\Port\PeriodFinder:
alias: App\Scolarite\Infrastructure\Service\DoctrinePeriodFinder alias: App\Scolarite\Infrastructure\Service\DoctrinePeriodFinder
App\Scolarite\Application\Port\TeacherStatisticsReader:
alias: App\Scolarite\Infrastructure\ReadModel\DbalTeacherStatisticsReader
App\Scolarite\Infrastructure\Persistence\Doctrine\DoctrineEvaluationStatisticsRepository: App\Scolarite\Infrastructure\Persistence\Doctrine\DoctrineEvaluationStatisticsRepository:
autowire: true autowire: true
@@ -333,6 +347,23 @@ services:
App\SuperAdmin\Domain\Repository\EstablishmentRepository: App\SuperAdmin\Domain\Repository\EstablishmentRepository:
alias: App\SuperAdmin\Infrastructure\Persistence\Doctrine\DoctrineEstablishmentRepository 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) # School Calendar Repository (Story 2.11 - Calendrier scolaire)
App\Administration\Domain\Model\SchoolCalendar\SchoolCalendarRepository: App\Administration\Domain\Model\SchoolCalendar\SchoolCalendarRepository:
alias: App\Administration\Infrastructure\Persistence\Doctrine\DoctrineSchoolCalendarRepository alias: App\Administration\Infrastructure\Persistence\Doctrine\DoctrineSchoolCalendarRepository

181
backend/seed-story-6.9.sql Normal file
View File

@@ -0,0 +1,181 @@
-- =============================================================================
-- Jeu de données local — Story 6.9 : GradeVoter & accès notes
-- =============================================================================
-- Prérequis : avoir lancé `make generate-demo` sur un tenant (ecole-beta par défaut)
-- Usage :
-- docker compose exec -T php php bin/console dbal:run-sql "$(cat backend/seed-story-6.9.sql)"
--
-- Mot de passe de tous les comptes démo : DemoPassword123!
-- URL : http://ecole-beta.classeo.local:5174/login
--
-- ┌──────────────────────────────────────────────────────────────────────────┐
-- │ Scénarios à tester │
-- │ │
-- │ AC1 — GradeVoter │
-- │ ✅ Amina Benali (prof maths, affectée 6A) → peut saisir notes │
-- │ ❌ Sophie Lambert (prof SVT, pas affectée 6A en maths) → bloquée │
-- │ ✅ David Nguyen (remplaçant actif maths 6A) → peut saisir notes │
-- │ │
-- │ AC2 — Retrait d'affectation │
-- │ ❌ Julie Caron (prof français, affectation retirée 6A) │
-- │ → peut voir les notes existantes, ne peut plus en saisir │
-- │ │
-- │ AC3 — Badge "Remplaçant" │
-- │ → Se connecter avec David Nguyen, saisir une note │
-- │ → Le badge violet "Remplaçant" apparaît sur la ligne │
-- │ │
-- │ AC4 — Parent StudentVoter │
-- │ ✅ Nadia Martin → voit les notes de Lina Martin (6A) │
-- │ ❌ Claire Bernard → ne voit PAS les notes de Lina Martin │
-- └──────────────────────────────────────────────────────────────────────────┘
DO $$
DECLARE
v_tenant_id UUID;
v_class_6a_id UUID;
v_subject_math_id UUID;
v_subject_fr_id UUID;
v_teacher_amina_id UUID; -- prof maths, affectée
v_teacher_julie_id UUID; -- prof français, affectation retirée
v_teacher_david_id UUID; -- prof anglais, utilisé comme remplaçant
v_student_lina_id UUID;
v_student_chloe_id UUID;
v_student_hugo_id UUID;
v_student_zoe_id UUID;
v_eval_math_id UUID;
v_eval_fr_id UUID;
v_subdomain TEXT := 'ecole-beta';
BEGIN
-- Résoudre le tenant via les utilisateurs démo
SELECT tenant_id INTO v_tenant_id
FROM users WHERE email = 'prof.amina.benali.' || v_subdomain || '@classeo.test' LIMIT 1;
IF v_tenant_id IS NULL THEN
RAISE EXCEPTION 'Aucun compte démo trouvé pour %. Lancez `make generate-demo` d''abord.', v_subdomain;
END IF;
-- Résoudre les entités de démo existantes
SELECT id INTO v_class_6a_id FROM school_classes WHERE tenant_id = v_tenant_id AND name = '6A';
SELECT id INTO v_subject_math_id FROM subjects WHERE tenant_id = v_tenant_id AND code = 'MATH';
SELECT id INTO v_subject_fr_id FROM subjects WHERE tenant_id = v_tenant_id AND code = 'FR';
SELECT id INTO v_teacher_amina_id FROM users WHERE tenant_id = v_tenant_id AND email = 'prof.amina.benali.' || v_subdomain || '@classeo.test';
SELECT id INTO v_teacher_julie_id FROM users WHERE tenant_id = v_tenant_id AND email = 'prof.julie.caron.' || v_subdomain || '@classeo.test';
SELECT id INTO v_teacher_david_id FROM users WHERE tenant_id = v_tenant_id AND email = 'prof.david.nguyen.' || v_subdomain || '@classeo.test';
SELECT id INTO v_student_lina_id FROM users WHERE tenant_id = v_tenant_id AND email = 'eleve.lina.martin.' || v_subdomain || '@classeo.test';
SELECT id INTO v_student_chloe_id FROM users WHERE tenant_id = v_tenant_id AND email = 'eleve.chloe.lopez.' || v_subdomain || '@classeo.test';
SELECT id INTO v_student_hugo_id FROM users WHERE tenant_id = v_tenant_id AND email = 'eleve.hugo.lopez.' || v_subdomain || '@classeo.test';
SELECT id INTO v_student_zoe_id FROM users WHERE tenant_id = v_tenant_id AND email = 'eleve.zoe.moreau.' || v_subdomain || '@classeo.test';
-- Vérifications
IF v_class_6a_id IS NULL THEN RAISE EXCEPTION 'Classe 6A introuvable.'; END IF;
IF v_subject_math_id IS NULL THEN RAISE EXCEPTION 'Matière MATH introuvable.'; END IF;
IF v_teacher_amina_id IS NULL THEN RAISE EXCEPTION 'Prof Amina introuvable.'; END IF;
IF v_teacher_david_id IS NULL THEN RAISE EXCEPTION 'Prof David introuvable.'; END IF;
IF v_student_lina_id IS NULL THEN RAISE EXCEPTION 'Élève Lina introuvable.'; END IF;
-- =========================================================================
-- 1. Évaluation de maths en 6A (propriétaire = Amina, affectée)
-- =========================================================================
v_eval_math_id := gen_random_uuid();
INSERT INTO evaluations (id, tenant_id, class_id, subject_id, teacher_id, title, description, evaluation_date, grade_scale, coefficient, status, created_at, updated_at, grades_published_at)
VALUES (v_eval_math_id, v_tenant_id, v_class_6a_id, v_subject_math_id, v_teacher_amina_id,
'Contrôle — Fractions', 'Chapitre 4 : fractions et décimaux',
CURRENT_DATE - INTERVAL '3 days', 20, 1.0, 'published', NOW(), NOW(),
NOW() - INTERVAL '2 days');
-- Notes pour les 4 élèves de 6A
-- Lina et Hugo : saisies par David (remplaçant) → badge "Remplaçant" visible
-- Chloe et Zoe : saisies par Amina (titulaire) → pas de badge
INSERT INTO grades (id, tenant_id, evaluation_id, student_id, value, status, created_by, created_at, updated_at)
VALUES
(gen_random_uuid(), v_tenant_id, v_eval_math_id, v_student_lina_id, 16.5, 'graded', v_teacher_david_id, NOW(), NOW()),
(gen_random_uuid(), v_tenant_id, v_eval_math_id, v_student_chloe_id, 12.0, 'graded', v_teacher_amina_id, NOW(), NOW()),
(gen_random_uuid(), v_tenant_id, v_eval_math_id, v_student_hugo_id, NULL, 'absent', v_teacher_david_id, NOW(), NOW()),
(gen_random_uuid(), v_tenant_id, v_eval_math_id, v_student_zoe_id, 18.0, 'graded', v_teacher_amina_id, NOW(), NOW());
-- Statistiques
INSERT INTO evaluation_statistics (evaluation_id, average, min_grade, max_grade, median_grade, graded_count, updated_at)
VALUES (v_eval_math_id, 15.5, 12.0, 18.0, 16.5, 3, NOW())
ON CONFLICT (evaluation_id) DO NOTHING;
-- =========================================================================
-- 2. Évaluation de français en 6A (propriétaire = Julie)
-- Julie aura son affectation RETIRÉE → AC2
-- =========================================================================
v_eval_fr_id := gen_random_uuid();
INSERT INTO evaluations (id, tenant_id, class_id, subject_id, teacher_id, title, description, evaluation_date, grade_scale, coefficient, status, created_at, updated_at, grades_published_at)
VALUES (v_eval_fr_id, v_tenant_id, v_class_6a_id, v_subject_fr_id, v_teacher_julie_id,
'Dictée — Les Misérables', NULL,
CURRENT_DATE - INTERVAL '5 days', 20, 1.0, 'published', NOW(), NOW(),
NOW() - INTERVAL '4 days');
INSERT INTO grades (id, tenant_id, evaluation_id, student_id, value, status, created_by, created_at, updated_at)
VALUES
(gen_random_uuid(), v_tenant_id, v_eval_fr_id, v_student_lina_id, 14.0, 'graded', v_teacher_julie_id, NOW(), NOW()),
(gen_random_uuid(), v_tenant_id, v_eval_fr_id, v_student_chloe_id, 17.5, 'graded', v_teacher_julie_id, NOW(), NOW());
INSERT INTO evaluation_statistics (evaluation_id, average, min_grade, max_grade, median_grade, graded_count, updated_at)
VALUES (v_eval_fr_id, 15.75, 14.0, 17.5, 15.75, 2, NOW())
ON CONFLICT (evaluation_id) DO NOTHING;
-- Retirer l'affectation français 6A de Julie → AC2
UPDATE teacher_assignments
SET status = 'removed', end_date = NOW(), updated_at = NOW()
WHERE tenant_id = v_tenant_id
AND teacher_id = v_teacher_julie_id
AND school_class_id = v_class_6a_id
AND subject_id = v_subject_fr_id;
-- =========================================================================
-- 3. Remplacement actif : David Nguyen remplace Amina en maths 6A → AC1/AC3
-- =========================================================================
INSERT INTO teacher_replacements (id, tenant_id, replaced_teacher_id, replacement_teacher_id, start_date, end_date, status, reason, created_by, created_at, updated_at)
VALUES (gen_random_uuid(), v_tenant_id, v_teacher_amina_id, v_teacher_david_id,
CURRENT_DATE - INTERVAL '1 day', CURRENT_DATE + INTERVAL '14 days',
'active', 'Formation continue — 2 semaines',
v_teacher_amina_id, NOW(), NOW());
-- Lier le remplacement à la classe/matière maths 6A
INSERT INTO replacement_classes (replacement_id, class_id, subject_id)
SELECT tr.id, v_class_6a_id, v_subject_math_id
FROM teacher_replacements tr
WHERE tr.tenant_id = v_tenant_id
AND tr.replacement_teacher_id = v_teacher_david_id
AND tr.replaced_teacher_id = v_teacher_amina_id
AND tr.status = 'active';
-- =========================================================================
-- Résumé
-- =========================================================================
RAISE NOTICE '';
RAISE NOTICE '══════════════════════════════════════════════════════════════';
RAISE NOTICE ' Story 6.9 — Jeu de données créé !';
RAISE NOTICE '══════════════════════════════════════════════════════════════';
RAISE NOTICE ' URL : http://ecole-beta.classeo.local:5174/login';
RAISE NOTICE ' Mot de passe : DemoPassword123!';
RAISE NOTICE '';
RAISE NOTICE ' AC1 — Enseignant affecté :';
RAISE NOTICE ' prof.amina.benali.ecole-beta@classeo.test → saisie maths 6A ✅';
RAISE NOTICE '';
RAISE NOTICE ' AC1 — Enseignant non affecté :';
RAISE NOTICE ' prof.sophie.lambert.ecole-beta@classeo.test';
RAISE NOTICE ' → /dashboard/teacher/evaluations/%/grades → bloqué ❌', v_eval_math_id;
RAISE NOTICE '';
RAISE NOTICE ' AC1/AC3 — Remplaçant actif + badge :';
RAISE NOTICE ' prof.david.nguyen.ecole-beta@classeo.test';
RAISE NOTICE ' → /dashboard/teacher/evaluations/%/grades → saisie ✅ + badge', v_eval_math_id;
RAISE NOTICE '';
RAISE NOTICE ' AC2 — Affectation retirée (lecture seule) :';
RAISE NOTICE ' prof.julie.caron.ecole-beta@classeo.test';
RAISE NOTICE ' → /dashboard/teacher/evaluations/%/grades → lecture ✅ saisie ❌', v_eval_fr_id;
RAISE NOTICE '';
RAISE NOTICE ' AC4 — Parent lié / non lié :';
RAISE NOTICE ' parent.nadia.martin.ecole-beta@classeo.test → notes Lina ✅';
RAISE NOTICE ' parent.claire.bernard.ecole-beta@classeo.test → pas Lina ❌';
RAISE NOTICE '══════════════════════════════════════════════════════════════';
END $$;

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Scolarite\Application\Command\PublishGrades; namespace App\Scolarite\Application\Command\PublishGrades;
use App\Administration\Domain\Model\User\UserId; use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Application\Service\AutorisationSaisieNotesChecker;
use App\Scolarite\Domain\Exception\AucuneNoteSaisieException; use App\Scolarite\Domain\Exception\AucuneNoteSaisieException;
use App\Scolarite\Domain\Exception\NonProprietaireDeLEvaluationException; use App\Scolarite\Domain\Exception\NonProprietaireDeLEvaluationException;
use App\Scolarite\Domain\Model\Evaluation\Evaluation; use App\Scolarite\Domain\Model\Evaluation\Evaluation;
@@ -21,6 +22,7 @@ final readonly class PublishGradesHandler
public function __construct( public function __construct(
private EvaluationRepository $evaluationRepository, private EvaluationRepository $evaluationRepository,
private GradeRepository $gradeRepository, private GradeRepository $gradeRepository,
private AutorisationSaisieNotesChecker $autorisationChecker,
private Clock $clock, private Clock $clock,
) { ) {
} }
@@ -34,8 +36,8 @@ final readonly class PublishGradesHandler
$evaluation = $this->evaluationRepository->get($evaluationId, $tenantId); $evaluation = $this->evaluationRepository->get($evaluationId, $tenantId);
if ((string) $evaluation->teacherId !== (string) $teacherId) { if (!$this->autorisationChecker->peutSaisirNotes($teacherId, $evaluation, $tenantId, $now)) {
throw NonProprietaireDeLEvaluationException::withId($evaluationId); throw NonProprietaireDeLEvaluationException::nonAffecte($evaluationId);
} }
if (!$this->gradeRepository->hasGradesForEvaluation($evaluationId, $tenantId)) { if (!$this->gradeRepository->hasGradesForEvaluation($evaluationId, $tenantId)) {

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Scolarite\Application\Command\SaveAppreciation; namespace App\Scolarite\Application\Command\SaveAppreciation;
use App\Administration\Domain\Model\User\UserId; use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Application\Service\AutorisationSaisieNotesChecker;
use App\Scolarite\Domain\Exception\NonProprietaireDeLEvaluationException; use App\Scolarite\Domain\Exception\NonProprietaireDeLEvaluationException;
use App\Scolarite\Domain\Model\Grade\Grade; use App\Scolarite\Domain\Model\Grade\Grade;
use App\Scolarite\Domain\Model\Grade\GradeId; use App\Scolarite\Domain\Model\Grade\GradeId;
@@ -20,6 +21,7 @@ final readonly class SaveAppreciationHandler
public function __construct( public function __construct(
private EvaluationRepository $evaluationRepository, private EvaluationRepository $evaluationRepository,
private GradeRepository $gradeRepository, private GradeRepository $gradeRepository,
private AutorisationSaisieNotesChecker $autorisationChecker,
private Clock $clock, private Clock $clock,
) { ) {
} }
@@ -33,12 +35,13 @@ final readonly class SaveAppreciationHandler
$grade = $this->gradeRepository->get($gradeId, $tenantId); $grade = $this->gradeRepository->get($gradeId, $tenantId);
$evaluation = $this->evaluationRepository->get($grade->evaluationId, $tenantId); $evaluation = $this->evaluationRepository->get($grade->evaluationId, $tenantId);
$now = $this->clock->now();
if ((string) $evaluation->teacherId !== (string) $teacherId) { if (!$this->autorisationChecker->peutSaisirNotes($teacherId, $evaluation, $tenantId, $now)) {
throw NonProprietaireDeLEvaluationException::withId($grade->evaluationId); throw NonProprietaireDeLEvaluationException::nonAffecte($grade->evaluationId);
} }
$grade->saisirAppreciation($command->appreciation, $this->clock->now()); $grade->saisirAppreciation($command->appreciation, $now);
$this->gradeRepository->saveAppreciation($grade); $this->gradeRepository->saveAppreciation($grade);
return $grade; return $grade;

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Scolarite\Application\Command\SaveGrades; namespace App\Scolarite\Application\Command\SaveGrades;
use App\Administration\Domain\Model\User\UserId; use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Application\Service\AutorisationSaisieNotesChecker;
use App\Scolarite\Domain\Exception\NonProprietaireDeLEvaluationException; use App\Scolarite\Domain\Exception\NonProprietaireDeLEvaluationException;
use App\Scolarite\Domain\Model\Evaluation\EvaluationId; use App\Scolarite\Domain\Model\Evaluation\EvaluationId;
use App\Scolarite\Domain\Model\Grade\Grade; use App\Scolarite\Domain\Model\Grade\Grade;
@@ -22,6 +23,7 @@ final readonly class SaveGradesHandler
public function __construct( public function __construct(
private EvaluationRepository $evaluationRepository, private EvaluationRepository $evaluationRepository,
private GradeRepository $gradeRepository, private GradeRepository $gradeRepository,
private AutorisationSaisieNotesChecker $autorisationChecker,
private Clock $clock, private Clock $clock,
) { ) {
} }
@@ -36,8 +38,8 @@ final readonly class SaveGradesHandler
$evaluation = $this->evaluationRepository->get($evaluationId, $tenantId); $evaluation = $this->evaluationRepository->get($evaluationId, $tenantId);
if ((string) $evaluation->teacherId !== (string) $teacherId) { if (!$this->autorisationChecker->peutSaisirNotes($teacherId, $evaluation, $tenantId, $now)) {
throw NonProprietaireDeLEvaluationException::withId($evaluationId); throw NonProprietaireDeLEvaluationException::nonAffecte($evaluationId);
} }
$existingGrades = $this->gradeRepository->findByEvaluation($evaluationId, $tenantId); $existingGrades = $this->gradeRepository->findByEvaluation($evaluationId, $tenantId);

View File

@@ -12,4 +12,9 @@ interface FileStorage
public function upload(string $path, mixed $content, string $mimeType): string; public function upload(string $path, mixed $content, string $mimeType): string;
public function delete(string $path): void; public function delete(string $path): void;
/**
* @return resource
*/
public function readStream(string $path): mixed;
} }

View File

@@ -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;
}

View File

@@ -6,22 +6,26 @@ namespace App\Scolarite\Application\Query\GetBlockedDates;
use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendarRepository; use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendarRepository;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId; 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 App\Shared\Domain\Tenant\TenantId;
use DateInterval; use DateInterval;
use DateTimeImmutable; use DateTimeImmutable;
use Symfony\Component\Messenger\Attribute\AsMessageHandler; use Symfony\Component\Messenger\Attribute\AsMessageHandler;
/** /**
* Retourne les dates bloquées (jours fériés, vacances, journées pédagogiques, weekends) * Retourne les dates bloquées (jours fériés, vacances, journées pédagogiques, weekends,
* pour une plage de dates donnée. * 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')] #[AsMessageHandler(bus: 'query.bus')]
final readonly class GetBlockedDatesHandler final readonly class GetBlockedDatesHandler
{ {
public function __construct( public function __construct(
private SchoolCalendarRepository $calendarRepository, private SchoolCalendarRepository $calendarRepository,
private HomeworkRulesChecker $rulesChecker,
private Clock $clock,
) { ) {
} }
@@ -37,6 +41,7 @@ final readonly class GetBlockedDatesHandler
$endDate = new DateTimeImmutable($query->endDate); $endDate = new DateTimeImmutable($query->endDate);
$oneDay = new DateInterval('P1D'); $oneDay = new DateInterval('P1D');
$now = $this->clock->now();
$blockedDates = []; $blockedDates = [];
$current = $startDate; $current = $startDate;
@@ -50,15 +55,22 @@ final readonly class GetBlockedDatesHandler
reason: $dayOfWeek === 6 ? 'Samedi' : 'Dimanche', reason: $dayOfWeek === 6 ? 'Samedi' : 'Dimanche',
type: 'weekend', type: 'weekend',
); );
} elseif ($calendar !== null) { } elseif ($calendar !== null && ($entry = $calendar->trouverEntreePourDate($current)) !== null) {
$entry = $calendar->trouverEntreePourDate($current);
if ($entry !== null) {
$blockedDates[] = new BlockedDateDto( $blockedDates[] = new BlockedDateDto(
date: $dateStr, date: $dateStr,
reason: $entry->label, reason: $entry->label,
type: $entry->type->value, type: $entry->type->value,
); );
} else {
$dueDate = new DateTimeImmutable($dateStr);
$result = $this->rulesChecker->verifier($tenantId, $dueDate, $now);
if (!$result->estValide()) {
$blockedDates[] = new BlockedDateDto(
date: $dateStr,
reason: $result->messages()[0] ?? 'Règle de devoirs',
type: $result->estBloquant() ? 'rule_hard' : 'rule_soft',
);
} }
} }

View File

@@ -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,
) {
}
}

View File

@@ -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,
);
}
}

View File

@@ -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,
) {
}
}

View File

@@ -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,
) {
}
}

View File

@@ -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,
) {
}
}

View File

@@ -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;
}
}

View File

@@ -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,
) {
}
}

View File

@@ -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,
);
}
}

View File

@@ -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,
) {
}
}

View File

@@ -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,
) {
}
}

View File

@@ -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,
) {
}
}

View File

@@ -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,
) {
}
}

View File

@@ -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,
);
}
}

View File

@@ -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,
) {
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Service;
use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Application\Port\EnseignantAffectationChecker;
use App\Scolarite\Domain\Model\Evaluation\Evaluation;
use App\Scolarite\Domain\Repository\TeacherReplacementRepository;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
final readonly class AutorisationSaisieNotesChecker
{
public function __construct(
private EnseignantAffectationChecker $affectationChecker,
private TeacherReplacementRepository $replacementRepository,
) {
}
public function peutSaisirNotes(
UserId $teacherId,
Evaluation $evaluation,
TenantId $tenantId,
DateTimeImmutable $now,
): bool {
if ($this->affectationChecker->estAffecte(
$teacherId,
$evaluation->classId,
$evaluation->subjectId,
$tenantId,
)) {
return true;
}
return $this->replacementRepository->findActiveReplacement(
$teacherId,
$evaluation->classId,
$evaluation->subjectId,
$now,
$tenantId,
) !== null;
}
}

View File

@@ -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 : '';
}
}

View File

@@ -18,4 +18,12 @@ final class NonProprietaireDeLEvaluationException extends DomainException
$id, $id,
)); ));
} }
public static function nonAffecte(EvaluationId $id): self
{
return new self(sprintf(
'Accès refusé aux notes de l\'évaluation "%s" : aucune affectation active ni remplacement en cours.',
$id,
));
}
} }

View File

@@ -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 : [02.5[, [2.55[, [57.5[, [7.510[, [1012.5[, [12.515[, [1517.5[, [17.520].
*
* @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);
}
}

View 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,
) {
}
}

View File

@@ -16,16 +16,16 @@ use App\Scolarite\Domain\Repository\HomeworkRepository;
use App\Shared\Domain\Tenant\TenantId; use App\Shared\Domain\Tenant\TenantId;
use function array_map; use function array_map;
use function realpath; use function fclose;
use function str_starts_with; use function fpassthru;
use RuntimeException;
use Symfony\Bundle\SecurityBundle\Security; use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\HttpFoundation\HeaderUtils;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; 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\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
@@ -39,8 +39,6 @@ final readonly class HomeworkAttachmentController
private HomeworkAttachmentRepository $attachmentRepository, private HomeworkAttachmentRepository $attachmentRepository,
private UploadHomeworkAttachmentHandler $uploadHandler, private UploadHomeworkAttachmentHandler $uploadHandler,
private FileStorage $fileStorage, 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'])] #[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(); $user = $this->getSecurityUser();
$tenantId = TenantId::fromString($user->tenantId()); $tenantId = TenantId::fromString($user->tenantId());
@@ -143,20 +141,29 @@ final readonly class HomeworkAttachmentController
foreach ($attachments as $attachment) { foreach ($attachments as $attachment) {
if ((string) $attachment->id === $attachmentId) { if ((string) $attachment->id === $attachmentId) {
$fullPath = $this->storageDir . '/' . $attachment->filePath; try {
$realPath = realpath($fullPath); $stream = $this->fileStorage->readStream($attachment->filePath);
$realStorageDir = realpath($this->storageDir); } catch (RuntimeException) {
if ($realPath === false || $realStorageDir === false || !str_starts_with($realPath, $realStorageDir)) {
throw new NotFoundHttpException('Pièce jointe non trouvée.'); throw new NotFoundHttpException('Pièce jointe non trouvée.');
} }
$response = new BinaryFileResponse($realPath); $response = new StreamedResponse(static function () use ($stream): void {
$response->setContentDisposition( try {
ResponseHeaderBag::DISPOSITION_INLINE, fpassthru($stream);
} finally {
fclose($stream);
}
});
$disposition = HeaderUtils::makeDisposition(
HeaderUtils::DISPOSITION_INLINE,
$attachment->filename, $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; return $response;
} }
} }

View File

@@ -5,6 +5,8 @@ declare(strict_types=1);
namespace App\Scolarite\Infrastructure\Api\Controller; namespace App\Scolarite\Infrastructure\Api\Controller;
use App\Administration\Infrastructure\Security\SecurityUser; use App\Administration\Infrastructure\Security\SecurityUser;
use App\Administration\Infrastructure\Security\StudentGuardianVoter;
use App\Scolarite\Application\Port\ParentChildrenReader;
use App\Scolarite\Application\Query\GetChildrenGrades\ChildGradesDto; use App\Scolarite\Application\Query\GetChildrenGrades\ChildGradesDto;
use App\Scolarite\Application\Query\GetChildrenGrades\ChildGradesSummaryDto; use App\Scolarite\Application\Query\GetChildrenGrades\ChildGradesSummaryDto;
use App\Scolarite\Application\Query\GetChildrenGrades\GetChildrenGradesHandler; use App\Scolarite\Application\Query\GetChildrenGrades\GetChildrenGradesHandler;
@@ -13,6 +15,7 @@ use App\Scolarite\Application\Query\GetChildrenGrades\GetChildrenGradesSummaryHa
use App\Scolarite\Application\Query\GetChildrenGrades\GetChildrenGradesSummaryQuery; use App\Scolarite\Application\Query\GetChildrenGrades\GetChildrenGradesSummaryQuery;
use App\Scolarite\Application\Query\GetChildrenGrades\ParentGradeDto; use App\Scolarite\Application\Query\GetChildrenGrades\ParentGradeDto;
use App\Scolarite\Infrastructure\Security\GradeParentVoter; use App\Scolarite\Infrastructure\Security\GradeParentVoter;
use App\Shared\Domain\Tenant\TenantId;
use function array_map; use function array_map;
use function is_string; use function is_string;
@@ -35,6 +38,7 @@ final readonly class ParentGradeController
private Security $security, private Security $security,
private GetChildrenGradesHandler $gradesHandler, private GetChildrenGradesHandler $gradesHandler,
private GetChildrenGradesSummaryHandler $summaryHandler, private GetChildrenGradesSummaryHandler $summaryHandler,
private ParentChildrenReader $parentChildrenReader,
) { ) {
} }
@@ -42,6 +46,7 @@ final readonly class ParentGradeController
* Notes d'un enfant spécifique. * Notes d'un enfant spécifique.
*/ */
#[Route('/api/me/children/{childId}/grades', name: 'api_parent_child_grades', methods: ['GET'])] #[Route('/api/me/children/{childId}/grades', name: 'api_parent_child_grades', methods: ['GET'])]
#[IsGranted(StudentGuardianVoter::VIEW_STUDENT, subject: 'childId')]
public function childGrades(string $childId): JsonResponse public function childGrades(string $childId): JsonResponse
{ {
$user = $this->getSecurityUser(); $user = $this->getSecurityUser();
@@ -65,6 +70,7 @@ final readonly class ParentGradeController
* Notes d'un enfant filtrées par matière. * Notes d'un enfant filtrées par matière.
*/ */
#[Route('/api/me/children/{childId}/grades/subject/{subjectId}', name: 'api_parent_child_grades_by_subject', methods: ['GET'])] #[Route('/api/me/children/{childId}/grades/subject/{subjectId}', name: 'api_parent_child_grades_by_subject', methods: ['GET'])]
#[IsGranted(StudentGuardianVoter::VIEW_STUDENT, subject: 'childId')]
public function childGradesBySubject(string $childId, string $subjectId): JsonResponse public function childGradesBySubject(string $childId, string $subjectId): JsonResponse
{ {
$user = $this->getSecurityUser(); $user = $this->getSecurityUser();
@@ -93,6 +99,15 @@ final readonly class ParentGradeController
{ {
$user = $this->getSecurityUser(); $user = $this->getSecurityUser();
$children = $this->parentChildrenReader->childrenOf(
$user->userId(),
TenantId::fromString($user->tenantId()),
);
if ($children === []) {
throw new AccessDeniedHttpException('Aucun enfant lié à ce compte parent.');
}
$periodId = $request->query->get('periodId'); $periodId = $request->query->get('periodId');
$summaries = ($this->summaryHandler)(new GetChildrenGradesSummaryQuery( $summaries = ($this->summaryHandler)(new GetChildrenGradesSummaryQuery(

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Scolarite\Infrastructure\Api\Controller; namespace App\Scolarite\Infrastructure\Api\Controller;
use App\Administration\Infrastructure\Security\SecurityUser; 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\ChildHomeworkDto;
use App\Scolarite\Application\Query\GetChildrenHomework\GetChildrenHomeworkDetailHandler; use App\Scolarite\Application\Query\GetChildrenHomework\GetChildrenHomeworkDetailHandler;
use App\Scolarite\Application\Query\GetChildrenHomework\GetChildrenHomeworkHandler; use App\Scolarite\Application\Query\GetChildrenHomework\GetChildrenHomeworkHandler;
@@ -18,16 +19,16 @@ use App\Scolarite\Infrastructure\Security\HomeworkParentVoter;
use App\Shared\Domain\Tenant\TenantId; use App\Shared\Domain\Tenant\TenantId;
use function array_map; use function array_map;
use function fclose;
use function fpassthru;
use function is_string; use function is_string;
use function realpath;
use function str_starts_with;
use RuntimeException;
use Symfony\Bundle\SecurityBundle\Security; use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\HttpFoundation\HeaderUtils;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request; 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\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
@@ -45,8 +46,7 @@ final readonly class ParentHomeworkController
private GetChildrenHomeworkDetailHandler $detailHandler, private GetChildrenHomeworkDetailHandler $detailHandler,
private HomeworkRepository $homeworkRepository, private HomeworkRepository $homeworkRepository,
private HomeworkAttachmentRepository $attachmentRepository, private HomeworkAttachmentRepository $attachmentRepository,
#[Autowire('%kernel.project_dir%/var/storage')] private FileStorage $fileStorage,
private string $uploadsDir,
) { ) {
} }
@@ -116,7 +116,7 @@ final readonly class ParentHomeworkController
* Téléchargement d'une pièce jointe (parent). * 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'])] #[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(); $user = $this->getSecurityUser();
$tenantId = TenantId::fromString($user->tenantId()); $tenantId = TenantId::fromString($user->tenantId());
@@ -138,20 +138,29 @@ final readonly class ParentHomeworkController
foreach ($attachments as $attachment) { foreach ($attachments as $attachment) {
if ((string) $attachment->id === $attachmentId) { if ((string) $attachment->id === $attachmentId) {
$fullPath = $this->uploadsDir . '/' . $attachment->filePath; try {
$realPath = realpath($fullPath); $stream = $this->fileStorage->readStream($attachment->filePath);
$realUploadsDir = realpath($this->uploadsDir); } catch (RuntimeException) {
if ($realPath === false || $realUploadsDir === false || !str_starts_with($realPath, $realUploadsDir)) {
throw new NotFoundHttpException('Pièce jointe non trouvée.'); throw new NotFoundHttpException('Pièce jointe non trouvée.');
} }
$response = new BinaryFileResponse($realPath); $response = new StreamedResponse(static function () use ($stream): void {
$response->setContentDisposition( try {
ResponseHeaderBag::DISPOSITION_INLINE, fpassthru($stream);
} finally {
fclose($stream);
}
});
$disposition = HeaderUtils::makeDisposition(
HeaderUtils::DISPOSITION_INLINE,
$attachment->filename, $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; return $response;
} }
} }

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Scolarite\Infrastructure\Api\Controller; namespace App\Scolarite\Infrastructure\Api\Controller;
use App\Administration\Infrastructure\Security\SecurityUser; use App\Administration\Infrastructure\Security\SecurityUser;
use App\Scolarite\Application\Port\FileStorage;
use App\Scolarite\Application\Port\ScheduleDisplayReader; use App\Scolarite\Application\Port\ScheduleDisplayReader;
use App\Scolarite\Application\Port\StudentClassReader; use App\Scolarite\Application\Port\StudentClassReader;
use App\Scolarite\Application\Query\GetStudentHomework\GetStudentHomeworkHandler; use App\Scolarite\Application\Query\GetStudentHomework\GetStudentHomeworkHandler;
@@ -19,16 +20,16 @@ use App\Scolarite\Infrastructure\Security\HomeworkStudentVoter;
use App\Shared\Domain\Tenant\TenantId; use App\Shared\Domain\Tenant\TenantId;
use function array_map; use function array_map;
use function fclose;
use function fpassthru;
use function is_string; use function is_string;
use function realpath;
use function str_starts_with;
use RuntimeException;
use Symfony\Bundle\SecurityBundle\Security; use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\HttpFoundation\HeaderUtils;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request; 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\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
@@ -44,8 +45,7 @@ final readonly class StudentHomeworkController
private HomeworkAttachmentRepository $attachmentRepository, private HomeworkAttachmentRepository $attachmentRepository,
private ScheduleDisplayReader $displayReader, private ScheduleDisplayReader $displayReader,
private StudentClassReader $studentClassReader, private StudentClassReader $studentClassReader,
#[Autowire('%kernel.project_dir%/var/storage')] private FileStorage $fileStorage,
private string $uploadsDir,
) { ) {
} }
@@ -98,7 +98,7 @@ final readonly class StudentHomeworkController
} }
#[Route('/api/me/homework/{homeworkId}/attachments/{attachmentId}', name: 'api_student_homework_attachment', methods: ['GET'])] #[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(); $user = $this->getSecurityUser();
$tenantId = TenantId::fromString($user->tenantId()); $tenantId = TenantId::fromString($user->tenantId());
@@ -115,20 +115,29 @@ final readonly class StudentHomeworkController
foreach ($attachments as $attachment) { foreach ($attachments as $attachment) {
if ((string) $attachment->id === $attachmentId) { if ((string) $attachment->id === $attachmentId) {
$fullPath = $this->uploadsDir . '/' . $attachment->filePath; try {
$realPath = realpath($fullPath); $stream = $this->fileStorage->readStream($attachment->filePath);
$realUploadsDir = realpath($this->uploadsDir); } catch (RuntimeException) {
if ($realPath === false || $realUploadsDir === false || !str_starts_with($realPath, $realUploadsDir)) {
throw new NotFoundHttpException('Pièce jointe non trouvée.'); throw new NotFoundHttpException('Pièce jointe non trouvée.');
} }
$response = new BinaryFileResponse($realPath); $response = new StreamedResponse(static function () use ($stream): void {
$response->setContentDisposition( try {
ResponseHeaderBag::DISPOSITION_INLINE, fpassthru($stream);
} finally {
fclose($stream);
}
});
$disposition = HeaderUtils::makeDisposition(
HeaderUtils::DISPOSITION_INLINE,
$attachment->filename, $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; return $response;
} }
} }

View File

@@ -6,6 +6,7 @@ namespace App\Scolarite\Infrastructure\Api\Controller;
use App\Administration\Infrastructure\Security\SecurityUser; use App\Administration\Infrastructure\Security\SecurityUser;
use App\Scolarite\Application\Port\ClassStudentsReader; use App\Scolarite\Application\Port\ClassStudentsReader;
use App\Scolarite\Application\Port\FileStorage;
use App\Scolarite\Domain\Model\Homework\HomeworkId; use App\Scolarite\Domain\Model\Homework\HomeworkId;
use App\Scolarite\Domain\Model\HomeworkSubmission\HomeworkSubmission; use App\Scolarite\Domain\Model\HomeworkSubmission\HomeworkSubmission;
use App\Scolarite\Domain\Model\HomeworkSubmission\HomeworkSubmissionId; use App\Scolarite\Domain\Model\HomeworkSubmission\HomeworkSubmissionId;
@@ -24,15 +25,15 @@ use function count;
use DateTimeImmutable; use DateTimeImmutable;
use function fclose;
use function fpassthru;
use function in_array; use function in_array;
use function realpath;
use function str_starts_with;
use RuntimeException;
use Symfony\Bundle\SecurityBundle\Security; use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\HttpFoundation\HeaderUtils;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\JsonResponse; 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\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
@@ -45,8 +46,7 @@ final readonly class TeacherSubmissionController
private HomeworkSubmissionRepository $submissionRepository, private HomeworkSubmissionRepository $submissionRepository,
private SubmissionAttachmentRepository $attachmentRepository, private SubmissionAttachmentRepository $attachmentRepository,
private ClassStudentsReader $classStudentsReader, private ClassStudentsReader $classStudentsReader,
#[Autowire('%kernel.project_dir%/var/storage')] private FileStorage $fileStorage,
private string $storageDir,
) { ) {
} }
@@ -240,7 +240,7 @@ final readonly class TeacherSubmissionController
} }
#[Route('/api/homework/{homeworkId}/submissions/{submissionId}/attachments/{attachmentId}', name: 'api_teacher_submission_attachment_download', methods: ['GET'])] #[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(); $user = $this->getSecurityUser();
$tenantId = TenantId::fromString($user->tenantId()); $tenantId = TenantId::fromString($user->tenantId());
@@ -268,20 +268,29 @@ final readonly class TeacherSubmissionController
foreach ($attachments as $attachment) { foreach ($attachments as $attachment) {
if ((string) $attachment->id === $attachmentId) { if ((string) $attachment->id === $attachmentId) {
$fullPath = $this->storageDir . '/' . $attachment->filePath; try {
$realPath = realpath($fullPath); $stream = $this->fileStorage->readStream($attachment->filePath);
$realStorageDir = realpath($this->storageDir); } catch (RuntimeException) {
if ($realPath === false || $realStorageDir === false || !str_starts_with($realPath, $realStorageDir)) {
throw new NotFoundHttpException('Pièce jointe non trouvée.'); throw new NotFoundHttpException('Pièce jointe non trouvée.');
} }
$response = new BinaryFileResponse($realPath); $response = new StreamedResponse(static function () use ($stream): void {
$response->setContentDisposition( try {
ResponseHeaderBag::DISPOSITION_INLINE, fpassthru($stream);
} finally {
fclose($stream);
}
});
$disposition = HeaderUtils::makeDisposition(
HeaderUtils::DISPOSITION_INLINE,
$attachment->filename, $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; return $response;
} }
} }

View File

@@ -12,12 +12,16 @@ use App\Scolarite\Application\Command\PublishGrades\PublishGradesHandler;
use App\Scolarite\Domain\Exception\AucuneNoteSaisieException; use App\Scolarite\Domain\Exception\AucuneNoteSaisieException;
use App\Scolarite\Domain\Exception\NonProprietaireDeLEvaluationException; use App\Scolarite\Domain\Exception\NonProprietaireDeLEvaluationException;
use App\Scolarite\Domain\Exception\NotesDejaPublieesException; use App\Scolarite\Domain\Exception\NotesDejaPublieesException;
use App\Scolarite\Domain\Model\Evaluation\EvaluationId;
use App\Scolarite\Domain\Repository\EvaluationRepository;
use App\Scolarite\Infrastructure\Api\Resource\GradeResource; use App\Scolarite\Infrastructure\Api\Resource\GradeResource;
use App\Scolarite\Infrastructure\Security\GradeVoter;
use App\Shared\Infrastructure\Tenant\TenantContext; use App\Shared\Infrastructure\Tenant\TenantContext;
use Override; use Override;
use Symfony\Bundle\SecurityBundle\Security; use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException; use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Messenger\MessageBusInterface;
@@ -28,6 +32,7 @@ final readonly class PublishGradesProcessor implements ProcessorInterface
{ {
public function __construct( public function __construct(
private PublishGradesHandler $handler, private PublishGradesHandler $handler,
private EvaluationRepository $evaluationRepository,
private TenantContext $tenantContext, private TenantContext $tenantContext,
private MessageBusInterface $eventBus, private MessageBusInterface $eventBus,
private Security $security, private Security $security,
@@ -53,9 +58,23 @@ final readonly class PublishGradesProcessor implements ProcessorInterface
/** @var string $evaluationId */ /** @var string $evaluationId */
$evaluationId = $uriVariables['evaluationId'] ?? ''; $evaluationId = $uriVariables['evaluationId'] ?? '';
$tenantId = $this->tenantContext->getCurrentTenantId();
$evaluation = $this->evaluationRepository->findById(
EvaluationId::fromString($evaluationId),
$tenantId,
);
if ($evaluation === null) {
throw new NotFoundHttpException('Évaluation non trouvée.');
}
if (!$this->security->isGranted(GradeVoter::EDIT, $evaluation)) {
throw new AccessDeniedHttpException('Accès refusé.');
}
try { try {
$command = new PublishGradesCommand( $command = new PublishGradesCommand(
tenantId: (string) $this->tenantContext->getCurrentTenantId(), tenantId: (string) $tenantId,
evaluationId: $evaluationId, evaluationId: $evaluationId,
teacherId: $user->userId(), teacherId: $user->userId(),
); );

View File

@@ -12,7 +12,11 @@ use App\Scolarite\Application\Command\SaveAppreciation\SaveAppreciationHandler;
use App\Scolarite\Domain\Exception\AppreciationTropLongueException; use App\Scolarite\Domain\Exception\AppreciationTropLongueException;
use App\Scolarite\Domain\Exception\GradeNotFoundException; use App\Scolarite\Domain\Exception\GradeNotFoundException;
use App\Scolarite\Domain\Exception\NonProprietaireDeLEvaluationException; use App\Scolarite\Domain\Exception\NonProprietaireDeLEvaluationException;
use App\Scolarite\Domain\Model\Grade\GradeId;
use App\Scolarite\Domain\Repository\EvaluationRepository;
use App\Scolarite\Domain\Repository\GradeRepository;
use App\Scolarite\Infrastructure\Api\Resource\GradeResource; use App\Scolarite\Infrastructure\Api\Resource\GradeResource;
use App\Scolarite\Infrastructure\Security\GradeVoter;
use App\Shared\Infrastructure\Tenant\TenantContext; use App\Shared\Infrastructure\Tenant\TenantContext;
use Override; use Override;
use Symfony\Bundle\SecurityBundle\Security; use Symfony\Bundle\SecurityBundle\Security;
@@ -28,6 +32,8 @@ final readonly class SaveAppreciationProcessor implements ProcessorInterface
{ {
public function __construct( public function __construct(
private SaveAppreciationHandler $handler, private SaveAppreciationHandler $handler,
private GradeRepository $gradeRepository,
private EvaluationRepository $evaluationRepository,
private TenantContext $tenantContext, private TenantContext $tenantContext,
private Security $security, private Security $security,
) { ) {
@@ -52,6 +58,23 @@ final readonly class SaveAppreciationProcessor implements ProcessorInterface
/** @var string $gradeId */ /** @var string $gradeId */
$gradeId = $uriVariables['id'] ?? ''; $gradeId = $uriVariables['id'] ?? '';
$tenantId = $this->tenantContext->getCurrentTenantId();
$grade = $this->gradeRepository->findById(GradeId::fromString($gradeId), $tenantId);
if ($grade === null) {
throw new NotFoundHttpException('Aucune note trouvée.');
}
$evaluation = $this->evaluationRepository->findById($grade->evaluationId, $tenantId);
if ($evaluation === null) {
throw new NotFoundHttpException('Évaluation non trouvée.');
}
if (!$this->security->isGranted(GradeVoter::EDIT, $evaluation)) {
throw new AccessDeniedHttpException('Accès refusé.');
}
try { try {
$command = new SaveAppreciationCommand( $command = new SaveAppreciationCommand(
tenantId: (string) $this->tenantContext->getCurrentTenantId(), tenantId: (string) $this->tenantContext->getCurrentTenantId(),

View File

@@ -12,7 +12,10 @@ use App\Scolarite\Application\Command\SaveGrades\SaveGradesHandler;
use App\Scolarite\Domain\Exception\NonProprietaireDeLEvaluationException; use App\Scolarite\Domain\Exception\NonProprietaireDeLEvaluationException;
use App\Scolarite\Domain\Exception\NoteRequiseException; use App\Scolarite\Domain\Exception\NoteRequiseException;
use App\Scolarite\Domain\Exception\ValeurNoteInvalideException; use App\Scolarite\Domain\Exception\ValeurNoteInvalideException;
use App\Scolarite\Domain\Model\Evaluation\EvaluationId;
use App\Scolarite\Domain\Repository\EvaluationRepository;
use App\Scolarite\Infrastructure\Api\Resource\GradeResource; use App\Scolarite\Infrastructure\Api\Resource\GradeResource;
use App\Scolarite\Infrastructure\Security\GradeVoter;
use App\Shared\Infrastructure\Tenant\TenantContext; use App\Shared\Infrastructure\Tenant\TenantContext;
use function array_map; use function array_map;
@@ -21,6 +24,7 @@ use Override;
use Symfony\Bundle\SecurityBundle\Security; use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException; use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Messenger\MessageBusInterface;
@@ -31,6 +35,7 @@ final readonly class SaveGradesProcessor implements ProcessorInterface
{ {
public function __construct( public function __construct(
private SaveGradesHandler $handler, private SaveGradesHandler $handler,
private EvaluationRepository $evaluationRepository,
private TenantContext $tenantContext, private TenantContext $tenantContext,
private MessageBusInterface $eventBus, private MessageBusInterface $eventBus,
private Security $security, private Security $security,
@@ -58,9 +63,23 @@ final readonly class SaveGradesProcessor implements ProcessorInterface
/** @var string $evaluationId */ /** @var string $evaluationId */
$evaluationId = $uriVariables['evaluationId'] ?? ''; $evaluationId = $uriVariables['evaluationId'] ?? '';
$tenantId = $this->tenantContext->getCurrentTenantId();
$evaluation = $this->evaluationRepository->findById(
EvaluationId::fromString($evaluationId),
$tenantId,
);
if ($evaluation === null) {
throw new NotFoundHttpException('Évaluation non trouvée.');
}
if (!$this->security->isGranted(GradeVoter::EDIT, $evaluation)) {
throw new AccessDeniedHttpException('Accès refusé.');
}
try { try {
$command = new SaveGradesCommand( $command = new SaveGradesCommand(
tenantId: (string) $this->tenantContext->getCurrentTenantId(), tenantId: (string) $tenantId,
evaluationId: $evaluationId, evaluationId: $evaluationId,
teacherId: $user->userId(), teacherId: $user->userId(),
grades: $data->grades ?? [], grades: $data->grades ?? [],
@@ -74,8 +93,13 @@ final readonly class SaveGradesProcessor implements ProcessorInterface
} }
} }
$evaluationTeacherId = (string) $evaluation->teacherId;
return array_map( return array_map(
static fn ($grade) => GradeResource::fromDomain($grade), static fn ($grade) => GradeResource::fromDomain(
$grade,
isReplacement: (string) $grade->createdBy !== $evaluationTeacherId,
),
$savedGrades, $savedGrades,
); );
} catch (NonProprietaireDeLEvaluationException $e) { } catch (NonProprietaireDeLEvaluationException $e) {

View File

@@ -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;
}
}

View File

@@ -12,6 +12,7 @@ use App\Administration\Infrastructure\Security\SecurityUser;
use App\Scolarite\Domain\Model\Evaluation\EvaluationId; use App\Scolarite\Domain\Model\Evaluation\EvaluationId;
use App\Scolarite\Domain\Repository\EvaluationRepository; use App\Scolarite\Domain\Repository\EvaluationRepository;
use App\Scolarite\Infrastructure\Api\Resource\EvaluationResource; use App\Scolarite\Infrastructure\Api\Resource\EvaluationResource;
use App\Scolarite\Infrastructure\Security\GradeVoter;
use App\Shared\Infrastructure\Tenant\TenantContext; use App\Shared\Infrastructure\Tenant\TenantContext;
use Override; use Override;
use Symfony\Bundle\SecurityBundle\Security; use Symfony\Bundle\SecurityBundle\Security;
@@ -58,8 +59,8 @@ final readonly class EvaluationItemProvider implements ProviderInterface
throw new NotFoundHttpException('Évaluation non trouvée.'); throw new NotFoundHttpException('Évaluation non trouvée.');
} }
if ((string) $evaluation->teacherId !== $user->userId()) { if (!$this->security->isGranted(GradeVoter::VIEW, $evaluation)) {
throw new AccessDeniedHttpException('Vous n\'êtes pas le propriétaire de cette évaluation.'); throw new AccessDeniedHttpException('Accès refusé.');
} }
$class = $this->classRepository->findById($evaluation->classId); $class = $this->classRepository->findById($evaluation->classId);

View File

@@ -10,6 +10,7 @@ use App\Administration\Infrastructure\Security\SecurityUser;
use App\Scolarite\Domain\Model\Evaluation\EvaluationId; use App\Scolarite\Domain\Model\Evaluation\EvaluationId;
use App\Scolarite\Domain\Repository\EvaluationRepository; use App\Scolarite\Domain\Repository\EvaluationRepository;
use App\Scolarite\Infrastructure\Api\Resource\GradeResource; use App\Scolarite\Infrastructure\Api\Resource\GradeResource;
use App\Scolarite\Infrastructure\Security\GradeVoter;
use App\Shared\Infrastructure\Tenant\TenantContext; use App\Shared\Infrastructure\Tenant\TenantContext;
use function array_map; use function array_map;
@@ -67,20 +68,23 @@ final readonly class GradeCollectionProvider implements ProviderInterface
throw new NotFoundHttpException('Évaluation non trouvée.'); throw new NotFoundHttpException('Évaluation non trouvée.');
} }
if ((string) $evaluation->teacherId !== $user->userId()) { if (!$this->security->isGranted(GradeVoter::VIEW, $evaluation)) {
throw new AccessDeniedHttpException('Accès refusé.'); throw new AccessDeniedHttpException('Accès refusé.');
} }
$classId = (string) $evaluation->classId; $classId = (string) $evaluation->classId;
$teacherOwnerId = (string) $evaluation->teacherId;
// Return all students in the class, with LEFT JOIN to grades // Return all students in the class, with LEFT JOIN to grades and grade creator
$rows = $this->connection->fetchAllAssociative( $rows = $this->connection->fetchAllAssociative(
'SELECT u.id AS student_id, u.first_name, u.last_name, 'SELECT u.id AS student_id, u.first_name, u.last_name,
g.id AS grade_id, g.evaluation_id, g.value, g.status AS grade_status, g.id AS grade_id, g.evaluation_id, g.value, g.status AS grade_status,
g.appreciation g.appreciation, g.created_by,
cb.first_name AS created_by_first_name, cb.last_name AS created_by_last_name
FROM class_assignments ca FROM class_assignments ca
JOIN users u ON u.id = ca.user_id JOIN users u ON u.id = ca.user_id
LEFT JOIN grades g ON g.student_id = u.id AND g.evaluation_id = :evaluation_id AND g.tenant_id = :tenant_id LEFT JOIN grades g ON g.student_id = u.id AND g.evaluation_id = :evaluation_id AND g.tenant_id = :tenant_id
LEFT JOIN users cb ON cb.id = g.created_by
WHERE ca.school_class_id = :class_id WHERE ca.school_class_id = :class_id
AND ca.tenant_id = :tenant_id AND ca.tenant_id = :tenant_id
ORDER BY u.last_name ASC, u.first_name ASC', ORDER BY u.last_name ASC, u.first_name ASC',
@@ -91,7 +95,7 @@ final readonly class GradeCollectionProvider implements ProviderInterface
], ],
); );
return array_map(static function (array $row) use ($evaluationIdStr): GradeResource { return array_map(static function (array $row) use ($evaluationIdStr, $teacherOwnerId): GradeResource {
$resource = new GradeResource(); $resource = new GradeResource();
/** @var string $studentId */ /** @var string $studentId */
$studentId = $row['student_id']; $studentId = $row['student_id'];
@@ -117,6 +121,19 @@ final readonly class GradeCollectionProvider implements ProviderInterface
$appreciation = $row['appreciation'] ?? null; $appreciation = $row['appreciation'] ?? null;
$resource->appreciation = $appreciation; $resource->appreciation = $appreciation;
/** @var string|null $createdBy */
$createdBy = $row['created_by'] ?? null;
/** @var string|null $cbFirstName */
$cbFirstName = $row['created_by_first_name'] ?? null;
/** @var string|null $cbLastName */
$cbLastName = $row['created_by_last_name'] ?? null;
if ($cbFirstName !== null && $cbLastName !== null) {
$resource->createdByName = $cbLastName . ' ' . $cbFirstName;
}
$resource->isReplacement = $createdBy !== null && $createdBy !== $teacherOwnerId;
return $resource; return $resource;
}, $rows); }, $rows);
} }

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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),
],
);
}
}

View File

@@ -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;
}
}

View File

@@ -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 = [];
}

View File

@@ -65,6 +65,10 @@ final class GradeResource
public ?string $appreciation = null; public ?string $appreciation = null;
public ?string $createdByName = null;
public ?bool $isReplacement = null;
public ?DateTimeImmutable $createdAt = null; public ?DateTimeImmutable $createdAt = null;
public ?DateTimeImmutable $updatedAt = null; public ?DateTimeImmutable $updatedAt = null;
@@ -76,8 +80,12 @@ final class GradeResource
public ?DateTimeImmutable $gradesPublishedAt = null; public ?DateTimeImmutable $gradesPublishedAt = null;
public static function fromDomain(Grade $grade, ?string $studentName = null): self public static function fromDomain(
{ Grade $grade,
?string $studentName = null,
?string $createdByName = null,
?bool $isReplacement = null,
): self {
$resource = new self(); $resource = new self();
$resource->id = (string) $grade->id; $resource->id = (string) $grade->id;
$resource->evaluationId = (string) $grade->evaluationId; $resource->evaluationId = (string) $grade->evaluationId;
@@ -86,6 +94,8 @@ final class GradeResource
$resource->value = $grade->value?->value; $resource->value = $grade->value?->value;
$resource->status = $grade->status->value; $resource->status = $grade->status->value;
$resource->appreciation = $grade->appreciation; $resource->appreciation = $grade->appreciation;
$resource->createdByName = $createdByName;
$resource->isReplacement = $isReplacement;
$resource->createdAt = $grade->createdAt; $resource->createdAt = $grade->createdAt;
$resource->updatedAt = $grade->updatedAt; $resource->updatedAt = $grade->updatedAt;

View File

@@ -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;
}

View File

@@ -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 = [];
}

View File

@@ -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 = '';
}

View File

@@ -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 = [];
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Infrastructure\Security;
use App\Administration\Domain\Model\User\Role;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Infrastructure\Security\SecurityUser;
use App\Scolarite\Application\Service\AutorisationSaisieNotesChecker;
use App\Scolarite\Domain\Model\Evaluation\Evaluation;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use App\Shared\Infrastructure\Tenant\TenantContext;
use function in_array;
use Override;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
* Contrôle l'accès aux notes en fonction de l'affectation enseignant ou du remplacement actif.
*
* VIEW : admin (supervision), enseignant affecté, propriétaire de l'évaluation (lecture seule post-retrait), ou remplaçant actif.
* EDIT : enseignant avec affectation active ou remplacement actif uniquement.
*
* @extends Voter<string, Evaluation>
*/
final class GradeVoter extends Voter
{
public const string VIEW = 'GRADE_VIEW';
public const string EDIT = 'GRADE_EDIT';
private const array SUPPORTED_ATTRIBUTES = [
self::VIEW,
self::EDIT,
];
public function __construct(
private readonly AutorisationSaisieNotesChecker $autorisationChecker,
private readonly TenantContext $tenantContext,
private readonly Clock $clock,
) {
}
#[Override]
protected function supports(string $attribute, mixed $subject): bool
{
return in_array($attribute, self::SUPPORTED_ATTRIBUTES, true)
&& $subject instanceof Evaluation;
}
#[Override]
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool
{
$user = $token->getUser();
if (!$user instanceof SecurityUser) {
return false;
}
$roles = $user->getRoles();
if ($this->isAdmin($roles)) {
return $attribute === self::VIEW;
}
if (!$this->isTeacher($roles)) {
return false;
}
if (!$this->tenantContext->hasTenant()) {
return false;
}
$teacherId = UserId::fromString($user->userId());
$tenantId = TenantId::fromString($user->tenantId());
$now = $this->clock->now();
if ($this->autorisationChecker->peutSaisirNotes($teacherId, $subject, $tenantId, $now)) {
return true;
}
// AC2 : propriétaire de l'évaluation → lecture seule après retrait d'affectation
if ($attribute === self::VIEW && $subject->teacherId->equals($teacherId)) {
return true;
}
return false;
}
/** @param string[] $roles */
private function isAdmin(array $roles): bool
{
return $this->hasAnyRole($roles, [
Role::SUPER_ADMIN->value,
Role::ADMIN->value,
]);
}
/** @param string[] $roles */
private function isTeacher(array $roles): bool
{
return in_array(Role::PROF->value, $roles, true);
}
/**
* @param string[] $userRoles
* @param string[] $allowedRoles
*/
private function hasAnyRole(array $userRoles, array $allowedRoles): bool
{
foreach ($userRoles as $role) {
if (in_array($role, $allowedRoles, true)) {
return true;
}
}
return false;
}
}

View File

@@ -8,6 +8,7 @@ use App\Scolarite\Application\Port\FileStorage;
use function dirname; use function dirname;
use function file_put_contents; use function file_put_contents;
use function fopen;
use function is_dir; use function is_dir;
use function is_file; use function is_file;
use function is_string; use function is_string;
@@ -15,6 +16,12 @@ use function mkdir;
use Override; use Override;
use function realpath;
use RuntimeException;
use function sprintf;
use function str_starts_with;
use function unlink; use function unlink;
final readonly class LocalFileStorage implements FileStorage final readonly class LocalFileStorage implements FileStorage
@@ -50,4 +57,24 @@ final readonly class LocalFileStorage implements FileStorage
unlink($fullPath); 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;
}
} }

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}

View File

@@ -17,23 +17,18 @@ final readonly class CreateEstablishmentHandler
) { ) {
} }
public function __invoke(CreateEstablishmentCommand $command): CreateEstablishmentResult public function __invoke(CreateEstablishmentCommand $command): Establishment
{ {
$establishment = Establishment::creer( $establishment = Establishment::creer(
name: $command->name, name: $command->name,
subdomain: $command->subdomain, subdomain: $command->subdomain,
adminEmail: $command->adminEmail,
createdBy: SuperAdminId::fromString($command->superAdminId), createdBy: SuperAdminId::fromString($command->superAdminId),
createdAt: $this->clock->now(), createdAt: $this->clock->now(),
); );
$this->establishmentRepository->save($establishment); $this->establishmentRepository->save($establishment);
return new CreateEstablishmentResult( return $establishment;
establishmentId: (string) $establishment->id,
tenantId: (string) $establishment->tenantId,
name: $establishment->name,
subdomain: $establishment->subdomain,
databaseName: $establishment->databaseName,
);
} }
} }

View File

@@ -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,
) {
}
}

View File

@@ -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,
) {
}
}

View File

@@ -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;
}

View File

@@ -18,6 +18,7 @@ final readonly class EtablissementCree implements DomainEvent
public TenantId $tenantId, public TenantId $tenantId,
public string $name, public string $name,
public string $subdomain, public string $subdomain,
public string $adminEmail,
private DateTimeImmutable $occurredOn, private DateTimeImmutable $occurredOn,
) { ) {
} }

View File

@@ -38,6 +38,7 @@ final class Establishment extends AggregateRoot
public static function creer( public static function creer(
string $name, string $name,
string $subdomain, string $subdomain,
string $adminEmail,
SuperAdminId $createdBy, SuperAdminId $createdBy,
DateTimeImmutable $createdAt, DateTimeImmutable $createdAt,
): self { ): self {
@@ -49,7 +50,7 @@ final class Establishment extends AggregateRoot
name: $name, name: $name,
subdomain: $subdomain, subdomain: $subdomain,
databaseName: sprintf('classeo_tenant_%s', str_replace('-', '', (string) $tenantId)), databaseName: sprintf('classeo_tenant_%s', str_replace('-', '', (string) $tenantId)),
status: EstablishmentStatus::ACTIF, status: EstablishmentStatus::PROVISIONING,
createdAt: $createdAt, createdAt: $createdAt,
createdBy: $createdBy, createdBy: $createdBy,
); );
@@ -59,12 +60,18 @@ final class Establishment extends AggregateRoot
tenantId: $establishment->tenantId, tenantId: $establishment->tenantId,
name: $name, name: $name,
subdomain: $subdomain, subdomain: $subdomain,
adminEmail: $adminEmail,
occurredOn: $createdAt, occurredOn: $createdAt,
)); ));
return $establishment; return $establishment;
} }
public function activer(): void
{
$this->status = EstablishmentStatus::ACTIF;
}
public function desactiver(DateTimeImmutable $at): void public function desactiver(DateTimeImmutable $at): void
{ {
if ($this->status !== EstablishmentStatus::ACTIF) { if ($this->status !== EstablishmentStatus::ACTIF) {

View File

@@ -6,6 +6,7 @@ namespace App\SuperAdmin\Domain\Model\Establishment;
enum EstablishmentStatus: string enum EstablishmentStatus: string
{ {
case PROVISIONING = 'provisioning';
case ACTIF = 'active'; case ACTIF = 'active';
case INACTIF = 'inactive'; case INACTIF = 'inactive';
} }

View File

@@ -8,10 +8,12 @@ use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface; use ApiPlatform\State\ProcessorInterface;
use App\SuperAdmin\Application\Command\CreateEstablishment\CreateEstablishmentCommand; use App\SuperAdmin\Application\Command\CreateEstablishment\CreateEstablishmentCommand;
use App\SuperAdmin\Application\Command\CreateEstablishment\CreateEstablishmentHandler; 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\Api\Resource\EstablishmentResource;
use App\SuperAdmin\Infrastructure\Security\SecuritySuperAdmin; use App\SuperAdmin\Infrastructure\Security\SecuritySuperAdmin;
use Override; use Override;
use Symfony\Bundle\SecurityBundle\Security; use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Messenger\MessageBusInterface;
/** /**
* @implements ProcessorInterface<EstablishmentResource, EstablishmentResource> * @implements ProcessorInterface<EstablishmentResource, EstablishmentResource>
@@ -21,6 +23,7 @@ final readonly class CreateEstablishmentProcessor implements ProcessorInterface
public function __construct( public function __construct(
private CreateEstablishmentHandler $handler, private CreateEstablishmentHandler $handler,
private Security $security, private Security $security,
private MessageBusInterface $commandBus,
) { ) {
} }
@@ -33,20 +36,29 @@ final readonly class CreateEstablishmentProcessor implements ProcessorInterface
/** @var SecuritySuperAdmin $user */ /** @var SecuritySuperAdmin $user */
$user = $this->security->getUser(); $user = $this->security->getUser();
$result = ($this->handler)(new CreateEstablishmentCommand( $establishment = ($this->handler)(new CreateEstablishmentCommand(
name: $data->name, name: $data->name,
subdomain: $data->subdomain, subdomain: $data->subdomain,
adminEmail: $data->adminEmail, adminEmail: $data->adminEmail,
superAdminId: $user->superAdminId(), 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 = new EstablishmentResource();
$resource->id = $result->establishmentId; $resource->id = (string) $establishment->id;
$resource->tenantId = $result->tenantId; $resource->tenantId = (string) $establishment->tenantId;
$resource->name = $result->name; $resource->name = $establishment->name;
$resource->subdomain = $result->subdomain; $resource->subdomain = $establishment->subdomain;
$resource->databaseName = $result->databaseName; $resource->databaseName = $establishment->databaseName;
$resource->status = 'active'; $resource->status = $establishment->status->value;
return $resource; return $resource;
} }

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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) . '"';
}
}

View File

@@ -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);
}
}

View File

@@ -36,11 +36,9 @@ final class PasswordResetEndpointsTest extends ApiTestCase
// Should NOT return 401 Unauthorized // Should NOT return 401 Unauthorized
// It should return 200 (success) or 429 (rate limited), but never 401 // 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'); $status = $response->getStatusCode();
self::assertNotEquals(401, $status, 'Password forgot endpoint should be accessible without JWT');
// The endpoint always returns success to prevent email enumeration self::assertContains($status, [200, 201, 429], 'Expected 200/201 (success) or 429 (rate limited)');
// Even for non-existent emails
self::assertResponseIsSuccessful();
} }
#[Test] #[Test]

View File

@@ -99,7 +99,7 @@ final class ParentGradeEndpointsTest extends ApiTestCase
} }
#[Test] #[Test]
public function getChildGradesReturns404ForUnlinkedChild(): void public function getChildGradesReturns403ForUnlinkedChild(): void
{ {
$unlinkedChildId = '99990001-0001-0001-0001-000000000099'; $unlinkedChildId = '99990001-0001-0001-0001-000000000099';
$client = $this->createAuthenticatedClient(self::PARENT_ID, ['ROLE_PARENT']); $client = $this->createAuthenticatedClient(self::PARENT_ID, ['ROLE_PARENT']);
@@ -107,7 +107,7 @@ final class ParentGradeEndpointsTest extends ApiTestCase
'headers' => ['Accept' => 'application/json'], 'headers' => ['Accept' => 'application/json'],
]); ]);
self::assertResponseStatusCodeSame(404); self::assertResponseStatusCodeSame(403);
} }
// ========================================================================= // =========================================================================
@@ -177,7 +177,7 @@ final class ParentGradeEndpointsTest extends ApiTestCase
} }
#[Test] #[Test]
public function getChildGradesBySubjectReturns404ForUnlinkedChild(): void public function getChildGradesBySubjectReturns403ForUnlinkedChild(): void
{ {
$unlinkedChildId = '99990001-0001-0001-0001-000000000099'; $unlinkedChildId = '99990001-0001-0001-0001-000000000099';
$client = $this->createAuthenticatedClient(self::PARENT_ID, ['ROLE_PARENT']); $client = $this->createAuthenticatedClient(self::PARENT_ID, ['ROLE_PARENT']);
@@ -185,7 +185,7 @@ final class ParentGradeEndpointsTest extends ApiTestCase
'headers' => ['Accept' => 'application/json'], 'headers' => ['Accept' => 'application/json'],
]); ]);
self::assertResponseStatusCodeSame(404); self::assertResponseStatusCodeSame(403);
} }
// ========================================================================= // =========================================================================

View File

@@ -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);
}
}
}

View File

@@ -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');
}
}

View File

@@ -21,6 +21,9 @@ use function sprintf;
use Symfony\Component\HttpClient\HttpClient; use Symfony\Component\HttpClient\HttpClient;
use function sys_get_temp_dir; use function sys_get_temp_dir;
use Throwable;
use function unlink; use function unlink;
/** /**
@@ -42,6 +45,16 @@ final class GouvFrCalendarApiTest extends TestCase
protected function setUp(): void 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(); $this->tempDir = sys_get_temp_dir() . '/classeo-calendar-test-' . uniqid();
mkdir($this->tempDir); mkdir($this->tempDir);
@@ -55,6 +68,10 @@ final class GouvFrCalendarApiTest extends TestCase
protected function tearDown(): void protected function tearDown(): void
{ {
if (!isset($this->tempDir) || !is_dir($this->tempDir)) {
return;
}
// Supprimer les fichiers générés // Supprimer les fichiers générés
$files = glob($this->tempDir . '/*.json'); $files = glob($this->tempDir . '/*.json');
foreach ($files as $file) { foreach ($files as $file) {

View File

@@ -9,6 +9,8 @@ use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\User\UserId; use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Application\Command\PublishGrades\PublishGradesCommand; use App\Scolarite\Application\Command\PublishGrades\PublishGradesCommand;
use App\Scolarite\Application\Command\PublishGrades\PublishGradesHandler; use App\Scolarite\Application\Command\PublishGrades\PublishGradesHandler;
use App\Scolarite\Application\Port\EnseignantAffectationChecker;
use App\Scolarite\Application\Service\AutorisationSaisieNotesChecker;
use App\Scolarite\Domain\Exception\AucuneNoteSaisieException; use App\Scolarite\Domain\Exception\AucuneNoteSaisieException;
use App\Scolarite\Domain\Exception\NonProprietaireDeLEvaluationException; use App\Scolarite\Domain\Exception\NonProprietaireDeLEvaluationException;
use App\Scolarite\Domain\Exception\NotesDejaPublieesException; use App\Scolarite\Domain\Exception\NotesDejaPublieesException;
@@ -22,6 +24,7 @@ use App\Scolarite\Domain\Model\Grade\GradeStatus;
use App\Scolarite\Domain\Model\Grade\GradeValue; use App\Scolarite\Domain\Model\Grade\GradeValue;
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryEvaluationRepository; use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryEvaluationRepository;
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryGradeRepository; use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryGradeRepository;
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryTeacherReplacementRepository;
use App\Shared\Domain\Clock; use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId; use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable; use DateTimeImmutable;
@@ -37,12 +40,17 @@ final class PublishGradesHandlerTest extends TestCase
private InMemoryEvaluationRepository $evaluationRepository; private InMemoryEvaluationRepository $evaluationRepository;
private InMemoryGradeRepository $gradeRepository; private InMemoryGradeRepository $gradeRepository;
private InMemoryTeacherReplacementRepository $replacementRepository;
private Clock $clock; private Clock $clock;
/** @var array<string, bool> */
private array $affectationResults = [];
protected function setUp(): void protected function setUp(): void
{ {
$this->evaluationRepository = new InMemoryEvaluationRepository(); $this->evaluationRepository = new InMemoryEvaluationRepository();
$this->gradeRepository = new InMemoryGradeRepository(); $this->gradeRepository = new InMemoryGradeRepository();
$this->replacementRepository = new InMemoryTeacherReplacementRepository();
$this->clock = new class implements Clock { $this->clock = new class implements Clock {
public function now(): DateTimeImmutable public function now(): DateTimeImmutable
{ {
@@ -50,9 +58,16 @@ final class PublishGradesHandlerTest extends TestCase
} }
}; };
$this->affectationResults = [];
$this->affectationResults[self::TEACHER_ID] = true;
$this->seedEvaluation(); $this->seedEvaluation();
} }
public function isTeacherAffecte(string $teacherId): bool
{
return $this->affectationResults[$teacherId] ?? false;
}
#[Test] #[Test]
public function itPublishesGradesWhenGradesExist(): void public function itPublishesGradesWhenGradesExist(): void
{ {
@@ -107,18 +122,18 @@ final class PublishGradesHandlerTest extends TestCase
} }
#[Test] #[Test]
public function itThrowsWhenTeacherNotOwner(): void public function itThrowsWhenTeacherNotAssigned(): void
{ {
$this->seedGrade(); $this->seedGrade();
$handler = $this->createHandler(); $handler = $this->createHandler();
$otherTeacher = '550e8400-e29b-41d4-a716-446655440099'; $unassignedTeacher = '550e8400-e29b-41d4-a716-446655440099';
$this->expectException(NonProprietaireDeLEvaluationException::class); $this->expectException(NonProprietaireDeLEvaluationException::class);
$handler(new PublishGradesCommand( $handler(new PublishGradesCommand(
tenantId: self::TENANT_ID, tenantId: self::TENANT_ID,
evaluationId: self::EVALUATION_ID, evaluationId: self::EVALUATION_ID,
teacherId: $otherTeacher, teacherId: $unassignedTeacher,
)); ));
} }
@@ -145,10 +160,35 @@ final class PublishGradesHandlerTest extends TestCase
return new PublishGradesHandler( return new PublishGradesHandler(
$this->evaluationRepository, $this->evaluationRepository,
$this->gradeRepository, $this->gradeRepository,
$this->createAutorisationChecker(),
$this->clock, $this->clock,
); );
} }
private function createAutorisationChecker(): AutorisationSaisieNotesChecker
{
$test = $this;
$affectationChecker = new class($test) implements EnseignantAffectationChecker {
public function __construct(private readonly PublishGradesHandlerTest $test)
{
}
public function estAffecte(
UserId $teacherId,
ClassId $classId,
SubjectId $subjectId,
TenantId $tenantId,
): bool {
return $this->test->isTeacherAffecte((string) $teacherId);
}
};
return new AutorisationSaisieNotesChecker(
$affectationChecker,
$this->replacementRepository,
);
}
private function seedEvaluation(): void private function seedEvaluation(): void
{ {
$evaluation = Evaluation::reconstitute( $evaluation = Evaluation::reconstitute(

View File

@@ -9,6 +9,8 @@ use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\User\UserId; use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Application\Command\SaveAppreciation\SaveAppreciationCommand; use App\Scolarite\Application\Command\SaveAppreciation\SaveAppreciationCommand;
use App\Scolarite\Application\Command\SaveAppreciation\SaveAppreciationHandler; use App\Scolarite\Application\Command\SaveAppreciation\SaveAppreciationHandler;
use App\Scolarite\Application\Port\EnseignantAffectationChecker;
use App\Scolarite\Application\Service\AutorisationSaisieNotesChecker;
use App\Scolarite\Domain\Exception\AppreciationTropLongueException; use App\Scolarite\Domain\Exception\AppreciationTropLongueException;
use App\Scolarite\Domain\Exception\GradeNotFoundException; use App\Scolarite\Domain\Exception\GradeNotFoundException;
use App\Scolarite\Domain\Exception\NonProprietaireDeLEvaluationException; use App\Scolarite\Domain\Exception\NonProprietaireDeLEvaluationException;
@@ -22,6 +24,7 @@ use App\Scolarite\Domain\Model\Grade\GradeStatus;
use App\Scolarite\Domain\Model\Grade\GradeValue; use App\Scolarite\Domain\Model\Grade\GradeValue;
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryEvaluationRepository; use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryEvaluationRepository;
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryGradeRepository; use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryGradeRepository;
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryTeacherReplacementRepository;
use App\Shared\Domain\Clock; use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId; use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable; use DateTimeImmutable;
@@ -39,13 +42,18 @@ final class SaveAppreciationHandlerTest extends TestCase
private InMemoryEvaluationRepository $evaluationRepository; private InMemoryEvaluationRepository $evaluationRepository;
private InMemoryGradeRepository $gradeRepository; private InMemoryGradeRepository $gradeRepository;
private InMemoryTeacherReplacementRepository $replacementRepository;
private Clock $clock; private Clock $clock;
private string $gradeId; private string $gradeId;
/** @var array<string, bool> */
private array $affectationResults = [];
protected function setUp(): void protected function setUp(): void
{ {
$this->evaluationRepository = new InMemoryEvaluationRepository(); $this->evaluationRepository = new InMemoryEvaluationRepository();
$this->gradeRepository = new InMemoryGradeRepository(); $this->gradeRepository = new InMemoryGradeRepository();
$this->replacementRepository = new InMemoryTeacherReplacementRepository();
$this->clock = new class implements Clock { $this->clock = new class implements Clock {
public function now(): DateTimeImmutable public function now(): DateTimeImmutable
{ {
@@ -53,9 +61,16 @@ final class SaveAppreciationHandlerTest extends TestCase
} }
}; };
$this->affectationResults = [];
$this->affectationResults[self::TEACHER_ID] = true;
$this->seedEvaluationAndGrade(); $this->seedEvaluationAndGrade();
} }
public function isTeacherAffecte(string $teacherId): bool
{
return $this->affectationResults[$teacherId] ?? false;
}
#[Test] #[Test]
public function itSavesAppreciation(): void public function itSavesAppreciation(): void
{ {
@@ -144,10 +159,35 @@ final class SaveAppreciationHandlerTest extends TestCase
return new SaveAppreciationHandler( return new SaveAppreciationHandler(
$this->evaluationRepository, $this->evaluationRepository,
$this->gradeRepository, $this->gradeRepository,
$this->createAutorisationChecker(),
$this->clock, $this->clock,
); );
} }
private function createAutorisationChecker(): AutorisationSaisieNotesChecker
{
$test = $this;
$affectationChecker = new class($test) implements EnseignantAffectationChecker {
public function __construct(private readonly SaveAppreciationHandlerTest $test)
{
}
public function estAffecte(
UserId $teacherId,
ClassId $classId,
SubjectId $subjectId,
TenantId $tenantId,
): bool {
return $this->test->isTeacherAffecte((string) $teacherId);
}
};
return new AutorisationSaisieNotesChecker(
$affectationChecker,
$this->replacementRepository,
);
}
private function seedEvaluationAndGrade(): void private function seedEvaluationAndGrade(): void
{ {
$tenantId = TenantId::fromString(self::TENANT_ID); $tenantId = TenantId::fromString(self::TENANT_ID);

View File

@@ -9,6 +9,8 @@ use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\User\UserId; use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Application\Command\SaveGrades\SaveGradesCommand; use App\Scolarite\Application\Command\SaveGrades\SaveGradesCommand;
use App\Scolarite\Application\Command\SaveGrades\SaveGradesHandler; use App\Scolarite\Application\Command\SaveGrades\SaveGradesHandler;
use App\Scolarite\Application\Port\EnseignantAffectationChecker;
use App\Scolarite\Application\Service\AutorisationSaisieNotesChecker;
use App\Scolarite\Domain\Exception\NonProprietaireDeLEvaluationException; use App\Scolarite\Domain\Exception\NonProprietaireDeLEvaluationException;
use App\Scolarite\Domain\Exception\NoteRequiseException; use App\Scolarite\Domain\Exception\NoteRequiseException;
use App\Scolarite\Domain\Exception\ValeurNoteInvalideException; use App\Scolarite\Domain\Exception\ValeurNoteInvalideException;
@@ -18,8 +20,11 @@ use App\Scolarite\Domain\Model\Evaluation\EvaluationId;
use App\Scolarite\Domain\Model\Evaluation\EvaluationStatus; use App\Scolarite\Domain\Model\Evaluation\EvaluationStatus;
use App\Scolarite\Domain\Model\Evaluation\GradeScale; use App\Scolarite\Domain\Model\Evaluation\GradeScale;
use App\Scolarite\Domain\Model\Grade\GradeStatus; use App\Scolarite\Domain\Model\Grade\GradeStatus;
use App\Scolarite\Domain\Model\TeacherReplacement\ClassSubjectPair;
use App\Scolarite\Domain\Model\TeacherReplacement\TeacherReplacement;
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryEvaluationRepository; use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryEvaluationRepository;
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryGradeRepository; use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryGradeRepository;
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryTeacherReplacementRepository;
use App\Shared\Domain\Clock; use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId; use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable; use DateTimeImmutable;
@@ -36,12 +41,17 @@ final class SaveGradesHandlerTest extends TestCase
private InMemoryEvaluationRepository $evaluationRepository; private InMemoryEvaluationRepository $evaluationRepository;
private InMemoryGradeRepository $gradeRepository; private InMemoryGradeRepository $gradeRepository;
private InMemoryTeacherReplacementRepository $replacementRepository;
private Clock $clock; private Clock $clock;
/** @var array<string, bool> */
private array $affectationResults = [];
protected function setUp(): void protected function setUp(): void
{ {
$this->evaluationRepository = new InMemoryEvaluationRepository(); $this->evaluationRepository = new InMemoryEvaluationRepository();
$this->gradeRepository = new InMemoryGradeRepository(); $this->gradeRepository = new InMemoryGradeRepository();
$this->replacementRepository = new InMemoryTeacherReplacementRepository();
$this->clock = new class implements Clock { $this->clock = new class implements Clock {
public function now(): DateTimeImmutable public function now(): DateTimeImmutable
{ {
@@ -49,9 +59,16 @@ final class SaveGradesHandlerTest extends TestCase
} }
}; };
$this->affectationResults = [];
$this->setTeacherAffecte(self::TEACHER_ID);
$this->seedEvaluation(); $this->seedEvaluation();
} }
private function setTeacherAffecte(string $teacherId): void
{
$this->affectationResults[$teacherId] = true;
}
#[Test] #[Test]
public function itSavesNewGrades(): void public function itSavesNewGrades(): void
{ {
@@ -169,24 +186,7 @@ final class SaveGradesHandlerTest extends TestCase
self::assertNull($savedGrades[0]->value); self::assertNull($savedGrades[0]->value);
} }
#[Test] /** @see itThrowsWhenTeacherNotAssigned - renamed, now checks assignment instead of ownership */
public function itThrowsWhenTeacherNotOwner(): void
{
$handler = $this->createHandler();
$otherTeacher = '550e8400-e29b-41d4-a716-446655440099';
$this->expectException(NonProprietaireDeLEvaluationException::class);
$handler(new SaveGradesCommand(
tenantId: self::TENANT_ID,
evaluationId: self::EVALUATION_ID,
teacherId: $otherTeacher,
grades: [
['studentId' => self::STUDENT_1_ID, 'value' => 15.5, 'status' => 'graded'],
],
));
}
#[Test] #[Test]
public function itThrowsWhenValueExceedsGradeScale(): void public function itThrowsWhenValueExceedsGradeScale(): void
{ {
@@ -221,15 +221,119 @@ final class SaveGradesHandlerTest extends TestCase
)); ));
} }
#[Test]
public function itThrowsWhenTeacherNotAssigned(): void
{
$handler = $this->createHandler();
$unassignedTeacher = '550e8400-e29b-41d4-a716-446655440099';
$this->expectException(NonProprietaireDeLEvaluationException::class);
$handler(new SaveGradesCommand(
tenantId: self::TENANT_ID,
evaluationId: self::EVALUATION_ID,
teacherId: $unassignedTeacher,
grades: [
['studentId' => self::STUDENT_1_ID, 'value' => 15.5, 'status' => 'graded'],
],
));
}
#[Test]
public function itAllowsReplacementTeacherToSave(): void
{
$replacementTeacherId = '550e8400-e29b-41d4-a716-446655440088';
$now = $this->clock->now();
$replacement = TeacherReplacement::designer(
tenantId: TenantId::fromString(self::TENANT_ID),
replacedTeacherId: UserId::fromString(self::TEACHER_ID),
replacementTeacherId: UserId::fromString($replacementTeacherId),
startDate: $now->modify('-1 day'),
endDate: $now->modify('+7 days'),
classes: [new ClassSubjectPair(
ClassId::fromString('550e8400-e29b-41d4-a716-446655440020'),
SubjectId::fromString('550e8400-e29b-41d4-a716-446655440030'),
)],
reason: 'Maladie',
createdBy: UserId::generate(),
now: $now->modify('-1 day'),
);
$this->replacementRepository->save($replacement);
$handler = $this->createHandler();
$savedGrades = $handler(new SaveGradesCommand(
tenantId: self::TENANT_ID,
evaluationId: self::EVALUATION_ID,
teacherId: $replacementTeacherId,
grades: [
['studentId' => self::STUDENT_1_ID, 'value' => 14.0, 'status' => 'graded'],
],
));
self::assertCount(1, $savedGrades);
self::assertSame((string) UserId::fromString($replacementTeacherId), (string) $savedGrades[0]->createdBy);
}
#[Test]
public function itBlocksEvaluationOwnerWithRemovedAssignment(): void
{
// Teacher IS the evaluation owner but has no active assignment
$this->affectationResults = []; // Remove all assignments
$handler = $this->createHandler();
$this->expectException(NonProprietaireDeLEvaluationException::class);
$handler(new SaveGradesCommand(
tenantId: self::TENANT_ID,
evaluationId: self::EVALUATION_ID,
teacherId: self::TEACHER_ID,
grades: [
['studentId' => self::STUDENT_1_ID, 'value' => 15.5, 'status' => 'graded'],
],
));
}
private function createHandler(): SaveGradesHandler private function createHandler(): SaveGradesHandler
{ {
return new SaveGradesHandler( return new SaveGradesHandler(
$this->evaluationRepository, $this->evaluationRepository,
$this->gradeRepository, $this->gradeRepository,
$this->createAutorisationChecker(),
$this->clock, $this->clock,
); );
} }
private function createAutorisationChecker(): AutorisationSaisieNotesChecker
{
$test = $this;
$affectationChecker = new class($test) implements EnseignantAffectationChecker {
public function __construct(private readonly SaveGradesHandlerTest $test)
{
}
public function estAffecte(
UserId $teacherId,
ClassId $classId,
SubjectId $subjectId,
TenantId $tenantId,
): bool {
return $this->test->isTeacherAffecte((string) $teacherId);
}
};
return new AutorisationSaisieNotesChecker(
$affectationChecker,
$this->replacementRepository,
);
}
public function isTeacherAffecte(string $teacherId): bool
{
return $this->affectationResults[$teacherId] ?? false;
}
private function seedEvaluation(): void private function seedEvaluation(): void
{ {
$evaluation = Evaluation::reconstitute( $evaluation = Evaluation::reconstitute(

View File

@@ -112,6 +112,14 @@ final class UploadSubmissionAttachmentHandlerTest extends TestCase
public function delete(string $path): void 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 { $clock = new class implements Clock {

View File

@@ -10,8 +10,11 @@ use App\Administration\Domain\Model\SchoolCalendar\CalendarEntryType;
use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendar; use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendar;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId; use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Infrastructure\Persistence\InMemory\InMemorySchoolCalendarRepository; 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\GetBlockedDatesHandler;
use App\Scolarite\Application\Query\GetBlockedDates\GetBlockedDatesQuery; use App\Scolarite\Application\Query\GetBlockedDates\GetBlockedDatesQuery;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId; use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable; use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\Test;
@@ -28,7 +31,29 @@ final class GetBlockedDatesHandlerTest extends TestCase
protected function setUp(): void protected function setUp(): void
{ {
$this->calendarRepository = new InMemorySchoolCalendarRepository(); $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] #[Test]
@@ -110,6 +135,93 @@ final class GetBlockedDatesHandlerTest extends TestCase
self::assertCount(5, $vacations); 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 private function createCalendarWithHoliday(DateTimeImmutable $date, string $label): SchoolCalendar
{ {
$tenantId = TenantId::fromString(self::TENANT_ID); $tenantId = TenantId::fromString(self::TENANT_ID);

View File

@@ -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'),
);
}
}

View File

@@ -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,
);
}
}

View File

@@ -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',
);
}
}

View File

@@ -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'),
);
}
}

View File

@@ -0,0 +1,249 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Scolarite\Application\Service;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Application\Port\EnseignantAffectationChecker;
use App\Scolarite\Application\Service\AutorisationSaisieNotesChecker;
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\TeacherReplacement\ClassSubjectPair;
use App\Scolarite\Domain\Model\TeacherReplacement\TeacherReplacement;
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryTeacherReplacementRepository;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class AutorisationSaisieNotesCheckerTest extends TestCase
{
private TenantId $tenantId;
private ClassId $classId;
private SubjectId $subjectId;
private InMemoryTeacherReplacementRepository $replacementRepository;
private DateTimeImmutable $now;
/** @var array<string, bool> */
private array $affectationResults = [];
protected function setUp(): void
{
$this->tenantId = TenantId::generate();
$this->classId = ClassId::generate();
$this->subjectId = SubjectId::generate();
$this->replacementRepository = new InMemoryTeacherReplacementRepository();
$this->now = new DateTimeImmutable('2026-04-14 10:00:00');
}
#[Test]
public function itReturnsTrueWhenTeacherIsAffected(): void
{
$teacherId = UserId::generate();
$this->setTeacherAffecte($teacherId);
$checker = $this->createChecker();
$evaluation = $this->createEvaluation();
self::assertTrue($checker->peutSaisirNotes($teacherId, $evaluation, $this->tenantId, $this->now));
}
#[Test]
public function itReturnsFalseWhenTeacherIsNotAffectedAndNoReplacement(): void
{
$teacherId = UserId::generate();
$checker = $this->createChecker();
$evaluation = $this->createEvaluation();
self::assertFalse($checker->peutSaisirNotes($teacherId, $evaluation, $this->tenantId, $this->now));
}
#[Test]
public function itReturnsTrueWhenTeacherHasActiveReplacement(): void
{
$replacementTeacherId = UserId::generate();
$this->createActiveReplacement($replacementTeacherId);
$checker = $this->createChecker();
$evaluation = $this->createEvaluation();
self::assertTrue($checker->peutSaisirNotes($replacementTeacherId, $evaluation, $this->tenantId, $this->now));
}
#[Test]
public function itReturnsFalseWhenReplacementIsExpired(): void
{
$replacementTeacherId = UserId::generate();
$this->createExpiredReplacement($replacementTeacherId);
$checker = $this->createChecker();
$evaluation = $this->createEvaluation();
self::assertFalse($checker->peutSaisirNotes($replacementTeacherId, $evaluation, $this->tenantId, $this->now));
}
#[Test]
public function itReturnsTrueWhenBothAffectedAndActiveReplacement(): void
{
$teacherId = UserId::generate();
$this->setTeacherAffecte($teacherId);
$this->createActiveReplacement($teacherId);
$checker = $this->createChecker();
$evaluation = $this->createEvaluation();
self::assertTrue($checker->peutSaisirNotes($teacherId, $evaluation, $this->tenantId, $this->now));
}
#[Test]
public function itReturnsFalseWhenReplacementIsOnDifferentClassSubject(): void
{
$replacementTeacherId = UserId::generate();
$otherClassId = ClassId::generate();
$otherSubjectId = SubjectId::generate();
$replacement = TeacherReplacement::designer(
tenantId: $this->tenantId,
replacedTeacherId: UserId::generate(),
replacementTeacherId: $replacementTeacherId,
startDate: $this->now->modify('-1 day'),
endDate: $this->now->modify('+7 days'),
classes: [new ClassSubjectPair($otherClassId, $otherSubjectId)],
reason: 'Maladie',
createdBy: UserId::generate(),
now: $this->now->modify('-1 day'),
);
$this->replacementRepository->save($replacement);
$checker = $this->createChecker();
$evaluation = $this->createEvaluation();
self::assertFalse($checker->peutSaisirNotes($replacementTeacherId, $evaluation, $this->tenantId, $this->now));
}
#[Test]
public function itReturnsFalseWhenReplacementStartsInTheFuture(): void
{
$replacementTeacherId = UserId::generate();
$futureStart = $this->now->modify('+1 day');
$replacement = TeacherReplacement::designer(
tenantId: $this->tenantId,
replacedTeacherId: UserId::generate(),
replacementTeacherId: $replacementTeacherId,
startDate: $futureStart,
endDate: $this->now->modify('+14 days'),
classes: [new ClassSubjectPair($this->classId, $this->subjectId)],
reason: 'Congé prévu',
createdBy: UserId::generate(),
now: $this->now,
);
$this->replacementRepository->save($replacement);
$checker = $this->createChecker();
$evaluation = $this->createEvaluation();
self::assertFalse($checker->peutSaisirNotes($replacementTeacherId, $evaluation, $this->tenantId, $this->now));
}
#[Test]
public function itShortCircuitsOnAffectationWithoutCheckingReplacement(): void
{
$teacherId = UserId::generate();
$this->setTeacherAffecte($teacherId);
// No replacement seeded — if it tries to check replacement for an
// assigned teacher, the result should still be true (short-circuit)
$checker = $this->createChecker();
$evaluation = $this->createEvaluation();
self::assertTrue($checker->peutSaisirNotes($teacherId, $evaluation, $this->tenantId, $this->now));
}
private function setTeacherAffecte(UserId $teacherId): void
{
$this->affectationResults[(string) $teacherId] = true;
}
private function createChecker(): AutorisationSaisieNotesChecker
{
$test = $this;
$affectationChecker = new class($test) implements EnseignantAffectationChecker {
public function __construct(private readonly AutorisationSaisieNotesCheckerTest $test)
{
}
public function estAffecte(
UserId $teacherId,
ClassId $classId,
SubjectId $subjectId,
TenantId $tenantId,
): bool {
return $this->test->isTeacherAffecte((string) $teacherId);
}
};
return new AutorisationSaisieNotesChecker(
$affectationChecker,
$this->replacementRepository,
);
}
public function isTeacherAffecte(string $teacherId): bool
{
return $this->affectationResults[$teacherId] ?? false;
}
private function createEvaluation(): Evaluation
{
return Evaluation::creer(
tenantId: $this->tenantId,
classId: $this->classId,
subjectId: $this->subjectId,
teacherId: UserId::generate(),
title: 'Contrôle de maths',
description: null,
evaluationDate: $this->now,
gradeScale: new GradeScale(20),
coefficient: new Coefficient(1.0),
now: $this->now,
);
}
private function createActiveReplacement(UserId $replacementTeacherId): void
{
$replacement = TeacherReplacement::designer(
tenantId: $this->tenantId,
replacedTeacherId: UserId::generate(),
replacementTeacherId: $replacementTeacherId,
startDate: $this->now->modify('-1 day'),
endDate: $this->now->modify('+7 days'),
classes: [new ClassSubjectPair($this->classId, $this->subjectId)],
reason: 'Maladie',
createdBy: UserId::generate(),
now: $this->now->modify('-1 day'),
);
$this->replacementRepository->save($replacement);
}
private function createExpiredReplacement(UserId $replacementTeacherId): void
{
$replacement = TeacherReplacement::designer(
tenantId: $this->tenantId,
replacedTeacherId: UserId::generate(),
replacementTeacherId: $replacementTeacherId,
startDate: $this->now->modify('-14 days'),
endDate: $this->now->modify('-1 day'),
classes: [new ClassSubjectPair($this->classId, $this->subjectId)],
reason: 'Maladie',
createdBy: UserId::generate(),
now: $this->now->modify('-14 days'),
);
$this->replacementRepository->save($replacement);
}
}

View File

@@ -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'));
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,459 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Scolarite\Infrastructure\Security;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\User\Role;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Infrastructure\Security\SecurityUser;
use App\Scolarite\Application\Port\EnseignantAffectationChecker;
use App\Scolarite\Application\Service\AutorisationSaisieNotesChecker;
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\TeacherReplacement\ClassSubjectPair;
use App\Scolarite\Domain\Model\TeacherReplacement\TeacherReplacement;
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryTeacherReplacementRepository;
use App\Scolarite\Infrastructure\Security\GradeVoter;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use App\Shared\Infrastructure\Tenant\TenantConfig;
use App\Shared\Infrastructure\Tenant\TenantContext;
use App\Shared\Infrastructure\Tenant\TenantId as InfraTenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
use Symfony\Component\Security\Core\User\UserInterface;
final class GradeVoterTest extends TestCase
{
private TenantId $tenantId;
private ClassId $classId;
private SubjectId $subjectId;
private InMemoryTeacherReplacementRepository $replacementRepository;
private TenantContext $tenantContext;
private DateTimeImmutable $now;
private GradeVoter $voter;
/** @var array<string, bool> */
private array $affectationResults = [];
protected function setUp(): void
{
$this->tenantId = TenantId::generate();
$this->classId = ClassId::generate();
$this->subjectId = SubjectId::generate();
$this->replacementRepository = new InMemoryTeacherReplacementRepository();
$this->tenantContext = new TenantContext();
$this->now = new DateTimeImmutable('2026-04-13 10:00:00');
$this->tenantContext->setCurrentTenant(new TenantConfig(
tenantId: InfraTenantId::fromString((string) $this->tenantId),
subdomain: 'test',
databaseUrl: 'sqlite:///:memory:',
));
$this->affectationResults = [];
$test = $this;
$affectationChecker = new class($test) implements EnseignantAffectationChecker {
public function __construct(private readonly GradeVoterTest $test)
{
}
public function estAffecte(
UserId $teacherId,
ClassId $classId,
SubjectId $subjectId,
TenantId $tenantId,
): bool {
return $this->test->getAffectationResult((string) $teacherId);
}
};
$autorisationChecker = new AutorisationSaisieNotesChecker(
$affectationChecker,
$this->replacementRepository,
);
$clock = $this->createMock(Clock::class);
$clock->method('now')->willReturn($this->now);
$this->voter = new GradeVoter(
$autorisationChecker,
$this->tenantContext,
$clock,
);
}
public function getAffectationResult(string $teacherId): bool
{
return $this->affectationResults[$teacherId] ?? false;
}
private function setTeacherAffecte(UserId $teacherId): void
{
$this->affectationResults[(string) $teacherId] = true;
}
#[Test]
public function itAbstainsForUnrelatedAttributes(): void
{
$evaluation = $this->createEvaluation();
$token = $this->tokenWithSecurityUser(Role::PROF->value);
$result = $this->voter->vote($token, $evaluation, ['SOME_OTHER_ATTRIBUTE']);
self::assertSame(Voter::ACCESS_ABSTAIN, $result);
}
#[Test]
public function itAbstainsWhenSubjectIsNotAnEvaluation(): void
{
$token = $this->tokenWithSecurityUser(Role::PROF->value);
$result = $this->voter->vote($token, null, [GradeVoter::VIEW]);
self::assertSame(Voter::ACCESS_ABSTAIN, $result);
}
#[Test]
public function itDeniesAccessToUnauthenticatedUsers(): void
{
$evaluation = $this->createEvaluation();
$token = $this->createMock(TokenInterface::class);
$token->method('getUser')->willReturn(null);
$result = $this->voter->vote($token, $evaluation, [GradeVoter::VIEW]);
self::assertSame(Voter::ACCESS_DENIED, $result);
}
#[Test]
public function itDeniesAccessToNonSecurityUser(): void
{
$evaluation = $this->createEvaluation();
$user = $this->createMock(UserInterface::class);
$user->method('getRoles')->willReturn([Role::PROF->value]);
$token = $this->createMock(TokenInterface::class);
$token->method('getUser')->willReturn($user);
$result = $this->voter->vote($token, $evaluation, [GradeVoter::VIEW]);
self::assertSame(Voter::ACCESS_DENIED, $result);
}
#[Test]
public function itGrantsViewToAdmin(): void
{
$evaluation = $this->createEvaluation();
$token = $this->tokenWithSecurityUser(Role::ADMIN->value);
$result = $this->voter->vote($token, $evaluation, [GradeVoter::VIEW]);
self::assertSame(Voter::ACCESS_GRANTED, $result);
}
#[Test]
public function itDeniesEditToAdmin(): void
{
$evaluation = $this->createEvaluation();
$token = $this->tokenWithSecurityUser(Role::ADMIN->value);
$result = $this->voter->vote($token, $evaluation, [GradeVoter::EDIT]);
self::assertSame(Voter::ACCESS_DENIED, $result);
}
#[Test]
public function itGrantsViewToSuperAdmin(): void
{
$evaluation = $this->createEvaluation();
$token = $this->tokenWithSecurityUser(Role::SUPER_ADMIN->value);
$result = $this->voter->vote($token, $evaluation, [GradeVoter::VIEW]);
self::assertSame(Voter::ACCESS_GRANTED, $result);
}
#[Test]
public function itGrantsViewToAssignedTeacher(): void
{
$teacherId = UserId::generate();
$this->setTeacherAffecte($teacherId);
$evaluation = $this->createEvaluation();
$token = $this->tokenWithSecurityUser(Role::PROF->value, $teacherId);
$result = $this->voter->vote($token, $evaluation, [GradeVoter::VIEW]);
self::assertSame(Voter::ACCESS_GRANTED, $result);
}
#[Test]
public function itGrantsEditToAssignedTeacher(): void
{
$teacherId = UserId::generate();
$this->setTeacherAffecte($teacherId);
$evaluation = $this->createEvaluation();
$token = $this->tokenWithSecurityUser(Role::PROF->value, $teacherId);
$result = $this->voter->vote($token, $evaluation, [GradeVoter::EDIT]);
self::assertSame(Voter::ACCESS_GRANTED, $result);
}
#[Test]
public function itDeniesEditToUnassignedTeacher(): void
{
$teacherId = UserId::generate();
// No assignment set
$evaluation = $this->createEvaluation();
$token = $this->tokenWithSecurityUser(Role::PROF->value, $teacherId);
$result = $this->voter->vote($token, $evaluation, [GradeVoter::EDIT]);
self::assertSame(Voter::ACCESS_DENIED, $result);
}
#[Test]
public function itGrantsViewToEvaluationOwnerWithoutAssignment(): void
{
$teacherId = UserId::generate();
// Teacher owns the evaluation but is no longer assigned
$evaluation = $this->createEvaluation(teacherId: $teacherId);
$token = $this->tokenWithSecurityUser(Role::PROF->value, $teacherId);
$result = $this->voter->vote($token, $evaluation, [GradeVoter::VIEW]);
self::assertSame(Voter::ACCESS_GRANTED, $result);
}
#[Test]
public function itDeniesEditToEvaluationOwnerWithoutAssignment(): void
{
$teacherId = UserId::generate();
// Teacher owns the evaluation but is no longer assigned
$evaluation = $this->createEvaluation(teacherId: $teacherId);
$token = $this->tokenWithSecurityUser(Role::PROF->value, $teacherId);
$result = $this->voter->vote($token, $evaluation, [GradeVoter::EDIT]);
self::assertSame(Voter::ACCESS_DENIED, $result);
}
#[Test]
public function itGrantsViewToActiveReplacement(): void
{
$replacementTeacherId = UserId::generate();
$this->createActiveReplacement($replacementTeacherId);
$evaluation = $this->createEvaluation();
$token = $this->tokenWithSecurityUser(Role::PROF->value, $replacementTeacherId);
$result = $this->voter->vote($token, $evaluation, [GradeVoter::VIEW]);
self::assertSame(Voter::ACCESS_GRANTED, $result);
}
#[Test]
public function itGrantsEditToActiveReplacement(): void
{
$replacementTeacherId = UserId::generate();
$this->createActiveReplacement($replacementTeacherId);
$evaluation = $this->createEvaluation();
$token = $this->tokenWithSecurityUser(Role::PROF->value, $replacementTeacherId);
$result = $this->voter->vote($token, $evaluation, [GradeVoter::EDIT]);
self::assertSame(Voter::ACCESS_GRANTED, $result);
}
#[Test]
public function itDeniesEditToExpiredReplacement(): void
{
$replacementTeacherId = UserId::generate();
$this->createExpiredReplacement($replacementTeacherId);
$evaluation = $this->createEvaluation();
$token = $this->tokenWithSecurityUser(Role::PROF->value, $replacementTeacherId);
$result = $this->voter->vote($token, $evaluation, [GradeVoter::EDIT]);
self::assertSame(Voter::ACCESS_DENIED, $result);
}
#[Test]
public function itDeniesViewToExpiredReplacementWhoIsNotOwner(): void
{
$replacementTeacherId = UserId::generate();
$this->createExpiredReplacement($replacementTeacherId);
$evaluation = $this->createEvaluation();
$token = $this->tokenWithSecurityUser(Role::PROF->value, $replacementTeacherId);
$result = $this->voter->vote($token, $evaluation, [GradeVoter::VIEW]);
self::assertSame(Voter::ACCESS_DENIED, $result);
}
#[Test]
public function itDeniesViewToReplacementOnDifferentClassSubject(): void
{
$replacementTeacherId = UserId::generate();
// Remplacement actif mais sur une AUTRE classe/matière
$otherClassId = ClassId::generate();
$otherSubjectId = SubjectId::generate();
$replacement = TeacherReplacement::designer(
tenantId: $this->tenantId,
replacedTeacherId: UserId::generate(),
replacementTeacherId: $replacementTeacherId,
startDate: $this->now->modify('-1 day'),
endDate: $this->now->modify('+7 days'),
classes: [new ClassSubjectPair($otherClassId, $otherSubjectId)],
reason: 'Maladie',
createdBy: UserId::generate(),
now: $this->now->modify('-1 day'),
);
$this->replacementRepository->save($replacement);
$evaluation = $this->createEvaluation();
$token = $this->tokenWithSecurityUser(Role::PROF->value, $replacementTeacherId);
$result = $this->voter->vote($token, $evaluation, [GradeVoter::VIEW]);
self::assertSame(Voter::ACCESS_DENIED, $result);
}
#[Test]
public function itDeniesViewToNonTeacherNonAdminRoles(): void
{
$evaluation = $this->createEvaluation();
foreach ([Role::ELEVE->value, Role::PARENT->value, Role::SECRETARIAT->value, Role::VIE_SCOLAIRE->value] as $role) {
$token = $this->tokenWithSecurityUser($role);
$result = $this->voter->vote($token, $evaluation, [GradeVoter::VIEW]);
self::assertSame(Voter::ACCESS_DENIED, $result, "Role {$role} should be denied VIEW");
}
}
#[Test]
public function itDeniesWhenNoTenantIsSet(): void
{
$teacherId = UserId::generate();
$this->setTeacherAffecte($teacherId);
$tenantContext = new TenantContext();
$clock = $this->createMock(Clock::class);
$clock->method('now')->willReturn($this->now);
$test = $this;
$affectationChecker = new class($test) implements EnseignantAffectationChecker {
public function __construct(private readonly GradeVoterTest $test)
{
}
public function estAffecte(
UserId $teacherId,
ClassId $classId,
SubjectId $subjectId,
TenantId $tenantId,
): bool {
return $this->test->getAffectationResult((string) $teacherId);
}
};
$autorisationChecker = new AutorisationSaisieNotesChecker(
$affectationChecker,
$this->replacementRepository,
);
$voter = new GradeVoter(
$autorisationChecker,
$tenantContext,
$clock,
);
$evaluation = $this->createEvaluation();
$token = $this->tokenWithSecurityUser(Role::PROF->value, $teacherId);
$result = $voter->vote($token, $evaluation, [GradeVoter::VIEW]);
self::assertSame(Voter::ACCESS_DENIED, $result);
}
private function createEvaluation(?UserId $teacherId = null): Evaluation
{
return Evaluation::creer(
tenantId: $this->tenantId,
classId: $this->classId,
subjectId: $this->subjectId,
teacherId: $teacherId ?? UserId::generate(),
title: 'Contrôle de maths',
description: null,
evaluationDate: $this->now,
gradeScale: new GradeScale(20),
coefficient: new Coefficient(1.0),
now: $this->now,
);
}
private function createActiveReplacement(UserId $replacementTeacherId): void
{
$replacement = TeacherReplacement::designer(
tenantId: $this->tenantId,
replacedTeacherId: UserId::generate(),
replacementTeacherId: $replacementTeacherId,
startDate: $this->now->modify('-1 day'),
endDate: $this->now->modify('+7 days'),
classes: [new ClassSubjectPair($this->classId, $this->subjectId)],
reason: 'Maladie',
createdBy: UserId::generate(),
now: $this->now->modify('-1 day'),
);
$this->replacementRepository->save($replacement);
}
private function createExpiredReplacement(UserId $replacementTeacherId): void
{
$replacement = TeacherReplacement::designer(
tenantId: $this->tenantId,
replacedTeacherId: UserId::generate(),
replacementTeacherId: $replacementTeacherId,
startDate: $this->now->modify('-14 days'),
endDate: $this->now->modify('-1 day'),
classes: [new ClassSubjectPair($this->classId, $this->subjectId)],
reason: 'Maladie',
createdBy: UserId::generate(),
now: $this->now->modify('-14 days'),
);
$this->replacementRepository->save($replacement);
}
private function tokenWithSecurityUser(string $role, ?UserId $userId = null): TokenInterface
{
$securityUser = new SecurityUser(
userId: $userId ?? UserId::generate(),
email: 'test@example.com',
hashedPassword: 'hashed',
tenantId: $this->tenantId,
roles: [$role],
);
$token = $this->createMock(TokenInterface::class);
$token->method('getUser')->willReturn($securityUser);
return $token;
}
}

View File

@@ -6,10 +6,16 @@ namespace App\Tests\Unit\Scolarite\Infrastructure\Storage;
use App\Scolarite\Application\Port\FileStorage; use App\Scolarite\Application\Port\FileStorage;
use function fopen;
use function fwrite;
use function is_string; use function is_string;
use Override; use Override;
use function rewind;
use RuntimeException;
final class InMemoryFileStorage implements FileStorage final class InMemoryFileStorage implements FileStorage
{ {
/** @var array<string, string> */ /** @var array<string, string> */
@@ -29,6 +35,21 @@ final class InMemoryFileStorage implements FileStorage
unset($this->files[$path]); 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 public function has(string $path): bool
{ {
return isset($this->files[$path]); return isset($this->files[$path]);

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -37,7 +37,7 @@ final class CreateEstablishmentHandlerTest extends TestCase
} }
#[Test] #[Test]
public function createsEstablishmentAndReturnsResult(): void public function createsEstablishmentAndReturnsIt(): void
{ {
$command = new CreateEstablishmentCommand( $command = new CreateEstablishmentCommand(
name: 'École Alpha', name: 'École Alpha',
@@ -46,13 +46,13 @@ final class CreateEstablishmentHandlerTest extends TestCase
superAdminId: self::SUPER_ADMIN_ID, superAdminId: self::SUPER_ADMIN_ID,
); );
$result = ($this->handler)($command); $establishment = ($this->handler)($command);
self::assertNotEmpty($result->establishmentId); self::assertNotEmpty((string) $establishment->id);
self::assertNotEmpty($result->tenantId); self::assertNotEmpty((string) $establishment->tenantId);
self::assertSame('École Alpha', $result->name); self::assertSame('École Alpha', $establishment->name);
self::assertSame('ecole-alpha', $result->subdomain); self::assertSame('ecole-alpha', $establishment->subdomain);
self::assertStringStartsWith('classeo_tenant_', $result->databaseName); self::assertStringStartsWith('classeo_tenant_', $establishment->databaseName);
} }
#[Test] #[Test]
@@ -65,10 +65,10 @@ final class CreateEstablishmentHandlerTest extends TestCase
superAdminId: self::SUPER_ADMIN_ID, superAdminId: self::SUPER_ADMIN_ID,
); );
$result = ($this->handler)($command); $establishment = ($this->handler)($command);
$establishments = $this->repository->findAll(); $establishments = $this->repository->findAll();
self::assertCount(1, $establishments); self::assertCount(1, $establishments);
self::assertSame($result->establishmentId, (string) $establishments[0]->id); self::assertSame((string) $establishment->id, (string) $establishments[0]->id);
} }
} }

View File

@@ -40,6 +40,7 @@ final class GetEstablishmentsHandlerTest extends TestCase
$this->repository->save(Establishment::creer( $this->repository->save(Establishment::creer(
name: 'École Alpha', name: 'École Alpha',
subdomain: 'ecole-alpha', subdomain: 'ecole-alpha',
adminEmail: 'admin@ecole-alpha.fr',
createdBy: SuperAdminId::fromString(self::SUPER_ADMIN_ID), createdBy: SuperAdminId::fromString(self::SUPER_ADMIN_ID),
createdAt: new DateTimeImmutable('2026-02-16 10:00:00'), createdAt: new DateTimeImmutable('2026-02-16 10:00:00'),
)); ));
@@ -47,6 +48,7 @@ final class GetEstablishmentsHandlerTest extends TestCase
$this->repository->save(Establishment::creer( $this->repository->save(Establishment::creer(
name: 'École Beta', name: 'École Beta',
subdomain: 'ecole-beta', subdomain: 'ecole-beta',
adminEmail: 'admin@ecole-beta.fr',
createdBy: SuperAdminId::fromString(self::SUPER_ADMIN_ID), createdBy: SuperAdminId::fromString(self::SUPER_ADMIN_ID),
createdAt: new DateTimeImmutable('2026-02-16 11:00:00'), createdAt: new DateTimeImmutable('2026-02-16 11:00:00'),
)); ));
@@ -56,6 +58,6 @@ final class GetEstablishmentsHandlerTest extends TestCase
self::assertCount(2, $result); self::assertCount(2, $result);
self::assertSame('École Alpha', $result[0]->name); self::assertSame('École Alpha', $result[0]->name);
self::assertSame('ecole-alpha', $result[0]->subdomain); self::assertSame('ecole-alpha', $result[0]->subdomain);
self::assertSame('active', $result[0]->status); self::assertSame('provisioning', $result[0]->status);
} }
} }

View File

@@ -23,11 +23,11 @@ final class EstablishmentTest extends TestCase
private const string SUBDOMAIN = 'ecole-alpha'; private const string SUBDOMAIN = 'ecole-alpha';
#[Test] #[Test]
public function creerCreatesActiveEstablishment(): void public function creerCreatesProvisioningEstablishment(): void
{ {
$establishment = $this->createEstablishment(); $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::ESTABLISHMENT_NAME, $establishment->name);
self::assertSame(self::SUBDOMAIN, $establishment->subdomain); self::assertSame(self::SUBDOMAIN, $establishment->subdomain);
self::assertNull($establishment->lastActivityAt); self::assertNull($establishment->lastActivityAt);
@@ -59,10 +59,21 @@ final class EstablishmentTest extends TestCase
self::assertStringStartsWith('classeo_tenant_', $establishment->databaseName); 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] #[Test]
public function desactiverChangesStatusToInactif(): void public function desactiverChangesStatusToInactif(): void
{ {
$establishment = $this->createEstablishment(); $establishment = $this->createEstablishment();
$establishment->activer();
$establishment->desactiver(new DateTimeImmutable('2026-02-16 12:00:00')); $establishment->desactiver(new DateTimeImmutable('2026-02-16 12:00:00'));
@@ -73,6 +84,7 @@ final class EstablishmentTest extends TestCase
public function desactiverRecordsEtablissementDesactiveEvent(): void public function desactiverRecordsEtablissementDesactiveEvent(): void
{ {
$establishment = $this->createEstablishment(); $establishment = $this->createEstablishment();
$establishment->activer();
$establishment->pullDomainEvents(); // Clear creation event $establishment->pullDomainEvents(); // Clear creation event
$establishment->desactiver(new DateTimeImmutable('2026-02-16 12:00:00')); $establishment->desactiver(new DateTimeImmutable('2026-02-16 12:00:00'));
@@ -86,6 +98,7 @@ final class EstablishmentTest extends TestCase
public function desactiverThrowsWhenAlreadyInactive(): void public function desactiverThrowsWhenAlreadyInactive(): void
{ {
$establishment = $this->createEstablishment(); $establishment = $this->createEstablishment();
$establishment->activer();
$establishment->desactiver(new DateTimeImmutable('2026-02-16 12:00:00')); $establishment->desactiver(new DateTimeImmutable('2026-02-16 12:00:00'));
$this->expectException(EstablishmentDejaInactifException::class); $this->expectException(EstablishmentDejaInactifException::class);
@@ -141,6 +154,7 @@ final class EstablishmentTest extends TestCase
return Establishment::creer( return Establishment::creer(
name: self::ESTABLISHMENT_NAME, name: self::ESTABLISHMENT_NAME,
subdomain: self::SUBDOMAIN, subdomain: self::SUBDOMAIN,
adminEmail: 'admin@ecole-alpha.fr',
createdBy: SuperAdminId::fromString(self::SUPER_ADMIN_ID), createdBy: SuperAdminId::fromString(self::SUPER_ADMIN_ID),
createdAt: new DateTimeImmutable('2026-02-16 10:00:00'), createdAt: new DateTimeImmutable('2026-02-16 10:00:00'),
); );

View File

@@ -7,6 +7,7 @@ namespace App\Tests\Unit\SuperAdmin\Infrastructure\Api\Processor;
use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Post;
use App\Shared\Domain\Clock; use App\Shared\Domain\Clock;
use App\SuperAdmin\Application\Command\CreateEstablishment\CreateEstablishmentHandler; use App\SuperAdmin\Application\Command\CreateEstablishment\CreateEstablishmentHandler;
use App\SuperAdmin\Application\Command\ProvisionEstablishment\ProvisionEstablishmentCommand;
use App\SuperAdmin\Domain\Model\SuperAdmin\SuperAdminId; use App\SuperAdmin\Domain\Model\SuperAdmin\SuperAdminId;
use App\SuperAdmin\Infrastructure\Api\Processor\CreateEstablishmentProcessor; use App\SuperAdmin\Infrastructure\Api\Processor\CreateEstablishmentProcessor;
use App\SuperAdmin\Infrastructure\Api\Resource\EstablishmentResource; use App\SuperAdmin\Infrastructure\Api\Resource\EstablishmentResource;
@@ -16,13 +17,15 @@ use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Symfony\Bundle\SecurityBundle\Security; use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\MessageBusInterface;
final class CreateEstablishmentProcessorTest extends TestCase final class CreateEstablishmentProcessorTest extends TestCase
{ {
private const string SUPER_ADMIN_ID = '550e8400-e29b-41d4-a716-446655440001'; private const string SUPER_ADMIN_ID = '550e8400-e29b-41d4-a716-446655440001';
#[Test] #[Test]
public function processCreatesEstablishmentAndReturnsResource(): void public function processCreatesEstablishmentAndDispatchesProvisioning(): void
{ {
$repository = new InMemoryEstablishmentRepository(); $repository = new InMemoryEstablishmentRepository();
$clock = new class implements Clock { $clock = new class implements Clock {
@@ -42,7 +45,16 @@ final class CreateEstablishmentProcessorTest extends TestCase
$security = $this->createMock(Security::class); $security = $this->createMock(Security::class);
$security->method('getUser')->willReturn($securityUser); $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 = new EstablishmentResource();
$input->name = 'École Gamma'; $input->name = 'École Gamma';
@@ -55,6 +67,12 @@ final class CreateEstablishmentProcessorTest extends TestCase
self::assertNotNull($result->tenantId); self::assertNotNull($result->tenantId);
self::assertSame('École Gamma', $result->name); self::assertSame('École Gamma', $result->name);
self::assertSame('ecole-gamma', $result->subdomain); 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);
} }
} }

View File

@@ -37,6 +37,7 @@ final class EstablishmentCollectionProviderTest extends TestCase
$repository->save(Establishment::creer( $repository->save(Establishment::creer(
name: 'École Alpha', name: 'École Alpha',
subdomain: 'ecole-alpha', subdomain: 'ecole-alpha',
adminEmail: 'admin@ecole-alpha.fr',
createdBy: SuperAdminId::fromString(self::SUPER_ADMIN_ID), createdBy: SuperAdminId::fromString(self::SUPER_ADMIN_ID),
createdAt: new DateTimeImmutable('2026-02-16 10:00:00'), createdAt: new DateTimeImmutable('2026-02-16 10:00:00'),
)); ));
@@ -49,6 +50,6 @@ final class EstablishmentCollectionProviderTest extends TestCase
self::assertCount(1, $result); self::assertCount(1, $result);
self::assertSame('École Alpha', $result[0]->name); self::assertSame('École Alpha', $result[0]->name);
self::assertSame('ecole-alpha', $result[0]->subdomain); self::assertSame('ecole-alpha', $result[0]->subdomain);
self::assertSame('active', $result[0]->status); self::assertSame('provisioning', $result[0]->status);
} }
} }

View File

@@ -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');
}
}

Some files were not shown because too many files have changed in this diff Show More