From afd86613fa8e43c4525ae374beb3b678db055641 Mon Sep 17 00:00:00 2001 From: Tom Udding Date: Mon, 13 Mar 2023 19:47:24 +0100 Subject: [PATCH 01/11] Add local Mailman 3 instance To create the superuser for configuration things in Postorius, execute `python manage.py createsuperuser` in the `mailman-web` container. This does not add authentication for the API endpoint. --- .env.dist | 1 + .gitignore | 3 ++ docker-compose.override.yml | 39 +++++++++++++++++++ .../create-gewisdb_report-database.sh | 2 + 4 files changed, 45 insertions(+) diff --git a/.env.dist b/.env.dist index ff0637925..ecface313 100644 --- a/.env.dist +++ b/.env.dist @@ -75,6 +75,7 @@ LDAP_BASEDN= # These are the environment variables for Postgres, only used in docker-compose.override.yaml for development POSTGRES_PASSWORD=gewisdb POSTGRES_USER=gewisdb +POSTGRES_MAILMAN_DATABASE=gewisdb_mailman PGADMIN_DEFAULT_EMAIL=pgadmin4@pgadmin.org PGADMIN_DEFAULT_PASSWORD=pgadmin PGADMIN_DISABLE_POSTFIX=true diff --git a/.gitignore b/.gitignore index 843c7af54..674951d94 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,6 @@ phpcs.xml # Language binaries are created during the docker build process or using 'make compilelang' *.mo + +# Local mailman data for development +mailman/ diff --git a/docker-compose.override.yml b/docker-compose.override.yml index 91926394a..89b9598ba 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -27,8 +27,46 @@ services: image: mailhog/mailhog ports: - "8025:8025" + mailman-core: + image: maxking/mailman-core:0.4 + container_name: mailman-core + hostname: mailman-core + volumes: + - ./mailman/core:/opt/mailman/ + depends_on: + - postgresql + environment: + - DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgresql/${POSTGRES_MAILMAN_DATABASE} + - DATABASE_TYPE=postgres + - DATABASE_CLASS=mailman.database.postgresql.PostgreSQLDatabase + - HYPERKITTY_API_KEY=somerandomapikeythatiobviouslydidnotcreatemyself + ports: + - "8020:8001" + networks: + - gewisdb_network + mailman-web: + image: maxking/mailman-web:0.4 + container_name: mailman-web + hostname: mailman-web + depends_on: + - postgresql + volumes: + - ./mailman/web:/opt/mailman-web-data/ + environment: + - DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgresql/${POSTGRES_MAILMAN_DATABASE} + - DATABASE_TYPE=postgres + - HYPERKITTY_API_KEY=somerandomapikeythatiobviouslydidnotcreatemyself + - SECRET_KEY=anotherandomkeythatiobviouslydidnotcreatemyself + - SERVE_FROM_DOMAIN=localhost + - UWSGI_STATIC_MAP=/static=/opt/mailman-web-data/static + ports: + - "8021:8000" + networks: + - gewisdb_network nginx: build: docker/nginx + volumes: + - ./mailman/web/static:/var/html/mailman/ ports: - "80:9725" stripe: @@ -48,6 +86,7 @@ services: dockerfile: docker/web/development/Dockerfile context: . depends_on: + - mailman-core - postgresql volumes: diff --git a/docker/postgresql/create-gewisdb_report-database.sh b/docker/postgresql/create-gewisdb_report-database.sh index 7ed948e3b..6275052e9 100755 --- a/docker/postgresql/create-gewisdb_report-database.sh +++ b/docker/postgresql/create-gewisdb_report-database.sh @@ -4,4 +4,6 @@ set -e psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" <<-PGSQL CREATE DATABASE $DOCTRINE_REPORT_DATABASE; GRANT ALL PRIVILEGES ON DATABASE $DOCTRINE_REPORT_DATABASE TO $POSTGRES_USER; + CREATE DATABASE $POSTGRES_MAILMAN_DATABASE; + GRANT ALL PRIVILEGES ON DATABASE $POSTGRES_MAILMAN_DATABASE TO $POSTGRES_USER; PGSQL From 51f029e88d620d3d3dcf37986ee41fc08b3a97ed Mon Sep 17 00:00:00 2001 From: Tom Udding Date: Mon, 13 Mar 2023 21:35:50 +0100 Subject: [PATCH 02/11] [WIP] Add initial synchronisation with Mailman This allows syncronisation of mailing list ids from Mailman and they can then be added to a mailing list. At this point there is no actual synchronisation of memberships. --- .env.dist | 5 + composer.json | 5 +- composer.lock | 2067 +++++++++-------- config/autoload/local.development.php.dist | 10 + config/autoload/local.production.php.dist | 10 + config/modules.config.php | 1 + docker-compose.override.yml | 4 +- docker-compose.yml | 7 + docker/web/development/Dockerfile | 3 + docker/web/production/Dockerfile | 4 + module/Application/src/Model/ConfigItem.php | 24 +- .../src/Model/Enums/ConfigNamespaces.php | 3 +- module/Application/src/Service/Config.php | 13 +- module/Database/config/module.config.php | 59 +- .../Factory/MemberControllerFactory.php | 4 + .../src/Controller/MemberController.php | 10 + .../src/Controller/SettingsController.php | 77 +- module/Database/src/Form/MailingList.php | 25 + module/Database/src/Form/MemberLists.php | 70 +- .../Factory/MailingListMemberFactory.php | 23 + .../Database/src/Mapper/MailingListMember.php | 69 + module/Database/src/Mapper/Member.php | 4 +- .../Database/src/Mapper/ProspectiveMember.php | 8 +- module/Database/src/Model/MailingList.php | 81 +- .../Database/src/Model/MailingListMember.php | 195 ++ module/Database/src/Model/Member.php | 66 +- .../Database/src/Model/ProspectiveMember.php | 37 +- module/Database/src/Module.php | 22 + module/Database/src/Service/Api.php | 5 + .../Service/Factory/MailingListFactory.php | 4 + .../src/Service/Factory/MailmanFactory.php | 40 + .../src/Service/Factory/MemberFactory.php | 8 + module/Database/src/Service/MailingList.php | 39 +- module/Database/src/Service/Mailman.php | 241 ++ module/Database/src/Service/Member.php | 92 +- .../database/email/member-registration.phtml | 4 +- .../view/database/member/mailman.phtml | 15 + .../Database/view/database/member/show.phtml | 2 +- .../database/prospective-member/show.phtml | 4 +- .../settings/{list.phtml => add-list.phtml} | 72 +- .../view/database/settings/delete-list.phtml | 2 +- .../view/database/settings/index.phtml | 2 +- .../view/database/settings/lists.phtml | 70 + .../src/Listener/DatabaseUpdateListener.php | 5 + module/Report/src/Model/MailingList.php | 98 +- module/Report/src/Model/MailingListMember.php | 187 ++ module/Report/src/Model/Member.php | 64 +- .../src/Service/Factory/MiscFactory.php | 4 + module/Report/src/Service/Member.php | 13 +- module/Report/src/Service/Misc.php | 50 +- phpcs.xml.dist | 2 + 51 files changed, 2604 insertions(+), 1325 deletions(-) create mode 100644 module/Database/src/Mapper/Factory/MailingListMemberFactory.php create mode 100644 module/Database/src/Mapper/MailingListMember.php create mode 100644 module/Database/src/Model/MailingListMember.php create mode 100644 module/Database/src/Service/Factory/MailmanFactory.php create mode 100644 module/Database/src/Service/Mailman.php create mode 100644 module/Database/view/database/member/mailman.phtml rename module/Database/view/database/settings/{list.phtml => add-list.phtml} (65%) create mode 100644 module/Database/view/database/settings/lists.phtml create mode 100644 module/Report/src/Model/MailingListMember.php diff --git a/.env.dist b/.env.dist index ecface313..7aa804d71 100644 --- a/.env.dist +++ b/.env.dist @@ -63,6 +63,11 @@ MAIL_TO_SUBSCRIPTION_NAME='Secretary of GEWIS' MAIL_FROM_SECRETARY_ADDRESS=example@example.com MAIL_FROM_SECRETARY_NAME='Secretary of GEWIS' +MAILMAN_API_ENDPOINT=http://mailmanc:8001/3.1/ +MAILMAN_API_VERSION='3.1' +MAILMAN_API_USERNAME=restadmin +MAILMAN_API_PASSWORD=restpass + # LDAP settings (fill in to enable LDAP) LDAP_SERVERS=ldaps.gewis.nl LDAP_STARTTLS=true diff --git a/composer.json b/composer.json index 78e8146f5..e6a2366db 100644 --- a/composer.json +++ b/composer.json @@ -12,6 +12,7 @@ "require": { "php": "^8.3.0", "ext-intl": "*", + "ext-memcached": "^3.2", "ext-pdo_pgsql": "*", "ext-pgsql": "*", "ext-zend-opcache": "*", @@ -56,7 +57,9 @@ "stripe/stripe-php": "^10.21", "doctrine/migrations": "^3.8", "doctrine/data-fixtures": "^2.0", - "fakerphp/faker": "^1.24" + "fakerphp/faker": "^1.24", + "laminas/laminas-cache": "^3.12.1", + "laminas/laminas-cache-storage-adapter-memcached": "^2.5.0" }, "require-dev": { "laminas/laminas-component-installer": "^3.4.0", diff --git a/composer.lock b/composer.lock index d807de9b2..1fd74787f 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": "805ac1ca140284ae3f9f5663c1f50efb", + "content-hash": "4849dd7b6cff3e8e7d854c38ee102c12", "packages": [ { "name": "brick/varexporter", @@ -355,29 +355,29 @@ }, { "name": "doctrine/collections", - "version": "2.2.2", + "version": "2.3.0", "source": { "type": "git", "url": "https://github.com/doctrine/collections.git", - "reference": "d8af7f248c74f195f7347424600fd9e17b57af59" + "reference": "2eb07e5953eed811ce1b309a7478a3b236f2273d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/collections/zipball/d8af7f248c74f195f7347424600fd9e17b57af59", - "reference": "d8af7f248c74f195f7347424600fd9e17b57af59", + "url": "https://api.github.com/repos/doctrine/collections/zipball/2eb07e5953eed811ce1b309a7478a3b236f2273d", + "reference": "2eb07e5953eed811ce1b309a7478a3b236f2273d", "shasum": "" }, "require": { "doctrine/deprecations": "^1", - "php": "^8.1" + "php": "^8.1", + "symfony/polyfill-php84": "^1.30" }, "require-dev": { "doctrine/coding-standard": "^12", "ext-json": "*", "phpstan/phpstan": "^1.8", "phpstan/phpstan-phpunit": "^1.0", - "phpunit/phpunit": "^10.5", - "vimeo/psalm": "^5.11" + "phpunit/phpunit": "^10.5" }, "type": "library", "autoload": { @@ -421,7 +421,7 @@ ], "support": { "issues": "https://github.com/doctrine/collections/issues", - "source": "https://github.com/doctrine/collections/tree/2.2.2" + "source": "https://github.com/doctrine/collections/tree/2.3.0" }, "funding": [ { @@ -437,24 +437,24 @@ "type": "tidelift" } ], - "time": "2024-04-18T06:56:21+00:00" + "time": "2025-03-22T10:17:19+00:00" }, { "name": "doctrine/common", - "version": "3.4.5", + "version": "3.5.0", "source": { "type": "git", "url": "https://github.com/doctrine/common.git", - "reference": "6c8fef961f67b8bc802ce3e32e3ebd1022907286" + "reference": "d9ea4a54ca2586db781f0265d36bea731ac66ec5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/common/zipball/6c8fef961f67b8bc802ce3e32e3ebd1022907286", - "reference": "6c8fef961f67b8bc802ce3e32e3ebd1022907286", + "url": "https://api.github.com/repos/doctrine/common/zipball/d9ea4a54ca2586db781f0265d36bea731ac66ec5", + "reference": "d9ea4a54ca2586db781f0265d36bea731ac66ec5", "shasum": "" }, "require": { - "doctrine/persistence": "^2.0 || ^3.0", + "doctrine/persistence": "^2.0 || ^3.0 || ^4.0", "php": "^7.1 || ^8.0" }, "require-dev": { @@ -512,7 +512,7 @@ ], "support": { "issues": "https://github.com/doctrine/common/issues", - "source": "https://github.com/doctrine/common/tree/3.4.5" + "source": "https://github.com/doctrine/common/tree/3.5.0" }, "funding": [ { @@ -528,24 +528,24 @@ "type": "tidelift" } ], - "time": "2024-10-08T15:53:43+00:00" + "time": "2025-01-01T22:12:03+00:00" }, { "name": "doctrine/data-fixtures", - "version": "2.0.0", + "version": "2.0.2", "source": { "type": "git", "url": "https://github.com/doctrine/data-fixtures.git", - "reference": "eddc4c37beff550a9172a449ab285f4511573dc8" + "reference": "f7f1e12d6bceb58c204b3e77210a103c1c57601e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/data-fixtures/zipball/eddc4c37beff550a9172a449ab285f4511573dc8", - "reference": "eddc4c37beff550a9172a449ab285f4511573dc8", + "url": "https://api.github.com/repos/doctrine/data-fixtures/zipball/f7f1e12d6bceb58c204b3e77210a103c1c57601e", + "reference": "f7f1e12d6bceb58c204b3e77210a103c1c57601e", "shasum": "" }, "require": { - "doctrine/persistence": "^3.1", + "doctrine/persistence": "^3.1 || ^4.0", "php": "^8.1", "psr/log": "^1.1 || ^2 || ^3" }, @@ -595,7 +595,7 @@ ], "support": { "issues": "https://github.com/doctrine/data-fixtures/issues", - "source": "https://github.com/doctrine/data-fixtures/tree/2.0.0" + "source": "https://github.com/doctrine/data-fixtures/tree/2.0.2" }, "funding": [ { @@ -611,20 +611,20 @@ "type": "tidelift" } ], - "time": "2024-11-06T07:58:24+00:00" + "time": "2025-01-21T13:21:31+00:00" }, { "name": "doctrine/dbal", - "version": "3.9.3", + "version": "3.9.5", "source": { "type": "git", "url": "https://github.com/doctrine/dbal.git", - "reference": "61446f07fcb522414d6cfd8b1c3e5f9e18c579ba" + "reference": "4a4e2eed3134036ee36a147ee0dac037dfa17868" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/dbal/zipball/61446f07fcb522414d6cfd8b1c3e5f9e18c579ba", - "reference": "61446f07fcb522414d6cfd8b1c3e5f9e18c579ba", + "url": "https://api.github.com/repos/doctrine/dbal/zipball/4a4e2eed3134036ee36a147ee0dac037dfa17868", + "reference": "4a4e2eed3134036ee36a147ee0dac037dfa17868", "shasum": "" }, "require": { @@ -637,18 +637,16 @@ "psr/log": "^1|^2|^3" }, "require-dev": { - "doctrine/coding-standard": "12.0.0", + "doctrine/coding-standard": "13.0.0", "fig/log-test": "^1", "jetbrains/phpstorm-stubs": "2023.1", - "phpstan/phpstan": "1.12.6", - "phpstan/phpstan-strict-rules": "^1.6", - "phpunit/phpunit": "9.6.20", - "psalm/plugin-phpunit": "0.18.4", - "slevomat/coding-standard": "8.13.1", - "squizlabs/php_codesniffer": "3.10.2", + "phpstan/phpstan": "2.1.17", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "9.6.23", + "slevomat/coding-standard": "8.16.2", + "squizlabs/php_codesniffer": "3.13.1", "symfony/cache": "^5.4|^6.0|^7.0", - "symfony/console": "^4.4|^5.4|^6.0|^7.0", - "vimeo/psalm": "4.30.0" + "symfony/console": "^4.4|^5.4|^6.0|^7.0" }, "suggest": { "symfony/console": "For helpful console commands such as SQL execution and import of files." @@ -708,7 +706,7 @@ ], "support": { "issues": "https://github.com/doctrine/dbal/issues", - "source": "https://github.com/doctrine/dbal/tree/3.9.3" + "source": "https://github.com/doctrine/dbal/tree/3.9.5" }, "funding": [ { @@ -724,33 +722,34 @@ "type": "tidelift" } ], - "time": "2024-10-10T17:56:43+00:00" + "time": "2025-06-15T22:40:05+00:00" }, { "name": "doctrine/deprecations", - "version": "1.1.3", + "version": "1.1.5", "source": { "type": "git", "url": "https://github.com/doctrine/deprecations.git", - "reference": "dfbaa3c2d2e9a9df1118213f3b8b0c597bb99fab" + "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/deprecations/zipball/dfbaa3c2d2e9a9df1118213f3b8b0c597bb99fab", - "reference": "dfbaa3c2d2e9a9df1118213f3b8b0c597bb99fab", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38", + "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38", "shasum": "" }, "require": { "php": "^7.1 || ^8.0" }, + "conflict": { + "phpunit/phpunit": "<=7.5 || >=13" + }, "require-dev": { - "doctrine/coding-standard": "^9", - "phpstan/phpstan": "1.4.10 || 1.10.15", - "phpstan/phpstan-phpunit": "^1.0", - "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", - "psalm/plugin-phpunit": "0.18.4", - "psr/log": "^1 || ^2 || ^3", - "vimeo/psalm": "4.30.0 || 5.12.0" + "doctrine/coding-standard": "^9 || ^12 || ^13", + "phpstan/phpstan": "1.4.10 || 2.1.11", + "phpstan/phpstan-phpunit": "^1.0 || ^2", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12", + "psr/log": "^1 || ^2 || ^3" }, "suggest": { "psr/log": "Allows logging deprecations via PSR-3 logger implementation" @@ -758,7 +757,7 @@ "type": "library", "autoload": { "psr-4": { - "Doctrine\\Deprecations\\": "lib/Doctrine/Deprecations" + "Doctrine\\Deprecations\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -769,38 +768,39 @@ "homepage": "https://www.doctrine-project.org/", "support": { "issues": "https://github.com/doctrine/deprecations/issues", - "source": "https://github.com/doctrine/deprecations/tree/1.1.3" + "source": "https://github.com/doctrine/deprecations/tree/1.1.5" }, - "time": "2024-01-30T19:34:25+00:00" + "time": "2025-04-07T20:06:18+00:00" }, { "name": "doctrine/doctrine-laminas-hydrator", - "version": "3.4.0", + "version": "3.6.1", "source": { "type": "git", "url": "https://github.com/doctrine/doctrine-laminas-hydrator.git", - "reference": "3026b89388106f1a4404d1be569e81221b568563" + "reference": "aff2fbeaf214889b53626df5ee1d68eb283cc50f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/doctrine-laminas-hydrator/zipball/3026b89388106f1a4404d1be569e81221b568563", - "reference": "3026b89388106f1a4404d1be569e81221b568563", + "url": "https://api.github.com/repos/doctrine/doctrine-laminas-hydrator/zipball/aff2fbeaf214889b53626df5ee1d68eb283cc50f", + "reference": "aff2fbeaf214889b53626df5ee1d68eb283cc50f", "shasum": "" }, "require": { - "doctrine/collections": "^1.8.0 || ^2.0.0", + "doctrine/collections": "^2.0.0", "doctrine/inflector": "^2.0.4", - "doctrine/persistence": "^2.5.0 || ^3.0.0", + "doctrine/persistence": "^3.4.0 || ^4.0.0", "ext-ctype": "*", "laminas/laminas-hydrator": "^4.13.0", "laminas/laminas-stdlib": "^3.14.0", - "php": "~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0" + "php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0" }, "require-dev": { "doctrine/coding-standard": "^12.0.0", - "phpstan/phpstan": "^1.9.2", - "phpunit/phpunit": "^9.5.26", - "vimeo/psalm": "^5.15.0" + "phpdocumentor/guides-cli": "^1.5.0", + "phpstan/phpstan": "^2.0.2", + "phpstan/phpstan-deprecation-rules": "^2.0.0", + "phpunit/phpunit": "^10.5.38" }, "type": "library", "autoload": { @@ -837,68 +837,67 @@ "type": "tidelift" } ], - "time": "2023-11-21T16:38:19+00:00" + "time": "2025-01-03T16:56:17+00:00" }, { "name": "doctrine/doctrine-module", - "version": "6.1.1", + "version": "6.3.0", "source": { "type": "git", "url": "https://github.com/doctrine/DoctrineModule.git", - "reference": "ece08030bcabc0d2090aa69587c79417306fea79" + "reference": "bac2ff88f29edaa2b25e17128214f8661a6024aa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/DoctrineModule/zipball/ece08030bcabc0d2090aa69587c79417306fea79", - "reference": "ece08030bcabc0d2090aa69587c79417306fea79", + "url": "https://api.github.com/repos/doctrine/DoctrineModule/zipball/bac2ff88f29edaa2b25e17128214f8661a6024aa", + "reference": "bac2ff88f29edaa2b25e17128214f8661a6024aa", "shasum": "" }, "require": { - "composer-runtime-api": "^2.0", - "composer/semver": "^3.0", - "doctrine/annotations": "^1.13.3 || ^2", - "doctrine/cache": "^1.13.0 || ^2.1.0", - "doctrine/collections": "^1.8.0 || ^2.1", + "composer-runtime-api": "^2.0.0", + "composer/semver": "^3.0.0", + "doctrine/annotations": "^2.0.0", + "doctrine/cache": "^2.1.0", + "doctrine/collections": "^2.0.0", "doctrine/doctrine-laminas-hydrator": "^3.2.0", - "doctrine/event-manager": "^1.2.0 || ^2.0", + "doctrine/event-manager": "^2.0.0", "doctrine/inflector": "^2.0.6", - "doctrine/persistence": "^2.5.5 || ^3.1.0", + "doctrine/persistence": "^3.0.0", "laminas/laminas-authentication": "^2.12.0", "laminas/laminas-cache": "^3.6.0", "laminas/laminas-cache-storage-adapter-filesystem": "^2.2.0", "laminas/laminas-cache-storage-adapter-memory": "^2.1.0", "laminas/laminas-eventmanager": "^3.5.0", "laminas/laminas-form": "^3.4.1", - "laminas/laminas-modulemanager": "^2.12.0", "laminas/laminas-mvc": "^3.3.5", "laminas/laminas-paginator": "^2.13.0", "laminas/laminas-servicemanager": "^3.17.0", "laminas/laminas-stdlib": "^3.13.0", "laminas/laminas-validator": "^2.25.0", - "php": "~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0", + "php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0", "psr/container": "^1.1.2", - "symfony/console": "^5.4.16 || ^6.2.1" + "symfony/console": "^6.2.1 || ^7.0.0" }, "conflict": { - "doctrine/orm": "2.12.0" + "doctrine/doctrine-mongo-odm-module": "<5.3.0", + "doctrine/doctrine-orm-module": "<6.3.0" }, "require-dev": { "doctrine/coding-standard": "^12.0.0", - "doctrine/mongodb-odm": "^2.5.0", - "doctrine/orm": "^2.13.4", - "jangregor/phpstan-prophecy": "^1.0.0", - "laminas/laminas-i18n": "^2.17.0", - "laminas/laminas-log": "^2.15.3", - "laminas/laminas-serializer": "^2.13.0", - "laminas/laminas-session": "^2.13.0", - "phpstan/phpstan": "^1.9.2", - "phpstan/phpstan-phpunit": "^1.3.0", - "phpunit/phpunit": "^9.5.27", - "predis/predis": "^1.1.10", - "vimeo/psalm": "^5.0" + "doctrine/mongodb-odm": "^2.7.1", + "doctrine/orm": "^2.20.1", + "jangregor/phpstan-prophecy": "^2.0.0", + "laminas/laminas-modulemanager": "^2.12.0", + "laminas/laminas-session": "^2.22.1", + "phpdocumentor/guides-cli": "^1.5.0", + "phpstan/phpstan": "^2.0.4", + "phpstan/phpstan-phpunit": "^2.0.3", + "phpunit/phpunit": "^10.5.40" }, "suggest": { - "doctrine/data-fixtures": "Data Fixtures if you want to generate test data or bootstrap data for your deployments" + "doctrine/data-fixtures": "Data Fixtures if you want to generate test data or bootstrap data for your deployments", + "doctrine/doctrine-mongo-odm-module": "For use with Doctrine MongDB ODM", + "doctrine/doctrine-orm-module": "For use with Doctrine ORM" }, "bin": [ "bin/doctrine-module" @@ -906,8 +905,8 @@ "type": "library", "extra": { "laminas": { - "config-provider": "DoctrineModule\\ConfigProvider", - "module": "DoctrineModule" + "module": "DoctrineModule", + "config-provider": "DoctrineModule\\ConfigProvider" } }, "autoload": { @@ -919,34 +918,8 @@ "license": [ "MIT" ], - "authors": [ - { - "name": "Kyle Spraggs", - "email": "theman@spiffyjr.me", - "homepage": "http://www.spiffyjr.me/" - }, - { - "name": "Marco Pivetta", - "email": "ocramius@gmail.com", - "homepage": "http://marco-pivetta.com/" - }, - { - "name": "Guilherme Blanco", - "email": "guilhermeblanco@hotmail.com" - }, - { - "name": "MichaĆ«l Gallego", - "email": "mic.gallego@gmail.com", - "homepage": "http://www.michaelgallego.fr" - }, - { - "name": "Tom H Anderson", - "email": "tom.h.anderson@gmail.com", - "homepage": "https://tomhanderson.com" - } - ], "description": "Laminas Module that provides Doctrine basic functionality required for ORM and ODM modules", - "homepage": "http://www.doctrine-project.org/", + "homepage": "https://www.doctrine-project.org/", "keywords": [ "doctrine", "laminas", @@ -954,7 +927,7 @@ ], "support": { "issues": "https://github.com/doctrine/DoctrineModule/issues", - "source": "https://github.com/doctrine/DoctrineModule/tree/6.1.1" + "source": "https://github.com/doctrine/DoctrineModule/tree/6.3.0" }, "funding": [ { @@ -970,60 +943,58 @@ "type": "tidelift" } ], - "time": "2024-01-26T07:13:30+00:00" + "time": "2025-01-23T18:55:44+00:00" }, { "name": "doctrine/doctrine-orm-module", - "version": "6.1.0", + "version": "6.3.0", "source": { "type": "git", "url": "https://github.com/doctrine/DoctrineORMModule.git", - "reference": "d9c7fa931d24e0c1e6c34dc9e43d6ddfa74877a5" + "reference": "189162f1839674785fcf97a4a6f41b48fd8f4409" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/DoctrineORMModule/zipball/d9c7fa931d24e0c1e6c34dc9e43d6ddfa74877a5", - "reference": "d9c7fa931d24e0c1e6c34dc9e43d6ddfa74877a5", + "url": "https://api.github.com/repos/doctrine/DoctrineORMModule/zipball/189162f1839674785fcf97a4a6f41b48fd8f4409", + "reference": "189162f1839674785fcf97a4a6f41b48fd8f4409", "shasum": "" }, "require": { - "doctrine/dbal": "^2.13.7 || ^3.3.2", - "doctrine/doctrine-laminas-hydrator": "^3.0.0", - "doctrine/doctrine-module": "^5.3.0 || ^6.0.2", - "doctrine/event-manager": "^1.1.1", - "doctrine/orm": "^2.11.1", - "doctrine/persistence": "^2.3.0 || ^3.0.0", + "doctrine/dbal": "^3.3.2", + "doctrine/doctrine-laminas-hydrator": "^3.2.0", + "doctrine/doctrine-module": "^6.3.0", + "doctrine/event-manager": "^2.0.0", + "doctrine/orm": "^2.13.0", + "doctrine/persistence": "^3.0.0", "ext-json": "*", - "laminas/laminas-eventmanager": "^3.4.0", + "laminas/laminas-eventmanager": "^3.5.0", "laminas/laminas-modulemanager": "^2.11.0", - "laminas/laminas-mvc": "^3.3.2", - "laminas/laminas-paginator": "^2.12.2", + "laminas/laminas-mvc": "^3.3.5", + "laminas/laminas-paginator": "^2.13.0", "laminas/laminas-servicemanager": "^3.17.0", - "laminas/laminas-stdlib": "^3.7.1", - "php": "~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0", + "laminas/laminas-stdlib": "^3.13.0", + "php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0", "psr/container": "^1.1.2", - "symfony/console": "^5.4.3 || ^6.0.3" + "symfony/console": "^6.1.2 || ^7.0.0" }, "conflict": { - "doctrine/migrations": "<3.3" + "doctrine/data-fixtures": "<2.0", + "doctrine/migrations": "<3.8", + "laminas/laminas-form": "<3.10" }, "require-dev": { - "doctrine/annotations": "^1.13.2", - "doctrine/coding-standard": "^9.0.0", - "doctrine/data-fixtures": "^1.5.2", - "doctrine/migrations": "^3.4.1", + "doctrine/annotations": "^2.0.0", + "doctrine/coding-standard": "^12.0.0", + "doctrine/data-fixtures": "^2.0.1", + "doctrine/migrations": "^3.8.0", "laminas/laminas-cache-storage-adapter-filesystem": "^2.0", "laminas/laminas-cache-storage-adapter-memory": "^2.0", "laminas/laminas-developer-tools": "^2.3.0", - "laminas/laminas-i18n": "^2.13.0", - "laminas/laminas-log": "^2.15.0", + "laminas/laminas-i18n": "^2.23.0", "laminas/laminas-serializer": "^2.12.0", - "ocramius/proxy-manager": "^2.2.0", - "phpstan/phpstan": "^1.4.6", - "phpstan/phpstan-phpunit": "^1.0.0", - "phpunit/phpunit": "^9.5.13", - "squizlabs/php_codesniffer": "^3.6.2", - "vimeo/psalm": "^5.4.0" + "phpstan/phpstan": "^2.0.4", + "phpstan/phpstan-phpunit": "^2.0.3", + "phpunit/phpunit": "^10.5.40" }, "suggest": { "doctrine/migrations": "doctrine migrations if you want to keep your schema definitions versioned", @@ -1033,8 +1004,8 @@ "type": "library", "extra": { "laminas": { - "config-provider": "DoctrineORMModule\\ConfigProvider", - "module": "DoctrineORMModule" + "module": "DoctrineORMModule", + "config-provider": "DoctrineORMModule\\ConfigProvider" } }, "autoload": { @@ -1046,33 +1017,8 @@ "license": [ "MIT" ], - "authors": [ - { - "name": "Kyle Spraggs", - "email": "theman@spiffyjr.me", - "homepage": "http://www.spiffyjr.me/" - }, - { - "name": "Marco Pivetta", - "email": "ocramius@gmail.com", - "homepage": "http://marco-pivetta.com/" - }, - { - "name": "Evan Coury", - "email": "me@evancoury.com", - "homepage": "http://blog.evan.pro/" - }, - { - "name": "Guilherme Blanco", - "email": "guilhermeblanco@hotmail.com" - }, - { - "name": "Tom H Anderson", - "email": "tom.h.anderson@gmail.com" - } - ], "description": "Laminas Module that provides Doctrine ORM functionality", - "homepage": "http://www.doctrine-project.org/", + "homepage": "https://www.doctrine-project.org/", "keywords": [ "doctrine", "laminas", @@ -1081,7 +1027,7 @@ ], "support": { "issues": "https://github.com/doctrine/DoctrineORMModule/issues", - "source": "https://github.com/doctrine/DoctrineORMModule/tree/6.1.0" + "source": "https://github.com/doctrine/DoctrineORMModule/tree/6.3.0" }, "funding": [ { @@ -1097,34 +1043,33 @@ "type": "tidelift" } ], - "time": "2024-01-25T22:03:02+00:00" + "time": "2025-01-28T08:28:57+00:00" }, { "name": "doctrine/event-manager", - "version": "1.2.0", + "version": "2.0.1", "source": { "type": "git", "url": "https://github.com/doctrine/event-manager.git", - "reference": "95aa4cb529f1e96576f3fda9f5705ada4056a520" + "reference": "b680156fa328f1dfd874fd48c7026c41570b9c6e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/event-manager/zipball/95aa4cb529f1e96576f3fda9f5705ada4056a520", - "reference": "95aa4cb529f1e96576f3fda9f5705ada4056a520", + "url": "https://api.github.com/repos/doctrine/event-manager/zipball/b680156fa328f1dfd874fd48c7026c41570b9c6e", + "reference": "b680156fa328f1dfd874fd48c7026c41570b9c6e", "shasum": "" }, "require": { - "doctrine/deprecations": "^0.5.3 || ^1", - "php": "^7.1 || ^8.0" + "php": "^8.1" }, "conflict": { "doctrine/common": "<2.9" }, "require-dev": { - "doctrine/coding-standard": "^9 || ^10", - "phpstan/phpstan": "~1.4.10 || ^1.8.8", - "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", - "vimeo/psalm": "^4.24" + "doctrine/coding-standard": "^12", + "phpstan/phpstan": "^1.8.8", + "phpunit/phpunit": "^10.5", + "vimeo/psalm": "^5.24" }, "type": "library", "autoload": { @@ -1173,7 +1118,7 @@ ], "support": { "issues": "https://github.com/doctrine/event-manager/issues", - "source": "https://github.com/doctrine/event-manager/tree/1.2.0" + "source": "https://github.com/doctrine/event-manager/tree/2.0.1" }, "funding": [ { @@ -1189,7 +1134,7 @@ "type": "tidelift" } ], - "time": "2022-10-12T20:51:15+00:00" + "time": "2024-05-22T20:47:39+00:00" }, { "name": "doctrine/inflector", @@ -1431,16 +1376,16 @@ }, { "name": "doctrine/migrations", - "version": "3.8.2", + "version": "3.9.0", "source": { "type": "git", "url": "https://github.com/doctrine/migrations.git", - "reference": "5007eb1168691225ac305fe16856755c20860842" + "reference": "325b61e41d032f5f7d7e2d11cbefff656eadc9ab" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/migrations/zipball/5007eb1168691225ac305fe16856755c20860842", - "reference": "5007eb1168691225ac305fe16856755c20860842", + "url": "https://api.github.com/repos/doctrine/migrations/zipball/325b61e41d032f5f7d7e2d11cbefff656eadc9ab", + "reference": "325b61e41d032f5f7d7e2d11cbefff656eadc9ab", "shasum": "" }, "require": { @@ -1460,7 +1405,7 @@ "require-dev": { "doctrine/coding-standard": "^12", "doctrine/orm": "^2.13 || ^3", - "doctrine/persistence": "^2 || ^3", + "doctrine/persistence": "^2 || ^3 || ^4", "doctrine/sql-formatter": "^1.0", "ext-pdo_sqlite": "*", "fig/log-test": "^1", @@ -1514,7 +1459,7 @@ ], "support": { "issues": "https://github.com/doctrine/migrations/issues", - "source": "https://github.com/doctrine/migrations/tree/3.8.2" + "source": "https://github.com/doctrine/migrations/tree/3.9.0" }, "funding": [ { @@ -1530,20 +1475,20 @@ "type": "tidelift" } ], - "time": "2024-10-10T21:35:27+00:00" + "time": "2025-03-26T06:48:45+00:00" }, { "name": "doctrine/orm", - "version": "2.20.0", + "version": "2.20.4", "source": { "type": "git", "url": "https://github.com/doctrine/orm.git", - "reference": "8ed6c2234aba019f9737a6bcc9516438e62da27c" + "reference": "71550106d491c3f888636b731c805473de3c8583" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/orm/zipball/8ed6c2234aba019f9737a6bcc9516438e62da27c", - "reference": "8ed6c2234aba019f9737a6bcc9516438e62da27c", + "url": "https://api.github.com/repos/doctrine/orm/zipball/71550106d491c3f888636b731c805473de3c8583", + "reference": "71550106d491c3f888636b731c805473de3c8583", "shasum": "" }, "require": { @@ -1570,18 +1515,17 @@ }, "require-dev": { "doctrine/annotations": "^1.13 || ^2", - "doctrine/coding-standard": "^9.0.2 || ^12.0", + "doctrine/coding-standard": "^9.0.2 || ^13.0", "phpbench/phpbench": "^0.16.10 || ^1.0", "phpstan/extension-installer": "~1.1.0 || ^1.4", - "phpstan/phpstan": "~1.4.10 || 1.12.6", - "phpstan/phpstan-deprecation-rules": "^1", + "phpstan/phpstan": "~1.4.10 || 2.0.3", + "phpstan/phpstan-deprecation-rules": "^1 || ^2", "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6", "psr/log": "^1 || ^2 || ^3", - "squizlabs/php_codesniffer": "3.7.2", + "squizlabs/php_codesniffer": "3.12.0", "symfony/cache": "^4.4 || ^5.4 || ^6.4 || ^7.0", "symfony/var-exporter": "^4.4 || ^5.4 || ^6.2 || ^7.0", - "symfony/yaml": "^3.4 || ^4.0 || ^5.0 || ^6.0 || ^7.0", - "vimeo/psalm": "4.30.0 || 5.24.0" + "symfony/yaml": "^3.4 || ^4.0 || ^5.0 || ^6.0 || ^7.0" }, "suggest": { "ext-dom": "Provides support for XSD validation for XML mapping files", @@ -1631,22 +1575,22 @@ ], "support": { "issues": "https://github.com/doctrine/orm/issues", - "source": "https://github.com/doctrine/orm/tree/2.20.0" + "source": "https://github.com/doctrine/orm/tree/2.20.4" }, - "time": "2024-10-11T11:47:24+00:00" + "time": "2025-06-09T20:24:12+00:00" }, { "name": "doctrine/persistence", - "version": "3.3.3", + "version": "3.4.0", "source": { "type": "git", "url": "https://github.com/doctrine/persistence.git", - "reference": "b337726451f5d530df338fc7f68dee8781b49779" + "reference": "0ea965320cec355dba75031c1b23d4c78362e3ff" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/persistence/zipball/b337726451f5d530df338fc7f68dee8781b49779", - "reference": "b337726451f5d530df338fc7f68dee8781b49779", + "url": "https://api.github.com/repos/doctrine/persistence/zipball/0ea965320cec355dba75031c1b23d4c78362e3ff", + "reference": "0ea965320cec355dba75031c1b23d4c78362e3ff", "shasum": "" }, "require": { @@ -1660,12 +1604,11 @@ "require-dev": { "doctrine/coding-standard": "^12", "doctrine/common": "^3.0", - "phpstan/phpstan": "1.11.1", + "phpstan/phpstan": "1.12.7", "phpstan/phpstan-phpunit": "^1", "phpstan/phpstan-strict-rules": "^1.1", - "phpunit/phpunit": "^8.5 || ^9.5", - "symfony/cache": "^4.4 || ^5.4 || ^6.0", - "vimeo/psalm": "4.30.0 || 5.24.0" + "phpunit/phpunit": "^8.5.38 || ^9.5", + "symfony/cache": "^4.4 || ^5.4 || ^6.0 || ^7.0" }, "type": "library", "autoload": { @@ -1714,7 +1657,7 @@ ], "support": { "issues": "https://github.com/doctrine/persistence/issues", - "source": "https://github.com/doctrine/persistence/tree/3.3.3" + "source": "https://github.com/doctrine/persistence/tree/3.4.0" }, "funding": [ { @@ -1730,20 +1673,20 @@ "type": "tidelift" } ], - "time": "2024-06-20T10:14:30+00:00" + "time": "2024-10-30T19:48:12+00:00" }, { "name": "fakerphp/faker", - "version": "v1.24.0", + "version": "v1.24.1", "source": { "type": "git", "url": "https://github.com/FakerPHP/Faker.git", - "reference": "a136842a532bac9ecd8a1c723852b09915d7db50" + "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/FakerPHP/Faker/zipball/a136842a532bac9ecd8a1c723852b09915d7db50", - "reference": "a136842a532bac9ecd8a1c723852b09915d7db50", + "url": "https://api.github.com/repos/FakerPHP/Faker/zipball/e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", + "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", "shasum": "" }, "require": { @@ -1791,44 +1734,44 @@ ], "support": { "issues": "https://github.com/FakerPHP/Faker/issues", - "source": "https://github.com/FakerPHP/Faker/tree/v1.24.0" + "source": "https://github.com/FakerPHP/Faker/tree/v1.24.1" }, - "time": "2024-11-07T15:11:20+00:00" + "time": "2024-11-21T13:46:39+00:00" }, { "name": "laminas/laminas-authentication", - "version": "2.17.0", + "version": "2.18.0", "source": { "type": "git", "url": "https://github.com/laminas/laminas-authentication.git", - "reference": "49202c2fdd7844209e05232334fe7fe7e4f62834" + "reference": "c1da3ec75bd4d6e3c63cf3a89f0f1a59a81a82bd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laminas/laminas-authentication/zipball/49202c2fdd7844209e05232334fe7fe7e4f62834", - "reference": "49202c2fdd7844209e05232334fe7fe7e4f62834", + "url": "https://api.github.com/repos/laminas/laminas-authentication/zipball/c1da3ec75bd4d6e3c63cf3a89f0f1a59a81a82bd", + "reference": "c1da3ec75bd4d6e3c63cf3a89f0f1a59a81a82bd", "shasum": "" }, "require": { "ext-mbstring": "*", - "laminas/laminas-stdlib": "^3.6.0", - "php": "~8.1.0 || ~8.2.0 || ~8.3.0" + "laminas/laminas-stdlib": "^3.19.0", + "php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0" }, "conflict": { "zendframework/zend-authentication": "*" }, "require-dev": { - "laminas/laminas-coding-standard": "~2.4.0", + "laminas/laminas-coding-standard": "~2.5.0", "laminas/laminas-db": "^2.20.0", "laminas/laminas-http": "^2.19.0", "laminas/laminas-ldap": "^2.18.1", "laminas/laminas-session": "^2.21.0", - "laminas/laminas-uri": "^2.11.0", - "laminas/laminas-validator": "^2.61.0", + "laminas/laminas-uri": "^2.12.0", + "laminas/laminas-validator": "^2.64.1", "phpunit/phpunit": "^9.6.20", - "psalm/plugin-phpunit": "^0.17.0", - "squizlabs/php_codesniffer": "^3.10.1", - "vimeo/psalm": "^4.30.0" + "psalm/plugin-phpunit": "^0.19.0", + "squizlabs/php_codesniffer": "^3.10.2", + "vimeo/psalm": "^5.26.0" }, "suggest": { "laminas/laminas-db": "Laminas\\Db component", @@ -1868,20 +1811,20 @@ "type": "community_bridge" } ], - "time": "2024-07-15T08:31:06+00:00" + "time": "2024-10-21T10:45:35+00:00" }, { "name": "laminas/laminas-cache", - "version": "3.12.2", + "version": "3.13.0", "source": { "type": "git", "url": "https://github.com/laminas/laminas-cache.git", - "reference": "f99d10dd1f13d5163a924f8561e9dca3d27d8ad2" + "reference": "8225b16d03bbff2ecf2bd5a4f38c39ca47389e50" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laminas/laminas-cache/zipball/f99d10dd1f13d5163a924f8561e9dca3d27d8ad2", - "reference": "f99d10dd1f13d5163a924f8561e9dca3d27d8ad2", + "url": "https://api.github.com/repos/laminas/laminas-cache/zipball/8225b16d03bbff2ecf2bd5a4f38c39ca47389e50", + "reference": "8225b16d03bbff2ecf2bd5a4f38c39ca47389e50", "shasum": "" }, "require": { @@ -1889,7 +1832,7 @@ "laminas/laminas-eventmanager": "^3.4", "laminas/laminas-servicemanager": "^3.21", "laminas/laminas-stdlib": "^3.6", - "php": "~8.1.0 || ~8.2.0 || ~8.3.0", + "php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0", "psr/cache": "^1.0", "psr/clock": "^1.0", "psr/simple-cache": "^1.0", @@ -1969,25 +1912,28 @@ "type": "community_bridge" } ], - "time": "2024-06-14T13:39:14+00:00" + "time": "2025-01-21T12:32:41+00:00" }, { "name": "laminas/laminas-cache-storage-adapter-filesystem", - "version": "2.4.1", + "version": "2.5.0", "source": { "type": "git", "url": "https://github.com/laminas/laminas-cache-storage-adapter-filesystem.git", - "reference": "6b017c485bb93a0c33e865e1d713ae28055ee8d5" + "reference": "d1777b7abf3ac9bcbf203be6aa25786be17cd336" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laminas/laminas-cache-storage-adapter-filesystem/zipball/6b017c485bb93a0c33e865e1d713ae28055ee8d5", - "reference": "6b017c485bb93a0c33e865e1d713ae28055ee8d5", + "url": "https://api.github.com/repos/laminas/laminas-cache-storage-adapter-filesystem/zipball/d1777b7abf3ac9bcbf203be6aa25786be17cd336", + "reference": "d1777b7abf3ac9bcbf203be6aa25786be17cd336", "shasum": "" }, "require": { "laminas/laminas-cache": "^3.10.0", - "php": "~8.1.0 || ~8.2.0 || ~8.3.0" + "php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0" + }, + "conflict": { + "amphp/amp": "<2.6.4" }, "provide": { "laminas/laminas-cache-storage-implementation": "1.0" @@ -1999,15 +1945,15 @@ "laminas/laminas-cache-storage-adapter-test": "^2.0 || 2.0.x-dev", "laminas/laminas-coding-standard": "~2.4", "laminas/laminas-serializer": "^2.14.0", - "phpunit/phpunit": "^9.5.26", - "psalm/plugin-phpunit": "^0.18.0", + "phpunit/phpunit": "^9.6.22", + "psalm/plugin-phpunit": "^0.19.0", "vimeo/psalm": "^5.18" }, "type": "library", "extra": { "laminas": { - "config-provider": "Laminas\\Cache\\Storage\\Adapter\\Filesystem\\ConfigProvider", - "module": "Laminas\\Cache\\Storage\\Adapter\\Filesystem" + "module": "Laminas\\Cache\\Storage\\Adapter\\Filesystem", + "config-provider": "Laminas\\Cache\\Storage\\Adapter\\Filesystem\\ConfigProvider" } }, "autoload": { @@ -2037,25 +1983,95 @@ "type": "community_bridge" } ], - "time": "2024-07-10T12:52:40+00:00" + "time": "2025-01-23T17:29:37+00:00" + }, + { + "name": "laminas/laminas-cache-storage-adapter-memcached", + "version": "2.6.0", + "source": { + "type": "git", + "url": "https://github.com/laminas/laminas-cache-storage-adapter-memcached.git", + "reference": "88bdebf512687885e2e1358021c61f7d15abf076" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laminas/laminas-cache-storage-adapter-memcached/zipball/88bdebf512687885e2e1358021c61f7d15abf076", + "reference": "88bdebf512687885e2e1358021c61f7d15abf076", + "shasum": "" + }, + "require": { + "ext-memcached": "^3.1.5", + "laminas/laminas-cache": "^3.10", + "php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0" + }, + "conflict": { + "laminas/laminas-servicemanager": "<3.11" + }, + "provide": { + "laminas/laminas-cache-storage-implementation": "1.0" + }, + "require-dev": { + "laminas/laminas-cache-storage-adapter-benchmark": "^1.0", + "laminas/laminas-cache-storage-adapter-test": "^2.0", + "laminas/laminas-coding-standard": "~2.5.0", + "phpunit/phpunit": "^9.6.22", + "psalm/plugin-phpunit": "^0.18.0", + "vimeo/psalm": "^5.18" + }, + "type": "library", + "extra": { + "laminas": { + "module": "Laminas\\Cache\\Storage\\Adapter\\Memcached", + "config-provider": "Laminas\\Cache\\Storage\\Adapter\\Memcached\\ConfigProvider" + } + }, + "autoload": { + "psr-4": { + "Laminas\\Cache\\Storage\\Adapter\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "Laminas cache adapter for memcached", + "keywords": [ + "cache", + "laminas", + "memcached" + ], + "support": { + "docs": "https://docs.laminas.dev/laminas-cache-storage-adapter-memcached/", + "forum": "https://discourse.laminas.dev/", + "issues": "https://github.com/laminas/laminas-cache-storage-adapter-memcached/issues", + "rss": "https://github.com/laminas/laminas-cache-storage-adapter-memcached/releases.atom", + "source": "https://github.com/laminas/laminas-cache-storage-adapter-memcached" + }, + "funding": [ + { + "url": "https://funding.communitybridge.org/projects/laminas-project", + "type": "community_bridge" + } + ], + "time": "2025-01-23T15:55:46+00:00" }, { "name": "laminas/laminas-cache-storage-adapter-memory", - "version": "2.3.0", + "version": "2.4.0", "source": { "type": "git", "url": "https://github.com/laminas/laminas-cache-storage-adapter-memory.git", - "reference": "d2c357a8b839ceb0e0781d5e9aebe46642dbf0b2" + "reference": "12d2b191fee7ed0c08fd5db4441282705184f1bf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laminas/laminas-cache-storage-adapter-memory/zipball/d2c357a8b839ceb0e0781d5e9aebe46642dbf0b2", - "reference": "d2c357a8b839ceb0e0781d5e9aebe46642dbf0b2", + "url": "https://api.github.com/repos/laminas/laminas-cache-storage-adapter-memory/zipball/12d2b191fee7ed0c08fd5db4441282705184f1bf", + "reference": "12d2b191fee7ed0c08fd5db4441282705184f1bf", "shasum": "" }, "require": { "laminas/laminas-cache": "^3.0", - "php": "~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0" + "php": "~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0" }, "conflict": { "laminas/laminas-servicemanager": "<3.11" @@ -2073,8 +2089,8 @@ "type": "library", "extra": { "laminas": { - "config-provider": "Laminas\\Cache\\Storage\\Adapter\\Memory\\ConfigProvider", - "module": "Laminas\\Cache\\Storage\\Adapter\\Memory" + "module": "Laminas\\Cache\\Storage\\Adapter\\Memory", + "config-provider": "Laminas\\Cache\\Storage\\Adapter\\Memory\\ConfigProvider" } }, "autoload": { @@ -2104,39 +2120,41 @@ "type": "community_bridge" } ], - "time": "2023-10-18T09:43:33+00:00" + "time": "2025-01-23T15:41:59+00:00" }, { "name": "laminas/laminas-cli", - "version": "1.10.0", + "version": "1.11.0", "source": { "type": "git", "url": "https://github.com/laminas/laminas-cli.git", - "reference": "cc59875b2a983b05a70abf4f9b3af739b1257f34" + "reference": "159b48f896fb2502cb6618a95307b56a0f23e592" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laminas/laminas-cli/zipball/cc59875b2a983b05a70abf4f9b3af739b1257f34", - "reference": "cc59875b2a983b05a70abf4f9b3af739b1257f34", + "url": "https://api.github.com/repos/laminas/laminas-cli/zipball/159b48f896fb2502cb6618a95307b56a0f23e592", + "reference": "159b48f896fb2502cb6618a95307b56a0f23e592", "shasum": "" }, "require": { "composer-runtime-api": "^2.0.0", - "php": "~8.1.0 || ~8.2.0 || ~8.3.0", + "php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0", "psr/container": "^1.0 || ^2.0", "symfony/console": "^6.0 || ^7.0", "symfony/event-dispatcher": "^6.0 || ^7.0", - "symfony/polyfill-php80": "^1.17", "webmozart/assert": "^1.10" }, + "conflict": { + "amphp/amp": "<2.6.4" + }, "require-dev": { - "laminas/laminas-coding-standard": "~2.5.0", - "laminas/laminas-mvc": "^3.7.0", - "laminas/laminas-servicemanager": "^3.22.1", + "laminas/laminas-coding-standard": "^3.0.1", + "laminas/laminas-mvc": "^3.8.0", + "laminas/laminas-servicemanager": "^3.23.0", "mikey179/vfsstream": "2.0.x-dev", - "phpunit/phpunit": "^10.5.5", - "psalm/plugin-phpunit": "^0.18.4", - "vimeo/psalm": "^5.18" + "phpunit/phpunit": "^10.5.38", + "psalm/plugin-phpunit": "^0.19.0", + "vimeo/psalm": "^5.26.1" }, "bin": [ "bin/laminas" @@ -2172,26 +2190,26 @@ "type": "community_bridge" } ], - "time": "2024-01-02T15:08:03+00:00" + "time": "2024-11-22T12:39:15+00:00" }, { "name": "laminas/laminas-config", - "version": "3.9.0", + "version": "3.10.1", "source": { "type": "git", "url": "https://github.com/laminas/laminas-config.git", - "reference": "e53717277f6c22b1c697a46473b9a5ec9a438efa" + "reference": "0f50adbf2b2e01e0fe99c13e37d3a6c1ef645185" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laminas/laminas-config/zipball/e53717277f6c22b1c697a46473b9a5ec9a438efa", - "reference": "e53717277f6c22b1c697a46473b9a5ec9a438efa", + "url": "https://api.github.com/repos/laminas/laminas-config/zipball/0f50adbf2b2e01e0fe99c13e37d3a6c1ef645185", + "reference": "0f50adbf2b2e01e0fe99c13e37d3a6c1ef645185", "shasum": "" }, "require": { "ext-json": "*", "laminas/laminas-stdlib": "^3.6", - "php": "~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0", + "php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0", "psr/container": "^1.0" }, "conflict": { @@ -2199,11 +2217,11 @@ "zendframework/zend-config": "*" }, "require-dev": { - "laminas/laminas-coding-standard": "~2.4.0", - "laminas/laminas-filter": "~2.23.0", - "laminas/laminas-i18n": "~2.19.0", - "laminas/laminas-servicemanager": "~3.19.0", - "phpunit/phpunit": "~9.5.25" + "laminas/laminas-coding-standard": "^3.0.1", + "laminas/laminas-filter": "^2.39.0", + "laminas/laminas-i18n": "^2.29.0", + "laminas/laminas-servicemanager": "^3.23.0", + "phpunit/phpunit": "^10.5.38" }, "suggest": { "laminas/laminas-filter": "^2.7.2; install if you want to use the Filter processor", @@ -2240,7 +2258,8 @@ "type": "community_bridge" } ], - "time": "2023-09-19T12:02:54+00:00" + "abandoned": true, + "time": "2024-12-05T14:32:05+00:00" }, { "name": "laminas/laminas-crypt", @@ -2308,33 +2327,32 @@ }, { "name": "laminas/laminas-escaper", - "version": "2.13.0", + "version": "2.17.0", "source": { "type": "git", "url": "https://github.com/laminas/laminas-escaper.git", - "reference": "af459883f4018d0f8a0c69c7a209daef3bf973ba" + "reference": "df1ef9503299a8e3920079a16263b578eaf7c3ba" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laminas/laminas-escaper/zipball/af459883f4018d0f8a0c69c7a209daef3bf973ba", - "reference": "af459883f4018d0f8a0c69c7a209daef3bf973ba", + "url": "https://api.github.com/repos/laminas/laminas-escaper/zipball/df1ef9503299a8e3920079a16263b578eaf7c3ba", + "reference": "df1ef9503299a8e3920079a16263b578eaf7c3ba", "shasum": "" }, "require": { "ext-ctype": "*", "ext-mbstring": "*", - "php": "~8.1.0 || ~8.2.0 || ~8.3.0" + "php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0" }, "conflict": { "zendframework/zend-escaper": "*" }, "require-dev": { - "infection/infection": "^0.27.0", - "laminas/laminas-coding-standard": "~2.5.0", - "maglnet/composer-require-checker": "^3.8.0", - "phpunit/phpunit": "^9.6.7", - "psalm/plugin-phpunit": "^0.18.4", - "vimeo/psalm": "^5.9" + "infection/infection": "^0.29.8", + "laminas/laminas-coding-standard": "~3.0.1", + "phpunit/phpunit": "^10.5.45", + "psalm/plugin-phpunit": "^0.19.2", + "vimeo/psalm": "^6.6.2" }, "type": "library", "autoload": { @@ -2366,37 +2384,37 @@ "type": "community_bridge" } ], - "time": "2023-10-10T08:35:13+00:00" + "time": "2025-05-06T19:29:36+00:00" }, { "name": "laminas/laminas-eventmanager", - "version": "3.13.1", + "version": "3.14.0", "source": { "type": "git", "url": "https://github.com/laminas/laminas-eventmanager.git", - "reference": "933d1b5cf03fa4cf3016cebfd0555fa2ba3f2024" + "reference": "1837cafaaaee74437f6d8ec9ff7da03e6f81d809" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laminas/laminas-eventmanager/zipball/933d1b5cf03fa4cf3016cebfd0555fa2ba3f2024", - "reference": "933d1b5cf03fa4cf3016cebfd0555fa2ba3f2024", + "url": "https://api.github.com/repos/laminas/laminas-eventmanager/zipball/1837cafaaaee74437f6d8ec9ff7da03e6f81d809", + "reference": "1837cafaaaee74437f6d8ec9ff7da03e6f81d809", "shasum": "" }, "require": { - "php": "~8.1.0 || ~8.2.0 || ~8.3.0" + "php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0" }, "conflict": { "container-interop/container-interop": "<1.2", "zendframework/zend-eventmanager": "*" }, "require-dev": { - "laminas/laminas-coding-standard": "~2.5.0", - "laminas/laminas-stdlib": "^3.18", - "phpbench/phpbench": "^1.2.15", - "phpunit/phpunit": "^10.5.5", - "psalm/plugin-phpunit": "^0.18.4", + "laminas/laminas-coding-standard": "~3.0.0", + "laminas/laminas-stdlib": "^3.20", + "phpbench/phpbench": "^1.3.1", + "phpunit/phpunit": "^10.5.38", + "psalm/plugin-phpunit": "^0.19.0", "psr/container": "^1.1.2 || ^2.0.2", - "vimeo/psalm": "^5.18" + "vimeo/psalm": "^5.26.1" }, "suggest": { "laminas/laminas-stdlib": "^2.7.3 || ^3.0, to use the FilterChain feature", @@ -2434,20 +2452,20 @@ "type": "community_bridge" } ], - "time": "2024-06-24T14:01:06+00:00" + "time": "2024-11-21T11:31:22+00:00" }, { "name": "laminas/laminas-filter", - "version": "2.38.0", + "version": "2.41.0", "source": { "type": "git", "url": "https://github.com/laminas/laminas-filter.git", - "reference": "3037168c2db8af3088aca8826bdf7e249fd24ce3" + "reference": "eaa00111231bf6669826ae84d3abe85b94477585" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laminas/laminas-filter/zipball/3037168c2db8af3088aca8826bdf7e249fd24ce3", - "reference": "3037168c2db8af3088aca8826bdf7e249fd24ce3", + "url": "https://api.github.com/repos/laminas/laminas-filter/zipball/eaa00111231bf6669826ae84d3abe85b94477585", + "reference": "eaa00111231bf6669826ae84d3abe85b94477585", "shasum": "" }, "require": { @@ -2513,7 +2531,7 @@ "type": "community_bridge" } ], - "time": "2024-10-17T20:43:05+00:00" + "time": "2025-05-05T02:02:31+00:00" }, { "name": "laminas/laminas-form", @@ -2614,23 +2632,23 @@ }, { "name": "laminas/laminas-http", - "version": "2.20.0", + "version": "2.22.0", "source": { "type": "git", "url": "https://github.com/laminas/laminas-http.git", - "reference": "a66bfb65c698aad6ee9f10df42cb5902f2c3dec8" + "reference": "5052177fb8176e00b0d4b89108648f557be072b7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laminas/laminas-http/zipball/a66bfb65c698aad6ee9f10df42cb5902f2c3dec8", - "reference": "a66bfb65c698aad6ee9f10df42cb5902f2c3dec8", + "url": "https://api.github.com/repos/laminas/laminas-http/zipball/5052177fb8176e00b0d4b89108648f557be072b7", + "reference": "5052177fb8176e00b0d4b89108648f557be072b7", "shasum": "" }, "require": { "laminas/laminas-loader": "^2.10", "laminas/laminas-stdlib": "^3.6", "laminas/laminas-uri": "^2.11", - "laminas/laminas-validator": "^2.15", + "laminas/laminas-validator": "^2.15 || ^3.0", "php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0" }, "conflict": { @@ -2638,8 +2656,8 @@ }, "require-dev": { "ext-curl": "*", - "laminas/laminas-coding-standard": "~2.4.0", - "phpunit/phpunit": "^9.6.21" + "laminas/laminas-coding-standard": "^3.0.1", + "phpunit/phpunit": "^10.5.38" }, "suggest": { "paragonie/certainty": "For automated management of cacert.pem" @@ -2675,46 +2693,46 @@ "type": "community_bridge" } ], - "time": "2024-10-18T07:35:59+00:00" + "time": "2025-05-06T08:24:40+00:00" }, { "name": "laminas/laminas-hydrator", - "version": "4.15.0", + "version": "4.16.0", "source": { "type": "git", "url": "https://github.com/laminas/laminas-hydrator.git", - "reference": "43ccca88313fdcceca37865109dffc69ecd2cf8f" + "reference": "a162bd571924968d67ef1f43aed044b8f9c108ef" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laminas/laminas-hydrator/zipball/43ccca88313fdcceca37865109dffc69ecd2cf8f", - "reference": "43ccca88313fdcceca37865109dffc69ecd2cf8f", + "url": "https://api.github.com/repos/laminas/laminas-hydrator/zipball/a162bd571924968d67ef1f43aed044b8f9c108ef", + "reference": "a162bd571924968d67ef1f43aed044b8f9c108ef", "shasum": "" }, "require": { - "laminas/laminas-stdlib": "^3.3", - "php": "~8.1.0 || ~8.2.0 || ~8.3.0", - "webmozart/assert": "^1.10" + "laminas/laminas-stdlib": "^3.20", + "php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0", + "webmozart/assert": "^1.11" }, "conflict": { "laminas/laminas-servicemanager": "<3.14.0", "zendframework/zend-hydrator": "*" }, "require-dev": { - "laminas/laminas-coding-standard": "~2.5.0", - "laminas/laminas-eventmanager": "^3.12", - "laminas/laminas-modulemanager": "^2.15.0", + "laminas/laminas-coding-standard": "~3.0", + "laminas/laminas-eventmanager": "^3.13.1", + "laminas/laminas-modulemanager": "^2.16.0", "laminas/laminas-serializer": "^2.17.0", - "laminas/laminas-servicemanager": "^3.22.1", - "phpbench/phpbench": "^1.2.14", - "phpunit/phpunit": "^10.4.2", - "psalm/plugin-phpunit": "^0.18.4", - "vimeo/psalm": "^5.15" + "laminas/laminas-servicemanager": "^3.23.0", + "phpbench/phpbench": "^1.3.1", + "phpunit/phpunit": "^10.5.38", + "psalm/plugin-phpunit": "^0.19.0", + "vimeo/psalm": "^5.26.1" }, "suggest": { - "laminas/laminas-eventmanager": "^3.2, to support aggregate hydrator usage", - "laminas/laminas-serializer": "^2.9, to use the SerializableStrategy", - "laminas/laminas-servicemanager": "^3.14, to support hydrator plugin manager usage" + "laminas/laminas-eventmanager": "^3.13, to support aggregate hydrator usage", + "laminas/laminas-serializer": "^2.17, to use the SerializableStrategy", + "laminas/laminas-servicemanager": "^3.22, to support hydrator plugin manager usage" }, "type": "library", "extra": { @@ -2752,20 +2770,20 @@ "type": "community_bridge" } ], - "time": "2023-11-08T11:11:45+00:00" + "time": "2024-11-13T14:04:02+00:00" }, { "name": "laminas/laminas-i18n", - "version": "2.29.0", + "version": "2.30.0", "source": { "type": "git", "url": "https://github.com/laminas/laminas-i18n.git", - "reference": "9aa7ef6073556e9b4cfd8d9a0cb8e41cd3883454" + "reference": "397907ee061e147939364df9d6c485ac1e0fed87" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laminas/laminas-i18n/zipball/9aa7ef6073556e9b4cfd8d9a0cb8e41cd3883454", - "reference": "9aa7ef6073556e9b4cfd8d9a0cb8e41cd3883454", + "url": "https://api.github.com/repos/laminas/laminas-i18n/zipball/397907ee061e147939364df9d6c485ac1e0fed87", + "reference": "397907ee061e147939364df9d6c485ac1e0fed87", "shasum": "" }, "require": { @@ -2780,18 +2798,18 @@ "zendframework/zend-i18n": "*" }, "require-dev": { - "laminas/laminas-cache": "^3.12.1", - "laminas/laminas-cache-storage-adapter-memory": "^2.3.0", - "laminas/laminas-cache-storage-deprecated-factory": "^1.2", + "laminas/laminas-cache": "^3.13.0", + "laminas/laminas-cache-storage-adapter-memory": "^2.4.0", + "laminas/laminas-cache-storage-deprecated-factory": "^1.3", "laminas/laminas-coding-standard": "~2.5.0", - "laminas/laminas-config": "^3.9.0", - "laminas/laminas-eventmanager": "^3.13", - "laminas/laminas-filter": "^2.34", - "laminas/laminas-validator": "^2.49", - "laminas/laminas-view": "^2.34", - "phpunit/phpunit": "^10.5.11", - "psalm/plugin-phpunit": "^0.19.0", - "vimeo/psalm": "^5.22.2" + "laminas/laminas-config": "^3.10.1", + "laminas/laminas-eventmanager": "^3.14.0", + "laminas/laminas-filter": "^2.40", + "laminas/laminas-validator": "^2.64.2", + "laminas/laminas-view": "^2.36", + "phpunit/phpunit": "^10.5.45", + "psalm/plugin-phpunit": "^0.19.5", + "vimeo/psalm": "^6.10.0" }, "suggest": { "laminas/laminas-cache": "You should install this package to cache the translations", @@ -2838,33 +2856,33 @@ "type": "community_bridge" } ], - "time": "2024-10-11T09:44:53+00:00" + "time": "2025-04-15T09:07:02+00:00" }, { "name": "laminas/laminas-i18n-resources", - "version": "2.10.0", + "version": "2.11.0", "source": { "type": "git", "url": "https://github.com/laminas/laminas-i18n-resources.git", - "reference": "f8216359ba16aeab1745660fec38aecf05b780e5" + "reference": "afe7fe066324ad0faaf8824f774f047ce79971c1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laminas/laminas-i18n-resources/zipball/f8216359ba16aeab1745660fec38aecf05b780e5", - "reference": "f8216359ba16aeab1745660fec38aecf05b780e5", + "url": "https://api.github.com/repos/laminas/laminas-i18n-resources/zipball/afe7fe066324ad0faaf8824f774f047ce79971c1", + "reference": "afe7fe066324ad0faaf8824f774f047ce79971c1", "shasum": "" }, "require": { - "php": "~8.1.0 || ~8.2.0 || ~8.3.0" + "php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0" }, "conflict": { "zendframework/zend-i18n-resources": "*" }, "require-dev": { - "laminas/laminas-coding-standard": "~2.5.0", - "phpunit/phpunit": "^10.4.2", - "psalm/plugin-phpunit": "^0.18.4", - "vimeo/psalm": "^5.15" + "laminas/laminas-coding-standard": "~3.0.1", + "phpunit/phpunit": "^10.5.38", + "psalm/plugin-phpunit": "^0.19.0", + "vimeo/psalm": "^5.26.1" }, "type": "library", "autoload": { @@ -2897,39 +2915,40 @@ "type": "community_bridge" } ], - "time": "2023-11-02T15:57:29+00:00" + "time": "2024-11-21T22:04:55+00:00" }, { "name": "laminas/laminas-inputfilter", - "version": "2.30.1", + "version": "2.33.0", "source": { "type": "git", "url": "https://github.com/laminas/laminas-inputfilter.git", - "reference": "f07a908df1052f28b18904d3745cdd5b183938c9" + "reference": "928afe6f5e7c7a17f9c02c40d4feca92944d8e2f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laminas/laminas-inputfilter/zipball/f07a908df1052f28b18904d3745cdd5b183938c9", - "reference": "f07a908df1052f28b18904d3745cdd5b183938c9", + "url": "https://api.github.com/repos/laminas/laminas-inputfilter/zipball/928afe6f5e7c7a17f9c02c40d4feca92944d8e2f", + "reference": "928afe6f5e7c7a17f9c02c40d4feca92944d8e2f", "shasum": "" }, "require": { "laminas/laminas-filter": "^2.19", "laminas/laminas-servicemanager": "^3.21.0", - "laminas/laminas-stdlib": "^3.0", - "laminas/laminas-validator": "^2.52", - "php": "~8.1.0 || ~8.2.0 || ~8.3.0" + "laminas/laminas-stdlib": "^3.19", + "laminas/laminas-validator": "^2.60.0", + "php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0", + "psr/container": "^1.1 || ^2.0" }, "conflict": { "zendframework/zend-inputfilter": "*" }, "require-dev": { "ext-json": "*", - "laminas/laminas-coding-standard": "~2.5.0", - "phpunit/phpunit": "^10.5.15", - "psalm/plugin-phpunit": "^0.19.0", + "laminas/laminas-coding-standard": "^3.1.0", + "phpunit/phpunit": "^10.5.46", + "psalm/plugin-phpunit": "^0.19.5", "psr/http-message": "^2.0", - "vimeo/psalm": "^5.23.1", + "vimeo/psalm": "^6.11.0", "webmozart/assert": "^1.11" }, "suggest": { @@ -2971,31 +2990,31 @@ "type": "community_bridge" } ], - "time": "2024-04-03T15:14:05+00:00" + "time": "2025-05-27T09:48:19+00:00" }, { "name": "laminas/laminas-json", - "version": "3.6.0", + "version": "3.7.1", "source": { "type": "git", "url": "https://github.com/laminas/laminas-json.git", - "reference": "53ff787b20b77197f38680c737e8dfffa846b85b" + "reference": "a0f9dca08e28f39a7a7a7a04370eb2f017369277" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laminas/laminas-json/zipball/53ff787b20b77197f38680c737e8dfffa846b85b", - "reference": "53ff787b20b77197f38680c737e8dfffa846b85b", + "url": "https://api.github.com/repos/laminas/laminas-json/zipball/a0f9dca08e28f39a7a7a7a04370eb2f017369277", + "reference": "a0f9dca08e28f39a7a7a7a04370eb2f017369277", "shasum": "" }, "require": { - "php": "~8.1.0 || ~8.2.0 || ~8.3.0" + "php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0" }, "conflict": { "zendframework/zend-json": "*" }, "require-dev": { "laminas/laminas-coding-standard": "~2.4.0", - "laminas/laminas-stdlib": "^2.7.7 || ^3.8", + "laminas/laminas-stdlib": "^2.7.7 || ^3.19", "phpunit/phpunit": "^9.5.25" }, "suggest": { @@ -3032,36 +3051,38 @@ "type": "community_bridge" } ], - "time": "2023-10-18T09:54:55+00:00" + "abandoned": true, + "time": "2024-12-05T14:51:57+00:00" }, { "name": "laminas/laminas-ldap", - "version": "2.18.1", + "version": "2.19.0", "source": { "type": "git", "url": "https://github.com/laminas/laminas-ldap.git", - "reference": "c337026c6c3555d06bcae37e95ffe92cafcea08a" + "reference": "309f4af659cb254e16fed2ba8f5e26afb479d419" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laminas/laminas-ldap/zipball/c337026c6c3555d06bcae37e95ffe92cafcea08a", - "reference": "c337026c6c3555d06bcae37e95ffe92cafcea08a", + "url": "https://api.github.com/repos/laminas/laminas-ldap/zipball/309f4af659cb254e16fed2ba8f5e26afb479d419", + "reference": "309f4af659cb254e16fed2ba8f5e26afb479d419", "shasum": "" }, "require": { "ext-ldap": "*", - "php": "~8.1.0 || ~8.2.0 || ~8.3.0" + "php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0" }, "conflict": { + "amphp/amp": "<2.6.4", "zendframework/zend-ldap": "*" }, "require-dev": { - "laminas/laminas-coding-standard": "~2.5.0", + "laminas/laminas-coding-standard": "~3.0.0", "laminas/laminas-config": "^3.8.0", "laminas/laminas-eventmanager": "^3.6.0", "laminas/laminas-stdlib": "^3.15.0", - "php-mock/php-mock-phpunit": "^2.6.1", - "phpunit/phpunit": "^9.5.26", + "php-mock/php-mock-phpunit": "^2.8.0", + "phpunit/phpunit": "^10.5.41", "psalm/plugin-phpunit": "^0.18.4", "vimeo/psalm": "^5.16" }, @@ -3098,20 +3119,20 @@ "type": "community_bridge" } ], - "time": "2023-12-06T11:19:05+00:00" + "time": "2025-01-28T21:40:41+00:00" }, { "name": "laminas/laminas-loader", - "version": "2.11.0", + "version": "2.11.1", "source": { "type": "git", "url": "https://github.com/laminas/laminas-loader.git", - "reference": "f2eedd3a6e774d965158fd11946bb1eba72e298c" + "reference": "c507d5eccb969f7208434e3980680a1f6c0b1d8d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laminas/laminas-loader/zipball/f2eedd3a6e774d965158fd11946bb1eba72e298c", - "reference": "f2eedd3a6e774d965158fd11946bb1eba72e298c", + "url": "https://api.github.com/repos/laminas/laminas-loader/zipball/c507d5eccb969f7208434e3980680a1f6c0b1d8d", + "reference": "c507d5eccb969f7208434e3980680a1f6c0b1d8d", "shasum": "" }, "require": { @@ -3154,7 +3175,8 @@ "type": "community_bridge" } ], - "time": "2024-10-16T09:06:57+00:00" + "abandoned": true, + "time": "2024-12-05T14:43:32+00:00" }, { "name": "laminas/laminas-mail", @@ -3234,21 +3256,21 @@ }, { "name": "laminas/laminas-math", - "version": "3.7.0", + "version": "3.8.1", "source": { "type": "git", "url": "https://github.com/laminas/laminas-math.git", - "reference": "3e90445828fd64308de2a600b48c3df051b3b17a" + "reference": "a9e54f68accf5f8a3e66dd01fc6b32180e520018" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laminas/laminas-math/zipball/3e90445828fd64308de2a600b48c3df051b3b17a", - "reference": "3e90445828fd64308de2a600b48c3df051b3b17a", + "url": "https://api.github.com/repos/laminas/laminas-math/zipball/a9e54f68accf5f8a3e66dd01fc6b32180e520018", + "reference": "a9e54f68accf5f8a3e66dd01fc6b32180e520018", "shasum": "" }, "require": { "ext-mbstring": "*", - "php": "~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0" + "php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0" }, "conflict": { "zendframework/zend-math": "*" @@ -3297,7 +3319,8 @@ "type": "community_bridge" } ], - "time": "2023-10-18T09:53:37+00:00" + "abandoned": true, + "time": "2024-12-05T13:49:56+00:00" }, { "name": "laminas/laminas-mime", @@ -3358,20 +3381,21 @@ "type": "community_bridge" } ], + "abandoned": "symfony/mime", "time": "2023-11-02T16:47:19+00:00" }, { "name": "laminas/laminas-modulemanager", - "version": "2.16.0", + "version": "2.17.0", "source": { "type": "git", "url": "https://github.com/laminas/laminas-modulemanager.git", - "reference": "8df7b237d75c04a1bc17b8f7d01eeb601cd7b7e3" + "reference": "3cd6e84ba767b43a47c6c4245a56b30ac3738c6a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laminas/laminas-modulemanager/zipball/8df7b237d75c04a1bc17b8f7d01eeb601cd7b7e3", - "reference": "8df7b237d75c04a1bc17b8f7d01eeb601cd7b7e3", + "url": "https://api.github.com/repos/laminas/laminas-modulemanager/zipball/3cd6e84ba767b43a47c6c4245a56b30ac3738c6a", + "reference": "3cd6e84ba767b43a47c6c4245a56b30ac3738c6a", "shasum": "" }, "require": { @@ -3379,20 +3403,21 @@ "laminas/laminas-config": "^3.7", "laminas/laminas-eventmanager": "^3.4", "laminas/laminas-stdlib": "^3.6", - "php": "~8.1.0 || ~8.2.0|| ~8.3.0", + "php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0", "webimpress/safe-writer": "^1.0.2 || ^2.1" }, "conflict": { + "amphp/amp": "<2.6.4", "zendframework/zend-modulemanager": "*" }, "require-dev": { - "laminas/laminas-coding-standard": "^2.5", - "laminas/laminas-loader": "^2.10", - "laminas/laminas-mvc": "^3.6.1", - "laminas/laminas-servicemanager": "^3.22.1", - "phpunit/phpunit": "^10.4.2", + "laminas/laminas-coding-standard": "^3.0.1", + "laminas/laminas-loader": "^2.11", + "laminas/laminas-mvc": "^3.7.0", + "laminas/laminas-servicemanager": "^3.23.0", + "phpunit/phpunit": "^10.5.38", "psalm/plugin-phpunit": "^0.19.0", - "vimeo/psalm": "^5.15" + "vimeo/psalm": "^5.26.1" }, "suggest": { "laminas/laminas-console": "Laminas\\Console component", @@ -3430,32 +3455,32 @@ "type": "community_bridge" } ], - "time": "2024-06-14T14:44:50+00:00" + "time": "2024-11-17T22:29:29+00:00" }, { "name": "laminas/laminas-mvc", - "version": "3.7.0", + "version": "3.8.0", "source": { "type": "git", "url": "https://github.com/laminas/laminas-mvc.git", - "reference": "3f65447addf487189000e54dc1525cd952951da4" + "reference": "53ba28b7222d3a3b49747a26babef43d1b17fb6f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laminas/laminas-mvc/zipball/3f65447addf487189000e54dc1525cd952951da4", - "reference": "3f65447addf487189000e54dc1525cd952951da4", + "url": "https://api.github.com/repos/laminas/laminas-mvc/zipball/53ba28b7222d3a3b49747a26babef43d1b17fb6f", + "reference": "53ba28b7222d3a3b49747a26babef43d1b17fb6f", "shasum": "" }, "require": { "container-interop/container-interop": "^1.2", "laminas/laminas-eventmanager": "^3.4", "laminas/laminas-http": "^2.15", - "laminas/laminas-modulemanager": "^2.8", + "laminas/laminas-modulemanager": "^2.16", "laminas/laminas-router": "^3.11.1", "laminas/laminas-servicemanager": "^3.20.0", - "laminas/laminas-stdlib": "^3.6", - "laminas/laminas-view": "^2.14", - "php": "~8.1.0 || ~8.2.0 || ~8.3.0" + "laminas/laminas-stdlib": "^3.19", + "laminas/laminas-view": "^2.18.0", + "php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0" }, "conflict": { "zendframework/zend-mvc": "*" @@ -3463,9 +3488,7 @@ "require-dev": { "laminas/laminas-coding-standard": "^2.5.0", "laminas/laminas-json": "^3.6", - "phpspec/prophecy": "^1.17.0", - "phpspec/prophecy-phpunit": "^2.0.2", - "phpunit/phpunit": "^9.6.13", + "phpunit/phpunit": "^10.5.38", "webmozart/assert": "^1.11" }, "suggest": { @@ -3511,7 +3534,7 @@ "type": "community_bridge" } ], - "time": "2023-11-14T09:44:53+00:00" + "time": "2024-11-18T00:14:29+00:00" }, { "name": "laminas/laminas-mvc-i18n", @@ -3593,16 +3616,16 @@ }, { "name": "laminas/laminas-mvc-plugin-flashmessenger", - "version": "1.10.1", + "version": "1.11.0", "source": { "type": "git", "url": "https://github.com/laminas/laminas-mvc-plugin-flashmessenger.git", - "reference": "852d8c661dfb6492d4ea6d4ab238e72180e82387" + "reference": "664822d8d9f259d880ae25b686848421235f96ec" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laminas/laminas-mvc-plugin-flashmessenger/zipball/852d8c661dfb6492d4ea6d4ab238e72180e82387", - "reference": "852d8c661dfb6492d4ea6d4ab238e72180e82387", + "url": "https://api.github.com/repos/laminas/laminas-mvc-plugin-flashmessenger/zipball/664822d8d9f259d880ae25b686848421235f96ec", + "reference": "664822d8d9f259d880ae25b686848421235f96ec", "shasum": "" }, "require": { @@ -3610,18 +3633,18 @@ "laminas/laminas-session": "^2.12.0", "laminas/laminas-stdlib": "^3.6.4", "laminas/laminas-view": "^2.13.1", - "php": "~8.1.0 || ~8.2.0 || ~8.3.0" + "php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0" }, "conflict": { "laminas/laminas-mvc": "<3.0.0", "zendframework/zend-mvc-plugin-flashmessenger": "*" }, "require-dev": { - "laminas/laminas-coding-standard": "~2.5.0", - "laminas/laminas-i18n": "^2.19.0", - "phpunit/phpunit": "^9.5.26", - "psalm/plugin-phpunit": "^0.18.0", - "vimeo/psalm": "^5.0.0" + "laminas/laminas-coding-standard": "~3.0.1", + "laminas/laminas-i18n": "^2.29.0", + "phpunit/phpunit": "^9.6.21", + "psalm/plugin-phpunit": "^0.19.0", + "vimeo/psalm": "^5.26.1" }, "type": "library", "extra": { @@ -3658,27 +3681,27 @@ "type": "community_bridge" } ], - "time": "2024-01-18T15:07:28+00:00" + "time": "2024-11-29T08:28:58+00:00" }, { "name": "laminas/laminas-mvc-plugin-identity", - "version": "1.6.0", + "version": "1.7.0", "source": { "type": "git", "url": "https://github.com/laminas/laminas-mvc-plugin-identity.git", - "reference": "f897cd476a575d861179f5a3ee0a868f4d29e836" + "reference": "2b42edde3fdf34f3074fba2fc2c178c1bc50b95c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laminas/laminas-mvc-plugin-identity/zipball/f897cd476a575d861179f5a3ee0a868f4d29e836", - "reference": "f897cd476a575d861179f5a3ee0a868f4d29e836", + "url": "https://api.github.com/repos/laminas/laminas-mvc-plugin-identity/zipball/2b42edde3fdf34f3074fba2fc2c178c1bc50b95c", + "reference": "2b42edde3fdf34f3074fba2fc2c178c1bc50b95c", "shasum": "" }, "require": { "laminas/laminas-authentication": "^2.11.0", "laminas/laminas-mvc": "^3.3.3", "laminas/laminas-servicemanager": "^3.15.1", - "php": "~8.1.0 || ~8.2.0 || ~8.3.0" + "php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0" }, "conflict": { "laminas/laminas-mvc": "<3.0.0", @@ -3687,7 +3710,7 @@ "require-dev": { "laminas/laminas-coding-standard": "~2.5.0", "phpunit/phpunit": "^10.5", - "psalm/plugin-phpunit": "^0.18.0", + "psalm/plugin-phpunit": "^0.19.0", "vimeo/psalm": "^5.16" }, "type": "library", @@ -3725,7 +3748,7 @@ "type": "community_bridge" } ], - "time": "2023-12-01T06:53:14+00:00" + "time": "2025-01-29T08:03:42+00:00" }, { "name": "laminas/laminas-paginator", @@ -3808,16 +3831,16 @@ }, { "name": "laminas/laminas-progressbar", - "version": "2.14.0", + "version": "2.14.1", "source": { "type": "git", "url": "https://github.com/laminas/laminas-progressbar.git", - "reference": "cb87f8d9a252e9944a4a924b3a7ce1c6687c7007" + "reference": "f5180549455495dc10f4a5f34bfeef2013b141a4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laminas/laminas-progressbar/zipball/cb87f8d9a252e9944a4a924b3a7ce1c6687c7007", - "reference": "cb87f8d9a252e9944a4a924b3a7ce1c6687c7007", + "url": "https://api.github.com/repos/laminas/laminas-progressbar/zipball/f5180549455495dc10f4a5f34bfeef2013b141a4", + "reference": "f5180549455495dc10f4a5f34bfeef2013b141a4", "shasum": "" }, "require": { @@ -3865,7 +3888,8 @@ "type": "community_bridge" } ], - "time": "2024-10-11T10:58:38+00:00" + "abandoned": true, + "time": "2024-12-05T16:32:56+00:00" }, { "name": "laminas/laminas-router", @@ -3940,22 +3964,22 @@ }, { "name": "laminas/laminas-serializer", - "version": "2.17.0", + "version": "2.18.0", "source": { "type": "git", "url": "https://github.com/laminas/laminas-serializer.git", - "reference": "9641dee4208078ff8dfbcdd74048adb0b3ee517e" + "reference": "7f177bc994ceee27acacd80edfad6d7e883a22a1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laminas/laminas-serializer/zipball/9641dee4208078ff8dfbcdd74048adb0b3ee517e", - "reference": "9641dee4208078ff8dfbcdd74048adb0b3ee517e", + "url": "https://api.github.com/repos/laminas/laminas-serializer/zipball/7f177bc994ceee27acacd80edfad6d7e883a22a1", + "reference": "7f177bc994ceee27acacd80edfad6d7e883a22a1", "shasum": "" }, "require": { "laminas/laminas-json": "^3.1", "laminas/laminas-stdlib": "^3.2", - "php": "~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0" + "php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0" }, "conflict": { "zendframework/zend-serializer": "*" @@ -3963,7 +3987,7 @@ "require-dev": { "laminas/laminas-coding-standard": "~2.4.0", "laminas/laminas-math": "^3.6", - "laminas/laminas-servicemanager": "~3.19.0", + "laminas/laminas-servicemanager": "^3.21.0", "phpunit/phpunit": "~9.6.0" }, "suggest": { @@ -4006,25 +4030,25 @@ "type": "community_bridge" } ], - "time": "2023-10-01T15:38:34+00:00" + "time": "2025-01-28T00:47:31+00:00" }, { "name": "laminas/laminas-servicemanager", - "version": "3.22.1", + "version": "3.23.0", "source": { "type": "git", "url": "https://github.com/laminas/laminas-servicemanager.git", - "reference": "de98d297d4743956a0558a6d71616979ff779328" + "reference": "a8640182b892b99767d54404d19c5c3b3699f79b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laminas/laminas-servicemanager/zipball/de98d297d4743956a0558a6d71616979ff779328", - "reference": "de98d297d4743956a0558a6d71616979ff779328", + "url": "https://api.github.com/repos/laminas/laminas-servicemanager/zipball/a8640182b892b99767d54404d19c5c3b3699f79b", + "reference": "a8640182b892b99767d54404d19c5c3b3699f79b", "shasum": "" }, "require": { - "laminas/laminas-stdlib": "^3.17", - "php": "~8.1.0 || ~8.2.0 || ~8.3.0", + "laminas/laminas-stdlib": "^3.19", + "php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0", "psr/container": "^1.0" }, "conflict": { @@ -4041,15 +4065,15 @@ }, "require-dev": { "composer/package-versions-deprecated": "^1.11.99.5", - "friendsofphp/proxy-manager-lts": "^1.0.14", - "laminas/laminas-code": "^4.10.0", + "friendsofphp/proxy-manager-lts": "^1.0.18", + "laminas/laminas-code": "^4.14.0", "laminas/laminas-coding-standard": "~2.5.0", "laminas/laminas-container-config-test": "^0.8", - "mikey179/vfsstream": "^1.6.11", - "phpbench/phpbench": "^1.2.9", - "phpunit/phpunit": "^10.4", + "mikey179/vfsstream": "^1.6.12", + "phpbench/phpbench": "^1.3.1", + "phpunit/phpunit": "^10.5.36", "psalm/plugin-phpunit": "^0.18.4", - "vimeo/psalm": "^5.8.0" + "vimeo/psalm": "^5.26.1" }, "suggest": { "friendsofphp/proxy-manager-lts": "ProxyManager ^2.1.1 to handle lazy initialization of services" @@ -4096,43 +4120,44 @@ "type": "community_bridge" } ], - "time": "2023-10-24T11:19:47+00:00" + "time": "2024-10-28T21:32:16+00:00" }, { "name": "laminas/laminas-session", - "version": "2.21.0", + "version": "2.24.0", "source": { "type": "git", "url": "https://github.com/laminas/laminas-session.git", - "reference": "b8cd890f7682a255b335c2ca45df9a7cbc58873d" + "reference": "487b6debacd3e029e27cbed7ce495b1328908dab" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laminas/laminas-session/zipball/b8cd890f7682a255b335c2ca45df9a7cbc58873d", - "reference": "b8cd890f7682a255b335c2ca45df9a7cbc58873d", + "url": "https://api.github.com/repos/laminas/laminas-session/zipball/487b6debacd3e029e27cbed7ce495b1328908dab", + "reference": "487b6debacd3e029e27cbed7ce495b1328908dab", "shasum": "" }, "require": { "laminas/laminas-eventmanager": "^3.12", "laminas/laminas-servicemanager": "^3.22", "laminas/laminas-stdlib": "^3.18", - "php": "~8.1.0 || ~8.2.0 || ~8.3.0" + "php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0" }, "conflict": { + "amphp/amp": "<2.6.4", "zendframework/zend-session": "*" }, "require-dev": { "ext-xdebug": "*", "laminas/laminas-cache": "^3.12.2", "laminas/laminas-cache-storage-adapter-memory": "^2.3", - "laminas/laminas-coding-standard": "~2.5.0", + "laminas/laminas-coding-standard": "~3.0.1", "laminas/laminas-db": "^2.20.0", - "laminas/laminas-http": "^2.19", - "laminas/laminas-validator": "^2.57.0", - "mongodb/mongodb": "~1.17.1", - "phpunit/phpunit": "^9.6.19", + "laminas/laminas-http": "^2.20", + "laminas/laminas-validator": "^2.64.1", + "mongodb/mongodb": "~1.20.0", + "phpunit/phpunit": "^10.5.38", "psalm/plugin-phpunit": "^0.19.0", - "vimeo/psalm": "^5.24.0" + "vimeo/psalm": "^5.26.1" }, "suggest": { "laminas/laminas-cache": "Laminas\\Cache component", @@ -4178,34 +4203,34 @@ "type": "community_bridge" } ], - "time": "2024-06-19T14:36:45+00:00" + "time": "2025-02-05T10:39:08+00:00" }, { "name": "laminas/laminas-stdlib", - "version": "3.19.0", + "version": "3.20.0", "source": { "type": "git", "url": "https://github.com/laminas/laminas-stdlib.git", - "reference": "6a192dd0882b514e45506f533b833b623b78fff3" + "reference": "8974a1213be42c3e2f70b2c27b17f910291ab2f4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laminas/laminas-stdlib/zipball/6a192dd0882b514e45506f533b833b623b78fff3", - "reference": "6a192dd0882b514e45506f533b833b623b78fff3", + "url": "https://api.github.com/repos/laminas/laminas-stdlib/zipball/8974a1213be42c3e2f70b2c27b17f910291ab2f4", + "reference": "8974a1213be42c3e2f70b2c27b17f910291ab2f4", "shasum": "" }, "require": { - "php": "~8.1.0 || ~8.2.0 || ~8.3.0" + "php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0" }, "conflict": { "zendframework/zend-stdlib": "*" }, "require-dev": { - "laminas/laminas-coding-standard": "^2.5", - "phpbench/phpbench": "^1.2.15", - "phpunit/phpunit": "^10.5.8", - "psalm/plugin-phpunit": "^0.18.4", - "vimeo/psalm": "^5.20.0" + "laminas/laminas-coding-standard": "^3.0", + "phpbench/phpbench": "^1.3.1", + "phpunit/phpunit": "^10.5.38", + "psalm/plugin-phpunit": "^0.19.0", + "vimeo/psalm": "^5.26.1" }, "type": "library", "autoload": { @@ -4237,27 +4262,27 @@ "type": "community_bridge" } ], - "time": "2024-01-19T12:39:49+00:00" + "time": "2024-10-29T13:46:07+00:00" }, { "name": "laminas/laminas-translator", - "version": "1.0.0", + "version": "1.1.0", "source": { "type": "git", "url": "https://github.com/laminas/laminas-translator.git", - "reference": "86d176c01a96b0ef205192b776cb69e8d4ca06b1" + "reference": "12897e710e21413c1f93fc38fe9dead6b51c5218" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laminas/laminas-translator/zipball/86d176c01a96b0ef205192b776cb69e8d4ca06b1", - "reference": "86d176c01a96b0ef205192b776cb69e8d4ca06b1", + "url": "https://api.github.com/repos/laminas/laminas-translator/zipball/12897e710e21413c1f93fc38fe9dead6b51c5218", + "reference": "12897e710e21413c1f93fc38fe9dead6b51c5218", "shasum": "" }, "require": { - "php": "~8.1.0 || ~8.2.0 || ~8.3.0" + "php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0" }, "require-dev": { - "laminas/laminas-coding-standard": "~2.5.0", + "laminas/laminas-coding-standard": "~3.0.0", "vimeo/psalm": "^5.24.0" }, "type": "library", @@ -4290,25 +4315,25 @@ "type": "community_bridge" } ], - "time": "2024-06-18T15:09:24+00:00" + "time": "2024-10-21T15:33:01+00:00" }, { "name": "laminas/laminas-uri", - "version": "2.12.0", + "version": "2.13.0", "source": { "type": "git", "url": "https://github.com/laminas/laminas-uri.git", - "reference": "95a41a7592bacf4c648648a88b7c94b0c5c22b9e" + "reference": "de53600ae8153b3605bb6edce8aeeef524eaafba" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laminas/laminas-uri/zipball/95a41a7592bacf4c648648a88b7c94b0c5c22b9e", - "reference": "95a41a7592bacf4c648648a88b7c94b0c5c22b9e", + "url": "https://api.github.com/repos/laminas/laminas-uri/zipball/de53600ae8153b3605bb6edce8aeeef524eaafba", + "reference": "de53600ae8153b3605bb6edce8aeeef524eaafba", "shasum": "" }, "require": { "laminas/laminas-escaper": "^2.9", - "laminas/laminas-validator": "^2.39", + "laminas/laminas-validator": "^2.39 || ^3.0", "php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0" }, "conflict": { @@ -4348,20 +4373,20 @@ "type": "community_bridge" } ], - "time": "2024-08-03T21:22:51+00:00" + "time": "2024-12-03T12:27:51+00:00" }, { "name": "laminas/laminas-validator", - "version": "2.64.1", + "version": "2.64.4", "source": { "type": "git", "url": "https://github.com/laminas/laminas-validator.git", - "reference": "9db115056b666b7540c951b6d4477b8e0b52b9ad" + "reference": "e2e6631f599a9b0db1e23adb633c09a2f0c68bed" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laminas/laminas-validator/zipball/9db115056b666b7540c951b6d4477b8e0b52b9ad", - "reference": "9db115056b666b7540c951b6d4477b8e0b52b9ad", + "url": "https://api.github.com/repos/laminas/laminas-validator/zipball/e2e6631f599a9b0db1e23adb633c09a2f0c68bed", + "reference": "e2e6631f599a9b0db1e23adb633c09a2f0c68bed", "shasum": "" }, "require": { @@ -4432,20 +4457,20 @@ "type": "community_bridge" } ], - "time": "2024-08-01T09:32:54+00:00" + "time": "2025-06-16T14:38:00+00:00" }, { "name": "laminas/laminas-view", - "version": "2.35.0", + "version": "2.39.0", "source": { "type": "git", "url": "https://github.com/laminas/laminas-view.git", - "reference": "f597148345dd406fb9d04d391a19c0c33bf71605" + "reference": "673f56af99b1780dc6babc3028d75724177969ed" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laminas/laminas-view/zipball/f597148345dd406fb9d04d391a19c0c33bf71605", - "reference": "f597148345dd406fb9d04d391a19c0c33bf71605", + "url": "https://api.github.com/repos/laminas/laminas-view/zipball/673f56af99b1780dc6babc3028d75724177969ed", + "reference": "673f56af99b1780dc6babc3028d75724177969ed", "shasum": "" }, "require": { @@ -4457,34 +4482,36 @@ "laminas/laminas-json": "^3.3", "laminas/laminas-servicemanager": "^3.21.0", "laminas/laminas-stdlib": "^3.10.1", - "php": "~8.1.0 || ~8.2.0 || ~8.3.0", + "php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0", "psr/container": "^1 || ^2" }, "conflict": { + "amphp/dns": "<2.1.2", + "amphp/socket": "<2.3.1", "container-interop/container-interop": "<1.2", "laminas/laminas-router": "<3.0.1", "laminas/laminas-session": "<2.12", "zendframework/zend-view": "*" }, "require-dev": { - "laminas/laminas-authentication": "^2.16", - "laminas/laminas-coding-standard": "~2.5.0", - "laminas/laminas-feed": "^2.22", - "laminas/laminas-filter": "^2.34", - "laminas/laminas-http": "^2.19", - "laminas/laminas-i18n": "^2.26.0", - "laminas/laminas-modulemanager": "^2.15", - "laminas/laminas-mvc": "^3.7.0", - "laminas/laminas-mvc-i18n": "^1.8", - "laminas/laminas-mvc-plugin-flashmessenger": "^1.10.1", - "laminas/laminas-navigation": "^2.19.1", - "laminas/laminas-paginator": "^2.18.1", - "laminas/laminas-permissions-acl": "^2.16", - "laminas/laminas-router": "^3.13.0", - "laminas/laminas-uri": "^2.11", - "phpunit/phpunit": "^10.5.13", - "psalm/plugin-phpunit": "^0.19.0", - "vimeo/psalm": "^5.23.1" + "laminas/laminas-authentication": "^2.18", + "laminas/laminas-coding-standard": "~3.0.1", + "laminas/laminas-feed": "^2.23", + "laminas/laminas-filter": "^2.40", + "laminas/laminas-http": "^2.21", + "laminas/laminas-i18n": "^2.30.0", + "laminas/laminas-modulemanager": "^2.17", + "laminas/laminas-mvc": "^3.8.0", + "laminas/laminas-mvc-i18n": "^1.9", + "laminas/laminas-mvc-plugin-flashmessenger": "^1.11.0", + "laminas/laminas-navigation": "^2.20.0", + "laminas/laminas-paginator": "^2.19.0", + "laminas/laminas-permissions-acl": "^2.17", + "laminas/laminas-router": "^3.14.0", + "laminas/laminas-uri": "^2.13", + "phpunit/phpunit": "^10.5.45", + "psalm/plugin-phpunit": "^0.19.5", + "vimeo/psalm": "^6.10.1" }, "suggest": { "laminas/laminas-authentication": "Laminas\\Authentication component", @@ -4532,7 +4559,7 @@ "type": "community_bridge" } ], - "time": "2024-06-04T06:44:31+00:00" + "time": "2025-04-30T08:01:59+00:00" }, { "name": "monolog/monolog", @@ -5088,47 +5115,47 @@ }, { "name": "symfony/console", - "version": "v6.4.12", + "version": "v7.3.0", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "72d080eb9edf80e36c19be61f72c98ed8273b765" + "reference": "66c1440edf6f339fd82ed6c7caa76cb006211b44" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/72d080eb9edf80e36c19be61f72c98ed8273b765", - "reference": "72d080eb9edf80e36c19be61f72c98ed8273b765", + "url": "https://api.github.com/repos/symfony/console/zipball/66c1440edf6f339fd82ed6c7caa76cb006211b44", + "reference": "66c1440edf6f339fd82ed6c7caa76cb006211b44", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-mbstring": "~1.0", "symfony/service-contracts": "^2.5|^3", - "symfony/string": "^5.4|^6.0|^7.0" + "symfony/string": "^7.2" }, "conflict": { - "symfony/dependency-injection": "<5.4", - "symfony/dotenv": "<5.4", - "symfony/event-dispatcher": "<5.4", - "symfony/lock": "<5.4", - "symfony/process": "<5.4" + "symfony/dependency-injection": "<6.4", + "symfony/dotenv": "<6.4", + "symfony/event-dispatcher": "<6.4", + "symfony/lock": "<6.4", + "symfony/process": "<6.4" }, "provide": { "psr/log-implementation": "1.0|2.0|3.0" }, "require-dev": { "psr/log": "^1|^2|^3", - "symfony/config": "^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/config": "^6.4|^7.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/event-dispatcher": "^6.4|^7.0", "symfony/http-foundation": "^6.4|^7.0", "symfony/http-kernel": "^6.4|^7.0", - "symfony/lock": "^5.4|^6.0|^7.0", - "symfony/messenger": "^5.4|^6.0|^7.0", - "symfony/process": "^5.4|^6.0|^7.0", - "symfony/stopwatch": "^5.4|^6.0|^7.0", - "symfony/var-dumper": "^5.4|^6.0|^7.0" + "symfony/lock": "^6.4|^7.0", + "symfony/messenger": "^6.4|^7.0", + "symfony/process": "^6.4|^7.0", + "symfony/stopwatch": "^6.4|^7.0", + "symfony/var-dumper": "^6.4|^7.0" }, "type": "library", "autoload": { @@ -5162,7 +5189,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v6.4.12" + "source": "https://github.com/symfony/console/tree/v7.3.0" }, "funding": [ { @@ -5178,20 +5205,20 @@ "type": "tidelift" } ], - "time": "2024-09-20T08:15:52+00:00" + "time": "2025-05-24T10:34:04+00:00" }, { "name": "symfony/deprecation-contracts", - "version": "v3.5.0", + "version": "v3.6.0", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "0e0d29ce1f20deffb4ab1b016a7257c4f1e789a1" + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/0e0d29ce1f20deffb4ab1b016a7257c4f1e789a1", - "reference": "0e0d29ce1f20deffb4ab1b016a7257c4f1e789a1", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", "shasum": "" }, "require": { @@ -5199,12 +5226,12 @@ }, "type": "library", "extra": { - "branch-alias": { - "dev-main": "3.5-dev" - }, "thanks": { - "name": "symfony/contracts", - "url": "https://github.com/symfony/contracts" + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" } }, "autoload": { @@ -5229,7 +5256,7 @@ "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.5.0" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0" }, "funding": [ { @@ -5245,20 +5272,20 @@ "type": "tidelift" } ], - "time": "2024-04-18T09:32:20+00:00" + "time": "2024-09-25T14:21:43+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v7.1.1", + "version": "v7.3.0", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "9fa7f7a21beb22a39a8f3f28618b29e50d7a55a7" + "reference": "497f73ac996a598c92409b44ac43b6690c4f666d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/9fa7f7a21beb22a39a8f3f28618b29e50d7a55a7", - "reference": "9fa7f7a21beb22a39a8f3f28618b29e50d7a55a7", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/497f73ac996a598c92409b44ac43b6690c4f666d", + "reference": "497f73ac996a598c92409b44ac43b6690c4f666d", "shasum": "" }, "require": { @@ -5309,7 +5336,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v7.1.1" + "source": "https://github.com/symfony/event-dispatcher/tree/v7.3.0" }, "funding": [ { @@ -5325,20 +5352,20 @@ "type": "tidelift" } ], - "time": "2024-05-31T14:57:53+00:00" + "time": "2025-04-22T09:11:45+00:00" }, { "name": "symfony/event-dispatcher-contracts", - "version": "v3.5.0", + "version": "v3.6.0", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher-contracts.git", - "reference": "8f93aec25d41b72493c6ddff14e916177c9efc50" + "reference": "59eb412e93815df44f05f342958efa9f46b1e586" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/8f93aec25d41b72493c6ddff14e916177c9efc50", - "reference": "8f93aec25d41b72493c6ddff14e916177c9efc50", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/59eb412e93815df44f05f342958efa9f46b1e586", + "reference": "59eb412e93815df44f05f342958efa9f46b1e586", "shasum": "" }, "require": { @@ -5347,12 +5374,12 @@ }, "type": "library", "extra": { - "branch-alias": { - "dev-main": "3.5-dev" - }, "thanks": { - "name": "symfony/contracts", - "url": "https://github.com/symfony/contracts" + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" } }, "autoload": { @@ -5385,7 +5412,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.5.0" + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.6.0" }, "funding": [ { @@ -5401,11 +5428,11 @@ "type": "tidelift" } ], - "time": "2024-04-18T09:32:20+00:00" + "time": "2024-09-25T14:21:43+00:00" }, { "name": "symfony/polyfill-ctype", - "version": "v1.31.0", + "version": "v1.32.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", @@ -5429,8 +5456,8 @@ "type": "library", "extra": { "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { @@ -5464,7 +5491,7 @@ "portable" ], "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.32.0" }, "funding": [ { @@ -5484,7 +5511,7 @@ }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.31.0", + "version": "v1.32.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", @@ -5505,8 +5532,8 @@ "type": "library", "extra": { "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { @@ -5542,7 +5569,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.32.0" }, "funding": [ { @@ -5562,16 +5589,16 @@ }, { "name": "symfony/polyfill-intl-idn", - "version": "v1.31.0", + "version": "v1.32.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-idn.git", - "reference": "c36586dcf89a12315939e00ec9b4474adcb1d773" + "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/c36586dcf89a12315939e00ec9b4474adcb1d773", - "reference": "c36586dcf89a12315939e00ec9b4474adcb1d773", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/9614ac4d8061dc257ecc64cba1b140873dce8ad3", + "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3", "shasum": "" }, "require": { @@ -5584,8 +5611,8 @@ "type": "library", "extra": { "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { @@ -5625,7 +5652,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.32.0" }, "funding": [ { @@ -5641,11 +5668,11 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2024-09-10T14:38:51+00:00" }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.31.0", + "version": "v1.32.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", @@ -5666,8 +5693,8 @@ "type": "library", "extra": { "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { @@ -5706,7 +5733,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.32.0" }, "funding": [ { @@ -5726,19 +5753,20 @@ }, { "name": "symfony/polyfill-mbstring", - "version": "v1.31.0", + "version": "v1.32.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341" + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/85181ba99b2345b0ef10ce42ecac37612d9fd341", - "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493", + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493", "shasum": "" }, "require": { + "ext-iconv": "*", "php": ">=7.2" }, "provide": { @@ -5750,8 +5778,8 @@ "type": "library", "extra": { "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { @@ -5786,7 +5814,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.32.0" }, "funding": [ { @@ -5802,7 +5830,7 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2024-12-23T08:48:59+00:00" }, { "name": "symfony/polyfill-php72", @@ -5824,8 +5852,8 @@ "type": "metapackage", "extra": { "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "notification-url": "https://packagist.org/downloads/", @@ -5871,16 +5899,16 @@ }, { "name": "symfony/polyfill-php80", - "version": "v1.31.0", + "version": "v1.32.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "60328e362d4c2c802a54fcbf04f9d3fb892b4cf8" + "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/60328e362d4c2c802a54fcbf04f9d3fb892b4cf8", - "reference": "60328e362d4c2c802a54fcbf04f9d3fb892b4cf8", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/0cc9dd0f17f61d8131e7df6b84bd344899fe2608", + "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608", "shasum": "" }, "require": { @@ -5889,8 +5917,8 @@ "type": "library", "extra": { "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { @@ -5931,7 +5959,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-php80/tree/v1.32.0" }, "funding": [ { @@ -5947,20 +5975,96 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2025-01-02T08:10:11+00:00" + }, + { + "name": "symfony/polyfill-php84", + "version": "v1.32.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php84.git", + "reference": "000df7860439609837bbe28670b0be15783b7fbf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/000df7860439609837bbe28670b0be15783b7fbf", + "reference": "000df7860439609837bbe28670b0be15783b7fbf", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php84\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php84/tree/v1.32.0" + }, + "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": "2025-02-20T12:04:08+00:00" }, { "name": "symfony/service-contracts", - "version": "v3.5.0", + "version": "v3.6.0", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "bd1d9e59a81d8fa4acdcea3f617c581f7475a80f" + "reference": "f021b05a130d35510bd6b25fe9053c2a8a15d5d4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/bd1d9e59a81d8fa4acdcea3f617c581f7475a80f", - "reference": "bd1d9e59a81d8fa4acdcea3f617c581f7475a80f", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/f021b05a130d35510bd6b25fe9053c2a8a15d5d4", + "reference": "f021b05a130d35510bd6b25fe9053c2a8a15d5d4", "shasum": "" }, "require": { @@ -5973,12 +6077,12 @@ }, "type": "library", "extra": { - "branch-alias": { - "dev-main": "3.5-dev" - }, "thanks": { - "name": "symfony/contracts", - "url": "https://github.com/symfony/contracts" + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" } }, "autoload": { @@ -6014,7 +6118,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.5.0" + "source": "https://github.com/symfony/service-contracts/tree/v3.6.0" }, "funding": [ { @@ -6030,20 +6134,20 @@ "type": "tidelift" } ], - "time": "2024-04-18T09:32:20+00:00" + "time": "2025-04-25T09:37:31+00:00" }, { "name": "symfony/stopwatch", - "version": "v7.1.1", + "version": "v7.3.0", "source": { "type": "git", "url": "https://github.com/symfony/stopwatch.git", - "reference": "5b75bb1ac2ba1b9d05c47fc4b3046a625377d23d" + "reference": "5a49289e2b308214c8b9c2fda4ea454d8b8ad7cd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/stopwatch/zipball/5b75bb1ac2ba1b9d05c47fc4b3046a625377d23d", - "reference": "5b75bb1ac2ba1b9d05c47fc4b3046a625377d23d", + "url": "https://api.github.com/repos/symfony/stopwatch/zipball/5a49289e2b308214c8b9c2fda4ea454d8b8ad7cd", + "reference": "5a49289e2b308214c8b9c2fda4ea454d8b8ad7cd", "shasum": "" }, "require": { @@ -6076,7 +6180,7 @@ "description": "Provides a way to profile code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/stopwatch/tree/v7.1.1" + "source": "https://github.com/symfony/stopwatch/tree/v7.3.0" }, "funding": [ { @@ -6092,20 +6196,20 @@ "type": "tidelift" } ], - "time": "2024-05-31T14:57:53+00:00" + "time": "2025-02-24T10:49:57+00:00" }, { "name": "symfony/string", - "version": "v7.1.5", + "version": "v7.3.0", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "d66f9c343fa894ec2037cc928381df90a7ad4306" + "reference": "f3570b8c61ca887a9e2938e85cb6458515d2b125" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/d66f9c343fa894ec2037cc928381df90a7ad4306", - "reference": "d66f9c343fa894ec2037cc928381df90a7ad4306", + "url": "https://api.github.com/repos/symfony/string/zipball/f3570b8c61ca887a9e2938e85cb6458515d2b125", + "reference": "f3570b8c61ca887a9e2938e85cb6458515d2b125", "shasum": "" }, "require": { @@ -6163,7 +6267,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v7.1.5" + "source": "https://github.com/symfony/string/tree/v7.3.0" }, "funding": [ { @@ -6179,24 +6283,25 @@ "type": "tidelift" } ], - "time": "2024-09-20T08:28:38+00:00" + "time": "2025-04-20T20:19:01+00:00" }, { "name": "symfony/var-exporter", - "version": "v7.1.2", + "version": "v7.3.0", "source": { "type": "git", "url": "https://github.com/symfony/var-exporter.git", - "reference": "b80a669a2264609f07f1667f891dbfca25eba44c" + "reference": "c9a1168891b5aaadfd6332ef44393330b3498c4c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-exporter/zipball/b80a669a2264609f07f1667f891dbfca25eba44c", - "reference": "b80a669a2264609f07f1667f891dbfca25eba44c", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/c9a1168891b5aaadfd6332ef44393330b3498c4c", + "reference": "c9a1168891b5aaadfd6332ef44393330b3498c4c", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3" }, "require-dev": { "symfony/property-access": "^6.4|^7.0", @@ -6239,7 +6344,7 @@ "serialize" ], "support": { - "source": "https://github.com/symfony/var-exporter/tree/v7.1.2" + "source": "https://github.com/symfony/var-exporter/tree/v7.3.0" }, "funding": [ { @@ -6255,7 +6360,7 @@ "type": "tidelift" } ], - "time": "2024-06-28T08:00:31+00:00" + "time": "2025-05-15T09:04:05+00:00" }, { "name": "webimpress/safe-writer", @@ -6538,21 +6643,21 @@ }, { "name": "bnf/phpstan-psr-container", - "version": "1.0.1", + "version": "1.1.0", "source": { "type": "git", "url": "https://github.com/bnf/phpstan-psr-container.git", - "reference": "38242e71616aa677a0fdd08af8edc4a730a24f67" + "reference": "2e2fd1973576bdc755ea814b2142fcdbc673fd1c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/bnf/phpstan-psr-container/zipball/38242e71616aa677a0fdd08af8edc4a730a24f67", - "reference": "38242e71616aa677a0fdd08af8edc4a730a24f67", + "url": "https://api.github.com/repos/bnf/phpstan-psr-container/zipball/2e2fd1973576bdc755ea814b2142fcdbc673fd1c", + "reference": "2e2fd1973576bdc755ea814b2142fcdbc673fd1c", "shasum": "" }, "require": { "php": "^7.0|^8.0", - "phpstan/phpstan": "^1.0", + "phpstan/phpstan": "^1.0|^2.0", "psr/container": "^1.0|^2.0" }, "type": "phpstan-extension", @@ -6589,9 +6694,9 @@ ], "support": { "issues": "https://github.com/bnf/phpstan-psr-container/issues", - "source": "https://github.com/bnf/phpstan-psr-container/tree/1.0.1" + "source": "https://github.com/bnf/phpstan-psr-container/tree/1.1.0" }, - "time": "2022-04-26T04:03:19+00:00" + "time": "2024-11-19T16:16:59+00:00" }, { "name": "clue/ndjson-react", @@ -6705,23 +6810,23 @@ }, { "name": "composer-unused/symbol-parser", - "version": "0.2.5", + "version": "0.2.8", "source": { "type": "git", "url": "https://github.com/composer-unused/symbol-parser.git", - "reference": "96cee7244aea405e936247d42c49332d52d90ae7" + "reference": "7576ca41ca6ebd46b3c4f18d6adb6f1b340bb694" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer-unused/symbol-parser/zipball/96cee7244aea405e936247d42c49332d52d90ae7", - "reference": "96cee7244aea405e936247d42c49332d52d90ae7", + "url": "https://api.github.com/repos/composer-unused/symbol-parser/zipball/7576ca41ca6ebd46b3c4f18d6adb6f1b340bb694", + "reference": "7576ca41ca6ebd46b3c4f18d6adb6f1b340bb694", "shasum": "" }, "require": { "composer-unused/contracts": "^0.3", "nikic/php-parser": "^4.18 || ^5.0", "php": "^7.4 || ^8.0", - "phpstan/phpdoc-parser": "^1.25", + "phpstan/phpdoc-parser": "^1.25 || ^2", "psr/container": "^1.0 || ^2.0", "psr/log": "^1.1 || ^2 || ^3", "symfony/finder": "^5.3 || ^6.0 || ^7.0" @@ -6772,7 +6877,7 @@ "type": "other" } ], - "time": "2024-03-09T15:25:51+00:00" + "time": "2025-03-17T14:27:54+00:00" }, { "name": "composer/package-versions-deprecated", @@ -6849,16 +6954,16 @@ }, { "name": "composer/pcre", - "version": "3.3.1", + "version": "3.3.2", "source": { "type": "git", "url": "https://github.com/composer/pcre.git", - "reference": "63aaeac21d7e775ff9bc9d45021e1745c97521c4" + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/pcre/zipball/63aaeac21d7e775ff9bc9d45021e1745c97521c4", - "reference": "63aaeac21d7e775ff9bc9d45021e1745c97521c4", + "url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e", "shasum": "" }, "require": { @@ -6868,19 +6973,19 @@ "phpstan/phpstan": "<1.11.10" }, "require-dev": { - "phpstan/phpstan": "^1.11.10", - "phpstan/phpstan-strict-rules": "^1.1", + "phpstan/phpstan": "^1.12 || ^2", + "phpstan/phpstan-strict-rules": "^1 || ^2", "phpunit/phpunit": "^8 || ^9" }, "type": "library", "extra": { - "branch-alias": { - "dev-main": "3.x-dev" - }, "phpstan": { "includes": [ "extension.neon" ] + }, + "branch-alias": { + "dev-main": "3.x-dev" } }, "autoload": { @@ -6908,7 +7013,7 @@ ], "support": { "issues": "https://github.com/composer/pcre/issues", - "source": "https://github.com/composer/pcre/tree/3.3.1" + "source": "https://github.com/composer/pcre/tree/3.3.2" }, "funding": [ { @@ -6924,7 +7029,7 @@ "type": "tidelift" } ], - "time": "2024-08-27T18:44:43+00:00" + "time": "2024-11-12T16:29:46+00:00" }, { "name": "composer/xdebug-handler", @@ -7318,16 +7423,16 @@ }, { "name": "friendsofphp/php-cs-fixer", - "version": "v3.64.0", + "version": "v3.75.0", "source": { "type": "git", "url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git", - "reference": "58dd9c931c785a79739310aef5178928305ffa67" + "reference": "399a128ff2fdaf4281e4e79b755693286cdf325c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/58dd9c931c785a79739310aef5178928305ffa67", - "reference": "58dd9c931c785a79739310aef5178928305ffa67", + "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/399a128ff2fdaf4281e4e79b755693286cdf325c", + "reference": "399a128ff2fdaf4281e4e79b755693286cdf325c", "shasum": "" }, "require": { @@ -7335,40 +7440,41 @@ "composer/semver": "^3.4", "composer/xdebug-handler": "^3.0.3", "ext-filter": "*", + "ext-hash": "*", "ext-json": "*", "ext-tokenizer": "*", - "fidry/cpu-core-counter": "^1.0", + "fidry/cpu-core-counter": "^1.2", "php": "^7.4 || ^8.0", "react/child-process": "^0.6.5", "react/event-loop": "^1.0", "react/promise": "^2.0 || ^3.0", "react/socket": "^1.0", "react/stream": "^1.0", - "sebastian/diff": "^4.0 || ^5.0 || ^6.0", - "symfony/console": "^5.4 || ^6.0 || ^7.0", - "symfony/event-dispatcher": "^5.4 || ^6.0 || ^7.0", - "symfony/filesystem": "^5.4 || ^6.0 || ^7.0", - "symfony/finder": "^5.4 || ^6.0 || ^7.0", - "symfony/options-resolver": "^5.4 || ^6.0 || ^7.0", - "symfony/polyfill-mbstring": "^1.28", - "symfony/polyfill-php80": "^1.28", - "symfony/polyfill-php81": "^1.28", - "symfony/process": "^5.4 || ^6.0 || ^7.0", - "symfony/stopwatch": "^5.4 || ^6.0 || ^7.0" - }, - "require-dev": { - "facile-it/paraunit": "^1.3 || ^2.3", - "infection/infection": "^0.29.5", - "justinrainbow/json-schema": "^5.2", + "sebastian/diff": "^4.0 || ^5.1 || ^6.0 || ^7.0", + "symfony/console": "^5.4 || ^6.4 || ^7.0", + "symfony/event-dispatcher": "^5.4 || ^6.4 || ^7.0", + "symfony/filesystem": "^5.4 || ^6.4 || ^7.0", + "symfony/finder": "^5.4 || ^6.4 || ^7.0", + "symfony/options-resolver": "^5.4 || ^6.4 || ^7.0", + "symfony/polyfill-mbstring": "^1.31", + "symfony/polyfill-php80": "^1.31", + "symfony/polyfill-php81": "^1.31", + "symfony/process": "^5.4 || ^6.4 || ^7.2", + "symfony/stopwatch": "^5.4 || ^6.4 || ^7.0" + }, + "require-dev": { + "facile-it/paraunit": "^1.3.1 || ^2.6", + "infection/infection": "^0.29.14", + "justinrainbow/json-schema": "^5.3 || ^6.2", "keradus/cli-executor": "^2.1", - "mikey179/vfsstream": "^1.6.11", + "mikey179/vfsstream": "^1.6.12", "php-coveralls/php-coveralls": "^2.7", "php-cs-fixer/accessible-object": "^1.1", - "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.5", - "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.5", - "phpunit/phpunit": "^9.6.19 || ^10.5.21 || ^11.2", - "symfony/var-dumper": "^5.4 || ^6.0 || ^7.0", - "symfony/yaml": "^5.4 || ^6.0 || ^7.0" + "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.6", + "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.6", + "phpunit/phpunit": "^9.6.22 || ^10.5.45 || ^11.5.12", + "symfony/var-dumper": "^5.4.48 || ^6.4.18 || ^7.2.3", + "symfony/yaml": "^5.4.45 || ^6.4.18 || ^7.2.3" }, "suggest": { "ext-dom": "For handling output formats in XML", @@ -7409,7 +7515,7 @@ ], "support": { "issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues", - "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.64.0" + "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.75.0" }, "funding": [ { @@ -7417,15 +7523,15 @@ "type": "github" } ], - "time": "2024-08-30T23:09:38+00:00" + "time": "2025-03-31T18:40:42+00:00" }, { "name": "gewis/gewisphp-coding-standards", - "version": "1.2.0", + "version": "1.2.1", "source": { "type": "git", "url": "https://github.com/GEWIS/gewisphp-coding-standards.git", - "reference": "39edf4a7d0410a9aba52ce8e60ea2653b91fa1b9" + "reference": "7915130290b3aceaf09b95665e453ad42dd71baa" }, "require": { "dealerdirect/phpcodesniffer-composer-installer": "^1.0.0", @@ -7449,7 +7555,7 @@ "gewis", "standard" ], - "time": "2024-09-11T13:36:50+00:00" + "time": "2025-02-22T15:24:48+00:00" }, { "name": "icanhazstring/composer-unused", @@ -7552,32 +7658,32 @@ }, { "name": "laminas/laminas-component-installer", - "version": "3.4.0", + "version": "3.5.0", "source": { "type": "git", "url": "https://github.com/laminas/laminas-component-installer.git", - "reference": "e4c15d50c5dcbe0207285659f083df70bb256bb6" + "reference": "8752d73b5df8c368ec0bf2aff2b32e00e0ac8b1b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laminas/laminas-component-installer/zipball/e4c15d50c5dcbe0207285659f083df70bb256bb6", - "reference": "e4c15d50c5dcbe0207285659f083df70bb256bb6", + "url": "https://api.github.com/repos/laminas/laminas-component-installer/zipball/8752d73b5df8c368ec0bf2aff2b32e00e0ac8b1b", + "reference": "8752d73b5df8c368ec0bf2aff2b32e00e0ac8b1b", "shasum": "" }, "require": { "composer-plugin-api": "^2.0", - "php": "~8.1.0 || ~8.2.0 || ~8.3.0" + "php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0" }, "conflict": { "zendframework/zend-component-installer": "*" }, "require-dev": { - "composer/composer": "^2.6.4", - "laminas/laminas-coding-standard": "~2.5.0", + "composer/composer": "^2.7.7", + "laminas/laminas-coding-standard": "~3.0.0", "mikey179/vfsstream": "^1.6.11", - "phpunit/phpunit": "^10.4", - "psalm/plugin-phpunit": "^0.18.0", - "vimeo/psalm": "^5.15.0", + "phpunit/phpunit": "^10.5.35", + "psalm/plugin-phpunit": "^0.19.0", + "vimeo/psalm": "^5.24.0", "webmozart/assert": "^1.11.0" }, "type": "composer-plugin", @@ -7615,41 +7721,41 @@ "type": "community_bridge" } ], - "time": "2023-11-21T15:32:55+00:00" + "time": "2024-11-26T14:11:43+00:00" }, { "name": "laminas/laminas-developer-tools", - "version": "2.9.0", + "version": "2.10.0", "source": { "type": "git", "url": "https://github.com/laminas/laminas-developer-tools.git", - "reference": "11cda549b80107b807492768743d8814971df171" + "reference": "238ecd3272a1b54d5b4b0aa65a9c5ec8a4053a51" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laminas/laminas-developer-tools/zipball/11cda549b80107b807492768743d8814971df171", - "reference": "11cda549b80107b807492768743d8814971df171", + "url": "https://api.github.com/repos/laminas/laminas-developer-tools/zipball/238ecd3272a1b54d5b4b0aa65a9c5ec8a4053a51", + "reference": "238ecd3272a1b54d5b4b0aa65a9c5ec8a4053a51", "shasum": "" }, "require": { "laminas/laminas-eventmanager": "^3.4", "laminas/laminas-http": "^2.15", "laminas/laminas-modulemanager": "^2.10", - "laminas/laminas-mvc": "^3.1", - "laminas/laminas-servicemanager": "^3.3", - "laminas/laminas-stdlib": "^3.6", - "laminas/laminas-view": "^2.13.1", - "php": "~8.1.0 || ~8.2.0 || ~8.3.0", + "laminas/laminas-mvc": "^3.3", + "laminas/laminas-servicemanager": "^3.8", + "laminas/laminas-stdlib": "^3.8", + "laminas/laminas-view": "^2.14", + "php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0", "symfony/var-dumper": "^6.0 || ^7.0" }, "conflict": { "zendframework/zend-developer-tools": "*" }, "require-dev": { - "laminas/laminas-coding-standard": "~2.5.0", - "phpunit/phpunit": "^9.5.26", - "psalm/plugin-phpunit": "^0.18.4", - "vimeo/psalm": "^5.19" + "laminas/laminas-coding-standard": "~3.0.0", + "phpunit/phpunit": "^10.5.38", + "psalm/plugin-phpunit": "^0.19.0", + "vimeo/psalm": "^5.26.1" }, "suggest": { "aist/aist-git-tools": "Show you information about current GIT repository", @@ -7696,20 +7802,20 @@ "type": "community_bridge" } ], - "time": "2024-01-18T10:51:15+00:00" + "time": "2024-12-29T18:21:12+00:00" }, { "name": "laminas/laminas-test", - "version": "4.10.2", + "version": "4.12.0", "source": { "type": "git", "url": "https://github.com/laminas/laminas-test.git", - "reference": "cd714a8ac91950fff8858041e7e0861f631fd302" + "reference": "9158227059fc31a7b76012606b9e00ae9521bb27" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laminas/laminas-test/zipball/cd714a8ac91950fff8858041e7e0861f631fd302", - "reference": "cd714a8ac91950fff8858041e7e0861f631fd302", + "url": "https://api.github.com/repos/laminas/laminas-test/zipball/9158227059fc31a7b76012606b9e00ae9521bb27", + "reference": "9158227059fc31a7b76012606b9e00ae9521bb27", "shasum": "" }, "require": { @@ -7719,8 +7825,8 @@ "laminas/laminas-servicemanager": "^3.0.3", "laminas/laminas-uri": "^2.5", "laminas/laminas-view": "^2.13.1", - "php": "~8.1.0 || ~8.2.0 || ~8.3.0", - "phpunit/phpunit": "^10.4", + "php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0", + "phpunit/phpunit": "^10.4 || ^11.0 || ^12.0", "symfony/css-selector": "^6.0 || ^7.0", "symfony/dom-crawler": "^6.0 || ^7.0" }, @@ -7728,17 +7834,17 @@ "zendframework/zend-test": "*" }, "require-dev": { - "laminas/laminas-coding-standard": "^2.4.0", - "laminas/laminas-i18n": "^2.21", - "laminas/laminas-modulemanager": "^2.14.0", - "laminas/laminas-mvc-plugin-flashmessenger": "^1.9.0", - "laminas/laminas-serializer": "^2.14.0", - "laminas/laminas-session": "^2.16", - "laminas/laminas-stdlib": "^3.16.1", - "laminas/laminas-validator": "^2.28", - "mikey179/vfsstream": "^1.6.11", - "psalm/plugin-phpunit": "^0.18.4", - "vimeo/psalm": "^5.1" + "laminas/laminas-coding-standard": "^3.0.1", + "laminas/laminas-i18n": "^2.29", + "laminas/laminas-modulemanager": "^2.17", + "laminas/laminas-mvc-plugin-flashmessenger": "^1.11", + "laminas/laminas-serializer": "^2.18", + "laminas/laminas-session": "^2.24", + "laminas/laminas-stdlib": "^3.20", + "laminas/laminas-validator": "^2.64.2", + "mikey179/vfsstream": "^1.6.12", + "psalm/plugin-phpunit": "^0.19.2", + "vimeo/psalm": "^5.26 || ^6.8.7" }, "type": "library", "autoload": { @@ -7770,7 +7876,7 @@ "type": "community_bridge" } ], - "time": "2024-09-16T22:22:45+00:00" + "time": "2025-02-25T16:14:43+00:00" }, { "name": "lctrs/psalm-psr-container-plugin", @@ -7852,16 +7958,16 @@ }, { "name": "maglnet/composer-require-checker", - "version": "4.13.0", + "version": "4.15.0", "source": { "type": "git", "url": "https://github.com/maglnet/ComposerRequireChecker.git", - "reference": "3f998740566e3e9b3f7321167fd2f4fd645129da" + "reference": "5109aed7b4695e6d772c4e748030c92da69a7f81" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/maglnet/ComposerRequireChecker/zipball/3f998740566e3e9b3f7321167fd2f4fd645129da", - "reference": "3f998740566e3e9b3f7321167fd2f4fd645129da", + "url": "https://api.github.com/repos/maglnet/ComposerRequireChecker/zipball/5109aed7b4695e6d772c4e748030c92da69a7f81", + "reference": "5109aed7b4695e6d772c4e748030c92da69a7f81", "shasum": "" }, "require": { @@ -7877,11 +7983,11 @@ "doctrine/coding-standard": "^12.0.0", "ext-zend-opcache": "*", "phing/phing": "^2.17.4", - "phpstan/phpstan": "^1.12.6", - "phpunit/phpunit": "^10.5.36", + "phpstan/phpstan": "^1.12.16", + "phpunit/phpunit": "^10.5.41", "psalm/plugin-phpunit": "^0.19.0", "roave/infection-static-analysis-plugin": "^1.35.0", - "spatie/temporary-directory": "^2.2.1", + "spatie/temporary-directory": "^2.3.0", "vimeo/psalm": "^5.26.1" }, "bin": [ @@ -7927,9 +8033,9 @@ ], "support": { "issues": "https://github.com/maglnet/ComposerRequireChecker/issues", - "source": "https://github.com/maglnet/ComposerRequireChecker/tree/4.13.0" + "source": "https://github.com/maglnet/ComposerRequireChecker/tree/4.15.0" }, - "time": "2024-10-18T08:08:55+00:00" + "time": "2025-01-28T06:35:41+00:00" }, { "name": "masterminds/html5", @@ -8000,16 +8106,16 @@ }, { "name": "myclabs/deep-copy", - "version": "1.12.0", + "version": "1.13.1", "source": { "type": "git", "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "3a6b9a42cd8f8771bd4295d13e1423fa7f3d942c" + "reference": "1720ddd719e16cf0db4eb1c6eca108031636d46c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/3a6b9a42cd8f8771bd4295d13e1423fa7f3d942c", - "reference": "3a6b9a42cd8f8771bd4295d13e1423fa7f3d942c", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/1720ddd719e16cf0db4eb1c6eca108031636d46c", + "reference": "1720ddd719e16cf0db4eb1c6eca108031636d46c", "shasum": "" }, "require": { @@ -8048,7 +8154,7 @@ ], "support": { "issues": "https://github.com/myclabs/DeepCopy/issues", - "source": "https://github.com/myclabs/DeepCopy/tree/1.12.0" + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.1" }, "funding": [ { @@ -8056,7 +8162,7 @@ "type": "tidelift" } ], - "time": "2024-06-12T14:39:25+00:00" + "time": "2025-04-29T12:36:36+00:00" }, { "name": "netresearch/jsonmapper", @@ -8360,16 +8466,16 @@ }, { "name": "phpdocumentor/reflection-docblock", - "version": "5.4.1", + "version": "5.6.2", "source": { "type": "git", "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", - "reference": "9d07b3f7fdcf5efec5d1609cba3c19c5ea2bdc9c" + "reference": "92dde6a5919e34835c506ac8c523ef095a95ed62" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/9d07b3f7fdcf5efec5d1609cba3c19c5ea2bdc9c", - "reference": "9d07b3f7fdcf5efec5d1609cba3c19c5ea2bdc9c", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/92dde6a5919e34835c506ac8c523ef095a95ed62", + "reference": "92dde6a5919e34835c506ac8c523ef095a95ed62", "shasum": "" }, "require": { @@ -8378,17 +8484,17 @@ "php": "^7.4 || ^8.0", "phpdocumentor/reflection-common": "^2.2", "phpdocumentor/type-resolver": "^1.7", - "phpstan/phpdoc-parser": "^1.7", + "phpstan/phpdoc-parser": "^1.7|^2.0", "webmozart/assert": "^1.9.1" }, "require-dev": { - "mockery/mockery": "~1.3.5", + "mockery/mockery": "~1.3.5 || ~1.6.0", "phpstan/extension-installer": "^1.1", "phpstan/phpstan": "^1.8", "phpstan/phpstan-mockery": "^1.1", "phpstan/phpstan-webmozart-assert": "^1.2", "phpunit/phpunit": "^9.5", - "vimeo/psalm": "^5.13" + "psalm/phar": "^5.26" }, "type": "library", "extra": { @@ -8418,29 +8524,29 @@ "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", "support": { "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", - "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.4.1" + "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.6.2" }, - "time": "2024-05-21T05:55:05+00:00" + "time": "2025-04-13T19:20:35+00:00" }, { "name": "phpdocumentor/type-resolver", - "version": "1.8.2", + "version": "1.10.0", "source": { "type": "git", "url": "https://github.com/phpDocumentor/TypeResolver.git", - "reference": "153ae662783729388a584b4361f2545e4d841e3c" + "reference": "679e3ce485b99e84c775d28e2e96fade9a7fb50a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/153ae662783729388a584b4361f2545e4d841e3c", - "reference": "153ae662783729388a584b4361f2545e4d841e3c", + "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/679e3ce485b99e84c775d28e2e96fade9a7fb50a", + "reference": "679e3ce485b99e84c775d28e2e96fade9a7fb50a", "shasum": "" }, "require": { "doctrine/deprecations": "^1.0", "php": "^7.3 || ^8.0", "phpdocumentor/reflection-common": "^2.0", - "phpstan/phpdoc-parser": "^1.13" + "phpstan/phpdoc-parser": "^1.18|^2.0" }, "require-dev": { "ext-tokenizer": "*", @@ -8476,9 +8582,9 @@ "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", "support": { "issues": "https://github.com/phpDocumentor/TypeResolver/issues", - "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.8.2" + "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.10.0" }, - "time": "2024-02-23T11:10:43+00:00" + "time": "2024-11-09T15:12:26+00:00" }, { "name": "phpstan/extension-installer", @@ -8577,16 +8683,16 @@ }, { "name": "phpstan/phpstan", - "version": "1.12.6", + "version": "1.12.27", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan.git", - "reference": "dc4d2f145a88ea7141ae698effd64d9df46527ae" + "reference": "3a6e423c076ab39dfedc307e2ac627ef579db162" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/dc4d2f145a88ea7141ae698effd64d9df46527ae", - "reference": "dc4d2f145a88ea7141ae698effd64d9df46527ae", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/3a6e423c076ab39dfedc307e2ac627ef579db162", + "reference": "3a6e423c076ab39dfedc307e2ac627ef579db162", "shasum": "" }, "require": { @@ -8631,25 +8737,25 @@ "type": "github" } ], - "time": "2024-10-06T15:03:59+00:00" + "time": "2025-05-21T20:51:45+00:00" }, { "name": "phpstan/phpstan-doctrine", - "version": "1.5.3", + "version": "1.5.7", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan-doctrine.git", - "reference": "38db3bad8f1567d7bf64806738d724261f8a2b5c" + "reference": "231d3f795ed5ef54c98961fd3958868cbe091207" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan-doctrine/zipball/38db3bad8f1567d7bf64806738d724261f8a2b5c", - "reference": "38db3bad8f1567d7bf64806738d724261f8a2b5c", + "url": "https://api.github.com/repos/phpstan/phpstan-doctrine/zipball/231d3f795ed5ef54c98961fd3958868cbe091207", + "reference": "231d3f795ed5ef54c98961fd3958868cbe091207", "shasum": "" }, "require": { "php": "^7.2 || ^8.0", - "phpstan/phpstan": "^1.11.7" + "phpstan/phpstan": "^1.12.12" }, "conflict": { "doctrine/collections": "<1.0", @@ -8676,7 +8782,7 @@ "php-parallel-lint/php-parallel-lint": "^1.2", "phpstan/phpstan-phpunit": "^1.3.13", "phpstan/phpstan-strict-rules": "^1.5.1", - "phpunit/phpunit": "^9.6.16", + "phpunit/phpunit": "^9.6.20", "ramsey/uuid": "^4.2", "symfony/cache": "^5.4" }, @@ -8701,27 +8807,27 @@ "description": "Doctrine extensions for PHPStan", "support": { "issues": "https://github.com/phpstan/phpstan-doctrine/issues", - "source": "https://github.com/phpstan/phpstan-doctrine/tree/1.5.3" + "source": "https://github.com/phpstan/phpstan-doctrine/tree/1.5.7" }, - "time": "2024-09-01T13:17:34+00:00" + "time": "2024-12-02T16:47:26+00:00" }, { "name": "phpstan/phpstan-phpunit", - "version": "1.4.0", + "version": "1.4.2", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan-phpunit.git", - "reference": "f3ea021866f4263f07ca3636bf22c64be9610c11" + "reference": "72a6721c9b64b3e4c9db55abbc38f790b318267e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan-phpunit/zipball/f3ea021866f4263f07ca3636bf22c64be9610c11", - "reference": "f3ea021866f4263f07ca3636bf22c64be9610c11", + "url": "https://api.github.com/repos/phpstan/phpstan-phpunit/zipball/72a6721c9b64b3e4c9db55abbc38f790b318267e", + "reference": "72a6721c9b64b3e4c9db55abbc38f790b318267e", "shasum": "" }, "require": { "php": "^7.2 || ^8.0", - "phpstan/phpstan": "^1.11" + "phpstan/phpstan": "^1.12" }, "conflict": { "phpunit/phpunit": "<7.0" @@ -8753,9 +8859,9 @@ "description": "PHPUnit extensions and rules for PHPStan", "support": { "issues": "https://github.com/phpstan/phpstan-phpunit/issues", - "source": "https://github.com/phpstan/phpstan-phpunit/tree/1.4.0" + "source": "https://github.com/phpstan/phpstan-phpunit/tree/1.4.2" }, - "time": "2024-04-20T06:39:00+00:00" + "time": "2024-12-17T17:20:49+00:00" }, { "name": "phpunit/php-code-coverage", @@ -9080,16 +9186,16 @@ }, { "name": "phpunit/phpunit", - "version": "10.5.36", + "version": "10.5.47", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "aa0a8ce701ea7ee314b0dfaa8970dc94f3f8c870" + "reference": "3637b3e50d32ab3a0d1a33b3b6177169ec3d95a3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/aa0a8ce701ea7ee314b0dfaa8970dc94f3f8c870", - "reference": "aa0a8ce701ea7ee314b0dfaa8970dc94f3f8c870", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/3637b3e50d32ab3a0d1a33b3b6177169ec3d95a3", + "reference": "3637b3e50d32ab3a0d1a33b3b6177169ec3d95a3", "shasum": "" }, "require": { @@ -9099,7 +9205,7 @@ "ext-mbstring": "*", "ext-xml": "*", "ext-xmlwriter": "*", - "myclabs/deep-copy": "^1.12.0", + "myclabs/deep-copy": "^1.13.1", "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=8.1", @@ -9110,7 +9216,7 @@ "phpunit/php-timer": "^6.0.0", "sebastian/cli-parser": "^2.0.1", "sebastian/code-unit": "^2.0.0", - "sebastian/comparator": "^5.0.2", + "sebastian/comparator": "^5.0.3", "sebastian/diff": "^5.1.1", "sebastian/environment": "^6.1.0", "sebastian/exporter": "^5.1.2", @@ -9161,7 +9267,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.36" + "source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.47" }, "funding": [ { @@ -9172,12 +9278,20 @@ "url": "https://github.com/sebastianbergmann", "type": "github" }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, { "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", "type": "tidelift" } ], - "time": "2024-10-08T15:36:51+00:00" + "time": "2025-06-20T11:29:11+00:00" }, { "name": "psalm/plugin-phpunit", @@ -9313,33 +9427,33 @@ }, { "name": "react/child-process", - "version": "v0.6.5", + "version": "v0.6.6", "source": { "type": "git", "url": "https://github.com/reactphp/child-process.git", - "reference": "e71eb1aa55f057c7a4a0d08d06b0b0a484bead43" + "reference": "1721e2b93d89b745664353b9cfc8f155ba8a6159" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/reactphp/child-process/zipball/e71eb1aa55f057c7a4a0d08d06b0b0a484bead43", - "reference": "e71eb1aa55f057c7a4a0d08d06b0b0a484bead43", + "url": "https://api.github.com/repos/reactphp/child-process/zipball/1721e2b93d89b745664353b9cfc8f155ba8a6159", + "reference": "1721e2b93d89b745664353b9cfc8f155ba8a6159", "shasum": "" }, "require": { "evenement/evenement": "^3.0 || ^2.0 || ^1.0", "php": ">=5.3.0", "react/event-loop": "^1.2", - "react/stream": "^1.2" + "react/stream": "^1.4" }, "require-dev": { - "phpunit/phpunit": "^9.3 || ^5.7 || ^4.8.35", - "react/socket": "^1.8", + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/socket": "^1.16", "sebastian/environment": "^5.0 || ^3.0 || ^2.0 || ^1.0" }, "type": "library", "autoload": { "psr-4": { - "React\\ChildProcess\\": "src" + "React\\ChildProcess\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -9376,19 +9490,15 @@ ], "support": { "issues": "https://github.com/reactphp/child-process/issues", - "source": "https://github.com/reactphp/child-process/tree/v0.6.5" + "source": "https://github.com/reactphp/child-process/tree/v0.6.6" }, "funding": [ { - "url": "https://github.com/WyriHaximus", - "type": "github" - }, - { - "url": "https://github.com/clue", - "type": "github" + "url": "https://opencollective.com/reactphp", + "type": "open_collective" } ], - "time": "2022-09-16T13:41:56+00:00" + "time": "2025-01-01T16:37:48+00:00" }, { "name": "react/dns", @@ -10015,16 +10125,16 @@ }, { "name": "sebastian/comparator", - "version": "5.0.2", + "version": "5.0.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "2d3e04c3b4c1e84a5e7382221ad8883c8fbc4f53" + "reference": "a18251eb0b7a2dcd2f7aa3d6078b18545ef0558e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/2d3e04c3b4c1e84a5e7382221ad8883c8fbc4f53", - "reference": "2d3e04c3b4c1e84a5e7382221ad8883c8fbc4f53", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/a18251eb0b7a2dcd2f7aa3d6078b18545ef0558e", + "reference": "a18251eb0b7a2dcd2f7aa3d6078b18545ef0558e", "shasum": "" }, "require": { @@ -10035,7 +10145,7 @@ "sebastian/exporter": "^5.0" }, "require-dev": { - "phpunit/phpunit": "^10.4" + "phpunit/phpunit": "^10.5" }, "type": "library", "extra": { @@ -10080,7 +10190,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", "security": "https://github.com/sebastianbergmann/comparator/security/policy", - "source": "https://github.com/sebastianbergmann/comparator/tree/5.0.2" + "source": "https://github.com/sebastianbergmann/comparator/tree/5.0.3" }, "funding": [ { @@ -10088,7 +10198,7 @@ "type": "github" } ], - "time": "2024-08-12T06:03:08+00:00" + "time": "2024-10-18T14:56:07+00:00" }, { "name": "sebastian/complexity", @@ -10924,16 +11034,16 @@ }, { "name": "spatie/array-to-xml", - "version": "3.3.0", + "version": "3.4.0", "source": { "type": "git", "url": "https://github.com/spatie/array-to-xml.git", - "reference": "f56b220fe2db1ade4c88098d83413ebdfc3bf876" + "reference": "7dcfc67d60b0272926dabad1ec01f6b8a5fb5e67" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/array-to-xml/zipball/f56b220fe2db1ade4c88098d83413ebdfc3bf876", - "reference": "f56b220fe2db1ade4c88098d83413ebdfc3bf876", + "url": "https://api.github.com/repos/spatie/array-to-xml/zipball/7dcfc67d60b0272926dabad1ec01f6b8a5fb5e67", + "reference": "7dcfc67d60b0272926dabad1ec01f6b8a5fb5e67", "shasum": "" }, "require": { @@ -10976,7 +11086,7 @@ "xml" ], "support": { - "source": "https://github.com/spatie/array-to-xml/tree/3.3.0" + "source": "https://github.com/spatie/array-to-xml/tree/3.4.0" }, "funding": [ { @@ -10988,20 +11098,20 @@ "type": "github" } ], - "time": "2024-05-01T10:20:27+00:00" + "time": "2024-12-16T12:45:15+00:00" }, { "name": "squizlabs/php_codesniffer", - "version": "3.10.3", + "version": "3.13.2", "source": { "type": "git", "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git", - "reference": "62d32998e820bddc40f99f8251958aed187a5c9c" + "reference": "5b5e3821314f947dd040c70f7992a64eac89025c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/62d32998e820bddc40f99f8251958aed187a5c9c", - "reference": "62d32998e820bddc40f99f8251958aed187a5c9c", + "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/5b5e3821314f947dd040c70f7992a64eac89025c", + "reference": "5b5e3821314f947dd040c70f7992a64eac89025c", "shasum": "" }, "require": { @@ -11066,22 +11176,26 @@ { "url": "https://opencollective.com/php_codesniffer", "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcsstandards", + "type": "thanks_dev" } ], - "time": "2024-09-18T10:38:58+00:00" + "time": "2025-06-17T22:17:01+00:00" }, { "name": "symfony/config", - "version": "v7.1.1", + "version": "v7.3.0", "source": { "type": "git", "url": "https://github.com/symfony/config.git", - "reference": "2210fc99fa42a259eb6c89d1f724ce0c4d62d5d2" + "reference": "ba62ae565f1327c2f6366726312ed828c85853bc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/config/zipball/2210fc99fa42a259eb6c89d1f724ce0c4d62d5d2", - "reference": "2210fc99fa42a259eb6c89d1f724ce0c4d62d5d2", + "url": "https://api.github.com/repos/symfony/config/zipball/ba62ae565f1327c2f6366726312ed828c85853bc", + "reference": "ba62ae565f1327c2f6366726312ed828c85853bc", "shasum": "" }, "require": { @@ -11127,7 +11241,7 @@ "description": "Helps you find, load, combine, autofill and validate configuration values of any kind", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/config/tree/v7.1.1" + "source": "https://github.com/symfony/config/tree/v7.3.0" }, "funding": [ { @@ -11143,20 +11257,20 @@ "type": "tidelift" } ], - "time": "2024-05-31T14:57:53+00:00" + "time": "2025-05-15T09:04:05+00:00" }, { "name": "symfony/css-selector", - "version": "v7.1.1", + "version": "v7.3.0", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", - "reference": "1c7cee86c6f812896af54434f8ce29c8d94f9ff4" + "reference": "601a5ce9aaad7bf10797e3663faefce9e26c24e2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/1c7cee86c6f812896af54434f8ce29c8d94f9ff4", - "reference": "1c7cee86c6f812896af54434f8ce29c8d94f9ff4", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/601a5ce9aaad7bf10797e3663faefce9e26c24e2", + "reference": "601a5ce9aaad7bf10797e3663faefce9e26c24e2", "shasum": "" }, "require": { @@ -11192,7 +11306,7 @@ "description": "Converts CSS selectors to XPath expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/css-selector/tree/v7.1.1" + "source": "https://github.com/symfony/css-selector/tree/v7.3.0" }, "funding": [ { @@ -11208,20 +11322,20 @@ "type": "tidelift" } ], - "time": "2024-05-31T14:57:53+00:00" + "time": "2024-09-25T14:21:43+00:00" }, { "name": "symfony/dependency-injection", - "version": "v7.1.5", + "version": "v7.3.0", "source": { "type": "git", "url": "https://github.com/symfony/dependency-injection.git", - "reference": "38465f925ec4e0707b090e9147c65869837d639d" + "reference": "f64a8f3fa7d4ad5e85de1b128a0e03faed02b732" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/38465f925ec4e0707b090e9147c65869837d639d", - "reference": "38465f925ec4e0707b090e9147c65869837d639d", + "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/f64a8f3fa7d4ad5e85de1b128a0e03faed02b732", + "reference": "f64a8f3fa7d4ad5e85de1b128a0e03faed02b732", "shasum": "" }, "require": { @@ -11229,7 +11343,7 @@ "psr/container": "^1.1|^2.0", "symfony/deprecation-contracts": "^2.5|^3", "symfony/service-contracts": "^3.5", - "symfony/var-exporter": "^6.4|^7.0" + "symfony/var-exporter": "^6.4.20|^7.2.5" }, "conflict": { "ext-psr": "<1.1|>=2", @@ -11272,7 +11386,7 @@ "description": "Allows you to standardize and centralize the way objects are constructed in your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/dependency-injection/tree/v7.1.5" + "source": "https://github.com/symfony/dependency-injection/tree/v7.3.0" }, "funding": [ { @@ -11288,20 +11402,20 @@ "type": "tidelift" } ], - "time": "2024-09-20T08:28:38+00:00" + "time": "2025-05-19T13:28:56+00:00" }, { "name": "symfony/dom-crawler", - "version": "v7.1.5", + "version": "v7.3.0", "source": { "type": "git", "url": "https://github.com/symfony/dom-crawler.git", - "reference": "b92af238457a7cdd2738f941cd525d76313e8283" + "reference": "0fabbc3d6a9c473b716a93fc8e7a537adb396166" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/b92af238457a7cdd2738f941cd525d76313e8283", - "reference": "b92af238457a7cdd2738f941cd525d76313e8283", + "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/0fabbc3d6a9c473b716a93fc8e7a537adb396166", + "reference": "0fabbc3d6a9c473b716a93fc8e7a537adb396166", "shasum": "" }, "require": { @@ -11339,7 +11453,7 @@ "description": "Eases DOM navigation for HTML and XML documents", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/dom-crawler/tree/v7.1.5" + "source": "https://github.com/symfony/dom-crawler/tree/v7.3.0" }, "funding": [ { @@ -11355,20 +11469,20 @@ "type": "tidelift" } ], - "time": "2024-09-15T06:48:17+00:00" + "time": "2025-03-05T10:15:41+00:00" }, { "name": "symfony/filesystem", - "version": "v7.1.5", + "version": "v7.3.0", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "61fe0566189bf32e8cfee78335d8776f64a66f5a" + "reference": "b8dce482de9d7c9fe2891155035a7248ab5c7fdb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/61fe0566189bf32e8cfee78335d8776f64a66f5a", - "reference": "61fe0566189bf32e8cfee78335d8776f64a66f5a", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/b8dce482de9d7c9fe2891155035a7248ab5c7fdb", + "reference": "b8dce482de9d7c9fe2891155035a7248ab5c7fdb", "shasum": "" }, "require": { @@ -11405,7 +11519,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v7.1.5" + "source": "https://github.com/symfony/filesystem/tree/v7.3.0" }, "funding": [ { @@ -11421,20 +11535,20 @@ "type": "tidelift" } ], - "time": "2024-09-17T09:16:35+00:00" + "time": "2024-10-25T15:15:23+00:00" }, { "name": "symfony/finder", - "version": "v7.1.4", + "version": "v7.3.0", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "d95bbf319f7d052082fb7af147e0f835a695e823" + "reference": "ec2344cf77a48253bbca6939aa3d2477773ea63d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/d95bbf319f7d052082fb7af147e0f835a695e823", - "reference": "d95bbf319f7d052082fb7af147e0f835a695e823", + "url": "https://api.github.com/repos/symfony/finder/zipball/ec2344cf77a48253bbca6939aa3d2477773ea63d", + "reference": "ec2344cf77a48253bbca6939aa3d2477773ea63d", "shasum": "" }, "require": { @@ -11469,7 +11583,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.1.4" + "source": "https://github.com/symfony/finder/tree/v7.3.0" }, "funding": [ { @@ -11485,20 +11599,20 @@ "type": "tidelift" } ], - "time": "2024-08-13T14:28:19+00:00" + "time": "2024-12-30T19:00:26+00:00" }, { "name": "symfony/options-resolver", - "version": "v7.1.1", + "version": "v7.3.0", "source": { "type": "git", "url": "https://github.com/symfony/options-resolver.git", - "reference": "47aa818121ed3950acd2b58d1d37d08a94f9bf55" + "reference": "afb9a8038025e5dbc657378bfab9198d75f10fca" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/options-resolver/zipball/47aa818121ed3950acd2b58d1d37d08a94f9bf55", - "reference": "47aa818121ed3950acd2b58d1d37d08a94f9bf55", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/afb9a8038025e5dbc657378bfab9198d75f10fca", + "reference": "afb9a8038025e5dbc657378bfab9198d75f10fca", "shasum": "" }, "require": { @@ -11536,7 +11650,7 @@ "options" ], "support": { - "source": "https://github.com/symfony/options-resolver/tree/v7.1.1" + "source": "https://github.com/symfony/options-resolver/tree/v7.3.0" }, "funding": [ { @@ -11552,11 +11666,11 @@ "type": "tidelift" } ], - "time": "2024-05-31T14:57:53+00:00" + "time": "2025-04-04T13:12:05+00:00" }, { "name": "symfony/polyfill-php81", - "version": "v1.31.0", + "version": "v1.32.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php81.git", @@ -11574,8 +11688,8 @@ "type": "library", "extra": { "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { @@ -11612,7 +11726,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php81/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-php81/tree/v1.32.0" }, "funding": [ { @@ -11632,7 +11746,7 @@ }, { "name": "symfony/polyfill-php83", - "version": "v1.31.0", + "version": "v1.32.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php83.git", @@ -11650,8 +11764,8 @@ "type": "library", "extra": { "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { @@ -11688,7 +11802,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php83/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-php83/tree/v1.32.0" }, "funding": [ { @@ -11708,16 +11822,16 @@ }, { "name": "symfony/process", - "version": "v7.1.5", + "version": "v7.3.0", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "5c03ee6369281177f07f7c68252a280beccba847" + "reference": "40c295f2deb408d5e9d2d32b8ba1dd61e36f05af" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/5c03ee6369281177f07f7c68252a280beccba847", - "reference": "5c03ee6369281177f07f7c68252a280beccba847", + "url": "https://api.github.com/repos/symfony/process/zipball/40c295f2deb408d5e9d2d32b8ba1dd61e36f05af", + "reference": "40c295f2deb408d5e9d2d32b8ba1dd61e36f05af", "shasum": "" }, "require": { @@ -11749,7 +11863,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.1.5" + "source": "https://github.com/symfony/process/tree/v7.3.0" }, "funding": [ { @@ -11765,20 +11879,20 @@ "type": "tidelift" } ], - "time": "2024-09-19T21:48:23+00:00" + "time": "2025-04-17T09:11:12+00:00" }, { "name": "symfony/property-access", - "version": "v7.1.4", + "version": "v7.3.0", "source": { "type": "git", "url": "https://github.com/symfony/property-access.git", - "reference": "6c709f97103355016e5782d0622437ae381012ad" + "reference": "3bcf43665d6aff90547b005348e1e351f4e2174b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/property-access/zipball/6c709f97103355016e5782d0622437ae381012ad", - "reference": "6c709f97103355016e5782d0622437ae381012ad", + "url": "https://api.github.com/repos/symfony/property-access/zipball/3bcf43665d6aff90547b005348e1e351f4e2174b", + "reference": "3bcf43665d6aff90547b005348e1e351f4e2174b", "shasum": "" }, "require": { @@ -11825,7 +11939,7 @@ "reflection" ], "support": { - "source": "https://github.com/symfony/property-access/tree/v7.1.4" + "source": "https://github.com/symfony/property-access/tree/v7.3.0" }, "funding": [ { @@ -11841,36 +11955,38 @@ "type": "tidelift" } ], - "time": "2024-08-30T16:12:47+00:00" + "time": "2025-05-10T11:59:09+00:00" }, { "name": "symfony/property-info", - "version": "v7.1.3", + "version": "v7.3.0", "source": { "type": "git", "url": "https://github.com/symfony/property-info.git", - "reference": "88a279df2db5b7919cac6f35d6a5d1d7147e6a9b" + "reference": "200d230d8553610ada73ac557501dc4609aad31f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/property-info/zipball/88a279df2db5b7919cac6f35d6a5d1d7147e6a9b", - "reference": "88a279df2db5b7919cac6f35d6a5d1d7147e6a9b", + "url": "https://api.github.com/repos/symfony/property-info/zipball/200d230d8553610ada73ac557501dc4609aad31f", + "reference": "200d230d8553610ada73ac557501dc4609aad31f", "shasum": "" }, "require": { "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/string": "^6.4|^7.0", - "symfony/type-info": "^7.1" + "symfony/type-info": "~7.1.9|^7.2.2" }, "conflict": { "phpdocumentor/reflection-docblock": "<5.2", "phpdocumentor/type-resolver": "<1.5.1", + "symfony/cache": "<6.4", "symfony/dependency-injection": "<6.4", "symfony/serializer": "<6.4" }, "require-dev": { "phpdocumentor/reflection-docblock": "^5.2", - "phpstan/phpdoc-parser": "^1.0", + "phpstan/phpdoc-parser": "^1.0|^2.0", "symfony/cache": "^6.4|^7.0", "symfony/dependency-injection": "^6.4|^7.0", "symfony/serializer": "^6.4|^7.0" @@ -11909,7 +12025,7 @@ "validator" ], "support": { - "source": "https://github.com/symfony/property-info/tree/v7.1.3" + "source": "https://github.com/symfony/property-info/tree/v7.3.0" }, "funding": [ { @@ -11925,20 +12041,20 @@ "type": "tidelift" } ], - "time": "2024-07-26T07:36:36+00:00" + "time": "2025-04-04T13:12:05+00:00" }, { "name": "symfony/serializer", - "version": "v7.1.5", + "version": "v7.3.0", "source": { "type": "git", "url": "https://github.com/symfony/serializer.git", - "reference": "71d6e1f70f00752d1469d0f5e83b0a51716f288b" + "reference": "2d86f81b1c506d7e1578789f93280dab4b8411bb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/serializer/zipball/71d6e1f70f00752d1469d0f5e83b0a51716f288b", - "reference": "71d6e1f70f00752d1469d0f5e83b0a51716f288b", + "url": "https://api.github.com/repos/symfony/serializer/zipball/2d86f81b1c506d7e1578789f93280dab4b8411bb", + "reference": "2d86f81b1c506d7e1578789f93280dab4b8411bb", "shasum": "" }, "require": { @@ -11952,19 +12068,18 @@ "symfony/dependency-injection": "<6.4", "symfony/property-access": "<6.4", "symfony/property-info": "<6.4", - "symfony/type-info": "<7.1.5", "symfony/uid": "<6.4", "symfony/validator": "<6.4", "symfony/yaml": "<6.4" }, "require-dev": { "phpdocumentor/reflection-docblock": "^3.2|^4.0|^5.0", - "phpstan/phpdoc-parser": "^1.0", + "phpstan/phpdoc-parser": "^1.0|^2.0", "seld/jsonlint": "^1.10", "symfony/cache": "^6.4|^7.0", "symfony/config": "^6.4|^7.0", "symfony/console": "^6.4|^7.0", - "symfony/dependency-injection": "^6.4|^7.0", + "symfony/dependency-injection": "^7.2", "symfony/error-handler": "^6.4|^7.0", "symfony/filesystem": "^6.4|^7.0", "symfony/form": "^6.4|^7.0", @@ -11975,7 +12090,7 @@ "symfony/property-access": "^6.4|^7.0", "symfony/property-info": "^6.4|^7.0", "symfony/translation-contracts": "^2.5|^3", - "symfony/type-info": "^7.1.5", + "symfony/type-info": "^7.1", "symfony/uid": "^6.4|^7.0", "symfony/validator": "^6.4|^7.0", "symfony/var-dumper": "^6.4|^7.0", @@ -12008,7 +12123,7 @@ "description": "Handles serializing and deserializing data structures, including object graphs, into array structures or other formats like XML and JSON.", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/serializer/tree/v7.1.5" + "source": "https://github.com/symfony/serializer/tree/v7.3.0" }, "funding": [ { @@ -12024,20 +12139,20 @@ "type": "tidelift" } ], - "time": "2024-09-20T12:13:15+00:00" + "time": "2025-05-12T14:48:23+00:00" }, { "name": "symfony/translation-contracts", - "version": "v3.5.0", + "version": "v3.6.0", "source": { "type": "git", "url": "https://github.com/symfony/translation-contracts.git", - "reference": "b9d2189887bb6b2e0367a9fc7136c5239ab9b05a" + "reference": "df210c7a2573f1913b2d17cc95f90f53a73d8f7d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/b9d2189887bb6b2e0367a9fc7136c5239ab9b05a", - "reference": "b9d2189887bb6b2e0367a9fc7136c5239ab9b05a", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/df210c7a2573f1913b2d17cc95f90f53a73d8f7d", + "reference": "df210c7a2573f1913b2d17cc95f90f53a73d8f7d", "shasum": "" }, "require": { @@ -12045,12 +12160,12 @@ }, "type": "library", "extra": { - "branch-alias": { - "dev-main": "3.5-dev" - }, "thanks": { - "name": "symfony/contracts", - "url": "https://github.com/symfony/contracts" + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" } }, "autoload": { @@ -12086,7 +12201,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/translation-contracts/tree/v3.5.0" + "source": "https://github.com/symfony/translation-contracts/tree/v3.6.0" }, "funding": [ { @@ -12102,35 +12217,32 @@ "type": "tidelift" } ], - "time": "2024-04-18T09:32:20+00:00" + "time": "2024-09-27T08:32:26+00:00" }, { "name": "symfony/type-info", - "version": "v7.1.5", + "version": "v7.3.0", "source": { "type": "git", "url": "https://github.com/symfony/type-info.git", - "reference": "9f6094aa900d2c06bd61576a6f279d4ac441515f" + "reference": "bc9af22e25796d98078f69c0749ab3a9d3454786" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/type-info/zipball/9f6094aa900d2c06bd61576a6f279d4ac441515f", - "reference": "9f6094aa900d2c06bd61576a6f279d4ac441515f", + "url": "https://api.github.com/repos/symfony/type-info/zipball/bc9af22e25796d98078f69c0749ab3a9d3454786", + "reference": "bc9af22e25796d98078f69c0749ab3a9d3454786", "shasum": "" }, "require": { "php": ">=8.2", - "psr/container": "^1.1|^2.0" + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" }, "conflict": { - "phpstan/phpdoc-parser": "<1.0", - "symfony/dependency-injection": "<6.4", - "symfony/property-info": "<6.4" + "phpstan/phpdoc-parser": "<1.30" }, "require-dev": { - "phpstan/phpdoc-parser": "^1.0", - "symfony/dependency-injection": "^6.4|^7.0", - "symfony/property-info": "^6.4|^7.0" + "phpstan/phpdoc-parser": "^1.30|^2.0" }, "type": "library", "autoload": { @@ -12168,7 +12280,7 @@ "type" ], "support": { - "source": "https://github.com/symfony/type-info/tree/v7.1.5" + "source": "https://github.com/symfony/type-info/tree/v7.3.0" }, "funding": [ { @@ -12184,20 +12296,20 @@ "type": "tidelift" } ], - "time": "2024-09-19T21:48:23+00:00" + "time": "2025-03-30T12:17:06+00:00" }, { "name": "symfony/validator", - "version": "v7.1.5", + "version": "v7.3.0", "source": { "type": "git", "url": "https://github.com/symfony/validator.git", - "reference": "e57592782dc2a86997477f28164c51af53512ad8" + "reference": "dabb03cddf50761c0aff4fbf5a3b3fffb3e5e38b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/validator/zipball/e57592782dc2a86997477f28164c51af53512ad8", - "reference": "e57592782dc2a86997477f28164c51af53512ad8", + "url": "https://api.github.com/repos/symfony/validator/zipball/dabb03cddf50761c0aff4fbf5a3b3fffb3e5e38b", + "reference": "dabb03cddf50761c0aff4fbf5a3b3fffb3e5e38b", "shasum": "" }, "require": { @@ -12234,6 +12346,7 @@ "symfony/mime": "^6.4|^7.0", "symfony/property-access": "^6.4|^7.0", "symfony/property-info": "^6.4|^7.0", + "symfony/string": "^6.4|^7.0", "symfony/translation": "^6.4.3|^7.0.3", "symfony/type-info": "^7.1", "symfony/yaml": "^6.4|^7.0" @@ -12265,7 +12378,7 @@ "description": "Provides tools to validate values", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/validator/tree/v7.1.5" + "source": "https://github.com/symfony/validator/tree/v7.3.0" }, "funding": [ { @@ -12281,24 +12394,25 @@ "type": "tidelift" } ], - "time": "2024-09-20T08:28:38+00:00" + "time": "2025-05-29T07:19:49+00:00" }, { "name": "symfony/var-dumper", - "version": "v7.1.5", + "version": "v7.3.0", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "e20e03889539fd4e4211e14d2179226c513c010d" + "reference": "548f6760c54197b1084e1e5c71f6d9d523f2f78e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/e20e03889539fd4e4211e14d2179226c513c010d", - "reference": "e20e03889539fd4e4211e14d2179226c513c010d", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/548f6760c54197b1084e1e5c71f6d9d523f2f78e", + "reference": "548f6760c54197b1084e1e5c71f6d9d523f2f78e", "shasum": "" }, "require": { "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-mbstring": "~1.0" }, "conflict": { @@ -12310,7 +12424,7 @@ "symfony/http-kernel": "^6.4|^7.0", "symfony/process": "^6.4|^7.0", "symfony/uid": "^6.4|^7.0", - "twig/twig": "^3.0.4" + "twig/twig": "^3.12" }, "bin": [ "Resources/bin/var-dump-server" @@ -12348,7 +12462,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.1.5" + "source": "https://github.com/symfony/var-dumper/tree/v7.3.0" }, "funding": [ { @@ -12364,7 +12478,7 @@ "type": "tidelift" } ], - "time": "2024-09-16T10:07:02+00:00" + "time": "2025-04-27T18:39:23+00:00" }, { "name": "theseer/tokenizer", @@ -12491,11 +12605,11 @@ "type": "project", "extra": { "branch-alias": { - "dev-master": "5.x-dev", - "dev-4.x": "4.x-dev", - "dev-3.x": "3.x-dev", + "dev-1.x": "1.x-dev", "dev-2.x": "2.x-dev", - "dev-1.x": "1.x-dev" + "dev-3.x": "3.x-dev", + "dev-4.x": "4.x-dev", + "dev-master": "5.x-dev" } }, "autoload": { @@ -12656,17 +12770,18 @@ ], "aliases": [], "minimum-stability": "dev", - "stability-flags": [], + "stability-flags": {}, "prefer-stable": true, "prefer-lowest": false, "platform": { "php": "^8.3.0", "ext-intl": "*", + "ext-memcached": "^3.2", "ext-pdo_pgsql": "*", "ext-pgsql": "*", "ext-zend-opcache": "*", "ext-zip": "^1.12.0" }, - "platform-dev": [], + "platform-dev": {}, "plugin-api-version": "2.6.0" } diff --git a/config/autoload/local.development.php.dist b/config/autoload/local.development.php.dist index d484e1e61..08a4e75a3 100644 --- a/config/autoload/local.development.php.dist +++ b/config/autoload/local.development.php.dist @@ -77,6 +77,16 @@ return [ ], ], + /** + * Mailman REST API configuration. + */ + 'mailman_api' => [ + 'endpoint' => getenv('MAILMAN_API_ENDPOINT'), + 'version' => getenv('MAILMAN_API_VERSION'), + 'username' => getenv('MAILMAN_API_USERNAME'), + 'password' => getenv('MAILMAN_API_PASSWORD'), + ], + /** * LDAP settings for login to database frontend */ diff --git a/config/autoload/local.production.php.dist b/config/autoload/local.production.php.dist index 74abca0c5..a04bfb151 100644 --- a/config/autoload/local.production.php.dist +++ b/config/autoload/local.production.php.dist @@ -77,6 +77,16 @@ return [ ], ], + /** + * Mailman REST API configuration. + */ + 'mailman_api' => [ + 'endpoint' => getenv('MAILMAN_API_ENDPOINT'), + 'version' => getenv('MAILMAN_API_VERSION'), + 'username' => getenv('MAILMAN_API_USERNAME'), + 'password' => getenv('MAILMAN_API_PASSWORD'), + ], + /** * LDAP settings for login to database frontend */ diff --git a/config/modules.config.php b/config/modules.config.php index 6597c7111..c60e2ecd1 100644 --- a/config/modules.config.php +++ b/config/modules.config.php @@ -26,6 +26,7 @@ 'DoctrineORMModule', 'Laminas\Cache\Storage\Adapter\Filesystem', 'Laminas\Cache\Storage\Adapter\Memory', + 'Laminas\Cache\Storage\Adapter\Memcached', 'Application', 'Database', 'Checker', diff --git a/docker-compose.override.yml b/docker-compose.override.yml index 89b9598ba..a087da448 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -30,7 +30,7 @@ services: mailman-core: image: maxking/mailman-core:0.4 container_name: mailman-core - hostname: mailman-core + hostname: mailmanc volumes: - ./mailman/core:/opt/mailman/ depends_on: @@ -47,7 +47,7 @@ services: mailman-web: image: maxking/mailman-web:0.4 container_name: mailman-web - hostname: mailman-web + hostname: mailmanw depends_on: - postgresql volumes: diff --git a/docker-compose.yml b/docker-compose.yml index ccb466dca..92db5c315 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -53,6 +53,7 @@ services: # - STRIPE_CANCEL_URL= # - STRIPE_SUCCESS_URL= depends_on: + - memcached - postfix volumes: - gewisdb_data:/code/data:rw @@ -60,6 +61,12 @@ services: networks: - gewisdb_network restart: unless-stopped + memcached: + image: memcached:alpine + entrypoint: [ 'memcached', '-m 256' ] + networks: + - gewisdb_network + restart: unless-stopped postfix: image: juanluisbaptiste/postfix env_file: diff --git a/docker/web/development/Dockerfile b/docker/web/development/Dockerfile index dd9c45647..9c6e97aca 100644 --- a/docker/web/development/Dockerfile +++ b/docker/web/development/Dockerfile @@ -18,6 +18,7 @@ RUN apk add --no-cache --virtual .build-deps \ $PHPIZE_DEPS \ curl-dev \ icu-dev \ + libmemcached-dev \ libpq-dev \ libzip-dev \ linux-headers \ @@ -41,6 +42,8 @@ RUN apk add --no-cache --virtual .build-deps \ pdo_pgsql \ pdo_sqlite \ zip \ + && pecl install memcached \ + && docker-php-ext-enable memcached \ && pecl install xdebug \ && docker-php-ext-enable xdebug \ && rm -r /tmp/pear \ diff --git a/docker/web/production/Dockerfile b/docker/web/production/Dockerfile index 6f1c7f85c..bf44a30d5 100644 --- a/docker/web/production/Dockerfile +++ b/docker/web/production/Dockerfile @@ -17,6 +17,7 @@ WORKDIR /code RUN apk add --no-cache --virtual .build-deps \ curl-dev \ icu-dev \ + libmemcached-dev \ libpq-dev \ libzip-dev \ openldap-dev \ @@ -34,6 +35,9 @@ RUN apk add --no-cache --virtual .build-deps \ pgsql \ pdo_pgsql \ zip \ + && pecl install memcached \ + && docker-php-ext-enable memcached \ + && rm -r /tmp/pear \ && runtimeDeps="$( \ scanelf --needed --nobanner --format '%n#p' --recursive /usr/local/lib/php/extensions \ | tr ',' '\n' \ diff --git a/module/Application/src/Model/ConfigItem.php b/module/Application/src/Model/ConfigItem.php index 469d5ba27..59a8e17e0 100644 --- a/module/Application/src/Model/ConfigItem.php +++ b/module/Application/src/Model/ConfigItem.php @@ -19,6 +19,7 @@ use LogicException; use TypeError; +use function is_bool; use function is_string; /** @@ -77,6 +78,15 @@ enumType: ConfigNamespaces::class, )] protected ?DateTime $valueDate = null; + /** + * If the item is a boolean, its value. + */ + #[Column( + type: 'boolean', + nullable: true, + )] + protected ?bool $valueBool = null; + #[PrePersist] #[PreUpdate] public function assertValid(): void @@ -102,20 +112,26 @@ public function setKey( /** * Set the value of the configuration item. */ - public function setValue(string|DateTime $value): void + public function setValue(bool|string|DateTime $value): void { if ($value instanceof DateTime) { $this->valueString = null; $this->valueDate = $value; + $this->valueBool = null; } elseif (is_string($value)) { $this->valueString = $value; $this->valueDate = null; + $this->valueBool = null; + } elseif (is_bool($value)) { + $this->valueString = null; + $this->valueDate = null; + $this->valueBool = $value; } else { throw new TypeError(); } } - public function getValue(): string|DateTime|null + public function getValue(): bool|string|DateTime|null { if (null !== $this->valueDate) { return $this->valueDate; @@ -125,6 +141,10 @@ public function getValue(): string|DateTime|null return $this->valueString; } + if (null !== $this->valueBool) { + return $this->valueBool; + } + return null; } } diff --git a/module/Application/src/Model/Enums/ConfigNamespaces.php b/module/Application/src/Model/Enums/ConfigNamespaces.php index 0a01e2f59..1effa22d4 100644 --- a/module/Application/src/Model/Enums/ConfigNamespaces.php +++ b/module/Application/src/Model/Enums/ConfigNamespaces.php @@ -6,7 +6,7 @@ /** * The different namespaces in which configuration items can be created. - * As a rule of thumb, a namespace should be restricted to one service or a welldefined set of a few services. + * As a rule of thumb, a namespace should be restricted to one service or a well-defined set of a few services. * * Ideally these namespaces are defined inside the respective modules, but defining them as an enum allows for * verification in IDEs. @@ -15,4 +15,5 @@ enum ConfigNamespaces: string { /* Database module */ case DatabaseApi = 'database_api'; + case DatabaseMailman = 'database_mailman'; } diff --git a/module/Application/src/Service/Config.php b/module/Application/src/Service/Config.php index 40fa94a61..bedadd080 100644 --- a/module/Application/src/Service/Config.php +++ b/module/Application/src/Service/Config.php @@ -15,11 +15,18 @@ public function __construct(private readonly ConfigItemMapper $configItemMapper) { } + /** + * @template T of bool|string|DateTime|null + * + * @psalm-param T $default + * + * @psalm-return (T is null ? bool|string|DateTime|null : T) + */ public function getConfig( ConfigNamespaces $namespace, string $key, - string|DateTime|null $default = null, - ): string|DateTime|null { + bool|string|DateTime|null $default = null, + ): bool|string|DateTime|null { $configItem = $this->getConfigItemMapper()->findByKey($namespace, $key); if (null === $configItem || null === $configItem->getValue()) { @@ -32,7 +39,7 @@ public function getConfig( public function setConfig( ConfigNamespaces $namespace, string $key, - string|DateTime $value, + bool|string|DateTime $value, ): void { $configItem = $this->getConfigItemMapper()->findByKey($namespace, $key); diff --git a/module/Database/config/module.config.php b/module/Database/config/module.config.php index 8d0feb103..e11c5901a 100644 --- a/module/Database/config/module.config.php +++ b/module/Database/config/module.config.php @@ -581,15 +581,57 @@ ], 'may_terminate' => true, 'child_routes' => [ - 'list-delete' => [ - 'type' => Segment::class, + 'lists' => [ + 'type' => Literal::class, 'options' => [ - 'route' => '/list/delete/:name', - 'constraints' => [ - 'name' => '[a-zA-Z0-9_-]+', - ], + 'route' => '/lists', 'defaults' => [ - 'action' => 'deleteList', + 'action' => 'lists', + ], + ], + 'may_terminate' => true, + 'child_routes' => [ + 'add' => [ + 'type' => Literal::class, + 'options' => [ + 'route' => '/add', + 'defaults' => [ + 'action' => 'addList', + ], + ], + ], + 'edit' => [ + 'type' => Segment::class, + 'options' => [ + 'route' => '/edit/:name', + 'constraints' => [ + 'name' => '[a-zA-Z0-9_-]+', + ], + 'defaults' => [ + 'action' => 'editList', + ], + ], + ], + 'delete' => [ + 'type' => Segment::class, + 'options' => [ + 'route' => '/delete/:name', + 'constraints' => [ + 'name' => '[a-zA-Z0-9_-]+', + ], + 'defaults' => [ + 'action' => 'deleteList', + ], + ], + ], + 'sync' => [ + 'type' => Literal::class, + 'options' => [ + 'route' => '/sync', + 'defaults' => [ + 'action' => 'syncLists', + ], + ], ], ], ], @@ -734,6 +776,9 @@ 'template_path_stack' => [ 'database' => __DIR__ . '/../view/', ], + 'template_map' => [ + 'database/settings/edit-list' => __DIR__ . '/../view/database/settings/add-list.phtml', + ], 'strategies' => [ 'ViewJsonStrategy', ], diff --git a/module/Database/src/Controller/Factory/MemberControllerFactory.php b/module/Database/src/Controller/Factory/MemberControllerFactory.php index 84630879f..d037cfe4e 100644 --- a/module/Database/src/Controller/Factory/MemberControllerFactory.php +++ b/module/Database/src/Controller/Factory/MemberControllerFactory.php @@ -6,6 +6,7 @@ use Checker\Service\Checker as CheckerService; use Database\Controller\MemberController; +use Database\Service\Mailman as MailmanService; use Database\Service\Member as MemberService; use Database\Service\Stripe as StripeService; use Laminas\Mvc\I18n\Translator as MvcTranslator; @@ -26,6 +27,8 @@ public function __invoke( $translator = $container->get(MvcTranslator::class); /** @var CheckerService $checkerService */ $checkerService = $container->get(CheckerService::class); + /** @var MailmanService $mailmanService */ + $mailmanService = $container->get(MailmanService::class); /** @var MemberService $memberService */ $memberService = $container->get(MemberService::class); /** @var StripeService $stripeService */ @@ -36,6 +39,7 @@ public function __invoke( return new MemberController( $translator, $checkerService, + $mailmanService, $memberService, $stripeService, $remoteAddress, diff --git a/module/Database/src/Controller/MemberController.php b/module/Database/src/Controller/MemberController.php index 3fe197d39..1e08f0028 100644 --- a/module/Database/src/Controller/MemberController.php +++ b/module/Database/src/Controller/MemberController.php @@ -7,6 +7,7 @@ use Application\Model\Enums\AddressTypes; use Checker\Service\Checker as CheckerService; use Database\Model\Member as MemberModel; +use Database\Service\Mailman as MailmanService; use Database\Service\Member as MemberService; use Database\Service\Stripe as StripeService; use DateTime; @@ -32,6 +33,7 @@ class MemberController extends AbstractActionController public function __construct( private readonly Translator $translator, private readonly CheckerService $checkerService, + private readonly MailmanService $mailmanService, private readonly MemberService $memberService, private readonly StripeService $stripeService, private readonly string $remoteAddress, @@ -427,6 +429,14 @@ public function listsAction(): HttpResponse|ViewModel return $this->memberIsDeleted($member); } + // If a Mailman sync is in progress, we cannot safely allow edits to mail list memberships. + if ($this->mailmanService->isSyncLocked()) { + $viewModel = new ViewModel(['member' => $member]); + $viewModel->setTemplate('database/member/mailman.phtml'); + + return $viewModel; + } + if ($this->getRequest()->isPost()) { $member = $this->memberService->subscribeLists( $member, diff --git a/module/Database/src/Controller/SettingsController.php b/module/Database/src/Controller/SettingsController.php index ffd73af06..d3fbd31de 100644 --- a/module/Database/src/Controller/SettingsController.php +++ b/module/Database/src/Controller/SettingsController.php @@ -5,7 +5,9 @@ namespace Database\Controller; use Database\Model\Enums\InstallationFunctions; +use Database\Model\MailingList as MailingListModel; use Database\Service\MailingList as MailingListService; +use Laminas\Http\Request; use Laminas\Http\Response as HttpResponse; use Laminas\Mvc\Controller\AbstractActionController; use Laminas\Mvc\I18n\Translator as MvcTranslator; @@ -42,15 +44,70 @@ public function functionAction(): ViewModel /** * Mailing list action */ - public function listAction(): ViewModel + public function listsAction(): ViewModel { - if ($this->getRequest()->isPost()) { - $this->mailingListService->addList($this->getRequest()->getPost()->toArray()); + return new ViewModel([ + 'lists' => $this->mailingListService->getAllLists(), + 'mailman' => $this->mailingListService->getMailmanService()->getMailingListIds(), + ]); + } + + public function addListAction(): HttpResponse|ViewModel + { + $form = $this->mailingListService->getListForm(); + $form->setMailmanIds($this->mailingListService->getMailmanService()->getMailingListIds()['lists']); + + /** @var Request $request */ + $request = $this->getRequest(); + if ($request->isPost()) { + $form->bind(new MailingListModel()); + $form->setData($request->getPost()->toArray()); + + if ($form->isValid()) { + /** @var MailingListModel $list */ + $list = $form->getData(); + $this->mailingListService->addList($list); + + return $this->redirect()->toRoute('settings/lists/edit', ['name' => $list->getName()]); + } } return new ViewModel([ - 'lists' => $this->mailingListService->getAllLists(), - 'form' => $this->mailingListService->getListForm(), + 'form' => $form, + 'action' => 'add', + ]); + } + + public function editListAction(): HttpResponse|ViewModel + { + $listName = $this->params()->fromRoute('name'); + $list = $this->mailingListService->getList($listName); + + if (null === $list) { + return $this->notFoundAction(); + } + + $form = $this->mailingListService->getListForm(); + $form->setMailmanIds($this->mailingListService->getMailmanService()->getMailingListIds()['lists']); + + /** @var Request $request */ + $request = $this->getRequest(); + if ($request->isPost()) { + $form->setData($request->getPost()->toArray()); + + if ($form->isValid()) { + $list = $this->mailingListService->editList($list, $form->getData()); + + return $this->redirect()->toRoute('settings/lists/edit', ['name' => $list->getName()]); + } + } + + $form->setData($list->toArray()); + + return new ViewModel([ + 'action' => 'edit', + 'form' => $form, + 'list' => $list, ]); } @@ -78,4 +135,14 @@ public function deleteListAction(): HttpResponse|ViewModel 'name' => $name, ]); } + + /** + * Sync known mailing list ids from Mailman + */ + public function syncListsAction(): HttpResponse + { + $this->mailingListService->getMailmanService()->cacheMailingLists(); + + return $this->redirect()->toRoute('settings/lists'); + } } diff --git a/module/Database/src/Form/MailingList.php b/module/Database/src/Form/MailingList.php index 64576322b..3f717eebe 100644 --- a/module/Database/src/Form/MailingList.php +++ b/module/Database/src/Form/MailingList.php @@ -6,6 +6,7 @@ use Laminas\Filter\StringTrim; use Laminas\Form\Element\Checkbox; +use Laminas\Form\Element\Select; use Laminas\Form\Element\Submit; use Laminas\Form\Element\Text; use Laminas\Form\Element\Textarea; @@ -61,6 +62,16 @@ public function __construct(private readonly Translator $translator) ], ]); + $this->add([ + 'name' => 'mailmanId', + 'type' => Select::class, + 'options' => [ + 'label' => $this->translator->translate('Mailman Mailing List'), + 'empty_option' => $this->translator->translate('Choose a mailing list'), + 'value_options' => [], + ], + ]); + $this->add([ 'name' => 'submit', 'type' => Submit::class, @@ -70,6 +81,20 @@ public function __construct(private readonly Translator $translator) ]); } + /** + * @param string[] $mailmanIds + */ + public function setMailmanIds(array $mailmanIds): void + { + $options = []; + + foreach ($mailmanIds as $mailmanId) { + $options[$mailmanId] = $mailmanId; + } + + $this->get('mailmanId')->setValueOptions($options); + } + /** * Specification of input filter. */ diff --git a/module/Database/src/Form/MemberLists.php b/module/Database/src/Form/MemberLists.php index 2c2acb1b6..f16acd4a5 100644 --- a/module/Database/src/Form/MemberLists.php +++ b/module/Database/src/Form/MemberLists.php @@ -6,12 +6,14 @@ use Database\Model\MailingList as MailingListModel; use Database\Model\Member as MemberModel; -use Laminas\Form\Element\Checkbox; +use Laminas\Form\Element\MultiCheckbox; use Laminas\Form\Element\Submit; use Laminas\Form\Form; use Laminas\InputFilter\InputFilterProviderInterface; use Laminas\Mvc\I18n\Translator; +use function array_key_exists; + class MemberLists extends Form implements InputFilterProviderInterface { /** @@ -24,22 +26,66 @@ public function __construct( ) { parent::__construct(); + $memberLists = []; + foreach ($member->getMailingListMemberships() as $mailingListMember) { + $memberLists[$mailingListMember->getMailingList()->getName()] = [ + 'synced' => null !== $mailingListMember->getLastSyncOn() && $mailingListMember->isLastSyncSuccess(), + 'toBeDeleted' => $mailingListMember->isToBeDeleted(), + ]; + } + + $listOptions = []; foreach ($this->lists as $list) { - $this->add([ - 'name' => 'list-' . $list->getName(), - 'type' => Checkbox::class, - 'options' => [ - 'label' => '' . $list->getName() . ' ' . $list->getDescription(), - ], - ]); - foreach ($member->getLists() as $lst) { - if ($lst->getName() === $list->getName()) { - $this->get('list-' . $list->getName())->setChecked(true); - break; + $listName = $list->getName(); + + $selected = array_key_exists($listName, $memberLists); + $synced = $memberLists[$listName]['synced']; + $toBeDeleted = $memberLists[$listName]['toBeDeleted']; + $disabled = $selected && ($toBeDeleted || !$synced); + + $label = $listName; + if ($selected) { + $label .= ' ('; + + if ( + $synced + && $toBeDeleted + ) { + $label .= $this->translator->translate('to be deleted'); + } elseif ( + $synced + && !$toBeDeleted + ) { + $label .= $this->translator->translate('synced'); + } elseif ( + !$synced + && $toBeDeleted + ) { + $label .= $this->translator->translate('to be deleted'); + } else { + $label .= $this->translator->translate('to be synced'); } + + $label .= ')'; } + + $listOptions[] = [ + 'value' => $listName, + 'label' => $label, + 'selected' => $selected, + 'disabled' => $disabled, + ]; } + $this->add([ + 'type' => MultiCheckbox::class, + 'name' => 'lists', + 'options' => [ + 'label' => $this->translator->translate('Lists'), + 'value_options' => $listOptions, + ], + ]); + $this->add([ 'name' => 'submit', 'type' => Submit::class, diff --git a/module/Database/src/Mapper/Factory/MailingListMemberFactory.php b/module/Database/src/Mapper/Factory/MailingListMemberFactory.php new file mode 100644 index 000000000..d7e691cf1 --- /dev/null +++ b/module/Database/src/Mapper/Factory/MailingListMemberFactory.php @@ -0,0 +1,23 @@ +get('doctrine.entitymanager.orm_default')); + } +} diff --git a/module/Database/src/Mapper/MailingListMember.php b/module/Database/src/Mapper/MailingListMember.php new file mode 100644 index 000000000..9a0438a34 --- /dev/null +++ b/module/Database/src/Mapper/MailingListMember.php @@ -0,0 +1,69 @@ +em->persist($list); + $this->em->flush(); + } + + /** + * Remove a membership. + */ + public function remove(MailingListMemberModel $list): void + { + $this->em->remove($list); + $this->em->flush(); + } + + public function findByListAndMember( + MailingListModel $list, + MemberModel $member, + ): ?MailingListMemberModel { + $qb = $this->getRepository()->createQueryBuilder('m'); + $qb->where('m.mailingList = :list') + ->andWhere('m.member = :member'); + + $qb->setParameter('list', $list) + ->setParameter('member', $member); + + return $qb->getQuery()->getOneOrNullResult(); + } + + /** + * @return MailingListMemberModel[] + */ + public function findAll(): array + { + return $this->getRepository()->findAll(); + } + + /** + * Get the repository for this mapper. + */ + public function getRepository(): EntityRepository + { + return $this->em->getRepository(MailingListMemberModel::class); + } +} diff --git a/module/Database/src/Mapper/Member.php b/module/Database/src/Mapper/Member.php index d71625828..d297e9520 100644 --- a/module/Database/src/Mapper/Member.php +++ b/module/Database/src/Mapper/Member.php @@ -154,7 +154,7 @@ public function find(int $lidnr): ?MemberModel ->from(MemberModel::class, 'm') ->where('m.lidnr = :lidnr') ->leftJoin('m.installations', 'r') - ->leftJoin('m.lists', 'l') + ->leftJoin('m.mailingListMemberships', 'l') ->andWhere('(r.function = \'Lid\' OR r.function = \'Inactief Lid\' OR r.function IS NULL)'); // discharges @@ -217,7 +217,7 @@ public function findSimple(int $lidnr): ?MemberModel $qb->select('m, l') ->from('Database\Model\Member', 'm') ->where('m.lidnr = :lidnr') - ->leftJoin('m.lists', 'l') + ->leftJoin('m.mailingListMemberships', 'l') ->orderBy('m.lidnr', 'DESC'); $qb->setParameter(':lidnr', $lidnr); diff --git a/module/Database/src/Mapper/ProspectiveMember.php b/module/Database/src/Mapper/ProspectiveMember.php index c944dd352..0d9dc22f1 100644 --- a/module/Database/src/Mapper/ProspectiveMember.php +++ b/module/Database/src/Mapper/ProspectiveMember.php @@ -116,12 +116,8 @@ public function findAll(): array */ public function find(int $lidnr): ?ProspectiveMemberModel { - $qb = $this->em->createQueryBuilder(); - - $qb->select('m, l') - ->from(ProspectiveMemberModel::class, 'm') - ->where('m.lidnr = :lidnr') - ->leftJoin('m.lists', 'l'); + $qb = $this->getRepository()->createQueryBuilder('m'); + $qb->where('m.lidnr = :lidnr'); $qb->setParameter(':lidnr', $lidnr); diff --git a/module/Database/src/Model/MailingList.php b/module/Database/src/Model/MailingList.php index 4fb6f95d0..07f3d323d 100644 --- a/module/Database/src/Model/MailingList.php +++ b/module/Database/src/Model/MailingList.php @@ -9,7 +9,7 @@ use Doctrine\ORM\Mapping\Column; use Doctrine\ORM\Mapping\Entity; use Doctrine\ORM\Mapping\Id; -use Doctrine\ORM\Mapping\ManyToMany; +use Doctrine\ORM\Mapping\OneToMany; /** * Mailing List model. @@ -50,20 +50,29 @@ class MailingList #[Column(type: 'boolean')] protected bool $defaultSub; + /** + * The identifier of the mailing list in Mailman. + */ + #[Column( + type: 'string', + unique: true, + )] + protected string $mailmanId; + /** * Mailing list members. * - * @var Collection + * @var Collection */ - #[ManyToMany( - targetEntity: Member::class, - mappedBy: 'lists', + #[OneToMany( + targetEntity: MailingListMember::class, + mappedBy: 'mailingList', )] - protected Collection $members; + protected Collection $mailingListMemberships; public function __construct() { - $this->members = new ArrayCollection(); + $this->mailingListMemberships = new ArrayCollection(); } /** @@ -114,22 +123,6 @@ public function setNlDescription(string $description): void $this->nl_description = $description; } - /** - * Get the description. - */ - public function getDescription(): string - { - return $this->getNlDescription(); - } - - /** - * Set the description. - */ - public function setDescription(string $description): void - { - $this->setNlDescription($description); - } - /** * Get if it should be on the form. */ @@ -162,21 +155,51 @@ public function setDefaultSub(bool $default): void $this->defaultSub = $default; } + /** + * Get the identifier of the mailing list in Mailman. + */ + public function getMailmanId(): string + { + return $this->mailmanId; + } + + /** + * Set the identifier of the mailing list in Mailman. + */ + public function setMailmanId(string $mailmanId): void + { + $this->mailmanId = $mailmanId; + } + /** * Get subscribed members. * - * @return Collection + * @return Collection */ - public function getMembers(): Collection + public function getMailingListMemberships(): Collection { - return $this->members; + return $this->mailingListMemberships; } /** - * Add a member. + * @return array{ + * name: string, + * nl_description: string, + * en_description: string, + * defaultSub: bool, + * onForm: bool, + * mailmanId: string, + * } */ - public function addMember(Member $member): void + public function toArray(): array { - $this->members->add($member); + return [ + 'name' => $this->getName(), + 'nl_description' => $this->getNlDescription(), + 'en_description' => $this->getEnDescription(), + 'defaultSub' => $this->getDefaultSub(), + 'onForm' => $this->getOnForm(), + 'mailmanId' => $this->getMailmanId(), + ]; } } diff --git a/module/Database/src/Model/MailingListMember.php b/module/Database/src/Model/MailingListMember.php new file mode 100644 index 000000000..88cbf37cc --- /dev/null +++ b/module/Database/src/Model/MailingListMember.php @@ -0,0 +1,195 @@ + member associations are never directly propagated to Mailman. When synchronizing the state directly, + * the chances of something going wrong are too high. For example, we do not want someone to be registered in Mailman + * for a list, but this is not directly visible in the database. What we do want is to always know in the database the + * state of a member who is a member of a mailing list, as such persisting this entity is our highest priority. + * + * The actual synchronization should take place through cron jobs. To keep track of what is supposed to happen, the + * additional properties in this entity are used for this. + */ +#[Entity] +#[UniqueConstraint( + name: 'mailinglistmember_unique_idx', + columns: ['mailingList', 'member'], +)] +class MailingListMember +{ + /** + * Mailing list. + */ + #[Id] + #[ManyToOne( + targetEntity: MailingList::class, + inversedBy: 'mailingListMemberships', + )] + #[JoinColumn( + name: 'mailingList', + referencedColumnName: 'name', + )] + private MailingList $mailingList; + + /** + * Member. + */ + #[Id] + #[ManyToOne( + targetEntity: Member::class, + inversedBy: 'mailingListMemberships', + )] + #[JoinColumn( + name: 'member', + referencedColumnName: 'lidnr', + )] + private Member $member; + + #[Column( + type: 'string', + nullable: true, + )] + private ?string $membershipId = null; + + /** + * When this association was last synced to/from Mailman. + */ + #[Column( + type: 'datetime', + nullable: true, + )] + protected ?DateTime $lastSyncOn = null; + + /** + * Whether the last attempted sync was successful. + * + * At creation of the association, no sync has taken place (i.e. {@see MailingListMember::$lastSyncOn} is `null`) so + * we default to `false`. + */ + #[Column(type: 'boolean')] + protected bool $lastSyncSuccess = false; + + /** + * Whether this entry still needs to be removed from Mailman. + * + * It indicates that there is no longer an association between the mailing list and the member. + */ + #[Column(type: 'boolean')] + protected bool $toBeDeleted = false; + + public function __construct() + { + } + + /** + * Get the mailing list. + */ + public function getMailingList(): MailingList + { + return $this->mailingList; + } + + /** + * Set the mailing list. + */ + public function setMailingList(MailingList $mailingList): void + { + $this->mailingList = $mailingList; + } + + /** + * Get the member. + */ + public function getMember(): Member + { + return $this->member; + } + + /** + * Set the member. + */ + public function setMember(Member $member): void + { + $this->member = $member; + } + + /** + * Get the Mailman `member_id` for this subscription. + */ + public function getMembershipId(): ?string + { + return $this->membershipId; + } + + /** + * Set the Mailman `member_id` for this subscription. + */ + public function setMembershipId(string $membershipId): void + { + $this->membershipId = $membershipId; + } + + /** + * Get when the last sync happened. + */ + public function getLastSyncOn(): ?DateTime + { + return $this->lastSyncOn; + } + + /** + * Set when the last sync happened. + */ + public function setLastSyncOn(DateTime $lastSyncOn): void + { + $this->lastSyncOn = $lastSyncOn; + } + + /** + * Get whether the last sync was successful. + */ + public function isLastSyncSuccess(): bool + { + return $this->lastSyncSuccess; + } + + /** + * Set whether the last sync was successful. + */ + public function setLastSyncSuccess(bool $lastSyncSuccess): void + { + $this->lastSyncSuccess = $lastSyncSuccess; + } + + /** + * Get whether the entry must still be removed from Mailman. + */ + public function isToBeDeleted(): bool + { + return $this->toBeDeleted; + } + + /** + * Set whether the entry must still be removed from Mailman. + */ + public function setToBeDeleted(bool $toBeDeleted): void + { + $this->toBeDeleted = $toBeDeleted; + } +} diff --git a/module/Database/src/Model/Member.php b/module/Database/src/Model/Member.php index 5935de19f..8612eeaa7 100644 --- a/module/Database/src/Model/Member.php +++ b/module/Database/src/Model/Member.php @@ -14,12 +14,9 @@ use Doctrine\ORM\Mapping\Entity; use Doctrine\ORM\Mapping\GeneratedValue; use Doctrine\ORM\Mapping\Id; -use Doctrine\ORM\Mapping\InverseJoinColumn; -use Doctrine\ORM\Mapping\JoinColumn; -use Doctrine\ORM\Mapping\JoinTable; -use Doctrine\ORM\Mapping\ManyToMany; use Doctrine\ORM\Mapping\OneToMany; use Laminas\Mail\Address as MailAddress; +use RuntimeException; use function mb_encode_mimeheader; @@ -216,22 +213,14 @@ enumType: MembershipTypes::class, /** * Memberships of mailing lists. * - * @var Collection + * @var Collection */ - #[ManyToMany( - targetEntity: MailingList::class, - inversedBy: 'members', - )] - #[JoinTable(name: 'members_mailinglists')] - #[JoinColumn( - name: 'lidnr', - referencedColumnName: 'lidnr', - )] - #[InverseJoinColumn( - name: 'name', - referencedColumnName: 'name', + #[OneToMany( + targetEntity: MailingListMember::class, + mappedBy: 'member', + cascade: ['persist'], )] - protected Collection $lists; + protected Collection $mailingListMemberships; /** * RenewalLinks of this member. @@ -283,7 +272,7 @@ public function __construct() { $this->addresses = new ArrayCollection(); $this->installations = new ArrayCollection(); - $this->lists = new ArrayCollection(); + $this->mailingListMemberships = new ArrayCollection(); } /** @@ -369,6 +358,17 @@ public function setLidnr(int $lidnr): void */ public function setEmail(?string $email): void { + $toBeDeletedExists = $this->mailingListMemberships->exists(static function ($key, MailingListMember $list) { + return $list->isToBeDeleted(); + }); + + if ($toBeDeletedExists) { + throw new RuntimeException( + // phpcs:ignore -- user-visible strings should not be split + 'The e-mail address cannot be updated as there are mailing list memberships marked to be deleted. Please wait till after the next sync with Mailman has happened.', + ); + } + $this->email = $email; } @@ -743,28 +743,30 @@ public function addAddress(Address $address): void /** * Get mailing list subscriptions. * - * @return Collection + * @return Collection */ - public function getLists(): Collection + public function getMailingListMemberships(): Collection { - return $this->lists; + return $this->mailingListMemberships; } /** * Add a mailing list subscription. - * - * Note that this is the owning side. */ - public function addList(MailingList $list): void + public function addList(MailingListMember $list): void { - $list->addMember($this); - $this->lists[] = $list; + if ($this->mailingListMemberships->contains($list)) { + return; + } + + $list->setMember($this); + $this->mailingListMemberships->add($list); } /** * Add multiple mailing lists. * - * @param MailingList[] $lists + * @param MailingListMember[] $lists */ public function addLists(array $lists): void { @@ -773,14 +775,6 @@ public function addLists(array $lists): void } } - /** - * Clear the lists. - */ - public function clearLists(): void - { - $this->lists = new ArrayCollection(); - } - /** * Set the home address. */ diff --git a/module/Database/src/Model/ProspectiveMember.php b/module/Database/src/Model/ProspectiveMember.php index 2e295aace..f6d5a7f12 100644 --- a/module/Database/src/Model/ProspectiveMember.php +++ b/module/Database/src/Model/ProspectiveMember.php @@ -14,14 +14,12 @@ use Doctrine\ORM\Mapping\Entity; use Doctrine\ORM\Mapping\GeneratedValue; use Doctrine\ORM\Mapping\Id; -use Doctrine\ORM\Mapping\InverseJoinColumn; -use Doctrine\ORM\Mapping\JoinColumn; -use Doctrine\ORM\Mapping\JoinTable; -use Doctrine\ORM\Mapping\ManyToMany; use Doctrine\ORM\Mapping\OneToMany; use Doctrine\ORM\Mapping\OneToOne; use Doctrine\ORM\Mapping\OrderBy; +use function in_array; + /** * ProspectiveMember model. */ @@ -144,22 +142,10 @@ enumType: PostalRegions::class, /** * Memberships of mailing lists. * - * @var Collection + * @var string[] $lists */ - #[ManyToMany( - targetEntity: MailingList::class, - inversedBy: 'members', - )] - #[JoinTable(name: 'prospective_members_mailinglists')] - #[JoinColumn( - name: 'lidnr', - referencedColumnName: 'lidnr', - )] - #[InverseJoinColumn( - name: 'name', - referencedColumnName: 'name', - )] - protected Collection $lists; + #[Column(type: 'simple_array')] + protected array $lists = []; /** * The Checkout Sessions for this prospective member. @@ -186,7 +172,6 @@ enumType: PostalRegions::class, public function __construct() { - $this->lists = new ArrayCollection(); $this->checkoutSessions = new ArrayCollection(); } @@ -494,9 +479,9 @@ public function setAddress(Address $address): void /** * Get mailing list subscriptions. * - * @return Collection + * @return string[] */ - public function getLists(): Collection + public function getLists(): array { return $this->lists; } @@ -506,15 +491,19 @@ public function getLists(): Collection * * Note that this is the owning side. */ - public function addList(MailingList $list): void + public function addList(string $list): void { + if (in_array($list, $this->lists)) { + return; + } + $this->lists[] = $list; } /** * Add multiple mailing lists. * - * @param MailingList[] $lists + * @param string[] $lists */ public function addLists(array $lists): void { diff --git a/module/Database/src/Module.php b/module/Database/src/Module.php index 9c305f944..61e228e04 100644 --- a/module/Database/src/Module.php +++ b/module/Database/src/Module.php @@ -72,6 +72,7 @@ use Database\Mapper\Factory\AuditFactory as AuditMapperFactory; use Database\Mapper\Factory\CheckoutSessionFactory as CheckoutSessionMapperFactory; use Database\Mapper\Factory\MailingListFactory as MailingListMapperFactory; +use Database\Mapper\Factory\MailingListMemberFactory as MailingListMemberMapperFactory; use Database\Mapper\Factory\MeetingFactory as MeetingMapperFactory; use Database\Mapper\Factory\MemberFactory as MemberMapperFactory; use Database\Mapper\Factory\MemberUpdateFactory as MemberUpdateMapperFactory; @@ -79,6 +80,7 @@ use Database\Mapper\Factory\ProspectiveMemberFactory as ProspectiveMemberMapperFactory; use Database\Mapper\Factory\SavedQueryFactory as SavedQueryMapperFactory; use Database\Mapper\MailingList as MailingListMapper; +use Database\Mapper\MailingListMember as MailingListMemberMapper; use Database\Mapper\Meeting as MeetingMapper; use Database\Mapper\Member as MemberMapper; use Database\Mapper\MemberUpdate as MemberUpdateMapper; @@ -99,21 +101,26 @@ use Database\Service\Factory\ApiFactory as ApiServiceFactory; use Database\Service\Factory\FrontPageFactory as FrontPageServiceFactory; use Database\Service\Factory\MailingListFactory as MailingListServiceFactory; +use Database\Service\Factory\MailmanFactory as MailmanServiceFactory; use Database\Service\Factory\MeetingFactory as MeetingServiceFactory; use Database\Service\Factory\MemberFactory as MemberServiceFactory; use Database\Service\Factory\QueryFactory as QueryServiceFactory; use Database\Service\Factory\StripeFactory as StripeServiceFactory; use Database\Service\FrontPage as FrontPageService; use Database\Service\MailingList as MailingListService; +use Database\Service\Mailman as MailmanService; use Database\Service\Meeting as MeetingService; use Database\Service\Member as MemberService; use Database\Service\Query as QueryService; use Database\Service\Stripe as StripeService; use Doctrine\Laminas\Hydrator\DoctrineObject; +use Laminas\Cache\Storage\Adapter\Memcached; +use Laminas\Cache\Storage\Adapter\MemcachedOptions; use Laminas\Http\PhpEnvironment\RemoteAddress; use Laminas\Hydrator\ObjectPropertyHydrator; use Laminas\Mvc\I18n\Translator as MvcTranslator; use Psr\Container\ContainerInterface; +use RuntimeException; use stdClass; use function array_map; @@ -161,6 +168,7 @@ public function getServiceConfig(): array ApiService::class => ApiServiceFactory::class, FrontPageService::class => FrontPageServiceFactory::class, MailingListService::class => MailingListServiceFactory::class, + MailmanService::class => MailmanServiceFactory::class, MeetingService::class => MeetingServiceFactory::class, MemberService::class => MemberServiceFactory::class, StripeService::class => StripeServiceFactory::class, @@ -540,6 +548,7 @@ public function getServiceConfig(): array ActionLinkMapper::class => ActionLinkMapperFactory::class, AuditMapper::class => AuditMapperFactory::class, MailingListMapper::class => MailingListMapperFactory::class, + MailingListMemberMapper::class => MailingListMemberMapperFactory::class, MeetingMapper::class => MeetingMapperFactory::class, MemberMapper::class => MemberMapperFactory::class, MemberUpdateMapper::class => MemberUpdateMapperFactory::class, @@ -599,6 +608,19 @@ static function (string $ip) { return $remote->getIpAddress(); }, + 'database_cache_mailman' => static function () { + $cache = new Memcached(); + // The TTL is 24 hours (60 * 60 * 24), unless manually refreshed. + $options = $cache->getOptions(); + if (!($options instanceof MemcachedOptions)) { + throw new RuntimeException('Unable to retrieve and set options for Memcached'); + } + + $options->setTtl(60 * 60 * 24); + $options->setServers(['memcached', '11211']); + + return $cache; + }, ], 'shared' => [ // every form should get a different meeting fieldset diff --git a/module/Database/src/Service/Api.php b/module/Database/src/Service/Api.php index dea2a5584..af87a0722 100644 --- a/module/Database/src/Service/Api.php +++ b/module/Database/src/Service/Api.php @@ -10,6 +10,7 @@ use Report\Mapper\Member as ReportMemberMapper; use function array_reduce; +use function is_bool; use function is_string; use function max; @@ -142,6 +143,10 @@ private function getSyncPausedUntil(): ?DateTime return null; } + if (is_bool($pausedUntil)) { + return null; + } + return $pausedUntil; } diff --git a/module/Database/src/Service/Factory/MailingListFactory.php b/module/Database/src/Service/Factory/MailingListFactory.php index 8b51587a0..56ead01e1 100644 --- a/module/Database/src/Service/Factory/MailingListFactory.php +++ b/module/Database/src/Service/Factory/MailingListFactory.php @@ -8,6 +8,7 @@ use Database\Form\MailingList as MailingListForm; use Database\Mapper\MailingList as MailingListMapper; use Database\Service\MailingList as MailingListService; +use Database\Service\Mailman as MailmanService; use Laminas\ServiceManager\Factory\FactoryInterface; use Psr\Container\ContainerInterface; @@ -27,11 +28,14 @@ public function __invoke( $mailingListForm = $container->get(MailingListForm::class); /** @var MailingListMapper $mailingListMapper */ $mailingListMapper = $container->get(MailingListMapper::class); + /** @var MailmanService $mailmanService */ + $mailmanService = $container->get(MailmanService::class); return new MailingListService( $deleteListForm, $mailingListForm, $mailingListMapper, + $mailmanService, ); } } diff --git a/module/Database/src/Service/Factory/MailmanFactory.php b/module/Database/src/Service/Factory/MailmanFactory.php new file mode 100644 index 000000000..f93aa66da --- /dev/null +++ b/module/Database/src/Service/Factory/MailmanFactory.php @@ -0,0 +1,40 @@ +get('database_cache_mailman'); + /** @var MailingListMemberMapper $mailingListMemberMapper */ + $mailingListMemberMapper = $container->get(MailingListMemberMapper::class); + /** @var ConfigService $configService */ + $configService = $container->get(ConfigService::class); + /** @var array $mailmanConfig */ + $mailmanConfig = $container->get('config')['mailman_api']; + + return new MailmanService( + $mailmanCache, + $mailingListMemberMapper, + $configService, + $mailmanConfig, + ); + } +} diff --git a/module/Database/src/Service/Factory/MemberFactory.php b/module/Database/src/Service/Factory/MemberFactory.php index 997e94ed2..24278807c 100644 --- a/module/Database/src/Service/Factory/MemberFactory.php +++ b/module/Database/src/Service/Factory/MemberFactory.php @@ -19,10 +19,12 @@ use Database\Mapper\ActionLink as ActionLinkMapper; use Database\Mapper\Audit as AuditMapper; use Database\Mapper\MailingList as MailingListMapper; +use Database\Mapper\MailingListMember as MailingListMemberMapper; use Database\Mapper\Member as MemberMapper; use Database\Mapper\MemberUpdate as MemberUpdateMapper; use Database\Mapper\ProspectiveMember as ProspectiveMemberMapper; use Database\Service\MailingList as MailingListService; +use Database\Service\Mailman as MailmanService; use Database\Service\Member as MemberService; use Laminas\Mail\Transport\TransportInterface; use Laminas\Mvc\I18n\Translator as MvcTranslator; @@ -67,6 +69,8 @@ public function __invoke( $auditMapper = $container->get(AuditMapper::class); /** @var MailingListMapper $mailingListMapper */ $mailingListMapper = $container->get(MailingListMapper::class); + /** @var MailingListMemberMapper $mailingListMemberMapper */ + $mailingListMemberMapper = $container->get(MailingListMemberMapper::class); /** @var MemberMapper $memberMapper */ $memberMapper = $container->get(MemberMapper::class); /** @var MemberUpdateMapper $memberUpdateMapper */ @@ -79,6 +83,8 @@ public function __invoke( $fileStorageService = $container->get(FileStorageService::class); /** @var MailingListService $mailingListService */ $mailingListService = $container->get(MailingListService::class); + /** @var MailmanService $mailmanService */ + $mailmanService = $container->get(MailmanService::class); /** @var RenewalService $renewalService */ $renewalService = $container->get(RenewalService::class); /** @var UserService $userService */ @@ -102,6 +108,7 @@ public function __invoke( $memberRenewalForm, $memberTypeForm, $mailingListMapper, + $mailingListMemberMapper, $actionLinkMapper, $auditMapper, $memberMapper, @@ -110,6 +117,7 @@ public function __invoke( $checkerService, $fileStorageService, $mailingListService, + $mailmanService, $renewalService, $userService, $viewRenderer, diff --git a/module/Database/src/Service/MailingList.php b/module/Database/src/Service/MailingList.php index 30b24238a..845b57fbb 100644 --- a/module/Database/src/Service/MailingList.php +++ b/module/Database/src/Service/MailingList.php @@ -8,6 +8,9 @@ use Database\Form\MailingList as MailingListForm; use Database\Mapper\MailingList as MailingListMapper; use Database\Model\MailingList as MailingListModel; +use Database\Service\Mailman as MailmanService; + +use function boolval; class MailingList { @@ -15,6 +18,7 @@ public function __construct( private readonly DeleteListForm $deleteListForm, private readonly MailingListForm $mailingListForm, private readonly MailingListMapper $mailingListMapper, + private readonly MailmanService $mailmanService, ) { } @@ -38,25 +42,29 @@ public function getList(string $name): ?MailingListModel /** * Add a list. - * - * @phpcsSuppress SlevomatCodingStandard.TypeHints.ParameterTypeHint.MissingTraversableTypeHintSpecification */ - public function addList(array $data): bool + public function addList(MailingListModel $list): void { - $form = $this->getListForm(); - - $form->bind(new MailingListModel()); - $form->setData($data); + $this->getListMapper()->persist($list); + } - if (!$form->isValid()) { - return false; - } + /** + * @phpcsSuppress SlevomatCodingStandard.TypeHints.ParameterTypeHint.MissingTraversableTypeHintSpecification + */ + public function editList( + MailingListModel $list, + array $data, + ): MailingListModel { + $list->setName($data['name']); + $list->setEnDescription($data['en_description']); + $list->setNlDescription($data['nl_description']); + $list->setOnForm(boolval($data['onForm'])); + $list->setDefaultSub(boolval($data['defaultSub'])); + $list->setMailmanId($data['mailmanId']); - /** @var MailingListModel $list */ - $list = $form->getData(); $this->getListMapper()->persist($list); - return true; + return $list; } /** @@ -105,4 +113,9 @@ public function getListMapper(): MailingListMapper { return $this->mailingListMapper; } + + public function getMailmanService(): MailmanService + { + return $this->mailmanService; + } } diff --git a/module/Database/src/Service/Mailman.php b/module/Database/src/Service/Mailman.php new file mode 100644 index 000000000..987678dff --- /dev/null +++ b/module/Database/src/Service/Mailman.php @@ -0,0 +1,241 @@ +setMethod($method) + ->setUri($this->mailmanConfig['endpoint'] . $uri); + $client->setAdapter(Curl::class) + ->setAuth($this->mailmanConfig['username'], $this->mailmanConfig['password']); + + // Data encoding is automatically set to `application/x-www-form-urlencoded` for "POST"-like requests. + switch ($method) { + case Request::METHOD_GET: + $client->setParameterGet($data); + break; + case Request::METHOD_POST: + case Request::METHOD_DELETE: + case Request::METHOD_PATCH: + $client->setParameterPost($data); + break; + } + + try { + $response = $client->send($request); + } catch (RuntimeException $e) { + throw new RuntimeException('Failed to send request: ' . $e->getMessage()); + } + + // We want to try to parse everything that returned a 2xx status code. + if (!$response->isSuccess()) { + throw new RuntimeException('Request failed with status code: ' . $response->getStatusCode()); + } + + // Parse body of response. + $body = $response->getBody(); + + // If the body is empty, return empty array (e.g. for 204 status code). + if ('' === $body) { + return []; + } + + if (!json_validate($body)) { + throw new RuntimeException('Failed to parse JSON response: ' . json_last_error_msg()); + } + + return json_decode($body, true); + } + + /** + * Acquire sync lock. + * + * To ensure that the sync between GEWISDB and Mailman is as clean as possible, we need to acquire a global lock on + * the mail list administration. This will prevent (if properly implemented and used) the secretary from modifying + * any mailing list memberships. + */ + public function acquireSyncLock(int $retries = 3): void + { + if (0 === $retries) { + throw new RuntimeException('Unable to acquire sync lock for Mailman sync: timeout.'); + } + + if ($this->isSyncLocked()) { + throw new RuntimeException('Unable to acquire sync lock for Mailman sync: locked by other process.'); + } + + $this->configService->setConfig(ConfigNamespaces::DatabaseMailman, 'locked', true); + + if ($this->isSyncLocked()) { + return; + } + + $this->acquireSyncLock($retries - 1); + } + + /** + * Release sync lock. + * + * Releases the sync lock after the sync between GEWISDB and Mailman happened. + */ + public function releaseSyncLock(): void + { + $this->configService->setConfig(ConfigNamespaces::DatabaseMailman, 'locked', false); + } + + /** + * Get state of sync lock. + */ + public function isSyncLocked(): bool + { + return $this->configService->getConfig(ConfigNamespaces::DatabaseMailman, 'locked', false); + } + + public function isMailmanHealthy(): bool + { + try { + $data = $this->performMailmanRequest('system/versions'); + } catch (RuntimeException) { + return false; + } + + return isset($data['api_version']) && $data['api_version'] === $this->mailmanConfig['version']; + } + + /** + * @return string[] + */ + private function getAllListIdsFromMailman(): array + { + $lists = $this->performMailmanRequest('lists'); + + if ( + isset($lists['total_size']) + && 0 !== $lists['total_size'] + ) { + return array_column($lists['entries'], 'list_id'); + } + + return []; + } + + public function cacheMailingLists(): void + { + $this->mailmanCache->setItem( + 'lists', + [ + 'synced' => new DateTime(), + 'lists' => $this->getAllListIdsFromMailman(), + ], + ); + } + + /** + * @return array{ + * synced: DateTime, + * lists: string[], + * } + */ + public function getMailingListIds(): array + { + if (!$this->mailmanCache->hasItem('lists')) { + $this->cacheMailingLists(); + } + + return $this->mailmanCache->getItem('lists'); + } + + /** + * Subscribe a member to a mailing list. + * + * Unfortunately, this must be done one at the time as there is no mass-subscribe functionality in the API. See + * https://gitlab.com/mailman/mailman/-/issues/419 for the open issue. + */ + private function subscribeMemberToMailingList(MailingListMemberModel $mailingListMember): void + { + $member = $mailingListMember->getMember(); + $listId = $mailingListMember->getMailingList()->getMailmanId(); + + // Create the data for the request + $data = [ + 'list_id' => $listId, + 'subscriber' => $member->getEmail(), + 'display_name' => $member->getFullName(), + 'role' => 'member', + 'pre_verified' => true, + 'pre_confirmed' => true, + 'pre_approved' => true, + 'send_welcome_message' => false, + 'delivery_mode' => 'regular', + 'delivery_status' => 'enabled', + ]; + + // Send the request to the Mailman API + $mailingListMember->setLastSyncOn(new DateTime()); + $response = $this->performMailmanRequest( + uri: 'members', + method: Request::METHOD_POST, + data: $data, + ); + + // Check if the request was successful + if (isset($response['member_id'])) { + $mailingListMember->setLastSyncSuccess(true); + $mailingListMember->setMembershipId($response['member_id']); + } else { + $mailingListMember->setLastSyncSuccess(false); + } + + $this->mailingListMemberMapper->persist($mailingListMember); + } + + public function unsubscribeMemberFromMailingList(): void + { + } + + public function massUnsubscribeMembersFromMailingList(): void + { + } +} diff --git a/module/Database/src/Service/Member.php b/module/Database/src/Service/Member.php index 2c07cee24..fb6a001fb 100644 --- a/module/Database/src/Service/Member.php +++ b/module/Database/src/Service/Member.php @@ -24,6 +24,7 @@ use Database\Mapper\ActionLink as ActionLinkMapper; use Database\Mapper\Audit as AuditMapper; use Database\Mapper\MailingList as MailingListMapper; +use Database\Mapper\MailingListMember as MailingListMemberMapper; use Database\Mapper\Member as MemberMapper; use Database\Mapper\MemberUpdate as MemberUpdateMapper; use Database\Mapper\ProspectiveMember as ProspectiveMemberMapper; @@ -32,12 +33,14 @@ use Database\Model\AuditNote as AuditNoteModel; use Database\Model\AuditRenewal as AuditRenewalModel; use Database\Model\MailingList as MailingListModel; +use Database\Model\MailingListMember as MailingListMemberModel; use Database\Model\Member as MemberModel; use Database\Model\MemberUpdate as MemberUpdateModel; use Database\Model\PaymentLink; use Database\Model\ProspectiveMember as ProspectiveMemberModel; use Database\Model\RenewalLink as RenewalLinkModel; use Database\Service\MailingList as MailingListService; +use Database\Service\Mailman as MailmanService; use DateTime; use InvalidArgumentException; use Laminas\Mail\Header\MessageId; @@ -52,6 +55,8 @@ use RuntimeException; use User\Service\UserService; +use function array_diff; +use function array_intersect; use function array_merge; use function bin2hex; use function count; @@ -76,6 +81,7 @@ public function __construct( private readonly MemberRenewalForm $memberRenewalForm, private readonly MemberTypeForm $memberTypeForm, private readonly MailingListMapper $mailingListMapper, + private readonly MailingListMemberMapper $mailingListMemberMapper, private readonly ActionLinkMapper $actionLinkMapper, private readonly AuditMapper $auditMapper, private readonly MemberMapper $memberMapper, @@ -84,6 +90,7 @@ public function __construct( private readonly CheckerService $checkerService, private readonly FileStorageService $fileStorageService, private readonly MailingListService $mailingListService, + private readonly MailmanService $mailmanService, private readonly RenewalService $renewalService, private readonly UserService $userService, private readonly PhpRenderer $viewRenderer, @@ -136,13 +143,13 @@ public function subscribe(array $data): ?ProspectiveMemberModel continue; } - $prospectiveMember->addList($list); + $prospectiveMember->addList($list->getName()); } // subscribe to default mailing lists not on the form $mailingMapper = $this->mailingListMapper; foreach ($mailingMapper->findDefault() as $list) { - $prospectiveMember->addList($list); + $prospectiveMember->addList($list->getName()); } $this->getProspectiveMemberMapper()->persist($prospectiveMember); @@ -302,7 +309,7 @@ public function finalizeSubscription( foreach ($form->getLists() as $list) { $result = '0'; foreach ($prospectiveMember->getLists() as $l) { - if ($list->getName() !== $l->getName()) { + if ($list->getName() !== $l) { continue; } @@ -382,13 +389,24 @@ public function finalizeSubscription( continue; } - $member->addList($list); + // Ignore Mailman sync lock here as we _always_ need to persist this information. Will be cascade persisted + // through `$member`. + $mailingListMember = new MailingListMemberModel(); + $mailingListMember->setMailingList($list); + $mailingListMember->setMember($member); + // Force cascade by adding to member. + $member->addList($mailingListMember); } // subscribe to default mailing lists not on the form - $mailingMapper = $this->mailingListMapper; - foreach ($mailingMapper->findDefault() as $list) { - $member->addList($list); + foreach ($this->mailingListMapper->findDefault() as $list) { + // Ignore Mailman sync lock here as we _always_ need to persist this information. Will be cascade persisted + // through `$member`. + $mailingListMember = new MailingListMemberModel(); + $mailingListMember->setMailingList($list); + $mailingListMember->setMember($member); + // Force cascade by adding to member. + $member->addList($mailingListMember); } // If this was requested, update the data with the TU/e data @@ -673,7 +691,7 @@ public function clear(MemberModel $member): void $member->setSupremum('optout'); $member->setHidden(true); $member->setDeleted(true); - $member->clearLists(); + $this->unsubscribeLists($member); $this->getMemberMapper()->persist($member); } @@ -902,38 +920,76 @@ public function subscribeLists( MemberModel $member, array $data, ): ?MemberModel { + // Check if we are performing a sync or not. + if ($this->mailmanService->isSyncLocked()) { + return null; + } + $formData = $this->getListForm($member); $form = $formData['form']; - $lists = $formData['lists']; $form->setData($data); + // Validate form. if (!$form->isValid()) { return null; } $data = $form->getData(); - $member->clearLists(); - foreach ($lists as $list) { - $name = 'list-' . $list->getName(); + /** @var string[] $selectedLists */ + $selectedLists = $data['lists']; + $currentLists = $member->getMailingListMemberships()->map( + static function (MailingListMemberModel $subscription) { + return $subscription->getMailingList()->getName(); + }, + )->toArray(); - if ( - !isset($data[$name]) - || !$data[$name] - ) { + // Determine which mailing lists the member should be (un)subscribed from/to. + $intersection = array_intersect($selectedLists, $currentLists); + $toRemove = array_diff($currentLists, $selectedLists); + $toAdd = array_diff($selectedLists, $intersection); + + // Unsubscribe member for some mailing lists. + foreach ($toRemove as $list) { + $list = $this->mailingListMapper->find($list); + + if (null === $list) { continue; } - $member->addList($list); + $membership = $this->mailingListMemberMapper->findByListAndMember($list, $member); + $membership->setToBeDeleted(true); } - // simply persist through member + foreach ($toAdd as $list) { + $list = $this->mailingListMapper->find($list); + + if (null === $list) { + continue; + } + + $mailingListMember = new MailingListMemberModel(); + $mailingListMember->setMailingList($list); + $mailingListMember->setMember($member); + // Force cascade by adding to member. + $member->addList($mailingListMember); + } + + // Simply cascade persist through member. $this->getMemberMapper()->persist($member); return $member; } + public function unsubscribeLists(MemberModel $member): void + { + foreach ($member->getMailingListMemberships() as $mailingListMembership) { + $mailingListMembership->setToBeDeleted(true); + $this->mailingListMemberMapper->persist($mailingListMembership); + } + } + /** * Add audit note to a member. */ diff --git a/module/Database/view/database/email/member-registration.phtml b/module/Database/view/database/email/member-registration.phtml index 8a55b9cd1..80bb96834 100644 --- a/module/Database/view/database/email/member-registration.phtml +++ b/module/Database/view/database/email/member-registration.phtml @@ -268,11 +268,11 @@ Registration Confirmation GEWIS - getLists()->count() > 0): ?> + getLists()) > 0): ?>

Mailing Lists

    getLists() as $list): ?> -
  • escapeHtml($list->getName()) ?>
  • +
  • escapeHtml($list) ?>
diff --git a/module/Database/view/database/member/mailman.phtml b/module/Database/view/database/member/mailman.phtml new file mode 100644 index 000000000..b6435cbfa --- /dev/null +++ b/module/Database/view/database/member/mailman.phtml @@ -0,0 +1,15 @@ + +
+
+

translate('Sync in progress') ?>

+

translate('A Mailman sync is currently in progress. It is now not possible to safely update the mailing list administration, please try again in a few minutes.') ?>

+
+
diff --git a/module/Database/view/database/member/show.phtml b/module/Database/view/database/member/show.phtml index b4574402f..46ac797d8 100644 --- a/module/Database/view/database/member/show.phtml +++ b/module/Database/view/database/member/show.phtml @@ -205,7 +205,7 @@ use Laminas\View\Renderer\PhpRenderer;

translate('Mailing List Subscriptions') ?>

    -getLists() as $list): ?> +getMailingListMemberships() as $list): ?>
  • getName() ?>
diff --git a/module/Database/view/database/prospective-member/show.phtml b/module/Database/view/database/prospective-member/show.phtml index 1725a2c2b..c1c8297dc 100644 --- a/module/Database/view/database/prospective-member/show.phtml +++ b/module/Database/view/database/prospective-member/show.phtml @@ -136,14 +136,14 @@ use Laminas\View\Renderer\PhpRenderer;

translate('Mailing List Subscriptions') ?>

- getLists()->count()): ?> + getLists())): ?>

translate('No mailing list subscriptions.') ?>

    getLists() as $list): ?> -
  • getName() ?>
  • +
diff --git a/module/Database/view/database/settings/list.phtml b/module/Database/view/database/settings/add-list.phtml similarity index 65% rename from module/Database/view/database/settings/list.phtml rename to module/Database/view/database/settings/add-list.phtml index 358e4152a..9952c096e 100644 --- a/module/Database/view/database/settings/list.phtml +++ b/module/Database/view/database/settings/add-list.phtml @@ -3,52 +3,34 @@ declare(strict_types=1); use Application\View\HelperTrait; +use Database\Form\MailingList as MailingListForm; +use Database\Model\MailingList as MailingListModel; use Laminas\View\Renderer\PhpRenderer; -/** @var PhpRenderer|HelperTrait $this */ +/** + * @var PhpRenderer|HelperTrait $this + * @var string $action + * @var MailingListForm $form + * @var MailingListModel $list + */ + +if ('add' === $action) { + $title = $this->translate('Add New Mailing List'); + $url = $this->url('settings/lists/add'); +} else { + $title = $this->translate('Edit Mailing List'); + $url = $this->url('settings/lists/edit', ['name' => $list->getName()]); +} ?>
-

translate('Current Mailing Lists') ?>

- - - - - - - - - - - - - - - - - - - - - - - -
translate('Name') ?>translate('Dutch Description') ?>translate('English Description') ?>translate('On Form') ?>translate('Auto-subscription') ?>translate('Delete') ?>
escapeHtml($list->getName()) ?>escapeHtml($list->getNlDescription()) ?>escapeHtml($list->getEnDescription()) ?> - - translate('Delete')?> - -
-
-
-
-
-

translate('Add New Mailing List') ?>

+

+ +

prepare(); - $form->setAttribute('action', $this->url('settings/default', ['action' => 'list'])); + $form->setAttribute('action', $url ); $form->setAttribute('method', 'post'); $form->setAttribute('role', 'form'); @@ -133,11 +115,23 @@ use Laminas\View\Renderer\PhpRenderer;
+
+ get('mailmanId'); + $element->setAttribute('placeholder', $element->getLabel()); + ?> +
+ + formSelect($element) ?> + formElementErrors($element) ?> +
+
+
get('submit'); - $submit->setLabel($submit->getValue()); + $submit->setLabel($title); $submit->setAttribute('class', 'btn btn-primary'); ?> formButton($submit) ?> diff --git a/module/Database/view/database/settings/delete-list.phtml b/module/Database/view/database/settings/delete-list.phtml index 410336ab5..b17e32fc4 100644 --- a/module/Database/view/database/settings/delete-list.phtml +++ b/module/Database/view/database/settings/delete-list.phtml @@ -15,7 +15,7 @@ if (!isset($form)): ?> $form->setAttribute( 'action', - $this->url('settings/list-delete', [ + $this->url('settings/lists/delete', [ 'name' => $name, ]), ); diff --git a/module/Database/view/database/settings/index.phtml b/module/Database/view/database/settings/index.phtml index decb56ed8..45b3541cc 100644 --- a/module/Database/view/database/settings/index.phtml +++ b/module/Database/view/database/settings/index.phtml @@ -21,7 +21,7 @@ use Laminas\View\Renderer\PhpRenderer;
  • - + translate('Mailing Lists') ?>
  • diff --git a/module/Database/view/database/settings/lists.phtml b/module/Database/view/database/settings/lists.phtml new file mode 100644 index 000000000..77856e943 --- /dev/null +++ b/module/Database/view/database/settings/lists.phtml @@ -0,0 +1,70 @@ + +
    + +
    + + + + + + + + + + + + + + + + + + + + + + + +
    translate('Name (Mailman)') ?>translate('Dutch Description') ?>translate('English Description') ?>translate('On Form') ?>translate('Auto-subscription') ?>translate('Delete') ?>
    escapeHtml($list->getName()) ?> (escapeHtml($list->getMailmanId())?>)escapeHtml($list->getNlDescription()) ?>escapeHtml($list->getEnDescription()) ?> + + translate('Edit')?> + + + translate('Delete')?> + +
    +
    +
    diff --git a/module/Report/src/Listener/DatabaseUpdateListener.php b/module/Report/src/Listener/DatabaseUpdateListener.php index 53df42c05..056209e41 100644 --- a/module/Report/src/Listener/DatabaseUpdateListener.php +++ b/module/Report/src/Listener/DatabaseUpdateListener.php @@ -7,6 +7,7 @@ use Database\Model\Address as DatabaseAddressModel; use Database\Model\Decision as DatabaseDecisionModel; use Database\Model\MailingList as DatabaseMailingListModel; +use Database\Model\MailingListMember as DatabaseMailingListMemberModel; use Database\Model\Meeting as DatabaseMeetingModel; use Database\Model\Member as DatabaseMemberModel; use Database\Model\SubDecision as DatabaseSubDecisionModel; @@ -96,6 +97,10 @@ public function postUpdate(LifecycleEventArgs $eventArgs): void $this->miscService->generateList($entity); break; + case $entity instanceof DatabaseMailingListMemberModel: + $this->miscService->generateListMembership($entity); + break; + default: return; } diff --git a/module/Report/src/Model/MailingList.php b/module/Report/src/Model/MailingList.php index c7c777dbd..5ba60f8fe 100644 --- a/module/Report/src/Model/MailingList.php +++ b/module/Report/src/Model/MailingList.php @@ -9,7 +9,7 @@ use Doctrine\ORM\Mapping\Column; use Doctrine\ORM\Mapping\Entity; use Doctrine\ORM\Mapping\Id; -use Doctrine\ORM\Mapping\ManyToMany; +use Doctrine\ORM\Mapping\OneToMany; /** * Mailing List model. @@ -36,34 +36,20 @@ class MailingList #[Column(type: 'text')] protected string $en_description; - /** - * If the mailing list should be on the form. - */ - #[Column(type: 'boolean')] - protected bool $onForm; - - /** - * If members should be subscribed by default. - * - * (when it is on the form, that means that the checkbox is checked by default) - */ - #[Column(type: 'boolean')] - protected bool $defaultSub; - /** * Mailing list members. * - * @var Collection + * @var Collection */ - #[ManyToMany( - targetEntity: Member::class, - mappedBy: 'lists', + #[OneToMany( + targetEntity: MailingListMember::class, + mappedBy: 'mailingList', )] - protected Collection $members; + protected Collection $mailingListMemberships; public function __construct() { - $this->members = new ArrayCollection(); + $this->mailingListMemberships = new ArrayCollection(); } /** @@ -114,77 +100,13 @@ public function setNlDescription(string $description): void $this->nl_description = $description; } - /** - * Get the description. - */ - public function getDescription(): string - { - return $this->getNlDescription(); - } - - /** - * Set the description. - */ - public function setDescription(string $description): void - { - $this->setNlDescription($description); - } - - /** - * Get if it should be on the form. - */ - public function getOnForm(): bool - { - return $this->onForm; - } - - /** - * Set if it should be on the form. - */ - public function setOnForm(bool $onForm): void - { - $this->onForm = $onForm; - } - - /** - * Get if it is a default list. - */ - public function getDefaultSub(): bool - { - return $this->defaultSub; - } - - /** - * Set if it is a default list. - */ - public function setDefaultSub(bool $default): void - { - $this->defaultSub = $default; - } - /** * Get subscribed members. * - * @return Collection - */ - public function getMembers(): Collection - { - return $this->members; - } - - /** - * Add a member. - */ - public function addMember(Member $member): void - { - $this->members[] = $member; - } - - /** - * Remove a member. + * @return Collection */ - public function removeMember(Member $member): void + public function getMailingListMemberships(): Collection { - $this->members->removeElement($member); + return $this->mailingListMemberships; } } diff --git a/module/Report/src/Model/MailingListMember.php b/module/Report/src/Model/MailingListMember.php new file mode 100644 index 000000000..d6ba623cb --- /dev/null +++ b/module/Report/src/Model/MailingListMember.php @@ -0,0 +1,187 @@ +mailingList; + } + + /** + * Set the mailing list. + */ + public function setMailingList(MailingList $mailingList): void + { + $this->mailingList = $mailingList; + } + + /** + * Get the member. + */ + public function getMember(): Member + { + return $this->member; + } + + /** + * Set the member. + */ + public function setMember(Member $member): void + { + $this->member = $member; + } + + /** + * Get the Mailman `member_id` for this subscription. + */ + public function getMembershipId(): ?string + { + return $this->membershipId; + } + + /** + * Set the Mailman `member_id` for this subscription. + */ + public function setMembershipId(string $membershipId): void + { + $this->membershipId = $membershipId; + } + + /** + * Get when the last sync happened. + */ + public function getLastSyncOn(): ?DateTime + { + return $this->lastSyncOn; + } + + /** + * Set when the last sync happened. + */ + public function setLastSyncOn(DateTime $lastSyncOn): void + { + $this->lastSyncOn = $lastSyncOn; + } + + /** + * Get whether the last sync was successful. + */ + public function isLastSyncSuccess(): bool + { + return $this->lastSyncSuccess; + } + + /** + * Set whether the last sync was successful. + */ + public function setLastSyncSuccess(bool $lastSyncSuccess): void + { + $this->lastSyncSuccess = $lastSyncSuccess; + } + + /** + * Get whether the entry must still be removed from Mailman. + */ + public function isToBeDeleted(): bool + { + return $this->toBeDeleted; + } + + /** + * Set whether the entry must still be removed from Mailman. + */ + public function setToBeDeleted(bool $toBeDeleted): void + { + $this->toBeDeleted = $toBeDeleted; + } +} diff --git a/module/Report/src/Model/Member.php b/module/Report/src/Model/Member.php index ac2cf60cf..eda38c5ca 100644 --- a/module/Report/src/Model/Member.php +++ b/module/Report/src/Model/Member.php @@ -12,10 +12,7 @@ use Doctrine\ORM\Mapping\Column; use Doctrine\ORM\Mapping\Entity; use Doctrine\ORM\Mapping\Id; -use Doctrine\ORM\Mapping\InverseJoinColumn; use Doctrine\ORM\Mapping\JoinColumn; -use Doctrine\ORM\Mapping\JoinTable; -use Doctrine\ORM\Mapping\ManyToMany; use Doctrine\ORM\Mapping\OneToMany; use Report\Model\SubDecision\Installation; @@ -187,22 +184,14 @@ enumType: MembershipTypes::class, /** * Memberships of mailing lists. * - * @var Collection + * @var Collection */ - #[ManyToMany( - targetEntity: MailingList::class, - inversedBy: 'members', - )] - #[JoinTable(name: 'members_mailinglists')] - #[JoinColumn( - name: 'lidnr', - referencedColumnName: 'lidnr', - )] - #[InverseJoinColumn( - name: 'name', - referencedColumnName: 'name', + #[OneToMany( + targetEntity: MailingListMember::class, + mappedBy: 'member', + cascade: ['persist'], )] - protected Collection $lists; + protected Collection $mailingListMemberships; /** * Organ memberships. @@ -263,7 +252,7 @@ public function __construct() $this->organInstallations = new ArrayCollection(); $this->boardInstallations = new ArrayCollection(); $this->keyGrantings = new ArrayCollection(); - $this->lists = new ArrayCollection(); + $this->mailingListMemberships = new ArrayCollection(); } /** @@ -800,28 +789,30 @@ static function ($c, $kg) { /** * Get mailing list subscriptions. * - * @return Collection + * @return Collection */ - public function getLists(): Collection + public function getMailingListMemberships(): Collection { - return $this->lists; + return $this->mailingListMemberships; } /** * Add a mailing list subscription. - * - * Note that this is the owning side. */ - public function addList(MailingList $list): void + public function addList(MailingListMember $list): void { - $list->addMember($this); - $this->lists[] = $list; + if ($this->mailingListMemberships->contains($list)) { + return; + } + + $list->setMember($this); + $this->mailingListMemberships->add($list); } /** * Add multiple mailing lists. * - * @param MailingList[] $lists + * @param MailingListMember[] $lists */ public function addLists(array $lists): void { @@ -829,23 +820,4 @@ public function addLists(array $lists): void $this->addList($list); } } - - /** - * Remove a mailing list subscription. - * - * Note that this is the owning side. - */ - public function removeList(MailingList $list): void - { - $list->removeMember($this); - $this->lists->removeElement($list); - } - - /** - * Clear the lists. - */ - public function clearLists(): void - { - $this->lists = new ArrayCollection(); - } } diff --git a/module/Report/src/Service/Factory/MiscFactory.php b/module/Report/src/Service/Factory/MiscFactory.php index 02e80f304..d2bc26954 100644 --- a/module/Report/src/Service/Factory/MiscFactory.php +++ b/module/Report/src/Service/Factory/MiscFactory.php @@ -5,6 +5,7 @@ namespace Report\Service\Factory; use Database\Mapper\MailingList as MailingListMapper; +use Database\Mapper\MailingListMember as MailingListMemberMapper; use Doctrine\ORM\EntityManager; use Laminas\ServiceManager\Factory\FactoryInterface; use Psr\Container\ContainerInterface; @@ -22,11 +23,14 @@ public function __invoke( ): MiscService { /** @var MailingListMapper $mailingListMapper */ $mailingListMapper = $container->get(MailingListMapper::class); + /** @var MailingListMemberMapper $mailingListMemberMapper */ + $mailingListMemberMapper = $container->get(MailingListMemberMapper::class); /** @var EntityManager $emReport */ $emReport = $container->get('doctrine.entitymanager.orm_report'); return new MiscService( $mailingListMapper, + $mailingListMemberMapper, $emReport, ); } diff --git a/module/Report/src/Service/Member.php b/module/Report/src/Service/Member.php index 2a156cac2..69f01b983 100644 --- a/module/Report/src/Service/Member.php +++ b/module/Report/src/Service/Member.php @@ -100,11 +100,11 @@ public function generateLists( $reportListRepo = $this->emReport->getRepository(ReportMailingListModel::class); $reportLists = array_map(static function ($list) { - return $list->getName(); - }, $reportMember->getLists()->toArray()); + return $list->getMailingList()->getName(); + }, $reportMember->getMailingListMemberships()->toArray()); $lists = array_map(static function ($list) { - return $list->getName(); - }, $member->getLists()->toArray()); + return $list->getMailingList()->getName(); + }, $member->getMailingListMemberships()->toArray()); foreach (array_diff($lists, $reportLists) as $list) { $reportList = $reportListRepo->find($list); @@ -113,7 +113,8 @@ public function generateLists( throw new LogicException('mailing list missing from reportdb'); } - $reportMember->addList($reportList); + // TODO: Add list to report member + // $reportMember->addList($reportList); $this->emReport->persist($reportList); } @@ -124,7 +125,7 @@ public function generateLists( throw new LogicException('mailing list missing from reportdb'); } - $reportMember->removeList($reportList); + // TODO: Remove list $this->emReport->persist($reportList); } } diff --git a/module/Report/src/Service/Misc.php b/module/Report/src/Service/Misc.php index f74ac2cfe..8b3744b52 100644 --- a/module/Report/src/Service/Misc.php +++ b/module/Report/src/Service/Misc.php @@ -5,14 +5,20 @@ namespace Report\Service; use Database\Mapper\MailingList as MailingListMapper; +use Database\Mapper\MailingListMember as MailingListMemberMapper; use Database\Model\MailingList as DatabaseMailingListModel; +use Database\Model\MailingListMember as DatabaseMailingListMemberModel; use Doctrine\ORM\EntityManager; +use LogicException; use Report\Model\MailingList as ReportMailingListModel; +use Report\Model\MailingListMember as ReportMailingListMemberModel; +use Report\Model\Member as ReportMemberModel; class Misc { public function __construct( private readonly MailingListMapper $mailingListMapper, + private readonly MailingListMemberMapper $mailingListMemberMapper, private readonly EntityManager $emReport, ) { } @@ -26,12 +32,17 @@ public function generate(): void $this->generateList($list); } + foreach ($this->mailingListMemberMapper->findAll() as $listMember) { + $this->generateListMembership($listMember); + } + $this->emReport->flush(); } public function generateList(DatabaseMailingListModel $list): void { $repo = $this->emReport->getRepository(ReportMailingListModel::class); + /** @var ReportMailingListModel|null $reportList */ $reportList = $repo->find($list->getName()); if (null === $reportList) { @@ -41,9 +52,44 @@ public function generateList(DatabaseMailingListModel $list): void $reportList->setEnDescription($list->getEnDescription()); $reportList->setNlDescription($list->getNlDescription()); - $reportList->setOnForm($list->getOnForm()); - $reportList->setDefaultSub($list->getDefaultSub()); $this->emReport->persist($reportList); } + + public function generateListMembership(DatabaseMailingListMemberModel $mailingListMember): void + { + $repo = $this->emReport->getRepository(ReportMailingListMemberModel::class); + /** @var ReportMailingListMemberModel|null $reportListMembership */ + $reportListMembership = $repo->find([ + 'mailingList' => $mailingListMember->getMailingList()->getName(), + 'member' => $mailingListMember->getMember()->getLidnr(), + ]); + + if (null === $reportListMembership) { + $reportList = $this->emReport->getRepository(ReportMailingListModel::class) + ->find($mailingListMember->getMailingList()->getName()); + + if (null === $reportList) { + throw new LogicException('List membership without list'); + } + + $reportMember = $this->emReport->getRepository(ReportMemberModel::class) + ->find($mailingListMember->getMember()->getLidnr()); + + if (null === $reportMember) { + throw new LogicException('List membership without member'); + } + + $reportListMembership = new ReportMailingListMemberModel(); + $reportListMembership->setMailingList($reportList); + $reportListMembership->setMember($reportMember); + } + + $reportListMembership->setLastSyncOn($mailingListMember->getLastSyncOn()); + $reportListMembership->setLastSyncSuccess($mailingListMember->isLastSyncSuccess()); + $reportListMembership->setToBeDeleted($mailingListMember->isToBeDeleted()); + $reportListMembership->setMembershipId($mailingListMember->getMembershipId()); + + $this->emReport->persist($reportListMembership); + } } diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 9d1dbe4c8..95fa489ef 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -64,6 +64,7 @@ module/Database/src/Mapper/Factory/AuditFactory.php module/Database/src/Mapper/Factory/CheckoutSessionFactory.php module/Database/src/Mapper/Factory/MailingListFactory.php + module/Database/src/Mapper/Factory/MailingListMemberFactory.php module/Database/src/Mapper/Factory/MeetingFactory.php module/Database/src/Mapper/Factory/MemberFactory.php module/Database/src/Mapper/Factory/MemberUpdateFactory.php @@ -73,6 +74,7 @@ module/Database/src/Service/Factory/ApiFactory.php module/Database/src/Service/Factory/FrontPageFactory.php module/Database/src/Service/Factory/MailingListFactory.php + module/Database/src/Service/Factory/MailmanFactory.php module/Database/src/Service/Factory/MeetingFactory.php module/Database/src/Service/Factory/MemberFactory.php module/Database/src/Service/Factory/StripeFactory.php From 4f48c8d17ff8d91ac4df930ffe28042df44995ff Mon Sep 17 00:00:00 2001 From: Rink Date: Sat, 21 Jun 2025 13:54:13 +0000 Subject: [PATCH 03/11] [WIP] Upgrades to mailman integration after rebase --- .devcontainer/devcontainer.json | 5 ++ docker-compose.override.yml | 4 +- .../migrations/Version20250621133119.php | 62 +++++++++++++++++++ .../Database/src/Model/ProspectiveMember.php | 5 +- .../migrations/Version20250621133119.php | 49 +++++++++++++++ 5 files changed, 122 insertions(+), 3 deletions(-) create mode 100644 module/Database/migrations/Version20250621133119.php create mode 100644 module/Report/migrations/Version20250621133119.php diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index f989fc8f7..059ab4a87 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -79,6 +79,11 @@ "onAutoForward": "openBrowserOnce", "protocol": "http" }, + "8021": { + "label": "Mailman web interface", + "onAutoForward": "silent", + "protocol": "http" + }, "8025": { "label": "mailhog", "onAutoForward": "silent", diff --git a/docker-compose.override.yml b/docker-compose.override.yml index a087da448..f0397b2bc 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -28,7 +28,7 @@ services: ports: - "8025:8025" mailman-core: - image: maxking/mailman-core:0.4 + image: maxking/mailman-core:0.5 container_name: mailman-core hostname: mailmanc volumes: @@ -45,7 +45,7 @@ services: networks: - gewisdb_network mailman-web: - image: maxking/mailman-web:0.4 + image: maxking/mailman-web:0.5 container_name: mailman-web hostname: mailmanw depends_on: diff --git a/module/Database/migrations/Version20250621133119.php b/module/Database/migrations/Version20250621133119.php new file mode 100644 index 000000000..c3b1d3836 --- /dev/null +++ b/module/Database/migrations/Version20250621133119.php @@ -0,0 +1,62 @@ +addSql('CREATE TABLE MailingListMember (member INT NOT NULL, membershipId VARCHAR(255) DEFAULT NULL, lastSyncOn TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, lastSyncSuccess BOOLEAN NOT NULL, toBeDeleted BOOLEAN NOT NULL, mailingList VARCHAR(255) NOT NULL, PRIMARY KEY(mailingList, member))'); + $this->addSql('CREATE INDEX IDX_3A8467A97B1AC3ED ON MailingListMember (mailingList)'); + $this->addSql('CREATE INDEX IDX_3A8467A970E4FA78 ON MailingListMember (member)'); + $this->addSql('CREATE UNIQUE INDEX mailinglistmember_unique_idx ON MailingListMember (mailingList, member)'); + $this->addSql('ALTER TABLE MailingListMember ADD CONSTRAINT FK_3A8467A97B1AC3ED FOREIGN KEY (mailingList) REFERENCES MailingList (name) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE MailingListMember ADD CONSTRAINT FK_3A8467A970E4FA78 FOREIGN KEY (member) REFERENCES Member (lidnr) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE prospective_members_mailinglists DROP CONSTRAINT fk_c86f04985e237e06'); + $this->addSql('ALTER TABLE prospective_members_mailinglists DROP CONSTRAINT fk_c86f0498d665e01d'); + $this->addSql('ALTER TABLE members_mailinglists DROP CONSTRAINT fk_5ad357d95e237e06'); + $this->addSql('ALTER TABLE members_mailinglists DROP CONSTRAINT fk_5ad357d9d665e01d'); + $this->addSql('DROP TABLE prospective_members_mailinglists'); + $this->addSql('DROP TABLE members_mailinglists'); + $this->addSql('ALTER TABLE configitem ADD valueBool BOOLEAN DEFAULT NULL'); + $this->addSql('ALTER TABLE mailinglist ADD mailmanId VARCHAR(255) NOT NULL'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_FD864C3AFD6980D2 ON mailinglist (mailmanId)'); + $this->addSql('ALTER TABLE prospectivemember ADD lists TEXT'); + $this->addSql('COMMENT ON COLUMN prospectivemember.lists IS \'(DC2Type:simple_array)\''); + } + + public function down(Schema $schema): void + { + $this->addSql('CREATE TABLE prospective_members_mailinglists (lidnr INT NOT NULL, name VARCHAR(255) NOT NULL, PRIMARY KEY(lidnr, name))'); + $this->addSql('CREATE INDEX idx_c86f04985e237e06 ON prospective_members_mailinglists (name)'); + $this->addSql('CREATE INDEX idx_c86f0498d665e01d ON prospective_members_mailinglists (lidnr)'); + $this->addSql('CREATE TABLE members_mailinglists (lidnr INT NOT NULL, name VARCHAR(255) NOT NULL, PRIMARY KEY(lidnr, name))'); + $this->addSql('CREATE INDEX idx_5ad357d95e237e06 ON members_mailinglists (name)'); + $this->addSql('CREATE INDEX idx_5ad357d9d665e01d ON members_mailinglists (lidnr)'); + $this->addSql('ALTER TABLE prospective_members_mailinglists ADD CONSTRAINT fk_c86f04985e237e06 FOREIGN KEY (name) REFERENCES mailinglist (name) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE prospective_members_mailinglists ADD CONSTRAINT fk_c86f0498d665e01d FOREIGN KEY (lidnr) REFERENCES prospectivemember (lidnr) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE members_mailinglists ADD CONSTRAINT fk_5ad357d95e237e06 FOREIGN KEY (name) REFERENCES mailinglist (name) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE members_mailinglists ADD CONSTRAINT fk_5ad357d9d665e01d FOREIGN KEY (lidnr) REFERENCES member (lidnr) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE MailingListMember DROP CONSTRAINT FK_3A8467A97B1AC3ED'); + $this->addSql('ALTER TABLE MailingListMember DROP CONSTRAINT FK_3A8467A970E4FA78'); + $this->addSql('DROP TABLE MailingListMember'); + $this->addSql('ALTER TABLE ConfigItem DROP valueBool'); + $this->addSql('DROP INDEX UNIQ_FD864C3AFD6980D2'); + $this->addSql('ALTER TABLE MailingList DROP mailmanId'); + $this->addSql('ALTER TABLE ProspectiveMember DROP lists'); + } +} diff --git a/module/Database/src/Model/ProspectiveMember.php b/module/Database/src/Model/ProspectiveMember.php index f6d5a7f12..69b4a2c8c 100644 --- a/module/Database/src/Model/ProspectiveMember.php +++ b/module/Database/src/Model/ProspectiveMember.php @@ -144,7 +144,10 @@ enumType: PostalRegions::class, * * @var string[] $lists */ - #[Column(type: 'simple_array')] + #[Column( + type: 'simple_array', + nullable: true, + )] protected array $lists = []; /** diff --git a/module/Report/migrations/Version20250621133119.php b/module/Report/migrations/Version20250621133119.php new file mode 100644 index 000000000..b985eefc4 --- /dev/null +++ b/module/Report/migrations/Version20250621133119.php @@ -0,0 +1,49 @@ +addSql('CREATE TABLE MailingListMember (member INT NOT NULL, membershipId VARCHAR(255) DEFAULT NULL, lastSyncOn TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, lastSyncSuccess BOOLEAN NOT NULL, toBeDeleted BOOLEAN NOT NULL, mailingList VARCHAR(255) NOT NULL, PRIMARY KEY(mailingList, member))'); + $this->addSql('CREATE INDEX IDX_3A8467A97B1AC3ED ON MailingListMember (mailingList)'); + $this->addSql('CREATE INDEX IDX_3A8467A970E4FA78 ON MailingListMember (member)'); + $this->addSql('CREATE UNIQUE INDEX mailinglistmember_unique_idx ON MailingListMember (mailingList, member)'); + $this->addSql('ALTER TABLE MailingListMember ADD CONSTRAINT FK_3A8467A97B1AC3ED FOREIGN KEY (mailingList) REFERENCES MailingList (name) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE MailingListMember ADD CONSTRAINT FK_3A8467A970E4FA78 FOREIGN KEY (member) REFERENCES Member (lidnr) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE members_mailinglists DROP CONSTRAINT fk_5ad357d95e237e06'); + $this->addSql('ALTER TABLE members_mailinglists DROP CONSTRAINT fk_5ad357d9d665e01d'); + $this->addSql('DROP TABLE members_mailinglists'); + $this->addSql('ALTER TABLE mailinglist DROP onform'); + $this->addSql('ALTER TABLE mailinglist DROP defaultsub'); + } + + public function down(Schema $schema): void + { + $this->addSql('CREATE TABLE members_mailinglists (lidnr INT NOT NULL, name VARCHAR(255) NOT NULL, PRIMARY KEY(lidnr, name))'); + $this->addSql('CREATE INDEX idx_5ad357d95e237e06 ON members_mailinglists (name)'); + $this->addSql('CREATE INDEX idx_5ad357d9d665e01d ON members_mailinglists (lidnr)'); + $this->addSql('ALTER TABLE members_mailinglists ADD CONSTRAINT fk_5ad357d95e237e06 FOREIGN KEY (name) REFERENCES mailinglist (name) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE members_mailinglists ADD CONSTRAINT fk_5ad357d9d665e01d FOREIGN KEY (lidnr) REFERENCES member (lidnr) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE MailingListMember DROP CONSTRAINT FK_3A8467A97B1AC3ED'); + $this->addSql('ALTER TABLE MailingListMember DROP CONSTRAINT FK_3A8467A970E4FA78'); + $this->addSql('DROP TABLE MailingListMember'); + $this->addSql('ALTER TABLE MailingList ADD onform BOOLEAN NOT NULL'); + $this->addSql('ALTER TABLE MailingList ADD defaultsub BOOLEAN NOT NULL'); + } +} From 2a654787a405d443edb8bbe7fab23fd494950d7a Mon Sep 17 00:00:00 2001 From: Rink Date: Sun, 22 Jun 2025 14:04:30 +0000 Subject: [PATCH 04/11] feat(mailman): Also seed mailman install --- .devcontainer/devcontainer.json | 1 + .env.dist | 3 ++- .gitignore | 2 +- Makefile | 9 ++++++++- docker-compose.override.yml | 12 ++++++++++++ docker-compose.yml | 2 ++ docker/mailman/settings_local.py | 15 +++++++++++++++ 7 files changed, 41 insertions(+), 3 deletions(-) create mode 100644 docker/mailman/settings_local.py diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 059ab4a87..1f21e30d4 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -66,6 +66,7 @@ "forwardPorts": [ 80, 8080, + 8021, 8025 ], "portsAttributes": { diff --git a/.env.dist b/.env.dist index 7aa804d71..5a783df0e 100644 --- a/.env.dist +++ b/.env.dist @@ -50,7 +50,8 @@ SMTP_SERVER=mail.gewis.nl SMTP_PORT=587 SMTP_USERNAME=service-web@gewis.nl SMTP_PASSWORD=gewis -SERVER_HOSTNAME=gewis.nl +SERVER_HOSTNAME=database.gewis.nl + MAIL_FROM_ADDRESS=example@example.com MAIL_FROM_NAME='Study Association GEWIS' diff --git a/.gitignore b/.gitignore index 674951d94..7ebcb05b7 100644 --- a/.gitignore +++ b/.gitignore @@ -39,4 +39,4 @@ phpcs.xml *.mo # Local mailman data for development -mailman/ +/mailman/ diff --git a/Makefile b/Makefile index 9936b6ef1..eadfcfc6a 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help runprod rundev runtest runcoverage update updatecomposer getvendordir phpstan phpcs phpcbf phpcsfix phpcsfixtypes replenish compilelang build buildprod builddev update +.PHONY: help runprod rundev runtest runcoverage update updatecomposer getvendordir phpstan phpcs phpcbf phpcsfix phpcsfixtypes replenish compilelang build buildprod builddev update preparemailman help: @echo "Makefile commands:" @@ -35,6 +35,7 @@ runprodtest: buildprod rundev: builddev @docker compose up -d --remove-orphans @make replenish + @make preparemailman @docker compose exec web rm -rf data/cache/module-config-cache.application.config.cache.php migrate: replenish @@ -64,6 +65,8 @@ migration-down: replenish migration-list seed: replenish @docker compose exec -T web ./web application:fixtures:load @docker compose exec web ./web report:generate:full + @docker compose exec mailman-web bash -c '(python3 ./manage.py createsuperuser --no-input 2>/dev/null); pkill -HUP uwsgi' + @docker compose exec -u mailman mailman-core bash -c '(mailman create news@$$MAILMAN_DOMAIN; mailman create other@$$MAILMAN_DOMAIN; true) 2>/dev/null' exec: docker compose exec -it web $(cmd) @@ -226,3 +229,7 @@ buildnginx: buildpgadmin: @docker compose build pgadmin + +preparemailman: + @docker compose cp ./docker/mailman/settings_local.py mailman-web:/opt/mailman-web/settings_local.py + @docker compose exec mailman-web bash -c "pkill -HUP uwsgi" \ No newline at end of file diff --git a/docker-compose.override.yml b/docker-compose.override.yml index f0397b2bc..9bcef070c 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -40,6 +40,10 @@ services: - DATABASE_TYPE=postgres - DATABASE_CLASS=mailman.database.postgresql.PostgreSQLDatabase - HYPERKITTY_API_KEY=somerandomapikeythatiobviouslydidnotcreatemyself + - MAILMAN_DOMAIN=${SERVER_HOSTNAME} + - SMTP_HOST=postfix + - SMTP_PORT=1025 + - SMTP_SECURE_MODE=smtp ports: - "8020:8001" networks: @@ -55,10 +59,18 @@ services: environment: - DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgresql/${POSTGRES_MAILMAN_DATABASE} - DATABASE_TYPE=postgres + - DJANGO_ALLOWED_HOSTS=127.0.0.1,localhost,mailmanw - HYPERKITTY_API_KEY=somerandomapikeythatiobviouslydidnotcreatemyself - SECRET_KEY=anotherandomkeythatiobviouslydidnotcreatemyself - SERVE_FROM_DOMAIN=localhost - UWSGI_STATIC_MAP=/static=/opt/mailman-web-data/static + - MAILMAN_ADMIN_USER=${DEMO_CREDENTIALS_USERNAME} + - MAILMAN_ADMIN_EMAIL=${DEMO_CREDENTIALS_USERNAME}@${SERVER_HOSTNAME} + - DJANGO_SUPERUSER_USERNAME=${DEMO_CREDENTIALS_USERNAME} + - DJANGO_SUPERUSER_EMAIL=${DEMO_CREDENTIALS_USERNAME}@${SERVER_HOSTNAME} + - DJANGO_SUPERUSER_PASSWORD=${DEMO_CREDENTIALS_PASSWORD} + - SMTP_HOST=postfix + - SMTP_PORT=1025 ports: - "8021:8000" networks: diff --git a/docker-compose.yml b/docker-compose.yml index 92db5c315..6a49322f9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -79,6 +79,8 @@ services: # - SERVER_HOSTNAME= networks: - gewisdb_network + expose: + - 1025 restart: unless-stopped stop_grace_period: 60s diff --git a/docker/mailman/settings_local.py b/docker/mailman/settings_local.py new file mode 100644 index 000000000..cda0e28fb --- /dev/null +++ b/docker/mailman/settings_local.py @@ -0,0 +1,15 @@ +# This configuration file only applies the second time the container is started +DEBUG = False + +ADMINS = () + +ACCOUNT_AUTHENTICATION_METHOD = "username" +ACCOUNT_EMAIL_REQUIRED = False +ACCOUNT_EMAIL_VERIFICATION = "none" + +MAILMAN_WEB_SOCIAL_AUTH = [ + 'allauth.socialaccount.providers.dummy', +] +SOCIALACCOUNT_PROVIDERS = { + 'dummy': {}, +} \ No newline at end of file From 2695959d4ff44f3b064084cfc76e5b4e8c9c0b2b Mon Sep 17 00:00:00 2001 From: Rink Date: Tue, 24 Jun 2025 18:37:35 +0000 Subject: [PATCH 05/11] feat(mailman): Replace cached mailman with fetching mailman lists --- .devcontainer/devcontainer.json | 6 +- .github/workflows/static-analysis.yml | 4 +- .github/workflows/unit-tests.yml | 2 +- .idea/php.xml | 1 - Makefile | 4 +- composer.json | 5 +- composer.lock | 73 +---------- config/modules.config.php | 1 - docker-compose.yml | 7 -- docker/web/development/Dockerfile | 3 - docker/web/development/crontab | 29 ++--- docker/web/production/Dockerfile | 4 - docker/web/production/crontab | 29 ++--- module/Database/config/module.config.php | 11 +- .../migrations/Version20250621133119.php | 8 +- .../FetchMailmanListsCommandFactory.php | 27 ++++ .../src/Command/FetchMailmanListsCommand.php | 32 +++++ .../src/Controller/SettingsController.php | 32 +++-- module/Database/src/Form/MailingList.php | 19 ++- .../Factory/MailmanMailingListFactory.php | 23 ++++ .../src/Mapper/MailmanMailingList.php | 95 ++++++++++++++ module/Database/src/Model/MailingList.php | 40 +++--- .../Database/src/Model/MailmanMailingList.php | 117 ++++++++++++++++++ .../Database/src/Model/ProspectiveMember.php | 8 +- module/Database/src/Module.php | 24 ++-- .../src/Service/Factory/MailmanFactory.php | 8 +- module/Database/src/Service/MailingList.php | 2 +- module/Database/src/Service/Mailman.php | 83 +++++++++---- .../view/database/settings/add-list.phtml | 2 +- .../view/database/settings/lists.phtml | 21 ++-- phpcs.xml.dist | 2 + 31 files changed, 490 insertions(+), 232 deletions(-) create mode 100644 module/Database/src/Command/Factory/FetchMailmanListsCommandFactory.php create mode 100644 module/Database/src/Command/FetchMailmanListsCommand.php create mode 100644 module/Database/src/Mapper/Factory/MailmanMailingListFactory.php create mode 100644 module/Database/src/Mapper/MailmanMailingList.php create mode 100644 module/Database/src/Model/MailmanMailingList.php diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 1f21e30d4..7d7feb056 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -55,13 +55,13 @@ }, "remoteUser": "vscode", "postCreateCommand": { - "build": "cp .env.dist .env && make builddev && docker compose build", + "build": "cp .env.dist .env", "noXdebug": "sudo rm -f /usr/local/etc/php/conf.d/xdebug.ini" }, "postStartCommand": { - "runGewisDb": "make rundev && make getvendordir" + "runGewisDb": "(make builddev && docker compose build && make rundev && make getvendordir); true" }, - "waitFor": "postStartCommand", + "waitFor": "postCreateCommand", //"postAttachCommand": "", "forwardPorts": [ 80, diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml index 17bb7afb8..8644732b9 100644 --- a/.github/workflows/static-analysis.yml +++ b/.github/workflows/static-analysis.yml @@ -41,7 +41,7 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: '8.3' - extensions: calendar, curl, intl, opcache, pgsql, pdo_pgsql, zip, memcached, xdebug + extensions: calendar, curl, intl, opcache, pgsql, pdo_pgsql, zip, xdebug tools: cs2pr - name: Extract configuration files @@ -128,7 +128,7 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: '8.3' - extensions: calendar, curl, intl, opcache, pgsql, pdo_pgsql, zip, memcached, xdebug + extensions: calendar, curl, intl, opcache, pgsql, pdo_pgsql, zip, xdebug - name: Extract configuration files run: | diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index dec84d9a3..9ad64d38a 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -25,7 +25,7 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: '8.3' - extensions: calendar, curl, intl, opcache, pdo_sqlite, sqlite3, zip, memcached + extensions: calendar, curl, intl, opcache, pdo_sqlite, sqlite3, zip - name: Setup problem matchers for PHPUnit run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" diff --git a/.idea/php.xml b/.idea/php.xml index b72389187..6f8b24128 100644 --- a/.idea/php.xml +++ b/.idea/php.xml @@ -189,7 +189,6 @@ - diff --git a/Makefile b/Makefile index eadfcfc6a..941fa2280 100644 --- a/Makefile +++ b/Makefile @@ -35,7 +35,6 @@ runprodtest: buildprod rundev: builddev @docker compose up -d --remove-orphans @make replenish - @make preparemailman @docker compose exec web rm -rf data/cache/module-config-cache.application.config.cache.php migrate: replenish @@ -65,8 +64,11 @@ migration-down: replenish migration-list seed: replenish @docker compose exec -T web ./web application:fixtures:load @docker compose exec web ./web report:generate:full + @make preparemailman @docker compose exec mailman-web bash -c '(python3 ./manage.py createsuperuser --no-input 2>/dev/null); pkill -HUP uwsgi' @docker compose exec -u mailman mailman-core bash -c '(mailman create news@$$MAILMAN_DOMAIN; mailman create other@$$MAILMAN_DOMAIN; true) 2>/dev/null' + @docker compose exec web ./web database:mailinglist:fetch + exec: docker compose exec -it web $(cmd) diff --git a/composer.json b/composer.json index e6a2366db..78e8146f5 100644 --- a/composer.json +++ b/composer.json @@ -12,7 +12,6 @@ "require": { "php": "^8.3.0", "ext-intl": "*", - "ext-memcached": "^3.2", "ext-pdo_pgsql": "*", "ext-pgsql": "*", "ext-zend-opcache": "*", @@ -57,9 +56,7 @@ "stripe/stripe-php": "^10.21", "doctrine/migrations": "^3.8", "doctrine/data-fixtures": "^2.0", - "fakerphp/faker": "^1.24", - "laminas/laminas-cache": "^3.12.1", - "laminas/laminas-cache-storage-adapter-memcached": "^2.5.0" + "fakerphp/faker": "^1.24" }, "require-dev": { "laminas/laminas-component-installer": "^3.4.0", diff --git a/composer.lock b/composer.lock index 1fd74787f..7b4788dd0 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": "4849dd7b6cff3e8e7d854c38ee102c12", + "content-hash": "0c8d48851f13c5a5fb0bb77fa096a000", "packages": [ { "name": "brick/varexporter", @@ -1985,76 +1985,6 @@ ], "time": "2025-01-23T17:29:37+00:00" }, - { - "name": "laminas/laminas-cache-storage-adapter-memcached", - "version": "2.6.0", - "source": { - "type": "git", - "url": "https://github.com/laminas/laminas-cache-storage-adapter-memcached.git", - "reference": "88bdebf512687885e2e1358021c61f7d15abf076" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/laminas/laminas-cache-storage-adapter-memcached/zipball/88bdebf512687885e2e1358021c61f7d15abf076", - "reference": "88bdebf512687885e2e1358021c61f7d15abf076", - "shasum": "" - }, - "require": { - "ext-memcached": "^3.1.5", - "laminas/laminas-cache": "^3.10", - "php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0" - }, - "conflict": { - "laminas/laminas-servicemanager": "<3.11" - }, - "provide": { - "laminas/laminas-cache-storage-implementation": "1.0" - }, - "require-dev": { - "laminas/laminas-cache-storage-adapter-benchmark": "^1.0", - "laminas/laminas-cache-storage-adapter-test": "^2.0", - "laminas/laminas-coding-standard": "~2.5.0", - "phpunit/phpunit": "^9.6.22", - "psalm/plugin-phpunit": "^0.18.0", - "vimeo/psalm": "^5.18" - }, - "type": "library", - "extra": { - "laminas": { - "module": "Laminas\\Cache\\Storage\\Adapter\\Memcached", - "config-provider": "Laminas\\Cache\\Storage\\Adapter\\Memcached\\ConfigProvider" - } - }, - "autoload": { - "psr-4": { - "Laminas\\Cache\\Storage\\Adapter\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "description": "Laminas cache adapter for memcached", - "keywords": [ - "cache", - "laminas", - "memcached" - ], - "support": { - "docs": "https://docs.laminas.dev/laminas-cache-storage-adapter-memcached/", - "forum": "https://discourse.laminas.dev/", - "issues": "https://github.com/laminas/laminas-cache-storage-adapter-memcached/issues", - "rss": "https://github.com/laminas/laminas-cache-storage-adapter-memcached/releases.atom", - "source": "https://github.com/laminas/laminas-cache-storage-adapter-memcached" - }, - "funding": [ - { - "url": "https://funding.communitybridge.org/projects/laminas-project", - "type": "community_bridge" - } - ], - "time": "2025-01-23T15:55:46+00:00" - }, { "name": "laminas/laminas-cache-storage-adapter-memory", "version": "2.4.0", @@ -12776,7 +12706,6 @@ "platform": { "php": "^8.3.0", "ext-intl": "*", - "ext-memcached": "^3.2", "ext-pdo_pgsql": "*", "ext-pgsql": "*", "ext-zend-opcache": "*", diff --git a/config/modules.config.php b/config/modules.config.php index c60e2ecd1..6597c7111 100644 --- a/config/modules.config.php +++ b/config/modules.config.php @@ -26,7 +26,6 @@ 'DoctrineORMModule', 'Laminas\Cache\Storage\Adapter\Filesystem', 'Laminas\Cache\Storage\Adapter\Memory', - 'Laminas\Cache\Storage\Adapter\Memcached', 'Application', 'Database', 'Checker', diff --git a/docker-compose.yml b/docker-compose.yml index 6a49322f9..f4597a467 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -53,7 +53,6 @@ services: # - STRIPE_CANCEL_URL= # - STRIPE_SUCCESS_URL= depends_on: - - memcached - postfix volumes: - gewisdb_data:/code/data:rw @@ -61,12 +60,6 @@ services: networks: - gewisdb_network restart: unless-stopped - memcached: - image: memcached:alpine - entrypoint: [ 'memcached', '-m 256' ] - networks: - - gewisdb_network - restart: unless-stopped postfix: image: juanluisbaptiste/postfix env_file: diff --git a/docker/web/development/Dockerfile b/docker/web/development/Dockerfile index 9c6e97aca..dd9c45647 100644 --- a/docker/web/development/Dockerfile +++ b/docker/web/development/Dockerfile @@ -18,7 +18,6 @@ RUN apk add --no-cache --virtual .build-deps \ $PHPIZE_DEPS \ curl-dev \ icu-dev \ - libmemcached-dev \ libpq-dev \ libzip-dev \ linux-headers \ @@ -42,8 +41,6 @@ RUN apk add --no-cache --virtual .build-deps \ pdo_pgsql \ pdo_sqlite \ zip \ - && pecl install memcached \ - && docker-php-ext-enable memcached \ && pecl install xdebug \ && docker-php-ext-enable xdebug \ && rm -r /tmp/pear \ diff --git a/docker/web/development/crontab b/docker/web/development/crontab index 95770982d..9abd823f2 100644 --- a/docker/web/development/crontab +++ b/docker/web/development/crontab @@ -1,16 +1,17 @@ -# ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ minute (0 - 59) -# │ ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ hour (0 - 23) -# │ │ ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ day of month (1 - 31) -# │ │ │ ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ month (1 - 12) -# │ │ │ │ ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ day of week (0 - 6) (Sunday to Saturday; -# │ │ │ │ │ 7 is also Sunday on some systems) -# │ │ │ │ │ -# │ │ │ │ │ +# ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ minute (0 - 59) +# │ ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ hour (0 - 23) +# │ │ ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ day of month (1 - 31) +# │ │ │ ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ month (1 - 12) +# │ │ │ │ ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ day of week (0 - 6) (Sunday to Saturday; +# │ │ │ │ │ 7 is also Sunday on some systems) +# │ │ │ │ │ +# │ │ │ │ │ # * * * * * command to execute # Don't remove the empty line at the end of this file. It is required to run the cron job -0 1 * * * { . /code/config/bash.env && /usr/local/bin/php /code/web report:generate:full; } > /code/data/logs/cron-report-partial.log 2>&1 -0 0 * 6 * { . /code/config/bash.env && /usr/local/bin/php /code/web check:membership:tue; } > /code/data/logs/cron-check-tue.log 2>&1 -0 0 1 7 * { . /code/config/bash.env && /usr/local/bin/php /code/web check:membership:expiration; } > /code/data/logs/cron-check-expiration.log 2>&1 -0 0 1 7 * { . /code/config/bash.env && /usr/local/bin/php /code/web check:membership:type; } > /code/data/logs/cron-check-type.log 2>&1 -*/30 * * * * { . /code/config/bash.env && /usr/local/bin/php /code/web check:membership:renewal:graduate; } > /code/data/logs/cron-check-renewal.log 2>&1 -0 2 * * * { . /code/config/bash.env && /usr/local/bin/php /code/web database:prospective-members:delete-expired; } > /code/data/logs/cron-delete-prospective.log 2>&1 +0 1 * * * { . /code/config/bash.env && /usr/local/bin/php /code/web report:generate:full; } > /code/data/logs/cron-report-partial.log 2>&1 +0 0 * 6 * { . /code/config/bash.env && /usr/local/bin/php /code/web check:membership:tue; } > /code/data/logs/cron-check-tue.log 2>&1 +0 0 1 7 * { . /code/config/bash.env && /usr/local/bin/php /code/web check:membership:expiration; } > /code/data/logs/cron-check-expiration.log 2>&1 +0 0 1 7 * { . /code/config/bash.env && /usr/local/bin/php /code/web check:membership:type; } > /code/data/logs/cron-check-type.log 2>&1 +*/30 * * * * { . /code/config/bash.env && /usr/local/bin/php /code/web check:membership:renewal:graduate; } > /code/data/logs/cron-check-renewal.log 2>&1 +0 2 * * * { . /code/config/bash.env && /usr/local/bin/php /code/web database:prospective-members:delete-expired; } > /code/data/logs/cron-delete-prospective.log 2>&1 +55 * * * * { . /code/config/bash.env && /usr/local/bin/php /code/web database:mailinglist:fetch; } > /code/data/logs/cron-mailinglist-fetch.log 2>&1 diff --git a/docker/web/production/Dockerfile b/docker/web/production/Dockerfile index bf44a30d5..6f1c7f85c 100644 --- a/docker/web/production/Dockerfile +++ b/docker/web/production/Dockerfile @@ -17,7 +17,6 @@ WORKDIR /code RUN apk add --no-cache --virtual .build-deps \ curl-dev \ icu-dev \ - libmemcached-dev \ libpq-dev \ libzip-dev \ openldap-dev \ @@ -35,9 +34,6 @@ RUN apk add --no-cache --virtual .build-deps \ pgsql \ pdo_pgsql \ zip \ - && pecl install memcached \ - && docker-php-ext-enable memcached \ - && rm -r /tmp/pear \ && runtimeDeps="$( \ scanelf --needed --nobanner --format '%n#p' --recursive /usr/local/lib/php/extensions \ | tr ',' '\n' \ diff --git a/docker/web/production/crontab b/docker/web/production/crontab index 95770982d..9abd823f2 100644 --- a/docker/web/production/crontab +++ b/docker/web/production/crontab @@ -1,16 +1,17 @@ -# ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ minute (0 - 59) -# │ ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ hour (0 - 23) -# │ │ ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ day of month (1 - 31) -# │ │ │ ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ month (1 - 12) -# │ │ │ │ ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ day of week (0 - 6) (Sunday to Saturday; -# │ │ │ │ │ 7 is also Sunday on some systems) -# │ │ │ │ │ -# │ │ │ │ │ +# ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ minute (0 - 59) +# │ ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ hour (0 - 23) +# │ │ ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ day of month (1 - 31) +# │ │ │ ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ month (1 - 12) +# │ │ │ │ ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ day of week (0 - 6) (Sunday to Saturday; +# │ │ │ │ │ 7 is also Sunday on some systems) +# │ │ │ │ │ +# │ │ │ │ │ # * * * * * command to execute # Don't remove the empty line at the end of this file. It is required to run the cron job -0 1 * * * { . /code/config/bash.env && /usr/local/bin/php /code/web report:generate:full; } > /code/data/logs/cron-report-partial.log 2>&1 -0 0 * 6 * { . /code/config/bash.env && /usr/local/bin/php /code/web check:membership:tue; } > /code/data/logs/cron-check-tue.log 2>&1 -0 0 1 7 * { . /code/config/bash.env && /usr/local/bin/php /code/web check:membership:expiration; } > /code/data/logs/cron-check-expiration.log 2>&1 -0 0 1 7 * { . /code/config/bash.env && /usr/local/bin/php /code/web check:membership:type; } > /code/data/logs/cron-check-type.log 2>&1 -*/30 * * * * { . /code/config/bash.env && /usr/local/bin/php /code/web check:membership:renewal:graduate; } > /code/data/logs/cron-check-renewal.log 2>&1 -0 2 * * * { . /code/config/bash.env && /usr/local/bin/php /code/web database:prospective-members:delete-expired; } > /code/data/logs/cron-delete-prospective.log 2>&1 +0 1 * * * { . /code/config/bash.env && /usr/local/bin/php /code/web report:generate:full; } > /code/data/logs/cron-report-partial.log 2>&1 +0 0 * 6 * { . /code/config/bash.env && /usr/local/bin/php /code/web check:membership:tue; } > /code/data/logs/cron-check-tue.log 2>&1 +0 0 1 7 * { . /code/config/bash.env && /usr/local/bin/php /code/web check:membership:expiration; } > /code/data/logs/cron-check-expiration.log 2>&1 +0 0 1 7 * { . /code/config/bash.env && /usr/local/bin/php /code/web check:membership:type; } > /code/data/logs/cron-check-type.log 2>&1 +*/30 * * * * { . /code/config/bash.env && /usr/local/bin/php /code/web check:membership:renewal:graduate; } > /code/data/logs/cron-check-renewal.log 2>&1 +0 2 * * * { . /code/config/bash.env && /usr/local/bin/php /code/web database:prospective-members:delete-expired; } > /code/data/logs/cron-delete-prospective.log 2>&1 +55 * * * * { . /code/config/bash.env && /usr/local/bin/php /code/web database:mailinglist:fetch; } > /code/data/logs/cron-mailinglist-fetch.log 2>&1 diff --git a/module/Database/config/module.config.php b/module/Database/config/module.config.php index e11c5901a..6a72b5121 100644 --- a/module/Database/config/module.config.php +++ b/module/Database/config/module.config.php @@ -6,6 +6,7 @@ use Database\Command\DeleteExpiredMembersCommand; use Database\Command\DeleteExpiredProspectiveMembersCommand; +use Database\Command\FetchMailmanListsCommand; use Database\Command\GenerateAuthenticationKeysCommand; use Database\Controller\ApiController; use Database\Controller\ExportController; @@ -624,15 +625,6 @@ ], ], ], - 'sync' => [ - 'type' => Literal::class, - 'options' => [ - 'route' => '/sync', - 'defaults' => [ - 'action' => 'syncLists', - ], - ], - ], ], ], 'default' => [ @@ -785,6 +777,7 @@ ], 'laminas-cli' => [ 'commands' => [ + 'database:mailinglist:fetch' => FetchMailmanListsCommand::class, 'database:members:delete-expired' => DeleteExpiredMembersCommand::class, 'database:members:generate-keys' => GenerateAuthenticationKeysCommand::class, 'database:prospective-members:delete-expired' => DeleteExpiredProspectiveMembersCommand::class, diff --git a/module/Database/migrations/Version20250621133119.php b/module/Database/migrations/Version20250621133119.php index c3b1d3836..f1563c23c 100644 --- a/module/Database/migrations/Version20250621133119.php +++ b/module/Database/migrations/Version20250621133119.php @@ -24,6 +24,7 @@ public function up(Schema $schema): void $this->addSql('CREATE INDEX IDX_3A8467A97B1AC3ED ON MailingListMember (mailingList)'); $this->addSql('CREATE INDEX IDX_3A8467A970E4FA78 ON MailingListMember (member)'); $this->addSql('CREATE UNIQUE INDEX mailinglistmember_unique_idx ON MailingListMember (mailingList, member)'); + $this->addSql('CREATE TABLE MailmanMailingList (id VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL, lastSeen TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))'); $this->addSql('ALTER TABLE MailingListMember ADD CONSTRAINT FK_3A8467A97B1AC3ED FOREIGN KEY (mailingList) REFERENCES MailingList (name) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE MailingListMember ADD CONSTRAINT FK_3A8467A970E4FA78 FOREIGN KEY (member) REFERENCES Member (lidnr) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE prospective_members_mailinglists DROP CONSTRAINT fk_c86f04985e237e06'); @@ -33,14 +34,16 @@ public function up(Schema $schema): void $this->addSql('DROP TABLE prospective_members_mailinglists'); $this->addSql('DROP TABLE members_mailinglists'); $this->addSql('ALTER TABLE configitem ADD valueBool BOOLEAN DEFAULT NULL'); - $this->addSql('ALTER TABLE mailinglist ADD mailmanId VARCHAR(255) NOT NULL'); + $this->addSql('ALTER TABLE mailinglist ADD mailmanId VARCHAR(255) DEFAULT NULL'); + $this->addSql('ALTER TABLE mailinglist ADD CONSTRAINT FK_FD864C3AFD6980D2 FOREIGN KEY (mailmanId) REFERENCES MailmanMailingList (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('CREATE UNIQUE INDEX UNIQ_FD864C3AFD6980D2 ON mailinglist (mailmanId)'); - $this->addSql('ALTER TABLE prospectivemember ADD lists TEXT'); + $this->addSql('ALTER TABLE prospectivemember ADD lists TEXT DEFAULT NULL'); $this->addSql('COMMENT ON COLUMN prospectivemember.lists IS \'(DC2Type:simple_array)\''); } public function down(Schema $schema): void { + $this->addSql('ALTER TABLE MailingList DROP CONSTRAINT FK_FD864C3AFD6980D2'); $this->addSql('CREATE TABLE prospective_members_mailinglists (lidnr INT NOT NULL, name VARCHAR(255) NOT NULL, PRIMARY KEY(lidnr, name))'); $this->addSql('CREATE INDEX idx_c86f04985e237e06 ON prospective_members_mailinglists (name)'); $this->addSql('CREATE INDEX idx_c86f0498d665e01d ON prospective_members_mailinglists (lidnr)'); @@ -54,6 +57,7 @@ public function down(Schema $schema): void $this->addSql('ALTER TABLE MailingListMember DROP CONSTRAINT FK_3A8467A97B1AC3ED'); $this->addSql('ALTER TABLE MailingListMember DROP CONSTRAINT FK_3A8467A970E4FA78'); $this->addSql('DROP TABLE MailingListMember'); + $this->addSql('DROP TABLE MailmanMailingList'); $this->addSql('ALTER TABLE ConfigItem DROP valueBool'); $this->addSql('DROP INDEX UNIQ_FD864C3AFD6980D2'); $this->addSql('ALTER TABLE MailingList DROP mailmanId'); diff --git a/module/Database/src/Command/Factory/FetchMailmanListsCommandFactory.php b/module/Database/src/Command/Factory/FetchMailmanListsCommandFactory.php new file mode 100644 index 000000000..c872cd809 --- /dev/null +++ b/module/Database/src/Command/Factory/FetchMailmanListsCommandFactory.php @@ -0,0 +1,27 @@ +get(MailmanService::class); + + return new FetchMailmanListsCommand($mailmanService); + } +} diff --git a/module/Database/src/Command/FetchMailmanListsCommand.php b/module/Database/src/Command/FetchMailmanListsCommand.php new file mode 100644 index 000000000..cd8605e00 --- /dev/null +++ b/module/Database/src/Command/FetchMailmanListsCommand.php @@ -0,0 +1,32 @@ +mailmanService->fetchMailingLists(); + + return Command::SUCCESS; + } +} diff --git a/module/Database/src/Controller/SettingsController.php b/module/Database/src/Controller/SettingsController.php index d3fbd31de..6b25ceb21 100644 --- a/module/Database/src/Controller/SettingsController.php +++ b/module/Database/src/Controller/SettingsController.php @@ -13,6 +13,8 @@ use Laminas\Mvc\I18n\Translator as MvcTranslator; use Laminas\View\Model\ViewModel; +use function array_filter; + class SettingsController extends AbstractActionController { public function __construct( @@ -48,14 +50,21 @@ public function listsAction(): ViewModel { return new ViewModel([ 'lists' => $this->mailingListService->getAllLists(), - 'mailman' => $this->mailingListService->getMailmanService()->getMailingListIds(), + 'mailmanLists' => $this->mailingListService->getMailmanService()->getMailingLists(), + 'mailmanLastFetch' => $this->mailingListService->getMailmanService()->getLastFetchTime(), ]); } public function addListAction(): HttpResponse|ViewModel { $form = $this->mailingListService->getListForm(); - $form->setMailmanIds($this->mailingListService->getMailmanService()->getMailingListIds()['lists']); + + // Each mailman list may be used for at most one db list, don't show previously used + $lists = array_filter( + $this->mailingListService->getMailmanService()->getMailingLists(), + static fn ($list) => !$list->isManaged(), + ); + $form->setMailmanLists($lists); /** @var Request $request */ $request = $this->getRequest(); @@ -88,7 +97,14 @@ public function editListAction(): HttpResponse|ViewModel } $form = $this->mailingListService->getListForm(); - $form->setMailmanIds($this->mailingListService->getMailmanService()->getMailingListIds()['lists']); + + // Provide mailman lists to the creation form, ideally filter out previously used lists + // except for if it used for this list (saving with the same value is allowed) + $lists = array_filter( + $this->mailingListService->getMailmanService()->getMailingLists(), + static fn ($list) => !$list->isManaged() || $list->getMailingList()->getName() === $listName, + ); + $form->setMailmanLists($lists); /** @var Request $request */ $request = $this->getRequest(); @@ -135,14 +151,4 @@ public function deleteListAction(): HttpResponse|ViewModel 'name' => $name, ]); } - - /** - * Sync known mailing list ids from Mailman - */ - public function syncListsAction(): HttpResponse - { - $this->mailingListService->getMailmanService()->cacheMailingLists(); - - return $this->redirect()->toRoute('settings/lists'); - } } diff --git a/module/Database/src/Form/MailingList.php b/module/Database/src/Form/MailingList.php index 3f717eebe..37a6b6d25 100644 --- a/module/Database/src/Form/MailingList.php +++ b/module/Database/src/Form/MailingList.php @@ -4,6 +4,7 @@ namespace Database\Form; +use Database\Model\MailmanMailingList as MailmanMailingListModel; use Laminas\Filter\StringTrim; use Laminas\Form\Element\Checkbox; use Laminas\Form\Element\Select; @@ -15,6 +16,8 @@ use Laminas\Mvc\I18n\Translator; use Laminas\Validator\StringLength; +use function sprintf; + class MailingList extends Form implements InputFilterProviderInterface { public function __construct(private readonly Translator $translator) @@ -63,7 +66,7 @@ public function __construct(private readonly Translator $translator) ]); $this->add([ - 'name' => 'mailmanId', + 'name' => 'mailmanList', 'type' => Select::class, 'options' => [ 'label' => $this->translator->translate('Mailman Mailing List'), @@ -82,17 +85,21 @@ public function __construct(private readonly Translator $translator) } /** - * @param string[] $mailmanIds + * @param MailmanMailingListModel[] $mailmanLists */ - public function setMailmanIds(array $mailmanIds): void + public function setMailmanLists(array $mailmanLists): void { $options = []; - foreach ($mailmanIds as $mailmanId) { - $options[$mailmanId] = $mailmanId; + foreach ($mailmanLists as $mailmanList) { + $options[$mailmanList->getMailmanId()] = sprintf( + '%s (%s)', + $mailmanList->getName(), + $mailmanList->getMailmanId(), + ); } - $this->get('mailmanId')->setValueOptions($options); + $this->get('mailmanList')->setValueOptions($options); } /** diff --git a/module/Database/src/Mapper/Factory/MailmanMailingListFactory.php b/module/Database/src/Mapper/Factory/MailmanMailingListFactory.php new file mode 100644 index 000000000..15a8e6757 --- /dev/null +++ b/module/Database/src/Mapper/Factory/MailmanMailingListFactory.php @@ -0,0 +1,23 @@ +get('doctrine.entitymanager.orm_default')); + } +} diff --git a/module/Database/src/Mapper/MailmanMailingList.php b/module/Database/src/Mapper/MailmanMailingList.php new file mode 100644 index 000000000..881ab66ca --- /dev/null +++ b/module/Database/src/Mapper/MailmanMailingList.php @@ -0,0 +1,95 @@ +em->persist($list); + $this->em->flush(); + } + + /** + * Remove a mailman list. + */ + public function remove(MailmanMailingListModel $list): void + { + $this->em->remove($list); + $this->em->flush(); + } + + /** + * Get the time of last sync, or null if none + */ + public function getLastFetchTime(): ?DateTime + { + $list = $this->getRepository()->findOneBy([], ['lastSeen' => 'DESC']); + + return $list?->getLastSeen(); + } + + /** + * Find active mailing lists (i.e. seen in the last 1 day). + * + * @return array + */ + public function findActive(): array + { + $lastFetch = $this->getLastFetchTime(); + + $qb = $this->em->createQueryBuilder(); + + $qb->select('l') + ->from(MailmanMailingListModel::class, 'l') + ->where('l.lastSeen >= :lastSeen'); + + $qb->setParameter('lastSeen', $lastFetch?->sub(new DateInterval('P1D'))); + + return $qb->getQuery()->getResult(); + } + + /** + * Find all. + * + * @return array + */ + public function findAll(): array + { + return $this->getRepository()->findBy([], ['id' => 'ASC']); + } + + /** + * Find a list by mailman ID. + */ + public function find(string $mailmanId): ?MailmanMailingListModel + { + return $this->getRepository()->find($mailmanId); + } + + /** + * Get the repository for this mapper. + */ + public function getRepository(): EntityRepository + { + return $this->em->getRepository('Database\Model\MailmanMailingList'); + } +} diff --git a/module/Database/src/Model/MailingList.php b/module/Database/src/Model/MailingList.php index 07f3d323d..fb50a7eb0 100644 --- a/module/Database/src/Model/MailingList.php +++ b/module/Database/src/Model/MailingList.php @@ -9,16 +9,18 @@ use Doctrine\ORM\Mapping\Column; use Doctrine\ORM\Mapping\Entity; use Doctrine\ORM\Mapping\Id; +use Doctrine\ORM\Mapping\JoinColumn; use Doctrine\ORM\Mapping\OneToMany; +use Doctrine\ORM\Mapping\OneToOne; /** - * Mailing List model. + * Mailing List model for liss on the db side. */ #[Entity] class MailingList { /** - * Mailman-identifier / name. + * Name of the mailing list */ #[Id] #[Column(type: 'string')] @@ -51,13 +53,17 @@ class MailingList protected bool $defaultSub; /** - * The identifier of the mailing list in Mailman. + * The corresponding mailman mailing list */ - #[Column( - type: 'string', - unique: true, + #[OneToOne( + targetEntity: MailmanMailingList::class, + inversedBy: 'mailingList', )] - protected string $mailmanId; + #[JoinColumn( + name: 'mailmanId', + referencedColumnName: 'id', + )] + protected ?MailmanMailingList $mailmanList; /** * Mailing list members. @@ -124,7 +130,7 @@ public function setNlDescription(string $description): void } /** - * Get if it should be on the form. + * Get if it should be on the join form. */ public function getOnForm(): bool { @@ -132,7 +138,7 @@ public function getOnForm(): bool } /** - * Set if it should be on the form. + * Set if it should be on the join form. */ public function setOnForm(bool $onForm): void { @@ -156,19 +162,19 @@ public function setDefaultSub(bool $default): void } /** - * Get the identifier of the mailing list in Mailman. + * Get the matching mailman list, or null if none */ - public function getMailmanId(): string + public function getMailmanList(): ?MailmanMailingList { - return $this->mailmanId; + return $this->mailmanList; } /** - * Set the identifier of the mailing list in Mailman. + * Set the corresponding mailman list */ - public function setMailmanId(string $mailmanId): void + public function setMailmanList(MailmanMailingList $mailmanList): void { - $this->mailmanId = $mailmanId; + $this->mailmanList = $mailmanList; } /** @@ -188,7 +194,7 @@ public function getMailingListMemberships(): Collection * en_description: string, * defaultSub: bool, * onForm: bool, - * mailmanId: string, + * mailmanList: string, * } */ public function toArray(): array @@ -199,7 +205,7 @@ public function toArray(): array 'en_description' => $this->getEnDescription(), 'defaultSub' => $this->getDefaultSub(), 'onForm' => $this->getOnForm(), - 'mailmanId' => $this->getMailmanId(), + 'mailmanList' => $this->getMailmanList()->getMailmanId(), ]; } } diff --git a/module/Database/src/Model/MailmanMailingList.php b/module/Database/src/Model/MailmanMailingList.php new file mode 100644 index 000000000..8ed4144e7 --- /dev/null +++ b/module/Database/src/Model/MailmanMailingList.php @@ -0,0 +1,117 @@ +mailmanId; + } + + /** + * Set the mailman ID + * It is only sensible if this happens during a sync + */ + public function setMailmanId(string $mailmanId): void + { + $this->mailmanId = $mailmanId; + } + + /** + * Get the name of the list in mailman + */ + public function getName(): string + { + return $this->name; + } + + /** + * Set the name of the list in mailman + * It is only sensible if this happens during a sync + */ + public function setName(string $name): void + { + $this->name = $name; + } + + /** + * Get the date the list was last seen + */ + public function getLastSeen(): DateTime + { + return $this->lastSeen; + } + + /** + * Set the date the list was last seen + * It is only sensible if this happens during a sync + */ + public function setLastSeen(DateTime $lastSeen = new DateTime()): void + { + $this->lastSeen = $lastSeen; + } + + /** + * Get the mailing list corresponding to this mailman list + */ + public function getMailingList(): ?MailingList + { + return $this->mailingList; + } + + /** + * Whether this mailman list is managed by GEWISDB + */ + public function isManaged(): bool + { + return null !== $this->mailingList; + } +} diff --git a/module/Database/src/Model/ProspectiveMember.php b/module/Database/src/Model/ProspectiveMember.php index 69b4a2c8c..a23df31c4 100644 --- a/module/Database/src/Model/ProspectiveMember.php +++ b/module/Database/src/Model/ProspectiveMember.php @@ -142,13 +142,13 @@ enumType: PostalRegions::class, /** * Memberships of mailing lists. * - * @var string[] $lists + * @var ?string[] $lists */ #[Column( type: 'simple_array', nullable: true, )] - protected array $lists = []; + protected ?array $lists = []; /** * The Checkout Sessions for this prospective member. @@ -486,6 +486,10 @@ public function setAddress(Address $address): void */ public function getLists(): array { + if (null === $this->lists) { + return []; + } + return $this->lists; } diff --git a/module/Database/src/Module.php b/module/Database/src/Module.php index 61e228e04..2c15ef4af 100644 --- a/module/Database/src/Module.php +++ b/module/Database/src/Module.php @@ -8,7 +8,9 @@ use Database\Command\DeleteExpiredProspectiveMembersCommand; use Database\Command\Factory\DeleteExpiredMembersCommandFactory; use Database\Command\Factory\DeleteExpiredProspectiveMembersCommandFactory; +use Database\Command\Factory\FetchMailmanListsCommandFactory; use Database\Command\Factory\GenerateAuthenticationKeysCommandFactory; +use Database\Command\FetchMailmanListsCommand; use Database\Command\GenerateAuthenticationKeysCommand; use Database\Form\Abolish as AbolishForm; use Database\Form\Address as AddressForm; @@ -73,6 +75,7 @@ use Database\Mapper\Factory\CheckoutSessionFactory as CheckoutSessionMapperFactory; use Database\Mapper\Factory\MailingListFactory as MailingListMapperFactory; use Database\Mapper\Factory\MailingListMemberFactory as MailingListMemberMapperFactory; +use Database\Mapper\Factory\MailmanMailingListFactory as MailmanMailingListMapperFactory; use Database\Mapper\Factory\MeetingFactory as MeetingMapperFactory; use Database\Mapper\Factory\MemberFactory as MemberMapperFactory; use Database\Mapper\Factory\MemberUpdateFactory as MemberUpdateMapperFactory; @@ -81,6 +84,7 @@ use Database\Mapper\Factory\SavedQueryFactory as SavedQueryMapperFactory; use Database\Mapper\MailingList as MailingListMapper; use Database\Mapper\MailingListMember as MailingListMemberMapper; +use Database\Mapper\MailmanMailingList as MailmanMailingListMapper; use Database\Mapper\Meeting as MeetingMapper; use Database\Mapper\Member as MemberMapper; use Database\Mapper\MemberUpdate as MemberUpdateMapper; @@ -114,13 +118,10 @@ use Database\Service\Query as QueryService; use Database\Service\Stripe as StripeService; use Doctrine\Laminas\Hydrator\DoctrineObject; -use Laminas\Cache\Storage\Adapter\Memcached; -use Laminas\Cache\Storage\Adapter\MemcachedOptions; use Laminas\Http\PhpEnvironment\RemoteAddress; use Laminas\Hydrator\ObjectPropertyHydrator; use Laminas\Mvc\I18n\Translator as MvcTranslator; use Psr\Container\ContainerInterface; -use RuntimeException; use stdClass; use function array_map; @@ -164,6 +165,7 @@ public function getServiceConfig(): array 'factories' => [ DeleteExpiredMembersCommand::class => DeleteExpiredMembersCommandFactory::class, DeleteExpiredProspectiveMembersCommand::class => DeleteExpiredProspectiveMembersCommandFactory::class, + FetchMailmanListsCommand::class => FetchMailmanListsCommandFactory::class, GenerateAuthenticationKeysCommand::class => GenerateAuthenticationKeysCommandFactory::class, ApiService::class => ApiServiceFactory::class, FrontPageService::class => FrontPageServiceFactory::class, @@ -547,6 +549,7 @@ public function getServiceConfig(): array }, ActionLinkMapper::class => ActionLinkMapperFactory::class, AuditMapper::class => AuditMapperFactory::class, + MailmanMailingListMapper::class => MailmanMailingListMapperFactory::class, MailingListMapper::class => MailingListMapperFactory::class, MailingListMemberMapper::class => MailingListMemberMapperFactory::class, MeetingMapper::class => MeetingMapperFactory::class, @@ -561,7 +564,9 @@ public function getServiceConfig(): array $config = $config['email']; $class = '\Laminas\Mail\Transport\\' . $config['transport']; $optionsClass = '\Laminas\Mail\Transport\\' . $config['transport'] . 'Options'; + /** @psalm-suppress InvalidStringClass */ $transport = new $class(); + /** @psalm-suppress InvalidStringClass */ $transport->setOptions(new $optionsClass($config['options'])); return $transport; @@ -608,19 +613,6 @@ static function (string $ip) { return $remote->getIpAddress(); }, - 'database_cache_mailman' => static function () { - $cache = new Memcached(); - // The TTL is 24 hours (60 * 60 * 24), unless manually refreshed. - $options = $cache->getOptions(); - if (!($options instanceof MemcachedOptions)) { - throw new RuntimeException('Unable to retrieve and set options for Memcached'); - } - - $options->setTtl(60 * 60 * 24); - $options->setServers(['memcached', '11211']); - - return $cache; - }, ], 'shared' => [ // every form should get a different meeting fieldset diff --git a/module/Database/src/Service/Factory/MailmanFactory.php b/module/Database/src/Service/Factory/MailmanFactory.php index f93aa66da..85ffcd4e8 100644 --- a/module/Database/src/Service/Factory/MailmanFactory.php +++ b/module/Database/src/Service/Factory/MailmanFactory.php @@ -6,8 +6,8 @@ use Application\Service\Config as ConfigService; use Database\Mapper\MailingListMember as MailingListMemberMapper; +use Database\Mapper\MailmanMailingList as MailmanMailingListMapper; use Database\Service\Mailman as MailmanService; -use Laminas\Cache\Storage\Adapter\AbstractAdapter; use Laminas\ServiceManager\Factory\FactoryInterface; use Psr\Container\ContainerInterface; @@ -21,8 +21,8 @@ public function __invoke( $requestedName, ?array $options = null, ): MailmanService { - /** @var AbstractAdapter $mailmanCache */ - $mailmanCache = $container->get('database_cache_mailman'); + /** @var MailmanMailingListMapper $mailmanMailingListMapper */ + $mailmanMailingListMapper = $container->get(MailmanMailingListMapper::class); /** @var MailingListMemberMapper $mailingListMemberMapper */ $mailingListMemberMapper = $container->get(MailingListMemberMapper::class); /** @var ConfigService $configService */ @@ -31,7 +31,7 @@ public function __invoke( $mailmanConfig = $container->get('config')['mailman_api']; return new MailmanService( - $mailmanCache, + $mailmanMailingListMapper, $mailingListMemberMapper, $configService, $mailmanConfig, diff --git a/module/Database/src/Service/MailingList.php b/module/Database/src/Service/MailingList.php index 845b57fbb..8dfabfdd6 100644 --- a/module/Database/src/Service/MailingList.php +++ b/module/Database/src/Service/MailingList.php @@ -60,7 +60,7 @@ public function editList( $list->setNlDescription($data['nl_description']); $list->setOnForm(boolval($data['onForm'])); $list->setDefaultSub(boolval($data['defaultSub'])); - $list->setMailmanId($data['mailmanId']); + $list->setMailmanList($this->getMailmanService()->getMailingList($data['mailmanList'])); $this->getListMapper()->persist($list); diff --git a/module/Database/src/Service/Mailman.php b/module/Database/src/Service/Mailman.php index 987678dff..ad033d5b3 100644 --- a/module/Database/src/Service/Mailman.php +++ b/module/Database/src/Service/Mailman.php @@ -7,15 +7,16 @@ use Application\Model\Enums\ConfigNamespaces; use Application\Service\Config as ConfigService; use Database\Mapper\MailingListMember as MailingListMemberMapper; +use Database\Mapper\MailmanMailingList as MailmanMailingListMapper; use Database\Model\MailingListMember as MailingListMemberModel; +use Database\Model\MailmanMailingList as MailmanMailingListModel; use DateTime; -use Laminas\Cache\Storage\Adapter\AbstractAdapter; use Laminas\Http\Client; use Laminas\Http\Client\Adapter\Curl; use Laminas\Http\Request; use RuntimeException; -use function array_column; +use function array_map; use function json_decode; use function json_last_error_msg; use function json_validate; @@ -26,7 +27,7 @@ class Mailman * @phpcsSuppress SlevomatCodingStandard.TypeHints.ParameterTypeHint.MissingTraversableTypeHintSpecification */ public function __construct( - private readonly AbstractAdapter $mailmanCache, + private readonly MailmanMailingListMapper $mailmanMailingListMapper, private readonly MailingListMemberMapper $mailingListMemberMapper, private readonly ConfigService $configService, private readonly array $mailmanConfig, @@ -54,6 +55,10 @@ private function performMailmanRequest( // Data encoding is automatically set to `application/x-www-form-urlencoded` for "POST"-like requests. switch ($method) { case Request::METHOD_GET: + if (null === $data) { + $data = []; + } + $client->setParameterGet($data); break; case Request::METHOD_POST: @@ -145,9 +150,12 @@ public function isMailmanHealthy(): bool } /** - * @return string[] + * @return array */ - private function getAllListIdsFromMailman(): array + private function getAllListsFromMailman(): array { $lists = $this->performMailmanRequest('lists'); @@ -155,36 +163,65 @@ private function getAllListIdsFromMailman(): array isset($lists['total_size']) && 0 !== $lists['total_size'] ) { - return array_column($lists['entries'], 'list_id'); + return array_map( + static fn ($list) => [ + 'list_id' => $list['list_id'], + 'display_name' => $list['display_name'], + ], + $lists['entries'], + ); } return []; } - public function cacheMailingLists(): void + /** + * Fetch mailing lists from mailman and import them to the mailmanlist model in GEWISDB + */ + public function fetchMailingLists(): void { - $this->mailmanCache->setItem( - 'lists', - [ - 'synced' => new DateTime(), - 'lists' => $this->getAllListIdsFromMailman(), - ], - ); + $lists = $this->getAllListsFromMailman(); + + foreach ($lists as $list) { + $l = $this->mailmanMailingListMapper->find($list['list_id']); + + if (null === $l) { + $l = new MailmanMailingListModel(); + } + + $l->setName($list['display_name']); + $l->setMailmanId($list['list_id']); + $l->setLastSeen(); + + $this->mailmanMailingListMapper->persist($l); + } + } + + public function getMailingList(string $mailmanId): ?MailmanMailingListModel + { + return $this->mailmanMailingListMapper->find($mailmanId); } /** - * @return array{ - * synced: DateTime, - * lists: string[], - * } + * Returns all recently seen mailing lists + * + * @return MailmanMailingListModel[] */ - public function getMailingListIds(): array + public function getMailingLists(bool $activeOnly = true): array { - if (!$this->mailmanCache->hasItem('lists')) { - $this->cacheMailingLists(); + if (false === $activeOnly) { + return $this->mailmanMailingListMapper->findAll(); } - return $this->mailmanCache->getItem('lists'); + return $this->mailmanMailingListMapper->findActive(); + } + + /** + * Get the last succesfull mailman sync (>= 1 list) + */ + public function getLastFetchTime(): ?DateTime + { + return $this->mailmanMailingListMapper->getLastFetchTime(); } /** @@ -196,7 +233,7 @@ public function getMailingListIds(): array private function subscribeMemberToMailingList(MailingListMemberModel $mailingListMember): void { $member = $mailingListMember->getMember(); - $listId = $mailingListMember->getMailingList()->getMailmanId(); + $listId = $mailingListMember->getMailingList()->getMailmanList()->getMailmanId(); // Create the data for the request $data = [ diff --git a/module/Database/view/database/settings/add-list.phtml b/module/Database/view/database/settings/add-list.phtml index 9952c096e..1ed8e1212 100644 --- a/module/Database/view/database/settings/add-list.phtml +++ b/module/Database/view/database/settings/add-list.phtml @@ -117,7 +117,7 @@ if ('add' === $action) {
    get('mailmanId'); + $element = $form->get('mailmanList'); $element->setAttribute('placeholder', $element->getLabel()); ?>
    diff --git a/module/Database/view/database/settings/lists.phtml b/module/Database/view/database/settings/lists.phtml index 77856e943..4310d99a9 100644 --- a/module/Database/view/database/settings/lists.phtml +++ b/module/Database/view/database/settings/lists.phtml @@ -3,32 +3,31 @@ declare(strict_types=1); use Application\View\HelperTrait; -use Laminas\View\Renderer\PhpRenderer; use Database\Model\MailingList as MailingListModel; +use Database\Model\MailmanMailingList as MailmanMailingListModel; +use Laminas\View\Renderer\PhpRenderer; /** * @var PhpRenderer|HelperTrait $this * @var MailingListModel[] $lists - * @var array{sync: DateTime, lists: string[]} $mailman + * @var MailmanMailingListModel[] $mailmanLists + * @var DateTime $mailmanLastFetch */ ?>
    @@ -45,7 +44,7 @@ use Database\Model\MailingList as MailingListModel; - + diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 95fa489ef..b4f502f13 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -50,6 +50,7 @@ module/Checker/src/Service/Factory/RenewalFactory.phpmodule/Database/src/Command/Factory/DeleteExpiredMembersCommandFactory.phpmodule/Database/src/Command/Factory/DeleteExpiredProspectiveMembersCommandFactory.php + module/Database/src/Command/Factory/FetchMailmanListsCommandFactory.phpmodule/Database/src/Command/Factory/GenerateAuthenticationKeysCommandFactory.phpmodule/Database/src/Controller/Factory/ApiControllerFactory.phpmodule/Database/src/Controller/Factory/ExportControllerFactory.php @@ -64,6 +65,7 @@ module/Database/src/Mapper/Factory/AuditFactory.phpmodule/Database/src/Mapper/Factory/CheckoutSessionFactory.phpmodule/Database/src/Mapper/Factory/MailingListFactory.php + module/Database/src/Mapper/Factory/MailmanMailingListFactory.phpmodule/Database/src/Mapper/Factory/MailingListMemberFactory.phpmodule/Database/src/Mapper/Factory/MeetingFactory.phpmodule/Database/src/Mapper/Factory/MemberFactory.php From 8a3ecff9129a1e1f153de129725f73a6dab5663e Mon Sep 17 00:00:00 2001 From: Rink Date: Thu, 26 Jun 2025 20:27:05 +0000 Subject: [PATCH 06/11] feat(mailman): Update MailingListMemberModel as designed Incl. frontpage notifications --- .../migrations/Version20250621133119.php | 2 +- module/Database/src/Form/MemberLists.php | 23 +++---- .../Database/src/Mapper/MailingListMember.php | 34 +++++++++++ .../Database/src/Model/MailingListMember.php | 37 ++++++++--- .../src/Service/Factory/FrontPageFactory.php | 3 + module/Database/src/Service/FrontPage.php | 16 ++++- module/Database/src/Service/Mailman.php | 31 +++++++++- module/Database/src/Service/Member.php | 7 ++- .../Database/view/database/index/index.phtml | 61 ++++++++++++++++++- .../Database/view/database/member/lists.phtml | 11 ++-- module/Report/src/Service/Misc.php | 1 - 11 files changed, 191 insertions(+), 35 deletions(-) diff --git a/module/Database/migrations/Version20250621133119.php b/module/Database/migrations/Version20250621133119.php index f1563c23c..e8615f058 100644 --- a/module/Database/migrations/Version20250621133119.php +++ b/module/Database/migrations/Version20250621133119.php @@ -20,7 +20,7 @@ public function getDescription(): string public function up(Schema $schema): void { - $this->addSql('CREATE TABLE MailingListMember (member INT NOT NULL, membershipId VARCHAR(255) DEFAULT NULL, lastSyncOn TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, lastSyncSuccess BOOLEAN NOT NULL, toBeDeleted BOOLEAN NOT NULL, mailingList VARCHAR(255) NOT NULL, PRIMARY KEY(mailingList, member))'); + $this->addSql('CREATE TABLE MailingListMember (member INT NOT NULL, email VARCHAR(255) NOT NULL, lastSyncOn TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, lastSyncSuccess BOOLEAN NOT NULL, toBeCreated BOOLEAN NOT NULL, toBeDeleted BOOLEAN NOT NULL, mailingList VARCHAR(255) NOT NULL, PRIMARY KEY(mailingList, member))'); $this->addSql('CREATE INDEX IDX_3A8467A97B1AC3ED ON MailingListMember (mailingList)'); $this->addSql('CREATE INDEX IDX_3A8467A970E4FA78 ON MailingListMember (member)'); $this->addSql('CREATE UNIQUE INDEX mailinglistmember_unique_idx ON MailingListMember (mailingList, member)'); diff --git a/module/Database/src/Form/MemberLists.php b/module/Database/src/Form/MemberLists.php index f16acd4a5..71af56501 100644 --- a/module/Database/src/Form/MemberLists.php +++ b/module/Database/src/Form/MemberLists.php @@ -29,7 +29,7 @@ public function __construct( $memberLists = []; foreach ($member->getMailingListMemberships() as $mailingListMember) { $memberLists[$mailingListMember->getMailingList()->getName()] = [ - 'synced' => null !== $mailingListMember->getLastSyncOn() && $mailingListMember->isLastSyncSuccess(), + 'toBeCreated' => $mailingListMember->isToBeCreated(), 'toBeDeleted' => $mailingListMember->isToBeDeleted(), ]; } @@ -39,34 +39,31 @@ public function __construct( $listName = $list->getName(); $selected = array_key_exists($listName, $memberLists); - $synced = $memberLists[$listName]['synced']; - $toBeDeleted = $memberLists[$listName]['toBeDeleted']; - $disabled = $selected && ($toBeDeleted || !$synced); $label = $listName; if ($selected) { + $toBeCreated = ! $memberLists[$listName]['toBeCreated']; + $toBeDeleted = $memberLists[$listName]['toBeDeleted']; + $disabled = $selected && ($toBeDeleted || $toBeCreated ); + $label .= ' ('; if ( - $synced - && $toBeDeleted + $toBeDeleted ) { $label .= $this->translator->translate('to be deleted'); } elseif ( - $synced + !$toBeCreated && !$toBeDeleted ) { $label .= $this->translator->translate('synced'); - } elseif ( - !$synced - && $toBeDeleted - ) { - $label .= $this->translator->translate('to be deleted'); } else { - $label .= $this->translator->translate('to be synced'); + $label .= $this->translator->translate('to be created'); } $label .= ')'; + } else { + $disabled = false; } $listOptions[] = [ diff --git a/module/Database/src/Mapper/MailingListMember.php b/module/Database/src/Mapper/MailingListMember.php index 9a0438a34..4707607c3 100644 --- a/module/Database/src/Mapper/MailingListMember.php +++ b/module/Database/src/Mapper/MailingListMember.php @@ -51,6 +51,40 @@ public function findByListAndMember( return $qb->getQuery()->getOneOrNullResult(); } + /** + * Get the pending number of creations + * Intentionally, does not do a findAll + */ + public function countPendingCreation(): int + { + $qb = $this->em->createQueryBuilder(); + + $qb->select($qb->expr()->count('mlm.member')) + ->from(MailingListMemberModel::class, 'mlm') + ->where('mlm.toBeCreated = True'); + + $query = $qb->getQuery(); + + return (int) $query->getSingleScalarResult(); + } + + /** + * Get the pending number of deletions + * Intentionally, does not do a findAll + */ + public function countPendingDeletion(): int + { + $qb = $this->em->createQueryBuilder(); + + $qb->select($qb->expr()->count('mlm.member')) + ->from(MailingListMemberModel::class, 'mlm') + ->where('mlm.toBeDeleted = True'); + + $query = $qb->getQuery(); + + return (int) $query->getSingleScalarResult(); + } + /** * @return MailingListMemberModel[] */ diff --git a/module/Database/src/Model/MailingListMember.php b/module/Database/src/Model/MailingListMember.php index 88cbf37cc..72a78aab2 100644 --- a/module/Database/src/Model/MailingListMember.php +++ b/module/Database/src/Model/MailingListMember.php @@ -61,11 +61,16 @@ class MailingListMember )] private Member $member; + /** + * In case of email address changes, we need to know the email address that is on the list + * + * For the old email address, we have an entry toBeDeleted=True, for the new address, we have a toBeCreated=True + */ #[Column( type: 'string', - nullable: true, + nullable: false, )] - private ?string $membershipId = null; + private string $email; /** * When this association was last synced to/from Mailman. @@ -85,6 +90,14 @@ class MailingListMember #[Column(type: 'boolean')] protected bool $lastSyncSuccess = false; + /** + * Whether this entry still needs to be created in Mailman. + * + * It indicates that a new registration on a mailing list should be performed + */ + #[Column(type: 'boolean')] + protected bool $toBeCreated = true; + /** * Whether this entry still needs to be removed from Mailman. * @@ -130,19 +143,19 @@ public function setMember(Member $member): void } /** - * Get the Mailman `member_id` for this subscription. + * Get the email address of this subscription */ - public function getMembershipId(): ?string + public function getEmail(): string { - return $this->membershipId; + return $this->email; } /** - * Set the Mailman `member_id` for this subscription. + * Set the email address of this subscription */ - public function setMembershipId(string $membershipId): void + public function setEmail(string $email): void { - $this->membershipId = $membershipId; + $this->email = $email; } /** @@ -177,6 +190,14 @@ public function setLastSyncSuccess(bool $lastSyncSuccess): void $this->lastSyncSuccess = $lastSyncSuccess; } + /** + * Get whether the entry must still be created in Mailman. + */ + public function isToBeCreated(): bool + { + return $this->toBeCreated; + } + /** * Get whether the entry must still be removed from Mailman. */ diff --git a/module/Database/src/Service/Factory/FrontPageFactory.php b/module/Database/src/Service/Factory/FrontPageFactory.php index 4a68d2a78..848d6efe0 100644 --- a/module/Database/src/Service/Factory/FrontPageFactory.php +++ b/module/Database/src/Service/Factory/FrontPageFactory.php @@ -6,6 +6,7 @@ use Database\Service\Api as ApiService; use Database\Service\FrontPage as FrontPageService; +use Database\Service\Mailman as MailmanService; use Database\Service\Member as MemberService; use Laminas\ServiceManager\Factory\FactoryInterface; use Psr\Container\ContainerInterface; @@ -21,10 +22,12 @@ public function __invoke( ?array $options = null, ): FrontPageService { $apiService = $container->get(ApiService::class); + $mailmanService = $container->get(MailmanService::class); $memberService = $container->get(MemberService::class); return new FrontPageService( $apiService, + $mailmanService, $memberService, ); } diff --git a/module/Database/src/Service/FrontPage.php b/module/Database/src/Service/FrontPage.php index e54ea9819..93cd1a512 100644 --- a/module/Database/src/Service/FrontPage.php +++ b/module/Database/src/Service/FrontPage.php @@ -5,6 +5,7 @@ namespace Database\Service; use Database\Service\Api as ApiService; +use Database\Service\Mailman as MailmanService; use Database\Service\Member as MemberService; use DateTime; @@ -14,6 +15,7 @@ class FrontPage { public function __construct( private readonly ApiService $apiService, + private readonly MailmanService $mailmanService, private readonly MemberService $memberService, ) { } @@ -31,6 +33,13 @@ public function __construct( * syncPaused: bool, * syncPausedUntil: ?DateTime, * totalCount: int, + * mailmanLastFetch: ?DateTime, + * mailmanLastFetchOverdue: bool, + * mailmanLastSync: DateTime, + * mailmanChangesPending: array{ + * creations: int, + * deletions: int, + * } * } */ public function getFrontpageData(): array @@ -38,16 +47,21 @@ public function getFrontpageData(): array return array_merge( $this->memberService->getFrontpageData(), $this->apiService->getFrontpageData(), + $this->mailmanService->getFrontpageData(), [ 'totalCount' => $this->getNotificationCount(), ], ); } + /** + * Get the total notification count to show in the navbar, not including 'info' messages + */ public function getNotificationCount(): int { return $this->memberService->getPendingUpdateCount() + (int) $this->apiService->isSyncPaused() + - $this->memberService->getPaidProspectivesCount(); + $this->memberService->getPaidProspectivesCount() + + (int) $this->mailmanService->isLastFetchOverdue(); } } diff --git a/module/Database/src/Service/Mailman.php b/module/Database/src/Service/Mailman.php index ad033d5b3..dec74839d 100644 --- a/module/Database/src/Service/Mailman.php +++ b/module/Database/src/Service/Mailman.php @@ -10,6 +10,7 @@ use Database\Mapper\MailmanMailingList as MailmanMailingListMapper; use Database\Model\MailingListMember as MailingListMemberModel; use Database\Model\MailmanMailingList as MailmanMailingListModel; +use DateInterval; use DateTime; use Laminas\Http\Client; use Laminas\Http\Client\Adapter\Curl; @@ -149,6 +150,30 @@ public function isMailmanHealthy(): bool return isset($data['api_version']) && $data['api_version'] === $this->mailmanConfig['version']; } + /** + * @return array{ + * mailmanLastFetch: ?DateTime, + * mailmanLastFetchOverdue: bool, + * mailmanLastSync: DateTime, + * mailmanChangesPending: array{ + * creations: int, + * deletions: int, + * }, + * } + */ + public function getFrontpageData(): array + { + return [ + 'mailmanLastFetch' => $this->getLastFetchTime(), + 'mailmanLastFetchOverdue' => $this->isLastFetchOverdue(), + 'mailmanLastSync' => new DateTime(), //TODO + 'mailmanChangesPending' => [ + 'creations' => $this->mailingListMemberMapper->countPendingCreation(), + 'deletions' => $this->mailingListMemberMapper->countPendingDeletion(), + ], + ]; + } + /** * @return arraymailmanMailingListMapper->getLastFetchTime(); } + public function isLastFetchOverdue(): bool + { + return $this->getLastFetchTime()->add(new DateInterval('PT1H5M')) < new DateTime(); + } + /** * Subscribe a member to a mailing list. * @@ -260,7 +290,6 @@ private function subscribeMemberToMailingList(MailingListMemberModel $mailingLis // Check if the request was successful if (isset($response['member_id'])) { $mailingListMember->setLastSyncSuccess(true); - $mailingListMember->setMembershipId($response['member_id']); } else { $mailingListMember->setLastSyncSuccess(false); } diff --git a/module/Database/src/Service/Member.php b/module/Database/src/Service/Member.php index fb6a001fb..1d0d10847 100644 --- a/module/Database/src/Service/Member.php +++ b/module/Database/src/Service/Member.php @@ -912,7 +912,7 @@ public function removeAddress( } /** - * Subscribe member to mailing lists. + * Update mailing list subscriptions of a member * * @phpcsSuppress SlevomatCodingStandard.TypeHints.ParameterTypeHint.MissingTraversableTypeHintSpecification */ @@ -950,7 +950,8 @@ static function (MailingListMemberModel $subscription) { $toRemove = array_diff($currentLists, $selectedLists); $toAdd = array_diff($selectedLists, $intersection); - // Unsubscribe member for some mailing lists. + // If a member unsubscribes, we set the to be deleted status of that entry + // This will later be processed and then this entry will be deleted foreach ($toRemove as $list) { $list = $this->mailingListMapper->find($list); @@ -962,6 +963,7 @@ static function (MailingListMemberModel $subscription) { $membership->setToBeDeleted(true); } + // Mailing lists to add foreach ($toAdd as $list) { $list = $this->mailingListMapper->find($list); @@ -972,6 +974,7 @@ static function (MailingListMemberModel $subscription) { $mailingListMember = new MailingListMemberModel(); $mailingListMember->setMailingList($list); $mailingListMember->setMember($member); + $mailingListMember->setEmail($member->getEmail()); // Force cascade by adding to member. $member->addList($mailingListMember); } diff --git a/module/Database/view/database/index/index.phtml b/module/Database/view/database/index/index.phtml index 6c6be824c..27771fce4 100644 --- a/module/Database/view/database/index/index.phtml +++ b/module/Database/view/database/index/index.phtml @@ -14,6 +14,13 @@ use Laminas\View\Renderer\PhpRenderer; * @var int $updates * @var bool $syncPaused * @var ?DateTime $syncPausedUntil + * @var DateTime $mailmanLastFetch + * @var bool $mailmanLastFetchOverdue + * @var DateTime $mailmanLastSync + * @var array{ + * creations: int, + * deletions: int, + * } $mailmanChangesPending * @var int $totalCount */ @@ -99,8 +106,8 @@ use Laminas\View\Renderer\PhpRenderer;
    translate('Prospective Members') ?>

    - translate( '%s prospective member(s) have paid and are pending approval.', ), @@ -111,6 +118,56 @@ use Laminas\View\Renderer\PhpRenderer;

    + +
    +
    translate('Mailman lists not being fetched') ?>
    +
    +

    + translate('Last fetch of available mailman lists was at %s.') . + ' ' . + $this->translate( + 'Possibly, the mailing list server is down or fetch mechanism is malfunctioning.', + ), + $mailmanLastFetch->format(DateTimeInterface::ATOM), + ); + ?> +

    +
    +
    + + translate('%d pending mailing list subscriptions'), + $mailmanChangesPending['creations'], + ); + } + + if (0 < $mailmanChangesPending['deletions']) { + $messages[] = sprintf( + $this->translate('%d pending mailing list unsubscriptions'), + $mailmanChangesPending['deletions'], + ); + } + ?> +
    +
    translate('Mailman sync') ?>
    +
    +

    + translate('There are') . ' ' . + implode( + ' ' . $this->translate('and') . ' ', + $messages, + ) . '.' + ?> +

    +
    +
    +
    diff --git a/module/Database/view/database/member/lists.phtml b/module/Database/view/database/member/lists.phtml index aa35cebb2..4a6e592ec 100644 --- a/module/Database/view/database/member/lists.phtml +++ b/module/Database/view/database/member/lists.phtml @@ -38,23 +38,22 @@ $form->setAttribute('class', 'form-horizontal'); ?> form()->openTag($form) ?> -
    get('list-' . $list->getName()); -$element->setAttribute('placeholder', $element->getLabel()); +$element = $form->get('lists'); ?> +getValueOptions() as $key => $value): ?>
    formElementErrors($element) ?>
    -
    +
    diff --git a/module/Report/src/Service/Misc.php b/module/Report/src/Service/Misc.php index 8b3744b52..18a913b6d 100644 --- a/module/Report/src/Service/Misc.php +++ b/module/Report/src/Service/Misc.php @@ -88,7 +88,6 @@ public function generateListMembership(DatabaseMailingListMemberModel $mailingLi $reportListMembership->setLastSyncOn($mailingListMember->getLastSyncOn()); $reportListMembership->setLastSyncSuccess($mailingListMember->isLastSyncSuccess()); $reportListMembership->setToBeDeleted($mailingListMember->isToBeDeleted()); - $reportListMembership->setMembershipId($mailingListMember->getMembershipId()); $this->emReport->persist($reportListMembership); } From 3712af9b32a6c82928d3a2eb5259eac81b0df432 Mon Sep 17 00:00:00 2001 From: Rink Date: Sat, 28 Jun 2025 13:51:08 +0000 Subject: [PATCH 07/11] feat(mailman): Mailing list in ReportDB Implements mailing lists in reportdb, updates frontend form --- .../migrations/Version20250621133119.php | 2 +- .../src/Controller/MemberController.php | 31 +++--- module/Database/src/Form/MemberLists.php | 15 +-- .../Database/src/Mapper/MailingListMember.php | 20 ++++ .../src/Mapper/MailmanMailingList.php | 4 +- module/Database/src/Model/MailingList.php | 2 +- .../Database/src/Model/MailingListMember.php | 9 ++ module/Database/src/Service/Mailman.php | 7 +- module/Database/src/Service/Member.php | 14 +-- .../Database/view/database/member/lists.phtml | 7 +- .../Database/view/database/member/show.phtml | 2 +- .../migrations/Version20250621133119.php | 2 +- .../src/Listener/DatabaseDeletionListener.php | 14 +++ .../src/Listener/DatabaseUpdateListener.php | 7 +- .../DatabaseDeletionListenerFactory.php | 4 + module/Report/src/Model/MailingList.php | 6 +- module/Report/src/Model/MailingListMember.php | 97 +++---------------- module/Report/src/Service/Misc.php | 50 +++++++++- public/css/style.css | 4 + 19 files changed, 163 insertions(+), 134 deletions(-) diff --git a/module/Database/migrations/Version20250621133119.php b/module/Database/migrations/Version20250621133119.php index e8615f058..5c0d67611 100644 --- a/module/Database/migrations/Version20250621133119.php +++ b/module/Database/migrations/Version20250621133119.php @@ -20,7 +20,7 @@ public function getDescription(): string public function up(Schema $schema): void { - $this->addSql('CREATE TABLE MailingListMember (member INT NOT NULL, email VARCHAR(255) NOT NULL, lastSyncOn TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, lastSyncSuccess BOOLEAN NOT NULL, toBeCreated BOOLEAN NOT NULL, toBeDeleted BOOLEAN NOT NULL, mailingList VARCHAR(255) NOT NULL, PRIMARY KEY(mailingList, member))'); + $this->addSql('CREATE TABLE MailingListMember (email VARCHAR(255) NOT NULL, member INT NOT NULL, lastSyncOn TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, lastSyncSuccess BOOLEAN NOT NULL, toBeCreated BOOLEAN NOT NULL, toBeDeleted BOOLEAN NOT NULL, mailingList VARCHAR(255) NOT NULL, PRIMARY KEY(mailingList, member, email))'); $this->addSql('CREATE INDEX IDX_3A8467A97B1AC3ED ON MailingListMember (mailingList)'); $this->addSql('CREATE INDEX IDX_3A8467A970E4FA78 ON MailingListMember (member)'); $this->addSql('CREATE UNIQUE INDEX mailinglistmember_unique_idx ON MailingListMember (mailingList, member)'); diff --git a/module/Database/src/Controller/MemberController.php b/module/Database/src/Controller/MemberController.php index 1e08f0028..9cf3a01f2 100644 --- a/module/Database/src/Controller/MemberController.php +++ b/module/Database/src/Controller/MemberController.php @@ -437,21 +437,28 @@ public function listsAction(): HttpResponse|ViewModel return $viewModel; } + $formData = $this->memberService->getListForm($member); + if ($this->getRequest()->isPost()) { - $member = $this->memberService->subscribeLists( - $member, - $this->getRequest()->getPost()->toArray(), - ); + $formData['form']->setData($this->getRequest()->getPost()->toArray()); - if (null !== $member) { - $this->flashMessenger()->addSuccessMessage( - sprintf( - $this->translator->translate('Change(s) of %s have been saved!'), - $this->translator->translate('mailing list subscriptions'), - ), + // Validate form. + if ($formData['form']->isValid()) { + $updatedMember = $this->memberService->subscribeLists( + $member, + $formData['form'], ); - return $this->redirect()->toRoute('member/show', ['id' => $member->getLidnr()]); + if (null !== $updatedMember) { + $this->flashMessenger()->addSuccessMessage( + sprintf( + $this->translator->translate('Change(s) of %s have been saved!'), + $this->translator->translate('mailing list subscriptions'), + ), + ); + + return $this->redirect()->toRoute('member/show', ['id' => $updatedMember->getLidnr()]); + } } $this->flashMessenger()->addErrorMessage( @@ -462,7 +469,7 @@ public function listsAction(): HttpResponse|ViewModel ); } - return new ViewModel($this->memberService->getListForm($member)); + return new ViewModel($formData); } /** diff --git a/module/Database/src/Form/MemberLists.php b/module/Database/src/Form/MemberLists.php index 71af56501..77340b860 100644 --- a/module/Database/src/Form/MemberLists.php +++ b/module/Database/src/Form/MemberLists.php @@ -42,23 +42,18 @@ public function __construct( $label = $listName; if ($selected) { - $toBeCreated = ! $memberLists[$listName]['toBeCreated']; + $toBeCreated = $memberLists[$listName]['toBeCreated']; $toBeDeleted = $memberLists[$listName]['toBeDeleted']; $disabled = $selected && ($toBeDeleted || $toBeCreated ); $label .= ' ('; - if ( - $toBeDeleted - ) { + if ($toBeDeleted) { $label .= $this->translator->translate('to be deleted'); - } elseif ( - !$toBeCreated - && !$toBeDeleted - ) { - $label .= $this->translator->translate('synced'); - } else { + } elseif ($toBeCreated) { $label .= $this->translator->translate('to be created'); + } else { + $label .= $this->translator->translate('synced'); } $label .= ')'; diff --git a/module/Database/src/Mapper/MailingListMember.php b/module/Database/src/Mapper/MailingListMember.php index 4707607c3..8096e21bd 100644 --- a/module/Database/src/Mapper/MailingListMember.php +++ b/module/Database/src/Mapper/MailingListMember.php @@ -85,6 +85,26 @@ public function countPendingDeletion(): int return (int) $query->getSingleScalarResult(); } + /** + * Get the mailing list members that should exist after the next sync + * Value of toBeCreated does not matter, toBeDeleted should be excluded + * + * @return MailingListMemberModel[] + */ + public function findAfterSync(): array + { + $qb = $this->em->createQueryBuilder(); + + $qb->select('mlm') + ->from(MailingListMemberModel::class, 'mlm') + ->where('mlm.toBeDeleted != True'); + + /** @var MailingListMemberModel[] $result */ + $result = $qb->getQuery()->getResult(); + + return $result; + } + /** * @return MailingListMemberModel[] */ diff --git a/module/Database/src/Mapper/MailmanMailingList.php b/module/Database/src/Mapper/MailmanMailingList.php index 881ab66ca..fa40921ee 100644 --- a/module/Database/src/Mapper/MailmanMailingList.php +++ b/module/Database/src/Mapper/MailmanMailingList.php @@ -48,7 +48,7 @@ public function getLastFetchTime(): ?DateTime } /** - * Find active mailing lists (i.e. seen in the last 1 day). + * Find active mailing lists (i.e. seen in the last fetch or the hour before) * * @return array */ @@ -62,7 +62,7 @@ public function findActive(): array ->from(MailmanMailingListModel::class, 'l') ->where('l.lastSeen >= :lastSeen'); - $qb->setParameter('lastSeen', $lastFetch?->sub(new DateInterval('P1D'))); + $qb->setParameter('lastSeen', $lastFetch?->sub(new DateInterval('PT1H5M'))); return $qb->getQuery()->getResult(); } diff --git a/module/Database/src/Model/MailingList.php b/module/Database/src/Model/MailingList.php index fb50a7eb0..d44306a5f 100644 --- a/module/Database/src/Model/MailingList.php +++ b/module/Database/src/Model/MailingList.php @@ -14,7 +14,7 @@ use Doctrine\ORM\Mapping\OneToOne; /** - * Mailing List model for liss on the db side. + * Mailing List model for lists on the db side. */ #[Entity] class MailingList diff --git a/module/Database/src/Model/MailingListMember.php b/module/Database/src/Model/MailingListMember.php index 72a78aab2..5c72d6c40 100644 --- a/module/Database/src/Model/MailingListMember.php +++ b/module/Database/src/Model/MailingListMember.php @@ -66,6 +66,7 @@ class MailingListMember * * For the old email address, we have an entry toBeDeleted=True, for the new address, we have a toBeCreated=True */ + #[Id] #[Column( type: 'string', nullable: false, @@ -198,6 +199,14 @@ public function isToBeCreated(): bool return $this->toBeCreated; } + /** + * Set whether the entry must still be created in Mailman. + */ + public function setToBeCreated(bool $toBeCreated): void + { + $this->toBeCreated = $toBeCreated; + } + /** * Get whether the entry must still be removed from Mailman. */ diff --git a/module/Database/src/Service/Mailman.php b/module/Database/src/Service/Mailman.php index dec74839d..a67c9d24e 100644 --- a/module/Database/src/Service/Mailman.php +++ b/module/Database/src/Service/Mailman.php @@ -251,7 +251,12 @@ public function getLastFetchTime(): ?DateTime public function isLastFetchOverdue(): bool { - return $this->getLastFetchTime()->add(new DateInterval('PT1H5M')) < new DateTime(); + $lastFetch = $this->getLastFetchTime(); + if (null === $lastFetch) { + return true; + } + + return $lastFetch->add(new DateInterval('PT1H5M')) < new DateTime(); } /** diff --git a/module/Database/src/Service/Member.php b/module/Database/src/Service/Member.php index 1d0d10847..1a0c47d5e 100644 --- a/module/Database/src/Service/Member.php +++ b/module/Database/src/Service/Member.php @@ -913,28 +913,16 @@ public function removeAddress( /** * Update mailing list subscriptions of a member - * - * @phpcsSuppress SlevomatCodingStandard.TypeHints.ParameterTypeHint.MissingTraversableTypeHintSpecification */ public function subscribeLists( MemberModel $member, - array $data, + MemberListsForm $form, ): ?MemberModel { // Check if we are performing a sync or not. if ($this->mailmanService->isSyncLocked()) { return null; } - $formData = $this->getListForm($member); - $form = $formData['form']; - - $form->setData($data); - - // Validate form. - if (!$form->isValid()) { - return null; - } - $data = $form->getData(); /** @var string[] $selectedLists */ diff --git a/module/Database/view/database/member/lists.phtml b/module/Database/view/database/member/lists.phtml index 4a6e592ec..9ea617695 100644 --- a/module/Database/view/database/member/lists.phtml +++ b/module/Database/view/database/member/lists.phtml @@ -38,17 +38,20 @@ $form->setAttribute('class', 'form-horizontal'); ?> form()->openTag($form) ?> -
    +
    get('lists'); + +// formMultiCheckbox($element) outputs all options on a single line ?> getValueOptions() as $key => $value): ?>
    + getName() . '[]" value="' . $value['value'] . '" />' : '' ?> formElementErrors($element) ?>
    diff --git a/module/Database/view/database/member/show.phtml b/module/Database/view/database/member/show.phtml index 46ac797d8..e6af4901f 100644 --- a/module/Database/view/database/member/show.phtml +++ b/module/Database/view/database/member/show.phtml @@ -206,7 +206,7 @@ use Laminas\View\Renderer\PhpRenderer;

    translate('Mailing List Subscriptions') ?>

      getMailingListMemberships() as $list): ?> -
    • getName() ?>
    • +
    • getMailingList()->getName() ?>
    Date: Sat, 28 Jun 2025 20:45:11 +0000 Subject: [PATCH 08/11] feat(mailman): Introduce basic (un)subscribe sync --- Makefile | 2 +- docker/web/development/crontab | 3 +- docker/web/production/crontab | 3 +- module/Database/config/module.config.php | 6 +- ...hp => MailmanFetchListsCommandFactory.php} | 8 +- .../MailmanSyncMembershipCommandFactory.php | 27 +++ ...mmand.php => MailmanFetchListsCommand.php} | 4 +- .../Command/MailmanSyncMembershipCommand.php | 62 ++++++ module/Database/src/Form/MemberLists.php | 7 +- module/Database/src/Module.php | 9 +- module/Database/src/Service/Mailman.php | 181 ++++++++++++++++-- module/Database/src/Service/Member.php | 2 +- .../Database/view/database/index/index.phtml | 4 +- phpcs.xml.dist | 3 +- 14 files changed, 285 insertions(+), 36 deletions(-) rename module/Database/src/Command/Factory/{FetchMailmanListsCommandFactory.php => MailmanFetchListsCommandFactory.php} (71%) create mode 100644 module/Database/src/Command/Factory/MailmanSyncMembershipCommandFactory.php rename module/Database/src/Command/{FetchMailmanListsCommand.php => MailmanFetchListsCommand.php} (89%) create mode 100644 module/Database/src/Command/MailmanSyncMembershipCommand.php diff --git a/Makefile b/Makefile index 941fa2280..fe9b043a7 100644 --- a/Makefile +++ b/Makefile @@ -67,7 +67,7 @@ seed: replenish @make preparemailman @docker compose exec mailman-web bash -c '(python3 ./manage.py createsuperuser --no-input 2>/dev/null); pkill -HUP uwsgi' @docker compose exec -u mailman mailman-core bash -c '(mailman create news@$$MAILMAN_DOMAIN; mailman create other@$$MAILMAN_DOMAIN; true) 2>/dev/null' - @docker compose exec web ./web database:mailinglist:fetch + @docker compose exec web ./web database:mailman:fetch exec: diff --git a/docker/web/development/crontab b/docker/web/development/crontab index 9abd823f2..462868abe 100644 --- a/docker/web/development/crontab +++ b/docker/web/development/crontab @@ -14,4 +14,5 @@ 0 0 1 7 * { . /code/config/bash.env && /usr/local/bin/php /code/web check:membership:type; } > /code/data/logs/cron-check-type.log 2>&1 */30 * * * * { . /code/config/bash.env && /usr/local/bin/php /code/web check:membership:renewal:graduate; } > /code/data/logs/cron-check-renewal.log 2>&1 0 2 * * * { . /code/config/bash.env && /usr/local/bin/php /code/web database:prospective-members:delete-expired; } > /code/data/logs/cron-delete-prospective.log 2>&1 -55 * * * * { . /code/config/bash.env && /usr/local/bin/php /code/web database:mailinglist:fetch; } > /code/data/logs/cron-mailinglist-fetch.log 2>&1 +55 * * * * { . /code/config/bash.env && /usr/local/bin/php /code/web database:mailman:fetch; } > /code/data/logs/cron-mailman-fetch.log 2>&1 +5 * * * * { . /code/config/bash.env && /usr/local/bin/php /code/web database:mailman:syncmembership -f -vv; } > /code/data/logs/cron-mailman-syncmembership.log 2>&1 diff --git a/docker/web/production/crontab b/docker/web/production/crontab index 9abd823f2..462868abe 100644 --- a/docker/web/production/crontab +++ b/docker/web/production/crontab @@ -14,4 +14,5 @@ 0 0 1 7 * { . /code/config/bash.env && /usr/local/bin/php /code/web check:membership:type; } > /code/data/logs/cron-check-type.log 2>&1 */30 * * * * { . /code/config/bash.env && /usr/local/bin/php /code/web check:membership:renewal:graduate; } > /code/data/logs/cron-check-renewal.log 2>&1 0 2 * * * { . /code/config/bash.env && /usr/local/bin/php /code/web database:prospective-members:delete-expired; } > /code/data/logs/cron-delete-prospective.log 2>&1 -55 * * * * { . /code/config/bash.env && /usr/local/bin/php /code/web database:mailinglist:fetch; } > /code/data/logs/cron-mailinglist-fetch.log 2>&1 +55 * * * * { . /code/config/bash.env && /usr/local/bin/php /code/web database:mailman:fetch; } > /code/data/logs/cron-mailman-fetch.log 2>&1 +5 * * * * { . /code/config/bash.env && /usr/local/bin/php /code/web database:mailman:syncmembership -f -vv; } > /code/data/logs/cron-mailman-syncmembership.log 2>&1 diff --git a/module/Database/config/module.config.php b/module/Database/config/module.config.php index 6a72b5121..4a9ff8511 100644 --- a/module/Database/config/module.config.php +++ b/module/Database/config/module.config.php @@ -6,8 +6,9 @@ use Database\Command\DeleteExpiredMembersCommand; use Database\Command\DeleteExpiredProspectiveMembersCommand; -use Database\Command\FetchMailmanListsCommand; use Database\Command\GenerateAuthenticationKeysCommand; +use Database\Command\MailmanFetchListsCommand; +use Database\Command\MailmanSyncMembershipCommand; use Database\Controller\ApiController; use Database\Controller\ExportController; use Database\Controller\Factory\ApiControllerFactory; @@ -777,7 +778,8 @@ ], 'laminas-cli' => [ 'commands' => [ - 'database:mailinglist:fetch' => FetchMailmanListsCommand::class, + 'database:mailman:fetch' => MailmanFetchListsCommand::class, + 'database:mailman:syncmembership' => MailmanSyncMembershipCommand::class, 'database:members:delete-expired' => DeleteExpiredMembersCommand::class, 'database:members:generate-keys' => GenerateAuthenticationKeysCommand::class, 'database:prospective-members:delete-expired' => DeleteExpiredProspectiveMembersCommand::class, diff --git a/module/Database/src/Command/Factory/FetchMailmanListsCommandFactory.php b/module/Database/src/Command/Factory/MailmanFetchListsCommandFactory.php similarity index 71% rename from module/Database/src/Command/Factory/FetchMailmanListsCommandFactory.php rename to module/Database/src/Command/Factory/MailmanFetchListsCommandFactory.php index c872cd809..3ef52bda4 100644 --- a/module/Database/src/Command/Factory/FetchMailmanListsCommandFactory.php +++ b/module/Database/src/Command/Factory/MailmanFetchListsCommandFactory.php @@ -4,12 +4,12 @@ namespace Database\Command\Factory; -use Database\Command\FetchMailmanListsCommand; +use Database\Command\MailmanFetchListsCommand; use Database\Service\Mailman as MailmanService; use Laminas\ServiceManager\Factory\FactoryInterface; use Psr\Container\ContainerInterface; -class FetchMailmanListsCommandFactory implements FactoryInterface +class MailmanFetchListsCommandFactory implements FactoryInterface { /** * @param string $requestedName @@ -18,10 +18,10 @@ public function __invoke( ContainerInterface $container, $requestedName, ?array $options = null, - ): FetchMailmanListsCommand { + ): MailmanFetchListsCommand { /** @var MailmanService $mailmanService */ $mailmanService = $container->get(MailmanService::class); - return new FetchMailmanListsCommand($mailmanService); + return new MailmanFetchListsCommand($mailmanService); } } diff --git a/module/Database/src/Command/Factory/MailmanSyncMembershipCommandFactory.php b/module/Database/src/Command/Factory/MailmanSyncMembershipCommandFactory.php new file mode 100644 index 000000000..e2ddb8457 --- /dev/null +++ b/module/Database/src/Command/Factory/MailmanSyncMembershipCommandFactory.php @@ -0,0 +1,27 @@ +get(MailmanService::class); + + return new MailmanSyncMembershipCommand($mailmanService); + } +} diff --git a/module/Database/src/Command/FetchMailmanListsCommand.php b/module/Database/src/Command/MailmanFetchListsCommand.php similarity index 89% rename from module/Database/src/Command/FetchMailmanListsCommand.php rename to module/Database/src/Command/MailmanFetchListsCommand.php index cd8605e00..d60e7a9e0 100644 --- a/module/Database/src/Command/FetchMailmanListsCommand.php +++ b/module/Database/src/Command/MailmanFetchListsCommand.php @@ -11,10 +11,10 @@ use Symfony\Component\Console\Output\OutputInterface; #[AsCommand( - name: 'database:mailinglist:fetch', + name: 'database:mailman:fetch', description: 'Fetch mailing lists from mailman and store store references in GEWISDB.', )] -class FetchMailmanListsCommand extends Command +class MailmanFetchListsCommand extends Command { public function __construct(private readonly MailmanService $mailmanService) { diff --git a/module/Database/src/Command/MailmanSyncMembershipCommand.php b/module/Database/src/Command/MailmanSyncMembershipCommand.php new file mode 100644 index 000000000..b3fb16cd9 --- /dev/null +++ b/module/Database/src/Command/MailmanSyncMembershipCommand.php @@ -0,0 +1,62 @@ +addParam( + (new BoolParam(self::PARAM_FORCE)) + ->setDescription('Perform actions in mailman') + ->setShortcut('f') + ->setDefault(true), + ); + } + + /** + * @param ParamAwareInputInterface $input + */ + protected function execute( + InputInterface $input, + OutputInterface $output, + ): int { + $dryRun = !$input->getParam(self::PARAM_FORCE); + + if ($dryRun) { + $output->writeln('NOTE: Not using -f, assuming dry-run.'); + $output->setVerbosity(OutputInterface::VERBOSITY_DEBUG); + $output->writeln( + 'Implying -vvv, displaying all pending changes', + OutputInterface::VERBOSITY_VERBOSE, + ); + } + + $output->writeln('Syncing mailman mailing list membership:'); + $this->mailmanService->syncMembership($output, $dryRun); + + return Command::SUCCESS; + } +} diff --git a/module/Database/src/Form/MemberLists.php b/module/Database/src/Form/MemberLists.php index 77340b860..1f7667aeb 100644 --- a/module/Database/src/Form/MemberLists.php +++ b/module/Database/src/Form/MemberLists.php @@ -89,9 +89,14 @@ public function __construct( /** * Specification of input filter. + * Should use Explode validator by default, so we only need to change required state */ public function getInputFilterSpecification(): array { - return []; + return [ + 'lists' => [ + 'required' => false, + ], + ]; } } diff --git a/module/Database/src/Module.php b/module/Database/src/Module.php index 2c15ef4af..f4bba7f9d 100644 --- a/module/Database/src/Module.php +++ b/module/Database/src/Module.php @@ -8,10 +8,12 @@ use Database\Command\DeleteExpiredProspectiveMembersCommand; use Database\Command\Factory\DeleteExpiredMembersCommandFactory; use Database\Command\Factory\DeleteExpiredProspectiveMembersCommandFactory; -use Database\Command\Factory\FetchMailmanListsCommandFactory; use Database\Command\Factory\GenerateAuthenticationKeysCommandFactory; -use Database\Command\FetchMailmanListsCommand; +use Database\Command\Factory\MailmanFetchListsCommandFactory; +use Database\Command\Factory\MailmanSyncMembershipCommandFactory; use Database\Command\GenerateAuthenticationKeysCommand; +use Database\Command\MailmanFetchListsCommand; +use Database\Command\MailmanSyncMembershipCommand; use Database\Form\Abolish as AbolishForm; use Database\Form\Address as AddressForm; use Database\Form\Annulment as AnnulmentForm; @@ -165,7 +167,8 @@ public function getServiceConfig(): array 'factories' => [ DeleteExpiredMembersCommand::class => DeleteExpiredMembersCommandFactory::class, DeleteExpiredProspectiveMembersCommand::class => DeleteExpiredProspectiveMembersCommandFactory::class, - FetchMailmanListsCommand::class => FetchMailmanListsCommandFactory::class, + MailmanFetchListsCommand::class => MailmanFetchListsCommandFactory::class, + MailmanSyncMembershipCommand::class => MailmanSyncMembershipCommandFactory::class, GenerateAuthenticationKeysCommand::class => GenerateAuthenticationKeysCommandFactory::class, ApiService::class => ApiServiceFactory::class, FrontPageService::class => FrontPageServiceFactory::class, diff --git a/module/Database/src/Service/Mailman.php b/module/Database/src/Service/Mailman.php index a67c9d24e..e55c3a8a7 100644 --- a/module/Database/src/Service/Mailman.php +++ b/module/Database/src/Service/Mailman.php @@ -16,14 +16,33 @@ use Laminas\Http\Client\Adapter\Curl; use Laminas\Http\Request; use RuntimeException; +use Symfony\Component\Console\Output\NullOutput; +use Symfony\Component\Console\Output\OutputInterface; use function array_map; use function json_decode; use function json_last_error_msg; use function json_validate; +use function rawurlencode; +use function sprintf; class Mailman { + private const MM_ROLE_NONMEMBER = 'nonmember'; + private const MM_ROLE_MEMBER = 'member'; + private const MM_ROLE_MODERATOR = 'moderator'; + private const MM_ROLE_OWNER = 'owner'; + + private const MM_DELIVERYMODE_REGULAR = 'regular'; + private const MM_DELIVERYMODE_DIGESTS_MIME = 'mime_digests'; + private const MM_DELIVERYMODE_DIGESTS_PLAIN = 'plaintext_digests'; + private const MM_DELLIVERYMODE_DIGESTS_SUMMARY = 'summary_digests'; + + private const MM_DELIVERYSTATUS_ENABLED = 'enabled'; + private const MM_DELIVERYSTATUS_DISABLED_BY_USER = 'by_user'; + private const MM_DELIVERYSTATUS_DISABLED_BY_BOUNCES = 'by_bounces'; + private const MM_DELIVERYSTATUS_DISABLED_BY_MODERATOR = 'by_moderator'; + /** * @phpcsSuppress SlevomatCodingStandard.TypeHints.ParameterTypeHint.MissingTraversableTypeHintSpecification */ @@ -46,12 +65,11 @@ private function performMailmanRequest( ?array $data = null, ): array { $client = new Client(); - $request = new Request(); - $request->setMethod($method) - ->setUri($this->mailmanConfig['endpoint'] . $uri); $client->setAdapter(Curl::class) - ->setAuth($this->mailmanConfig['username'], $this->mailmanConfig['password']); + ->setAuth($this->mailmanConfig['username'], $this->mailmanConfig['password']) + ->setMethod($method) + ->setUri($this->mailmanConfig['endpoint'] . $uri); // Data encoding is automatically set to `application/x-www-form-urlencoded` for "POST"-like requests. switch ($method) { @@ -65,12 +83,15 @@ private function performMailmanRequest( case Request::METHOD_POST: case Request::METHOD_DELETE: case Request::METHOD_PATCH: - $client->setParameterPost($data); + if (null !== $data) { + $client->setParameterPost($data); + } + break; } try { - $response = $client->send($request); + $response = $client->send(); } catch (RuntimeException $e) { throw new RuntimeException('Failed to send request: ' . $e->getMessage()); } @@ -102,7 +123,7 @@ private function performMailmanRequest( * the mail list administration. This will prevent (if properly implemented and used) the secretary from modifying * any mailing list memberships. */ - public function acquireSyncLock(int $retries = 3): void + private function acquireSyncLock(int $retries = 3): void { if (0 === $retries) { throw new RuntimeException('Unable to acquire sync lock for Mailman sync: timeout.'); @@ -126,7 +147,7 @@ public function acquireSyncLock(int $retries = 3): void * * Releases the sync lock after the sync between GEWISDB and Mailman happened. */ - public function releaseSyncLock(): void + private function releaseSyncLock(): void { $this->configService->setConfig(ConfigNamespaces::DatabaseMailman, 'locked', false); } @@ -139,6 +160,66 @@ public function isSyncLocked(): bool return $this->configService->getConfig(ConfigNamespaces::DatabaseMailman, 'locked', false); } + /** + * This functions syncs the mailing list membership of all mailing lists + */ + public function syncMembership( + OutputInterface $output = new NullOutput(), + bool $dryRun = false, + ): void { + $lists = $this->mailmanMailingListMapper->findActive(); + + foreach ($lists as $list) { + if (null === $list->getMailingList()) { + continue; + } + + $this->syncMembershipSingle($list, $output, $dryRun); + } + } + + /** + * This function syncs the membership of a mailing list + */ + private function syncMembershipSingle( + MailmanMailingListModel $mmList, + OutputInterface $output, + bool $dryRun, + ): void { + $dbList = $mmList->getMailingList(); + $dbMemberships = $dbList->getMailingListMemberships(); + + $output->writeln( + sprintf( + '-> Syncing membership changes for %s (%s)', + $dbList->getName(), + $mmList->getMailmanId(), + ), + OutputInterface::VERBOSITY_VERBOSE, + ); + + // Phase 1: Sync all pending changes from DB side + foreach ($dbMemberships as $mailingListMember) { + if ($mailingListMember->isToBeDeleted()) { + $this->unsubscribeMemberFromMailingList( + mailingListMember: $mailingListMember, + output: $output, + dryRun: $dryRun, + ); + } elseif ($mailingListMember->isToBeCreated()) { + $this->subscribeMemberToMailingList( + mailingListMember: $mailingListMember, + output: $output, + dryRun: $dryRun, + sendWelcomeEmail: true, + ); + } + } + + // Phase 2: Voor alle to be created, perform creation + // TODO + } + public function isMailmanHealthy(): bool { try { @@ -265,8 +346,12 @@ public function isLastFetchOverdue(): bool * Unfortunately, this must be done one at the time as there is no mass-subscribe functionality in the API. See * https://gitlab.com/mailman/mailman/-/issues/419 for the open issue. */ - private function subscribeMemberToMailingList(MailingListMemberModel $mailingListMember): void - { + private function subscribeMemberToMailingList( + MailingListMemberModel $mailingListMember, + OutputInterface $output, + bool $dryRun, + bool $sendWelcomeEmail, + ): void { $member = $mailingListMember->getMember(); $listId = $mailingListMember->getMailingList()->getMailmanList()->getMailmanId(); @@ -275,15 +360,29 @@ private function subscribeMemberToMailingList(MailingListMemberModel $mailingLis 'list_id' => $listId, 'subscriber' => $member->getEmail(), 'display_name' => $member->getFullName(), - 'role' => 'member', + 'role' => self::MM_ROLE_MEMBER, 'pre_verified' => true, 'pre_confirmed' => true, 'pre_approved' => true, - 'send_welcome_message' => false, - 'delivery_mode' => 'regular', - 'delivery_status' => 'enabled', + 'send_welcome_message' => $sendWelcomeEmail, + 'delivery_mode' => self::MM_DELIVERYMODE_REGULAR, + 'delivery_status' => self::MM_DELIVERYSTATUS_ENABLED, ]; + $output->writeln( + sprintf( + '--> Subscribing %s to %s (send_welcome_message=%s)', + $data['subscriber'], + $data['list_id'], + (int) $data['send_welcome_message'], + ), + OutputInterface::VERBOSITY_VERY_VERBOSE, + ); + + if ($dryRun) { + return; + } + // Send the request to the Mailman API $mailingListMember->setLastSyncOn(new DateTime()); $response = $this->performMailmanRequest( @@ -293,8 +392,10 @@ private function subscribeMemberToMailingList(MailingListMemberModel $mailingLis ); // Check if the request was successful - if (isset($response['member_id'])) { + // Status code 201 + empty array means success + if ([] === $response) { $mailingListMember->setLastSyncSuccess(true); + $mailingListMember->setToBeCreated(false); } else { $mailingListMember->setLastSyncSuccess(false); } @@ -302,8 +403,54 @@ private function subscribeMemberToMailingList(MailingListMemberModel $mailingLis $this->mailingListMemberMapper->persist($mailingListMember); } - public function unsubscribeMemberFromMailingList(): void - { + private function unsubscribeMemberFromMailingList( + MailingListMemberModel $mailingListMember, + OutputInterface $output, + bool $dryRun, + ): void { + $member = $mailingListMember->getMember(); + $listId = $mailingListMember->getMailingList()->getMailmanList()->getMailmanId(); + + $data = [ + 'list_id' => $listId, + 'subscriber' => $member->getEmail(), + 'role' => self::MM_ROLE_MEMBER, + ]; + + $response = $this->performMailmanRequest('members/find', data: $data); + + // There should be at most one entry + if (1 < $response['total_size']) { + throw new RuntimeException( + sprintf( + 'Found more than one member %s with role %s on list %s', + $data['subscriber'], + $data['role'], + $data['list_id'], + ), + ); + } + + $output->writeln( + sprintf( + '--> Removing %s from %s', + $data['subscriber'], + $data['list_id'], + ), + OutputInterface::VERBOSITY_VERY_VERBOSE, + ); + + if ($dryRun) { + return; + } + + if (1 === $response['total_size']) { + $memberId = $response['entries'][0]['member_id']; + + $this->performMailmanRequest('members/' . rawurlencode($memberId), method: Request::METHOD_DELETE); + } + + $this->mailingListMemberMapper->remove($mailingListMember); } public function massUnsubscribeMembersFromMailingList(): void diff --git a/module/Database/src/Service/Member.php b/module/Database/src/Service/Member.php index 1a0c47d5e..e1fae2861 100644 --- a/module/Database/src/Service/Member.php +++ b/module/Database/src/Service/Member.php @@ -926,7 +926,7 @@ public function subscribeLists( $data = $form->getData(); /** @var string[] $selectedLists */ - $selectedLists = $data['lists']; + $selectedLists = $data['lists'] ?: []; $currentLists = $member->getMailingListMemberships()->map( static function (MailingListMemberModel $subscription) { return $subscription->getMailingList()->getName(); diff --git a/module/Database/view/database/index/index.phtml b/module/Database/view/database/index/index.phtml index 27771fce4..444c3926b 100644 --- a/module/Database/view/database/index/index.phtml +++ b/module/Database/view/database/index/index.phtml @@ -125,12 +125,12 @@ use Laminas\View\Renderer\PhpRenderer;

    translate('Last fetch of available mailman lists was at %s.') . + $this->translate('Last fetch of available mailman lists was %s.') . ' ' . $this->translate( 'Possibly, the mailing list server is down or fetch mechanism is malfunctioning.', ), - $mailmanLastFetch->format(DateTimeInterface::ATOM), + $mailmanLastFetch?->format(DateTimeInterface::ATOM) ?: $this->translate('never'), ); ?>

    diff --git a/phpcs.xml.dist b/phpcs.xml.dist index b4f502f13..140e33f78 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -50,8 +50,9 @@ module/Checker/src/Service/Factory/RenewalFactory.php module/Database/src/Command/Factory/DeleteExpiredMembersCommandFactory.php module/Database/src/Command/Factory/DeleteExpiredProspectiveMembersCommandFactory.php - module/Database/src/Command/Factory/FetchMailmanListsCommandFactory.php module/Database/src/Command/Factory/GenerateAuthenticationKeysCommandFactory.php + module/Database/src/Command/Factory/MailmanFetchListsCommandFactory.php + module/Database/src/Command/Factory/MailmanSyncMembershipCommandFactory.php module/Database/src/Controller/Factory/ApiControllerFactory.php module/Database/src/Controller/Factory/ExportControllerFactory.php module/Database/src/Controller/Factory/IndexControllerFactory.php From c44bc34b7c94a48313cd7cb8b2d5fffea0073104 Mon Sep 17 00:00:00 2001 From: rinkp Date: Sun, 29 Jun 2025 16:28:26 +0200 Subject: [PATCH 09/11] feat(dev): Make it easier to migrate to, up or down Adapts the makefile by introducing helper scripts to find a migration and EM_ALIAS --- Makefile | 14 ++++++-------- docker/web/development/php.ini | 1 - scripts/migrate-alias.sh | 10 ++++++++++ scripts/migrate-version.sh | 16 ++++++++++++++++ 4 files changed, 32 insertions(+), 9 deletions(-) create mode 100644 scripts/migrate-alias.sh create mode 100755 scripts/migrate-version.sh diff --git a/Makefile b/Makefile index fe9b043a7..44d442279 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help runprod rundev runtest runcoverage update updatecomposer getvendordir phpstan phpcs phpcbf phpcsfix phpcsfixtypes replenish compilelang build buildprod builddev update preparemailman +.PHONY: help runprod rundev runtest runcoverage update updatecomposer getvendordir phpstan phpcs phpcbf phpcsfix phpcsfixtypes replenish compilelang build buildprod builddev update preparemailman migrate migrate-to migration-down migration-up migration-diff help: @echo "Makefile commands:" @@ -41,6 +41,9 @@ migrate: replenish @docker compose exec -it web ./orm migrations:migrate --object-manager doctrine.entitymanager.orm_default @docker compose exec -it web ./orm migrations:migrate --object-manager doctrine.entitymanager.orm_report +migrate-to: + @docker compose exec web sh -c '. ./scripts/migrate-version.sh && ./orm migrations:migrate $$migrations --object-manager doctrine.entitymanager.$$alias' + migration-list: replenish @docker compose exec -T web ./orm migrations:list --object-manager doctrine.entitymanager.orm_default @docker compose exec -T web ./orm migrations:list --object-manager doctrine.entitymanager.orm_report @@ -52,14 +55,10 @@ migration-diff: replenish @docker cp "$(shell docker compose ps -q web)":/code/module/Report/migrations ./module/Report migration-up: replenish migration-list - @read -p "Enter EM_ALIAS (orm_default or orm_report): " alias; \ - read -p "Enter the migration version to execute (e.g., -- note escaping the backslashes is required): " version; \ - docker compose exec -it web ./orm migrations:execute --up $$version --object-manager doctrine.entitymanager.$$alias + @docker compose exec web sh -c '. ./scripts/migrate-version.sh && ./orm migrations:execute --up $$migrations --object-manager doctrine.entitymanager.$$alias' migration-down: replenish migration-list - @read -p "Enter EM_ALIAS (orm_default or orm_report): " alias; \ - read -p "Enter the migration version to down (e.g., -- note escaping the backslashes is required): " version; \ - docker compose exec -it web ./orm migrations:execute --down $$version --object-manager doctrine.entitymanager.$$alias + @docker compose exec web sh -c '. ./scripts/migrate-version.sh && ./orm migrations:execute --down $$migrations --object-manager doctrine.entitymanager.$$alias' seed: replenish @docker compose exec -T web ./web application:fixtures:load @@ -69,7 +68,6 @@ seed: replenish @docker compose exec -u mailman mailman-core bash -c '(mailman create news@$$MAILMAN_DOMAIN; mailman create other@$$MAILMAN_DOMAIN; true) 2>/dev/null' @docker compose exec web ./web database:mailman:fetch - exec: docker compose exec -it web $(cmd) diff --git a/docker/web/development/php.ini b/docker/web/development/php.ini index 913502b8e..b3cefb91d 100644 --- a/docker/web/development/php.ini +++ b/docker/web/development/php.ini @@ -64,7 +64,6 @@ session.gc_divisor = 100 session.gc_maxlifetime = 43200 [XDebug] -zend_extension="xdebug.so" xdebug.max_nesting_level = 256 xdebug.mode = develop,coverage,debug xdebug.client_host = host.docker.internal diff --git a/scripts/migrate-alias.sh b/scripts/migrate-alias.sh new file mode 100644 index 000000000..457f5aff8 --- /dev/null +++ b/scripts/migrate-alias.sh @@ -0,0 +1,10 @@ +#/bin/sh + +# This script allows selecting an alias +# If you put this directly in the makefile, replace $ with $$ +set -e + +read -rp "Enter EM_ALIAS (orm_default or orm_report): " alias +([ "$alias" == "orm_default" ] || [ "$alias" == "orm_report" ]) || (echo "Not a valid alias, expected orm_default or orm_report, exiting..."; exit 1) + +export alias=$alias diff --git a/scripts/migrate-version.sh b/scripts/migrate-version.sh new file mode 100755 index 000000000..9034d49b5 --- /dev/null +++ b/scripts/migrate-version.sh @@ -0,0 +1,16 @@ +#/bin/sh + +# This script finds a verison given a partial version number +# If you put this directly in the makefile, replace $ with $$ +set -e + +. /code/scripts/migrate-alias.sh + +read -rp "Give (partial, unique) version name (e.g. Database\Migrations\Version20241020224949 or 20241020)): " version + +migrations=$(./orm migrations:list --no-interaction --no-ansi --object-manager doctrine.entitymanager.$alias | grep -F "$version" | awk "{print \$2}") +migrationcount=$(echo $migrations | wc -w) +[ "$migrationcount" == "1" ] || (echo "Found $migrationcount migrations, expecting exactly 1, exiting..." 1>&2; exit 1) + +echo "Found migration $migrations" 1>&2 +export migrations=$migrations From 6ae677e5b8b2fce6278fe8e0fdec52c2c8fd7cd1 Mon Sep 17 00:00:00 2001 From: rinkp Date: Sun, 29 Jun 2025 16:42:07 +0200 Subject: [PATCH 10/11] feat(mailman): Database migrations --- .../migrations/Version20250621133117.php | 30 ++++++++++ .../migrations/Version20250621133118.php | 36 +++++++++++ .../migrations/Version20250621133119.php | 35 ++++------- .../migrations/Version20250621133120.php | 60 +++++++++++++++++++ .../migrations/Version20250621133118.php | 34 +++++++++++ .../migrations/Version20250621133119.php | 16 +++-- 6 files changed, 185 insertions(+), 26 deletions(-) create mode 100644 module/Database/migrations/Version20250621133117.php create mode 100644 module/Database/migrations/Version20250621133118.php create mode 100644 module/Database/migrations/Version20250621133120.php create mode 100644 module/Report/migrations/Version20250621133118.php diff --git a/module/Database/migrations/Version20250621133117.php b/module/Database/migrations/Version20250621133117.php new file mode 100644 index 000000000..bad5a9fe6 --- /dev/null +++ b/module/Database/migrations/Version20250621133117.php @@ -0,0 +1,30 @@ +addSql('ALTER TABLE configitem ADD valueBool BOOLEAN DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE ConfigItem DROP valueBool'); + } +} diff --git a/module/Database/migrations/Version20250621133118.php b/module/Database/migrations/Version20250621133118.php new file mode 100644 index 000000000..5249c597c --- /dev/null +++ b/module/Database/migrations/Version20250621133118.php @@ -0,0 +1,36 @@ +addSql('CREATE TABLE MailmanMailingList (id VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL, lastSeen TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))'); + $this->addSql('ALTER TABLE mailinglist ADD mailmanId VARCHAR(255) DEFAULT NULL'); + $this->addSql('ALTER TABLE mailinglist ADD CONSTRAINT FK_FD864C3AFD6980D2 FOREIGN KEY (mailmanId) REFERENCES MailmanMailingList (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_FD864C3AFD6980D2 ON mailinglist (mailmanId)'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE MailingList DROP CONSTRAINT FK_FD864C3AFD6980D2'); + $this->addSql('DROP TABLE MailmanMailingList'); + $this->addSql('DROP INDEX UNIQ_FD864C3AFD6980D2'); + $this->addSql('ALTER TABLE MailingList DROP mailmanId'); + } +} diff --git a/module/Database/migrations/Version20250621133119.php b/module/Database/migrations/Version20250621133119.php index 5c0d67611..4af0a6cbb 100644 --- a/module/Database/migrations/Version20250621133119.php +++ b/module/Database/migrations/Version20250621133119.php @@ -15,52 +15,43 @@ final class Version20250621133119 extends AbstractMigration { public function getDescription(): string { - return 'Introduces the necessary tables for mailman mailing lists'; + return 'Introduces the possibility for attributes on the many-to-many relation between mailing lists and members'; } public function up(Schema $schema): void { + // Set up the new table $this->addSql('CREATE TABLE MailingListMember (email VARCHAR(255) NOT NULL, member INT NOT NULL, lastSyncOn TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, lastSyncSuccess BOOLEAN NOT NULL, toBeCreated BOOLEAN NOT NULL, toBeDeleted BOOLEAN NOT NULL, mailingList VARCHAR(255) NOT NULL, PRIMARY KEY(mailingList, member, email))'); $this->addSql('CREATE INDEX IDX_3A8467A97B1AC3ED ON MailingListMember (mailingList)'); $this->addSql('CREATE INDEX IDX_3A8467A970E4FA78 ON MailingListMember (member)'); $this->addSql('CREATE UNIQUE INDEX mailinglistmember_unique_idx ON MailingListMember (mailingList, member)'); - $this->addSql('CREATE TABLE MailmanMailingList (id VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL, lastSeen TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))'); $this->addSql('ALTER TABLE MailingListMember ADD CONSTRAINT FK_3A8467A97B1AC3ED FOREIGN KEY (mailingList) REFERENCES MailingList (name) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE MailingListMember ADD CONSTRAINT FK_3A8467A970E4FA78 FOREIGN KEY (member) REFERENCES Member (lidnr) NOT DEFERRABLE INITIALLY IMMEDIATE'); - $this->addSql('ALTER TABLE prospective_members_mailinglists DROP CONSTRAINT fk_c86f04985e237e06'); - $this->addSql('ALTER TABLE prospective_members_mailinglists DROP CONSTRAINT fk_c86f0498d665e01d'); + + // Data members_mailinglists -> MailingListMember + $this->addSql('INSERT INTO MailingListMember (member, email, mailingList, toBeCreated, toBeDeleted, lastSyncSuccess, lastSyncOn) (SELECT m.lidnr as member, m.email as email, "name" as mailingList, false as toBeCreated, false as toBeDeleted, true as lastSyncSuccess, null as lastSyncOn FROM members_mailinglists as mm LEFT JOIN member as m on m.lidnr = mm.lidnr)'); + + // Remove old members_mailinglists $this->addSql('ALTER TABLE members_mailinglists DROP CONSTRAINT fk_5ad357d95e237e06'); $this->addSql('ALTER TABLE members_mailinglists DROP CONSTRAINT fk_5ad357d9d665e01d'); - $this->addSql('DROP TABLE prospective_members_mailinglists'); $this->addSql('DROP TABLE members_mailinglists'); - $this->addSql('ALTER TABLE configitem ADD valueBool BOOLEAN DEFAULT NULL'); - $this->addSql('ALTER TABLE mailinglist ADD mailmanId VARCHAR(255) DEFAULT NULL'); - $this->addSql('ALTER TABLE mailinglist ADD CONSTRAINT FK_FD864C3AFD6980D2 FOREIGN KEY (mailmanId) REFERENCES MailmanMailingList (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); - $this->addSql('CREATE UNIQUE INDEX UNIQ_FD864C3AFD6980D2 ON mailinglist (mailmanId)'); - $this->addSql('ALTER TABLE prospectivemember ADD lists TEXT DEFAULT NULL'); - $this->addSql('COMMENT ON COLUMN prospectivemember.lists IS \'(DC2Type:simple_array)\''); } public function down(Schema $schema): void { - $this->addSql('ALTER TABLE MailingList DROP CONSTRAINT FK_FD864C3AFD6980D2'); - $this->addSql('CREATE TABLE prospective_members_mailinglists (lidnr INT NOT NULL, name VARCHAR(255) NOT NULL, PRIMARY KEY(lidnr, name))'); - $this->addSql('CREATE INDEX idx_c86f04985e237e06 ON prospective_members_mailinglists (name)'); - $this->addSql('CREATE INDEX idx_c86f0498d665e01d ON prospective_members_mailinglists (lidnr)'); + // Recreate the old table $this->addSql('CREATE TABLE members_mailinglists (lidnr INT NOT NULL, name VARCHAR(255) NOT NULL, PRIMARY KEY(lidnr, name))'); $this->addSql('CREATE INDEX idx_5ad357d95e237e06 ON members_mailinglists (name)'); $this->addSql('CREATE INDEX idx_5ad357d9d665e01d ON members_mailinglists (lidnr)'); - $this->addSql('ALTER TABLE prospective_members_mailinglists ADD CONSTRAINT fk_c86f04985e237e06 FOREIGN KEY (name) REFERENCES mailinglist (name) NOT DEFERRABLE INITIALLY IMMEDIATE'); - $this->addSql('ALTER TABLE prospective_members_mailinglists ADD CONSTRAINT fk_c86f0498d665e01d FOREIGN KEY (lidnr) REFERENCES prospectivemember (lidnr) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE members_mailinglists ADD CONSTRAINT fk_5ad357d95e237e06 FOREIGN KEY (name) REFERENCES mailinglist (name) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE members_mailinglists ADD CONSTRAINT fk_5ad357d9d665e01d FOREIGN KEY (lidnr) REFERENCES member (lidnr) NOT DEFERRABLE INITIALLY IMMEDIATE'); + + // Move data back MailingListMember -> members_mailinglists + $this->addSql('INSERT INTO members_mailinglists (lidnr, name) (SELECT member as lidnr, mailingList as name from MailingListMember WHERE toBeCreated = False AND toBeDeleted = False)'); + + // Undo creation of the new table $this->addSql('ALTER TABLE MailingListMember DROP CONSTRAINT FK_3A8467A97B1AC3ED'); $this->addSql('ALTER TABLE MailingListMember DROP CONSTRAINT FK_3A8467A970E4FA78'); $this->addSql('DROP TABLE MailingListMember'); - $this->addSql('DROP TABLE MailmanMailingList'); - $this->addSql('ALTER TABLE ConfigItem DROP valueBool'); - $this->addSql('DROP INDEX UNIQ_FD864C3AFD6980D2'); - $this->addSql('ALTER TABLE MailingList DROP mailmanId'); - $this->addSql('ALTER TABLE ProspectiveMember DROP lists'); } } diff --git a/module/Database/migrations/Version20250621133120.php b/module/Database/migrations/Version20250621133120.php new file mode 100644 index 000000000..e21d3db5f --- /dev/null +++ b/module/Database/migrations/Version20250621133120.php @@ -0,0 +1,60 @@ +addSql('ALTER TABLE prospectivemember ADD lists TEXT DEFAULT NULL'); + $this->addSql('COMMENT ON COLUMN prospectivemember.lists IS \'(DC2Type:simple_array)\''); + + // Data prospective_members_mailinglists -> simple_array in prospectivemember + if ($this->connection->getDatabasePlatform() instanceof PostgreSQLPlatform) { + $this->addSql('UPDATE prospectivemember as pm SET lists = (SELECT STRING_AGG(DISTINCT name, \',\') as lists FROM prospective_members_mailinglists as pl WHERE pl.lidnr = pm.lidnr GROUP BY pl.lidnr)'); + } else { + $this->addSql('UPDATE prospectivemember as pm SET lists = (SELECT GROUP_CONCAT(DISTINCT name SEPARATOR \',\') as lists FROM prospective_members_mailinglists as pl WHERE pl.lidnr = pm.lidnr GROUP BY pl.lidnr)'); + } + + // Remove old prospective_members_mailinglists + $this->addSql('ALTER TABLE prospective_members_mailinglists DROP CONSTRAINT fk_c86f04985e237e06'); + $this->addSql('ALTER TABLE prospective_members_mailinglists DROP CONSTRAINT fk_c86f0498d665e01d'); + $this->addSql('DROP TABLE prospective_members_mailinglists'); + } + + public function down(Schema $schema): void + { + // Recreate the old table + $this->addSql('CREATE TABLE prospective_members_mailinglists (lidnr INT NOT NULL, name VARCHAR(255) NOT NULL, PRIMARY KEY(lidnr, name))'); + $this->addSql('CREATE INDEX idx_c86f04985e237e06 ON prospective_members_mailinglists (name)'); + $this->addSql('CREATE INDEX idx_c86f0498d665e01d ON prospective_members_mailinglists (lidnr)'); + $this->addSql('ALTER TABLE prospective_members_mailinglists ADD CONSTRAINT fk_c86f04985e237e06 FOREIGN KEY (name) REFERENCES mailinglist (name) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE prospective_members_mailinglists ADD CONSTRAINT fk_c86f0498d665e01d FOREIGN KEY (lidnr) REFERENCES prospectivemember (lidnr) NOT DEFERRABLE INITIALLY IMMEDIATE'); + + // Move data back MailingListMember -> members_mailinglists + if ($this->connection->getDatabasePlatform() instanceof PostgreSQLPlatform) { + $this->addSql('INSERT INTO prospective_members_mailinglists (lidnr, name) (SELECT lidnr, UNNEST(STRING_TO_ARRAY(lists, \',\')) as name FROM prospectivemember)'); + } else { + $this->abortIf(true, 'Explode-like functionality is only supported on PostgreSQL; continuing will drop all mailing list subscriptions for prospective members'); + } + + // Undo creation of the new table + $this->addSql('ALTER TABLE ProspectiveMember DROP lists'); + } +} diff --git a/module/Report/migrations/Version20250621133118.php b/module/Report/migrations/Version20250621133118.php new file mode 100644 index 000000000..28039b62c --- /dev/null +++ b/module/Report/migrations/Version20250621133118.php @@ -0,0 +1,34 @@ +addSql('ALTER TABLE MailingList DROP onform'); + $this->addSql('ALTER TABLE MailingList DROP defaultsub'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE MailingList ADD onform BOOLEAN NOT NULL DEFAULT false'); + $this->addSql('ALTER TABLE MailingList ADD defaultsub BOOLEAN NOT NULL DEFAULT false'); + $this->addSql('ALTER TABLE MailingList ALTER COLUMN onform DROP DEFAULT'); + $this->addSql('ALTER TABLE MailingList ALTER COLUMN defaultsub DROP DEFAULT'); + } +} diff --git a/module/Report/migrations/Version20250621133119.php b/module/Report/migrations/Version20250621133119.php index 5674f91b3..96d1f0f77 100644 --- a/module/Report/migrations/Version20250621133119.php +++ b/module/Report/migrations/Version20250621133119.php @@ -20,30 +20,38 @@ public function getDescription(): string public function up(Schema $schema): void { + // Set up the new table $this->addSql('CREATE TABLE MailingListMember (email VARCHAR(255) NOT NULL, member INT NOT NULL, mailingList VARCHAR(255) NOT NULL, PRIMARY KEY(mailingList, member, email))'); $this->addSql('CREATE INDEX IDX_3A8467A97B1AC3ED ON MailingListMember (mailingList)'); $this->addSql('CREATE INDEX IDX_3A8467A970E4FA78 ON MailingListMember (member)'); $this->addSql('CREATE UNIQUE INDEX mailinglistmember_unique_idx ON MailingListMember (mailingList, member)'); $this->addSql('ALTER TABLE MailingListMember ADD CONSTRAINT FK_3A8467A97B1AC3ED FOREIGN KEY (mailingList) REFERENCES MailingList (name) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE MailingListMember ADD CONSTRAINT FK_3A8467A970E4FA78 FOREIGN KEY (member) REFERENCES Member (lidnr) NOT DEFERRABLE INITIALLY IMMEDIATE'); + + // Data members_mailinglists -> MailingListMember + $this->addSql('INSERT INTO MailingListMember (member, email, mailingList) (SELECT m.lidnr as member, m.email as email, "name" as mailingList FROM members_mailinglists as mm LEFT JOIN member as m on m.lidnr = mm.lidnr)'); + + // Remove old members_mailinglists $this->addSql('ALTER TABLE members_mailinglists DROP CONSTRAINT fk_5ad357d95e237e06'); $this->addSql('ALTER TABLE members_mailinglists DROP CONSTRAINT fk_5ad357d9d665e01d'); $this->addSql('DROP TABLE members_mailinglists'); - $this->addSql('ALTER TABLE mailinglist DROP onform'); - $this->addSql('ALTER TABLE mailinglist DROP defaultsub'); } public function down(Schema $schema): void { + // Recreate the old table $this->addSql('CREATE TABLE members_mailinglists (lidnr INT NOT NULL, name VARCHAR(255) NOT NULL, PRIMARY KEY(lidnr, name))'); $this->addSql('CREATE INDEX idx_5ad357d95e237e06 ON members_mailinglists (name)'); $this->addSql('CREATE INDEX idx_5ad357d9d665e01d ON members_mailinglists (lidnr)'); $this->addSql('ALTER TABLE members_mailinglists ADD CONSTRAINT fk_5ad357d95e237e06 FOREIGN KEY (name) REFERENCES mailinglist (name) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE members_mailinglists ADD CONSTRAINT fk_5ad357d9d665e01d FOREIGN KEY (lidnr) REFERENCES member (lidnr) NOT DEFERRABLE INITIALLY IMMEDIATE'); + + // Move data back MailingListMember -> members_mailinglists + $this->addSql('INSERT INTO members_mailinglists (lidnr, name) (SELECT member as lidnr, mailingList as name from MailingListMember)'); + + // Undo creation of the new table $this->addSql('ALTER TABLE MailingListMember DROP CONSTRAINT FK_3A8467A97B1AC3ED'); $this->addSql('ALTER TABLE MailingListMember DROP CONSTRAINT FK_3A8467A970E4FA78'); $this->addSql('DROP TABLE MailingListMember'); - $this->addSql('ALTER TABLE MailingList ADD onform BOOLEAN NOT NULL'); - $this->addSql('ALTER TABLE MailingList ADD defaultsub BOOLEAN NOT NULL'); } } From 2b33f3d1c2371541391fd6152229b7980bd1648a Mon Sep 17 00:00:00 2001 From: rinkp Date: Sun, 29 Jun 2025 23:10:55 +0200 Subject: [PATCH 11/11] feat(mailman): Implement phase 2 of mailman sync --- Makefile | 2 +- docker/web/development/crontab | 2 +- docker/web/production/crontab | 2 +- .../migrations/Version20250621133118.php | 2 +- .../ProspectiveMemberController.php | 2 +- module/Database/src/Form/MailingList.php | 3 + module/Database/src/Mapper/Member.php | 16 +- module/Database/src/Model/MailingList.php | 14 +- .../Database/src/Model/MailingListMember.php | 10 +- .../Database/src/Model/MailmanMailingList.php | 25 +++ .../Database/src/Model/ProspectiveMember.php | 2 + .../src/Service/Factory/MailmanFactory.php | 8 + module/Database/src/Service/Mailman.php | 193 ++++++++++++++++-- module/Database/src/Service/Member.php | 2 - module/Database/test/Seeder/MemberFixture.php | 8 +- .../Database/view/database/member/show.phtml | 2 +- .../view/database/settings/lists.phtml | 5 +- module/Report/src/Service/Member.php | 33 ++- 18 files changed, 290 insertions(+), 41 deletions(-) diff --git a/Makefile b/Makefile index 44d442279..accfa03ef 100644 --- a/Makefile +++ b/Makefile @@ -72,7 +72,7 @@ exec: docker compose exec -it web $(cmd) stop: - @docker compose down + @docker compose down --remove-orphans runtest: loadenv @vendor/phpunit/phpunit/phpunit --bootstrap ./bootstrap.php --configuration ./phpunit.xml --stop-on-error --stop-on-failure diff --git a/docker/web/development/crontab b/docker/web/development/crontab index 462868abe..576a9a5ba 100644 --- a/docker/web/development/crontab +++ b/docker/web/development/crontab @@ -15,4 +15,4 @@ */30 * * * * { . /code/config/bash.env && /usr/local/bin/php /code/web check:membership:renewal:graduate; } > /code/data/logs/cron-check-renewal.log 2>&1 0 2 * * * { . /code/config/bash.env && /usr/local/bin/php /code/web database:prospective-members:delete-expired; } > /code/data/logs/cron-delete-prospective.log 2>&1 55 * * * * { . /code/config/bash.env && /usr/local/bin/php /code/web database:mailman:fetch; } > /code/data/logs/cron-mailman-fetch.log 2>&1 -5 * * * * { . /code/config/bash.env && /usr/local/bin/php /code/web database:mailman:syncmembership -f -vv; } > /code/data/logs/cron-mailman-syncmembership.log 2>&1 +*/15 * * * * { . /code/config/bash.env && /usr/local/bin/php /code/web database:mailman:syncmembership -f -vv; } > /code/data/logs/cron-mailman-syncmembership.log 2>&1 diff --git a/docker/web/production/crontab b/docker/web/production/crontab index 462868abe..576a9a5ba 100644 --- a/docker/web/production/crontab +++ b/docker/web/production/crontab @@ -15,4 +15,4 @@ */30 * * * * { . /code/config/bash.env && /usr/local/bin/php /code/web check:membership:renewal:graduate; } > /code/data/logs/cron-check-renewal.log 2>&1 0 2 * * * { . /code/config/bash.env && /usr/local/bin/php /code/web database:prospective-members:delete-expired; } > /code/data/logs/cron-delete-prospective.log 2>&1 55 * * * * { . /code/config/bash.env && /usr/local/bin/php /code/web database:mailman:fetch; } > /code/data/logs/cron-mailman-fetch.log 2>&1 -5 * * * * { . /code/config/bash.env && /usr/local/bin/php /code/web database:mailman:syncmembership -f -vv; } > /code/data/logs/cron-mailman-syncmembership.log 2>&1 +*/15 * * * * { . /code/config/bash.env && /usr/local/bin/php /code/web database:mailman:syncmembership -f -vv; } > /code/data/logs/cron-mailman-syncmembership.log 2>&1 diff --git a/module/Database/migrations/Version20250621133118.php b/module/Database/migrations/Version20250621133118.php index 5249c597c..a53a10bf4 100644 --- a/module/Database/migrations/Version20250621133118.php +++ b/module/Database/migrations/Version20250621133118.php @@ -20,7 +20,7 @@ public function getDescription(): string public function up(Schema $schema): void { - $this->addSql('CREATE TABLE MailmanMailingList (id VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL, lastSeen TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE TABLE MailmanMailingList (id VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL, lastSeen TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, lastCheck TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, PRIMARY KEY(id))'); $this->addSql('ALTER TABLE mailinglist ADD mailmanId VARCHAR(255) DEFAULT NULL'); $this->addSql('ALTER TABLE mailinglist ADD CONSTRAINT FK_FD864C3AFD6980D2 FOREIGN KEY (mailmanId) REFERENCES MailmanMailingList (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('CREATE UNIQUE INDEX UNIQ_FD864C3AFD6980D2 ON mailinglist (mailmanId)'); diff --git a/module/Database/src/Controller/ProspectiveMemberController.php b/module/Database/src/Controller/ProspectiveMemberController.php index 018f7eae6..3904bf24e 100644 --- a/module/Database/src/Controller/ProspectiveMemberController.php +++ b/module/Database/src/Controller/ProspectiveMemberController.php @@ -68,7 +68,7 @@ public function showAction(): ViewModel } /** - * Show action. + * Approve or finalize action. * * Shows prospective member information. */ diff --git a/module/Database/src/Form/MailingList.php b/module/Database/src/Form/MailingList.php index 37a6b6d25..17f34d4aa 100644 --- a/module/Database/src/Form/MailingList.php +++ b/module/Database/src/Form/MailingList.php @@ -157,6 +157,9 @@ public function getInputFilterSpecification(): array ], ], ], + 'mailmanList' => [ + 'required' => false, + ], ]; } } diff --git a/module/Database/src/Mapper/Member.php b/module/Database/src/Mapper/Member.php index d297e9520..7a9b923ca 100644 --- a/module/Database/src/Mapper/Member.php +++ b/module/Database/src/Mapper/Member.php @@ -35,6 +35,18 @@ public function __construct(protected readonly EntityManager $em) * See if we can find a member with the same email. */ public function hasMemberWith(string $email): bool + { + $ret = $this->findByEmail($email); + + return null !== $ret && count($ret) > 0; + } + + /** + * Find by email + * + * @return MemberModel[] + */ + public function findByEmail(string $email): array { $qb = $this->em->createQueryBuilder(); @@ -45,9 +57,7 @@ public function hasMemberWith(string $email): bool $qb->setParameter(':email', $email); - $ret = $qb->getQuery()->getResult(); - - return null !== $ret && count($ret) > 0; + return $qb->getQuery()->getResult(); } /** diff --git a/module/Database/src/Model/MailingList.php b/module/Database/src/Model/MailingList.php index d44306a5f..6edb92971 100644 --- a/module/Database/src/Model/MailingList.php +++ b/module/Database/src/Model/MailingList.php @@ -169,10 +169,18 @@ public function getMailmanList(): ?MailmanMailingList return $this->mailmanList; } + /** + * Check if this has a mailman mailing list + */ + public function hasMailmanList(): bool + { + return null !== $this->mailmanList; + } + /** * Set the corresponding mailman list */ - public function setMailmanList(MailmanMailingList $mailmanList): void + public function setMailmanList(?MailmanMailingList $mailmanList): void { $this->mailmanList = $mailmanList; } @@ -194,7 +202,7 @@ public function getMailingListMemberships(): Collection * en_description: string, * defaultSub: bool, * onForm: bool, - * mailmanList: string, + * mailmanList: ?string, * } */ public function toArray(): array @@ -205,7 +213,7 @@ public function toArray(): array 'en_description' => $this->getEnDescription(), 'defaultSub' => $this->getDefaultSub(), 'onForm' => $this->getOnForm(), - 'mailmanList' => $this->getMailmanList()->getMailmanId(), + 'mailmanList' => $this->getMailmanList()?->getMailmanId(), ]; } } diff --git a/module/Database/src/Model/MailingListMember.php b/module/Database/src/Model/MailingListMember.php index 5c72d6c40..a457b7f7b 100644 --- a/module/Database/src/Model/MailingListMember.php +++ b/module/Database/src/Model/MailingListMember.php @@ -125,6 +125,12 @@ public function getMailingList(): MailingList public function setMailingList(MailingList $mailingList): void { $this->mailingList = $mailingList; + + if ($mailingList->hasMailmanList()) { + return; + } + + $this->setToBeCreated(false); } /** @@ -137,10 +143,12 @@ public function getMember(): Member /** * Set the member. + * By default, this also sets the email address, but can be overriden with setEmail() */ public function setMember(Member $member): void { $this->member = $member; + $this->setEmail($member->getEmail()); } /** @@ -170,7 +178,7 @@ public function getLastSyncOn(): ?DateTime /** * Set when the last sync happened. */ - public function setLastSyncOn(DateTime $lastSyncOn): void + public function setLastSyncOn(DateTime $lastSyncOn = new DateTime()): void { $this->lastSyncOn = $lastSyncOn; } diff --git a/module/Database/src/Model/MailmanMailingList.php b/module/Database/src/Model/MailmanMailingList.php index 8ed4144e7..394483408 100644 --- a/module/Database/src/Model/MailmanMailingList.php +++ b/module/Database/src/Model/MailmanMailingList.php @@ -38,6 +38,15 @@ class MailmanMailingList #[Column(type: 'datetime')] protected DateTime $lastSeen; + /** + * When the last full check of this mailing list took place + */ + #[Column( + type: 'datetime', + nullable: true, + )] + protected ?DateTime $lastCheck = null; + /** * The corresponding gewisdb mailing list * If null, this list is not managed by GEWISDB @@ -99,6 +108,22 @@ public function setLastSeen(DateTime $lastSeen = new DateTime()): void $this->lastSeen = $lastSeen; } + /** + * Get the date the list was last fully checked + */ + public function getLastCheck(): ?DateTime + { + return $this->lastCheck; + } + + /** + * Set the date the list was last fully checked + */ + public function setLastCheck(DateTime $lastCheck = new DateTime()): void + { + $this->lastCheck = $lastCheck; + } + /** * Get the mailing list corresponding to this mailman list */ diff --git a/module/Database/src/Model/ProspectiveMember.php b/module/Database/src/Model/ProspectiveMember.php index a23df31c4..b7dbc6ed5 100644 --- a/module/Database/src/Model/ProspectiveMember.php +++ b/module/Database/src/Model/ProspectiveMember.php @@ -415,6 +415,7 @@ public function setPaid(int $paid): void * tueUsername: ?string, * study: ?string, * birth: string, + * lists: string[], * address: array{ * type: AddressTypes, * country: PostalRegions, @@ -441,6 +442,7 @@ public function toArray(): array 'tueUsername' => $this->getTueUsername(), 'study' => $this->getStudy(), 'birth' => $this->getBirth()->format('Y-m-d'), + 'lists' => $this->getLists(), 'address' => $this->getAddresses()['studentAddress']->toArray(), 'agreed' => '1', 'agreedStripe' => '1', diff --git a/module/Database/src/Service/Factory/MailmanFactory.php b/module/Database/src/Service/Factory/MailmanFactory.php index 85ffcd4e8..2dde6d631 100644 --- a/module/Database/src/Service/Factory/MailmanFactory.php +++ b/module/Database/src/Service/Factory/MailmanFactory.php @@ -5,8 +5,10 @@ namespace Database\Service\Factory; use Application\Service\Config as ConfigService; +use Database\Mapper\MailingList as MailingListMapper; use Database\Mapper\MailingListMember as MailingListMemberMapper; use Database\Mapper\MailmanMailingList as MailmanMailingListMapper; +use Database\Mapper\Member as MemberMapper; use Database\Service\Mailman as MailmanService; use Laminas\ServiceManager\Factory\FactoryInterface; use Psr\Container\ContainerInterface; @@ -21,18 +23,24 @@ public function __invoke( $requestedName, ?array $options = null, ): MailmanService { + /** @var MailingListMapper $mailingListMapper */ + $mailingListMapper = $container->get(MailingListMapper::class); /** @var MailmanMailingListMapper $mailmanMailingListMapper */ $mailmanMailingListMapper = $container->get(MailmanMailingListMapper::class); /** @var MailingListMemberMapper $mailingListMemberMapper */ $mailingListMemberMapper = $container->get(MailingListMemberMapper::class); + /** @var MemberMapper $memberMapper */ + $memberMapper = $container->get(MemberMapper::class); /** @var ConfigService $configService */ $configService = $container->get(ConfigService::class); /** @var array $mailmanConfig */ $mailmanConfig = $container->get('config')['mailman_api']; return new MailmanService( + $mailingListMapper, $mailmanMailingListMapper, $mailingListMemberMapper, + $memberMapper, $configService, $mailmanConfig, ); diff --git a/module/Database/src/Service/Mailman.php b/module/Database/src/Service/Mailman.php index e55c3a8a7..6385716a1 100644 --- a/module/Database/src/Service/Mailman.php +++ b/module/Database/src/Service/Mailman.php @@ -6,8 +6,11 @@ use Application\Model\Enums\ConfigNamespaces; use Application\Service\Config as ConfigService; +use Database\Mapper\MailingList as MailingListMapper; use Database\Mapper\MailingListMember as MailingListMemberMapper; use Database\Mapper\MailmanMailingList as MailmanMailingListMapper; +use Database\Mapper\Member as MemberMapper; +use Database\Model\MailingList as MailingListModel; use Database\Model\MailingListMember as MailingListMemberModel; use Database\Model\MailmanMailingList as MailmanMailingListModel; use DateInterval; @@ -15,11 +18,13 @@ use Laminas\Http\Client; use Laminas\Http\Client\Adapter\Curl; use Laminas\Http\Request; +use LogicException; use RuntimeException; use Symfony\Component\Console\Output\NullOutput; use Symfony\Component\Console\Output\OutputInterface; use function array_map; +use function count; use function json_decode; use function json_last_error_msg; use function json_validate; @@ -47,8 +52,10 @@ class Mailman * @phpcsSuppress SlevomatCodingStandard.TypeHints.ParameterTypeHint.MissingTraversableTypeHintSpecification */ public function __construct( + private readonly MailingListMapper $mailingListMapper, private readonly MailmanMailingListMapper $mailmanMailingListMapper, private readonly MailingListMemberMapper $mailingListMemberMapper, + private readonly MemberMapper $memberMapper, private readonly ConfigService $configService, private readonly array $mailmanConfig, ) { @@ -162,18 +169,15 @@ public function isSyncLocked(): bool /** * This functions syncs the mailing list membership of all mailing lists + * Even if they don't have an associated mailman mailing list, to keep the code throughout the application the same */ public function syncMembership( OutputInterface $output = new NullOutput(), bool $dryRun = false, ): void { - $lists = $this->mailmanMailingListMapper->findActive(); + $lists = $this->mailingListMapper->findAll(); foreach ($lists as $list) { - if (null === $list->getMailingList()) { - continue; - } - $this->syncMembershipSingle($list, $output, $dryRun); } } @@ -182,22 +186,23 @@ public function syncMembership( * This function syncs the membership of a mailing list */ private function syncMembershipSingle( - MailmanMailingListModel $mmList, + MailingListModel $dbList, OutputInterface $output, bool $dryRun, ): void { - $dbList = $mmList->getMailingList(); $dbMemberships = $dbList->getMailingListMemberships(); $output->writeln( sprintf( '-> Syncing membership changes for %s (%s)', $dbList->getName(), - $mmList->getMailmanId(), + $dbList->hasMailmanList() ? $dbList->getMailmanList()->getMailmanId() : 'local', ), OutputInterface::VERBOSITY_VERBOSE, ); + $verifyTime = (new DateTime())->sub(new DateInterval('P1D')); + // Phase 1: Sync all pending changes from DB side foreach ($dbMemberships as $mailingListMember) { if ($mailingListMember->isToBeDeleted()) { @@ -213,11 +218,31 @@ private function syncMembershipSingle( dryRun: $dryRun, sendWelcomeEmail: true, ); + } elseif ($dbList->hasMailmanList() && $mailingListMember->getLastSyncOn() < $verifyTime) { + $this->verifyMemberOnMailingList( + mailingListMember: $mailingListMember, + output: $output, + dryRun: $dryRun, + ); } } - // Phase 2: Voor alle to be created, perform creation - // TODO + // The rest only applies to mailing lists that have a mailman list + if (!$dbList->hasMailmanList()) { + return; + } + + // Phase 2: once per 24 hours + if ($dbList->getMailmanList()->getLastCheck() > $verifyTime) { + return; + } + + // Sync all unknowns from mailman + $this->fullCheckMailmanList( + mailingList: $dbList, + output: $output, + dryRun: $dryRun, + ); } public function isMailmanHealthy(): bool @@ -352,6 +377,15 @@ private function subscribeMemberToMailingList( bool $dryRun, bool $sendWelcomeEmail, ): void { + // If there is no associated mailman list, assume processed + if (!$mailingListMember->getMailingList()->hasMailmanList()) { + $mailingListMember->setLastSyncSuccess(true); + $mailingListMember->setToBeCreated(false); + $this->mailingListMemberMapper->persist($mailingListMember); + + return; + } + $member = $mailingListMember->getMember(); $listId = $mailingListMember->getMailingList()->getMailmanList()->getMailmanId(); @@ -384,7 +418,7 @@ private function subscribeMemberToMailingList( } // Send the request to the Mailman API - $mailingListMember->setLastSyncOn(new DateTime()); + $mailingListMember->setLastSyncOn(); $response = $this->performMailmanRequest( uri: 'members', method: Request::METHOD_POST, @@ -408,6 +442,13 @@ private function unsubscribeMemberFromMailingList( OutputInterface $output, bool $dryRun, ): void { + // If there is no associated mailman list, assume processed + if (!$mailingListMember->getMailingList()->hasMailmanList()) { + $this->mailingListMemberMapper->remove($mailingListMember); + + return; + } + $member = $mailingListMember->getMember(); $listId = $mailingListMember->getMailingList()->getMailmanList()->getMailmanId(); @@ -453,7 +494,133 @@ private function unsubscribeMemberFromMailingList( $this->mailingListMemberMapper->remove($mailingListMember); } - public function massUnsubscribeMembersFromMailingList(): void - { + /** + * This function verifies that a member is still on a given mailing list + * and if not, removes the mailinglistMemberModel + */ + private function verifyMemberOnMailingList( + MailingListMemberModel $mailingListMember, + OutputInterface $output, + bool $dryRun, + ): void { + // If there is no associated mailman list, assume this is right + if (!$mailingListMember->getMailingList()->hasMailmanList()) { + throw new LogicException('Cannot verify mailing list subscription for non-mailman list'); + } + + $member = $mailingListMember->getMember(); + $listId = $mailingListMember->getMailingList()->getMailmanList()->getMailmanId(); + + $data = [ + 'list_id' => $listId, + 'subscriber' => $member->getEmail(), + 'role' => self::MM_ROLE_MEMBER, + ]; + + $response = $this->performMailmanRequest('members/find', data: $data); + + // There should be at most one entry + if (1 < $response['total_size']) { + throw new RuntimeException( + sprintf( + 'Found more than one member %s with role %s on list %s', + $data['subscriber'], + $data['role'], + $data['list_id'], + ), + ); + } + + if (1 === $response['total_size']) { + return; + } + + $output->writeln( + sprintf( + '--> %s has disappeared from %s, removing db entry', + $data['subscriber'], + $data['list_id'], + ), + OutputInterface::VERBOSITY_VERY_VERBOSE, + ); + + if ($dryRun) { + return; + } + + $this->mailingListMemberMapper->remove($mailingListMember); + } + + private function fullCheckMailmanList( + MailingListModel $mailingList, + OutputInterface $output, + bool $dryRun, + ): void { + $mmList = $mailingList->getMailmanList(); + $membersDB = $mailingList->getMailingListMemberships(); + $listId = $mmList->getMailmanId(); + + $data = [ + 'list_id' => $listId, + 'role' => self::MM_ROLE_MEMBER, + ]; + + $response = $this->performMailmanRequest('members/find', data: $data); + + foreach ($response['entries'] as $entry) { + $found = false; + foreach ($membersDB as $member) { + if ($member->getEmail() !== $entry['email']) { + continue; + } + + $found = true; + } + + $foundMembers = $this->memberMapper->findByEmail($entry['email']); + + if (!$found && 0 === count($foundMembers)) { + $output->writeln( + sprintf( + '--> Removing unknown email %s from %s', + $entry['email'], + $data['list_id'], + ), + OutputInterface::VERBOSITY_VERY_VERBOSE, + ); + + if (!$dryRun) { + $this->performMailmanRequest( + 'members/' . rawurlencode($entry['member_id']), + method: Request::METHOD_DELETE, + ); + } + } elseif (!$found) { + $output->writeln( + sprintf( + '--> Found %s on %s, updating database', + $entry['email'], + $data['list_id'], + ), + OutputInterface::VERBOSITY_VERY_VERBOSE, + ); + + if (!$dryRun) { + $mailingListMember = new MailingListMemberModel(); + $mailingListMember->setMailingList($mailingList); + $mailingListMember->setMember($foundMembers[0]); + $mailingListMember->setEmail($entry['email']); + $mailingListMember->setToBeCreated(false); + $this->mailingListMemberMapper->persist($mailingListMember); + } + } + } + + if ($dryRun) { + return; + } + + $mmList->setLastCheck(); + $this->mailmanMailingListMapper->persist($mmList); } } diff --git a/module/Database/src/Service/Member.php b/module/Database/src/Service/Member.php index e1fae2861..643252133 100644 --- a/module/Database/src/Service/Member.php +++ b/module/Database/src/Service/Member.php @@ -383,7 +383,6 @@ public function finalizeSubscription( // add address $member->addAddresses($prospectiveMember->getAddresses()); - // add mailing lists foreach ($form->getLists() as $list) { if (!$form->get('list-' . $list->getName())->isChecked()) { continue; @@ -962,7 +961,6 @@ static function (MailingListMemberModel $subscription) { $mailingListMember = new MailingListMemberModel(); $mailingListMember->setMailingList($list); $mailingListMember->setMember($member); - $mailingListMember->setEmail($member->getEmail()); // Force cascade by adding to member. $member->addList($mailingListMember); } diff --git a/module/Database/test/Seeder/MemberFixture.php b/module/Database/test/Seeder/MemberFixture.php index 30c468d6c..66db9ac7d 100644 --- a/module/Database/test/Seeder/MemberFixture.php +++ b/module/Database/test/Seeder/MemberFixture.php @@ -35,7 +35,7 @@ public function load(ObjectManager $manager): void $pros->setLastName('Testdata'); $pros->setTueUsername('20190001'); $pros->setBirth(new DateTime('2001-01-01')); - $pros->setEmail('tara@gewisdb.local'); + $pros->setEmail('tara@example.com'); $pros->setPaid(15); $pros->setChangedOn(new DateTime()); $prosAddress = new Address(); @@ -65,7 +65,7 @@ public function load(ObjectManager $manager): void $student->setFirstName('Timon'); $student->setMiddleName('de'); $student->setLastName('Teststudent'); - $student->setEmail('timon@gewisdb.local'); + $student->setEmail('timon@example.com'); $student->setBirth(new DateTime('2000-01-01')); $student->setGeneration(2018); $student->setType(MembershipTypes::Ordinary); @@ -83,7 +83,7 @@ public function load(ObjectManager $manager): void $external->setFirstName('Joe'); $external->setMiddleName(''); $external->setLastName('Bloggs'); - $external->setEmail('joe@gewisdb.local'); + $external->setEmail('joe@example.com'); $external->setBirth(new DateTime('1999-01-01')); $external->setGeneration(2017); $external->setType(MembershipTypes::External); @@ -100,7 +100,7 @@ public function load(ObjectManager $manager): void $graduate->setFirstName('Jack'); $graduate->setMiddleName('van'); $graduate->setLastName('Lint'); - $graduate->setEmail('vanlint@gewisdb.local'); + $graduate->setEmail('vanlint@example.com'); $graduate->setBirth(new DateTime('1932-09-01')); $graduate->setGeneration(1989); $graduate->setType(MembershipTypes::Graduate); diff --git a/module/Database/view/database/member/show.phtml b/module/Database/view/database/member/show.phtml index e6af4901f..eb0cabc82 100644 --- a/module/Database/view/database/member/show.phtml +++ b/module/Database/view/database/member/show.phtml @@ -100,7 +100,7 @@ use Laminas\View\Renderer\PhpRenderer;
    - + diff --git a/module/Report/src/Service/Member.php b/module/Report/src/Service/Member.php index 69f01b983..82ac318da 100644 --- a/module/Report/src/Service/Member.php +++ b/module/Report/src/Service/Member.php @@ -6,6 +6,7 @@ use Database\Mapper\Member as MemberMapper; use Database\Model\Address as DatabaseAddressModel; +use Database\Model\MailingListMember as DatabaseMailingListMemberModel; use Database\Model\Member as DatabaseMemberModel; use Doctrine\ORM\EntityManager; use Laminas\ProgressBar\Adapter\Console; @@ -13,9 +14,11 @@ use LogicException; use Report\Model\Address as ReportAddressModel; use Report\Model\MailingList as ReportMailingListModel; +use Report\Model\MailingListMember as ReportMailingListMemberModel; use Report\Model\Member as ReportMemberModel; use function array_diff; +use function array_filter; use function array_map; use function count; @@ -102,9 +105,17 @@ public function generateLists( $reportLists = array_map(static function ($list) { return $list->getMailingList()->getName(); }, $reportMember->getMailingListMemberships()->toArray()); - $lists = array_map(static function ($list) { - return $list->getMailingList()->getName(); - }, $member->getMailingListMemberships()->toArray()); + $lists = array_map( + static function ($list) { + return $list->getMailingList()->getName(); + }, + array_filter( + $member->getMailingListMemberships()->toArray(), + static function (DatabaseMailingListMemberModel $list) use ($reportMember) { + return !$list->isToBeDeleted() && $list->getEmail() === $reportMember->getEmail(); + }, + ), + ); foreach (array_diff($lists, $reportLists) as $list) { $reportList = $reportListRepo->find($list); @@ -113,8 +124,11 @@ public function generateLists( throw new LogicException('mailing list missing from reportdb'); } - // TODO: Add list to report member - // $reportMember->addList($reportList); + $reportMailingListMember = new ReportMailingListMemberModel(); + $reportMailingListMember->setMailingList($reportList); + $reportMailingListMember->setEmail($reportMember->getEmail()); + + $reportMember->addList($reportMailingListMember); $this->emReport->persist($reportList); } @@ -125,8 +139,13 @@ public function generateLists( throw new LogicException('mailing list missing from reportdb'); } - // TODO: Remove list - $this->emReport->persist($reportList); + foreach ($reportMember->getMailingListMemberships() as $repMLM) { + if ($repMLM->getMailingList() !== $list) { + continue; + } + + $this->emReport->remove($repMLM); + } } }
    escapeHtml($list->getName()) ?> (escapeHtml($list->getMailmanId())?>)escapeHtml($list->getName()) ?> (escapeHtml($list->getMailmanList()->getMailmanId())?>) escapeHtml($list->getNlDescription()) ?> escapeHtml($list->getEnDescription()) ?> translate('Membership Ends On (Expires On)') ?> getMembershipEndsOn()) ? $member->getMembershipEndsOn()->format('l j F Y') : $this->translate('N/A'), + (null !== $member->getMembershipEndsOn()) ? $member->getMembershipEndsOn()->format('l j F Y') : $this->translate('n/a'), $member->getExpiration()->format('l j F Y') )?> getType() || MembershipTypes::Graduate === $member->getType()): ?> diff --git a/module/Database/view/database/settings/lists.phtml b/module/Database/view/database/settings/lists.phtml index 4310d99a9..a081a5635 100644 --- a/module/Database/view/database/settings/lists.phtml +++ b/module/Database/view/database/settings/lists.phtml @@ -26,7 +26,7 @@ use Laminas\View\Renderer\PhpRenderer; translate('Last fetch of mailman lists'), - $mailmanLastFetch->format('Y-m-d H:i:s') + $mailmanLastFetch?->format('Y-m-d H:i:s') ?: $this->translate('never') ) ?>
    @@ -44,7 +44,8 @@ use Laminas\View\Renderer\PhpRenderer;
    escapeHtml($list->getName()) ?> (escapeHtml($list->getMailmanList()->getMailmanId())?>)escapeHtml($list->getName()) ?> + (escapeHtml($list->getMailmanList()?->getMailmanId() ?: $this->translate('n/a'))?>) escapeHtml($list->getNlDescription()) ?> escapeHtml($list->getEnDescription()) ?>