From 7a18c33a1fdab46a1166f51722f38e372eb30a2c Mon Sep 17 00:00:00 2001 From: Arnaud de Mouhy Date: Thu, 3 Oct 2024 15:43:43 +0200 Subject: [PATCH] feat: execute ffta sync each night, and send result by email --- composer.json | 5 +- composer.lock | 208 +++++++++++++++++- config/packages/flysystem.yaml | 10 + config/packages/messenger.yaml | 14 +- config/packages/security.yaml | 3 +- docker-compose.override.yml | 10 +- docker-compose.yml | 22 +- docker/build/image_build.sh | 1 - docker/config/cron.d/crowdnews | 1 - docker/config/cron.d/symfony | 1 - docker/config/supervisor/supervisord.conf | 7 - docker/post_build/entrypoint.sh | 20 +- src/DBAL/Types/UserRoleType.php | 4 +- src/Entity/License.php | 8 +- src/Entity/Licensee.php | 8 +- src/Entity/User.php | 2 +- src/Helper/EmailHelper.php | 36 ++- src/Helper/FftaHelper.php | 48 +++- src/Helper/ObjectComparator.php | 46 ++++ src/Helper/SyncReturnValues.php | 11 + src/Repository/UserRepository.php | 21 ++ src/Scheduler/FftaLicenseesProvider.php | 43 ++++ .../Handler/SyncFftaLicenseesHandler.php | 24 ++ src/Scheduler/Message/SyncFftaLicensees.php | 28 +++ src/Scrapper/FftaScrapper.php | 15 +- .../updated_licensees.txt.twig | 19 ++ tests/integration/Helper/FftaHelperTest.php | 93 ++++++++ .../Helper/FftaHelperTestDataLoader.php | 79 +++++++ 28 files changed, 723 insertions(+), 64 deletions(-) delete mode 100644 docker/config/cron.d/crowdnews delete mode 100644 docker/config/cron.d/symfony create mode 100644 src/Helper/ObjectComparator.php create mode 100644 src/Helper/SyncReturnValues.php create mode 100644 src/Scheduler/FftaLicenseesProvider.php create mode 100644 src/Scheduler/Handler/SyncFftaLicenseesHandler.php create mode 100644 src/Scheduler/Message/SyncFftaLicensees.php create mode 100644 templates/email_notification/updated_licensees.txt.twig create mode 100644 tests/integration/Helper/FftaHelperTest.php create mode 100644 tests/integration/Helper/FftaHelperTestDataLoader.php diff --git a/composer.json b/composer.json index fb542f0..8d91598 100644 --- a/composer.json +++ b/composer.json @@ -24,6 +24,7 @@ "doctrine/doctrine-bundle": "^2.5", "doctrine/doctrine-migrations-bundle": "^3.2", "doctrine/orm": "^2.11", + "dragonmantank/cron-expression": "^3.3", "easycorp/easyadmin-bundle": "^4.1", "fresh/doctrine-enum-bundle": "^9.0", "gedmo/doctrine-extensions": "^3.9", @@ -32,6 +33,7 @@ "knpuniversity/oauth2-client-bundle": "^2.10", "league/flysystem-async-aws-s3": "^3.0", "league/flysystem-bundle": "^3.0", + "league/flysystem-memory": "^3.29", "moneyphp/money": "^4.0", "nelmio/cors-bundle": "^2.2", "phpdocumentor/reflection-docblock": "^5.3", @@ -62,6 +64,7 @@ "symfony/proxy-manager-bridge": "6.4.*", "symfony/runtime": "6.4.*", "symfony/scaleway-mailer": "6.4.*", + "symfony/scheduler": "6.4.*", "symfony/security-bundle": "6.4.*", "symfony/serializer": "6.4.*", "symfony/stimulus-bundle": "^2.11", @@ -143,7 +146,7 @@ "require-dev": { "dama/doctrine-test-bundle": "^7.1", "doctrine/doctrine-fixtures-bundle": "^3.4", - "fakerphp/faker": "^1.20", + "fakerphp/faker": "^1.23", "friendsofphp/php-cs-fixer": "^3.14", "hautelook/alice-bundle": "^2.11", "phpstan/phpstan": "^1.8", diff --git a/composer.lock b/composer.lock index 84aa7ce..35ca5cd 100644 --- a/composer.lock +++ b/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": "cef49560a4e5ec72dd6ece6723b1a5dc", + "content-hash": "e057b05bef5c3c1b23607baa420ec17d", "packages": [ { "name": "api-platform/core", @@ -2344,6 +2344,67 @@ }, "time": "2022-05-23T21:33:49+00:00" }, + { + "name": "dragonmantank/cron-expression", + "version": "v3.3.3", + "source": { + "type": "git", + "url": "https://github.com/dragonmantank/cron-expression.git", + "reference": "adfb1f505deb6384dc8b39804c5065dd3c8c8c0a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/adfb1f505deb6384dc8b39804c5065dd3c8c8c0a", + "reference": "adfb1f505deb6384dc8b39804c5065dd3c8c8c0a", + "shasum": "" + }, + "require": { + "php": "^7.2|^8.0", + "webmozart/assert": "^1.0" + }, + "replace": { + "mtdowling/cron-expression": "^1.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^1.0", + "phpstan/phpstan-webmozart-assert": "^1.0", + "phpunit/phpunit": "^7.0|^8.0|^9.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Cron\\": "src/Cron/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Chris Tankersley", + "email": "chris@ctankersley.com", + "homepage": "https://github.com/dragonmantank" + } + ], + "description": "CRON for PHP: Calculate the next or previous run date and determine if a CRON expression is due", + "keywords": [ + "cron", + "schedule" + ], + "support": { + "issues": "https://github.com/dragonmantank/cron-expression/issues", + "source": "https://github.com/dragonmantank/cron-expression/tree/v3.3.3" + }, + "funding": [ + { + "url": "https://github.com/dragonmantank", + "type": "github" + } + ], + "time": "2023-08-10T19:36:49+00:00" + }, { "name": "easycorp/easyadmin-bundle", "version": "v4.8.6", @@ -4077,6 +4138,54 @@ ], "time": "2023-12-04T10:14:46+00:00" }, + { + "name": "league/flysystem-memory", + "version": "3.29.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem-memory.git", + "reference": "219c79ad8b1d614a58ac17b775bfb3a6b7228126" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem-memory/zipball/219c79ad8b1d614a58ac17b775bfb3a6b7228126", + "reference": "219c79ad8b1d614a58ac17b775bfb3a6b7228126", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "league/flysystem": "^3.0.0", + "php": "^8.0.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\Flysystem\\InMemory\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "In-memory filesystem adapter for Flysystem.", + "keywords": [ + "Flysystem", + "file", + "files", + "filesystem", + "memory" + ], + "support": { + "source": "https://github.com/thephpleague/flysystem-memory/tree/3.29.0" + }, + "time": "2024-08-09T21:24:39+00:00" + }, { "name": "league/mime-type-detection", "version": "1.14.0", @@ -11540,6 +11649,86 @@ ], "time": "2024-01-29T10:46:25+00:00" }, + { + "name": "symfony/scheduler", + "version": "v6.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/scheduler.git", + "reference": "fc2a6adb254a2b150e3ee0bff3e0f9bf8a6af8b0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/scheduler/zipball/fc2a6adb254a2b150e3ee0bff3e0f9bf8a6af8b0", + "reference": "fc2a6adb254a2b150e3ee0bff3e0f9bf8a6af8b0", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/clock": "^6.3|^7.0" + }, + "require-dev": { + "dragonmantank/cron-expression": "^3.1", + "symfony/cache": "^5.4|^6.0|^7.0", + "symfony/console": "^5.4|^6.0|^7.0", + "symfony/dependency-injection": "^5.4|^6.0|^7.0", + "symfony/event-dispatcher": "^5.4|^6.0|^7.0", + "symfony/lock": "^5.4|^6.0|^7.0", + "symfony/messenger": "^6.3|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Scheduler\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Sergey Rabochiy", + "email": "upyx.00@gmail.com" + }, + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides scheduling through Symfony Messenger", + "homepage": "https://symfony.com", + "keywords": [ + "cron", + "schedule", + "scheduler" + ], + "support": { + "source": "https://github.com/symfony/scheduler/tree/v6.4.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-06-01T07:51:32+00:00" + }, { "name": "symfony/security-bundle", "version": "v6.4.0", @@ -14926,16 +15115,16 @@ }, { "name": "fakerphp/faker", - "version": "v1.23.0", + "version": "v1.23.1", "source": { "type": "git", "url": "https://github.com/FakerPHP/Faker.git", - "reference": "e3daa170d00fde61ea7719ef47bb09bb8f1d9b01" + "reference": "bfb4fe148adbf78eff521199619b93a52ae3554b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/FakerPHP/Faker/zipball/e3daa170d00fde61ea7719ef47bb09bb8f1d9b01", - "reference": "e3daa170d00fde61ea7719ef47bb09bb8f1d9b01", + "url": "https://api.github.com/repos/FakerPHP/Faker/zipball/bfb4fe148adbf78eff521199619b93a52ae3554b", + "reference": "bfb4fe148adbf78eff521199619b93a52ae3554b", "shasum": "" }, "require": { @@ -14961,11 +15150,6 @@ "ext-mbstring": "Required for multibyte Unicode string functionality." }, "type": "library", - "extra": { - "branch-alias": { - "dev-main": "v1.21-dev" - } - }, "autoload": { "psr-4": { "Faker\\": "src/Faker/" @@ -14988,9 +15172,9 @@ ], "support": { "issues": "https://github.com/FakerPHP/Faker/issues", - "source": "https://github.com/FakerPHP/Faker/tree/v1.23.0" + "source": "https://github.com/FakerPHP/Faker/tree/v1.23.1" }, - "time": "2023-06-12T08:44:38+00:00" + "time": "2024-01-02T13:46:09+00:00" }, { "name": "friendsofphp/php-cs-fixer", diff --git a/config/packages/flysystem.yaml b/config/packages/flysystem.yaml index 5773b12..ad13c53 100644 --- a/config/packages/flysystem.yaml +++ b/config/packages/flysystem.yaml @@ -20,3 +20,13 @@ flysystem: client: 'scaleway_object_storage_client' bucket: '%env(STORAGE_BUCKET_MYARCHERYCLUB)%' prefix: 'events' + +when@test: + flysystem: + storages: + clubs.logos.storage: + adapter: 'memory' + licensees.storage: + adapter: 'memory' + events.storage: + adapter: 'memory' \ No newline at end of file diff --git a/config/packages/messenger.yaml b/config/packages/messenger.yaml index 62feb28..a6756f8 100644 --- a/config/packages/messenger.yaml +++ b/config/packages/messenger.yaml @@ -23,10 +23,10 @@ framework: # Route your messages to the transports # 'App\Message\YourMessage': async -# when@test: -# framework: -# messenger: -# transports: -# # replace with your transport name here (e.g., my_transport: 'in-memory://') -# # For more Messenger testing tools, see https://github.com/zenstruck/messenger-test -# async: 'in-memory://' +when@test: + framework: + messenger: + transports: + # replace with your transport name here (e.g., my_transport: 'in-memory://') + # For more Messenger testing tools, see https://github.com/zenstruck/messenger-test + async: 'test://' diff --git a/config/packages/security.yaml b/config/packages/security.yaml index a66ca4d..38c0faf 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -53,8 +53,7 @@ security: - { path: ^/, roles: ROLE_USER } role_hierarchy: - ROLE_ADMIN: [ ROLE_COACH, ROLE_USER, ROLE_ALLOWED_TO_SWITCH ] - ROLE_COACH: ROLE_USER + ROLE_ADMIN: [ ROLE_ALLOWED_TO_SWITCH ] when@test: security: diff --git a/docker-compose.override.yml b/docker-compose.override.yml index 1068b2d..e9941d1 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -1,11 +1,17 @@ -version: '3' - services: app: volumes: - .:/app ports: - "8080:80" + + messenger-async: + volumes: + - .:/app + + scheduler-ffta-licensees: + volumes: + - .:/app ###> doctrine/doctrine-bundle ### diff --git a/docker-compose.yml b/docker-compose.yml index ca32f83..1bf1352 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: '3' - services: app: build: @@ -8,6 +6,26 @@ services: environment: - APP_ENV=dev + messenger-async: + build: + context: . + dockerfile: docker/Dockerfile + command: messenger-async + environment: + - APP_ENV=dev + depends_on: + - app + + scheduler-ffta-licensees: + build: + context: . + dockerfile: docker/Dockerfile + command: scheduler-ffta_licensees + environment: + - APP_ENV=dev + depends_on: + - app + ###> doctrine/doctrine-bundle ### database: image: mariadb:${MARIADB_VERSION:-10.11.5} diff --git a/docker/build/image_build.sh b/docker/build/image_build.sh index f893f19..628bf5b 100644 --- a/docker/build/image_build.sh +++ b/docker/build/image_build.sh @@ -22,7 +22,6 @@ apt-get upgrade -y apt-get install -y --no-install-recommends \ tzdata \ ca-certificates \ - cron \ curl \ git \ gosu \ diff --git a/docker/config/cron.d/crowdnews b/docker/config/cron.d/crowdnews deleted file mode 100644 index aebec5e..0000000 --- a/docker/config/cron.d/crowdnews +++ /dev/null @@ -1 +0,0 @@ -* * * * * /bin/true diff --git a/docker/config/cron.d/symfony b/docker/config/cron.d/symfony deleted file mode 100644 index aebec5e..0000000 --- a/docker/config/cron.d/symfony +++ /dev/null @@ -1 +0,0 @@ -* * * * * /bin/true diff --git a/docker/config/supervisor/supervisord.conf b/docker/config/supervisor/supervisord.conf index 57e6ac9..b3ed5e9 100644 --- a/docker/config/supervisor/supervisord.conf +++ b/docker/config/supervisor/supervisord.conf @@ -18,13 +18,6 @@ stdout_logfile_maxbytes=0 stderr_logfile=/dev/stderr stderr_logfile_maxbytes=0 -[program:cron] -command=/usr/sbin/cron -f -L 15 -stdout_logfile=/dev/stdout -stdout_logfile_maxbytes=0 -stderr_logfile=/dev/stderr -stderr_logfile_maxbytes=0 - [program:consumer] command=/usr/bin/php /app/bin/console messenger:consume async user=symfony diff --git a/docker/post_build/entrypoint.sh b/docker/post_build/entrypoint.sh index 038f234..caddfa4 100644 --- a/docker/post_build/entrypoint.sh +++ b/docker/post_build/entrypoint.sh @@ -44,7 +44,7 @@ then fi export APP_ENV=${ORIGINAL_APP_ENV} -if [[ "${APP_ENV}" == "dev" || "${APP_ENV}" == "test" ]]; then +if [[ -z "${1:-}" && ("${APP_ENV}" == "dev" || "${APP_ENV}" == "test") ]]; then mkdir -p ${APP_ROOT_PATH}/{vendor,node_modules,var/log} chown symfony: ${APP_ROOT_PATH}/node_modules chown symfony: ${APP_ROOT_PATH}/vendor @@ -76,8 +76,10 @@ while ! nc -w 1 -vz "${DATABASE_HOST}" "${DATABASE_PORT}"; do sleep 1; done -# Executing migrations -${GOSU} php bin/console doctrine:migrations:migrate --no-interaction +if [[ -z "${1:-}" || "${1:-}" == "sut" ]]; then + # Executing migrations + ${GOSU} php bin/console doctrine:migrations:migrate --no-interaction +fi # System Under Test if [[ "${1:-}" == "sut" ]]; then @@ -141,5 +143,13 @@ if [[ "${1:-}" == "sut" ]]; then exit $TEST_RESULT fi -echo "+ Launching services..." -exec supervisord -c /etc/supervisor/supervisord.conf +if [[ "${1:-}" == "messenger-async" ]]; then + echo "+ Launch bin/console messenger:consume async" + exec /usr/bin/php /app/bin/console messenger:consume async +elif [[ "${1:-}" == "scheduler-ffta_licensees" ]]; then + echo "+ Launch bin/console messenger:consume scheduler_ffta_licensees" + exec /usr/bin/php /app/bin/console messenger:consume scheduler_ffta_licensees +else + echo "+ Launching services..." + exec supervisord -c /etc/supervisor/supervisord.conf +fi diff --git a/src/DBAL/Types/UserRoleType.php b/src/DBAL/Types/UserRoleType.php index 54d5f73..466e929 100644 --- a/src/DBAL/Types/UserRoleType.php +++ b/src/DBAL/Types/UserRoleType.php @@ -11,11 +11,13 @@ final class UserRoleType extends AbstractEnumType { public const USER = 'ROLE_USER'; public const COACH = 'ROLE_COACH'; + public const CLUB_ADMIN = 'ROLE_CLUB_ADMIN'; public const ADMIN = 'ROLE_ADMIN'; protected static array $choices = [ self::USER => 'Utilisateur', self::COACH => 'Entraîneur', - self::ADMIN => 'Admin', + self::CLUB_ADMIN => 'Admin du club', + self::ADMIN => 'Admin système', ]; } diff --git a/src/Entity/License.php b/src/Entity/License.php index 5d14be2..5260af4 100644 --- a/src/Entity/License.php +++ b/src/Entity/License.php @@ -6,6 +6,8 @@ use App\DBAL\Types\LicenseAgeCategoryType; use App\DBAL\Types\LicenseCategoryType; use App\DBAL\Types\LicenseType; +use App\Helper\ObjectComparator; +use App\Helper\SyncReturnValues; use App\Repository\LicenseRepository; use DH\Auditor\Provider\Doctrine\Auditing\Annotation\Auditable; use Doctrine\ORM\Mapping as ORM; @@ -133,13 +135,17 @@ public function setActivities(array $activities): self return $this; } - public function mergeWith(self $license): void + public function mergeWith(self $license): SyncReturnValues { + $syncResult = ObjectComparator::equal($this, $license) ? SyncReturnValues::UNTOUCHED : SyncReturnValues::UPDATED; + $this->setActivities($license->getActivities()); $this->setAgeCategory($license->getAgeCategory()); $this->setCategory($license->getCategory()); $this->setSeason($license->getSeason()); $this->setType($license->getType()); + + return $syncResult; } public function getClub(): ?Club diff --git a/src/Entity/Licensee.php b/src/Entity/Licensee.php index 2920eb2..50973eb 100644 --- a/src/Entity/Licensee.php +++ b/src/Entity/Licensee.php @@ -4,6 +4,8 @@ use ApiPlatform\Metadata\ApiResource; use App\DBAL\Types\LicenseeAttachmentType; +use App\Helper\ObjectComparator; +use App\Helper\SyncReturnValues; use App\Repository\LicenseeRepository; use DH\Auditor\Provider\Doctrine\Auditing\Annotation\Auditable; use Doctrine\Common\Collections\ArrayCollection; @@ -534,14 +536,18 @@ public function removeGivenPracticeAdvice( return $this; } - public function mergeWith(self $licensee): void + public function mergeWith(self $licensee): SyncReturnValues { + $syncResult = ObjectComparator::equal($this, $licensee) ? SyncReturnValues::UNTOUCHED : SyncReturnValues::UPDATED; + $this->setGender($licensee->getGender()); $this->setLastname($licensee->getLastname()); $this->setFirstname($licensee->getFirstname()); $this->setBirthdate($licensee->getBirthdate()); $this->setFftaMemberCode($licensee->getFftaMemberCode()); $this->setFftaId($licensee->getFftaId()); + + return $syncResult; } /** diff --git a/src/Entity/User.php b/src/Entity/User.php index df5fbe0..8415985 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -166,7 +166,7 @@ public function setPassword(string $password): self /** * @see UserInterface */ - public function eraseCredentials() + public function eraseCredentials(): void { // If you store any temporary, sensitive data on the user, clear it here // $this->plainPassword = null; diff --git a/src/Helper/EmailHelper.php b/src/Helper/EmailHelper.php index c92e3e2..bc0539f 100644 --- a/src/Helper/EmailHelper.php +++ b/src/Helper/EmailHelper.php @@ -4,14 +4,19 @@ use App\Entity\Club; use App\Entity\Licensee; +use App\Entity\User; +use App\Repository\LicenseeRepository; use Symfony\Bridge\Twig\Mime\TemplatedEmail; use Symfony\Component\Mailer\Exception\TransportExceptionInterface; -use Symfony\Component\Mailer\MailerInterface; +use Symfony\Component\Mailer\Transport\TransportInterface; +use Symfony\Component\Mime\Address; -class EmailHelper +readonly class EmailHelper { - public function __construct(protected readonly MailerInterface $mailer) - { + public function __construct( + private TransportInterface $mailer, + private LicenseeRepository $licenseeRepository + ) { } /** @@ -33,4 +38,27 @@ public function sendWelcomeEmail(Licensee $licensee, Club $club): void $this->mailer->send($email); } + + public function sendLicenseesSyncResults(array $toEmails, array $syncResults) + { + $count = \count($syncResults[SyncReturnValues::CREATED->value] + $syncResults[SyncReturnValues::UPDATED->value] + $syncResults[SyncReturnValues::REMOVED->value]); + $added = $this->licenseeRepository->findBy(['fftaId' => $syncResults[SyncReturnValues::CREATED->value]]); + $updated = $this->licenseeRepository->findBy(['fftaId' => $syncResults[SyncReturnValues::UPDATED->value]]); + + $to = array_map(fn (User $user) => new Address($user->getEmail(), $user->getFullname()), $toEmails); + $email = (new TemplatedEmail()) + ->to(...$to) + ->subject('Synchronisation FFTA') + ->text('test') + ->htmlTemplate('email_notification/updated_licensees.txt.twig') + ->textTemplate('email_notification/updated_licensees.txt.twig') + ->locale('fr') + ->context([ + 'count' => $count, + 'added' => $added, + 'updated' => $updated, + ]); + + $this->mailer->send($email); + } } diff --git a/src/Helper/FftaHelper.php b/src/Helper/FftaHelper.php index 7d9e807..e50bfcc 100644 --- a/src/Helper/FftaHelper.php +++ b/src/Helper/FftaHelper.php @@ -24,6 +24,7 @@ use Symfony\Component\Mailer\Exception\TransportExceptionInterface; use Symfony\Component\Mailer\MailerInterface; use Symfony\Component\Mime\MimeTypeGuesserInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; class FftaHelper { @@ -38,14 +39,26 @@ public function __construct( protected FilesystemOperator $licenseesStorage, protected MimeTypeGuesserInterface $mimeTypeGuesser, protected EmailHelper $emailHelper, + protected UserRepository $userRepository, + protected HttpClientInterface $httpClient, ) { $this->mimeTypes = new MimeTypes(); } + public function setHttpClient(HttpClientInterface $httpClient): void + { + $this->httpClient = $httpClient; + } + + public function setUserRepository(UserRepository $userRepository): void + { + $this->userRepository = $userRepository; + } + public function getScrapper(Club $club): FftaScrapper { if (!isset($this->scrappers[$club->getId()])) { - $this->scrappers[$club->getId()] = new FftaScrapper($club); + $this->scrappers[$club->getId()] = new FftaScrapper($club, $this->httpClient); } return $this->scrappers[$club->getId()]; @@ -61,8 +74,9 @@ public function setLogger(LoggerInterface $logger): void * @throws TransportExceptionInterface * @throws \Exception */ - public function syncLicensees(Club $club, int $season): void + public function syncLicensees(Club $club, int $season): array { + $syncResults = array_fill_keys(array_map(fn ($enum) => $enum->value, SyncReturnValues::cases()), []); $scrapper = $this->getScrapper($club); $fftaIds = $scrapper->fetchLicenseeIdList($season); $this->logger->notice( @@ -72,8 +86,16 @@ public function syncLicensees(Club $club, int $season): void foreach ($fftaIds as $fftaId) { $this->logger->notice(sprintf('==== %s ====', $fftaId)); - $this->syncLicenseeWithId($club, $fftaId, $season); + $syncReturn = $this->syncLicenseeWithId($club, $fftaId, $season); + $syncResults[$syncReturn->value][] = $fftaId; + } + + if (!empty($syncResults[SyncReturnValues::CREATED->value]) || !empty($syncResults[SyncReturnValues::UPDATED->value])) { + $managers = $this->userRepository->findByClubAndRole($club, 'ROLE_CLUB_MANAGER', $season); + $this->emailHelper->sendLicenseesSyncResults($managers, $syncResults); } + + return $syncResults; } /** @@ -81,8 +103,9 @@ public function syncLicensees(Club $club, int $season): void * @throws TransportExceptionInterface * @throws \Exception */ - public function syncLicenseeWithId(Club $club, string $fftaId, int $season): Licensee + public function syncLicenseeWithId(Club $club, string $fftaId, int $season): SyncReturnValues { + $syncResult = SyncReturnValues::UNTOUCHED; $scrapper = $this->getScrapper($club); /** @var LicenseeRepository $licenseeRepository */ @@ -94,6 +117,7 @@ public function syncLicenseeWithId(Club $club, string $fftaId, int $season): Lic $fftaProfile = $scrapper->fetchLicenseeProfile($fftaId, $season); $fftaLicensee = LicenseeFactory::createFromFftaProfile($fftaProfile); if (!$licensee) { + $syncResult = SyncReturnValues::CREATED; $this->logger->notice( sprintf( '+ New Licensee: %s (%s)', @@ -141,7 +165,7 @@ public function syncLicenseeWithId(Club $club, string $fftaId, int $season): Lic $licensee->getFftaMemberCode(), ), ); - $licensee->mergeWith($fftaLicensee); + $syncResult = $licensee->mergeWith($fftaLicensee); // TODO check image date (with its filename) instead of downloading files and calculating checksums $fftaProfilePicture = $this->profilePictureAttachmentForLicensee($club, $licensee); $fftaProfilePictureContent = $fftaProfilePicture?->getUploadedFile()?->getContent(); @@ -169,6 +193,7 @@ public function syncLicenseeWithId(Club $club, string $fftaId, int $season): Lic $licensee->addAttachment($fftaProfilePicture); $licensee->setUpdatedAt(new \DateTimeImmutable()); $this->entityManager->persist($fftaProfilePicture); + $syncResult = SyncReturnValues::UPDATED; } else { $this->logger->notice(' = Same profile picture. Not updating.'); } @@ -177,12 +202,14 @@ public function syncLicenseeWithId(Club $club, string $fftaId, int $season): Lic $this->logger->notice(' - Removing profile picture'); $licensee->removeAttachment($dbProfilePicture); $this->entityManager->remove($dbProfilePicture); + $syncResult = SyncReturnValues::UPDATED; } if (!$dbProfilePicture && $fftaProfilePicture) { $this->logger->notice(' + Adding profile picture'); $licensee->addAttachment($fftaProfilePicture); $licensee->setUpdatedAt(new \DateTimeImmutable()); $this->entityManager->persist($fftaProfilePicture); + $syncResult = SyncReturnValues::UPDATED; } if (!$dbProfilePicture && !$fftaProfilePicture) { $this->logger->notice(' ! No profile picture'); @@ -190,9 +217,7 @@ public function syncLicenseeWithId(Club $club, string $fftaId, int $season): Lic } $this->entityManager->flush(); - $this->syncLicenseForLicensee($club, $licensee, $season); - - return $licensee; + return SyncReturnValues::UNTOUCHED === $syncResult ? $this->syncLicenseForLicensee($club, $licensee, $season) : $syncResult; } public function fetchProfilePictureForLicensee(Club $club, Licensee $licensee): ?string @@ -248,7 +273,7 @@ public function profilePictureAttachmentForLicensee(Club $club, Licensee $licens * * @throws \Exception */ - public function syncLicenseForLicensee(Club $club, Licensee $licensee, int $season): License + public function syncLicenseForLicensee(Club $club, Licensee $licensee, int $season): SyncReturnValues { $fftaLicense = $this->createLicenseForLicenseeAndSeason( $club, @@ -261,14 +286,15 @@ public function syncLicenseForLicensee(Club $club, Licensee $licensee, int $seas $license = $fftaLicense; $license->setLicensee($licensee); $this->entityManager->persist($license); + $syncResult = SyncReturnValues::CREATED; } else { $this->logger->notice(sprintf(' ~ Merging existing License for %s', $fftaLicense->getSeason())); - $license->mergeWith($fftaLicense); + $syncResult = $license->mergeWith($fftaLicense); } $this->entityManager->flush(); - return $license; + return $syncResult; } /** diff --git a/src/Helper/ObjectComparator.php b/src/Helper/ObjectComparator.php new file mode 100644 index 0000000..fdce648 --- /dev/null +++ b/src/Helper/ObjectComparator.php @@ -0,0 +1,46 @@ +getProperties(); + $o2Reflected = new \ReflectionObject($o2); + + foreach ($o1Properties as $o1Property) { + $o2Property = $o2Reflected->getProperty($o1Property->getName()); + if (($oldValue = $o1Property->getValue($o1)) != ($newValue = $o2Property->getValue($o2))) { + $diff[$o1Property->getName()] = [ + 'old_value' => $oldValue, + 'new_value' => $newValue, + ]; + } + } + } + + return $diff; + } +} diff --git a/src/Helper/SyncReturnValues.php b/src/Helper/SyncReturnValues.php new file mode 100644 index 0000000..4e119b5 --- /dev/null +++ b/src/Helper/SyncReturnValues.php @@ -0,0 +1,11 @@ +getQuery() ->getOneOrNullResult(); } + + public function findByClubAndRole(Club $club, string $role, int $season = null): array + { + $season ??= Season::seasonForDate(new \DateTimeImmutable()); + + return $this->createQueryBuilder('u') + ->leftJoin('u.licensees', 'l') + ->leftJoin('l.licenses', 'ls') + ->where('u.roles LIKE :role') + ->andWhere('ls.club = :club') + ->andWhere('ls.season = :season') + ->setParameters([ + 'club' => $club, + 'role' => $role, + 'season' => $season, + ]) + ->getQuery() + ->getArrayResult(); + } } diff --git a/src/Scheduler/FftaLicenseesProvider.php b/src/Scheduler/FftaLicenseesProvider.php new file mode 100644 index 0000000..12d0966 --- /dev/null +++ b/src/Scheduler/FftaLicenseesProvider.php @@ -0,0 +1,43 @@ +schedule ??= (new Schedule()) + ->with( + RecurringMessage::cron( + '#midnight', + new CallbackMessageProvider($this->generateSyncLicenseesMessages(...)) + ) + ); + } + + public function generateSyncLicenseesMessages() + { + $season = Season::seasonForDate(new \DateTimeImmutable()); + $clubs = $this->clubRepository->findAll(); + + foreach ($clubs as $club) { + yield new SyncFftaLicensees($club->getId(), $club->getFftaCode(), $season); + } + } +} diff --git a/src/Scheduler/Handler/SyncFftaLicenseesHandler.php b/src/Scheduler/Handler/SyncFftaLicenseesHandler.php new file mode 100644 index 0000000..8a9b466 --- /dev/null +++ b/src/Scheduler/Handler/SyncFftaLicenseesHandler.php @@ -0,0 +1,24 @@ +clubRepository->findOneByCode($message->getClubCode()); + $this->fftaHelper->syncLicensees($club, $message->getSeason()); + } +} diff --git a/src/Scheduler/Message/SyncFftaLicensees.php b/src/Scheduler/Message/SyncFftaLicensees.php new file mode 100644 index 0000000..d77c76b --- /dev/null +++ b/src/Scheduler/Message/SyncFftaLicensees.php @@ -0,0 +1,28 @@ +id; + } + + public function getClubCode(): int + { + return $this->clubCode; + } + + public function getSeason(): int + { + return $this->season; + } +} diff --git a/src/Scrapper/FftaScrapper.php b/src/Scrapper/FftaScrapper.php index d4fa567..18b6e8f 100644 --- a/src/Scrapper/FftaScrapper.php +++ b/src/Scrapper/FftaScrapper.php @@ -19,12 +19,15 @@ use Symfony\Component\HttpFoundation\Exception\BadRequestException; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; +use Symfony\Contracts\HttpClient\HttpClientInterface; class FftaScrapper { + protected HttpClientInterface $managerSpaceHttpClient; protected HttpBrowser $managerSpaceBrowser; protected bool $managerSpaceIsConnected = false; + protected HttpClientInterface $myFftaSpaceHttpClient; protected HttpBrowser $myFftaSpaceBrowser; protected bool $myFftaSpaceIsConnected = false; private string $managerSpaceBaseUrl = 'https://dirigeant.ffta.fr'; @@ -35,11 +38,15 @@ class FftaScrapper private array $cachedResponse; - public function __construct(private readonly Club $club) - { + public function __construct( + private readonly Club $club, + HttpClientInterface $httpClient = null + ) { if (!$this->club->getFftaUsername() || !$this->club->getFftaPassword()) { throw new \Exception('FFTA Credentials not set'); } + $this->managerSpaceHttpClient = $httpClient ? clone $httpClient : HttpClient::create(); + $this->myFftaSpaceHttpClient = $httpClient ? clone $httpClient : HttpClient::create(); $this->defaultParameters = [ 'draw' => '1', @@ -659,7 +666,7 @@ protected function loginManagerSpace(): void if ($this->managerSpaceIsConnected) { return; } - $this->managerSpaceBrowser = new HttpBrowser(HttpClient::create()); + $this->managerSpaceBrowser = new HttpBrowser($this->managerSpaceHttpClient); $crawler = $this->managerSpaceBrowser->request( 'GET', sprintf('%s/auth/login', $this->managerSpaceBaseUrl), @@ -682,7 +689,7 @@ protected function loginMyFftaSpace(): void if ($this->myFftaSpaceIsConnected) { return; } - $this->myFftaSpaceBrowser = new HttpBrowser(HttpClient::create()); + $this->myFftaSpaceBrowser = new HttpBrowser($this->myFftaSpaceHttpClient); $crawler = $this->myFftaSpaceBrowser->request( 'GET', sprintf('%s', $this->myFftaSpaceBaseUrl), diff --git a/templates/email_notification/updated_licensees.txt.twig b/templates/email_notification/updated_licensees.txt.twig new file mode 100644 index 0000000..859d618 --- /dev/null +++ b/templates/email_notification/updated_licensees.txt.twig @@ -0,0 +1,19 @@ +Bonjour, + +Une synchronisation vient d'être effectuée avec la FFTA et {{ count }} licenciés ont été ajoutés ou mis à jour. + +# Ajoutés ({{ added|length }}) + +{% for licensee in added %} +- {{ licensee.fullname }} +{% else %} +Aucun +{% endfor %} + +# Mis à jour ({{ updated|length }}) + +{% for licensee in updated %} +- {{ licensee.fullname }} +{% else %} +Aucun +{% endfor %} \ No newline at end of file diff --git a/tests/integration/Helper/FftaHelperTest.php b/tests/integration/Helper/FftaHelperTest.php new file mode 100644 index 0000000..f2479e3 --- /dev/null +++ b/tests/integration/Helper/FftaHelperTest.php @@ -0,0 +1,93 @@ +setEmail('manager@club.fr') + ->setFirstname('Firstname') + ->setLastname('Lastname'), + ]; + + $mockUserRepository = $this->createMock(UserRepository::class); + $mockUserRepository->expects($this->any()) + ->method('findByClubAndRole') + ->willReturn($clubManagers); + + $baseUrl = 'https://dirigeant.ffta.fr'; + $mockResponses = function ($method, $url, $options): MockResponse { + $url = preg_replace('!^https://dirigeant\.ffta\.fr!', '', $url); + $url = preg_replace('!\?.*$!', '', $url); + if ('/auth/login' === $url) { + if ('GET' === $method) { + return new MockResponse( + '
+ + +
' + ); + } + if ('POST' === $method) { + return new JsonMockResponse(); + } + } + if (1 === preg_match('!/structures/fiche/\d+/licencies/ajax!', $url)) { + $count = 5; + + return new JsonMockResponse([ + 'draw' => 1, + 'data' => $this->getLicensees($count), + 'total' => $count, + ]); + } + if (1 === preg_match('!/personnes/fiche/\d+/infos!', $url)) { + return new MockResponse( + 'Photo de M John Doe' + ); + } + if (1 === preg_match('!/elicence-core/images/default/default_profil_homme.png!', $url)) { + return new MockResponse( + base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAACXBIWXMAAC4jAAAuIwF4pT92AAAADElEQVQImWP4z8AAAAMBAQCc479ZAAAAAElFTkSuQmCC'), + [ + 'response_headers' => [ + 'Content-Type' => 'image/png', + ], + ] + ); + } + throw new \Exception(sprintf('Missing url handling: %s %s', $method, $url)); + }; + + self::bootKernel(); + + $club = (new Club()) + ->setFftaCode('1033093') + ->setFftaUsername('invalid@ffta.fr') + ->setFftaPassword('invalid') + ->setContactEmail('reply@club.invalid'); + + $mockHttpClient = new MockHttpClient($mockResponses, $baseUrl); + /** @var FftaHelper $fftaHelper */ + $fftaHelper = $this->getContainer()->get(FftaHelper::class); + $fftaHelper->setHttpClient($mockHttpClient); + $fftaHelper->setUserRepository($mockUserRepository); + $fftaHelper->syncLicensees($club, 2025); + + self::assertEmailCount(1); + } +} diff --git a/tests/integration/Helper/FftaHelperTestDataLoader.php b/tests/integration/Helper/FftaHelperTestDataLoader.php new file mode 100644 index 0000000..02f469d --- /dev/null +++ b/tests/integration/Helper/FftaHelperTestDataLoader.php @@ -0,0 +1,79 @@ +faker) { + $this->faker = Factory::create('fr_FR'); + $this->faker->seed(42); + } + + return $this->faker; + } + + public function getLicensees($count = 5): array + { + $licensees = []; + $faker = $this->getFaker(); + for ($i = 0; $i < $count; ++$i) { + $id = $faker->randomNumber(8, true); + $licensees[] = $this->getLicensee($id); + } + + return $licensees; + } + + public function getLicensee(int $id): array + { + if (!isset($this->licensees[$id])) { + $faker = $this->getFaker(); + $gender = $faker->randomElement(['male', 'female']); + $letter = $faker->randomLetter(); + $this->licensees[$id] = [ + 'id' => $id, + 'personne_id' => $id, + 'personne_url' => sprintf('https://dirigeant.ffta.fr/personnes/fiche/%s/licences', $id), + 'code_adherent' => sprintf('%s%s', substr($id, 0, 7), strtoupper((string) $letter)), + 'nom' => $faker->lastName(), + 'prenom' => $faker->firstName($gender), + 'sexe' => 'male' == $gender ? 'Masculin' : 'Féminin', + 'ddn' => $faker->dateTimeBetween('-65 years', '-12 years')->format('d/m/Y'), + 'photo' => false, + 'photo_url' => 'https://dirigeant.ffta.fr/storage/visuels/profil_homme.png', + 'etat' => 'Active', + 'etat_icon' => 'icon-checkmark3', + 'etat_color' => 'success', + 'date_demande' => '27/09/2024', + 'date_debut_validite' => '27/09/2024', + 'date_fin' => '31/08/2025', + 'saisie_par' => 'Club Unisport', + 'type_libelle' => 'Jeune', + 'discipline' => "
\n
\n \n
\n \n \n \n \n \n Tir à l'arc\n \n \n\n \n
\n
\n
\n\n", + 'categorie_age' => 'U18', + 'mutation' => 'Non', + 'surclassement' => 'Non', + 'mail' => $faker->email(), + 'telephone' => $faker->e164PhoneNumber(), + 'adresse' => '', + 'code_postal' => '', + 'commune' => '', + 'structure' => '1033093 - LES ARCHERS DE GUYENNE', + 'structure_url' => 'https://dirigeant.ffta.fr/structures/fiche/556', + 'representant_legal_1' => '', + 'representant_legal_2' => '', + 'ia' => true, + ]; + } + + return $this->licensees[$id]; + } +}