From e72867932dd2154711ff718fc5e0c26880bb794b Mon Sep 17 00:00:00 2001 From: Mathias STRASSER Date: Wed, 8 Apr 2026 13:55:41 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Provisionner=20automatiquement=20un=20n?= =?UTF-8?q?ouvel=20=C3=A9tablissement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../sprint-status.yaml | 6 +- backend/.env | 8 + backend/composer.json | 2 + backend/composer.lock | 790 +++++++++++++++++- backend/config/packages/messenger.yaml | 2 + backend/config/packages/prod/tenant.yaml | 25 +- backend/config/services.yaml | 33 +- .../Application/Port/FileStorage.php | 5 + .../Port/TeacherStatisticsReader.php | 133 +++ .../GetBlockedDatesHandler.php | 28 +- .../ClassStatisticsDetailDto.php | 22 + .../GetClassStatisticsDetailHandler.php | 113 +++ .../GetClassStatisticsDetailQuery.php | 17 + .../StudentAverageDto.php | 17 + .../EvaluationDifficultyDto.php | 23 + .../GetEvaluationDifficultyHandler.php | 73 ++ .../GetEvaluationDifficultyQuery.php | 14 + .../GetStudentProgressionHandler.php | 68 ++ .../GetStudentProgressionQuery.php | 17 + .../GetStudentProgression/GradePointDto.php | 15 + .../StudentProgressionDto.php | 19 + .../ClassOverviewDto.php | 20 + .../GetTeacherStatisticsOverviewHandler.php | 58 ++ .../GetTeacherStatisticsOverviewQuery.php | 14 + .../Service/StatisticsExporter.php | 64 ++ .../Service/TeacherStatisticsCalculator.php | 154 ++++ .../Scolarite/Domain/Service/TrendResult.php | 14 + .../HomeworkAttachmentController.php | 39 +- .../Controller/ParentHomeworkController.php | 41 +- .../Controller/StudentHomeworkController.php | 41 +- .../TeacherSubmissionController.php | 41 +- .../Provider/EvaluationDifficultyProvider.php | 84 ++ .../Provider/StudentProgressionProvider.php | 107 +++ .../TeacherClassStatisticsProvider.php | 112 +++ .../TeacherStatisticsExportProvider.php | 105 +++ .../TeacherStatisticsOverviewProvider.php | 80 ++ .../Resource/EvaluationDifficultyResource.php | 29 + .../Resource/StudentProgressionResource.php | 36 + .../TeacherClassStatisticsResource.php | 41 + .../TeacherStatisticsExportResource.php | 32 + .../TeacherStatisticsOverviewResource.php | 31 + .../ReadModel/DbalTeacherStatisticsReader.php | 487 +++++++++++ .../InMemoryTeacherStatisticsReader.php | 122 +++ .../Storage/LocalFileStorage.php | 27 + .../Infrastructure/Storage/S3FileStorage.php | 91 ++ .../Tenant/DoctrineTenantRegistry.php | 132 +++ .../CreateEstablishmentHandler.php | 11 +- .../CreateEstablishmentResult.php | 17 - .../ProvisionEstablishmentCommand.php | 25 + .../Application/Port/TenantProvisioner.php | 20 + .../Domain/Event/EtablissementCree.php | 1 + .../Model/Establishment/Establishment.php | 9 +- .../Establishment/EstablishmentStatus.php | 1 + .../CreateEstablishmentProcessor.php | 26 +- .../DatabaseTenantProvisioner.php | 27 + .../ProvisionEstablishmentHandler.php | 180 ++++ .../Provisioning/TenantDatabaseCreator.php | 76 ++ .../Provisioning/TenantMigrator.php | 78 ++ .../Api/PasswordResetEndpointsTest.php | 8 +- .../Api/TeacherStatisticsEndpointsTest.php | 527 ++++++++++++ .../Audit/AuditTrailFunctionalTest.php | 185 ++++ .../Service/GouvFrCalendarApiTest.php | 17 + .../UploadSubmissionAttachmentHandlerTest.php | 8 + .../GetBlockedDatesHandlerTest.php | 114 ++- .../GetClassStatisticsDetailHandlerTest.php | 109 +++ .../GetEvaluationDifficultyHandlerTest.php | 101 +++ .../GetStudentProgressionHandlerTest.php | 78 ++ ...etTeacherStatisticsOverviewHandlerTest.php | 106 +++ .../Service/StatisticsExporterTest.php | 82 ++ .../TeacherStatisticsCalculatorTest.php | 281 +++++++ .../HomeworkAttachmentControllerTest.php | 271 ++++++ .../Storage/InMemoryFileStorage.php | 21 + .../Storage/S3FileStorageTest.php | 141 ++++ .../Tenant/DoctrineTenantRegistryTest.php | 119 +++ .../CreateEstablishmentHandlerTest.php | 18 +- .../GetEstablishmentsHandlerTest.php | 4 +- .../Model/Establishment/EstablishmentTest.php | 18 +- .../CreateEstablishmentProcessorTest.php | 24 +- .../EstablishmentCollectionProviderTest.php | 3 +- .../DatabaseTenantProvisionerTest.php | 72 ++ .../ProvisionEstablishmentHandlerTest.php | 236 ++++++ .../ProvisioningIntegrationTest.php | 166 ++++ .../Provisioning/SpyDatabaseSwitcher.php | 32 + compose.yaml | 40 + deploy/vps/Caddyfile | 12 +- frontend/e2e/grades.spec.ts | 2 +- ...s.spec.ts => homework-attachments.spec.ts} | 268 ++---- frontend/e2e/homework-calendar.spec.ts | 395 +++++++++ frontend/e2e/homework-exception.spec.ts | 4 +- frontend/e2e/homework-wysiwyg.spec.ts | 316 +++++++ frontend/e2e/role-access-control.spec.ts | 1 + frontend/e2e/role-assignment.spec.ts | 167 ++++ frontend/e2e/sessions.spec.ts | 13 +- frontend/e2e/settings.spec.ts | 3 +- frontend/e2e/student-creation.spec.ts | 2 +- frontend/e2e/student-schedule.spec.ts | 2 +- frontend/e2e/super-admin-provisioning.spec.ts | 205 +++++ frontend/e2e/teacher-replacements.spec.ts | 1 + frontend/e2e/teacher-statistics.spec.ts | 330 ++++++++ frontend/e2e/token-edge-cases.spec.ts | 2 +- .../CalendarDatePicker.svelte | 563 +++++++++++++ .../molecules/FileUpload/FileUpload.svelte | 134 ++- .../Dashboard/DashboardTeacher.svelte | 108 ++- frontend/src/routes/dashboard/+layout.svelte | 4 + .../dashboard/teacher/homework/+page.svelte | 74 +- .../dashboard/teacher/statistics/+page.svelte | 785 +++++++++++++++++ .../super-admin/establishments/+page.svelte | 17 +- 107 files changed, 9709 insertions(+), 383 deletions(-) create mode 100644 backend/src/Scolarite/Application/Port/TeacherStatisticsReader.php create mode 100644 backend/src/Scolarite/Application/Query/GetClassStatisticsDetail/ClassStatisticsDetailDto.php create mode 100644 backend/src/Scolarite/Application/Query/GetClassStatisticsDetail/GetClassStatisticsDetailHandler.php create mode 100644 backend/src/Scolarite/Application/Query/GetClassStatisticsDetail/GetClassStatisticsDetailQuery.php create mode 100644 backend/src/Scolarite/Application/Query/GetClassStatisticsDetail/StudentAverageDto.php create mode 100644 backend/src/Scolarite/Application/Query/GetEvaluationDifficulty/EvaluationDifficultyDto.php create mode 100644 backend/src/Scolarite/Application/Query/GetEvaluationDifficulty/GetEvaluationDifficultyHandler.php create mode 100644 backend/src/Scolarite/Application/Query/GetEvaluationDifficulty/GetEvaluationDifficultyQuery.php create mode 100644 backend/src/Scolarite/Application/Query/GetStudentProgression/GetStudentProgressionHandler.php create mode 100644 backend/src/Scolarite/Application/Query/GetStudentProgression/GetStudentProgressionQuery.php create mode 100644 backend/src/Scolarite/Application/Query/GetStudentProgression/GradePointDto.php create mode 100644 backend/src/Scolarite/Application/Query/GetStudentProgression/StudentProgressionDto.php create mode 100644 backend/src/Scolarite/Application/Query/GetTeacherStatisticsOverview/ClassOverviewDto.php create mode 100644 backend/src/Scolarite/Application/Query/GetTeacherStatisticsOverview/GetTeacherStatisticsOverviewHandler.php create mode 100644 backend/src/Scolarite/Application/Query/GetTeacherStatisticsOverview/GetTeacherStatisticsOverviewQuery.php create mode 100644 backend/src/Scolarite/Application/Service/StatisticsExporter.php create mode 100644 backend/src/Scolarite/Domain/Service/TeacherStatisticsCalculator.php create mode 100644 backend/src/Scolarite/Domain/Service/TrendResult.php create mode 100644 backend/src/Scolarite/Infrastructure/Api/Provider/EvaluationDifficultyProvider.php create mode 100644 backend/src/Scolarite/Infrastructure/Api/Provider/StudentProgressionProvider.php create mode 100644 backend/src/Scolarite/Infrastructure/Api/Provider/TeacherClassStatisticsProvider.php create mode 100644 backend/src/Scolarite/Infrastructure/Api/Provider/TeacherStatisticsExportProvider.php create mode 100644 backend/src/Scolarite/Infrastructure/Api/Provider/TeacherStatisticsOverviewProvider.php create mode 100644 backend/src/Scolarite/Infrastructure/Api/Resource/EvaluationDifficultyResource.php create mode 100644 backend/src/Scolarite/Infrastructure/Api/Resource/StudentProgressionResource.php create mode 100644 backend/src/Scolarite/Infrastructure/Api/Resource/TeacherClassStatisticsResource.php create mode 100644 backend/src/Scolarite/Infrastructure/Api/Resource/TeacherStatisticsExportResource.php create mode 100644 backend/src/Scolarite/Infrastructure/Api/Resource/TeacherStatisticsOverviewResource.php create mode 100644 backend/src/Scolarite/Infrastructure/ReadModel/DbalTeacherStatisticsReader.php create mode 100644 backend/src/Scolarite/Infrastructure/ReadModel/InMemoryTeacherStatisticsReader.php create mode 100644 backend/src/Scolarite/Infrastructure/Storage/S3FileStorage.php create mode 100644 backend/src/Shared/Infrastructure/Tenant/DoctrineTenantRegistry.php delete mode 100644 backend/src/SuperAdmin/Application/Command/CreateEstablishment/CreateEstablishmentResult.php create mode 100644 backend/src/SuperAdmin/Application/Command/ProvisionEstablishment/ProvisionEstablishmentCommand.php create mode 100644 backend/src/SuperAdmin/Application/Port/TenantProvisioner.php create mode 100644 backend/src/SuperAdmin/Infrastructure/Provisioning/DatabaseTenantProvisioner.php create mode 100644 backend/src/SuperAdmin/Infrastructure/Provisioning/ProvisionEstablishmentHandler.php create mode 100644 backend/src/SuperAdmin/Infrastructure/Provisioning/TenantDatabaseCreator.php create mode 100644 backend/src/SuperAdmin/Infrastructure/Provisioning/TenantMigrator.php create mode 100644 backend/tests/Functional/Scolarite/Api/TeacherStatisticsEndpointsTest.php create mode 100644 backend/tests/Functional/Shared/Infrastructure/Audit/AuditTrailFunctionalTest.php create mode 100644 backend/tests/Unit/Scolarite/Application/Query/GetClassStatisticsDetail/GetClassStatisticsDetailHandlerTest.php create mode 100644 backend/tests/Unit/Scolarite/Application/Query/GetEvaluationDifficulty/GetEvaluationDifficultyHandlerTest.php create mode 100644 backend/tests/Unit/Scolarite/Application/Query/GetStudentProgression/GetStudentProgressionHandlerTest.php create mode 100644 backend/tests/Unit/Scolarite/Application/Query/GetTeacherStatisticsOverview/GetTeacherStatisticsOverviewHandlerTest.php create mode 100644 backend/tests/Unit/Scolarite/Application/Service/StatisticsExporterTest.php create mode 100644 backend/tests/Unit/Scolarite/Domain/Service/TeacherStatisticsCalculatorTest.php create mode 100644 backend/tests/Unit/Scolarite/Infrastructure/Api/Controller/HomeworkAttachmentControllerTest.php create mode 100644 backend/tests/Unit/Scolarite/Infrastructure/Storage/S3FileStorageTest.php create mode 100644 backend/tests/Unit/Shared/Infrastructure/Tenant/DoctrineTenantRegistryTest.php create mode 100644 backend/tests/Unit/SuperAdmin/Infrastructure/Provisioning/DatabaseTenantProvisionerTest.php create mode 100644 backend/tests/Unit/SuperAdmin/Infrastructure/Provisioning/ProvisionEstablishmentHandlerTest.php create mode 100644 backend/tests/Unit/SuperAdmin/Infrastructure/Provisioning/ProvisioningIntegrationTest.php create mode 100644 backend/tests/Unit/SuperAdmin/Infrastructure/Provisioning/SpyDatabaseSwitcher.php rename frontend/e2e/{homework-richtext-attachments.spec.ts => homework-attachments.spec.ts} (62%) create mode 100644 frontend/e2e/homework-calendar.spec.ts create mode 100644 frontend/e2e/homework-wysiwyg.spec.ts create mode 100644 frontend/e2e/role-assignment.spec.ts create mode 100644 frontend/e2e/super-admin-provisioning.spec.ts create mode 100644 frontend/e2e/teacher-statistics.spec.ts create mode 100644 frontend/src/lib/components/molecules/CalendarDatePicker/CalendarDatePicker.svelte create mode 100644 frontend/src/routes/dashboard/teacher/statistics/+page.svelte diff --git a/_bmad-output/implementation-artifacts/sprint-status.yaml b/_bmad-output/implementation-artifacts/sprint-status.yaml index a6924e9..364e7af 100644 --- a/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -74,7 +74,7 @@ development_status: 2-12b-optimistic-update-pages-admin: done 2-13-personnalisation-visuelle-etablissement: 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 3: Import & Onboarding (5 stories) @@ -108,7 +108,7 @@ development_status: 5-8-consultation-des-devoirs-par-le-parent: done 5-9-description-enrichie-et-pieces-jointes-enseignant: 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 6: Notes & Évaluations (12 stories) @@ -120,7 +120,7 @@ development_status: 6-5-mode-competences: done 6-6-consultation-notes-par-leleve: 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-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 diff --git a/backend/.env b/backend/.env index 2c0ee93..3a10a96 100644 --- a/backend/.env +++ b/backend/.env @@ -89,6 +89,14 @@ TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA TURNSTILE_FAIL_OPEN=true ###< cloudflare/turnstile ### +###> s3/minio ### +S3_ENDPOINT=http://minio:9000 +S3_BUCKET=classeo +S3_KEY=classeo +S3_SECRET=classeo_secret +S3_REGION=us-east-1 +###< s3/minio ### + ###> symfony/lock ### # Choose one of the stores below # postgresql+advisory://db_user:db_password@localhost/db_name diff --git a/backend/composer.json b/backend/composer.json index 261dbfa..8edc475 100644 --- a/backend/composer.json +++ b/backend/composer.json @@ -15,6 +15,7 @@ "doctrine/doctrine-bundle": "^2.13 || ^3.0@dev", "doctrine/doctrine-migrations-bundle": "^3.4", "doctrine/orm": "^3.3", + "league/flysystem-aws-s3-v3": "^3.32", "lexik/jwt-authentication-bundle": "^3.2", "nelmio/cors-bundle": "^2.6", "phpoffice/phpspreadsheet": "^5.4", @@ -26,6 +27,7 @@ "symfony/console": "^8.0", "symfony/doctrine-messenger": "^8.0", "symfony/dotenv": "^8.0", + "symfony/expression-language": "8.0.*", "symfony/flex": "^2", "symfony/framework-bundle": "^8.0", "symfony/html-sanitizer": "8.0.*", diff --git a/backend/composer.lock b/backend/composer.lock index 57b067c..a9d0d98 100644 --- a/backend/composer.lock +++ b/backend/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "92b9472c96a59c314d96372c4094f185", + "content-hash": "851abcf008c69423a69ad329ae88a255", "packages": [ { "name": "api-platform/core", @@ -224,6 +224,157 @@ }, "time": "2026-01-23T15:23:18+00:00" }, + { + "name": "aws/aws-crt-php", + "version": "v1.2.7", + "source": { + "type": "git", + "url": "https://github.com/awslabs/aws-crt-php.git", + "reference": "d71d9906c7bb63a28295447ba12e74723bd3730e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/awslabs/aws-crt-php/zipball/d71d9906c7bb63a28295447ba12e74723bd3730e", + "reference": "d71d9906c7bb63a28295447ba12e74723bd3730e", + "shasum": "" + }, + "require": { + "php": ">=5.5" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35||^5.6.3||^9.5", + "yoast/phpunit-polyfills": "^1.0" + }, + "suggest": { + "ext-awscrt": "Make sure you install awscrt native extension to use any of the functionality." + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "AWS SDK Common Runtime Team", + "email": "aws-sdk-common-runtime@amazon.com" + } + ], + "description": "AWS Common Runtime for PHP", + "homepage": "https://github.com/awslabs/aws-crt-php", + "keywords": [ + "amazon", + "aws", + "crt", + "sdk" + ], + "support": { + "issues": "https://github.com/awslabs/aws-crt-php/issues", + "source": "https://github.com/awslabs/aws-crt-php/tree/v1.2.7" + }, + "time": "2024-10-18T22:15:13+00:00" + }, + { + "name": "aws/aws-sdk-php", + "version": "3.378.0", + "source": { + "type": "git", + "url": "https://github.com/aws/aws-sdk-php.git", + "reference": "7a95e0665ad13c2cb8999d64439cf969c86724dd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/7a95e0665ad13c2cb8999d64439cf969c86724dd", + "reference": "7a95e0665ad13c2cb8999d64439cf969c86724dd", + "shasum": "" + }, + "require": { + "aws/aws-crt-php": "^1.2.3", + "ext-json": "*", + "ext-pcre": "*", + "ext-simplexml": "*", + "guzzlehttp/guzzle": "^7.4.5", + "guzzlehttp/promises": "^2.0", + "guzzlehttp/psr7": "^2.4.5", + "mtdowling/jmespath.php": "^2.8.0", + "php": ">=8.1", + "psr/http-message": "^1.0 || ^2.0", + "symfony/filesystem": "^v5.4.45 || ^v6.4.3 || ^v7.1.0 || ^v8.0.0" + }, + "require-dev": { + "andrewsville/php-token-reflection": "^1.4", + "aws/aws-php-sns-message-validator": "~1.0", + "behat/behat": "~3.0", + "composer/composer": "^2.7.8", + "dms/phpunit-arraysubset-asserts": "^v0.5.0", + "doctrine/cache": "~1.4", + "ext-dom": "*", + "ext-openssl": "*", + "ext-sockets": "*", + "phpunit/phpunit": "^10.0", + "psr/cache": "^2.0 || ^3.0", + "psr/simple-cache": "^2.0 || ^3.0", + "sebastian/comparator": "^1.2.3 || ^4.0 || ^5.0", + "yoast/phpunit-polyfills": "^2.0" + }, + "suggest": { + "aws/aws-php-sns-message-validator": "To validate incoming SNS notifications", + "doctrine/cache": "To use the DoctrineCacheAdapter", + "ext-curl": "To send requests using cURL", + "ext-openssl": "Allows working with CloudFront private distributions and verifying received SNS messages", + "ext-pcntl": "To use client-side monitoring", + "ext-sockets": "To use client-side monitoring" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Aws\\": "src/" + }, + "exclude-from-classmap": [ + "src/data/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Amazon Web Services", + "homepage": "https://aws.amazon.com" + } + ], + "description": "AWS SDK for PHP - Use Amazon Web Services in your PHP project", + "homepage": "https://aws.amazon.com/sdk-for-php", + "keywords": [ + "amazon", + "aws", + "cloud", + "dynamodb", + "ec2", + "glacier", + "s3", + "sdk" + ], + "support": { + "forum": "https://github.com/aws/aws-sdk-php/discussions", + "issues": "https://github.com/aws/aws-sdk-php/issues", + "source": "https://github.com/aws/aws-sdk-php/tree/3.378.0" + }, + "time": "2026-04-08T18:13:19+00:00" + }, { "name": "brick/math", "version": "0.14.1", @@ -1536,6 +1687,215 @@ ], "time": "2025-03-06T22:45:56+00:00" }, + { + "name": "guzzlehttp/guzzle", + "version": "7.10.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/guzzle.git", + "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", + "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", + "shasum": "" + }, + "require": { + "ext-json": "*", + "guzzlehttp/promises": "^2.3", + "guzzlehttp/psr7": "^2.8", + "php": "^7.2.5 || ^8.0", + "psr/http-client": "^1.0", + "symfony/deprecation-contracts": "^2.2 || ^3.0" + }, + "provide": { + "psr/http-client-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-curl": "*", + "guzzle/client-integration-tests": "3.0.2", + "php-http/message-factory": "^1.1", + "phpunit/phpunit": "^8.5.39 || ^9.6.20", + "psr/log": "^1.1 || ^2.0 || ^3.0" + }, + "suggest": { + "ext-curl": "Required for CURL handler support", + "ext-intl": "Required for Internationalized Domain Name (IDN) support", + "psr/log": "Required for using the Log middleware" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "GuzzleHttp\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Jeremy Lindblom", + "email": "jeremeamia@gmail.com", + "homepage": "https://github.com/jeremeamia" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle is a PHP HTTP client library", + "keywords": [ + "client", + "curl", + "framework", + "http", + "http client", + "psr-18", + "psr-7", + "rest", + "web service" + ], + "support": { + "issues": "https://github.com/guzzle/guzzle/issues", + "source": "https://github.com/guzzle/guzzle/tree/7.10.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle", + "type": "tidelift" + } + ], + "time": "2025-08-23T22:36:01+00:00" + }, + { + "name": "guzzlehttp/promises", + "version": "2.3.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/promises.git", + "reference": "481557b130ef3790cf82b713667b43030dc9c957" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/promises/zipball/481557b130ef3790cf82b713667b43030dc9c957", + "reference": "481557b130ef3790cf82b713667b43030dc9c957", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.44 || ^9.6.25" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle promises library", + "keywords": [ + "promise" + ], + "support": { + "issues": "https://github.com/guzzle/promises/issues", + "source": "https://github.com/guzzle/promises/tree/2.3.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises", + "type": "tidelift" + } + ], + "time": "2025-08-22T14:34:08+00:00" + }, { "name": "guzzlehttp/psr7", "version": "2.8.0", @@ -1785,6 +2145,249 @@ ], "time": "2025-10-17T11:30:53+00:00" }, + { + "name": "league/flysystem", + "version": "3.33.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem.git", + "reference": "570b8871e0ce693764434b29154c54b434905350" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/570b8871e0ce693764434b29154c54b434905350", + "reference": "570b8871e0ce693764434b29154c54b434905350", + "shasum": "" + }, + "require": { + "league/flysystem-local": "^3.0.0", + "league/mime-type-detection": "^1.0.0", + "php": "^8.0.2" + }, + "conflict": { + "async-aws/core": "<1.19.0", + "async-aws/s3": "<1.14.0", + "aws/aws-sdk-php": "3.209.31 || 3.210.0", + "guzzlehttp/guzzle": "<7.0", + "guzzlehttp/ringphp": "<1.1.1", + "phpseclib/phpseclib": "3.0.15", + "symfony/http-client": "<5.2" + }, + "require-dev": { + "async-aws/s3": "^1.5 || ^2.0", + "async-aws/simple-s3": "^1.1 || ^2.0", + "aws/aws-sdk-php": "^3.295.10", + "composer/semver": "^3.0", + "ext-fileinfo": "*", + "ext-ftp": "*", + "ext-mongodb": "^1.3|^2", + "ext-zip": "*", + "friendsofphp/php-cs-fixer": "^3.5", + "google/cloud-storage": "^1.23", + "guzzlehttp/psr7": "^2.6", + "microsoft/azure-storage-blob": "^1.1", + "mongodb/mongodb": "^1.2|^2", + "phpseclib/phpseclib": "^3.0.36", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^9.5.11|^10.0", + "sabre/dav": "^4.6.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\Flysystem\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "File storage abstraction for PHP", + "keywords": [ + "WebDAV", + "aws", + "cloud", + "file", + "files", + "filesystem", + "filesystems", + "ftp", + "s3", + "sftp", + "storage" + ], + "support": { + "issues": "https://github.com/thephpleague/flysystem/issues", + "source": "https://github.com/thephpleague/flysystem/tree/3.33.0" + }, + "time": "2026-03-25T07:59:30+00:00" + }, + { + "name": "league/flysystem-aws-s3-v3", + "version": "3.32.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem-aws-s3-v3.git", + "reference": "a1979df7c9784d334ea6df356aed3d18ac6673d0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/a1979df7c9784d334ea6df356aed3d18ac6673d0", + "reference": "a1979df7c9784d334ea6df356aed3d18ac6673d0", + "shasum": "" + }, + "require": { + "aws/aws-sdk-php": "^3.295.10", + "league/flysystem": "^3.10.0", + "league/mime-type-detection": "^1.0.0", + "php": "^8.0.2" + }, + "conflict": { + "guzzlehttp/guzzle": "<7.0", + "guzzlehttp/ringphp": "<1.1.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\Flysystem\\AwsS3V3\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "AWS S3 filesystem adapter for Flysystem.", + "keywords": [ + "Flysystem", + "aws", + "file", + "files", + "filesystem", + "s3", + "storage" + ], + "support": { + "source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/3.32.0" + }, + "time": "2026-02-25T16:46:44+00:00" + }, + { + "name": "league/flysystem-local", + "version": "3.31.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem-local.git", + "reference": "2f669db18a4c20c755c2bb7d3a7b0b2340488079" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/2f669db18a4c20c755c2bb7d3a7b0b2340488079", + "reference": "2f669db18a4c20c755c2bb7d3a7b0b2340488079", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "league/flysystem": "^3.0.0", + "league/mime-type-detection": "^1.0.0", + "php": "^8.0.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\Flysystem\\Local\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "Local filesystem adapter for Flysystem.", + "keywords": [ + "Flysystem", + "file", + "files", + "filesystem", + "local" + ], + "support": { + "source": "https://github.com/thephpleague/flysystem-local/tree/3.31.0" + }, + "time": "2026-01-23T15:30:45+00:00" + }, + { + "name": "league/mime-type-detection", + "version": "1.16.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/mime-type-detection.git", + "reference": "2d6702ff215bf922936ccc1ad31007edc76451b9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/2d6702ff215bf922936ccc1ad31007edc76451b9", + "reference": "2d6702ff215bf922936ccc1ad31007edc76451b9", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.2", + "phpstan/phpstan": "^0.12.68", + "phpunit/phpunit": "^8.5.8 || ^9.3 || ^10.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\MimeTypeDetection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "Mime-type detection for Flysystem", + "support": { + "issues": "https://github.com/thephpleague/mime-type-detection/issues", + "source": "https://github.com/thephpleague/mime-type-detection/tree/1.16.0" + }, + "funding": [ + { + "url": "https://github.com/frankdejonge", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/league/flysystem", + "type": "tidelift" + } + ], + "time": "2024-09-21T08:32:55+00:00" + }, { "name": "league/uri", "version": "7.8.1", @@ -2371,6 +2974,72 @@ ], "time": "2026-01-02T08:56:05+00:00" }, + { + "name": "mtdowling/jmespath.php", + "version": "2.8.0", + "source": { + "type": "git", + "url": "https://github.com/jmespath/jmespath.php.git", + "reference": "a2a865e05d5f420b50cc2f85bb78d565db12a6bc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/jmespath/jmespath.php/zipball/a2a865e05d5f420b50cc2f85bb78d565db12a6bc", + "reference": "a2a865e05d5f420b50cc2f85bb78d565db12a6bc", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "symfony/polyfill-mbstring": "^1.17" + }, + "require-dev": { + "composer/xdebug-handler": "^3.0.3", + "phpunit/phpunit": "^8.5.33" + }, + "bin": [ + "bin/jp.php" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.8-dev" + } + }, + "autoload": { + "files": [ + "src/JmesPath.php" + ], + "psr-4": { + "JmesPath\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + } + ], + "description": "Declaratively specify how to extract elements from a JSON document", + "keywords": [ + "json", + "jsonpath" + ], + "support": { + "issues": "https://github.com/jmespath/jmespath.php/issues", + "source": "https://github.com/jmespath/jmespath.php/tree/2.8.0" + }, + "time": "2024-09-04T18:46:31+00:00" + }, { "name": "nelmio/cors-bundle", "version": "2.6.1", @@ -2813,6 +3482,58 @@ }, "time": "2019-01-08T18:20:26+00:00" }, + { + "name": "psr/http-client", + "version": "1.0.3", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-client.git", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP clients", + "homepage": "https://github.com/php-fig/http-client", + "keywords": [ + "http", + "http-client", + "psr", + "psr-18" + ], + "support": { + "source": "https://github.com/php-fig/http-client" + }, + "time": "2023-09-23T14:17:50+00:00" + }, { "name": "psr/http-factory", "version": "1.1.0", @@ -4672,6 +5393,73 @@ ], "time": "2024-09-25T14:21:43+00:00" }, + { + "name": "symfony/expression-language", + "version": "v8.0.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/expression-language.git", + "reference": "b2a5fd3b7331ae10cc0ed75a28d64b25b67d2c7b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/expression-language/zipball/b2a5fd3b7331ae10cc0ed75a28d64b25b67d2c7b", + "reference": "b2a5fd3b7331ae10cc0ed75a28d64b25b67d2c7b", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/cache": "^7.4|^8.0", + "symfony/service-contracts": "^2.5|^3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\ExpressionLanguage\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an engine that can compile and evaluate expressions", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/expression-language/tree/v8.0.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-30T15:14:47+00:00" + }, { "name": "symfony/filesystem", "version": "v8.0.1", diff --git a/backend/config/packages/messenger.yaml b/backend/config/packages/messenger.yaml index 3376c5d..0249116 100644 --- a/backend/config/packages/messenger.yaml +++ b/backend/config/packages/messenger.yaml @@ -64,3 +64,5 @@ framework: # Import élèves/enseignants → async (batch processing, peut être long) App\Administration\Application\Command\ImportStudents\ImportStudentsCommand: async App\Administration\Application\Command\ImportTeachers\ImportTeachersCommand: async + # Provisioning établissement → async (création BDD, migrations, premier admin) + App\SuperAdmin\Application\Command\ProvisionEstablishment\ProvisionEstablishmentCommand: async diff --git a/backend/config/packages/prod/tenant.yaml b/backend/config/packages/prod/tenant.yaml index dbff0af..9e2d852 100644 --- a/backend/config/packages/prod/tenant.yaml +++ b/backend/config/packages/prod/tenant.yaml @@ -1,19 +1,14 @@ -# Configuration des tenants en production +# Tenants en production : résolution dynamique depuis la base establishments # -# En production, les tenants peuvent être configurés de deux façons : -# 1. Via la variable d'environnement TENANT_CONFIGS (JSON) -# 2. Via une implémentation DatabaseTenantRegistry (à implémenter) -# -# Pour l'instant, on utilise InMemoryTenantRegistry avec configuration env. -# Si aucun tenant n'est configuré, toutes les requêtes retourneront 404. - -parameters: - # Format JSON attendu: [{"tenantId":"uuid","subdomain":"ecole","databaseUrl":"postgres://..."}] - tenant.prod_configs_json: '%env(default::TENANT_CONFIGS)%' +# Le DoctrineTenantRegistry interroge la table establishments sur la base master. +# Les nouveaux établissements sont immédiatement accessibles via leur sous-domaine +# sans redémarrage de l'application. services: - App\Shared\Infrastructure\Tenant\TenantRegistry: - class: App\Shared\Infrastructure\Tenant\InMemoryTenantRegistry - factory: ['@App\Shared\Infrastructure\Tenant\TenantRegistryFactory', 'createFromEnv'] + App\Shared\Infrastructure\Tenant\DoctrineTenantRegistry: arguments: - $configsJson: '%tenant.prod_configs_json%' + $connection: '@doctrine.dbal.master_connection' + $masterDatabaseUrl: '%env(DATABASE_URL)%' + + App\Shared\Infrastructure\Tenant\TenantRegistry: + alias: App\Shared\Infrastructure\Tenant\DoctrineTenantRegistry diff --git a/backend/config/services.yaml b/backend/config/services.yaml index 6f43968..4d1ee82 100644 --- a/backend/config/services.yaml +++ b/backend/config/services.yaml @@ -247,12 +247,20 @@ services: $homeworkSanitizer: '@html_sanitizer.sanitizer.homework_sanitizer' App\Scolarite\Application\Port\FileStorage: - alias: App\Scolarite\Infrastructure\Storage\LocalFileStorage + alias: App\Scolarite\Infrastructure\Storage\S3FileStorage App\Scolarite\Infrastructure\Storage\LocalFileStorage: arguments: $storagePath: '%kernel.project_dir%/var/storage' + App\Scolarite\Infrastructure\Storage\S3FileStorage: + arguments: + $endpoint: '%env(S3_ENDPOINT)%' + $bucket: '%env(S3_BUCKET)%' + $key: '%env(S3_KEY)%' + $secret: '%env(S3_SECRET)%' + $region: '%env(S3_REGION)%' + # Schedule (Story 4.1 - Emploi du temps) App\Scolarite\Domain\Repository\ScheduleSlotRepository: alias: App\Scolarite\Infrastructure\Persistence\Doctrine\DoctrineScheduleSlotRepository @@ -298,12 +306,18 @@ services: App\Scolarite\Domain\Service\AverageCalculator: autowire: true + App\Scolarite\Domain\Service\TeacherStatisticsCalculator: + autowire: true + App\Scolarite\Application\Service\RecalculerMoyennesService: autowire: true App\Scolarite\Application\Port\PeriodFinder: alias: App\Scolarite\Infrastructure\Service\DoctrinePeriodFinder + App\Scolarite\Application\Port\TeacherStatisticsReader: + alias: App\Scolarite\Infrastructure\ReadModel\DbalTeacherStatisticsReader + App\Scolarite\Infrastructure\Persistence\Doctrine\DoctrineEvaluationStatisticsRepository: autowire: true @@ -333,6 +347,23 @@ services: App\SuperAdmin\Domain\Repository\EstablishmentRepository: alias: App\SuperAdmin\Infrastructure\Persistence\Doctrine\DoctrineEstablishmentRepository + # Provisioning (Story 2.17 - Provisioning automatique) + App\SuperAdmin\Infrastructure\Provisioning\TenantDatabaseCreator: + arguments: + $connection: '@doctrine.dbal.master_connection' + + App\SuperAdmin\Infrastructure\Provisioning\TenantMigrator: + arguments: + $projectDir: '%kernel.project_dir%' + $masterDatabaseUrl: '%env(DATABASE_URL)%' + + App\SuperAdmin\Application\Port\TenantProvisioner: + alias: App\SuperAdmin\Infrastructure\Provisioning\DatabaseTenantProvisioner + + App\SuperAdmin\Infrastructure\Provisioning\ProvisionEstablishmentHandler: + arguments: + $masterDatabaseUrl: '%env(DATABASE_URL)%' + # School Calendar Repository (Story 2.11 - Calendrier scolaire) App\Administration\Domain\Model\SchoolCalendar\SchoolCalendarRepository: alias: App\Administration\Infrastructure\Persistence\Doctrine\DoctrineSchoolCalendarRepository diff --git a/backend/src/Scolarite/Application/Port/FileStorage.php b/backend/src/Scolarite/Application/Port/FileStorage.php index 7f78916..1d3045d 100644 --- a/backend/src/Scolarite/Application/Port/FileStorage.php +++ b/backend/src/Scolarite/Application/Port/FileStorage.php @@ -12,4 +12,9 @@ interface FileStorage public function upload(string $path, mixed $content, string $mimeType): string; public function delete(string $path): void; + + /** + * @return resource + */ + public function readStream(string $path): mixed; } diff --git a/backend/src/Scolarite/Application/Port/TeacherStatisticsReader.php b/backend/src/Scolarite/Application/Port/TeacherStatisticsReader.php new file mode 100644 index 0000000..64f6613 --- /dev/null +++ b/backend/src/Scolarite/Application/Port/TeacherStatisticsReader.php @@ -0,0 +1,133 @@ + + */ + 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 + */ + 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 + */ + 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 + */ + 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> + */ + 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 + */ + 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 + */ + public function teacherEvaluationDifficulties(string $teacherId, string $tenantId): array; + + /** + * Moyennes des évaluations des autres enseignants pour une matière (anonymisé). + * + * @return list Moyennes des évaluations des autres enseignants + */ + public function subjectAveragesForOtherTeachers( + string $teacherId, + string $subjectId, + string $tenantId, + ): array; +} diff --git a/backend/src/Scolarite/Application/Query/GetBlockedDates/GetBlockedDatesHandler.php b/backend/src/Scolarite/Application/Query/GetBlockedDates/GetBlockedDatesHandler.php index 5b7d10d..279339b 100644 --- a/backend/src/Scolarite/Application/Query/GetBlockedDates/GetBlockedDatesHandler.php +++ b/backend/src/Scolarite/Application/Query/GetBlockedDates/GetBlockedDatesHandler.php @@ -6,22 +6,26 @@ namespace App\Scolarite\Application\Query\GetBlockedDates; use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendarRepository; use App\Administration\Domain\Model\SchoolClass\AcademicYearId; +use App\Scolarite\Application\Port\HomeworkRulesChecker; +use App\Shared\Domain\Clock; use App\Shared\Domain\Tenant\TenantId; use DateInterval; use DateTimeImmutable; use Symfony\Component\Messenger\Attribute\AsMessageHandler; /** - * Retourne les dates bloquées (jours fériés, vacances, journées pédagogiques, weekends) - * pour une plage de dates donnée. + * Retourne les dates bloquées (jours fériés, vacances, journées pédagogiques, weekends, + * et dates non conformes aux règles de devoirs) pour une plage de dates donnée. * - * Utilisé par le frontend pour griser les jours non modifiables dans la grille EDT. + * Utilisé par le frontend pour griser les jours non disponibles dans le calendrier. */ #[AsMessageHandler(bus: 'query.bus')] final readonly class GetBlockedDatesHandler { public function __construct( private SchoolCalendarRepository $calendarRepository, + private HomeworkRulesChecker $rulesChecker, + private Clock $clock, ) { } @@ -37,6 +41,7 @@ final readonly class GetBlockedDatesHandler $endDate = new DateTimeImmutable($query->endDate); $oneDay = new DateInterval('P1D'); + $now = $this->clock->now(); $blockedDates = []; $current = $startDate; @@ -50,14 +55,21 @@ final readonly class GetBlockedDatesHandler reason: $dayOfWeek === 6 ? 'Samedi' : 'Dimanche', type: 'weekend', ); - } elseif ($calendar !== null) { - $entry = $calendar->trouverEntreePourDate($current); + } elseif ($calendar !== null && ($entry = $calendar->trouverEntreePourDate($current)) !== null) { + $blockedDates[] = new BlockedDateDto( + date: $dateStr, + reason: $entry->label, + type: $entry->type->value, + ); + } else { + $dueDate = new DateTimeImmutable($dateStr); + $result = $this->rulesChecker->verifier($tenantId, $dueDate, $now); - if ($entry !== null) { + if (!$result->estValide()) { $blockedDates[] = new BlockedDateDto( date: $dateStr, - reason: $entry->label, - type: $entry->type->value, + reason: $result->messages()[0] ?? 'Règle de devoirs', + type: $result->estBloquant() ? 'rule_hard' : 'rule_soft', ); } } diff --git a/backend/src/Scolarite/Application/Query/GetClassStatisticsDetail/ClassStatisticsDetailDto.php b/backend/src/Scolarite/Application/Query/GetClassStatisticsDetail/ClassStatisticsDetailDto.php new file mode 100644 index 0000000..c8685ef --- /dev/null +++ b/backend/src/Scolarite/Application/Query/GetClassStatisticsDetail/ClassStatisticsDetailDto.php @@ -0,0 +1,22 @@ + $distribution + * @param list $evolution + * @param list $students + */ + public function __construct( + public ?float $average, + public float $successRate, + public array $distribution, + public array $evolution, + public array $students, + ) { + } +} diff --git a/backend/src/Scolarite/Application/Query/GetClassStatisticsDetail/GetClassStatisticsDetailHandler.php b/backend/src/Scolarite/Application/Query/GetClassStatisticsDetail/GetClassStatisticsDetailHandler.php new file mode 100644 index 0000000..127707e --- /dev/null +++ b/backend/src/Scolarite/Application/Query/GetClassStatisticsDetail/GetClassStatisticsDetailHandler.php @@ -0,0 +1,113 @@ +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, + ); + } +} diff --git a/backend/src/Scolarite/Application/Query/GetClassStatisticsDetail/GetClassStatisticsDetailQuery.php b/backend/src/Scolarite/Application/Query/GetClassStatisticsDetail/GetClassStatisticsDetailQuery.php new file mode 100644 index 0000000..b8f3af6 --- /dev/null +++ b/backend/src/Scolarite/Application/Query/GetClassStatisticsDetail/GetClassStatisticsDetailQuery.php @@ -0,0 +1,17 @@ + */ + public function __invoke(GetEvaluationDifficultyQuery $query): array + { + $evaluations = $this->reader->teacherEvaluationDifficulties( + $query->teacherId, + $query->tenantId, + ); + + /** @var array> $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; + } +} diff --git a/backend/src/Scolarite/Application/Query/GetEvaluationDifficulty/GetEvaluationDifficultyQuery.php b/backend/src/Scolarite/Application/Query/GetEvaluationDifficulty/GetEvaluationDifficultyQuery.php new file mode 100644 index 0000000..97972d8 --- /dev/null +++ b/backend/src/Scolarite/Application/Query/GetEvaluationDifficulty/GetEvaluationDifficultyQuery.php @@ -0,0 +1,14 @@ +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, + ); + } +} diff --git a/backend/src/Scolarite/Application/Query/GetStudentProgression/GetStudentProgressionQuery.php b/backend/src/Scolarite/Application/Query/GetStudentProgression/GetStudentProgressionQuery.php new file mode 100644 index 0000000..7eaa0a6 --- /dev/null +++ b/backend/src/Scolarite/Application/Query/GetStudentProgression/GetStudentProgressionQuery.php @@ -0,0 +1,17 @@ + $grades + */ + public function __construct( + public array $grades, + public ?TrendResult $trendLine, + ) { + } +} diff --git a/backend/src/Scolarite/Application/Query/GetTeacherStatisticsOverview/ClassOverviewDto.php b/backend/src/Scolarite/Application/Query/GetTeacherStatisticsOverview/ClassOverviewDto.php new file mode 100644 index 0000000..f815e7f --- /dev/null +++ b/backend/src/Scolarite/Application/Query/GetTeacherStatisticsOverview/ClassOverviewDto.php @@ -0,0 +1,20 @@ + */ + 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, + ); + } +} diff --git a/backend/src/Scolarite/Application/Query/GetTeacherStatisticsOverview/GetTeacherStatisticsOverviewQuery.php b/backend/src/Scolarite/Application/Query/GetTeacherStatisticsOverview/GetTeacherStatisticsOverviewQuery.php new file mode 100644 index 0000000..99715e8 --- /dev/null +++ b/backend/src/Scolarite/Application/Query/GetTeacherStatisticsOverview/GetTeacherStatisticsOverviewQuery.php @@ -0,0 +1,14 @@ +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 : ''; + } +} diff --git a/backend/src/Scolarite/Domain/Service/TeacherStatisticsCalculator.php b/backend/src/Scolarite/Domain/Service/TeacherStatisticsCalculator.php new file mode 100644 index 0000000..d75d54c --- /dev/null +++ b/backend/src/Scolarite/Domain/Service/TeacherStatisticsCalculator.php @@ -0,0 +1,154 @@ + $normalizedValues Notes normalisées sur /20 + * + * @return list 8 éléments + */ + public function calculateDistribution(array $normalizedValues): array + { + /** @var list $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 $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 $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 $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 $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); + } +} diff --git a/backend/src/Scolarite/Domain/Service/TrendResult.php b/backend/src/Scolarite/Domain/Service/TrendResult.php new file mode 100644 index 0000000..9ebb8b0 --- /dev/null +++ b/backend/src/Scolarite/Domain/Service/TrendResult.php @@ -0,0 +1,14 @@ +getSecurityUser(); $tenantId = TenantId::fromString($user->tenantId()); @@ -143,20 +141,29 @@ final readonly class HomeworkAttachmentController foreach ($attachments as $attachment) { if ((string) $attachment->id === $attachmentId) { - $fullPath = $this->storageDir . '/' . $attachment->filePath; - $realPath = realpath($fullPath); - $realStorageDir = realpath($this->storageDir); - - if ($realPath === false || $realStorageDir === false || !str_starts_with($realPath, $realStorageDir)) { + try { + $stream = $this->fileStorage->readStream($attachment->filePath); + } catch (RuntimeException) { throw new NotFoundHttpException('Pièce jointe non trouvée.'); } - $response = new BinaryFileResponse($realPath); - $response->setContentDisposition( - ResponseHeaderBag::DISPOSITION_INLINE, + $response = new StreamedResponse(static function () use ($stream): void { + try { + fpassthru($stream); + } finally { + fclose($stream); + } + }); + + $disposition = HeaderUtils::makeDisposition( + HeaderUtils::DISPOSITION_INLINE, $attachment->filename, ); + $response->headers->set('Content-Type', $attachment->mimeType); + $response->headers->set('Content-Disposition', $disposition); + $response->headers->set('Content-Length', (string) $attachment->fileSize); + return $response; } } diff --git a/backend/src/Scolarite/Infrastructure/Api/Controller/ParentHomeworkController.php b/backend/src/Scolarite/Infrastructure/Api/Controller/ParentHomeworkController.php index c00bce2..fe4a179 100644 --- a/backend/src/Scolarite/Infrastructure/Api/Controller/ParentHomeworkController.php +++ b/backend/src/Scolarite/Infrastructure/Api/Controller/ParentHomeworkController.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Scolarite\Infrastructure\Api\Controller; use App\Administration\Infrastructure\Security\SecurityUser; +use App\Scolarite\Application\Port\FileStorage; use App\Scolarite\Application\Query\GetChildrenHomework\ChildHomeworkDto; use App\Scolarite\Application\Query\GetChildrenHomework\GetChildrenHomeworkDetailHandler; use App\Scolarite\Application\Query\GetChildrenHomework\GetChildrenHomeworkHandler; @@ -18,16 +19,16 @@ use App\Scolarite\Infrastructure\Security\HomeworkParentVoter; use App\Shared\Domain\Tenant\TenantId; use function array_map; +use function fclose; +use function fpassthru; use function is_string; -use function realpath; -use function str_starts_with; +use RuntimeException; use Symfony\Bundle\SecurityBundle\Security; -use Symfony\Component\DependencyInjection\Attribute\Autowire; -use Symfony\Component\HttpFoundation\BinaryFileResponse; +use Symfony\Component\HttpFoundation\HeaderUtils; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpFoundation\ResponseHeaderBag; +use Symfony\Component\HttpFoundation\StreamedResponse; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\Routing\Attribute\Route; @@ -45,8 +46,7 @@ final readonly class ParentHomeworkController private GetChildrenHomeworkDetailHandler $detailHandler, private HomeworkRepository $homeworkRepository, private HomeworkAttachmentRepository $attachmentRepository, - #[Autowire('%kernel.project_dir%/var/storage')] - private string $uploadsDir, + private FileStorage $fileStorage, ) { } @@ -116,7 +116,7 @@ final readonly class ParentHomeworkController * Téléchargement d'une pièce jointe (parent). */ #[Route('/api/me/children/homework/{homeworkId}/attachments/{attachmentId}', name: 'api_parent_child_homework_attachment', methods: ['GET'])] - public function downloadAttachment(string $homeworkId, string $attachmentId): BinaryFileResponse + public function downloadAttachment(string $homeworkId, string $attachmentId): StreamedResponse { $user = $this->getSecurityUser(); $tenantId = TenantId::fromString($user->tenantId()); @@ -138,20 +138,29 @@ final readonly class ParentHomeworkController foreach ($attachments as $attachment) { if ((string) $attachment->id === $attachmentId) { - $fullPath = $this->uploadsDir . '/' . $attachment->filePath; - $realPath = realpath($fullPath); - $realUploadsDir = realpath($this->uploadsDir); - - if ($realPath === false || $realUploadsDir === false || !str_starts_with($realPath, $realUploadsDir)) { + try { + $stream = $this->fileStorage->readStream($attachment->filePath); + } catch (RuntimeException) { throw new NotFoundHttpException('Pièce jointe non trouvée.'); } - $response = new BinaryFileResponse($realPath); - $response->setContentDisposition( - ResponseHeaderBag::DISPOSITION_INLINE, + $response = new StreamedResponse(static function () use ($stream): void { + try { + fpassthru($stream); + } finally { + fclose($stream); + } + }); + + $disposition = HeaderUtils::makeDisposition( + HeaderUtils::DISPOSITION_INLINE, $attachment->filename, ); + $response->headers->set('Content-Type', $attachment->mimeType); + $response->headers->set('Content-Disposition', $disposition); + $response->headers->set('Content-Length', (string) $attachment->fileSize); + return $response; } } diff --git a/backend/src/Scolarite/Infrastructure/Api/Controller/StudentHomeworkController.php b/backend/src/Scolarite/Infrastructure/Api/Controller/StudentHomeworkController.php index e2ecad1..997eeb5 100644 --- a/backend/src/Scolarite/Infrastructure/Api/Controller/StudentHomeworkController.php +++ b/backend/src/Scolarite/Infrastructure/Api/Controller/StudentHomeworkController.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Scolarite\Infrastructure\Api\Controller; use App\Administration\Infrastructure\Security\SecurityUser; +use App\Scolarite\Application\Port\FileStorage; use App\Scolarite\Application\Port\ScheduleDisplayReader; use App\Scolarite\Application\Port\StudentClassReader; use App\Scolarite\Application\Query\GetStudentHomework\GetStudentHomeworkHandler; @@ -19,16 +20,16 @@ use App\Scolarite\Infrastructure\Security\HomeworkStudentVoter; use App\Shared\Domain\Tenant\TenantId; use function array_map; +use function fclose; +use function fpassthru; use function is_string; -use function realpath; -use function str_starts_with; +use RuntimeException; use Symfony\Bundle\SecurityBundle\Security; -use Symfony\Component\DependencyInjection\Attribute\Autowire; -use Symfony\Component\HttpFoundation\BinaryFileResponse; +use Symfony\Component\HttpFoundation\HeaderUtils; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpFoundation\ResponseHeaderBag; +use Symfony\Component\HttpFoundation\StreamedResponse; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\Routing\Attribute\Route; @@ -44,8 +45,7 @@ final readonly class StudentHomeworkController private HomeworkAttachmentRepository $attachmentRepository, private ScheduleDisplayReader $displayReader, private StudentClassReader $studentClassReader, - #[Autowire('%kernel.project_dir%/var/storage')] - private string $uploadsDir, + private FileStorage $fileStorage, ) { } @@ -98,7 +98,7 @@ final readonly class StudentHomeworkController } #[Route('/api/me/homework/{homeworkId}/attachments/{attachmentId}', name: 'api_student_homework_attachment', methods: ['GET'])] - public function downloadAttachment(string $homeworkId, string $attachmentId): BinaryFileResponse + public function downloadAttachment(string $homeworkId, string $attachmentId): StreamedResponse { $user = $this->getSecurityUser(); $tenantId = TenantId::fromString($user->tenantId()); @@ -115,20 +115,29 @@ final readonly class StudentHomeworkController foreach ($attachments as $attachment) { if ((string) $attachment->id === $attachmentId) { - $fullPath = $this->uploadsDir . '/' . $attachment->filePath; - $realPath = realpath($fullPath); - $realUploadsDir = realpath($this->uploadsDir); - - if ($realPath === false || $realUploadsDir === false || !str_starts_with($realPath, $realUploadsDir)) { + try { + $stream = $this->fileStorage->readStream($attachment->filePath); + } catch (RuntimeException) { throw new NotFoundHttpException('Pièce jointe non trouvée.'); } - $response = new BinaryFileResponse($realPath); - $response->setContentDisposition( - ResponseHeaderBag::DISPOSITION_INLINE, + $response = new StreamedResponse(static function () use ($stream): void { + try { + fpassthru($stream); + } finally { + fclose($stream); + } + }); + + $disposition = HeaderUtils::makeDisposition( + HeaderUtils::DISPOSITION_INLINE, $attachment->filename, ); + $response->headers->set('Content-Type', $attachment->mimeType); + $response->headers->set('Content-Disposition', $disposition); + $response->headers->set('Content-Length', (string) $attachment->fileSize); + return $response; } } diff --git a/backend/src/Scolarite/Infrastructure/Api/Controller/TeacherSubmissionController.php b/backend/src/Scolarite/Infrastructure/Api/Controller/TeacherSubmissionController.php index 93d3c33..88698b7 100644 --- a/backend/src/Scolarite/Infrastructure/Api/Controller/TeacherSubmissionController.php +++ b/backend/src/Scolarite/Infrastructure/Api/Controller/TeacherSubmissionController.php @@ -6,6 +6,7 @@ namespace App\Scolarite\Infrastructure\Api\Controller; use App\Administration\Infrastructure\Security\SecurityUser; use App\Scolarite\Application\Port\ClassStudentsReader; +use App\Scolarite\Application\Port\FileStorage; use App\Scolarite\Domain\Model\Homework\HomeworkId; use App\Scolarite\Domain\Model\HomeworkSubmission\HomeworkSubmission; use App\Scolarite\Domain\Model\HomeworkSubmission\HomeworkSubmissionId; @@ -24,15 +25,15 @@ use function count; use DateTimeImmutable; +use function fclose; +use function fpassthru; use function in_array; -use function realpath; -use function str_starts_with; +use RuntimeException; use Symfony\Bundle\SecurityBundle\Security; -use Symfony\Component\DependencyInjection\Attribute\Autowire; -use Symfony\Component\HttpFoundation\BinaryFileResponse; +use Symfony\Component\HttpFoundation\HeaderUtils; use Symfony\Component\HttpFoundation\JsonResponse; -use Symfony\Component\HttpFoundation\ResponseHeaderBag; +use Symfony\Component\HttpFoundation\StreamedResponse; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\Routing\Attribute\Route; @@ -45,8 +46,7 @@ final readonly class TeacherSubmissionController private HomeworkSubmissionRepository $submissionRepository, private SubmissionAttachmentRepository $attachmentRepository, private ClassStudentsReader $classStudentsReader, - #[Autowire('%kernel.project_dir%/var/storage')] - private string $storageDir, + private FileStorage $fileStorage, ) { } @@ -240,7 +240,7 @@ final readonly class TeacherSubmissionController } #[Route('/api/homework/{homeworkId}/submissions/{submissionId}/attachments/{attachmentId}', name: 'api_teacher_submission_attachment_download', methods: ['GET'])] - public function downloadAttachment(string $homeworkId, string $submissionId, string $attachmentId): BinaryFileResponse + public function downloadAttachment(string $homeworkId, string $submissionId, string $attachmentId): StreamedResponse { $user = $this->getSecurityUser(); $tenantId = TenantId::fromString($user->tenantId()); @@ -268,20 +268,29 @@ final readonly class TeacherSubmissionController foreach ($attachments as $attachment) { if ((string) $attachment->id === $attachmentId) { - $fullPath = $this->storageDir . '/' . $attachment->filePath; - $realPath = realpath($fullPath); - $realStorageDir = realpath($this->storageDir); - - if ($realPath === false || $realStorageDir === false || !str_starts_with($realPath, $realStorageDir)) { + try { + $stream = $this->fileStorage->readStream($attachment->filePath); + } catch (RuntimeException) { throw new NotFoundHttpException('Pièce jointe non trouvée.'); } - $response = new BinaryFileResponse($realPath); - $response->setContentDisposition( - ResponseHeaderBag::DISPOSITION_INLINE, + $response = new StreamedResponse(static function () use ($stream): void { + try { + fpassthru($stream); + } finally { + fclose($stream); + } + }); + + $disposition = HeaderUtils::makeDisposition( + HeaderUtils::DISPOSITION_INLINE, $attachment->filename, ); + $response->headers->set('Content-Type', $attachment->mimeType); + $response->headers->set('Content-Disposition', $disposition); + $response->headers->set('Content-Length', (string) $attachment->fileSize); + return $response; } } diff --git a/backend/src/Scolarite/Infrastructure/Api/Provider/EvaluationDifficultyProvider.php b/backend/src/Scolarite/Infrastructure/Api/Provider/EvaluationDifficultyProvider.php new file mode 100644 index 0000000..a5f1657 --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Api/Provider/EvaluationDifficultyProvider.php @@ -0,0 +1,84 @@ + + */ +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 $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; + } +} diff --git a/backend/src/Scolarite/Infrastructure/Api/Provider/StudentProgressionProvider.php b/backend/src/Scolarite/Infrastructure/Api/Provider/StudentProgressionProvider.php new file mode 100644 index 0000000..4644c5c --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Api/Provider/StudentProgressionProvider.php @@ -0,0 +1,107 @@ + + */ +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 $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; + } +} diff --git a/backend/src/Scolarite/Infrastructure/Api/Provider/TeacherClassStatisticsProvider.php b/backend/src/Scolarite/Infrastructure/Api/Provider/TeacherClassStatisticsProvider.php new file mode 100644 index 0000000..b4f2ef8 --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Api/Provider/TeacherClassStatisticsProvider.php @@ -0,0 +1,112 @@ + + */ +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 $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; + } +} diff --git a/backend/src/Scolarite/Infrastructure/Api/Provider/TeacherStatisticsExportProvider.php b/backend/src/Scolarite/Infrastructure/Api/Provider/TeacherStatisticsExportProvider.php new file mode 100644 index 0000000..3067f82 --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Api/Provider/TeacherStatisticsExportProvider.php @@ -0,0 +1,105 @@ + + */ +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 $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), + ], + ); + } +} diff --git a/backend/src/Scolarite/Infrastructure/Api/Provider/TeacherStatisticsOverviewProvider.php b/backend/src/Scolarite/Infrastructure/Api/Provider/TeacherStatisticsOverviewProvider.php new file mode 100644 index 0000000..65f5cee --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Api/Provider/TeacherStatisticsOverviewProvider.php @@ -0,0 +1,80 @@ + + */ +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; + } +} diff --git a/backend/src/Scolarite/Infrastructure/Api/Resource/EvaluationDifficultyResource.php b/backend/src/Scolarite/Infrastructure/Api/Resource/EvaluationDifficultyResource.php new file mode 100644 index 0000000..667a7d8 --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Api/Resource/EvaluationDifficultyResource.php @@ -0,0 +1,29 @@ + */ + public array $evaluations = []; +} diff --git a/backend/src/Scolarite/Infrastructure/Api/Resource/StudentProgressionResource.php b/backend/src/Scolarite/Infrastructure/Api/Resource/StudentProgressionResource.php new file mode 100644 index 0000000..d307b3f --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Api/Resource/StudentProgressionResource.php @@ -0,0 +1,36 @@ + */ + public array $grades = []; + + /** @var array{slope: float, intercept: float}|null */ + public ?array $trendLine = null; +} diff --git a/backend/src/Scolarite/Infrastructure/Api/Resource/TeacherClassStatisticsResource.php b/backend/src/Scolarite/Infrastructure/Api/Resource/TeacherClassStatisticsResource.php new file mode 100644 index 0000000..680a096 --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Api/Resource/TeacherClassStatisticsResource.php @@ -0,0 +1,41 @@ + */ + public array $distribution = []; + + /** @var list */ + public array $evolution = []; + + /** @var list */ + public array $students = []; +} diff --git a/backend/src/Scolarite/Infrastructure/Api/Resource/TeacherStatisticsExportResource.php b/backend/src/Scolarite/Infrastructure/Api/Resource/TeacherStatisticsExportResource.php new file mode 100644 index 0000000..3849e75 --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Api/Resource/TeacherStatisticsExportResource.php @@ -0,0 +1,32 @@ + */ + public array $classes = []; +} diff --git a/backend/src/Scolarite/Infrastructure/ReadModel/DbalTeacherStatisticsReader.php b/backend/src/Scolarite/Infrastructure/ReadModel/DbalTeacherStatisticsReader.php new file mode 100644 index 0000000..7a38816 --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/ReadModel/DbalTeacherStatisticsReader.php @@ -0,0 +1,487 @@ +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> $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; + } +} diff --git a/backend/src/Scolarite/Infrastructure/ReadModel/InMemoryTeacherStatisticsReader.php b/backend/src/Scolarite/Infrastructure/ReadModel/InMemoryTeacherStatisticsReader.php new file mode 100644 index 0000000..38ecdc0 --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/ReadModel/InMemoryTeacherStatisticsReader.php @@ -0,0 +1,122 @@ + */ + private array $classesSummary = []; + + /** @var list */ + private array $classGrades = []; + + /** @var list */ + private array $monthlyAverages = []; + + /** @var list */ + private array $studentAverages = []; + + /** @var list */ + private array $gradeHistory = []; + + /** @var list */ + private array $evaluationDifficulties = []; + + /** @var array> */ + private array $studentMonthlyAverages = []; + + /** @var list */ + private array $otherTeachersAverages = []; + + /** @param list $data */ + public function feedClassesSummary(array $data): void + { + $this->classesSummary = $data; + } + + /** @param list $grades */ + public function feedClassGrades(array $grades): void + { + $this->classGrades = $grades; + } + + /** @param list $averages */ + public function feedMonthlyAverages(array $averages): void + { + $this->monthlyAverages = $averages; + } + + /** @param list $averages */ + public function feedStudentAverages(array $averages): void + { + $this->studentAverages = $averages; + } + + /** @param list $history */ + public function feedGradeHistory(array $history): void + { + $this->gradeHistory = $history; + } + + /** @param list $difficulties */ + public function feedEvaluationDifficulties(array $difficulties): void + { + $this->evaluationDifficulties = $difficulties; + } + + /** @param array> $averages */ + public function feedStudentMonthlyAverages(array $averages): void + { + $this->studentMonthlyAverages = $averages; + } + + /** @param list $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; + } +} diff --git a/backend/src/Scolarite/Infrastructure/Storage/LocalFileStorage.php b/backend/src/Scolarite/Infrastructure/Storage/LocalFileStorage.php index ffe2296..62d2bd5 100644 --- a/backend/src/Scolarite/Infrastructure/Storage/LocalFileStorage.php +++ b/backend/src/Scolarite/Infrastructure/Storage/LocalFileStorage.php @@ -8,6 +8,7 @@ use App\Scolarite\Application\Port\FileStorage; use function dirname; use function file_put_contents; +use function fopen; use function is_dir; use function is_file; use function is_string; @@ -15,6 +16,12 @@ use function mkdir; use Override; +use function realpath; + +use RuntimeException; + +use function sprintf; +use function str_starts_with; use function unlink; final readonly class LocalFileStorage implements FileStorage @@ -50,4 +57,24 @@ final readonly class LocalFileStorage implements FileStorage unlink($fullPath); } } + + #[Override] + public function readStream(string $path): mixed + { + $fullPath = $this->storagePath . '/' . $path; + $realPath = realpath($fullPath); + $realStoragePath = realpath($this->storagePath); + + if ($realPath === false || $realStoragePath === false || !str_starts_with($realPath, $realStoragePath)) { + throw new RuntimeException(sprintf('Impossible de lire le fichier : %s', $path)); + } + + $stream = fopen($realPath, 'r'); + + if ($stream === false) { + throw new RuntimeException(sprintf('Impossible de lire le fichier : %s', $path)); + } + + return $stream; + } } diff --git a/backend/src/Scolarite/Infrastructure/Storage/S3FileStorage.php b/backend/src/Scolarite/Infrastructure/Storage/S3FileStorage.php new file mode 100644 index 0000000..7a5242f --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Storage/S3FileStorage.php @@ -0,0 +1,91 @@ +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); + } + } +} diff --git a/backend/src/Shared/Infrastructure/Tenant/DoctrineTenantRegistry.php b/backend/src/Shared/Infrastructure/Tenant/DoctrineTenantRegistry.php new file mode 100644 index 0000000..5e64a06 --- /dev/null +++ b/backend/src/Shared/Infrastructure/Tenant/DoctrineTenantRegistry.php @@ -0,0 +1,132 @@ +|null Indexed by tenant ID */ + private ?array $byId = null; + + /** @var array|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 $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 $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); + } +} diff --git a/backend/src/SuperAdmin/Application/Command/CreateEstablishment/CreateEstablishmentHandler.php b/backend/src/SuperAdmin/Application/Command/CreateEstablishment/CreateEstablishmentHandler.php index 56a378b..0681342 100644 --- a/backend/src/SuperAdmin/Application/Command/CreateEstablishment/CreateEstablishmentHandler.php +++ b/backend/src/SuperAdmin/Application/Command/CreateEstablishment/CreateEstablishmentHandler.php @@ -17,23 +17,18 @@ final readonly class CreateEstablishmentHandler ) { } - public function __invoke(CreateEstablishmentCommand $command): CreateEstablishmentResult + public function __invoke(CreateEstablishmentCommand $command): Establishment { $establishment = Establishment::creer( name: $command->name, subdomain: $command->subdomain, + adminEmail: $command->adminEmail, createdBy: SuperAdminId::fromString($command->superAdminId), createdAt: $this->clock->now(), ); $this->establishmentRepository->save($establishment); - return new CreateEstablishmentResult( - establishmentId: (string) $establishment->id, - tenantId: (string) $establishment->tenantId, - name: $establishment->name, - subdomain: $establishment->subdomain, - databaseName: $establishment->databaseName, - ); + return $establishment; } } diff --git a/backend/src/SuperAdmin/Application/Command/CreateEstablishment/CreateEstablishmentResult.php b/backend/src/SuperAdmin/Application/Command/CreateEstablishment/CreateEstablishmentResult.php deleted file mode 100644 index fe24055..0000000 --- a/backend/src/SuperAdmin/Application/Command/CreateEstablishment/CreateEstablishmentResult.php +++ /dev/null @@ -1,17 +0,0 @@ -tenantId, name: $name, subdomain: $subdomain, + adminEmail: $adminEmail, occurredOn: $createdAt, )); return $establishment; } + public function activer(): void + { + $this->status = EstablishmentStatus::ACTIF; + } + public function desactiver(DateTimeImmutable $at): void { if ($this->status !== EstablishmentStatus::ACTIF) { diff --git a/backend/src/SuperAdmin/Domain/Model/Establishment/EstablishmentStatus.php b/backend/src/SuperAdmin/Domain/Model/Establishment/EstablishmentStatus.php index 356404a..52054b5 100644 --- a/backend/src/SuperAdmin/Domain/Model/Establishment/EstablishmentStatus.php +++ b/backend/src/SuperAdmin/Domain/Model/Establishment/EstablishmentStatus.php @@ -6,6 +6,7 @@ namespace App\SuperAdmin\Domain\Model\Establishment; enum EstablishmentStatus: string { + case PROVISIONING = 'provisioning'; case ACTIF = 'active'; case INACTIF = 'inactive'; } diff --git a/backend/src/SuperAdmin/Infrastructure/Api/Processor/CreateEstablishmentProcessor.php b/backend/src/SuperAdmin/Infrastructure/Api/Processor/CreateEstablishmentProcessor.php index 6dfe858..c053c75 100644 --- a/backend/src/SuperAdmin/Infrastructure/Api/Processor/CreateEstablishmentProcessor.php +++ b/backend/src/SuperAdmin/Infrastructure/Api/Processor/CreateEstablishmentProcessor.php @@ -8,10 +8,12 @@ use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ProcessorInterface; use App\SuperAdmin\Application\Command\CreateEstablishment\CreateEstablishmentCommand; use App\SuperAdmin\Application\Command\CreateEstablishment\CreateEstablishmentHandler; +use App\SuperAdmin\Application\Command\ProvisionEstablishment\ProvisionEstablishmentCommand; use App\SuperAdmin\Infrastructure\Api\Resource\EstablishmentResource; use App\SuperAdmin\Infrastructure\Security\SecuritySuperAdmin; use Override; use Symfony\Bundle\SecurityBundle\Security; +use Symfony\Component\Messenger\MessageBusInterface; /** * @implements ProcessorInterface @@ -21,6 +23,7 @@ final readonly class CreateEstablishmentProcessor implements ProcessorInterface public function __construct( private CreateEstablishmentHandler $handler, private Security $security, + private MessageBusInterface $commandBus, ) { } @@ -33,20 +36,29 @@ final readonly class CreateEstablishmentProcessor implements ProcessorInterface /** @var SecuritySuperAdmin $user */ $user = $this->security->getUser(); - $result = ($this->handler)(new CreateEstablishmentCommand( + $establishment = ($this->handler)(new CreateEstablishmentCommand( name: $data->name, subdomain: $data->subdomain, adminEmail: $data->adminEmail, superAdminId: $user->superAdminId(), )); + $this->commandBus->dispatch(new ProvisionEstablishmentCommand( + establishmentId: (string) $establishment->id, + establishmentTenantId: (string) $establishment->tenantId, + databaseName: $establishment->databaseName, + subdomain: $establishment->subdomain, + adminEmail: $data->adminEmail, + establishmentName: $establishment->name, + )); + $resource = new EstablishmentResource(); - $resource->id = $result->establishmentId; - $resource->tenantId = $result->tenantId; - $resource->name = $result->name; - $resource->subdomain = $result->subdomain; - $resource->databaseName = $result->databaseName; - $resource->status = 'active'; + $resource->id = (string) $establishment->id; + $resource->tenantId = (string) $establishment->tenantId; + $resource->name = $establishment->name; + $resource->subdomain = $establishment->subdomain; + $resource->databaseName = $establishment->databaseName; + $resource->status = $establishment->status->value; return $resource; } diff --git a/backend/src/SuperAdmin/Infrastructure/Provisioning/DatabaseTenantProvisioner.php b/backend/src/SuperAdmin/Infrastructure/Provisioning/DatabaseTenantProvisioner.php new file mode 100644 index 0000000..722efa0 --- /dev/null +++ b/backend/src/SuperAdmin/Infrastructure/Provisioning/DatabaseTenantProvisioner.php @@ -0,0 +1,27 @@ +databaseCreator->create($databaseName); + $this->migrator->migrate($databaseName); + } +} diff --git a/backend/src/SuperAdmin/Infrastructure/Provisioning/ProvisionEstablishmentHandler.php b/backend/src/SuperAdmin/Infrastructure/Provisioning/ProvisionEstablishmentHandler.php new file mode 100644 index 0000000..46bdfc4 --- /dev/null +++ b/backend/src/SuperAdmin/Infrastructure/Provisioning/ProvisionEstablishmentHandler.php @@ -0,0 +1,180 @@ +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); + } +} diff --git a/backend/src/SuperAdmin/Infrastructure/Provisioning/TenantDatabaseCreator.php b/backend/src/SuperAdmin/Infrastructure/Provisioning/TenantDatabaseCreator.php new file mode 100644 index 0000000..3ffb740 --- /dev/null +++ b/backend/src/SuperAdmin/Infrastructure/Provisioning/TenantDatabaseCreator.php @@ -0,0 +1,76 @@ +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) . '"'; + } +} diff --git a/backend/src/SuperAdmin/Infrastructure/Provisioning/TenantMigrator.php b/backend/src/SuperAdmin/Infrastructure/Provisioning/TenantMigrator.php new file mode 100644 index 0000000..14bd21d --- /dev/null +++ b/backend/src/SuperAdmin/Infrastructure/Provisioning/TenantMigrator.php @@ -0,0 +1,78 @@ +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); + } +} diff --git a/backend/tests/Functional/Administration/Api/PasswordResetEndpointsTest.php b/backend/tests/Functional/Administration/Api/PasswordResetEndpointsTest.php index 1360746..3fc7d42 100644 --- a/backend/tests/Functional/Administration/Api/PasswordResetEndpointsTest.php +++ b/backend/tests/Functional/Administration/Api/PasswordResetEndpointsTest.php @@ -36,11 +36,9 @@ final class PasswordResetEndpointsTest extends ApiTestCase // Should NOT return 401 Unauthorized // It should return 200 (success) or 429 (rate limited), but never 401 - self::assertNotEquals(401, $response->getStatusCode(), 'Password forgot endpoint should be accessible without JWT'); - - // The endpoint always returns success to prevent email enumeration - // Even for non-existent emails - self::assertResponseIsSuccessful(); + $status = $response->getStatusCode(); + self::assertNotEquals(401, $status, 'Password forgot endpoint should be accessible without JWT'); + self::assertContains($status, [200, 201, 429], 'Expected 200/201 (success) or 429 (rate limited)'); } #[Test] diff --git a/backend/tests/Functional/Scolarite/Api/TeacherStatisticsEndpointsTest.php b/backend/tests/Functional/Scolarite/Api/TeacherStatisticsEndpointsTest.php new file mode 100644 index 0000000..3dd4324 --- /dev/null +++ b/backend/tests/Functional/Scolarite/Api/TeacherStatisticsEndpointsTest.php @@ -0,0 +1,527 @@ +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 $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 $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 $payload */ + $payload = json_decode($content, true, 512, JSON_THROW_ON_ERROR); + + self::assertArrayHasKey('evaluations', $payload); + /** @var list> $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 $data */ + $data = json_decode($content, true, 512, JSON_THROW_ON_ERROR); + + self::assertArrayHasKey('grades', $data); + self::assertIsArray($data['grades']); + } + + // ========================================================================= + // Helpers + // ========================================================================= + + /** + * @param list $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); + } + } +} diff --git a/backend/tests/Functional/Shared/Infrastructure/Audit/AuditTrailFunctionalTest.php b/backend/tests/Functional/Shared/Infrastructure/Audit/AuditTrailFunctionalTest.php new file mode 100644 index 0000000..f7ea95f --- /dev/null +++ b/backend/tests/Functional/Shared/Infrastructure/Audit/AuditTrailFunctionalTest.php @@ -0,0 +1,185 @@ +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'); + } +} diff --git a/backend/tests/Integration/Administration/Infrastructure/Service/GouvFrCalendarApiTest.php b/backend/tests/Integration/Administration/Infrastructure/Service/GouvFrCalendarApiTest.php index 16447b8..a85d2cd 100644 --- a/backend/tests/Integration/Administration/Infrastructure/Service/GouvFrCalendarApiTest.php +++ b/backend/tests/Integration/Administration/Infrastructure/Service/GouvFrCalendarApiTest.php @@ -21,6 +21,9 @@ use function sprintf; use Symfony\Component\HttpClient\HttpClient; use function sys_get_temp_dir; + +use Throwable; + use function unlink; /** @@ -42,6 +45,16 @@ final class GouvFrCalendarApiTest extends TestCase protected function setUp(): void { + // Skip si l'API externe est injoignable (timeout réseau, DNS, etc.) + try { + $check = HttpClient::create()->request('GET', 'https://data.education.gouv.fr', [ + 'timeout' => 5, + ]); + $check->getStatusCode(); + } catch (Throwable) { + self::markTestSkipped('API data.education.gouv.fr injoignable — test ignoré.'); + } + $this->tempDir = sys_get_temp_dir() . '/classeo-calendar-test-' . uniqid(); mkdir($this->tempDir); @@ -55,6 +68,10 @@ final class GouvFrCalendarApiTest extends TestCase protected function tearDown(): void { + if (!isset($this->tempDir) || !is_dir($this->tempDir)) { + return; + } + // Supprimer les fichiers générés $files = glob($this->tempDir . '/*.json'); foreach ($files as $file) { diff --git a/backend/tests/Unit/Scolarite/Application/Command/UploadSubmissionAttachment/UploadSubmissionAttachmentHandlerTest.php b/backend/tests/Unit/Scolarite/Application/Command/UploadSubmissionAttachment/UploadSubmissionAttachmentHandlerTest.php index 1231d9b..e124d4c 100644 --- a/backend/tests/Unit/Scolarite/Application/Command/UploadSubmissionAttachment/UploadSubmissionAttachmentHandlerTest.php +++ b/backend/tests/Unit/Scolarite/Application/Command/UploadSubmissionAttachment/UploadSubmissionAttachmentHandlerTest.php @@ -112,6 +112,14 @@ final class UploadSubmissionAttachmentHandlerTest extends TestCase public function delete(string $path): void { } + + public function readStream(string $path): mixed + { + /** @var resource $stream */ + $stream = fopen('php://memory', 'r+'); + + return $stream; + } }; $clock = new class implements Clock { diff --git a/backend/tests/Unit/Scolarite/Application/Query/GetBlockedDates/GetBlockedDatesHandlerTest.php b/backend/tests/Unit/Scolarite/Application/Query/GetBlockedDates/GetBlockedDatesHandlerTest.php index 13beaf2..8093516 100644 --- a/backend/tests/Unit/Scolarite/Application/Query/GetBlockedDates/GetBlockedDatesHandlerTest.php +++ b/backend/tests/Unit/Scolarite/Application/Query/GetBlockedDates/GetBlockedDatesHandlerTest.php @@ -10,8 +10,11 @@ use App\Administration\Domain\Model\SchoolCalendar\CalendarEntryType; use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendar; use App\Administration\Domain\Model\SchoolClass\AcademicYearId; use App\Administration\Infrastructure\Persistence\InMemory\InMemorySchoolCalendarRepository; +use App\Scolarite\Application\Port\HomeworkRulesChecker; +use App\Scolarite\Application\Port\HomeworkRulesCheckResult; use App\Scolarite\Application\Query\GetBlockedDates\GetBlockedDatesHandler; use App\Scolarite\Application\Query\GetBlockedDates\GetBlockedDatesQuery; +use App\Shared\Domain\Clock; use App\Shared\Domain\Tenant\TenantId; use DateTimeImmutable; use PHPUnit\Framework\Attributes\Test; @@ -28,7 +31,29 @@ final class GetBlockedDatesHandlerTest extends TestCase protected function setUp(): void { $this->calendarRepository = new InMemorySchoolCalendarRepository(); - $this->handler = new GetBlockedDatesHandler($this->calendarRepository); + + $rulesChecker = new class implements HomeworkRulesChecker { + public function verifier( + TenantId $tenantId, + DateTimeImmutable $dueDate, + DateTimeImmutable $creationDate, + ): HomeworkRulesCheckResult { + return HomeworkRulesCheckResult::ok(); + } + }; + + $clock = new class implements Clock { + public function now(): DateTimeImmutable + { + return new DateTimeImmutable('2026-03-01 10:00:00'); + } + }; + + $this->handler = new GetBlockedDatesHandler( + $this->calendarRepository, + $rulesChecker, + $clock, + ); } #[Test] @@ -110,6 +135,93 @@ final class GetBlockedDatesHandlerTest extends TestCase self::assertCount(5, $vacations); } + #[Test] + public function returnsRuleHardBlockedDates(): void + { + $rulesChecker = new class implements HomeworkRulesChecker { + public function verifier( + TenantId $tenantId, + DateTimeImmutable $dueDate, + DateTimeImmutable $creationDate, + ): HomeworkRulesCheckResult { + // Block Tuesday March 3 + if ($dueDate->format('Y-m-d') === '2026-03-03') { + return new HomeworkRulesCheckResult( + warnings: [new \App\Scolarite\Application\Port\RuleWarning('minimum_delay', 'Délai minimum non respecté')], + bloquant: true, + ); + } + + return HomeworkRulesCheckResult::ok(); + } + }; + + $clock = new class implements Clock { + public function now(): DateTimeImmutable + { + return new DateTimeImmutable('2026-03-01 10:00:00'); + } + }; + + $handler = new GetBlockedDatesHandler($this->calendarRepository, $rulesChecker, $clock); + + $result = ($handler)(new GetBlockedDatesQuery( + tenantId: self::TENANT_ID, + academicYearId: self::ACADEMIC_YEAR_ID, + startDate: '2026-03-02', + endDate: '2026-03-06', + )); + + $ruleBlocked = array_filter($result, static fn ($d) => $d->type === 'rule_hard'); + self::assertCount(1, $ruleBlocked); + $blocked = array_values($ruleBlocked)[0]; + self::assertSame('2026-03-03', $blocked->date); + self::assertSame('Délai minimum non respecté', $blocked->reason); + } + + #[Test] + public function returnsRuleSoftWarningDates(): void + { + $rulesChecker = new class implements HomeworkRulesChecker { + public function verifier( + TenantId $tenantId, + DateTimeImmutable $dueDate, + DateTimeImmutable $creationDate, + ): HomeworkRulesCheckResult { + if ($dueDate->format('Y-m-d') === '2026-03-04') { + return new HomeworkRulesCheckResult( + warnings: [new \App\Scolarite\Application\Port\RuleWarning('no_monday_after', 'Devoirs pour lundi déconseillés')], + bloquant: false, + ); + } + + return HomeworkRulesCheckResult::ok(); + } + }; + + $clock = new class implements Clock { + public function now(): DateTimeImmutable + { + return new DateTimeImmutable('2026-03-01 10:00:00'); + } + }; + + $handler = new GetBlockedDatesHandler($this->calendarRepository, $rulesChecker, $clock); + + $result = ($handler)(new GetBlockedDatesQuery( + tenantId: self::TENANT_ID, + academicYearId: self::ACADEMIC_YEAR_ID, + startDate: '2026-03-02', + endDate: '2026-03-06', + )); + + $ruleSoft = array_filter($result, static fn ($d) => $d->type === 'rule_soft'); + self::assertCount(1, $ruleSoft); + $soft = array_values($ruleSoft)[0]; + self::assertSame('2026-03-04', $soft->date); + self::assertSame('rule_soft', $soft->type); + } + private function createCalendarWithHoliday(DateTimeImmutable $date, string $label): SchoolCalendar { $tenantId = TenantId::fromString(self::TENANT_ID); diff --git a/backend/tests/Unit/Scolarite/Application/Query/GetClassStatisticsDetail/GetClassStatisticsDetailHandlerTest.php b/backend/tests/Unit/Scolarite/Application/Query/GetClassStatisticsDetail/GetClassStatisticsDetailHandlerTest.php new file mode 100644 index 0000000..6ef87fd --- /dev/null +++ b/backend/tests/Unit/Scolarite/Application/Query/GetClassStatisticsDetail/GetClassStatisticsDetailHandlerTest.php @@ -0,0 +1,109 @@ +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'), + ); + } +} diff --git a/backend/tests/Unit/Scolarite/Application/Query/GetEvaluationDifficulty/GetEvaluationDifficultyHandlerTest.php b/backend/tests/Unit/Scolarite/Application/Query/GetEvaluationDifficulty/GetEvaluationDifficultyHandlerTest.php new file mode 100644 index 0000000..61f8e57 --- /dev/null +++ b/backend/tests/Unit/Scolarite/Application/Query/GetEvaluationDifficulty/GetEvaluationDifficultyHandlerTest.php @@ -0,0 +1,101 @@ +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, + ); + } +} diff --git a/backend/tests/Unit/Scolarite/Application/Query/GetStudentProgression/GetStudentProgressionHandlerTest.php b/backend/tests/Unit/Scolarite/Application/Query/GetStudentProgression/GetStudentProgressionHandlerTest.php new file mode 100644 index 0000000..5afb28c --- /dev/null +++ b/backend/tests/Unit/Scolarite/Application/Query/GetStudentProgression/GetStudentProgressionHandlerTest.php @@ -0,0 +1,78 @@ +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', + ); + } +} diff --git a/backend/tests/Unit/Scolarite/Application/Query/GetTeacherStatisticsOverview/GetTeacherStatisticsOverviewHandlerTest.php b/backend/tests/Unit/Scolarite/Application/Query/GetTeacherStatisticsOverview/GetTeacherStatisticsOverviewHandlerTest.php new file mode 100644 index 0000000..3412a01 --- /dev/null +++ b/backend/tests/Unit/Scolarite/Application/Query/GetTeacherStatisticsOverview/GetTeacherStatisticsOverviewHandlerTest.php @@ -0,0 +1,106 @@ +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'), + ); + } +} diff --git a/backend/tests/Unit/Scolarite/Application/Service/StatisticsExporterTest.php b/backend/tests/Unit/Scolarite/Application/Service/StatisticsExporterTest.php new file mode 100644 index 0000000..4b49637 --- /dev/null +++ b/backend/tests/Unit/Scolarite/Application/Service/StatisticsExporterTest.php @@ -0,0 +1,82 @@ +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')); + } +} diff --git a/backend/tests/Unit/Scolarite/Domain/Service/TeacherStatisticsCalculatorTest.php b/backend/tests/Unit/Scolarite/Domain/Service/TeacherStatisticsCalculatorTest.php new file mode 100644 index 0000000..07bd5aa --- /dev/null +++ b/backend/tests/Unit/Scolarite/Domain/Service/TeacherStatisticsCalculatorTest.php @@ -0,0 +1,281 @@ +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); + } +} diff --git a/backend/tests/Unit/Scolarite/Infrastructure/Api/Controller/HomeworkAttachmentControllerTest.php b/backend/tests/Unit/Scolarite/Infrastructure/Api/Controller/HomeworkAttachmentControllerTest.php new file mode 100644 index 0000000..02788f2 --- /dev/null +++ b/backend/tests/Unit/Scolarite/Infrastructure/Api/Controller/HomeworkAttachmentControllerTest.php @@ -0,0 +1,271 @@ +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 $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); + } +} diff --git a/backend/tests/Unit/Scolarite/Infrastructure/Storage/InMemoryFileStorage.php b/backend/tests/Unit/Scolarite/Infrastructure/Storage/InMemoryFileStorage.php index 890d6d9..3f561c9 100644 --- a/backend/tests/Unit/Scolarite/Infrastructure/Storage/InMemoryFileStorage.php +++ b/backend/tests/Unit/Scolarite/Infrastructure/Storage/InMemoryFileStorage.php @@ -6,10 +6,16 @@ namespace App\Tests\Unit\Scolarite\Infrastructure\Storage; use App\Scolarite\Application\Port\FileStorage; +use function fopen; +use function fwrite; use function is_string; use Override; +use function rewind; + +use RuntimeException; + final class InMemoryFileStorage implements FileStorage { /** @var array */ @@ -29,6 +35,21 @@ final class InMemoryFileStorage implements FileStorage unset($this->files[$path]); } + #[Override] + public function readStream(string $path): mixed + { + if (!isset($this->files[$path])) { + throw new RuntimeException("File not found: {$path}"); + } + + /** @var resource $stream */ + $stream = fopen('php://memory', 'r+'); + fwrite($stream, $this->files[$path]); + rewind($stream); + + return $stream; + } + public function has(string $path): bool { return isset($this->files[$path]); diff --git a/backend/tests/Unit/Scolarite/Infrastructure/Storage/S3FileStorageTest.php b/backend/tests/Unit/Scolarite/Infrastructure/Storage/S3FileStorageTest.php new file mode 100644 index 0000000..a34bad5 --- /dev/null +++ b/backend/tests/Unit/Scolarite/Infrastructure/Storage/S3FileStorageTest.php @@ -0,0 +1,141 @@ +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; + } +} diff --git a/backend/tests/Unit/Shared/Infrastructure/Tenant/DoctrineTenantRegistryTest.php b/backend/tests/Unit/Shared/Infrastructure/Tenant/DoctrineTenantRegistryTest.php new file mode 100644 index 0000000..e777c48 --- /dev/null +++ b/backend/tests/Unit/Shared/Infrastructure/Tenant/DoctrineTenantRegistryTest.php @@ -0,0 +1,119 @@ +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 $rows + */ + private function registryWith(array $rows): DoctrineTenantRegistry + { + $connection = $this->createMock(Connection::class); + $connection->method('fetchAllAssociative')->willReturn($rows); + + return new DoctrineTenantRegistry($connection, self::MASTER_URL); + } +} diff --git a/backend/tests/Unit/SuperAdmin/Application/Command/CreateEstablishment/CreateEstablishmentHandlerTest.php b/backend/tests/Unit/SuperAdmin/Application/Command/CreateEstablishment/CreateEstablishmentHandlerTest.php index 8345d96..626677d 100644 --- a/backend/tests/Unit/SuperAdmin/Application/Command/CreateEstablishment/CreateEstablishmentHandlerTest.php +++ b/backend/tests/Unit/SuperAdmin/Application/Command/CreateEstablishment/CreateEstablishmentHandlerTest.php @@ -37,7 +37,7 @@ final class CreateEstablishmentHandlerTest extends TestCase } #[Test] - public function createsEstablishmentAndReturnsResult(): void + public function createsEstablishmentAndReturnsIt(): void { $command = new CreateEstablishmentCommand( name: 'École Alpha', @@ -46,13 +46,13 @@ final class CreateEstablishmentHandlerTest extends TestCase superAdminId: self::SUPER_ADMIN_ID, ); - $result = ($this->handler)($command); + $establishment = ($this->handler)($command); - self::assertNotEmpty($result->establishmentId); - self::assertNotEmpty($result->tenantId); - self::assertSame('École Alpha', $result->name); - self::assertSame('ecole-alpha', $result->subdomain); - self::assertStringStartsWith('classeo_tenant_', $result->databaseName); + self::assertNotEmpty((string) $establishment->id); + self::assertNotEmpty((string) $establishment->tenantId); + self::assertSame('École Alpha', $establishment->name); + self::assertSame('ecole-alpha', $establishment->subdomain); + self::assertStringStartsWith('classeo_tenant_', $establishment->databaseName); } #[Test] @@ -65,10 +65,10 @@ final class CreateEstablishmentHandlerTest extends TestCase superAdminId: self::SUPER_ADMIN_ID, ); - $result = ($this->handler)($command); + $establishment = ($this->handler)($command); $establishments = $this->repository->findAll(); self::assertCount(1, $establishments); - self::assertSame($result->establishmentId, (string) $establishments[0]->id); + self::assertSame((string) $establishment->id, (string) $establishments[0]->id); } } diff --git a/backend/tests/Unit/SuperAdmin/Application/Query/GetEstablishments/GetEstablishmentsHandlerTest.php b/backend/tests/Unit/SuperAdmin/Application/Query/GetEstablishments/GetEstablishmentsHandlerTest.php index 0252806..1dba9f0 100644 --- a/backend/tests/Unit/SuperAdmin/Application/Query/GetEstablishments/GetEstablishmentsHandlerTest.php +++ b/backend/tests/Unit/SuperAdmin/Application/Query/GetEstablishments/GetEstablishmentsHandlerTest.php @@ -40,6 +40,7 @@ final class GetEstablishmentsHandlerTest extends TestCase $this->repository->save(Establishment::creer( name: 'École Alpha', subdomain: 'ecole-alpha', + adminEmail: 'admin@ecole-alpha.fr', createdBy: SuperAdminId::fromString(self::SUPER_ADMIN_ID), createdAt: new DateTimeImmutable('2026-02-16 10:00:00'), )); @@ -47,6 +48,7 @@ final class GetEstablishmentsHandlerTest extends TestCase $this->repository->save(Establishment::creer( name: 'École Beta', subdomain: 'ecole-beta', + adminEmail: 'admin@ecole-beta.fr', createdBy: SuperAdminId::fromString(self::SUPER_ADMIN_ID), createdAt: new DateTimeImmutable('2026-02-16 11:00:00'), )); @@ -56,6 +58,6 @@ final class GetEstablishmentsHandlerTest extends TestCase self::assertCount(2, $result); self::assertSame('École Alpha', $result[0]->name); self::assertSame('ecole-alpha', $result[0]->subdomain); - self::assertSame('active', $result[0]->status); + self::assertSame('provisioning', $result[0]->status); } } diff --git a/backend/tests/Unit/SuperAdmin/Domain/Model/Establishment/EstablishmentTest.php b/backend/tests/Unit/SuperAdmin/Domain/Model/Establishment/EstablishmentTest.php index f0951a9..bc7b52c 100644 --- a/backend/tests/Unit/SuperAdmin/Domain/Model/Establishment/EstablishmentTest.php +++ b/backend/tests/Unit/SuperAdmin/Domain/Model/Establishment/EstablishmentTest.php @@ -23,11 +23,11 @@ final class EstablishmentTest extends TestCase private const string SUBDOMAIN = 'ecole-alpha'; #[Test] - public function creerCreatesActiveEstablishment(): void + public function creerCreatesProvisioningEstablishment(): void { $establishment = $this->createEstablishment(); - self::assertSame(EstablishmentStatus::ACTIF, $establishment->status); + self::assertSame(EstablishmentStatus::PROVISIONING, $establishment->status); self::assertSame(self::ESTABLISHMENT_NAME, $establishment->name); self::assertSame(self::SUBDOMAIN, $establishment->subdomain); self::assertNull($establishment->lastActivityAt); @@ -59,10 +59,21 @@ final class EstablishmentTest extends TestCase self::assertStringStartsWith('classeo_tenant_', $establishment->databaseName); } + #[Test] + public function activerChangesStatusToActif(): void + { + $establishment = $this->createEstablishment(); + + self::assertSame(EstablishmentStatus::PROVISIONING, $establishment->status); + $establishment->activer(); + self::assertSame(EstablishmentStatus::ACTIF, $establishment->status); + } + #[Test] public function desactiverChangesStatusToInactif(): void { $establishment = $this->createEstablishment(); + $establishment->activer(); $establishment->desactiver(new DateTimeImmutable('2026-02-16 12:00:00')); @@ -73,6 +84,7 @@ final class EstablishmentTest extends TestCase public function desactiverRecordsEtablissementDesactiveEvent(): void { $establishment = $this->createEstablishment(); + $establishment->activer(); $establishment->pullDomainEvents(); // Clear creation event $establishment->desactiver(new DateTimeImmutable('2026-02-16 12:00:00')); @@ -86,6 +98,7 @@ final class EstablishmentTest extends TestCase public function desactiverThrowsWhenAlreadyInactive(): void { $establishment = $this->createEstablishment(); + $establishment->activer(); $establishment->desactiver(new DateTimeImmutable('2026-02-16 12:00:00')); $this->expectException(EstablishmentDejaInactifException::class); @@ -141,6 +154,7 @@ final class EstablishmentTest extends TestCase return Establishment::creer( name: self::ESTABLISHMENT_NAME, subdomain: self::SUBDOMAIN, + adminEmail: 'admin@ecole-alpha.fr', createdBy: SuperAdminId::fromString(self::SUPER_ADMIN_ID), createdAt: new DateTimeImmutable('2026-02-16 10:00:00'), ); diff --git a/backend/tests/Unit/SuperAdmin/Infrastructure/Api/Processor/CreateEstablishmentProcessorTest.php b/backend/tests/Unit/SuperAdmin/Infrastructure/Api/Processor/CreateEstablishmentProcessorTest.php index f8e6c34..878a754 100644 --- a/backend/tests/Unit/SuperAdmin/Infrastructure/Api/Processor/CreateEstablishmentProcessorTest.php +++ b/backend/tests/Unit/SuperAdmin/Infrastructure/Api/Processor/CreateEstablishmentProcessorTest.php @@ -7,6 +7,7 @@ namespace App\Tests\Unit\SuperAdmin\Infrastructure\Api\Processor; use ApiPlatform\Metadata\Post; use App\Shared\Domain\Clock; use App\SuperAdmin\Application\Command\CreateEstablishment\CreateEstablishmentHandler; +use App\SuperAdmin\Application\Command\ProvisionEstablishment\ProvisionEstablishmentCommand; use App\SuperAdmin\Domain\Model\SuperAdmin\SuperAdminId; use App\SuperAdmin\Infrastructure\Api\Processor\CreateEstablishmentProcessor; use App\SuperAdmin\Infrastructure\Api\Resource\EstablishmentResource; @@ -16,13 +17,15 @@ use DateTimeImmutable; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; use Symfony\Bundle\SecurityBundle\Security; +use Symfony\Component\Messenger\Envelope; +use Symfony\Component\Messenger\MessageBusInterface; final class CreateEstablishmentProcessorTest extends TestCase { private const string SUPER_ADMIN_ID = '550e8400-e29b-41d4-a716-446655440001'; #[Test] - public function processCreatesEstablishmentAndReturnsResource(): void + public function processCreatesEstablishmentAndDispatchesProvisioning(): void { $repository = new InMemoryEstablishmentRepository(); $clock = new class implements Clock { @@ -42,7 +45,16 @@ final class CreateEstablishmentProcessorTest extends TestCase $security = $this->createMock(Security::class); $security->method('getUser')->willReturn($securityUser); - $processor = new CreateEstablishmentProcessor($handler, $security); + $dispatched = []; + $commandBus = $this->createMock(MessageBusInterface::class); + $commandBus->method('dispatch') + ->willReturnCallback(static function (object $message) use (&$dispatched): Envelope { + $dispatched[] = $message; + + return new Envelope($message); + }); + + $processor = new CreateEstablishmentProcessor($handler, $security, $commandBus); $input = new EstablishmentResource(); $input->name = 'École Gamma'; @@ -55,6 +67,12 @@ final class CreateEstablishmentProcessorTest extends TestCase self::assertNotNull($result->tenantId); self::assertSame('École Gamma', $result->name); self::assertSame('ecole-gamma', $result->subdomain); - self::assertSame('active', $result->status); + self::assertSame('provisioning', $result->status); + + self::assertCount(1, $dispatched); + self::assertInstanceOf(ProvisionEstablishmentCommand::class, $dispatched[0]); + self::assertSame('admin@ecole-gamma.fr', $dispatched[0]->adminEmail); + self::assertSame('ecole-gamma', $dispatched[0]->subdomain); + self::assertSame('École Gamma', $dispatched[0]->establishmentName); } } diff --git a/backend/tests/Unit/SuperAdmin/Infrastructure/Api/Provider/EstablishmentCollectionProviderTest.php b/backend/tests/Unit/SuperAdmin/Infrastructure/Api/Provider/EstablishmentCollectionProviderTest.php index 94771e2..9b0131b 100644 --- a/backend/tests/Unit/SuperAdmin/Infrastructure/Api/Provider/EstablishmentCollectionProviderTest.php +++ b/backend/tests/Unit/SuperAdmin/Infrastructure/Api/Provider/EstablishmentCollectionProviderTest.php @@ -37,6 +37,7 @@ final class EstablishmentCollectionProviderTest extends TestCase $repository->save(Establishment::creer( name: 'École Alpha', subdomain: 'ecole-alpha', + adminEmail: 'admin@ecole-alpha.fr', createdBy: SuperAdminId::fromString(self::SUPER_ADMIN_ID), createdAt: new DateTimeImmutable('2026-02-16 10:00:00'), )); @@ -49,6 +50,6 @@ final class EstablishmentCollectionProviderTest extends TestCase self::assertCount(1, $result); self::assertSame('École Alpha', $result[0]->name); self::assertSame('ecole-alpha', $result[0]->subdomain); - self::assertSame('active', $result[0]->status); + self::assertSame('provisioning', $result[0]->status); } } diff --git a/backend/tests/Unit/SuperAdmin/Infrastructure/Provisioning/DatabaseTenantProvisionerTest.php b/backend/tests/Unit/SuperAdmin/Infrastructure/Provisioning/DatabaseTenantProvisionerTest.php new file mode 100644 index 0000000..d283664 --- /dev/null +++ b/backend/tests/Unit/SuperAdmin/Infrastructure/Provisioning/DatabaseTenantProvisionerTest.php @@ -0,0 +1,72 @@ +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'); + } +} diff --git a/backend/tests/Unit/SuperAdmin/Infrastructure/Provisioning/ProvisionEstablishmentHandlerTest.php b/backend/tests/Unit/SuperAdmin/Infrastructure/Provisioning/ProvisionEstablishmentHandlerTest.php new file mode 100644 index 0000000..e5283d7 --- /dev/null +++ b/backend/tests/Unit/SuperAdmin/Infrastructure/Provisioning/ProvisionEstablishmentHandlerTest.php @@ -0,0 +1,236 @@ +createMock(TenantProvisioner::class); + $provisioner->expects(self::once()) + ->method('provision') + ->with('classeo_tenant_abc123'); + + $handler = $this->buildHandler(provisioner: $provisioner); + $handler($this->command()); + } + + #[Test] + public function itCreatesAdminUser(): void + { + $userRepository = new InMemoryUserRepository(); + + $handler = $this->buildHandler(userRepository: $userRepository); + $handler($this->command()); + + $users = $userRepository->findAllByTenant(TenantId::fromString(self::TENANT_ID)); + self::assertCount(1, $users); + self::assertSame('admin@ecole-gamma.fr', (string) $users[0]->email); + } + + #[Test] + public function itDispatchesInvitationEvent(): void + { + $dispatched = []; + $eventBus = $this->spyEventBus($dispatched); + + $handler = $this->buildHandler(eventBus: $eventBus); + $handler($this->command()); + + self::assertNotEmpty($dispatched); + self::assertInstanceOf(UtilisateurInvite::class, $dispatched[0]); + } + + #[Test] + public function itActivatesEstablishmentAfterProvisioning(): void + { + $establishmentRepo = $this->establishmentRepoWithProvisioningEstablishment(); + + $handler = $this->buildHandler(establishmentRepository: $establishmentRepo); + $handler($this->command()); + + $establishment = $establishmentRepo->get( + EstablishmentId::fromString(self::ESTABLISHMENT_ID), + ); + self::assertSame(EstablishmentStatus::ACTIF, $establishment->status); + } + + #[Test] + public function itIsIdempotentWhenAdminAlreadyExists(): void + { + $userRepository = new InMemoryUserRepository(); + $dispatched = []; + $eventBus = $this->spyEventBus($dispatched); + + $handler = $this->buildHandler(userRepository: $userRepository, eventBus: $eventBus); + + // First call creates the admin + $handler($this->command()); + self::assertCount(1, $dispatched); + self::assertInstanceOf(UtilisateurInvite::class, $dispatched[0]); + + // Second call is idempotent — re-sends invitation + $dispatched = []; + $handler($this->command()); + self::assertCount(1, $dispatched); + self::assertInstanceOf(InvitationRenvoyee::class, $dispatched[0]); + } + + #[Test] + public function itSwitchesDatabaseAndRestores(): void + { + $switcher = new SpyDatabaseSwitcher(); + + $handler = $this->buildHandler(databaseSwitcher: $switcher); + $handler($this->command()); + + self::assertCount(1, $switcher->switchedTo); + self::assertStringContainsString('classeo_tenant_abc123', $switcher->switchedTo[0]); + self::assertTrue($switcher->restoredToDefault); + } + + #[Test] + public function itPreservesQueryParametersInDatabaseUrl(): void + { + $switcher = new SpyDatabaseSwitcher(); + + $handler = $this->buildHandler(databaseSwitcher: $switcher); + $handler($this->command()); + + self::assertStringContainsString('?serverVersion=18', $switcher->switchedTo[0]); + } + + #[Test] + public function itRestoresDatabaseEvenOnFailure(): void + { + $switcher = new SpyDatabaseSwitcher(); + + $eventBus = $this->createMock(MessageBusInterface::class); + $eventBus->method('dispatch') + ->willThrowException(new RuntimeException('Event bus failure')); + + $handler = $this->buildHandler(databaseSwitcher: $switcher, eventBus: $eventBus); + + try { + $handler($this->command()); + } catch (RuntimeException) { + // Expected + } + + self::assertTrue($switcher->restoredToDefault); + } + + private function command(): ProvisionEstablishmentCommand + { + return new ProvisionEstablishmentCommand( + establishmentId: self::ESTABLISHMENT_ID, + establishmentTenantId: self::TENANT_ID, + databaseName: 'classeo_tenant_abc123', + subdomain: 'ecole-gamma', + adminEmail: 'admin@ecole-gamma.fr', + establishmentName: 'École Gamma', + ); + } + + private function establishmentRepoWithProvisioningEstablishment(): InMemoryEstablishmentRepository + { + $repo = new InMemoryEstablishmentRepository(); + $establishment = Establishment::reconstitute( + id: EstablishmentId::fromString(self::ESTABLISHMENT_ID), + tenantId: TenantId::fromString(self::TENANT_ID), + name: 'École Gamma', + subdomain: 'ecole-gamma', + databaseName: 'classeo_tenant_abc123', + status: EstablishmentStatus::PROVISIONING, + createdAt: new DateTimeImmutable('2026-04-07 10:00:00'), + createdBy: SuperAdminId::fromString('550e8400-e29b-41d4-a716-446655440002'), + ); + $repo->save($establishment); + + return $repo; + } + + /** + * @param object[] $dispatched + */ + private function spyEventBus(array &$dispatched): MessageBusInterface + { + $eventBus = $this->createMock(MessageBusInterface::class); + $eventBus->method('dispatch') + ->willReturnCallback(static function (object $message) use (&$dispatched): Envelope { + $dispatched[] = $message; + + return new Envelope($message); + }); + + return $eventBus; + } + + private function buildHandler( + ?TenantProvisioner $provisioner = null, + ?InMemoryUserRepository $userRepository = null, + ?SpyDatabaseSwitcher $databaseSwitcher = null, + ?InMemoryEstablishmentRepository $establishmentRepository = null, + ?MessageBusInterface $eventBus = null, + ): ProvisionEstablishmentHandler { + $provisioner ??= $this->createMock(TenantProvisioner::class); + + $clock = new class implements Clock { + public function now(): DateTimeImmutable + { + return new DateTimeImmutable('2026-04-07 10:00:00'); + } + }; + + $userRepository ??= new InMemoryUserRepository(); + + $databaseSwitcher ??= new SpyDatabaseSwitcher(); + + $establishmentRepository ??= $this->establishmentRepoWithProvisioningEstablishment(); + + $eventBus ??= $this->createMock(MessageBusInterface::class); + $eventBus->method('dispatch') + ->willReturnCallback(static fn (object $m): Envelope => new Envelope($m)); + + return new ProvisionEstablishmentHandler( + tenantProvisioner: $provisioner, + inviteUserHandler: new InviteUserHandler($userRepository, $clock), + userRepository: $userRepository, + clock: $clock, + databaseSwitcher: $databaseSwitcher, + establishmentRepository: $establishmentRepository, + eventBus: $eventBus, + logger: new NullLogger(), + masterDatabaseUrl: self::MASTER_URL, + ); + } +} diff --git a/backend/tests/Unit/SuperAdmin/Infrastructure/Provisioning/ProvisioningIntegrationTest.php b/backend/tests/Unit/SuperAdmin/Infrastructure/Provisioning/ProvisioningIntegrationTest.php new file mode 100644 index 0000000..b6ae9e2 --- /dev/null +++ b/backend/tests/Unit/SuperAdmin/Infrastructure/Provisioning/ProvisioningIntegrationTest.php @@ -0,0 +1,166 @@ +establishmentRepository = new InMemoryEstablishmentRepository(); + $createHandler = new CreateEstablishmentHandler($this->establishmentRepository, $clock); + + $security = $this->createMock(Security::class); + $security->method('getUser')->willReturn(new SecuritySuperAdmin( + SuperAdminId::fromString(self::SUPER_ADMIN_ID), + 'superadmin@classeo.fr', + 'hashed', + )); + + $this->provisionCommand = null; + $commandBus = $this->createMock(MessageBusInterface::class); + $commandBus->method('dispatch') + ->willReturnCallback(function (object $message): Envelope { + if ($message instanceof ProvisionEstablishmentCommand) { + $this->provisionCommand = $message; + } + + return new Envelope($message); + }); + + $processor = new CreateEstablishmentProcessor($createHandler, $security, $commandBus); + + $input = new EstablishmentResource(); + $input->name = 'École Test'; + $input->subdomain = 'ecole-test'; + $input->adminEmail = 'admin@ecole-test.fr'; + + $processor->process($input, new Post()); + + // Phase 2: Provisioning handler processes the command + self::assertNotNull($this->provisionCommand); + + $this->userRepository = new InMemoryUserRepository(); + $this->dispatchedEvents = []; + + $eventBus = $this->createMock(MessageBusInterface::class); + $eventBus->method('dispatch') + ->willReturnCallback(function (object $message): Envelope { + $this->dispatchedEvents[] = $message; + + return new Envelope($message); + }); + + $provisioner = $this->createMock(TenantProvisioner::class); + + $switcher = new SpyDatabaseSwitcher(); + + $provisionHandler = new ProvisionEstablishmentHandler( + tenantProvisioner: $provisioner, + inviteUserHandler: new InviteUserHandler($this->userRepository, $clock), + userRepository: $this->userRepository, + clock: $clock, + databaseSwitcher: $switcher, + establishmentRepository: $this->establishmentRepository, + eventBus: $eventBus, + logger: new NullLogger(), + masterDatabaseUrl: self::MASTER_URL, + ); + + $provisionHandler($this->provisionCommand); + } + + #[Test] + public function processorCreatesEstablishmentInProvisioningStatus(): void + { + $this->runFullFlow(); + + $establishments = $this->establishmentRepository->findAll(); + self::assertCount(1, $establishments); + self::assertSame('École Test', $establishments[0]->name); + } + + #[Test] + public function processorDispatchesProvisioningCommandWithAdminEmail(): void + { + $this->runFullFlow(); + + self::assertNotNull($this->provisionCommand); + self::assertSame('admin@ecole-test.fr', $this->provisionCommand->adminEmail); + self::assertSame('ecole-test', $this->provisionCommand->subdomain); + } + + #[Test] + public function provisioningCreatesAdminUserWithCorrectRole(): void + { + $this->runFullFlow(); + + $users = $this->userRepository->findAllByTenant( + TenantId::fromString($this->provisionCommand->establishmentTenantId), + ); + self::assertCount(1, $users); + self::assertSame('admin@ecole-test.fr', (string) $users[0]->email); + self::assertSame(Role::ADMIN, $users[0]->role); + } + + #[Test] + public function provisioningActivatesEstablishmentAndDispatchesEvent(): void + { + $this->runFullFlow(); + + $establishments = $this->establishmentRepository->findAll(); + self::assertSame(EstablishmentStatus::ACTIF, $establishments[0]->status); + + self::assertCount(1, $this->dispatchedEvents); + self::assertInstanceOf(UtilisateurInvite::class, $this->dispatchedEvents[0]); + } +} diff --git a/backend/tests/Unit/SuperAdmin/Infrastructure/Provisioning/SpyDatabaseSwitcher.php b/backend/tests/Unit/SuperAdmin/Infrastructure/Provisioning/SpyDatabaseSwitcher.php new file mode 100644 index 0000000..779a213 --- /dev/null +++ b/backend/tests/Unit/SuperAdmin/Infrastructure/Provisioning/SpyDatabaseSwitcher.php @@ -0,0 +1,32 @@ +switchedTo[] = $databaseUrl; + } + + public function useDefaultDatabase(): void + { + $this->restoredToDefault = true; + } + + public function currentDatabaseUrl(): ?string + { + return null; + } +} diff --git a/compose.yaml b/compose.yaml index 1d8102e..b8a8da4 100644 --- a/compose.yaml +++ b/compose.yaml @@ -39,6 +39,8 @@ services: condition: service_healthy rabbitmq: condition: service_healthy + minio-init: + condition: service_completed_successfully healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8000/api/docs"] interval: 10s @@ -221,6 +223,43 @@ services: memory: 256M restart: unless-stopped + # ============================================================================= + # OBJECT STORAGE - MinIO (S3-compatible) + # ============================================================================= + minio: + image: minio/minio:latest + container_name: classeo_minio + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: classeo + MINIO_ROOT_PASSWORD: classeo_secret + ports: + - "9000:9000" + - "9001:9001" + volumes: + - minio_data:/data + healthcheck: + test: ["CMD", "mc", "ready", "local"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + restart: unless-stopped + + # Init container: creates the S3 bucket if it doesn't exist + minio-init: + image: minio/mc + container_name: classeo_minio_init + depends_on: + minio: + condition: service_healthy + entrypoint: > + /bin/sh -c " + mc alias set local http://minio:9000 classeo classeo_secret && + mc mb --ignore-existing local/classeo + " + restart: "no" + # ============================================================================= # EMAIL TESTING - Mailpit # ============================================================================= @@ -246,6 +285,7 @@ volumes: redis_data: rabbitmq_data: meilisearch_data: + minio_data: frontend_node_modules: caddy_data: caddy_config: diff --git a/deploy/vps/Caddyfile b/deploy/vps/Caddyfile index 0eacf78..0faf9d9 100644 --- a/deploy/vps/Caddyfile +++ b/deploy/vps/Caddyfile @@ -1,6 +1,16 @@ -{$APP_DOMAIN} { +# Domaine principal et sous-domaines wildcard (multi-tenant) +# Caddy provisionne automatiquement les certificats TLS via Let's Encrypt. +# Le wildcard nécessite un DNS challenge : configurer CADDY_DNS_PROVIDER +# et les credentials DNS dans les variables d'environnement. + +{$APP_DOMAIN}, *.{$APP_DOMAIN} { encode zstd gzip + # Le certificat wildcard nécessite un DNS challenge + tls { + dns {$CADDY_DNS_PROVIDER:cloudflare} {$CADDY_DNS_API_TOKEN} + } + handle /api/* { reverse_proxy php:8000 } diff --git a/frontend/e2e/grades.spec.ts b/frontend/e2e/grades.spec.ts index 2d1e070..ff2a4dc 100644 --- a/frontend/e2e/grades.spec.ts +++ b/frontend/e2e/grades.spec.ts @@ -171,7 +171,7 @@ test.describe('Grade Input Grid (Story 6.2)', () => { await firstInput.fill('25'); // Should show error - await expect(page.locator('.input-error-msg').first()).toBeVisible({ timeout: 5000 }); + await expect(page.locator('.input-error-msg').first()).toBeVisible({ timeout: 10000 }); }); }); diff --git a/frontend/e2e/homework-richtext-attachments.spec.ts b/frontend/e2e/homework-attachments.spec.ts similarity index 62% rename from frontend/e2e/homework-richtext-attachments.spec.ts rename to frontend/e2e/homework-attachments.spec.ts index 3bdd0a0..7e904fa 100644 --- a/frontend/e2e/homework-richtext-attachments.spec.ts +++ b/frontend/e2e/homework-attachments.spec.ts @@ -12,7 +12,6 @@ const urlMatch = baseUrl.match(/:(\d+)$/); const PORT = urlMatch ? urlMatch[1] : '4173'; const ALPHA_URL = `http://ecole-alpha.classeo.local:${PORT}`; -// Réutilise le même enseignant que homework.spec.ts pour partager le setup const TEACHER_EMAIL = 'e2e-homework-teacher@example.com'; const TEACHER_PASSWORD = 'HomeworkTest123'; const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'; @@ -105,7 +104,6 @@ async function createHomework(page: import('@playwright/test').Page, title: stri await page.locator('#hw-subject').selectOption({ index: 1 }); await page.locator('#hw-title').fill(title); - // Type in WYSIWYG editor (TipTap initializes asynchronously) const editorContent = page.locator('.modal .rich-text-content'); await expect(editorContent).toBeVisible({ timeout: 10000 }); await editorContent.click(); @@ -158,9 +156,8 @@ function cleanupTempFiles() { } } -test.describe('Rich Text & Attachments (Story 5.9)', () => { +test.describe('Homework Attachments (Story 5.9/5.11)', () => { test.beforeAll(async () => { - // Ensure teacher user exists (same as homework.spec.ts) execSync( `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${TEACHER_EMAIL} --password=${TEACHER_PASSWORD} --role=ROLE_PROF 2>&1`, { encoding: 'utf-8' } @@ -189,7 +186,6 @@ test.describe('Rich Text & Attachments (Story 5.9)', () => { }); test.beforeEach(async () => { - // homework_submissions has NO CASCADE on homework_id — delete submissions first try { runSql(`DELETE FROM submission_attachments WHERE submission_id IN (SELECT hs.id FROM homework_submissions hs JOIN homework h ON hs.homework_id = h.id WHERE h.tenant_id = '${TENANT_ID}' AND h.teacher_id IN (SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}'))`); } catch { /* Table may not exist */ } @@ -198,138 +194,41 @@ test.describe('Rich Text & Attachments (Story 5.9)', () => { } catch { /* Table may not exist */ } try { runSql(`DELETE FROM homework WHERE tenant_id = '${TENANT_ID}' AND teacher_id IN (SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}')`); - } catch { - // Table may not exist - } - - // Disable any homework rules left by other test files (homework-rules-warning, - // homework-rules-hard) to prevent rule warnings blocking homework creation. + } catch { /* Table may not exist */ } try { runSql(`UPDATE homework_rules SET enabled = false, updated_at = NOW() WHERE tenant_id = '${TENANT_ID}'`); } catch { /* Table may not exist */ } - - // Clear school calendar entries that may block dates (Vacances de Printemps, etc.) try { runSql(`DELETE FROM school_calendar_entries WHERE tenant_id = '${TENANT_ID}'`); } catch { /* Table may not exist */ } - clearCache(); }); - // ============================================================================ - // T4.1 : WYSIWYG Editor - // ============================================================================ - test.describe('WYSIWYG Editor', () => { - test('create form shows rich text editor with toolbar', async ({ page }) => { - await loginAsTeacher(page); - await navigateToHomework(page); - - await page.getByRole('button', { name: /nouveau devoir/i }).click(); - await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 }); - - // Rich text editor with toolbar should be visible - const editor = page.locator('.rich-text-editor'); - await expect(editor).toBeVisible({ timeout: 5000 }); - await expect(page.locator('.toolbar')).toBeVisible(); - - // Toolbar buttons - await expect(page.getByRole('button', { name: 'Gras' })).toBeVisible(); - await expect(page.getByRole('button', { name: 'Italique' })).toBeVisible(); - await expect(page.getByRole('button', { name: 'Liste à puces' })).toBeVisible(); - await expect(page.getByRole('button', { name: 'Liste numérotée' })).toBeVisible(); - await expect(page.getByRole('button', { name: 'Lien' })).toBeVisible(); - }); - - test('can create homework with rich text description', async ({ page }) => { - await loginAsTeacher(page); - await navigateToHomework(page); - - await page.getByRole('button', { name: /nouveau devoir/i }).click(); - await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 }); - - await page.locator('#hw-class').selectOption({ index: 1 }); - await page.locator('#hw-subject').selectOption({ index: 1 }); - await page.locator('#hw-title').fill('Devoir texte riche'); - - // Type in rich text editor (TipTap initializes asynchronously) - const editorContent = page.locator('.modal .rich-text-content'); - await expect(editorContent).toBeVisible({ timeout: 10000 }); - await editorContent.click(); - await page.keyboard.type('Consignes importantes'); - - await page.locator('#hw-due-date').fill(getNextWeekday(5)); - await page.getByRole('button', { name: /créer le devoir/i }).click(); - - await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 }); - await expect(page.getByText('Devoir texte riche')).toBeVisible({ timeout: 10000 }); - }); - - test('bold formatting works in editor', async ({ page }) => { - await loginAsTeacher(page); - await navigateToHomework(page); - - await page.getByRole('button', { name: /nouveau devoir/i }).click(); - await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 }); - - await page.locator('#hw-class').selectOption({ index: 1 }); - await page.locator('#hw-subject').selectOption({ index: 1 }); - await page.locator('#hw-title').fill('Devoir gras test'); - - const editorContent = page.locator('.modal .rich-text-content'); - await expect(editorContent).toBeVisible({ timeout: 10000 }); - await editorContent.click(); - await page.keyboard.type('Normal '); - - // Apply bold via keyboard shortcut (more reliable than toolbar click) - await page.keyboard.press('Control+b'); - await page.keyboard.type('en gras'); - - await page.locator('#hw-due-date').fill(getNextWeekday(5)); - await page.getByRole('button', { name: /créer le devoir/i }).click(); - - await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 }); - await expect(page.getByText('Devoir gras test')).toBeVisible({ timeout: 10000 }); - - // Verify bold is rendered in the description - const description = page.locator('.homework-description'); - await expect(description.locator('strong')).toContainText('en gras'); - }); - }); - - // ============================================================================ - // T4.2 : Upload attachment - // ============================================================================ - test.describe('Attachments', () => { + test.describe('Upload & Delete', () => { test('can upload a PDF attachment to homework via edit modal', async ({ page }) => { await loginAsTeacher(page); await navigateToHomework(page); - // Create homework await createHomework(page, 'Devoir avec PJ'); - // Open edit modal const hwCard = page.locator('.homework-card', { hasText: 'Devoir avec PJ' }); await hwCard.getByRole('button', { name: /modifier/i }).click(); await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 }); - // Upload file const pdfPath = createTempPdf(); const fileInput = page.locator('.file-input-hidden'); await fileInput.setInputFiles(pdfPath); - // File appears in list await expect(page.getByText('test-attachment.pdf')).toBeVisible({ timeout: 15000 }); }); - // T4.3 : Delete attachment test('can delete an uploaded attachment', async ({ page }) => { - test.slow(); // upload + delete needs more than 30s + test.slow(); await loginAsTeacher(page); await navigateToHomework(page); await createHomework(page, 'Devoir suppr PJ'); - // Open edit modal and upload const hwCard = page.locator('.homework-card', { hasText: 'Devoir suppr PJ' }); await hwCard.getByRole('button', { name: /modifier/i }).click(); await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 }); @@ -338,80 +237,142 @@ test.describe('Rich Text & Attachments (Story 5.9)', () => { await page.locator('.file-input-hidden').setInputFiles(pdfPath); await expect(page.getByText('test-attachment.pdf')).toBeVisible({ timeout: 15000 }); - // Delete the attachment await page.getByRole('button', { name: /supprimer test-attachment.pdf/i }).click(); await expect(page.getByText('test-attachment.pdf')).not.toBeVisible({ timeout: 5000 }); }); }); - // ============================================================================ - // T5.9.1 : Invalid file type rejection (P1) - // ============================================================================ - test.describe('Invalid File Type Rejection', () => { + test.describe('Drop Zone UI', () => { + test('create form shows drag-and-drop zone for attachments', async ({ page }) => { + await loginAsTeacher(page); + await navigateToHomework(page); + + await createHomework(page, 'Devoir drop zone'); + + const hwCard = page.locator('.homework-card', { hasText: 'Devoir drop zone' }); + await hwCard.getByRole('button', { name: /modifier/i }).click(); + await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 }); + + const dropZone = page.locator('.drop-zone'); + await expect(dropZone).toBeVisible({ timeout: 5000 }); + await expect(dropZone).toContainText('Glissez-déposez'); + await expect(dropZone.locator('.drop-zone-browse')).toContainText('parcourir'); + }); + + test('browse button in drop zone opens file dialog and uploads', async ({ page }) => { + await loginAsTeacher(page); + await navigateToHomework(page); + + await createHomework(page, 'Devoir browse btn'); + + const hwCard = page.locator('.homework-card', { hasText: 'Devoir browse btn' }); + await hwCard.getByRole('button', { name: /modifier/i }).click(); + await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 }); + + const pdfPath = createTempPdf(); + const fileInput = page.locator('.file-input-hidden'); + await fileInput.setInputFiles(pdfPath); + + await expect(page.getByText('test-attachment.pdf')).toBeVisible({ timeout: 15000 }); + }); + + test('drag-and-drop visual feedback appears on dragover', async ({ page }) => { + await loginAsTeacher(page); + await navigateToHomework(page); + + await createHomework(page, 'Devoir drag feedback'); + + const hwCard = page.locator('.homework-card', { hasText: 'Devoir drag feedback' }); + await hwCard.getByRole('button', { name: /modifier/i }).click(); + await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 }); + + const dropZone = page.locator('.drop-zone'); + await expect(dropZone).toBeVisible({ timeout: 5000 }); + + await dropZone.evaluate((el) => { + el.dispatchEvent(new DragEvent('dragover', { dataTransfer: new DataTransfer(), bubbles: true })); + }); + await expect(dropZone).toHaveClass(/drop-zone-active/); + + await dropZone.evaluate((el) => { + el.dispatchEvent(new DragEvent('dragleave', { bubbles: true })); + }); + await expect(dropZone).not.toHaveClass(/drop-zone-active/); + }); + }); + + test.describe('File Type Badge', () => { + test('uploaded PDF shows formatted type badge', async ({ page }) => { + await loginAsTeacher(page); + await navigateToHomework(page); + + await createHomework(page, 'Devoir badge type'); + + const hwCard = page.locator('.homework-card', { hasText: 'Devoir badge type' }); + await hwCard.getByRole('button', { name: /modifier/i }).click(); + await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 }); + + const pdfPath = createTempPdf(); + const fileInput = page.locator('.file-input-hidden'); + await fileInput.setInputFiles(pdfPath); + await expect(page.getByText('test-attachment.pdf')).toBeVisible({ timeout: 15000 }); + + const fileType = page.locator('.file-type'); + await expect(fileType).toBeVisible({ timeout: 5000 }); + await expect(fileType).toHaveText('PDF'); + }); + }); + + test.describe('Validation', () => { test('rejects a .txt file with an error message', async ({ page }) => { await loginAsTeacher(page); await navigateToHomework(page); await createHomework(page, 'Devoir rejet fichier'); - // Open edit modal const hwCard = page.locator('.homework-card', { hasText: 'Devoir rejet fichier' }); await hwCard.getByRole('button', { name: /modifier/i }).click(); await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 }); - // Try to upload a .txt file const txtPath = createTempTxt(); const fileInput = page.locator('.file-input-hidden'); await fileInput.setInputFiles(txtPath); - // Error message should appear const errorAlert = page.locator('[role="alert"]'); await expect(errorAlert).toBeVisible({ timeout: 5000 }); await expect(errorAlert).toContainText('Type de fichier non accepté'); - // The .txt file should NOT appear in the file list await expect(page.getByText('test-invalid.txt')).not.toBeVisible(); }); }); - // ============================================================================ - // T5.9.2 : Attachment persistence after save (P1) - // ============================================================================ - test.describe('Attachment Persistence', () => { + test.describe('Persistence', () => { test('uploaded attachment persists after saving and reopening edit modal', async ({ page }) => { await loginAsTeacher(page); await navigateToHomework(page); await createHomework(page, 'Devoir persistance PJ'); - // Open edit modal const hwCard = page.locator('.homework-card', { hasText: 'Devoir persistance PJ' }); await hwCard.getByRole('button', { name: /modifier/i }).click(); await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 }); - // Upload a PDF const pdfPath = createTempPdf(); const fileInput = page.locator('.file-input-hidden'); await fileInput.setInputFiles(pdfPath); await expect(page.getByText('test-attachment.pdf')).toBeVisible({ timeout: 15000 }); - // Save the changes await page.getByRole('button', { name: /enregistrer/i }).click(); await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 }); - // Reopen the edit modal const hwCardAfterSave = page.locator('.homework-card', { hasText: 'Devoir persistance PJ' }); await hwCardAfterSave.getByRole('button', { name: /modifier/i }).click(); await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 }); - // The attachment should still be there await expect(page.getByText('test-attachment.pdf')).toBeVisible({ timeout: 15000 }); }); }); - // ============================================================================ - // T5.9.3 : File size display after upload (P2) - // ============================================================================ test.describe('File Size Display', () => { test('shows formatted file size after uploading a PDF', async ({ page }) => { await loginAsTeacher(page); @@ -419,73 +380,18 @@ test.describe('Rich Text & Attachments (Story 5.9)', () => { await createHomework(page, 'Devoir taille fichier'); - // Open edit modal const hwCard = page.locator('.homework-card', { hasText: 'Devoir taille fichier' }); await hwCard.getByRole('button', { name: /modifier/i }).click(); await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 }); - // Upload a PDF const pdfPath = createTempPdf(); const fileInput = page.locator('.file-input-hidden'); await fileInput.setInputFiles(pdfPath); await expect(page.getByText('test-attachment.pdf')).toBeVisible({ timeout: 15000 }); - // The file size element should be visible and show a formatted size (e.g., "xxx o" or "xxx Ko") const fileSize = page.locator('.file-size'); await expect(fileSize).toBeVisible({ timeout: 5000 }); await expect(fileSize).toHaveText(/\d+(\.\d+)?\s*(o|Ko|Mo)/); }); }); - - // ============================================================================ - // T4.4 : Backward compatibility - // ============================================================================ - test.describe('Backward Compatibility', () => { - test('existing plain text homework displays correctly', async ({ page }) => { - // Create homework with plain text description via SQL - runSql( - `INSERT INTO homework (id, tenant_id, class_id, subject_id, teacher_id, title, description, due_date, status, created_at, updated_at) ` + - `SELECT gen_random_uuid(), '${TENANT_ID}', c.id, s.id, u.id, 'Devoir texte brut E2E', 'Description simple sans balise HTML', '${getNextWeekday(10)}', 'published', NOW(), NOW() ` + - `FROM users u, school_classes c, subjects s ` + - `WHERE u.email = '${TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` + - `AND c.tenant_id = '${TENANT_ID}' AND c.name = 'E2E-HW-6A' ` + - `AND s.tenant_id = '${TENANT_ID}' AND s.name = 'E2E-HW-Maths' ` + - `LIMIT 1` - ); - clearCache(); - - await loginAsTeacher(page); - await navigateToHomework(page); - - // Plain text description displays correctly - await expect(page.getByText('Devoir texte brut E2E')).toBeVisible({ timeout: 10000 }); - await expect(page.getByText('Description simple sans balise HTML')).toBeVisible(); - }); - - test('edit modal loads plain text in WYSIWYG editor', async ({ page }) => { - runSql( - `INSERT INTO homework (id, tenant_id, class_id, subject_id, teacher_id, title, description, due_date, status, created_at, updated_at) ` + - `SELECT gen_random_uuid(), '${TENANT_ID}', c.id, s.id, u.id, 'Devoir edit brut E2E', 'Ancienne description', '${getNextWeekday(10)}', 'published', NOW(), NOW() ` + - `FROM users u, school_classes c, subjects s ` + - `WHERE u.email = '${TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` + - `AND c.tenant_id = '${TENANT_ID}' AND c.name = 'E2E-HW-6A' ` + - `AND s.tenant_id = '${TENANT_ID}' AND s.name = 'E2E-HW-Maths' ` + - `LIMIT 1` - ); - clearCache(); - - await loginAsTeacher(page); - await navigateToHomework(page); - - // Open edit modal - const hwCard = page.locator('.homework-card', { hasText: 'Devoir edit brut E2E' }); - await hwCard.getByRole('button', { name: /modifier/i }).click(); - await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 }); - - // WYSIWYG editor contains the old text (TipTap initializes asynchronously) - const editorContent = page.locator('.modal .rich-text-content'); - await expect(editorContent).toBeVisible({ timeout: 10000 }); - await expect(editorContent).toContainText('Ancienne description', { timeout: 5000 }); - }); - }); }); diff --git a/frontend/e2e/homework-calendar.spec.ts b/frontend/e2e/homework-calendar.spec.ts new file mode 100644 index 0000000..cef2abd --- /dev/null +++ b/frontend/e2e/homework-calendar.spec.ts @@ -0,0 +1,395 @@ +import { test, expect } from '@playwright/test'; +import { execSync } from 'child_process'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const baseUrl = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:4173'; +const urlMatch = baseUrl.match(/:(\d+)$/); +const PORT = urlMatch ? urlMatch[1] : '4173'; +const ALPHA_URL = `http://ecole-alpha.classeo.local:${PORT}`; + +const TEACHER_EMAIL = 'e2e-homework-teacher@example.com'; +const TEACHER_PASSWORD = 'HomeworkTest123'; +const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'; + +const projectRoot = join(__dirname, '../..'); +const composeFile = join(projectRoot, 'compose.yaml'); + +function runSql(sql: string) { + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "${sql}" 2>&1`, + { encoding: 'utf-8' } + ); +} + +function clearCache() { + try { + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear paginated_queries.cache 2>&1`, + { encoding: 'utf-8' } + ); + } catch { + // Cache pool may not exist + } +} + +function resolveDeterministicIds(): { schoolId: string; academicYearId: string } { + const output = execSync( + `docker compose -f "${composeFile}" exec -T php php -r '` + + `require "/app/vendor/autoload.php"; ` + + `$t="${TENANT_ID}"; $ns="6ba7b814-9dad-11d1-80b4-00c04fd430c8"; ` + + `echo Ramsey\\Uuid\\Uuid::uuid5($ns,"school-$t")->toString()."\\n"; ` + + `$m=(int)date("n"); $s=$m>=9?(int)date("Y"):(int)date("Y")-1; $e=$s+1; ` + + `echo Ramsey\\Uuid\\Uuid::uuid5($ns,"$t:$s-$e")->toString();` + + `' 2>&1`, + { encoding: 'utf-8' } + ).trim(); + const [schoolId, academicYearId] = output.split('\n'); + return { schoolId: schoolId!, academicYearId: academicYearId! }; +} + +function getNextWeekday(daysFromNow: number): string { + const date = new Date(); + date.setDate(date.getDate() + daysFromNow); + const day = date.getDay(); + if (day === 0) date.setDate(date.getDate() + 1); + if (day === 6) date.setDate(date.getDate() + 2); + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, '0'); + const d = String(date.getDate()).padStart(2, '0'); + return `${y}-${m}-${d}`; +} + +function seedTeacherAssignments() { + const { academicYearId } = resolveDeterministicIds(); + try { + runSql( + `INSERT INTO teacher_assignments (id, tenant_id, teacher_id, school_class_id, subject_id, academic_year_id, status, start_date, created_at, updated_at) ` + + `SELECT gen_random_uuid(), '${TENANT_ID}', u.id, c.id, s.id, '${academicYearId}', 'active', NOW(), NOW(), NOW() ` + + `FROM users u CROSS JOIN school_classes c CROSS JOIN subjects s ` + + `WHERE u.email = '${TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` + + `AND c.tenant_id = '${TENANT_ID}' ` + + `AND s.tenant_id = '${TENANT_ID}' ` + + `ON CONFLICT DO NOTHING` + ); + } catch { + // May already exist + } +} + +async function loginAsTeacher(page: import('@playwright/test').Page) { + await page.goto(`${ALPHA_URL}/login`); + await page.locator('#email').fill(TEACHER_EMAIL); + await page.locator('#password').fill(TEACHER_PASSWORD); + await Promise.all([ + page.waitForURL(/\/dashboard/, { timeout: 60000 }), + page.getByRole('button', { name: /se connecter/i }).click() + ]); +} + +async function navigateToHomework(page: import('@playwright/test').Page) { + await page.goto(`${ALPHA_URL}/dashboard/teacher/homework`); + await expect(page.getByRole('heading', { name: /mes devoirs/i })).toBeVisible({ timeout: 15000 }); +} + +test.describe('Calendar Date Picker (Story 5.11)', () => { + test.beforeAll(async () => { + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${TEACHER_EMAIL} --password=${TEACHER_PASSWORD} --role=ROLE_PROF 2>&1`, + { encoding: 'utf-8' } + ); + + const { schoolId, academicYearId } = resolveDeterministicIds(); + try { + runSql( + `INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) ` + + `VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E-HW-6A', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` + ); + runSql( + `INSERT INTO subjects (id, tenant_id, school_id, name, code, status, created_at, updated_at) ` + + `VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', 'E2E-HW-Maths', 'E2EMAT', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` + ); + } catch { + // May already exist + } + + seedTeacherAssignments(); + clearCache(); + }); + + test.beforeEach(async () => { + try { + runSql(`DELETE FROM submission_attachments WHERE submission_id IN (SELECT hs.id FROM homework_submissions hs JOIN homework h ON hs.homework_id = h.id WHERE h.tenant_id = '${TENANT_ID}' AND h.teacher_id IN (SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}'))`); + } catch { /* Table may not exist */ } + try { + runSql(`DELETE FROM homework_submissions WHERE homework_id IN (SELECT id FROM homework WHERE tenant_id = '${TENANT_ID}' AND teacher_id IN (SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}'))`); + } catch { /* Table may not exist */ } + try { + runSql(`DELETE FROM homework WHERE tenant_id = '${TENANT_ID}' AND teacher_id IN (SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}')`); + } catch { /* Table may not exist */ } + try { + runSql(`UPDATE homework_rules SET enabled = false, updated_at = NOW() WHERE tenant_id = '${TENANT_ID}'`); + } catch { /* Table may not exist */ } + try { + runSql(`DELETE FROM school_calendar_entries WHERE tenant_id = '${TENANT_ID}'`); + } catch { /* Table may not exist */ } + clearCache(); + }); + + test('create form shows calendar date picker instead of native input', async ({ page }) => { + await loginAsTeacher(page); + await navigateToHomework(page); + + await page.getByRole('button', { name: /nouveau devoir/i }).click(); + await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 }); + + // Calendar date picker should be visible + const picker = page.locator('.calendar-date-picker'); + await expect(picker).toBeVisible({ timeout: 5000 }); + + // Trigger button should show placeholder text + await expect(picker.locator('.picker-trigger')).toContainText('Choisir une date'); + + // Native date input is sr-only (hidden but present for form semantics) + await expect(page.locator('#hw-due-date')).toHaveAttribute('aria-hidden', 'true'); + }); + + test('clicking picker opens calendar dropdown', async ({ page }) => { + await loginAsTeacher(page); + await navigateToHomework(page); + + await page.getByRole('button', { name: /nouveau devoir/i }).click(); + await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 }); + + const picker = page.locator('.modal .calendar-date-picker'); + await picker.locator('.picker-trigger').click(); + + // Calendar dropdown should be visible + const dropdown = picker.locator('.calendar-dropdown'); + await expect(dropdown).toBeVisible({ timeout: 3000 }); + + // Day names header should be visible + await expect(dropdown.locator('.day-name').first()).toBeVisible(); + + // Month label should be visible + await expect(dropdown.locator('.month-label')).toBeVisible(); + }); + + test('weekends are disabled in calendar', async ({ page }) => { + await loginAsTeacher(page); + await navigateToHomework(page); + + await page.getByRole('button', { name: /nouveau devoir/i }).click(); + await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 }); + + const picker = page.locator('.modal .calendar-date-picker'); + await picker.locator('.picker-trigger').click(); + await expect(picker.locator('.calendar-dropdown')).toBeVisible({ timeout: 3000 }); + + // Weekend cells should have the weekend class and be disabled + const weekendCells = picker.locator('.day-cell.weekend'); + const count = await weekendCells.count(); + expect(count).toBeGreaterThan(0); + + // Weekend cells should be disabled + const firstWeekend = weekendCells.first(); + await expect(firstWeekend).toBeDisabled(); + }); + + test('can select a date by clicking a day in the calendar', async ({ page }) => { + await loginAsTeacher(page); + await navigateToHomework(page); + + await page.getByRole('button', { name: /nouveau devoir/i }).click(); + await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 }); + + await page.locator('#hw-class').selectOption({ index: 1 }); + await page.locator('#hw-subject').selectOption({ index: 1 }); + await page.locator('#hw-title').fill('Devoir calendrier test'); + + const editorContent = page.locator('.modal .rich-text-content'); + await expect(editorContent).toBeVisible({ timeout: 10000 }); + await editorContent.click(); + await page.keyboard.type('Test description'); + + const picker = page.locator('.modal .calendar-date-picker'); + await picker.locator('.picker-trigger').click(); + await expect(picker.locator('.calendar-dropdown')).toBeVisible({ timeout: 3000 }); + + // Find a valid weekday button (not disabled, not weekend, not before-min) + const validDays = picker.locator('.day-cell:not(.weekend):not(.blocked):not(.before-min):not(.empty):enabled'); + const validCount = await validDays.count(); + expect(validCount).toBeGreaterThan(0); + + // Click the last available day (likely to be far enough in the future) + await validDays.last().click(); + + // Dropdown should close + await expect(picker.locator('.calendar-dropdown')).not.toBeVisible({ timeout: 3000 }); + + // Trigger should now show the selected date (not placeholder) + await expect(picker.locator('.picker-value')).toBeVisible(); + }); + + test('can navigate to next/previous month', async ({ page }) => { + await loginAsTeacher(page); + await navigateToHomework(page); + + await page.getByRole('button', { name: /nouveau devoir/i }).click(); + await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 }); + + const picker = page.locator('.modal .calendar-date-picker'); + await picker.locator('.picker-trigger').click(); + await expect(picker.locator('.calendar-dropdown')).toBeVisible({ timeout: 3000 }); + + const monthLabel = picker.locator('.month-label'); + const initialMonth = await monthLabel.textContent(); + + // Navigate to next month + await picker.getByRole('button', { name: /mois suivant/i }).click(); + const nextMonth = await monthLabel.textContent(); + expect(nextMonth).not.toBe(initialMonth); + + // Navigate back + await picker.getByRole('button', { name: /mois précédent/i }).click(); + const backMonth = await monthLabel.textContent(); + expect(backMonth).toBe(initialMonth); + }); + + test('can create homework using calendar date picker (hidden input fallback)', async ({ page }) => { + await loginAsTeacher(page); + await navigateToHomework(page); + + await page.getByRole('button', { name: /nouveau devoir/i }).click(); + await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 }); + + await page.locator('#hw-class').selectOption({ index: 1 }); + await page.locator('#hw-subject').selectOption({ index: 1 }); + await page.locator('#hw-title').fill('Devoir via calendrier'); + + const editorContent = page.locator('.modal .rich-text-content'); + await expect(editorContent).toBeVisible({ timeout: 10000 }); + await editorContent.click(); + await page.keyboard.type('Description du devoir'); + + // Use hidden date input for programmatic date selection (E2E compatibility) + await page.locator('#hw-due-date').fill(getNextWeekday(5)); + + await page.getByRole('button', { name: /créer le devoir/i }).click(); + await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 }); + await expect(page.getByText('Devoir via calendrier')).toBeVisible({ timeout: 10000 }); + }); + + test('edit modal shows calendar date picker with current date selected', async ({ page }) => { + await loginAsTeacher(page); + await navigateToHomework(page); + + // Create homework first via hidden input + await page.getByRole('button', { name: /nouveau devoir/i }).click(); + await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 }); + + await page.locator('#hw-class').selectOption({ index: 1 }); + await page.locator('#hw-subject').selectOption({ index: 1 }); + await page.locator('#hw-title').fill('Devoir edit calendrier'); + + const editorContent = page.locator('.modal .rich-text-content'); + await expect(editorContent).toBeVisible({ timeout: 10000 }); + await editorContent.click(); + await page.keyboard.type('Description'); + + await page.locator('#hw-due-date').fill(getNextWeekday(5)); + await page.getByRole('button', { name: /créer le devoir/i }).click(); + await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 }); + + // Open edit modal + const hwCard = page.locator('.homework-card', { hasText: 'Devoir edit calendrier' }); + await hwCard.getByRole('button', { name: /modifier/i }).click(); + await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 }); + + // Edit modal should have calendar picker with date displayed + const editPicker = page.locator('.modal .calendar-date-picker'); + await expect(editPicker).toBeVisible({ timeout: 5000 }); + await expect(editPicker.locator('.picker-value')).toBeVisible(); + }); + + test('rule-hard blocked dates show colored dots in calendar', async ({ page }) => { + // Seed a homework rule (minimum_delay=30 days, hard mode) so dates within 30 days are blocked. + // Using 30 days ensures the current month always has blocked weekdays visible. + const rulesJson = '[{\\"type\\":\\"minimum_delay\\",\\"params\\":{\\"days\\":30}}]'; + runSql( + `INSERT INTO homework_rules (id, tenant_id, rules, enforcement_mode, enabled, created_at, updated_at) ` + + `VALUES (gen_random_uuid(), '${TENANT_ID}', '${rulesJson}'::jsonb, 'hard', true, NOW(), NOW()) ` + + `ON CONFLICT (tenant_id) DO UPDATE SET rules = '${rulesJson}'::jsonb, enforcement_mode = 'hard', enabled = true, updated_at = NOW()` + ); + clearCache(); + + await loginAsTeacher(page); + await navigateToHomework(page); + + await page.getByRole('button', { name: /nouveau devoir/i }).click(); + await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 }); + + const picker = page.locator('.modal .calendar-date-picker'); + await picker.locator('.picker-trigger').click(); + await expect(picker.locator('.calendar-dropdown')).toBeVisible({ timeout: 3000 }); + + // Navigate to next month to guarantee blocked dates are visible + await picker.getByRole('button', { name: /mois suivant/i }).click(); + + // Days within the 30-day delay window should be rule-blocked + const blockedWithDot = picker.locator('.day-cell.blocked .blocked-dot'); + await expect(blockedWithDot.first()).toBeVisible({ timeout: 5000 }); + + // Legend should show "Règle (bloquant)" + await expect(picker.locator('.calendar-legend')).toContainText('Règle (bloquant)'); + }); + + test('blocked dates (holidays) show colored dots in calendar', async ({ page }) => { + // Seed a holiday entry covering a guaranteed weekday next month + const { academicYearId } = resolveDeterministicIds(); + const nextMonth = new Date(); + nextMonth.setMonth(nextMonth.getMonth() + 1); + // Start from the 10th and find the first weekday (Mon-Fri) + let holidayDay = 10; + const probe = new Date(nextMonth.getFullYear(), nextMonth.getMonth(), holidayDay); + while (probe.getDay() === 0 || probe.getDay() === 6) { + holidayDay++; + probe.setDate(holidayDay); + } + const holidayDate = `${nextMonth.getFullYear()}-${String(nextMonth.getMonth() + 1).padStart(2, '0')}-${String(holidayDay).padStart(2, '0')}`; + try { + runSql( + `INSERT INTO school_calendar_entries (id, tenant_id, academic_year_id, entry_type, start_date, end_date, label, description, created_at) ` + + `VALUES (gen_random_uuid(), '${TENANT_ID}', '${academicYearId}', 'holiday', '${holidayDate}', '${holidayDate}', 'Jour férié E2E', NULL, NOW())` + ); + } catch { + // May already exist + } + clearCache(); + + await loginAsTeacher(page); + await navigateToHomework(page); + + await page.getByRole('button', { name: /nouveau devoir/i }).click(); + await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 }); + + const picker = page.locator('.modal .calendar-date-picker'); + await picker.locator('.picker-trigger').click(); + await expect(picker.locator('.calendar-dropdown')).toBeVisible({ timeout: 3000 }); + + // Navigate to next month + await picker.getByRole('button', { name: /mois suivant/i }).click(); + + // The holiday day should be blocked with a colored dot + const holidayCell = picker.getByRole('gridcell', { name: String(holidayDay), exact: true }); + await expect(holidayCell).toBeVisible({ timeout: 5000 }); + await expect(holidayCell).toBeDisabled(); + await expect(holidayCell.locator('.blocked-dot')).toBeVisible(); + + // Legend should show "Jour férié" + await expect(picker.locator('.calendar-legend')).toContainText('Jour férié'); + }); +}); diff --git a/frontend/e2e/homework-exception.spec.ts b/frontend/e2e/homework-exception.spec.ts index a32ff0d..1e1a1bc 100644 --- a/frontend/e2e/homework-exception.spec.ts +++ b/frontend/e2e/homework-exception.spec.ts @@ -297,8 +297,8 @@ test.describe('Homework Exception Request (Story 5.6)', () => { .fill('Justification suffisamment longue pour être valide.'); await exceptionDialog.getByRole('button', { name: /créer avec exception/i }).click(); - // Wait for homework to appear - await expect(page.getByText('Devoir avec badge')).toBeVisible({ timeout: 10000 }); + // Wait for homework to appear (Firefox needs more time after exception flow) + await expect(page.getByText('Devoir avec badge')).toBeVisible({ timeout: 20000 }); // Exception badge visible (⚠ Exception text or rule override badge) const card = page.locator('.homework-card', { hasText: 'Devoir avec badge' }); diff --git a/frontend/e2e/homework-wysiwyg.spec.ts b/frontend/e2e/homework-wysiwyg.spec.ts new file mode 100644 index 0000000..4996a47 --- /dev/null +++ b/frontend/e2e/homework-wysiwyg.spec.ts @@ -0,0 +1,316 @@ +import { test, expect } from '@playwright/test'; +import { execSync } from 'child_process'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const baseUrl = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:4173'; +const urlMatch = baseUrl.match(/:(\d+)$/); +const PORT = urlMatch ? urlMatch[1] : '4173'; +const ALPHA_URL = `http://ecole-alpha.classeo.local:${PORT}`; + +const TEACHER_EMAIL = 'e2e-homework-teacher@example.com'; +const TEACHER_PASSWORD = 'HomeworkTest123'; +const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'; + +const projectRoot = join(__dirname, '../..'); +const composeFile = join(projectRoot, 'compose.yaml'); + +function runSql(sql: string) { + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "${sql}" 2>&1`, + { encoding: 'utf-8' } + ); +} + +function clearCache() { + try { + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear paginated_queries.cache 2>&1`, + { encoding: 'utf-8' } + ); + } catch { + // Cache pool may not exist + } +} + +function resolveDeterministicIds(): { schoolId: string; academicYearId: string } { + const output = execSync( + `docker compose -f "${composeFile}" exec -T php php -r '` + + `require "/app/vendor/autoload.php"; ` + + `$t="${TENANT_ID}"; $ns="6ba7b814-9dad-11d1-80b4-00c04fd430c8"; ` + + `echo Ramsey\\Uuid\\Uuid::uuid5($ns,"school-$t")->toString()."\\n"; ` + + `$m=(int)date("n"); $s=$m>=9?(int)date("Y"):(int)date("Y")-1; $e=$s+1; ` + + `echo Ramsey\\Uuid\\Uuid::uuid5($ns,"$t:$s-$e")->toString();` + + `' 2>&1`, + { encoding: 'utf-8' } + ).trim(); + const [schoolId, academicYearId] = output.split('\n'); + return { schoolId: schoolId!, academicYearId: academicYearId! }; +} + +function getNextWeekday(daysFromNow: number): string { + const date = new Date(); + date.setDate(date.getDate() + daysFromNow); + const day = date.getDay(); + if (day === 0) date.setDate(date.getDate() + 1); + if (day === 6) date.setDate(date.getDate() + 2); + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, '0'); + const d = String(date.getDate()).padStart(2, '0'); + return `${y}-${m}-${d}`; +} + +function seedTeacherAssignments() { + const { academicYearId } = resolveDeterministicIds(); + try { + runSql( + `INSERT INTO teacher_assignments (id, tenant_id, teacher_id, school_class_id, subject_id, academic_year_id, status, start_date, created_at, updated_at) ` + + `SELECT gen_random_uuid(), '${TENANT_ID}', u.id, c.id, s.id, '${academicYearId}', 'active', NOW(), NOW(), NOW() ` + + `FROM users u CROSS JOIN school_classes c CROSS JOIN subjects s ` + + `WHERE u.email = '${TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` + + `AND c.tenant_id = '${TENANT_ID}' ` + + `AND s.tenant_id = '${TENANT_ID}' ` + + `ON CONFLICT DO NOTHING` + ); + } catch { + // May already exist + } +} + +async function loginAsTeacher(page: import('@playwright/test').Page) { + await page.goto(`${ALPHA_URL}/login`); + await page.locator('#email').fill(TEACHER_EMAIL); + await page.locator('#password').fill(TEACHER_PASSWORD); + await Promise.all([ + page.waitForURL(/\/dashboard/, { timeout: 60000 }), + page.getByRole('button', { name: /se connecter/i }).click() + ]); +} + +async function navigateToHomework(page: import('@playwright/test').Page) { + await page.goto(`${ALPHA_URL}/dashboard/teacher/homework`); + await expect(page.getByRole('heading', { name: /mes devoirs/i })).toBeVisible({ timeout: 15000 }); +} + +test.describe('WYSIWYG Editor & Backward Compatibility (Story 5.9/5.11)', () => { + test.beforeAll(async () => { + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${TEACHER_EMAIL} --password=${TEACHER_PASSWORD} --role=ROLE_PROF 2>&1`, + { encoding: 'utf-8' } + ); + + const { schoolId, academicYearId } = resolveDeterministicIds(); + try { + runSql( + `INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) ` + + `VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E-HW-6A', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` + ); + runSql( + `INSERT INTO subjects (id, tenant_id, school_id, name, code, status, created_at, updated_at) ` + + `VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', 'E2E-HW-Maths', 'E2EMAT', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` + ); + } catch { + // May already exist + } + + seedTeacherAssignments(); + clearCache(); + }); + + test.beforeEach(async () => { + try { + runSql(`DELETE FROM submission_attachments WHERE submission_id IN (SELECT hs.id FROM homework_submissions hs JOIN homework h ON hs.homework_id = h.id WHERE h.tenant_id = '${TENANT_ID}' AND h.teacher_id IN (SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}'))`); + } catch { /* Table may not exist */ } + try { + runSql(`DELETE FROM homework_submissions WHERE homework_id IN (SELECT id FROM homework WHERE tenant_id = '${TENANT_ID}' AND teacher_id IN (SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}'))`); + } catch { /* Table may not exist */ } + try { + runSql(`DELETE FROM homework WHERE tenant_id = '${TENANT_ID}' AND teacher_id IN (SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}')`); + } catch { /* Table may not exist */ } + try { + runSql(`UPDATE homework_rules SET enabled = false, updated_at = NOW() WHERE tenant_id = '${TENANT_ID}'`); + } catch { /* Table may not exist */ } + try { + runSql(`DELETE FROM school_calendar_entries WHERE tenant_id = '${TENANT_ID}'`); + } catch { /* Table may not exist */ } + clearCache(); + }); + + test.describe('WYSIWYG Editor', () => { + test('create form shows rich text editor with toolbar', async ({ page }) => { + await loginAsTeacher(page); + await navigateToHomework(page); + + await page.getByRole('button', { name: /nouveau devoir/i }).click(); + await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 }); + + const editor = page.locator('.rich-text-editor'); + await expect(editor).toBeVisible({ timeout: 5000 }); + await expect(page.locator('.toolbar')).toBeVisible(); + + await expect(page.getByRole('button', { name: 'Gras' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Italique' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Liste à puces' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Liste numérotée' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Lien' })).toBeVisible(); + }); + + test('can create homework with rich text description', async ({ page }) => { + await loginAsTeacher(page); + await navigateToHomework(page); + + await page.getByRole('button', { name: /nouveau devoir/i }).click(); + await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 }); + + await page.locator('#hw-class').selectOption({ index: 1 }); + await page.locator('#hw-subject').selectOption({ index: 1 }); + await page.locator('#hw-title').fill('Devoir texte riche'); + + const editorContent = page.locator('.modal .rich-text-content'); + await expect(editorContent).toBeVisible({ timeout: 10000 }); + await editorContent.click(); + await page.keyboard.type('Consignes importantes'); + + await page.locator('#hw-due-date').fill(getNextWeekday(5)); + await page.getByRole('button', { name: /créer le devoir/i }).click(); + + await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 }); + await expect(page.getByText('Devoir texte riche')).toBeVisible({ timeout: 10000 }); + }); + + test('bold formatting works in editor', async ({ page }) => { + await loginAsTeacher(page); + await navigateToHomework(page); + + await page.getByRole('button', { name: /nouveau devoir/i }).click(); + await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 }); + + await page.locator('#hw-class').selectOption({ index: 1 }); + await page.locator('#hw-subject').selectOption({ index: 1 }); + await page.locator('#hw-title').fill('Devoir gras test'); + + const editorContent = page.locator('.modal .rich-text-content'); + await expect(editorContent).toBeVisible({ timeout: 10000 }); + await editorContent.click(); + await page.keyboard.type('Normal '); + + await page.keyboard.press('Control+b'); + await page.keyboard.type('en gras'); + + await page.locator('#hw-due-date').fill(getNextWeekday(5)); + await page.getByRole('button', { name: /créer le devoir/i }).click(); + + await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 }); + await expect(page.getByText('Devoir gras test')).toBeVisible({ timeout: 10000 }); + + const description = page.locator('.homework-description'); + await expect(description.locator('strong')).toContainText('en gras'); + }); + + test('italic formatting works in editor', async ({ page }) => { + await loginAsTeacher(page); + await navigateToHomework(page); + + await page.getByRole('button', { name: /nouveau devoir/i }).click(); + await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 }); + + await page.locator('#hw-class').selectOption({ index: 1 }); + await page.locator('#hw-subject').selectOption({ index: 1 }); + await page.locator('#hw-title').fill('Devoir italique test'); + + const editorContent = page.locator('.modal .rich-text-content'); + await expect(editorContent).toBeVisible({ timeout: 10000 }); + await editorContent.click(); + await page.keyboard.type('Normal '); + + await page.keyboard.press('Control+i'); + await page.keyboard.type('en italique'); + + await page.locator('#hw-due-date').fill(getNextWeekday(5)); + await page.getByRole('button', { name: /créer le devoir/i }).click(); + + await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 }); + await expect(page.getByText('Devoir italique test')).toBeVisible({ timeout: 10000 }); + + const description = page.locator('.homework-description'); + await expect(description.locator('em')).toContainText('en italique'); + }); + + test('bullet list formatting works in editor', async ({ page }) => { + await loginAsTeacher(page); + await navigateToHomework(page); + + await page.getByRole('button', { name: /nouveau devoir/i }).click(); + await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 }); + + await page.locator('#hw-class').selectOption({ index: 1 }); + await page.locator('#hw-subject').selectOption({ index: 1 }); + await page.locator('#hw-title').fill('Devoir liste test'); + + const editorContent = page.locator('.modal .rich-text-content'); + await expect(editorContent).toBeVisible({ timeout: 10000 }); + await editorContent.click(); + + await editorContent.press('Control+Shift+8'); + await page.keyboard.type('Premier élément'); + + await page.locator('#hw-due-date').fill(getNextWeekday(5)); + await page.getByRole('button', { name: /créer le devoir/i }).click(); + + await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 }); + await expect(page.getByText('Devoir liste test')).toBeVisible({ timeout: 10000 }); + + const description = page.locator('.homework-description'); + await expect(description.locator('ul')).toBeVisible(); + await expect(description.locator('li')).toContainText('Premier élément'); + }); + }); + + test.describe('Backward Compatibility', () => { + test('existing plain text homework displays correctly', async ({ page }) => { + runSql( + `INSERT INTO homework (id, tenant_id, class_id, subject_id, teacher_id, title, description, due_date, status, created_at, updated_at) ` + + `SELECT gen_random_uuid(), '${TENANT_ID}', c.id, s.id, u.id, 'Devoir texte brut E2E', 'Description simple sans balise HTML', '${getNextWeekday(10)}', 'published', NOW(), NOW() ` + + `FROM users u, school_classes c, subjects s ` + + `WHERE u.email = '${TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` + + `AND c.tenant_id = '${TENANT_ID}' AND c.name = 'E2E-HW-6A' ` + + `AND s.tenant_id = '${TENANT_ID}' AND s.name = 'E2E-HW-Maths' ` + + `LIMIT 1` + ); + clearCache(); + + await loginAsTeacher(page); + await navigateToHomework(page); + + await expect(page.getByText('Devoir texte brut E2E')).toBeVisible({ timeout: 10000 }); + await expect(page.getByText('Description simple sans balise HTML')).toBeVisible(); + }); + + test('edit modal loads plain text in WYSIWYG editor', async ({ page }) => { + runSql( + `INSERT INTO homework (id, tenant_id, class_id, subject_id, teacher_id, title, description, due_date, status, created_at, updated_at) ` + + `SELECT gen_random_uuid(), '${TENANT_ID}', c.id, s.id, u.id, 'Devoir edit brut E2E', 'Ancienne description', '${getNextWeekday(10)}', 'published', NOW(), NOW() ` + + `FROM users u, school_classes c, subjects s ` + + `WHERE u.email = '${TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` + + `AND c.tenant_id = '${TENANT_ID}' AND c.name = 'E2E-HW-6A' ` + + `AND s.tenant_id = '${TENANT_ID}' AND s.name = 'E2E-HW-Maths' ` + + `LIMIT 1` + ); + clearCache(); + + await loginAsTeacher(page); + await navigateToHomework(page); + + const hwCard = page.locator('.homework-card', { hasText: 'Devoir edit brut E2E' }); + await hwCard.getByRole('button', { name: /modifier/i }).click(); + await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 }); + + const editorContent = page.locator('.modal .rich-text-content'); + await expect(editorContent).toBeVisible({ timeout: 10000 }); + await expect(editorContent).toContainText('Ancienne description', { timeout: 5000 }); + }); + }); +}); diff --git a/frontend/e2e/role-access-control.spec.ts b/frontend/e2e/role-access-control.spec.ts index b617e10..9e2d5e6 100644 --- a/frontend/e2e/role-access-control.spec.ts +++ b/frontend/e2e/role-access-control.spec.ts @@ -52,6 +52,7 @@ test.describe('Role-Based Access Control [P0]', () => { password: string ) { await page.goto(`${ALPHA_URL}/login`); + await page.waitForLoadState('networkidle'); await page.locator('#email').fill(email); await page.locator('#password').fill(password); await Promise.all([ diff --git a/frontend/e2e/role-assignment.spec.ts b/frontend/e2e/role-assignment.spec.ts new file mode 100644 index 0000000..cc0b2c3 --- /dev/null +++ b/frontend/e2e/role-assignment.spec.ts @@ -0,0 +1,167 @@ +import { test, expect } from '@playwright/test'; +import { execSync } from 'child_process'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const baseUrl = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:4173'; +const urlMatch = baseUrl.match(/:(\d+)$/); +const PORT = urlMatch ? urlMatch[1] : '4173'; +const ALPHA_URL = `http://ecole-alpha.classeo.local:${PORT}`; + +const ADMIN_EMAIL = 'e2e-roles-admin@example.com'; +const ADMIN_PASSWORD = 'RolesAdmin123'; +const TARGET_EMAIL = `e2e-roles-target-${Date.now()}@example.com`; +const TARGET_PASSWORD = 'RolesTarget123'; + +test.describe('Multi-Role Assignment (FR5) [P2]', () => { + test.describe.configure({ mode: 'serial' }); + + test.beforeAll(async () => { + const projectRoot = join(__dirname, '../..'); + const composeFile = join(projectRoot, 'compose.yaml'); + + // Create admin user + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${ADMIN_EMAIL} --password=${ADMIN_PASSWORD} --role=ROLE_ADMIN 2>&1`, + { encoding: 'utf-8' } + ); + + // Create target user with single role (PROF) + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${TARGET_EMAIL} --password=${TARGET_PASSWORD} --role=ROLE_PROF 2>&1`, + { encoding: 'utf-8' } + ); + }); + + async function loginAsAdmin(page: import('@playwright/test').Page) { + await page.goto(`${ALPHA_URL}/login`); + await page.locator('#email').fill(ADMIN_EMAIL); + await page.locator('#password').fill(ADMIN_PASSWORD); + await Promise.all([ + page.waitForURL(/\/dashboard/, { timeout: 60000 }), + page.getByRole('button', { name: /se connecter/i }).click() + ]); + } + + async function openRolesModalForTarget(page: import('@playwright/test').Page) { + await page.goto(`${ALPHA_URL}/admin/users`); + await expect(page.locator('.users-table')).toBeVisible({ timeout: 10000 }); + + // Search for the target user (paginated list may not show them on page 1) + await page.getByRole('searchbox').fill(TARGET_EMAIL); + await page.waitForTimeout(500); // debounce + + // Find the target user row and click "Rôles" button + const targetRow = page.locator('tr', { has: page.locator(`text=${TARGET_EMAIL}`) }); + await expect(targetRow).toBeVisible({ timeout: 10000 }); + await targetRow.getByRole('button', { name: 'Rôles' }).click(); + + // Modal should appear + await expect(page.getByRole('dialog')).toBeVisible(); + await expect(page.locator('#roles-modal-title')).toHaveText('Modifier les rôles'); + } + + test('[P2] admin can open role modal showing current roles', async ({ page }) => { + await loginAsAdmin(page); + await openRolesModalForTarget(page); + + // Target user email should be displayed in modal + await expect(page.locator('.roles-modal-user')).toContainText(TARGET_EMAIL); + + // ROLE_PROF should be checked (current role) + const profCheckbox = page.locator('.role-checkbox-label', { hasText: 'Enseignant' }).locator('input[type="checkbox"]'); + await expect(profCheckbox).toBeChecked(); + + // Other roles should be unchecked + const adminCheckbox = page.locator('.role-checkbox-label', { hasText: 'Directeur' }).locator('input[type="checkbox"]'); + await expect(adminCheckbox).not.toBeChecked(); + }); + + test('[P2] admin can assign multiple roles to a user', async ({ page }) => { + await loginAsAdmin(page); + await openRolesModalForTarget(page); + + // Add Vie Scolaire role in addition to PROF + const vieScolaireLabel = page.locator('.role-checkbox-label', { hasText: 'Vie Scolaire' }); + await vieScolaireLabel.locator('input[type="checkbox"]').check(); + + // Both should now be checked + const profCheckbox = page.locator('.role-checkbox-label', { hasText: 'Enseignant' }).locator('input[type="checkbox"]'); + await expect(profCheckbox).toBeChecked(); + await expect(vieScolaireLabel.locator('input[type="checkbox"]')).toBeChecked(); + + // Save + const saveResponsePromise = page.waitForResponse( + (resp) => resp.url().includes('/roles') && resp.request().method() === 'PUT' + ); + await page.getByRole('button', { name: 'Enregistrer' }).click(); + const saveResponse = await saveResponsePromise; + expect(saveResponse.status()).toBeLessThan(400); + + // Success message should appear + await expect(page.locator('.alert-success')).toContainText(/rôles.*mis à jour/i, { timeout: 5000 }); + }); + + test('[P2] assigned roles persist after page reload', async ({ page }) => { + await loginAsAdmin(page); + await openRolesModalForTarget(page); + + // Both PROF and VIE_SCOLAIRE should still be checked after reload + const profCheckbox = page.locator('.role-checkbox-label', { hasText: 'Enseignant' }).locator('input[type="checkbox"]'); + const vieScolaireCheckbox = page.locator('.role-checkbox-label', { hasText: 'Vie Scolaire' }).locator('input[type="checkbox"]'); + + await expect(profCheckbox).toBeChecked(); + await expect(vieScolaireCheckbox).toBeChecked(); + }); + + test('[P2] admin can remove a role while keeping at least one', async ({ page }) => { + await loginAsAdmin(page); + await openRolesModalForTarget(page); + + // Uncheck Vie Scolaire (added in previous test) + const vieScolaireCheckbox = page.locator('.role-checkbox-label', { hasText: 'Vie Scolaire' }).locator('input[type="checkbox"]'); + await vieScolaireCheckbox.uncheck(); + + // PROF should still be checked + const profCheckbox = page.locator('.role-checkbox-label', { hasText: 'Enseignant' }).locator('input[type="checkbox"]'); + await expect(profCheckbox).toBeChecked(); + await expect(vieScolaireCheckbox).not.toBeChecked(); + + // Save + const saveResponsePromise = page.waitForResponse( + (resp) => resp.url().includes('/roles') && resp.request().method() === 'PUT' + ); + await page.getByRole('button', { name: 'Enregistrer' }).click(); + await saveResponsePromise; + + await expect(page.locator('.alert-success')).toContainText(/rôles.*mis à jour/i, { timeout: 5000 }); + }); + + test('[P2] last role checkbox is disabled to prevent removal', async ({ page }) => { + await loginAsAdmin(page); + await openRolesModalForTarget(page); + + // Only PROF should be checked now (after previous test removed VIE_SCOLAIRE) + const profCheckbox = page.locator('.role-checkbox-label', { hasText: 'Enseignant' }).locator('input[type="checkbox"]'); + await expect(profCheckbox).toBeChecked(); + + // Last role checkbox should be disabled + await expect(profCheckbox).toBeDisabled(); + + // "(dernier rôle)" hint should be visible + await expect( + page.locator('.role-checkbox-label', { hasText: 'Enseignant' }).locator('.role-checkbox-hint') + ).toContainText('dernier rôle'); + }); + + test('[P2] role modal can be closed with Escape', async ({ page }) => { + await loginAsAdmin(page); + await openRolesModalForTarget(page); + + await page.getByRole('dialog').press('Escape'); + await expect(page.getByRole('dialog')).not.toBeVisible(); + }); +}); diff --git a/frontend/e2e/sessions.spec.ts b/frontend/e2e/sessions.spec.ts index 6c8256c..1382b29 100644 --- a/frontend/e2e/sessions.spec.ts +++ b/frontend/e2e/sessions.spec.ts @@ -264,14 +264,15 @@ test.describe('Sessions Management', () => { await login(page, email); await page.goto(getTenantUrl('/settings')); + await page.waitForLoadState('networkidle'); // Click logout button const logoutButton = page.getByRole('button', { name: /d[eé]connexion/i }); await expect(logoutButton).toBeVisible(); - await logoutButton.click(); - - // Wait for redirect to login - await expect(page).toHaveURL(/login/, { timeout: 10000 }); + await Promise.all([ + page.waitForURL(/login/, { timeout: 30000 }), + logoutButton.click() + ]); }); test('logout clears authentication', async ({ page, browserName }, testInfo) => { @@ -288,8 +289,8 @@ test.describe('Sessions Management', () => { await expect(logoutButton).toBeVisible(); await logoutButton.click(); - // Wait for redirect to login - await expect(page).toHaveURL(/login/, { timeout: 10000 }); + // Wait for redirect to login (Firefox can be slow to redirect) + await expect(page).toHaveURL(/login/, { timeout: 20000 }); // Try to access protected page await page.goto(getTenantUrl('/settings/sessions')); diff --git a/frontend/e2e/settings.spec.ts b/frontend/e2e/settings.spec.ts index 806f872..f0743ae 100644 --- a/frontend/e2e/settings.spec.ts +++ b/frontend/e2e/settings.spec.ts @@ -153,10 +153,11 @@ test.describe('Settings Page [P1]', () => { await login(page, email); await page.goto(getTenantUrl('/settings')); + await page.waitForLoadState('networkidle'); await expect( page.getByRole('link', { name: /tableau de bord/i }) - ).toBeVisible(); + ).toBeVisible({ timeout: 10000 }); }); test('[P1] settings layout shows Parametres navigation link as active', async ({ page }, testInfo) => { diff --git a/frontend/e2e/student-creation.spec.ts b/frontend/e2e/student-creation.spec.ts index 3a68161..adc5eeb 100644 --- a/frontend/e2e/student-creation.spec.ts +++ b/frontend/e2e/student-creation.spec.ts @@ -484,7 +484,7 @@ test.describe('Student Creation & Management (Story 3.0)', () => { // Should find the student (use .first() because AC3 duplicate test creates a second one) await expect( page.locator('td', { hasText: `Dupont-${UNIQUE_SUFFIX}` }).first() - ).toBeVisible({ timeout: 10000 }); + ).toBeVisible({ timeout: 20000 }); }); test('rows are clickable and navigate to student detail', async ({ page }) => { diff --git a/frontend/e2e/student-schedule.spec.ts b/frontend/e2e/student-schedule.spec.ts index dee412b..6e96a28 100644 --- a/frontend/e2e/student-schedule.spec.ts +++ b/frontend/e2e/student-schedule.spec.ts @@ -481,7 +481,7 @@ test.describe('Student Schedule Consultation (Story 4.3)', () => { await navigateToSeededDay(page); const firstSlot = page.locator('[data-testid="schedule-slot"]').first(); - await expect(firstSlot).toBeVisible({ timeout: 15000 }); + await expect(firstSlot).toBeVisible({ timeout: 20000 }); await firstSlot.click(); const dialog = page.getByRole('dialog'); diff --git a/frontend/e2e/super-admin-provisioning.spec.ts b/frontend/e2e/super-admin-provisioning.spec.ts new file mode 100644 index 0000000..dd0b52a --- /dev/null +++ b/frontend/e2e/super-admin-provisioning.spec.ts @@ -0,0 +1,205 @@ +import { test, expect } from '@playwright/test'; +import { execSync } from 'child_process'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const projectRoot = join(__dirname, '../..'); +const composeFile = join(projectRoot, 'compose.yaml'); + +const baseUrl = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:4173'; +const urlMatch = baseUrl.match(/:(\d+)$/); +const PORT = urlMatch ? urlMatch[1] : '4173'; +const ALPHA_URL = `http://ecole-alpha.classeo.local:${PORT}`; + +const SA_PASSWORD = 'SuperAdmin123'; +const UNIQUE_SUFFIX = Date.now(); + +function getSuperAdminEmail(browserName: string): string { + return `e2e-prov-sa-${browserName}@test.com`; +} + +// eslint-disable-next-line no-empty-pattern +test.beforeAll(async ({}, testInfo) => { + const browserName = testInfo.project.name; + const saEmail = getSuperAdminEmail(browserName); + + try { + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-super-admin --email=${saEmail} --password=${SA_PASSWORD} 2>&1`, + { encoding: 'utf-8' } + ); + } catch (error) { + console.error(`[${browserName}] Failed to create super admin:`, error); + } +}); + +async function loginAsSuperAdmin( + page: import('@playwright/test').Page, + email: string +) { + await page.goto(`${ALPHA_URL}/login`); + await expect(page.getByRole('heading', { name: /connexion/i })).toBeVisible(); + + await page.locator('#email').fill(email); + await page.locator('#password').fill(SA_PASSWORD); + + const submitButton = page.getByRole('button', { name: /se connecter/i }); + await Promise.all([ + page.waitForURL('**/super-admin/dashboard', { timeout: 30000 }), + submitButton.click() + ]); +} + +async function navigateToEstablishments(page: import('@playwright/test').Page) { + const link = page.getByRole('link', { name: /établissements/i }); + await Promise.all([ + page.waitForURL('**/super-admin/establishments', { timeout: 10000 }), + link.click() + ]); + await expect(page.getByRole('heading', { name: /établissements/i })).toBeVisible({ timeout: 10000 }); +} + +async function navigateToCreateForm(page: import('@playwright/test').Page) { + const newLink = page.getByRole('link', { name: /nouvel établissement/i }); + await expect(newLink).toBeVisible({ timeout: 10000 }); + await Promise.all([ + page.waitForURL('**/super-admin/establishments/new', { timeout: 10000 }), + newLink.click() + ]); + await expect(page.locator('#name')).toBeVisible({ timeout: 10000 }); +} + +test.describe('Establishment Provisioning (Story 2-17) [P1]', () => { + test.describe.configure({ mode: 'serial' }); + + test.describe('Subdomain Auto-generation', () => { + test('[P1] typing name auto-generates subdomain', async ({ page }, testInfo) => { + const email = getSuperAdminEmail(testInfo.project.name); + await loginAsSuperAdmin(page, email); + await navigateToEstablishments(page); + await navigateToCreateForm(page); + + await page.locator('#name').fill('École Saint-Exupéry'); + + // Subdomain should be auto-generated: accents removed, spaces→hyphens, lowercase + await expect(page.locator('#subdomain')).toHaveValue('ecole-saint-exupery'); + }); + + test('[P2] subdomain suffix .classeo.fr is displayed', async ({ page }, testInfo) => { + const email = getSuperAdminEmail(testInfo.project.name); + await loginAsSuperAdmin(page, email); + await navigateToEstablishments(page); + await navigateToCreateForm(page); + + await expect(page.locator('.subdomain-suffix')).toHaveText('.classeo.fr'); + }); + }); + + test.describe('Create Establishment Flow', () => { + const establishmentName = `E2E Test ${UNIQUE_SUFFIX}`; + const adminEmailForEstab = `admin-prov-${UNIQUE_SUFFIX}@test.com`; + + test('[P1] submitting form creates establishment and redirects to list', async ({ page }, testInfo) => { + const email = getSuperAdminEmail(testInfo.project.name); + await loginAsSuperAdmin(page, email); + await navigateToEstablishments(page); + await navigateToCreateForm(page); + + // Fill in the form + await page.locator('#name').fill(establishmentName); + await page.locator('#adminEmail').fill(adminEmailForEstab); + + // Subdomain should be auto-generated + const subdomain = await page.locator('#subdomain').inputValue(); + expect(subdomain.length).toBeGreaterThan(0); + + // Submit + const submitButton = page.getByRole('button', { name: /créer l'établissement/i }); + await expect(submitButton).toBeEnabled(); + + const apiResponsePromise = page.waitForResponse( + (resp) => resp.url().includes('/super-admin/establishments') && resp.request().method() === 'POST' + ); + + await submitButton.click(); + + // Verify API returns establishment in provisioning status + const apiResponse = await apiResponsePromise; + expect(apiResponse.status()).toBeLessThan(400); + const body = await apiResponse.json(); + expect(body.status).toBe('provisioning'); + + // Should redirect back to establishments list + await page.waitForURL('**/super-admin/establishments', { timeout: 15000 }); + await expect(page.getByRole('heading', { name: /établissements/i })).toBeVisible({ timeout: 10000 }); + }); + + test('[P1] created establishment appears in the list', async ({ page }, testInfo) => { + const email = getSuperAdminEmail(testInfo.project.name); + await loginAsSuperAdmin(page, email); + await navigateToEstablishments(page); + + // The establishment created in previous test should be visible + await expect(page.locator('table')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('td', { hasText: establishmentName })).toBeVisible({ timeout: 10000 }); + }); + + test('[P1] created establishment has a visible status badge', async ({ page }, testInfo) => { + const email = getSuperAdminEmail(testInfo.project.name); + await loginAsSuperAdmin(page, email); + await navigateToEstablishments(page); + + // Find the row for our establishment + const row = page.locator('tr', { has: page.locator(`text=${establishmentName}`) }); + await expect(row).toBeVisible({ timeout: 10000 }); + + // Status badge should be visible (provisioning status already verified via API response in creation test) + const badge = row.locator('.badge'); + await expect(badge).toBeVisible(); + await expect(badge).not.toHaveText(''); + }); + }); + + test.describe('Form Validation', () => { + test('[P2] submit button disabled with empty fields', async ({ page }, testInfo) => { + const email = getSuperAdminEmail(testInfo.project.name); + await loginAsSuperAdmin(page, email); + await navigateToEstablishments(page); + await navigateToCreateForm(page); + + const submitButton = page.getByRole('button', { name: /créer l'établissement/i }); + await expect(submitButton).toBeDisabled(); + }); + + test('[P2] submit button enabled when all fields filled', async ({ page }, testInfo) => { + const email = getSuperAdminEmail(testInfo.project.name); + await loginAsSuperAdmin(page, email); + await navigateToEstablishments(page); + await navigateToCreateForm(page); + + await page.locator('#name').fill('Test School'); + await page.locator('#adminEmail').fill('admin@test.com'); + + const submitButton = page.getByRole('button', { name: /créer l'établissement/i }); + await expect(submitButton).toBeEnabled(); + }); + + test('[P2] cancel button returns to establishments list', async ({ page }, testInfo) => { + const email = getSuperAdminEmail(testInfo.project.name); + await loginAsSuperAdmin(page, email); + await navigateToEstablishments(page); + await navigateToCreateForm(page); + + const cancelLink = page.getByRole('link', { name: /annuler/i }); + await Promise.all([ + page.waitForURL('**/super-admin/establishments', { timeout: 10000 }), + cancelLink.click() + ]); + + await expect(page.getByRole('heading', { name: /établissements/i })).toBeVisible(); + }); + }); +}); diff --git a/frontend/e2e/teacher-replacements.spec.ts b/frontend/e2e/teacher-replacements.spec.ts index 90dd3c7..ab9947f 100644 --- a/frontend/e2e/teacher-replacements.spec.ts +++ b/frontend/e2e/teacher-replacements.spec.ts @@ -53,6 +53,7 @@ function resolveDeterministicIds(): { schoolId: string; academicYearId: string } async function loginAsAdmin(page: import('@playwright/test').Page) { await page.goto(`${ALPHA_URL}/login`); + await page.waitForLoadState('networkidle'); await page.locator('#email').fill(ADMIN_EMAIL); await page.locator('#password').fill(ADMIN_PASSWORD); await Promise.all([ diff --git a/frontend/e2e/teacher-statistics.spec.ts b/frontend/e2e/teacher-statistics.spec.ts new file mode 100644 index 0000000..e475fbc --- /dev/null +++ b/frontend/e2e/teacher-statistics.spec.ts @@ -0,0 +1,330 @@ +import { test, expect } from '@playwright/test'; +import { execWithRetry, runSql, resolveDeterministicIds, createTestUser, composeFile } from './helpers'; + +const baseUrl = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:4173'; +const urlMatch = baseUrl.match(/:(\d+)$/); +const PORT = urlMatch ? urlMatch[1] : '4173'; +const ALPHA_URL = `http://ecole-alpha.classeo.local:${PORT}`; +const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'; + +async function loginAs(page: import('@playwright/test').Page, email: string, password: string) { + await page.goto(`${ALPHA_URL}/login`); + await page.locator('#email').fill(email); + await page.locator('#password').fill(password); + await Promise.all([ + page.waitForURL(/\/dashboard/, { timeout: 60000 }), + page.getByRole('button', { name: /se connecter/i }).click() + ]); +} + +function querySql(sql: string): string { + return execWithRetry( + `docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "${sql}" 2>&1` + ); +} + +// ========================================================================= +// Smoke tests — navigation only, no data dependency +// ========================================================================= + +test.describe('Teacher Statistics — Navigation (Story 6.8)', () => { + const TEACHER_EMAIL = 'e2e-stats-teacher@example.com'; + const TEACHER_PASSWORD = 'StatsTest123'; + + test.beforeAll(async () => { + createTestUser('ecole-alpha', TEACHER_EMAIL, TEACHER_PASSWORD, 'ROLE_PROF'); + }); + + test('should display statistics page with navigation link', async ({ page }) => { + await loginAs(page, TEACHER_EMAIL, TEACHER_PASSWORD); + + const statsLink = page.getByRole('link', { name: /statistiques/i }); + await expect(statsLink).toBeVisible({ timeout: 10000 }); + + await statsLink.click(); + await expect(page).toHaveURL(/\/dashboard\/teacher\/statistics/); + await expect(page.getByRole('heading', { name: /mes statistiques/i })).toBeVisible({ timeout: 15000 }); + }); + + test('should show overview or empty state', async ({ page }) => { + await loginAs(page, TEACHER_EMAIL, TEACHER_PASSWORD); + await page.goto(`${ALPHA_URL}/dashboard/teacher/statistics`); + + await expect(page.getByRole('heading', { name: /mes statistiques/i })).toBeVisible({ timeout: 15000 }); + await expect(page.getByText('Chargement des statistiques...')).not.toBeVisible({ timeout: 15000 }); + + // Page must show either class cards or the "Aucune donnée" empty state + const hasCards = await page.getByRole('button', { name: /moyenne/i }).count(); + const hasEmptyState = await page.getByText('Aucune donnée statistique').count(); + expect(hasCards + hasEmptyState).toBeGreaterThan(0); + }); +}); + +// ========================================================================= +// Data-driven tests with seeded evaluations and grades +// ========================================================================= + +test.describe('Teacher Statistics — Data-Driven (Story 6.8)', () => { + const DATA_TEACHER_EMAIL = 'e2e-stats-data-teacher@example.com'; + const DATA_TEACHER_PASSWORD = 'StatsData123'; + + let classId: string; + let subjectId: string; + let teacherId: string; + + test.beforeAll(async () => { + createTestUser('ecole-alpha', DATA_TEACHER_EMAIL, DATA_TEACHER_PASSWORD, 'ROLE_PROF'); + + const { academicYearId } = resolveDeterministicIds(TENANT_ID); + + // Resolve teacher ID + const teacherOutput = querySql( + `SELECT id FROM users WHERE email = '${DATA_TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}'` + ); + teacherId = teacherOutput.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/)?.[0] ?? ''; + + // Find existing class + classId = querySql( + `SELECT id FROM school_classes WHERE tenant_id = '${TENANT_ID}' LIMIT 1` + ).match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/)?.[0] ?? ''; + + // Find existing subject + subjectId = querySql( + `SELECT id FROM subjects WHERE tenant_id = '${TENANT_ID}' LIMIT 1` + ).match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/)?.[0] ?? ''; + + if (!teacherId || !classId || !subjectId) return; + + // Ensure at least 3 students in the class for grade diversity + const testStudents = [ + { email: 'e2e-stats-student-a@example.com', firstName: 'Alice', lastName: 'Stats' }, + { email: 'e2e-stats-student-b@example.com', firstName: 'Bob', lastName: 'Stats' }, + { email: 'e2e-stats-student-c@example.com', firstName: 'Charlie', lastName: 'Stats' }, + ]; + for (const { email, firstName, lastName } of testStudents) { + createTestUser('ecole-alpha', email, 'StatsStudent123', 'ROLE_ELEVE'); + try { + runSql( + `UPDATE users SET first_name = '${firstName}', last_name = '${lastName}' ` + + `WHERE email = '${email}' AND tenant_id = '${TENANT_ID}' AND first_name = ''` + ); + } catch { /* best effort */ } + const sidOutput = querySql( + `SELECT id FROM users WHERE email = '${email}' AND tenant_id = '${TENANT_ID}'` + ); + const sid = sidOutput.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/)?.[0]; + if (sid) { + try { + runSql( + `INSERT INTO class_assignments (id, tenant_id, user_id, school_class_id, academic_year_id, created_at, updated_at) ` + + `VALUES (gen_random_uuid(), '${TENANT_ID}', '${sid}', '${classId}', '${academicYearId}', NOW(), NOW()) ` + + `ON CONFLICT DO NOTHING` + ); + } catch { /* may exist */ } + } + } + + // Create teacher assignment + try { + runSql( + `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(), '${TENANT_ID}', '${teacherId}', '${classId}', '${subjectId}', '${academicYearId}', 'active', NOW(), NOW(), NOW()) ` + + `ON CONFLICT DO NOTHING` + ); + } catch { /* may exist */ } + + // Create a published evaluation with grades + try { + const evalIdOutput = querySql(`SELECT gen_random_uuid()::text`); + const evalId = evalIdOutput.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/)?.[0] ?? ''; + + runSql( + `INSERT INTO evaluations (id, tenant_id, class_id, subject_id, teacher_id, title, evaluation_date, grade_scale, coefficient, status, grades_published_at, created_at, updated_at) ` + + `VALUES ('${evalId}', '${TENANT_ID}', '${classId}', '${subjectId}', '${teacherId}', 'E2E Stats Eval', CURRENT_DATE - INTERVAL '7 days', 20, 1.0, 'published', NOW(), NOW(), NOW()) ` + + `ON CONFLICT DO NOTHING` + ); + + // Get students in the class (class_assignments stores student-class links) + const studentOutput = querySql( + `SELECT ca.user_id FROM class_assignments ca WHERE ca.school_class_id = '${classId}' AND ca.tenant_id = '${TENANT_ID}' LIMIT 3` + ); + const studentIds = [...studentOutput.matchAll(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g)].map(m => m[0]); + + // Find current academic period for student_averages + const periodOutput = querySql( + `SELECT id FROM academic_periods WHERE tenant_id = '${TENANT_ID}' AND start_date <= CURRENT_DATE AND end_date >= CURRENT_DATE LIMIT 1` + ); + const periodId = periodOutput.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/)?.[0] ?? ''; + + const grades = [15.0, 7.0, 12.0]; + studentIds.forEach((sid, i) => { + const grade = grades[i] ?? 10.0; + try { + runSql( + `INSERT INTO grades (id, tenant_id, evaluation_id, student_id, value, status, created_by, created_at, updated_at) ` + + `VALUES (gen_random_uuid(), '${TENANT_ID}', '${evalId}', '${sid}', ${grade}, 'graded', '${teacherId}', NOW(), NOW()) ` + + `ON CONFLICT DO NOTHING` + ); + // Populate student_averages so difficulty badges work + if (periodId) { + runSql( + `INSERT INTO student_averages (id, tenant_id, student_id, subject_id, period_id, average, grade_count, updated_at) ` + + `VALUES (gen_random_uuid(), '${TENANT_ID}', '${sid}', '${subjectId}', '${periodId}', ${grade}, 1, NOW()) ` + + `ON CONFLICT DO NOTHING` + ); + } + } catch { /* may exist */ } + }); + } catch { /* may already exist */ } + }); + + test.afterAll(() => { + // Cleanup seeded data + if (teacherId) { + try { + runSql(`DELETE FROM student_averages WHERE tenant_id = '${TENANT_ID}' AND subject_id = '${subjectId}'`); + runSql(`DELETE FROM grades WHERE tenant_id = '${TENANT_ID}' AND created_by = '${teacherId}'`); + runSql(`DELETE FROM evaluations WHERE tenant_id = '${TENANT_ID}' AND teacher_id = '${teacherId}'`); + runSql(`DELETE FROM teacher_assignments WHERE tenant_id = '${TENANT_ID}' AND teacher_id = '${teacherId}'`); + } catch { /* best effort cleanup */ } + } + }); + + test('should display class cards with evaluation and student counts', async ({ page }) => { + await loginAs(page, DATA_TEACHER_EMAIL, DATA_TEACHER_PASSWORD); + await page.goto(`${ALPHA_URL}/dashboard/teacher/statistics`); + + await expect(page.getByRole('heading', { name: /mes statistiques/i })).toBeVisible({ timeout: 15000 }); + await expect(page.getByText('Chargement des statistiques...')).not.toBeVisible({ timeout: 15000 }); + + // Class card is a button containing "Moyenne" stat label + const classCard = page.locator('button.class-card').first(); + await expect(classCard).toBeVisible({ timeout: 10000 }); + + // Card should contain key stat labels + await expect(classCard.getByText('Moyenne')).toBeVisible(); + await expect(classCard.getByText('Évaluations')).toBeVisible(); + await expect(classCard.getByText('Élèves')).toBeVisible(); + }); + + test('should show export and print buttons in class detail', async ({ page }) => { + await loginAs(page, DATA_TEACHER_EMAIL, DATA_TEACHER_PASSWORD); + await page.goto(`${ALPHA_URL}/dashboard/teacher/statistics`); + + await expect(page.getByRole('heading', { name: /mes statistiques/i })).toBeVisible({ timeout: 15000 }); + await expect(page.getByText('Chargement des statistiques...')).not.toBeVisible({ timeout: 15000 }); + + // Click first class card — data is seeded so it must exist + const classCard = page.locator('button.class-card').first(); + await expect(classCard).toBeVisible({ timeout: 10000 }); + await classCard.click(); + + // Detail view buttons + await expect(page.getByRole('button', { name: /exporter csv/i })).toBeVisible({ timeout: 10000 }); + await expect(page.getByRole('button', { name: /imprimer/i })).toBeVisible(); + await expect(page.getByRole('button', { name: /retour/i })).toBeVisible(); + }); + + test('should navigate back from detail to overview', async ({ page }) => { + await loginAs(page, DATA_TEACHER_EMAIL, DATA_TEACHER_PASSWORD); + await page.goto(`${ALPHA_URL}/dashboard/teacher/statistics`); + + await expect(page.getByRole('heading', { name: /mes statistiques/i })).toBeVisible({ timeout: 15000 }); + await expect(page.getByText('Chargement des statistiques...')).not.toBeVisible({ timeout: 15000 }); + + const classCard = page.locator('button.class-card').first(); + await expect(classCard).toBeVisible({ timeout: 10000 }); + await classCard.click(); + + await expect(page.getByRole('button', { name: /retour/i })).toBeVisible({ timeout: 10000 }); + await page.getByRole('button', { name: /retour/i }).click(); + + await expect(page.getByRole('heading', { name: /mes statistiques/i })).toBeVisible(); + }); + + test('should show class detail with student table and histogram', async ({ page }) => { + await loginAs(page, DATA_TEACHER_EMAIL, DATA_TEACHER_PASSWORD); + await page.goto(`${ALPHA_URL}/dashboard/teacher/statistics`); + + await expect(page.getByRole('heading', { name: /mes statistiques/i })).toBeVisible({ timeout: 15000 }); + await expect(page.getByText('Chargement des statistiques...')).not.toBeVisible({ timeout: 15000 }); + + const classCard = page.locator('button.class-card').first(); + await expect(classCard).toBeVisible({ timeout: 10000 }); + await classCard.click(); + + await expect(page.getByRole('button', { name: /retour/i })).toBeVisible({ timeout: 10000 }); + + // Histogram section + await expect(page.locator('.histogram')).toBeVisible({ timeout: 10000 }); + + // Student table with headers + await expect(page.getByRole('columnheader', { name: 'Élève' })).toBeVisible(); + await expect(page.getByRole('columnheader', { name: 'Moyenne' })).toBeVisible(); + await expect(page.getByRole('columnheader', { name: 'Statut' })).toBeVisible(); + + // At least one student row + const studentRows = page.locator('table.student-table tbody tr'); + const count = await studentRows.count(); + expect(count).toBeGreaterThan(0); + }); + + test('should show difficulty indicators for struggling students', async ({ page }) => { + await loginAs(page, DATA_TEACHER_EMAIL, DATA_TEACHER_PASSWORD); + await page.goto(`${ALPHA_URL}/dashboard/teacher/statistics`); + + await expect(page.getByRole('heading', { name: /mes statistiques/i })).toBeVisible({ timeout: 15000 }); + await expect(page.getByText('Chargement des statistiques...')).not.toBeVisible({ timeout: 15000 }); + + const classCard = page.locator('button.class-card').first(); + await expect(classCard).toBeVisible({ timeout: 10000 }); + await classCard.click(); + await expect(page.getByRole('button', { name: /retour/i })).toBeVisible({ timeout: 10000 }); + + // Student with grade 7.0 (below 8.0 threshold) should have "En difficulté" badge + await expect(page.getByText('En difficulté')).toBeVisible({ timeout: 10000 }); + }); + + test('should trigger CSV export with correct response headers', async ({ page }) => { + await loginAs(page, DATA_TEACHER_EMAIL, DATA_TEACHER_PASSWORD); + await page.goto(`${ALPHA_URL}/dashboard/teacher/statistics`); + + await expect(page.getByRole('heading', { name: /mes statistiques/i })).toBeVisible({ timeout: 15000 }); + await expect(page.getByText('Chargement des statistiques...')).not.toBeVisible({ timeout: 15000 }); + + const classCard = page.locator('button.class-card').first(); + await expect(classCard).toBeVisible({ timeout: 10000 }); + await classCard.click(); + await expect(page.getByRole('button', { name: /retour/i })).toBeVisible({ timeout: 10000 }); + + const exportButton = page.getByRole('button', { name: /exporter csv/i }); + await expect(exportButton).toBeVisible({ timeout: 10000 }); + + const downloadPromise = page.waitForEvent('download', { timeout: 15000 }); + await exportButton.click(); + const download = await downloadPromise; + + expect(download.suggestedFilename()).toContain('.csv'); + }); + + test('should navigate to student progression view', async ({ page }) => { + await loginAs(page, DATA_TEACHER_EMAIL, DATA_TEACHER_PASSWORD); + await page.goto(`${ALPHA_URL}/dashboard/teacher/statistics`); + + await expect(page.getByRole('heading', { name: /mes statistiques/i })).toBeVisible({ timeout: 15000 }); + await expect(page.getByText('Chargement des statistiques...')).not.toBeVisible({ timeout: 15000 }); + + const classCard = page.locator('button.class-card').first(); + await expect(classCard).toBeVisible({ timeout: 10000 }); + await classCard.click(); + await expect(page.getByRole('button', { name: /retour/i })).toBeVisible({ timeout: 10000 }); + + // Click a student who has grades (test student "Alice Stats" has grade data) + const studentLink = page.getByRole('button', { name: /Alice Stats/i }); + await expect(studentLink).toBeVisible({ timeout: 10000 }); + await studentLink.click(); + + // Progression view should show chart with proper ARIA label + await expect(page.getByRole('img', { name: /progression/i })).toBeVisible({ timeout: 10000 }); + }); +}); diff --git a/frontend/e2e/token-edge-cases.spec.ts b/frontend/e2e/token-edge-cases.spec.ts index e38ba6c..c3eac46 100644 --- a/frontend/e2e/token-edge-cases.spec.ts +++ b/frontend/e2e/token-edge-cases.spec.ts @@ -95,7 +95,7 @@ test.describe('Expired/Invalid Token Scenarios [P0]', () => { await page.goto(`/activate/${token}`); const form = page.locator('form'); - await expect(form).toBeVisible({ timeout: 5000 }); + await expect(form).toBeVisible({ timeout: 15000 }); // Fill valid password (must include special char for 5/5 requirements) await page.locator('#password').fill('SecurePass123!'); diff --git a/frontend/src/lib/components/molecules/CalendarDatePicker/CalendarDatePicker.svelte b/frontend/src/lib/components/molecules/CalendarDatePicker/CalendarDatePicker.svelte new file mode 100644 index 0000000..63c2c7d --- /dev/null +++ b/frontend/src/lib/components/molecules/CalendarDatePicker/CalendarDatePicker.svelte @@ -0,0 +1,563 @@ + + + +
+ {#if id} + onSelect((e.target as HTMLInputElement).value)} + class="sr-only-input" + tabindex={-1} + aria-hidden="true" + /> + {/if} + + + {#if isOpen} + + {/if} +
+ + diff --git a/frontend/src/lib/components/molecules/FileUpload/FileUpload.svelte b/frontend/src/lib/components/molecules/FileUpload/FileUpload.svelte index 7c4d466..e800025 100644 --- a/frontend/src/lib/components/molecules/FileUpload/FileUpload.svelte +++ b/frontend/src/lib/components/molecules/FileUpload/FileUpload.svelte @@ -34,6 +34,7 @@ let files = $state(existingFiles); let pendingFiles = $state<{ name: string; size: number }[]>([]); let error = $state(null); + let isDragging = $state(false); let fileInput: HTMLInputElement; $effect(() => { @@ -52,6 +53,15 @@ return '📎'; } + function formatFileType(mimeType: string): string { + if (mimeType === 'application/pdf') return 'PDF'; + if (mimeType === 'image/jpeg') return 'JPEG'; + if (mimeType === 'image/png') return 'PNG'; + if (mimeType.includes('wordprocessingml')) return 'DOCX'; + const parts = mimeType.split('/'); + return parts[1]?.toUpperCase() ?? mimeType; + } + function validateFile(file: File): string | null { if (!acceptedTypes.includes(file.type)) { return `Type de fichier non accepté : ${file.type}.`; @@ -62,14 +72,10 @@ return null; } - async function handleFileSelect(event: Event) { - const input = event.target as HTMLInputElement; - const selectedFiles = input.files; - if (!selectedFiles || selectedFiles.length === 0) return; - + async function processFiles(fileList: globalThis.FileList) { error = null; - for (const file of selectedFiles) { + for (const file of fileList) { const validationError = validateFile(file); if (validationError) { error = validationError; @@ -87,10 +93,37 @@ pendingFiles = pendingFiles.filter((p) => p.name !== file.name); } } + } + async function handleFileSelect(event: Event) { + const input = event.target as HTMLInputElement; + const selectedFiles = input.files; + if (!selectedFiles || selectedFiles.length === 0) return; + + await processFiles(selectedFiles); input.value = ''; } + function handleDragOver(event: DragEvent) { + event.preventDefault(); + isDragging = true; + } + + function handleDragLeave(event: DragEvent) { + const target = event.currentTarget as HTMLElement; + const related = event.relatedTarget as globalThis.Node | null; + if (related && target.contains(related)) return; + isDragging = false; + } + + async function handleDrop(event: DragEvent) { + event.preventDefault(); + isDragging = false; + const droppedFiles = event.dataTransfer?.files; + if (!droppedFiles || droppedFiles.length === 0) return; + await processFiles(droppedFiles); + } + async function handleDelete(fileId: string) { error = null; @@ -114,6 +147,7 @@
  • {getFileIcon(file.mimeType)} {file.filename} + {formatFileType(file.mimeType)} {formatFileSize(file.fileSize)} {#if !disabled && showDelete} +

    + Glissez-déposez vos fichiers ici ou + +

    +

    {hint}

    + -

    {hint}

    {/if} @@ -216,6 +266,16 @@ white-space: nowrap; } + .file-type { + color: #6b7280; + font-size: 0.6875rem; + font-weight: 500; + padding: 0.0625rem 0.375rem; + background: #f3f4f6; + border-radius: 0.25rem; + flex-shrink: 0; + } + .file-size { color: #9ca3af; font-size: 0.75rem; @@ -249,25 +309,51 @@ color: #dc2626; } - .upload-btn { - display: inline-flex; + .drop-zone { + display: flex; + flex-direction: column; align-items: center; - gap: 0.375rem; - padding: 0.5rem 0.75rem; - border: 1px dashed #d1d5db; - border-radius: 0.375rem; - background: white; - color: #374151; - font-size: 0.875rem; + gap: 0.5rem; + padding: 1.5rem; + border: 2px dashed #d1d5db; + border-radius: 0.5rem; + background: #fafafa; + color: #6b7280; cursor: pointer; transition: border-color 0.15s, background-color 0.15s; - align-self: flex-start; } - .upload-btn:hover { - border-color: #3b82f6; + .drop-zone:hover { + border-color: #93c5fd; background: #eff6ff; + } + + .drop-zone-active { + border-color: #3b82f6; + background: #dbeafe; + } + + .drop-zone-text { + margin: 0; + font-size: 0.875rem; + color: #6b7280; + text-align: center; + } + + .drop-zone-browse { + display: inline; + padding: 0; + border: none; + background: none; color: #2563eb; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + text-decoration: underline; + } + + .drop-zone-browse:hover { + color: #1d4ed8; } .file-input-hidden { diff --git a/frontend/src/lib/components/organisms/Dashboard/DashboardTeacher.svelte b/frontend/src/lib/components/organisms/Dashboard/DashboardTeacher.svelte index deab3cf..804141b 100644 --- a/frontend/src/lib/components/organisms/Dashboard/DashboardTeacher.svelte +++ b/frontend/src/lib/components/organisms/Dashboard/DashboardTeacher.svelte @@ -25,13 +25,23 @@ endDate: string; } + interface QuickStats { + totalClasses: number; + totalEvaluations: number; + totalStudents: number; + globalAverage: number | null; + } + let replacedClasses = $state([]); let replacementsLoading = $state(false); + let quickStats = $state(null); + let statsLoading = $state(false); $effect(() => { untrack(() => { if (isAuthenticated()) { loadReplacedClasses(); + loadQuickStats(); } }); }); @@ -53,6 +63,35 @@ } } + async function loadQuickStats() { + try { + statsLoading = true; + const apiUrl = getApiBaseUrl(); + const response = await authenticatedFetch(`${apiUrl}/me/statistics`); + if (!response.ok) return; + const data = await response.json(); + const classes: { evaluationCount: number; studentCount: number; average: number | null }[] = data.classes ?? []; + if (classes.length === 0) return; + + const totalEvaluations = classes.reduce((s, c) => s + (c.evaluationCount ?? 0), 0); + const averages = classes.map(c => c.average).filter((a): a is number => a != null); + const globalAverage = averages.length > 0 + ? Math.round((averages.reduce((s, a) => s + a, 0) / averages.length) * 100) / 100 + : null; + + quickStats = { + totalClasses: classes.length, + totalEvaluations, + totalStudents: classes.reduce((s, c) => s + (c.studentCount ?? 0), 0), + globalAverage, + }; + } catch { + // Non-critical + } finally { + statsLoading = false; + } + } + function daysRemaining(endDate: string): number { const end = new Date(endDate); const now = new Date(); @@ -158,11 +197,31 @@ - {#if hasRealData && isLoading} + {#if statsLoading} + {:else if quickStats} +
    +
    + {quickStats.globalAverage != null ? quickStats.globalAverage.toFixed(1) : '—'} + Moyenne générale +
    +
    + {quickStats.totalClasses} + Classe{quickStats.totalClasses > 1 ? 's' : ''} +
    +
    + {quickStats.totalEvaluations} + Évaluation{quickStats.totalEvaluations > 1 ? 's' : ''} +
    +
    + {quickStats.totalStudents} + Élève{quickStats.totalStudents > 1 ? 's' : ''} +
    +
    + Voir les statistiques détaillées {/if}
    @@ -309,6 +368,49 @@ color: #dc2626; } + /* Quick Stats */ + .quick-stats { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 1rem; + } + + .quick-stat { + display: flex; + flex-direction: column; + align-items: center; + padding: 0.75rem 0.5rem; + background: #f9fafb; + border-radius: 0.5rem; + } + + .quick-stat-value { + font-size: 1.5rem; + font-weight: 700; + color: #1e293b; + } + + .quick-stat-label { + font-size: 0.75rem; + color: #6b7280; + text-transform: uppercase; + letter-spacing: 0.02em; + } + + .stats-link { + display: block; + margin-top: 1rem; + text-align: center; + color: #3b82f6; + font-size: 0.875rem; + font-weight: 500; + text-decoration: none; + } + + .stats-link:hover { + text-decoration: underline; + } + @media (min-width: 768px) { .dashboard-grid { grid-template-columns: repeat(2, 1fr); diff --git a/frontend/src/routes/dashboard/+layout.svelte b/frontend/src/routes/dashboard/+layout.svelte index adf0ee7..5d40c4d 100644 --- a/frontend/src/routes/dashboard/+layout.svelte +++ b/frontend/src/routes/dashboard/+layout.svelte @@ -106,6 +106,7 @@ {#if isProf} Devoirs Évaluations + Mes statistiques {/if} {#if isEleve} Mon EDT @@ -161,6 +162,9 @@ Évaluations + + Mes statistiques + {/if} {#if isEleve} diff --git a/frontend/src/routes/dashboard/teacher/homework/+page.svelte b/frontend/src/routes/dashboard/teacher/homework/+page.svelte index 2e23a06..910d633 100644 --- a/frontend/src/routes/dashboard/teacher/homework/+page.svelte +++ b/frontend/src/routes/dashboard/teacher/homework/+page.svelte @@ -6,6 +6,8 @@ import Pagination from '$lib/components/molecules/Pagination/Pagination.svelte'; import ExceptionRequestModal from '$lib/components/molecules/ExceptionRequestModal/ExceptionRequestModal.svelte'; import RuleBlockedModal from '$lib/components/molecules/RuleBlockedModal/RuleBlockedModal.svelte'; + import CalendarDatePicker from '$lib/components/molecules/CalendarDatePicker/CalendarDatePicker.svelte'; + import type { BlockedDate } from '$lib/components/molecules/CalendarDatePicker/CalendarDatePicker.svelte'; import FileUpload from '$lib/components/molecules/FileUpload/FileUpload.svelte'; import RichTextEditor from '$lib/components/molecules/RichTextEditor/RichTextEditor.svelte'; import SearchInput from '$lib/components/molecules/SearchInput/SearchInput.svelte'; @@ -86,6 +88,14 @@ let isSubmitting = $state(false); let newPendingFiles = $state([]); + // Blocked dates for calendar + let calendarBlockedDates = $state([]); + + // Edit calendar: rules don't apply (homework already assigned to students) + let editCalendarBlockedDates = $derived( + calendarBlockedDates.filter((d) => !d.type.startsWith('rule_')) + ); + // Attachments let editAttachments = $state([]); @@ -96,6 +106,7 @@ let editDescription = $state(''); let editDueDate = $state(''); let isUpdating = $state(false); + let editError = $state(null); // Delete modal let showDeleteModal = $state(false); @@ -158,7 +169,10 @@ // Load on mount $effect(() => { - untrack(() => loadAll()); + untrack(() => { + loadAll(); + loadBlockedDates(); + }); }); function extractCollection(data: Record): T[] { @@ -168,6 +182,28 @@ return []; } + async function loadBlockedDates() { + try { + const apiUrl = getApiBaseUrl(); + const today = new Date(); + const startDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`; + // Load 3 months ahead (stays within current academic year boundary) + const endDate = new Date(today.getFullYear(), today.getMonth() + 4, 0); + const endDateStr = `${endDate.getFullYear()}-${String(endDate.getMonth() + 1).padStart(2, '0')}-${String(endDate.getDate()).padStart(2, '0')}`; + + const res = await authenticatedFetch( + `${apiUrl}/schedule/blocked-dates?startDate=${startDate}&endDate=${endDateStr}` + ); + + if (res.ok) { + const data = await res.json(); + calendarBlockedDates = extractCollection(data); + } + } catch { + // Silent fail — calendar will work without blocked dates + } + } + async function loadAssignments() { const userId = await getAuthenticatedUserId(); if (!userId) return; @@ -602,6 +638,7 @@ editDescription = hw.description ?? ''; editDueDate = hw.dueDate; editAttachments = []; + editError = null; showEditModal = true; // Charger les pièces jointes existantes en arrière-plan @@ -619,7 +656,7 @@ try { isUpdating = true; - error = null; + editError = null; const apiUrl = getApiBaseUrl(); const response = await authenticatedFetch(`${apiUrl}/homework/${editHomework.id}`, { method: 'PATCH', @@ -644,7 +681,7 @@ closeEditModal(); await loadHomeworks(); } catch (e) { - error = e instanceof Error ? e.message : 'Erreur lors de la modification'; + editError = e instanceof Error ? e.message : 'Erreur lors de la modification'; } finally { isUpdating = false; } @@ -986,14 +1023,14 @@
    - - Date d'échéance * + handleDueDateChange((e.target as HTMLInputElement).value)} - required + onSelect={(date) => handleDueDateChange(date)} min={ruleConformMinDate || minDueDate} + blockedDates={calendarBlockedDates} + disabled={isSubmitting} /> {#if dueDateError} {dueDateError} @@ -1001,8 +1038,6 @@ Date minimale conforme aux règles : {new Date(ruleConformMinDate + 'T00:00:00').toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })} - {:else} - La date doit être au minimum demain, hors jours fériés et vacances {/if}
    @@ -1073,6 +1108,14 @@ handleUpdate(); }} > + {#if editError} +
    + + {editError} + +
    + {/if} +
    Classe : {editHomework.className ?? getClassName(editHomework.classId)} @@ -1103,8 +1146,15 @@
    - - + + (editDueDate = date)} + min={minDueDate} + blockedDates={editCalendarBlockedDates} + disabled={isUpdating} + />
    {#if editHomework} diff --git a/frontend/src/routes/dashboard/teacher/statistics/+page.svelte b/frontend/src/routes/dashboard/teacher/statistics/+page.svelte new file mode 100644 index 0000000..e7a170c --- /dev/null +++ b/frontend/src/routes/dashboard/teacher/statistics/+page.svelte @@ -0,0 +1,785 @@ + + + + Mes statistiques - Classeo + + +
    + + + {#if error} + + {/if} + + + {#if currentView === 'overview'} + {#if isLoading} +
    Chargement des statistiques...
    + {:else if overview.length === 0} +
    +

    Aucune donnée statistique disponible pour la période en cours.

    +

    Les statistiques apparaîtront après la publication de notes.

    +
    + {:else} +
    + {#each overview as cls} + + {/each} +
    + + + {#if evaluationDifficulties.length > 0} +
    +

    Mes évaluations — Analyse de difficulté

    +
    + + + + + + + + + + + + + + {#each evaluationDifficulties as ev} + + + + + + + + + + {/each} + +
    ÉvaluationClasseMatièreDateMoyenneMoy. matièrePercentile
    {ev.title}{ev.className}{ev.subjectName}{ev.date}{ev.average != null ? ev.average.toFixed(1) : '—'}{ev.subjectAverage != null ? ev.subjectAverage.toFixed(1) : '—'}{ev.percentile != null ? `${ev.percentile.toFixed(0)}%` : '—'}
    +
    +
    + {/if} + {/if} + {/if} + + + {#if currentView === 'class-detail'} + {#if isLoadingDetail} +
    Chargement du détail...
    + {:else if classDetail} +
    + +
    +
    + {classDetail.average != null ? classDetail.average.toFixed(2) : '—'} + Moyenne de classe +
    +
    + {classDetail.successRate.toFixed(0)}% + Taux de réussite (≥ 10/20) +
    +
    + + +
    +

    Répartition des notes

    +
    + {#each classDetail.distribution as count, i} +
    +
    + {#if count > 0}{count}{/if} +
    + {distributionLabels[i]} +
    + {/each} +
    +
    + + + {#if classDetail.evolution.length >= 2} +
    +

    Évolution sur l'année

    + +
    + {:else} +
    +

    Évolution sur l'année

    +

    Pas assez de données pour afficher l'évolution (minimum 2 mois).

    +
    + {/if} + + +
    +

    Moyennes par élève

    +
    + + + + + + + + + + + {#each classDetail.students as student} + + + + + + + {/each} + +
    ÉlèveMoyenneStatutTendance
    + + {student.average != null ? student.average.toFixed(2) : '—'} + {#if student.inDifficulty} + En difficulté + {:else} + OK + {/if} + + {trendIcon(student.trend)} +
    +
    +
    +
    + {/if} + {/if} + + + {#if currentView === 'student-progression'} + {#if isLoadingProgression} +
    Chargement de la progression...
    + {:else if studentProgression && studentProgression.grades.length > 0} +
    +

    Notes sur l'année

    + +
    + {#each progGrades as g} +
    + {g.date} + {g.evaluationTitle} + {g.value.toFixed(1)}/20 +
    + {/each} +
    +
    + {:else} +
    Aucune note publiée pour cet élève dans cette matière.
    + {/if} + {/if} +
    + + diff --git a/frontend/src/routes/super-admin/establishments/+page.svelte b/frontend/src/routes/super-admin/establishments/+page.svelte index 5e57483..4eaf9f0 100644 --- a/frontend/src/routes/super-admin/establishments/+page.svelte +++ b/frontend/src/routes/super-admin/establishments/+page.svelte @@ -71,8 +71,16 @@ {establishment.subdomain} - - {establishment.status === 'active' ? 'Actif' : 'Inactif'} + + {establishment.status === 'active' + ? 'Actif' + : establishment.status === 'provisioning' + ? 'Provisioning…' + : 'Inactif'} @@ -207,6 +215,11 @@ color: #16a34a; } + .badge.provisioning { + background: #fef3c7; + color: #d97706; + } + .actions-cell { white-space: nowrap; }