diff --git a/.editorconfig b/.editorconfig index d51908caf..a3e5a0fc1 100644 --- a/.editorconfig +++ b/.editorconfig @@ -18,10 +18,8 @@ max_line_length = 80 [COMMIT_EDITMSG] max_line_length = 0 -<<<<<<< Updated upstream -======= [*.{js, vue, ts}] indent_size = 2 indent_style = space ->>>>>>> Stashed changes + diff --git a/.gitignore b/.gitignore index 7e0372d3c..38d06d315 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,5 @@ docs/build/ /.php-cs-fixer.cache /.idea/ /.psalm/ + +node_modules/* diff --git a/.php_cs.dist.php b/.php_cs.dist.php index 843d215ff..bf15b5876 100644 --- a/.php_cs.dist.php +++ b/.php_cs.dist.php @@ -1,12 +1,5 @@ +* [person][export] Fixed: rename the alias for `accompanying_period` to `acp` in filter associated with person +* [activity][export] Feature: improve label for aliases in "Filter by activity type" +* [activity][export] DX/Feature: use of an `ActivityTypeRepositoryInterface` instead of the old-style EntityRepository +* [person][export] Fixed: some inconsistency with date filter on accompanying courses +* [person][export] Fixed: use left join for related entities in accompanying course aggregators * [workflow] Feature: allow user to copy and send manually the access link for the workflow * [workflow] Feature: show the email addresses that received an access link for the workflow @@ -32,6 +37,9 @@ and this project adheres to * [social_action]: only show active objectives (https://gitlab.com/champs-libres/departement-de-la-vendee/chill/-/issues/625) * [household]: Reposition and cut button for enfant hors menage have been deleted (https://gitlab.com/champs-libres/departement-de-la-vendee/chill/-/issues/620) * [admin]: Add crud for composition type in admin (https://gitlab.com/champs-libres/departement-de-la-vendee/chill/-/issues/611) +* [social_action]: only show active objectives (https://gitlab.com/champs-libres/departement-de-la-vendee/chill/-/issues/625) + +## Test releases ### 2022-05-30 diff --git a/composer.json b/composer.json index aaeb8ebca..45d7ca832 100644 --- a/composer.json +++ b/composer.json @@ -19,10 +19,12 @@ "graylog2/gelf-php": "^1.5", "knplabs/knp-menu-bundle": "^3.0", "knplabs/knp-time-bundle": "^1.12", + "knpuniversity/oauth2-client-bundle": "^2.10", "league/csv": "^9.7.1", "nyholm/psr7": "^1.4", "ocramius/package-versions": "^1.10 || ^2", "odolbeau/phone-number-bundle": "^3.6", + "ovh/ovh": "^3.0", "phpoffice/phpspreadsheet": "^1.16", "ramsey/uuid-doctrine": "^1.7", "sensio/framework-extra-bundle": "^5.5", @@ -36,6 +38,7 @@ "symfony/http-foundation": "^4.4", "symfony/intl": "^4.4", "symfony/mailer": "^5.4", + "symfony/messenger": "^5.4", "symfony/mime": "^5.4", "symfony/monolog-bundle": "^3.5", "symfony/security-bundle": "^4.4", @@ -49,6 +52,7 @@ "symfony/webpack-encore-bundle": "^1.11", "symfony/workflow": "^4.4", "symfony/yaml": "^4.4", + "thenetworg/oauth2-azure": "^2.0", "twig/extra-bundle": "^3.0", "twig/intl-extra": "^3.0", "twig/markdown-extra": "^3.3", diff --git a/docs/source/_static/code/exports/BirthdateFilter.php b/docs/source/_static/code/exports/BirthdateFilter.php index dfa222519..64c1d53a9 100644 --- a/docs/source/_static/code/exports/BirthdateFilter.php +++ b/docs/source/_static/code/exports/BirthdateFilter.php @@ -1,12 +1,5 @@ getQuery()->getResult(Query::HYDRATE_SCALAR); + return $query->getQuery()->getResult(Query::HYDRATE_SCALAR); } public function getTitle() @@ -113,9 +105,9 @@ class CountPerson implements ExportInterface return $qb; } - public function requiredRole() + public function requiredRole(): string { - return new Role(PersonVoter::STATS); + return PersonVoter::STATS; } public function supportsModifiers() diff --git a/docs/source/development/cronjob.rst b/docs/source/development/cronjob.rst new file mode 100644 index 000000000..df72fa922 --- /dev/null +++ b/docs/source/development/cronjob.rst @@ -0,0 +1,93 @@ + +.. Copyright (C) 2014-2023 Champs Libres Cooperative SCRLFS + Permission is granted to copy, distribute and/or modify this document + under the terms of the GNU Free Documentation License, Version 1.3 + or any later version published by the Free Software Foundation; + with no Invariant Sections, no Front-Cover Texts, and no Back-Cover Texts. + A copy of the license is included in the section entitled "GNU + Free Documentation License". + +.. _cronjob: + +Cron jobs +********* + +Some tasks must be executed regularly: refresh some materialized views, remove old data, ... + +For this purpose, one can programmatically implements a "cron job", which will be scheduled by a specific command. + +The command :code:`chill:cron-job:execute` +========================================== + +The command :code:`chill:cron-job:execute` will schedule a task, one by one. In a classical implementation, it should +be executed every 15 minutes (more or less), to ensure that every task can be executed. + +.. warning:: + + This command should not be executed in parallel. The installer should ensure that two job are executed concurrently. + +How to implements a cron job ? +============================== + +Implements a :code:`Chill\MainBundle\Cron\CronJobInterface`. Here is an example: + +.. code-block:: php + + namespace Chill\MainBundle\Service\Something; + + use Chill\MainBundle\Cron\CronJobInterface; + use Chill\MainBundle\Entity\CronJobExecution; + use DateInterval; + use DateTimeImmutable; + + class MyCronJob implements CronJobInterface + { + public function canRun(?CronJobExecution $cronJobExecution): bool + { + // the parameter $cronJobExecution contains data about the last execution of the cronjob + // if it is null, it should be executed immediatly + if (null === $cronJobExecution) { + return true; + } + + if ($cronJobExecution->getKey() !== $this->getKey()) { + throw new UnexpectedValueException(); + } + + // this cron job should be executed if the last execution is greater than one day, but only during the night + + $now = new DateTimeImmutable('now'); + + return $cronJobExecution->getLastStart() < $now->sub(new DateInterval('P1D')) + && in_array($now->format('H'), self::ACCEPTED_HOURS, true) + // introduce a random component to ensure a roll of task execution when multiple instances are hosted on same machines + && mt_rand(0, 5) === 0; + } + + public function getKey(): string + { + return 'arbitrary-and-unique-key'; + } + + public function run(): void + { + // here, we execute the command + } + } + +How are cron job scheduled ? +============================ + +If the command :code:`chill:cron-job:execute` is run with one or more :code:`job` argument, those jobs are run, **without checking that the job can run** (the method :code:`canRun` is not executed). + +If any :code:`job` argument is given, the :code:`CronManager` schedule job with those steps: + +* the tasks are ordered, with: + * a priority is given for tasks that weren't never executed; + * then, the tasks are ordered, the last executed are the first in the list +* then, for each tasks, and in the given order, the first task where :code:`canRun` return :code:`TRUE` will be executed. + +The command :code:`chill:cron-job:execute` execute **only one** task. + + + diff --git a/docs/source/development/index.rst b/docs/source/development/index.rst index f35bc12db..52c541c8e 100644 --- a/docs/source/development/index.rst +++ b/docs/source/development/index.rst @@ -34,6 +34,7 @@ As Chill rely on the `symfony `_ framework, reading the fram Useful snippets manual/index.rst Assets + Cron Jobs Layout and UI ************** diff --git a/docs/source/development/pagination/example.php b/docs/source/development/pagination/example.php index 4f92eac18..0d79395a7 100644 --- a/docs/source/development/pagination/example.php +++ b/docs/source/development/pagination/example.php @@ -1,12 +1,5 @@ `_. The postal codes are loaded from `the official list of +postal codes `_ + +.. code-block:: bash + + # first, load postal codes + bin/console chill:main:postal-code:load:FR + # then, load all addresses, by departement (multiple departement can be loaded by repeating the departement code + bin/console chill:main:address-ref-from-bano 57 54 51 + +In Belgium +========== + +Addresses are prepared from the `BeST Address data `_. + +Postal code are loaded from this database. There is no need to load postal codes from another source (actually, this is strongly discouraged). + +The data are prepared for Chill (`See this repository `_). +One can select postal code by his first number (:code:`1xxx` for postal codes from 1000 to 1999), or a limited list for development purpose. + +.. code-block:: bash + + # load postal code from 1000 to 3999: + bin/console chill:main:address-ref-from-best-addresse 1xxx 2xxx 3xxx + + # load only an extract (for dev purposes) + bin/console chill:main:address-ref-from-best-addresse extract + + # load full addresses (discouraged) + bin/console chill:main:address-ref-from-best-addresse full + +.. note:: + + There is a possibility to load the full list of addresses is discouraged: the loading is optimized with smaller extracts. + + Once you load the full list, it is not possible to load smaller extract: each extract loaded **after** will not + delete the addresses loaded with the full extract (and some addresses will be present twice). diff --git a/docs/source/installation/msgraph-configure.rst b/docs/source/installation/msgraph-configure.rst new file mode 100644 index 000000000..2a0a17882 --- /dev/null +++ b/docs/source/installation/msgraph-configure.rst @@ -0,0 +1,320 @@ + +Configure Chill for calendar sync and SSO with Microsoft Graph (Outlook) +======================================================================== + +Chill offers the possibility to: + +* authenticate users using Microsoft Graph, with relatively small adaptations; +* synchronize calendar in both ways (`see the user manual for a large description of the feature `_). + +Both can be configured separately (synchronising calendars without SSO, or SSO without calendar). When calendar sync is configured without SSL, the user's email address is the key to associate Chill's users with Microsoft's ones. + +Configure SSO +------------- + +On Azure side +************* + +Configure an app with the Azure interface, and give it the name of your choice. + +Grab the tenant's ID for your app, which is visible on the main tab "Vue d'ensemble": + +.. figure:: ./saml_login_id_general.png + +This the variable which will be named :code:`SAML_IDP_APP_UUID`. + +Go to the "Single sign-on" ("Authentication unique") section. Choose "SAML" as protocol, and fill those values: + +.. figure:: ./saml_login_1.png + +1. The :code:`entityId` seems to be arbitrary. This will be your variable :code:`SAML_ENTITY_ID`; +2. The url response must be your Chill's URL appended by :code:`/saml/acs` +3. The only used attributes is :code:`emailaddress`, which must match the user's email one. + +.. figure:: ./saml_login_2.png + +You must download the certificate, as base64. The format for the download is :code:`cer`: you will remove the first and last line (the ones with :code:`-----BEGIN CERTIFICATE-----` and :code:`-----END CERTIFICATE-----`), and remove all the return line. The final result should be something as :code:`MIIAbcdef...XyZA=`. + +This certificat will be your :code:`SAML_IDP_X509_CERT` variable. + +The url login will be filled automatically with your tenant id. + +Do not forget to provider user's accesses to your app, using the "Utilisateurs et groupes" tab: + +.. figure:: ./saml_login_appro.png + + +You must know have gathered all the required variables for SSO: + +.. code-block:: + + SAML_BASE_URL=https://test.chill.be # must be + SAML_ENTITY_ID=https://test.chill.be # must match the one entered + SAML_IDP_APP_UUID=42XXXXXX-xxxx-xxxx-xxxx-xxxxxxxxxxxx + SAML_IDP_X509_CERT: MIIC...E8u3bk # truncated + +Configure chill app +******************* + +* add the bundle :code:`hslavich/oneloginsaml-bundle` +* add the configuration file (see example above) +* configure the security part (see example above) +* add a user SAML factory into your src, and register it + + +.. code-block:: yaml + + # config/packages/hslavich_onelogin.yaml + + parameters: + saml_base_url: '%env(resolve:SAML_BASE_URL)%' + saml_entity_id: '%env(resolve:SAML_ENTITY_ID)%' + saml_idp_x509cert: '%env(resolve:SAML_IDP_X509_CERT)%' + saml_idp_app_uuid: '%env(resolve:SAML_IDP_APP_UUID)%' + + + hslavich_onelogin_saml: + # Basic settings + idp: + entityId: 'https://sts.windows.net/%saml_idp_app_uuid%/' + singleSignOnService: + url: 'https://login.microsoftonline.com/%saml_idp_app_uuid%/saml2' + binding: 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect' + singleLogoutService: + url: 'https://login.microsoftonline.com/%saml_idp_app_uuid%/saml2' + binding: 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect' + x509cert: '%saml_idp_x509cert%' + sp: + entityId: '%saml_entity_id%' + assertionConsumerService: + url: '%saml_base_url%/saml/acs' + binding: 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST' + singleLogoutService: + url: '%saml_base_url%/saml/' + binding: 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect' + privateKey: '' + # Optional settings. + baseurl: '%saml_base_url%/saml' + strict: true + debug: true + security: + nameIdEncrypted: false + authnRequestsSigned: false + logoutRequestSigned: false + logoutResponseSigned: false + wantMessagesSigned: false + wantAssertionsSigned: false + wantNameIdEncrypted: false + requestedAuthnContext: true + signMetadata: false + wantXMLValidation: true + signatureAlgorithm: 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256' + digestAlgorithm: 'http://www.w3.org/2001/04/xmlenc#sha256' + contactPerson: + technical: + givenName: 'Tech User' + emailAddress: 'techuser@example.com' + support: + givenName: 'Support User' + emailAddress: 'supportuser@example.com' + organization: + en: + name: 'Example' + displayname: 'Example' + url: 'http://example.com' + + +.. code-block:: yaml + + # config/security.yaml + # merge this with other existing configurations + + security: + + + providers: + saml_provider: + # Loads user from user repository + entity: + class: Chill\MainBundle\Entity\User + property: username + + firewalls: + + + default: + # saml part: + saml: + username_attribute: http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress + # weird behaviour in dev environment... configuration seems different + # username_attribute: http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name + # Use the attribute's friendlyName instead of the name + use_attribute_friendly_name: false + user_factory: user_from_saml_factory + persist_user: true + check_path: saml_acs + login_path: saml_login + logout: + path: /saml/logout + + +.. code-block:: php + + // src/Security/SamlFactory.php + + namespace App\Security; + + use Chill\MainBundle\Entity\User; + use Hslavich\OneloginSamlBundle\Security\Authentication\Token\SamlTokenInterface; + use Hslavich\OneloginSamlBundle\Security\User\SamlUserFactoryInterface; + + class UserSamlFactory implements SamlUserFactoryInterface + { + public function createUser(SamlTokenInterface $token) + { + $attributes = $token->getAttributes(); + $user = new User(); + $user->setUsername($attributes['http://schemas.microsoft.com/identity/claims/displayname'][0]); + $user->setLabel($attributes['http://schemas.microsoft.com/identity/claims/displayname'][0]); + $user->setPassword(''); + $user->setEmail($attributes['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'][0]); + $user->setAttributes($attributes); + + return $user; + } + } + + + +Configure sync +-------------- + +The sync processe might be configured in the same app, or into a different app. + +The synchronization processes use Oauth2.0 for authentication and authorization. + +.. note:: + + Two flows are in use: + + * we authenticate "on behalf of a user", to allow users to see their own calendar or other user's calendar into the web interface. + + Typically, when the page is loaded, Chill first check that an authorization token exists. If not, the user is redirected to Microsoft Azure for authentification and a new token is grabbed (most of the times, this is transparent for users). + + * Chill also acts "as a machine", to synchronize calendars with a daemon background. + +One can access the configuration using this screen (it is quite well hidden into the multiple of tabs): + +.. figure:: ./oauth_app_registration.png + + You can find the oauth configuration on the "Securité > Autorisations" tab, and click on "application registration" (not translated). + +Add a redirection URI for you authentification: + +.. figure:: ./oauth_api_authentification.png + + The URI must be "your chill public url" with :code:`/connect/azure/check` at the end. + +Allow some authorizations for your app: + +.. figure:: ./oauth_api_autorisees.png + +Take care of the separation between autorization "on behalf of a user" (déléguée), or "for a machine" (application). + +Some explanation: + +* Users must be allowed to read their user profile (:code:`User.Read`), and the profile of other users (:code:`User.ReadBasicAll`); +* They must be allowed to read their calendar (:code:`Calendars.Read`), and the calendars shared with them (:code:`Calendars.Read.Shared`); + +The sync daemon must have write access: + +* the daemon must be allowed to read all users and their profile, to establish a link between them and the Chill's users: (:code:`Users.Read.All`); +* it must also be allowed to read and write into the calendars (:code:`Calendars.ReadWrite.All`) +* for sending invitation to other users, the permission (:code:`Mail.Send`) must be granted. + +At this step, you might choose to accept those permissions for all users, or let them do it by yourself. + +Grab your client id: + +.. figure:: ./oauth_api_client_id.png + +This will be your :code:`OAUTH_AZURE_CLIENT_ID` variable. + + +Generate a secret: + +.. figure:: ./oauth_api_secret.png + +This will be your :code:`OAUTH_AZURE_CLIENT_SECRET` variable. + +And get you azure's tenant id, which is the same as the :code:`SAML_IDP_APP_UUID` (see above). + +Your variables will be: + +.. code-block:: + + OAUTH_AZURE_CLIENT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + OAUTH_AZURE_CLIENT_TENANT=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + OAUTH_AZURE_CLIENT_SECRET: 3-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + +Then, configure chill: + +Enable the calendar sync with microsoft azure: + +.. code-block:: yaml + + # config/packages/chill_calendar.yaml + + chill_calendar: + remote_calendars_sync: + microsoft_graph: + enabled: true + +and configure the oauth client: + +.. code-block:: yaml + + # config/packages/knp_oauth2_client.yaml + knpu_oauth2_client: + clients: + azure: + type: azure + client_id: '%env(OAUTH_AZURE_CLIENT_ID)%' + client_secret: '%env(OAUTH_AZURE_CLIENT_SECRET)%' + redirect_route: chill_calendar_remote_connect_azure_check + redirect_params: { } + tenant: '%env(OAUTH_AZURE_CLIENT_TENANT)%' + url_api: 'https://graph.microsoft.com/' + default_end_point_version: '2.0' + + +You can now process for the first api authorization on the application side, (unless you did it in the Azure interface), and get a first token, by using : + +:code:`bin/console chill:calendar:msgraph-grant-admin-consent` + +This will generate a url that you can use to grant your app for your tenant. The redirection may fails in the browser, but this is not relevant: if you get an authorization token in the CLI, the authentication works. + +Run the processes to synchronize +-------------------------------- + +The calendar synchronization is processed using symfony messenger. It seems to be intersting to configure a queue (in the postgresql database it is the most simple way), and to run a worker for synchronization, at least in production. + +The association between chill's users and Microsoft's users is done by this cli command: + +.. code-block:: + + bin/console chill:calendar:msgraph-user-map-subscribe + +This command: + +* will associate the Microsoft's user metadata in our database; +* and, most important, create a subscription to get notification when the user alter his calendar, to sync chill's event and ranges in sync. + +The subscription least at most 3 days. This command should be runned: + +* at least each time a user is added; +* and, at least, every three days. + +In production, we advise to run it at least every day to get the sync working. + + diff --git a/docs/source/installation/oauth_api_authentification.png b/docs/source/installation/oauth_api_authentification.png new file mode 100644 index 000000000..f6ecad32b Binary files /dev/null and b/docs/source/installation/oauth_api_authentification.png differ diff --git a/docs/source/installation/oauth_api_autorisees.png b/docs/source/installation/oauth_api_autorisees.png new file mode 100644 index 000000000..41222f971 Binary files /dev/null and b/docs/source/installation/oauth_api_autorisees.png differ diff --git a/docs/source/installation/oauth_api_client_id.png b/docs/source/installation/oauth_api_client_id.png new file mode 100644 index 000000000..7aa620ba7 Binary files /dev/null and b/docs/source/installation/oauth_api_client_id.png differ diff --git a/docs/source/installation/oauth_api_secret.png b/docs/source/installation/oauth_api_secret.png new file mode 100644 index 000000000..7e2fc1c28 Binary files /dev/null and b/docs/source/installation/oauth_api_secret.png differ diff --git a/docs/source/installation/oauth_app_registration.png b/docs/source/installation/oauth_app_registration.png new file mode 100644 index 000000000..757d34f00 Binary files /dev/null and b/docs/source/installation/oauth_app_registration.png differ diff --git a/docs/source/installation/prod-calendar-sms-sending.rst b/docs/source/installation/prod-calendar-sms-sending.rst new file mode 100644 index 000000000..6529b95d9 --- /dev/null +++ b/docs/source/installation/prod-calendar-sms-sending.rst @@ -0,0 +1,27 @@ + +Send short messages (SMS) with calendar bundle +============================================== + +To activate the sending of messages, you should run this command on a regularly basis (using, for instance, a cronjob): + +.. code-block:: bash + + bin/console chill:calendar:send-short-messages + +A transporter must be configured for the message to be effectively sent. + +Configure OVH Transporter +------------------------- + +Currently, this is the only one transporter available. + +For configuring this, simply add this config variable in your environment: + +```env +SHORT_MESSAGE_DSN=ovh://applicationKey:applicationSecret@endpoint?consumerKey=xxxx&sender=yyyy&service_name=zzzz +``` + +In order to generate the application key, secret, and consumerKey, refers to their `documentation `_. + +Before to be able to send your first sms, you must enable your account, grab some credits, and configure a sender. The service_name is an internal configuration generated by OVH. + diff --git a/docs/source/installation/prod.rst b/docs/source/installation/prod.rst new file mode 100644 index 000000000..f51141e8d --- /dev/null +++ b/docs/source/installation/prod.rst @@ -0,0 +1,63 @@ +.. Copyright (C) 2014-2019 Champs Libres Cooperative SCRLFS + Permission is granted to copy, distribute and/or modify this document + under the terms of the GNU Free Documentation License, Version 1.3 + or any later version published by the Free Software Foundation; + with no Invariant Sections, no Front-Cover Texts, and no Back-Cover Texts. + A copy of the license is included in the section entitled "GNU + Free Documentation License". + +.. _prod: + +Installation for production +########################### + +An installation use these services, which are deployed using docker containers: + +* a php-fpm image, which run the Php and Symfony code for Chill; +* a nginx image, which serves the assets, and usually proxy the php requests to the fpm image; +* a redis server, which stores the cache, sessions (this is currently hardcoded in the php image), and some useful keys (like wopi locks); +* a postgresql database. The use of postgresql is mandatory; +* a relatorio service, which transform odt templates to full documents (replacing the placeholders); + +Some external services: + +* (required) an openstack object store, configured with `temporary url ` configured (no openstack users is required). This is currently the only way to store documents from chill; +* a mailer service (SMTP) +* (optional) a service for verifying phone number. Currently, only Twilio is possible; +* (optional) a service for sending Short Messages (SMS). Currently, only Ovh is possible; + +The `docker-compose.yaml` file of chill app is a basis for a production install. The environment variable in the ```.env``` and ```.env.prod``` should be overriden by environment variables, or ```.env.local``` files. + +This should be adapted to your needs: + +* The image for php and nginx apps are pre-compiled images, with the default configuration and bundle. If they do not fullfill your needs, you should compile your own images. + + .. TODO: + + As the time of writing (2022-07-03) those images are not published yet. + +* Think about how you will backup your database. Some adminsys find easier to store database outside of docker, which might be easier to administrate or replicate. + +Cron jobs +========= + +The command :code:`chill:cron-job:execute` should be executed every 15 minutes (more or less). + +This command should never be executed concurrently. It should be not have more than one process for a single instance. + +Post-install tasks +================== + +- import addresses. See :ref:`addresses`. + + +Tweak symfony messenger +======================= + +Calendar sync is processed using symfony messenger. + +You can tweak the configuration + +Going further: + +* Configure the saml login and synchronisation with Outlook api diff --git a/docs/source/installation/saml_login_1.png b/docs/source/installation/saml_login_1.png new file mode 100644 index 000000000..ec62a7acb Binary files /dev/null and b/docs/source/installation/saml_login_1.png differ diff --git a/docs/source/installation/saml_login_2.png b/docs/source/installation/saml_login_2.png new file mode 100644 index 000000000..736b74579 Binary files /dev/null and b/docs/source/installation/saml_login_2.png differ diff --git a/docs/source/installation/saml_login_appro.png b/docs/source/installation/saml_login_appro.png new file mode 100644 index 000000000..526eff63d Binary files /dev/null and b/docs/source/installation/saml_login_appro.png differ diff --git a/docs/source/installation/saml_login_id_general.png b/docs/source/installation/saml_login_id_general.png new file mode 100644 index 000000000..3062e6515 Binary files /dev/null and b/docs/source/installation/saml_login_id_general.png differ diff --git a/exports_alias_conventions.csv b/exports_alias_conventions.csv new file mode 100644 index 000000000..ab32cda8e --- /dev/null +++ b/exports_alias_conventions.csv @@ -0,0 +1,63 @@ +Entity,Join,Attribute,Alias +AccompanyingPeriod::class,,,acp +,AccompanyingPeriodWork::class,acp.works,acpw +,AccompanyingPeriodParticipation::class,acp.participations,acppart +,Location::class,acp.administrativeLocation,acploc +,ClosingMotive::class,acp.closingMotive,acpmotive +,UserJob::class,acp.job,acpjob +,Origin::class,acp.origin,acporigin +,Scope::class,acp.scopes,acpscope +,SocialIssue::class,acp.socialIssues,acpsocialissue +,User::class,acp.user,acpuser +AccompanyingPeriodWork::class,,,acpw +,AccompanyingPeriodWorkEvaluation::class,acpw.accompanyingPeriodWorkEvaluations,workeval +,User::class,acpw.referrers,acpwuser +,SocialAction::class,acpw.socialAction,acpwsocialaction +,Goal::class,acpw.goals,goal +,Result::class,acpw.results,result +AccompanyingPeriodParticipation::class,,,acppart +,Person::class,acppart.person,partperson +AccompanyingPeriodWorkEvaluation::class,,,workeval +,Evaluation::class,workeval.evaluation,eval +Goal::class,,,goal +,Result::class,goal.results,goalresult +Person::class,,,person +,Center::class,person.center,center +,HouseholdMember::class,partperson.householdParticipations,householdmember +,MaritalStatus::class,person.maritalStatus,personmarital +,VendeePerson::class,,vp +,VendeePersonMineur::class,,vpm +ResidentialAddress::class,,,resaddr +,ThirdParty::class,resaddr.hostThirdParty,tparty +ThirdParty::class,,,tparty +,ThirdPartyCategory::class,tparty.categories,tpartycat +HouseholdMember::class,,,householdmember +,Household::class,householdmember.household,household +,Person::class,householdmember.person,memberperson +,,memberperson.center,membercenter +Household::class,,,household +,HouseholdComposition::class,household.compositions,composition +Activity::class,,,activity +,Person::class,activity.person,actperson +,AccompanyingPeriod::class,activity.accompanyingPeriod,acp +,Person::class,activity_person_having_activity.person,person_person_having_activity +,ActivityReason::class,activity_person_having_activity.reasons,reasons_person_having_activity +,ActivityType::class,activity.activityType,acttype +,Location::class,activity.location,actloc +,SocialAction::class,activity.socialActions,actsocialaction +,SocialIssue::class,activity.socialIssues,actsocialssue +,ThirdParty::class,activity.thirdParties,acttparty +,User::class,activity.user,actuser +,User::class,activity.users,actusers +,ActivityReason::class,activity.reasons,actreasons +,Center::class,actperson.center,actcenter +ActivityReason::class,,,actreasons +,ActivityReasonCategory::class,actreason.category,actreasoncat +Calendar::class,,,cal +,CancelReason::class,cal.cancelReason,calcancel +,Location::class,cal.location,calloc +,User::class,cal.user,caluser +VendeePerson::class,,,vp +,SituationProfessionelle::class,vp.situationProfessionelle,vpprof +,StatutLogement::class,vp.statutLogement,vplog +,TempsDeTravail::class,vp.tempsDeTravail,vptt diff --git a/exports_alias_conventions.md b/exports_alias_conventions.md new file mode 100644 index 000000000..fd7844691 --- /dev/null +++ b/exports_alias_conventions.md @@ -0,0 +1,74 @@ +# Export conventions + + +Add condition with distinct alias on each export join clauses (Indicators + Filters + Aggregators) + +These are alias conventions : + +| Entity | Join | Attribute | Alias | +|:----------------------------------------|:----------------------------------------|:-------------------------------------------|:---------------------------------------| +| AccompanyingPeriod::class | | | acp | +| | AccompanyingPeriodWork::class | acp.works | acpw | +| | AccompanyingPeriodParticipation::class | acp.participations | acppart | +| | Location::class | acp.administrativeLocation | acploc | +| | ClosingMotive::class | acp.closingMotive | acpmotive | +| | UserJob::class | acp.job | acpjob | +| | Origin::class | acp.origin | acporigin | +| | Scope::class | acp.scopes | acpscope | +| | SocialIssue::class | acp.socialIssues | acpsocialissue | +| | User::class | acp.user | acpuser | +| | AccompanyingPeriopStepHistory::class | acp.stepHistories | acpstephistories | +| AccompanyingPeriodWork::class | | | acpw | +| | AccompanyingPeriodWorkEvaluation::class | acpw.accompanyingPeriodWorkEvaluations | workeval | +| | User::class | acpw.referrers | acpwuser | +| | SocialAction::class | acpw.socialAction | acpwsocialaction | +| | Goal::class | acpw.goals | goal | +| | Result::class | acpw.results | result | +| AccompanyingPeriodParticipation::class | | | acppart | +| | Person::class | acppart.person | partperson | +| AccompanyingPeriodWorkEvaluation::class | | | workeval | +| | Evaluation::class | workeval.evaluation | eval | +| Goal::class | | | goal | +| | Result::class | goal.results | goalresult | +| Person::class | | | person | +| | Center::class | person.center | center | +| | HouseholdMember::class | partperson.householdParticipations | householdmember | +| | MaritalStatus::class | person.maritalStatus | personmarital | +| | VendeePerson::class | | vp | +| | VendeePersonMineur::class | | vpm | +| | CurrentPersonAddress::class | person.currentPersonAddress | currentPersonAddress (on a given date) | +| ResidentialAddress::class | | | resaddr | +| | ThirdParty::class | resaddr.hostThirdParty | tparty | +| ThirdParty::class | | | tparty | +| | ThirdPartyCategory::class | tparty.categories | tpartycat | +| HouseholdMember::class | | | householdmember | +| | Household::class | householdmember.household | household | +| | Person::class | householdmember.person | memberperson | +| | | memberperson.center | membercenter | +| Household::class | | | household | +| | HouseholdComposition::class | household.compositions | composition | +| Activity::class | | | activity | +| | Person::class | activity.person | actperson | +| | AccompanyingPeriod::class | activity.accompanyingPeriod | acp | +| | Person::class | activity\_person\_having\_activity.person | person\_person\_having\_activity | +| | ActivityReason::class | activity\_person\_having\_activity.reasons | reasons\_person\_having\_activity | +| | ActivityType::class | activity.activityType | acttype | +| | Location::class | activity.location | actloc | +| | SocialAction::class | activity.socialActions | actsocialaction | +| | SocialIssue::class | activity.socialIssues | actsocialssue | +| | ThirdParty::class | activity.thirdParties | acttparty | +| | User::class | activity.user | actuser | +| | User::class | activity.users | actusers | +| | ActivityReason::class | activity.reasons | actreasons | +| | Center::class | actperson.center | actcenter | +| | Person::class | activity.createdBy | actcreator | +| ActivityReason::class | | | actreasons | +| | ActivityReasonCategory::class | actreason.category | actreasoncat | +| Calendar::class | | | cal | +| | CancelReason::class | cal.cancelReason | calcancel | +| | Location::class | cal.location | calloc | +| | User::class | cal.user | caluser | +| VendeePerson::class | | | vp | +| | SituationProfessionelle::class | vp.situationProfessionelle | vpprof | +| | StatutLogement::class | vp.statutLogement | vplog | +| | TempsDeTravail::class | vp.tempsDeTravail | vptt | diff --git a/package.json b/package.json new file mode 100644 index 000000000..17b02c63a --- /dev/null +++ b/package.json @@ -0,0 +1,67 @@ +{ + "name": "chill", + "version": "2.0.0", + "devDependencies": { + "@alexlafroscia/yaml-merge": "^4.0.0", + "@apidevtools/swagger-cli": "^4.0.4", + "@babel/core": "^7.20.5", + "@babel/preset-env": "^7.20.2", + "@ckeditor/ckeditor5-build-classic": "^35.3.2", + "@ckeditor/ckeditor5-dev-utils": "^31.1.13", + "@ckeditor/ckeditor5-dev-webpack-plugin": "^31.1.13", + "@ckeditor/ckeditor5-markdown-gfm": "^35.3.2", + "@ckeditor/ckeditor5-theme-lark": "^35.3.2", + "@ckeditor/ckeditor5-vue": "^4.0.1", + "@symfony/webpack-encore": "^4.1.0", + "@tsconfig/node14": "^1.0.1", + "bindings": "^1.5.0", + "bootstrap": "^5.0.1", + "chokidar": "^3.5.1", + "fork-awesome": "^1.1.7", + "jquery": "^3.6.0", + "node-sass": "^8.0.0", + "popper.js": "^1.16.1", + "postcss-loader": "^7.0.2", + "raw-loader": "^4.0.2", + "sass-loader": "^13.0.0", + "select2": "^4.0.13", + "select2-bootstrap-theme": "0.1.0-beta.10", + "style-loader": "^3.3.1", + "ts-loader": "^9.3.1", + "typescript": "^4.7.2", + "vue-loader": "^17.0.0", + "webpack": "^5.75.0", + "webpack-cli": "^5.0.1" + }, + "dependencies": { + "@fullcalendar/core": "^5.11.0", + "@fullcalendar/daygrid": "^5.11.0", + "@fullcalendar/interaction": "^5.11.0", + "@fullcalendar/list": "^5.11.0", + "@fullcalendar/timegrid": "^5.11.0", + "@fullcalendar/vue3": "^5.11.1", + "@popperjs/core": "^2.9.2", + "dropzone": "^5.7.6", + "es6-promise": "^4.2.8", + "leaflet": "^1.7.1", + "masonry-layout": "^4.2.2", + "mime": "^3.0.0", + "swagger-ui": "^4.15.5", + "vis-network": "^9.1.0", + "vue": "^3.2.37", + "vue-i18n": "^9.1.6", + "vue-multiselect": "3.0.0-alpha.2", + "vue-toast-notification": "^2.0", + "vuex": "^4.0.0" + }, + "browserslist": [ + "Firefox ESR" + ], + "scripts": { + "dev-server": "encore dev-server", + "dev": "encore dev", + "watch": "encore dev --watch", + "build": "encore production --progress" + }, + "private": true +} diff --git a/phpstan-critical.neon b/phpstan-critical.neon index 1dc516834..bfbb2dc7c 100644 --- a/phpstan-critical.neon +++ b/phpstan-critical.neon @@ -5,11 +5,6 @@ parameters: count: 1 path: src/Bundle/ChillPersonBundle/Entity/AccompanyingPeriod.php - - - message: "#^Access to an undefined property Chill\\\\PersonBundle\\\\Entity\\\\Household\\\\PersonHouseholdAddress\\:\\:\\$relation\\.$#" - count: 1 - path: src/Bundle/ChillPersonBundle/Entity/Household/PersonHouseholdAddress.php - - message: "#^Access to an undefined property Chill\\\\PersonBundle\\\\Entity\\\\AccompanyingPeriod\\:\\:\\$work\\.$#" count: 1 @@ -30,11 +25,6 @@ parameters: count: 1 path: src/Bundle/ChillPersonBundle/Serializer/Normalizer/MembersEditorNormalizer.php - - - message: "#^Undefined variable\\: \\$choiceSlug$#" - count: 1 - path: src/Bundle/ChillPersonBundle/Export/Export/ListPerson.php - - message: "#^Undefined variable\\: \\$choiceSlug$#" count: 1 diff --git a/phpstan-deprecations.neon b/phpstan-deprecations.neon index 9a6d862b5..aa8506411 100644 --- a/phpstan-deprecations.neon +++ b/phpstan-deprecations.neon @@ -23,150 +23,6 @@ parameters: count: 1 path: src/Bundle/ChillActivityBundle/Entity/ActivityReasonCategory.php - - - message: """ - #^Instantiation of deprecated class Symfony\\\\Component\\\\Security\\\\Core\\\\Role\\\\Role\\: - since Symfony 4\\.3, to be removed in 5\\.0\\. Use strings as roles instead\\.$# - """ - count: 1 - path: src/Bundle/ChillActivityBundle/Export/Aggregator/ActivityReasonAggregator.php - - - - message: """ - #^Return type of method Chill\\\\ActivityBundle\\\\Export\\\\Aggregator\\\\ActivityReasonAggregator\\:\\:addRole\\(\\) has typehint with deprecated class Symfony\\\\Component\\\\Security\\\\Core\\\\Role\\\\Role\\: - since Symfony 4\\.3, to be removed in 5\\.0\\. Use strings as roles instead\\.$# - """ - count: 1 - path: src/Bundle/ChillActivityBundle/Export/Aggregator/ActivityReasonAggregator.php - - - - message: """ - #^Instantiation of deprecated class Symfony\\\\Component\\\\Security\\\\Core\\\\Role\\\\Role\\: - since Symfony 4\\.3, to be removed in 5\\.0\\. Use strings as roles instead\\.$# - """ - count: 1 - path: src/Bundle/ChillActivityBundle/Export/Aggregator/ActivityTypeAggregator.php - - - - message: """ - #^Return type of method Chill\\\\ActivityBundle\\\\Export\\\\Aggregator\\\\ActivityTypeAggregator\\:\\:addRole\\(\\) has typehint with deprecated class Symfony\\\\Component\\\\Security\\\\Core\\\\Role\\\\Role\\: - since Symfony 4\\.3, to be removed in 5\\.0\\. Use strings as roles instead\\.$# - """ - count: 1 - path: src/Bundle/ChillActivityBundle/Export/Aggregator/ActivityTypeAggregator.php - - - - message: """ - #^Instantiation of deprecated class Symfony\\\\Component\\\\Security\\\\Core\\\\Role\\\\Role\\: - since Symfony 4\\.3, to be removed in 5\\.0\\. Use strings as roles instead\\.$# - """ - count: 1 - path: src/Bundle/ChillActivityBundle/Export/Aggregator/ActivityUserAggregator.php - - - - message: """ - #^Return type of method Chill\\\\ActivityBundle\\\\Export\\\\Aggregator\\\\ActivityUserAggregator\\:\\:addRole\\(\\) has typehint with deprecated class Symfony\\\\Component\\\\Security\\\\Core\\\\Role\\\\Role\\: - since Symfony 4\\.3, to be removed in 5\\.0\\. Use strings as roles instead\\.$# - """ - count: 1 - path: src/Bundle/ChillActivityBundle/Export/Aggregator/ActivityUserAggregator.php - - - - message: """ - #^Instantiation of deprecated class Symfony\\\\Component\\\\Security\\\\Core\\\\Role\\\\Role\\: - since Symfony 4\\.3, to be removed in 5\\.0\\. Use strings as roles instead\\.$# - """ - count: 1 - path: src/Bundle/ChillActivityBundle/Export/Export/CountActivity.php - - - - message: """ - #^Return type of method Chill\\\\ActivityBundle\\\\Export\\\\Export\\\\CountActivity\\:\\:requiredRole\\(\\) has typehint with deprecated class Symfony\\\\Component\\\\Security\\\\Core\\\\Role\\\\Role\\: - since Symfony 4\\.3, to be removed in 5\\.0\\. Use strings as roles instead\\.$# - """ - count: 1 - path: src/Bundle/ChillActivityBundle/Export/Export/CountActivity.php - - - - message: """ - #^Instantiation of deprecated class Symfony\\\\Component\\\\Security\\\\Core\\\\Role\\\\Role\\: - since Symfony 4\\.3, to be removed in 5\\.0\\. Use strings as roles instead\\.$# - """ - count: 1 - path: src/Bundle/ChillActivityBundle/Export/Export/ListActivity.php - - - - message: """ - #^Return type of method Chill\\\\ActivityBundle\\\\Export\\\\Export\\\\ListActivity\\:\\:requiredRole\\(\\) has typehint with deprecated class Symfony\\\\Component\\\\Security\\\\Core\\\\Role\\\\Role\\: - since Symfony 4\\.3, to be removed in 5\\.0\\. Use strings as roles instead\\.$# - """ - count: 1 - path: src/Bundle/ChillActivityBundle/Export/Export/ListActivity.php - - - - message: """ - #^Instantiation of deprecated class Symfony\\\\Component\\\\Security\\\\Core\\\\Role\\\\Role\\: - since Symfony 4\\.3, to be removed in 5\\.0\\. Use strings as roles instead\\.$# - """ - count: 1 - path: src/Bundle/ChillActivityBundle/Export/Export/StatActivityDuration.php - - - - message: """ - #^Return type of method Chill\\\\ActivityBundle\\\\Export\\\\Export\\\\StatActivityDuration\\:\\:requiredRole\\(\\) has typehint with deprecated class Symfony\\\\Component\\\\Security\\\\Core\\\\Role\\\\Role\\: - since Symfony 4\\.3, to be removed in 5\\.0\\. Use strings as roles instead\\.$# - """ - count: 1 - path: src/Bundle/ChillActivityBundle/Export/Export/StatActivityDuration.php - - - - message: """ - #^Return type of method Chill\\\\ActivityBundle\\\\Export\\\\Filter\\\\ActivityDateFilter\\:\\:addRole\\(\\) has typehint with deprecated class Symfony\\\\Component\\\\Security\\\\Core\\\\Role\\\\Role\\: - since Symfony 4\\.3, to be removed in 5\\.0\\. Use strings as roles instead\\.$# - """ - count: 1 - path: src/Bundle/ChillActivityBundle/Export/Filter/ActivityDateFilter.php - - - - message: """ - #^Instantiation of deprecated class Symfony\\\\Component\\\\Security\\\\Core\\\\Role\\\\Role\\: - since Symfony 4\\.3, to be removed in 5\\.0\\. Use strings as roles instead\\.$# - """ - count: 1 - path: src/Bundle/ChillActivityBundle/Export/Filter/ActivityReasonFilter.php - - - - message: """ - #^Return type of method Chill\\\\ActivityBundle\\\\Export\\\\Filter\\\\ActivityReasonFilter\\:\\:addRole\\(\\) has typehint with deprecated class Symfony\\\\Component\\\\Security\\\\Core\\\\Role\\\\Role\\: - since Symfony 4\\.3, to be removed in 5\\.0\\. Use strings as roles instead\\.$# - """ - count: 1 - path: src/Bundle/ChillActivityBundle/Export/Filter/ActivityReasonFilter.php - - - - message: """ - #^Instantiation of deprecated class Symfony\\\\Component\\\\Security\\\\Core\\\\Role\\\\Role\\: - since Symfony 4\\.3, to be removed in 5\\.0\\. Use strings as roles instead\\.$# - """ - count: 1 - path: src/Bundle/ChillActivityBundle/Export/Filter/ActivityTypeFilter.php - - - - message: """ - #^Return type of method Chill\\\\ActivityBundle\\\\Export\\\\Filter\\\\ActivityTypeFilter\\:\\:addRole\\(\\) has typehint with deprecated class Symfony\\\\Component\\\\Security\\\\Core\\\\Role\\\\Role\\: - since Symfony 4\\.3, to be removed in 5\\.0\\. Use strings as roles instead\\.$# - """ - count: 1 - path: src/Bundle/ChillActivityBundle/Export/Filter/ActivityTypeFilter.php - - - - message: """ - #^Return type of method Chill\\\\ActivityBundle\\\\Export\\\\Filter\\\\PersonHavingActivityBetweenDateFilter\\:\\:addRole\\(\\) has typehint with deprecated class Symfony\\\\Component\\\\Security\\\\Core\\\\Role\\\\Role\\: - since Symfony 4\\.3, to be removed in 5\\.0\\. Use strings as roles instead\\.$# - """ - count: 1 - path: src/Bundle/ChillActivityBundle/Export/Filter/PersonHavingActivityBetweenDateFilter.php - - message: """ #^Fetching class constant class of deprecated class Symfony\\\\Component\\\\Security\\\\Core\\\\Role\\\\Role\\: @@ -359,31 +215,6 @@ parameters: count: 1 path: src/Bundle/ChillMainBundle/Entity/User.php - - - message: """ - #^Return type of method Chill\\\\MainBundle\\\\Export\\\\DirectExportInterface\\:\\:requiredRole\\(\\) has typehint with deprecated class Symfony\\\\Component\\\\Security\\\\Core\\\\Role\\\\Role\\: - since Symfony 4\\.3, to be removed in 5\\.0\\. Use strings as roles instead\\.$# - """ - count: 1 - path: src/Bundle/ChillMainBundle/Export/DirectExportInterface.php - - - - message: """ - #^Return type of method Chill\\\\MainBundle\\\\Export\\\\ExportInterface\\:\\:requiredRole\\(\\) has typehint with deprecated class Symfony\\\\Component\\\\Security\\\\Core\\\\Role\\\\Role\\: - since Symfony 4\\.3, to be removed in 5\\.0\\. Use strings as roles instead\\.$# - """ - count: 1 - path: src/Bundle/ChillMainBundle/Export/ExportInterface.php - - - - - message: """ - #^Return type of method Chill\\\\MainBundle\\\\Export\\\\ModifierInterface\\:\\:addRole\\(\\) has typehint with deprecated class Symfony\\\\Component\\\\Security\\\\Core\\\\Role\\\\Role\\: - since Symfony 4\\.3, to be removed in 5\\.0\\. Use strings as roles instead\\.$# - """ - count: 1 - path: src/Bundle/ChillMainBundle/Export/ModifierInterface.php - - message: """ #^Class Chill\\\\MainBundle\\\\Form\\\\Event\\\\CustomizeFormEvent extends deprecated class Symfony\\\\Component\\\\EventDispatcher\\\\Event\\: @@ -543,142 +374,6 @@ parameters: count: 2 path: src/Bundle/ChillPersonBundle/Entity/Person.php - - - message: """ - #^Return type of method Chill\\\\PersonBundle\\\\Export\\\\Aggregator\\\\AgeAggregator\\:\\:addRole\\(\\) has typehint with deprecated class Symfony\\\\Component\\\\Security\\\\Core\\\\Role\\\\Role\\: - since Symfony 4\\.3, to be removed in 5\\.0\\. Use strings as roles instead\\.$# - """ - count: 1 - path: src/Bundle/ChillPersonBundle/Export/Aggregator/AgeAggregator.php - - - - message: """ - #^Return type of method Chill\\\\PersonBundle\\\\Export\\\\Aggregator\\\\CountryOfBirthAggregator\\:\\:addRole\\(\\) has typehint with deprecated class Symfony\\\\Component\\\\Security\\\\Core\\\\Role\\\\Role\\: - since Symfony 4\\.3, to be removed in 5\\.0\\. Use strings as roles instead\\.$# - """ - count: 1 - path: src/Bundle/ChillPersonBundle/Export/Aggregator/CountryOfBirthAggregator.php - - - - message: """ - #^Return type of method Chill\\\\PersonBundle\\\\Export\\\\Aggregator\\\\GenderAggregator\\:\\:addRole\\(\\) has typehint with deprecated class Symfony\\\\Component\\\\Security\\\\Core\\\\Role\\\\Role\\: - since Symfony 4\\.3, to be removed in 5\\.0\\. Use strings as roles instead\\.$# - """ - count: 1 - path: src/Bundle/ChillPersonBundle/Export/Aggregator/GenderAggregator.php - - - - message: """ - #^Return type of method Chill\\\\PersonBundle\\\\Export\\\\Aggregator\\\\NationalityAggregator\\:\\:addRole\\(\\) has typehint with deprecated class Symfony\\\\Component\\\\Security\\\\Core\\\\Role\\\\Role\\: - since Symfony 4\\.3, to be removed in 5\\.0\\. Use strings as roles instead\\.$# - """ - count: 1 - path: src/Bundle/ChillPersonBundle/Export/Aggregator/NationalityAggregator.php - - - - message: """ - #^Instantiation of deprecated class Symfony\\\\Component\\\\Security\\\\Core\\\\Role\\\\Role\\: - since Symfony 4\\.3, to be removed in 5\\.0\\. Use strings as roles instead\\.$# - """ - count: 1 - path: src/Bundle/ChillPersonBundle/Export/Export/CountPerson.php - - - - message: """ - #^Return type of method Chill\\\\PersonBundle\\\\Export\\\\Export\\\\CountPerson\\:\\:requiredRole\\(\\) has typehint with deprecated class Symfony\\\\Component\\\\Security\\\\Core\\\\Role\\\\Role\\: - since Symfony 4\\.3, to be removed in 5\\.0\\. Use strings as roles instead\\.$# - """ - count: 1 - path: src/Bundle/ChillPersonBundle/Export/Export/CountPerson.php - - - - message: """ - #^Instantiation of deprecated class Symfony\\\\Component\\\\Security\\\\Core\\\\Role\\\\Role\\: - since Symfony 4\\.3, to be removed in 5\\.0\\. Use strings as roles instead\\.$# - """ - count: 1 - path: src/Bundle/ChillPersonBundle/Export/Export/ListPerson.php - - - - message: """ - #^Return type of method Chill\\\\PersonBundle\\\\Export\\\\Export\\\\ListPerson\\:\\:requiredRole\\(\\) has typehint with deprecated class Symfony\\\\Component\\\\Security\\\\Core\\\\Role\\\\Role\\: - since Symfony 4\\.3, to be removed in 5\\.0\\. Use strings as roles instead\\.$# - """ - count: 1 - path: src/Bundle/ChillPersonBundle/Export/Export/ListPerson.php - - - - message: """ - #^Call to deprecated method execute\\(\\) of class Doctrine\\\\DBAL\\\\Statement\\: - Statement\\:\\:execute\\(\\) is deprecated, use Statement\\:\\:executeQuery\\(\\) or executeStatement\\(\\) instead$# - """ - count: 1 - path: src/Bundle/ChillPersonBundle/Export/Export/ListPersonDuplicate.php - - - - message: """ - #^Instantiation of deprecated class Symfony\\\\Component\\\\Security\\\\Core\\\\Role\\\\Role\\: - since Symfony 4\\.3, to be removed in 5\\.0\\. Use strings as roles instead\\.$# - """ - count: 1 - path: src/Bundle/ChillPersonBundle/Export/Export/ListPersonDuplicate.php - - - - message: """ - #^Return type of method Chill\\\\PersonBundle\\\\Export\\\\Export\\\\ListPersonDuplicate\\:\\:requiredRole\\(\\) has typehint with deprecated class Symfony\\\\Component\\\\Security\\\\Core\\\\Role\\\\Role\\: - since Symfony 4\\.3, to be removed in 5\\.0\\. Use strings as roles instead\\.$# - """ - count: 1 - path: src/Bundle/ChillPersonBundle/Export/Export/ListPersonDuplicate.php - - - - message: """ - #^Return type of method Chill\\\\PersonBundle\\\\Export\\\\Filter\\\\AccompanyingPeriodClosingFilter\\:\\:addRole\\(\\) has typehint with deprecated class Symfony\\\\Component\\\\Security\\\\Core\\\\Role\\\\Role\\: - since Symfony 4\\.3, to be removed in 5\\.0\\. Use strings as roles instead\\.$# - """ - count: 1 - path: src/Bundle/ChillPersonBundle/Export/Filter/AccompanyingPeriodClosingFilter.php - - - - message: """ - #^Return type of method Chill\\\\PersonBundle\\\\Export\\\\Filter\\\\AccompanyingPeriodFilter\\:\\:addRole\\(\\) has typehint with deprecated class Symfony\\\\Component\\\\Security\\\\Core\\\\Role\\\\Role\\: - since Symfony 4\\.3, to be removed in 5\\.0\\. Use strings as roles instead\\.$# - """ - count: 1 - path: src/Bundle/ChillPersonBundle/Export/Filter/AccompanyingPeriodFilter.php - - - - message: """ - #^Return type of method Chill\\\\PersonBundle\\\\Export\\\\Filter\\\\AccompanyingPeriodOpeningFilter\\:\\:addRole\\(\\) has typehint with deprecated class Symfony\\\\Component\\\\Security\\\\Core\\\\Role\\\\Role\\: - since Symfony 4\\.3, to be removed in 5\\.0\\. Use strings as roles instead\\.$# - """ - count: 1 - path: src/Bundle/ChillPersonBundle/Export/Filter/AccompanyingPeriodOpeningFilter.php - - - - message: """ - #^Return type of method Chill\\\\PersonBundle\\\\Export\\\\Filter\\\\BirthdateFilter\\:\\:addRole\\(\\) has typehint with deprecated class Symfony\\\\Component\\\\Security\\\\Core\\\\Role\\\\Role\\: - since Symfony 4\\.3, to be removed in 5\\.0\\. Use strings as roles instead\\.$# - """ - count: 1 - path: src/Bundle/ChillPersonBundle/Export/Filter/BirthdateFilter.php - - - - message: """ - #^Return type of method Chill\\\\PersonBundle\\\\Export\\\\Filter\\\\GenderFilter\\:\\:addRole\\(\\) has typehint with deprecated class Symfony\\\\Component\\\\Security\\\\Core\\\\Role\\\\Role\\: - since Symfony 4\\.3, to be removed in 5\\.0\\. Use strings as roles instead\\.$# - """ - count: 1 - path: src/Bundle/ChillPersonBundle/Export/Filter/GenderFilter.php - - - - message: """ - #^Return type of method Chill\\\\PersonBundle\\\\Export\\\\Filter\\\\NationalityFilter\\:\\:addRole\\(\\) has typehint with deprecated class Symfony\\\\Component\\\\Security\\\\Core\\\\Role\\\\Role\\: - since Symfony 4\\.3, to be removed in 5\\.0\\. Use strings as roles instead\\.$# - """ - count: 1 - path: src/Bundle/ChillPersonBundle/Export/Filter/NationalityFilter.php - - message: """ #^Instantiation of deprecated class Symfony\\\\Component\\\\Security\\\\Core\\\\Role\\\\Role\\: @@ -740,29 +435,6 @@ parameters: count: 3 path: src/Bundle/ChillReportBundle/Controller/ReportController.php - - - message: """ - #^Instantiation of deprecated class Symfony\\\\Component\\\\Security\\\\Core\\\\Role\\\\Role\\: - since Symfony 4\\.3, to be removed in 5\\.0\\. Use strings as roles instead\\.$# - """ - count: 1 - path: src/Bundle/ChillReportBundle/Export/Export/ReportList.php - - - - message: """ - #^Return type of method Chill\\\\ReportBundle\\\\Export\\\\Export\\\\ReportList\\:\\:requiredRole\\(\\) has typehint with deprecated class Symfony\\\\Component\\\\Security\\\\Core\\\\Role\\\\Role\\: - since Symfony 4\\.3, to be removed in 5\\.0\\. Use strings as roles instead\\.$# - """ - count: 1 - path: src/Bundle/ChillReportBundle/Export/Export/ReportList.php - - - - message: """ - #^Return type of method Chill\\\\ReportBundle\\\\Export\\\\Filter\\\\ReportDateFilter\\:\\:addRole\\(\\) has typehint with deprecated class Symfony\\\\Component\\\\Security\\\\Core\\\\Role\\\\Role\\: - since Symfony 4\\.3, to be removed in 5\\.0\\. Use strings as roles instead\\.$# - """ - count: 1 - path: src/Bundle/ChillReportBundle/Export/Filter/ReportDateFilter.php - message: """ @@ -845,14 +517,6 @@ parameters: count: 1 path: src/Bundle/ChillTaskBundle/Form/SingleTaskType.php - - - message: """ - #^Parameter \\$centerResolverDispatcher of method Chill\\\\TaskBundle\\\\Repository\\\\SingleTaskAclAwareRepository\\:\\:__construct\\(\\) has typehint with deprecated interface Chill\\\\MainBundle\\\\Security\\\\Resolver\\\\CenterResolverDispatcherInterface\\: - Use CenterResolverManager and its interface CenterResolverManagerInterface$# - """ - count: 1 - path: src/Bundle/ChillTaskBundle/Repository/SingleTaskAclAwareRepository.php - - message: """ #^Instantiation of deprecated class Symfony\\\\Component\\\\Security\\\\Core\\\\Role\\\\Role\\: diff --git a/phpstan-types.neon b/phpstan-types.neon index c8a0791e0..1aae06880 100644 --- a/phpstan-types.neon +++ b/phpstan-types.neon @@ -10,16 +10,6 @@ parameters: count: 1 path: src/Bundle/ChillActivityBundle/Entity/ActivityReasonCategory.php - - - message: "#^Method Chill\\\\ActivityBundle\\\\Export\\\\Export\\\\StatActivityDuration\\:\\:getDescription\\(\\) should return string but return statement is missing\\.$#" - count: 1 - path: src/Bundle/ChillActivityBundle/Export/Export/StatActivityDuration.php - - - - message: "#^Method Chill\\\\ActivityBundle\\\\Export\\\\Export\\\\StatActivityDuration\\:\\:getTitle\\(\\) should return string but return statement is missing\\.$#" - count: 1 - path: src/Bundle/ChillActivityBundle/Export/Export/StatActivityDuration.php - - message: "#^Construct empty\\(\\) is not allowed\\. Use more strict comparison\\.$#" count: 1 @@ -245,11 +235,6 @@ parameters: count: 1 path: src/Bundle/ChillMainBundle/Entity/User.php - - - message: "#^Only booleans are allowed in a ternary operator condition, mixed given\\.$#" - count: 1 - path: src/Bundle/ChillMainBundle/Export/Formatter/SpreadsheetListFormatter.php - - message: "#^Construct empty\\(\\) is not allowed\\. Use more strict comparison\\.$#" count: 1 @@ -335,21 +320,6 @@ parameters: count: 6 path: src/Bundle/ChillPersonBundle/Command/ImportPeopleFromCSVCommand.php - - - message: "#^Construct empty\\(\\) is not allowed\\. Use more strict comparison\\.$#" - count: 1 - path: src/Bundle/ChillPersonBundle/Export/Aggregator/CountryOfBirthAggregator.php - - - - message: "#^Construct empty\\(\\) is not allowed\\. Use more strict comparison\\.$#" - count: 1 - path: src/Bundle/ChillPersonBundle/Export/Aggregator/NationalityAggregator.php - - - - message: "#^Construct empty\\(\\) is not allowed\\. Use more strict comparison\\.$#" - count: 2 - path: src/Bundle/ChillPersonBundle/Export/Export/ListPerson.php - - message: "#^Construct empty\\(\\) is not allowed\\. Use more strict comparison\\.$#" count: 1 diff --git a/src/Bundle/ChillActivityBundle/Controller/ActivityController.php b/src/Bundle/ChillActivityBundle/Controller/ActivityController.php index d76bffe80..e67e0f3c2 100644 --- a/src/Bundle/ChillActivityBundle/Controller/ActivityController.php +++ b/src/Bundle/ChillActivityBundle/Controller/ActivityController.php @@ -17,10 +17,11 @@ use Chill\ActivityBundle\Form\ActivityType; use Chill\ActivityBundle\Repository\ActivityACLAwareRepositoryInterface; use Chill\ActivityBundle\Repository\ActivityRepository; use Chill\ActivityBundle\Repository\ActivityTypeCategoryRepository; -use Chill\ActivityBundle\Repository\ActivityTypeRepository; +use Chill\ActivityBundle\Repository\ActivityTypeRepositoryInterface; use Chill\ActivityBundle\Security\Authorization\ActivityVoter; use Chill\MainBundle\Entity\Embeddable\CommentEmbeddable; use Chill\MainBundle\Repository\LocationRepository; +use Chill\MainBundle\Repository\UserRepositoryInterface; use Chill\MainBundle\Security\Resolver\CenterResolverManagerInterface; use Chill\PersonBundle\Entity\AccompanyingPeriod; use Chill\PersonBundle\Entity\Person; @@ -41,7 +42,6 @@ use Symfony\Component\Form\FormInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Security\Core\Role\Role; - use Symfony\Component\Serializer\SerializerInterface; use function array_key_exists; @@ -55,7 +55,7 @@ final class ActivityController extends AbstractController private ActivityTypeCategoryRepository $activityTypeCategoryRepository; - private ActivityTypeRepository $activityTypeRepository; + private ActivityTypeRepositoryInterface $activityTypeRepository; private CenterResolverManagerInterface $centerResolver; @@ -73,9 +73,11 @@ final class ActivityController extends AbstractController private ThirdPartyRepository $thirdPartyRepository; + private UserRepositoryInterface $userRepository; + public function __construct( ActivityACLAwareRepositoryInterface $activityACLAwareRepository, - ActivityTypeRepository $activityTypeRepository, + ActivityTypeRepositoryInterface $activityTypeRepository, ActivityTypeCategoryRepository $activityTypeCategoryRepository, PersonRepository $personRepository, ThirdPartyRepository $thirdPartyRepository, @@ -86,6 +88,7 @@ final class ActivityController extends AbstractController EventDispatcherInterface $eventDispatcher, LoggerInterface $logger, SerializerInterface $serializer, + UserRepositoryInterface $userRepository, CenterResolverManagerInterface $centerResolver ) { $this->activityACLAwareRepository = $activityACLAwareRepository; @@ -100,6 +103,7 @@ final class ActivityController extends AbstractController $this->eventDispatcher = $eventDispatcher; $this->logger = $logger; $this->serializer = $serializer; + $this->userRepository = $userRepository; $this->centerResolver = $centerResolver; } @@ -372,7 +376,7 @@ final class ActivityController extends AbstractController if ($request->query->has('activityData')) { $activityData = $request->query->get('activityData'); - if (array_key_exists('durationTime', $activityData)) { + if (array_key_exists('durationTime', $activityData) && $activityType->getDurationTimeVisible() > 0) { $durationTimeInMinutes = $activityData['durationTime']; $hours = floor($durationTimeInMinutes / 60); $minutes = $durationTimeInMinutes % 60; @@ -391,26 +395,36 @@ final class ActivityController extends AbstractController } } - if (array_key_exists('personsId', $activityData)) { + if (array_key_exists('personsId', $activityData) && $activityType->getPersonsVisible() > 0) { foreach ($activityData['personsId'] as $personId) { $concernedPerson = $this->personRepository->find($personId); $entity->addPerson($concernedPerson); } } - if (array_key_exists('professionalsId', $activityData)) { + if (array_key_exists('professionalsId', $activityData) && $activityType->getThirdPartiesVisible() > 0) { foreach ($activityData['professionalsId'] as $professionalsId) { $professional = $this->thirdPartyRepository->find($professionalsId); $entity->addThirdParty($professional); } } - if (array_key_exists('location', $activityData)) { + if (array_key_exists('usersId', $activityData) && $activityType->getUsersVisible() > 0) { + foreach ($activityData['usersId'] as $userId) { + $user = $this->userRepository->find($userId); + + if (null !== $user) { + $entity->addUser($user); + } + } + } + + if (array_key_exists('location', $activityData) && $activityType->getLocationVisible() > 0) { $location = $this->locationRepository->find($activityData['location']); $entity->setLocation($location); } - if (array_key_exists('comment', $activityData)) { + if (array_key_exists('comment', $activityData) && $activityType->getCommentVisible() > 0) { $comment = new CommentEmbeddable(); $comment->setComment($activityData['comment']); $comment->setUserId($this->getUser()->getid()); diff --git a/src/Bundle/ChillActivityBundle/Entity/Activity.php b/src/Bundle/ChillActivityBundle/Entity/Activity.php index 98142149f..8fcae3e0b 100644 --- a/src/Bundle/ChillActivityBundle/Entity/Activity.php +++ b/src/Bundle/ChillActivityBundle/Entity/Activity.php @@ -13,6 +13,10 @@ namespace Chill\ActivityBundle\Entity; use Chill\ActivityBundle\Validator\Constraints as ActivityValidator; use Chill\DocStoreBundle\Entity\StoredObject; +use Chill\MainBundle\Doctrine\Model\TrackCreationInterface; +use Chill\MainBundle\Doctrine\Model\TrackCreationTrait; +use Chill\MainBundle\Doctrine\Model\TrackUpdateInterface; +use Chill\MainBundle\Doctrine\Model\TrackUpdateTrait; use Chill\MainBundle\Entity\Center; use Chill\MainBundle\Entity\Embeddable\CommentEmbeddable; use Chill\MainBundle\Entity\Embeddable\PrivateCommentEmbeddable; @@ -55,8 +59,12 @@ use Symfony\Component\Validator\Constraints as Assert; * getUserFunction="getUser", * path="scope") */ -class Activity implements AccompanyingPeriodLinkedWithSocialIssuesEntityInterface, HasCentersInterface, HasScopesInterface +class Activity implements AccompanyingPeriodLinkedWithSocialIssuesEntityInterface, HasCentersInterface, HasScopesInterface, TrackCreationInterface, TrackUpdateInterface { + use TrackCreationTrait; + + use TrackUpdateTrait; + public const SENTRECEIVED_RECEIVED = 'received'; public const SENTRECEIVED_SENT = 'sent'; diff --git a/src/Bundle/ChillActivityBundle/Entity/ActivityType.php b/src/Bundle/ChillActivityBundle/Entity/ActivityType.php index 645d55f7a..1f44a1af0 100644 --- a/src/Bundle/ChillActivityBundle/Entity/ActivityType.php +++ b/src/Bundle/ChillActivityBundle/Entity/ActivityType.php @@ -516,6 +516,11 @@ class ActivityType return $this->userVisible; } + public function hasCategory(): bool + { + return null !== $this->getCategory(); + } + /** * Is active * return true if the type is active. diff --git a/src/Bundle/ChillActivityBundle/Export/Aggregator/ACPAggregators/ByActivityNumberAggregator.php b/src/Bundle/ChillActivityBundle/Export/Aggregator/ACPAggregators/ByActivityNumberAggregator.php new file mode 100644 index 000000000..5c6656009 --- /dev/null +++ b/src/Bundle/ChillActivityBundle/Export/Aggregator/ACPAggregators/ByActivityNumberAggregator.php @@ -0,0 +1,68 @@ +addSelect('(SELECT COUNT(activity.id) FROM ' . Activity::class . ' activity WHERE activity.accompanyingPeriod = acp) AS activity_by_number_aggregator') + ->addGroupBy('activity_by_number_aggregator'); + } + + public function applyOn(): string + { + return Declarations::ACP_TYPE; + } + + public function buildForm(FormBuilderInterface $builder): void + { + // No form needed + } + + public function getLabels($key, array $values, $data) + { + return static function ($value) { + if ('_header' === $value) { + return ''; + } + + if (null === $value) { + return ''; + } + + return $value; + }; + } + + public function getQueryKeys($data): array + { + return ['activity_by_number_aggregator']; + } + + public function getTitle(): string + { + return 'Group acp by activity number'; + } +} diff --git a/src/Bundle/ChillActivityBundle/Export/Aggregator/ACPAggregators/ByUserAggregator.php b/src/Bundle/ChillActivityBundle/Export/Aggregator/ACPAggregators/ByCreatorAggregator.php similarity index 65% rename from src/Bundle/ChillActivityBundle/Export/Aggregator/ACPAggregators/ByUserAggregator.php rename to src/Bundle/ChillActivityBundle/Export/Aggregator/ACPAggregators/ByCreatorAggregator.php index 1fbb0b546..c09685e4e 100644 --- a/src/Bundle/ChillActivityBundle/Export/Aggregator/ACPAggregators/ByUserAggregator.php +++ b/src/Bundle/ChillActivityBundle/Export/Aggregator/ACPAggregators/ByCreatorAggregator.php @@ -13,46 +13,34 @@ namespace Chill\ActivityBundle\Export\Aggregator\ACPAggregators; use Chill\ActivityBundle\Export\Declarations; use Chill\MainBundle\Export\AggregatorInterface; -use Chill\MainBundle\Repository\UserRepository; +use Chill\MainBundle\Repository\UserRepositoryInterface; use Chill\MainBundle\Templating\Entity\UserRender; use Doctrine\ORM\QueryBuilder; use Symfony\Component\Form\FormBuilderInterface; -use function in_array; -class ByUserAggregator implements AggregatorInterface +class ByCreatorAggregator implements AggregatorInterface { private UserRender $userRender; - private UserRepository $userRepository; + private UserRepositoryInterface $userRepository; public function __construct( - UserRepository $userRepository, + UserRepositoryInterface $userRepository, UserRender $userRender ) { $this->userRepository = $userRepository; $this->userRender = $userRender; } - public function addRole() + public function addRole(): ?string { return null; } public function alterQuery(QueryBuilder $qb, $data) { - if (!in_array('user', $qb->getAllAliases(), true)) { - $qb->join('activity.users', 'user'); - } - - $qb->addSelect('user.id AS users_aggregator'); - - $groupBy = $qb->getDQLPart('groupBy'); - - if (!empty($groupBy)) { - $qb->addGroupBy('users_aggregator'); - } else { - $qb->groupBy('users_aggregator'); - } + $qb->addSelect('IDENTITY(activity.createdBy) AS creator_aggregator'); + $qb->addGroupBy('creator_aggregator'); } public function applyOn(): string @@ -69,7 +57,11 @@ class ByUserAggregator implements AggregatorInterface { return function ($value): string { if ('_header' === $value) { - return 'Accepted users'; + return 'Created by'; + } + + if (null === $value) { + return ''; } $u = $this->userRepository->find($value); @@ -80,11 +72,11 @@ class ByUserAggregator implements AggregatorInterface public function getQueryKeys($data): array { - return ['users_aggregator']; + return ['creator_aggregator']; } public function getTitle(): string { - return 'Group activity by linked users'; + return 'Group activity by creator'; } } diff --git a/src/Bundle/ChillActivityBundle/Export/Aggregator/ACPAggregators/BySocialActionAggregator.php b/src/Bundle/ChillActivityBundle/Export/Aggregator/ACPAggregators/BySocialActionAggregator.php index 4f85158fb..58c824adc 100644 --- a/src/Bundle/ChillActivityBundle/Export/Aggregator/ACPAggregators/BySocialActionAggregator.php +++ b/src/Bundle/ChillActivityBundle/Export/Aggregator/ACPAggregators/BySocialActionAggregator.php @@ -33,26 +33,19 @@ class BySocialActionAggregator implements AggregatorInterface $this->actionRepository = $actionRepository; } - public function addRole() + public function addRole(): ?string { return null; } public function alterQuery(QueryBuilder $qb, $data) { - if (!in_array('socialaction', $qb->getAllAliases(), true)) { - $qb->join('activity.socialActions', 'socialaction'); + if (!in_array('actsocialaction', $qb->getAllAliases(), true)) { + $qb->leftJoin('activity.socialActions', 'actsocialaction'); } - $qb->addSelect('socialaction.id AS socialaction_aggregator'); - - $groupBy = $qb->getDQLPart('groupBy'); - - if (!empty($groupBy)) { - $qb->addGroupBy('socialaction_aggregator'); - } else { - $qb->groupBy('socialaction_aggregator'); - } + $qb->addSelect('actsocialaction.id AS socialaction_aggregator'); + $qb->addGroupBy('socialaction_aggregator'); } public function applyOn(): string @@ -72,6 +65,10 @@ class BySocialActionAggregator implements AggregatorInterface return 'Social action'; } + if (null === $value) { + return ''; + } + $sa = $this->actionRepository->find($value); return $this->actionRender->renderString($sa, []); diff --git a/src/Bundle/ChillActivityBundle/Export/Aggregator/ACPAggregators/BySocialIssueAggregator.php b/src/Bundle/ChillActivityBundle/Export/Aggregator/ACPAggregators/BySocialIssueAggregator.php index 5f0c8102b..fe07fd23f 100644 --- a/src/Bundle/ChillActivityBundle/Export/Aggregator/ACPAggregators/BySocialIssueAggregator.php +++ b/src/Bundle/ChillActivityBundle/Export/Aggregator/ACPAggregators/BySocialIssueAggregator.php @@ -33,26 +33,19 @@ class BySocialIssueAggregator implements AggregatorInterface $this->issueRender = $issueRender; } - public function addRole() + public function addRole(): ?string { return null; } public function alterQuery(QueryBuilder $qb, $data) { - if (!in_array('socialissue', $qb->getAllAliases(), true)) { - $qb->join('activity.socialIssues', 'socialissue'); + if (!in_array('actsocialissue', $qb->getAllAliases(), true)) { + $qb->leftJoin('activity.socialIssues', 'actsocialissue'); } - $qb->addSelect('socialissue.id AS socialissue_aggregator'); - - $groupBy = $qb->getDQLPart('groupBy'); - - if (!empty($groupBy)) { - $qb->addGroupBy('socialissue_aggregator'); - } else { - $qb->groupBy('socialissue_aggregator'); - } + $qb->addSelect('actsocialissue.id AS socialissue_aggregator'); + $qb->addGroupBy('socialissue_aggregator'); } public function applyOn(): string @@ -72,6 +65,10 @@ class BySocialIssueAggregator implements AggregatorInterface return 'Social issues'; } + if (null === $value) { + return ''; + } + $i = $this->issueRepository->find($value); return $this->issueRender->renderString($i, []); diff --git a/src/Bundle/ChillActivityBundle/Export/Aggregator/ACPAggregators/ByThirdpartyAggregator.php b/src/Bundle/ChillActivityBundle/Export/Aggregator/ACPAggregators/ByThirdpartyAggregator.php index 745058e2d..5eab4cae3 100644 --- a/src/Bundle/ChillActivityBundle/Export/Aggregator/ACPAggregators/ByThirdpartyAggregator.php +++ b/src/Bundle/ChillActivityBundle/Export/Aggregator/ACPAggregators/ByThirdpartyAggregator.php @@ -33,26 +33,19 @@ class ByThirdpartyAggregator implements AggregatorInterface $this->thirdPartyRender = $thirdPartyRender; } - public function addRole() + public function addRole(): ?string { return null; } public function alterQuery(QueryBuilder $qb, $data) { - if (!in_array('thirdparty', $qb->getAllAliases(), true)) { - $qb->join('activity.thirdParties', 'thirdparty'); + if (!in_array('acttparty', $qb->getAllAliases(), true)) { + $qb->leftJoin('activity.thirdParties', 'acttparty'); } - $qb->addSelect('thirdparty.id AS thirdparty_aggregator'); - - $groupBy = $qb->getDQLPart('groupBy'); - - if (!empty($groupBy)) { - $qb->addGroupBy('thirdparty_aggregator'); - } else { - $qb->groupBy('thirdparty_aggregator'); - } + $qb->addSelect('acttparty.id AS thirdparty_aggregator'); + $qb->addGroupBy('thirdparty_aggregator'); } public function applyOn(): string @@ -72,6 +65,10 @@ class ByThirdpartyAggregator implements AggregatorInterface return 'Accepted thirdparty'; } + if (null === $value) { + return ''; + } + $tp = $this->thirdPartyRepository->find($value); return $this->thirdPartyRender->renderString($tp, []); diff --git a/src/Bundle/ChillActivityBundle/Export/Aggregator/ACPAggregators/UserScopeAggregator.php b/src/Bundle/ChillActivityBundle/Export/Aggregator/ACPAggregators/CreatorScopeAggregator.php similarity index 75% rename from src/Bundle/ChillActivityBundle/Export/Aggregator/ACPAggregators/UserScopeAggregator.php rename to src/Bundle/ChillActivityBundle/Export/Aggregator/ACPAggregators/CreatorScopeAggregator.php index d95e3cacb..2041fcbb4 100644 --- a/src/Bundle/ChillActivityBundle/Export/Aggregator/ACPAggregators/UserScopeAggregator.php +++ b/src/Bundle/ChillActivityBundle/Export/Aggregator/ACPAggregators/CreatorScopeAggregator.php @@ -19,7 +19,7 @@ use Doctrine\ORM\QueryBuilder; use Symfony\Component\Form\FormBuilderInterface; use function in_array; -class UserScopeAggregator implements AggregatorInterface +class CreatorScopeAggregator implements AggregatorInterface { private ScopeRepository $scopeRepository; @@ -33,26 +33,19 @@ class UserScopeAggregator implements AggregatorInterface $this->translatableStringHelper = $translatableStringHelper; } - public function addRole() + public function addRole(): ?string { return null; } public function alterQuery(QueryBuilder $qb, $data) { - if (!in_array('user', $qb->getAllAliases(), true)) { - $qb->join('activity.user', 'user'); + if (!in_array('actcreator', $qb->getAllAliases(), true)) { + $qb->leftJoin('activity.createdBy', 'actcreator'); } - $qb->addSelect('IDENTITY(user.mainScope) AS userscope_aggregator'); - - $groupBy = $qb->getDQLPart('groupBy'); - - if (!empty($groupBy)) { - $qb->addGroupBy('userscope_aggregator'); - } else { - $qb->groupBy('userscope_aggregator'); - } + $qb->addSelect('IDENTITY(actcreator.mainScope) AS creatorscope_aggregator'); + $qb->addGroupBy('creatorscope_aggregator'); } public function applyOn(): string @@ -72,6 +65,10 @@ class UserScopeAggregator implements AggregatorInterface return 'Scope'; } + if (null === $value) { + return ''; + } + $s = $this->scopeRepository->find($value); return $this->translatableStringHelper->localize( @@ -82,11 +79,11 @@ class UserScopeAggregator implements AggregatorInterface public function getQueryKeys($data): array { - return ['userscope_aggregator']; + return ['creatorscope_aggregator']; } public function getTitle(): string { - return 'Group activity by userscope'; + return 'Group activity by creator scope'; } } diff --git a/src/Bundle/ChillActivityBundle/Export/Aggregator/ACPAggregators/DateAggregator.php b/src/Bundle/ChillActivityBundle/Export/Aggregator/ACPAggregators/DateAggregator.php index a32cb68a0..dbbeb2715 100644 --- a/src/Bundle/ChillActivityBundle/Export/Aggregator/ACPAggregators/DateAggregator.php +++ b/src/Bundle/ChillActivityBundle/Export/Aggregator/ACPAggregators/DateAggregator.php @@ -13,7 +13,6 @@ namespace Chill\ActivityBundle\Export\Aggregator\ACPAggregators; use Chill\ActivityBundle\Export\Declarations; use Chill\MainBundle\Export\AggregatorInterface; -use DateTime; use Doctrine\ORM\QueryBuilder; use RuntimeException; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; @@ -38,7 +37,7 @@ class DateAggregator implements AggregatorInterface $this->translator = $translator; } - public function addRole() + public function addRole(): ?string { return null; } @@ -49,41 +48,27 @@ class DateAggregator implements AggregatorInterface switch ($data['frequency']) { case 'month': - $fmt = 'MM'; + $fmt = 'YYYY-MM'; -break; + break; case 'week': - $fmt = 'IW'; + $fmt = 'YYYY-IW'; -break; + break; case 'year': $fmt = 'YYYY'; $order = 'DESC'; -break; + break; // order DESC does not works ! default: throw new RuntimeException(sprintf("The frequency data '%s' is invalid.", $data['frequency'])); } $qb->addSelect(sprintf("TO_CHAR(activity.date, '%s') AS date_aggregator", $fmt)); - - $groupBy = $qb->getDQLPart('groupBy'); - - if (!empty($groupBy)) { - $qb->addGroupBy('date_aggregator'); - } else { - $qb->groupBy('date_aggregator'); - } - - $orderBy = $qb->getDQLPart('orderBy'); - - if (!empty($orderBy)) { - $qb->addOrderBy('date_aggregator', $order); - } else { - $qb->orderBy('date_aggregator', $order); - } + $qb->addGroupBy('date_aggregator'); + $qb->addOrderBy('date_aggregator', $order); } public function applyOn(): string @@ -109,16 +94,12 @@ break; return 'by ' . $data['frequency']; } + if (null === $value) { + return ''; + } + switch ($data['frequency']) { case 'month': - $month = DateTime::createFromFormat('!m', $value); - - return sprintf( - '%02d (%s)', - $value, - $month->format('M') - ); - case 'week': //return $this->translator->trans('for week') .' '. $value ; diff --git a/src/Bundle/ChillActivityBundle/Export/Aggregator/ACPAggregators/LocationTypeAggregator.php b/src/Bundle/ChillActivityBundle/Export/Aggregator/ACPAggregators/LocationTypeAggregator.php index dd43f8ae9..b648fcf83 100644 --- a/src/Bundle/ChillActivityBundle/Export/Aggregator/ACPAggregators/LocationTypeAggregator.php +++ b/src/Bundle/ChillActivityBundle/Export/Aggregator/ACPAggregators/LocationTypeAggregator.php @@ -33,26 +33,19 @@ class LocationTypeAggregator implements AggregatorInterface $this->translatableStringHelper = $translatableStringHelper; } - public function addRole() + public function addRole(): ?string { return null; } public function alterQuery(QueryBuilder $qb, $data) { - if (!in_array('location', $qb->getAllAliases(), true)) { - $qb->join('activity.location', 'location'); + if (!in_array('actloc', $qb->getAllAliases(), true)) { + $qb->leftJoin('activity.location', 'actloc'); } - $qb->addSelect('IDENTITY(location.locationType) AS locationtype_aggregator'); - - $groupBy = $qb->getDQLPart('groupBy'); - - if (!empty($groupBy)) { - $qb->addGroupBy('locationtype_aggregator'); - } else { - $qb->groupBy('locationtype_aggregator'); - } + $qb->addSelect('IDENTITY(actloc.locationType) AS locationtype_aggregator'); + $qb->addGroupBy('locationtype_aggregator'); } public function applyOn(): string @@ -72,6 +65,10 @@ class LocationTypeAggregator implements AggregatorInterface return 'Accepted locationtype'; } + if (null === $value) { + return ''; + } + $lt = $this->locationTypeRepository->find($value); return $this->translatableStringHelper->localize( diff --git a/src/Bundle/ChillActivityBundle/Export/Aggregator/ActivityTypeAggregator.php b/src/Bundle/ChillActivityBundle/Export/Aggregator/ActivityTypeAggregator.php index 67c7be0aa..071ccd232 100644 --- a/src/Bundle/ChillActivityBundle/Export/Aggregator/ActivityTypeAggregator.php +++ b/src/Bundle/ChillActivityBundle/Export/Aggregator/ActivityTypeAggregator.php @@ -12,53 +12,43 @@ declare(strict_types=1); namespace Chill\ActivityBundle\Export\Aggregator; use Chill\ActivityBundle\Export\Declarations; -use Chill\ActivityBundle\Repository\ActivityTypeRepository; -use Chill\ActivityBundle\Security\Authorization\ActivityStatsVoter; +use Chill\ActivityBundle\Repository\ActivityTypeRepositoryInterface; use Chill\MainBundle\Export\AggregatorInterface; use Chill\MainBundle\Templating\TranslatableStringHelperInterface; use Closure; -use Doctrine\ORM\Query\Expr\Join; use Doctrine\ORM\QueryBuilder; use Symfony\Component\Form\FormBuilderInterface; -use Symfony\Component\Security\Core\Role\Role; use function in_array; class ActivityTypeAggregator implements AggregatorInterface { public const KEY = 'activity_type_aggregator'; - protected ActivityTypeRepository $activityTypeRepository; + protected ActivityTypeRepositoryInterface $activityTypeRepository; protected TranslatableStringHelperInterface $translatableStringHelper; public function __construct( - ActivityTypeRepository $activityTypeRepository, + ActivityTypeRepositoryInterface $activityTypeRepository, TranslatableStringHelperInterface $translatableStringHelper ) { $this->activityTypeRepository = $activityTypeRepository; $this->translatableStringHelper = $translatableStringHelper; } - public function addRole() + public function addRole(): ?string { - return new Role(ActivityStatsVoter::STATS); + return null; } public function alterQuery(QueryBuilder $qb, $data) { - if (!in_array('type', $qb->getAllAliases(), true)) { - $qb->join('activity.activityType', 'type'); + if (!in_array('acttype', $qb->getAllAliases(), true)) { + $qb->leftJoin('activity.activityType', 'acttype'); } $qb->addSelect(sprintf('IDENTITY(activity.activityType) AS %s', self::KEY)); - - $groupby = $qb->getDQLPart('groupBy'); - - if (!empty($groupBy)) { - $qb->addGroupBy(self::KEY); - } else { - $qb->groupBy(self::KEY); - } + $qb->addGroupBy(self::KEY); } public function applyOn(): string @@ -81,6 +71,10 @@ class ActivityTypeAggregator implements AggregatorInterface return 'Activity type'; } + if (null === $value) { + return ''; + } + $t = $this->activityTypeRepository->find($value); return $this->translatableStringHelper->localize($t->getName()); @@ -96,23 +90,4 @@ class ActivityTypeAggregator implements AggregatorInterface { return 'Aggregate by activity type'; } - - /** - * Check if a join between Activity and another alias. - * - * @param Join[] $joins - * @param string $alias the alias to search for - * - * @return bool - */ - private function checkJoinAlreadyDefined(array $joins, $alias) - { - foreach ($joins as $join) { - if ($join->getAlias() === $alias) { - return true; - } - } - - return false; - } } diff --git a/src/Bundle/ChillActivityBundle/Export/Aggregator/ActivityUserAggregator.php b/src/Bundle/ChillActivityBundle/Export/Aggregator/ActivityUserAggregator.php index f5d55e9bf..2b1ac57b5 100644 --- a/src/Bundle/ChillActivityBundle/Export/Aggregator/ActivityUserAggregator.php +++ b/src/Bundle/ChillActivityBundle/Export/Aggregator/ActivityUserAggregator.php @@ -12,14 +12,12 @@ declare(strict_types=1); namespace Chill\ActivityBundle\Export\Aggregator; use Chill\ActivityBundle\Export\Declarations; -use Chill\ActivityBundle\Security\Authorization\ActivityStatsVoter; use Chill\MainBundle\Export\AggregatorInterface; use Chill\MainBundle\Repository\UserRepository; use Chill\MainBundle\Templating\Entity\UserRender; use Closure; use Doctrine\ORM\QueryBuilder; use Symfony\Component\Form\FormBuilderInterface; -use Symfony\Component\Security\Core\Role\Role; class ActivityUserAggregator implements AggregatorInterface { @@ -37,9 +35,9 @@ class ActivityUserAggregator implements AggregatorInterface $this->userRender = $userRender; } - public function addRole() + public function addRole(): ?string { - return new Role(ActivityStatsVoter::STATS); + return null; } public function alterQuery(QueryBuilder $qb, $data) @@ -63,14 +61,15 @@ class ActivityUserAggregator implements AggregatorInterface public function getLabels($key, $values, $data): Closure { - // preload users at once - $this->userRepository->findBy(['id' => $values]); - return function ($value) { if ('_header' === $value) { return 'Activity user'; } + if (null === $value) { + return ''; + } + $u = $this->userRepository->find($value); return $this->userRender->renderString($u, []); diff --git a/src/Bundle/ChillActivityBundle/Export/Aggregator/ActivityUsersAggregator.php b/src/Bundle/ChillActivityBundle/Export/Aggregator/ActivityUsersAggregator.php new file mode 100644 index 000000000..ccccc48a0 --- /dev/null +++ b/src/Bundle/ChillActivityBundle/Export/Aggregator/ActivityUsersAggregator.php @@ -0,0 +1,86 @@ +userRepository = $userRepository; + $this->userRender = $userRender; + } + + public function addRole(): ?string + { + return null; + } + + public function alterQuery(QueryBuilder $qb, $data) + { + if (!in_array('actusers', $qb->getAllAliases(), true)) { + $qb->leftJoin('activity.users', 'actusers'); + } + + $qb + ->addSelect('actusers.id AS activity_users_aggregator') + ->addGroupBy('activity_users_aggregator'); + } + + public function applyOn(): string + { + return Declarations::ACTIVITY; + } + + public function buildForm(FormBuilderInterface $builder) + { + // nothing to add on the form + } + + public function getLabels($key, array $values, $data) + { + return function ($value) { + if ('_header' === $value) { + return 'Activity users'; + } + + if (null === $value) { + return ''; + } + + $u = $this->userRepository->find($value); + + return $this->userRender->renderString($u, []); + }; + } + + public function getQueryKeys($data) + { + return ['activity_users_aggregator']; + } + + public function getTitle() + { + return 'Aggregate by activity users'; + } +} diff --git a/src/Bundle/ChillActivityBundle/Export/Aggregator/ActivityUsersJobAggregator.php b/src/Bundle/ChillActivityBundle/Export/Aggregator/ActivityUsersJobAggregator.php new file mode 100644 index 000000000..a0a6a439b --- /dev/null +++ b/src/Bundle/ChillActivityBundle/Export/Aggregator/ActivityUsersJobAggregator.php @@ -0,0 +1,87 @@ +userJobRepository = $userJobRepository; + $this->translatableStringHelper = $translatableStringHelper; + } + + public function addRole(): ?string + { + return null; + } + + public function alterQuery(QueryBuilder $qb, $data) + { + if (!in_array('actusers', $qb->getAllAliases(), true)) { + $qb->leftJoin('activity.users', 'actusers'); + } + + $qb + ->addSelect('IDENTITY(actusers.userJob) AS activity_users_job_aggregator') + ->addGroupBy('activity_users_job_aggregator'); + } + + public function applyOn() + { + return Declarations::ACTIVITY; + } + + public function buildForm(FormBuilderInterface $builder) + { + // nothing to add in the form + } + + public function getLabels($key, array $values, $data) + { + return function ($value): string { + if ('_header' === $value) { + return 'Users \'s job'; + } + + if (null === $value) { + return ''; + } + + $j = $this->userJobRepository->find($value); + + return $this->translatableStringHelper->localize( + $j->getLabel() + ); + }; + } + + public function getQueryKeys($data): array + { + return ['activity_users_job_aggregator']; + } + + public function getTitle() + { + return 'Aggregate by users job'; + } +} diff --git a/src/Bundle/ChillActivityBundle/Export/Aggregator/ActivityUsersScopeAggregator.php b/src/Bundle/ChillActivityBundle/Export/Aggregator/ActivityUsersScopeAggregator.php new file mode 100644 index 000000000..975c5df27 --- /dev/null +++ b/src/Bundle/ChillActivityBundle/Export/Aggregator/ActivityUsersScopeAggregator.php @@ -0,0 +1,87 @@ +scopeRepository = $scopeRepository; + $this->translatableStringHelper = $translatableStringHelper; + } + + public function addRole(): ?string + { + return null; + } + + public function alterQuery(QueryBuilder $qb, $data) + { + if (!in_array('actusers', $qb->getAllAliases(), true)) { + $qb->leftJoin('activity.users', 'actusers'); + } + + $qb + ->addSelect('IDENTITY(actusers.mainScope) AS activity_users_main_scope_aggregator') + ->addGroupBy('activity_users_main_scope_aggregator'); + } + + public function applyOn() + { + return Declarations::ACTIVITY; + } + + public function buildForm(FormBuilderInterface $builder) + { + // nothing to add in the form + } + + public function getLabels($key, array $values, $data) + { + return function ($value): string { + if ('_header' === $value) { + return 'Users \'s scope'; + } + + if (null === $value) { + return ''; + } + + $s = $this->scopeRepository->find($value); + + return $this->translatableStringHelper->localize( + $s->getName() + ); + }; + } + + public function getQueryKeys($data): array + { + return ['activity_users_main_scope_aggregator']; + } + + public function getTitle() + { + return 'Aggregate by users scope'; + } +} diff --git a/src/Bundle/ChillActivityBundle/Export/Aggregator/PersonAggregators/ActivityReasonAggregator.php b/src/Bundle/ChillActivityBundle/Export/Aggregator/PersonAggregators/ActivityReasonAggregator.php index eedd5a2da..bfd785554 100644 --- a/src/Bundle/ChillActivityBundle/Export/Aggregator/PersonAggregators/ActivityReasonAggregator.php +++ b/src/Bundle/ChillActivityBundle/Export/Aggregator/PersonAggregators/ActivityReasonAggregator.php @@ -14,7 +14,6 @@ namespace Chill\ActivityBundle\Export\Aggregator\PersonAggregators; use Chill\ActivityBundle\Export\Declarations; use Chill\ActivityBundle\Repository\ActivityReasonCategoryRepository; use Chill\ActivityBundle\Repository\ActivityReasonRepository; -use Chill\ActivityBundle\Security\Authorization\ActivityStatsVoter; use Chill\MainBundle\Export\AggregatorInterface; use Chill\MainBundle\Export\ExportElementValidatedInterface; use Chill\MainBundle\Templating\TranslatableStringHelper; @@ -24,11 +23,10 @@ use Doctrine\ORM\QueryBuilder; use RuntimeException; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\FormBuilderInterface; -use Symfony\Component\Security\Core\Role\Role; use Symfony\Component\Validator\Context\ExecutionContextInterface; -use function array_key_exists; use function count; +use function in_array; class ActivityReasonAggregator implements AggregatorInterface, ExportElementValidatedInterface { @@ -48,19 +46,19 @@ class ActivityReasonAggregator implements AggregatorInterface, ExportElementVali $this->translatableStringHelper = $translatableStringHelper; } - public function addRole() + public function addRole(): ?string { - return new Role(ActivityStatsVoter::STATS); + return null; } public function alterQuery(QueryBuilder $qb, $data) { // add select element if ('reasons' === $data['level']) { - $elem = 'reasons.id'; + $elem = 'actreasons.id'; $alias = 'activity_reasons_id'; } elseif ('categories' === $data['level']) { - $elem = 'category.id'; + $elem = 'actreasoncat.id'; $alias = 'activity_categories_id'; } else { throw new RuntimeException('The data provided are not recognized.'); @@ -69,29 +67,15 @@ class ActivityReasonAggregator implements AggregatorInterface, ExportElementVali $qb->addSelect($elem . ' as ' . $alias); // make a jointure only if needed - $join = $qb->getDQLPart('join'); - - if ( - ( - array_key_exists('activity', $join) - && !$this->checkJoinAlreadyDefined($join['activity'], 'reasons') - ) - || (!array_key_exists('activity', $join)) - ) { - $qb->add( - 'join', - [ - 'activity' => new Join(Join::INNER_JOIN, 'activity.reasons', 'reasons'), - ], - true - ); + if (!in_array('actreasons', $qb->getAllAliases(), true)) { + $qb->innerJoin('activity.reasons', 'actreasons'); } // join category if necessary if ('activity_categories_id' === $alias) { // add join only if needed - if (!$this->checkJoinAlreadyDefined($qb->getDQLPart('join')['activity'], 'category')) { - $qb->join('reasons.category', 'category'); + if (!in_array('actreasoncat', $qb->getAllAliases(), true)) { + $qb->join('actreasons.category', 'actreasoncat'); } } @@ -195,23 +179,4 @@ class ActivityReasonAggregator implements AggregatorInterface, ExportElementVali ->addViolation(); } } - - /** - * Check if a join between Activity and another alias. - * - * @param Join[] $joins - * @param string $alias the alias to search for - * - * @return bool - */ - private function checkJoinAlreadyDefined(array $joins, $alias) - { - foreach ($joins as $join) { - if ($join->getAlias() === $alias) { - return true; - } - } - - return false; - } } diff --git a/src/Bundle/ChillActivityBundle/Export/Aggregator/SentReceivedAggregator.php b/src/Bundle/ChillActivityBundle/Export/Aggregator/SentReceivedAggregator.php new file mode 100644 index 000000000..7d77a7c69 --- /dev/null +++ b/src/Bundle/ChillActivityBundle/Export/Aggregator/SentReceivedAggregator.php @@ -0,0 +1,83 @@ +translator = $translator; + } + + public function addRole(): ?string + { + return null; + } + + public function alterQuery(QueryBuilder $qb, $data): void + { + $qb->addSelect('activity.sentReceived AS activity_sentreceived_aggregator') + ->addGroupBy('activity_sentreceived_aggregator'); + } + + public function applyOn(): string + { + return Declarations::ACTIVITY; + } + + public function buildForm(FormBuilderInterface $builder): void + { + // No form needed + } + + public function getLabels($key, array $values, $data): callable + { + return function (?string $value): string { + if ('_header' === $value) { + return 'export.aggregator.activity.by_sent_received.Sent or received'; + } + + switch ($value) { + case null: + return ''; + + case 'sent': + return $this->translator->trans('export.aggregator.activity.by_sent_received.is sent'); + + case 'received': + return $this->translator->trans('export.aggregator.activity.by_sent_received.is received'); + + default: + throw new LogicException(sprintf('The value %s is not valid', $value)); + } + }; + } + + public function getQueryKeys($data): array + { + return ['activity_sentreceived_aggregator']; + } + + public function getTitle(): string + { + return 'export.aggregator.activity.by_sent_received.Group activity by sentreceived'; + } +} diff --git a/src/Bundle/ChillActivityBundle/Export/Export/LinkedToACP/AvgActivityDuration.php b/src/Bundle/ChillActivityBundle/Export/Export/LinkedToACP/AvgActivityDuration.php index 67d9658fd..2b6919340 100644 --- a/src/Bundle/ChillActivityBundle/Export/Export/LinkedToACP/AvgActivityDuration.php +++ b/src/Bundle/ChillActivityBundle/Export/Export/LinkedToACP/AvgActivityDuration.php @@ -17,11 +17,14 @@ use Chill\ActivityBundle\Security\Authorization\ActivityStatsVoter; use Chill\MainBundle\Export\ExportInterface; use Chill\MainBundle\Export\FormatterInterface; use Chill\MainBundle\Export\GroupedExportInterface; +use Chill\PersonBundle\Entity\AccompanyingPeriodParticipation; +use Chill\PersonBundle\Entity\Person\PersonCenterHistory; +use Chill\PersonBundle\Export\Declarations as PersonDeclarations; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityRepository; use Doctrine\ORM\Query; +use LogicException; use Symfony\Component\Form\FormBuilderInterface; -use Symfony\Component\Security\Core\Role\Role; class AvgActivityDuration implements ExportInterface, GroupedExportInterface { @@ -35,7 +38,6 @@ class AvgActivityDuration implements ExportInterface, GroupedExportInterface public function buildForm(FormBuilderInterface $builder) { - // TODO: Implement buildForm() method. } public function getAllowedFormattersTypes(): array @@ -67,9 +69,9 @@ class AvgActivityDuration implements ExportInterface, GroupedExportInterface return ['export_avg_activity_duration']; } - public function getResult($qb, $data) + public function getResult($query, $data) { - return $qb->getQuery()->getResult(Query::HYDRATE_SCALAR); + return $query->getQuery()->getResult(Query::HYDRATE_SCALAR); } public function getTitle(): string @@ -84,17 +86,34 @@ class AvgActivityDuration implements ExportInterface, GroupedExportInterface public function initiateQuery(array $requiredModifiers, array $acl, array $data = []) { - $qb = $this->repository->createQueryBuilder('activity') - ->join('activity.accompanyingPeriod', 'acp'); + $centers = array_map(static function ($el) { + return $el['center']; + }, $acl); - $qb->select('AVG(activity.durationTime) as export_avg_activity_duration'); + $qb = $this->repository->createQueryBuilder('activity'); + + $qb + ->join('activity.accompanyingPeriod', 'acp') + ->select('AVG(activity.durationTime) as export_avg_activity_duration') + ->andWhere($qb->expr()->isNotNull('activity.durationTime')); + + $qb + ->andWhere( + $qb->expr()->exists( + 'SELECT 1 FROM ' . AccompanyingPeriodParticipation::class . ' acl_count_part + JOIN ' . PersonCenterHistory::class . ' acl_count_person_history WITH IDENTITY(acl_count_person_history.person) = IDENTITY(acl_count_part.person) + WHERE acl_count_part.accompanyingPeriod = acp.id AND acl_count_person_history.center IN (:authorized_centers) + ' + ) + ) + ->setParameter('authorized_centers', $centers); return $qb; } - public function requiredRole(): Role + public function requiredRole(): string { - return new Role(ActivityStatsVoter::STATS); + return ActivityStatsVoter::STATS; } public function supportsModifiers(): array @@ -102,7 +121,7 @@ class AvgActivityDuration implements ExportInterface, GroupedExportInterface return [ Declarations::ACTIVITY, Declarations::ACTIVITY_ACP, - //PersonDeclarations::ACP_TYPE, + PersonDeclarations::ACP_TYPE, ]; } } diff --git a/src/Bundle/ChillActivityBundle/Export/Export/LinkedToACP/AvgActivityVisitDuration.php b/src/Bundle/ChillActivityBundle/Export/Export/LinkedToACP/AvgActivityVisitDuration.php index 25dbe6172..359593059 100644 --- a/src/Bundle/ChillActivityBundle/Export/Export/LinkedToACP/AvgActivityVisitDuration.php +++ b/src/Bundle/ChillActivityBundle/Export/Export/LinkedToACP/AvgActivityVisitDuration.php @@ -17,11 +17,14 @@ use Chill\ActivityBundle\Security\Authorization\ActivityStatsVoter; use Chill\MainBundle\Export\ExportInterface; use Chill\MainBundle\Export\FormatterInterface; use Chill\MainBundle\Export\GroupedExportInterface; +use Chill\PersonBundle\Entity\AccompanyingPeriodParticipation; +use Chill\PersonBundle\Entity\Person\PersonCenterHistory; +use Chill\PersonBundle\Export\Declarations as PersonDeclarations; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityRepository; use Doctrine\ORM\Query; +use LogicException; use Symfony\Component\Form\FormBuilderInterface; -use Symfony\Component\Security\Core\Role\Role; class AvgActivityVisitDuration implements ExportInterface, GroupedExportInterface { @@ -67,9 +70,9 @@ class AvgActivityVisitDuration implements ExportInterface, GroupedExportInterfac return ['export_avg_activity_visit_duration']; } - public function getResult($qb, $data) + public function getResult($query, $data) { - return $qb->getQuery()->getResult(Query::HYDRATE_SCALAR); + return $query->getQuery()->getResult(Query::HYDRATE_SCALAR); } public function getTitle(): string @@ -84,17 +87,34 @@ class AvgActivityVisitDuration implements ExportInterface, GroupedExportInterfac public function initiateQuery(array $requiredModifiers, array $acl, array $data = []) { - $qb = $this->repository->createQueryBuilder('activity') - ->join('activity.accompanyingPeriod', 'acp'); + $centers = array_map(static function ($el) { + return $el['center']; + }, $acl); - $qb->select('AVG(activity.travelTime) as export_avg_activity_visit_duration'); + $qb = $this->repository->createQueryBuilder('activity'); + + $qb + ->join('activity.accompanyingPeriod', 'acp') + ->select('AVG(activity.travelTime) as export_avg_activity_visit_duration') + ->andWhere($qb->expr()->isNotNull('activity.travelTime')); + + $qb + ->andWhere( + $qb->expr()->exists( + 'SELECT 1 FROM ' . AccompanyingPeriodParticipation::class . ' acl_count_part + JOIN ' . PersonCenterHistory::class . ' acl_count_person_history WITH IDENTITY(acl_count_person_history.person) = IDENTITY(acl_count_part.person) + WHERE acl_count_part.accompanyingPeriod = acp.id AND acl_count_person_history.center IN (:authorized_centers) + ' + ) + ) + ->setParameter('authorized_centers', $centers); return $qb; } - public function requiredRole(): Role + public function requiredRole(): string { - return new Role(ActivityStatsVoter::STATS); + return ActivityStatsVoter::STATS; } public function supportsModifiers(): array @@ -102,7 +122,7 @@ class AvgActivityVisitDuration implements ExportInterface, GroupedExportInterfac return [ Declarations::ACTIVITY, Declarations::ACTIVITY_ACP, - //PersonDeclarations::ACP_TYPE, + PersonDeclarations::ACP_TYPE, ]; } } diff --git a/src/Bundle/ChillActivityBundle/Export/Export/LinkedToACP/CountActivity.php b/src/Bundle/ChillActivityBundle/Export/Export/LinkedToACP/CountActivity.php index 3e6c878f3..2dc844aa2 100644 --- a/src/Bundle/ChillActivityBundle/Export/Export/LinkedToACP/CountActivity.php +++ b/src/Bundle/ChillActivityBundle/Export/Export/LinkedToACP/CountActivity.php @@ -17,13 +17,14 @@ use Chill\ActivityBundle\Security\Authorization\ActivityStatsVoter; use Chill\MainBundle\Export\ExportInterface; use Chill\MainBundle\Export\FormatterInterface; use Chill\MainBundle\Export\GroupedExportInterface; +use Chill\PersonBundle\Entity\AccompanyingPeriodParticipation; +use Chill\PersonBundle\Entity\Person\PersonCenterHistory; use Chill\PersonBundle\Export\Declarations as PersonDeclarations; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityRepository; use Doctrine\ORM\Query; use LogicException; use Symfony\Component\Form\FormBuilderInterface; -use Symfony\Component\Security\Core\Role\Role; class CountActivity implements ExportInterface, GroupedExportInterface { @@ -68,9 +69,9 @@ class CountActivity implements ExportInterface, GroupedExportInterface return ['export_count_activity']; } - public function getResult($qb, $data) + public function getResult($query, $data) { - return $qb->getQuery()->getResult(Query::HYDRATE_SCALAR); + return $query->getQuery()->getResult(Query::HYDRATE_SCALAR); } public function getTitle(): string @@ -85,17 +86,33 @@ class CountActivity implements ExportInterface, GroupedExportInterface public function initiateQuery(array $requiredModifiers, array $acl, array $data = []) { - $qb = $this->repository->createQueryBuilder('activity') + $centers = array_map(static function ($el) { + return $el['center']; + }, $acl); + + $qb = $this->repository + ->createQueryBuilder('activity') ->join('activity.accompanyingPeriod', 'acp'); - $qb->select('COUNT(activity.id) as export_count_activity'); + $qb + ->andWhere( + $qb->expr()->exists( + 'SELECT 1 FROM ' . AccompanyingPeriodParticipation::class . ' acl_count_part + JOIN ' . PersonCenterHistory::class . ' acl_count_person_history WITH IDENTITY(acl_count_person_history.person) = IDENTITY(acl_count_part.person) + WHERE acl_count_part.accompanyingPeriod = acp.id AND acl_count_person_history.center IN (:authorized_centers) + ' + ) + ) + ->setParameter('authorized_centers', $centers); + + $qb->select('COUNT(DISTINCT activity.id) as export_count_activity'); return $qb; } - public function requiredRole(): Role + public function requiredRole(): string { - return new Role(ActivityStatsVoter::STATS); + return ActivityStatsVoter::STATS; } public function supportsModifiers(): array @@ -103,7 +120,7 @@ class CountActivity implements ExportInterface, GroupedExportInterface return [ Declarations::ACTIVITY, Declarations::ACTIVITY_ACP, - //PersonDeclarations::ACP_TYPE, + PersonDeclarations::ACP_TYPE, ]; } } diff --git a/src/Bundle/ChillActivityBundle/Export/Export/LinkedToACP/ListActivity.php b/src/Bundle/ChillActivityBundle/Export/Export/LinkedToACP/ListActivity.php new file mode 100644 index 000000000..77444e414 --- /dev/null +++ b/src/Bundle/ChillActivityBundle/Export/Export/LinkedToACP/ListActivity.php @@ -0,0 +1,165 @@ +helper = $helper; + $this->entityManager = $entityManager; + $this->translatableStringExportLabelHelper = $translatableStringExportLabelHelper; + } + + public function buildForm(FormBuilderInterface $builder) + { + $this->helper->buildForm($builder); + } + + public function getAllowedFormattersTypes() + { + return $this->helper->getAllowedFormattersTypes(); + } + + public function getDescription() + { + return ListActivityHelper::MSG_KEY . 'List activities linked to an accompanying course'; + } + + public function getGroup(): string + { + return 'Exports of activities linked to an accompanying period'; + } + + public function getLabels($key, array $values, $data) + { + switch ($key) { + case 'acpId': + return static function ($value) { + if ('_header' === $value) { + return ListActivityHelper::MSG_KEY . 'accompanying course id'; + } + + return $value ?? ''; + }; + + case 'scopesNames': + return $this->translatableStringExportLabelHelper->getLabelMulti($key, $values, ListActivityHelper::MSG_KEY . 'course circles'); + + default: + return $this->helper->getLabels($key, $values, $data); + } + } + + public function getQueryKeys($data) + { + return + array_merge( + $this->helper->getQueryKeys($data), + [ + 'acpId', + 'scopesNames', + ] + ); + } + + public function getResult($query, $data) + { + return $this->helper->getResult($query, $data); + } + + public function getTitle() + { + return ListActivityHelper::MSG_KEY . 'List activity linked to a course'; + } + + public function getType() + { + return $this->helper->getType(); + } + + public function initiateQuery(array $requiredModifiers, array $acl, array $data = []) + { + $centers = array_map(static function ($el) { + return $el['center']; + }, $acl); + + $qb = $this->entityManager->createQueryBuilder(); + + $qb + ->distinct() + ->from(Activity::class, 'activity') + ->join('activity.accompanyingPeriod', 'acp') + ->leftJoin('acp.participations', 'acppart') + ->leftJoin('acppart.person', 'person') + ->andWhere('acppart.startDate != acppart.endDate OR acppart.endDate IS NULL') + ->andWhere( + $qb->expr()->exists( + 'SELECT 1 + FROM ' . PersonCenterHistory::class . ' acl_count_person_history + WHERE acl_count_person_history.person = person + AND acl_count_person_history.center IN (:authorized_centers) + ' + ) + ) + // some grouping are necessary + ->addGroupBy('acp.id') + ->addOrderBy('activity.date') + ->addOrderBy('activity.id') + ->setParameter('authorized_centers', $centers); + + $this->helper->addSelect($qb); + + // add select for this step + $qb + ->addSelect('acp.id AS acpId') + ->addSelect('(SELECT AGGREGATE(acpScope.name) FROM ' . Scope::class . ' acpScope WHERE acpScope MEMBER OF acp.scopes) AS scopesNames') + ->addGroupBy('scopesNames'); + + return $qb; + } + + public function requiredRole(): string + { + return ActivityStatsVoter::LISTS; + } + + public function supportsModifiers() + { + return array_merge( + $this->helper->supportsModifiers(), + [ + \Chill\PersonBundle\Export\Declarations::ACP_TYPE, + ] + ); + } +} diff --git a/src/Bundle/ChillActivityBundle/Export/Export/LinkedToACP/SumActivityDuration.php b/src/Bundle/ChillActivityBundle/Export/Export/LinkedToACP/SumActivityDuration.php index cc516cbbf..1cf20dc5f 100644 --- a/src/Bundle/ChillActivityBundle/Export/Export/LinkedToACP/SumActivityDuration.php +++ b/src/Bundle/ChillActivityBundle/Export/Export/LinkedToACP/SumActivityDuration.php @@ -17,11 +17,14 @@ use Chill\ActivityBundle\Security\Authorization\ActivityStatsVoter; use Chill\MainBundle\Export\ExportInterface; use Chill\MainBundle\Export\FormatterInterface; use Chill\MainBundle\Export\GroupedExportInterface; +use Chill\PersonBundle\Entity\AccompanyingPeriodParticipation; +use Chill\PersonBundle\Entity\Person\PersonCenterHistory; +use Chill\PersonBundle\Export\Declarations as PersonDeclarations; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityRepository; use Doctrine\ORM\Query; +use LogicException; use Symfony\Component\Form\FormBuilderInterface; -use Symfony\Component\Security\Core\Role\Role; class SumActivityDuration implements ExportInterface, GroupedExportInterface { @@ -67,9 +70,9 @@ class SumActivityDuration implements ExportInterface, GroupedExportInterface return ['export_sum_activity_duration']; } - public function getResult($qb, $data) + public function getResult($query, $data) { - return $qb->getQuery()->getResult(Query::HYDRATE_SCALAR); + return $query->getQuery()->getResult(Query::HYDRATE_SCALAR); } public function getTitle(): string @@ -84,17 +87,34 @@ class SumActivityDuration implements ExportInterface, GroupedExportInterface public function initiateQuery(array $requiredModifiers, array $acl, array $data = []) { - $qb = $this->repository->createQueryBuilder('activity') + $centers = array_map(static function ($el) { + return $el['center']; + }, $acl); + + $qb = $this->repository + ->createQueryBuilder('activity') ->join('activity.accompanyingPeriod', 'acp'); - $qb->select('SUM(activity.durationTime) as export_sum_activity_duration'); + $qb->select('SUM(activity.durationTime) as export_sum_activity_duration') + ->andWhere($qb->expr()->isNotNull('activity.durationTime')); + + $qb + ->andWhere( + $qb->expr()->exists( + 'SELECT 1 FROM ' . AccompanyingPeriodParticipation::class . ' acl_count_part + JOIN ' . PersonCenterHistory::class . ' acl_count_person_history WITH IDENTITY(acl_count_person_history.person) = IDENTITY(acl_count_part.person) + WHERE acl_count_part.accompanyingPeriod = acp.id AND acl_count_person_history.center IN (:authorized_centers) + ' + ) + ) + ->setParameter('authorized_centers', $centers); return $qb; } - public function requiredRole(): Role + public function requiredRole(): string { - return new Role(ActivityStatsVoter::STATS); + return ActivityStatsVoter::STATS; } public function supportsModifiers(): array @@ -102,7 +122,7 @@ class SumActivityDuration implements ExportInterface, GroupedExportInterface return [ Declarations::ACTIVITY, Declarations::ACTIVITY_ACP, - //PersonDeclarations::ACP_TYPE, + PersonDeclarations::ACP_TYPE, ]; } } diff --git a/src/Bundle/ChillActivityBundle/Export/Export/LinkedToACP/SumActivityVisitDuration.php b/src/Bundle/ChillActivityBundle/Export/Export/LinkedToACP/SumActivityVisitDuration.php index 0bbd7327b..2c160f3cf 100644 --- a/src/Bundle/ChillActivityBundle/Export/Export/LinkedToACP/SumActivityVisitDuration.php +++ b/src/Bundle/ChillActivityBundle/Export/Export/LinkedToACP/SumActivityVisitDuration.php @@ -17,11 +17,14 @@ use Chill\ActivityBundle\Security\Authorization\ActivityStatsVoter; use Chill\MainBundle\Export\ExportInterface; use Chill\MainBundle\Export\FormatterInterface; use Chill\MainBundle\Export\GroupedExportInterface; +use Chill\PersonBundle\Entity\AccompanyingPeriodParticipation; +use Chill\PersonBundle\Entity\Person\PersonCenterHistory; +use Chill\PersonBundle\Export\Declarations as PersonDeclarations; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityRepository; use Doctrine\ORM\Query; +use LogicException; use Symfony\Component\Form\FormBuilderInterface; -use Symfony\Component\Security\Core\Role\Role; class SumActivityVisitDuration implements ExportInterface, GroupedExportInterface { @@ -67,9 +70,9 @@ class SumActivityVisitDuration implements ExportInterface, GroupedExportInterfac return ['export_sum_activity_visit_duration']; } - public function getResult($qb, $data) + public function getResult($query, $data) { - return $qb->getQuery()->getResult(Query::HYDRATE_SCALAR); + return $query->getQuery()->getResult(Query::HYDRATE_SCALAR); } public function getTitle(): string @@ -84,17 +87,34 @@ class SumActivityVisitDuration implements ExportInterface, GroupedExportInterfac public function initiateQuery(array $requiredModifiers, array $acl, array $data = []) { - $qb = $this->repository->createQueryBuilder('activity') + $centers = array_map(static function ($el) { + return $el['center']; + }, $acl); + + $qb = $this->repository + ->createQueryBuilder('activity') ->join('activity.accompanyingPeriod', 'acp'); - $qb->select('SUM(activity.travelTime) as export_sum_activity_visit_duration'); + $qb->select('SUM(activity.travelTime) as export_sum_activity_visit_duration') + ->andWhere($qb->expr()->isNotNull('activity.travelTime')); + + $qb + ->andWhere( + $qb->expr()->exists( + 'SELECT 1 FROM ' . AccompanyingPeriodParticipation::class . ' acl_count_part + JOIN ' . PersonCenterHistory::class . ' acl_count_person_history WITH IDENTITY(acl_count_person_history.person) = IDENTITY(acl_count_part.person) + WHERE acl_count_part.accompanyingPeriod = acp.id AND acl_count_person_history.center IN (:authorized_centers) + ' + ) + ) + ->setParameter('authorized_centers', $centers); return $qb; } - public function requiredRole(): Role + public function requiredRole(): string { - return new Role(ActivityStatsVoter::STATS); + return ActivityStatsVoter::STATS; } public function supportsModifiers(): array @@ -102,7 +122,7 @@ class SumActivityVisitDuration implements ExportInterface, GroupedExportInterfac return [ Declarations::ACTIVITY, Declarations::ACTIVITY_ACP, - //PersonDeclarations::ACP_TYPE, + PersonDeclarations::ACP_TYPE, ]; } } diff --git a/src/Bundle/ChillActivityBundle/Export/Export/LinkedToPerson/CountActivity.php b/src/Bundle/ChillActivityBundle/Export/Export/LinkedToPerson/CountActivity.php index 057815266..4246df173 100644 --- a/src/Bundle/ChillActivityBundle/Export/Export/LinkedToPerson/CountActivity.php +++ b/src/Bundle/ChillActivityBundle/Export/Export/LinkedToPerson/CountActivity.php @@ -21,7 +21,6 @@ use Chill\PersonBundle\Export\Declarations as PersonDeclarations; use Doctrine\ORM\Query; use LogicException; use Symfony\Component\Form\FormBuilderInterface; -use Symfony\Component\Security\Core\Role\Role; class CountActivity implements ExportInterface, GroupedExportInterface { @@ -66,9 +65,9 @@ class CountActivity implements ExportInterface, GroupedExportInterface return ['export_count_activity']; } - public function getResult($qb, $data) + public function getResult($query, $data) { - return $qb->getQuery()->getResult(Query::HYDRATE_SCALAR); + return $query->getQuery()->getResult(Query::HYDRATE_SCALAR); } public function getTitle() @@ -85,21 +84,32 @@ class CountActivity implements ExportInterface, GroupedExportInterface { $centers = array_map(static fn ($el) => $el['center'], $acl); - $qb = $this->activityRepository->createQueryBuilder('activity') - ->join('activity.person', 'person'); + $qb = $this->activityRepository + ->createQueryBuilder('activity') + ->join('activity.person', 'person') + ->join('person.centerHistory', 'centerHistory'); $qb->select('COUNT(activity.id) as export_count_activity'); $qb - ->where($qb->expr()->in('person.center', ':centers')) + ->where( + $qb->expr()->andX( + $qb->expr()->lte('centerHistory.startDate', 'activity.date'), + $qb->expr()->orX( + $qb->expr()->isNull('centerHistory.endDate'), + $qb->expr()->gt('centerHistory.endDate', 'activity.date') + ) + ) + ) + ->andWhere($qb->expr()->in('centerHistory.center', ':centers')) ->setParameter('centers', $centers); return $qb; } - public function requiredRole() + public function requiredRole(): string { - return new Role(ActivityStatsVoter::STATS); + return ActivityStatsVoter::STATS; } public function supportsModifiers() @@ -107,7 +117,7 @@ class CountActivity implements ExportInterface, GroupedExportInterface return [ Declarations::ACTIVITY, Declarations::ACTIVITY_PERSON, - //PersonDeclarations::PERSON_TYPE, + PersonDeclarations::PERSON_TYPE, ]; } } diff --git a/src/Bundle/ChillActivityBundle/Export/Export/LinkedToPerson/ListActivity.php b/src/Bundle/ChillActivityBundle/Export/Export/LinkedToPerson/ListActivity.php index 632d01bc2..5d438d3a5 100644 --- a/src/Bundle/ChillActivityBundle/Export/Export/LinkedToPerson/ListActivity.php +++ b/src/Bundle/ChillActivityBundle/Export/Export/LinkedToPerson/ListActivity.php @@ -26,7 +26,6 @@ use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Query; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\FormBuilderInterface; -use Symfony\Component\Security\Core\Role\Role; use Symfony\Component\Validator\Constraints\Callback; use Symfony\Component\Validator\Context\ExecutionContextInterface; use Symfony\Contracts\Translation\TranslatorInterface; @@ -125,7 +124,7 @@ class ListActivity implements ListInterface, GroupedExportInterface return 'attendee'; } - return $value ? 1 : 0; + return $value ? 'X' : ''; }; case 'list_reasons': @@ -211,10 +210,20 @@ class ListActivity implements ListInterface, GroupedExportInterface $qb ->from('ChillActivityBundle:Activity', 'activity') - ->join('activity.person', 'person') - ->join('person.center', 'center') - ->andWhere('center IN (:authorized_centers)') - ->setParameter('authorized_centers', $centers); + ->join('activity.person', 'actperson') + ->join('actperson.centerHistory', 'centerHistory'); + + $qb->where( + $qb->expr()->andX( + $qb->expr()->lte('centerHistory.startDate', 'activity.date'), + $qb->expr()->orX( + $qb->expr()->isNull('centerHistory.endDate'), + $qb->expr()->gt('centerHistory.endDate', 'activity.date') + ) + ) + ) + ->andWhere($qb->expr()->in('centerHistory.center', ':centers')) + ->setParameter('centers', $centers); foreach ($this->fields as $f) { if (in_array($f, $data['fields'], true)) { @@ -225,23 +234,23 @@ class ListActivity implements ListInterface, GroupedExportInterface break; case 'person_firstname': - $qb->addSelect('person.firstName AS person_firstname'); + $qb->addSelect('actperson.firstName AS person_firstname'); break; case 'person_lastname': - $qb->addSelect('person.lastName AS person_lastname'); + $qb->addSelect('actperson.lastName AS person_lastname'); break; case 'person_id': - $qb->addSelect('person.id AS person_id'); + $qb->addSelect('actperson.id AS person_id'); break; case 'user_username': - $qb->join('activity.user', 'user'); - $qb->addSelect('user.username AS user_username'); + $qb->join('activity.user', 'actuser'); + $qb->addSelect('actuser.username AS user_username'); break; @@ -252,7 +261,7 @@ class ListActivity implements ListInterface, GroupedExportInterface break; case 'type_name': - $qb->join('activity.type', 'type'); + $qb->join('activity.activityType', 'type'); $qb->addSelect('type.name AS type_name'); break; @@ -264,6 +273,11 @@ class ListActivity implements ListInterface, GroupedExportInterface break; + case 'attendee': + $qb->addSelect('IDENTITY(activity.attendee) AS attendee'); + + break; + default: $qb->addSelect(sprintf('activity.%s as %s', $f, $f)); @@ -275,9 +289,9 @@ class ListActivity implements ListInterface, GroupedExportInterface return $qb; } - public function requiredRole() + public function requiredRole(): string { - return new Role(ActivityStatsVoter::LISTS); + return ActivityStatsVoter::LISTS; } public function supportsModifiers() diff --git a/src/Bundle/ChillActivityBundle/Export/Export/LinkedToPerson/StatActivityDuration.php b/src/Bundle/ChillActivityBundle/Export/Export/LinkedToPerson/StatActivityDuration.php index 8a54c027f..050034954 100644 --- a/src/Bundle/ChillActivityBundle/Export/Export/LinkedToPerson/StatActivityDuration.php +++ b/src/Bundle/ChillActivityBundle/Export/Export/LinkedToPerson/StatActivityDuration.php @@ -22,7 +22,6 @@ use Chill\PersonBundle\Export\Declarations as PersonDeclarations; use Doctrine\ORM\Query; use LogicException; use Symfony\Component\Form\FormBuilderInterface; -use Symfony\Component\Security\Core\Role\Role; /** * This export allow to compute stats on activity duration. @@ -65,6 +64,8 @@ class StatActivityDuration implements ExportInterface, GroupedExportInterface if (self::SUM === $this->action) { return 'Sum activities linked to a person duration by various parameters.'; } + + throw new LogicException('this action is not supported: ' . $this->action); } public function getGroup(): string @@ -88,9 +89,9 @@ class StatActivityDuration implements ExportInterface, GroupedExportInterface return ['export_stat_activity']; } - public function getResult($qb, $data) + public function getResult($query, $data) { - return $qb->getQuery()->getResult(Query::HYDRATE_SCALAR); + return $query->getQuery()->getResult(Query::HYDRATE_SCALAR); } public function getTitle() @@ -98,6 +99,8 @@ class StatActivityDuration implements ExportInterface, GroupedExportInterface if (self::SUM === $this->action) { return 'Sum activity linked to a person duration'; } + + throw new LogicException('This action is not supported: ' . $this->action); } public function getType(): string @@ -120,16 +123,29 @@ class StatActivityDuration implements ExportInterface, GroupedExportInterface $select = 'SUM(activity.durationTime) AS export_stat_activity'; } - return $qb->select($select) + $qb->select($select) ->join('activity.person', 'person') - ->join('person.center', 'center') - ->where($qb->expr()->in('center', ':centers')) - ->setParameter(':centers', $centers); + ->join('person.centerHistory', 'centerHistory'); + + $qb + ->where( + $qb->expr()->andX( + $qb->expr()->lte('centerHistory.startDate', 'activity.date'), + $qb->expr()->orX( + $qb->expr()->isNull('centerHistory.endDate'), + $qb->expr()->gt('centerHistory.endDate', 'activity.date') + ) + ) + ) + ->andWhere($qb->expr()->in('centerHistory.center', ':centers')) + ->setParameter('centers', $centers); + + return $qb; } - public function requiredRole() + public function requiredRole(): string { - return new Role(ActivityStatsVoter::STATS); + return ActivityStatsVoter::STATS; } public function supportsModifiers() diff --git a/src/Bundle/ChillActivityBundle/Export/Export/ListActivityHelper.php b/src/Bundle/ChillActivityBundle/Export/Export/ListActivityHelper.php new file mode 100644 index 000000000..0e8b28ab4 --- /dev/null +++ b/src/Bundle/ChillActivityBundle/Export/Export/ListActivityHelper.php @@ -0,0 +1,269 @@ +activityPresenceRepository = $activityPresenceRepository; + $this->activityTypeRepository = $activityTypeRepository; + $this->dateTimeHelper = $dateTimeHelper; + $this->labelPersonHelper = $labelPersonHelper; + $this->labelThirdPartyHelper = $labelThirdPartyHelper; + $this->translator = $translator; + $this->translatableStringHelper = $translatableStringHelper; + $this->translatableStringLabelHelper = $translatableStringLabelHelper; + $this->userHelper = $userHelper; + } + + public function addSelect(QueryBuilder $qb): void + { + $qb + ->addSelect('activity.id AS id') + ->addSelect('activity.date') + ->addSelect('IDENTITY(activity.activityType) AS typeName') + ->leftJoin('activity.reasons', 'reasons') + ->addSelect('AGGREGATE(reasons.name) AS listReasons') + ->leftJoin('activity.persons', 'actPerson') + ->addSelect('AGGREGATE(actPerson.id) AS personsIds') + ->addSelect('AGGREGATE(actPerson.id) AS personsNames') + ->leftJoin('activity.users', 'users_u') + ->addSelect('AGGREGATE(users_u.id) AS usersIds') + ->addSelect('AGGREGATE(users_u.id) AS usersNames') + ->leftJoin('activity.thirdParties', 'thirdparty') + ->addSelect('AGGREGATE(thirdparty.id) AS thirdPartiesIds') + ->addSelect('AGGREGATE(thirdparty.id) AS thirdPartiesNames') + ->addSelect('IDENTITY(activity.attendee) AS attendeeName') + ->addSelect('activity.durationTime') + ->addSelect('activity.travelTime') + ->addSelect('activity.emergency') + ->leftJoin('activity.location', 'location') + ->addSelect('location.name AS locationName') + ->addSelect('activity.sentReceived') + ->addSelect('IDENTITY(activity.createdBy) AS createdBy') + ->addSelect('activity.createdAt') + ->addSelect('IDENTITY(activity.updatedBy) AS updatedBy') + ->addSelect('activity.updatedAt') + ->addGroupBy('activity.id') + ->addGroupBy('location.id'); + } + + public function buildForm(FormBuilderInterface $builder) + { + } + + public function getAllowedFormattersTypes() + { + return [FormatterInterface::TYPE_LIST]; + } + + public function getLabels($key, array $values, $data) + { + switch ($key) { + case 'createdAt': + case 'updatedAt': + return $this->dateTimeHelper->getLabel($key); + + case 'createdBy': + case 'updatedBy': + return $this->userHelper->getLabel($key, $values, $key); + + case 'date': + return $this->dateTimeHelper->getLabel(self::MSG_KEY . $key); + + case 'attendeeName': + return function ($value) { + if ('_header' === $value) { + return 'Attendee'; + } + + if (null === $value || null === $presence = $this->activityPresenceRepository->find($value)) { + return ''; + } + + return $this->translatableStringHelper->localize($presence->getName()); + }; + + case 'listReasons': + return $this->translatableStringLabelHelper->getLabelMulti($key, $values, 'Activity Reasons'); + + case 'typeName': + return function ($value) { + if ('_header' === $value) { + return 'Activity type'; + } + + if (null === $value || null === $type = $this->activityTypeRepository->find($value)) { + return ''; + } + + return $this->translatableStringHelper->localize($type->getName()); + }; + + case 'usersNames': + return $this->userHelper->getLabelMulti($key, $values, self::MSG_KEY . 'users name'); + + case 'usersIds': + case 'thirdPartiesIds': + case 'personsIds': + return static function ($value) use ($key) { + if ('_header' === $value) { + switch ($key) { + case 'usersIds': + return self::MSG_KEY . 'users ids'; + + case 'thirdPartiesIds': + return self::MSG_KEY . 'third parties ids'; + + case 'personsIds': + return self::MSG_KEY . 'persons ids'; + + default: + throw new LogicException('key not supported'); + } + } + + $decoded = json_decode($value); + + return implode( + '|', + array_unique( + array_filter($decoded, static fn (?int $id) => null !== $id), + SORT_NUMERIC + ) + ); + }; + + case 'personsNames': + return $this->labelPersonHelper->getLabelMulti($key, $values, self::MSG_KEY . 'persons name'); + + case 'thirdPartiesNames': + return $this->labelThirdPartyHelper->getLabelMulti($key, $values, self::MSG_KEY . 'thirds parties'); + + case 'sentReceived': + return function ($value) { + if ('_header' === $value) { + return self::MSG_KEY . 'sent received'; + } + + if (null === $value) { + return ''; + } + + return $this->translator->trans($value); + }; + + default: + return function ($value) use ($key) { + if ('_header' === $value) { + return self::MSG_KEY . $key; + } + + if (null === $value) { + return ''; + } + + return $this->translator->trans($value); + }; + } + } + + public function getQueryKeys($data) + { + return [ + 'id', + 'date', + 'typeName', + 'listReasons', + 'attendeeName', + 'durationTime', + 'travelTime', + 'emergency', + 'locationName', + 'sentReceived', + 'personsIds', + 'personsNames', + 'usersIds', + 'usersNames', + 'thirdPartiesIds', + 'thirdPartiesNames', + 'createdBy', + 'createdAt', + 'updatedBy', + 'updatedAt', + ]; + } + + public function getResult($query, $data) + { + return $query->getQuery()->getResult(AbstractQuery::HYDRATE_SCALAR); + } + + public function getType(): string + { + return Declarations::ACTIVITY; + } + + public function supportsModifiers() + { + return [ + Declarations::ACTIVITY, + ]; + } +} diff --git a/src/Bundle/ChillPersonBundle/Export/Filter/AccompanyingCourseFilters/ActivityTypeFilter.php b/src/Bundle/ChillActivityBundle/Export/Filter/ACPFilters/ActivityTypeFilter.php similarity index 52% rename from src/Bundle/ChillPersonBundle/Export/Filter/AccompanyingCourseFilters/ActivityTypeFilter.php rename to src/Bundle/ChillActivityBundle/Export/Filter/ACPFilters/ActivityTypeFilter.php index 3fe9c0104..575d11815 100644 --- a/src/Bundle/ChillPersonBundle/Export/Filter/AccompanyingCourseFilters/ActivityTypeFilter.php +++ b/src/Bundle/ChillActivityBundle/Export/Filter/ACPFilters/ActivityTypeFilter.php @@ -9,57 +9,49 @@ declare(strict_types=1); * the LICENSE file that was distributed with this source code. */ -namespace Chill\PersonBundle\Export\Filter\AccompanyingCourseFilters; +namespace Chill\ActivityBundle\Export\Filter\ACPFilters; +use Chill\ActivityBundle\Entity\Activity; use Chill\ActivityBundle\Entity\ActivityType; +use Chill\ActivityBundle\Repository\ActivityTypeRepositoryInterface; use Chill\MainBundle\Export\FilterInterface; -use Chill\MainBundle\Templating\TranslatableStringHelper; +use Chill\MainBundle\Templating\TranslatableStringHelperInterface; use Chill\PersonBundle\Export\Declarations; -use Doctrine\ORM\Query\Expr\Andx; +use Doctrine\ORM\Query\Expr; use Doctrine\ORM\QueryBuilder; use Symfony\Bridge\Doctrine\Form\Type\EntityType; use Symfony\Component\Form\FormBuilderInterface; +use function in_array; -/** - * TODO merge with ActivityTypeFilter in ChillActivity (!?). - */ class ActivityTypeFilter implements FilterInterface { - private TranslatableStringHelper $translatableStringHelper; + private ActivityTypeRepositoryInterface $activityTypeRepository; - public function __construct(TranslatableStringHelper $translatableStringHelper) - { + private TranslatableStringHelperInterface $translatableStringHelper; + + public function __construct( + ActivityTypeRepositoryInterface $activityTypeRepository, + TranslatableStringHelperInterface $translatableStringHelper + ) { + $this->activityTypeRepository = $activityTypeRepository; $this->translatableStringHelper = $translatableStringHelper; } - public function addRole() + public function addRole(): ?string { return null; } public function alterQuery(QueryBuilder $qb, $data) { - // One2many between activity and accompanyingperiod is not reversed ! - // we replace indicator 'from' clause by 'act', and put 'acp' in a join - - $qb->resetDQLPart('from'); - $qb->from('ChillActivityBundle:Activity', 'act'); - - $qb - ->join('act.accompanyingPeriod', 'acp') - ->join('act.activityType', 'aty'); - - $where = $qb->getDQLPart('where'); - $clause = $qb->expr()->in('aty.id', ':activitytypes'); - - if ($where instanceof Andx) { - $where->add($clause); - } else { - $where = $qb->expr()->andX($clause); + if (!in_array('activity', $qb->getAllAliases(), true)) { + $qb->join(Activity::class, 'activity', Expr\Join::WITH, 'activity.accompanyingPeriod = acp'); } - $qb->add('where', $where); - $qb->setParameter('activitytypes', $data['accepted_activitytypes']); + $clause = $qb->expr()->in('activity.activityType', ':selected_activity_types'); + + $qb->andWhere($clause); + $qb->setParameter('selected_activity_types', $data['types']); } public function applyOn() @@ -71,8 +63,12 @@ class ActivityTypeFilter implements FilterInterface { $builder->add('accepted_activitytypes', EntityType::class, [ 'class' => ActivityType::class, + 'choices' => $this->activityTypeRepository->findAllActive(), 'choice_label' => function (ActivityType $aty) { - return $this->translatableStringHelper->localize($aty->getName()); + return + ($aty->hasCategory() ? $this->translatableStringHelper->localize($aty->getCategory()->getName()) . ' > ' : '') + . + $this->translatableStringHelper->localize($aty->getName()); }, 'multiple' => true, 'expanded' => true, @@ -88,7 +84,7 @@ class ActivityTypeFilter implements FilterInterface } return ['Filtered by activity types: only %activitytypes%', [ - '%activitytypes%' => implode(', ou ', $types), + '%activitytypes%' => implode(', ', $types), ]]; } diff --git a/src/Bundle/ChillActivityBundle/Export/Filter/ACPFilters/ByUserFilter.php b/src/Bundle/ChillActivityBundle/Export/Filter/ACPFilters/ByCreatorFilter.php similarity index 52% rename from src/Bundle/ChillActivityBundle/Export/Filter/ACPFilters/ByUserFilter.php rename to src/Bundle/ChillActivityBundle/Export/Filter/ACPFilters/ByCreatorFilter.php index 2adc21f22..322393f32 100644 --- a/src/Bundle/ChillActivityBundle/Export/Filter/ACPFilters/ByUserFilter.php +++ b/src/Bundle/ChillActivityBundle/Export/Filter/ACPFilters/ByCreatorFilter.php @@ -12,16 +12,13 @@ declare(strict_types=1); namespace Chill\ActivityBundle\Export\Filter\ACPFilters; use Chill\ActivityBundle\Export\Declarations; -use Chill\MainBundle\Entity\User; use Chill\MainBundle\Export\FilterInterface; +use Chill\MainBundle\Form\Type\PickUserDynamicType; use Chill\MainBundle\Templating\Entity\UserRender; -use Doctrine\ORM\Query\Expr\Andx; use Doctrine\ORM\QueryBuilder; -use Symfony\Bridge\Doctrine\Form\Type\EntityType; use Symfony\Component\Form\FormBuilderInterface; -use function in_array; -class ByUserFilter implements FilterInterface +class ByCreatorFilter implements FilterInterface { private UserRender $userRender; @@ -30,29 +27,18 @@ class ByUserFilter implements FilterInterface $this->userRender = $userRender; } - public function addRole() + public function addRole(): ?string { return null; } public function alterQuery(QueryBuilder $qb, $data) { - $where = $qb->getDQLPart('where'); - - if (!in_array('user', $qb->getAllAliases(), true)) { - $qb->join('activity.users', 'user'); - } - - $clause = $qb->expr()->in('user.id', ':users'); - - if ($where instanceof Andx) { - $where->add($clause); - } else { - $where = $qb->expr()->andX($clause); - } - - $qb->add('where', $where); - $qb->setParameter('users', $data['accepted_users']); + $qb + ->andWhere( + $qb->expr()->in('activity.createdBy', ':users') + ) + ->setParameter('users', $data['accepted_users']); } public function applyOn(): string @@ -62,13 +48,8 @@ class ByUserFilter implements FilterInterface public function buildForm(FormBuilderInterface $builder) { - $builder->add('accepted_users', EntityType::class, [ - 'class' => User::class, - 'choice_label' => function (User $u) { - return $this->userRender->renderString($u, []); - }, + $builder->add('accepted_users', PickUserDynamicType::class, [ 'multiple' => true, - 'expanded' => true, ]); } @@ -80,13 +61,13 @@ class ByUserFilter implements FilterInterface $users[] = $this->userRender->renderString($u, []); } - return ['Filtered activity by linked users: only %users%', [ - '%users%' => implode(', ou ', $users), + return ['Filtered activity by creator: only %users%', [ + '%users%' => implode(', ', $users), ]]; } public function getTitle(): string { - return 'Filter activity by linked users'; + return 'Filter activity by creator'; } } diff --git a/src/Bundle/ChillActivityBundle/Export/Filter/ACPFilters/BySocialActionFilter.php b/src/Bundle/ChillActivityBundle/Export/Filter/ACPFilters/BySocialActionFilter.php index b5bd5287b..d0c1b0fc7 100644 --- a/src/Bundle/ChillActivityBundle/Export/Filter/ACPFilters/BySocialActionFilter.php +++ b/src/Bundle/ChillActivityBundle/Export/Filter/ACPFilters/BySocialActionFilter.php @@ -14,10 +14,9 @@ namespace Chill\ActivityBundle\Export\Filter\ACPFilters; use Chill\ActivityBundle\Export\Declarations; use Chill\MainBundle\Export\FilterInterface; use Chill\PersonBundle\Entity\SocialWork\SocialAction; +use Chill\PersonBundle\Form\Type\PickSocialActionType; use Chill\PersonBundle\Templating\Entity\SocialActionRender; -use Doctrine\ORM\Query\Expr\Andx; use Doctrine\ORM\QueryBuilder; -use Symfony\Bridge\Doctrine\Form\Type\EntityType; use Symfony\Component\Form\FormBuilderInterface; use function in_array; @@ -30,29 +29,24 @@ class BySocialActionFilter implements FilterInterface $this->actionRender = $actionRender; } - public function addRole() + public function addRole(): ?string { return null; } public function alterQuery(QueryBuilder $qb, $data) { - $where = $qb->getDQLPart('where'); - - if (!in_array('socialaction', $qb->getAllAliases(), true)) { - $qb->join('activity.socialActions', 'socialaction'); + if (!in_array('actsocialaction', $qb->getAllAliases(), true)) { + $qb->join('activity.socialActions', 'actsocialaction'); } - $clause = $qb->expr()->in('socialaction.id', ':socialactions'); + $clause = $qb->expr()->in('actsocialaction.id', ':socialactions'); - if ($where instanceof Andx) { - $where->add($clause); - } else { - $where = $qb->expr()->andX($clause); - } - - $qb->add('where', $where); - $qb->setParameter('socialactions', $data['accepted_socialactions']); + $qb->andWhere($clause) + ->setParameter( + 'socialactions', + SocialAction::getDescendantsWithThisForActions($data['accepted_socialactions']) + ); } public function applyOn(): string @@ -62,13 +56,8 @@ class BySocialActionFilter implements FilterInterface public function buildForm(FormBuilderInterface $builder) { - $builder->add('accepted_socialactions', EntityType::class, [ - 'class' => SocialAction::class, - 'choice_label' => function (SocialAction $sa) { - return $this->actionRender->renderString($sa, []); - }, + $builder->add('accepted_socialactions', PickSocialActionType::class, [ 'multiple' => true, - 'expanded' => true, ]); } @@ -76,12 +65,14 @@ class BySocialActionFilter implements FilterInterface { $actions = []; - foreach ($data['accepted_socialactions'] as $sa) { - $actions[] = $this->actionRender->renderString($sa, []); + foreach ($data['accepted_socialactions'] as $action) { + $actions[] = $this->actionRender->renderString($action, [ + 'show_and_children' => true, + ]); } return ['Filtered activity by linked socialaction: only %actions%', [ - '%actions%' => implode(', ou ', $actions), + '%actions%' => implode(', ', $actions), ]]; } diff --git a/src/Bundle/ChillActivityBundle/Export/Filter/ACPFilters/BySocialIssueFilter.php b/src/Bundle/ChillActivityBundle/Export/Filter/ACPFilters/BySocialIssueFilter.php index 766eabec6..bbb882a65 100644 --- a/src/Bundle/ChillActivityBundle/Export/Filter/ACPFilters/BySocialIssueFilter.php +++ b/src/Bundle/ChillActivityBundle/Export/Filter/ACPFilters/BySocialIssueFilter.php @@ -14,10 +14,9 @@ namespace Chill\ActivityBundle\Export\Filter\ACPFilters; use Chill\ActivityBundle\Export\Declarations; use Chill\MainBundle\Export\FilterInterface; use Chill\PersonBundle\Entity\SocialWork\SocialIssue; +use Chill\PersonBundle\Form\Type\PickSocialIssueType; use Chill\PersonBundle\Templating\Entity\SocialIssueRender; -use Doctrine\ORM\Query\Expr\Andx; use Doctrine\ORM\QueryBuilder; -use Symfony\Bridge\Doctrine\Form\Type\EntityType; use Symfony\Component\Form\FormBuilderInterface; use function in_array; @@ -30,29 +29,24 @@ class BySocialIssueFilter implements FilterInterface $this->issueRender = $issueRender; } - public function addRole() + public function addRole(): ?string { return null; } public function alterQuery(QueryBuilder $qb, $data) { - $where = $qb->getDQLPart('where'); - - if (!in_array('socialissue', $qb->getAllAliases(), true)) { - $qb->join('activity.socialIssues', 'socialissue'); + if (!in_array('actsocialissue', $qb->getAllAliases(), true)) { + $qb->join('activity.socialIssues', 'actsocialissue'); } - $clause = $qb->expr()->in('socialissue.id', ':socialissues'); + $clause = $qb->expr()->in('actsocialissue.id', ':socialissues'); - if ($where instanceof Andx) { - $where->add($clause); - } else { - $where = $qb->expr()->andX($clause); - } - - $qb->add('where', $where); - $qb->setParameter('socialissues', $data['accepted_socialissues']); + $qb->andWhere($clause) + ->setParameter( + 'socialissues', + SocialIssue::getDescendantsWithThisForIssues($data['accepted_socialissues']) + ); } public function applyOn(): string @@ -62,13 +56,8 @@ class BySocialIssueFilter implements FilterInterface public function buildForm(FormBuilderInterface $builder) { - $builder->add('accepted_socialissues', EntityType::class, [ - 'class' => SocialIssue::class, - 'choice_label' => function (SocialIssue $si) { - return $this->issueRender->renderString($si, []); - }, + $builder->add('accepted_socialissues', PickSocialIssueType::class, [ 'multiple' => true, - 'expanded' => true, ]); } @@ -76,12 +65,14 @@ class BySocialIssueFilter implements FilterInterface { $issues = []; - foreach ($data['accepted_socialissues'] as $si) { - $issues[] = $this->issueRender->renderString($si, []); + foreach ($data['accepted_socialissues'] as $issue) { + $issues[] = $this->issueRender->renderString($issue, [ + 'show_and_children' => true, + ]); } return ['Filtered activity by linked socialissue: only %issues%', [ - '%issues%' => implode(', ou ', $issues), + '%issues%' => implode(', ', $issues), ]]; } diff --git a/src/Bundle/ChillActivityBundle/Export/Filter/ACPFilters/EmergencyFilter.php b/src/Bundle/ChillActivityBundle/Export/Filter/ACPFilters/EmergencyFilter.php index f5d952bf2..b79c2ca10 100644 --- a/src/Bundle/ChillActivityBundle/Export/Filter/ACPFilters/EmergencyFilter.php +++ b/src/Bundle/ChillActivityBundle/Export/Filter/ACPFilters/EmergencyFilter.php @@ -35,7 +35,7 @@ class EmergencyFilter implements FilterInterface $this->translator = $translator; } - public function addRole() + public function addRole(): ?string { return null; } @@ -74,15 +74,13 @@ class EmergencyFilter implements FilterInterface public function describeAction($data, $format = 'string'): array { - foreach (self::CHOICES as $k => $v) { - if ($v === $data['accepted_emergency']) { - $choice = $k; - } - } - - return ['Filtered activity by emergency: only %emergency%', [ - '%emergency%' => $this->translator->trans($choice), - ]]; + return [ + 'Filtered by emergency: only %emergency%', [ + '%emergency%' => $this->translator->trans( + $data['accepted_emergency'] ? 'is emergency' : 'is not emergency' + ), + ], + ]; } public function getTitle(): string diff --git a/src/Bundle/ChillActivityBundle/Export/Filter/ACPFilters/HasNoActivityFilter.php b/src/Bundle/ChillActivityBundle/Export/Filter/ACPFilters/HasNoActivityFilter.php new file mode 100644 index 000000000..570f42ae0 --- /dev/null +++ b/src/Bundle/ChillActivityBundle/Export/Filter/ACPFilters/HasNoActivityFilter.php @@ -0,0 +1,57 @@ +andWhere(' + NOT EXISTS ( + SELECT 1 FROM ' . Activity::class . ' activity + WHERE activity.accompanyingPeriod = acp + ) + '); + } + + public function applyOn(): string + { + return Declarations::ACP_TYPE; + } + + public function buildForm(FormBuilderInterface $builder) + { + //no form needed + } + + public function describeAction($data, $format = 'string'): array + { + return ['Filtered acp which has no activities', []]; + } + + public function getTitle(): string + { + return 'Filter acp which has no activity'; + } +} diff --git a/src/Bundle/ChillActivityBundle/Export/Filter/ACPFilters/LocationTypeFilter.php b/src/Bundle/ChillActivityBundle/Export/Filter/ACPFilters/LocationTypeFilter.php index a17639481..5fe928b6c 100644 --- a/src/Bundle/ChillActivityBundle/Export/Filter/ACPFilters/LocationTypeFilter.php +++ b/src/Bundle/ChillActivityBundle/Export/Filter/ACPFilters/LocationTypeFilter.php @@ -12,12 +12,11 @@ declare(strict_types=1); namespace Chill\ActivityBundle\Export\Filter\ACPFilters; use Chill\ActivityBundle\Export\Declarations; -use Chill\MainBundle\Entity\LocationType; use Chill\MainBundle\Export\FilterInterface; +use Chill\MainBundle\Form\Type\PickLocationTypeType; use Chill\MainBundle\Templating\TranslatableStringHelper; use Doctrine\ORM\Query\Expr\Andx; use Doctrine\ORM\QueryBuilder; -use Symfony\Bridge\Doctrine\Form\Type\EntityType; use Symfony\Component\Form\FormBuilderInterface; use function in_array; @@ -30,19 +29,19 @@ class LocationTypeFilter implements FilterInterface $this->translatableStringHelper = $translatableStringHelper; } - public function addRole() + public function addRole(): ?string { return null; } public function alterQuery(QueryBuilder $qb, $data) { - if (!in_array('location', $qb->getAllAliases(), true)) { - $qb->join('activity.location', 'location'); + if (!in_array('actloc', $qb->getAllAliases(), true)) { + $qb->join('activity.location', 'actloc'); } $where = $qb->getDQLPart('where'); - $clause = $qb->expr()->in('location.locationType', ':locationtype'); + $clause = $qb->expr()->in('actloc.locationType', ':locationtype'); if ($where instanceof Andx) { $where->add($clause); @@ -61,13 +60,9 @@ class LocationTypeFilter implements FilterInterface public function buildForm(FormBuilderInterface $builder) { - $builder->add('accepted_locationtype', EntityType::class, [ - 'class' => LocationType::class, - 'choice_label' => function (LocationType $type) { - return $this->translatableStringHelper->localize($type->getTitle()); - }, + $builder->add('accepted_locationtype', PickLocationTypeType::class, [ 'multiple' => true, - 'expanded' => true, + //'label' => false, ]); } @@ -82,7 +77,7 @@ class LocationTypeFilter implements FilterInterface } return ['Filtered activity by locationtype: only %types%', [ - '%types%' => implode(', ou ', $types), + '%types%' => implode(', ', $types), ]]; } diff --git a/src/Bundle/ChillActivityBundle/Export/Filter/ACPFilters/SentReceivedFilter.php b/src/Bundle/ChillActivityBundle/Export/Filter/ACPFilters/SentReceivedFilter.php index cb2848a96..8daa7a781 100644 --- a/src/Bundle/ChillActivityBundle/Export/Filter/ACPFilters/SentReceivedFilter.php +++ b/src/Bundle/ChillActivityBundle/Export/Filter/ACPFilters/SentReceivedFilter.php @@ -36,7 +36,7 @@ class SentReceivedFilter implements FilterInterface $this->translator = $translator; } - public function addRole() + public function addRole(): ?string { return null; } diff --git a/src/Bundle/ChillActivityBundle/Export/Filter/ACPFilters/UserFilter.php b/src/Bundle/ChillActivityBundle/Export/Filter/ACPFilters/UserFilter.php index 3664be64b..6350f3ace 100644 --- a/src/Bundle/ChillActivityBundle/Export/Filter/ACPFilters/UserFilter.php +++ b/src/Bundle/ChillActivityBundle/Export/Filter/ACPFilters/UserFilter.php @@ -12,12 +12,11 @@ declare(strict_types=1); namespace Chill\ActivityBundle\Export\Filter\ACPFilters; use Chill\ActivityBundle\Export\Declarations; -use Chill\MainBundle\Entity\User; use Chill\MainBundle\Export\FilterInterface; +use Chill\MainBundle\Form\Type\PickUserDynamicType; use Chill\MainBundle\Templating\Entity\UserRender; use Doctrine\ORM\Query\Expr\Andx; use Doctrine\ORM\QueryBuilder; -use Symfony\Bridge\Doctrine\Form\Type\EntityType; use Symfony\Component\Form\FormBuilderInterface; class UserFilter implements FilterInterface @@ -29,7 +28,7 @@ class UserFilter implements FilterInterface $this->userRender = $userRender; } - public function addRole() + public function addRole(): ?string { return null; } @@ -57,13 +56,8 @@ class UserFilter implements FilterInterface public function buildForm(FormBuilderInterface $builder) { - $builder->add('accepted_users', EntityType::class, [ - 'class' => User::class, - 'choice_label' => function (User $u) { - return $this->userRender->renderString($u, []); - }, + $builder->add('accepted_users', PickUserDynamicType::class, [ 'multiple' => true, - 'expanded' => true, 'label' => 'Creators', ]); } @@ -77,7 +71,7 @@ class UserFilter implements FilterInterface } return ['Filtered activity by user: only %users%', [ - '%users%' => implode(', ou ', $users), + '%users%' => implode(', ', $users), ]]; } diff --git a/src/Bundle/ChillActivityBundle/Export/Filter/ACPFilters/UserScopeFilter.php b/src/Bundle/ChillActivityBundle/Export/Filter/ACPFilters/UserScopeFilter.php index 35c4dc657..1906db75e 100644 --- a/src/Bundle/ChillActivityBundle/Export/Filter/ACPFilters/UserScopeFilter.php +++ b/src/Bundle/ChillActivityBundle/Export/Filter/ACPFilters/UserScopeFilter.php @@ -30,20 +30,20 @@ class UserScopeFilter implements FilterInterface $this->translatableStringHelper = $translatableStringHelper; } - public function addRole() + public function addRole(): ?string { return null; } public function alterQuery(QueryBuilder $qb, $data) { - if (!in_array('user', $qb->getAllAliases(), true)) { - $qb->join('activity.user', 'user'); + if (!in_array('actuser', $qb->getAllAliases(), true)) { + $qb->join('activity.user', 'actuser'); } $where = $qb->getDQLPart('where'); - $clause = $qb->expr()->in('user.mainScope', ':userscope'); + $clause = $qb->expr()->in('actuser.mainScope', ':userscope'); if ($where instanceof Andx) { $where->add($clause); @@ -85,7 +85,7 @@ class UserScopeFilter implements FilterInterface } return ['Filtered activity by userscope: only %scopes%', [ - '%scopes%' => implode(', ou ', $scopes), + '%scopes%' => implode(', ', $scopes), ]]; } diff --git a/src/Bundle/ChillActivityBundle/Export/Filter/ActivityDateFilter.php b/src/Bundle/ChillActivityBundle/Export/Filter/ActivityDateFilter.php index 28534fc2f..f2216c929 100644 --- a/src/Bundle/ChillActivityBundle/Export/Filter/ActivityDateFilter.php +++ b/src/Bundle/ChillActivityBundle/Export/Filter/ActivityDateFilter.php @@ -13,9 +13,10 @@ namespace Chill\ActivityBundle\Export\Filter; use Chill\ActivityBundle\Export\Declarations; use Chill\MainBundle\Export\FilterInterface; -use Chill\MainBundle\Form\Type\ChillDateType; use Chill\MainBundle\Form\Type\Export\FilterType; -use DateTime; +use Chill\MainBundle\Form\Type\PickRollingDateType; +use Chill\MainBundle\Service\RollingDate\RollingDate; +use Chill\MainBundle\Service\RollingDate\RollingDateConverterInterface; use Doctrine\ORM\Query\Expr; use Doctrine\ORM\QueryBuilder; use Symfony\Component\Form\FormBuilderInterface; @@ -28,12 +29,17 @@ class ActivityDateFilter implements FilterInterface { protected TranslatorInterface $translator; - public function __construct(TranslatorInterface $translator) - { + private RollingDateConverterInterface $rollingDateConverter; + + public function __construct( + TranslatorInterface $translator, + RollingDateConverterInterface $rollingDateConverter + ) { $this->translator = $translator; + $this->rollingDateConverter = $rollingDateConverter; } - public function addRole() + public function addRole(): ?string { return null; } @@ -54,8 +60,14 @@ class ActivityDateFilter implements FilterInterface } $qb->add('where', $where); - $qb->setParameter('date_from', $data['date_from']); - $qb->setParameter('date_to', $data['date_to']); + $qb->setParameter( + 'date_from', + $this->rollingDateConverter->convert($data['date_from']) + ); + $qb->setParameter( + 'date_to', + $this->rollingDateConverter->convert($data['date_to']) + ); } public function applyOn(): string @@ -66,13 +78,13 @@ class ActivityDateFilter implements FilterInterface public function buildForm(FormBuilderInterface $builder) { $builder - ->add('date_from', ChillDateType::class, [ + ->add('date_from', PickRollingDateType::class, [ 'label' => 'Activities after this date', - 'data' => new DateTime(), + 'data' => new RollingDate(RollingDate::T_YEAR_PREVIOUS_START), ]) - ->add('date_to', ChillDateType::class, [ + ->add('date_to', PickRollingDateType::class, [ 'label' => 'Activities before this date', - 'data' => new DateTime(), + 'data' => new RollingDate(RollingDate::T_TODAY), ]); $builder->addEventListener(FormEvents::POST_SUBMIT, function (FormEvent $event) { @@ -121,8 +133,8 @@ class ActivityDateFilter implements FilterInterface return [ 'Filtered by date of activity: only between %date_from% and %date_to%', [ - '%date_from%' => $data['date_from']->format('d-m-Y'), - '%date_to%' => $data['date_to']->format('d-m-Y'), + '%date_from%' => $this->rollingDateConverter->convert($data['date_from'])->format('d-m-Y'), + '%date_to%' => $this->rollingDateConverter->convert($data['date_to'])->format('d-m-Y'), ], ]; } diff --git a/src/Bundle/ChillActivityBundle/Export/Filter/ActivityTypeFilter.php b/src/Bundle/ChillActivityBundle/Export/Filter/ActivityTypeFilter.php index e355fe2b2..d1758039a 100644 --- a/src/Bundle/ChillActivityBundle/Export/Filter/ActivityTypeFilter.php +++ b/src/Bundle/ChillActivityBundle/Export/Filter/ActivityTypeFilter.php @@ -13,52 +13,41 @@ namespace Chill\ActivityBundle\Export\Filter; use Chill\ActivityBundle\Entity\ActivityType; use Chill\ActivityBundle\Export\Declarations; -use Chill\ActivityBundle\Repository\ActivityTypeRepository; -use Chill\ActivityBundle\Security\Authorization\ActivityStatsVoter; +use Chill\ActivityBundle\Repository\ActivityTypeRepositoryInterface; use Chill\MainBundle\Export\ExportElementValidatedInterface; use Chill\MainBundle\Export\FilterInterface; use Chill\MainBundle\Templating\TranslatableStringHelperInterface; -use Doctrine\ORM\Query\Expr; -use Doctrine\ORM\Query\Expr\Join; use Doctrine\ORM\QueryBuilder; use Symfony\Bridge\Doctrine\Form\Type\EntityType; use Symfony\Component\Form\FormBuilderInterface; -use Symfony\Component\Security\Core\Role\Role; use Symfony\Component\Validator\Context\ExecutionContextInterface; use function count; class ActivityTypeFilter implements ExportElementValidatedInterface, FilterInterface { - protected ActivityTypeRepository $activityTypeRepository; + protected ActivityTypeRepositoryInterface $activityTypeRepository; protected TranslatableStringHelperInterface $translatableStringHelper; public function __construct( TranslatableStringHelperInterface $translatableStringHelper, - ActivityTypeRepository $activityTypeRepository + ActivityTypeRepositoryInterface $activityTypeRepository ) { $this->translatableStringHelper = $translatableStringHelper; $this->activityTypeRepository = $activityTypeRepository; } - public function addRole() + public function addRole(): ?string { - return new Role(ActivityStatsVoter::STATS); + return null; } public function alterQuery(QueryBuilder $qb, $data) { - $where = $qb->getDQLPart('where'); $clause = $qb->expr()->in('activity.activityType', ':selected_activity_types'); - if ($where instanceof Expr\Andx) { - $where->add($clause); - } else { - $where = $qb->expr()->andX($clause); - } - - $qb->add('where', $where); + $qb->andWhere($clause); $qb->setParameter('selected_activity_types', $data['types']); } @@ -70,11 +59,26 @@ class ActivityTypeFilter implements ExportElementValidatedInterface, FilterInter public function buildForm(FormBuilderInterface $builder) { $builder->add('types', EntityType::class, [ + 'choices' => $this->activityTypeRepository->findAllActive(), 'class' => ActivityType::class, - 'choice_label' => fn (ActivityType $type) => $this->translatableStringHelper->localize($type->getName()), - 'group_by' => fn (ActivityType $type) => $this->translatableStringHelper->localize($type->getCategory()->getName()), + 'choice_label' => function (ActivityType $aty) { + return + ($aty->hasCategory() ? $this->translatableStringHelper->localize($aty->getCategory()->getName()) . ' > ' : '') + . + $this->translatableStringHelper->localize($aty->getName()); + }, + 'group_by' => function (ActivityType $type) { + if (!$type->hasCategory()) { + return null; + } + + return $this->translatableStringHelper->localize($type->getCategory()->getName()); + }, 'multiple' => true, 'expanded' => false, + 'attr' => [ + 'class' => 'select2', + ], ]); } @@ -87,7 +91,7 @@ class ActivityTypeFilter implements ExportElementValidatedInterface, FilterInter ); return ['Filtered by activity type: only %list%', [ - '%list%' => implode(', ou ', $reasonsNames), + '%list%' => implode(', ', $reasonsNames), ]]; } @@ -104,23 +108,4 @@ class ActivityTypeFilter implements ExportElementValidatedInterface, FilterInter ->addViolation(); } } - - /** - * Check if a join between Activity and Reason is already defined. - * - * @param Join[] $joins - * @param mixed $alias - * - * @return bool - */ - private function checkJoinAlreadyDefined(array $joins, $alias) - { - foreach ($joins as $join) { - if ($join->getAlias() === $alias) { - return true; - } - } - - return false; - } } diff --git a/src/Bundle/ChillActivityBundle/Export/Filter/ActivityUsersFilter.php b/src/Bundle/ChillActivityBundle/Export/Filter/ActivityUsersFilter.php new file mode 100644 index 000000000..2f6cd8462 --- /dev/null +++ b/src/Bundle/ChillActivityBundle/Export/Filter/ActivityUsersFilter.php @@ -0,0 +1,77 @@ +userRender = $userRender; + } + + public function addRole(): ?string + { + return null; + } + + public function alterQuery(QueryBuilder $qb, $data) + { + $orX = $qb->expr()->orX(); + + foreach ($data['accepted_users'] as $key => $user) { + $orX->add($qb->expr()->isMemberOf(':activity_users_filter_u' . $key, 'activity.users')); + $qb->setParameter('activity_users_filter_u' . $key, $user); + } + + $qb->andWhere($orX); + } + + public function applyOn() + { + return Declarations::ACTIVITY; + } + + public function buildForm(FormBuilderInterface $builder) + { + $builder->add('accepted_users', PickUserDynamicType::class, [ + 'multiple' => true, + 'label' => 'Users', + ]); + } + + public function describeAction($data, $format = 'string') + { + $users = []; + + foreach ($data['accepted_users'] as $u) { + $users[] = $this->userRender->renderString($u, []); + } + + return ['Filtered activity by users: only %users%', [ + '%users%' => implode(', ', $users), + ]]; + } + + public function getTitle(): string + { + return 'Filter activity by users'; + } +} diff --git a/src/Bundle/ChillActivityBundle/Export/Filter/PersonFilters/ActivityReasonFilter.php b/src/Bundle/ChillActivityBundle/Export/Filter/PersonFilters/ActivityReasonFilter.php index bb22237a8..5634358c7 100644 --- a/src/Bundle/ChillActivityBundle/Export/Filter/PersonFilters/ActivityReasonFilter.php +++ b/src/Bundle/ChillActivityBundle/Export/Filter/PersonFilters/ActivityReasonFilter.php @@ -14,21 +14,18 @@ namespace Chill\ActivityBundle\Export\Filter\PersonFilters; use Chill\ActivityBundle\Entity\ActivityReason; use Chill\ActivityBundle\Export\Declarations; use Chill\ActivityBundle\Repository\ActivityReasonRepository; -use Chill\ActivityBundle\Security\Authorization\ActivityStatsVoter; use Chill\MainBundle\Export\ExportElementValidatedInterface; use Chill\MainBundle\Export\FilterInterface; use Chill\MainBundle\Templating\TranslatableStringHelper; use Chill\MainBundle\Templating\TranslatableStringHelperInterface; use Doctrine\ORM\Query\Expr; -use Doctrine\ORM\Query\Expr\Join; use Doctrine\ORM\QueryBuilder; use Symfony\Bridge\Doctrine\Form\Type\EntityType; use Symfony\Component\Form\FormBuilderInterface; -use Symfony\Component\Security\Core\Role\Role; use Symfony\Component\Validator\Context\ExecutionContextInterface; -use function array_key_exists; use function count; +use function in_array; class ActivityReasonFilter implements ExportElementValidatedInterface, FilterInterface { @@ -44,9 +41,9 @@ class ActivityReasonFilter implements ExportElementValidatedInterface, FilterInt $this->activityReasonRepository = $activityReasonRepository; } - public function addRole() + public function addRole(): ?string { - return new Role(ActivityStatsVoter::STATS); + return null; } public function alterQuery(QueryBuilder $qb, $data) @@ -54,20 +51,9 @@ class ActivityReasonFilter implements ExportElementValidatedInterface, FilterInt $where = $qb->getDQLPart('where'); $join = $qb->getDQLPart('join'); $clause = $qb->expr()->in('reasons', ':selected_activity_reasons'); - //dump($join); - // add a join to reasons only if needed - if ( - ( - array_key_exists('activity', $join) - && !$this->checkJoinAlreadyDefined($join['activity'], 'reasons') - ) - || (!array_key_exists('activity', $join)) - ) { - $qb->add( - 'join', - ['activity' => new Join(Join::INNER_JOIN, 'activity.reasons', 'reasons')], - true - ); + + if (!in_array('actreasons', $qb->getAllAliases(), true)) { + $qb->join('activity.reasons', 'actreasons'); } if ($where instanceof Expr\Andx) { @@ -125,21 +111,4 @@ class ActivityReasonFilter implements ExportElementValidatedInterface, FilterInt ->addViolation(); } } - - /** - * Check if a join between Activity and Reason is already defined. - * - * @param Join[] $joins - * @param mixed $alias - */ - private function checkJoinAlreadyDefined(array $joins, $alias): bool - { - foreach ($joins as $join) { - if ($join->getAlias() === $alias) { - return true; - } - } - - return false; - } } diff --git a/src/Bundle/ChillActivityBundle/Export/Filter/PersonFilters/PersonHavingActivityBetweenDateFilter.php b/src/Bundle/ChillActivityBundle/Export/Filter/PersonFilters/PersonHavingActivityBetweenDateFilter.php index cc16f1519..e3c85fe9c 100644 --- a/src/Bundle/ChillActivityBundle/Export/Filter/PersonFilters/PersonHavingActivityBetweenDateFilter.php +++ b/src/Bundle/ChillActivityBundle/Export/Filter/PersonFilters/PersonHavingActivityBetweenDateFilter.php @@ -11,6 +11,7 @@ declare(strict_types=1); namespace Chill\ActivityBundle\Export\Filter\PersonFilters; +use Chill\ActivityBundle\Entity\Activity; use Chill\ActivityBundle\Entity\ActivityReason; use Chill\ActivityBundle\Repository\ActivityReasonRepository; use Chill\MainBundle\Export\ExportElementValidatedInterface; @@ -52,17 +53,17 @@ class PersonHavingActivityBetweenDateFilter implements ExportElementValidatedInt $this->translator = $translator; } - public function addRole() + public function addRole(): ?string { return null; } public function alterQuery(QueryBuilder $qb, $data) { - // create a query for activity + // create a subquery for activity $sqb = $qb->getEntityManager()->createQueryBuilder(); $sqb->select('person_person_having_activity.id') - ->from('ChillActivityBundle:Activity', 'activity_person_having_activity') + ->from(Activity::class, 'activity_person_having_activity') ->join('activity_person_having_activity.person', 'person_person_having_activity'); // add clause between date @@ -197,7 +198,7 @@ class PersonHavingActivityBetweenDateFilter implements ExportElementValidatedInt public function getTitle() { - return 'Filtered by person having an activity in a period'; + return 'Filter by person having an activity in a period'; } public function validateForm($data, ExecutionContextInterface $context) diff --git a/src/Bundle/ChillActivityBundle/Export/Filter/UsersJobFilter.php b/src/Bundle/ChillActivityBundle/Export/Filter/UsersJobFilter.php new file mode 100644 index 000000000..b52ef441c --- /dev/null +++ b/src/Bundle/ChillActivityBundle/Export/Filter/UsersJobFilter.php @@ -0,0 +1,81 @@ +translatableStringHelper = $translatableStringHelper; + } + + public function addRole(): ?string + { + return null; + } + + public function alterQuery(QueryBuilder $qb, $data) + { + $qb + ->andWhere( + $qb->expr()->exists( + 'SELECT 1 FROM ' . Activity::class . ' activity_users_job_filter_act + JOIN activity_users_job_filter_act.users users WHERE users.userJob IN (:activity_users_job_filter_jobs) AND activity_users_job_filter_act = activity ' + ) + ) + ->setParameter('activity_users_job_filter_jobs', $data['jobs']); + } + + public function applyOn() + { + return Declarations::ACTIVITY; + } + + public function buildForm(FormBuilderInterface $builder) + { + $builder->add('jobs', EntityType::class, [ + 'class' => UserJob::class, + 'choice_label' => fn (UserJob $j) => $this->translatableStringHelper->localize($j->getLabel()), + 'multiple' => true, + 'expanded' => true, + ]); + } + + public function describeAction($data, $format = 'string') + { + return ['export.filter.activity.by_usersjob.Filtered activity by users job: only %jobs%', [ + '%jobs%' => implode( + ', ', + array_map( + fn (UserJob $job) => $this->translatableStringHelper->localize($job->getLabel()), + $data['jobs']->toArray() + ) + ), + ]]; + } + + public function getTitle() + { + return 'export.filter.activity.by_usersjob.Filter by users job'; + } +} diff --git a/src/Bundle/ChillActivityBundle/Export/Filter/UsersScopeFilter.php b/src/Bundle/ChillActivityBundle/Export/Filter/UsersScopeFilter.php new file mode 100644 index 000000000..61b12264e --- /dev/null +++ b/src/Bundle/ChillActivityBundle/Export/Filter/UsersScopeFilter.php @@ -0,0 +1,88 @@ +scopeRepository = $scopeRepository; + $this->translatableStringHelper = $translatableStringHelper; + } + + public function addRole(): ?string + { + return null; + } + + public function alterQuery(QueryBuilder $qb, $data) + { + $qb + ->andWhere( + $qb->expr()->exists( + 'SELECT 1 FROM ' . Activity::class . ' activity_users_scope_filter_act + JOIN activity_users_scope_filter_act.users users WHERE users.mainScope IN (:activity_users_scope_filter_scopes) AND activity_users_scope_filter_act = activity ' + ) + ) + ->setParameter('activity_users_scope_filter_scopes', $data['scopes']); + } + + public function applyOn() + { + return Declarations::ACTIVITY; + } + + public function buildForm(FormBuilderInterface $builder) + { + $builder->add('scopes', EntityType::class, [ + 'class' => Scope::class, + 'choices' => $this->scopeRepository->findAllActive(), + 'choice_label' => fn (Scope $s) => $this->translatableStringHelper->localize($s->getName()), + 'multiple' => true, + 'expanded' => true, + ]); + } + + public function describeAction($data, $format = 'string') + { + return ['export.filter.activity.by_usersscope.Filtered activity by users scope: only %scopes%', [ + '%scopes%' => implode( + ', ', + array_map( + fn (Scope $s) => $this->translatableStringHelper->localize($s->getName()), + $data['scopes']->toArray() + ) + ), + ]]; + } + + public function getTitle() + { + return 'export.filter.activity.by_usersscope.Filter by users scope'; + } +} diff --git a/src/Bundle/ChillActivityBundle/Form/Type/TranslatableActivityType.php b/src/Bundle/ChillActivityBundle/Form/Type/TranslatableActivityType.php index 39417ea91..4a1d4bfa7 100644 --- a/src/Bundle/ChillActivityBundle/Form/Type/TranslatableActivityType.php +++ b/src/Bundle/ChillActivityBundle/Form/Type/TranslatableActivityType.php @@ -12,48 +12,33 @@ declare(strict_types=1); namespace Chill\ActivityBundle\Form\Type; use Chill\ActivityBundle\Entity\ActivityType; -use Chill\ActivityBundle\Repository\ActivityTypeRepository; +use Chill\ActivityBundle\Repository\ActivityTypeRepositoryInterface; use Chill\MainBundle\Templating\TranslatableStringHelperInterface; -use Doctrine\DBAL\Types\Types; -use Doctrine\ORM\QueryBuilder; use Symfony\Bridge\Doctrine\Form\Type\EntityType; use Symfony\Component\Form\AbstractType; -use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; class TranslatableActivityType extends AbstractType { - protected ActivityTypeRepository $activityTypeRepository; + protected ActivityTypeRepositoryInterface $activityTypeRepository; protected TranslatableStringHelperInterface $translatableStringHelper; public function __construct( TranslatableStringHelperInterface $helper, - ActivityTypeRepository $activityTypeRepository + ActivityTypeRepositoryInterface $activityTypeRepository ) { $this->translatableStringHelper = $helper; $this->activityTypeRepository = $activityTypeRepository; } - public function buildForm(FormBuilderInterface $builder, array $options) - { - /** @var QueryBuilder $qb */ - $qb = $options['query_builder']; - - if (true === $options['active_only']) { - $qb->where($qb->expr()->eq('at.active', ':active')); - $qb->setParameter('active', true, Types::BOOLEAN); - } - } - public function configureOptions(OptionsResolver $resolver) { $resolver->setDefaults( [ 'class' => ActivityType::class, 'active_only' => true, - 'query_builder' => $this->activityTypeRepository - ->createQueryBuilder('at'), + 'choices' => $this->activityTypeRepository->findAllActive(), 'choice_label' => function (ActivityType $type) { return $this->translatableStringHelper->localize($type->getName()); }, diff --git a/src/Bundle/ChillActivityBundle/Repository/ActivityPresenceRepository.php b/src/Bundle/ChillActivityBundle/Repository/ActivityPresenceRepository.php new file mode 100644 index 000000000..2cf9f9470 --- /dev/null +++ b/src/Bundle/ChillActivityBundle/Repository/ActivityPresenceRepository.php @@ -0,0 +1,51 @@ +repository = $entityManager->getRepository($this->getClassName()); + } + + public function find($id): ?ActivityPresence + { + return $this->repository->find($id); + } + + public function findAll(): array + { + return $this->repository->findAll(); + } + + public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array + { + return $this->findBy($criteria, $orderBy, $limit, $offset); + } + + public function findOneBy(array $criteria): ?ActivityPresence + { + return $this->findOneBy($criteria); + } + + public function getClassName(): string + { + return ActivityPresence::class; + } +} diff --git a/src/Bundle/ChillActivityBundle/Repository/ActivityPresenceRepositoryInterface.php b/src/Bundle/ChillActivityBundle/Repository/ActivityPresenceRepositoryInterface.php new file mode 100644 index 000000000..228d70856 --- /dev/null +++ b/src/Bundle/ChillActivityBundle/Repository/ActivityPresenceRepositoryInterface.php @@ -0,0 +1,33 @@ +repository = $em->getRepository(ActivityType::class); + } + + public function find($id): ?ActivityType + { + return $this->repository->find($id); + } + + /** + * @return array|ActivityType[] + */ + public function findAll(): array + { + return $this->repository->findAll(); + } + + /** + * @return array|ActivityType[] + */ + public function findAllActive(): array + { + return $this->findBy(['active' => true]); + } + + /** + * @return array|ActivityType[] + */ + public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array + { + return $this->repository->findBy($criteria, $orderBy, $limit, $offset); + } + + public function findOneBy(array $criteria): ?ActivityType + { + return $this->repository->findOneBy($criteria); + } + + public function getClassName(): string + { + return ActivityType::class; } } diff --git a/src/Bundle/ChillActivityBundle/Repository/ActivityTypeRepositoryInterface.php b/src/Bundle/ChillActivityBundle/Repository/ActivityTypeRepositoryInterface.php new file mode 100644 index 000000000..2148a02f5 --- /dev/null +++ b/src/Bundle/ChillActivityBundle/Repository/ActivityTypeRepositoryInterface.php @@ -0,0 +1,23 @@ + fetchResults('/api/1.0/main/location.json'); const getLocationTypes = () => fetchResults('/api/1.0/main/location-type.json'); -const getUserCurrentLocation = +const getUserCurrentLocation = () => fetch('/api/1.0/main/user-current-location.json') .then(response => { if (response.ok) { return response.json(); } @@ -35,6 +35,13 @@ const getLocationTypeByDefaultFor = (entity) => { ); }; +/** + * Post a location + * + * **NOTE**: also in use for Calendar + * @param body + * @returns {Promise} + */ const postLocation = (body) => { const url = `/api/1.0/main/location.json`; return fetch(url, { diff --git a/src/Bundle/ChillActivityBundle/Resources/public/vuejs/Activity/store.locations.js b/src/Bundle/ChillActivityBundle/Resources/public/vuejs/Activity/store.locations.js index 051f0b4f6..7a216f628 100644 --- a/src/Bundle/ChillActivityBundle/Resources/public/vuejs/Activity/store.locations.js +++ b/src/Bundle/ChillActivityBundle/Resources/public/vuejs/Activity/store.locations.js @@ -58,7 +58,7 @@ const makeAccompanyingPeriodLocation = (locationType, store) => { export default function prepareLocations(store) { -// find the locations + // find the locations let allLocations = getLocations().then( (results) => { store.commit('addAvailableLocationGroup', { @@ -114,7 +114,7 @@ export default function prepareLocations(store) { if (window.default_location_id) { for (let group of store.state.availableLocations) { let location = group.locations.find((l) => l.id === window.default_location_id); - if (location !== undefined & store.state.activity.location === null) { + if (location !== undefined && store.state.activity.location === null) { store.dispatch('updateLocation', location); break; } diff --git a/src/Bundle/ChillActivityBundle/Resources/views/Activity/concernedGroups.html.twig b/src/Bundle/ChillActivityBundle/Resources/views/Activity/concernedGroups.html.twig index d9d72845a..96cfef39b 100644 --- a/src/Bundle/ChillActivityBundle/Resources/views/Activity/concernedGroups.html.twig +++ b/src/Bundle/ChillActivityBundle/Resources/views/Activity/concernedGroups.html.twig @@ -1,3 +1,14 @@ +{# + WARNING: this file is in use in both ActivityBundle and CalendarBundle. + + Take care when editing this file. + + Maybe should we think about abstracting this file a bit more ? Moving it to PersonBundle ? +#} +{% if context == 'calendar_accompanyingCourse' %} + {% import "@ChillCalendar/_invite.html.twig" as invite %} +{% endif %} + {% macro href(pathname, key, value) %} {% set parms = { (key): value } %} {{ path(pathname, parms) }} @@ -18,7 +29,7 @@ {% endmacro %} {% set blocks = [] %} -{% if entity.activityType.personsVisible %} +{% if context == 'calendar_accompanyingCourse' or context == 'calendar_person' or entity.activityType.personsVisible %} {% if context == 'person' %} {% set blocks = blocks|merge([{ 'title': 'Others persons'|trans, @@ -43,7 +54,7 @@ }]) %} {% endif %} {% endif %} -{% if entity.activityType.thirdPartiesVisible %} +{% if context == 'calendar_accompanyingCourse' or context == 'calendar_person' or entity.activityType.thirdPartiesVisible %} {% set blocks = blocks|merge([{ 'title': 'Third parties'|trans, 'items': entity.thirdParties, @@ -52,7 +63,7 @@ 'key' : 'id', }]) %} {% endif %} -{% if entity.activityType.usersVisible %} +{% if context == 'calendar_accompanyingCourse' or context == 'calendar_person' or entity.activityType.usersVisible %} {% set blocks = blocks|merge([{ 'title': 'Users concerned'|trans, 'items': entity.users, @@ -132,6 +143,12 @@ {% if bloc.type == 'user' %} {{ item|chill_entity_render_box({'render': 'raw', 'addAltNames': false }) }} + {%- if context == 'calendar_accompanyingCourse' or context == 'calendar_person' %} + {% set invite = entity.inviteForUser(item) %} + {% if invite is not null %} + {{ invite.invite_span(invite) }} + {% endif %} + {%- endif -%} {% else %} {{ _self.insert_onthefly(bloc.type, item) }} diff --git a/src/Bundle/ChillActivityBundle/Security/Authorization/ActivityStatsVoter.php b/src/Bundle/ChillActivityBundle/Security/Authorization/ActivityStatsVoter.php index fc586ae8f..2e55f862c 100644 --- a/src/Bundle/ChillActivityBundle/Security/Authorization/ActivityStatsVoter.php +++ b/src/Bundle/ChillActivityBundle/Security/Authorization/ActivityStatsVoter.php @@ -13,10 +13,10 @@ namespace Chill\ActivityBundle\Security\Authorization; use Chill\MainBundle\Entity\Center; use Chill\MainBundle\Security\Authorization\AbstractChillVoter; -use Chill\MainBundle\Security\Authorization\AuthorizationHelper; +use Chill\MainBundle\Security\Authorization\VoterHelperFactoryInterface; +use Chill\MainBundle\Security\Authorization\VoterHelperInterface; use Chill\MainBundle\Security\ProvideRoleHierarchyInterface; - -use function in_array; +use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; class ActivityStatsVoter extends AbstractChillVoter implements ProvideRoleHierarchyInterface { @@ -24,14 +24,14 @@ class ActivityStatsVoter extends AbstractChillVoter implements ProvideRoleHierar public const STATS = 'CHILL_ACTIVITY_STATS'; - /** - * @var AuthorizationHelper - */ - protected $helper; + protected VoterHelperInterface $helper; - public function __construct(AuthorizationHelper $helper) + public function __construct(VoterHelperFactoryInterface $voterHelperFactory) { - $this->helper = $helper; + $this->helper = $voterHelperFactory + ->generate(self::class) + ->addCheckFor(Center::class, [self::STATS, self::LISTS]) + ->build(); } public function getRoles(): array @@ -49,30 +49,14 @@ class ActivityStatsVoter extends AbstractChillVoter implements ProvideRoleHierar return $this->getAttributes(); } - protected function getSupportedClasses() - { - return [Center::class]; - } - - protected function isGranted($attribute, $object, $user = null) - { - if (!$user instanceof \Symfony\Component\Security\Core\User\UserInterface) { - return false; - } - - return $this->helper->userHasAccess($user, $object, $attribute); - } - protected function supports($attribute, $subject) { - if ( - $subject instanceof Center - && in_array($attribute, $this->getAttributes(), true) - ) { - return true; - } + return $this->helper->supports($attribute, $subject); + } - return false; + protected function voteOnAttribute($attribute, $subject, TokenInterface $token) + { + return $this->helper->voteOnAttribute($attribute, $subject, $token); } private function getAttributes() diff --git a/src/Bundle/ChillActivityBundle/Tests/Controller/ActivityControllerTest.php b/src/Bundle/ChillActivityBundle/Tests/Controller/ActivityControllerTest.php index 55a1442f1..8a30a6c9b 100644 --- a/src/Bundle/ChillActivityBundle/Tests/Controller/ActivityControllerTest.php +++ b/src/Bundle/ChillActivityBundle/Tests/Controller/ActivityControllerTest.php @@ -369,8 +369,12 @@ final class ActivityControllerTest extends WebTestCase $center ); $reachableScopesId = array_intersect( - array_map(static function ($s) { return $s->getId(); }, $reachableScopesDelete), - array_map(static function ($s) { return $s->getId(); }, $reachableScopesUpdate) + array_map(static function ($s) { + return $s->getId(); + }, $reachableScopesDelete), + array_map(static function ($s) { + return $s->getId(); + }, $reachableScopesUpdate) ); if (count($reachableScopesId) === 0) { diff --git a/src/Bundle/ChillActivityBundle/Tests/Export/Aggregator/ACPAggregators/BySocialActionAggregatorTest.php b/src/Bundle/ChillActivityBundle/Tests/Export/Aggregator/ACPAggregators/BySocialActionAggregatorTest.php new file mode 100644 index 000000000..7f6c76332 --- /dev/null +++ b/src/Bundle/ChillActivityBundle/Tests/Export/Aggregator/ACPAggregators/BySocialActionAggregatorTest.php @@ -0,0 +1,62 @@ +aggregator = self::$container->get('chill.activity.export.bysocialaction_aggregator'); + } + + public function getAggregator() + { + return $this->aggregator; + } + + public function getFormData(): array + { + return [ + [], + ]; + } + + public function getQueryBuilders(): array + { + if (null === self::$kernel) { + self::bootKernel(); + } + + $em = self::$container->get(EntityManagerInterface::class); + + return [ + $em->createQueryBuilder() + ->select('count(activity.id)') + ->from(Activity::class, 'activity') + ->join('activity.accompanyingPeriod', 'acp') + ->join('activity.socialActions', 'actsocialaction'), + ]; + } +} diff --git a/src/Bundle/ChillActivityBundle/Tests/Export/Aggregator/ACPAggregators/BySocialIssueAggregatorTest.php b/src/Bundle/ChillActivityBundle/Tests/Export/Aggregator/ACPAggregators/BySocialIssueAggregatorTest.php new file mode 100644 index 000000000..c8374d370 --- /dev/null +++ b/src/Bundle/ChillActivityBundle/Tests/Export/Aggregator/ACPAggregators/BySocialIssueAggregatorTest.php @@ -0,0 +1,62 @@ +aggregator = self::$container->get('chill.activity.export.bysocialissue_aggregator'); + } + + public function getAggregator() + { + return $this->aggregator; + } + + public function getFormData(): array + { + return [ + [], + ]; + } + + public function getQueryBuilders(): array + { + if (null === self::$kernel) { + self::bootKernel(); + } + + $em = self::$container->get(EntityManagerInterface::class); + + return [ + $em->createQueryBuilder() + ->select('count(activity.id)') + ->from(Activity::class, 'activity') + ->join('activity.accompanyingPeriod', 'acp') + ->join('activity.socialIssues', 'actsocialissue'), + ]; + } +} diff --git a/src/Bundle/ChillActivityBundle/Tests/Export/Aggregator/ACPAggregators/ByThirdpartyAggregatorTest.php b/src/Bundle/ChillActivityBundle/Tests/Export/Aggregator/ACPAggregators/ByThirdpartyAggregatorTest.php new file mode 100644 index 000000000..e3bbde25f --- /dev/null +++ b/src/Bundle/ChillActivityBundle/Tests/Export/Aggregator/ACPAggregators/ByThirdpartyAggregatorTest.php @@ -0,0 +1,62 @@ +aggregator = self::$container->get('chill.activity.export.bythirdparty_aggregator'); + } + + public function getAggregator() + { + return $this->aggregator; + } + + public function getFormData(): array + { + return [ + [], + ]; + } + + public function getQueryBuilders(): array + { + if (null === self::$kernel) { + self::bootKernel(); + } + + $em = self::$container->get(EntityManagerInterface::class); + + return [ + $em->createQueryBuilder() + ->select('count(activity.id)') + ->from(Activity::class, 'activity') + ->join('activity.accompanyingPeriod', 'acp') + ->join('activity.thirdParties', 'acttparty'), + ]; + } +} diff --git a/src/Bundle/ChillActivityBundle/Tests/Export/Aggregator/ACPAggregators/ByUserAggregatorTest.php b/src/Bundle/ChillActivityBundle/Tests/Export/Aggregator/ACPAggregators/ByUserAggregatorTest.php new file mode 100644 index 000000000..ff4f42ec4 --- /dev/null +++ b/src/Bundle/ChillActivityBundle/Tests/Export/Aggregator/ACPAggregators/ByUserAggregatorTest.php @@ -0,0 +1,62 @@ +aggregator = self::$container->get('chill.activity.export.byuser_aggregator'); + } + + public function getAggregator() + { + return $this->aggregator; + } + + public function getFormData(): array + { + return [ + [], + ]; + } + + public function getQueryBuilders(): array + { + if (null === self::$kernel) { + self::bootKernel(); + } + + $em = self::$container->get(EntityManagerInterface::class); + + return [ + $em->createQueryBuilder() + ->select('count(activity.id)') + ->from(Activity::class, 'activity') + ->join('activity.accompanyingPeriod', 'acp') + ->join('activity.users', 'actusers'), + ]; + } +} diff --git a/src/Bundle/ChillActivityBundle/Tests/Export/Aggregator/ACPAggregators/DateAggregatorTest.php b/src/Bundle/ChillActivityBundle/Tests/Export/Aggregator/ACPAggregators/DateAggregatorTest.php new file mode 100644 index 000000000..feb5ed970 --- /dev/null +++ b/src/Bundle/ChillActivityBundle/Tests/Export/Aggregator/ACPAggregators/DateAggregatorTest.php @@ -0,0 +1,69 @@ +aggregator = self::$container->get('chill.activity.export.date_aggregator'); + } + + public function getAggregator() + { + return $this->aggregator; + } + + public function getFormData(): array + { + return [ + [ + 'frequency' => 'month', + ], + [ + 'frequency' => 'week', + ], + [ + 'frequency' => 'year', + ], + ]; + } + + public function getQueryBuilders(): array + { + if (null === self::$kernel) { + self::bootKernel(); + } + + $em = self::$container->get(EntityManagerInterface::class); + + return [ + $em->createQueryBuilder() + ->select('count(activity.id)') + ->from(Activity::class, 'activity') + ->join('activity.accompanyingPeriod', 'acp'), + ]; + } +} diff --git a/src/Bundle/ChillActivityBundle/Tests/Export/Aggregator/ACPAggregators/LocationTypeAggregatorTest.php b/src/Bundle/ChillActivityBundle/Tests/Export/Aggregator/ACPAggregators/LocationTypeAggregatorTest.php new file mode 100644 index 000000000..c384eb772 --- /dev/null +++ b/src/Bundle/ChillActivityBundle/Tests/Export/Aggregator/ACPAggregators/LocationTypeAggregatorTest.php @@ -0,0 +1,62 @@ +aggregator = self::$container->get('chill.activity.export.locationtype_aggregator'); + } + + public function getAggregator() + { + return $this->aggregator; + } + + public function getFormData(): array + { + return [ + [], + ]; + } + + public function getQueryBuilders(): array + { + if (null === self::$kernel) { + self::bootKernel(); + } + + $em = self::$container->get(EntityManagerInterface::class); + + return [ + $em->createQueryBuilder() + ->select('count(activity.id)') + ->from(Activity::class, 'activity') + ->join('activity.accompanyingPeriod', 'acp') + ->join('activity.location', 'actloc'), + ]; + } +} diff --git a/src/Bundle/ChillActivityBundle/Tests/Export/Aggregator/ACPAggregators/UserScopeAggregatorTest.php b/src/Bundle/ChillActivityBundle/Tests/Export/Aggregator/ACPAggregators/UserScopeAggregatorTest.php new file mode 100644 index 000000000..1265804f9 --- /dev/null +++ b/src/Bundle/ChillActivityBundle/Tests/Export/Aggregator/ACPAggregators/UserScopeAggregatorTest.php @@ -0,0 +1,62 @@ +aggregator = self::$container->get('chill.activity.export.userscope_aggregator'); + } + + public function getAggregator() + { + return $this->aggregator; + } + + public function getFormData(): array + { + return [ + [], + ]; + } + + public function getQueryBuilders(): array + { + if (null === self::$kernel) { + self::bootKernel(); + } + + $em = self::$container->get(EntityManagerInterface::class); + + return [ + $em->createQueryBuilder() + ->select('count(activity.id)') + ->from(Activity::class, 'activity') + ->join('activity.accompanyingPeriod', 'acp') + ->join('activity.user', 'actuser'), + ]; + } +} diff --git a/src/Bundle/ChillActivityBundle/Tests/Export/Aggregator/ActivityTypeAggregatorTest.php b/src/Bundle/ChillActivityBundle/Tests/Export/Aggregator/ActivityTypeAggregatorTest.php index 805b387f3..31633b9a3 100644 --- a/src/Bundle/ChillActivityBundle/Tests/Export/Aggregator/ActivityTypeAggregatorTest.php +++ b/src/Bundle/ChillActivityBundle/Tests/Export/Aggregator/ActivityTypeAggregatorTest.php @@ -11,7 +11,10 @@ declare(strict_types=1); namespace Chill\ActivityBundle\Tests\Export\Aggregator; +use Chill\ActivityBundle\Export\Aggregator\ActivityTypeAggregator; use Chill\MainBundle\Test\Export\AbstractAggregatorTest; +use Doctrine\ORM\EntityManagerInterface; +use Prophecy\PhpUnit\ProphecyTrait; /** * Add tests for ActivityTypeAggregator. @@ -21,26 +24,22 @@ use Chill\MainBundle\Test\Export\AbstractAggregatorTest; */ final class ActivityTypeAggregatorTest extends AbstractAggregatorTest { - /** - * @var \Chill\ActivityBundle\Export\Aggregator\ActivityReasonAggregator - */ - private $aggregator; + use ProphecyTrait; + + private ActivityTypeAggregator $aggregator; protected function setUp(): void { self::bootKernel(); - $container = self::$kernel->getContainer(); + $this->aggregator = self::$container->get('chill.activity.export.type_aggregator'); - $this->aggregator = $container->get('chill.activity.export.type_aggregator'); + $request = $this->prophesize() + ->willExtend(\Symfony\Component\HttpFoundation\Request::class); - // add a fake request with a default locale (used in translatable string) - $prophet = new \Prophecy\Prophet(); - $request = $prophet->prophesize(); - $request->willExtend(\Symfony\Component\HttpFoundation\Request::class); $request->getLocale()->willReturn('fr'); - $container->get('request_stack') + self::$container->get('request_stack') ->push($request->reveal()); } @@ -62,8 +61,7 @@ final class ActivityTypeAggregatorTest extends AbstractAggregatorTest self::bootKernel(); } - $em = self::$kernel->getContainer() - ->get('doctrine.orm.entity_manager'); + $em = self::$container->get(EntityManagerInterface::class); return [ $em->createQueryBuilder() @@ -72,12 +70,7 @@ final class ActivityTypeAggregatorTest extends AbstractAggregatorTest $em->createQueryBuilder() ->select('count(activity.id)') ->from('ChillActivityBundle:Activity', 'activity') - ->join('activity.reasons', 'reasons'), - $em->createQueryBuilder() - ->select('count(activity.id)') - ->from('ChillActivityBundle:Activity', 'activity') - ->join('activity.reasons', 'reasons') - ->join('reasons.category', 'category'), + ->join('activity.activityType', 'acttype'), ]; } } diff --git a/src/Bundle/ChillActivityBundle/Tests/Export/Aggregator/ActivityUserAggregatorTest.php b/src/Bundle/ChillActivityBundle/Tests/Export/Aggregator/ActivityUserAggregatorTest.php index 5a4539b96..06542aa41 100644 --- a/src/Bundle/ChillActivityBundle/Tests/Export/Aggregator/ActivityUserAggregatorTest.php +++ b/src/Bundle/ChillActivityBundle/Tests/Export/Aggregator/ActivityUserAggregatorTest.php @@ -11,7 +11,10 @@ declare(strict_types=1); namespace Chill\ActivityBundle\Tests\Export\Aggregator; +use Chill\ActivityBundle\Export\Aggregator\ActivityUserAggregator; use Chill\MainBundle\Test\Export\AbstractAggregatorTest; +use Doctrine\ORM\EntityManagerInterface; +use Prophecy\PhpUnit\ProphecyTrait; /** * Add tests for ActivityUsernAggregator. @@ -21,26 +24,22 @@ use Chill\MainBundle\Test\Export\AbstractAggregatorTest; */ final class ActivityUserAggregatorTest extends AbstractAggregatorTest { - /** - * @var \Chill\ActivityBundle\Export\Aggregator\ActivityUserAggregator - */ - private $aggregator; + use ProphecyTrait; + + private ActivityUserAggregator $aggregator; protected function setUp(): void { self::bootKernel(); - $container = self::$kernel->getContainer(); + $this->aggregator = self::$container->get('chill.activity.export.user_aggregator'); - $this->aggregator = $container->get('chill.activity.export.user_aggregator'); + $request = $this->prophesize() + ->willExtend(\Symfony\Component\HttpFoundation\Request::class); - // add a fake request with a default locale (used in translatable string) - $prophet = new \Prophecy\Prophet(); - $request = $prophet->prophesize(); - $request->willExtend(\Symfony\Component\HttpFoundation\Request::class); $request->getLocale()->willReturn('fr'); - $container->get('request_stack') + self::$container->get('request_stack') ->push($request->reveal()); } @@ -49,35 +48,25 @@ final class ActivityUserAggregatorTest extends AbstractAggregatorTest return $this->aggregator; } - public function getFormData() + public function getFormData(): array { return [ [], ]; } - public function getQueryBuilders() + public function getQueryBuilders(): array { if (null === self::$kernel) { self::bootKernel(); } - $em = self::$kernel->getContainer() - ->get('doctrine.orm.entity_manager'); + $em = self::$container->get(EntityManagerInterface::class); return [ $em->createQueryBuilder() ->select('count(activity.id)') ->from('ChillActivityBundle:Activity', 'activity'), - $em->createQueryBuilder() - ->select('count(activity.id)') - ->from('ChillActivityBundle:Activity', 'activity') - ->join('activity.reasons', 'reasons'), - $em->createQueryBuilder() - ->select('count(activity.id)') - ->from('ChillActivityBundle:Activity', 'activity') - ->join('activity.reasons', 'reasons') - ->join('reasons.category', 'category'), ]; } } diff --git a/src/Bundle/ChillActivityBundle/Tests/Export/Aggregator/ActivityReasonAggregatorTest.php b/src/Bundle/ChillActivityBundle/Tests/Export/Aggregator/PersonAggregators/ActivityReasonAggregatorTest.php similarity index 57% rename from src/Bundle/ChillActivityBundle/Tests/Export/Aggregator/ActivityReasonAggregatorTest.php rename to src/Bundle/ChillActivityBundle/Tests/Export/Aggregator/PersonAggregators/ActivityReasonAggregatorTest.php index 15eec100a..0a61e983f 100644 --- a/src/Bundle/ChillActivityBundle/Tests/Export/Aggregator/ActivityReasonAggregatorTest.php +++ b/src/Bundle/ChillActivityBundle/Tests/Export/Aggregator/PersonAggregators/ActivityReasonAggregatorTest.php @@ -9,38 +9,35 @@ declare(strict_types=1); * the LICENSE file that was distributed with this source code. */ -namespace Chill\ActivityBundle\Tests\Export\Aggregator; +namespace Chill\ActivityBundle\Tests\Export\Aggregator\PersonAggregators; +use Chill\ActivityBundle\Export\Aggregator\PersonAggregators\ActivityReasonAggregator; use Chill\MainBundle\Test\Export\AbstractAggregatorTest; +use Doctrine\ORM\EntityManagerInterface; +use Prophecy\PhpUnit\ProphecyTrait; /** - * Add tests for ActivityReasonAggregator. - * * @internal * @coversNothing */ final class ActivityReasonAggregatorTest extends AbstractAggregatorTest { - /** - * @var \Chill\ActivityBundle\Export\Aggregator\ActivityReasonAggregator - */ - private $aggregator; + use ProphecyTrait; + + private ActivityReasonAggregator $aggregator; protected function setUp(): void { self::bootKernel(); - $container = self::$kernel->getContainer(); + $this->aggregator = self::$container->get('chill.activity.export.reason_aggregator'); - $this->aggregator = $container->get('chill.activity.export.reason_aggregator'); + $request = $this->prophesize() + ->willExtend(\Symfony\Component\HttpFoundation\Request::class); - // add a fake request with a default locale (used in translatable string) - $prophet = new \Prophecy\Prophet(); - $request = $prophet->prophesize(); - $request->willExtend(\Symfony\Component\HttpFoundation\Request::class); $request->getLocale()->willReturn('fr'); - $container->get('request_stack') + self::$container->get('request_stack') ->push($request->reveal()); } @@ -49,7 +46,7 @@ final class ActivityReasonAggregatorTest extends AbstractAggregatorTest return $this->aggregator; } - public function getFormData() + public function getFormData(): array { return [ ['level' => 'reasons'], @@ -57,14 +54,13 @@ final class ActivityReasonAggregatorTest extends AbstractAggregatorTest ]; } - public function getQueryBuilders() + public function getQueryBuilders(): array { if (null === self::$kernel) { self::bootKernel(); } - $em = self::$kernel->getContainer() - ->get('doctrine.orm.entity_manager'); + $em = self::$container->get(EntityManagerInterface::class); return [ $em->createQueryBuilder() @@ -73,12 +69,12 @@ final class ActivityReasonAggregatorTest extends AbstractAggregatorTest $em->createQueryBuilder() ->select('count(activity.id)') ->from('ChillActivityBundle:Activity', 'activity') - ->join('activity.reasons', 'reasons'), + ->join('activity.reasons', 'actreasons'), $em->createQueryBuilder() ->select('count(activity.id)') ->from('ChillActivityBundle:Activity', 'activity') - ->join('activity.reasons', 'reasons') - ->join('reasons.category', 'category'), + ->join('activity.reasons', 'actreasons') + ->join('actreasons.category', 'actreasoncat'), ]; } } diff --git a/src/Bundle/ChillActivityBundle/Tests/Export/Export/LinkedToACP/AvgActivityDurationTest.php b/src/Bundle/ChillActivityBundle/Tests/Export/Export/LinkedToACP/AvgActivityDurationTest.php new file mode 100644 index 000000000..2eb2176b0 --- /dev/null +++ b/src/Bundle/ChillActivityBundle/Tests/Export/Export/LinkedToACP/AvgActivityDurationTest.php @@ -0,0 +1,51 @@ +export = self::$container->get('chill.activity.export.avg_activity_duration_linked_to_acp'); + } + + public function getExport() + { + return $this->export; + } + + public function getFormData(): array + { + return [ + [], + ]; + } + + public function getModifiersCombination(): array + { + return [ + ['activity'], + ['activity', 'accompanying_period'], + ]; + } +} diff --git a/src/Bundle/ChillActivityBundle/Tests/Export/Export/LinkedToACP/AvgActivityVisitDurationTest.php b/src/Bundle/ChillActivityBundle/Tests/Export/Export/LinkedToACP/AvgActivityVisitDurationTest.php new file mode 100644 index 000000000..59b4384cf --- /dev/null +++ b/src/Bundle/ChillActivityBundle/Tests/Export/Export/LinkedToACP/AvgActivityVisitDurationTest.php @@ -0,0 +1,51 @@ +export = self::$container->get('chill.activity.export.avg_activity_visit_duration_linked_to_acp'); + } + + public function getExport() + { + return $this->export; + } + + public function getFormData(): array + { + return [ + [], + ]; + } + + public function getModifiersCombination(): array + { + return [ + ['activity'], + ['activity', 'accompanying_period'], + ]; + } +} diff --git a/src/Bundle/ChillActivityBundle/Tests/Export/Export/LinkedToACP/CountActivityTest.php b/src/Bundle/ChillActivityBundle/Tests/Export/Export/LinkedToACP/CountActivityTest.php new file mode 100644 index 000000000..f03bbb956 --- /dev/null +++ b/src/Bundle/ChillActivityBundle/Tests/Export/Export/LinkedToACP/CountActivityTest.php @@ -0,0 +1,51 @@ +export = self::$container->get('chill.activity.export.count_activity_linked_to_acp'); + } + + public function getExport() + { + return $this->export; + } + + public function getFormData(): array + { + return [ + [], + ]; + } + + public function getModifiersCombination(): array + { + return [ + ['activity'], + ['activity', 'accompanying_period'], + ]; + } +} diff --git a/src/Bundle/ChillActivityBundle/Tests/Export/Export/LinkedToACP/SumActivityDurationTest.php b/src/Bundle/ChillActivityBundle/Tests/Export/Export/LinkedToACP/SumActivityDurationTest.php new file mode 100644 index 000000000..24a5133b6 --- /dev/null +++ b/src/Bundle/ChillActivityBundle/Tests/Export/Export/LinkedToACP/SumActivityDurationTest.php @@ -0,0 +1,51 @@ +export = self::$container->get('chill.activity.export.sum_activity_duration_linked_to_acp'); + } + + public function getExport() + { + return $this->export; + } + + public function getFormData(): array + { + return [ + [], + ]; + } + + public function getModifiersCombination(): array + { + return [ + ['activity'], + ['activity', 'accompanying_period'], + ]; + } +} diff --git a/src/Bundle/ChillActivityBundle/Tests/Export/Export/LinkedToACP/SumActivityVisitDurationTest.php b/src/Bundle/ChillActivityBundle/Tests/Export/Export/LinkedToACP/SumActivityVisitDurationTest.php new file mode 100644 index 000000000..1940140ac --- /dev/null +++ b/src/Bundle/ChillActivityBundle/Tests/Export/Export/LinkedToACP/SumActivityVisitDurationTest.php @@ -0,0 +1,51 @@ +export = self::$container->get('chill.activity.export.sum_activity_visit_duration_linked_to_acp'); + } + + public function getExport() + { + return $this->export; + } + + public function getFormData(): array + { + return [ + [], + ]; + } + + public function getModifiersCombination(): array + { + return [ + ['activity'], + ['activity', 'accompanying_period'], + ]; + } +} diff --git a/src/Bundle/ChillActivityBundle/Tests/Export/Export/CountActivityTest.php b/src/Bundle/ChillActivityBundle/Tests/Export/Export/LinkedToPerson/CountActivityTest.php similarity index 62% rename from src/Bundle/ChillActivityBundle/Tests/Export/Export/CountActivityTest.php rename to src/Bundle/ChillActivityBundle/Tests/Export/Export/LinkedToPerson/CountActivityTest.php index 09c1a8dcc..864ff55ba 100644 --- a/src/Bundle/ChillActivityBundle/Tests/Export/Export/CountActivityTest.php +++ b/src/Bundle/ChillActivityBundle/Tests/Export/Export/LinkedToPerson/CountActivityTest.php @@ -9,8 +9,9 @@ declare(strict_types=1); * the LICENSE file that was distributed with this source code. */ -namespace Chill\ActivityBundle\Tests\Export\Export; +namespace Chill\ActivityBundle\Tests\Export\Export\LinkedToPerson; +use Chill\ActivityBundle\Export\Export\LinkedToPerson\CountActivity; use Chill\MainBundle\Test\Export\AbstractExportTest; /** @@ -19,19 +20,13 @@ use Chill\MainBundle\Test\Export\AbstractExportTest; */ final class CountActivityTest extends AbstractExportTest { - /** - * @var - */ - private $export; + private CountActivity $export; protected function setUp(): void { self::bootKernel(); - /** @var \Symfony\Component\DependencyInjection\ContainerInterface $container */ - $container = self::$kernel->getContainer(); - - $this->export = $container->get('chill.activity.export.count_activity'); + $this->export = self::$container->get('chill.activity.export.count_activity_linked_to_person'); } public function getExport() @@ -39,14 +34,14 @@ final class CountActivityTest extends AbstractExportTest return $this->export; } - public function getFormData() + public function getFormData(): array { return [ [], ]; } - public function getModifiersCombination() + public function getModifiersCombination(): array { return [ ['activity'], diff --git a/src/Bundle/ChillActivityBundle/Tests/Export/Export/ListActivityTest.php b/src/Bundle/ChillActivityBundle/Tests/Export/Export/LinkedToPerson/ListActivityTest.php similarity index 65% rename from src/Bundle/ChillActivityBundle/Tests/Export/Export/ListActivityTest.php rename to src/Bundle/ChillActivityBundle/Tests/Export/Export/LinkedToPerson/ListActivityTest.php index d587d348d..afa3da111 100644 --- a/src/Bundle/ChillActivityBundle/Tests/Export/Export/ListActivityTest.php +++ b/src/Bundle/ChillActivityBundle/Tests/Export/Export/LinkedToPerson/ListActivityTest.php @@ -9,9 +9,11 @@ declare(strict_types=1); * the LICENSE file that was distributed with this source code. */ -namespace Chill\ActivityBundle\Tests\Export\Export; +namespace Chill\ActivityBundle\Tests\Export\Export\LinkedToPerson; +use Chill\ActivityBundle\Export\Export\LinkedToPerson\ListActivity; use Chill\MainBundle\Test\Export\AbstractExportTest; +use Prophecy\PhpUnit\ProphecyTrait; /** * @internal @@ -19,27 +21,22 @@ use Chill\MainBundle\Test\Export\AbstractExportTest; */ final class ListActivityTest extends AbstractExportTest { - /** - * @var \Chill\ActivityBundle\Export\Export\ListActivity - */ - private $export; + use ProphecyTrait; + + private ListActivity $export; protected function setUp(): void { self::bootKernel(); - /** @var \Symfony\Component\DependencyInjection\ContainerInterface $container */ - $container = self::$kernel->getContainer(); + $this->export = self::$container->get('chill.activity.export.list_activity_linked_to_person'); - $this->export = $container->get('chill.activity.export.list_activity'); + $request = $this->prophesize() + ->willExtend(\Symfony\Component\HttpFoundation\Request::class); - // add a fake request with a default locale (used in translatable string) - $prophet = new \Prophecy\Prophet(); - $request = $prophet->prophesize(); - $request->willExtend(\Symfony\Component\HttpFoundation\Request::class); $request->getLocale()->willReturn('fr'); - $container->get('request_stack') + self::$container->get('request_stack') ->push($request->reveal()); } diff --git a/src/Bundle/ChillActivityBundle/Tests/Export/Export/StatActivityDurationSumTest.php b/src/Bundle/ChillActivityBundle/Tests/Export/Export/LinkedToPerson/StatActivityDurationTest.php similarity index 55% rename from src/Bundle/ChillActivityBundle/Tests/Export/Export/StatActivityDurationSumTest.php rename to src/Bundle/ChillActivityBundle/Tests/Export/Export/LinkedToPerson/StatActivityDurationTest.php index 10c967857..44d327ffd 100644 --- a/src/Bundle/ChillActivityBundle/Tests/Export/Export/StatActivityDurationSumTest.php +++ b/src/Bundle/ChillActivityBundle/Tests/Export/Export/LinkedToPerson/StatActivityDurationTest.php @@ -9,8 +9,9 @@ declare(strict_types=1); * the LICENSE file that was distributed with this source code. */ -namespace Chill\ActivityBundle\Tests\Export\Export; +namespace Chill\ActivityBundle\Tests\Export\Export\LinkedToPerson; +use Chill\ActivityBundle\Export\Export\LinkedToPerson\StatActivityDuration; use Chill\MainBundle\Test\Export\AbstractExportTest; /** @@ -19,21 +20,15 @@ use Chill\MainBundle\Test\Export\AbstractExportTest; * @internal * @coversNothing */ -final class StatActivityDurationSumTest extends AbstractExportTest +final class StatActivityDurationTest extends AbstractExportTest { - /** - * @var \Chill\ActivityBundle\Export\Export\StatActivityDuration - */ - private $export; + private StatActivityDuration $export; protected function setUp(): void { self::bootKernel(); - /** @var \Symfony\Component\DependencyInjection\ContainerInterface $container */ - $container = self::$kernel->getContainer(); - - $this->export = $container->get('chill.activity.export.sum_activity_duration'); + $this->export = self::$container->get('chill.activity.export.sum_activity_duration_linked_to_person'); } public function getExport() @@ -41,14 +36,14 @@ final class StatActivityDurationSumTest extends AbstractExportTest return $this->export; } - public function getFormData() + public function getFormData(): array { return [ [], ]; } - public function getModifiersCombination() + public function getModifiersCombination(): array { return [ ['activity'], diff --git a/src/Bundle/ChillActivityBundle/Tests/Export/Filter/ACPFilters/ActivityTypeFilterTest.php b/src/Bundle/ChillActivityBundle/Tests/Export/Filter/ACPFilters/ActivityTypeFilterTest.php new file mode 100644 index 000000000..72b99375b --- /dev/null +++ b/src/Bundle/ChillActivityBundle/Tests/Export/Filter/ACPFilters/ActivityTypeFilterTest.php @@ -0,0 +1,79 @@ +filter = self::$container->get('chill.activity.export.filter_activitytype'); + } + + public function getFilter() + { + return $this->filter; + } + + public function getFormData(): array + { + $em = self::$container->get(EntityManagerInterface::class); + + $array = $em->createQueryBuilder() + ->from(ActivityType::class, 'at') + ->select('at') + ->getQuery() + ->getResult(); + + $data = []; + + foreach ($array as $a) { + $data[] = [ + 'accepted_activitytypes' => $a, + ]; + } + + return $data; + } + + public function getQueryBuilders(): array + { + if (null === self::$kernel) { + self::bootKernel(); + } + + $em = self::$container->get(EntityManagerInterface::class); + + return [ + $em->createQueryBuilder() + ->select('count(activity.id)') + ->from(AccompanyingPeriod::class, 'acp') + ->join(Activity::class, 'activity', Expr\Join::WITH, 'activity.accompanyingPeriod = acp') + ->join('activity.activityType', 'acttype'), + ]; + } +} diff --git a/src/Bundle/ChillActivityBundle/Tests/Export/Filter/ACPFilters/BySocialActionFilterTest.php b/src/Bundle/ChillActivityBundle/Tests/Export/Filter/ACPFilters/BySocialActionFilterTest.php new file mode 100644 index 000000000..a707e1242 --- /dev/null +++ b/src/Bundle/ChillActivityBundle/Tests/Export/Filter/ACPFilters/BySocialActionFilterTest.php @@ -0,0 +1,76 @@ +filter = self::$container->get('chill.activity.export.bysocialaction_filter'); + } + + public function getFilter() + { + return $this->filter; + } + + public function getFormData(): array + { + $em = self::$container->get(EntityManagerInterface::class); + + $array = $em->createQueryBuilder() + ->from(SocialAction::class, 'sa') + ->select('sa') + ->getQuery() + ->getResult(); + + $data = []; + + foreach ($array as $a) { + $data[] = [ + 'accepted_socialactions' => $a, + ]; + } + + return $data; + } + + public function getQueryBuilders(): array + { + if (null === self::$kernel) { + self::bootKernel(); + } + + $em = self::$container->get(EntityManagerInterface::class); + + return [ + $em->createQueryBuilder() + ->select('count(activity.id)') + ->from(Activity::class, 'activity') + ->join('activity.socialActions', 'actsocialaction'), + ]; + } +} diff --git a/src/Bundle/ChillActivityBundle/Tests/Export/Filter/ACPFilters/BySocialIssueFilterTest.php b/src/Bundle/ChillActivityBundle/Tests/Export/Filter/ACPFilters/BySocialIssueFilterTest.php new file mode 100644 index 000000000..5f34a8f3b --- /dev/null +++ b/src/Bundle/ChillActivityBundle/Tests/Export/Filter/ACPFilters/BySocialIssueFilterTest.php @@ -0,0 +1,76 @@ +filter = self::$container->get('chill.activity.export.bysocialissue_filter'); + } + + public function getFilter() + { + return $this->filter; + } + + public function getFormData(): array + { + $em = self::$container->get(EntityManagerInterface::class); + + $array = $em->createQueryBuilder() + ->from(SocialIssue::class, 'si') + ->select('si') + ->getQuery() + ->getResult(); + + $data = []; + + foreach ($array as $a) { + $data[] = [ + 'accepted_socialissues' => $a, + ]; + } + + return $data; + } + + public function getQueryBuilders(): array + { + if (null === self::$kernel) { + self::bootKernel(); + } + + $em = self::$container->get(EntityManagerInterface::class); + + return [ + $em->createQueryBuilder() + ->select('count(activity.id)') + ->from(Activity::class, 'activity') + ->join('activity.socialIssues', 'actsocialissue'), + ]; + } +} diff --git a/src/Bundle/ChillActivityBundle/Tests/Export/Filter/ACPFilters/ByUserFilterTest.php b/src/Bundle/ChillActivityBundle/Tests/Export/Filter/ACPFilters/ByUserFilterTest.php new file mode 100644 index 000000000..47e76e25c --- /dev/null +++ b/src/Bundle/ChillActivityBundle/Tests/Export/Filter/ACPFilters/ByUserFilterTest.php @@ -0,0 +1,76 @@ +filter = self::$container->get('chill.activity.export.byuser_filter'); + } + + public function getFilter() + { + return $this->filter; + } + + public function getFormData(): array + { + $em = self::$container->get(EntityManagerInterface::class); + + $array = $em->createQueryBuilder() + ->from(User::class, 'u') + ->select('u') + ->getQuery() + ->getResult(); + + $data = []; + + foreach ($array as $a) { + $data[] = [ + 'accepted_users' => $a, + ]; + } + + return $data; + } + + public function getQueryBuilders(): array + { + if (null === self::$kernel) { + self::bootKernel(); + } + + $em = self::$container->get(EntityManagerInterface::class); + + return [ + $em->createQueryBuilder() + ->select('count(activity.id)') + ->from(Activity::class, 'activity') + ->join('activity.users', 'actusers'), + ]; + } +} diff --git a/src/Bundle/ChillActivityBundle/Tests/Export/Filter/ACPFilters/EmergencyFilterTest.php b/src/Bundle/ChillActivityBundle/Tests/Export/Filter/ACPFilters/EmergencyFilterTest.php new file mode 100644 index 000000000..5242f337a --- /dev/null +++ b/src/Bundle/ChillActivityBundle/Tests/Export/Filter/ACPFilters/EmergencyFilterTest.php @@ -0,0 +1,61 @@ +filter = self::$container->get('chill.activity.export.emergency_filter'); + } + + public function getFilter() + { + return $this->filter; + } + + public function getFormData(): array + { + return [ + ['accepted_emergency' => true], + ['accepted_emergency' => false], + ]; + } + + public function getQueryBuilders(): array + { + if (null === self::$kernel) { + self::bootKernel(); + } + + $em = self::$container->get(EntityManagerInterface::class); + + return [ + $em->createQueryBuilder() + ->select('count(activity.id)') + ->from(Activity::class, 'activity'), + ]; + } +} diff --git a/src/Bundle/ChillActivityBundle/Tests/Export/Filter/ACPFilters/LocationTypeFilterTest.php b/src/Bundle/ChillActivityBundle/Tests/Export/Filter/ACPFilters/LocationTypeFilterTest.php new file mode 100644 index 000000000..c037a28b1 --- /dev/null +++ b/src/Bundle/ChillActivityBundle/Tests/Export/Filter/ACPFilters/LocationTypeFilterTest.php @@ -0,0 +1,76 @@ +filter = self::$container->get('chill.activity.export.locationtype_filter'); + } + + public function getFilter() + { + return $this->filter; + } + + public function getFormData(): array + { + $em = self::$container->get(EntityManagerInterface::class); + + $array = $em->createQueryBuilder() + ->from(LocationType::class, 'lt') + ->select('lt') + ->getQuery() + ->getResult(); + + $data = []; + + foreach ($array as $a) { + $data[] = [ + 'accepted_locationtype' => $a, + ]; + } + + return $data; + } + + public function getQueryBuilders(): array + { + if (null === self::$kernel) { + self::bootKernel(); + } + + $em = self::$container->get(EntityManagerInterface::class); + + return [ + $em->createQueryBuilder() + ->select('count(activity.id)') + ->from(Activity::class, 'activity') + ->join('activity.location', 'actloc'), + ]; + } +} diff --git a/src/Bundle/ChillActivityBundle/Tests/Export/Filter/ACPFilters/SentReceivedFilterTest.php b/src/Bundle/ChillActivityBundle/Tests/Export/Filter/ACPFilters/SentReceivedFilterTest.php new file mode 100644 index 000000000..6b16daa99 --- /dev/null +++ b/src/Bundle/ChillActivityBundle/Tests/Export/Filter/ACPFilters/SentReceivedFilterTest.php @@ -0,0 +1,61 @@ +filter = self::$container->get('chill.activity.export.sentreceived_filter'); + } + + public function getFilter() + { + return $this->filter; + } + + public function getFormData(): array + { + return [ + ['accepted_sentreceived' => Activity::SENTRECEIVED_SENT], + ['accepted_sentreceived' => Activity::SENTRECEIVED_RECEIVED], + ]; + } + + public function getQueryBuilders(): array + { + if (null === self::$kernel) { + self::bootKernel(); + } + + $em = self::$container->get(EntityManagerInterface::class); + + return [ + $em->createQueryBuilder() + ->select('count(activity.id)') + ->from(Activity::class, 'activity'), + ]; + } +} diff --git a/src/Bundle/ChillActivityBundle/Tests/Export/Filter/ACPFilters/UserFilterTest.php b/src/Bundle/ChillActivityBundle/Tests/Export/Filter/ACPFilters/UserFilterTest.php new file mode 100644 index 000000000..fda613d2e --- /dev/null +++ b/src/Bundle/ChillActivityBundle/Tests/Export/Filter/ACPFilters/UserFilterTest.php @@ -0,0 +1,75 @@ +filter = self::$container->get('chill.activity.export.user_filter'); + } + + public function getFilter() + { + return $this->filter; + } + + public function getFormData(): array + { + $em = self::$container->get(EntityManagerInterface::class); + + $array = $em->createQueryBuilder() + ->from(User::class, 'u') + ->select('u') + ->getQuery() + ->getResult(); + + $data = []; + + foreach ($array as $a) { + $data[] = [ + 'accepted_users' => $a, + ]; + } + + return $data; + } + + public function getQueryBuilders(): array + { + if (null === self::$kernel) { + self::bootKernel(); + } + + $em = self::$container->get(EntityManagerInterface::class); + + return [ + $em->createQueryBuilder() + ->select('count(activity.id)') + ->from(Activity::class, 'activity'), + ]; + } +} diff --git a/src/Bundle/ChillActivityBundle/Tests/Export/Filter/ACPFilters/UserScopeFilterTest.php b/src/Bundle/ChillActivityBundle/Tests/Export/Filter/ACPFilters/UserScopeFilterTest.php new file mode 100644 index 000000000..5742662b2 --- /dev/null +++ b/src/Bundle/ChillActivityBundle/Tests/Export/Filter/ACPFilters/UserScopeFilterTest.php @@ -0,0 +1,76 @@ +filter = self::$container->get('chill.activity.export.userscope_filter'); + } + + public function getFilter() + { + return $this->filter; + } + + public function getFormData(): array + { + $em = self::$container->get(EntityManagerInterface::class); + + $array = $em->createQueryBuilder() + ->from(Scope::class, 's') + ->select('s') + ->getQuery() + ->getResult(); + + $data = []; + + foreach ($array as $a) { + $data[] = [ + 'accepted_userscope' => $a, + ]; + } + + return $data; + } + + public function getQueryBuilders(): array + { + if (null === self::$kernel) { + self::bootKernel(); + } + + $em = self::$container->get(EntityManagerInterface::class); + + return [ + $em->createQueryBuilder() + ->select('count(activity.id)') + ->from(Activity::class, 'activity') + ->join('activity.user', 'actuser'), + ]; + } +} diff --git a/src/Bundle/ChillActivityBundle/Tests/Export/Filter/ActivityDateFilterTest.php b/src/Bundle/ChillActivityBundle/Tests/Export/Filter/ActivityDateFilterTest.php new file mode 100644 index 000000000..8d6d6b790 --- /dev/null +++ b/src/Bundle/ChillActivityBundle/Tests/Export/Filter/ActivityDateFilterTest.php @@ -0,0 +1,64 @@ +filter = self::$container->get('chill.activity.export.date_filter'); + } + + public function getFilter() + { + return $this->filter; + } + + public function getFormData(): array + { + return [ + [ + 'date_from' => DateTime::createFromFormat('Y-m-d', '2020-01-01'), + 'date_to' => DateTime::createFromFormat('Y-m-d', '2021-01-01'), + ], + ]; + } + + public function getQueryBuilders(): array + { + if (null === self::$kernel) { + self::bootKernel(); + } + + $em = self::$container->get(EntityManagerInterface::class); + + return [ + $em->createQueryBuilder() + ->select('count(activity.id)') + ->from(Activity::class, 'activity'), + ]; + } +} diff --git a/src/Bundle/ChillActivityBundle/Tests/Export/Filter/ActivityReasonFilterTest.php b/src/Bundle/ChillActivityBundle/Tests/Export/Filter/ActivityReasonFilterTest.php index a8090fd24..26d5248ae 100644 --- a/src/Bundle/ChillActivityBundle/Tests/Export/Filter/ActivityReasonFilterTest.php +++ b/src/Bundle/ChillActivityBundle/Tests/Export/Filter/ActivityReasonFilterTest.php @@ -11,8 +11,10 @@ declare(strict_types=1); namespace Chill\ActivityBundle\Tests\Export\Filter; +use Chill\ActivityBundle\Export\Filter\PersonFilters\ActivityReasonFilter; use Chill\MainBundle\Test\Export\AbstractFilterTest; use Doctrine\Common\Collections\ArrayCollection; +use Prophecy\PhpUnit\ProphecyTrait; /** * @internal @@ -20,26 +22,22 @@ use Doctrine\Common\Collections\ArrayCollection; */ final class ActivityReasonFilterTest extends AbstractFilterTest { - /** - * @var \Chill\PersonBundle\Export\Filter\GenderFilter - */ - private $filter; + use ProphecyTrait; + + private ActivityReasonFilter $filter; protected function setUp(): void { self::bootKernel(); - $container = self::$kernel->getContainer(); + $this->filter = self::$container->get('chill.activity.export.reason_filter'); - $this->filter = $container->get('chill.activity.export.reason_filter'); + $request = $this->prophesize() + ->willExtend(\Symfony\Component\HttpFoundation\Request::class); - // add a fake request with a default locale (used in translatable string) - $prophet = new \Prophecy\Prophet(); - $request = $prophet->prophesize(); - $request->willExtend(\Symfony\Component\HttpFoundation\Request::class); $request->getLocale()->willReturn('fr'); - $container->get('request_stack') + self::$container->get('request_stack') ->push($request->reveal()); } diff --git a/src/Bundle/ChillPersonBundle/Tests/Export/Filter/ActivityTypeFilterTest.php b/src/Bundle/ChillActivityBundle/Tests/Export/Filter/ActivityTypeFilterTest.php similarity index 65% rename from src/Bundle/ChillPersonBundle/Tests/Export/Filter/ActivityTypeFilterTest.php rename to src/Bundle/ChillActivityBundle/Tests/Export/Filter/ActivityTypeFilterTest.php index a2b1b166e..9fd1250ca 100644 --- a/src/Bundle/ChillPersonBundle/Tests/Export/Filter/ActivityTypeFilterTest.php +++ b/src/Bundle/ChillActivityBundle/Tests/Export/Filter/ActivityTypeFilterTest.php @@ -9,13 +9,13 @@ declare(strict_types=1); * the LICENSE file that was distributed with this source code. */ -namespace Export\Filter; +namespace Chill\ActivityBundle\Tests\Export\Filter; +use Chill\ActivityBundle\Entity\Activity; use Chill\ActivityBundle\Entity\ActivityType; +use Chill\ActivityBundle\Export\Filter\ActivityTypeFilter; use Chill\MainBundle\Test\Export\AbstractFilterTest; -use Chill\PersonBundle\Export\Filter\ActivityTypeFilter; use Doctrine\ORM\EntityManagerInterface; -use Symfony\Component\HttpFoundation\Request; /** * @internal @@ -27,16 +27,9 @@ final class ActivityTypeFilterTest extends AbstractFilterTest protected function setUp(): void { - //parent::setUp(); self::bootKernel(); - // add a fake request with a default locale (used in translatable string) - $request = $this->prophesize(); - - $request->willExtend(Request::class); - $request->getLocale()->willReturn('fr'); - - $this->filter = self::$container->get('chill.person.export.filter_activitytype'); + $this->filter = self::$container->get('chill.activity.export.type_filter'); } public function getFilter() @@ -56,8 +49,10 @@ final class ActivityTypeFilterTest extends AbstractFilterTest $data = []; - foreach ($array as $t) { - $data[] = ['accepted_activitytypes' => $t]; + foreach ($array as $a) { + $data[] = [ + 'types' => $a, + ]; } return $data; @@ -73,8 +68,8 @@ final class ActivityTypeFilterTest extends AbstractFilterTest return [ $em->createQueryBuilder() - ->from('ChillPersonBundle:AccompanyingPeriod', 'acp') - ->select('acp.id'), + ->select('count(activity.id)') + ->from(Activity::class, 'activity'), ]; } } diff --git a/src/Bundle/ChillActivityBundle/Tests/Export/Filter/PersonFilters/ActivityReasonFilterTest.php b/src/Bundle/ChillActivityBundle/Tests/Export/Filter/PersonFilters/ActivityReasonFilterTest.php new file mode 100644 index 000000000..b4e962ba9 --- /dev/null +++ b/src/Bundle/ChillActivityBundle/Tests/Export/Filter/PersonFilters/ActivityReasonFilterTest.php @@ -0,0 +1,76 @@ +filter = self::$container->get('chill.activity.export.reason_filter'); + } + + public function getFilter() + { + return $this->filter; + } + + public function getFormData(): array + { + $em = self::$container->get(EntityManagerInterface::class); + + $array = $em->createQueryBuilder() + ->from(ActivityReason::class, 'ar') + ->select('ar') + ->getQuery() + ->getResult(); + + $data = []; + + foreach ($array as $a) { + $data[] = [ + 'reasons' => $a, + ]; + } + + return $data; + } + + public function getQueryBuilders(): array + { + if (null === self::$kernel) { + self::bootKernel(); + } + + $em = self::$container->get(EntityManagerInterface::class); + + return [ + $em->createQueryBuilder() + ->select('count(activity.id)') + ->from(Activity::class, 'activity') + ->join('activity.reasons', 'actreasons'), + ]; + } +} diff --git a/src/Bundle/ChillActivityBundle/Tests/Export/Filter/PersonFilters/PersonHavingActivityBetweenDateFilterTest.php b/src/Bundle/ChillActivityBundle/Tests/Export/Filter/PersonFilters/PersonHavingActivityBetweenDateFilterTest.php new file mode 100644 index 000000000..eb296e9a4 --- /dev/null +++ b/src/Bundle/ChillActivityBundle/Tests/Export/Filter/PersonFilters/PersonHavingActivityBetweenDateFilterTest.php @@ -0,0 +1,78 @@ +filter = self::$container->get('chill.activity.export.person_having_an_activity_between_date_filter'); + } + + public function getFilter() + { + return $this->filter; + } + + public function getFormData(): array + { + $em = self::$container->get(EntityManagerInterface::class); + + $array = $em->createQueryBuilder() + ->from(ActivityReason::class, 'ar') + ->select('ar') + ->getQuery() + ->getResult(); + + $data = []; + + foreach ($array as $a) { + $data[] = [ + 'date_from' => DateTime::createFromFormat('Y-m-d', '2021-07-01'), + 'date_to' => DateTime::createFromFormat('Y-m-d', '2022-07-01'), + 'reasons' => $a, + ]; + } + + return $data; + } + + public function getQueryBuilders(): array + { + if (null === self::$kernel) { + self::bootKernel(); + } + + $em = self::$container->get(EntityManagerInterface::class); + + return [ + $em->createQueryBuilder() + ->select('count(activity.id)') + ->from(Activity::class, 'activity'), + ]; + } +} diff --git a/src/Bundle/ChillActivityBundle/Tests/Export/Filter/PersonHavingActivityBetweenDateFilterTest.php b/src/Bundle/ChillActivityBundle/Tests/Export/Filter/PersonHavingActivityBetweenDateFilterTest.php index 9e100d4e3..762de529e 100644 --- a/src/Bundle/ChillActivityBundle/Tests/Export/Filter/PersonHavingActivityBetweenDateFilterTest.php +++ b/src/Bundle/ChillActivityBundle/Tests/Export/Filter/PersonHavingActivityBetweenDateFilterTest.php @@ -11,6 +11,7 @@ declare(strict_types=1); namespace Chill\ActivityBundle\Tests\Export\Filter; +use Chill\ActivityBundle\Export\Filter\PersonFilters\PersonHavingActivityBetweenDateFilter; use Chill\MainBundle\Test\Export\AbstractFilterTest; use DateTime; use function array_slice; @@ -21,27 +22,20 @@ use function array_slice; */ final class PersonHavingActivityBetweenDateFilterTest extends AbstractFilterTest { - /** - * @var \Chill\PersonBundle\Export\Filter\PersonHavingActivityBetweenDateFilter - */ - private $filter; + private PersonHavingActivityBetweenDateFilter $filter; protected function setUp(): void { self::bootKernel(); - $container = self::$kernel->getContainer(); + $this->filter = self::$container->get('chill.activity.export.person_having_an_activity_between_date_filter'); - $this->filter = $container->get('chill.activity.export.' - . 'person_having_an_activity_between_date_filter'); + $request = $this->prophesize() + ->willExtend(\Symfony\Component\HttpFoundation\Request::class); - // add a fake request with a default locale (used in translatable string) - $prophet = new \Prophecy\Prophet(); - $request = $prophet->prophesize(); - $request->willExtend(\Symfony\Component\HttpFoundation\Request::class); $request->getLocale()->willReturn('fr'); - $container->get('request_stack') + self::$container->get('request_stack') ->push($request->reveal()); } diff --git a/src/Bundle/ChillActivityBundle/Tests/Form/ActivityTypeTest.php b/src/Bundle/ChillActivityBundle/Tests/Form/ActivityTypeTest.php index d0b4b5bb2..b6cb6fc7b 100644 --- a/src/Bundle/ChillActivityBundle/Tests/Form/ActivityTypeTest.php +++ b/src/Bundle/ChillActivityBundle/Tests/Form/ActivityTypeTest.php @@ -188,7 +188,9 @@ final class ActivityTypeTest extends KernelTestCase // map all the values in an array $values = array_map( - static function ($choice) { return $choice->value; }, + static function ($choice) { + return $choice->value; + }, $view['activity']['durationTime']->vars['choices'] ); diff --git a/src/Bundle/ChillActivityBundle/config/services/export.yaml b/src/Bundle/ChillActivityBundle/config/services/export.yaml index c88046c5f..16addd2a3 100644 --- a/src/Bundle/ChillActivityBundle/config/services/export.yaml +++ b/src/Bundle/ChillActivityBundle/config/services/export.yaml @@ -4,18 +4,15 @@ services: autoconfigure: true ## Indicators - chill.activity.export.count_activity_linked_to_person: - class: Chill\ActivityBundle\Export\Export\LinkedToPerson\CountActivity + Chill\ActivityBundle\Export\Export\LinkedToPerson\CountActivity: tags: - { name: chill.export, alias: 'count_activity_linked_to_person' } - chill.activity.export.sum_activity_duration_linked_to_person: - class: Chill\ActivityBundle\Export\Export\LinkedToPerson\StatActivityDuration + Chill\ActivityBundle\Export\Export\LinkedToPerson\StatActivityDuration: tags: - { name: chill.export, alias: 'sum_activity_duration_linked_to_person' } - chill.activity.export.list_activity_linked_to_person: - class: Chill\ActivityBundle\Export\Export\LinkedToPerson\ListActivity + Chill\ActivityBundle\Export\Export\LinkedToPerson\ListActivity: tags: - { name: chill.export, alias: 'list_activity_linked_to_person' } @@ -44,6 +41,12 @@ services: tags: - { name: chill.export, alias: 'avg_activity_visit_duration_linked_to_acp' } + Chill\ActivityBundle\Export\Export\LinkedToACP\ListActivity: + tags: + - { name: chill.export, alias: 'list_activity_acp'} + + Chill\ActivityBundle\Export\Export\ListActivityHelper: ~ + ## Filters chill.activity.export.type_filter: class: Chill\ActivityBundle\Export\Filter\ActivityTypeFilter @@ -55,6 +58,10 @@ services: tags: - { name: chill.export_filter, alias: 'activity_date_filter' } + Chill\ActivityBundle\Export\Filter\ActivityUsersFilter: + tags: + - { name: chill.export_filter, alias: 'activity_users_filter' } + chill.activity.export.reason_filter: class: Chill\ActivityBundle\Export\Filter\PersonFilters\ActivityReasonFilter tags: @@ -67,15 +74,19 @@ services: name: chill.export_filter alias: 'activity_person_having_ac_bw_date_filter' + chill.activity.export.filter_activitytype: + class: Chill\ActivityBundle\Export\Filter\ACPFilters\ActivityTypeFilter + tags: + - { name: chill.export_filter, alias: 'accompanyingcourse_activitytype_filter' } + chill.activity.export.locationtype_filter: class: Chill\ActivityBundle\Export\Filter\ACPFilters\LocationTypeFilter tags: - { name: chill.export_filter, alias: 'activity_locationtype_filter' } - chill.activity.export.byuser_filter: # TMS (M2M) - class: Chill\ActivityBundle\Export\Filter\ACPFilters\ByUserFilter + Chill\ActivityBundle\Export\Filter\ACPFilters\ByCreatorFilter: tags: - - { name: chill.export_filter, alias: 'activity_byuser_filter' } + - { name: chill.export_filter, alias: 'activity_bycreator_filter' } chill.activity.export.emergency_filter: class: Chill\ActivityBundle\Export\Filter\ACPFilters\EmergencyFilter @@ -107,16 +118,26 @@ services: tags: - { name: chill.export_filter, alias: 'activity_userscope_filter' } + Chill\ActivityBundle\Export\Filter\UsersJobFilter: + tags: + - { name: chill.export_filter, alias: 'activity_usersjob_filter' } + + Chill\ActivityBundle\Export\Filter\UsersScopeFilter: + tags: + - { name: chill.export_filter, alias: 'activity_usersscope_filter' } + + Chill\ActivityBundle\Export\Filter\ACPFilters\HasNoActivityFilter: + tags: + - { name: chill.export_filter, alias: 'accompanyingcourse_has_no_activity_filter' } + ## Aggregators - chill.activity.export.reason_aggregator: - class: Chill\ActivityBundle\Export\Aggregator\PersonAggregators\ActivityReasonAggregator + Chill\ActivityBundle\Export\Aggregator\PersonAggregators\ActivityReasonAggregator: tags: - { name: chill.export_aggregator, alias: activity_reason_aggregator } - chill.activity.export.type_aggregator: - class: Chill\ActivityBundle\Export\Aggregator\ActivityTypeAggregator + Chill\ActivityBundle\Export\Aggregator\ActivityTypeAggregator: tags: - - { name: chill.export_aggregator, alias: activity_type_aggregator } + - { name: chill.export_aggregator, alias: activity_common_type_aggregator } chill.activity.export.user_aggregator: class: Chill\ActivityBundle\Export\Aggregator\ActivityUserAggregator @@ -133,10 +154,9 @@ services: tags: - { name: chill.export_aggregator, alias: activity_date_aggregator } - chill.activity.export.byuser_aggregator: - class: Chill\ActivityBundle\Export\Aggregator\ACPAggregators\ByUserAggregator + Chill\ActivityBundle\Export\Aggregator\ACPAggregators\ByCreatorAggregator: tags: - - { name: chill.export_aggregator, alias: activity_byuser_aggregator } + - { name: chill.export_aggregator, alias: activity_by_creator_aggregator } chill.activity.export.bythirdparty_aggregator: class: Chill\ActivityBundle\Export\Aggregator\ACPAggregators\ByThirdpartyAggregator @@ -153,7 +173,26 @@ services: tags: - { name: chill.export_aggregator, alias: activity_bysocialissue_aggregator } - chill.activity.export.userscope_aggregator: - class: Chill\ActivityBundle\Export\Aggregator\ACPAggregators\UserScopeAggregator + Chill\ActivityBundle\Export\Aggregator\ACPAggregators\CreatorScopeAggregator: tags: - - { name: chill.export_aggregator, alias: activity_userscope_aggregator } + - { name: chill.export_aggregator, alias: activity_creator_scope_aggregator } + + Chill\ActivityBundle\Export\Aggregator\ActivityUsersAggregator: + tags: + - { name: chill.export_aggregator, alias: activity_users_aggregator } + + Chill\ActivityBundle\Export\Aggregator\ActivityUsersScopeAggregator: + tags: + - { name: chill.export_aggregator, alias: activity_users_scope_aggregator } + + Chill\ActivityBundle\Export\Aggregator\ActivityUsersJobAggregator: + tags: + - { name: chill.export_aggregator, alias: activity_users_job_aggregator } + + Chill\ActivityBundle\Export\Aggregator\ACPAggregators\ByActivityNumberAggregator: + tags: + - { name: chill.export_aggregator, alias: accompanyingcourse_by_activity_number_aggregator } + + Chill\ActivityBundle\Export\Aggregator\SentReceivedAggregator: + tags: + - { name: chill.export_aggregator, alias: activity_sentreceived_aggregator } diff --git a/src/Bundle/ChillActivityBundle/migrations/Version20221014130554.php b/src/Bundle/ChillActivityBundle/migrations/Version20221014130554.php new file mode 100644 index 000000000..4cbe47d26 --- /dev/null +++ b/src/Bundle/ChillActivityBundle/migrations/Version20221014130554.php @@ -0,0 +1,59 @@ +addSql('ALTER TABLE activity DROP updatedAt'); + $this->addSql('ALTER TABLE activity DROP createdAt'); + $this->addSql('ALTER TABLE activity DROP updatedBy_id'); + $this->addSql('ALTER TABLE activity DROP createdBy_id'); + + // rename some indexes on activity + $this->addSql('ALTER INDEX idx_ac74095a217bbb47 RENAME TO idx_55026b0c217bbb47'); + $this->addSql('ALTER INDEX idx_ac74095a682b5931 RENAME TO idx_55026b0c682b5931'); + $this->addSql('ALTER INDEX idx_ac74095aa76ed395 RENAME TO idx_55026b0ca76ed395'); + $this->addSql('ALTER INDEX idx_ac74095ac54c8c93 RENAME TO idx_55026b0cc54c8c93'); + } + + public function getDescription(): string + { + return 'Track update and create on activity'; + } + + public function up(Schema $schema): void + { + $this->addSql('ALTER TABLE activity ADD updatedAt TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL'); + $this->addSql('ALTER TABLE activity ADD createdAt TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL'); + $this->addSql('ALTER TABLE activity ADD updatedBy_id INT DEFAULT NULL'); + $this->addSql('ALTER TABLE activity ADD createdBy_id INT DEFAULT NULL'); + $this->addSql('COMMENT ON COLUMN activity.updatedAt IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('COMMENT ON COLUMN activity.createdAt IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('ALTER TABLE activity ADD CONSTRAINT FK_AC74095A65FF1AEC FOREIGN KEY (updatedBy_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE activity ADD CONSTRAINT FK_AC74095A3174800F FOREIGN KEY (createdBy_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('CREATE INDEX IDX_AC74095A65FF1AEC ON activity (updatedBy_id)'); + $this->addSql('CREATE INDEX IDX_AC74095A3174800F ON activity (createdBy_id)'); + + // rename some indexes on activity + $this->addSql('ALTER INDEX idx_55026b0cc54c8c93 RENAME TO IDX_AC74095AC54C8C93'); + $this->addSql('ALTER INDEX idx_55026b0c217bbb47 RENAME TO IDX_AC74095A217BBB47'); + $this->addSql('ALTER INDEX idx_55026b0c682b5931 RENAME TO IDX_AC74095A682B5931'); + $this->addSql('ALTER INDEX idx_55026b0ca76ed395 RENAME TO IDX_AC74095AA76ED395'); + + $this->addSql('UPDATE activity SET updatedBy_id=user_id, createdBy_id=user_id, createdAt="date", updatedAt="date"'); + } +} diff --git a/src/Bundle/ChillActivityBundle/translations/messages.fr.yml b/src/Bundle/ChillActivityBundle/translations/messages.fr.yml index 617935dc5..2f7aee6ac 100644 --- a/src/Bundle/ChillActivityBundle/translations/messages.fr.yml +++ b/src/Bundle/ChillActivityBundle/translations/messages.fr.yml @@ -45,6 +45,8 @@ by: 'Par ' location: Lieu Reasons: Sujets Private comment: Commentaire privé +sent: Envoyé +received: Reçu #forms @@ -119,15 +121,15 @@ Activity Presences: Presences aux activités # Crud crud: - activity_type: - title_new: Nouveau type d'activité - title_edit: Edition d'un type d'activité - activity_type_category: - title_new: Nouvelle catégorie de type d'activité - title_edit: Edition d'une catégorie de type d'activité - activity_presence: - title_new: Nouvelle Présence aux activités - title_edit: Edition d'une Présence aux activités + activity_type: + title_new: Nouveau type d'activité + title_edit: Edition d'un type d'activité + activity_type_category: + title_new: Nouvelle catégorie de type d'activité + title_edit: Edition d'une catégorie de type d'activité + activity_presence: + title_new: Nouvelle Présence aux activités + title_edit: Edition d'une Présence aux activités # activity reason admin ActivityReason list: Liste des sujets @@ -252,8 +254,6 @@ Filter by activity type: Filtrer les activités par type Filter activity by locationtype: Filtrer les activités par type de localisation 'Filtered activity by locationtype: only %types%': "Filtré par type de localisation: uniquement %types%" Accepted locationtype: Types de localisation -Filter activity by linked users: Filtrer les activités par TMS -'Filtered activity by linked users: only %users%': "Filtré par TMS: uniquement %users%" Accepted users: TMS(s) Filter activity by emergency: Filtrer les activités par urgence 'Filtered activity by emergency: only %emergency%': "Filtré par urgence: uniquement si %emergency%" @@ -262,18 +262,24 @@ activity is not emergency: l'activité n'est pas urgente Filter activity by sentreceived: Filtrer les activités par envoyé/reçu 'Filtered activity by sentreceived: only %sentreceived%': "Filtré par envoyé/reçu: uniquement %sentreceived%" Accepted sentreceived: '' -is sent: envoyé -is received: reçu Filter activity by linked socialaction: Filtrer les activités par action liée 'Filtered activity by linked socialaction: only %actions%': "Filtré par action liée: uniquement %actions%" Filter activity by linked socialissue: Filtrer les activités par problématique liée 'Filtered activity by linked socialissue: only %issues%': "Filtré par problématique liée: uniquement %issues%" Filter activity by user: Filtrer les activités par créateur -'Filtered activity by user: only %users%': "Filtré par créateur: uniquement %users%" +Filter activity by users: Filtrer les activités par utilisateur participant +Filter activity by creator: Filtrer les activités par créateur de l'échange +'Filtered activity by user: only %users%': "Filtré par référent: uniquement %users%" +'Filtered activity by users: only %users%': "Filtré par utilisateurs participants: uniquement %users%" +'Filtered activity by creator: only %users%': "Filtré par créateur: uniquement %users%" Creators: Créateurs Filter activity by userscope: Filtrer les activités par service du créateur 'Filtered activity by userscope: only %scopes%': "Filtré par service du créateur: uniquement %scopes%" Accepted userscope: Services + +Filter acp which has no activity: Filtrer les parcours qui n’ont pas d’activité +Filtered acp which has no activities: Filtrer les parcours sans activité associée +Group acp by activity number: Grouper les parcours par nombre d’activité #aggregators Activity type: Type d'activité @@ -282,9 +288,14 @@ By reason: Par sujet By category of reason: Par catégorie de sujet Reason's level: Niveau du sujet Group by reasons: Sujet d'activité -Aggregate by activity user: Grouper les activités par utilisateur +Aggregate by activity user: Grouper les activités par référent +Aggregate by activity users: Grouper les activités par utilisateurs participants Aggregate by activity type: Grouper les activités par type Aggregate by activity reason: Grouper les activités par sujet +Aggregate by users scope: Grouper les activités par service principal de l'utilisateur +Users 's scope: Service principal des utilisateurs participants à l'activité +Aggregate by users job: Grouper les activités par métier des utilisateurs participants +Users 's job: Métier des utilisateurs participants à l'activité Group activity by locationtype: Grouper les activités par type de localisation Group activity by date: Grouper les activités par date @@ -294,7 +305,8 @@ by week: Par semaine for week: Semaine by year: Par année in year: En -Group activity by linked users: Grouper les activités par TMS impliqué +Group activity by creator: Grouper les activités par créateur de l'échange +Group activity by creator scope: Grouper les activités par service du créateur de l'échange Group activity by linked thirdparties: Grouper les activités par tiers impliqué Accepted thirdparty: Tiers impliqué Group activity by linked socialaction: Grouper les activités par action liée @@ -314,3 +326,41 @@ docgen: A basic context for activity: Contexte pour les activités Accompanying period with a list of activities: Parcours d'accompagnement avec liste des activités Accompanying period with a list of activities description: Ce contexte reprend les informations du parcours, et tous les activités pour un parcours. Les activités ne sont pas filtrés. + +export: + list: + activity: + users name: Nom des utilisateurs + users ids: Identifiant des utilisateurs + third parties ids: Identifiant des tiers + persons ids: Identifiant des personnes + persons name: Nom des personnes + thirds parties: Tiers + date: Date de l'activité + locationName: Localisation + sent received: Envoyé ou reçu + emergency: Urgence + accompanying course id: Identifiant du parcours + course circles: Cercles du parcours + travelTime: Durée de déplacement + durationTime: Durée + id: Identifiant + List activities linked to an accompanying course: Liste les activités liées à un parcours en fonction de différents filtres. + List activity linked to a course: Liste des activités liées à un parcours + + + filter: + activity: + by_usersjob: + Filter by users job: Filtrer les activités par métier d'au moins un utilisateur participant + 'Filtered activity by users job: only %jobs%': 'Filtré par métier d''au moins un utilisateur participant: seulement %jobs%' + by_usersscope: + Filter by users scope: Filtrer les activités par services d'au moins un utilisateur participant + 'Filtered activity by users scope: only %scopes%': 'Filtré par service d''au moins un utilisateur participant: seulement %scopes%' + aggregator: + activity: + by_sent_received: + Sent or received: Envoyé ou reçu + is sent: envoyé + is received: reçu + Group activity by sentreceived: Grouper les activités par envoyé / reçu diff --git a/src/Bundle/ChillAsideActivityBundle/src/DependencyInjection/ChillAsideActivityExtension.php b/src/Bundle/ChillAsideActivityBundle/src/DependencyInjection/ChillAsideActivityExtension.php index e81b887c0..927de1845 100644 --- a/src/Bundle/ChillAsideActivityBundle/src/DependencyInjection/ChillAsideActivityExtension.php +++ b/src/Bundle/ChillAsideActivityBundle/src/DependencyInjection/ChillAsideActivityExtension.php @@ -30,6 +30,8 @@ final class ChillAsideActivityExtension extends Extension implements PrependExte $loader->load('services.yaml'); $loader->load('services/form.yaml'); $loader->load('services/menu.yaml'); + $loader->load('services/security.yaml'); + $loader->load('services/export.yaml'); } public function prepend(ContainerBuilder $container) diff --git a/src/Bundle/ChillAsideActivityBundle/src/Export/Aggregator/ByActivityTypeAggregator.php b/src/Bundle/ChillAsideActivityBundle/src/Export/Aggregator/ByActivityTypeAggregator.php new file mode 100644 index 000000000..6c91c9336 --- /dev/null +++ b/src/Bundle/ChillAsideActivityBundle/src/Export/Aggregator/ByActivityTypeAggregator.php @@ -0,0 +1,82 @@ +asideActivityCategoryRepository = $asideActivityCategoryRepository; + $this->translatableStringHelper = $translatableStringHelper; + } + + public function addRole(): ?string + { + return null; + } + + public function alterQuery(QueryBuilder $qb, $data) + { + $qb->addSelect('IDENTITY(aside.type) AS by_aside_activity_type_aggregator') + ->addGroupBy('by_aside_activity_type_aggregator'); + } + + public function applyOn(): string + { + return Declarations::ASIDE_ACTIVITY_TYPE; + } + + public function buildForm(FormBuilderInterface $builder) + { + // No form needed + } + + public function getLabels($key, array $values, $data) + { + $this->asideActivityCategoryRepository->findBy(['id' => $values]); + + return function ($value): string { + if ('_header' === $value) { + return 'export.aggregator.Aside activity type'; + } + + if (null === $value) { + return ''; + } + + $t = $this->asideActivityCategoryRepository->find($value); + + return $this->translatableStringHelper->localize($t->getTitle()); + }; + } + + public function getQueryKeys($data): array + { + return ['by_aside_activity_type_aggregator']; + } + + public function getTitle(): string + { + return 'export.aggregator.Group by aside activity type'; + } +} diff --git a/src/Bundle/ChillAsideActivityBundle/src/Export/Declarations.php b/src/Bundle/ChillAsideActivityBundle/src/Export/Declarations.php new file mode 100644 index 000000000..8c808ea37 --- /dev/null +++ b/src/Bundle/ChillAsideActivityBundle/src/Export/Declarations.php @@ -0,0 +1,20 @@ +repository = $repository; + } + + public function buildForm(FormBuilderInterface $builder) + { + } + + public function getAllowedFormattersTypes(): array + { + return [FormatterInterface::TYPE_TABULAR]; + } + + public function getDescription(): string + { + return 'export.Count aside activities by various parameters.'; + } + + public function getGroup(): string + { + return 'export.Exports of aside activities'; + } + + public function getLabels($key, array $values, $data) + { + if ('export_result' !== $key) { + throw new LogicException("the key {$key} is not used by this export"); + } + + $labels = array_combine($values, $values); + $labels['_header'] = $this->getTitle(); + + return static function ($value) use ($labels) { + return $labels[$value]; + }; + } + + public function getQueryKeys($data): array + { + return ['export_result']; + } + + public function getResult($query, $data) + { + return $query->getQuery()->getResult(Query::HYDRATE_SCALAR); + } + + public function getTitle(): string + { + return 'export.Count aside activities'; + } + + public function getType(): string + { + return Declarations::ASIDE_ACTIVITY_TYPE; + } + + public function initiateQuery(array $requiredModifiers, array $acl, array $data = []) + { + $qb = $this->repository->createQueryBuilder('aside'); + + $qb->select('COUNT(DISTINCT aside.id) AS export_result'); + + return $qb; + } + + public function requiredRole(): string + { + return AsideActivityVoter::STATS; + } + + public function supportsModifiers(): array + { + return []; + } +} diff --git a/src/Bundle/ChillAsideActivityBundle/src/Export/Filter/ByActivityTypeFilter.php b/src/Bundle/ChillAsideActivityBundle/src/Export/Filter/ByActivityTypeFilter.php new file mode 100644 index 000000000..85b327795 --- /dev/null +++ b/src/Bundle/ChillAsideActivityBundle/src/Export/Filter/ByActivityTypeFilter.php @@ -0,0 +1,96 @@ +categoryRender = $categoryRender; + $this->asideActivityTypeRepository = $asideActivityTypeRepository; + $this->translatableStringHelper = $translatableStringHelper; + } + + public function addRole(): ?string + { + return null; + } + + public function alterQuery(QueryBuilder $qb, $data) + { + $clause = $qb->expr()->in('aside.type', ':types'); + + $qb->andWhere($clause); + $qb->setParameter('types', $data['types']); + } + + public function applyOn(): string + { + return Declarations::ASIDE_ACTIVITY_TYPE; + } + + public function buildForm(FormBuilderInterface $builder) + { + $builder + ->add('types', EntityType::class, [ + 'class' => AsideActivityCategory::class, + 'choices' => $this->asideActivityTypeRepository->findAllActive(), + 'required' => false, + 'multiple' => true, + 'expanded' => false, + 'attr' => [ + 'class' => 'select2', + ], + 'choice_label' => function (AsideActivityCategory $category) { + $options = []; + + return $this->categoryRender->renderString($category, $options); + }, + ]); + } + + public function describeAction($data, $format = 'string'): array + { + $types = array_map( + fn (AsideActivityCategory $t): string => $this->translatableStringHelper->localize($t->getName()), + $this->asideActivityTypeRepository->findBy(['id' => $data['types']->toArray()]) + ); + + return ['export.filter.Filtered by aside activity type: only %type%', [ + '%type%' => implode(', ', $types), + ]]; + } + + public function getTitle(): string + { + return 'export.filter.Filter by aside activity type'; + } +} diff --git a/src/Bundle/ChillAsideActivityBundle/src/Export/Filter/ByDateFilter.php b/src/Bundle/ChillAsideActivityBundle/src/Export/Filter/ByDateFilter.php new file mode 100644 index 000000000..099c87fc0 --- /dev/null +++ b/src/Bundle/ChillAsideActivityBundle/src/Export/Filter/ByDateFilter.php @@ -0,0 +1,143 @@ +translator = $translator; + $this->rollingDateConverter = $rollingDateConverter; + } + + public function addRole(): ?string + { + return null; + } + + public function alterQuery(QueryBuilder $qb, $data) + { + $where = $qb->getDQLPart('where'); + $clause = $qb->expr()->between( + 'aside.date', + ':date_from', + ':date_to' + ); + + if ($where instanceof Andx) { + $where->add($clause); + } else { + $where = $qb->expr()->andX($clause); + } + + $qb->add('where', $where); + $qb->setParameter( + 'date_from', + $this->rollingDateConverter->convert($data['date_from']) + ); + $qb->setParameter( + 'date_to', + $this->rollingDateConverter->convert($data['date_to']) + ); + } + + public function applyOn(): string + { + return Declarations::ASIDE_ACTIVITY_TYPE; + } + + public function buildForm(FormBuilderInterface $builder) + { + $builder + ->add('date_from', PickRollingDateType::class, [ + 'label' => 'export.filter.Aside activities after this date', + 'data' => new RollingDate(RollingDate::T_YEAR_PREVIOUS_START), + ]) + ->add('date_to', PickRollingDateType::class, [ + 'label' => 'export.filter.Aside activities before this date', + 'data' => new RollingDate(RollingDate::T_TODAY), + ]); + + $builder->addEventListener(FormEvents::POST_SUBMIT, function (FormEvent $event) { + /** @var \Symfony\Component\Form\FormInterface $filterForm */ + $filterForm = $event->getForm()->getParent(); + $enabled = $filterForm->get(FilterType::ENABLED_FIELD)->getData(); + + if (true === $enabled) { + // if the filter is enabled, add some validation + $form = $event->getForm(); + $date_from = $form->get('date_from')->getData(); + $date_to = $form->get('date_to')->getData(); + + // check that fields are not empty + if (null === $date_from) { + $form->get('date_from')->addError(new FormError( + $this->translator->trans('This field ' + . 'should not be empty') + )); + } + + if (null === $date_to) { + $form->get('date_to')->addError(new FormError( + $this->translator->trans('This field ' + . 'should not be empty') + )); + } + + // check that date_from is before date_to + if ( + (null !== $date_from && null !== $date_to) + && $date_from >= $date_to + ) { + $form->get('date_to')->addError(new FormError( + $this->translator->trans('export.filter.This date should be after ' + . 'the date given in "Implied in an aside activity after ' + . 'this date" field') + )); + } + } + }); + } + + public function describeAction($data, $format = 'string'): array + { + return ['export.filter.Filtered by aside activities between %dateFrom% and %dateTo%', [ + '%dateFrom%' => $this->rollingDateConverter->convert($data['date_from'])->format('d-m-Y'), + '%dateTo%' => $this->rollingDateConverter->convert($data['date_to'])->format('d-m-Y'), + ]]; + } + + public function getTitle(): string + { + return 'export.filter.Filter by aside activity date'; + } +} diff --git a/src/Bundle/ChillAsideActivityBundle/src/Repository/AsideActivityCategoryRepository.php b/src/Bundle/ChillAsideActivityBundle/src/Repository/AsideActivityCategoryRepository.php index 85ea9faf4..918cec586 100644 --- a/src/Bundle/ChillAsideActivityBundle/src/Repository/AsideActivityCategoryRepository.php +++ b/src/Bundle/ChillAsideActivityBundle/src/Repository/AsideActivityCategoryRepository.php @@ -38,6 +38,11 @@ class AsideActivityCategoryRepository implements ObjectRepository return $this->repository->findAll(); } + public function findAllActive(): array + { + return $this->repository->findBy(['isActive' => true]); + } + /** * @param mixed|null $limit * @param mixed|null $offset diff --git a/src/Bundle/ChillAsideActivityBundle/src/Repository/AsideActivityRepository.php b/src/Bundle/ChillAsideActivityBundle/src/Repository/AsideActivityRepository.php index f2ad9c072..b3dc46f87 100644 --- a/src/Bundle/ChillAsideActivityBundle/src/Repository/AsideActivityRepository.php +++ b/src/Bundle/ChillAsideActivityBundle/src/Repository/AsideActivityRepository.php @@ -12,46 +12,20 @@ declare(strict_types=1); namespace Chill\AsideActivityBundle\Repository; use Chill\AsideActivityBundle\Entity\AsideActivity; -use Doctrine\ORM\EntityManagerInterface; -use Doctrine\ORM\EntityRepository; -use Doctrine\Persistence\ObjectRepository; +use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; +use Doctrine\Persistence\ManagerRegistry; -final class AsideActivityRepository implements ObjectRepository +/** + * @method AsideActivity|null find($id, $lockMode = null, $lockVersion = null) + * @method AsideActivity|null findOneBy(array $criteria, array $orderBy = null) + * @method AsideActivity[] findAll() + * @method AsideActivity[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) + */ +final class AsideActivityRepository extends ServiceEntityRepository { - private EntityRepository $repository; - - public function __construct(EntityManagerInterface $entityManager) + public function __construct(ManagerRegistry $registry) { - $this->repository = $entityManager->getRepository(AsideActivity::class); - } - - public function find($id): ?AsideActivity - { - return $this->repository->find($id); - } - - /** - * @return AsideActivity[] - */ - public function findAll(): array - { - return $this->repository->findAll(); - } - - /** - * @param mixed|null $limit - * @param mixed|null $offset - * - * @return AsideActivity[] - */ - public function findBy(array $criteria, ?array $orderBy = null, $limit = null, $offset = null): array - { - return $this->repository->findBy($criteria, $orderBy, $limit, $offset); - } - - public function findOneBy(array $criteria): ?AsideActivity - { - return $this->repository->findOneBy($criteria); + parent::__construct($registry, AsideActivity::class); } public function getClassName(): string diff --git a/src/Bundle/ChillAsideActivityBundle/src/Security/AsideActivityVoter.php b/src/Bundle/ChillAsideActivityBundle/src/Security/AsideActivityVoter.php new file mode 100644 index 000000000..6b308b5f2 --- /dev/null +++ b/src/Bundle/ChillAsideActivityBundle/src/Security/AsideActivityVoter.php @@ -0,0 +1,79 @@ +voterHelper = $voterHelperFactory + ->generate(self::class) + ->addCheckFor(Center::class, [self::STATS]) + ->build(); + } + + /** + * @return string[] + */ + public function getRoles(): array + { + return $this->getAttributes(); + } + + /** + * @return string[][] + */ + public function getRolesWithHierarchy(): array + { + return ['Aside activity' => $this->getRoles()]; + } + + /** + * @return string[] + */ + public function getRolesWithoutScope(): array + { + return $this->getAttributes(); + } + + protected function supports($attribute, $subject) + { + return $this->voterHelper->supports($attribute, $subject); + } + + protected function voteOnAttribute($attribute, $subject, TokenInterface $token): bool + { + if (!$token->getUser() instanceof User) { + return false; + } + + return $this->voterHelper->voteOnAttribute($attribute, $subject, $token); + } + + private function getAttributes(): array + { + return [self::STATS]; + } +} diff --git a/src/Bundle/ChillAsideActivityBundle/src/config/services.yaml b/src/Bundle/ChillAsideActivityBundle/src/config/services.yaml index 34bb6da33..2a7c30d7c 100644 --- a/src/Bundle/ChillAsideActivityBundle/src/config/services.yaml +++ b/src/Bundle/ChillAsideActivityBundle/src/config/services.yaml @@ -20,3 +20,33 @@ services: resource: "../Controller" autowire: true autoconfigure: true + + + ## Exports + + # indicators + Chill\AsideActivityBundle\Export\Export\CountAsideActivity: + autowire: true + autoconfigure: true + tags: + - { name: chill.export, alias: count_asideactivity } + + # filters + Chill\AsideActivityBundle\Export\Filter\ByDateFilter: + autowire: true + autoconfigure: true + tags: + - { name: chill.export_filter, alias: asideactivity_bydate_filter } + + Chill\AsideActivityBundle\Export\Filter\ByActivityTypeFilter: + autowire: true + autoconfigure: true + tags: + - { name: chill.export_filter, alias: asideactivity_activitytype_filter } + + # aggregators + Chill\AsideActivityBundle\Export\Aggregator\ByActivityTypeAggregator: + autowire: true + autoconfigure: true + tags: + - { name: chill.export_aggregator, alias: asideactivity_activitytype_aggregator } diff --git a/src/Bundle/ChillAsideActivityBundle/src/config/services/export.yaml b/src/Bundle/ChillAsideActivityBundle/src/config/services/export.yaml new file mode 100644 index 000000000..1b6b05e1c --- /dev/null +++ b/src/Bundle/ChillAsideActivityBundle/src/config/services/export.yaml @@ -0,0 +1,27 @@ +services: + _defaults: + autowire: true + autoconfigure: true + + ## Indicators + Chill\AsideActivityBundle\Export\Export\CountAsideActivity: + tags: + - { name: chill.export, alias: 'count_aside_activity' } + + ## Filters + chill.aside_activity.export.date_filter: + class: Chill\AsideActivityBundle\Export\Filter\ByDateFilter + tags: + - { name: chill.export_filter, alias: 'aside_activity_date_filter' } + + chill.aside_activity.export.type_filter: + class: Chill\AsideActivityBundle\Export\Filter\ByActivityTypeFilter + tags: + - { name: chill.export_filter, alias: 'aside_activity_type_filter' } + + ## Aggregators + + chill.aside_activity.export.type_aggregator: + class: Chill\AsideActivityBundle\Export\Aggregator\ByActivityTypeAggregator + tags: + - { name: chill.export_aggregator, alias: activity_type_aggregator } \ No newline at end of file diff --git a/src/Bundle/ChillAsideActivityBundle/src/config/services/security.yaml b/src/Bundle/ChillAsideActivityBundle/src/config/services/security.yaml new file mode 100644 index 000000000..eb3327959 --- /dev/null +++ b/src/Bundle/ChillAsideActivityBundle/src/config/services/security.yaml @@ -0,0 +1,7 @@ +services: + Chill\AsideActivityBundle\Security\AsideActivityVoter: + autowire: true + autoconfigure: true + tags: + - { name: security.voter } + - { name: chill.role } \ No newline at end of file diff --git a/src/Bundle/ChillAsideActivityBundle/src/translations/messages.fr.yml b/src/Bundle/ChillAsideActivityBundle/src/translations/messages.fr.yml index 0a7be1fcd..35a4d6a22 100644 --- a/src/Bundle/ChillAsideActivityBundle/src/translations/messages.fr.yml +++ b/src/Bundle/ChillAsideActivityBundle/src/translations/messages.fr.yml @@ -166,3 +166,21 @@ Aside activities: Activités annexes Aside activity types: Types d'activités annexes Aside activity type configuration: Configuration des categories d'activités annexes Aside activity configuration: Configuration des activités annexes + +# exports +export: + Exports of aside activities: Exports des activités annexes + Count aside activities: Nombre d'activités annexes + Count aside activities by various parameters.: Compte le nombre d'activités annexes selon divers critères + filter: + Filter by aside activity date: Filtrer les activités annexes par date + Filter by aside activity type: Filtrer les activités annexes par type d'activité + 'Filtered by aside activity type: only %type%': "Filtré par type d'activité annexe: uniquement %type%" + This date should be after the date given in "Implied in an aside activity after this date" field: Cette date devrait être postérieure à la date donnée dans le champ "activités annexes après cette date" + Aside activities after this date: Actvitités annexes après cette date + Aside activities before this date: Actvitités annexes avant cette date + aggregator: + Group by aside activity type: Grouper les activités annexes par type d'activité + Aside activity type: Type d'activité annexe + + diff --git a/src/Bundle/ChillBudgetBundle/Config/ConfigRepository.php b/src/Bundle/ChillBudgetBundle/Config/ConfigRepository.php index 2497bcf21..f28a83a4d 100644 --- a/src/Bundle/ChillBudgetBundle/Config/ConfigRepository.php +++ b/src/Bundle/ChillBudgetBundle/Config/ConfigRepository.php @@ -31,7 +31,9 @@ class ConfigRepository public function getChargesKeys(bool $onlyActive = false): array { - return array_map(static function ($element) { return $element['key']; }, $this->getCharges($onlyActive)); + return array_map(static function ($element) { + return $element['key']; + }, $this->getCharges($onlyActive)); } /** @@ -50,7 +52,9 @@ class ConfigRepository public function getResourcesKeys(bool $onlyActive = false): array { - return array_map(static function ($element) { return $element['key']; }, $this->getResources($onlyActive)); + return array_map(static function ($element) { + return $element['key']; + }, $this->getResources($onlyActive)); } /** @@ -70,14 +74,18 @@ class ConfigRepository private function getCharges(bool $onlyActive = false): array { return $onlyActive ? - array_filter($this->charges, static function ($el) { return $el['active']; }) + array_filter($this->charges, static function ($el) { + return $el['active']; + }) : $this->charges; } private function getResources(bool $onlyActive = false): array { return $onlyActive ? - array_filter($this->resources, static function ($el) { return $el['active']; }) + array_filter($this->resources, static function ($el) { + return $el['active']; + }) : $this->resources; } diff --git a/src/Bundle/ChillBudgetBundle/Entity/AbstractElement.php b/src/Bundle/ChillBudgetBundle/Entity/AbstractElement.php index 5ff3e142f..73060f40b 100644 --- a/src/Bundle/ChillBudgetBundle/Entity/AbstractElement.php +++ b/src/Bundle/ChillBudgetBundle/Entity/AbstractElement.php @@ -150,7 +150,7 @@ abstract class AbstractElement return $this; } - public function setHousehold(Household $household): self + public function setHousehold(?Household $household): self { $this->household = $household; diff --git a/src/Bundle/ChillBudgetBundle/Service/Summary/SummaryBudget.php b/src/Bundle/ChillBudgetBundle/Service/Summary/SummaryBudget.php index 6ae7e049b..f02d2e64a 100644 --- a/src/Bundle/ChillBudgetBundle/Service/Summary/SummaryBudget.php +++ b/src/Bundle/ChillBudgetBundle/Service/Summary/SummaryBudget.php @@ -61,7 +61,9 @@ class SummaryBudget implements SummaryBudgetInterface ]; } - $personIds = $household->getCurrentPersons()->map(static function (Person $p) { return $p->getId(); }); + $personIds = $household->getCurrentPersons()->map(static function (Person $p) { + return $p->getId(); + }); $ids = implode(', ', array_fill(0, count($personIds), '?')); $parameters = [...$personIds, $household->getId()]; diff --git a/src/Bundle/ChillCalendarBundle/ChillCalendarBundle.php b/src/Bundle/ChillCalendarBundle/ChillCalendarBundle.php index 8ccbdc8a1..e2042b114 100644 --- a/src/Bundle/ChillCalendarBundle/ChillCalendarBundle.php +++ b/src/Bundle/ChillCalendarBundle/ChillCalendarBundle.php @@ -11,8 +11,16 @@ declare(strict_types=1); namespace Chill\CalendarBundle; +use Chill\CalendarBundle\RemoteCalendar\DependencyInjection\RemoteCalendarCompilerPass; +use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\HttpKernel\Bundle\Bundle; class ChillCalendarBundle extends Bundle { + public function build(ContainerBuilder $container) + { + parent::build($container); + + $container->addCompilerPass(new RemoteCalendarCompilerPass()); + } } diff --git a/src/Bundle/ChillCalendarBundle/Command/AzureGrantAdminConsentAndAcquireToken.php b/src/Bundle/ChillCalendarBundle/Command/AzureGrantAdminConsentAndAcquireToken.php new file mode 100644 index 000000000..400619990 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Command/AzureGrantAdminConsentAndAcquireToken.php @@ -0,0 +1,84 @@ +azure = $azure; + $this->clientRegistry = $clientRegistry; + $this->machineTokenStorage = $machineTokenStorage; + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + /** @var FormatterHelper $formatter */ + $formatter = $this->getHelper('formatter'); + $this->azure->scope = ['https://graph.microsoft.com/.default']; + $authorizationUrl = explode('?', $this->azure->getAuthorizationUrl(['prompt' => 'admin_consent'])); + + // replace the first part by the admin consent authorization url + $authorizationUrl[0] = strtr('https://login.microsoftonline.com/{tenant}/adminconsent', ['{tenant}' => $this->azure->tenant]); + + $output->writeln('Go to the url'); + $output->writeln(implode('?', $authorizationUrl)); + $output->writeln('Authenticate as admin, and grant admin consent'); + + // not necessary ? + $helper = $this->getHelper('question'); + $question = new ConfirmationQuestion('Access granted ?'); + + if (!$helper->ask($input, $output, $question)) { + $messages = ['No problem, we will wait for you', 'Grant access and come back here']; + $output->writeln($formatter->formatBlock($messages, 'warning')); + + return 0; + } + + $token = $this->machineTokenStorage->getToken(); + + $messages = ['Token acquired!', 'We could acquire a machine token successfully']; + $output->writeln($formatter->formatBlock($messages, 'success')); + + $output->writeln('Token information:'); + $output->writeln($token->getToken()); + $output->writeln('Expires at: ' . $token->getExpires()); + $output->writeln('To inspect the token content, go to https://jwt.ms/#access_token=' . urlencode($token->getToken())); + + return 0; + } +} diff --git a/src/Bundle/ChillCalendarBundle/Command/MapAndSubscribeUserCalendarCommand.php b/src/Bundle/ChillCalendarBundle/Command/MapAndSubscribeUserCalendarCommand.php new file mode 100644 index 000000000..902fe4c38 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Command/MapAndSubscribeUserCalendarCommand.php @@ -0,0 +1,171 @@ +em = $em; + $this->eventsOnUserSubscriptionCreator = $eventsOnUserSubscriptionCreator; + $this->logger = $logger; + $this->mapCalendarToUser = $mapCalendarToUser; + $this->userRepository = $userRepository; + } + + public function execute(InputInterface $input, OutputInterface $output): int + { + $this->logger->info(__CLASS__ . ' execute command'); + + $limit = 50; + $offset = 0; + /** @var DateInterval $interval the interval before the end of the expiration */ + $interval = new DateInterval('P1D'); + $expiration = (new DateTimeImmutable('now'))->add(new DateInterval($input->getOption('subscription-duration'))); + $total = $this->userRepository->countByMostOldSubscriptionOrWithoutSubscriptionOrData($interval); + $created = 0; + $renewed = 0; + + $this->logger->info(__CLASS__ . ' the number of user to get - renew', [ + 'total' => $total, + 'expiration' => $expiration->format(DateTimeImmutable::ATOM), + ]); + + while ($offset < ($total - 1)) { + $users = $this->userRepository->findByMostOldSubscriptionOrWithoutSubscriptionOrData( + $interval, + $limit, + $offset + ); + + foreach ($users as $user) { + if (!$this->mapCalendarToUser->hasUserId($user)) { + $this->mapCalendarToUser->writeMetadata($user); + } + + if ($this->mapCalendarToUser->hasUserId($user)) { + // we first try to renew an existing subscription, if any. + // if not, or if it fails, we try to create a new one + if ($this->mapCalendarToUser->hasActiveSubscription($user)) { + $this->logger->debug(__CLASS__ . ' renew a subscription for', [ + 'userId' => $user->getId(), + 'username' => $user->getUsernameCanonical(), + ]); + + ['secret' => $secret, 'id' => $id, 'expiration' => $expirationTs] + = $this->eventsOnUserSubscriptionCreator->renewSubscriptionForUser($user, $expiration); + $this->mapCalendarToUser->writeSubscriptionMetadata($user, $expirationTs, $id, $secret); + + if (0 !== $expirationTs) { + ++$renewed; + } else { + $this->logger->warning(__CLASS__ . ' could not renew subscription for a user', [ + 'userId' => $user->getId(), + 'username' => $user->getUsernameCanonical(), + ]); + } + } + + if (!$this->mapCalendarToUser->hasActiveSubscription($user)) { + $this->logger->debug(__CLASS__ . ' create a subscription for', [ + 'userId' => $user->getId(), + 'username' => $user->getUsernameCanonical(), + ]); + + ['secret' => $secret, 'id' => $id, 'expiration' => $expirationTs] + = $this->eventsOnUserSubscriptionCreator->createSubscriptionForUser($user, $expiration); + $this->mapCalendarToUser->writeSubscriptionMetadata($user, $expirationTs, $id, $secret); + + if (0 !== $expirationTs) { + ++$created; + } else { + $this->logger->warning(__CLASS__ . ' could not create subscription for a user', [ + 'userId' => $user->getId(), + 'username' => $user->getUsernameCanonical(), + ]); + } + } + } + + ++$offset; + } + + $this->em->flush(); + $this->em->clear(); + } + + $this->logger->warning(__CLASS__ . ' process executed', [ + 'created' => $created, + 'renewed' => $renewed, + ]); + + return 0; + } + + protected function configure() + { + parent::configure(); + + $this + ->setDescription('MSGraph: collect user metadata and create subscription on events for users') + ->addOption( + 'renew-before-end-interval', + 'r', + InputOption::VALUE_OPTIONAL, + 'delay before renewing subscription', + 'P1D' + ) + ->addOption( + 'subscription-duration', + 's', + InputOption::VALUE_OPTIONAL, + 'duration for the subscription', + 'PT4230M' + ); + } +} diff --git a/src/Bundle/ChillCalendarBundle/Command/SendShortMessageOnEligibleCalendar.php b/src/Bundle/ChillCalendarBundle/Command/SendShortMessageOnEligibleCalendar.php new file mode 100644 index 000000000..6625a7adb --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Command/SendShortMessageOnEligibleCalendar.php @@ -0,0 +1,48 @@ +messageSender = $messageSender; + } + + public function getName() + { + return 'chill:calendar:send-short-messages'; + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + $this->messageSender->sendBulkMessageToEligibleCalendars(); + + return 0; + } +} diff --git a/src/Bundle/ChillCalendarBundle/Command/SendTestShortMessageOnCalendarCommand.php b/src/Bundle/ChillCalendarBundle/Command/SendTestShortMessageOnCalendarCommand.php new file mode 100644 index 000000000..5891a7be8 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Command/SendTestShortMessageOnCalendarCommand.php @@ -0,0 +1,207 @@ +personRepository = $personRepository; + $this->phoneNumberUtil = $phoneNumberUtil; + $this->phoneNumberHelper = $phoneNumberHelper; + $this->messageForCalendarBuilder = $messageForCalendarBuilder; + $this->transporter = $transporter; + $this->userRepository = $userRepository; + } + + public function getName() + { + return 'chill:calendar:test-send-short-message'; + } + + protected function configure() + { + $this->setDescription('Test sending a SMS for a dummy calendar appointment'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $calendar = new Calendar(); + $calendar->setSendSMS(true); + + /** @var QuestionHelper $helper */ + $helper = $this->getHelper('question'); + + // start date + $question = new Question('When will start the appointment ? (default: "1 hour") ', '1 hour'); + $startDate = new DateTimeImmutable($helper->ask($input, $output, $question)); + + if (false === $startDate) { + throw new UnexpectedValueException('could not create a date with this date and time'); + } + + $calendar->setStartDate($startDate); + + // end date + $question = new Question('How long will last the appointment ? (default: "PT30M") ', 'PT30M'); + $interval = new DateInterval($helper->ask($input, $output, $question)); + + if (false === $interval) { + throw new UnexpectedValueException('could not create the interval'); + } + + $calendar->setEndDate($calendar->getStartDate()->add($interval)); + + // a person + $question = new Question('Who will participate ? Give an id for a person. '); + $question + ->setValidator(function ($answer): Person { + if (!is_numeric($answer)) { + throw new UnexpectedValueException('the answer must be numeric'); + } + + if (0 >= (int) $answer) { + throw new UnexpectedValueException('the answer must be greater than zero'); + } + + $person = $this->personRepository->find((int) $answer); + + if (null === $person) { + throw new UnexpectedValueException('The person is not found'); + } + + return $person; + }); + + $person = $helper->ask($input, $output, $question); + $calendar->addPerson($person); + + // a main user + $question = new Question('Who will be the main user ? Give an id for a user. '); + $question + ->setValidator(function ($answer): User { + if (!is_numeric($answer)) { + throw new UnexpectedValueException('the answer must be numeric'); + } + + if (0 >= (int) $answer) { + throw new UnexpectedValueException('the answer must be greater than zero'); + } + + $user = $this->userRepository->find((int) $answer); + + if (null === $user) { + throw new UnexpectedValueException('The user is not found'); + } + + return $user; + }); + + $user = $helper->ask($input, $output, $question); + $calendar->setMainUser($user); + + // phonenumber + $phonenumberFormatted = null !== $person->getMobilenumber() ? + $this->phoneNumberUtil->format($person->getMobilenumber(), PhoneNumberFormat::E164) : ''; + $question = new Question( + sprintf('To which number are we going to send this fake message ? (default to: %s)', $phonenumberFormatted), + $phonenumberFormatted + ); + + $question->setNormalizer(function ($answer): PhoneNumber { + if (null === $answer) { + throw new UnexpectedValueException('The person is not found'); + } + + $phone = $this->phoneNumberUtil->parse($answer, 'BE'); + + if (!$this->phoneNumberUtil->isPossibleNumberForType($phone, PhoneNumberType::MOBILE)) { + throw new UnexpectedValueException('Phone number si not a mobile'); + } + + return $phone; + }); + + $phone = $helper->ask($input, $output, $question); + + $question = new ConfirmationQuestion('really send the message to the phone ?'); + $reallySend = (bool) $helper->ask($input, $output, $question); + + $messages = $this->messageForCalendarBuilder->buildMessageForCalendar($calendar); + + if (0 === count($messages)) { + $output->writeln('no message to send to this user'); + } + + foreach ($messages as $key => $message) { + $output->writeln("The short message for SMS {$key} will be: "); + $output->writeln($message->getContent()); + $message->setPhoneNumber($phone); + + if ($reallySend) { + $this->transporter->send($message); + } + } + + return 0; + } +} diff --git a/src/Bundle/ChillCalendarBundle/Controller/CalendarAPIController.php b/src/Bundle/ChillCalendarBundle/Controller/CalendarAPIController.php index e452edac0..8e0e8099c 100644 --- a/src/Bundle/ChillCalendarBundle/Controller/CalendarAPIController.php +++ b/src/Bundle/ChillCalendarBundle/Controller/CalendarAPIController.php @@ -11,11 +11,73 @@ declare(strict_types=1); namespace Chill\CalendarBundle\Controller; +use Chill\CalendarBundle\Repository\CalendarRepository; use Chill\MainBundle\CRUD\Controller\ApiController; +use Chill\MainBundle\Entity\User; +use Chill\MainBundle\Serializer\Model\Collection; +use DateTimeImmutable; +use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; +use Symfony\Component\Routing\Annotation\Route; class CalendarAPIController extends ApiController { + private CalendarRepository $calendarRepository; + + public function __construct(CalendarRepository $calendarRepository) + { + $this->calendarRepository = $calendarRepository; + } + + /** + * @Route("/api/1.0/calendar/calendar/by-user/{id}.{_format}", + * name="chill_api_single_calendar_list_by-user", + * requirements={"_format": "json"} + * ) + */ + public function listByUser(User $user, Request $request, string $_format): JsonResponse + { + $this->denyAccessUnlessGranted('ROLE_USER'); + + if (!$request->query->has('dateFrom')) { + throw new BadRequestHttpException('You must provide a dateFrom parameter'); + } + + if (false === $dateFrom = DateTimeImmutable::createFromFormat( + DateTimeImmutable::ATOM, + $request->query->get('dateFrom') + )) { + throw new BadRequestHttpException('dateFrom not parsable'); + } + + if (!$request->query->has('dateTo')) { + throw new BadRequestHttpException('You must provide a dateTo parameter'); + } + + if (false === $dateTo = DateTimeImmutable::createFromFormat( + DateTimeImmutable::ATOM, + $request->query->get('dateTo') + )) { + throw new BadRequestHttpException('dateTo not parsable'); + } + + $total = $this->calendarRepository->countByUser($user, $dateFrom, $dateTo); + $paginator = $this->getPaginatorFactory()->create($total); + $ranges = $this->calendarRepository->findByUser( + $user, + $dateFrom, + $dateTo, + $paginator->getItemsPerPage(), + $paginator->getCurrentPageFirstItemNumber() + ); + + $collection = new Collection($ranges, $paginator); + + return $this->json($collection, Response::HTTP_OK, [], ['groups' => ['calendar:light']]); + } + protected function customizeQuery(string $action, Request $request, $qb): void { if ($request->query->has('main_user')) { diff --git a/src/Bundle/ChillCalendarBundle/Controller/CalendarController.php b/src/Bundle/ChillCalendarBundle/Controller/CalendarController.php index b9bdbc79f..db5f2ec16 100644 --- a/src/Bundle/ChillCalendarBundle/Controller/CalendarController.php +++ b/src/Bundle/ChillCalendarBundle/Controller/CalendarController.php @@ -13,53 +13,87 @@ namespace Chill\CalendarBundle\Controller; use Chill\CalendarBundle\Entity\Calendar; use Chill\CalendarBundle\Form\CalendarType; -use Chill\CalendarBundle\Repository\CalendarRepository; +use Chill\CalendarBundle\RemoteCalendar\Connector\RemoteCalendarConnectorInterface; +use Chill\CalendarBundle\Repository\CalendarACLAwareRepositoryInterface; +use Chill\CalendarBundle\Security\Voter\CalendarVoter; +use Chill\DocGeneratorBundle\Repository\DocGeneratorTemplateRepository; use Chill\MainBundle\Entity\User; use Chill\MainBundle\Pagination\PaginatorFactory; -use Chill\MainBundle\Security\Authorization\AuthorizationHelper; +use Chill\MainBundle\Repository\UserRepositoryInterface; +use Chill\MainBundle\Templating\Listing\FilterOrderHelper; +use Chill\MainBundle\Templating\Listing\FilterOrderHelperFactoryInterface; +use Chill\MainBundle\Templating\TranslatableStringHelperInterface; use Chill\PersonBundle\Entity\AccompanyingPeriod; use Chill\PersonBundle\Entity\Person; +use Chill\PersonBundle\Repository\AccompanyingPeriodRepository; +use Chill\PersonBundle\Repository\PersonRepository; +use Chill\PersonBundle\Security\Authorization\AccompanyingPeriodVoter; +use Chill\PersonBundle\Security\Authorization\PersonVoter; use Chill\ThirdPartyBundle\Entity\ThirdParty; +use DateTimeImmutable; use Exception; +use http\Exception\UnexpectedValueException; use Psr\Log\LoggerInterface; +use RuntimeException; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; -use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\Form\Extension\Core\Type\SubmitType; use Symfony\Component\Form\Form; use Symfony\Component\Form\FormInterface; +use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException; use Symfony\Component\Routing\Annotation\Route; use Symfony\Component\Serializer\SerializerInterface; class CalendarController extends AbstractController { - protected AuthorizationHelper $authorizationHelper; + private AccompanyingPeriodRepository $accompanyingPeriodRepository; - protected EventDispatcherInterface $eventDispatcher; + private CalendarACLAwareRepositoryInterface $calendarACLAwareRepository; - protected LoggerInterface $logger; + private DocGeneratorTemplateRepository $docGeneratorTemplateRepository; - protected PaginatorFactory $paginator; + private FilterOrderHelperFactoryInterface $filterOrderHelperFactory; - protected SerializerInterface $serializer; + private LoggerInterface $logger; - private CalendarRepository $calendarRepository; + private PaginatorFactory $paginator; + + private PersonRepository $personRepository; + + private RemoteCalendarConnectorInterface $remoteCalendarConnector; + + private SerializerInterface $serializer; + + private TranslatableStringHelperInterface $translatableStringHelper; + + private UserRepositoryInterface $userRepository; public function __construct( - EventDispatcherInterface $eventDispatcher, - AuthorizationHelper $authorizationHelper, + CalendarACLAwareRepositoryInterface $calendarACLAwareRepository, + DocGeneratorTemplateRepository $docGeneratorTemplateRepository, + FilterOrderHelperFactoryInterface $filterOrderHelperFactory, LoggerInterface $logger, - SerializerInterface $serializer, PaginatorFactory $paginator, - CalendarRepository $calendarRepository + RemoteCalendarConnectorInterface $remoteCalendarConnector, + SerializerInterface $serializer, + TranslatableStringHelperInterface $translatableStringHelper, + PersonRepository $personRepository, + AccompanyingPeriodRepository $accompanyingPeriodRepository, + UserRepositoryInterface $userRepository ) { - $this->eventDispatcher = $eventDispatcher; - $this->authorizationHelper = $authorizationHelper; + $this->calendarACLAwareRepository = $calendarACLAwareRepository; + $this->docGeneratorTemplateRepository = $docGeneratorTemplateRepository; + $this->filterOrderHelperFactory = $filterOrderHelperFactory; $this->logger = $logger; - $this->serializer = $serializer; $this->paginator = $paginator; - $this->calendarRepository = $calendarRepository; + $this->remoteCalendarConnector = $remoteCalendarConnector; + $this->serializer = $serializer; + $this->translatableStringHelper = $translatableStringHelper; + $this->personRepository = $personRepository; + $this->accompanyingPeriodRepository = $accompanyingPeriodRepository; + $this->userRepository = $userRepository; } /** @@ -67,27 +101,23 @@ class CalendarController extends AbstractController * * @Route("/{_locale}/calendar/{id}/delete", name="chill_calendar_calendar_delete") */ - public function deleteAction(Request $request, int $id) + public function deleteAction(Request $request, Calendar $entity) { - $view = null; $em = $this->getDoctrine()->getManager(); - [$user, $accompanyingPeriod] = $this->getEntity($request); + [$person, $accompanyingPeriod] = [$entity->getPerson(), $entity->getAccompanyingPeriod()]; if ($accompanyingPeriod instanceof AccompanyingPeriod) { $view = '@ChillCalendar/Calendar/confirm_deleteByAccompanyingCourse.html.twig'; - } elseif ($user instanceof User) { - $view = '@ChillCalendar/Calendar/confirm_deleteByUser.html.twig'; + $redirectRoute = $this->generateUrl('chill_calendar_calendar_list_by_period', ['id' => $accompanyingPeriod->getId()]); + } elseif ($person instanceof Person) { + $view = '@ChillCalendar/Calendar/confirm_deleteByPerson.html.twig'; + $redirectRoute = $this->generateUrl('chill_calendar_calendar_list_by_person', ['id' => $person->getId()]); + } else { + throw new RuntimeException('nor person or accompanying period'); } - /** @var Calendar $entity */ - $entity = $em->getRepository(\Chill\CalendarBundle\Entity\Calendar::class)->find($id); - - if (!$entity) { - throw $this->createNotFoundException('Unable to find Calendar entity.'); - } - - $form = $this->createDeleteForm($id, $user, $accompanyingPeriod); + $form = $this->createDeleteForm($entity); if ($request->getMethod() === Request::METHOD_DELETE) { $form->handleRequest($request); @@ -104,20 +134,15 @@ class CalendarController extends AbstractController $this->addFlash('success', $this->get('translator') ->trans('The calendar item has been successfully removed.')); - $params = $this->buildParamsToUrl($user, $accompanyingPeriod); - - return $this->redirectToRoute('chill_calendar_calendar_list', $params); + return new RedirectResponse($redirectRoute); } } - if (null === $view) { - throw $this->createNotFoundException('Template not found'); - } - return $this->render($view, [ 'calendar' => $entity, 'delete_form' => $form->createView(), 'accompanyingCourse' => $accompanyingPeriod, + 'person' => $person, ]); } @@ -126,104 +151,168 @@ class CalendarController extends AbstractController * * @Route("/{_locale}/calendar/calendar/{id}/edit", name="chill_calendar_calendar_edit") */ - public function editAction(int $id, Request $request): Response + public function editAction(Calendar $entity, Request $request): Response { - $view = null; + $this->denyAccessUnlessGranted(CalendarVoter::EDIT, $entity); + + if (!$this->remoteCalendarConnector->isReady()) { + return $this->remoteCalendarConnector->getMakeReadyResponse($request->getUri()); + } + $em = $this->getDoctrine()->getManager(); - [$user, $accompanyingPeriod] = $this->getEntity($request); + [$person, $accompanyingPeriod] = [$entity->getPerson(), $entity->getAccompanyingPeriod()]; if ($accompanyingPeriod instanceof AccompanyingPeriod) { $view = '@ChillCalendar/Calendar/editByAccompanyingCourse.html.twig'; - } elseif ($user instanceof User) { - $view = '@ChillCalendar/Calendar/editByUser.html.twig'; + $redirectRoute = $this->generateUrl('chill_calendar_calendar_list_by_period', ['id' => $accompanyingPeriod->getId()]); + } elseif ($person instanceof Person) { + $view = '@ChillCalendar/Calendar/editByPerson.html.twig'; + $redirectRoute = $this->generateUrl('chill_calendar_calendar_list_by_person', ['id' => $person->getId()]); + } else { + throw new RuntimeException('no person nor accompanying period'); } - $entity = $em->getRepository(\Chill\CalendarBundle\Entity\Calendar::class)->find($id); + $form = $this->createForm(CalendarType::class, $entity) + ->add('save', SubmitType::class); - if (!$entity) { - throw $this->createNotFoundException('Unable to find Calendar entity.'); + $form->add('save_and_upload_doc', SubmitType::class); + $templates = $this->docGeneratorTemplateRepository->findByEntity(Calendar::class); + + foreach ($templates as $template) { + $form->add('save_and_generate_doc_' . $template->getId(), SubmitType::class, [ + 'label' => $this->translatableStringHelper->localize($template->getName()), + ]); } - $form = $this->createForm(CalendarType::class, $entity, [ - 'accompanyingPeriod' => $accompanyingPeriod, - ])->handleRequest($request); + $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { - $em->persist($entity); $em->flush(); $this->addFlash('success', $this->get('translator')->trans('Success : calendar item updated!')); - $params = $this->buildParamsToUrl($user, $accompanyingPeriod); + if ($form->get('save_and_upload_doc')->isClicked()) { + return $this->redirectToRoute('chill_calendar_calendardoc_new', ['id' => $entity->getId()]); + } - return $this->redirectToRoute('chill_calendar_calendar_list', $params); + foreach ($templates as $template) { + if ($form->get('save_and_generate_doc_' . $template->getId())->isClicked()) { + return $this->redirectToRoute('chill_docgenerator_generate_from_template', [ + 'entityClassName' => Calendar::class, + 'entityId' => $entity->getId(), + 'template' => $template->getId(), + ]); + } + } + + return new RedirectResponse($redirectRoute); } if ($form->isSubmitted() && !$form->isValid()) { $this->addFlash('error', $this->get('translator')->trans('This form contains errors')); } - $deleteForm = $this->createDeleteForm($id, $user, $accompanyingPeriod); - - if (null === $view) { - throw $this->createNotFoundException('Template not found'); - } - $entity_array = $this->serializer->normalize($entity, 'json', ['groups' => 'read']); return $this->render($view, [ 'entity' => $entity, 'form' => $form->createView(), - 'delete_form' => $deleteForm->createView(), - 'accompanyingCourse' => $accompanyingPeriod, - 'user' => $user, + 'accompanyingCourse' => $entity->getAccompanyingPeriod(), + 'person' => $entity->getPerson(), 'entity_json' => $entity_array, + 'templates' => $templates, ]); } /** * Lists all Calendar entities. * - * @Route("/{_locale}/calendar/calendar/", name="chill_calendar_calendar_list") + * @Route("/{_locale}/calendar/calendar/by-period/{id}", name="chill_calendar_calendar_list_by_period") */ - public function listAction(Request $request): Response + public function listActionByCourse(AccompanyingPeriod $accompanyingPeriod): Response { - $view = null; + $this->denyAccessUnlessGranted(CalendarVoter::SEE, $accompanyingPeriod); - [$user, $accompanyingPeriod] = $this->getEntity($request); + $filterOrder = $this->buildListFilterOrder(); + ['from' => $from, 'to' => $to] = $filterOrder->getDateRangeData('startDate'); - if ($user instanceof User) { - $calendarItems = $this->calendarRepository->findByUser($user); + $total = $this->calendarACLAwareRepository + ->countByAccompanyingPeriod($accompanyingPeriod, $from, $to); + $paginator = $this->paginator->create($total); + $calendarItems = $this->calendarACLAwareRepository->findByAccompanyingPeriod( + $accompanyingPeriod, + $from, + $to, + ['startDate' => 'DESC'], + $paginator->getCurrentPageFirstItemNumber(), + $paginator->getItemsPerPage() + ); - $view = '@ChillCalendar/Calendar/listByUser.html.twig'; + return $this->render('@ChillCalendar/Calendar/listByAccompanyingCourse.html.twig', [ + 'calendarItems' => $calendarItems, + 'accompanyingCourse' => $accompanyingPeriod, + 'paginator' => $paginator, + 'filterOrder' => $filterOrder, + 'nbIgnored' => $this->calendarACLAwareRepository->countIgnoredByAccompanyingPeriod($accompanyingPeriod, $from, $to), + 'templates' => $this->docGeneratorTemplateRepository->findByEntity(Calendar::class), + ]); + } - return $this->render($view, [ - 'calendarItems' => $calendarItems, - 'user' => $user, - ]); + /** + * Lists all Calendar entities on a person. + * + * @Route("/{_locale}/calendar/calendar/by-person/{id}", name="chill_calendar_calendar_list_by_person") + */ + public function listActionByPerson(Person $person): Response + { + $this->denyAccessUnlessGranted(CalendarVoter::SEE, $person); + + $filterOrder = $this->buildListFilterOrder(); + ['from' => $from, 'to' => $to] = $filterOrder->getDateRangeData('startDate'); + + $total = $this->calendarACLAwareRepository + ->countByPerson($person, $from, $to); + $paginator = $this->paginator->create($total); + $calendarItems = $this->calendarACLAwareRepository->findByPerson( + $person, + $from, + $to, + ['startDate' => 'DESC'], + $paginator->getCurrentPageFirstItemNumber(), + $paginator->getItemsPerPage() + ); + + return $this->render('@ChillCalendar/Calendar/listByPerson.html.twig', [ + 'calendarItems' => $calendarItems, + 'person' => $person, + 'paginator' => $paginator, + 'filterOrder' => $filterOrder, + 'nbIgnored' => $this->calendarACLAwareRepository->countIgnoredByPerson($person, $from, $to), + 'templates' => $this->docGeneratorTemplateRepository->findByEntity(Calendar::class), + ]); + } + + /** + * @Route("/{_locale}/calendar/calendar/my", name="chill_calendar_calendar_list_my") + */ + public function myCalendar(Request $request): Response + { + $this->denyAccessUnlessGranted('ROLE_USER'); + + if (!$this->remoteCalendarConnector->isReady()) { + return $this->remoteCalendarConnector->getMakeReadyResponse($request->getUri()); } - if ($accompanyingPeriod instanceof AccompanyingPeriod) { - $total = $this->calendarRepository->countByAccompanyingPeriod($accompanyingPeriod); - $paginator = $this->paginator->create($total); - $calendarItems = $this->calendarRepository->findBy( - ['accompanyingPeriod' => $accompanyingPeriod], - ['startDate' => 'DESC'], - $paginator->getItemsPerPage(), - $paginator->getCurrentPageFirstItemNumber() - ); - - $view = '@ChillCalendar/Calendar/listByAccompanyingCourse.html.twig'; - - return $this->render($view, [ - 'calendarItems' => $calendarItems, - 'accompanyingCourse' => $accompanyingPeriod, - 'paginator' => $paginator, - ]); + if (!$this->getUser() instanceof User) { + throw new UnauthorizedHttpException('you are not an user'); } - throw new Exception('Unable to list actions.'); + $view = '@ChillCalendar/Calendar/listByUser.html.twig'; + + return $this->render($view, [ + 'user' => $this->getUser(), + ]); } /** @@ -233,33 +322,48 @@ class CalendarController extends AbstractController */ public function newAction(Request $request): Response { + if (!$this->remoteCalendarConnector->isReady()) { + return $this->remoteCalendarConnector->getMakeReadyResponse($request->getUri()); + } + $view = null; $em = $this->getDoctrine()->getManager(); - [$user, $accompanyingPeriod] = $this->getEntity($request); + [$person, $accompanyingPeriod] = $this->getEntity($request); + + $entity = new Calendar(); + + $redirectRoute = ''; if ($accompanyingPeriod instanceof AccompanyingPeriod) { $view = '@ChillCalendar/Calendar/newByAccompanyingCourse.html.twig'; - } - // elseif ($user instanceof User) { - // $view = '@ChillCalendar/Calendar/newUser.html.twig'; - // } - - $entity = new Calendar(); - $entity->setUser($this->getUser()); - $entity->setStatus($entity::STATUS_VALID); - - // if ($user instanceof User) { - // $entity->setPerson($user); - // } - - if ($accompanyingPeriod instanceof AccompanyingPeriod) { $entity->setAccompanyingPeriod($accompanyingPeriod); + $redirectRoute = $this->generateUrl('chill_calendar_calendar_list_by_period', ['id' => $accompanyingPeriod->getId()]); + } elseif (null !== $person) { + $view = '@ChillCalendar/Calendar/newByPerson.html.twig'; + $entity->setPerson($person)->addPerson($person); + $redirectRoute = $this->generateUrl('chill_calendar_calendar_list_by_person', ['id' => $person->getId()]); } - $form = $this->createForm(CalendarType::class, $entity, [ - 'accompanyingPeriod' => $accompanyingPeriod, - ])->handleRequest($request); + if ($request->query->has('mainUser')) { + $entity->setMainUser($this->userRepository->find($request->query->getInt('mainUser'))); + } + + $this->denyAccessUnlessGranted(CalendarVoter::CREATE, $entity); + + $form = $this->createForm(CalendarType::class, $entity) + ->add('save', SubmitType::class); + + $templates = $this->docGeneratorTemplateRepository->findByEntity(Calendar::class); + $form->add('save_and_upload_doc', SubmitType::class); + + foreach ($templates as $template) { + $form->add('save_and_generate_doc_' . $template->getId(), SubmitType::class, [ + 'label' => $this->translatableStringHelper->localize($template->getName()), + ]); + } + + $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { $em->persist($entity); @@ -267,9 +371,25 @@ class CalendarController extends AbstractController $this->addFlash('success', $this->get('translator')->trans('Success : calendar item created!')); - $params = $this->buildParamsToUrl($user, $accompanyingPeriod); + if ($form->get('save_and_upload_doc')->isClicked()) { + return $this->redirectToRoute('chill_calendar_calendardoc_new', ['id' => $entity->getId()]); + } - return $this->redirectToRoute('chill_calendar_calendar_list', $params); + foreach ($templates as $template) { + if ($form->get('save_and_generate_doc_' . $template->getId())->isClicked()) { + return $this->redirectToRoute('chill_docgenerator_generate_from_template', [ + 'entityClassName' => Calendar::class, + 'entityId' => $entity->getId(), + 'template' => $template->getId(), + ]); + } + } + + if ('' !== $redirectRoute) { + return new RedirectResponse($redirectRoute); + } + + throw new UnexpectedValueException('No person id or accompanying period id was given'); } if ($form->isSubmitted() && !$form->isValid()) { @@ -283,11 +403,13 @@ class CalendarController extends AbstractController $entity_array = $this->serializer->normalize($entity, 'json', ['groups' => 'read']); return $this->render($view, [ - 'user' => $user, + 'context' => $entity->getContext(), + 'person' => $person, 'accompanyingCourse' => $accompanyingPeriod, 'entity' => $entity, 'form' => $form->createView(), 'entity_json' => $entity_array, + 'templates' => $templates, ]); } @@ -298,6 +420,7 @@ class CalendarController extends AbstractController */ public function showAction(Request $request, int $id): Response { + throw new Exception('not implemented'); $view = null; $em = $this->getDoctrine()->getManager(); @@ -349,7 +472,7 @@ class CalendarController extends AbstractController 'professionalsId' => $professionalsId, 'date' => $entity->getStartDate()->format('Y-m-d'), 'durationTime' => $durationTimeInMinutes, - 'location' => $entity->getLocation()->getId(), + 'location' => $entity->getLocation() ? $entity->getLocation()->getId() : null, 'comment' => $entity->getComment()->getComment(), ]; @@ -362,6 +485,60 @@ class CalendarController extends AbstractController ]); } + /** + * @Route("/{_locale}/calendar/calendar/{id}/to-activity", name="chill_calendar_calendar_to_activity") + */ + public function toActivity(Request $request, Calendar $calendar): RedirectResponse + { + $this->denyAccessUnlessGranted(CalendarVoter::SEE, $calendar); + + $personsId = array_map( + static fn (Person $p): int => $p->getId(), + $calendar->getPersons()->toArray() + ); + + $professionalsId = array_map( + static fn (ThirdParty $thirdParty): ?int => $thirdParty->getId(), + $calendar->getProfessionals()->toArray() + ); + + $usersId = array_map( + static fn (User $user): ?int => $user->getId(), + array_merge($calendar->getUsers()->toArray(), [$calendar->getMainUser()]) + ); + + $durationTime = $calendar->getEndDate()->diff($calendar->getStartDate()); + $durationTimeInMinutes = $durationTime->days * 1440 + $durationTime->h * 60 + $durationTime->i; + + $activityData = [ + 'calendarId' => $calendar->getId(), + 'personsId' => $personsId, + 'professionalsId' => $professionalsId, + 'usersId' => $usersId, + 'date' => $calendar->getStartDate()->format('Y-m-d'), + 'durationTime' => $durationTimeInMinutes, + 'location' => $calendar->getLocation() ? $calendar->getLocation()->getId() : null, + 'comment' => $calendar->getComment()->getComment(), + ]; + + return $this->redirectToRoute( + 'chill_activity_activity_new', + [ + 'accompanying_period_id' => $calendar->getAccompanyingPeriod()->getId(), + 'activityData' => $activityData, + 'returnPath' => $request->query->get('returnPath', null), + ] + ); + } + + private function buildListFilterOrder(): FilterOrderHelper + { + $filterOrder = $this->filterOrderHelperFactory->create(self::class); + $filterOrder->addDateRange('startDate', null, new DateTimeImmutable('3 days ago'), null); + + return $filterOrder->build(); + } + private function buildParamsToUrl(?User $user, ?AccompanyingPeriod $accompanyingPeriod): array { $params = []; @@ -371,7 +548,7 @@ class CalendarController extends AbstractController } if (null !== $accompanyingPeriod) { - $params['accompanying_period_id'] = $accompanyingPeriod->getId(); + $params['id'] = $accompanyingPeriod->getId(); } return $params; @@ -380,49 +557,45 @@ class CalendarController extends AbstractController /** * Creates a form to delete a Calendar entity by id. */ - private function createDeleteForm(int $id, ?User $user, ?AccompanyingPeriod $accompanyingPeriod): FormInterface + private function createDeleteForm(Calendar $calendar): FormInterface { - $params = $this->buildParamsToUrl($user, $accompanyingPeriod); - $params['id'] = $id; - return $this->createFormBuilder() - ->setAction($this->generateUrl('chill_calendar_calendar_delete', $params)) + ->setAction($this->generateUrl('chill_calendar_calendar_delete', ['id' => $calendar->getId()])) ->setMethod('DELETE') ->add('submit', SubmitType::class, ['label' => 'Delete']) ->getForm(); } + /** + * @return array{0: ?Person, 1: ?AccompanyingPeriod} + */ private function getEntity(Request $request): array { $em = $this->getDoctrine()->getManager(); - $user = $accompanyingPeriod = null; + $person = $accompanyingPeriod = null; - if ($request->query->has('user_id')) { - $user_id = $request->get('user_id'); - $user = $em->getRepository(User::class)->find($user_id); + if ($request->query->has('person_id')) { + $person = $this->personRepository->find($request->query->getInt('person_id')); - if (null === $user) { - throw $this->createNotFoundException('User not found'); + if (null === $person) { + throw $this->createNotFoundException('Person not found'); } - // TODO Add permission - // $this->denyAccessUnlessGranted('CHILL_PERSON_SEE', $user); + $this->denyAccessUnlessGranted(PersonVoter::SEE, $person); } elseif ($request->query->has('accompanying_period_id')) { - $accompanying_period_id = $request->get('accompanying_period_id'); - $accompanyingPeriod = $em->getRepository(AccompanyingPeriod::class)->find($accompanying_period_id); + $accompanyingPeriod = $this->accompanyingPeriodRepository->find($request->query->getInt('accompanying_period_id')); if (null === $accompanyingPeriod) { throw $this->createNotFoundException('Accompanying Period not found'); } - // TODO Add permission - // $this->denyAccessUnlessGranted('CHILL_PERSON_SEE', $person); + $this->denyAccessUnlessGranted(AccompanyingPeriodVoter::SEE, $accompanyingPeriod); } else { throw $this->createNotFoundException('Person or Accompanying Period not found'); } return [ - $user, $accompanyingPeriod, + $person, $accompanyingPeriod, ]; } } diff --git a/src/Bundle/ChillCalendarBundle/Controller/CalendarDocController.php b/src/Bundle/ChillCalendarBundle/Controller/CalendarDocController.php new file mode 100644 index 000000000..74f8d93e9 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Controller/CalendarDocController.php @@ -0,0 +1,237 @@ +docGeneratorTemplateRepository = $docGeneratorTemplateRepository; + $this->engine = $engine; + $this->entityManager = $entityManager; + $this->formFactory = $formFactory; + $this->security = $security; + $this->urlGenerator = $urlGenerator; + } + + /** + * @Route("/{_locale}/calendar/calendar-doc/{id}/new", name="chill_calendar_calendardoc_new") + */ + public function create(Calendar $calendar, Request $request): Response + { + $calendarDoc = (new CalendarDoc($calendar, null))->setCalendar($calendar); + + if (!$this->security->isGranted(CalendarDocVoter::EDIT, $calendarDoc)) { + throw new AccessDeniedHttpException(); + } + + // set variables + switch ($calendarDoc->getCalendar()->getContext()) { + case 'accompanying_period': + $view = '@ChillCalendar/CalendarDoc/new_accompanying_period.html.twig'; + $returnRoute = 'chill_calendar_calendar_list_by_period'; + $returnParams = ['id' => $calendarDoc->getCalendar()->getAccompanyingPeriod()->getId()]; + + break; + + case 'person': + $view = '@ChillCalendar/CalendarDoc/new_person.html.twig'; + $returnRoute = 'chill_calendar_calendar_list_by_person'; + $returnParams = ['id' => $calendarDoc->getCalendar()->getPerson()->getId()]; + + break; + + default: + throw new UnexpectedValueException('Unsupported context'); + } + + $calendarDocDTO = new CalendarDoc\CalendarDocCreateDTO(); + $form = $this->formFactory->create(CalendarDocCreateType::class, $calendarDocDTO); + + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $calendarDoc->createFromDTO($calendarDocDTO); + + $this->entityManager->persist($calendarDoc); + $this->entityManager->flush(); + + if ($request->query->has('returnPath')) { + return new RedirectResponse($request->query->get('returnPath')); + } + + return new RedirectResponse( + $this->urlGenerator->generate($returnRoute, $returnParams) + ); + } + + return new Response( + $this->engine->render( + $view, + ['calendar_doc' => $calendarDoc, 'form' => $form->createView()] + ) + ); + } + + /** + * @Route("/{_locale}/calendar/calendar-doc/{id}/delete", name="chill_calendar_calendardoc_delete") + */ + public function delete(CalendarDoc $calendarDoc, Request $request): Response + { + if (!$this->security->isGranted(CalendarDocVoter::EDIT, $calendarDoc)) { + throw new AccessDeniedHttpException('Not authorized to delete document'); + } + + switch ($calendarDoc->getCalendar()->getContext()) { + case 'accompanying_period': + $view = '@ChillCalendar/CalendarDoc/delete_accompanying_period.html.twig'; + $returnRoute = 'chill_calendar_calendar_list_by_period'; + $returnParams = ['id' => $calendarDoc->getCalendar()->getAccompanyingPeriod()->getId()]; + + break; + + case 'person': + $view = '@ChillCalendar/CalendarDoc/delete_person.html.twig'; + $returnRoute = 'chill_calendar_calendar_list_by_person'; + $returnParams = ['id' => $calendarDoc->getCalendar()->getPerson()->getId()]; + + break; + } + + $form = $this->formFactory->createBuilder() + ->add('submit', SubmitType::class, [ + 'label' => 'Delete', + ]) + ->getForm(); + + $form->handleRequest($request); + + if ($form->isSubmitted()) { + $this->entityManager->remove($calendarDoc); + $this->entityManager->flush(); + + if ($request->query->has('returnPath')) { + return new RedirectResponse($request->query->get('returnPath')); + } + + return new RedirectResponse( + $this->urlGenerator->generate($returnRoute, $returnParams) + ); + } + + return new Response( + $this->engine->render( + $view, + [ + 'calendar_doc' => $calendarDoc, + 'form' => $form->createView(), + ] + ) + ); + } + + /** + * @Route("/{_locale}/calendar/calendar-doc/{id}/edit", name="chill_calendar_calendardoc_edit") + */ + public function edit(CalendarDoc $calendarDoc, Request $request): Response + { + if (!$this->security->isGranted(CalendarDocVoter::EDIT, $calendarDoc)) { + throw new AccessDeniedHttpException(); + } + + // set variables + switch ($calendarDoc->getCalendar()->getContext()) { + case 'accompanying_period': + $view = '@ChillCalendar/CalendarDoc/edit_accompanying_period.html.twig'; + $returnRoute = 'chill_calendar_calendar_list_by_period'; + $returnParams = ['id' => $calendarDoc->getCalendar()->getAccompanyingPeriod()->getId()]; + + break; + + case 'person': + $view = '@ChillCalendar/CalendarDoc/edit_person.html.twig'; + $returnRoute = 'chill_calendar_calendar_list_by_person'; + $returnParams = ['id' => $calendarDoc->getCalendar()->getPerson()->getId()]; + + break; + + default: + throw new UnexpectedValueException('Unsupported context'); + } + + $calendarDocEditDTO = new CalendarDoc\CalendarDocEditDTO($calendarDoc); + $form = $this->formFactory->create(CalendarDocEditType::class, $calendarDocEditDTO); + + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $calendarDoc->editFromDTO($calendarDocEditDTO); + + $this->entityManager->flush(); + + if ($request->query->has('returnPath')) { + return new RedirectResponse($request->query->get('returnPath')); + } + + return new RedirectResponse( + $this->urlGenerator->generate($returnRoute, $returnParams) + ); + } + + return new Response( + $this->engine->render( + $view, + ['calendar_doc' => $calendarDoc, 'form' => $form->createView()] + ) + ); + } +} diff --git a/src/Bundle/ChillCalendarBundle/Controller/CalendarRangeAPIController.php b/src/Bundle/ChillCalendarBundle/Controller/CalendarRangeAPIController.php index 989a6a704..383e24efc 100644 --- a/src/Bundle/ChillCalendarBundle/Controller/CalendarRangeAPIController.php +++ b/src/Bundle/ChillCalendarBundle/Controller/CalendarRangeAPIController.php @@ -11,38 +11,72 @@ declare(strict_types=1); namespace Chill\CalendarBundle\Controller; +use Chill\CalendarBundle\Repository\CalendarRangeRepository; use Chill\MainBundle\CRUD\Controller\ApiController; +use Chill\MainBundle\Entity\User; +use Chill\MainBundle\Serializer\Model\Collection; +use DateTimeImmutable; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\Routing\Annotation\Route; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; -use function count; +use Symfony\Component\Routing\Annotation\Route; class CalendarRangeAPIController extends ApiController { - /** - * @Route("/api/1.0/calendar/calendar-range-available.{_format}", name="chill_api_single_calendar_range_available") - */ - public function availableRanges(Request $request, string $_format): JsonResponse + private CalendarRangeRepository $calendarRangeRepository; + + public function __construct(CalendarRangeRepository $calendarRangeRepository) { - $em = $this->getDoctrine()->getManager(); + $this->calendarRangeRepository = $calendarRangeRepository; + } - $sql = 'SELECT c FROM ChillCalendarBundle:CalendarRange c - WHERE NOT EXISTS (SELECT cal.id FROM ChillCalendarBundle:Calendar cal WHERE cal.calendarRange = c.id)'; + /** + * @Route("/api/1.0/calendar/calendar-range-available/{id}.{_format}", + * name="chill_api_single_calendar_range_available", + * requirements={"_format": "json"} + * ) + */ + public function availableRanges(User $user, Request $request, string $_format): JsonResponse + { + //return new JsonResponse(['ok' => true], 200, [], false); + $this->denyAccessUnlessGranted('ROLE_USER'); - if ($request->query->has('user')) { - $user = $request->query->get('user'); - $sql = $sql . ' AND c.user = :user'; - $query = $em->createQuery($sql) - ->setParameter('user', $user); - } else { - $query = $em->createQuery($sql); + if (!$request->query->has('dateFrom')) { + throw new BadRequestHttpException('You must provide a dateFrom parameter'); } - $results = $query->getResult(); + if (false === $dateFrom = DateTimeImmutable::createFromFormat( + DateTimeImmutable::ATOM, + $request->query->get('dateFrom') + )) { + throw new BadRequestHttpException('dateFrom not parsable'); + } - return $this->json(['count' => count($results), 'results' => $results], Response::HTTP_OK, [], ['groups' => ['read']]); - //TODO use also the paginator, eg return $this->serializeCollection('get', $request, $_format, $paginator, $results); + if (!$request->query->has('dateTo')) { + throw new BadRequestHttpException('You must provide a dateTo parameter'); + } + + if (false === $dateTo = DateTimeImmutable::createFromFormat( + DateTimeImmutable::ATOM, + $request->query->get('dateTo') + )) { + throw new BadRequestHttpException('dateTo not parsable'); + } + + $total = $this->calendarRangeRepository->countByAvailableRangesForUser($user, $dateFrom, $dateTo); + $paginator = $this->getPaginatorFactory()->create($total); + $ranges = $this->calendarRangeRepository->findByAvailableRangesForUser( + $user, + $dateFrom, + $dateTo, + $paginator->getItemsPerPage(), + $paginator->getCurrentPageFirstItemNumber() + ); + + $collection = new Collection($ranges, $paginator); + + return $this->json($collection, Response::HTTP_OK, [], ['groups' => ['read']]); } } diff --git a/src/Bundle/ChillCalendarBundle/Controller/InviteApiController.php b/src/Bundle/ChillCalendarBundle/Controller/InviteApiController.php new file mode 100644 index 000000000..3016e1660 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Controller/InviteApiController.php @@ -0,0 +1,83 @@ +entityManager = $entityManager; + $this->messageBus = $messageBus; + $this->security = $security; + } + + /** + * Give an answer to a calendar invite. + * + * @Route("/api/1.0/calendar/calendar/{id}/answer/{answer}.json", methods={"post"}) + */ + public function answer(Calendar $calendar, string $answer): Response + { + $user = $this->security->getUser(); + + if (!$user instanceof User) { + throw new AccessDeniedHttpException('not a regular user'); + } + + if (null === $invite = $calendar->getInviteForUser($user)) { + throw new AccessDeniedHttpException('not invited to this calendar'); + } + + if (!$this->security->isGranted(InviteVoter::ANSWER, $invite)) { + throw new AccessDeniedHttpException('not allowed to answer on this invitation'); + } + + if (!in_array($answer, Invite::STATUSES, true)) { + throw new BadRequestHttpException('answer not valid'); + } + + $invite->setStatus($answer); + $this->entityManager->flush(); + + $this->messageBus->dispatch(new InviteUpdateMessage($invite, $this->security->getUser())); + + return new JsonResponse(null, Response::HTTP_ACCEPTED, [], false); + } +} diff --git a/src/Bundle/ChillCalendarBundle/Controller/RemoteCalendarConnectAzureController.php b/src/Bundle/ChillCalendarBundle/Controller/RemoteCalendarConnectAzureController.php new file mode 100644 index 000000000..9352e42fa --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Controller/RemoteCalendarConnectAzureController.php @@ -0,0 +1,76 @@ +clientRegistry = $clientRegistry; + $this->MSGraphTokenStorage = $MSGraphTokenStorage; + } + + /** + * @Route("/{_locale}/connect/azure", name="chill_calendar_remote_connect_azure") + */ + public function connectAzure(Request $request): Response + { + $request->getSession()->set('azure_return_path', $request->query->get('returnPath', '/')); + + return $this->clientRegistry + ->getClient('azure') // key used in config/packages/knpu_oauth2_client.yaml + ->redirect(['https://graph.microsoft.com/.default', 'offline_access'], []); + } + + /** + * @Route("/connect/azure/check", name="chill_calendar_remote_connect_azure_check") + */ + public function connectAzureCheck(Request $request): Response + { + /** @var Azure $client */ + $client = $this->clientRegistry->getClient('azure'); + + try { + /** @var AccessToken $token */ + $token = $client->getAccessToken([]); + + $this->MSGraphTokenStorage->setToken($token); + } catch (IdentityProviderException $e) { + throw $e; + } + + return new RedirectResponse($request->getSession()->remove('azure_return_path', '/')); + } +} diff --git a/src/Bundle/ChillCalendarBundle/Controller/RemoteCalendarMSGraphSyncController.php b/src/Bundle/ChillCalendarBundle/Controller/RemoteCalendarMSGraphSyncController.php new file mode 100644 index 000000000..535dda2f5 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Controller/RemoteCalendarMSGraphSyncController.php @@ -0,0 +1,61 @@ +messageBus = $messageBus; + } + + /** + * @Route("/public/incoming-hook/calendar/msgraph/events/{userId}", name="chill_calendar_remote_msgraph_incoming_webhook_events", + * methods={"POST"}) + */ + public function webhookCalendarReceiver(int $userId, Request $request): Response + { + if ($request->query->has('validationToken')) { + return new Response($request->query->get('validationToken'), Response::HTTP_OK, [ + 'content-type' => 'text/plain', + ]); + } + + try { + $body = json_decode($request->getContent(), true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + throw new BadRequestHttpException('could not decode json', $e); + } + + $this->messageBus->dispatch(new MSGraphChangeNotificationMessage($body, $userId)); + + return new Response('', Response::HTTP_ACCEPTED); + } +} diff --git a/src/Bundle/ChillCalendarBundle/Controller/RemoteCalendarProxyController.php b/src/Bundle/ChillCalendarBundle/Controller/RemoteCalendarProxyController.php new file mode 100644 index 000000000..a95aa1a3f --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Controller/RemoteCalendarProxyController.php @@ -0,0 +1,114 @@ +paginatorFactory = $paginatorFactory; + $this->remoteCalendarConnector = $remoteCalendarConnector; + $this->serializer = $serializer; + } + + /** + * @Route("api/1.0/calendar/proxy/calendar/by-user/{id}/events") + */ + public function listEventForCalendar(User $user, Request $request): Response + { + if (!$request->query->has('dateFrom')) { + throw new BadRequestHttpException('You must provide a dateFrom parameter'); + } + + if (false === $dateFrom = DateTimeImmutable::createFromFormat( + DateTimeImmutable::ATOM, + $request->query->get('dateFrom') + )) { + throw new BadRequestHttpException('dateFrom not parsable'); + } + + if (!$request->query->has('dateTo')) { + throw new BadRequestHttpException('You must provide a dateTo parameter'); + } + + if (false === $dateTo = DateTimeImmutable::createFromFormat( + DateTimeImmutable::ATOM, + $request->query->get('dateTo') + )) { + throw new BadRequestHttpException('dateTo not parsable'); + } + + $total = $this->remoteCalendarConnector->countEventsForUser($user, $dateFrom, $dateTo); + $paginator = $this->paginatorFactory->create($total); + + if (0 === $total) { + return new JsonResponse( + $this->serializer->serialize(new Collection([], $paginator), 'json'), + JsonResponse::HTTP_OK, + [], + true + ); + } + + $events = $this->remoteCalendarConnector->listEventsForUser( + $user, + $dateFrom, + $dateTo, + $paginator->getCurrentPageFirstItemNumber(), + $paginator->getItemsPerPage() + ); + + // in some case, we cannot paginate: we have to fetch all the items at once. We must avoid + // further requests by forcing the number of items returned. + if (count($events) > $paginator->getItemsPerPage()) { + $paginator->setItemsPerPage(count($events)); + } + + $collection = new Collection($events, $paginator); + + return new JsonResponse( + $this->serializer->serialize($collection, 'json', ['groups' => ['read']]), + JsonResponse::HTTP_OK, + [], + true + ); + } +} diff --git a/src/Bundle/ChillCalendarBundle/DataFixtures/ORM/LoadCalendarRange.php b/src/Bundle/ChillCalendarBundle/DataFixtures/ORM/LoadCalendarRange.php index d244b89c3..a1226ca6a 100644 --- a/src/Bundle/ChillCalendarBundle/DataFixtures/ORM/LoadCalendarRange.php +++ b/src/Bundle/ChillCalendarBundle/DataFixtures/ORM/LoadCalendarRange.php @@ -12,12 +12,18 @@ declare(strict_types=1); namespace Chill\CalendarBundle\DataFixtures\ORM; use Chill\CalendarBundle\Entity\CalendarRange; +use Chill\MainBundle\Entity\Address; +use Chill\MainBundle\Entity\Country; +use Chill\MainBundle\Entity\Location; +use Chill\MainBundle\Entity\LocationType; +use Chill\MainBundle\Entity\PostalCode; use Chill\MainBundle\Repository\UserRepository; use DateTimeImmutable; use Doctrine\Bundle\FixturesBundle\Fixture; use Doctrine\Bundle\FixturesBundle\FixtureGroupInterface; use Doctrine\Common\DataFixtures\OrderedFixtureInterface; use Doctrine\Persistence\ObjectManager; +use libphonenumber\PhoneNumberUtil; class LoadCalendarRange extends Fixture implements FixtureGroupInterface, OrderedFixtureInterface { @@ -49,6 +55,24 @@ class LoadCalendarRange extends Fixture implements FixtureGroupInterface, Ordere $users = $this->userRepository->findAll(); + $location = (new Location()) + ->setAddress($address = new Address()) + ->setName('Centre A') + ->setEmail('centreA@test.chill.social') + ->setLocationType($type = new LocationType()) + ->setPhonenumber1(PhoneNumberUtil::getInstance()->parse('+3287653812')); + $type->setTitle('Service'); + $address->setStreet('Rue des Épaules')->setStreetNumber('14') + ->setPostcode($postCode = new PostalCode()); + $postCode->setCode('4145')->setName('Houte-Si-Plout')->setCountry( + ($country = new Country())->setName(['fr' => 'Pays'])->setCountryCode('ZZ') + ); + $manager->persist($country); + $manager->persist($postCode); + $manager->persist($address); + $manager->persist($type); + $manager->persist($location); + $days = [ '2021-08-23', '2021-08-24', @@ -58,6 +82,8 @@ class LoadCalendarRange extends Fixture implements FixtureGroupInterface, Ordere '2021-08-31', '2021-09-01', '2021-09-02', + (new DateTimeImmutable('tomorrow'))->format('Y-m-d'), + (new DateTimeImmutable('today'))->format('Y-m-d'), ]; $hours = [ @@ -76,7 +102,8 @@ class LoadCalendarRange extends Fixture implements FixtureGroupInterface, Ordere $calendarRange = (new CalendarRange()) ->setUser($u) ->setStartDate($startEvent) - ->setEndDate($endEvent); + ->setEndDate($endEvent) + ->setLocation($location); $manager->persist($calendarRange); } diff --git a/src/Bundle/ChillCalendarBundle/DependencyInjection/ChillCalendarExtension.php b/src/Bundle/ChillCalendarBundle/DependencyInjection/ChillCalendarExtension.php index 26b3050ab..c848366bb 100644 --- a/src/Bundle/ChillCalendarBundle/DependencyInjection/ChillCalendarExtension.php +++ b/src/Bundle/ChillCalendarBundle/DependencyInjection/ChillCalendarExtension.php @@ -11,6 +11,7 @@ declare(strict_types=1); namespace Chill\CalendarBundle\DependencyInjection; +use Chill\CalendarBundle\Security\Voter\CalendarVoter; use Symfony\Component\Config\FileLocator; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface; @@ -37,15 +38,25 @@ class ChillCalendarExtension extends Extension implements PrependExtensionInterf $loader->load('services/fixtures.yml'); $loader->load('services/form.yml'); $loader->load('services/event.yml'); + $loader->load('services/remote_calendar.yaml'); + + $container->setParameter('chill_calendar', $config); + + if ($config['short_messages']['enabled']) { + $container->setParameter('chill_calendar.short_messages', $config['short_messages']); + } else { + $container->setParameter('chill_calendar.short_messages', null); + } } public function prepend(ContainerBuilder $container) { $this->preprendRoutes($container); $this->prependCruds($container); + $this->prependRoleHierarchy($container); } - protected function prependCruds(ContainerBuilder $container) + private function prependCruds(ContainerBuilder $container) { $container->prependExtensionConfig('chill_main', [ 'cruds' => [ @@ -121,7 +132,18 @@ class ChillCalendarExtension extends Extension implements PrependExtensionInterf ]); } - protected function preprendRoutes(ContainerBuilder $container) + private function prependRoleHierarchy(ContainerBuilder $container): void + { + $container->prependExtensionConfig('security', [ + 'role_hierarchy' => [ + CalendarVoter::CREATE => [CalendarVoter::SEE], + CalendarVoter::EDIT => [CalendarVoter::SEE], + CalendarVoter::DELETE => [CalendarVoter::SEE], + ], + ]); + } + + private function preprendRoutes(ContainerBuilder $container) { $container->prependExtensionConfig('chill_main', [ 'routing' => [ diff --git a/src/Bundle/ChillCalendarBundle/DependencyInjection/Configuration.php b/src/Bundle/ChillCalendarBundle/DependencyInjection/Configuration.php index 7aa373d91..127b69047 100644 --- a/src/Bundle/ChillCalendarBundle/DependencyInjection/Configuration.php +++ b/src/Bundle/ChillCalendarBundle/DependencyInjection/Configuration.php @@ -26,9 +26,22 @@ class Configuration implements ConfigurationInterface $treeBuilder = new TreeBuilder('chill_calendar'); $rootNode = $treeBuilder->getRootNode('chill_calendar'); - // Here you should define the parameters that are allowed to - // configure your bundle. See the documentation linked above for - // more information on that topic. + $rootNode + ->children() + ->arrayNode('short_messages') + ->canBeDisabled() + ->children()->end() + ->end() // end for short_messages + ->arrayNode('remote_calendars_sync')->canBeEnabled() + ->children() + ->arrayNode('microsoft_graph')->canBeEnabled() + ->children() + ->end() // end of machine_access_token + ->end() // end of microsoft_graph children + ->end() // end of array microsoft_graph + ->end() // end of children's remote_calendars_sync + ->end() // end of array remote_calendars_sync + ->end(); return $treeBuilder; } diff --git a/src/Bundle/ChillCalendarBundle/Entity/Calendar.php b/src/Bundle/ChillCalendarBundle/Entity/Calendar.php index b4bd2963e..0f112cf36 100644 --- a/src/Bundle/ChillCalendarBundle/Entity/Calendar.php +++ b/src/Bundle/ChillCalendarBundle/Entity/Calendar.php @@ -12,43 +12,88 @@ declare(strict_types=1); namespace Chill\CalendarBundle\Entity; use Chill\ActivityBundle\Entity\Activity; -use Chill\CalendarBundle\Repository\CalendarRepository; +use Chill\MainBundle\Doctrine\Model\TrackCreationInterface; +use Chill\MainBundle\Doctrine\Model\TrackCreationTrait; +use Chill\MainBundle\Doctrine\Model\TrackUpdateInterface; +use Chill\MainBundle\Doctrine\Model\TrackUpdateTrait; use Chill\MainBundle\Entity\Embeddable\CommentEmbeddable; use Chill\MainBundle\Entity\Embeddable\PrivateCommentEmbeddable; +use Chill\MainBundle\Entity\HasCentersInterface; use Chill\MainBundle\Entity\Location; use Chill\MainBundle\Entity\User; use Chill\PersonBundle\Entity\AccompanyingPeriod; use Chill\PersonBundle\Entity\Person; use Chill\ThirdPartyBundle\Entity\ThirdParty; +use DateInterval; use DateTimeImmutable; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; +use Doctrine\Common\Collections\Criteria; use Doctrine\ORM\Mapping as ORM; +use LogicException; use Symfony\Component\Serializer\Annotation as Serializer; -use Symfony\Component\Serializer\Annotation\Groups; +use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Constraints\NotBlank; + use Symfony\Component\Validator\Constraints\Range; use Symfony\Component\Validator\Mapping\ClassMetadata; - use function in_array; /** - * @ORM\Table(name="chill_calendar.calendar") - * @ORM\Entity(repositoryClass=CalendarRepository::class) + * @ORM\Table( + * name="chill_calendar.calendar", + * uniqueConstraints={@ORM\UniqueConstraint(name="idx_calendar_remote", columns={"remoteId"}, options={"where": "remoteId <> ''"})} + * ) + * @ORM\Entity + * @Serializer\DiscriminatorMap(typeProperty="type", mapping={ + * "chill_calendar_calendar": Calendar::class + * }) */ -class Calendar +class Calendar implements TrackCreationInterface, TrackUpdateInterface, HasCentersInterface { + use RemoteCalendarTrait; + + use TrackCreationTrait; + + use TrackUpdateTrait; + + public const SMS_CANCEL_PENDING = 'sms_cancel_pending'; + + public const SMS_PENDING = 'sms_pending'; + + public const SMS_SENT = 'sms_sent'; + public const STATUS_CANCELED = 'canceled'; + /** + * @deprecated + */ public const STATUS_MOVED = 'moved'; public const STATUS_VALID = 'valid'; /** - * @ORM\ManyToOne(targetEntity="Chill\PersonBundle\Entity\AccompanyingPeriod") - * @Groups({"read"}) + * a list of invite which have been added during this session. + * + * @var array|Invite[] */ - private AccompanyingPeriod $accompanyingPeriod; + public array $newInvites = []; + + /** + * a list of invite which have been removed during this session. + * + * @var array|Invite[] + */ + public array $oldInvites = []; + + public ?CalendarRange $previousCalendarRange = null; + + public ?User $previousMainUser = null; + + /** + * @ORM\ManyToOne(targetEntity="Chill\PersonBundle\Entity\AccompanyingPeriod", inversedBy="calendars") + */ + private ?AccompanyingPeriod $accompanyingPeriod = null; /** * @ORM\ManyToOne(targetEntity="Chill\ActivityBundle\Entity\Activity") @@ -56,7 +101,8 @@ class Calendar private ?Activity $activity = null; /** - * @ORM\ManyToOne(targetEntity="CalendarRange", inversedBy="calendars") + * @ORM\OneToOne(targetEntity="CalendarRange", inversedBy="calendar") + * @Serializer\Groups({"calendar:read", "read"}) */ private ?CalendarRange $calendarRange = null; @@ -67,13 +113,25 @@ class Calendar /** * @ORM\Embedded(class=CommentEmbeddable::class, columnPrefix="comment_") - * @Serializer\Groups({"calendar:read"}) + * @Serializer\Groups({"calendar:read", "read", "docgen:read"}) */ private CommentEmbeddable $comment; /** - * @ORM\Column(type="datetimetz_immutable") - * @Serializer\Groups({"calendar:read"}) + * @ORM\Column(type="integer", nullable=false, options={"default": 0}) + */ + private int $dateTimeVersion = 0; + + /** + * @var Collection + * @ORM\OneToMany(targetEntity=CalendarDoc::class, mappedBy="calendar", orphanRemoval=true) + */ + private Collection $documents; + + /** + * @ORM\Column(type="datetime_immutable", nullable=false) + * @Serializer\Groups({"calendar:read", "read", "calendar:light", "docgen:read"}) + * @Assert\NotNull(message="calendar.An end date is required") */ private ?DateTimeImmutable $endDate = null; @@ -81,38 +139,49 @@ class Calendar * @ORM\Id * @ORM\GeneratedValue * @ORM\Column(type="integer") - * @Serializer\Groups({"calendar:read"}) + * @Serializer\Groups({"calendar:read", "read", "calendar:light", "docgen:read"}) */ - private ?int $id; + private ?int $id = null; /** - * @ORM\ManyToMany( - * targetEntity="Invite", - * cascade={"persist", "remove", "merge", "detach"}) + * @ORM\OneToMany( + * targetEntity=Invite::class, + * mappedBy="calendar", + * orphanRemoval=true, + * cascade={"persist", "remove", "merge", "detach"} + * ) * @ORM\JoinTable(name="chill_calendar.calendar_to_invites") - * @Groups({"read"}) + * @Serializer\Groups({"read", "docgen:read"}) */ private Collection $invites; /** * @ORM\ManyToOne(targetEntity="Chill\MainBundle\Entity\Location") - * @groups({"read"}) + * @Serializer\Groups({"read", "docgen:read"}) + * @Assert\NotNull(message="calendar.A location is required") */ private ?Location $location = null; /** * @ORM\ManyToOne(targetEntity="Chill\MainBundle\Entity\User") - * @Serializer\Groups({"calendar:read"}) + * @Serializer\Groups({"calendar:read", "read", "calendar:light", "docgen:read"}) + * @Serializer\Context(normalizationContext={"read"}, groups={"calendar:light"}) + * @Assert\NotNull(message="calendar.A main user is mandatory") */ - private ?User $mainUser; + private ?User $mainUser = null; /** - * @ORM\ManyToMany( - * targetEntity="Chill\PersonBundle\Entity\Person", - * cascade={"persist", "remove", "merge", "detach"}) + * @ORM\ManyToOne(targetEntity=Person::class) + * @ORM\JoinColumn(nullable=true) + */ + private ?Person $person = null; + + /** + * @ORM\ManyToMany(targetEntity="Chill\PersonBundle\Entity\Person", inversedBy="calendars") * @ORM\JoinTable(name="chill_calendar.calendar_to_persons") - * @Groups({"read"}) - * @Serializer\Groups({"calendar:read"}) + * @Serializer\Groups({"calendar:read", "read", "calendar:light", "docgen:read"}) + * @Serializer\Context(normalizationContext={"read"}, groups={"calendar:light"}) + * @Assert\Count(min=1, minMessage="calendar.At least {{ limit }} person is required.") */ private Collection $persons; @@ -123,69 +192,102 @@ class Calendar private PrivateCommentEmbeddable $privateComment; /** - * @ORM\ManyToMany( - * targetEntity="Chill\ThirdPartyBundle\Entity\ThirdParty", - * cascade={"persist", "remove", "merge", "detach"}) + * @ORM\ManyToMany(targetEntity="Chill\ThirdPartyBundle\Entity\ThirdParty") * @ORM\JoinTable(name="chill_calendar.calendar_to_thirdparties") - * @Groups({"read"}) - * @Serializer\Groups({"calendar:read"}) + * @Serializer\Groups({"calendar:read", "read", "calendar:light", "docgen:read"}) + * @Serializer\Context(normalizationContext={"read"}, groups={"calendar:light"}) */ private Collection $professionals; /** * @ORM\Column(type="boolean", nullable=true) + * @Serializer\Groups({"docgen:read"}) */ - private ?bool $sendSMS; + private ?bool $sendSMS = false; /** - * @ORM\Column(type="datetimetz_immutable") - * @Serializer\Groups({"calendar:read"}) + * @ORM\Column(type="text", nullable=false, options={"default": Calendar::SMS_PENDING}) + */ + private string $smsStatus = self::SMS_PENDING; + + /** + * @ORM\Column(type="datetime_immutable", nullable=false) + * @Serializer\Groups({"calendar:read", "read", "calendar:light", "docgen:read"}) + * @Serializer\Context(normalizationContext={"read"}, groups={"calendar:light"}) + * @Assert\NotNull(message="calendar.A start date is required") */ private ?DateTimeImmutable $startDate = null; /** - * @ORM\Column(type="string", length=255) + * @ORM\Column(type="string", length=255, nullable=false, options={"default": "valid"}) + * @Serializer\Groups({"calendar:read", "read", "calendar:light"}) + * @Serializer\Context(normalizationContext={"read"}, groups={"calendar:light"}) */ - private ?string $status = null; + private string $status = self::STATUS_VALID; /** - * @ORM\ManyToOne(targetEntity="Chill\MainBundle\Entity\User") - * @Groups({"read"}) - * @Serializer\Groups({"calendar:read"}) + * @ORM\Column(type="boolean", nullable=true) + * @Serializer\Groups({"docgen:read"}) */ - private ?User $user = null; + private ?bool $urgent = false; public function __construct() { $this->comment = new CommentEmbeddable(); + $this->documents = new ArrayCollection(); $this->privateComment = new PrivateCommentEmbeddable(); $this->persons = new ArrayCollection(); $this->professionals = new ArrayCollection(); $this->invites = new ArrayCollection(); } - public function addInvite(?Invite $invite): self + /** + * @internal use @{CalendarDoc::__construct} instead + */ + public function addDocument(CalendarDoc $calendarDoc): self { - if (null !== $invite) { - $this->invites[] = $invite; + if ($this->documents->contains($calendarDoc)) { + $this->documents[] = $calendarDoc; } return $this; } - public function addPerson(?Person $person): self + /** + * @internal Use {@link (Calendar::addUser)} instead + */ + public function addInvite(Invite $invite): self { - if (null !== $person) { - $this->persons[] = $person; + if ($invite->getCalendar() instanceof Calendar && $invite->getCalendar() !== $this) { + throw new LogicException('Not allowed to move an invitation to another Calendar'); } + $this->invites[] = $invite; + $this->newInvites[] = $invite; + + $invite->setCalendar($this); + return $this; } - public function addProfessional(?ThirdParty $professional): self + public function addPerson(Person $person): self { - if (null !== $professional) { - $this->professionals[] = $professional; + $this->persons[] = $person; + + return $this; + } + + public function addProfessional(ThirdParty $professional): self + { + $this->professionals[] = $professional; + + return $this; + } + + public function addUser(User $user): self + { + if (!$this->getUsers()->contains($user) && $this->getMainUser() !== $user) { + $this->addInvite((new Invite())->setUser($user)); } return $this; @@ -211,11 +313,66 @@ class Calendar return $this->cancelReason; } + public function getCenters(): ?iterable + { + switch ($this->getContext()) { + case 'person': + return [$this->getPerson()->getCenter()]; + + case 'accompanying_period': + return $this->getAccompanyingPeriod()->getCenters(); + + default: + throw new LogicException('context not supported: ' . $this->getContext()); + } + } + public function getComment(): CommentEmbeddable { return $this->comment; } + /** + * @return 'person'|'accompanying_period'|null + */ + public function getContext(): ?string + { + if ($this->getAccompanyingPeriod() !== null) { + return 'accompanying_period'; + } + + if ($this->getPerson() !== null) { + return 'person'; + } + + return null; + } + + /** + * Each time the date and time is update, this version is incremented. + */ + public function getDateTimeVersion(): int + { + return $this->dateTimeVersion; + } + + public function getDocuments(): Collection + { + return $this->documents; + } + + /** + * @Serializer\Groups({"docgen:read"}) + */ + public function getDuration(): ?DateInterval + { + if ($this->getStartDate() === null || $this->getEndDate() === null) { + return null; + } + + return $this->getStartDate()->diff($this->getEndDate()); + } + public function getEndDate(): ?DateTimeImmutable { return $this->endDate; @@ -226,6 +383,21 @@ class Calendar return $this->id; } + public function getInviteForUser(User $user): ?Invite + { + $criteria = Criteria::create(); + $criteria->where(Criteria::expr()->eq('user', $user)); + + $matchings = $this->invites + ->matching($criteria); + + if (1 === $matchings->count()) { + return $matchings->first(); + } + + return null; + } + /** * @return Collection|Invite[] */ @@ -244,6 +416,11 @@ class Calendar return $this->mainUser; } + public function getPerson(): ?Person + { + return $this->person; + } + /** * @return Collection|Person[] */ @@ -304,6 +481,11 @@ class Calendar return $this->sendSMS; } + public function getSmsStatus(): string + { + return $this->smsStatus; + } + public function getStartDate(): ?DateTimeImmutable { return $this->startDate; @@ -319,14 +501,40 @@ class Calendar return $this->getProfessionals(); } - public function getUser(): ?User + public function getUrgent(): ?bool { - return $this->user; + return $this->urgent; } - public function getusers(): Collection + /** + * @return Collection|User[] + * @Serializer\Groups({"calendar:read", "read"}) + */ + public function getUsers(): Collection { - return $this->getInvites(); //TODO get users of the invite + return $this->getInvites()->map(static function (Invite $i) { return $i->getUser(); }); + } + + public function hasCalendarRange(): bool + { + return null !== $this->calendarRange; + } + + public function hasLocation(): bool + { + return null !== $this->getLocation(); + } + + /** + * return true if the user is invited. + */ + public function isInvited(User $user): bool + { + if ($this->getMainUser() === $user) { + return false; + } + + return $this->getUsers()->contains($user); } public static function loadValidatorMetadata(ClassMetadata $metadata): void @@ -343,9 +551,27 @@ class Calendar ])); } + /** + * @internal use @{CalendarDoc::setCalendar} with null instead + */ + public function removeDocument(CalendarDoc $calendarDoc): self + { + if ($calendarDoc->getCalendar() !== $this) { + throw new LogicException('cannot remove document of another calendar'); + } + + return $this; + } + + /** + * @internal Use {@link (Calendar::removeUser)} instead + */ public function removeInvite(Invite $invite): self { - $this->invites->removeElement($invite); + if ($this->invites->removeElement($invite)) { + $invite->setCalendar(null); + $this->oldInvites[] = $invite; + } return $this; } @@ -364,6 +590,20 @@ class Calendar return $this; } + public function removeUser(User $user): self + { + if (!$this->getUsers()->contains($user)) { + return $this; + } + + $invite = $this->invites + ->filter(static function (Invite $invite) use ($user) { return $invite->getUser() === $user; }) + ->first(); + $this->removeInvite($invite); + + return $this; + } + public function setAccompanyingPeriod(?AccompanyingPeriod $accompanyingPeriod): self { $this->accompanyingPeriod = $accompanyingPeriod; @@ -380,8 +620,20 @@ class Calendar public function setCalendarRange(?CalendarRange $calendarRange): self { + if ($this->calendarRange !== $calendarRange) { + $this->previousCalendarRange = $this->calendarRange; + + if (null !== $this->previousCalendarRange) { + $this->previousCalendarRange->setCalendar(null); + } + } + $this->calendarRange = $calendarRange; + if ($this->calendarRange instanceof CalendarRange) { + $this->calendarRange->setCalendar($this); + } + return $this; } @@ -401,6 +653,10 @@ class Calendar public function setEndDate(DateTimeImmutable $endDate): self { + if (null === $this->endDate || $this->endDate->getTimestamp() !== $endDate->getTimestamp()) { + $this->increaseaDatetimeVersion(); + } + $this->endDate = $endDate; return $this; @@ -415,7 +671,19 @@ class Calendar public function setMainUser(?User $mainUser): self { + if ($this->mainUser !== $mainUser) { + $this->previousMainUser = $this->mainUser; + } + $this->mainUser = $mainUser; + $this->removeUser($mainUser); + + return $this; + } + + public function setPerson(?Person $person): Calendar + { + $this->person = $person; return $this; } @@ -434,8 +702,19 @@ class Calendar return $this; } + public function setSmsStatus(string $smsStatus): self + { + $this->smsStatus = $smsStatus; + + return $this; + } + public function setStartDate(DateTimeImmutable $startDate): self { + if (null === $this->startDate || $this->startDate->getTimestamp() !== $startDate->getTimestamp()) { + $this->increaseaDatetimeVersion(); + } + $this->startDate = $startDate; return $this; @@ -445,13 +724,22 @@ class Calendar { $this->status = $status; + if (self::STATUS_CANCELED === $status && $this->getSmsStatus() === self::SMS_SENT) { + $this->setSmsStatus(self::SMS_CANCEL_PENDING); + } + return $this; } - public function setUser(?User $user): self + public function setUrgent(bool $urgent): self { - $this->user = $user; + $this->urgent = $urgent; return $this; } + + private function increaseaDatetimeVersion(): void + { + ++$this->dateTimeVersion; + } } diff --git a/src/Bundle/ChillCalendarBundle/Entity/CalendarDoc.php b/src/Bundle/ChillCalendarBundle/Entity/CalendarDoc.php new file mode 100644 index 000000000..7012b64e0 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Entity/CalendarDoc.php @@ -0,0 +1,153 @@ +setCalendar($calendar); + + $this->storedObject = $storedObject; + $this->datetimeVersion = $calendar->getDateTimeVersion(); + } + + public function createFromDTO(CalendarDocCreateDTO $calendarDocCreateDTO): void + { + $this->storedObject = $calendarDocCreateDTO->doc; + $this->storedObject->setTitle($calendarDocCreateDTO->title); + } + + public function editFromDTO(CalendarDocEditDTO $calendarDocEditDTO): void + { + if (null !== $calendarDocEditDTO->doc) { + $calendarDocEditDTO->doc->setTitle($this->getStoredObject()->getTitle()); + $this->setStoredObject($calendarDocEditDTO->doc); + } + + $this->getStoredObject()->setTitle($calendarDocEditDTO->title); + } + + public function getCalendar(): Calendar + { + return $this->calendar; + } + + public function getDatetimeVersion(): int + { + return $this->datetimeVersion; + } + + public function getId(): ?int + { + return $this->id; + } + + public function getStoredObject(): StoredObject + { + return $this->storedObject; + } + + public function isTrackDateTimeVersion(): bool + { + return $this->trackDateTimeVersion; + } + + /** + * @internal use @see{Calendar::removeDocument} instead + * + * @param Calendar $calendar + */ + public function setCalendar(?Calendar $calendar): CalendarDoc + { + if (null === $calendar) { + $this->calendar->removeDocument($this); + } else { + $calendar->addDocument($this); + } + + $this->calendar = $calendar; + + $this->datetimeVersion = $calendar->getDateTimeVersion(); + + return $this; + } + + public function setDatetimeVersion(int $datetimeVersion): CalendarDoc + { + $this->datetimeVersion = $datetimeVersion; + + return $this; + } + + public function setStoredObject(StoredObject $storedObject): CalendarDoc + { + $this->storedObject = $storedObject; + + return $this; + } + + public function setTrackDateTimeVersion(bool $trackDateTimeVersion): CalendarDoc + { + $this->trackDateTimeVersion = $trackDateTimeVersion; + + return $this; + } +} diff --git a/src/Bundle/ChillCalendarBundle/Entity/CalendarDoc/CalendarDocCreateDTO.php b/src/Bundle/ChillCalendarBundle/Entity/CalendarDoc/CalendarDocCreateDTO.php new file mode 100644 index 000000000..b7209e46f --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Entity/CalendarDoc/CalendarDocCreateDTO.php @@ -0,0 +1,30 @@ +title = $calendarDoc->getStoredObject()->getTitle(); + } +} diff --git a/src/Bundle/ChillCalendarBundle/Entity/CalendarRange.php b/src/Bundle/ChillCalendarBundle/Entity/CalendarRange.php index 03f9e9ce1..14cfad98b 100644 --- a/src/Bundle/ChillCalendarBundle/Entity/CalendarRange.php +++ b/src/Bundle/ChillCalendarBundle/Entity/CalendarRange.php @@ -11,29 +11,41 @@ declare(strict_types=1); namespace Chill\CalendarBundle\Entity; -use Chill\CalendarBundle\Repository\CalendarRangeRepository; +use Chill\MainBundle\Doctrine\Model\TrackCreationInterface; +use Chill\MainBundle\Doctrine\Model\TrackCreationTrait; +use Chill\MainBundle\Doctrine\Model\TrackUpdateInterface; +use Chill\MainBundle\Doctrine\Model\TrackUpdateTrait; +use Chill\MainBundle\Entity\Location; use Chill\MainBundle\Entity\User; use DateTimeImmutable; -use Doctrine\Common\Collections\ArrayCollection; -use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Serializer\Annotation\Groups; +use Symfony\Component\Validator\Constraints as Assert; /** - * @ORM\Table(name="chill_calendar.calendar_range") - * @ORM\Entity(repositoryClass=CalendarRangeRepository::class) + * @ORM\Table( + * name="chill_calendar.calendar_range", + * uniqueConstraints={@ORM\UniqueConstraint(name="idx_calendar_range_remote", columns={"remoteId"}, options={"where": "remoteId <> ''"})} + * ) + * @ORM\Entity */ -class CalendarRange +class CalendarRange implements TrackCreationInterface, TrackUpdateInterface { - /** - * @ORM\OneToMany(targetEntity=Calendar::class, - * mappedBy="calendarRange") - */ - private Collection $calendars; + use RemoteCalendarTrait; + + use TrackCreationTrait; + + use TrackUpdateTrait; /** - * @ORM\Column(type="datetimetz_immutable") - * @groups({"read", "write"}) + * @ORM\OneToOne(targetEntity=Calendar::class, mappedBy="calendarRange") + */ + private ?Calendar $calendar = null; + + /** + * @ORM\Column(type="datetime_immutable", nullable=false) + * @Groups({"read", "write", "calendar:read"}) + * @Assert\NotNull */ private ?DateTimeImmutable $endDate = null; @@ -41,27 +53,35 @@ class CalendarRange * @ORM\Id * @ORM\GeneratedValue * @ORM\Column(type="integer") - * @groups({"read"}) + * @Groups({"read"}) */ private $id; /** - * @ORM\Column(type="datetimetz_immutable") - * @groups({"read", "write"}) + * @ORM\ManyToOne(targetEntity=Location::class) + * @ORM\JoinColumn(nullable=false) + * @Groups({"read", "write", "calendar:read"}) + * @Assert\NotNull + */ + private ?Location $location; + + /** + * @ORM\Column(type="datetime_immutable", nullable=false) + * @groups({"read", "write", "calendar:read"}) + * @Assert\NotNull */ private ?DateTimeImmutable $startDate = null; /** * @ORM\ManyToOne(targetEntity="Chill\MainBundle\Entity\User") - * @groups({"read", "write"}) + * @Groups({"read", "write", "calendar:read"}) + * @Assert\NotNull */ private ?User $user = null; - //TODO Lieu - - public function __construct() + public function getCalendar(): ?Calendar { - $this->calendars = new ArrayCollection(); + return $this->calendar; } public function getEndDate(): ?DateTimeImmutable @@ -74,6 +94,11 @@ class CalendarRange return $this->id; } + public function getLocation(): ?Location + { + return $this->location; + } + public function getStartDate(): ?DateTimeImmutable { return $this->startDate; @@ -84,6 +109,14 @@ class CalendarRange return $this->user; } + /** + * @internal use {@link (Calendar::setCalendarRange)} instead + */ + public function setCalendar(?Calendar $calendar): void + { + $this->calendar = $calendar; + } + public function setEndDate(DateTimeImmutable $endDate): self { $this->endDate = $endDate; @@ -91,6 +124,13 @@ class CalendarRange return $this; } + public function setLocation(?Location $location): self + { + $this->location = $location; + + return $this; + } + public function setStartDate(DateTimeImmutable $startDate): self { $this->startDate = $startDate; diff --git a/src/Bundle/ChillCalendarBundle/Entity/Invite.php b/src/Bundle/ChillCalendarBundle/Entity/Invite.php index aa97c92d3..c2d79aff2 100644 --- a/src/Bundle/ChillCalendarBundle/Entity/Invite.php +++ b/src/Bundle/ChillCalendarBundle/Entity/Invite.php @@ -11,39 +11,90 @@ declare(strict_types=1); namespace Chill\CalendarBundle\Entity; -use Chill\CalendarBundle\Repository\InviteRepository; +use Chill\MainBundle\Doctrine\Model\TrackCreationInterface; +use Chill\MainBundle\Doctrine\Model\TrackCreationTrait; +use Chill\MainBundle\Doctrine\Model\TrackUpdateInterface; +use Chill\MainBundle\Doctrine\Model\TrackUpdateTrait; use Chill\MainBundle\Entity\User; use Doctrine\ORM\Mapping as ORM; +use LogicException; +use Symfony\Component\Serializer\Annotation as Serializer; /** - * @ORM\Table(name="chill_calendar.invite") - * @ORM\Entity(repositoryClass=InviteRepository::class) + * An invitation for another user to a Calendar. + * + * The event/calendar in the user may have a different id than the mainUser. We add then fields to store the + * remote id of this event in the remote calendar. + * + * @ORM\Table( + * name="chill_calendar.invite", + * uniqueConstraints={@ORM\UniqueConstraint(name="idx_calendar_invite_remote", columns={"remoteId"}, options={"where": "remoteId <> ''"})} + * ) + * @ORM\Entity */ -class Invite +class Invite implements TrackUpdateInterface, TrackCreationInterface { + use RemoteCalendarTrait; + + use TrackCreationTrait; + + use TrackUpdateTrait; + + public const ACCEPTED = 'accepted'; + + public const DECLINED = 'declined'; + + public const PENDING = 'pending'; + + /** + * all statuses in one const. + */ + public const STATUSES = [ + self::ACCEPTED, + self::DECLINED, + self::PENDING, + self::TENTATIVELY_ACCEPTED, + ]; + + public const TENTATIVELY_ACCEPTED = 'tentative'; + + /** + * @ORM\ManyToOne(targetEntity=Calendar::class, inversedBy="invites") + */ + private ?Calendar $calendar = null; + /** * @ORM\Id * @ORM\GeneratedValue * @ORM\Column(type="integer") + * @Serializer\Groups(groups={"calendar:read", "read"}) */ - private $id; + private ?int $id = null; /** - * @ORM\Column(type="json") + * @ORM\Column(type="text", nullable=false, options={"default": "pending"}) + * @Serializer\Groups(groups={"calendar:read", "read", "docgen:read"}) */ - private array $status = []; + private string $status = self::PENDING; /** * @ORM\ManyToOne(targetEntity="Chill\MainBundle\Entity\User") + * @ORM\JoinColumn(nullable=false) + * @Serializer\Groups(groups={"calendar:read", "read", "docgen:read"}) */ - private User $user; + private ?User $user = null; + + public function getCalendar(): ?Calendar + { + return $this->calendar; + } public function getId(): ?int { return $this->id; } - public function getStatus(): ?array + public function getStatus(): string { return $this->status; } @@ -53,7 +104,15 @@ class Invite return $this->user; } - public function setStatus(array $status): self + /** + * @internal use Calendar::addInvite instead + */ + public function setCalendar(?Calendar $calendar): void + { + $this->calendar = $calendar; + } + + public function setStatus(string $status): self { $this->status = $status; @@ -62,6 +121,10 @@ class Invite public function setUser(?User $user): self { + if ($user instanceof User && $this->user instanceof User && $user !== $this->user) { + throw new LogicException('Not allowed to associate an invite to a different user'); + } + $this->user = $user; return $this; diff --git a/src/Bundle/ChillCalendarBundle/Entity/RemoteCalendarTrait.php b/src/Bundle/ChillCalendarBundle/Entity/RemoteCalendarTrait.php new file mode 100644 index 000000000..31282fb0b --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Entity/RemoteCalendarTrait.php @@ -0,0 +1,71 @@ +remoteAttributes = array_merge($this->remoteAttributes, $remoteAttributes); + + return $this; + } + + public function getRemoteAttributes(): array + { + return $this->remoteAttributes; + } + + public function getRemoteId(): string + { + return $this->remoteId; + } + + public function hasRemoteId(): bool + { + return '' !== $this->remoteId; + } + + public function setRemoteId(string $remoteId): self + { + $this->remoteId = $remoteId; + + return $this; + } +} diff --git a/src/Bundle/ChillCalendarBundle/Export/Aggregator/AgentAggregator.php b/src/Bundle/ChillCalendarBundle/Export/Aggregator/AgentAggregator.php index a4963e13d..9b0ad9057 100644 --- a/src/Bundle/ChillCalendarBundle/Export/Aggregator/AgentAggregator.php +++ b/src/Bundle/ChillCalendarBundle/Export/Aggregator/AgentAggregator.php @@ -18,6 +18,7 @@ use Chill\MainBundle\Templating\Entity\UserRender; use Closure; use Doctrine\ORM\QueryBuilder; use Symfony\Component\Form\FormBuilderInterface; +use function in_array; final class AgentAggregator implements AggregatorInterface { @@ -33,24 +34,19 @@ final class AgentAggregator implements AggregatorInterface $this->userRender = $userRender; } - public function addRole() + public function addRole(): ?string { return null; } public function alterQuery(QueryBuilder $qb, $data) { - $qb->join('cal.user', 'u'); - - $qb->addSelect('u.id AS agent_aggregator'); - - $groupBy = $qb->getDQLPart('groupBy'); - - if (!empty($groupBy)) { - $qb->addGroupBy('agent_aggregator'); - } else { - $qb->groupBy('agent_aggregator'); + if (!in_array('caluser', $qb->getAllAliases(), true)) { + $qb->join('cal.mainUser', 'caluser'); } + + $qb->addSelect('caluser.id AS agent_aggregator'); + $qb->addGroupBy('agent_aggregator'); } public function applyOn(): string @@ -83,6 +79,6 @@ final class AgentAggregator implements AggregatorInterface public function getTitle(): string { - return 'Group by agent'; + return 'Group calendars by agent'; } } diff --git a/src/Bundle/ChillCalendarBundle/Export/Aggregator/CancelReasonAggregator.php b/src/Bundle/ChillCalendarBundle/Export/Aggregator/CancelReasonAggregator.php index e9f18a555..329e2e50e 100644 --- a/src/Bundle/ChillCalendarBundle/Export/Aggregator/CancelReasonAggregator.php +++ b/src/Bundle/ChillCalendarBundle/Export/Aggregator/CancelReasonAggregator.php @@ -18,6 +18,7 @@ use Chill\MainBundle\Templating\TranslatableStringHelper; use Closure; use Doctrine\ORM\QueryBuilder; use Symfony\Component\Form\FormBuilderInterface; +use function in_array; class CancelReasonAggregator implements AggregatorInterface { @@ -33,25 +34,20 @@ class CancelReasonAggregator implements AggregatorInterface $this->translatableStringHelper = $translatableStringHelper; } - public function addRole() + public function addRole(): ?string { return null; } public function alterQuery(QueryBuilder $qb, $data) { - // TODO: still needs to take into account appointments without a cancel reason somehow - $qb->join('cal.cancelReason', 'cr'); + // TODO: still needs to take into account calendars without a cancel reason somehow + if (!in_array('calcancel', $qb->getAllAliases(), true)) { + $qb->join('cal.cancelReason', 'calcancel'); + } $qb->addSelect('IDENTITY(cal.cancelReason) as cancel_reason_aggregator'); - - $groupBy = $qb->getDQLPart('groupBy'); - - if (!empty($groupBy)) { - $qb->addGroupBy('cancel_reason_aggregator'); - } else { - $qb->groupBy('cancel_reason_aggregator'); - } + $qb->addGroupBy('cancel_reason_aggregator'); } public function applyOn(): string @@ -86,6 +82,6 @@ class CancelReasonAggregator implements AggregatorInterface public function getTitle(): string { - return 'Group by cancel reason'; + return 'Group calendars by cancel reason'; } } diff --git a/src/Bundle/ChillCalendarBundle/Export/Aggregator/JobAggregator.php b/src/Bundle/ChillCalendarBundle/Export/Aggregator/JobAggregator.php index a576d7fc7..2a6f3b63e 100644 --- a/src/Bundle/ChillCalendarBundle/Export/Aggregator/JobAggregator.php +++ b/src/Bundle/ChillCalendarBundle/Export/Aggregator/JobAggregator.php @@ -18,6 +18,7 @@ use Chill\MainBundle\Templating\TranslatableStringHelper; use Closure; use Doctrine\ORM\QueryBuilder; use Symfony\Component\Form\FormBuilderInterface; +use function in_array; final class JobAggregator implements AggregatorInterface { @@ -33,24 +34,19 @@ final class JobAggregator implements AggregatorInterface $this->translatableStringHelper = $translatableStringHelper; } - public function addRole() + public function addRole(): ?string { return null; } public function alterQuery(QueryBuilder $qb, $data) { - $qb->join('cal.user', 'u'); - - $qb->addSelect('IDENTITY(u.userJob) as job_aggregator'); - - $groupBy = $qb->getDQLPart('groupBy'); - - if (!empty($groupBy)) { - $qb->addGroupBy('job_aggregator'); - } else { - $qb->groupBy('job_aggregator'); + if (!in_array('caluser', $qb->getAllAliases(), true)) { + $qb->join('cal.mainUser', 'caluser'); } + + $qb->addSelect('IDENTITY(caluser.userJob) as job_aggregator'); + $qb->addGroupBy('job_aggregator'); } public function applyOn(): string @@ -85,6 +81,6 @@ final class JobAggregator implements AggregatorInterface public function getTitle(): string { - return 'Group by agent job'; + return 'Group calendars by agent job'; } } diff --git a/src/Bundle/ChillCalendarBundle/Export/Aggregator/LocationAggregator.php b/src/Bundle/ChillCalendarBundle/Export/Aggregator/LocationAggregator.php index b1c6b10b4..687dc9096 100644 --- a/src/Bundle/ChillCalendarBundle/Export/Aggregator/LocationAggregator.php +++ b/src/Bundle/ChillCalendarBundle/Export/Aggregator/LocationAggregator.php @@ -17,7 +17,7 @@ use Chill\MainBundle\Repository\LocationRepository; use Closure; use Doctrine\ORM\QueryBuilder; use Symfony\Component\Form\FormBuilderInterface; -use Symfony\Component\Security\Core\Role\Role; +use function in_array; final class LocationAggregator implements AggregatorInterface { @@ -29,23 +29,18 @@ final class LocationAggregator implements AggregatorInterface $this->locationRepository = $locationRepository; } - public function addRole(): ?Role + public function addRole(): ?string { return null; } public function alterQuery(QueryBuilder $qb, $data) { - $qb->join('cal.location', 'l'); - $qb->addSelect('IDENTITY(cal.location) as location_aggregator'); - - $groupBy = $qb->getDQLPart('groupBy'); - - if (!empty($groupBy)) { - $qb->addGroupBy('location_aggregator'); - } else { - $qb->groupBy('location_aggregator'); + if (!in_array('calloc', $qb->getAllAliases(), true)) { + $qb->join('cal.location', 'calloc'); } + $qb->addSelect('IDENTITY(cal.location) as location_aggregator'); + $qb->addGroupBy('location_aggregator'); } public function applyOn(): string @@ -78,6 +73,6 @@ final class LocationAggregator implements AggregatorInterface public function getTitle(): string { - return 'Group by location'; + return 'Group calendars by location'; } } diff --git a/src/Bundle/ChillCalendarBundle/Export/Aggregator/LocationTypeAggregator.php b/src/Bundle/ChillCalendarBundle/Export/Aggregator/LocationTypeAggregator.php index 13dda9bea..b23b304f6 100644 --- a/src/Bundle/ChillCalendarBundle/Export/Aggregator/LocationTypeAggregator.php +++ b/src/Bundle/ChillCalendarBundle/Export/Aggregator/LocationTypeAggregator.php @@ -18,6 +18,7 @@ use Chill\MainBundle\Templating\TranslatableStringHelper; use Closure; use Doctrine\ORM\QueryBuilder; use Symfony\Component\Form\FormBuilderInterface; +use function in_array; final class LocationTypeAggregator implements AggregatorInterface { @@ -33,24 +34,19 @@ final class LocationTypeAggregator implements AggregatorInterface $this->translatableStringHelper = $translatableStringHelper; } - public function addRole() + public function addRole(): ?string { return null; } public function alterQuery(QueryBuilder $qb, $data) { - $qb->join('cal.location', 'l'); - - $qb->addSelect('IDENTITY(l.locationType) as location_type_aggregator'); - - $groupBy = $qb->getDQLPart('groupBy'); - - if (!empty($groupBy)) { - $qb->addGroupBy('location_type_aggregator'); - } else { - $qb->groupBy('location_type_aggregator'); + if (!in_array('calloc', $qb->getAllAliases(), true)) { + $qb->join('cal.location', 'calloc'); } + + $qb->addSelect('IDENTITY(calloc.locationType) as location_type_aggregator'); + $qb->addGroupBy('location_type_aggregator'); } public function applyOn(): string @@ -85,6 +81,6 @@ final class LocationTypeAggregator implements AggregatorInterface public function getTitle(): string { - return 'Group by location type'; + return 'Group calendars by location type'; } } diff --git a/src/Bundle/ChillCalendarBundle/Export/Aggregator/MonthYearAggregator.php b/src/Bundle/ChillCalendarBundle/Export/Aggregator/MonthYearAggregator.php index cc193f1bd..7b2a5e898 100644 --- a/src/Bundle/ChillCalendarBundle/Export/Aggregator/MonthYearAggregator.php +++ b/src/Bundle/ChillCalendarBundle/Export/Aggregator/MonthYearAggregator.php @@ -16,11 +16,10 @@ use Chill\MainBundle\Export\AggregatorInterface; use Closure; use Doctrine\ORM\QueryBuilder; use Symfony\Component\Form\FormBuilderInterface; -use Symfony\Component\Security\Core\Role\Role; class MonthYearAggregator implements AggregatorInterface { - public function addRole(): ?Role + public function addRole(): ?string { return null; } @@ -29,13 +28,7 @@ class MonthYearAggregator implements AggregatorInterface { $qb->addSelect("to_char(cal.startDate, 'MM-YYYY') AS month_year_aggregator"); // $qb->addSelect("extract(month from age(cal.startDate, cal.endDate)) AS month_aggregator"); - $groupBy = $qb->getDQLPart('groupBy'); - - if (!empty($groupBy)) { - $qb->addGroupBy('month_year_aggregator'); - } else { - $qb->groupBy('month_year_aggregator'); - } + $qb->addGroupBy('month_year_aggregator'); } public function applyOn(): string @@ -55,10 +48,7 @@ class MonthYearAggregator implements AggregatorInterface return 'by month and year'; } - $month = substr($value, 0, 2); - $year = substr($value, 3, 4); - - return strftime('%B %G', mktime(0, 0, 0, $month, '1', $year)); + return $value; }; } @@ -69,6 +59,6 @@ class MonthYearAggregator implements AggregatorInterface public function getTitle(): string { - return 'Group by month and year'; + return 'Group calendars by month and year'; } } diff --git a/src/Bundle/ChillCalendarBundle/Export/Aggregator/ScopeAggregator.php b/src/Bundle/ChillCalendarBundle/Export/Aggregator/ScopeAggregator.php index 917222d72..b16b06d84 100644 --- a/src/Bundle/ChillCalendarBundle/Export/Aggregator/ScopeAggregator.php +++ b/src/Bundle/ChillCalendarBundle/Export/Aggregator/ScopeAggregator.php @@ -18,6 +18,7 @@ use Chill\MainBundle\Templating\TranslatableStringHelper; use Closure; use Doctrine\ORM\QueryBuilder; use Symfony\Component\Form\FormBuilderInterface; +use function in_array; final class ScopeAggregator implements AggregatorInterface { @@ -33,24 +34,19 @@ final class ScopeAggregator implements AggregatorInterface $this->translatableStringHelper = $translatableStringHelper; } - public function addRole() + public function addRole(): ?string { return null; } public function alterQuery(QueryBuilder $qb, $data) { - $qb->join('cal.user', 'u'); - - $qb->addSelect('IDENTITY(u.mainScope) as scope_aggregator'); - - $groupBy = $qb->getDQLPart('groupBy'); - - if (!empty($groupBy)) { - $qb->addGroupBy('scope_aggregator'); - } else { - $qb->groupBy('scope_aggregator'); + if (!in_array('caluser', $qb->getAllAliases(), true)) { + $qb->join('cal.mainUser', 'caluser'); } + + $qb->addSelect('IDENTITY(caluser.mainScope) as scope_aggregator'); + $qb->addGroupBy('scope_aggregator'); } public function applyOn(): string @@ -85,6 +81,6 @@ final class ScopeAggregator implements AggregatorInterface public function getTitle(): string { - return 'Group by agent scope'; + return 'Group calendars by agent scope'; } } diff --git a/src/Bundle/ChillCalendarBundle/Export/Aggregator/UrgencyAggregator.php b/src/Bundle/ChillCalendarBundle/Export/Aggregator/UrgencyAggregator.php new file mode 100644 index 000000000..a80653441 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Export/Aggregator/UrgencyAggregator.php @@ -0,0 +1,96 @@ +translator = $translator; + } + + public function addRole(): ?string + { + return null; + } + + public function alterQuery(QueryBuilder $qb, $data) + { + $qb->addSelect('cal.urgent AS urgency_aggregator'); + + $groupBy = $qb->getDQLPart('groupBy'); + + if (!empty($groupBy)) { + $qb->addGroupBy('urgency_aggregator'); + } else { + $qb->groupBy('urgency_aggregator'); + } + } + + public function applyOn(): string + { + return Declarations::CALENDAR_TYPE; + } + + public function buildForm(FormBuilderInterface $builder) + { + // no form + } + + public function getLabels($key, array $values, $data): Closure + { + return function ($value): string { + if ('_header' === $value) { + return 'Urgency'; + } + + switch ($value) { + case true: + return $this->translator->trans('is urgent'); + + case false: + return $this->translator->trans('is not urgent'); + + default: + throw new LogicException(sprintf('The value %s is not valid', $value)); + } + }; + } + + public function getQueryKeys($data): array + { + return ['urgency_aggregator']; + } + + public function getTitle(): string + { + return 'Group calendars by urgency'; + } +} diff --git a/src/Bundle/ChillCalendarBundle/Export/Export/CountAppointments.php b/src/Bundle/ChillCalendarBundle/Export/Export/CountCalendars.php similarity index 84% rename from src/Bundle/ChillCalendarBundle/Export/Export/CountAppointments.php rename to src/Bundle/ChillCalendarBundle/Export/Export/CountCalendars.php index 13aa1ce24..06d6defca 100644 --- a/src/Bundle/ChillCalendarBundle/Export/Export/CountAppointments.php +++ b/src/Bundle/ChillCalendarBundle/Export/Export/CountCalendars.php @@ -21,10 +21,9 @@ use Closure; use Doctrine\ORM\AbstractQuery; use Doctrine\ORM\QueryBuilder; use Symfony\Component\Form\FormBuilderInterface; -use Symfony\Component\Process\Exception\LogicException; -use Symfony\Component\Security\Core\Role\Role; +use Symfony\Component\Validator\Exception\LogicException; -class CountAppointments implements ExportInterface, GroupedExportInterface +class CountCalendars implements ExportInterface, GroupedExportInterface { private CalendarRepository $calendarRepository; @@ -45,7 +44,7 @@ class CountAppointments implements ExportInterface, GroupedExportInterface public function getDescription(): string { - return 'Count appointments by various parameters.'; + return 'Count calendars by various parameters.'; } public function getGroup(): string @@ -72,14 +71,14 @@ class CountAppointments implements ExportInterface, GroupedExportInterface return ['export_result']; } - public function getResult($qb, $data) + public function getResult($query, $data) { - return $qb->getQuery()->getResult(AbstractQuery::HYDRATE_SCALAR); + return $query->getQuery()->getResult(AbstractQuery::HYDRATE_SCALAR); } public function getTitle(): string { - return 'Count appointments'; + return 'Count calendars'; } public function getType(): string @@ -103,10 +102,10 @@ class CountAppointments implements ExportInterface, GroupedExportInterface return $qb; } - public function requiredRole(): Role + public function requiredRole(): string { // which role should we give here? - return new Role(PersonVoter::STATS); + return PersonVoter::STATS; } public function supportsModifiers(): array diff --git a/src/Bundle/ChillCalendarBundle/Export/Export/StatAppointmentAvgDuration.php b/src/Bundle/ChillCalendarBundle/Export/Export/StatCalendarAvgDuration.php similarity index 83% rename from src/Bundle/ChillCalendarBundle/Export/Export/StatAppointmentAvgDuration.php rename to src/Bundle/ChillCalendarBundle/Export/Export/StatCalendarAvgDuration.php index 3e0953e18..ddecba415 100644 --- a/src/Bundle/ChillCalendarBundle/Export/Export/StatAppointmentAvgDuration.php +++ b/src/Bundle/ChillCalendarBundle/Export/Export/StatCalendarAvgDuration.php @@ -21,9 +21,8 @@ use Doctrine\ORM\Query; use Doctrine\ORM\QueryBuilder; use LogicException; use Symfony\Component\Form\FormBuilderInterface; -use Symfony\Component\Security\Core\Role\Role; -class StatAppointmentAvgDuration implements ExportInterface, GroupedExportInterface +class StatCalendarAvgDuration implements ExportInterface, GroupedExportInterface { private CalendarRepository $calendarRepository; @@ -45,7 +44,7 @@ class StatAppointmentAvgDuration implements ExportInterface, GroupedExportInterf public function getDescription(): string { - return 'Get the average of appointment duration according to various filters'; + return 'Get the average of calendar duration according to various filters'; } public function getGroup(): string @@ -72,14 +71,14 @@ class StatAppointmentAvgDuration implements ExportInterface, GroupedExportInterf return ['export_result']; } - public function getResult($qb, $data) + public function getResult($query, $data) { - return $qb->getQuery()->getResult(Query::HYDRATE_SCALAR); + return $query->getQuery()->getResult(Query::HYDRATE_SCALAR); } public function getTitle(): string { - return 'Average appointment duration'; + return 'Average calendar duration'; } public function getType(): string @@ -97,9 +96,9 @@ class StatAppointmentAvgDuration implements ExportInterface, GroupedExportInterf return $qb; } - public function requiredRole(): Role + public function requiredRole(): string { - return new Role(AccompanyingPeriodVoter::STATS); + return AccompanyingPeriodVoter::STATS; } public function supportsModifiers(): array diff --git a/src/Bundle/ChillCalendarBundle/Export/Export/StatAppointmentSumDuration.php b/src/Bundle/ChillCalendarBundle/Export/Export/StatCalendarSumDuration.php similarity index 83% rename from src/Bundle/ChillCalendarBundle/Export/Export/StatAppointmentSumDuration.php rename to src/Bundle/ChillCalendarBundle/Export/Export/StatCalendarSumDuration.php index 0877e0478..d99e73a2e 100644 --- a/src/Bundle/ChillCalendarBundle/Export/Export/StatAppointmentSumDuration.php +++ b/src/Bundle/ChillCalendarBundle/Export/Export/StatCalendarSumDuration.php @@ -21,9 +21,8 @@ use Doctrine\ORM\Query; use Doctrine\ORM\QueryBuilder; use LogicException; use Symfony\Component\Form\FormBuilderInterface; -use Symfony\Component\Security\Core\Role\Role; -class StatAppointmentSumDuration implements ExportInterface, GroupedExportInterface +class StatCalendarSumDuration implements ExportInterface, GroupedExportInterface { private CalendarRepository $calendarRepository; @@ -45,7 +44,7 @@ class StatAppointmentSumDuration implements ExportInterface, GroupedExportInterf public function getDescription(): string { - return 'Get the sum of appointment durations according to various filters'; + return 'Get the sum of calendar durations according to various filters'; } public function getGroup(): string @@ -72,14 +71,14 @@ class StatAppointmentSumDuration implements ExportInterface, GroupedExportInterf return ['export_result']; } - public function getResult($qb, $data) + public function getResult($query, $data) { - return $qb->getQuery()->getResult(Query::HYDRATE_SCALAR); + return $query->getQuery()->getResult(Query::HYDRATE_SCALAR); } public function getTitle(): string { - return 'Sum of appointment durations'; + return 'Sum of calendar durations'; } public function getType(): string @@ -97,9 +96,9 @@ class StatAppointmentSumDuration implements ExportInterface, GroupedExportInterf return $qb; } - public function requiredRole(): Role + public function requiredRole(): string { - return new Role(AccompanyingPeriodVoter::STATS); + return AccompanyingPeriodVoter::STATS; } public function supportsModifiers(): array diff --git a/src/Bundle/ChillCalendarBundle/Export/Filter/AgentFilter.php b/src/Bundle/ChillCalendarBundle/Export/Filter/AgentFilter.php index 15dbf064e..18a4b0f4b 100644 --- a/src/Bundle/ChillCalendarBundle/Export/Filter/AgentFilter.php +++ b/src/Bundle/ChillCalendarBundle/Export/Filter/AgentFilter.php @@ -29,7 +29,7 @@ class AgentFilter implements FilterInterface $this->userRender = $userRender; } - public function addRole() + public function addRole(): ?string { return null; } @@ -37,7 +37,7 @@ class AgentFilter implements FilterInterface public function alterQuery(QueryBuilder $qb, $data) { $where = $qb->getDQLPart('where'); - $clause = $qb->expr()->in('cal.user', ':agents'); + $clause = $qb->expr()->in('cal.mainUser', ':agents'); if ($where instanceof Andx) { $where->add($clause); @@ -76,12 +76,12 @@ class AgentFilter implements FilterInterface return [ 'Filtered by agent: only %agents%', [ - '%agents' => implode(', ou ', $users), + '%agents' => implode(', ', $users), ], ]; } public function getTitle(): string { - return 'Filter by agent'; + return 'Filter calendars by agent'; } } diff --git a/src/Bundle/ChillCalendarBundle/Export/Filter/BetweenDatesFilter.php b/src/Bundle/ChillCalendarBundle/Export/Filter/BetweenDatesFilter.php index b65001941..59019ac03 100644 --- a/src/Bundle/ChillCalendarBundle/Export/Filter/BetweenDatesFilter.php +++ b/src/Bundle/ChillCalendarBundle/Export/Filter/BetweenDatesFilter.php @@ -13,38 +13,43 @@ namespace Chill\CalendarBundle\Export\Filter; use Chill\CalendarBundle\Export\Declarations; use Chill\MainBundle\Export\FilterInterface; -use Chill\MainBundle\Form\Type\ChillDateType; -use DateTime; -use Doctrine\ORM\Query\Expr\Andx; +use Chill\MainBundle\Form\Type\PickRollingDateType; +use Chill\MainBundle\Service\RollingDate\RollingDate; +use Chill\MainBundle\Service\RollingDate\RollingDateConverterInterface; use Doctrine\ORM\QueryBuilder; use Symfony\Component\Form\FormBuilderInterface; class BetweenDatesFilter implements FilterInterface { - public function addRole() + private RollingDateConverterInterface $rollingDateConverter; + + public function __construct(RollingDateConverterInterface $rollingDateConverter) + { + $this->rollingDateConverter = $rollingDateConverter; + } + + public function addRole(): ?string { return null; } public function alterQuery(QueryBuilder $qb, $data) { - $where = $qb->getDQLPart('where'); - $clause = $qb->expr()->andX( $qb->expr()->gte('cal.startDate', ':dateFrom'), $qb->expr()->lte('cal.endDate', ':dateTo') ); - if ($where instanceof Andx) { - $where->add($clause); - } else { - $where = $qb->expr()->andX($clause); - } - - $qb->add('where', $where); - $qb->setParameter('dateFrom', $data['date_from']); + $qb->andWhere($clause); + $qb->setParameter( + 'dateFrom', + $this->rollingDateConverter->convert($data['date_from']) + ); // modify dateTo so that entire day is also taken into account up until the beginning of the next day. - $qb->setParameter('dateTo', $data['date_to']->modify('+1 day')); + $qb->setParameter( + 'dateTo', + $this->rollingDateConverter->convert($data['date_to'])->modify('+1 day') + ); } public function applyOn(): string @@ -55,24 +60,24 @@ class BetweenDatesFilter implements FilterInterface public function buildForm(FormBuilderInterface $builder) { $builder - ->add('date_from', ChillDateType::class, [ - 'data' => new DateTime(), + ->add('date_from', PickRollingDateType::class, [ + 'data' => new RollingDate(RollingDate::T_YEAR_PREVIOUS_START), ]) - ->add('date_to', ChillDateType::class, [ - 'data' => new DateTime(), + ->add('date_to', PickRollingDateType::class, [ + 'data' => new RollingDate(RollingDate::T_TODAY), ]); } public function describeAction($data, $format = 'string'): array { - return ['Filtered by appointments between %dateFrom% and %dateTo%', [ - '%dateFrom%' => $data['date_from']->format('d-m-Y'), - '%dateTo%' => $data['date_to']->format('d-m-Y'), + return ['Filtered by calendars between %dateFrom% and %dateTo%', [ + '%dateFrom%' => $this->rollingDateConverter->convert($data['date_from'])->format('d-m-Y'), + '%dateTo%' => $this->rollingDateConverter->convert($data['date_to'])->format('d-m-Y'), ]]; } public function getTitle(): string { - return 'Filter by appointments between certain dates'; + return 'Filter calendars between certain dates'; } } diff --git a/src/Bundle/ChillCalendarBundle/Export/Filter/CalendarRangeFilter.php b/src/Bundle/ChillCalendarBundle/Export/Filter/CalendarRangeFilter.php new file mode 100644 index 000000000..fde867b64 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Export/Filter/CalendarRangeFilter.php @@ -0,0 +1,109 @@ + true, + 'Made within a calendar range' => false, + ]; + + private const DEFAULT_CHOICE = false; + + private TranslatorInterface $translator; + + public function __construct(TranslatorInterface $translator) + { + $this->translator = $translator; + } + + public function addRole(): ?string + { + return null; + } + + public function alterQuery(QueryBuilder $qb, $data) + { + $where = $qb->getDQLPart('where'); + + dump($data); + + if ($data['hasCalendarRange']) { + $clause = $qb->expr()->isNotNull('cal.calendarRange'); + } else { + $clause = $qb->expr()->isNull('cal.calendarRange'); + } + + if ($where instanceof Andx) { + $where->add($clause); + } else { + $where = $qb->expr()->andX($clause); + } + + $qb->add('where', $where); + } + + public function applyOn(): string + { + return Declarations::CALENDAR_TYPE; + } + + public function buildForm(FormBuilderInterface $builder) + { + $builder->add('hasCalendarRange', ChoiceType::class, [ + 'choices' => self::CHOICES, + 'label' => 'has calendar range', + 'multiple' => false, + 'expanded' => true, + 'empty_data' => self::DEFAULT_CHOICE, + 'data' => self::DEFAULT_CHOICE, + ]); + } + + public function describeAction($data, $format = 'string'): array + { + foreach (self::CHOICES as $k => $v) { + if ($v === $data['hasCalendarRange']) { + $choice = $k; + } else { + $choice = 'Not made within a calendar range'; + } + } + + return [ + 'Filtered by calendar range: only %calendarRange%', [ + '%calendarRange%' => $this->translator->trans($choice), + ], + ]; + } + + public function getTitle(): string + { + return 'Filter by calendar range'; + } +} diff --git a/src/Bundle/ChillCalendarBundle/Export/Filter/JobFilter.php b/src/Bundle/ChillCalendarBundle/Export/Filter/JobFilter.php index b3c772da3..0f0f42adc 100644 --- a/src/Bundle/ChillCalendarBundle/Export/Filter/JobFilter.php +++ b/src/Bundle/ChillCalendarBundle/Export/Filter/JobFilter.php @@ -20,6 +20,7 @@ use Doctrine\ORM\QueryBuilder; use Symfony\Bridge\Doctrine\Form\Type\EntityType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Contracts\Translation\TranslatorInterface; +use function in_array; class JobFilter implements FilterInterface { @@ -35,17 +36,19 @@ class JobFilter implements FilterInterface $this->translatableStringHelper = $translatableStringHelper; } - public function addRole() + public function addRole(): ?string { return null; } public function alterQuery(QueryBuilder $qb, $data) { - $qb->join('cal.user', 'u'); + if (!in_array('caluser', $qb->getAllAliases(), true)) { + $qb->join('cal.mainUser', 'caluser'); + } $where = $qb->getDQLPart('where'); - $clause = $qb->expr()->in('u.userJob', ':job'); + $clause = $qb->expr()->in('caluser.userJob', ':job'); if ($where instanceof Andx) { $where->add($clause); @@ -87,12 +90,12 @@ class JobFilter implements FilterInterface } return ['Filtered by agent job: only %jobs%', [ - '%jobs%' => implode(', ou ', $userJobs), + '%jobs%' => implode(', ', $userJobs), ]]; } public function getTitle(): string { - return 'Filter by agent job'; + return 'Filter calendars by agent job'; } } diff --git a/src/Bundle/ChillCalendarBundle/Export/Filter/ScopeFilter.php b/src/Bundle/ChillCalendarBundle/Export/Filter/ScopeFilter.php index b1e8c014f..4d84543a3 100644 --- a/src/Bundle/ChillCalendarBundle/Export/Filter/ScopeFilter.php +++ b/src/Bundle/ChillCalendarBundle/Export/Filter/ScopeFilter.php @@ -20,6 +20,7 @@ use Doctrine\ORM\QueryBuilder; use Symfony\Bridge\Doctrine\Form\Type\EntityType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Contracts\Translation\TranslatorInterface; +use function in_array; class ScopeFilter implements FilterInterface { @@ -35,17 +36,19 @@ class ScopeFilter implements FilterInterface $this->translatableStringHelper = $translatableStringHelper; } - public function addRole() + public function addRole(): ?string { return null; } public function alterQuery(QueryBuilder $qb, $data) { - $qb->join('cal.user', 'u'); + if (!in_array('caluser', $qb->getAllAliases(), true)) { + $qb->join('cal.mainUser', 'caluser'); + } $where = $qb->getDQLPart('where'); - $clause = $qb->expr()->in('u.mainScope', ':scope'); + $clause = $qb->expr()->in('caluser.mainScope', ':scope'); if ($where instanceof Andx) { $where->add($clause); @@ -87,12 +90,12 @@ class ScopeFilter implements FilterInterface } return ['Filtered by agent scope: only %scopes%', [ - '%scopes%' => implode(', ou ', $scopes), + '%scopes%' => implode(', ', $scopes), ]]; } public function getTitle() { - return 'Filter by agent scope'; + return 'Filter calendars by agent scope'; } } diff --git a/src/Bundle/ChillCalendarBundle/Form/CalendarDocCreateType.php b/src/Bundle/ChillCalendarBundle/Form/CalendarDocCreateType.php new file mode 100644 index 000000000..b77886097 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Form/CalendarDocCreateType.php @@ -0,0 +1,42 @@ +add('title', TextType::class, [ + 'label' => 'chill_calendar.Document title', + 'required' => true, + ]) + ->add('doc', StoredObjectType::class, [ + 'label' => 'chill_calendar.Document object', + 'required' => true, + ]); + } + + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'data_class' => CalendarDocCreateDTO::class, + ]); + } +} diff --git a/src/Bundle/ChillCalendarBundle/Form/CalendarDocEditType.php b/src/Bundle/ChillCalendarBundle/Form/CalendarDocEditType.php new file mode 100644 index 000000000..26a9d59e2 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Form/CalendarDocEditType.php @@ -0,0 +1,41 @@ +add('title', TextType::class, [ + 'label' => 'chill_calendar.Document title', + 'required' => true, + ]) + ->add('doc', StoredObjectType::class, [ + 'label' => 'chill_calendar.Document object', + ]); + } + + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'data_class' => CalendarDocEditDTO::class, + ]); + } +} diff --git a/src/Bundle/ChillCalendarBundle/Form/CalendarType.php b/src/Bundle/ChillCalendarBundle/Form/CalendarType.php index 1dc6d8197..a2d15484f 100644 --- a/src/Bundle/ChillCalendarBundle/Form/CalendarType.php +++ b/src/Bundle/ChillCalendarBundle/Form/CalendarType.php @@ -12,18 +12,16 @@ declare(strict_types=1); namespace Chill\CalendarBundle\Form; use Chill\CalendarBundle\Entity\Calendar; -use Chill\CalendarBundle\Entity\CalendarRange; use Chill\CalendarBundle\Entity\CancelReason; -use Chill\CalendarBundle\Entity\Invite; -use Chill\MainBundle\Entity\Location; -use Chill\MainBundle\Entity\User; +use Chill\CalendarBundle\Form\DataTransformer\IdToCalendarRangeDataTransformer; +use Chill\MainBundle\Form\DataTransformer\IdToLocationDataTransformer; +use Chill\MainBundle\Form\DataTransformer\IdToUserDataTransformer; +use Chill\MainBundle\Form\DataTransformer\IdToUsersDataTransformer; use Chill\MainBundle\Form\Type\CommentType; use Chill\MainBundle\Form\Type\PrivateCommentType; -use Chill\MainBundle\Templating\TranslatableStringHelper; -use Chill\PersonBundle\Entity\Person; -use Chill\ThirdPartyBundle\Entity\ThirdParty; +use Chill\PersonBundle\Form\DataTransformer\PersonsToIdDataTransformer; +use Chill\ThirdPartyBundle\Form\DataTransformer\ThirdPartiesToIdDataTransformer; use DateTimeImmutable; -use Doctrine\Persistence\ObjectManager; use Symfony\Bridge\Doctrine\Form\Type\EntityType; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\CallbackTransformer; @@ -34,16 +32,32 @@ use Symfony\Component\OptionsResolver\OptionsResolver; class CalendarType extends AbstractType { - protected ObjectManager $om; + private IdToCalendarRangeDataTransformer $calendarRangeDataTransformer; - protected TranslatableStringHelper $translatableStringHelper; + private IdToLocationDataTransformer $idToLocationDataTransformer; + + private IdToUserDataTransformer $idToUserDataTransformer; + + private IdToUsersDataTransformer $idToUsersDataTransformer; + + private ThirdPartiesToIdDataTransformer $partiesToIdDataTransformer; + + private PersonsToIdDataTransformer $personsToIdDataTransformer; public function __construct( - TranslatableStringHelper $translatableStringHelper, - ObjectManager $om + PersonsToIdDataTransformer $personsToIdDataTransformer, + IdToUserDataTransformer $idToUserDataTransformer, + IdToUsersDataTransformer $idToUsersDataTransformer, + IdToLocationDataTransformer $idToLocationDataTransformer, + ThirdPartiesToIdDataTransformer $partiesToIdDataTransformer, + IdToCalendarRangeDataTransformer $idToCalendarRangeDataTransformer ) { - $this->translatableStringHelper = $translatableStringHelper; - $this->om = $om; + $this->personsToIdDataTransformer = $personsToIdDataTransformer; + $this->idToUserDataTransformer = $idToUserDataTransformer; + $this->idToUsersDataTransformer = $idToUsersDataTransformer; + $this->idToLocationDataTransformer = $idToLocationDataTransformer; + $this->partiesToIdDataTransformer = $partiesToIdDataTransformer; + $this->calendarRangeDataTransformer = $idToCalendarRangeDataTransformer; } public function buildForm(FormBuilderInterface $builder, array $options) @@ -64,7 +78,6 @@ class CalendarType extends AbstractType // }, // ]) ->add('sendSMS', ChoiceType::class, [ - 'required' => false, 'choices' => [ 'Oui' => true, 'Non' => false, @@ -73,36 +86,26 @@ class CalendarType extends AbstractType ]); $builder->add('mainUser', HiddenType::class); - $builder->get('mainUser') - ->addModelTransformer(new CallbackTransformer( - static function (?User $user): int { - if (null !== $user) { - $res = $user->getId(); - } else { - $res = -1; //TODO cannot be null in any ways... - } - - return $res; - }, - function (?int $userId): User { - return $this->om->getRepository(user::class)->findOneBy(['id' => (int) $userId]); - } - )); + $builder->get('mainUser')->addModelTransformer($this->idToUserDataTransformer); $builder->add('startDate', HiddenType::class); $builder->get('startDate') ->addModelTransformer(new CallbackTransformer( static function (?DateTimeImmutable $dateTimeImmutable): string { if (null !== $dateTimeImmutable) { - $res = date_format($dateTimeImmutable, 'Y-m-d H:i:s'); + $res = date_format($dateTimeImmutable, DateTimeImmutable::ATOM); } else { $res = ''; } return $res; }, - static function (?string $dateAsString): DateTimeImmutable { - return new DateTimeImmutable($dateAsString); + static function (?string $dateAsString): ?DateTimeImmutable { + if ('' === $dateAsString || null === $dateAsString) { + return null; + } + + return DateTimeImmutable::createFromFormat(DateTimeImmutable::ATOM, $dateAsString); } )); @@ -111,115 +114,41 @@ class CalendarType extends AbstractType ->addModelTransformer(new CallbackTransformer( static function (?DateTimeImmutable $dateTimeImmutable): string { if (null !== $dateTimeImmutable) { - $res = date_format($dateTimeImmutable, 'Y-m-d H:i:s'); + $res = date_format($dateTimeImmutable, DateTimeImmutable::ATOM); } else { $res = ''; } return $res; }, - static function (?string $dateAsString): DateTimeImmutable { - return new DateTimeImmutable($dateAsString); + static function (?string $dateAsString): ?DateTimeImmutable { + if ('' === $dateAsString || null === $dateAsString) { + return null; + } + + return DateTimeImmutable::createFromFormat(DateTimeImmutable::ATOM, $dateAsString); } )); $builder->add('persons', HiddenType::class); $builder->get('persons') - ->addModelTransformer(new CallbackTransformer( - static function (iterable $personsAsIterable): string { - $personIds = []; - - foreach ($personsAsIterable as $value) { - $personIds[] = $value->getId(); - } - - return implode(',', $personIds); - }, - function (?string $personsAsString): array { - return array_map( - fn (string $id): ?Person => $this->om->getRepository(Person::class)->findOneBy(['id' => (int) $id]), - explode(',', $personsAsString) - ); - } - )); + ->addModelTransformer($this->personsToIdDataTransformer); $builder->add('professionals', HiddenType::class); $builder->get('professionals') - ->addModelTransformer(new CallbackTransformer( - static function (iterable $thirdpartyAsIterable): string { - $thirdpartyIds = []; + ->addModelTransformer($this->partiesToIdDataTransformer); - foreach ($thirdpartyAsIterable as $value) { - $thirdpartyIds[] = $value->getId(); - } - - return implode(',', $thirdpartyIds); - }, - function (?string $thirdpartyAsString): array { - return array_map( - fn (string $id): ?ThirdParty => $this->om->getRepository(ThirdParty::class)->findOneBy(['id' => (int) $id]), - explode(',', $thirdpartyAsString) - ); - } - )); + $builder->add('users', HiddenType::class); + $builder->get('users') + ->addModelTransformer($this->idToUsersDataTransformer); $builder->add('calendarRange', HiddenType::class); $builder->get('calendarRange') - ->addModelTransformer(new CallbackTransformer( - static function (?CalendarRange $calendarRange): ?int { - if (null !== $calendarRange) { - $res = $calendarRange->getId(); - } else { - $res = -1; - } - - return $res; - }, - function (?string $calendarRangeId): ?CalendarRange { - if (null !== $calendarRangeId) { - $res = $this->om->getRepository(CalendarRange::class)->findOneBy(['id' => (int) $calendarRangeId]); - } else { - $res = null; - } - - return $res; - } - )); + ->addModelTransformer($this->calendarRangeDataTransformer); $builder->add('location', HiddenType::class) ->get('location') - ->addModelTransformer(new CallbackTransformer( - static function (?Location $location): string { - if (null === $location) { - return ''; - } - - return $location->getId(); - }, - function (?string $id): ?Location { - return $this->om->getRepository(Location::class)->findOneBy(['id' => (int) $id]); - } - )); - - $builder->add('invites', HiddenType::class); - $builder->get('invites') - ->addModelTransformer(new CallbackTransformer( - static function (iterable $usersAsIterable): string { - $userIds = []; - - foreach ($usersAsIterable as $value) { - $userIds[] = $value->getId(); - } - - return implode(',', $userIds); - }, - function (?string $usersAsString): array { - return array_map( - fn (string $id): ?Invite => $this->om->getRepository(Invite::class)->findOneBy(['id' => (int) $id]), - explode(',', $usersAsString) - ); - } - )); + ->addModelTransformer($this->idToLocationDataTransformer); } public function configureOptions(OptionsResolver $resolver) @@ -227,14 +156,11 @@ class CalendarType extends AbstractType $resolver->setDefaults([ 'data_class' => Calendar::class, ]); - - $resolver - ->setRequired(['accompanyingPeriod']) - ->setAllowedTypes('accompanyingPeriod', [\Chill\PersonBundle\Entity\AccompanyingPeriod::class, 'null']); } public function getBlockPrefix() { - return 'chill_calendarbundle_calendar'; + // as the js share some hardcoded items from activity, we have to rewrite block prefix + return 'chill_activitybundle_activity'; } } diff --git a/src/Bundle/ChillCalendarBundle/Form/DataTransformer/IdToCalendarRangeDataTransformer.php b/src/Bundle/ChillCalendarBundle/Form/DataTransformer/IdToCalendarRangeDataTransformer.php new file mode 100644 index 000000000..c9643b953 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Form/DataTransformer/IdToCalendarRangeDataTransformer.php @@ -0,0 +1,30 @@ +security = $security; $this->translator = $translator; - $this->authorizationHelper = $authorizationHelper; - $this->tokenStorage = $tokenStorage; } public function buildMenu($menuId, MenuItem $menu, array $parameters) { $period = $parameters['accompanyingCourse']; - if (AccompanyingPeriod::STEP_DRAFT !== $period->getStep()) { - /* + if ($this->security->isGranted(CalendarVoter::SEE, $period)) { $menu->addChild($this->translator->trans('Calendar'), [ - 'route' => 'chill_calendar_calendar_list', + 'route' => 'chill_calendar_calendar_list_by_period', 'routeParameters' => [ - 'accompanying_period_id' => $period->getId(), + 'id' => $period->getId(), ], ]) ->setExtras(['order' => 35]); - */ } } diff --git a/src/Bundle/ChillCalendarBundle/Menu/PersonMenuBuilder.php b/src/Bundle/ChillCalendarBundle/Menu/PersonMenuBuilder.php new file mode 100644 index 000000000..eccbe1ffb --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Menu/PersonMenuBuilder.php @@ -0,0 +1,52 @@ +security = $security; + $this->translator = $translator; + } + + public function buildMenu($menuId, MenuItem $menu, array $parameters) + { + $person = $parameters['person']; + + if ($this->security->isGranted(CalendarVoter::SEE, $person)) { + $menu->addChild($this->translator->trans('Calendar'), [ + 'route' => 'chill_calendar_calendar_list_by_person', + 'routeParameters' => [ + 'id' => $person->getId(), + ], ]) + ->setExtras(['order' => 198]); + } + } + + public static function getMenuIds(): array + { + return ['person']; + } +} diff --git a/src/Bundle/ChillCalendarBundle/Menu/UserMenuBuilder.php b/src/Bundle/ChillCalendarBundle/Menu/UserMenuBuilder.php index 9da2f4ffd..525039910 100644 --- a/src/Bundle/ChillCalendarBundle/Menu/UserMenuBuilder.php +++ b/src/Bundle/ChillCalendarBundle/Menu/UserMenuBuilder.php @@ -12,53 +12,29 @@ declare(strict_types=1); namespace Chill\CalendarBundle\Menu; use Chill\MainBundle\Routing\LocalMenuBuilderInterface; -use Chill\TaskBundle\Templating\UI\CountNotificationTask; use Knp\Menu\MenuItem; -use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; -use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; +use Symfony\Component\Security\Core\Security; use Symfony\Contracts\Translation\TranslatorInterface; class UserMenuBuilder implements LocalMenuBuilderInterface { - /** - * @var AuthorizationCheckerInterface - */ - public $authorizationChecker; + public TranslatorInterface $translator; - /** - * @var CountNotificationTask - */ - public $counter; - - /** - * @var TokenStorageInterface - */ - public $tokenStorage; - - /** - * @var TranslatorInterface - */ - public $translator; + private Security $security; public function __construct( - CountNotificationTask $counter, - TokenStorageInterface $tokenStorage, - TranslatorInterface $translator, - AuthorizationCheckerInterface $authorizationChecker + Security $security, + TranslatorInterface $translator ) { - $this->counter = $counter; - $this->tokenStorage = $tokenStorage; + $this->security = $security; $this->translator = $translator; - $this->authorizationChecker = $authorizationChecker; } public function buildMenu($menuId, MenuItem $menu, array $parameters) { - $user = $this->tokenStorage->getToken()->getUser(); - - if ($this->authorizationChecker->isGranted('ROLE_USER')) { + if ($this->security->isGranted('ROLE_USER')) { $menu->addChild('My calendar list', [ - 'route' => 'chill_calendar_calendar_list', + 'route' => 'chill_calendar_calendar_list_my', ]) ->setExtras([ 'order' => 9, diff --git a/src/Bundle/ChillCalendarBundle/Messenger/Doctrine/CalendarEntityListener.php b/src/Bundle/ChillCalendarBundle/Messenger/Doctrine/CalendarEntityListener.php new file mode 100644 index 000000000..94ba4974c --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Messenger/Doctrine/CalendarEntityListener.php @@ -0,0 +1,77 @@ +messageBus = $messageBus; + $this->security = $security; + } + + public function postPersist(Calendar $calendar, LifecycleEventArgs $args): void + { + if (!$calendar->preventEnqueueChanges) { + $this->messageBus->dispatch( + new CalendarMessage( + $calendar, + CalendarMessage::CALENDAR_PERSIST, + $this->security->getUser() + ) + ); + } + } + + public function postRemove(Calendar $calendar, LifecycleEventArgs $args): void + { + if (!$calendar->preventEnqueueChanges) { + $this->messageBus->dispatch( + new CalendarRemovedMessage( + $calendar, + $this->security->getUser() + ) + ); + } + } + + public function postUpdate(Calendar $calendar, LifecycleEventArgs $args): void + { + if (!$calendar->preventEnqueueChanges) { + $this->messageBus->dispatch( + new CalendarMessage( + $calendar, + CalendarMessage::CALENDAR_UPDATE, + $this->security->getUser() + ) + ); + } + } +} diff --git a/src/Bundle/ChillCalendarBundle/Messenger/Doctrine/CalendarRangeEntityListener.php b/src/Bundle/ChillCalendarBundle/Messenger/Doctrine/CalendarRangeEntityListener.php new file mode 100644 index 000000000..7628c7af4 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Messenger/Doctrine/CalendarRangeEntityListener.php @@ -0,0 +1,77 @@ +messageBus = $messageBus; + $this->security = $security; + } + + public function postPersist(CalendarRange $calendarRange, LifecycleEventArgs $eventArgs): void + { + if (!$calendarRange->preventEnqueueChanges) { + $this->messageBus->dispatch( + new CalendarRangeMessage( + $calendarRange, + CalendarRangeMessage::CALENDAR_RANGE_PERSIST, + $this->security->getUser() + ) + ); + } + } + + public function postRemove(CalendarRange $calendarRange, LifecycleEventArgs $eventArgs): void + { + if (!$calendarRange->preventEnqueueChanges) { + $this->messageBus->dispatch( + new CalendarRangeRemovedMessage( + $calendarRange, + $this->security->getUser() + ) + ); + } + } + + public function postUpdate(CalendarRange $calendarRange, LifecycleEventArgs $eventArgs): void + { + if (!$calendarRange->preventEnqueueChanges) { + $this->messageBus->dispatch( + new CalendarRangeMessage( + $calendarRange, + CalendarRangeMessage::CALENDAR_RANGE_UPDATE, + $this->security->getUser() + ) + ); + } + } +} diff --git a/src/Bundle/ChillCalendarBundle/Messenger/Handler/CalendarRangeRemoveToRemoteHandler.php b/src/Bundle/ChillCalendarBundle/Messenger/Handler/CalendarRangeRemoveToRemoteHandler.php new file mode 100644 index 000000000..4e3ae5891 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Messenger/Handler/CalendarRangeRemoveToRemoteHandler.php @@ -0,0 +1,52 @@ +remoteCalendarConnector = $remoteCalendarConnector; + $this->userRepository = $userRepository; + } + + public function __invoke(CalendarRangeRemovedMessage $calendarRangeRemovedMessage) + { + $this->remoteCalendarConnector->removeCalendarRange( + $calendarRangeRemovedMessage->getRemoteId(), + $calendarRangeRemovedMessage->getRemoteAttributes(), + $this->userRepository->find($calendarRangeRemovedMessage->getCalendarRangeUserId()) + ); + } +} diff --git a/src/Bundle/ChillCalendarBundle/Messenger/Handler/CalendarRangeToRemoteHandler.php b/src/Bundle/ChillCalendarBundle/Messenger/Handler/CalendarRangeToRemoteHandler.php new file mode 100644 index 000000000..79bcd24ee --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Messenger/Handler/CalendarRangeToRemoteHandler.php @@ -0,0 +1,64 @@ +calendarRangeRepository = $calendarRangeRepository; + $this->remoteCalendarConnector = $remoteCalendarConnector; + $this->entityManager = $entityManager; + } + + public function __invoke(CalendarRangeMessage $calendarRangeMessage): void + { + $range = $this->calendarRangeRepository->find($calendarRangeMessage->getCalendarRangeId()); + + if (null === $range) { + return; + } + + $this->remoteCalendarConnector->syncCalendarRange($range); + $range->preventEnqueueChanges = true; + + $this->entityManager->flush(); + } +} diff --git a/src/Bundle/ChillCalendarBundle/Messenger/Handler/CalendarRemoveHandler.php b/src/Bundle/ChillCalendarBundle/Messenger/Handler/CalendarRemoveHandler.php new file mode 100644 index 000000000..f087766ec --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Messenger/Handler/CalendarRemoveHandler.php @@ -0,0 +1,62 @@ +remoteCalendarConnector = $remoteCalendarConnector; + $this->calendarRangeRepository = $calendarRangeRepository; + $this->userRepository = $userRepository; + } + + public function __invoke(CalendarRemovedMessage $message) + { + if (null !== $message->getAssociatedCalendarRangeId()) { + $associatedRange = $this->calendarRangeRepository->find($message->getAssociatedCalendarRangeId()); + } else { + $associatedRange = null; + } + + $this->remoteCalendarConnector->removeCalendar( + $message->getRemoteId(), + $message->getRemoteAttributes(), + $this->userRepository->find($message->getCalendarUserId()), + $associatedRange + ); + } +} diff --git a/src/Bundle/ChillCalendarBundle/Messenger/Handler/CalendarToRemoteHandler.php b/src/Bundle/ChillCalendarBundle/Messenger/Handler/CalendarToRemoteHandler.php new file mode 100644 index 000000000..361fd33c2 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Messenger/Handler/CalendarToRemoteHandler.php @@ -0,0 +1,119 @@ +calendarConnector = $calendarConnector; + $this->calendarRepository = $calendarRepository; + $this->calendarRangeRepository = $calendarRangeRepository; + $this->entityManager = $entityManager; + $this->userRepository = $userRepository; + $this->inviteRepository = $inviteRepository; + } + + public function __invoke(CalendarMessage $calendarMessage) + { + $calendar = $this->calendarRepository->find($calendarMessage->getCalendarId()); + + if (null === $calendar) { + return; + } + + if (null !== $calendarMessage->getPreviousCalendarRangeId()) { + $previousCalendarRange = $this->calendarRangeRepository + ->find($calendarMessage->getPreviousCalendarRangeId()); + } else { + $previousCalendarRange = null; + } + + if (null !== $calendarMessage->getPreviousMainUserId()) { + $previousMainUser = $this->userRepository + ->find($calendarMessage->getPreviousMainUserId()); + } else { + $previousMainUser = null; + } + + $newInvites = array_filter( + array_map( + function ($id) { return $this->inviteRepository->find($id); }, + $calendarMessage->getNewInvitesIds(), + ), + static function (?Invite $invite) { return null !== $invite; } + ); + + $this->calendarConnector->syncCalendar( + $calendar, + $calendarMessage->getAction(), + $previousCalendarRange, + $previousMainUser, + $calendarMessage->getOldInvites(), + $newInvites + ); + + $calendar->preventEnqueueChanges = true; + + if ($calendar->hasCalendarRange()) { + $calendar->getCalendarRange()->preventEnqueueChanges = true; + } + + if ($previousCalendarRange instanceof CalendarRange) { + $previousCalendarRange->preventEnqueueChanges = true; + } + + $this->entityManager->flush(); + } +} diff --git a/src/Bundle/ChillCalendarBundle/Messenger/Handler/InviteUpdateHandler.php b/src/Bundle/ChillCalendarBundle/Messenger/Handler/InviteUpdateHandler.php new file mode 100644 index 000000000..e1df3ac3d --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Messenger/Handler/InviteUpdateHandler.php @@ -0,0 +1,57 @@ +em = $em; + $this->inviteRepository = $inviteRepository; + $this->remoteCalendarConnector = $remoteCalendarConnector; + } + + public function __invoke(InviteUpdateMessage $inviteUpdateMessage): void + { + if (null === $invite = $this->inviteRepository->find($inviteUpdateMessage->getInviteId())) { + return; + } + + $this->remoteCalendarConnector->syncInvite($invite); + + $this->em->flush(); + } +} diff --git a/src/Bundle/ChillCalendarBundle/Messenger/Handler/MSGraphChangeNotificationHandler.php b/src/Bundle/ChillCalendarBundle/Messenger/Handler/MSGraphChangeNotificationHandler.php new file mode 100644 index 000000000..9c1e84511 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Messenger/Handler/MSGraphChangeNotificationHandler.php @@ -0,0 +1,110 @@ +calendarRangeRepository = $calendarRangeRepository; + $this->calendarRangeSyncer = $calendarRangeSyncer; + $this->calendarRepository = $calendarRepository; + $this->calendarSyncer = $calendarSyncer; + $this->em = $em; + $this->logger = $logger; + $this->mapCalendarToUser = $mapCalendarToUser; + $this->userRepository = $userRepository; + } + + public function __invoke(MSGraphChangeNotificationMessage $changeNotificationMessage): void + { + $user = $this->userRepository->find($changeNotificationMessage->getUserId()); + + if (null === $user) { + $this->logger->warning(__CLASS__ . ' notification concern non-existent user, skipping'); + + return; + } + + foreach ($changeNotificationMessage->getContent()['value'] as $notification) { + $secret = $this->mapCalendarToUser->getSubscriptionSecret($user); + + if ($secret !== ($notification['clientState'] ?? -1)) { + $this->logger->warning(__CLASS__ . ' could not validate secret, skipping'); + + continue; + } + + $remoteId = $notification['resourceData']['id']; + + // is this a calendar range ? + if (null !== $calendarRange = $this->calendarRangeRepository->findOneBy(['remoteId' => $remoteId])) { + $this->calendarRangeSyncer->handleCalendarRangeSync($calendarRange, $notification, $user); + $this->em->flush(); + } elseif (null !== $calendar = $this->calendarRepository->findOneBy(['remoteId' => $remoteId])) { + $this->calendarSyncer->handleCalendarSync($calendar, $notification, $user); + $this->em->flush(); + } else { + $this->logger->info(__CLASS__ . ' id not found in any calendar nor calendar range'); + } + } + + $this->em->flush(); + } +} diff --git a/src/Bundle/ChillCalendarBundle/Messenger/Message/CalendarMessage.php b/src/Bundle/ChillCalendarBundle/Messenger/Message/CalendarMessage.php new file mode 100644 index 000000000..ec5977ad3 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Messenger/Message/CalendarMessage.php @@ -0,0 +1,111 @@ + + */ + private array $oldInvites = []; + + private ?int $previousCalendarRangeId = null; + + private ?int $previousMainUserId = null; + + public function __construct( + Calendar $calendar, + string $action, + User $byUser + ) { + $this->calendarId = $calendar->getId(); + $this->byUserId = $byUser->getId(); + $this->action = $action; + $this->previousCalendarRangeId = null !== $calendar->previousCalendarRange ? + $calendar->previousCalendarRange->getId() : null; + $this->previousMainUserId = null !== $calendar->previousMainUser ? + $calendar->previousMainUser->getId() : null; + $this->newInvitesIds = array_map(static fn (Invite $i) => $i->getId(), $calendar->newInvites); + $this->oldInvites = array_map(static function (Invite $i) { + return [ + 'inviteId' => $i->getId(), + 'userId' => $i->getUser()->getId(), + 'userEmail' => $i->getUser()->getEmail(), + 'userLabel' => $i->getUser()->getLabel(), + ]; + }, $calendar->oldInvites); + } + + public function getAction(): string + { + return $this->action; + } + + public function getByUserId(): ?int + { + return $this->byUserId; + } + + public function getCalendarId(): ?int + { + return $this->calendarId; + } + + /** + * @return array|int[]|null[] + */ + public function getNewInvitesIds(): array + { + return $this->newInvitesIds; + } + + /** + * @return array + */ + public function getOldInvites(): array + { + return $this->oldInvites; + } + + public function getPreviousCalendarRangeId(): ?int + { + return $this->previousCalendarRangeId; + } + + public function getPreviousMainUserId(): ?int + { + return $this->previousMainUserId; + } +} diff --git a/src/Bundle/ChillCalendarBundle/Messenger/Message/CalendarRangeMessage.php b/src/Bundle/ChillCalendarBundle/Messenger/Message/CalendarRangeMessage.php new file mode 100644 index 000000000..526826ff8 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Messenger/Message/CalendarRangeMessage.php @@ -0,0 +1,60 @@ +action = $action; + $this->calendarRangeId = $calendarRange->getId(); + + if (null !== $byUser) { + $this->byUserId = $byUser->getId(); + } + } + + public function getAction(): string + { + return $this->action; + } + + public function getByUserId(): ?int + { + return $this->byUserId; + } + + public function getCalendarRangeId(): ?int + { + return $this->calendarRangeId; + } +} diff --git a/src/Bundle/ChillCalendarBundle/Messenger/Message/CalendarRangeRemovedMessage.php b/src/Bundle/ChillCalendarBundle/Messenger/Message/CalendarRangeRemovedMessage.php new file mode 100644 index 000000000..783484592 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Messenger/Message/CalendarRangeRemovedMessage.php @@ -0,0 +1,64 @@ +remoteId = $calendarRange->getRemoteId(); + $this->remoteAttributes = $calendarRange->getRemoteAttributes(); + $this->calendarRangeUserId = $calendarRange->getUser()->getId(); + + if (null !== $byUser) { + $this->byUserId = $byUser->getId(); + } + } + + public function getByUserId(): ?int + { + return $this->byUserId; + } + + public function getCalendarRangeUserId(): ?int + { + return $this->calendarRangeUserId; + } + + public function getRemoteAttributes(): array + { + return $this->remoteAttributes; + } + + public function getRemoteId(): string + { + return $this->remoteId; + } +} diff --git a/src/Bundle/ChillCalendarBundle/Messenger/Message/CalendarRemovedMessage.php b/src/Bundle/ChillCalendarBundle/Messenger/Message/CalendarRemovedMessage.php new file mode 100644 index 000000000..65831ebe0 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Messenger/Message/CalendarRemovedMessage.php @@ -0,0 +1,75 @@ +remoteId = $calendar->getRemoteId(); + $this->remoteAttributes = $calendar->getRemoteAttributes(); + $this->calendarUserId = $calendar->getMainUser()->getId(); + + if ($calendar->hasCalendarRange()) { + $this->associatedCalendarRangeId = $calendar->getCalendarRange()->getId(); + } + + if (null !== $byUser) { + $this->byUserId = $byUser->getId(); + } + } + + public function getAssociatedCalendarRangeId(): ?int + { + return $this->associatedCalendarRangeId; + } + + public function getByUserId(): ?int + { + return $this->byUserId; + } + + public function getCalendarUserId(): ?int + { + return $this->calendarUserId; + } + + public function getRemoteAttributes(): array + { + return $this->remoteAttributes; + } + + public function getRemoteId(): string + { + return $this->remoteId; + } +} diff --git a/src/Bundle/ChillCalendarBundle/Messenger/Message/InviteUpdateMessage.php b/src/Bundle/ChillCalendarBundle/Messenger/Message/InviteUpdateMessage.php new file mode 100644 index 000000000..35f78fc7d --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Messenger/Message/InviteUpdateMessage.php @@ -0,0 +1,45 @@ +inviteId = $invite->getId(); + $this->byUserId = $byUser->getId(); + } + + public function getByUserId(): int + { + return $this->byUserId; + } + + public function getInviteId(): int + { + return $this->inviteId; + } +} diff --git a/src/Bundle/ChillCalendarBundle/Messenger/Message/MSGraphChangeNotificationMessage.php b/src/Bundle/ChillCalendarBundle/Messenger/Message/MSGraphChangeNotificationMessage.php new file mode 100644 index 000000000..f1d3b6b04 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Messenger/Message/MSGraphChangeNotificationMessage.php @@ -0,0 +1,42 @@ +content = $content; + $this->userId = $userId; + } + + public function getContent(): array + { + return $this->content; + } + + public function getUserId(): int + { + return $this->userId; + } +} diff --git a/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/AddressConverter.php b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/AddressConverter.php new file mode 100644 index 000000000..8b86ba0ec --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/AddressConverter.php @@ -0,0 +1,48 @@ +addressRender = $addressRender; + $this->translatableStringHelper = $translatableStringHelper; + } + + public function addressToRemote(Address $address): array + { + return [ + 'city' => $address->getPostcode()->getName(), + 'postalCode' => $address->getPostcode()->getCode(), + 'countryOrRegion' => $this->translatableStringHelper->localize($address->getPostcode()->getCountry()->getName()), + 'street' => $address->isNoAddress() ? '' : + implode(', ', $this->addressRender->renderLines($address, false, false)), + 'state' => '', + ]; + } +} diff --git a/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/EventsOnUserSubscriptionCreator.php b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/EventsOnUserSubscriptionCreator.php new file mode 100644 index 000000000..c400b6694 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/EventsOnUserSubscriptionCreator.php @@ -0,0 +1,140 @@ +logger = $logger; + $this->machineHttpClient = $machineHttpClient; + $this->mapCalendarToUser = $mapCalendarToUser; + $this->urlGenerator = $urlGenerator; + } + + /** + * @throws ClientExceptionInterface + * @throws \Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface + * @throws \Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface + * @throws \Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface + * @throws \Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface + * + * @return array + */ + public function createSubscriptionForUser(User $user, DateTimeImmutable $expiration): array + { + if (null === $userId = $this->mapCalendarToUser->getUserId($user)) { + throw new LogicException('no user id'); + } + + $subscription = [ + 'changeType' => 'deleted,updated', + 'notificationUrl' => $this->urlGenerator->generate( + 'chill_calendar_remote_msgraph_incoming_webhook_events', + ['userId' => $user->getId()], + UrlGeneratorInterface::ABSOLUTE_URL + ), + 'resource' => "/users/{$userId}/calendar/events", + 'clientState' => $secret = base64_encode(openssl_random_pseudo_bytes(92, $cstrong)), + 'expirationDateTime' => $expiration->format(DateTimeImmutable::ATOM), + ]; + + try { + $subs = $this->machineHttpClient->request( + 'POST', + '/v1.0/subscriptions', + [ + 'json' => $subscription, + ] + )->toArray(); + } catch (ClientExceptionInterface $e) { + $this->logger->error('could not create subscription for user events', [ + 'body' => $e->getResponse()->getContent(false), + ]); + + return ['secret' => '', 'id' => '', 'expiration' => 0]; + } + + return ['secret' => $secret, 'id' => $subs['id'], 'expiration' => $expiration->getTimestamp()]; + } + + /** + * @throws ClientExceptionInterface + * @throws \Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface + * @throws \Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface + * @throws \Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface + * @throws \Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface + * + * @return array + */ + public function renewSubscriptionForUser(User $user, DateTimeImmutable $expiration): array + { + if (null === $userId = $this->mapCalendarToUser->getUserId($user)) { + throw new LogicException('no user id'); + } + + if (null === $subscriptionId = $this->mapCalendarToUser->getActiveSubscriptionId($user)) { + throw new LogicException('no user id'); + } + + $subscription = [ + 'expirationDateTime' => $expiration->format(DateTimeImmutable::ATOM), + ]; + + try { + $subs = $this->machineHttpClient->request( + 'PATCH', + "/v1.0/subscriptions/{$subscriptionId}", + [ + 'json' => $subscription, + ] + )->toArray(); + } catch (ClientExceptionInterface $e) { + $this->logger->error('could not patch subscription for user events', [ + 'body' => $e->getResponse()->getContent(false), + ]); + + return ['secret' => '', 'id' => '', 'expiration' => 0]; + } + + return ['secret' => $subs['clientState'], 'id' => $subs['id'], 'expiration' => $expiration->getTimestamp()]; + } +} diff --git a/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/LocationConverter.php b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/LocationConverter.php new file mode 100644 index 000000000..396dfd931 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/LocationConverter.php @@ -0,0 +1,53 @@ +addressConverter = $addressConverter; + } + + public function locationToRemote(Location $location): array + { + $results = []; + + if ($location->hasAddress()) { + $results['address'] = $this->addressConverter->addressToRemote($location->getAddress()); + + if ($location->getAddress()->hasAddressReference() && $location->getAddress()->getAddressReference()->hasPoint()) { + $results['coordinates'] = [ + 'latitude' => $location->getAddress()->getAddressReference()->getPoint()->getLat(), + 'longitude' => $location->getAddress()->getAddressReference()->getPoint()->getLon(), + ]; + } + } + + if (null !== $location->getName()) { + $results['displayName'] = $location->getName(); + } + + return $results; + } +} diff --git a/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/MSGraphUserRepository.php b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/MSGraphUserRepository.php new file mode 100644 index 000000000..c523a1e92 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/MSGraphUserRepository.php @@ -0,0 +1,84 @@ +'msgraph' ?? 'subscription_events_expiration' + OR (attributes->'msgraph' ?? 'subscription_events_expiration' AND (attributes->'msgraph'->>'subscription_events_expiration')::int < EXTRACT(EPOCH FROM (NOW() + :interval::interval))) + LIMIT :limit OFFSET :offset + ; + SQL; + + private EntityManagerInterface $entityManager; + + public function __construct(EntityManagerInterface $entityManager) + { + $this->entityManager = $entityManager; + } + + public function countByMostOldSubscriptionOrWithoutSubscriptionOrData(DateInterval $interval): int + { + $rsm = new ResultSetMapping(); + $rsm->addScalarResult('c', 'c'); + + $sql = strtr(self::MOST_OLD_SUBSCRIPTION_OR_ANY_MS_GRAPH, [ + '{select}' => 'COUNT(u) AS c', + 'LIMIT :limit OFFSET :offset' => '', + ]); + + return $this->entityManager->createNativeQuery($sql, $rsm)->setParameters([ + 'interval' => $interval, + ])->getSingleScalarResult(); + } + + /** + * @return array|User[] + */ + public function findByMostOldSubscriptionOrWithoutSubscriptionOrData(DateInterval $interval, int $limit = 50, int $offset = 0): array + { + $rsm = new ResultSetMappingBuilder($this->entityManager); + $rsm->addRootEntityFromClassMetadata(User::class, 'u'); + + return $this->entityManager->createNativeQuery( + strtr(self::MOST_OLD_SUBSCRIPTION_OR_ANY_MS_GRAPH, ['{select}' => $rsm->generateSelectClause()]), + $rsm + )->setParameters([ + 'interval' => $interval, + 'limit' => $limit, + 'offset' => $offset, + ])->getResult(); + } +} diff --git a/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/MachineHttpClient.php b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/MachineHttpClient.php new file mode 100644 index 000000000..cc1692fb7 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/MachineHttpClient.php @@ -0,0 +1,82 @@ +decoratedClient = $decoratedClient ?? \Symfony\Component\HttpClient\HttpClient::create(); + $this->machineTokenStorage = $machineTokenStorage; + } + + /** + * @throws \Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface + * @throws LogicException if method is not supported + */ + public function request(string $method, string $url, array $options = []): ResponseInterface + { + $options['headers'] = array_merge( + $options['headers'] ?? [], + $this->getAuthorizationHeaders($this->machineTokenStorage->getToken()) + ); + $options['base_uri'] = 'https://graph.microsoft.com/v1.0/'; + + switch ($method) { + case 'GET': + case 'HEAD': + case 'DELETE': + $options['headers']['Accept'] = 'application/json'; + + break; + + case 'POST': + case 'PUT': + case 'PATCH': + $options['headers']['Content-Type'] = 'application/json'; + + break; + + default: + throw new LogicException("Method not supported: {$method}"); + } + + return $this->decoratedClient->request($method, $url, $options); + } + + public function stream($responses, ?float $timeout = null): ResponseStreamInterface + { + return $this->decoratedClient->stream($responses, $timeout); + } +} diff --git a/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/MachineTokenStorage.php b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/MachineTokenStorage.php new file mode 100644 index 000000000..ac62d44ac --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/MachineTokenStorage.php @@ -0,0 +1,57 @@ +azure = $azure; + $this->chillRedis = $chillRedis; + } + + public function getToken(): AccessTokenInterface + { + if (null === $this->accessToken || $this->accessToken->hasExpired()) { + $this->accessToken = $this->azure->getAccessToken('client_credentials', [ + 'scope' => 'https://graph.microsoft.com/.default', + ]); + } + + return $this->accessToken; + } + + public function storeToken(AccessToken $token): void + { + $this->chillRedis->set(self::KEY, serialize($token)); + } +} diff --git a/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/MapCalendarToUser.php b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/MapCalendarToUser.php new file mode 100644 index 000000000..504d48ffc --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/MapCalendarToUser.php @@ -0,0 +1,200 @@ +machineHttpClient = $machineHttpClient; + $this->logger = $logger; + } + + public function getActiveSubscriptionId(User $user): string + { + if (!array_key_exists(self::METADATA_KEY, $user->getAttributes())) { + throw new LogicException('do not contains msgraph metadata'); + } + + if (!array_key_exists(self::ID_SUBSCRIPTION_EVENT, $user->getAttributes()[self::METADATA_KEY])) { + throw new LogicException('do not contains metadata for subscription id'); + } + + return $user->getAttributes()[self::METADATA_KEY][self::ID_SUBSCRIPTION_EVENT]; + } + + public function getCalendarId(User $user): ?string + { + if (null === $msKey = ($user->getAttributes()[self::METADATA_KEY] ?? null)) { + return null; + } + + return $msKey['defaultCalendarId'] ?? null; + } + + public function getDefaultUserCalendar(string $idOrUserPrincipalName): ?array + { + $value = $this->machineHttpClient->request('GET', "users/{$idOrUserPrincipalName}/calendars", [ + 'query' => ['$filter' => 'isDefaultCalendar eq true'], + ])->toArray()['value']; + + return $value[0] ?? null; + } + + public function getSubscriptionSecret(User $user): string + { + if (!array_key_exists(self::METADATA_KEY, $user->getAttributes())) { + throw new LogicException('do not contains msgraph metadata'); + } + + if (!array_key_exists(self::SECRET_SUBSCRIPTION_EVENT, $user->getAttributes()[self::METADATA_KEY])) { + throw new LogicException('do not contains secret in msgraph'); + } + + return $user->getAttributes()[self::METADATA_KEY][self::SECRET_SUBSCRIPTION_EVENT]; + } + + public function getUserByEmail(string $email): ?array + { + $value = $this->machineHttpClient->request('GET', 'users', [ + 'query' => ['$filter' => "mail eq '{$email}'"], + ])->toArray()['value']; + + return $value[0] ?? null; + } + + public function getUserId(User $user): ?string + { + if (null === $msKey = ($user->getAttributes()[self::METADATA_KEY] ?? null)) { + return null; + } + + return $msKey['id'] ?? null; + } + + public function hasActiveSubscription(User $user): bool + { + if (!array_key_exists(self::METADATA_KEY, $user->getAttributes())) { + return false; + } + + if (!array_key_exists(self::EXPIRATION_SUBSCRIPTION_EVENT, $user->getAttributes()[self::METADATA_KEY])) { + return false; + } + + return $user->getAttributes()[self::METADATA_KEY][self::EXPIRATION_SUBSCRIPTION_EVENT] + >= (new DateTimeImmutable('now'))->getTimestamp(); + } + + public function hasSubscriptionSecret(User $user): bool + { + if (!array_key_exists(self::METADATA_KEY, $user->getAttributes())) { + return false; + } + + return array_key_exists(self::SECRET_SUBSCRIPTION_EVENT, $user->getAttributes()[self::METADATA_KEY]); + } + + public function hasUserId(User $user): bool + { + if (null === $user->getEmail() || '' === $user->getEmail()) { + return false; + } + + if (!array_key_exists(self::METADATA_KEY, $user->getAttributes())) { + return false; + } + + return array_key_exists('id', $user->getAttributes()[self::METADATA_KEY]); + } + + public function writeMetadata(User $user): User + { + if (null === $user->getEmail() || '' === $user->getEmail()) { + return $user; + } + + if (null === $userData = $this->getUserByEmail($user->getEmailCanonical())) { + $this->logger->warning('[MapCalendarToUser] could not find user on msgraph', ['userId' => $user->getId(), 'email' => $user->getEmailCanonical()]); + + return $this->writeNullData($user); + } + + if (null === $defaultCalendar = $this->getDefaultUserCalendar($userData['id'])) { + $this->logger->warning('[MapCalendarToUser] could not find default calendar', ['userId' => $user->getId(), 'email' => $user->getEmailCanonical()]); + + return $this->writeNullData($user); + } + + return $user->setAttributes([self::METADATA_KEY => [ + 'id' => $userData['id'], + 'userPrincipalName' => $userData['userPrincipalName'], + 'defaultCalendarId' => $defaultCalendar['id'], + ]]); + } + + /** + * @param int $expiration the expiration time as unix timestamp + */ + public function writeSubscriptionMetadata( + User $user, + int $expiration, + ?string $id = null, + ?string $secret = null + ): void { + $user->setAttributeByDomain(self::METADATA_KEY, self::EXPIRATION_SUBSCRIPTION_EVENT, $expiration); + + if (null !== $id) { + $user->setAttributeByDomain(self::METADATA_KEY, self::ID_SUBSCRIPTION_EVENT, $id); + } + + if (null !== $secret) { + $user->setAttributeByDomain(self::METADATA_KEY, self::SECRET_SUBSCRIPTION_EVENT, $secret); + } + } + + private function writeNullData(User $user): User + { + return $user->unsetAttribute(self::METADATA_KEY); + } +} diff --git a/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/OnBehalfOfUserHttpClient.php b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/OnBehalfOfUserHttpClient.php new file mode 100644 index 000000000..9777bf4c0 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/OnBehalfOfUserHttpClient.php @@ -0,0 +1,77 @@ +decoratedClient = $decoratedClient ?? \Symfony\Component\HttpClient\HttpClient::create(); + $this->tokenStorage = $tokenStorage; + } + + public function request(string $method, string $url, array $options = []): ResponseInterface + { + $options['headers'] = array_merge( + $options['headers'] ?? [], + $this->getAuthorizationHeaders($this->tokenStorage->getToken()) + ); + $options['base_uri'] = 'https://graph.microsoft.com/v1.0/'; + + switch ($method) { + case 'GET': + case 'HEAD': + $options['headers']['Accept'] = 'application/json'; + + break; + + case 'POST': + case 'PUT': + case 'PATCH': + $options['headers']['Content-Type'] = 'application/json'; + + break; + + default: + throw new LogicException("Method not supported: {$method}"); + } + + return $this->decoratedClient->request($method, $url, $options); + } + + public function stream($responses, ?float $timeout = null): ResponseStreamInterface + { + return $this->decoratedClient->stream($responses, $timeout); + } +} diff --git a/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/OnBehalfOfUserTokenStorage.php b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/OnBehalfOfUserTokenStorage.php new file mode 100644 index 000000000..28f68e0e9 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/OnBehalfOfUserTokenStorage.php @@ -0,0 +1,72 @@ +azure = $azure; + $this->session = $session; + } + + public function getToken(): AccessToken + { + /** @var ?AccessToken $token */ + $token = $this->session->get(self::MS_GRAPH_ACCESS_TOKEN, null); + + if (null === $token) { + throw new LogicException('unexisting token'); + } + + if ($token->hasExpired()) { + $token = $this->azure->getAccessToken('refresh_token', [ + 'refresh_token' => $token->getRefreshToken(), + ]); + + $this->setToken($token); + } + + return $token; + } + + public function hasToken(): bool + { + return $this->session->has(self::MS_GRAPH_ACCESS_TOKEN); + } + + public function setToken(AccessToken $token): void + { + $this->session->set(self::MS_GRAPH_ACCESS_TOKEN, $token); + } +} diff --git a/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/RemoteEventConverter.php b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/RemoteEventConverter.php new file mode 100644 index 000000000..ac1de552a --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/RemoteEventConverter.php @@ -0,0 +1,284 @@ +engine = $engine; + $this->locationConverter = $locationConverter; + $this->logger = $logger; + $this->translator = $translator; + $this->personRender = $personRender; + $this->defaultDateTimeZone = (new DateTimeImmutable())->getTimezone(); + $this->remoteDateTimeZone = self::getRemoteTimeZone(); + } + + /** + * Transform a CalendarRange into a representation suitable for storing into MSGraph. + * + * @return array an array representation for event in MS Graph + */ + public function calendarRangeToEvent(CalendarRange $calendarRange): array + { + return [ + 'subject' => $this->translator->trans('remote_calendar.calendar_range_title'), + 'start' => [ + 'dateTime' => $calendarRange->getStartDate()->setTimezone($this->remoteDateTimeZone) + ->format(self::REMOTE_DATE_FORMAT), + 'timeZone' => 'UTC', + ], + 'end' => [ + 'dateTime' => $calendarRange->getEndDate()->setTimezone($this->remoteDateTimeZone) + ->format(self::REMOTE_DATE_FORMAT), + 'timeZone' => 'UTC', + ], + 'attendees' => [ + [ + 'emailAddress' => [ + 'address' => $calendarRange->getUser()->getEmailCanonical(), + 'name' => $calendarRange->getUser()->getLabel(), + ], + ], + ], + 'isReminderOn' => false, + 'location' => $this->locationConverter->locationToRemote($calendarRange->getLocation()), + ]; + } + + public function calendarToEvent(Calendar $calendar): array + { + $result = array_merge( + [ + 'subject' => '[Chill] ' . + implode( + ', ', + $calendar->getPersons()->map(function (Person $p) { + return $this->personRender->renderString($p, []); + })->toArray() + ), + 'start' => [ + 'dateTime' => $calendar->getStartDate()->setTimezone($this->remoteDateTimeZone) + ->format(self::REMOTE_DATE_FORMAT), + 'timeZone' => 'UTC', + ], + 'end' => [ + 'dateTime' => $calendar->getEndDate()->setTimezone($this->remoteDateTimeZone) + ->format(self::REMOTE_DATE_FORMAT), + 'timeZone' => 'UTC', + ], + 'allowNewTimeProposals' => false, + 'transactionId' => 'calendar_' . $calendar->getId(), + 'body' => [ + 'contentType' => 'text', + 'content' => $this->engine->render( + '@ChillCalendar/MSGraph/calendar_event_body.html.twig', + ['calendar' => $calendar] + ), + ], + 'responseRequested' => true, + 'isReminderOn' => false, + ], + $this->calendarToEventAttendeesOnly($calendar) + ); + + if ($calendar->hasLocation()) { + $result['location'] = $this->locationConverter->locationToRemote($calendar->getLocation()); + } + + return $result; + } + + public function calendarToEventAttendeesOnly(Calendar $calendar): array + { + return [ + 'attendees' => $calendar->getInvites()->map( + function (Invite $i) { + return $this->buildInviteToAttendee($i); + } + )->toArray(), + ]; + } + + public function convertAvailabilityToRemoteEvent(array $event): RemoteEvent + { + $startDate = + DateTimeImmutable::createFromFormat(self::REMOTE_DATE_FORMAT, $event['start']['dateTime'], $this->remoteDateTimeZone) + ->setTimezone($this->defaultDateTimeZone); + $endDate = + DateTimeImmutable::createFromFormat(self::REMOTE_DATE_FORMAT, $event['end']['dateTime'], $this->remoteDateTimeZone) + ->setTimezone($this->defaultDateTimeZone); + + return new RemoteEvent( + uniqid('generated_'), + $this->translator->trans('remote_ms_graph.freebusy_statuses.' . $event['status']), + '', + $startDate, + $endDate + ); + } + + public static function convertStringDateWithoutTimezone(string $date): DateTimeImmutable + { + $d = DateTimeImmutable::createFromFormat( + self::REMOTE_DATETIME_WITHOUT_TZ_FORMAT, + $date, + self::getRemoteTimeZone() + ); + + if (false === $d) { + throw new RuntimeException("could not convert string date to datetime: {$date}"); + } + + return $d->setTimezone((new DateTimeImmutable())->getTimezone()); + } + + public static function convertStringDateWithTimezone(string $date): DateTimeImmutable + { + $d = DateTimeImmutable::createFromFormat(self::REMOTE_DATETIMEZONE_FORMAT, $date); + + if (false === $d) { + throw new RuntimeException("could not convert string date to datetime: {$date}"); + } + + $d->setTimezone((new DateTimeImmutable())->getTimezone()); + + return $d; + } + + public function convertToRemote(array $event): RemoteEvent + { + $startDate = + DateTimeImmutable::createFromFormat(self::REMOTE_DATE_FORMAT, $event['start']['dateTime'], $this->remoteDateTimeZone) + ->setTimezone($this->defaultDateTimeZone); + $endDate = + DateTimeImmutable::createFromFormat(self::REMOTE_DATE_FORMAT, $event['end']['dateTime'], $this->remoteDateTimeZone) + ->setTimezone($this->defaultDateTimeZone); + + return new RemoteEvent( + $event['id'], + $event['subject'], + '', + $startDate, + $endDate, + $event['isAllDay'] + ); + } + + public function getLastModifiedDate(array $event): DateTimeImmutable + { + $date = DateTimeImmutable::createFromFormat(self::REMOTE_DATETIMEZONE_FORMAT, $event['lastModifiedDateTime']); + + if (false === $date) { + $date = DateTimeImmutable::createFromFormat(self::REMOTE_DATETIMEZONE_FORMAT_ALT, $event['lastModifiedDateTime']); + } + + if (false === $date) { + $this->logger->error(self::class . ' Could not convert lastModifiedDate', [ + 'actual' => $event['lastModifiedDateTime'], + 'format' => self::REMOTE_DATETIMEZONE_FORMAT, + 'format_alt' => self::REMOTE_DATETIMEZONE_FORMAT_ALT, + ]); + + throw new RuntimeException(sprintf( + 'could not convert lastModifiedDate: %s, expected format: %s', + $event['lastModifiedDateTime'], + self::REMOTE_DATETIMEZONE_FORMAT . ' and ' . self::REMOTE_DATETIMEZONE_FORMAT_ALT + )); + } + + return $date; + } + + /** + * Return a string which format a DateTime to string. To be used in POST requests,. + */ + public static function getRemoteDateTimeSimpleFormat(): string + { + return 'Y-m-d\TH:i:s'; + } + + public static function getRemoteTimeZone(): DateTimeZone + { + return new DateTimeZone('UTC'); + } + + private function buildInviteToAttendee(Invite $invite): array + { + return [ + 'emailAddress' => [ + 'address' => $invite->getUser()->getEmail(), + 'name' => $invite->getUser()->getLabel(), + ], + 'type' => 'Required', + ]; + } +} diff --git a/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/RemoteToLocalSync/CalendarRangeSyncer.php b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/RemoteToLocalSync/CalendarRangeSyncer.php new file mode 100644 index 000000000..a9227282d --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/RemoteToLocalSync/CalendarRangeSyncer.php @@ -0,0 +1,110 @@ +em = $em; + $this->logger = $logger; + $this->machineHttpClient = $machineHttpClient; + } + + public function handleCalendarRangeSync(CalendarRange $calendarRange, array $notification, User $user): void + { + switch ($notification['changeType']) { + case 'deleted': + // test if the notification is not linked to a Calendar + if (null !== $calendarRange->getCalendar()) { + return; + } + $calendarRange->preventEnqueueChanges = true; + + $this->logger->info(__CLASS__ . ' remove a calendar range because deleted on remote calendar'); + $this->em->remove($calendarRange); + + break; + + case 'updated': + try { + $new = $this->machineHttpClient->request( + 'GET', + $notification['resource'] + )->toArray(); + } catch (ClientExceptionInterface $clientException) { + $this->logger->warning(__CLASS__ . ' could not retrieve event from ms graph. Already deleted ?', [ + 'calendarRangeId' => $calendarRange->getId(), + 'remoteEventId' => $notification['resource'], + ]); + + throw $clientException; + } + + $lastModified = RemoteEventConverter::convertStringDateWithTimezone($new['lastModifiedDateTime']); + + if ($calendarRange->getRemoteAttributes()['lastModifiedDateTime'] === $lastModified->getTimestamp()) { + $this->logger->info(__CLASS__ . ' change key is equals. Source is probably a local update', [ + 'calendarRangeId' => $calendarRange->getId(), + 'remoteEventId' => $notification['resource'], + ]); + + return; + } + + $startDate = RemoteEventConverter::convertStringDateWithoutTimezone($new['start']['dateTime']); + $endDate = RemoteEventConverter::convertStringDateWithoutTimezone($new['end']['dateTime']); + + $calendarRange + ->setStartDate($startDate)->setEndDate($endDate) + ->addRemoteAttributes([ + 'lastModifiedDateTime' => $lastModified->getTimestamp(), + 'changeKey' => $new['changeKey'], + ]) + ->preventEnqueueChanges = true; + + break; + + default: + throw new RuntimeException('This changeType is not suppored: ' . $notification['changeType']); + } + } +} diff --git a/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/RemoteToLocalSync/CalendarSyncer.php b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/RemoteToLocalSync/CalendarSyncer.php new file mode 100644 index 000000000..984e77d87 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/RemoteToLocalSync/CalendarSyncer.php @@ -0,0 +1,189 @@ +logger = $logger; + $this->machineHttpClient = $machineHttpClient; + $this->userRepository = $userRepository; + } + + public function handleCalendarSync(Calendar $calendar, array $notification, User $user): void + { + switch ($notification['changeType']) { + case 'deleted': + $this->handleDeleteCalendar($calendar, $notification, $user); + + break; + + case 'updated': + $this->handleUpdateCalendar($calendar, $notification, $user); + + break; + + default: + throw new RuntimeException('this change type is not supported: ' . $notification['changeType']); + } + } + + private function handleDeleteCalendar(Calendar $calendar, array $notification, User $user): void + { + $calendar + ->setStatus(Calendar::STATUS_CANCELED) + ->setCalendarRange(null); + $calendar->preventEnqueueChanges = true; + } + + private function handleUpdateCalendar(Calendar $calendar, array $notification, User $user): void + { + try { + $new = $this->machineHttpClient->request( + 'GET', + $notification['resource'] + )->toArray(); + } catch (ClientExceptionInterface $clientException) { + $this->logger->warning(__CLASS__ . ' could not retrieve event from ms graph. Already deleted ?', [ + 'calendarId' => $calendar->getId(), + 'remoteEventId' => $notification['resource'], + ]); + + throw $clientException; + } + + if (false === $new['isOrganizer']) { + return; + } + + $lastModified = RemoteEventConverter::convertStringDateWithTimezone( + $new['lastModifiedDateTime'] + ); + + if ($calendar->getRemoteAttributes()['lastModifiedDateTime'] === $lastModified->getTimestamp()) { + $this->logger->info(__CLASS__ . ' change key is equals. Source is probably a local update', [ + 'calendarRangeId' => $calendar->getId(), + 'remoteEventId' => $notification['resource'], + ]); + + return; + } + + $this->syncAttendees($calendar, $new['attendees']); + + $startDate = RemoteEventConverter::convertStringDateWithoutTimezone($new['start']['dateTime']); + $endDate = RemoteEventConverter::convertStringDateWithoutTimezone($new['end']['dateTime']); + + if ($startDate->getTimestamp() !== $calendar->getStartDate()->getTimestamp()) { + $calendar->setStartDate($startDate)->setStatus(Calendar::STATUS_MOVED); + } + + if ($endDate->getTimestamp() !== $calendar->getEndDate()->getTimestamp()) { + $calendar->setEndDate($endDate)->setStatus(Calendar::STATUS_MOVED); + } + + $calendar + ->addRemoteAttributes([ + 'lastModifiedDateTime' => $lastModified->getTimestamp(), + 'changeKey' => $new['changeKey'], + ]) + ->preventEnqueueChanges = true; + } + + private function syncAttendees(Calendar $calendar, array $attendees): void + { + $emails = []; + + foreach ($attendees as $attendee) { + $status = $attendee['status']['response']; + + if ('organizer' === $status) { + continue; + } + + $email = $attendee['emailAddress']['address']; + $emails[] = strtolower($email); + $user = $this->userRepository->findOneByUsernameOrEmail($email); + + if (null === $user) { + continue; + } + + if (!$calendar->isInvited($user)) { + $calendar->addUser($user); + } + + $invite = $calendar->getInviteForUser($user); + + switch ($status) { + // possible cases: none, organizer, tentativelyAccepted, accepted, declined, notResponded. + case 'none': + case 'notResponded': + $invite->setStatus(Invite::PENDING); + + break; + + case 'tentativelyAccepted': + $invite->setStatus(Invite::TENTATIVELY_ACCEPTED); + + break; + + case 'accepted': + $invite->setStatus(Invite::ACCEPTED); + + break; + + case 'declined': + $invite->setStatus(Invite::DECLINED); + + break; + + default: + throw new LogicException('should not happens, not implemented: ' . $status); + + break; + } + } + + foreach ($calendar->getUsers() as $user) { + if (!in_array(strtolower($user->getEmailCanonical()), $emails, true)) { + $calendar->removeUser($user); + } + } + } +} diff --git a/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraphRemoteCalendarConnector.php b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraphRemoteCalendarConnector.php new file mode 100644 index 000000000..c376f9680 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraphRemoteCalendarConnector.php @@ -0,0 +1,740 @@ +calendarRepository = $calendarRepository; + $this->calendarRangeRepository = $calendarRangeRepository; + $this->machineHttpClient = $machineHttpClient; + $this->mapCalendarToUser = $mapCalendarToUser; + $this->logger = $logger; + $this->remoteEventConverter = $remoteEventConverter; + $this->tokenStorage = $tokenStorage; + $this->translator = $translator; + $this->urlGenerator = $urlGenerator; + $this->userHttpClient = $userHttpClient; + } + + public function countEventsForUser(User $user, DateTimeImmutable $startDate, DateTimeImmutable $endDate): int + { + $userId = $this->mapCalendarToUser->getUserId($user); + + if (null === $userId) { + return 0; + } + + try { + $data = $this->userHttpClient->request( + 'GET', + 'users/' . $userId . '/calendarView', + [ + 'query' => [ + 'startDateTime' => $startDate->format(DateTimeImmutable::ATOM), + 'endDateTime' => $endDate->format(DateTimeImmutable::ATOM), + '$count' => 'true', + '$top' => 0, + ], + ] + )->toArray(); + } catch (ClientExceptionInterface $e) { + if (403 === $e->getResponse()->getStatusCode()) { + return count($this->getScheduleTimesForUser($user, $startDate, $endDate)); + } + + $this->logger->error('Could not get list of event on MSGraph', [ + 'error_code' => $e->getResponse()->getStatusCode(), + 'error' => $e->getResponse()->getInfo(), + ]); + + return 0; + } + + return $data['@odata.count']; + } + + public function getMakeReadyResponse(string $returnPath): Response + { + return new RedirectResponse($this->urlGenerator + ->generate('chill_calendar_remote_connect_azure', ['returnPath' => $returnPath])); + } + + public function isReady(): bool + { + return $this->tokenStorage->hasToken(); + } + + /** + * @throws \Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface + * @throws \Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface + * @throws \Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface + * @throws \Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface + * + * @return array|\Chill\CalendarBundle\RemoteCalendar\Model\RemoteEvent[] + */ + public function listEventsForUser(User $user, DateTimeImmutable $startDate, DateTimeImmutable $endDate, ?int $offset = 0, ?int $limit = 50): array + { + $userId = $this->mapCalendarToUser->getUserId($user); + + if (null === $userId) { + return []; + } + + try { + $bareEvents = $this->userHttpClient->request( + 'GET', + 'users/' . $userId . '/calendarView', + [ + 'query' => [ + 'startDateTime' => $startDate->format(DateTimeImmutable::ATOM), + 'endDateTime' => $endDate->format(DateTimeImmutable::ATOM), + '$select' => 'id,subject,start,end,isAllDay', + '$top' => $limit, + '$skip' => $offset, + ], + ] + )->toArray(); + + $ids = array_map(static function ($item) { return $item['id']; }, $bareEvents['value']); + $existingIdsInRange = $this->calendarRangeRepository->findRemoteIdsPresent($ids); + $existingIdsInCalendar = $this->calendarRepository->findRemoteIdsPresent($ids); + + return array_values( + array_map( + function ($item) { + return $this->remoteEventConverter->convertToRemote($item); + }, + // filter all event to keep only the one not in range + array_filter( + $bareEvents['value'], + static function ($item) use ($existingIdsInRange, $existingIdsInCalendar) { + return ((!$existingIdsInRange[$item['id']]) ?? true) && ((!$existingIdsInCalendar[$item['id']]) ?? true); + } + ) + ) + ); + } catch (ClientExceptionInterface $e) { + if (403 === $e->getResponse()->getStatusCode()) { + return $this->getScheduleTimesForUser($user, $startDate, $endDate); + } + + $this->logger->error('Could not get list of event on MSGraph', [ + 'error_code' => $e->getResponse()->getStatusCode(), + 'error' => $e->getResponse()->getInfo(), + ]); + + return []; + } + } + + public function removeCalendar(string $remoteId, array $remoteAttributes, User $user, ?CalendarRange $associatedCalendarRange = null): void + { + if ('' === $remoteId) { + return; + } + + $this->removeEvent($remoteId, $user); + + if (null !== $associatedCalendarRange) { + $this->syncCalendarRange($associatedCalendarRange); + } + } + + public function removeCalendarRange(string $remoteId, array $remoteAttributes, User $user): void + { + if ('' === $remoteId) { + return; + } + + $this->removeEvent($remoteId, $user); + } + + public function syncCalendar(Calendar $calendar, string $action, ?CalendarRange $previousCalendarRange, ?User $previousMainUser, ?array $oldInvites, ?array $newInvites): void + { + /* + * cases to support: + * + * * a calendar range is created: + * * create on remote + * * if calendar range is associated: remove the range + * * a Calendar change the CalendarRange: + * * re-create the previous calendar range; + * * remove the current calendar range + * * a calendar change the mainUser + * * cancel the calendar in the previous mainUser + * * recreate the previous calendar range in the previousMainUser, if any + * * delete the current calendar range in the current mainUser, if any + * * create the calendar in the current mainUser + * + */ + + if (!$calendar->hasRemoteId()) { + $this->createCalendarOnRemote($calendar); + } else { + if (null !== $previousMainUser) { + // cancel event in previousMainUserCalendar + $this->cancelOnRemote( + $calendar->getRemoteId(), + $this->translator->trans('remote_ms_graph.cancel_event_because_main_user_is_%label%', ['%label%' => $calendar->getMainUser()]), + $previousMainUser, + 'calendar_' . $calendar->getRemoteId() + ); + $this->createCalendarOnRemote($calendar); + } else { + $this->patchCalendarOnRemote($calendar, $newInvites); + } + } + + if ($calendar->hasCalendarRange() && $calendar->getCalendarRange()->hasRemoteId()) { + $this->removeEvent( + $calendar->getCalendarRange()->getRemoteId(), + $calendar->getCalendarRange()->getUser() + ); + + $calendar->getCalendarRange() + ->addRemoteAttributes([ + 'lastModifiedDateTime' => null, + 'changeKey' => null, + 'previousId' => $calendar->getCalendarRange()->getRemoteId(), + ]) + ->setRemoteId(''); + } + + if (null !== $previousCalendarRange) { + $this->createRemoteCalendarRange($previousCalendarRange); + } + } + + public function syncCalendarRange(CalendarRange $calendarRange): void + { + if ($calendarRange->hasRemoteId()) { + $this->updateRemoteCalendarRange($calendarRange); + } else { + $this->createRemoteCalendarRange($calendarRange); + } + } + + public function syncInvite(Invite $invite): void + { + if ('' === $remoteId = $invite->getCalendar()->getRemoteId()) { + return; + } + + if (null === $invite->getUser()) { + return; + } + + if (null === $userId = $this->mapCalendarToUser->getUserId($invite->getUser())) { + return; + } + + if ($invite->hasRemoteId()) { + $remoteIdAttendeeCalendar = $invite->getRemoteId(); + } else { + $remoteIdAttendeeCalendar = $this->findRemoteIdOnUserCalendar($invite->getCalendar(), $invite->getUser()); + $invite->setRemoteId($remoteIdAttendeeCalendar); + } + + switch ($invite->getStatus()) { + case Invite::PENDING: + return; + + case Invite::ACCEPTED: + $url = "/v1.0/users/{$userId}/calendar/events/{$remoteIdAttendeeCalendar}/accept"; + + break; + + case Invite::TENTATIVELY_ACCEPTED: + $url = "/v1.0/users/{$userId}/calendar/events/{$remoteIdAttendeeCalendar}/tentativelyAccept"; + + break; + + case Invite::DECLINED: + $url = "/v1.0/users/{$userId}/calendar/events/{$remoteIdAttendeeCalendar}/decline"; + + break; + + default: + throw new Exception('not supported'); + } + + try { + $this->machineHttpClient->request( + 'POST', + $url, + ['json' => ['sendResponse' => true]] + ); + } catch (ClientExceptionInterface $e) { + $this->logger->warning('could not update calendar range to remote', [ + 'exception' => $e->getTraceAsString(), + 'content' => $e->getResponse()->getContent(), + 'calendarRangeId' => 'invite_' . $invite->getId(), + ]); + + throw $e; + } + } + + private function cancelOnRemote(string $remoteId, string $comment, User $user, string $identifier): void + { + $userId = $this->mapCalendarToUser->getUserId($user); + + if (null === $userId) { + return; + } + + try { + $this->machineHttpClient->request( + 'POST', + "users/{$userId}/calendar/events/{$remoteId}/cancel", + [ + 'json' => ['Comment' => $comment], + ] + ); + } catch (ClientExceptionInterface $e) { + $this->logger->warning('could not update calendar range to remote', [ + 'exception' => $e->getTraceAsString(), + 'content' => $e->getResponse()->getContent(), + 'calendarRangeId' => $identifier, + ]); + + throw $e; + } + } + + private function createCalendarOnRemote(Calendar $calendar): void + { + $eventData = $this->remoteEventConverter->calendarToEvent($calendar); + + [ + 'id' => $id, + 'lastModifiedDateTime' => $lastModified, + 'changeKey' => $changeKey + ] = $this->createOnRemote($eventData, $calendar->getMainUser(), 'calendar_' . $calendar->getId()); + + if (null === $id) { + return; + } + + $calendar + ->setRemoteId($id) + ->addRemoteAttributes([ + 'lastModifiedDateTime' => $lastModified, + 'changeKey' => $changeKey, + ]); + } + + /** + * @param string $identifier an identifier for logging in case of something does not work + * + * @return array{?id: string, ?lastModifiedDateTime: int, ?changeKey: string} + */ + private function createOnRemote(array $eventData, User $user, string $identifier): array + { + $userId = $this->mapCalendarToUser->getUserId($user); + + if (null === $userId) { + $this->logger->warning('user does not have userId nor calendarId', [ + 'user_id' => $user->getId(), + 'calendar_identifier' => $identifier, + ]); + + return ['id' => null, 'lastModifiedDateTime' => null, 'changeKey' => null]; + } + + try { + $event = $this->machineHttpClient->request( + 'POST', + 'users/' . $userId . '/calendar/events', + [ + 'json' => $eventData, + ] + )->toArray(); + } catch (ClientExceptionInterface $e) { + $this->logger->warning('could not save calendar range to remote', [ + 'exception' => $e->getTraceAsString(), + 'content' => $e->getResponse()->getContent(), + 'calendar_identifier' => $identifier, + ]); + + throw $e; + } + + return [ + 'id' => $event['id'], + 'lastModifiedDateTime' => $this->remoteEventConverter->getLastModifiedDate($event)->getTimestamp(), + 'changeKey' => $event['changeKey'], + ]; + } + + private function createRemoteCalendarRange(CalendarRange $calendarRange): void + { + $userId = $this->mapCalendarToUser->getUserId($calendarRange->getUser()); + + if (null === $userId) { + $this->logger->warning('user does not have userId nor calendarId', [ + 'user_id' => $calendarRange->getUser()->getId(), + 'calendar_range_id' => $calendarRange->getId(), + ]); + + return; + } + + $eventData = $this->remoteEventConverter->calendarRangeToEvent($calendarRange); + + [ + 'id' => $id, + 'lastModifiedDateTime' => $lastModified, + 'changeKey' => $changeKey + ] = $this->createOnRemote( + $eventData, + $calendarRange->getUser(), + 'calendar_range_' . $calendarRange->getId() + ); + + $calendarRange->setRemoteId($id) + ->addRemoteAttributes([ + 'lastModifiedDateTime' => $lastModified, + 'changeKey' => $changeKey, + ]); + } + + /** + * the remoteId is not the same across different user calendars. This method allow to find + * the correct remoteId in another calendar. + * + * For achieving this, the iCalUid is used. + */ + private function findRemoteIdOnUserCalendar(Calendar $calendar, User $user): ?string + { + // find the icalUid on original user + $event = $this->getOnRemote($calendar->getMainUser(), $calendar->getRemoteId()); + $userId = $this->mapCalendarToUser->getUserId($user); + + if ('' === $iCalUid = ($event['iCalUId'] ?? '')) { + throw new Exception('no iCalUid for this event'); + } + + try { + $events = $this->machineHttpClient->request( + 'GET', + "/v1.0/users/{$userId}/calendar/events", + [ + 'query' => [ + '$select' => 'id', + '$filter' => "iCalUId eq '{$iCalUid}'", + ], + ] + )->toArray(); + } catch (ClientExceptionInterface $clientException) { + throw $clientException; + } + + if (1 !== count($events['value'])) { + throw new Exception('multiple events found with same iCalUid'); + } + + return $events['value'][0]['id']; + } + + private function getOnRemote(User $user, string $remoteId): array + { + $userId = $this->mapCalendarToUser->getUserId($user); + + if (null === $userId) { + throw new Exception('no remote calendar for this user', [ + 'user' => $user->getId(), + 'remoteId' => $remoteId, + ]); + } + + try { + return $this->machineHttpClient->request( + 'GET', + 'users/' . $userId . '/calendar/events/' . $remoteId + )->toArray(); + } catch (ClientExceptionInterface $e) { + $this->logger->warning('Could not get event from calendar', [ + 'remoteId' => $remoteId, + ]); + + throw $e; + } + } + + private function getScheduleTimesForUser(User $user, DateTimeImmutable $startDate, DateTimeImmutable $endDate): array + { + $userId = $this->mapCalendarToUser->getUserId($user); + + if (array_key_exists($userId, $this->cacheScheduleTimeForUser)) { + return $this->cacheScheduleTimeForUser[$userId]; + } + + if (null === $userId) { + return []; + } + + if (null === $user->getEmailCanonical() || '' === $user->getEmailCanonical()) { + return []; + } + + $body = [ + 'schedules' => [$user->getEmailCanonical()], + 'startTime' => [ + 'dateTime' => ($startDate->setTimezone(RemoteEventConverter::getRemoteTimeZone())->format(RemoteEventConverter::getRemoteDateTimeSimpleFormat())), + 'timeZone' => 'UTC', + ], + 'endTime' => [ + 'dateTime' => ($endDate->setTimezone(RemoteEventConverter::getRemoteTimeZone())->format(RemoteEventConverter::getRemoteDateTimeSimpleFormat())), + 'timeZone' => 'UTC', + ], + ]; + + try { + $response = $this->userHttpClient->request('POST', 'users/' . $userId . '/calendar/getSchedule', [ + 'json' => $body, + ])->toArray(); + } catch (ClientExceptionInterface $e) { + $this->logger->debug('Could not get schedule on MSGraph', [ + 'error_code' => $e->getResponse()->getStatusCode(), + 'error' => $e->getResponse()->getInfo(), + ]); + + return []; + } + + $this->cacheScheduleTimeForUser[$userId] = array_map( + function ($item) { + return $this->remoteEventConverter->convertAvailabilityToRemoteEvent($item); + }, + $response['value'][0]['scheduleItems'] + ); + + return $this->cacheScheduleTimeForUser[$userId]; + } + + private function patchCalendarOnRemote(Calendar $calendar, array $newInvites): void + { + $eventDatas = []; + $eventDatas[] = $this->remoteEventConverter->calendarToEvent($calendar); + + if (0 < count($newInvites)) { + // it seems that invitaiton are always send, even if attendee changes are mixed with other datas + // $eventDatas[] = $this->remoteEventConverter->calendarToEventAttendeesOnly($calendar); + } + + foreach ($eventDatas as $eventData) { + [ + 'id' => $id, + 'lastModifiedDateTime' => $lastModified, + 'changeKey' => $changeKey + ] = $this->patchOnRemote( + $calendar->getRemoteId(), + $eventData, + $calendar->getMainUser(), + 'calendar_' . $calendar->getId() + ); + + $calendar->addRemoteAttributes([ + 'lastModifiedDateTime' => $lastModified, + 'changeKey' => $changeKey, + ]); + } + } + + /** + * @param string $identifier an identifier for logging in case of something does not work + * + * @return array{?id: string, ?lastModifiedDateTime: int, ?changeKey: string} + */ + private function patchOnRemote(string $remoteId, array $eventData, User $user, string $identifier): array + { + $userId = $this->mapCalendarToUser->getUserId($user); + + if (null === $userId) { + $this->logger->warning('user does not have userId nor calendarId', [ + 'user_id' => $user->getId(), + 'calendar_identifier' => $identifier, + ]); + + return ['id' => null, 'lastModifiedDateTime' => null, 'changeKey' => null]; + } + + try { + $event = $this->machineHttpClient->request( + 'PATCH', + 'users/' . $userId . '/calendar/events/' . $remoteId, + [ + 'json' => $eventData, + ] + )->toArray(); + } catch (ClientExceptionInterface $e) { + $this->logger->warning('could not update calendar range to remote', [ + 'exception' => $e->getTraceAsString(), + 'calendarRangeId' => $identifier, + ]); + + throw $e; + } + + return [ + 'id' => $event['id'], + 'lastModifiedDateTime' => $this->remoteEventConverter->getLastModifiedDate($event)->getTimestamp(), + 'changeKey' => $event['changeKey'], + ]; + } + + private function removeEvent($remoteId, User $user): void + { + $userId = $this->mapCalendarToUser->getUserId($user); + + try { + $this->machineHttpClient->request( + 'DELETE', + 'users/' . $userId . '/calendar/events/' . $remoteId + ); + } catch (ClientExceptionInterface $e) { + $this->logger->warning('could not remove event from calendar', [ + 'event_remote_id' => $remoteId, + 'user_id' => $user->getId(), + ]); + } + } + + private function updateRemoteCalendarRange(CalendarRange $calendarRange): void + { + $userId = $this->mapCalendarToUser->getUserId($calendarRange->getUser()); + $calendarId = $this->mapCalendarToUser->getCalendarId($calendarRange->getUser()); + + if (null === $userId || null === $calendarId) { + $this->logger->warning('user does not have userId nor calendarId', [ + 'user_id' => $calendarRange->getUser()->getId(), + 'calendar_range_id' => $calendarRange->getId(), + ]); + + return; + } + + try { + $event = $this->machineHttpClient->request( + 'GET', + 'users/' . $userId . '/calendar/events/' . $calendarRange->getRemoteId() + )->toArray(); + } catch (ClientExceptionInterface $e) { + $this->logger->warning('Could not get event from calendar', [ + 'calendar_range_id' => $calendarRange->getId(), + 'calendar_range_remote_id' => $calendarRange->getRemoteId(), + ]); + + throw $e; + } + + if ($this->remoteEventConverter->getLastModifiedDate($event)->getTimestamp() > $calendarRange->getUpdatedAt()->getTimestamp()) { + $this->logger->info('Skip updating as the lastModified date seems more fresh than the database one', [ + 'calendar_range_id' => $calendarRange->getId(), + 'calendar_range_remote_id' => $calendarRange->getRemoteId(), + 'db_last_updated' => $calendarRange->getUpdatedAt()->getTimestamp(), + 'remote_last_updated' => $this->remoteEventConverter->getLastModifiedDate($event)->getTimestamp(), + ]); + + return; + } + + $eventData = $this->remoteEventConverter->calendarRangeToEvent($calendarRange); + + try { + $event = $this->machineHttpClient->request( + 'PATCH', + 'users/' . $userId . '/calendar/events/' . $calendarRange->getRemoteId(), + [ + 'json' => $eventData, + ] + )->toArray(); + } catch (ClientExceptionInterface $e) { + $this->logger->warning('could not update calendar range to remote', [ + 'exception' => $e->getTraceAsString(), + 'calendarRangeId' => $calendarRange->getId(), + ]); + + throw $e; + } + + $calendarRange + ->addRemoteAttributes([ + 'lastModifiedDateTime' => $this->remoteEventConverter->getLastModifiedDate($event)->getTimestamp(), + 'changeKey' => $event['changeKey'], + ]); + } +} diff --git a/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/NullRemoteCalendarConnector.php b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/NullRemoteCalendarConnector.php new file mode 100644 index 000000000..211810abf --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/NullRemoteCalendarConnector.php @@ -0,0 +1,70 @@ + $oldInvites + */ + public function syncCalendar(Calendar $calendar, string $action, ?CalendarRange $previousCalendarRange, ?User $previousMainUser, ?array $oldInvites, ?array $newInvites): void; + + public function syncCalendarRange(CalendarRange $calendarRange): void; + + public function syncInvite(Invite $invite): void; +} diff --git a/src/Bundle/ChillCalendarBundle/RemoteCalendar/DependencyInjection/RemoteCalendarCompilerPass.php b/src/Bundle/ChillCalendarBundle/RemoteCalendar/DependencyInjection/RemoteCalendarCompilerPass.php new file mode 100644 index 000000000..f56735de7 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/RemoteCalendar/DependencyInjection/RemoteCalendarCompilerPass.php @@ -0,0 +1,82 @@ +getParameter('chill_calendar'); + $connector = null; + + if (!$config['remote_calendars_sync']['enabled']) { + $connector = NullRemoteCalendarConnector::class; + } + + if ($config['remote_calendars_sync']['microsoft_graph']['enabled']) { + $connector = MSGraphRemoteCalendarConnector::class; + + $container->setAlias(HttpClientInterface::class . ' $machineHttpClient', MachineHttpClient::class); + } else { + // remove services which cannot be loaded + $container->removeDefinition(MapAndSubscribeUserCalendarCommand::class); + $container->removeDefinition(AzureGrantAdminConsentAndAcquireToken::class); + $container->removeDefinition(RemoteCalendarConnectAzureController::class); + $container->removeDefinition(MachineTokenStorage::class); + $container->removeDefinition(MachineHttpClient::class); + $container->removeDefinition(MSGraphRemoteCalendarConnector::class); + } + + if (!$container->hasAlias(Azure::class) && $container->hasDefinition('knpu.oauth2.client.azure')) { + $container->setAlias(Azure::class, 'knpu.oauth2.provider.azure'); + } + + if (null === $connector) { + throw new RuntimeException('Could not configure remote calendar'); + } + + foreach ([ + NullRemoteCalendarConnector::class, + MSGraphRemoteCalendarConnector::class, ] as $serviceId) { + if ($connector === $serviceId) { + $container->getDefinition($serviceId) + ->setDecoratedService(RemoteCalendarConnectorInterface::class); + } else { + // keep the container lighter by removing definitions + if ($container->hasDefinition($serviceId)) { + $container->removeDefinition($serviceId); + } + } + } + } +} diff --git a/src/Bundle/ChillCalendarBundle/RemoteCalendar/Model/RemoteEvent.php b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Model/RemoteEvent.php new file mode 100644 index 000000000..13c4a1c6a --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Model/RemoteEvent.php @@ -0,0 +1,62 @@ +id = $id; + $this->title = $title; + $this->description = $description; + $this->startDate = $startDate; + $this->endDate = $endDate; + $this->isAllDay = $isAllDay; + } +} diff --git a/src/Bundle/ChillCalendarBundle/Repository/CalendarACLAwareRepository.php b/src/Bundle/ChillCalendarBundle/Repository/CalendarACLAwareRepository.php new file mode 100644 index 000000000..6861df341 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Repository/CalendarACLAwareRepository.php @@ -0,0 +1,230 @@ +accompanyingPeriodACLAwareRepository = $accompanyingPeriodACLAwareRepository; + $this->em = $em; + } + + public function buildQueryByAccompanyingPeriod(AccompanyingPeriod $period, ?DateTimeImmutable $startDate, ?DateTimeImmutable $endDate): QueryBuilder + { + $qb = $this->em->createQueryBuilder(); + $qb->from(Calendar::class, 'c'); + + $andX = $qb->expr()->andX($qb->expr()->eq('c.accompanyingPeriod', ':period')); + $qb->setParameter('period', $period); + + if (null !== $startDate) { + $andX->add($qb->expr()->gte('c.startDate', ':startDate')); + $qb->setParameter('startDate', $startDate); + } + + if (null !== $endDate) { + $andX->add($qb->expr()->lte('c.endDate', ':endDate')); + $qb->setParameter('endDate', $endDate); + } + + $qb->where($andX); + + return $qb; + } + + public function buildQueryByAccompanyingPeriodIgnoredByDates(AccompanyingPeriod $period, ?DateTimeImmutable $startDate, ?DateTimeImmutable $endDate): QueryBuilder + { + $qb = $this->em->createQueryBuilder(); + $qb->from(Calendar::class, 'c'); + + $andX = $qb->expr()->andX($qb->expr()->eq('c.accompanyingPeriod', ':period')); + $qb->setParameter('period', $period); + + if (null !== $startDate) { + $andX->add($qb->expr()->lt('c.startDate', ':startDate')); + $qb->setParameter('startDate', $startDate); + } + + if (null !== $endDate) { + $andX->add($qb->expr()->gt('c.endDate', ':endDate')); + $qb->setParameter('endDate', $endDate); + } + + $qb->where($andX); + + return $qb; + } + + /** + * Base implementation. The list of allowed accompanying period is retrieved "manually" from @see{AccompanyingPeriodACLAwareRepository}. + */ + public function buildQueryByPerson(Person $person, ?DateTimeImmutable $startDate, ?DateTimeImmutable $endDate): QueryBuilder + { + $qb = $this->em->createQueryBuilder() + ->from(Calendar::class, 'c'); + + $this->addQueryByPersonWithoutDate($qb, $person); + + // filter by date + if (null !== $startDate) { + $qb->andWhere($qb->expr()->gte('c.startDate', ':startDate')) + ->setParameter('startDate', $startDate); + } + + if (null !== $endDate) { + $qb->andWhere($qb->expr()->lte('c.endDate', ':endDate')) + ->setParameter('endDate', $endDate); + } + + return $qb; + } + + /** + * Base implementation. The list of allowed accompanying period is retrieved "manually" from @see{AccompanyingPeriodACLAwareRepository}. + */ + public function buildQueryByPersonIgnoredByDates(Person $person, ?DateTimeImmutable $startDate, ?DateTimeImmutable $endDate): QueryBuilder + { + $qb = $this->em->createQueryBuilder() + ->from(Calendar::class, 'c'); + + $this->addQueryByPersonWithoutDate($qb, $person); + + // filter by date + if (null !== $startDate) { + $qb->andWhere($qb->expr()->lt('c.startDate', ':startDate')) + ->setParameter('startDate', $startDate); + } + + if (null !== $endDate) { + $qb->andWhere($qb->expr()->gt('c.endDate', ':endDate')) + ->setParameter('endDate', $endDate); + } + + return $qb; + } + + public function countByAccompanyingPeriod(AccompanyingPeriod $period, ?DateTimeImmutable $startDate, ?DateTimeImmutable $endDate): int + { + $qb = $this->buildQueryByAccompanyingPeriod($period, $startDate, $endDate)->select('count(c)'); + + return $qb->getQuery()->getSingleScalarResult(); + } + + public function countByPerson(Person $person, ?DateTimeImmutable $startDate, ?DateTimeImmutable $endDate): int + { + return $this->buildQueryByPerson($person, $startDate, $endDate) + ->select('COUNT(c)') + ->getQuery() + ->getSingleScalarResult(); + } + + public function countIgnoredByAccompanyingPeriod(AccompanyingPeriod $period, ?DateTimeImmutable $startDate, ?DateTimeImmutable $endDate): int + { + $qb = $this->buildQueryByAccompanyingPeriodIgnoredByDates($period, $startDate, $endDate)->select('count(c)'); + + return $qb->getQuery()->getSingleScalarResult(); + } + + public function countIgnoredByPerson(Person $person, ?DateTimeImmutable $startDate, ?DateTimeImmutable $endDate): int + { + return $this->buildQueryByPersonIgnoredByDates($person, $startDate, $endDate) + ->select('COUNT(c)') + ->getQuery() + ->getSingleScalarResult(); + } + + /** + * @return array|Calendar[] + */ + public function findByAccompanyingPeriod(AccompanyingPeriod $period, ?DateTimeImmutable $startDate, ?DateTimeImmutable $endDate, ?array $orderBy = [], ?int $offset = null, ?int $limit = null): array + { + $qb = $this->buildQueryByAccompanyingPeriod($period, $startDate, $endDate)->select('c'); + + foreach ($orderBy as $sort => $order) { + $qb->addOrderBy('c.' . $sort, $order); + } + + if (null !== $offset) { + $qb->setFirstResult($offset); + } + + if (null !== $limit) { + $qb->setMaxResults($limit); + } + + return $qb->getQuery()->getResult(); + } + + public function findByPerson(Person $person, ?DateTimeImmutable $startDate, ?DateTimeImmutable $endDate, ?array $orderBy = [], ?int $offset = null, ?int $limit = null): array + { + $qb = $this->buildQueryByPerson($person, $startDate, $endDate) + ->select('c'); + + foreach ($orderBy as $sort => $order) { + $qb->addOrderBy('c.' . $sort, $order); + } + + if (null !== $offset) { + $qb->setFirstResult($offset); + } + + if (null !== $limit) { + $qb->setMaxResults($limit); + } + + return $qb->getQuery()->getResult(); + } + + private function addQueryByPersonWithoutDate(QueryBuilder $qb, Person $person): void + { + // find the reachable accompanying periods for person + $periods = $this->accompanyingPeriodACLAwareRepository->findByPerson($person, AccompanyingPeriodVoter::SEE); + + $qb + ->where( + $qb->expr()->orX( + // the calendar where the person is the main person: + $qb->expr()->eq('c.person', ':person'), + // when the calendar is in a reachable period, and contains person + $qb->expr()->andX( + $qb->expr()->in('c.accompanyingPeriod', ':periods'), + $qb->expr()->isMemberOf(':person', 'c.persons') + ) + ) + ) + ->setParameter('person', $person) + ->setParameter('periods', $periods); + } +} diff --git a/src/Bundle/ChillCalendarBundle/Repository/CalendarACLAwareRepositoryInterface.php b/src/Bundle/ChillCalendarBundle/Repository/CalendarACLAwareRepositoryInterface.php new file mode 100644 index 000000000..70fb02590 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Repository/CalendarACLAwareRepositoryInterface.php @@ -0,0 +1,63 @@ +repository = $entityManager->getRepository($this->getClassName()); + } + + public function find($id): ?CalendarDoc + { + return $this->repository->find($id); + } + + public function findAll(): array + { + return $this->repository->findAll(); + } + + public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null) + { + return $this->repository->findBy($criteria, $orderBy, $limit, $offset); + } + + public function findOneBy(array $criteria): ?CalendarDoc + { + return $this->findOneBy($criteria); + } + + public function getClassName() + { + return CalendarDoc::class; + } +} diff --git a/src/Bundle/ChillCalendarBundle/Repository/CalendarDocRepositoryInterface.php b/src/Bundle/ChillCalendarBundle/Repository/CalendarDocRepositoryInterface.php new file mode 100644 index 000000000..d2b1951df --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Repository/CalendarDocRepositoryInterface.php @@ -0,0 +1,33 @@ +em = $entityManager; + $this->repository = $entityManager->getRepository(CalendarRange::class); } - // /** - // * @return CalendarRange[] Returns an array of CalendarRange objects - // */ - /* - public function findByExampleField($value) + public function countByAvailableRangesForUser(User $user, DateTimeImmutable $from, DateTimeImmutable $to): int { - return $this->createQueryBuilder('c') - ->andWhere('c.exampleField = :val') - ->setParameter('val', $value) - ->orderBy('c.id', 'ASC') - ->setMaxResults(10) - ->getQuery() - ->getResult() - ; + return $this->buildQueryAvailableRangesForUser($user, $from, $to) + ->select('COUNT(cr)') + ->getQuery()->getSingleScalarResult(); } - */ - /* - public function findOneBySomeField($value): ?CalendarRange + public function find($id): ?CalendarRange { - return $this->createQueryBuilder('c') - ->andWhere('c.exampleField = :val') - ->setParameter('val', $value) - ->getQuery() - ->getOneOrNullResult() - ; + return $this->repository->find($id); } + + /** + * @return array|CalendarRange[] */ + public function findAll(): array + { + return $this->repository->findAll(); + } + + /** + * @return array|CalendarRange[] + */ + public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null) + { + return $this->repository->findBy($criteria, $orderBy, $limit, $offset); + } + + /** + * @return array|CalendarRange[] + */ + public function findByAvailableRangesForUser( + User $user, + DateTimeImmutable $from, + DateTimeImmutable $to, + ?int $limit = null, + ?int $offset = null + ): array { + $qb = $this->buildQueryAvailableRangesForUser($user, $from, $to); + + if (null !== $limit) { + $qb->setMaxResults($limit); + } + + if (null !== $offset) { + $qb->setFirstResult($offset); + } + + return $qb->getQuery()->getResult(); + } + + public function findOneBy(array $criteria): ?CalendarRange + { + return $this->repository->findOneBy($criteria); + } + + /** + * Given a list of remote ids, return an array where + * keys are the remoteIds, and value is a boolean, true if the + * id is present in database. + * + * @param array|list $remoteIds + * + * @return array + */ + public function findRemoteIdsPresent(array $remoteIds): array + { + if (0 === count($remoteIds)) { + return []; + } + + $sql = 'SELECT + sq.remoteId as remoteid, + EXISTS (SELECT 1 FROM chill_calendar.calendar_range cr WHERE cr.remoteId = sq.remoteId) AS present + FROM + ( + VALUES %remoteIds% + ) AS sq(remoteId); + '; + + $remoteIdsStr = implode( + ', ', + array_fill(0, count($remoteIds), '((?))') + ); + + $rsm = new ResultSetMapping(); + $rsm + ->addScalarResult('remoteid', 'remoteId', Types::STRING) + ->addScalarResult('present', 'present', Types::BOOLEAN); + + $rows = $this->em + ->createNativeQuery( + strtr($sql, ['%remoteIds%' => $remoteIdsStr]), + $rsm + ) + ->setParameters(array_values($remoteIds)) + ->getResult(); + + $results = []; + + foreach ($rows as $r) { + $results[$r['remoteId']] = $r['present']; + } + + return $results; + } + + public function getClassName(): string + { + return CalendarRange::class; + } + + private function buildQueryAvailableRangesForUser(User $user, DateTimeImmutable $from, DateTimeImmutable $to): QueryBuilder + { + $qb = $this->repository->createQueryBuilder('cr'); + + $qb->leftJoin('cr.calendar', 'calendar'); + + return $qb + ->where( + $qb->expr()->andX( + $qb->expr()->eq('cr.user', ':user'), + $qb->expr()->gte('cr.startDate', ':startDate'), + $qb->expr()->lte('cr.endDate', ':endDate'), + $qb->expr()->isNull('calendar') + ) + ) + ->setParameters([ + 'user' => $user, + 'startDate' => $from, + 'endDate' => $to, + ]); + } } diff --git a/src/Bundle/ChillCalendarBundle/Repository/CalendarRepository.php b/src/Bundle/ChillCalendarBundle/Repository/CalendarRepository.php index ea8f8b7c4..4fd9b8a29 100644 --- a/src/Bundle/ChillCalendarBundle/Repository/CalendarRepository.php +++ b/src/Bundle/ChillCalendarBundle/Repository/CalendarRepository.php @@ -12,52 +12,220 @@ declare(strict_types=1); namespace Chill\CalendarBundle\Repository; use Chill\CalendarBundle\Entity\Calendar; -use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; +use Chill\MainBundle\Entity\User; +use Chill\PersonBundle\Entity\AccompanyingPeriod; +use DateTimeImmutable; +use Doctrine\DBAL\Types\Types; +use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityRepository; -use Doctrine\Persistence\ManagerRegistry; +use Doctrine\ORM\Query\ResultSetMapping; +use Doctrine\ORM\QueryBuilder; +use Doctrine\Persistence\ObjectRepository; +use function count; -/** - * @method Calendar|null find($id, $lockMode = null, $lockVersion = null) - * @method Calendar|null findOneBy(array $criteria, array $orderBy = null) - * @method Calendar[] findAll() - * @method Calendar[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) - */ -class CalendarRepository extends ServiceEntityRepository +class CalendarRepository implements ObjectRepository { - // private EntityRepository $repository; + private EntityManagerInterface $em; - public function __construct(ManagerRegistry $registry) + private EntityRepository $repository; + + public function __construct(EntityManagerInterface $entityManager) { - parent::__construct($registry, Calendar::class); - // $this->repository = $entityManager->getRepository(AccompanyingPeriodWork::class); + $this->repository = $entityManager->getRepository(Calendar::class); + $this->em = $entityManager; } - // /** - // * @return Calendar[] Returns an array of Calendar objects - // */ - /* - public function findByExampleField($value) + public function countByAccompanyingPeriod(AccompanyingPeriod $period): int { - return $this->createQueryBuilder('c') - ->andWhere('c.exampleField = :val') - ->setParameter('val', $value) - ->orderBy('c.id', 'ASC') - ->setMaxResults(10) + return $this->repository->count(['accompanyingPeriod' => $period]); + } + + public function countByUser(User $user, DateTimeImmutable $from, DateTimeImmutable $to): int + { + return $this->buildQueryByUser($user, $from, $to) + ->select('COUNT(c)') ->getQuery() - ->getResult() - ; + ->getSingleScalarResult(); } - */ - /* - public function findOneBySomeField($value): ?Calendar + public function createQueryBuilder(string $alias, ?string $indexBy = null): QueryBuilder { - return $this->createQueryBuilder('c') - ->andWhere('c.exampleField = :val') - ->setParameter('val', $value) - ->getQuery() - ->getOneOrNullResult() - ; + return $this->repository->createQueryBuilder($alias, $indexBy); } + + public function find($id): ?Calendar + { + return $this->repository->find($id); + } + + /** + * @return array|Calendar[] */ + public function findAll(): array + { + return $this->repository->findAll(); + } + + /** + * @return array|Calendar[] + */ + public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array + { + return $this->repository->findBy($criteria, $orderBy, $limit, $offset); + } + + /** + * @return array|Calendar[] + */ + public function findByAccompanyingPeriod(AccompanyingPeriod $period, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array + { + return $this->findBy( + [ + 'accompanyingPeriod' => $period, + ], + $orderBy, + $limit, + $orderBy + ); + } + + public function findByNotificationAvailable(DateTimeImmutable $startDate, DateTimeImmutable $endDate, ?int $limit = null, ?int $offset = null): array + { + $qb = $this->queryByNotificationAvailable($startDate, $endDate)->select('c'); + + if (null !== $limit) { + $qb->setMaxResults($limit); + } + + if (null !== $offset) { + $qb->setFirstResult($offset); + } + + return $qb->getQuery()->getResult(); + } + + /** + * @return array|Calendar[] + */ + public function findByUser(User $user, DateTimeImmutable $from, DateTimeImmutable $to, ?int $limit = null, ?int $offset = null): array + { + $qb = $this->buildQueryByUser($user, $from, $to)->select('c'); + + if (null !== $limit) { + $qb->setMaxResults($limit); + } + + if (null !== $offset) { + $qb->setFirstResult($offset); + } + + return $qb->getQuery()->getResult(); + } + + public function findOneBy(array $criteria): ?Calendar + { + return $this->repository->findOneBy($criteria); + } + + /** + * Given a list of remote ids, return an array where + * keys are the remoteIds, and value is a boolean, true if the + * id is present in database. + * + * @param array|list $remoteIds + * + * @return array + */ + public function findRemoteIdsPresent(array $remoteIds): array + { + if (0 === count($remoteIds)) { + return []; + } + + $remoteIdsStr = implode( + ', ', + array_fill(0, count($remoteIds), '((?))') + ); + + $sql = "SELECT + sq.remoteId as remoteid, + EXISTS (SELECT 1 FROM chill_calendar.calendar c WHERE c.remoteId = sq.remoteId) AS present + FROM + ( + VALUES {$remoteIdsStr} + ) AS sq(remoteId); + "; + + $rsm = new ResultSetMapping(); + $rsm + ->addScalarResult('remoteid', 'remoteId', Types::STRING) + ->addScalarResult('present', 'present', Types::BOOLEAN); + + $rows = $this->em + ->createNativeQuery( + $sql, + $rsm + ) + ->setParameters(array_values($remoteIds)) + ->getResult(); + + $results = []; + + foreach ($rows as $r) { + $results[$r['remoteId']] = $r['present']; + } + + return $results; + } + + public function getClassName() + { + return Calendar::class; + } + + private function buildQueryByUser(User $user, DateTimeImmutable $from, DateTimeImmutable $to): QueryBuilder + { + $qb = $this->repository->createQueryBuilder('c'); + + return $qb + ->where( + $qb->expr()->andX( + $qb->expr()->eq('c.mainUser', ':user'), + $qb->expr()->gte('c.startDate', ':startDate'), + $qb->expr()->lte('c.endDate', ':endDate'), + ) + ) + ->setParameters([ + 'user' => $user, + 'startDate' => $from, + 'endDate' => $to, + ]); + } + + private function queryByNotificationAvailable(DateTimeImmutable $startDate, DateTimeImmutable $endDate): QueryBuilder + { + $qb = $this->repository->createQueryBuilder('c'); + + $qb->where( + $qb->expr()->andX( + $qb->expr()->eq('c.sendSMS', ':true'), + $qb->expr()->gte('c.startDate', ':startDate'), + $qb->expr()->lt('c.startDate', ':endDate'), + $qb->expr()->orX( + $qb->expr()->eq('c.smsStatus', ':pending'), + $qb->expr()->eq('c.smsStatus', ':cancel_pending') + ) + ) + ); + + $qb->setParameters([ + 'true' => true, + 'startDate' => $startDate, + 'endDate' => $endDate, + 'pending' => Calendar::SMS_PENDING, + 'cancel_pending' => Calendar::SMS_CANCEL_PENDING, + ]); + + return $qb; + } } diff --git a/src/Bundle/ChillCalendarBundle/Repository/InviteRepository.php b/src/Bundle/ChillCalendarBundle/Repository/InviteRepository.php index b0c2f6588..f0bd8fe88 100644 --- a/src/Bundle/ChillCalendarBundle/Repository/InviteRepository.php +++ b/src/Bundle/ChillCalendarBundle/Repository/InviteRepository.php @@ -12,48 +12,47 @@ declare(strict_types=1); namespace Chill\CalendarBundle\Repository; use Chill\CalendarBundle\Entity\Invite; -use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; -use Doctrine\Persistence\ManagerRegistry; +use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\EntityRepository; +use Doctrine\Persistence\ObjectRepository; -/** - * @method Invite|null find($id, $lockMode = null, $lockVersion = null) - * @method Invite|null findOneBy(array $criteria, array $orderBy = null) - * @method Invite[] findAll() - * @method Invite[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) - */ -class InviteRepository extends ServiceEntityRepository +class InviteRepository implements ObjectRepository { - public function __construct(ManagerRegistry $registry) + private EntityRepository $entityRepository; + + public function __construct(EntityManagerInterface $em) { - parent::__construct($registry, Invite::class); + $this->entityRepository = $em->getRepository(Invite::class); } - // /** - // * @return Invite[] Returns an array of Invite objects - // */ - /* - public function findByExampleField($value) + public function find($id): ?Invite { - return $this->createQueryBuilder('i') - ->andWhere('i.exampleField = :val') - ->setParameter('val', $value) - ->orderBy('i.id', 'ASC') - ->setMaxResults(10) - ->getQuery() - ->getResult() - ; + return $this->entityRepository->find($id); } - */ - /* - public function findOneBySomeField($value): ?Invite - { - return $this->createQueryBuilder('i') - ->andWhere('i.exampleField = :val') - ->setParameter('val', $value) - ->getQuery() - ->getOneOrNullResult() - ; - } + /** + * @return array|Invite[] */ + public function findAll(): array + { + return $this->entityRepository->findAll(); + } + + /** + * @return array|Invite[] + */ + public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null) + { + return $this->entityRepository->findBy($criteria, $orderBy, $limit, $offset); + } + + public function findOneBy(array $criteria): ?Invite + { + return $this->entityRepository->findOneBy($criteria); + } + + public function getClassName(): string + { + return Invite::class; + } } diff --git a/src/Bundle/ChillCalendarBundle/Resources/config/services.yml b/src/Bundle/ChillCalendarBundle/Resources/config/services.yml index c41c2ad93..7e316acf3 100644 --- a/src/Bundle/ChillCalendarBundle/Resources/config/services.yml +++ b/src/Bundle/ChillCalendarBundle/Resources/config/services.yml @@ -3,12 +3,41 @@ services: Chill\CalendarBundle\Repository\: autowire: true + autoconfigure: true resource: '../../Repository/' - tags: - - { name: 'doctrine.repository_service' } Chill\CalendarBundle\Menu\: autowire: true autoconfigure: true resource: '../../Menu/' tags: ['chill.menu_builder'] + + Chill\CalendarBundle\Command\: + autowire: true + autoconfigure: true + resource: '../../Command/' + + Chill\CalendarBundle\Messenger\: + autowire: true + autoconfigure: true + resource: '../../Messenger/' + + Chill\CalendarBundle\Command\AzureGrantAdminConsentAndAcquireToken: + autoconfigure: true + autowire: true + arguments: + $azure: '@knpu.oauth2.provider.azure' + tags: ['console.command'] + + Chill\CalendarBundle\Security\: + autoconfigure: true + autowire: true + resource: '../../Security/' + + Chill\CalendarBundle\Service\: + autoconfigure: true + autowire: true + resource: '../../Service/' + + Chill\CalendarBundle\Service\ShortMessageForCalendarBuilderInterface: + alias: Chill\CalendarBundle\Service\DefaultShortMessageForCalendarBuider diff --git a/src/Bundle/ChillCalendarBundle/Resources/config/services/event.yml b/src/Bundle/ChillCalendarBundle/Resources/config/services/event.yml index 348677174..365d8c2da 100644 --- a/src/Bundle/ChillCalendarBundle/Resources/config/services/event.yml +++ b/src/Bundle/ChillCalendarBundle/Resources/config/services/event.yml @@ -7,4 +7,37 @@ services: name: 'doctrine.orm.entity_listener' event: 'postPersist' entity: 'Chill\ActivityBundle\Entity\Activity' - \ No newline at end of file + + Chill\CalendarBundle\Messenger\Doctrine\CalendarRangeEntityListener: + autowire: true + autoconfigure: true + tags: + - + name: 'doctrine.orm.entity_listener' + event: 'postPersist' + entity: 'Chill\CalendarBundle\Entity\CalendarRange' + - + name: 'doctrine.orm.entity_listener' + event: 'postUpdate' + entity: 'Chill\CalendarBundle\Entity\CalendarRange' + - + name: 'doctrine.orm.entity_listener' + event: 'postRemove' + entity: 'Chill\CalendarBundle\Entity\CalendarRange' + + Chill\CalendarBundle\Messenger\Doctrine\CalendarEntityListener: + autowire: true + autoconfigure: true + tags: + - + name: 'doctrine.orm.entity_listener' + event: 'postPersist' + entity: 'Chill\CalendarBundle\Entity\Calendar' + - + name: 'doctrine.orm.entity_listener' + event: 'postUpdate' + entity: 'Chill\CalendarBundle\Entity\Calendar' + - + name: 'doctrine.orm.entity_listener' + event: 'postRemove' + entity: 'Chill\CalendarBundle\Entity\Calendar' diff --git a/src/Bundle/ChillCalendarBundle/Resources/config/services/exports.yaml b/src/Bundle/ChillCalendarBundle/Resources/config/services/exports.yaml index 56580dba1..3eb4bbfdc 100644 --- a/src/Bundle/ChillCalendarBundle/Resources/config/services/exports.yaml +++ b/src/Bundle/ChillCalendarBundle/Resources/config/services/exports.yaml @@ -1,26 +1,26 @@ services: ## Indicators - chill.calendar.export.count_appointments: - class: Chill\CalendarBundle\Export\Export\CountAppointments + chill.calendar.export.count_calendars: + class: Chill\CalendarBundle\Export\Export\CountCalendars autowire: true autoconfigure: true tags: - - { name: chill.export, alias: count_appointments } + - { name: chill.export, alias: count_calendars } - chill.calendar.export.average_duration_appointments: - class: Chill\CalendarBundle\Export\Export\StatAppointmentAvgDuration + chill.calendar.export.average_duration_calendars: + class: Chill\CalendarBundle\Export\Export\StatCalendarAvgDuration autowire: true autoconfigure: true tags: - - { name: chill.export, alias: average_duration_appointments } + - { name: chill.export, alias: average_duration_calendars } - chill.calendar.export.sum_duration_appointments: - class: Chill\CalendarBundle\Export\Export\StatAppointmentSumDuration + chill.calendar.export.sum_duration_calendars: + class: Chill\CalendarBundle\Export\Export\StatCalendarSumDuration autowire: true autoconfigure: true tags: - - { name: chill.export, alias: sum_duration_appointments } + - { name: chill.export, alias: sum_duration_calendars } ## Filters @@ -52,6 +52,13 @@ services: tags: - { name: chill.export_filter, alias: between_dates_filter } + chill.calendar.export.calendar_range_filter: + class: Chill\CalendarBundle\Export\Filter\CalendarRangeFilter + autowire: true + autoconfigure: true + tags: + - { name: chill.export_filter, alias: calendar_range_filter } + ## Aggregator chill.calendar.export.agent_aggregator: @@ -101,4 +108,11 @@ services: autowire: true autoconfigure: true tags: - - { name: chill.export_aggregator, alias: month_aggregator } \ No newline at end of file + - { name: chill.export_aggregator, alias: month_aggregator } + + chill.calendar.export.urgency_aggregator: + class: Chill\CalendarBundle\Export\Aggregator\UrgencyAggregator + autowire: true + autoconfigure: true + tags: + - { name: chill.export_aggregator, alias: urgency_aggregator } diff --git a/src/Bundle/ChillCalendarBundle/Resources/config/services/form.yml b/src/Bundle/ChillCalendarBundle/Resources/config/services/form.yml index 85095dc9a..310fce05f 100644 --- a/src/Bundle/ChillCalendarBundle/Resources/config/services/form.yml +++ b/src/Bundle/ChillCalendarBundle/Resources/config/services/form.yml @@ -1,10 +1,6 @@ --- -services: - chill.calendar.form.type.calendar: - class: Chill\CalendarBundle\Form\CalendarType - arguments: - - "@chill.main.helper.translatable_string" - - "@doctrine.orm.entity_manager" - - tags: - - { name: form.type, alias: chill_calendarbundle_calendar } \ No newline at end of file +services: + Chill\CalendarBundle\Form\: + resource: './../../Form' + autowire: true + autoconfigure: true diff --git a/src/Bundle/ChillCalendarBundle/Resources/config/services/remote_calendar.yaml b/src/Bundle/ChillCalendarBundle/Resources/config/services/remote_calendar.yaml new file mode 100644 index 000000000..af55692ca --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Resources/config/services/remote_calendar.yaml @@ -0,0 +1,15 @@ +services: + _defaults: + autoconfigure: true + autowire: true + + Chill\CalendarBundle\RemoteCalendar\Connector\RemoteCalendarConnectorInterface: ~ + + Chill\CalendarBundle\RemoteCalendar\Connector\NullRemoteCalendarConnector: ~ + + Chill\CalendarBundle\RemoteCalendar\Connector\MSGraphRemoteCalendarConnector: ~ + + Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph\: + resource: '../../RemoteCalendar/Connector/MSGraph/' + + diff --git a/src/Bundle/ChillCalendarBundle/Resources/public/module/Invite/answer.js b/src/Bundle/ChillCalendarBundle/Resources/public/module/Invite/answer.js new file mode 100644 index 000000000..a97fe584d --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Resources/public/module/Invite/answer.js @@ -0,0 +1,33 @@ + +import { createApp } from 'vue'; +import Answer from 'ChillCalendarAssets/vuejs/Invite/Answer'; +import { _createI18n } from 'ChillMainAssets/vuejs/_js/i18n'; + +const i18n = _createI18n({}); + +document.addEventListener('DOMContentLoaded', function (e) { + console.log('dom loaded answer'); + document.querySelectorAll('div[invite-answer]').forEach(function (el) { + console.log('element found', el); + + const app = createApp({ + components: { + Answer, + }, + data() { + return { + status: el.dataset.status, + calendarId: Number.parseInt(el.dataset.calendarId), + } + }, + template: '', + methods: { + onStatusChanged: function(newStatus) { + this.$data.status = newStatus; + }, + } + }); + + app.use(i18n).mount(el); + }); +}); diff --git a/src/Bundle/ChillCalendarBundle/Resources/public/types.ts b/src/Bundle/ChillCalendarBundle/Resources/public/types.ts new file mode 100644 index 000000000..ebc7ab7be --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Resources/public/types.ts @@ -0,0 +1,67 @@ +import {EventInput} from '@fullcalendar/vue3'; +import {DateTime, Location, User, UserAssociatedInterface} from '../../../ChillMainBundle/Resources/public/types' ; +import {Person} from "../../../ChillPersonBundle/Resources/public/types"; + +export interface CalendarRange { + id: number; + endDate: DateTime; + startDate: DateTime; + user: User; + location: Location; + createdAt: DateTime; + createdBy: User; + updatedAt: DateTime; + updatedBy: User; +} + +export interface CalendarRangeCreate { + user: UserAssociatedInterface; + startDate: DateTime; + endDate: DateTime; + location: Location; +} + +export interface CalendarRangeEdit { + startDate?: DateTime, + endDate?: DateTime + location?: Location; +} + +export interface Calendar { + id: number; +} + +export interface CalendarLight { + id: number; + endDate: DateTime; + startDate: DateTime; + mainUser: User; + persons: Person[]; + status: "valid" | "moved" | "canceled"; +} + +export interface CalendarRemote { + id: number; + endDate: DateTime; + startDate: DateTime; + title: string; + isAllDay: boolean; +} + +export type EventInputCalendarRange = EventInput & { + id: string, + userId: number, + userLabel: string, + calendarRangeId: number, + locationId: number, + locationName: string, + start: string, + end: string, + is: "range" +}; + +export function isEventInputCalendarRange(toBeDetermined: EventInputCalendarRange | EventInput): toBeDetermined is EventInputCalendarRange { + return typeof toBeDetermined.is === "string" && toBeDetermined.is === "range"; +} + +export {}; diff --git a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/App.vue b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/App.vue index 2741f8b8f..472bb525b 100644 --- a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/App.vue +++ b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/App.vue @@ -1,156 +1,280 @@ + diff --git a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/Components/CalendarActive.vue b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/Components/CalendarActive.vue new file mode 100644 index 000000000..cd6e52815 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/Components/CalendarActive.vue @@ -0,0 +1,80 @@ + + + + + diff --git a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/api.ts b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/api.ts new file mode 100644 index 000000000..17ef256ad --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/api.ts @@ -0,0 +1,38 @@ +import {fetchResults} from '../../../../../ChillMainBundle/Resources/public/lib/api/apiMethods'; +import {datetimeToISO} from '../../../../../ChillMainBundle/Resources/public/chill/js/date'; +import {User} from '../../../../../ChillMainBundle/Resources/public/types'; +import {CalendarLight, CalendarRange, CalendarRemote} from '../../types'; + +// re-export whoami +export {whoami} from "../../../../../ChillMainBundle/Resources/public/lib/api/user"; + +/** + * + * @param user + * @param Date start + * @param Date end + * @return Promise + */ +export const fetchCalendarRangeForUser = (user: User, start: Date, end: Date): Promise => { + const uri = `/api/1.0/calendar/calendar-range-available/${user.id}.json`; + const dateFrom = datetimeToISO(start); + const dateTo = datetimeToISO(end); + + return fetchResults(uri, {dateFrom, dateTo}); +} + +export const fetchCalendarRemoteForUser = (user: User, start: Date, end: Date): Promise => { + const uri = `/api/1.0/calendar/proxy/calendar/by-user/${user.id}/events`; + const dateFrom = datetimeToISO(start); + const dateTo = datetimeToISO(end); + + return fetchResults(uri, {dateFrom, dateTo}); +} + +export const fetchCalendarLocalForUser = (user: User, start: Date, end: Date): Promise => { + const uri = `/api/1.0/calendar/calendar/by-user/${user.id}.json`; + const dateFrom = datetimeToISO(start); + const dateTo = datetimeToISO(end); + + return fetchResults(uri, {dateFrom, dateTo}); +} diff --git a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/const.ts b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/const.ts new file mode 100644 index 000000000..05a0d5627 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/const.ts @@ -0,0 +1,19 @@ + +const COLORS = [ /* from https://colorbrewer2.org/#type=qualitative&scheme=Set3&n=12 */ + '#8dd3c7', + '#ffffb3', + '#bebada', + '#fb8072', + '#80b1d3', + '#fdb462', + '#b3de69', + '#fccde5', + '#d9d9d9', + '#bc80bd', + '#ccebc5', + '#ffed6f' +]; + +export { + COLORS, +}; diff --git a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/i18n.js b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/i18n.js index 3020502e9..502db50b2 100644 --- a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/i18n.js +++ b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/i18n.js @@ -1,19 +1,25 @@ -import { personMessages } from 'ChillPersonAssets/vuejs/_js/i18n' -import { calendarUserSelectorMessages } from '../_components/CalendarUserSelector/js/i18n'; -import { activityMessages } from 'ChillActivityAssets/vuejs/Activity/i18n'; +import {personMessages} from 'ChillPersonAssets/vuejs/_js/i18n' +import {calendarUserSelectorMessages} from '../_components/CalendarUserSelector/js/i18n'; +import {activityMessages} from 'ChillActivityAssets/vuejs/Activity/i18n'; const appMessages = { - fr: { - choose_your_date: "Sélectionnez votre plage", - activity: { - add_persons: "Ajouter des personnes concernées", - bloc_persons: "Usagers", - bloc_persons_associated: "Usagers du parcours", - bloc_persons_not_associated: "Tiers non-pro.", - bloc_thirdparty: "Tiers professionnels", - bloc_users: "T(M)S", - } - } + fr: { + choose_your_date: "Sélectionnez votre plage", + activity: { + add_persons: "Ajouter des personnes concernées", + bloc_persons: "Usagers", + bloc_persons_associated: "Usagers du parcours", + bloc_persons_not_associated: "Tiers non-pro.", + bloc_thirdparty: "Tiers professionnels", + bloc_users: "T(M)S", + }, + this_calendar_range_will_change_main_user: "Cette plage de disponibilité n'est pas celle de l'utilisateur principal. Si vous continuez, l'utilisateur principal sera adapté. Êtes-vous sûr·e ?", + will_change_main_user_for_me: "Vous ne pouvez pas écrire dans le calendrier d'un autre utilisateur. Voulez-vous être l'utilisateur principal de ce rendez-vous ?", + main_user_is_mandatory: "L'utilisateur principal est requis. Vous pouvez le modifier, mais pas le supprimer", + change_main_user_will_reset_event_data: "Modifier l'utilisateur principal nécessite de choisir une autre plage de disponibilité ou un autre horaire. Ces informations seront perdues. Êtes-vous sûr·e de vouloir continuer ?", + list_three_days: 'Liste 3 jours', + current_selected: 'Rendez-vous fixé', + } } Object.assign(appMessages.fr, personMessages.fr); @@ -21,5 +27,5 @@ Object.assign(appMessages.fr, calendarUserSelectorMessages.fr); Object.assign(appMessages.fr, activityMessages.fr); export { - appMessages + appMessages }; diff --git a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/store.js b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/store.js deleted file mode 100644 index db2950bd4..000000000 --- a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/store.js +++ /dev/null @@ -1,228 +0,0 @@ -import 'es6-promise/auto'; -import { createStore } from 'vuex'; -import { postLocation } from 'ChillActivityAssets/vuejs/Activity/api'; -import { - getLocations, getLocationTypeByDefaultFor, - getUserCurrentLocation -} from "../../../../../ChillActivityBundle/Resources/public/vuejs/Activity/api"; - -const debug = process.env.NODE_ENV !== 'production'; - -const addIdToValue = (string, id) => { - let array = string ? string.split(',') : []; - array.push(id.toString()); - let str = array.join(); - return str; -}; - -const removeIdFromValue = (string, id) => { - let array = string.split(','); - array = array.filter(el => el !== id.toString()); - let str = array.join(); - return str; -}; - -/* -* Assign missing keys for the ConcernedGroups component -*/ -const mapEntity = (entity) => { - Object.assign(entity, {thirdParties: entity.professionals, users: entity.invites}); - return entity; -}; - -const store = createStore({ - strict: debug, - state: { - activity: mapEntity(window.entity), // activity is the calendar entity actually - currentEvent: null - }, - getters: { - suggestedEntities(state) { - if (typeof(state.activity.accompanyingPeriod) === 'undefined') { - return []; - } - const allEntities = [ - ...store.getters.suggestedPersons, - ...store.getters.suggestedRequestor, - ...store.getters.suggestedUser, - ...store.getters.suggestedResources - ]; - const uniqueIds = [...new Set(allEntities.map(i => `${i.type}-${i.id}`))]; - return Array.from(uniqueIds, id => allEntities.filter(r => `${r.type}-${r.id}` === id)[0]); - }, - suggestedPersons(state) { - const existingPersonIds = state.activity.persons.map(p => p.id); - return state.activity.accompanyingPeriod.participations - .filter(p => p.endDate === null) - .map(p => p.person) - .filter(p => !existingPersonIds.includes(p.id)) - }, - suggestedRequestor(state) { - const existingPersonIds = state.activity.persons.map(p => p.id); - const existingThirdPartyIds = state.activity.thirdParties.map(p => p.id); - return [state.activity.accompanyingPeriod.requestor] - .filter(r => - (r.type === 'person' && !existingPersonIds.includes(r.id)) || - (r.type === 'thirdparty' && !existingThirdPartyIds.includes(r.id)) - ); - }, - suggestedUser(state) { - const existingUserIds = state.activity.users.map(p => p.id); - return [state.activity.accompanyingPeriod.user] - .filter( - u => !existingUserIds.includes(u.id) - ); - }, - suggestedResources(state) { - const resources = state.activity.accompanyingPeriod.resources; - const existingPersonIds = state.activity.persons.map(p => p.id); - const existingThirdPartyIds = state.activity.thirdParties.map(p => p.id); - return state.activity.accompanyingPeriod.resources - .map(r => r.resource) - .filter(r => - (r.type === 'person' && !existingPersonIds.includes(r.id)) || - (r.type === 'thirdparty' && !existingThirdPartyIds.includes(r.id)) - ); - } - }, - mutations: { - - // ConcernedGroups - addPersonsInvolved(state, payload) { - //console.log('### mutation addPersonsInvolved', payload.result.type); - switch (payload.result.type) { - case 'person': - state.activity.persons.push(payload.result); - break; - case 'thirdparty': - state.activity.thirdParties.push(payload.result); - break; - case 'user': - state.activity.users.push(payload.result); - break; - }; - }, - removePersonInvolved(state, payload) { - //console.log('### mutation removePersonInvolved', payload.type); - switch (payload.type) { - case 'person': - state.activity.persons = state.activity.persons.filter(person => person !== payload); - break; - case 'thirdparty': - state.activity.thirdParties = state.activity.thirdParties.filter(thirdparty => thirdparty !== payload); - break; - case 'user': - state.activity.users = state.activity.users.filter(user => user !== payload); - break; - }; - }, - // Calendar - setEvents(state, payload) { - console.log(payload) - state.currentEvent = {start: payload.start, end: payload.end} - }, - // Location - updateLocation(state, value) { - console.log('### mutation: updateLocation', value); - state.activity.location = value; - } - }, - actions: { - addPersonsInvolved({ commit }, payload) { - console.log('### action addPersonsInvolved', payload.result.type); - switch (payload.result.type) { - case 'person': - let aPersons = document.getElementById("chill_calendarbundle_calendar_persons"); - aPersons.value = addIdToValue(aPersons.value, payload.result.id); - break; - case 'thirdparty': - let aThirdParties = document.getElementById("chill_calendarbundle_calendar_professionals"); - aThirdParties.value = addIdToValue(aThirdParties.value, payload.result.id); - break; - case 'user': - let aUsers = document.getElementById("chill_calendarbundle_calendar_invites"); - aUsers.value = addIdToValue(aUsers.value, payload.result.id); - break; - }; - commit('addPersonsInvolved', payload); - }, - removePersonInvolved({ commit }, payload) { - //console.log('### action removePersonInvolved', payload); - switch (payload.type) { - case 'person': - let aPersons = document.getElementById("chill_calendarbundle_calendar_persons"); - aPersons.value = removeIdFromValue(aPersons.value, payload.id); - break; - case 'thirdparty': - let aThirdParties = document.getElementById("chill_calendarbundle_calendar_professionals"); - aThirdParties.value = removeIdFromValue(aThirdParties.value, payload.id); - break; - case 'user': - let aUsers = document.getElementById("chill_calendarbundle_calendar_invites"); - aUsers.value = removeIdFromValue(aUsers.value, payload.id); - break; - }; - commit('removePersonInvolved', payload); - }, - - // Calendar - createEvent({ commit }, payload) { - console.log('### action createEvent', payload); - let startDateInput = document.getElementById("chill_calendarbundle_calendar_startDate"); - startDateInput.value = payload.startStr; - let endDateInput = document.getElementById("chill_calendarbundle_calendar_endDate"); - endDateInput.value = payload.endStr; - let mainUserInput = document.getElementById("chill_calendarbundle_calendar_mainUser"); - mainUserInput.value = payload.users.logged.id; - commit('setEvents', payload); - }, - updateEvent({ commit }, payload) { - console.log('### action updateEvent', payload); - let startDateInput = document.getElementById("chill_calendarbundle_calendar_startDate"); - startDateInput.value = payload.event.start.toISOString(); - let endDateInput = document.getElementById("chill_calendarbundle_calendar_endDate"); - endDateInput.value = payload.event.end.toISOString(); - let calendarRangeInput = document.getElementById("chill_calendarbundle_calendar_calendarRange"); - calendarRangeInput.value = Number(payload.event.extendedProps.calendarRangeId); - let mainUserInput = document.getElementById("chill_calendarbundle_calendar_mainUser"); - mainUserInput.value = Number(payload.event.source.id); - commit('setEvents', payload); - }, - - // Location - updateLocation({ commit }, value) { - console.log('### action: updateLocation', value); - let hiddenLocation = document.getElementById("chill_calendarbundle_calendar_location"); - if (value.onthefly) { - const body = { - "type": "location", - "name": value.name === '__AccompanyingCourseLocation__' ? null : value.name, - "locationType": { - "id": value.locationType.id, - "type": "location-type" - } - }; - if (value.address.id) { - Object.assign(body, { - "address": { - "id": value.address.id - }, - }) - } - postLocation(body) - .then( - location => hiddenLocation.value = location.id - ).catch( - err => { - console.log(err.message); - } - ); - } else { - hiddenLocation.value = value.id; - } - commit("updateLocation", value); - } - } -}); - -export default store; diff --git a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/store/actions.js b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/store/actions.js new file mode 100644 index 000000000..1568944db --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/store/actions.js @@ -0,0 +1,241 @@ +import { + addIdToValue, + removeIdFromValue, +} from './utils'; +import { + fetchCalendarRangeForUser, + fetchCalendarRemoteForUser, + fetchCalendarLocalForUser, +} from './../api'; +import {datetimeToISO} from 'ChillMainAssets/chill/js/date'; +import {postLocation} from 'ChillActivityAssets/vuejs/Activity/api'; + +/** + * This will store a unique key for each value, and prevent to launch the same + * request multiple times, when fetching user calendars. + * + * Actually, each time a user is added or removed, the methods "dateSet" is executed and this + * sparkle a request by user to get the calendar data. When the calendar data is fetched, it is + * immediatly added to the calendar which, in turn , launch the event dateSet and re-launch fetch + * queries which has not yet ended. Storing the queries already executed prevent this loop. + * + * @type {Set} + */ +const fetchings = new Set(); + +export default { + setCurrentDatesView({commit, dispatch}, {start, end}) { + commit('setCurrentDatesView', {start, end}); + + return dispatch('fetchCalendarEvents'); + }, + fetchCalendarEvents({state, getters, dispatch}) { + if (state.currentView.start === null && state.currentView.end === null) { + return Promise.resolve(); + } + + let promises = []; + for (const uid of state.currentView.users.keys()) { + let unique = `${uid}, ${state.currentView.start.toISOString()}, ${state.currentView.end.toISOString()}`; + + if (fetchings.has(unique)) { + console.log('prevent from fetching for a user', unique); + continue; + } + + fetchings.add(unique); + + promises.push( + dispatch( + 'fetchCalendarRangeForUser', + {user: state.usersData.get(uid).user, start: state.currentView.start, end: state.currentView.end} + ) + ); + promises.push( + dispatch( + 'fetchCalendarRemotesForUser', + {user: state.usersData.get(uid).user, start: state.currentView.start, end: state.currentView.end} + ) + ); + promises.push( + dispatch( + 'fetchCalendarLocalsForUser', + {user: state.usersData.get(uid).user, start: state.currentView.start, end: state.currentView.end} + ) + ); + } + + return Promise.all(promises); + }, + fetchCalendarRangeForUser({commit, getters}, {user, start, end}) { + if (!getters.isCalendarRangeLoadedForUser({user, start, end})) { + return fetchCalendarRangeForUser(user, start, end).then((ranges) => { + commit('addCalendarRangesForUser', {user, ranges, start, end}); + + return Promise.resolve(); + }); + } + }, + fetchCalendarRemotesForUser({commit, getters}, {user, start, end}) { + if (!getters.isCalendarRemoteLoadedForUser({user, start, end})) { + return fetchCalendarRemoteForUser(user, start, end).then((remotes) => { + commit('addCalendarRemotesForUser', {user, remotes, start, end}); + + return Promise.resolve(); + }); + } + }, + fetchCalendarLocalsForUser({commit, getters}, {user, start, end}) { + if (!getters.isCalendarRemoteLoadedForUser({user, start, end})) { + return fetchCalendarLocalForUser(user, start, end).then((locals) => { + commit('addCalendarLocalsForUser', {user, locals, start, end}); + + return Promise.resolve(); + }); + } + }, + addPersonsInvolved({commit, dispatch}, payload) { + console.log('### action addPersonsInvolved', payload.result.type); + console.log('### action addPersonsInvolved payload result', payload.result); + switch (payload.result.type) { + case 'person': + let aPersons = document.getElementById("chill_activitybundle_activity_persons"); + aPersons.value = addIdToValue(aPersons.value, payload.result.id); + break; + case 'thirdparty': + let aThirdParties = document.getElementById("chill_activitybundle_activity_professionals"); + aThirdParties.value = addIdToValue(aThirdParties.value, payload.result.id); + break; + case 'user': + let aUsers = document.getElementById("chill_activitybundle_activity_users"); + aUsers.value = addIdToValue(aUsers.value, payload.result.id); + commit('showUserOnCalendar', {user: payload.result, ranges: false, remotes: true}); + dispatch('fetchCalendarEvents'); + break; + } + ; + commit('addPersonsInvolved', payload); + }, + removePersonInvolved({commit}, payload) { + //console.log('### action removePersonInvolved', payload); + switch (payload.type) { + case 'person': + let aPersons = document.getElementById("chill_activitybundle_activity_persons"); + aPersons.value = removeIdFromValue(aPersons.value, payload.id); + break; + case 'thirdparty': + let aThirdParties = document.getElementById("chill_activitybundle_activity_professionals"); + aThirdParties.value = removeIdFromValue(aThirdParties.value, payload.id); + break; + case 'user': + let aUsers = document.getElementById("chill_activitybundle_activity_users"); + aUsers.value = removeIdFromValue(aUsers.value, payload.id); + break; + } + ; + commit('removePersonInvolved', payload); + }, + + // Calendar + /** + * set event startDate and endDate. + * + * if the mainUser is different from "me", it will replace the mainUser + * + * @param commit + * @param state + * @param getters + * @param start + * @param end + */ + setEventTimes({commit, state, getters}, {start, end}) { + console.log('### action createEvent', {start, end}); + let startDateInput = document.getElementById("chill_activitybundle_activity_startDate"); + startDateInput.value = null !== start ? datetimeToISO(start) : ''; + let endDateInput = document.getElementById("chill_activitybundle_activity_endDate"); + endDateInput.value = null !== end ? datetimeToISO(end) : ''; + let calendarRangeInput = document.getElementById("chill_activitybundle_activity_calendarRange"); + calendarRangeInput.value = ""; + + if (getters.getMainUser === null || getters.getMainUser.id !== state.me.id) { + let mainUserInput = document.getElementById("chill_activitybundle_activity_mainUser"); + mainUserInput.value = state.me.id; + commit('setMainUser', state.me); + } + + commit('setEventTimes', {start, end}); + }, + associateCalendarToRange({state, commit, dispatch, getters}, {range}) { + console.log('### action associateCAlendarToRange', range); + let startDateInput = document.getElementById("chill_activitybundle_activity_startDate"); + startDateInput.value = null !== range ? datetimeToISO(range.start) : ""; + let endDateInput = document.getElementById("chill_activitybundle_activity_endDate"); + endDateInput.value = null !== range ? datetimeToISO(range.end) : ""; + let calendarRangeInput = document.getElementById("chill_activitybundle_activity_calendarRange"); + calendarRangeInput.value = null !== range ? Number(range.extendedProps.calendarRangeId) : ""; + + if (null !== range) { + let location = getters.getLocationById(range.extendedProps.locationId); + + if (null === location) { + console.error("location not found!", range.extendedProps.locationId); + } + + dispatch('updateLocation', location); + + const userId = range.extendedProps.userId; + if (state.activity.mainUser !== null && state.activity.mainUser.id !== userId) { + dispatch('setMainUser', state.usersData.get(userId).user); + + // TODO: remove persons involved with this user + } + } + + commit('associateCalendarToRange', {range}); + return Promise.resolve(); + }, + setMainUser({commit, dispatch, state}, mainUser) { + console.log('setMainUser', mainUser); + + let mainUserInput = document.getElementById("chill_activitybundle_activity_mainUser"); + mainUserInput.value = Number(mainUser.id); + + return dispatch('associateCalendarToRange', { range: null }).then(() => { + commit('setMainUser', mainUser); + }); + }, + + // Location + updateLocation({commit}, value) { + console.log('### action: updateLocation', value); + let hiddenLocation = document.getElementById("chill_activitybundle_activity_location"); + if (value.onthefly) { + const body = { + "type": "location", + "name": value.name === '__AccompanyingCourseLocation__' ? null : value.name, + "locationType": { + "id": value.locationType.id, + "type": "location-type" + } + }; + if (value.address.id) { + Object.assign(body, { + "address": { + "id": value.address.id + }, + }) + } + postLocation(body) + .then( + location => hiddenLocation.value = location.id + ).catch( + err => { + console.log(err.message); + } + ); + } else { + hiddenLocation.value = value.id; + } + commit("updateLocation", value); + } +} diff --git a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/store/getters.js b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/store/getters.js new file mode 100644 index 000000000..b9d9b6a82 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/store/getters.js @@ -0,0 +1,272 @@ +export default { + /** + * get the main user of the event/Calendar + * + * @param state + * @returns {*|null} + */ + getMainUser(state) { + return state.activity.mainUser || null; + }, + /** + * return the date of the event/Calendar + * + * @param state + * @returns {Date} + */ + getEventDate(state) { + if (null === state.activity.start) { + return new Date(); + } + throw 'transform date to object ?'; + }, + /** + * Compute the event sources to show on the FullCalendar + * + * @param state + * @param getters + * @returns {[]} + */ + getEventSources(state, getters) { + let sources = []; + + // current calendar + if (state.activity.startDate !== null && state.activity.endDate !== null) { + const s = { + id: 'current', + events: [ + { + title: "Rendez-vous", + start: state.activity.startDate, + end: state.activity.endDate, + allDay: false, + is: "current", + classNames: ['iscurrent'], + } + ], + editable: state.activity.calendarRange === null, + }; + + sources.push(s); + } + + for (const [userId, kinds] of state.currentView.users.entries()) { + if (!state.usersData.has(userId)) { + console.log('try to get events on a user which not exists', userId); + continue; + } + + const userData = state.usersData.get(userId); + + if (kinds.ranges && userData.calendarRanges.length > 0) { + const s = { + id: `ranges_${userId}`, + events: userData.calendarRanges.filter(r => state.activity.calendarRange === null || r.calendarRangeId !== state.activity.calendarRange.calendarRangeId), + color: userData.mainColor, + classNames: ['isrange'], + backgroundColor: 'white', + textColor: 'black', + editable: false, + }; + + sources.push(s); + } + + if (kinds.remotes && userData.remotes.length > 0) { + const s = { + 'id': `remote_${userId}`, + events: userData.remotes, + color: userData.mainColor, + textColor: 'black', + editable: false, + }; + + sources.push(s); + } + + // if remotes is checked, we display also the locals calendars + if (kinds.remotes && userData.locals.length > 0) { + const s = { + 'id': `local_${userId}`, + events: userData.locals.filter(l => l.originId !== state.activity.id), + color: userData.mainColor, + textColor: 'black', + editable: false, + }; + + sources.push(s); + } + } + + return sources; + }, + getInitialDate(state) { + return state.activity.startDate; + }, + getInviteForUser: (state) => (user) => { + return state.activity.invites.find(i => i.user.id === user.id); + }, + /** + * get the user data for a specific user + * + * @param state + * @returns {function(*): unknown} + */ + getUserData: (state) => (user) => { + return state.usersData.get(user.id); + }, + getUserDataById: (state) => (userId) => { + return state.usersData.get(userId); + }, + /** + * return true if the user has an entry in userData + * + * @param state + * @returns {function(*): boolean} + */ + hasUserData: (state) => (user) => { + return state.usersData.has(user.id); + }, + hasUserDataById: (state) => (userId) => { + return state.usersData.has(userId); + }, + /** + * return true if there was a fetch query for event between this date (start and end), + * those date are included. + * + * @param state + * @param getters + * @returns {(function({user: *, start: *, end: *}): (boolean))|*} + */ + isCalendarRangeLoadedForUser: (state, getters) => ({user, start, end}) => { + if (!getters.hasUserData(user)) { + return false; + } + + for (let interval of getters.getUserData(user).calendarRangesLoaded) { + if (start >= interval.start && end <= interval.end) { + return true; + } + } + + return false; + }, + /** + * return true if there was a fetch query for event between this date (start and end), + * those date are included. + * + * @param state + * @param getters + * @returns {(function({user: *, start: *, end: *}): (boolean))|*} + */ + isCalendarRemoteLoadedForUser: (state, getters) => ({user, start, end}) => { + if (!getters.hasUserData(user)) { + return false; + } + + for (let interval of getters.getUserData(user).remotesLoaded) { + if (start >= interval.start && end <= interval.end) { + return true; + } + } + + return false; + }, + /** + * return true if the user ranges are shown on calendar + * + * @param state + * @returns boolean + */ + isRangeShownOnCalendarForUser: (state) => (user) => { + const k = state.currentView.users.get(user.id); + if (typeof k === 'undefined') { + console.error('try to determinate if calendar range is shown and user is not in currentView'); + return false; + } + + return k.ranges; + }, + + /** + * return true if the user remote is shown on calendar + * @param state + * @returns boolean + */ + isRemoteShownOnCalendarForUser: (state) => (user) => { + const k = state.currentView.users.get(user.id); + if (typeof k === 'undefined') { + console.error('try to determinate if calendar range is shown and user is not in currentView'); + return false; + } + + return k.remotes; + }, + + getLocationById: (state) => (id) => { + for (let group of state.availableLocations) { + console.log('group', group); + const found = group.locations.find(l => l.id === id); + if (typeof found !== "undefined") { + return found; + } + } + + return null; + }, + + suggestedEntities(state, getters) { + if (typeof (state.activity.accompanyingPeriod) === 'undefined') { + return []; + } + const allEntities = [ + ...getters.suggestedPersons, + ...getters.suggestedRequestor, + ...getters.suggestedUser, + ...getters.suggestedResources + ]; + const uniqueIds = [...new Set(allEntities.map(i => `${i.type}-${i.id}`))]; + return Array.from(uniqueIds, id => allEntities.filter(r => `${r.type}-${r.id}` === id)[0]); + }, + suggestedPersons(state) { + const existingPersonIds = state.activity.persons.map(p => p.id); + return state.activity.accompanyingPeriod.participations + .filter(p => p.endDate === null) + .map(p => p.person) + .filter(p => !existingPersonIds.includes(p.id)) + }, + suggestedRequestor(state) { + if (state.activity.accompanyingPeriod.requestor === null) { + return []; + } + + const existingPersonIds = state.activity.persons.map(p => p.id); + const existingThirdPartyIds = state.activity.thirdParties.map(p => p.id); + return [state.activity.accompanyingPeriod.requestor] + .filter(r => + (r.type === 'person' && !existingPersonIds.includes(r.id)) || + (r.type === 'thirdparty' && !existingThirdPartyIds.includes(r.id)) + ); + }, + suggestedUser(state) { + if (null === state.activity.users) { + return []; + } + const existingUserIds = state.activity.users.map(p => p.id); + return [state.activity.accompanyingPeriod.user] + .filter( + u => u !== null && !existingUserIds.includes(u.id) + ); + }, + suggestedResources(state) { + const resources = state.activity.accompanyingPeriod.resources; + const existingPersonIds = state.activity.persons.map(p => p.id); + const existingThirdPartyIds = state.activity.thirdParties.map(p => p.id); + return state.activity.accompanyingPeriod.resources + .map(r => r.resource) + .filter(r => + (r.type === 'person' && !existingPersonIds.includes(r.id)) || + (r.type === 'thirdparty' && !existingThirdPartyIds.includes(r.id)) + ); + } +} diff --git a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/store/index.js b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/store/index.js new file mode 100644 index 000000000..ffb186cdf --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/store/index.js @@ -0,0 +1,59 @@ +import 'es6-promise/auto'; +import { createStore } from 'vuex'; +import { postLocation } from 'ChillActivityAssets/vuejs/Activity/api'; +import getters from './getters'; +import actions from './actions'; +import mutations from './mutations'; +import { mapEntity } from './utils'; +import { whoami } from '../api'; +import prepareLocations from "ChillActivityAssets/vuejs/Activity/store.locations"; + +const debug = process.env.NODE_ENV !== 'production'; + +const store = createStore({ + strict: debug, + state: { + activity: mapEntity(window.entity), // activity is the calendar entity actually + currentEvent: null, + availableLocations: [], + /** + * the current user + */ + me: null, + /** + * store information about current view + */ + currentView: { + start: null, + end: null, + users: new Map(), + }, + /** + * store a list of existing event, to avoid storing them twice + */ + existingEvents: new Set(), + /** + * store user data + */ + usersData: new Map(), + }, + getters, + mutations, + actions, +}); + +whoami().then(me => { + store.commit('setWhoAmiI', me); +}); + +if (null !== store.getters.getMainUser) { + store.commit('showUserOnCalendar', {ranges: true, remotes: true, user: store.getters.getMainUser}); +} + +for (let u of store.state.activity.users) { + store.commit('showUserOnCalendar', {ranges: false, remotes: false, user: u}); +} + +prepareLocations(store); + +export default store; diff --git a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/store/mutations.js b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/store/mutations.js new file mode 100644 index 000000000..c956b0d87 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/store/mutations.js @@ -0,0 +1,195 @@ +import { + createUserData, + calendarRangeToFullCalendarEvent, + remoteToFullCalendarEvent, + localsToFullCalendarEvent, +} from './utils'; + +export default { + setWhoAmiI(state, me) { + state.me = me; + }, + setCurrentDatesView(state, {start, end}) { + state.currentView.start = start; + state.currentView.end = end; + }, + showUserOnCalendar(state, {user, ranges, remotes}) { + if (!state.usersData.has(user.id)) { + state.usersData.set(user.id, createUserData(user, state.usersData.size)); + } + + const cur = state.currentView.users.get(user.id); + + state.currentView.users.set( + user.id, + { + ranges: typeof ranges !== 'undefined' ? ranges : cur.ranges, + remotes: typeof remotes !== 'undefined' ? remotes : cur.remotes, + } + ); + }, + /** + * Set the event start and end to the given start and end, + * and remove eventually the calendar range. + * + * @param state + * @param Date start + * @param Date end + */ + setEventTimes(state, {start, end}) { + state.activity.startDate = start; + state.activity.endDate = end; + state.activity.calendarRange = null; + }, + /** + * Set the event's start and end from the calendar range data, + * and associate event to calendar range. + * + * @param state + * @param range + */ + associateCalendarToRange(state, {range}) { + console.log('associateCalendarToRange', range); + + if (null === range) { + state.activity.calendarRange = null; + state.activity.startDate = null; + state.activity.endDate = null; + + return; + } + + console.log('userId', range.extendedProps.userId); + + const r = state.usersData.get(range.extendedProps.userId).calendarRanges + .find(r => r.calendarRangeId === range.extendedProps.calendarRangeId); + + if (typeof r === 'undefined') { + throw Error('Could not find managed calendar range'); + } + + console.log('range found', r); + + state.activity.startDate = range.start; + state.activity.endDate = range.end; + state.activity.calendarRange = r; + + console.log('activity', state.activity); + }, + + setMainUser(state, user) { + state.activity.mainUser = user; + }, + + // ConcernedGroups + addPersonsInvolved(state, payload) { + //console.log('### mutation addPersonsInvolved', payload.result.type); + switch (payload.result.type) { + case 'person': + state.activity.persons.push(payload.result); + break; + case 'thirdparty': + state.activity.thirdParties.push(payload.result); + break; + case 'user': + state.activity.users.push(payload.result); + break; + } + ; + }, + removePersonInvolved(state, payload) { + //console.log('### mutation removePersonInvolved', payload.type); + switch (payload.type) { + case 'person': + state.activity.persons = state.activity.persons.filter(person => person !== payload); + break; + case 'thirdparty': + state.activity.thirdParties = state.activity.thirdParties.filter(thirdparty => thirdparty !== payload); + break; + case 'user': + state.activity.users = state.activity.users.filter(user => user !== payload); + break; + } + ; + }, + /** + * Add CalendarRange object for an user + * + * @param state + * @param user + * @param ranges + * @param start + * @param end + */ + addCalendarRangesForUser(state, {user, ranges, start, end}) { + let userData; + if (state.usersData.has(user.id)) { + userData = state.usersData.get(user.id); + } else { + userData = createUserData(user, state.usersData.size); + state.usersData.set(user.id, userData); + } + + const eventRanges = ranges + .filter(r => !state.existingEvents.has(`range_${r.id}`)) + .map(r => { + // add to existing ids + state.existingEvents.add(`range_${r.id}`); + return r; + }) + .map(r => calendarRangeToFullCalendarEvent(r)); + + userData.calendarRanges = userData.calendarRanges.concat(eventRanges); + userData.calendarRangesLoaded.push({start, end}); + }, + addCalendarRemotesForUser(state, {user, remotes, start, end}) { + let userData; + if (state.usersData.has(user.id)) { + userData = state.usersData.get(user.id); + } else { + userData = createUserData(user, state.usersData.size); + state.usersData.set(user.id, userData); + } + + const eventRemotes = remotes + .filter(r => !state.existingEvents.has(`remote_${r.id}`)) + .map(r => { + // add to existing ids + state.existingEvents.add(`remote_${r.id}`); + return r; + }) + .map(r => remoteToFullCalendarEvent(r)); + + userData.remotes = userData.remotes.concat(eventRemotes); + userData.remotesLoaded.push({start, end}); + }, + addCalendarLocalsForUser(state, {user, locals, start, end}) { + let userData; + if (state.usersData.has(user.id)) { + userData = state.usersData.get(user.id); + } else { + userData = createUserData(user, state.usersData.size); + state.usersData.set(user.id, userData); + } + + const eventRemotes = locals + .filter(r => !state.existingEvents.has(`locals_${r.id}`)) + .map(r => { + // add to existing ids + state.existingEvents.add(`locals_${r.id}`); + return r; + }) + .map(r => localsToFullCalendarEvent(r)); + + userData.locals = userData.locals.concat(eventRemotes); + userData.localsLoaded.push({start, end}); + }, + // Location + updateLocation(state, value) { + console.log('### mutation: updateLocation', value); + state.activity.location = value; + }, + addAvailableLocationGroup(state, group) { + state.availableLocations.push(group); + }, +}; diff --git a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/store/utils.ts b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/store/utils.ts new file mode 100644 index 000000000..61259a984 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/store/utils.ts @@ -0,0 +1,108 @@ +import {COLORS} from '../const'; +import {ISOToDatetime} from '../../../../../../ChillMainBundle/Resources/public/chill/js/date'; +import {DateTime, User} from '../../../../../../ChillMainBundle/Resources/public/types'; +import {CalendarLight, CalendarRange, CalendarRemote} from '../../../types'; +import type {EventInputCalendarRange} from '../../../types'; +import {EventInput} from '@fullcalendar/vue3'; + +export interface UserData { + user: User, + calendarRanges: CalendarRange[], + calendarRangesLoaded: {}[], + remotes: CalendarRemote[], + remotesLoaded: {}[], + locals: CalendarRemote[], + localsLoaded: {}[], + mainColor: string, +} + +export const addIdToValue = (string: string, id: number): string => { + let array = string ? string.split(',') : []; + array.push(id.toString()); + let str = array.join(); + return str; +}; + +export const removeIdFromValue = (string: string, id: number) => { + let array = string.split(','); + array = array.filter(el => el !== id.toString()); + let str = array.join(); + return str; +}; + +/* +* Assign missing keys for the ConcernedGroups component +*/ +export const mapEntity = (entity: EventInput): EventInput => { + let calendar = { ...entity}; + Object.assign(calendar, {thirdParties: entity.professionals}); + + if (entity.startDate !== null ) { + calendar.startDate = ISOToDatetime(entity.startDate.datetime); + } + if (entity.endDate !== null) { + calendar.endDate = ISOToDatetime(entity.endDate.datetime); + } + + if (entity.calendarRange !== null) { + calendar.calendarRange.calendarRangeId = entity.calendarRange.id; + calendar.calendarRange.id = `range_${entity.calendarRange.id}`; + } + + return calendar; +}; + +export const createUserData = (user: User, colorIndex: number): UserData => { + const colorId = colorIndex % COLORS.length; + console.log('colorId', colorId); + return { + user: user, + calendarRanges: [], + calendarRangesLoaded: [], + remotes: [], + remotesLoaded: [], + locals: [], + localsLoaded: [], + mainColor: COLORS[colorId], + } +} + +// TODO move this function to a more global namespace, as it is also in use in MyCalendarRange app +export const calendarRangeToFullCalendarEvent = (entity: CalendarRange): EventInputCalendarRange => { + return { + id: `range_${entity.id}`, + title: "(" + entity.user.text + ")", + start: entity.startDate.datetime8601, + end: entity.endDate.datetime8601, + allDay: false, + userId: entity.user.id, + userLabel: entity.user.label, + calendarRangeId: entity.id, + locationId: entity.location.id, + locationName: entity.location.name, + is: 'range', + }; +} + +export const remoteToFullCalendarEvent = (entity: CalendarRemote): EventInput & {id: string} => { + return { + id: `range_${entity.id}`, + title: entity.title, + start: entity.startDate.datetime8601, + end: entity.endDate.datetime8601, + allDay: entity.isAllDay, + is: 'remote', + }; +} + +export const localsToFullCalendarEvent = (entity: CalendarLight): EventInput & {id: string; originId: number;} => { + return { + id: `local_${entity.id}`, + title: entity.persons.map(p => p.text).join(', '), + originId: entity.id, + start: entity.startDate.datetime8601, + end: entity.endDate.datetime8601, + allDay: false, + is: 'local', + }; +} diff --git a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Invite/Answer.vue b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Invite/Answer.vue new file mode 100644 index 000000000..4607af7fc --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Invite/Answer.vue @@ -0,0 +1,95 @@ + + + + + + diff --git a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/App.vue b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/App.vue deleted file mode 100644 index a30dd2000..000000000 --- a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/App.vue +++ /dev/null @@ -1,468 +0,0 @@ - - - diff --git a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/App2.vue b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/App2.vue new file mode 100644 index 000000000..5cbd17a76 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/App2.vue @@ -0,0 +1,304 @@ + + + + + diff --git a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/Components/EditLocation.vue b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/Components/EditLocation.vue new file mode 100644 index 000000000..3b605dec7 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/Components/EditLocation.vue @@ -0,0 +1,88 @@ + + + + + diff --git a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/i18n.js b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/i18n.ts similarity index 76% rename from src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/i18n.js rename to src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/i18n.ts index 2305b307b..383fff19f 100644 --- a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/i18n.js +++ b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/i18n.ts @@ -3,6 +3,8 @@ const appMessages = { edit_your_calendar_range: "Planifiez vos plages de disponibilités", show_my_calendar: "Afficher mon calendrier", show_weekends: "Afficher les week-ends", + copy_range: "Copier", + copy_range_from_to: "Copier les plages d'un jour à l'autre", copy_range_to_next_day: "Copier les plages du jour au jour suivant", copy_range_from_day: "Copier les plages du ", to_the_next_day: " au jour suivant", @@ -12,7 +14,13 @@ const appMessages = { update_range_to_save: "Plages à modifier", delete_range_to_save: "Plages à supprimer", by: "Par", - main_user_concerned: "Utilisateur concerné" + main_user_concerned: "Utilisateur concerné", + dateFrom: "De", + dateTo: "à", + day: "Jour", + week: "Semaine", + month: "Mois", + today: "Aujourd'hui", } } diff --git a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/index.js b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/index.js deleted file mode 100644 index a10a16601..000000000 --- a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/index.js +++ /dev/null @@ -1,16 +0,0 @@ -import { createApp } from 'vue'; -import { _createI18n } from 'ChillMainAssets/vuejs/_js/i18n' -import { appMessages } from './i18n' -import store from './store' - -import App from './App.vue'; - -const i18n = _createI18n(appMessages); - -const app = createApp({ - template: ``, -}) -.use(store) -.use(i18n) -.component('app', App) -.mount('#myCalendar'); diff --git a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/index2.ts b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/index2.ts new file mode 100644 index 000000000..52dcd318d --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/index2.ts @@ -0,0 +1,19 @@ +import { createApp } from 'vue'; +import { _createI18n } from '../../../../../ChillMainBundle/Resources/public/vuejs/_js/i18n' +import { appMessages } from './i18n' +import futureStore, {key} from './store/index' + +import App2 from './App2.vue'; +import {useI18n} from "vue-i18n"; + +futureStore().then((store) => { + const i18n = _createI18n(appMessages, false); + + const app = createApp({ + template: ``, + }) + .use(store, key) + .use(i18n) + .component('app', App2) + .mount('#myCalendar'); +}); diff --git a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/store.js b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/store.js deleted file mode 100644 index 997f95b11..000000000 --- a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/store.js +++ /dev/null @@ -1,89 +0,0 @@ -import 'es6-promise/auto'; -import { createStore } from 'vuex'; -import { postCalendarRange, patchCalendarRange, deleteCalendarRange } from '../_api/api'; - -const debug = process.env.NODE_ENV !== 'production'; - -const store = createStore({ - strict: debug, - state: { - newCalendarRanges: [], - updateCalendarRanges: [], - deleteCalendarRanges: [] - }, - mutations: { - updateRange(state, payload) { - state.updateCalendarRanges.push({ - id: payload.event.extendedProps.calendarRangeId, - start: payload.event.start, - end: payload.event.end - }); - }, - addRange(state, payload) { - state.newCalendarRanges.push({ - start: payload.start, - end: payload.end - }); - }, - deleteRange(state, payload) { - state.deleteCalendarRanges.push({ - id: payload.extendedProps.calendarRangeId, - start: payload.start, - end: payload.end - }); - }, - clearNewCalendarRanges(state) { - state.newCalendarRanges = []; - }, - clearUpdateCalendarRanges(state) { - state.updateCalendarRanges = []; - }, - clearDeleteCalendarRanges(state) { - state.deleteCalendarRanges = []; - }, - removeNewCalendarRanges(state, payload) { - let filteredCollection = state.newCalendarRanges.filter( - (e) => e.start.toString() !== payload.start.toString() && e.end.toString() !== payload.end.toString() - ) - state.newCalendarRanges = filteredCollection; - }, - removeFromDeleteRange(state, payload) { - let filteredCollection = state.deleteCalendarRanges.filter( - (e) => e.start.toString() !== payload.start.toString() && e.end.toString() !== payload.end.toString() - ) - state.deleteCalendarRanges = filteredCollection; - }, - }, - actions: { - createRange({ commit }, payload) { - console.log('### action createRange', payload); - commit('addRange', payload); - }, - updateRange({ commit }, payload) { - console.log('### action updateRange', payload); - commit('updateRange', payload); - }, - deleteRange({ commit }, payload) { - console.log('### action deleteRange', payload); - commit('deleteRange', payload); - }, - clearNewCalendarRanges({ commit }, payload) { - commit('clearNewCalendarRanges', payload); - }, - clearUpdateCalendarRanges({ commit }, payload) { - commit('clearUpdateCalendarRanges', payload); - }, - clearDeleteCalendarRanges({ commit }, payload) { - commit('clearDeleteCalendarRanges', payload); - }, - removeNewCalendarRanges({ commit }, payload) { - commit('removeNewCalendarRanges', payload); - }, - removeFromDeleteRange({ commit }, payload) { - commit('removeFromDeleteRange', payload); - }, - } -}); - - -export default store; diff --git a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/store/index.ts b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/store/index.ts new file mode 100644 index 000000000..f87832cb5 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/store/index.ts @@ -0,0 +1,50 @@ +import 'es6-promise/auto'; +import {Store, createStore} from 'vuex'; +import {InjectionKey} from "vue"; +import me, {MeState} from './modules/me'; +import fullCalendar, {FullCalendarState} from './modules/fullcalendar'; +import calendarRanges, {CalendarRangesState} from './modules/calendarRanges'; +import calendarRemotes, {CalendarRemotesState} from './modules/calendarRemotes'; +import {whoami} from "../../../../../../ChillMainBundle/Resources/public/lib/api/user"; +import {User} from '../../../../../../ChillMainBundle/Resources/public/types'; +import locations, {LocationState} from "./modules/location"; +import calendarLocals, {CalendarLocalsState} from "./modules/calendarLocals"; + +const debug = process.env.NODE_ENV !== 'production'; + +export interface State { + calendarRanges: CalendarRangesState, + calendarRemotes: CalendarRemotesState, + calendarLocals: CalendarLocalsState, + fullCalendar: FullCalendarState, + me: MeState, + locations: LocationState +} + +export const key: InjectionKey> = Symbol(); + +const futureStore = function(): Promise> { + return whoami().then((user: User) => { + const store = createStore({ + strict: debug, + modules: { + me, + fullCalendar, + calendarRanges, + calendarRemotes, + calendarLocals, + locations, + }, + mutations: {} + }); + + store.commit('me/setWhoAmi', user, {root: true}); + store.dispatch('locations/getLocations', null, {root: true}).then(_ => { + return store.dispatch('locations/getCurrentLocation', null, {root: true}); + }); + + return Promise.resolve(store); + }); +} + +export default futureStore; diff --git a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/store/modules/calendarLocals.ts b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/store/modules/calendarLocals.ts new file mode 100644 index 000000000..12df9663d --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/store/modules/calendarLocals.ts @@ -0,0 +1,95 @@ +import {State} from './../index'; +import {ActionContext, Module} from 'vuex'; +import {CalendarLight} from '../../../../types'; +import {fetchCalendarLocalForUser} from '../../../Calendar/api'; +import {EventInput} from '@fullcalendar/vue3'; +import {localsToFullCalendarEvent} from "../../../Calendar/store/utils"; +import {TransportExceptionInterface} from "../../../../../../../ChillMainBundle/Resources/public/lib/api/apiMethods"; +import {COLORS} from "../../../Calendar/const"; + +export interface CalendarLocalsState { + locals: EventInput[], + localsLoaded: {start: number, end: number}[], + localsIndex: Set, + key: number +} + +type Context = ActionContext; + +export default > { + namespaced: true, + state: (): CalendarLocalsState => ({ + locals: [], + localsLoaded: [], + localsIndex: new Set(), + key: 0 + }), + getters: { + isLocalsLoaded: (state: CalendarLocalsState) => ({start, end}: {start: Date, end: Date}): boolean => { + for (let range of state.localsLoaded) { + if (start.getTime() === range.start && end.getTime() === range.end) { + return true; + } + } + + return false; + }, + }, + mutations: { + addLocals(state: CalendarLocalsState, ranges: CalendarLight[]) { + console.log('addLocals', ranges); + + const toAdd = ranges + .map(cr => localsToFullCalendarEvent(cr)) + .filter(r => !state.localsIndex.has(r.id)); + + toAdd.forEach((r) => { + state.localsIndex.add(r.id); + state.locals.push(r); + }); + state.key = state.key + toAdd.length; + }, + addLoaded(state: CalendarLocalsState, payload: {start: Date, end: Date}) { + state.localsLoaded.push({start: payload.start.getTime(), end: payload.end.getTime()}); + }, + }, + actions: { + fetchLocals(ctx: Context, payload: {start: Date, end: Date}): Promise { + const start = payload.start; + const end = payload.end; + + if (ctx.rootGetters['me/getMe'] === null) { + return Promise.resolve(null); + } + + if (ctx.getters.isLocalsLoaded({start, end})) { + return Promise.resolve(ctx.getters.getRangeSource); + } + + ctx.commit('addLoaded', { + start: start, + end: end, + }); + + return fetchCalendarLocalForUser( + ctx.rootGetters['me/getMe'], + start, + end + ) + .then((remotes: CalendarLight[]) => { + // to be add when reactivity problem will be solve ? + //ctx.commit('addRemotes', remotes); + const inputs = remotes + .map(cr => localsToFullCalendarEvent(cr)) + .map(cr => ({...cr, backgroundColor: COLORS[0], textColor: 'black', editable: false})) + ctx.commit('calendarRanges/addExternals', inputs, {root: true}); + return Promise.resolve(null); + }) + .catch((e: TransportExceptionInterface) => { + console.error(e); + + return Promise.resolve(null); + }); + } + } +}; diff --git a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/store/modules/calendarRanges.ts b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/store/modules/calendarRanges.ts new file mode 100644 index 000000000..fd7e2d0c1 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/store/modules/calendarRanges.ts @@ -0,0 +1,252 @@ +import {State} from './../index'; +import {ActionContext, Module} from 'vuex'; +import {CalendarRange, CalendarRangeCreate, CalendarRangeEdit, isEventInputCalendarRange} from "../../../../types"; +import {Location} from "../../../../../../../ChillMainBundle/Resources/public/types"; +import {fetchCalendarRangeForUser} from '../../../Calendar/api'; +import {calendarRangeToFullCalendarEvent} from '../../../Calendar/store/utils'; +import {EventInput} from '@fullcalendar/vue3'; +import {makeFetch} from "../../../../../../../ChillMainBundle/Resources/public/lib/api/apiMethods"; +import { + datetimeToISO, + dateToISO, + ISOToDatetime +} from "../../../../../../../ChillMainBundle/Resources/public/chill/js/date"; +import type {EventInputCalendarRange} from "../../../../types"; + +export interface CalendarRangesState { + ranges: (EventInput | EventInputCalendarRange) [], + rangesLoaded: { start: number, end: number }[], + rangesIndex: Set, + key: number +} + +type Context = ActionContext; + +export default >{ + namespaced: true, + state: (): CalendarRangesState => ({ + ranges: [], + rangesLoaded: [], + rangesIndex: new Set(), + key: 0 + }), + getters: { + isRangeLoaded: (state: CalendarRangesState) => ({start, end}: { start: Date, end: Date }): boolean => { + for (let range of state.rangesLoaded) { + if (start.getTime() === range.start && end.getTime() === range.end) { + return true; + } + } + + return false; + }, + getRangesOnDate: (state: CalendarRangesState) => (date: Date): EventInputCalendarRange[] => { + const founds = []; + const dateStr = dateToISO(date); + + for (let range of state.ranges) { + if (isEventInputCalendarRange(range) + && range.start.startsWith(dateStr) + ) { + founds.push(range); + } + } + + return founds; + }, + }, + mutations: { + addRanges(state: CalendarRangesState, ranges: CalendarRange[]) { + const toAdd = ranges + .map(cr => calendarRangeToFullCalendarEvent(cr)) + .map(cr => ({ + ...cr, backgroundColor: 'white', borderColor: '#3788d8', + textColor: 'black' + })) + .filter(r => !state.rangesIndex.has(r.id)); + + toAdd.forEach((r) => { + state.rangesIndex.add(r.id); + state.ranges.push(r); + }); + state.key = state.key + toAdd.length; + }, + addExternals(state, externalEvents: (EventInput & { id: string })[]) { + const toAdd = externalEvents + .filter(r => !state.rangesIndex.has(r.id)); + + toAdd.forEach((r) => { + state.rangesIndex.add(r.id); + state.ranges.push(r); + }); + state.key = state.key + toAdd.length; + }, + addLoaded(state: CalendarRangesState, payload: { start: Date, end: Date }) { + state.rangesLoaded.push({start: payload.start.getTime(), end: payload.end.getTime()}); + }, + addRange(state: CalendarRangesState, payload: CalendarRange) { + const asEvent = calendarRangeToFullCalendarEvent(payload); + state.ranges.push({...asEvent, backgroundColor: 'white', borderColor: '#3788d8', textColor: 'black'}); + state.rangesIndex.add(asEvent.id); + state.key = state.key + 1; + }, + removeRange(state: CalendarRangesState, calendarRangeId: number) { + const found = state.ranges.find(r => r.calendarRangeId === calendarRangeId && r.is === "range"); + + if (found !== undefined) { + state.ranges = state.ranges.filter( + (r) => !(r.calendarRangeId === calendarRangeId && r.is === "range") + ); + + if (typeof found.id === "string") { // should always be true + state.rangesIndex.delete(found.id); + } + + state.key = state.key + 1; + } + }, + updateRange(state, range: CalendarRange) { + const found = state.ranges.find(r => r.calendarRangeId === range.id && r.is === "range"); + const newEvent = calendarRangeToFullCalendarEvent(range); + + if (found !== undefined) { + found.start = newEvent.start; + found.end = newEvent.end; + found.locationId = range.location.id; + found.locationName = range.location.name; + } + + state.key = state.key + 1; + } + }, + actions: { + fetchRanges(ctx: Context, payload: { start: Date, end: Date }): Promise { + const start = payload.start; + const end = payload.end; + + if (ctx.rootGetters['me/getMe'] === null) { + return Promise.resolve(ctx.getters.getRangeSource); + } + + if (ctx.getters.isRangeLoaded({start, end})) { + return Promise.resolve(ctx.getters.getRangeSource); + } + + ctx.commit('addLoaded', { + start: start, + end: end, + }); + + return fetchCalendarRangeForUser( + ctx.rootGetters['me/getMe'], + start, + end + ) + .then((ranges: CalendarRange[]) => { + ctx.commit('addRanges', ranges); + return Promise.resolve(null); + }); + }, + createRange(ctx, {start, end, location}: { start: Date, end: Date, location: Location }): Promise { + const url = `/api/1.0/calendar/calendar-range.json?`; + + if (ctx.rootState.me.me === null) { + throw new Error('user is currently null'); + } + + const body = { + user: { + id: ctx.rootState.me.me.id, + type: "user" + }, + startDate: { + datetime: datetimeToISO(start), + }, + endDate: { + datetime: datetimeToISO(end) + }, + location: { + id: location.id, + type: "location" + } + } as CalendarRangeCreate; + + return makeFetch('POST', url, body) + .then((newRange) => { + + ctx.commit('addRange', newRange); + + return Promise.resolve(null); + }) + .catch((error) => { + console.error(error); + + throw error; + }) + }, + deleteRange(ctx, calendarRangeId: number) { + const url = `/api/1.0/calendar/calendar-range/${calendarRangeId}.json`; + + makeFetch('DELETE', url) + .then((_) => { + ctx.commit('removeRange', calendarRangeId); + }); + }, + patchRangeTime(ctx, {calendarRangeId, start, end}: {calendarRangeId: number, start: Date, end: Date}): Promise { + const url = `/api/1.0/calendar/calendar-range/${calendarRangeId}.json`; + const body = { + startDate: { + datetime: datetimeToISO(start) + }, + endDate: { + datetime: datetimeToISO(end) + }, + } as CalendarRangeEdit; + + return makeFetch('PATCH', url, body) + .then((range) => { + ctx.commit('updateRange', range); + return Promise.resolve(null); + }) + .catch((error) => { + console.error(error); + return Promise.resolve(null); + }) + }, + patchRangeLocation(ctx, {location, calendarRangeId}: {location: Location, calendarRangeId: number}): Promise { + const url = `/api/1.0/calendar/calendar-range/${calendarRangeId}.json`; + const body = { + location: { + id: location.id, + type: "location" + }, + } as CalendarRangeEdit; + + return makeFetch('PATCH', url, body) + .then((range) => { + ctx.commit('updateRange', range); + return Promise.resolve(null); + }) + .catch((error) => { + console.error(error); + return Promise.resolve(null); + }) + }, + copyFromDayToAnotherDay(ctx, {from, to}: {from: Date, to: Date}): Promise { + const rangesToCopy: EventInputCalendarRange[] = ctx.getters['getRangesOnDate'](from); + const promises = []; + + for (let r of rangesToCopy) { + let start = new Date(ISOToDatetime(r.start)); + start.setFullYear(to.getFullYear(), to.getMonth(), to.getDate()) + let end = new Date(ISOToDatetime(r.end)); + end.setFullYear(to.getFullYear(), to.getMonth(), to.getDate()); + let location = ctx.rootGetters['locations/getLocationById'](r.locationId); + + promises.push(ctx.dispatch('createRange', {start, end, location})); + } + + return Promise.all(promises).then(_ => Promise.resolve(null)); + } + } +}; diff --git a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/store/modules/calendarRemotes.ts b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/store/modules/calendarRemotes.ts new file mode 100644 index 000000000..260f79a42 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/store/modules/calendarRemotes.ts @@ -0,0 +1,95 @@ +import {State} from './../index'; +import {ActionContext, Module} from 'vuex'; +import {CalendarRemote} from '../../../../types'; +import {fetchCalendarRemoteForUser} from '../../../Calendar/api'; +import {EventInput, EventSource} from '@fullcalendar/vue3'; +import {remoteToFullCalendarEvent} from "../../../Calendar/store/utils"; +import {TransportExceptionInterface} from "../../../../../../../ChillMainBundle/Resources/public/lib/api/apiMethods"; +import {COLORS} from "../../../Calendar/const"; + +export interface CalendarRemotesState { + remotes: EventInput[], + remotesLoaded: {start: number, end: number}[], + remotesIndex: Set, + key: number +} + +type Context = ActionContext; + +export default > { + namespaced: true, + state: (): CalendarRemotesState => ({ + remotes: [], + remotesLoaded: [], + remotesIndex: new Set(), + key: 0 + }), + getters: { + isRemotesLoaded: (state: CalendarRemotesState) => ({start, end}: {start: Date, end: Date}): boolean => { + for (let range of state.remotesLoaded) { + if (start.getTime() === range.start && end.getTime() === range.end) { + return true; + } + } + + return false; + }, + }, + mutations: { + addRemotes(state: CalendarRemotesState, ranges: CalendarRemote[]) { + console.log('addRemotes', ranges); + + const toAdd = ranges + .map(cr => remoteToFullCalendarEvent(cr)) + .filter(r => !state.remotesIndex.has(r.id)); + + toAdd.forEach((r) => { + state.remotesIndex.add(r.id); + state.remotes.push(r); + }); + state.key = state.key + toAdd.length; + }, + addLoaded(state: CalendarRemotesState, payload: {start: Date, end: Date}) { + state.remotesLoaded.push({start: payload.start.getTime(), end: payload.end.getTime()}); + }, + }, + actions: { + fetchRemotes(ctx: Context, payload: {start: Date, end: Date}): Promise { + const start = payload.start; + const end = payload.end; + + if (ctx.rootGetters['me/getMe'] === null) { + return Promise.resolve(null); + } + + if (ctx.getters.isRemotesLoaded({start, end})) { + return Promise.resolve(ctx.getters.getRangeSource); + } + + ctx.commit('addLoaded', { + start: start, + end: end, + }); + + return fetchCalendarRemoteForUser( + ctx.rootGetters['me/getMe'], + start, + end + ) + .then((remotes: CalendarRemote[]) => { + // to be add when reactivity problem will be solve ? + //ctx.commit('addRemotes', remotes); + const inputs = remotes + .map(cr => remoteToFullCalendarEvent(cr)) + .map(cr => ({...cr, backgroundColor: COLORS[0], textColor: 'black', editable: false})) + ctx.commit('calendarRanges/addExternals', inputs, {root: true}); + return Promise.resolve(null); + }) + .catch((e: TransportExceptionInterface) => { + console.error(e); + + return Promise.resolve(null); + }); + } + } +}; diff --git a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/store/modules/fullcalendar.ts b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/store/modules/fullcalendar.ts new file mode 100644 index 000000000..de379753f --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/store/modules/fullcalendar.ts @@ -0,0 +1,56 @@ +import {State} from './../index'; +import {ActionContext} from 'vuex'; + +export interface FullCalendarState { + currentView: { + start: Date|null, + end: Date|null, + }, + key: number +} + +type Context = ActionContext; + +export default { + namespaced: true, + state: (): FullCalendarState => ({ + currentView: { + start: null, + end: null, + }, + key: 0, + }), + mutations: { + setCurrentDatesView: function(state: FullCalendarState, payload: {start: Date, end: Date}): void { + state.currentView.start = payload.start; + state.currentView.end = payload.end; + }, + increaseKey: function(state: FullCalendarState): void { + state.key = state.key + 1; + } + }, + actions: { + setCurrentDatesView(ctx: Context, {start, end}: {start: Date|null, end: Date|null}): Promise { + console.log('dispatch setCurrentDatesView', {start, end}); + + if (ctx.state.currentView.start !== start || ctx.state.currentView.end !== end) { + ctx.commit('setCurrentDatesView', {start, end}); + } + + if (start !== null && end !== null) { + + return Promise.all([ + ctx.dispatch('calendarRanges/fetchRanges', {start, end}, {root: true}).then(_ => Promise.resolve(null)), + ctx.dispatch('calendarRemotes/fetchRemotes', {start, end}, {root: true}).then(_ => Promise.resolve(null)), + ctx.dispatch('calendarLocals/fetchLocals', {start, end}, {root: true}).then(_ => Promise.resolve(null)) + ] + ).then(_ => Promise.resolve(null)); + + } else { + return Promise.resolve(null); + } + }, + } +} + + diff --git a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/store/modules/location.ts b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/store/modules/location.ts new file mode 100644 index 000000000..7fbfe7fce --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/store/modules/location.ts @@ -0,0 +1,62 @@ +import {Location} from "../../../../../../../ChillMainBundle/Resources/public/types"; +import {State} from '../index'; +import {Module} from 'vuex'; +import {getLocations} from "../../../../../../../ChillMainBundle/Resources/public/lib/api/locations"; +import {whereami} from "../../../../../../../ChillMainBundle/Resources/public/lib/api/user"; + +export interface LocationState { + locations: Location[]; + locationPicked: Location | null; + currentLocation: Location | null; +} + +export default >{ + namespaced: true, + state: (): LocationState => { + return { + locations: [], + locationPicked: null, + currentLocation: null, + } + }, + getters: { + getLocationById: (state) => (id: number): Location|undefined => { + return state.locations.find(l => l.id === id); + }, + }, + mutations: { + setLocations(state, locations): void { + state.locations = locations; + }, + setLocationPicked(state, location: Location | null): void { + if (null === location) { + state.locationPicked = null; + return; + } + + state.locationPicked = state.locations.find(l => l.id === location.id) || null; + }, + setCurrentLocation(state, location: Location | null): void { + if (null === location) { + state.currentLocation = null; + return; + } + + state.currentLocation = state.locations.find(l => l.id === location.id) || null; + } + }, + actions: { + getLocations(ctx): Promise { + return getLocations().then(locations => { + ctx.commit('setLocations', locations); + return Promise.resolve(); + }); + }, + getCurrentLocation(ctx): Promise { + return whereami().then(location => { + ctx.commit('setCurrentLocation', location); + }) + } + } +} + diff --git a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/store/modules/me.ts b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/store/modules/me.ts new file mode 100644 index 000000000..8610b76d3 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/store/modules/me.ts @@ -0,0 +1,29 @@ +import {State} from './../index'; +import {User} from '../../../../../../../ChillMainBundle/Resources/public/types'; +import {ActionContext} from 'vuex'; + +export interface MeState { + me: User|null, +} + +type Context = ActionContext; + +export default { + namespaced: true, + state: (): MeState => ({ + me: null, + }), + getters: { + getMe: function(state: MeState): User|null { + return state.me; + }, + }, + mutations: { + setWhoAmi(state: MeState, me: User) { + state.me = me; + }, + } +}; + + + diff --git a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/_api/api.js b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/_api/api.js index 7aad8ace2..b2bee8afe 100644 --- a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/_api/api.js +++ b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/_api/api.js @@ -4,6 +4,7 @@ * @returns {Promise} a promise containing all Calendar ranges objects */ const fetchCalendarRanges = () => { + return Promise.resolve([]); const url = `/api/1.0/calendar/calendar-range-available.json`; return fetch(url) .then(response => { @@ -13,6 +14,7 @@ const fetchCalendarRanges = () => { }; const fetchCalendarRangesByUser = (userId) => { + return Promise.resolve([]); const url = `/api/1.0/calendar/calendar-range-available.json?user=${userId}`; return fetch(url) .then(response => { @@ -27,6 +29,7 @@ const fetchCalendarRangesByUser = (userId) => { * @returns {Promise} a promise containing all Calendar objects */ const fetchCalendar = (mainUserId) => { + return Promise.resolve([]); const url = `/api/1.0/calendar/calendar.json?main_user=${mainUserId}&item_per_page=1000`; return fetch(url) .then(response => { diff --git a/src/Bundle/ChillCalendarBundle/Resources/views/Calendar/_documents.twig.html b/src/Bundle/ChillCalendarBundle/Resources/views/Calendar/_documents.twig.html new file mode 100644 index 000000000..a568ec009 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Resources/views/Calendar/_documents.twig.html @@ -0,0 +1,53 @@ +{% if calendar.documents|length > 0 %} + +{% import "@ChillDocStore/Macro/macro.html.twig" as m %} +{% import "@ChillDocStore/Macro/macro_mimeicon.html.twig" as mm %} + +
+ + + + + + {% for d in calendar.documents %} + {% if is_granted('CHILL_CALENDAR_DOC_SEE', d) %} + + + + {% endif %} + {% endfor %} + +
+

{{ 'chill_calendar.Documents'|trans }}

+
+
    +
  • + {{ mm.mimeIcon(d.storedObject.type) }} + {{ d.storedObject.title }} + {% if d.dateTimeVersion < d.calendar.dateTimeVersion %} + {{ 'chill_calendar.Document outdated'|trans }} + {% endif %} + +
      + {% if chill_document_is_editable(d.storedObject) and is_granted('CHILL_CALENDAR_DOC_EDIT', d) %} +
    • + +
    • +
    • + {{ d.storedObject|chill_document_edit_button }} +
    • + {% endif %} + {% if is_granted('CHILL_CALENDAR_DOC_EDIT', d) %} +
    • + +
    • + {% endif %} +
    • + {{ m.download_button(d.storedObject, d.storedObject.title) }} +
    • +
    +
  • +
+
+
+{% endif %} diff --git a/src/Bundle/ChillCalendarBundle/Resources/views/Calendar/_list.html.twig b/src/Bundle/ChillCalendarBundle/Resources/views/Calendar/_list.html.twig new file mode 100644 index 000000000..d6110917a --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Resources/views/Calendar/_list.html.twig @@ -0,0 +1,202 @@ +{# list used in context of person or accompanyingPeriod #} + +{% if calendarItems|length > 0 %} +
+ + {% for calendar in calendarItems %} + +
+
+
+
+
+
+

+ {% if context == 'person' and calendar.context == 'accompanying_period' %} + + + {{ calendar.accompanyingPeriod.id }} + + + {% endif %} + {% if calendar.endDate.diff(calendar.startDate).days >= 1 %} + {{ calendar.startDate|format_datetime('short', 'short') }} + - {{ calendar.endDate|format_datetime('short', 'short') }} + {% else %} + {{ calendar.startDate|format_datetime('short', 'short') }} + - {{ calendar.endDate|format_datetime('none', 'short') }} + {% endif %} +

+ +
+ + {{ calendar.duration|date('%H:%I') }} + {% if false == calendar.sendSMS or null == calendar.sendSMS %} + + {% else %} + {% if calendar.smsStatus == 'sms_sent' %} + + + + + {% else %} + + + + + {% endif %} + {% endif %} +
+ +
+
+
+ +
+
    + {% if calendar.mainUser is not empty %} + {{ calendar.mainUser|chill_entity_render_box }} + {% endif %} +
+
+ +
+
+ + {% if calendar.comment.comment is not empty + or calendar.users|length > 0 + or calendar.thirdParties|length > 0 + or calendar.users|length > 0 %} +
+
+ {% include 'ChillActivityBundle:Activity:concernedGroups.html.twig' with { + 'context': calendar.context == 'person' ? 'calendar_person' : 'calendar_accompanyingCourse', + 'render': 'wrap-list', + 'entity': calendar + } %} +
+ +
+ {% endif %} + + {% if calendar.comment.comment is not empty %} +
+
+ {{ calendar.comment|chill_entity_render_box( { 'limit_lines': 3, 'metadata': false } ) }} +
+
+ {% endif %} + + {% if calendar.location is not empty %} +
+
+ {% if calendar.location.address is not same as(null) and calendar.location.name is not empty %} + {% endif %} + {% if calendar.location.name is not empty %}{{ calendar.location.name }}{% endif %} + {% if calendar.location.address is not same as(null) %}{{ calendar.location.address|chill_entity_render_box({'multiline': false, 'with_picto': (calendar.location.name is empty)}) }}{% else %} + {% endif %} + {% if calendar.location.phonenumber1 is not empty %} {{ calendar.location.phonenumber1|chill_format_phonenumber }}{% endif %} + {% if calendar.location.phonenumber2 is not empty %} {{ calendar.location.phonenumber2|chill_format_phonenumber }}{% endif %} +
+
+ {% endif %} + +
+
+ + {{ include('@ChillCalendar/Calendar/_documents.twig.html') }} +
+
+ +
+ + +
+ +
+ {% endfor %} + + {% if calendarItems|length < paginator.getTotalItems %} + {{ chill_pagination(paginator) }} + {% endif %} + +
+{% endif %} diff --git a/src/Bundle/ChillCalendarBundle/Resources/views/Calendar/confirm_deleteByAccompanyingCourse.html.twig b/src/Bundle/ChillCalendarBundle/Resources/views/Calendar/confirm_deleteByAccompanyingCourse.html.twig index 26818bb6c..161f4d8b4 100644 --- a/src/Bundle/ChillCalendarBundle/Resources/views/Calendar/confirm_deleteByAccompanyingCourse.html.twig +++ b/src/Bundle/ChillCalendarBundle/Resources/views/Calendar/confirm_deleteByAccompanyingCourse.html.twig @@ -9,8 +9,8 @@ { 'title' : 'Remove calendar item'|trans, 'confirm_question' : 'Are you sure you want to remove the calendar item?'|trans, - 'cancel_route' : 'chill_calendar_calendar_list', - 'cancel_parameters' : { 'accompanying_period_id' : accompanyingCourse.id, 'id' : calendar.id }, + 'cancel_route' : 'chill_calendar_calendar_list_by_period', + 'cancel_parameters' : { 'id' : accompanyingCourse.id }, 'form' : delete_form } ) }} {% endblock %} diff --git a/src/Bundle/ChillCalendarBundle/Resources/views/Calendar/confirm_deleteByPerson.html.twig b/src/Bundle/ChillCalendarBundle/Resources/views/Calendar/confirm_deleteByPerson.html.twig new file mode 100644 index 000000000..f72d0d21b --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Resources/views/Calendar/confirm_deleteByPerson.html.twig @@ -0,0 +1,16 @@ +{% extends "@ChillPerson/Person/layout.html.twig" %} + +{% set activeRouteKey = 'chill_calendar_calendar_list' %} + +{% block title 'Remove calendar item'|trans %} + +{% block content %} + {{ include('@ChillMain/Util/confirmation_template.html.twig', + { + 'title' : 'Remove calendar item'|trans, + 'confirm_question' : 'Are you sure you want to remove the calendar item?'|trans, + 'cancel_route' : 'chill_calendar_calendar_list_by_period', + 'cancel_parameters' : { 'id' : person.id }, + 'form' : delete_form + } ) }} +{% endblock %} diff --git a/src/Bundle/ChillCalendarBundle/Resources/views/Calendar/edit.html.twig b/src/Bundle/ChillCalendarBundle/Resources/views/Calendar/edit.html.twig index b96d12c06..df814282a 100644 --- a/src/Bundle/ChillCalendarBundle/Resources/views/Calendar/edit.html.twig +++ b/src/Bundle/ChillCalendarBundle/Resources/views/Calendar/edit.html.twig @@ -3,9 +3,9 @@ {{ form_start(form) }} {{ form_errors(form) }} -{%- if form.mainUser is defined -%} - {{ form_row(form.mainUser) }} -{% endif %} +
{# <=== vue component #} + +
{# <=== vue component: mainUser #}

{{ 'Concerned groups'|trans }}

@@ -26,6 +26,13 @@

{{ 'Calendar data'|trans }}

+
+ +{%- if form.location is defined -%} + {{ form_row(form.location) }} +
+{% endif %} + {%- if form.startDate is defined -%} {{ form_row(form.startDate) }} {% endif %} @@ -38,10 +45,7 @@ {{ form_row(form.calendarRange) }} {% endif %} -{%- if form.location is defined -%} - {{ form_row(form.location) }} -
-{% endif %} +
{%- if form.comment is defined -%} {{ form_row(form.comment) }} @@ -59,7 +63,6 @@
{% endif %} -
  • @@ -68,17 +71,37 @@ {%- if context == 'user' -%} href="{{ chill_return_path_or('chill_calendar_calendar_list', { 'user_id': user.id } )}}" {%- elseif context == 'accompanyingCourse' -%} - href="{{ chill_return_path_or('chill_calendar_calendar_list', { 'accompanying_period_id': accompanyingCourse.id } )}}" + href="{{ chill_return_path_or('chill_calendar_calendar_list_by_period', { 'id': accompanyingCourse.id } )}}" {%- endif -%} > {{ 'Cancel'|trans|chill_return_path_label }}
  • -
  • - -
  • + {% if templates|length == 0 %} +
  • + {{ form_widget(form.save_and_upload_doc, { 'attr' : { 'class' : 'btn btn-create' }, 'label': 'chill_calendar.Create and add a document'|trans }) }} +
  • + {% else %} +
  • + +
  • + {% endif %} +
  • + {{ form_widget(form.save, { 'attr' : { 'class' : 'btn btn-create' }, 'label': 'Save'|trans }) }} +
{{ form_end(form) }} diff --git a/src/Bundle/ChillCalendarBundle/Resources/views/Calendar/editByAccompanyingCourse.html.twig b/src/Bundle/ChillCalendarBundle/Resources/views/Calendar/editByAccompanyingCourse.html.twig index 850bfbecb..a167d5c18 100644 --- a/src/Bundle/ChillCalendarBundle/Resources/views/Calendar/editByAccompanyingCourse.html.twig +++ b/src/Bundle/ChillCalendarBundle/Resources/views/Calendar/editByAccompanyingCourse.html.twig @@ -7,7 +7,6 @@ {% block content %}
-
{# <=== vue component #} {% include 'ChillCalendarBundle:Calendar:edit.html.twig' with {'context': 'accompanyingCourse'} %}
@@ -16,10 +15,6 @@ {% block js %} {{ parent() }} + {{ encore_entry_script_tags('vue_calendar') }} +{% endblock %} + +{% block css %} + {{ parent() }} + {{ encore_entry_link_tags('vue_calendar') }} + {{ encore_entry_link_tags('page_calendar') }} +{% endblock %} + +{% block block_post_menu %} +
+{% endblock %} diff --git a/src/Bundle/ChillCalendarBundle/Resources/views/Calendar/listByAccompanyingCourse.html.twig b/src/Bundle/ChillCalendarBundle/Resources/views/Calendar/listByAccompanyingCourse.html.twig index 6cf97a58d..d85c5237e 100644 --- a/src/Bundle/ChillCalendarBundle/Resources/views/Calendar/listByAccompanyingCourse.html.twig +++ b/src/Bundle/ChillCalendarBundle/Resources/views/Calendar/listByAccompanyingCourse.html.twig @@ -1,4 +1,4 @@ -{% extends "@ChillPerson/AccompanyingCourse/layout.html.twig" %} +{% extends "@ChillPerson/AccompanyingCourse/layout.html.twig" %} {% set activeRouteKey = 'chill_calendar_calendar_list' %} @@ -7,123 +7,51 @@ {% set user_id = null %} {% set accompanying_course_id = accompanyingCourse.id %} +{% block js %} + {{ parent() }} + {{ encore_entry_script_tags('mod_answer') }} + {{ encore_entry_script_tags('mod_async_upload') }} +{% endblock %} + +{% block css %} + {{ parent() }} + {{ encore_entry_link_tags('mod_answer') }} + {{ encore_entry_link_tags('mod_async_upload') }} +{% endblock %} + {% block content %} -

{{ 'Calendar list' |trans }}

+

{{ 'Calendar list' |trans }}

-{% if calendarItems|length == 0 %} -

- {{ "There is no calendar items."|trans }} - -

-{% else %} + {{ filterOrder|chill_render_filter_order_helper }} -
+ {% if calendarItems|length == 0 %} +

+ {% if nbIgnored == 0 %} + {{ "There is no calendar items."|trans }} + {% else %} + {{ 'chill_calendar.There are count ignored calendars by date filter'|trans({'nbIgnored': nbIgnored}) }} + {% endif %} +

+ {% else %} + {{ include('@ChillCalendar/Calendar/_list.html.twig', {context: 'accompanying_course'}) }} + {% endif %} - {% for calendar in calendarItems %} - -
-
-
- - {% if calendar.startDate and calendar.endDate %} - {% if calendar.endDate.diff(calendar.startDate).days >= 1 %} -

{{ "From the day"|trans }} {{ calendar.startDate|format_datetime('medium', 'short') }}

-

{{ "to the day"|trans }} {{ calendar.endDate|format_datetime('medium', 'short') }}

- {% else %} -

{{ calendar.startDate|format_date('full') }}

-

{{ calendar.startDate|format_datetime('none', 'short', locale='fr') }} - {{ calendar.endDate|format_datetime('none', 'short', locale='fr') }}

- -
-

- - {{ calendar.endDate.diff(calendar.startDate)|date("%H:%M")}} -

-
- {% endif %} - - {% endif %} - -
-
-
    - {% if calendar.user %} -
  • - {{ 'by'|trans }}{{ calendar.user.usernameCanonical }} -
  • - {% endif %} - - {% if calendar.mainUser is not empty %} -
  • - {{ 'main user concerned'|trans }}: {{ calendar.mainUser.usernameCanonical }} -
  • - {% endif %} - -
-
    -
  • - -
  • - {# TOOD - {% if is_granted('CHILL_ACTIVITY_UPDATE', calendar) %} - #} -
  • - -
  • - {# TOOD - {% endif %} - {% if is_granted('CHILL_ACTIVITY_DELETE', calendar) %} - #} -
  • - -
  • - {# - {% endif %} - #} -
-
-
- - {% - if calendar.comment.comment is not empty - or calendar.users|length > 0 - or calendar.thirdParties|length > 0 - or calendar.users|length > 0 - %} -
-
- {% include 'ChillActivityBundle:Activity:concernedGroups.html.twig' with { - 'context': accompanyingCourse, - 'render': 'row', - 'entity': calendar - } %} -
- - {% if calendar.comment.comment is not empty %} -
- {{ calendar.comment|chill_entity_render_box( { 'limit_lines': 3, 'metadata': false } ) }} -
- {% endif %} -
- {% endif %} - -
- {% endfor %} - - {% if calendarItems|length < paginator.getTotalItems %} - {{ chill_pagination(paginator) }} +
-{% endif %} - - - +
  • + + {{ 'Create'|trans }} + +
  • + {% endblock %} diff --git a/src/Bundle/ChillCalendarBundle/Resources/views/Calendar/listByPerson.html.twig b/src/Bundle/ChillCalendarBundle/Resources/views/Calendar/listByPerson.html.twig new file mode 100644 index 000000000..7c5fdc639 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Resources/views/Calendar/listByPerson.html.twig @@ -0,0 +1,54 @@ +{% extends "@ChillPerson/Person/layout.html.twig" %} + +{% set activeRouteKey = 'chill_calendar_calendar_list' %} + +{% block title %}{{ 'Calendar list' |trans }}{% endblock title %} + +{% set user_id = null %} + +{% block js %} + {{ parent() }} + {{ encore_entry_script_tags('mod_answer') }} + {{ encore_entry_script_tags('mod_async_upload') }} +{% endblock %} + +{% block css %} + {{ parent() }} + {{ encore_entry_link_tags('mod_answer') }} + {{ encore_entry_link_tags('mod_async_upload') }} +{% endblock %} + +{% block content %} + +

    {{ 'Calendar list' |trans }}

    + + {{ filterOrder|chill_render_filter_order_helper }} + + {% if calendarItems|length == 0 %} +

    + {% if nbIgnored == 0 %} + {{ "There is no calendar items."|trans }} + {% else %} + {{ 'chill_calendar.There are count ignored calendars by date filter'|trans({'nbIgnored': nbIgnored}) }} + {% endif %} +

    + {% else %} + {{ include ('@ChillCalendar/Calendar/_list.html.twig', {context: 'person'}) }} + {% endif %} + + + +{% endblock %} diff --git a/src/Bundle/ChillCalendarBundle/Resources/views/Calendar/listByUser.html.twig b/src/Bundle/ChillCalendarBundle/Resources/views/Calendar/listByUser.html.twig index 055d967a7..fa034a89b 100644 --- a/src/Bundle/ChillCalendarBundle/Resources/views/Calendar/listByUser.html.twig +++ b/src/Bundle/ChillCalendarBundle/Resources/views/Calendar/listByUser.html.twig @@ -10,7 +10,7 @@
    {% endblock %} - + {% block js %} {{ parent() }} {{ encore_entry_script_tags('vue_calendar') }} {% endblock %} @@ -28,6 +26,7 @@ {% block css %} {{ parent() }} {{ encore_entry_link_tags('vue_calendar') }} + {{ encore_entry_link_tags('mod_pickentity_type') }} {% endblock %} {% block block_post_menu %} diff --git a/src/Bundle/ChillCalendarBundle/Resources/views/Calendar/newByPerson.html.twig b/src/Bundle/ChillCalendarBundle/Resources/views/Calendar/newByPerson.html.twig new file mode 100644 index 000000000..b561e6aa7 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Resources/views/Calendar/newByPerson.html.twig @@ -0,0 +1,34 @@ +{% extends "@ChillPerson/Person/layout.html.twig" %} + +{% set activeRouteKey = 'chill_calendar_calendar_new' %} + +{% block title 'Calendar item creation' |trans %} + +{% block content %} +
    + +
    {# <=== vue component #} + {% include 'ChillCalendarBundle:Calendar:new.html.twig' with {'context': 'person'} %} + +
    +{% endblock %} + +{% block js %} + {{ parent() }} + {{ encore_entry_script_tags('mod_pickentity_type') }} + + {{ encore_entry_script_tags('vue_calendar') }} +{% endblock %} + +{% block css %} + {{ parent() }} + {{ encore_entry_link_tags('vue_calendar') }} + {{ encore_entry_link_tags('mod_pickentity_type') }} +{% endblock %} + +{% block block_post_menu %} +
    +{% endblock %} diff --git a/src/Bundle/ChillCalendarBundle/Resources/views/Calendar/show.html.twig b/src/Bundle/ChillCalendarBundle/Resources/views/Calendar/show.html.twig index 3a1bf03d5..bff4baa53 100644 --- a/src/Bundle/ChillCalendarBundle/Resources/views/Calendar/show.html.twig +++ b/src/Bundle/ChillCalendarBundle/Resources/views/Calendar/show.html.twig @@ -1,4 +1,13 @@

    {{ "Calendar"|trans }}

    +

    + {% if entity.endDate.diff(entity.startDate).days >= 1 %} + {{ "From the day"|trans }} {{ entity.startDate|format_datetime('medium', 'short') }} + {{ "to the day"|trans }} {{ entity.endDate|format_datetime('medium', 'short') }} + {% else %} + {{ entity.startDate|format_date('full') }}, + {{ entity.startDate|format_datetime('none', 'short', locale='fr') }} - {{ entity.endDate|format_datetime('none', 'short', locale='fr') }} + {% endif %} +

    {{ 'main user concerned'|trans }}
    @@ -6,7 +15,7 @@

    {{ 'Concerned groups'|trans }}

    -{% include 'ChillActivityBundle:Activity:concernedGroups.html.twig' with {'context': context, 'render': 'bloc' } %} +{% include 'ChillActivityBundle:Activity:concernedGroups.html.twig' with {'context': 'calendar_' ~ context, 'render': 'bloc' } %}

    {{ 'Calendar data'|trans }}

    @@ -79,8 +88,8 @@
    • - + {{ 'Back to the list'|trans }}
    • diff --git a/src/Bundle/ChillCalendarBundle/Resources/views/CalendarDoc/delete_accompanying_period.html.twig b/src/Bundle/ChillCalendarBundle/Resources/views/CalendarDoc/delete_accompanying_period.html.twig new file mode 100644 index 000000000..b26b280ac --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Resources/views/CalendarDoc/delete_accompanying_period.html.twig @@ -0,0 +1,19 @@ +{% extends "@ChillPerson/AccompanyingCourse/layout.html.twig" %} + +{% set activeRouteKey = 'chill_calendar_calendar_list' %} + +{% block title %}{{ 'chill_calendar.Remove a calendar document' |trans }}{% endblock title %} + +{% set accompanyingCourse = calendar_doc.calendar.accompanyingPeriod %} +{% set accompanyingCourseId = accompanyingCourse.id %} + +{% block content %} + {{ include('@ChillMain/Util/confirmation_template.html.twig', + { + 'title' : 'chill_calendar.Remove a calendar document'|trans, + 'confirm_question' : 'chill_calendar.Are you sure you want to remove the doc?'|trans, + 'cancel_route' : 'chill_calendar_calendar_list_by_period', + 'cancel_parameters' : { 'id' : accompanyingCourse.id }, + 'form' : form + } ) }} +{% endblock %} diff --git a/src/Bundle/ChillCalendarBundle/Resources/views/CalendarDoc/delete_person.html.twig b/src/Bundle/ChillCalendarBundle/Resources/views/CalendarDoc/delete_person.html.twig new file mode 100644 index 000000000..64e03d37e --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Resources/views/CalendarDoc/delete_person.html.twig @@ -0,0 +1,18 @@ +{% extends "@ChillPerson/Person/layout.html.twig" %} + +{% set activeRouteKey = 'chill_calendar_calendar_list' %} + +{% block title %}{{ 'chill_calendar.Edit a document' |trans }}{% endblock title %} + +{% set person = calendar_doc.calendar.person %} + +{% block content %} + {{ include('@ChillMain/Util/confirmation_template.html.twig', + { + 'title' : 'chill_calendar.Remove a calendar document'|trans, + 'confirm_question' : 'chill_calendar.Are you sure you want to remove the doc?'|trans, + 'cancel_route' : 'chill_calendar_calendar_list_by_person', + 'cancel_parameters' : { 'id' : person.id }, + 'form' : form + } ) }} +{% endblock %} diff --git a/src/Bundle/ChillCalendarBundle/Resources/views/CalendarDoc/edit_accompanying_period.html.twig b/src/Bundle/ChillCalendarBundle/Resources/views/CalendarDoc/edit_accompanying_period.html.twig new file mode 100644 index 000000000..9941b000a --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Resources/views/CalendarDoc/edit_accompanying_period.html.twig @@ -0,0 +1,40 @@ +{% extends "@ChillPerson/AccompanyingCourse/layout.html.twig" %} + +{% set activeRouteKey = 'chill_calendar_calendar_list' %} + +{% block title %}{{ 'chill_calendar.Edit a document' |trans }}{% endblock title %} + +{% set accompanyingCourse = calendar_doc.calendar.accompanyingPeriod %} +{% set accompanyingCourseId = accompanyingCourse.id %} + +{% block js %} + {{ parent() }} + {{ encore_entry_script_tags('mod_async_upload') }} +{% endblock %} + +{% block css %} + {{ parent() }} + {{ encore_entry_link_tags('mod_async_upload') }} +{% endblock %} + +{% block content %} + +

      {{ 'chill_calendar.Edit a document'|trans }}

      + + {{ form_start(form) }} + + {{ form_row(form.title) }} + {{ form_row(form.doc) }} + + + + {{ form_end(form) }} +{% endblock %} diff --git a/src/Bundle/ChillCalendarBundle/Resources/views/CalendarDoc/edit_person.html.twig b/src/Bundle/ChillCalendarBundle/Resources/views/CalendarDoc/edit_person.html.twig new file mode 100644 index 000000000..8ed218d29 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Resources/views/CalendarDoc/edit_person.html.twig @@ -0,0 +1,39 @@ +{% extends "@ChillPerson/Person/layout.html.twig" %} + +{% set activeRouteKey = 'chill_calendar_calendar_list' %} + +{% block title %}{{ 'chill_calendar.Edit a document' |trans }}{% endblock title %} + +{% set person = calendar_doc.calendar.person %} + +{% block js %} + {{ parent() }} + {{ encore_entry_script_tags('mod_async_upload') }} +{% endblock %} + +{% block css %} + {{ parent() }} + {{ encore_entry_link_tags('mod_async_upload') }} +{% endblock %} + +{% block content %} + +

      {{ 'chill_calendar.Edit a document'|trans }}

      + + {{ form_start(form) }} + + {{ form_row(form.title) }} + {{ form_row(form.doc) }} + + + + {{ form_end(form) }} +{% endblock %} diff --git a/src/Bundle/ChillCalendarBundle/Resources/views/CalendarDoc/new_accompanying_period.html.twig b/src/Bundle/ChillCalendarBundle/Resources/views/CalendarDoc/new_accompanying_period.html.twig new file mode 100644 index 000000000..d397a9132 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Resources/views/CalendarDoc/new_accompanying_period.html.twig @@ -0,0 +1,40 @@ +{% extends "@ChillPerson/AccompanyingCourse/layout.html.twig" %} + +{% set activeRouteKey = 'chill_calendar_calendar_list' %} + +{% block title %}{{ 'chill_calendar.Add a document' |trans }}{% endblock title %} + +{% set accompanyingCourse = calendar_doc.calendar.accompanyingPeriod %} +{% set accompanyingCourseId = accompanyingCourse.id %} + +{% block js %} + {{ parent() }} + {{ encore_entry_script_tags('mod_async_upload') }} +{% endblock %} + +{% block css %} + {{ parent() }} + {{ encore_entry_link_tags('mod_async_upload') }} +{% endblock %} + +{% block content %} + +

      {{ 'chill_calendar.Add a document'|trans }}

      + + {{ form_start(form) }} + + {{ form_row(form.title) }} + {{ form_row(form.doc) }} + + + + {{ form_end(form) }} +{% endblock %} diff --git a/src/Bundle/ChillCalendarBundle/Resources/views/CalendarDoc/new_person.html.twig b/src/Bundle/ChillCalendarBundle/Resources/views/CalendarDoc/new_person.html.twig new file mode 100644 index 000000000..0a5003aed --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Resources/views/CalendarDoc/new_person.html.twig @@ -0,0 +1,39 @@ +{% extends "@ChillPerson/Person/layout.html.twig" %} + +{% set activeRouteKey = 'chill_calendar_calendar_list' %} + +{% block title %}{{ 'chill_calendar.Add a document' |trans }}{% endblock title %} + +{% set person = calendar_doc.calendar.person %} + +{% block js %} + {{ parent() }} + {{ encore_entry_script_tags('mod_async_upload') }} +{% endblock %} + +{% block css %} + {{ parent() }} + {{ encore_entry_link_tags('mod_async_upload') }} +{% endblock %} + +{% block content %} + +

      {{ 'chill_calendar.Add a document'|trans }}

      + + {{ form_start(form) }} + + {{ form_row(form.title) }} + {{ form_row(form.doc) }} + + + + {{ form_end(form) }} +{% endblock %} diff --git a/src/Bundle/ChillCalendarBundle/Resources/views/CalendarShortMessage/short_message.txt.twig b/src/Bundle/ChillCalendarBundle/Resources/views/CalendarShortMessage/short_message.txt.twig new file mode 100644 index 000000000..1669ac1d1 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Resources/views/CalendarShortMessage/short_message.txt.twig @@ -0,0 +1 @@ +Votre travailleur social {{ calendar.mainUser.label }} vous rencontrera le {{ calendar.startDate|format_date('short', locale='fr') }} à {{ calendar.startDate|format_time('short', locale='fr') }} - {% if calendar.location is not null%}{{ calendar.location.name }}{% endif %}{% if calendar.mainUser.mainLocation is not null and calendar.mainUser.mainLocation.phonenumber1 is not null %} En cas d'indisponibilité, appelez-nous au {{ calendar.mainUser.mainLocation.phonenumber1|chill_format_phonenumber }}.{% endif %} diff --git a/src/Bundle/ChillCalendarBundle/Resources/views/CalendarShortMessage/short_message_canceled.twig b/src/Bundle/ChillCalendarBundle/Resources/views/CalendarShortMessage/short_message_canceled.twig new file mode 100644 index 000000000..82453a7a9 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Resources/views/CalendarShortMessage/short_message_canceled.twig @@ -0,0 +1 @@ +Votre RDV avec votre travailleur social {{ calendar.mainUser.label }} prévu le {{ calendar.startDate|format_date('short', locale='fr') }} à {{ calendar.startDate|format_time('short', locale='fr') }} est annulé. {% if calendar.mainUser.mainLocation is not null and calendar.mainUser.mainLocation.phonenumber1 is not null %} En cas de question, appelez-nous au {{ calendar.mainUser.mainLocation.phonenumber1|chill_format_phonenumber }}.{% endif %} diff --git a/src/Bundle/ChillCalendarBundle/Resources/views/MSGraph/calendar_event_body.html.twig b/src/Bundle/ChillCalendarBundle/Resources/views/MSGraph/calendar_event_body.html.twig new file mode 100644 index 000000000..63a206ae0 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Resources/views/MSGraph/calendar_event_body.html.twig @@ -0,0 +1,16 @@ +Ce rendez-vous a été généré automatiquement par Chill. + +Pour modifier ou supprimer ce rendez-vous, utilisez l’adresse suivante: + +{{ absolute_url(path('chill_calendar_calendar_edit', {'id': calendar.id, '_locale': 'fr'})) }} + +{{ calendar.comment.comment|nl2br }} + +Usagers et tiers concernés: + +{% for p in calendar.persons %} +- {{ p|chill_entity_render_string }} +{%- endfor -%} +{% for t in calendar.professionals %} +- {{ t|chill_entity_render_string }} +{% endfor %} diff --git a/src/Bundle/ChillCalendarBundle/Resources/views/_invite.html.twig b/src/Bundle/ChillCalendarBundle/Resources/views/_invite.html.twig new file mode 100644 index 000000000..c3f2aef58 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Resources/views/_invite.html.twig @@ -0,0 +1,14 @@ +{% macro invite_span(invite) %} + {% if invite.status == 'accepted' %} + {% set fa = 'check' %} + {% elseif invite.status == 'declined' %} + {% set fa = 'times' %} + {% elseif invite.status == 'pending' %} + {% set fa = 'hourglass' %} + {% elseif invite.status == 'tentative' %} + {% set fa = 'question' %} + {% else %} + {% set fa = invite.status %} + {% endif %} + +{% endmacro %} diff --git a/src/Bundle/ChillCalendarBundle/Security/Voter/CalendarDocVoter.php b/src/Bundle/ChillCalendarBundle/Security/Voter/CalendarDocVoter.php new file mode 100644 index 000000000..452286e85 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Security/Voter/CalendarDocVoter.php @@ -0,0 +1,61 @@ +security = $security; + } + + protected function supports($attribute, $subject): bool + { + return in_array($attribute, self::ALL, true) && $subject instanceof CalendarDoc; + } + + /** + * @param CalendarDoc $subject + * @param mixed $attribute + */ + protected function voteOnAttribute($attribute, $subject, TokenInterface $token): bool + { + switch ($attribute) { + case self::EDIT: + return $this->security->isGranted(CalendarVoter::EDIT, $subject->getCalendar()); + + case self::SEE: + return $this->security->isGranted(CalendarVoter::SEE, $subject->getCalendar()); + + default: + throw new UnexpectedValueException('Attribute not supported: ' . $attribute); + } + } +} diff --git a/src/Bundle/ChillCalendarBundle/Security/Voter/CalendarVoter.php b/src/Bundle/ChillCalendarBundle/Security/Voter/CalendarVoter.php new file mode 100644 index 000000000..0e2be1d0f --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Security/Voter/CalendarVoter.php @@ -0,0 +1,134 @@ +security = $security; + $this->voterHelper = $voterHelperFactory + ->generate(self::class) + ->addCheckFor(AccompanyingPeriod::class, [self::SEE, self::CREATE]) + ->addCheckFor(Person::class, [self::SEE, self::CREATE]) + ->addCheckFor(Calendar::class, [self::SEE, self::CREATE, self::EDIT, self::DELETE]) + ->build(); + } + + public function getRoles(): array + { + return [ + self::CREATE, + self::DELETE, + self::EDIT, + self::SEE, + ]; + } + + public function getRolesWithHierarchy(): array + { + return ['Calendar' => $this->getRoles()]; + } + + public function getRolesWithoutScope(): array + { + return [ + self::CREATE, + self::DELETE, + self::EDIT, + self::SEE, + ]; + } + + protected function supports($attribute, $subject): bool + { + return $this->voterHelper->supports($attribute, $subject); + } + + protected function voteOnAttribute($attribute, $subject, TokenInterface $token): bool + { + if ($subject instanceof AccompanyingPeriod) { + switch ($attribute) { + case self::SEE: + case self::CREATE: + if ($subject->getStep() === AccompanyingPeriod::STEP_DRAFT) { + return false; + } + + // we first check here that the user has read access to the period + if (!$this->security->isGranted(AccompanyingPeriodVoter::SEE, $subject)) { + return false; + } + + // There is no scope on Calendar, but there are some on accompanying period + // so, to ignore AccompanyingPeriod's scopes, we create a blank Calendar + // linked with an accompanying period. + return $this->voterHelper->voteOnAttribute($attribute, (new Calendar())->setAccompanyingPeriod($subject), $token); + } + } elseif ($subject instanceof Person) { + switch ($attribute) { + case self::SEE: + case self::CREATE: + return $this->voterHelper->voteOnAttribute($attribute, $subject, $token); + } + } elseif ($subject instanceof Calendar) { + switch ($attribute) { + case self::SEE: + case self::EDIT: + case self::CREATE: + case self::DELETE: + return $this->voterHelper->voteOnAttribute($attribute, $subject, $token); + } + } + + throw new LogicException('attribute or not implemented'); + } +} diff --git a/src/Bundle/ChillCalendarBundle/Security/Voter/InviteVoter.php b/src/Bundle/ChillCalendarBundle/Security/Voter/InviteVoter.php new file mode 100644 index 000000000..36ca66d05 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Security/Voter/InviteVoter.php @@ -0,0 +1,42 @@ +getUser() === $subject->getUser(); + } +} diff --git a/src/Bundle/ChillCalendarBundle/Service/DocGenerator/CalendarContext.php b/src/Bundle/ChillCalendarBundle/Service/DocGenerator/CalendarContext.php new file mode 100644 index 000000000..9ba9e36b4 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Service/DocGenerator/CalendarContext.php @@ -0,0 +1,249 @@ +baseContextData = $baseContextData; + $this->entityManager = $entityManager; + $this->normalizer = $normalizer; + $this->personRender = $personRender; + $this->thirdPartyRender = $thirdPartyRender; + $this->translatableStringHelper = $translatableStringHelper; + } + + public function adminFormReverseTransform(array $data): array + { + return array_merge( + [ + 'trackDatetime' => true, + 'askMainPerson' => true, + 'mainPersonLabel' => 'docgen.calendar.Destinee', + 'askThirdParty' => false, + 'thirdPartyLabel' => 'Third party', + ], + $data + ); + } + + public function adminFormTransform(array $data): array + { + return $data; + } + + public function buildAdminForm(FormBuilderInterface $builder): void + { + $builder + ->add('trackDatetime', CheckboxType::class, [ + 'required' => false, + 'label' => 'docgen.calendar.Track changes on datetime and warn user if date time is updated after the doc generation', + ]) + ->add('askMainPerson', CheckboxType::class, [ + 'required' => false, + 'label' => 'docgen.calendar.Ask main person', + ]) + ->add('mainPersonLabel', TextType::class, [ + 'required' => false, + 'label' => 'docgen.calendar.Main person label', + ]) + ->add('askThirdParty', CheckboxType::class, [ + 'required' => false, + 'label' => 'docgen.calendar.Ask third party', + ]) + ->add('thirdPartyLabel', TextType::class, [ + 'required' => false, + 'label' => 'docgen.calendar.Third party label', + ]); + } + + public function buildPublicForm(FormBuilderInterface $builder, DocGeneratorTemplate $template, $entity): void + { + $options = $this->getOptions($template); + + $builder->add('title', TextType::class, [ + 'required' => true, + 'label' => 'docgen.calendar.title of the generated document', + 'data' => $this->translatableStringHelper->localize($template->getName()), + ]); + + if ($options['askMainPerson']) { + $builder->add('mainPerson', EntityType::class, [ + 'class' => Person::class, + 'multiple' => false, + 'label' => $options['mainPersonLabel'] ?? 'docgen.calendar.Main person label', + 'required' => false, + 'choices' => $entity->getPersons(), + 'choice_label' => fn (Person $p) => $this->personRender->renderString($p, []), + 'expanded' => false, + ]); + } + + if ($options['askThirdParty']) { + $builder->add('thirdParty', EntityType::class, [ + 'class' => ThirdParty::class, + 'multiple' => false, + 'label' => $options['thirdPartyLabel'] ?? 'Third party', + 'choices' => $entity->getProfessionals(), + 'choice_label' => fn (ThirdParty $tp) => $this->thirdPartyRender->renderString($tp, []), + 'expanded' => false, + ]); + } + } + + /** + * @param array{mainPerson?: Person, thirdParty?: ThirdParty, title: string} $contextGenerationData + * @param mixed $entity + */ + public function getData(DocGeneratorTemplate $template, $entity, array $contextGenerationData = []): array + { + $options = $this->getOptions($template); + + $data = array_merge( + $this->baseContextData->getData(), + [ + 'calendar' => $this->normalizer->normalize($entity, 'docgen', ['docgen:expects' => Calendar::class, 'groups' => ['docgen:read']]), + ] + ); + + if ($options['askMainPerson']) { + $data['mainPerson'] = $this->normalizer->normalize($contextGenerationData['mainPerson'] ?? null, 'docgen', [ + 'docgen:expects' => Person::class, + 'groups' => ['docgen:read'], + 'docgen:person:with-household' => true, + 'docgen:person:with-relations' => true, + 'docgen:person:with-budget' => true, + ]); + } + + if ($options['askThirdParty']) { + $data['thirdParty'] = $this->normalizer->normalize($contextGenerationData['thirdParty'] ?? null, 'docgen', [ + 'docgen:expects' => ThirdParty::class, + 'groups' => ['docgen:read'], + ]); + } + + return $data; + } + + public function getDescription(): string + { + return 'docgen.calendar.A base context for generating document on calendar'; + } + + public function getEntityClass(): string + { + return Calendar::class; + } + + public function getFormData(DocGeneratorTemplate $template, $entity): array + { + $options = $this->getOptions($template); + $data = []; + + if ($options['askMainPerson']) { + $data['mainPerson'] = null; + + if (1 === count($entity->getPersons())) { + $data['mainPerson'] = $entity->getPersons()->first(); + } + } + + if ($options['askThirdParty']) { + $data['thirdParty'] = null; + + if (1 === count($entity->getProfessionals())) { + $data['thirdParty'] = $entity->getProfessionals()->first(); + } + } + + return $data; + } + + public static function getKey(): string + { + return self::class; + } + + public function getName(): string + { + return 'docgen.calendar.Base context for calendar'; + } + + public function hasAdminForm(): bool + { + return true; + } + + public function hasPublicForm(DocGeneratorTemplate $template, $entity): bool + { + return true; + } + + /** + * @param array{mainPerson?: Person, thirdParty?: ThirdParty, title: string} $contextGenerationData + */ + public function storeGenerated(DocGeneratorTemplate $template, StoredObject $storedObject, object $entity, array $contextGenerationData): void + { + $options = $this->getOptions($template); + $storedObject->setTitle($contextGenerationData['title']); + $doc = new CalendarDoc($entity, $storedObject); + $doc->setTrackDateTimeVersion($options['trackDatetime']); + + $this->entityManager->persist($doc); + } + + /** + * @return array{askMainPerson: bool, mainPersonLabel: ?string, askThirdParty: bool, thirdPartyLabel: ?string, trackDateTime: bool} $options + */ + private function getOptions(DocGeneratorTemplate $template): array + { + return $template->getOptions(); + } +} diff --git a/src/Bundle/ChillCalendarBundle/Service/DocGenerator/CalendarContextInterface.php b/src/Bundle/ChillCalendarBundle/Service/DocGenerator/CalendarContextInterface.php new file mode 100644 index 000000000..d02cdc2c2 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Service/DocGenerator/CalendarContextInterface.php @@ -0,0 +1,63 @@ +provider = $provider; + $this->em = $em; + $this->logger = $logger; + $this->messageBus = $messageBus; + $this->messageForCalendarBuilder = $messageForCalendarBuilder; + } + + public function sendBulkMessageToEligibleCalendars() + { + $countCalendars = 0; + $countSms = 0; + + foreach ($this->provider->getCalendars(new DateTimeImmutable('now')) as $calendar) { + $smses = $this->messageForCalendarBuilder->buildMessageForCalendar($calendar); + + foreach ($smses as $sms) { + $this->messageBus->dispatch($sms); + ++$countSms; + } + + $this->em + ->createQuery('UPDATE ' . Calendar::class . ' c SET c.smsStatus = :smsStatus WHERE c.id = :id') + ->setParameters(['smsStatus' => Calendar::SMS_SENT, 'id' => $calendar->getId()]) + ->execute(); + ++$countCalendars; + $this->em->refresh($calendar); + } + + $this->logger->info(__CLASS__ . 'a bulk of messages was sent', ['count_calendars' => $countCalendars, 'count_sms' => $countSms]); + } +} diff --git a/src/Bundle/ChillCalendarBundle/Service/ShortMessageNotification/CalendarForShortMessageProvider.php b/src/Bundle/ChillCalendarBundle/Service/ShortMessageNotification/CalendarForShortMessageProvider.php new file mode 100644 index 000000000..2316a5408 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Service/ShortMessageNotification/CalendarForShortMessageProvider.php @@ -0,0 +1,81 @@ +calendarRepository = $calendarRepository; + $this->em = $em; + $this->rangeGenerator = $rangeGenerator; + } + + /** + * Generate calendars instance. + * + * Warning: this method takes care of clearing the EntityManager at regular interval + * + * @return iterable|Calendar[] + */ + public function getCalendars(DateTimeImmutable $at): iterable + { + $range = $this->rangeGenerator->generateRange($at); + + if (null === $range) { + return; + } + + ['startDate' => $startDate, 'endDate' => $endDate] = $range; + + $offset = 0; + $batchSize = 10; + + $calendars = $this->calendarRepository + ->findByNotificationAvailable($startDate, $endDate, $batchSize, $offset); + + do { + foreach ($calendars as $calendar) { + ++$offset; + + yield $calendar; + } + + $this->em->clear(); + + $calendars = $this->calendarRepository + ->findByNotificationAvailable($startDate, $endDate, $batchSize, $offset); + } while (count($calendars) === $batchSize); + } +} diff --git a/src/Bundle/ChillCalendarBundle/Service/ShortMessageNotification/DefaultRangeGenerator.php b/src/Bundle/ChillCalendarBundle/Service/ShortMessageNotification/DefaultRangeGenerator.php new file mode 100644 index 000000000..7d6da3ef0 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Service/ShortMessageNotification/DefaultRangeGenerator.php @@ -0,0 +1,75 @@ + Envoi des rdv du mardi et mercredi. + * * Mardi => Envoi des rdv du jeudi. + * * Mercredi => Envoi des rdv du vendredi + * * Jeudi => envoi des rdv du samedi et dimanche + * * Vendredi => Envoi des rdv du lundi. + */ +class DefaultRangeGenerator implements RangeGeneratorInterface +{ + public function generateRange(\DateTimeImmutable $date): ?array + { + $onMidnight = DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $date->format('Y-m-d') . ' 00:00:00'); + + switch ($dow = (int) $onMidnight->format('w')) { + case 6: // Saturday + case 0: // Sunday + return null; + + case 1: // Monday + // send for Tuesday and Wednesday + $startDate = $onMidnight->add(new DateInterval('P1D')); + $endDate = $startDate->add(new DateInterval('P2D')); + + break; + + case 2: // tuesday + case 3: // wednesday + $startDate = $onMidnight->add(new DateInterval('P2D')); + $endDate = $startDate->add(new DateInterval('P1D')); + + break; + + case 4: // thursday + $startDate = $onMidnight->add(new DateInterval('P2D')); + $endDate = $startDate->add(new DateInterval('P2D')); + + break; + + case 5: // friday + $startDate = $onMidnight->add(new DateInterval('P3D')); + $endDate = $startDate->add(new DateInterval('P1D')); + + break; + + default: + throw new UnexpectedValueException('a day of a week should not have the value: ' . $dow); + } + + return ['startDate' => $startDate, 'endDate' => $endDate]; + } +} diff --git a/src/Bundle/ChillCalendarBundle/Service/ShortMessageNotification/DefaultShortMessageForCalendarBuilder.php b/src/Bundle/ChillCalendarBundle/Service/ShortMessageNotification/DefaultShortMessageForCalendarBuilder.php new file mode 100644 index 000000000..cd61a8624 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Service/ShortMessageNotification/DefaultShortMessageForCalendarBuilder.php @@ -0,0 +1,65 @@ +engine = $engine; + } + + public function buildMessageForCalendar(Calendar $calendar): array + { + if (true !== $calendar->getSendSMS()) { + return []; + } + + $toUsers = []; + + foreach ($calendar->getPersons() as $person) { + if (false === $person->getAcceptSMS() || null === $person->getAcceptSMS() || null === $person->getMobilenumber()) { + continue; + } + + if (Calendar::SMS_PENDING === $calendar->getSmsStatus()) { + $toUsers[] = new ShortMessage( + $this->engine->render('@ChillCalendar/CalendarShortMessage/short_message.txt.twig', ['calendar' => $calendar]), + $person->getMobilenumber(), + ShortMessage::PRIORITY_LOW + ); + } elseif (Calendar::SMS_CANCEL_PENDING === $calendar->getSmsStatus()) { + $toUsers[] = new ShortMessage( + $this->engine->render('@ChillCalendar/CalendarShortMessage/short_message_canceled.txt.twig', ['calendar' => $calendar]), + $person->getMobilenumber(), + ShortMessage::PRIORITY_LOW + ); + } + } + + return $toUsers; + } +} diff --git a/src/Bundle/ChillCalendarBundle/Service/ShortMessageNotification/RangeGeneratorInterface.php b/src/Bundle/ChillCalendarBundle/Service/ShortMessageNotification/RangeGeneratorInterface.php new file mode 100644 index 000000000..357e264d9 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Service/ShortMessageNotification/RangeGeneratorInterface.php @@ -0,0 +1,29 @@ +request( + 'POST', + '/public/incoming-hook/calendar/msgraph/events/23', + [], + [], + [], + self::SAMPLE_BODY + ); + + $this->assertResponseIsSuccessful(); + $this->assertResponseStatusCodeSame(202); + + /** @var InMemoryTransport $transport */ + $transport = self::$container->get('messenger.transport.async'); + $this->assertCount(1, $transport->getSent()); + } + + public function testValidateSubscription(): void + { + $client = self::createClient(); + $client->request( + 'POST', + '/public/incoming-hook/calendar/msgraph/events/23?validationToken=something%20to%20decode' + ); + + $this->assertResponseIsSuccessful(); + + $response = $client->getResponse(); + + $this->assertResponseHasHeader('Content-Type'); + $this->assertStringContainsString('text/plain', $response->headers->get('Content-Type')); + $this->assertEquals('something to decode', $response->getContent()); + } +} diff --git a/src/Bundle/ChillCalendarBundle/Tests/Entity/CalendarDocTest.php b/src/Bundle/ChillCalendarBundle/Tests/Entity/CalendarDocTest.php new file mode 100644 index 000000000..fa6132b03 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Tests/Entity/CalendarDocTest.php @@ -0,0 +1,63 @@ +title = 'tagada'; + $create->doc = $obj1 = new StoredObject(); + + $doc->createFromDTO($create); + + $this->assertSame($obj1, $doc->getStoredObject()); + $this->assertEquals('tagada', $doc->getStoredObject()->getTitle()); + + $edit = new CalendarDoc\CalendarDocEditDTO($doc); + $edit->title = 'tsointsoin'; + + $doc->editFromDTO($edit); + + $this->assertSame($obj1, $doc->getStoredObject()); + $this->assertEquals('tsointsoin', $doc->getStoredObject()->getTitle()); + + $edit2 = new CalendarDoc\CalendarDocEditDTO($doc); + $edit2->doc = $obj2 = new StoredObject(); + + $doc->editFromDTO($edit2); + + $this->assertSame($obj2, $doc->getStoredObject()); + $this->assertEquals('tsointsoin', $doc->getStoredObject()->getTitle()); + + $edit3 = new CalendarDoc\CalendarDocEditDTO($doc); + $edit3->doc = $obj3 = new StoredObject(); + $edit3->title = 'tagada'; + + $doc->editFromDTO($edit3); + + $this->assertSame($obj3, $doc->getStoredObject()); + $this->assertEquals('tagada', $doc->getStoredObject()->getTitle()); + } +} diff --git a/src/Bundle/ChillCalendarBundle/Tests/Entity/CalendarTest.php b/src/Bundle/ChillCalendarBundle/Tests/Entity/CalendarTest.php new file mode 100644 index 000000000..e37436ad3 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Tests/Entity/CalendarTest.php @@ -0,0 +1,63 @@ +assertCount(0, $calendar->getInvites()); + $this->assertCount(0, $calendar->getUsers()); + + $calendar->addUser($user0 = new User()); + + $this->assertCount(1, $calendar->getInvites()); + $this->assertCount(1, $calendar->getUsers()); + $this->assertSame($user0, $calendar->getUsers()->first()); + + $calendar->addUser($user1 = new User()); + + $this->assertCount(2, $calendar->getInvites()); + $this->assertCount(2, $calendar->getUsers()); + $this->assertContains($user0, $calendar->getUsers()); + $this->assertContains($user1, $calendar->getUsers()); + + $calendar->removeUser($user0); + + $this->assertCount(1, $calendar->getInvites()); + $this->assertCount(1, $calendar->getUsers()); + $this->assertNotSame($user0, $calendar->getUsers()->first()); + $this->assertSame($user1, $calendar->getUsers()->first()); + + $calendar->removeUser($user1); + + $this->assertCount(0, $calendar->getInvites()); + $this->assertCount(0, $calendar->getUsers()); + } +} diff --git a/src/Bundle/ChillCalendarBundle/Tests/Export/Aggregator/AgentAggregatorTest.php b/src/Bundle/ChillCalendarBundle/Tests/Export/Aggregator/AgentAggregatorTest.php new file mode 100644 index 000000000..6746282a4 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Tests/Export/Aggregator/AgentAggregatorTest.php @@ -0,0 +1,68 @@ +aggregator = self::$container->get('chill.calendar.export.agent_aggregator'); + } + + public function getAggregator() + { + return $this->aggregator; + } + + public function getFormData(): array + { + return [ + [], + ]; + } + + public function getQueryBuilders(): array + { + if (null === self::$kernel) { + self::bootKernel(); + } + + $em = self::$container->get(EntityManagerInterface::class); + + return [ + $em->createQueryBuilder() + ->select('count(cal.id)') + ->from(Calendar::class, 'cal') + ->join('cal.mainUser', 'caluser'), + ]; + } +} diff --git a/src/Bundle/ChillCalendarBundle/Tests/Export/Aggregator/CancelReasonAggregatorTest.php b/src/Bundle/ChillCalendarBundle/Tests/Export/Aggregator/CancelReasonAggregatorTest.php new file mode 100644 index 000000000..956a81174 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Tests/Export/Aggregator/CancelReasonAggregatorTest.php @@ -0,0 +1,68 @@ +aggregator = self::$container->get('chill.calendar.export.cancel_reason_aggregator'); + } + + public function getAggregator() + { + return $this->aggregator; + } + + public function getFormData(): array + { + return [ + [], + ]; + } + + public function getQueryBuilders(): array + { + if (null === self::$kernel) { + self::bootKernel(); + } + + $em = self::$container->get(EntityManagerInterface::class); + + return [ + $em->createQueryBuilder() + ->select('count(cal.id)') + ->from(Calendar::class, 'cal') + ->join('cal.cancelReason', 'calcancel'), + ]; + } +} diff --git a/src/Bundle/ChillCalendarBundle/Tests/Export/Aggregator/JobAggregatorTest.php b/src/Bundle/ChillCalendarBundle/Tests/Export/Aggregator/JobAggregatorTest.php new file mode 100644 index 000000000..b389b1536 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Tests/Export/Aggregator/JobAggregatorTest.php @@ -0,0 +1,68 @@ +aggregator = self::$container->get('chill.calendar.export.job_aggregator'); + } + + public function getAggregator() + { + return $this->aggregator; + } + + public function getFormData(): array + { + return [ + [], + ]; + } + + public function getQueryBuilders(): array + { + if (null === self::$kernel) { + self::bootKernel(); + } + + $em = self::$container->get(EntityManagerInterface::class); + + return [ + $em->createQueryBuilder() + ->select('count(cal.id)') + ->from(Calendar::class, 'cal') + ->join('cal.user', 'caluser'), + ]; + } +} diff --git a/src/Bundle/ChillCalendarBundle/Tests/Export/Aggregator/LocationAggregatorTest.php b/src/Bundle/ChillCalendarBundle/Tests/Export/Aggregator/LocationAggregatorTest.php new file mode 100644 index 000000000..50ee456e5 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Tests/Export/Aggregator/LocationAggregatorTest.php @@ -0,0 +1,68 @@ +aggregator = self::$container->get('chill.calendar.export.location_aggregator'); + } + + public function getAggregator() + { + return $this->aggregator; + } + + public function getFormData(): array + { + return [ + [], + ]; + } + + public function getQueryBuilders(): array + { + if (null === self::$kernel) { + self::bootKernel(); + } + + $em = self::$container->get(EntityManagerInterface::class); + + return [ + $em->createQueryBuilder() + ->select('count(cal.id)') + ->from(Calendar::class, 'cal') + ->join('cal.location', 'calloc'), + ]; + } +} diff --git a/src/Bundle/ChillCalendarBundle/Tests/Export/Aggregator/LocationTypeAggregatorTest.php b/src/Bundle/ChillCalendarBundle/Tests/Export/Aggregator/LocationTypeAggregatorTest.php new file mode 100644 index 000000000..55dcf317f --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Tests/Export/Aggregator/LocationTypeAggregatorTest.php @@ -0,0 +1,68 @@ +aggregator = self::$container->get('chill.calendar.export.location_type_aggregator'); + } + + public function getAggregator() + { + return $this->aggregator; + } + + public function getFormData(): array + { + return [ + [], + ]; + } + + public function getQueryBuilders(): array + { + if (null === self::$kernel) { + self::bootKernel(); + } + + $em = self::$container->get(EntityManagerInterface::class); + + return [ + $em->createQueryBuilder() + ->select('count(cal.id)') + ->from(Calendar::class, 'cal') + ->join('cal.location', 'calloc'), + ]; + } +} diff --git a/src/Bundle/ChillCalendarBundle/Tests/Export/Aggregator/MonthYearAggregatorTest.php b/src/Bundle/ChillCalendarBundle/Tests/Export/Aggregator/MonthYearAggregatorTest.php new file mode 100644 index 000000000..8e016b54c --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Tests/Export/Aggregator/MonthYearAggregatorTest.php @@ -0,0 +1,67 @@ +aggregator = self::$container->get('chill.calendar.export.month_aggregator'); + } + + public function getAggregator() + { + return $this->aggregator; + } + + public function getFormData(): array + { + return [ + [], + ]; + } + + public function getQueryBuilders(): array + { + if (null === self::$kernel) { + self::bootKernel(); + } + + $em = self::$container->get(EntityManagerInterface::class); + + return [ + $em->createQueryBuilder() + ->select('count(cal.id)') + ->from(Calendar::class, 'cal'), + ]; + } +} diff --git a/src/Bundle/ChillCalendarBundle/Tests/Export/Aggregator/ScopeAggregatorTest.php b/src/Bundle/ChillCalendarBundle/Tests/Export/Aggregator/ScopeAggregatorTest.php new file mode 100644 index 000000000..fa816f4a5 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Tests/Export/Aggregator/ScopeAggregatorTest.php @@ -0,0 +1,68 @@ +aggregator = self::$container->get('chill.calendar.export.scope_aggregator'); + } + + public function getAggregator() + { + return $this->aggregator; + } + + public function getFormData(): array + { + return [ + [], + ]; + } + + public function getQueryBuilders(): array + { + if (null === self::$kernel) { + self::bootKernel(); + } + + $em = self::$container->get(EntityManagerInterface::class); + + return [ + $em->createQueryBuilder() + ->select('count(cal.id)') + ->from(Calendar::class, 'cal') + ->join('cal.user', 'caluser'), + ]; + } +} diff --git a/src/Bundle/ChillCalendarBundle/Tests/Export/Filter/AgentFilterTest.php b/src/Bundle/ChillCalendarBundle/Tests/Export/Filter/AgentFilterTest.php new file mode 100644 index 000000000..d5ec9b22d --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Tests/Export/Filter/AgentFilterTest.php @@ -0,0 +1,88 @@ +prophesize(); + + $request->willExtend(\Symfony\Component\HttpFoundation\Request::class); + $request->getLocale()->willReturn('fr'); + + $this->filter = self::$container->get('chill.calendar.export.agent_filter'); + } + + public function getFilter() + { + return $this->filter; + } + + public function getFormData(): array + { + $em = self::$container->get(EntityManagerInterface::class); + + $array = $em->createQueryBuilder() + ->from(User::class, 'u') + ->select('u') + ->getQuery() + ->getResult(); + + $data = []; + + foreach ($array as $a) { + $data[] = [ + 'accepted_agents' => $a, + ]; + } + + return $data; + } + + public function getQueryBuilders(): array + { + if (null === self::$kernel) { + self::bootKernel(); + } + + $em = self::$container->get(EntityManagerInterface::class); + + return [ + $em->createQueryBuilder() + ->select('cal.id') + ->from(Calendar::class, 'cal'), + ]; + } +} diff --git a/src/Bundle/ChillCalendarBundle/Tests/Export/Filter/BetweenDatesFilterTest.php b/src/Bundle/ChillCalendarBundle/Tests/Export/Filter/BetweenDatesFilterTest.php new file mode 100644 index 000000000..85db16c72 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Tests/Export/Filter/BetweenDatesFilterTest.php @@ -0,0 +1,77 @@ +prophesize(); + + $request->willExtend(\Symfony\Component\HttpFoundation\Request::class); + $request->getLocale()->willReturn('fr'); + + $this->filter = self::$container->get('chill.calendar.export.between_dates_filter'); + } + + public function getFilter() + { + return $this->filter; + } + + public function getFormData(): array + { + return [ + [ + 'date_from' => DateTime::createFromFormat('Y-m-d', '2022-05-01'), + 'date_to' => DateTime::createFromFormat('Y-m-d', '2022-06-01'), + ], + ]; + } + + public function getQueryBuilders(): array + { + if (null === self::$kernel) { + self::bootKernel(); + } + + $em = self::$container->get(EntityManagerInterface::class); + + return [ + $em->createQueryBuilder() + ->select('cal.id') + ->from(Calendar::class, 'cal'), + ]; + } +} diff --git a/src/Bundle/ChillCalendarBundle/Tests/Export/Filter/JobFilterTest.php b/src/Bundle/ChillCalendarBundle/Tests/Export/Filter/JobFilterTest.php new file mode 100644 index 000000000..61cb47120 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Tests/Export/Filter/JobFilterTest.php @@ -0,0 +1,89 @@ +prophesize(); + + $request->willExtend(\Symfony\Component\HttpFoundation\Request::class); + $request->getLocale()->willReturn('fr'); + + $this->filter = self::$container->get('chill.calendar.export.job_filter'); + } + + public function getFilter() + { + return $this->filter; + } + + public function getFormData(): array + { + $em = self::$container->get(EntityManagerInterface::class); + + $array = $em->createQueryBuilder() + ->from(UserJob::class, 'uj') + ->select('uj') + ->getQuery() + ->getResult(); + + $data = []; + + foreach ($array as $a) { + $data[] = [ + 'job' => $a, + ]; + } + + return $data; + } + + public function getQueryBuilders(): array + { + if (null === self::$kernel) { + self::bootKernel(); + } + + $em = self::$container->get(EntityManagerInterface::class); + + return [ + $em->createQueryBuilder() + ->select('cal.id') + ->from(Calendar::class, 'cal') + ->join('cal.user', 'caluser'), + ]; + } +} diff --git a/src/Bundle/ChillCalendarBundle/Tests/Export/Filter/ScopeFilterTest.php b/src/Bundle/ChillCalendarBundle/Tests/Export/Filter/ScopeFilterTest.php new file mode 100644 index 000000000..a6ab9ed7b --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Tests/Export/Filter/ScopeFilterTest.php @@ -0,0 +1,89 @@ +prophesize(); + + $request->willExtend(\Symfony\Component\HttpFoundation\Request::class); + $request->getLocale()->willReturn('fr'); + + $this->filter = self::$container->get('chill.calendar.export.scope_filter'); + } + + public function getFilter() + { + return $this->filter; + } + + public function getFormData(): array + { + $em = self::$container->get(EntityManagerInterface::class); + + $array = $em->createQueryBuilder() + ->from(Scope::class, 's') + ->select('s') + ->getQuery() + ->getResult(); + + $data = []; + + foreach ($array as $a) { + $data[] = [ + 'scope' => $a, + ]; + } + + return $data; + } + + public function getQueryBuilders(): array + { + if (null === self::$kernel) { + self::bootKernel(); + } + + $em = self::$container->get(EntityManagerInterface::class); + + return [ + $em->createQueryBuilder() + ->select('cal.id') + ->from(Calendar::class, 'cal') + ->join('cal.user', 'caluser'), + ]; + } +} diff --git a/src/Bundle/ChillCalendarBundle/Tests/Form/CalendarTypeTest.php b/src/Bundle/ChillCalendarBundle/Tests/Form/CalendarTypeTest.php new file mode 100644 index 000000000..3ddc2e836 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Tests/Form/CalendarTypeTest.php @@ -0,0 +1,210 @@ +personsToIdDataTransformer = $this->buildMultiToIdDataTransformer(PersonsToIdDataTransformer::class, Person::class); + $this->idToUserDataTransformer = $this->buildSingleToIdDataTransformer(IdToUserDataTransformer::class, User::class); + $this->idToUsersDataTransformer = $this->buildMultiToIdDataTransformer(IdToUsersDataTransformer::class, User::class); + $this->idToLocationDataTransformer = $this->buildSingleToIdDataTransformer(IdToLocationDataTransformer::class, Location::class); + $this->partiesToIdDataTransformer = $this->buildMultiToIdDataTransformer(ThirdPartiesToIdDataTransformer::class, ThirdParty::class); + $this->calendarRangeDataTransformer = $this->buildSingleToIdDataTransformer(IdToCalendarRangeDataTransformer::class, CalendarRange::class); + $tokenStorage = $this->prophesize(TokenStorageInterface::class); + $token = $this->prophesize(TokenInterface::class); + $token->getUser()->willReturn(new User()); + $tokenStorage->getToken()->willReturn($token->reveal()); + $this->tokenStorage = $tokenStorage->reveal(); + + parent::setUp(); + } + + public function testSubmitValidData() + { + $formData = [ + 'mainUser' => '1', + 'users' => '2,3', + 'professionnals' => '4,5', + 'startDate' => '2022-05-05 14:00:00', + 'endDate' => '2022-05-05 14:30:00', + 'persons' => '7', + 'calendarRange' => '8', + 'location' => '9', + 'sendSMS' => '1', + ]; + + $calendar = new Calendar(); + $calendar->setMainUser(new class() extends User { + public function getId() + { + return '1'; + } + }); + + $form = $this->factory->create(CalendarType::class, $calendar); + + $form->submit($formData); + + $this->assertTrue($form->isSynchronized()); + $this->assertEquals(DateTimeImmutable::createFromFormat('Y-m-d H:i:s', '2022-05-05 14:00:00'), $calendar->getStartDate()); + $this->assertEquals(DateTimeImmutable::createFromFormat('Y-m-d H:i:s', '2022-05-05 14:30:00'), $calendar->getEndDate()); + $this->assertEquals(7, $calendar->getPersons()->first()->getId()); + $this->assertEquals(8, $calendar->getCalendarRange()->getId()); + $this->assertEquals(9, $calendar->getLocation()->getId()); + $this->assertEquals(true, $calendar->getSendSMS()); + $this->assertContains(2, $calendar->getUsers()->map(static function (User $u) { return $u->getId(); })); + $this->assertContains(3, $calendar->getUsers()->map(static function (User $u) { return $u->getId(); })); + } + + protected function getExtensions() + { + $parents = parent::getExtensions(); + + $calendarType = new CalendarType( + $this->personsToIdDataTransformer, + $this->idToUserDataTransformer, + $this->idToUsersDataTransformer, + $this->idToLocationDataTransformer, + $this->partiesToIdDataTransformer, + $this->calendarRangeDataTransformer + ); + $commentType = new CommentType($this->tokenStorage); + + return array_merge( + parent::getExtensions(), + [new PreloadedExtension([$calendarType, $commentType], [])] + ); + } + + private function buildMultiToIdDataTransformer( + string $classTransformer, + string $objClass + ) { + $transformer = $this->prophesize($classTransformer); + $transformer->transform(Argument::type('array')) + ->will(static function ($args) { + return implode( + ',', + array_map(static function ($p) { return $p->getId(); }, $args[0]) + ); + }); + $transformer->transform(Argument::exact(null)) + ->willReturn([]); + $transformer->transform(Argument::type(Collection::class)) + ->will(static function ($args) { + return implode( + ',', + array_map(static function ($p) { return $p->getId(); }, $args[0]->toArray()) + ); + }); + $transformer->reverseTransform(Argument::type('string')) + ->will(static function ($args) use ($objClass) { + if (null === $args[0]) { + return []; + } + + return array_map( + static function ($id) use ($objClass) { + $obj = new $objClass(); + $reflectionProperty = new ReflectionProperty($objClass, 'id'); + $reflectionProperty->setAccessible(true); + $reflectionProperty->setValue($obj, (int) $id); + + return $obj; + }, + explode(',', $args[0]) + ); + }); + + return $transformer->reveal(); + } + + private function buildSingleToIdDataTransformer( + string $classTransformer, + string $class + ) { + $transformer = $this->prophesize($classTransformer); + $transformer->transform(Argument::type('object')) + ->will(static function ($args) { + return (string) $args[0]->getId(); + }); + $transformer->transform(Argument::exact(null)) + ->willReturn(''); + $transformer->reverseTransform(Argument::type('string')) + ->will(static function ($args) use ($class) { + if (null === $args[0]) { + return null; + } + $obj = new $class(); + $reflectionProperty = new ReflectionProperty($class, 'id'); + $reflectionProperty->setAccessible(true); + $reflectionProperty->setValue($obj, (int) $args[0]); + + return $obj; + }); + + return $transformer->reveal(); + } +} diff --git a/src/Bundle/ChillCalendarBundle/Tests/RemoteCalendar/Connector/MSGraph/AddressConverterTest.php b/src/Bundle/ChillCalendarBundle/Tests/RemoteCalendar/Connector/MSGraph/AddressConverterTest.php new file mode 100644 index 000000000..485146939 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Tests/RemoteCalendar/Connector/MSGraph/AddressConverterTest.php @@ -0,0 +1,74 @@ +setName(['fr' => 'Belgique']); + $postalCode = (new PostalCode())->setName('Houte-Si-Plout')->setCode('4122') + ->setCountry($country); + $address = (new Address())->setPostcode($postalCode)->setStreet("Rue de l'Église") + ->setStreetNumber('15B')->setBuildingName('Résidence de la Truite'); + + $actual = $this->buildAddressConverter()->addressToRemote($address); + + $this->assertArrayHasKey('city', $actual); + $this->assertStringContainsString($actual['city'], 'Houte-Si-Plout'); + $this->assertArrayHasKey('postalCode', $actual); + $this->assertStringContainsString($actual['postalCode'], '4122'); + $this->assertArrayHasKey('countryOrRegion', $actual); + $this->assertStringContainsString('Belgique', $actual['countryOrRegion']); + $this->assertArrayHasKey('street', $actual); + $this->assertStringContainsString('Rue de l\'Église', $actual['street']); + $this->assertStringContainsString('15B', $actual['street']); + $this->assertStringContainsString('Résidence de la Truite', $actual['street']); + } + + private function buildAddressConverter(): AddressConverter + { + $engine = $this->prophesize(EngineInterface::class); + $translatableStringHelper = $this->prophesize(TranslatableStringHelperInterface::class); + $translatableStringHelper->localize(Argument::type('array'))->will(static function ($args): string { + return ($args[0] ?? ['fr' => 'not provided'])['fr'] ?? 'not provided'; + }); + + $addressRender = new AddressRender($engine->reveal(), $translatableStringHelper->reveal()); + + return new AddressConverter($addressRender, $translatableStringHelper->reveal()); + } +} diff --git a/src/Bundle/ChillCalendarBundle/Tests/RemoteCalendar/Connector/MSGraph/CalendarRangeSyncerTest.php b/src/Bundle/ChillCalendarBundle/Tests/RemoteCalendar/Connector/MSGraph/CalendarRangeSyncerTest.php new file mode 100644 index 000000000..92280f6c1 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Tests/RemoteCalendar/Connector/MSGraph/CalendarRangeSyncerTest.php @@ -0,0 +1,255 @@ +prophesize(EntityManagerInterface::class); + $em->remove(Argument::type(CalendarRange::class))->shouldNotBeCalled(); + + $machineHttpClient = new MockHttpClient([ + new MockResponse(self::REMOTE_CALENDAR_RANGE, ['http_code' => 200]), + ]); + + $calendarRangeSyncer = new CalendarRangeSyncer( + $em->reveal(), + new NullLogger(), + $machineHttpClient + ); + + $calendarRange = new CalendarRange(); + $calendarRange + ->setUser($user = new User()); + $calendar = new Calendar(); + $calendar->setCalendarRange($calendarRange); + + $notification = json_decode(self::NOTIF_DELETE, true); + + $calendarRangeSyncer->handleCalendarRangeSync( + $calendarRange, + $notification['value'][0], + $user + ); + } + + public function testDeleteCalendarRangeWithoutAssociation(): void + { + $em = $this->prophesize(EntityManagerInterface::class); + $em->remove(Argument::type(CalendarRange::class))->shouldBeCalled(); + + $machineHttpClient = new MockHttpClient([ + new MockResponse(self::REMOTE_CALENDAR_RANGE, ['http_code' => 200]), + ]); + + $calendarRangeSyncer = new CalendarRangeSyncer( + $em->reveal(), + new NullLogger(), + $machineHttpClient + ); + + $calendarRange = new CalendarRange(); + $calendarRange + ->setUser($user = new User()); + $notification = json_decode(self::NOTIF_DELETE, true); + + $calendarRangeSyncer->handleCalendarRangeSync( + $calendarRange, + $notification['value'][0], + $user + ); + $this->assertTrue($calendarRange->preventEnqueueChanges); + } + + public function testUpdateCalendarRange(): void + { + $em = $this->prophesize(EntityManagerInterface::class); + $machineHttpClient = new MockHttpClient([ + new MockResponse(self::REMOTE_CALENDAR_RANGE, ['http_code' => 200]), + ]); + + $calendarRangeSyncer = new CalendarRangeSyncer( + $em->reveal(), + new NullLogger(), + $machineHttpClient + ); + + $calendarRange = new CalendarRange(); + $calendarRange + ->setUser($user = new User()) + ->setStartDate(new DateTimeImmutable('2020-01-01 15:00:00')) + ->setEndDate(new DateTimeImmutable('2020-01-01 15:30:00')) + ->addRemoteAttributes([ + 'lastModifiedDateTime' => 0, + 'changeKey' => 'abc', + ]); + $notification = json_decode(self::NOTIF_UPDATE, true); + + $calendarRangeSyncer->handleCalendarRangeSync( + $calendarRange, + $notification['value'][0], + $user + ); + + $this->assertStringContainsString( + '2022-06-10T15:30:00', + $calendarRange->getStartDate()->format(DateTimeImmutable::ATOM) + ); + $this->assertStringContainsString( + '2022-06-10T17:30:00', + $calendarRange->getEndDate()->format(DateTimeImmutable::ATOM) + ); + $this->assertTrue($calendarRange->preventEnqueueChanges); + } +} diff --git a/src/Bundle/ChillCalendarBundle/Tests/RemoteCalendar/Connector/MSGraph/CalendarSyncerTest.php b/src/Bundle/ChillCalendarBundle/Tests/RemoteCalendar/Connector/MSGraph/CalendarSyncerTest.php new file mode 100644 index 000000000..6ccd7a35e --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Tests/RemoteCalendar/Connector/MSGraph/CalendarSyncerTest.php @@ -0,0 +1,596 @@ +\r\n\r\n\r\n\r\n\r\n
      \r\n
      \r\n
      ________________________________________________________________________________\r\n
      \r\n
      \r\n
      Réunion Microsoft Teams\r\n
      \r\n
      \r\n
      Rejoindre sur votre ordinateur ou application mobile\r\n
      \r\nCliquez\r\n ici pour participer à la réunion
      \r\n\r\n
      \r\n
      \r\n
      \r\n
      \r\n
      \r\n
      ________________________________________________________________________________\r\n
      \r\n\r\n\r\n" + }, + "start": { + "dateTime": "2022-06-11T12:30:00.0000000", + "timeZone": "UTC" + }, + "end": { + "dateTime": "2022-06-11T13:30:00.0000000", + "timeZone": "UTC" + }, + "location": { + "displayName": "", + "locationType": "default", + "uniqueIdType": "unknown", + "address": {}, + "coordinates": {} + }, + "locations": [], + "recurrence": null, + "attendees": [ + { + "type": "required", + "status": { + "response": "accepted", + "time": "2022-06-08T16:22:15.1392583Z" + }, + "emailAddress": { + "name": "Alex Wilber", + "address": "AlexW@2zy74l.onmicrosoft.com" + } + }, + { + "type": "required", + "status": { + "response": "declined", + "time": "2022-06-08T16:22:15.1392583Z" + }, + "emailAddress": { + "name": "Alfred Nobel", + "address": "alfredN@2zy74l.onmicrosoft.com" + } + } + ], + "organizer": { + "emailAddress": { + "name": "Diego Siciliani", + "address": "DiegoS@2zy74l.onmicrosoft.com" + } + }, + "onlineMeeting": { + "joinUrl": "https://teams.microsoft.com/l/meetup-join/19%3ameeting_NTE3ODUxY2ItNGJhNi00Y2UwLTljN2QtMmQ3YjAxNWY1Nzk2%40thread.v2/0?context=%7b%22Tid%22%3a%22421bf216-3f48-47bd-a7cf-8b1995cb24bd%22%2c%22Oid%22%3a%224feb0ae3-7ffb-48dd-891e-c86b2cdeefd4%22%7d" + } + } + JSON; + + private const REMOTE_CALENDAR_WITH_ATTENDEES = <<<'JSON' + { + "@odata.etag": "W/\"B3bmsWoxX06b9JHIZPptRQAAJcrv7A==\"", + "id": "AAMkADM1MTdlMGIzLTZhZWUtNDQ0ZC05Y2M4LWViMjhmOWJlMDhhMQBGAAAAAAA5e3965gkBSLcU1p00sMSyBwAHduaxajFfTpv0kchk_m1FAAAAAAENAAAHduaxajFfTpv0kchk_m1FAAAl1BuqAAA=", + "createdDateTime": "2022-06-08T16:19:18.997293Z", + "lastModifiedDateTime": "2022-06-08T16:22:18.7276485Z", + "changeKey": "B3bmsWoxX06b9JHIZPptRQAAJcrv7A==", + "categories": [], + "transactionId": "42ac0b77-313e-20ca-2cac-1a3f58b3452d", + "originalStartTimeZone": "Romance Standard Time", + "originalEndTimeZone": "Romance Standard Time", + "iCalUId": "040000008200E00074C5B7101A82E00800000000A8955A81537BD801000000000000000010000000007EB443987CD641B6794C4BA48EE356", + "reminderMinutesBeforeStart": 15, + "isReminderOn": true, + "hasAttachments": false, + "subject": "test 2", + "bodyPreview": "________________________________________________________________________________\r\nRéunion Microsoft Teams\r\nRejoindre sur votre ordinateur ou application mobile\r\nCliquez ici pour participer à la réunion\r\nPour en savoir plus | Options de réunion\r\n________", + "importance": "normal", + "sensitivity": "normal", + "isAllDay": false, + "isCancelled": false, + "isOrganizer": true, + "responseRequested": true, + "seriesMasterId": null, + "showAs": "busy", + "type": "singleInstance", + "webLink": "https://outlook.office365.com/owa/?itemid=AAMkADM1MTdlMGIzLTZhZWUtNDQ0ZC05Y2M4LWViMjhmOWJlMDhhMQBGAAAAAAA5e3965gkBSLcU1p00sMSyBwAHduaxajFfTpv0kchk%2Bm1FAAAAAAENAAAHduaxajFfTpv0kchk%2Bm1FAAAl1BuqAAA%3D&exvsurl=1&path=/calendar/item", + "onlineMeetingUrl": null, + "isOnlineMeeting": true, + "onlineMeetingProvider": "teamsForBusiness", + "allowNewTimeProposals": true, + "occurrenceId": null, + "isDraft": false, + "hideAttendees": false, + "responseStatus": { + "response": "organizer", + "time": "0001-01-01T00:00:00Z" + }, + "body": { + "contentType": "html", + "content": "\r\n\r\n\r\n\r\n\r\n
      \r\n
      \r\n
      ________________________________________________________________________________\r\n
      \r\n
      \r\n
      Réunion Microsoft Teams\r\n
      \r\n
      \r\n
      Rejoindre sur votre ordinateur ou application mobile\r\n
      \r\nCliquez\r\n ici pour participer à la réunion
      \r\n\r\n
      \r\n
      \r\n
      \r\n
      \r\n
      \r\n
      ________________________________________________________________________________\r\n
      \r\n\r\n\r\n" + }, + "start": { + "dateTime": "2022-06-11T12:30:00.0000000", + "timeZone": "UTC" + }, + "end": { + "dateTime": "2022-06-11T13:30:00.0000000", + "timeZone": "UTC" + }, + "location": { + "displayName": "", + "locationType": "default", + "uniqueIdType": "unknown", + "address": {}, + "coordinates": {} + }, + "locations": [], + "recurrence": null, + "attendees": [ + { + "type": "required", + "status": { + "response": "accepted", + "time": "2022-06-08T16:22:15.1392583Z" + }, + "emailAddress": { + "name": "Alex Wilber", + "address": "AlexW@2zy74l.onmicrosoft.com" + } + }, + { + "type": "required", + "status": { + "response": "accepted", + "time": "2022-06-08T16:22:15.1392583Z" + }, + "emailAddress": { + "name": "External User", + "address": "external@example.com" + } + }, + { + "type": "required", + "status": { + "response": "declined", + "time": "2022-06-08T16:22:15.1392583Z" + }, + "emailAddress": { + "name": "Alfred Nobel", + "address": "alfredN@2zy74l.onmicrosoft.com" + } + } + ], + "organizer": { + "emailAddress": { + "name": "Diego Siciliani", + "address": "DiegoS@2zy74l.onmicrosoft.com" + } + }, + "onlineMeeting": { + "joinUrl": "https://teams.microsoft.com/l/meetup-join/19%3ameeting_NTE3ODUxY2ItNGJhNi00Y2UwLTljN2QtMmQ3YjAxNWY1Nzk2%40thread.v2/0?context=%7b%22Tid%22%3a%22421bf216-3f48-47bd-a7cf-8b1995cb24bd%22%2c%22Oid%22%3a%224feb0ae3-7ffb-48dd-891e-c86b2cdeefd4%22%7d" + } + } + JSON; + + protected function setUp(): void + { + parent::setUp(); + + // all tests should run when timezone = +02:00 + $brussels = new DateTimeZone('Europe/Brussels'); + + if (7200 === $brussels->getOffset(new DateTimeImmutable())) { + date_default_timezone_set('Europe/Brussels'); + } else { + date_default_timezone_set('Europe/Moscow'); + } + } + + public function testHandleAttendeesConfirmingCalendar(): void + { + $machineHttpClient = new MockHttpClient([ + new MockResponse(self::REMOTE_CALENDAR_WITH_ATTENDEES, ['http_code' => 200]), + ]); + + $userA = (new User())->setEmail('alexw@2zy74l.onmicrosoft.com') + ->setEmailCanonical('alexw@2zy74l.onmicrosoft.com'); + $userB = (new User())->setEmail('zzzzz@2zy74l.onmicrosoft.com') + ->setEmailCanonical('zzzzz@2zy74l.onmicrosoft.com'); + $userC = (new User())->setEmail('alfredN@2zy74l.onmicrosoft.com') + ->setEmailCanonical('alfredn@2zy74l.onmicrosoft.com'); + + $userRepository = $this->prophesize(UserRepositoryInterface::class); + $userRepository->findOneByUsernameOrEmail(Argument::exact('AlexW@2zy74l.onmicrosoft.com')) + ->willReturn($userA); + $userRepository->findOneByUsernameOrEmail(Argument::exact('zzzzz@2zy74l.onmicrosoft.com')) + ->willReturn($userB); + $userRepository->findOneByUsernameOrEmail(Argument::exact('alfredN@2zy74l.onmicrosoft.com')) + ->willReturn($userC); + $userRepository->findOneByUsernameOrEmail(Argument::exact('external@example.com')) + ->willReturn(null); + + $calendarSyncer = new CalendarSyncer( + new NullLogger(), + $machineHttpClient, + $userRepository->reveal() + ); + + $calendar = new Calendar(); + $calendar + ->setMainUser($user = new User()) + ->setStartDate(new DateTimeImmutable('2022-06-11 14:30:00')) + ->setEndDate(new DateTimeImmutable('2022-06-11 15:30:00')) + ->addUser($userA) + ->addUser($userB) + ->setCalendarRange(new CalendarRange()) + ->addRemoteAttributes([ + 'lastModifiedDateTime' => 0, + 'changeKey' => 'abcd', + ]); + + $notification = json_decode(self::NOTIF_UPDATE, true); + + $calendarSyncer->handleCalendarSync( + $calendar, + $notification['value'][0], + $user + ); + + $this->assertTrue($calendar->preventEnqueueChanges); + $this->assertEquals(Calendar::STATUS_VALID, $calendar->getStatus()); + // user A is invited, and accepted + $this->assertTrue($calendar->isInvited($userA)); + $this->assertEquals(Invite::ACCEPTED, $calendar->getInviteForUser($userA)->getStatus()); + $this->assertFalse($calendar->getInviteForUser($userA)->preventEnqueueChanges); + // user B is no more invited + $this->assertFalse($calendar->isInvited($userB)); + // user C is invited, but declined + $this->assertFalse($calendar->getInviteForUser($userC)->preventEnqueueChanges); + $this->assertTrue($calendar->isInvited($userC)); + $this->assertEquals(Invite::DECLINED, $calendar->getInviteForUser($userC)->getStatus()); + } + + public function testHandleDeleteCalendar(): void + { + $machineHttpClient = new MockHttpClient([]); + $userRepository = $this->prophesize(UserRepositoryInterface::class); + + $calendarSyncer = new CalendarSyncer( + new NullLogger(), + $machineHttpClient, + $userRepository->reveal() + ); + + $calendar = new Calendar(); + $calendar + ->setMainUser($user = new User()) + ->setCalendarRange($calendarRange = new CalendarRange()); + + $notification = json_decode(self::NOTIF_DELETE, true); + + $calendarSyncer->handleCalendarSync( + $calendar, + $notification['value'][0], + $user + ); + + $this->assertEquals(Calendar::STATUS_CANCELED, $calendar->getStatus()); + $this->assertNull($calendar->getCalendarRange()); + $this->assertTrue($calendar->preventEnqueueChanges); + } + + public function testHandleMoveCalendar(): void + { + $machineHttpClient = new MockHttpClient([ + new MockResponse(self::REMOTE_CALENDAR_NO_ATTENDEES, ['http_code' => 200]), + ]); + $userRepository = $this->prophesize(UserRepositoryInterface::class); + + $calendarSyncer = new CalendarSyncer( + new NullLogger(), + $machineHttpClient, + $userRepository->reveal() + ); + + $calendar = new Calendar(); + $calendar + ->setMainUser($user = new User()) + ->setStartDate(new DateTimeImmutable('2020-01-01 10:00:00')) + ->setEndDate(new DateTimeImmutable('2020-01-01 12:00:00')) + ->setCalendarRange(new CalendarRange()) + ->addRemoteAttributes([ + 'lastModifiedDateTime' => 0, + 'changeKey' => 'abcd', + ]); + $notification = json_decode(self::NOTIF_UPDATE, true); + + $calendarSyncer->handleCalendarSync( + $calendar, + $notification['value'][0], + $user + ); + + $this->assertStringContainsString( + '2022-06-10T15:30:00', + $calendar->getStartDate()->format(DateTimeImmutable::ATOM) + ); + $this->assertStringContainsString( + '2022-06-10T17:30:00', + $calendar->getEndDate()->format(DateTimeImmutable::ATOM) + ); + $this->assertTrue($calendar->preventEnqueueChanges); + $this->assertEquals(Calendar::STATUS_MOVED, $calendar->getStatus()); + } + + public function testHandleNotMovedCalendar(): void + { + $machineHttpClient = new MockHttpClient([ + new MockResponse(self::REMOTE_CALENDAR_NO_ATTENDEES, ['http_code' => 200]), + ]); + $userRepository = $this->prophesize(UserRepositoryInterface::class); + + $calendarSyncer = new CalendarSyncer( + new NullLogger(), + $machineHttpClient, + $userRepository->reveal() + ); + + $calendar = new Calendar(); + $calendar + ->setMainUser($user = new User()) + ->setStartDate(new DateTimeImmutable('2022-06-10 15:30:00')) + ->setEndDate(new DateTimeImmutable('2022-06-10 17:30:00')) + ->setCalendarRange(new CalendarRange()) + ->addRemoteAttributes([ + 'lastModifiedDateTime' => 0, + 'changeKey' => 'abcd', + ]); + $notification = json_decode(self::NOTIF_UPDATE, true); + + $calendarSyncer->handleCalendarSync( + $calendar, + $notification['value'][0], + $user + ); + + $this->assertStringContainsString( + '2022-06-10T15:30:00', + $calendar->getStartDate()->format(DateTimeImmutable::ATOM) + ); + $this->assertStringContainsString( + '2022-06-10T17:30:00', + $calendar->getEndDate()->format(DateTimeImmutable::ATOM) + ); + $this->assertTrue($calendar->preventEnqueueChanges); + $this->assertEquals(Calendar::STATUS_VALID, $calendar->getStatus()); + } + + public function testHandleNotOrganizer(): void + { + // when isOrganiser === false, nothing should happens + $machineHttpClient = new MockHttpClient([ + new MockResponse(self::REMOTE_CALENDAR_NOT_ORGANIZER, ['http_code' => 200]), + ]); + $userRepository = $this->prophesize(UserRepositoryInterface::class); + + $calendarSyncer = new CalendarSyncer( + new NullLogger(), + $machineHttpClient, + $userRepository->reveal() + ); + + $calendar = new Calendar(); + $calendar + ->setMainUser($user = new User()) + ->setStartDate(new DateTimeImmutable('2020-01-01 10:00:00')) + ->setEndDate(new DateTimeImmutable('2020-01-01 12:00:00')) + ->setCalendarRange(new CalendarRange()) + ->addRemoteAttributes([ + 'lastModifiedDateTime' => 0, + 'changeKey' => 'abcd', + ]); + $notification = json_decode(self::NOTIF_UPDATE, true); + + $calendarSyncer->handleCalendarSync( + $calendar, + $notification['value'][0], + $user + ); + + $this->assertStringContainsString( + '2020-01-01T10:00:00', + $calendar->getStartDate()->format(DateTimeImmutable::ATOM) + ); + $this->assertStringContainsString( + '2020-01-01T12:00:00', + $calendar->getEndDate()->format(DateTimeImmutable::ATOM) + ); + + $this->assertEquals(Calendar::STATUS_VALID, $calendar->getStatus()); + } +} diff --git a/src/Bundle/ChillCalendarBundle/Tests/RemoteCalendar/Connector/MSGraph/LocationConverterTest.php b/src/Bundle/ChillCalendarBundle/Tests/RemoteCalendar/Connector/MSGraph/LocationConverterTest.php new file mode 100644 index 000000000..604b707c0 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Tests/RemoteCalendar/Connector/MSGraph/LocationConverterTest.php @@ -0,0 +1,86 @@ +setName('display')->setAddress($address = new Address()); + $address->setAddressReference($reference = new AddressReference()); + + $actual = $this->buildLocationConverter()->locationToRemote($location); + + $this->assertArrayHasKey('address', $actual); + $this->assertArrayNotHasKey('coordinates', $actual); + $this->assertArrayHasKey('displayName', $actual); + $this->assertEquals('display', $actual['displayName']); + } + + public function testConvertToRemoteWithAddressWithPoint(): void + { + $location = (new Location())->setName('display')->setAddress($address = new Address()); + $address->setAddressReference($reference = new AddressReference()); + $reference->setPoint($point = Point::fromLonLat(5.3134, 50.3134)); + + $actual = $this->buildLocationConverter()->locationToRemote($location); + + $this->assertArrayHasKey('address', $actual); + $this->assertArrayHasKey('coordinates', $actual); + $this->assertEquals(['latitude' => 50.3134, 'longitude' => 5.3134], $actual['coordinates']); + $this->assertArrayHasKey('displayName', $actual); + $this->assertEquals('display', $actual['displayName']); + } + + public function testConvertToRemoteWithoutAddressWithoutPoint(): void + { + $location = (new Location())->setName('display'); + + $actual = $this->buildLocationConverter()->locationToRemote($location); + + $this->assertArrayNotHasKey('address', $actual); + $this->assertArrayNotHasKey('coordinates', $actual); + $this->assertArrayHasKey('displayName', $actual); + $this->assertEquals('display', $actual['displayName']); + } + + private function buildLocationConverter(): LocationConverter + { + $addressConverter = $this->prophesize(AddressConverter::class); + $addressConverter->addressToRemote(Argument::type(Address::class))->willReturn(['street' => 'dummy']); + + return new LocationConverter($addressConverter->reveal()); + } +} diff --git a/src/Bundle/ChillCalendarBundle/Tests/Repository/CalendarACLAwareRepositoryTest.php b/src/Bundle/ChillCalendarBundle/Tests/Repository/CalendarACLAwareRepositoryTest.php new file mode 100644 index 000000000..0fb4a662b --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Tests/Repository/CalendarACLAwareRepositoryTest.php @@ -0,0 +1,78 @@ +entityManager = self::$container->get(EntityManagerInterface::class); + } + + public function testCountByPerosn() + { + $person = $this->getRandomPerson($this->entityManager); + + $periodRepository = $this->prophesize(AccompanyingPeriodACLAwareRepositoryInterface::class); + $periodRepository->findByPerson($person, AccompanyingPeriodVoter::SEE)->willReturn([]); + + $calendarRepository = new CalendarACLAwareRepository( + $periodRepository->reveal(), + $this->entityManager + ); + + $count = $calendarRepository->countByPerson($person, new DateTimeImmutable('yesterday'), new DateTimeImmutable('tomorrow')); + + $this->assertIsInt($count); + } + + /** + * Test that the query does not throw any error. + */ + public function testFindByPerson() + { + $person = $this->getRandomPerson($this->entityManager); + + $periodRepository = $this->prophesize(AccompanyingPeriodACLAwareRepositoryInterface::class); + $periodRepository->findByPerson($person, AccompanyingPeriodVoter::SEE)->willReturn([]); + + $calendarRepository = new CalendarACLAwareRepository( + $periodRepository->reveal(), + $this->entityManager + ); + + $calendars = $calendarRepository->findByPerson($person, null, null, ['startDate' => 'ASC'], 10, 1); + + $this->assertIsArray($calendars); + } +} diff --git a/src/Bundle/ChillCalendarBundle/Tests/Serializer/Normalizer/CalendarNormalizerTest.php b/src/Bundle/ChillCalendarBundle/Tests/Serializer/Normalizer/CalendarNormalizerTest.php new file mode 100644 index 000000000..2a0975206 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Tests/Serializer/Normalizer/CalendarNormalizerTest.php @@ -0,0 +1,109 @@ +normalizer = self::$container->get(NormalizerInterface::class); + } + + public function testNormalizationCalendar() + { + $calendar = (new Calendar()) + ->setComment( + $comment = new CommentEmbeddable() + ) + ->setStartDate(DateTimeImmutable::createFromFormat(DateTimeImmutable::ATOM, '2020-10-15T15:00:00+0000')) + ->setEndDate(DateTimeImmutable::createFromFormat(DateTimeImmutable::ATOM, '2020-15-15T15:30:00+0000')) + ->addPerson(new Person()) + ->addPerson(new Person()) + ->addUser(new User()) + ->addProfessional(new ThirdParty()); + + $expected = [ + 'type' => 'chill_calendar_calendar', + 'isNull' => false, + 'urgent' => false, + 'sendSMS' => false, + ]; + + $actual = $this->normalizer->normalize( + $calendar, + 'docgen', + ['groups' => ['docgen:read'], 'docgen:expects' => Calendar::class] + ); + + // we first check for the known key/value... + foreach ($expected as $key => $value) { + $this->assertArrayHasKey($key, $actual); + $this->assertEquals($value, $actual[$key]); + } + + // ... and then check for some other values + $this->assertArrayHasKey('persons', $actual); + $this->assertIsArray($actual['persons']); + $this->assertArrayHasKey('invites', $actual); + $this->assertIsArray($actual['invites']); + $this->assertArrayHasKey('startDate', $actual); + $this->assertIsArray($actual['startDate']); + $this->assertArrayHasKey('endDate', $actual); + $this->assertIsArray($actual['endDate']); + $this->assertArrayHasKey('professionals', $actual); + $this->assertIsArray($actual['professionals']); + $this->assertArrayHasKey('location', $actual); + $this->assertIsArray($actual['location']); + $this->assertArrayHasKey('mainUser', $actual); + $this->assertIsArray($actual['mainUser']); + $this->assertArrayHasKey('comment', $actual); + $this->assertIsArray($actual['comment']); + $this->assertArrayHasKey('duration', $actual); + $this->assertIsArray($actual['duration']); + } + + public function testNormalizationOnNullHasSameKeys() + { + $calendar = new Calendar(); + + $notNullCalendar = $this->normalizer->normalize( + $calendar, + 'docgen', + ['groups' => ['docgen:read'], 'docgen:expects' => Calendar::class] + ); + + $isNullCalendar = $this->normalizer->normalize( + null, + 'docgen', + ['groups' => ['docgen:read'], 'docgen:expects' => Calendar::class] + ); + + $this->assertEqualsCanonicalizing(array_keys($notNullCalendar), array_keys($isNullCalendar)); + } +} diff --git a/src/Bundle/ChillCalendarBundle/Tests/Service/DocGenerator/CalendarContextTest.php b/src/Bundle/ChillCalendarBundle/Tests/Service/DocGenerator/CalendarContextTest.php new file mode 100644 index 000000000..be31485d4 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Tests/Service/DocGenerator/CalendarContextTest.php @@ -0,0 +1,236 @@ + true, + 'askMainPerson' => true, + 'mainPersonLabel' => 'docgen.calendar.Destinee', + 'askThirdParty' => false, + 'thirdPartyLabel' => 'Third party', + ]; + + $this->assertEqualsCanonicalizing($expected, $this->buildCalendarContext()->adminFormReverseTransform([])); + } + + public function testAdminFormTransform() + { + $expected = + [ + 'track_datetime' => true, + 'askMainPerson' => true, + 'mainPersonLabel' => 'docgen.calendar.Destinee', + 'askThirdParty' => false, + 'thirdPartyLabel' => 'Third party', + ]; + + $this->assertEqualsCanonicalizing($expected, $this->buildCalendarContext()->adminFormTransform($expected)); + } + + public function testBuildPublicForm() + { + $formBuilder = $this->prophesize(FormBuilderInterface::class); + $calendar = new Calendar(); + $calendar + ->addProfessional($tp1 = new ThirdParty()) + ->addProfessional($tp2 = new ThirdParty()) + ->addPerson($p1 = new Person()); + + // we will try once with askThirdParty = true, once with askPerson = true, and once with both + // so, we expect the call to be twice for each method + $formBuilder->add('thirdParty', EntityType::class, Argument::type('array')) + ->should(static function ($calls, $object, $method) use ($tp1, $tp2) { + if (2 !== count($calls)) { + throw new FailedPredictionException(sprintf('the $builder->add should be called exactly 2, %d receivved', count($calls))); + } + + $opts = $calls[0]->getArguments()[2]; + + if (!array_key_exists('label', $opts)) { + throw new FailedPredictionException('the $builder->add should have a label key'); + } + + if ('tplabel' !== $opts['label']) { + throw new FailedPredictionException('third party label not expected'); + } + + if (!$opts['choices']->contains($tp1) || !$opts['choices']->contains($tp2)) { + throw new FailedPredictionException('third party not present'); + } + }); + $formBuilder->add('mainPerson', EntityType::class, Argument::type('array')) + ->should(static function ($calls, $object, $method) use ($p1) { + if (2 !== count($calls)) { + throw new FailedPredictionException(sprintf('the $builder->add should be called exactly 2, %d receivved', count($calls))); + } + + $opts = $calls[0]->getArguments()[2]; + + if (!array_key_exists('label', $opts)) { + throw new FailedPredictionException('the $builder->add should have a label key'); + } + + if ('personLabel' !== $opts['label']) { + throw new FailedPredictionException('person label not expected'); + } + + if (!$opts['choices']->contains($p1)) { + throw new FailedPredictionException('person not present'); + } + }); + + $formBuilder->add('title', TextType::class, Argument::type('array')) + ->shouldBeCalledTimes(3); + + foreach ([ + ['askMainPerson' => true, 'mainPersonLabel' => 'personLabel', 'askThirdParty' => true, 'thirdPartyLabel' => 'tplabel'], + ['askMainPerson' => false, 'mainPersonLabel' => 'personLabel', 'askThirdParty' => true, 'thirdPartyLabel' => 'tplabel'], + ['askMainPerson' => true, 'mainPersonLabel' => 'personLabel', 'askThirdParty' => false, 'thirdPartyLabel' => 'tplabel'], + ] as $options) { + $template = new DocGeneratorTemplate(); + $template->setOptions($options); + + $this->buildCalendarContext()->buildPublicForm($formBuilder->reveal(), $template, $calendar); + } + } + + public function testGetData() + { + $calendar = (new Calendar()) + ->addPerson($p1 = new Person()) + ->addProfessional($t1 = new ThirdParty()); + $template = (new DocGeneratorTemplate())->setOptions( + ['askMainPerson' => true, 'mainPersonLabel' => 'personLabel', 'askThirdParty' => true, 'thirdPartyLabel' => 'tplabel'], + ); + $contextData = [ + 'mainPerson' => $p1, + 'thirdParty' => $t1, + ]; + + $normalizer = $this->prophesize(NormalizerInterface::class); + $normalizer->normalize($p1, 'docgen', Argument::type('array'))->willReturn(['person' => '1']); + $normalizer->normalize($t1, 'docgen', Argument::type('array'))->willReturn(['tp' => '1']); + $normalizer->normalize($calendar, 'docgen', Argument::type('array'))->willReturn(['calendar' => '1']); + + $actual = $this->buildCalendarContext(null, $normalizer->reveal()) + ->getData($template, $calendar, $contextData); + + $this->assertEqualsCanonicalizing([ + 'calendar' => ['calendar' => '1'], + 'mainPerson' => ['person' => '1'], + 'thirdParty' => ['tp' => '1'], + 'base_context' => 'data', + ], $actual); + } + + public function testStoreGenerated() + { + $calendar = new Calendar(); + $storedObject = new StoredObject(); + $contextData = ['title' => 'blabla']; + $template = (new DocGeneratorTemplate())->setOptions(['trackDatetime' => true]); + + $em = $this->prophesize(EntityManagerInterface::class); + $em->persist(Argument::type(CalendarDoc::class))->should( + static function ($calls, $object, $method) use ($storedObject) { + if (1 !== count($calls)) { + throw new FailedPredictionException('the persist method should be called once'); + } + + /** @var CalendarDoc $calendarDoc */ + $calendarDoc = $calls[0]->getArguments()[0]; + + if ($calendarDoc->getStoredObject() !== $storedObject) { + throw new FailedPredictionException('the stored object is not correct'); + } + + if ($calendarDoc->getStoredObject()->getTitle() !== 'blabla') { + throw new FailedPredictionException('the doc title should be the one provided'); + } + + if (!$calendarDoc->isTrackDateTimeVersion()) { + throw new FailedPredictionException('the track date time should be true'); + } + } + ); + + $this->buildCalendarContext($em->reveal())->storeGenerated($template, $storedObject, $calendar, $contextData); + } + + private function buildCalendarContext( + ?EntityManagerInterface $entityManager = null, + ?NormalizerInterface $normalizer = null + ): CalendarContext { + $baseContext = $this->prophesize(BaseContextData::class); + $baseContext->getData()->willReturn(['base_context' => 'data']); + + $personRender = $this->prophesize(PersonRender::class); + $personRender->renderString(Argument::type(Person::class), [])->willReturn('person name'); + + $thirdPartyRender = $this->prophesize(ThirdPartyRender::class); + $thirdPartyRender->renderString(Argument::type(ThirdParty::class), [])->willReturn('third party name'); + + $translatableStringHelper = $this->prophesize(TranslatableStringHelperInterface::class); + $translatableStringHelper->localize(Argument::type('array'))->willReturn('blabla'); + + if (null === $normalizer) { + $normalizer = $this->prophesize(NormalizerInterface::class)->reveal(); + } + + if (null === $entityManager) { + $entityManager = $this->prophesize(EntityManagerInterface::class)->reveal(); + } + + return new CalendarContext( + $baseContext->reveal(), + $entityManager, + $normalizer, + $personRender->reveal(), + $thirdPartyRender->reveal(), + $translatableStringHelper->reveal() + ); + } +} diff --git a/src/Bundle/ChillCalendarBundle/Tests/Service/ShortMessageNotification/BulkCalendarShortMessageSenderTest.php b/src/Bundle/ChillCalendarBundle/Tests/Service/ShortMessageNotification/BulkCalendarShortMessageSenderTest.php new file mode 100644 index 000000000..0c190f51d --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Tests/Service/ShortMessageNotification/BulkCalendarShortMessageSenderTest.php @@ -0,0 +1,135 @@ +get(EntityManagerInterface::class); + + foreach ($this->toDelete as [$entity, $id]) { + $entity = $em->find($entity, $id); + $em->remove($entity); + } + + $em->flush(); + } + + public function testSendBulkMessageToEligibleCalendar() + { + $em = self::$container->get(EntityManagerInterface::class); + $calendar = new Calendar(); + $calendar + ->addPerson($this->getRandomPerson($em)) + ->setMainUser($user = $this->prepareUser([])) + ->setStartDate(new DateTimeImmutable('now')) + ->setEndDate($calendar->getStartDate()->add(new DateInterval('PT30M'))) + ->setSendSMS(true); + + $user->setUsername(uniqid()); + $user->setEmail(uniqid() . '@gmail.com'); + $calendar->getPersons()->first()->setAcceptSMS(true); + + // hack to prevent side effect with messages + $calendar->preventEnqueueChanges = true; + + $em->persist($user); + //$this->toDelete[] = [User::class, $user->getId()]; + $em->persist($calendar); + //$this->toDelete[] = [Calendar::class, $calendar->getId()]; + $em->flush(); + + $provider = $this->prophesize(CalendarForShortMessageProvider::class); + $provider->getCalendars(Argument::type(DateTimeImmutable::class)) + ->willReturn(new ArrayIterator([$calendar])); + + $messageBuilder = $this->prophesize(ShortMessageForCalendarBuilderInterface::class); + $messageBuilder->buildMessageForCalendar(Argument::type(Calendar::class)) + ->willReturn( + [ + new ShortMessage( + 'content', + PhoneNumberUtil::getInstance()->parse('+32470123456', 'BE'), + ShortMessage::PRIORITY_MEDIUM + ), + ] + ); + + $bus = $this->prophesize(MessageBusInterface::class); + $bus->dispatch(Argument::type(ShortMessage::class)) + ->willReturn(new Envelope(new stdClass())) + ->shouldBeCalledTimes(1); + + $bulk = new BulkCalendarShortMessageSender( + $provider->reveal(), + $em, + new NullLogger(), + $bus->reveal(), + $messageBuilder->reveal() + ); + + $bulk->sendBulkMessageToEligibleCalendars(); + + $em->clear(); + $calendar = $em->find(Calendar::class, $calendar->getId()); + + $this->assertEquals(Calendar::SMS_SENT, $calendar->getSmsStatus()); + } +} diff --git a/src/Bundle/ChillCalendarBundle/Tests/Service/ShortMessageNotification/CalendarForShortMessageProviderTest.php b/src/Bundle/ChillCalendarBundle/Tests/Service/ShortMessageNotification/CalendarForShortMessageProviderTest.php new file mode 100644 index 000000000..f3e35ef93 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Tests/Service/ShortMessageNotification/CalendarForShortMessageProviderTest.php @@ -0,0 +1,137 @@ +prophesize(CalendarRepository::class); + $calendarRepository->findByNotificationAvailable( + Argument::type(DateTimeImmutable::class), + Argument::type(DateTimeImmutable::class), + Argument::type('int'), + Argument::exact(0) + )->shouldBeCalledTimes(0); + $rangeGenerator = $this->prophesize(RangeGeneratorInterface::class); + $rangeGenerator->generateRange(Argument::type(DateTimeImmutable::class))->willReturn(null); + + $em = $this->prophesize(EntityManagerInterface::class); + $em->clear()->shouldNotBeCalled(); + + $provider = new CalendarForShortMessageProvider( + $calendarRepository->reveal(), + $em->reveal(), + $rangeGenerator->reveal() + ); + + $calendars = iterator_to_array($provider->getCalendars(new DateTimeImmutable('now'))); + + $this->assertEquals(0, count($calendars)); + } + + public function testGetCalendars() + { + $calendarRepository = $this->prophesize(CalendarRepository::class); + $calendarRepository->findByNotificationAvailable( + Argument::type(DateTimeImmutable::class), + Argument::type(DateTimeImmutable::class), + Argument::type('int'), + Argument::exact(0) + )->will(static function ($args) { + return array_fill(0, $args[2], new Calendar()); + })->shouldBeCalledTimes(1); + $calendarRepository->findByNotificationAvailable( + Argument::type(DateTimeImmutable::class), + Argument::type(DateTimeImmutable::class), + Argument::type('int'), + Argument::not(0) + )->will(static function ($args) { + return array_fill(0, $args[2] - 1, new Calendar()); + })->shouldBeCalledTimes(1); + + $em = $this->prophesize(EntityManagerInterface::class); + $em->clear()->shouldBeCalled(); + + $provider = new CalendarForShortMessageProvider( + $calendarRepository->reveal(), + $em->reveal(), + new DefaultRangeGenerator() + ); + + $calendars = iterator_to_array($provider->getCalendars(new DateTimeImmutable('now'))); + + $this->assertGreaterThan(1, count($calendars)); + $this->assertLessThan(100, count($calendars)); + $this->assertContainsOnly(Calendar::class, $calendars); + } + + public function testGetCalendarsWithOnlyOneCalendar() + { + $calendarRepository = $this->prophesize(CalendarRepository::class); + $calendarRepository->findByNotificationAvailable( + Argument::type(DateTimeImmutable::class), + Argument::type(DateTimeImmutable::class), + Argument::type('int'), + Argument::exact(0) + )->will(static function ($args) { + return array_fill(0, 1, new Calendar()); + })->shouldBeCalledTimes(1); + $calendarRepository->findByNotificationAvailable( + Argument::type(DateTimeImmutable::class), + Argument::type(DateTimeImmutable::class), + Argument::type('int'), + Argument::not(0) + )->will(static function ($args) { + return []; + })->shouldBeCalledTimes(1); + + $em = $this->prophesize(EntityManagerInterface::class); + $em->clear()->shouldBeCalled(); + + $provider = new CalendarForShortMessageProvider( + $calendarRepository->reveal(), + $em->reveal(), + new DefaultRangeGenerator() + ); + + $calendars = iterator_to_array($provider->getCalendars(new DateTimeImmutable('now'))); + + $this->assertEquals(1, count($calendars)); + $this->assertContainsOnly(Calendar::class, $calendars); + } +} diff --git a/src/Bundle/ChillCalendarBundle/Tests/Service/ShortMessageNotification/DefaultRangeGeneratorTest.php b/src/Bundle/ChillCalendarBundle/Tests/Service/ShortMessageNotification/DefaultRangeGeneratorTest.php new file mode 100644 index 000000000..de2b97963 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Tests/Service/ShortMessageNotification/DefaultRangeGeneratorTest.php @@ -0,0 +1,101 @@ + Envoi des rdv du mardi et mercredi. + * * Mardi => Envoi des rdv du jeudi. + * * Mercredi => Envoi des rdv du vendredi + * * Jeudi => envoi des rdv du samedi et dimanche + * * Vendredi => Envoi des rdv du lundi. + */ + public function generateData(): Iterator + { + yield [ + new DateTimeImmutable('2022-06-13 10:45:00'), + new DateTimeImmutable('2022-06-14 00:00:00'), + new DateTimeImmutable('2022-06-16 00:00:00'), + ]; + + yield [ + new DateTimeImmutable('2022-06-14 15:45:00'), + new DateTimeImmutable('2022-06-16 00:00:00'), + new DateTimeImmutable('2022-06-17 00:00:00'), + ]; + + yield [ + new DateTimeImmutable('2022-06-15 13:45:18'), + new DateTimeImmutable('2022-06-17 00:00:00'), + new DateTimeImmutable('2022-06-18 00:00:00'), + ]; + + yield [ + new DateTimeImmutable('2022-06-16 01:30:55'), + new DateTimeImmutable('2022-06-18 00:00:00'), + new DateTimeImmutable('2022-06-20 00:00:00'), + ]; + + yield [ + new DateTimeImmutable('2022-06-17 21:30:55'), + new DateTimeImmutable('2022-06-20 00:00:00'), + new DateTimeImmutable('2022-06-21 00:00:00'), + ]; + + yield [ + new DateTimeImmutable('2022-06-18 21:30:55'), + null, + null, + ]; + + yield [ + new DateTimeImmutable('2022-06-19 21:30:55'), + null, + null, + ]; + } + + /** + * @dataProvider generateData + */ + public function testGenerateRange(DateTimeImmutable $date, ?DateTimeImmutable $startDate, ?DateTimeImmutable $endDate) + { + $generator = new DefaultRangeGenerator(); + + ['startDate' => $actualStartDate, 'endDate' => $actualEndDate] = $generator->generateRange($date); + + if (null === $startDate) { + $this->assertNull($actualStartDate); + $this->assertNull($actualEndDate); + } else { + $this->assertEquals($startDate->format(DateTimeImmutable::ATOM), $actualStartDate->format(DateTimeImmutable::ATOM)); + $this->assertEquals($endDate->format(DateTimeImmutable::ATOM), $actualEndDate->format(DateTimeImmutable::ATOM)); + } + } +} diff --git a/src/Bundle/ChillCalendarBundle/Tests/Service/ShortMessageNotification/DefaultShortMessageForCalendarBuilderTest.php b/src/Bundle/ChillCalendarBundle/Tests/Service/ShortMessageNotification/DefaultShortMessageForCalendarBuilderTest.php new file mode 100644 index 000000000..6d42540cc --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Tests/Service/ShortMessageNotification/DefaultShortMessageForCalendarBuilderTest.php @@ -0,0 +1,115 @@ +phoneNumberUtil = PhoneNumberUtil::getInstance(); + } + + public function testBuildMessageForCalendar() + { + $calendar = new Calendar(); + $calendar + ->setStartDate(new DateTimeImmutable('now')) + ->setEndDate($calendar->getStartDate()->add(new DateInterval('PT30M'))) + ->setMainUser($user = new User()) + ->addPerson($person = new Person()) + ->setSendSMS(false); + $user + ->setLabel('Alex') + ->setMainLocation($location = new Location()); + $location->setName('LOCAMAT'); + $person + ->setMobilenumber($this->phoneNumberUtil->parse('+32470123456', 'BE')) + ->setAcceptSMS(false); + + $engine = $this->prophesize(EngineInterface::class); + $engine->render(Argument::exact('@ChillCalendar/CalendarShortMessage/short_message.txt.twig'), Argument::withKey('calendar')) + ->willReturn('message content') + ->shouldBeCalledTimes(1); + $engine->render(Argument::exact('@ChillCalendar/CalendarShortMessage/short_message_canceled.txt.twig'), Argument::withKey('calendar')) + ->willReturn('message canceled') + ->shouldBeCalledTimes(1); + + $builder = new DefaultShortMessageForCalendarBuilder( + $engine->reveal() + ); + + // if the calendar should not send sms + $sms = $builder->buildMessageForCalendar($calendar); + $this->assertCount(0, $sms); + + // if the person do not accept sms + $calendar->setSendSMS(true); + $sms = $builder->buildMessageForCalendar($calendar); + $this->assertCount(0, $sms); + + // person accepts sms + $person->setAcceptSMS(true); + $sms = $builder->buildMessageForCalendar($calendar); + + $this->assertCount(1, $sms); + $this->assertEquals( + '+32470123456', + $this->phoneNumberUtil->format($sms[0]->getPhoneNumber(), PhoneNumberFormat::E164) + ); + $this->assertEquals('message content', $sms[0]->getContent()); + $this->assertEquals('low', $sms[0]->getPriority()); + + // if the calendar is canceled + $calendar + ->setSmsStatus(Calendar::SMS_SENT) + ->setStatus(Calendar::STATUS_CANCELED); + + $sms = $builder->buildMessageForCalendar($calendar); + + $this->assertCount(1, $sms); + $this->assertEquals( + '+32470123456', + $this->phoneNumberUtil->format($sms[0]->getPhoneNumber(), PhoneNumberFormat::E164) + ); + $this->assertEquals('message canceled', $sms[0]->getContent()); + $this->assertEquals('low', $sms[0]->getPriority()); + } +} diff --git a/src/Bundle/ChillCalendarBundle/chill.api.specs.yaml b/src/Bundle/ChillCalendarBundle/chill.api.specs.yaml index ebe0de88d..8d8df2568 100644 --- a/src/Bundle/ChillCalendarBundle/chill.api.specs.yaml +++ b/src/Bundle/ChillCalendarBundle/chill.api.specs.yaml @@ -1,181 +1,276 @@ --- -openapi: "3.0.0" -info: - version: "1.0.0" - title: "Chill api" - description: "Api documentation for chill. Currently, work in progress" -servers: - - url: "/api" - description: "Your current dev server" +#openapi: "3.0.0" +#info: +# version: "1.0.0" +# title: "Chill api" +# description: "Api documentation for chill. Currently, work in progress" +#servers: +# - url: "/api" +# description: "Your current dev server" components: - schemas: - Date: - type: object - properties: - datetime: - type: string - format: date-time - User: - type: object - properties: - id: - type: integer - type: - type: string - enum: - - user - username: - type: string - text: - type: string + schemas: + Date: + type: object + properties: + datetime: + type: string + format: date-time + User: + type: object + properties: + id: + type: integer + type: + type: string + enum: + - user + username: + type: string + text: + type: string paths: - /1.0/calendar/calendar.json: - get: - tags: - - calendar - summary: Return a list of all calendar items - responses: - 200: - description: "ok" + /1.0/calendar/calendar/{id}/answer/{answer}.json: + post: + tags: + - calendar + summary: Answer to a calendar's invite + parameters: + - + in: path + name: id + required: true + description: the calendar id + schema: + type: integer + format: integer + minimum: 0 + - + in: path + name: answer + required: true + description: the answer + schema: + type: string + enum: + - accepted + - declined + - tentative + responses: + 400: + description: bad answer + 403: + description: not invited + 404: + description: not found + 202: + description: accepted - /1.0/calendar/calendar/{id}.json: - get: - tags: - - calendar - summary: Return an calendar item by id - parameters: - - name: id - in: path - required: true - description: The calendar id - schema: - type: integer - format: integer - minimum: 1 - responses: - 200: - description: "ok" - 404: - description: "not found" - 401: - description: "Unauthorized" + /1.0/calendar/calendar.json: + get: + tags: + - calendar + summary: Return a list of all calendar items + responses: + 200: + description: "ok" - /1.0/calendar/calendar-range.json: - get: - tags: - - calendar - summary: Return a list of all calendar range items - responses: - 200: - description: "ok" - post: - tags: - - calendar - summary: create a new calendar range - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - user: - $ref: '#/components/schemas/User' - startDate: - $ref: '#/components/schemas/Date' - endDate: - $ref: '#/components/schemas/Date' - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" - 422: - description: "Unprocessable entity (validation errors)" - 400: - description: "transition cannot be applyed" + /1.0/calendar/calendar/{id}.json: + get: + tags: + - calendar + summary: Return an calendar item by id + parameters: + - name: id + in: path + required: true + description: The calendar id + schema: + type: integer + format: integer + minimum: 1 + responses: + 200: + description: "ok" + 404: + description: "not found" + 403: + description: "Unauthorized" - /1.0/calendar/calendar-range/{id}.json: - get: - tags: - - calendar - summary: Return an calendar-range item by id - parameters: - - name: id - in: path - required: true - description: The calendar-range id - schema: - type: integer - format: integer - minimum: 1 - responses: - 200: - description: "ok" - 404: - description: "not found" - 401: - description: "Unauthorized" - patch: - tags: - - calendar - summary: update a calendar range - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - user: - $ref: '#/components/schemas/User' - startDate: - $ref: '#/components/schemas/Date' - endDate: - $ref: '#/components/schemas/Date' - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" - 422: - description: "Unprocessable entity (validation errors)" - 400: - description: "transition cannot be applyed" - delete: - tags: - - calendar - summary: "Remove a calendar range" - parameters: - - name: id - in: path - required: true - description: The calendar range id - schema: - type: integer - format: integer - minimum: 1 - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" - 422: - description: "object with validation errors" - - /1.0/calendar/calendar-range-available.json: - get: - tags: - - calendar - summary: Return a list of available calendar range items. Available means calendar-range not being taken by a calendar entity - responses: - 200: - description: "ok" \ No newline at end of file + /1.0/calendar/calendar/by-user/{userId}.json: + get: + tags: + - calendar + summary: Return a list of calendars for a user + parameters: + - name: userId + in: path + required: true + description: The user id + schema: + type: integer + format: integer + minimum: 1 + - name: dateFrom + in: query + required: true + description: The date from, formatted as ISO8601 string + schema: + type: string + format: date-time + - name: dateTo + in: query + required: true + description: The date to, formatted as ISO8601 string + schema: + type: string + format: date-time + responses: + 200: + description: "ok" + 404: + description: "not found" + 403: + description: "Unauthorized" + + /1.0/calendar/calendar-range.json: + get: + tags: + - calendar + summary: Return a list of all calendar range items + responses: + 200: + description: "ok" + post: + tags: + - calendar + summary: create a new calendar range + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + user: + $ref: '#/components/schemas/User' + startDate: + $ref: '#/components/schemas/Date' + endDate: + $ref: '#/components/schemas/Date' + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" + 422: + description: "Unprocessable entity (validation errors)" + 400: + description: "transition cannot be applyed" + + /1.0/calendar/calendar-range/{id}.json: + get: + tags: + - calendar + summary: Return an calendar-range item by id + parameters: + - name: id + in: path + required: true + description: The calendar-range id + schema: + type: integer + format: integer + minimum: 1 + responses: + 200: + description: "ok" + 404: + description: "not found" + 401: + description: "Unauthorized" + patch: + tags: + - calendar + summary: update a calendar range + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + user: + $ref: '#/components/schemas/User' + startDate: + $ref: '#/components/schemas/Date' + endDate: + $ref: '#/components/schemas/Date' + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" + 422: + description: "Unprocessable entity (validation errors)" + 400: + description: "transition cannot be applyed" + delete: + tags: + - calendar + summary: "Remove a calendar range" + parameters: + - name: id + in: path + required: true + description: The calendar range id + schema: + type: integer + format: integer + minimum: 1 + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" + 422: + description: "object with validation errors" + + /1.0/calendar/calendar-range-available/{userId}.json: + get: + tags: + - calendar + summary: Return a list of available calendar range items. Available means calendar-range not being taken by a calendar entity + parameters: + - name: userId + in: path + required: true + description: The user id + schema: + type: integer + format: integer + minimum: 1 + - name: dateFrom + in: query + required: true + description: The date from, formatted as ISO8601 string + schema: + type: string + format: date-time + - name: dateTo + in: query + required: true + description: The date to, formatted as ISO8601 string + schema: + type: string + format: date-time + responses: + 200: + description: "ok" diff --git a/src/Bundle/ChillCalendarBundle/chill.webpack.config.js b/src/Bundle/ChillCalendarBundle/chill.webpack.config.js index f91b30268..e82210087 100644 --- a/src/Bundle/ChillCalendarBundle/chill.webpack.config.js +++ b/src/Bundle/ChillCalendarBundle/chill.webpack.config.js @@ -6,6 +6,7 @@ module.exports = function(encore, entries) { }); encore.addEntry('vue_calendar', __dirname + '/Resources/public/vuejs/Calendar/index.js'); - encore.addEntry('vue_mycalendarrange', __dirname + '/Resources/public/vuejs/MyCalendarRange/index.js'); + encore.addEntry('vue_mycalendarrange', __dirname + '/Resources/public/vuejs/MyCalendarRange/index2.ts'); encore.addEntry('page_calendar', __dirname + '/Resources/public/chill/index.js'); + encore.addEntry('mod_answer', __dirname + '/Resources/public/module/Invite/answer.js'); }; diff --git a/src/Bundle/ChillCalendarBundle/migrations/Version20220510155609.php b/src/Bundle/ChillCalendarBundle/migrations/Version20220510155609.php new file mode 100644 index 000000000..e023389ca --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/migrations/Version20220510155609.php @@ -0,0 +1,62 @@ +addSql('ALTER TABLE chill_calendar.calendar_range DROP CONSTRAINT FK_38D57D0565FF1AEC'); + $this->addSql('ALTER TABLE chill_calendar.calendar_range DROP CONSTRAINT FK_38D57D053174800F'); + $this->addSql('DROP INDEX chill_calendar.IDX_38D57D0565FF1AEC'); + $this->addSql('DROP INDEX chill_calendar.IDX_38D57D053174800F'); + $this->addSql('DROP INDEX chill_calendar.idx_calendar_range_remote'); + $this->addSql('ALTER TABLE chill_calendar.calendar_range DROP remoteId'); + $this->addSql('ALTER TABLE chill_calendar.calendar_range DROP remoteAttributes'); + $this->addSql('ALTER TABLE chill_calendar.calendar_range DROP updatedAt'); + $this->addSql('ALTER TABLE chill_calendar.calendar_range DROP createdAt'); + $this->addSql('ALTER TABLE chill_calendar.calendar_range DROP updatedBy_id'); + $this->addSql('ALTER TABLE chill_calendar.calendar_range DROP createdBy_id'); + } + + public function getDescription(): string + { + return 'Add columns on calendar range to handle remote'; + } + + public function up(Schema $schema): void + { + $this->addSql('ALTER TABLE chill_calendar.calendar_range ADD remoteId TEXT DEFAULT \'\' NOT NULL'); + $this->addSql('ALTER TABLE chill_calendar.calendar_range ADD remoteAttributes JSON DEFAULT \'[]\' NOT NULL'); + $this->addSql('ALTER TABLE chill_calendar.calendar_range ADD updatedAt TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL'); + $this->addSql('ALTER TABLE chill_calendar.calendar_range ADD createdAt TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL'); + $this->addSql('ALTER TABLE chill_calendar.calendar_range ADD updatedBy_id INT DEFAULT NULL'); + $this->addSql('ALTER TABLE chill_calendar.calendar_range ADD createdBy_id INT DEFAULT NULL'); + $this->addSql('COMMENT ON COLUMN chill_calendar.calendar_range.updatedAt IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('COMMENT ON COLUMN chill_calendar.calendar_range.createdAt IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('ALTER TABLE chill_calendar.calendar_range ADD CONSTRAINT FK_38D57D0565FF1AEC FOREIGN KEY (updatedBy_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE chill_calendar.calendar_range ADD CONSTRAINT FK_38D57D053174800F FOREIGN KEY (createdBy_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('CREATE INDEX IDX_38D57D0565FF1AEC ON chill_calendar.calendar_range (updatedBy_id)'); + $this->addSql('CREATE INDEX IDX_38D57D053174800F ON chill_calendar.calendar_range (createdBy_id)'); + $this->addSql('CREATE INDEX idx_calendar_range_remote ON chill_calendar.calendar_range (remoteId)'); + } +} diff --git a/src/Bundle/ChillCalendarBundle/migrations/Version20220511134619.php b/src/Bundle/ChillCalendarBundle/migrations/Version20220511134619.php new file mode 100644 index 000000000..b82d1b442 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/migrations/Version20220511134619.php @@ -0,0 +1,94 @@ +addSql('CREATE TABLE chill_calendar.calendar_to_invites (calendar_id INT NOT NULL, invite_id INT NOT NULL, PRIMARY KEY(calendar_id, invite_id))'); + $this->addSql('CREATE INDEX idx_fcbeaaaea417747 ON chill_calendar.calendar_to_invites (invite_id)'); + $this->addSql('CREATE INDEX idx_fcbeaaaa40a2c8 ON chill_calendar.calendar_to_invites (calendar_id)'); + $this->addSql('ALTER TABLE chill_calendar.calendar_to_invites ADD CONSTRAINT fk_fcbeaaaa40a2c8 FOREIGN KEY (calendar_id) REFERENCES chill_calendar.calendar (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE chill_calendar.calendar_to_invites ADD CONSTRAINT fk_fcbeaaaea417747 FOREIGN KEY (invite_id) REFERENCES chill_calendar.invite (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE chill_calendar.invite DROP CONSTRAINT FK_F517FFA7A40A2C8'); + $this->addSql('ALTER TABLE chill_calendar.invite DROP CONSTRAINT FK_F517FFA73174800F'); + $this->addSql('ALTER TABLE chill_calendar.invite DROP CONSTRAINT FK_F517FFA765FF1AEC'); + $this->addSql('DROP INDEX chill_calendar.IDX_F517FFA7A40A2C8'); + $this->addSql('DROP INDEX chill_calendar.IDX_F517FFA73174800F'); + $this->addSql('DROP INDEX chill_calendar.IDX_F517FFA765FF1AEC'); + $this->addSql('ALTER TABLE chill_calendar.invite DROP calendar_id'); + $this->addSql('ALTER TABLE chill_calendar.invite DROP createdAt'); + $this->addSql('ALTER TABLE chill_calendar.invite DROP updatedAt'); + $this->addSql('ALTER TABLE chill_calendar.invite DROP createdBy_id'); + $this->addSql('ALTER TABLE chill_calendar.invite DROP updatedBy_id'); + $this->addSql('ALTER TABLE chill_calendar.invite ALTER user_id DROP NOT NULL'); + $this->addSql('ALTER TABLE chill_calendar.invite DROP COLUMN status'); + $this->addSql('ALTER TABLE chill_calendar.invite ADD COLUMN status JSON'); + $this->addSql('ALTER TABLE chill_calendar.invite ALTER status DROP DEFAULT'); + $this->addSql('ALTER TABLE chill_calendar.calendar DROP CONSTRAINT FK_712315AC3174800F'); + $this->addSql('ALTER TABLE chill_calendar.calendar DROP CONSTRAINT FK_712315AC65FF1AEC'); + $this->addSql('DROP INDEX chill_calendar.IDX_712315AC3174800F'); + $this->addSql('DROP INDEX chill_calendar.IDX_712315AC65FF1AEC'); + $this->addSql('ALTER TABLE chill_calendar.calendar DROP createdAt'); + $this->addSql('ALTER TABLE chill_calendar.calendar DROP updatedAt'); + $this->addSql('ALTER TABLE chill_calendar.calendar DROP createdBy_id'); + $this->addSql('ALTER TABLE chill_calendar.calendar DROP updatedBy_id'); + } + + public function getDescription(): string + { + return 'Prepare schema for handling calendar invites'; + } + + public function up(Schema $schema): void + { + $this->addSql('DROP TABLE chill_calendar.calendar_to_invites'); + $this->addSql('ALTER TABLE chill_calendar.calendar ADD createdAt TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL'); + $this->addSql('ALTER TABLE chill_calendar.calendar ADD updatedAt TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL'); + $this->addSql('ALTER TABLE chill_calendar.calendar ADD createdBy_id INT DEFAULT NULL'); + $this->addSql('ALTER TABLE chill_calendar.calendar ADD updatedBy_id INT DEFAULT NULL'); + $this->addSql('COMMENT ON COLUMN chill_calendar.calendar.createdAt IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('COMMENT ON COLUMN chill_calendar.calendar.updatedAt IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('ALTER TABLE chill_calendar.calendar ADD CONSTRAINT FK_712315AC3174800F FOREIGN KEY (createdBy_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE chill_calendar.calendar ADD CONSTRAINT FK_712315AC65FF1AEC FOREIGN KEY (updatedBy_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('CREATE INDEX IDX_712315AC3174800F ON chill_calendar.calendar (createdBy_id)'); + $this->addSql('CREATE INDEX IDX_712315AC65FF1AEC ON chill_calendar.calendar (updatedBy_id)'); + $this->addSql('ALTER TABLE chill_calendar.invite ADD calendar_id INT DEFAULT NULL'); + $this->addSql('ALTER TABLE chill_calendar.invite ADD createdAt TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL'); + $this->addSql('ALTER TABLE chill_calendar.invite ADD updatedAt TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL'); + $this->addSql('ALTER TABLE chill_calendar.invite ADD createdBy_id INT DEFAULT NULL'); + $this->addSql('ALTER TABLE chill_calendar.invite ADD updatedBy_id INT DEFAULT NULL'); + $this->addSql('DELETE FROM chill_calendar.invite WHERE user_id IS NULL'); + $this->addSql('ALTER TABLE chill_calendar.invite ALTER user_id SET NOT NULL'); + $this->addSql('ALTER TABLE chill_calendar.invite DROP COLUMN status'); + $this->addSql('ALTER TABLE chill_calendar.invite ADD COLUMN status TEXT DEFAULT \'pending\''); + $this->addSql('COMMENT ON COLUMN chill_calendar.invite.createdAt IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('COMMENT ON COLUMN chill_calendar.invite.updatedAt IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('ALTER TABLE chill_calendar.invite ADD CONSTRAINT FK_F517FFA7A40A2C8 FOREIGN KEY (calendar_id) REFERENCES chill_calendar.calendar (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE chill_calendar.invite ADD CONSTRAINT FK_F517FFA73174800F FOREIGN KEY (createdBy_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE chill_calendar.invite ADD CONSTRAINT FK_F517FFA765FF1AEC FOREIGN KEY (updatedBy_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('CREATE INDEX IDX_F517FFA7A40A2C8 ON chill_calendar.invite (calendar_id)'); + $this->addSql('CREATE INDEX IDX_F517FFA73174800F ON chill_calendar.invite (createdBy_id)'); + $this->addSql('CREATE INDEX IDX_F517FFA765FF1AEC ON chill_calendar.invite (updatedBy_id)'); + } +} diff --git a/src/Bundle/ChillCalendarBundle/migrations/Version20220525080633.php b/src/Bundle/ChillCalendarBundle/migrations/Version20220525080633.php new file mode 100644 index 000000000..344d3a2e6 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/migrations/Version20220525080633.php @@ -0,0 +1,59 @@ +addSql('ALTER TABLE chill_calendar.invite ALTER status DROP NOT NULL'); + $this->addSql('DROP INDEX chill_calendar.UNIQ_712315ACC5CB285D'); + $this->addSql('DROP INDEX chill_calendar.idx_calendar_remote'); + $this->addSql('ALTER TABLE chill_calendar.calendar ADD user_id INT DEFAULT NULL'); + $this->addSql('ALTER TABLE chill_calendar.calendar DROP remoteAttributes'); + $this->addSql('ALTER TABLE chill_calendar.calendar DROP remoteId'); + $this->addSql('ALTER TABLE chill_calendar.calendar ALTER status DROP DEFAULT'); + $this->addSql('ALTER TABLE chill_calendar.calendar ADD CONSTRAINT fk_712315aca76ed395 FOREIGN KEY (user_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('CREATE INDEX idx_712315acc5cb285d ON chill_calendar.calendar (calendarrange_id)'); + $this->addSql('CREATE INDEX idx_712315aca76ed395 ON chill_calendar.calendar (user_id)'); + } + + public function getDescription(): string + { + return 'Calendar: add remote infos and fix associations'; + } + + public function up(Schema $schema): void + { + $this->addSql('ALTER TABLE chill_calendar.calendar DROP CONSTRAINT fk_712315aca76ed395'); + $this->addSql('DROP INDEX chill_calendar.idx_712315acc5cb285d'); + $this->addSql('DROP INDEX chill_calendar.idx_712315aca76ed395'); + $this->addSql('ALTER TABLE chill_calendar.calendar ADD remoteAttributes JSON DEFAULT \'[]\' NOT NULL'); + $this->addSql('ALTER TABLE chill_calendar.calendar ADD remoteId TEXT DEFAULT \'\' NOT NULL'); + $this->addSql('ALTER TABLE chill_calendar.calendar DROP user_id'); + $this->addSql('ALTER TABLE chill_calendar.calendar ALTER status SET DEFAULT \'valid\''); + $this->addSql('CREATE UNIQUE INDEX UNIQ_712315ACC5CB285D ON chill_calendar.calendar (calendarRange_id)'); + $this->addSql('CREATE INDEX idx_calendar_remote ON chill_calendar.calendar (remoteId)'); + $this->addSql('UPDATE chill_calendar.invite SET status=\'pending\' WHERE status IS NULL'); + $this->addSql('ALTER TABLE chill_calendar.invite ALTER status SET NOT NULL'); + } +} diff --git a/src/Bundle/ChillCalendarBundle/migrations/Version20220606153851.php b/src/Bundle/ChillCalendarBundle/migrations/Version20220606153851.php new file mode 100644 index 000000000..8b6974670 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/migrations/Version20220606153851.php @@ -0,0 +1,50 @@ +addSql('ALTER TABLE chill_calendar.calendar ALTER endDate TYPE TIMESTAMP(0) WITH TIME ZONE'); + $this->addSql('ALTER TABLE chill_calendar.calendar ALTER endDate DROP DEFAULT'); + $this->addSql('ALTER TABLE chill_calendar.calendar ALTER startDate TYPE TIMESTAMP(0) WITH TIME ZONE'); + $this->addSql('ALTER TABLE chill_calendar.calendar ALTER startDate DROP DEFAULT'); + $this->addSql('COMMENT ON COLUMN chill_calendar.calendar.enddate IS \'(DC2Type:datetimetz_immutable)\''); + $this->addSql('COMMENT ON COLUMN chill_calendar.calendar.startdate IS \'(DC2Type:datetimetz_immutable)\''); + } + + public function getDescription(): string + { + return 'remove timezone from dates in calendar entity'; + } + + public function up(Schema $schema): void + { + $this->addSql('ALTER TABLE chill_calendar.calendar ALTER startdate TYPE TIMESTAMP(0) WITHOUT TIME ZONE'); + $this->addSql('ALTER TABLE chill_calendar.calendar ALTER startdate DROP DEFAULT'); + $this->addSql('ALTER TABLE chill_calendar.calendar ALTER enddate TYPE TIMESTAMP(0) WITHOUT TIME ZONE'); + $this->addSql('ALTER TABLE chill_calendar.calendar ALTER enddate DROP DEFAULT'); + $this->addSql('COMMENT ON COLUMN chill_calendar.calendar.startDate IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('COMMENT ON COLUMN chill_calendar.calendar.endDate IS \'(DC2Type:datetime_immutable)\''); + } +} diff --git a/src/Bundle/ChillCalendarBundle/migrations/Version20220606154119.php b/src/Bundle/ChillCalendarBundle/migrations/Version20220606154119.php new file mode 100644 index 000000000..eec87a628 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/migrations/Version20220606154119.php @@ -0,0 +1,50 @@ +addSql('ALTER TABLE chill_calendar.calendar_range ALTER endDate TYPE TIMESTAMP(0) WITH TIME ZONE'); + $this->addSql('ALTER TABLE chill_calendar.calendar_range ALTER endDate DROP DEFAULT'); + $this->addSql('ALTER TABLE chill_calendar.calendar_range ALTER startDate TYPE TIMESTAMP(0) WITH TIME ZONE'); + $this->addSql('ALTER TABLE chill_calendar.calendar_range ALTER startDate DROP DEFAULT'); + $this->addSql('COMMENT ON COLUMN chill_calendar.calendar_range.enddate IS \'(DC2Type:datetimetz_immutable)\''); + $this->addSql('COMMENT ON COLUMN chill_calendar.calendar_range.startdate IS \'(DC2Type:datetimetz_immutable)\''); + } + + public function getDescription(): string + { + return 'remove timezone from calendar range'; + } + + public function up(Schema $schema): void + { + $this->addSql('ALTER TABLE chill_calendar.calendar_range ALTER startdate TYPE TIMESTAMP(0) WITHOUT TIME ZONE'); + $this->addSql('ALTER TABLE chill_calendar.calendar_range ALTER startdate DROP DEFAULT'); + $this->addSql('ALTER TABLE chill_calendar.calendar_range ALTER enddate TYPE TIMESTAMP(0) WITHOUT TIME ZONE'); + $this->addSql('ALTER TABLE chill_calendar.calendar_range ALTER enddate DROP DEFAULT'); + $this->addSql('COMMENT ON COLUMN chill_calendar.calendar_range.startDate IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('COMMENT ON COLUMN chill_calendar.calendar_range.endDate IS \'(DC2Type:datetime_immutable)\''); + } +} diff --git a/src/Bundle/ChillCalendarBundle/migrations/Version20220608084052.php b/src/Bundle/ChillCalendarBundle/migrations/Version20220608084052.php new file mode 100644 index 000000000..8fa727533 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/migrations/Version20220608084052.php @@ -0,0 +1,43 @@ +addSql('ALTER TABLE chill_calendar.invite DROP remoteAttributes'); + $this->addSql('ALTER TABLE chill_calendar.invite DROP remoteId'); + } + + public function getDescription(): string + { + return 'Add remoteId for invitation'; + } + + public function up(Schema $schema): void + { + $this->addSql('ALTER TABLE chill_calendar.invite ADD remoteAttributes JSON DEFAULT \'[]\' NOT NULL'); + $this->addSql('ALTER TABLE chill_calendar.invite ADD remoteId TEXT DEFAULT \'\' NOT NULL'); + $this->addSql('CREATE INDEX idx_calendar_invite_remote ON chill_calendar.invite (remoteId)'); + } +} diff --git a/src/Bundle/ChillCalendarBundle/migrations/Version20220609200857.php b/src/Bundle/ChillCalendarBundle/migrations/Version20220609200857.php new file mode 100644 index 000000000..75e3fa61c --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/migrations/Version20220609200857.php @@ -0,0 +1,50 @@ +addSql('DROP INDEX chill_calendar.idx_calendar_range_remote'); + $this->addSql('CREATE INDEX idx_calendar_range_remote ON chill_calendar.calendar_range (remoteId)'); + $this->addSql('DROP INDEX chill_calendar.idx_calendar_remote'); + $this->addSql('CREATE INDEX idx_calendar_remote ON chill_calendar.calendar (remoteId)'); + $this->addSql('DROP INDEX chill_calendar.idx_calendar_invite_remote'); + $this->addSql('CREATE INDEX idx_calendar_invite_remote ON chill_calendar.invite (remoteId)'); + } + + public function getDescription(): string + { + return 'Set an unique contraint on remoteId on calendar object which are synced to a remote'; + } + + public function up(Schema $schema): void + { + $this->addSql('DROP INDEX chill_calendar.idx_calendar_range_remote'); + $this->addSql('CREATE UNIQUE INDEX idx_calendar_range_remote ON chill_calendar.calendar_range (remoteId) WHERE remoteId <> \'\''); + $this->addSql('DROP INDEX chill_calendar.idx_calendar_remote'); + $this->addSql('CREATE UNIQUE INDEX idx_calendar_remote ON chill_calendar.calendar (remoteId) WHERE remoteId <> \'\''); + $this->addSql('DROP INDEX chill_calendar.idx_calendar_invite_remote'); + $this->addSql('CREATE UNIQUE INDEX idx_calendar_invite_remote ON chill_calendar.invite (remoteId) WHERE remoteId <> \'\''); + } +} diff --git a/src/Bundle/ChillCalendarBundle/migrations/Version20220613202636.php b/src/Bundle/ChillCalendarBundle/migrations/Version20220613202636.php new file mode 100644 index 000000000..82eda3800 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/migrations/Version20220613202636.php @@ -0,0 +1,40 @@ +addSql('ALTER TABLE chill_calendar.calendar DROP smsStatus'); + } + + public function getDescription(): string + { + return 'Add sms status on calendars'; + } + + public function up(Schema $schema): void + { + $this->addSql('ALTER TABLE chill_calendar.calendar ADD smsStatus TEXT DEFAULT \'sms_pending\' NOT NULL'); + } +} diff --git a/src/Bundle/ChillCalendarBundle/migrations/Version20220629095515.php b/src/Bundle/ChillCalendarBundle/migrations/Version20220629095515.php new file mode 100644 index 000000000..7179bb79a --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/migrations/Version20220629095515.php @@ -0,0 +1,44 @@ +addSql('alter table chill_calendar.calendar_range DROP COLUMN location_id'); + } + + public function getDescription(): string + { + return 'Add location on calendar range'; + } + + public function up(Schema $schema): void + { + $this->addSql('ALTER TABLE chill_calendar.calendar_range ADD location_id INT DEFAULT NULL'); + $this->addSql('UPDATE chill_calendar.calendar_range SET location_id = n.min FROM (SELECT min(id) FROM public.chill_main_location) AS n'); + $this->addSql('ALTER TABLE chill_calendar.calendar_range ALTER COLUMN location_id SET NOT NULL'); + $this->addSql('ALTER TABLE chill_calendar.calendar_range ADD CONSTRAINT FK_38D57D0564D218E FOREIGN KEY (location_id) REFERENCES chill_main_location (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('CREATE INDEX IDX_38D57D0564D218E ON chill_calendar.calendar_range (location_id)'); + } +} diff --git a/src/Bundle/ChillCalendarBundle/migrations/Version20220921101643.php b/src/Bundle/ChillCalendarBundle/migrations/Version20220921101643.php new file mode 100644 index 000000000..b9a3619c2 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/migrations/Version20220921101643.php @@ -0,0 +1,45 @@ +addSql('ALTER TABLE chill_calendar.calendar DROP urgent'); + } + + public function getDescription(): string + { + return 'Add urgent property to calendar entity'; + } + + public function up(Schema $schema): void + { + // this up() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE chill_calendar.calendar ADD urgent BOOLEAN DEFAULT NULL'); + } +} diff --git a/src/Bundle/ChillCalendarBundle/migrations/Version20221020101547.php b/src/Bundle/ChillCalendarBundle/migrations/Version20221020101547.php new file mode 100644 index 000000000..1fbfde60e --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/migrations/Version20221020101547.php @@ -0,0 +1,47 @@ +addSql('DROP SEQUENCE chill_calendar.calendar_doc_id_seq CASCADE'); + $this->addSql('DROP TABLE chill_calendar.calendar_doc'); + $this->addSql('ALTER TABLE chill_calendar.calendar DROP dateTimeVersion'); + } + + public function getDescription(): string + { + return 'Add calendardoc on Calendar'; + } + + public function up(Schema $schema): void + { + $this->addSql('CREATE SEQUENCE chill_calendar.calendar_doc_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE TABLE chill_calendar.calendar_doc (id INT NOT NULL, calendar_id INT NOT NULL, datetimeVersion INT DEFAULT 0 NOT NULL, trackDateTimeVersion BOOLEAN DEFAULT false NOT NULL, createdAt TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, updatedAt TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, storedObject_id INT NOT NULL, createdBy_id INT DEFAULT NULL, updatedBy_id INT DEFAULT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX IDX_4FD11573A40A2C8 ON chill_calendar.calendar_doc (calendar_id)'); + $this->addSql('CREATE INDEX IDX_4FD115736C99C13A ON chill_calendar.calendar_doc (storedObject_id)'); + $this->addSql('CREATE INDEX IDX_4FD115733174800F ON chill_calendar.calendar_doc (createdBy_id)'); + $this->addSql('CREATE INDEX IDX_4FD1157365FF1AEC ON chill_calendar.calendar_doc (updatedBy_id)'); + $this->addSql('COMMENT ON COLUMN chill_calendar.calendar_doc.createdAt IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('COMMENT ON COLUMN chill_calendar.calendar_doc.updatedAt IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('ALTER TABLE chill_calendar.calendar_doc ADD CONSTRAINT FK_4FD11573A40A2C8 FOREIGN KEY (calendar_id) REFERENCES chill_calendar.calendar (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE chill_calendar.calendar_doc ADD CONSTRAINT FK_4FD115736C99C13A FOREIGN KEY (storedObject_id) REFERENCES chill_doc.stored_object (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE chill_calendar.calendar_doc ADD CONSTRAINT FK_4FD115733174800F FOREIGN KEY (createdBy_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE chill_calendar.calendar_doc ADD CONSTRAINT FK_4FD1157365FF1AEC FOREIGN KEY (updatedBy_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE chill_calendar.calendar ADD dateTimeVersion INT DEFAULT 0 NOT NULL'); + } +} diff --git a/src/Bundle/ChillCalendarBundle/migrations/Version20221021092541.php b/src/Bundle/ChillCalendarBundle/migrations/Version20221021092541.php new file mode 100644 index 000000000..732b975e3 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/migrations/Version20221021092541.php @@ -0,0 +1,35 @@ +addSql('ALTER TABLE chill_calendar.calendar DROP person_id'); + } + + public function getDescription(): string + { + return 'Associate a calendar with a person'; + } + + public function up(Schema $schema): void + { + $this->addSql('ALTER TABLE chill_calendar.calendar ADD person_id INT DEFAULT NULL'); + $this->addSql('ALTER TABLE chill_calendar.calendar ADD CONSTRAINT FK_712315AC217BBB47 FOREIGN KEY (person_id) REFERENCES chill_person_person (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('CREATE INDEX IDX_712315AC217BBB47 ON chill_calendar.calendar (person_id)'); + } +} diff --git a/src/Bundle/ChillCalendarBundle/migrations/Version20221125144205.php b/src/Bundle/ChillCalendarBundle/migrations/Version20221125144205.php new file mode 100644 index 000000000..624fdda00 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/migrations/Version20221125144205.php @@ -0,0 +1,41 @@ +throwIrreversibleMigrationException(); + } + + public function getDescription(): string + { + return 'Calendar: remove association between scope and calendar'; + } + + public function up(Schema $schema): void + { + $this->addSql( + sprintf( + 'UPDATE role_scopes SET scope_id=NULL WHERE role IN (\'%s\', \'%s\', \'%s\', \'%s\')', + 'CHILL_CALENDAR_CALENDAR_CREATE', + 'CHILL_CALENDAR_CALENDAR_DELETE', + 'CHILL_CALENDAR_CALENDAR_EDIT', + 'CHILL_CALENDAR_CALENDAR_SEE' + ) + ); + } +} diff --git a/src/Bundle/ChillCalendarBundle/translations/messages+intl-icu.fr.yml b/src/Bundle/ChillCalendarBundle/translations/messages+intl-icu.fr.yml new file mode 100644 index 000000000..4bbc105b9 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/translations/messages+intl-icu.fr.yml @@ -0,0 +1,8 @@ +chill_calendar: + There are count ignored calendars by date filter: >- + {nbIgnored, plural, + =0 {Il n'y a aucun rendez-vous ignoré par le filtre de date.} + one {Il y a un rendez-vous ignoré par le filtre de date. Modifiez le filtre de date pour le voir apparaitre.} + few {# rendez-vous sont ignorés par le filtre de date. Modifiez le filtre de date pour les voir apparaitre.} + other {# rendez-vous sont ignorés par le filtre de date. Modifiez le filtre de date pour les voir apparaitre.} + } diff --git a/src/Bundle/ChillCalendarBundle/translations/messages.fr.yml b/src/Bundle/ChillCalendarBundle/translations/messages.fr.yml index 11937d2b7..623eb02cb 100644 --- a/src/Bundle/ChillCalendarBundle/translations/messages.fr.yml +++ b/src/Bundle/ChillCalendarBundle/translations/messages.fr.yml @@ -14,7 +14,7 @@ start date: début du rendez-vous end date: fin du rendez-vous cancel reason: motif d'annulation status: Statut du rendez-vous -calendar location: Localistion du rendez-vous +calendar location: Localisation du rendez-vous calendar comment: Remarque sur le rendez-vous sendSMS: Envoi d'un SMS Send s m s: Envoi d'un SMS ? @@ -26,6 +26,9 @@ The calendar item has been successfully removed.: Le rendez-vous a été supprim From the day: Du to the day: au Transform to activity: Transformer en échange +Will send SMS: Un SMS de rappel sera envoyé +Will not send SMS: Aucun SMS de rappel ne sera envoyé +SMS already sent: Un SMS a été envoyé canceledBy: supprimé par Canceled by: supprimé par @@ -39,10 +42,52 @@ crud: title_new: Nouveau motif d'annulation title_edit: Modifier le motif d'annulation +chill_calendar: + form: + The main user is mandatory. He will organize the appointment.: L'utilisateur principal est obligatoire. Il est l'organisateur de l'événement. + Create for referrer: Créer pour le référent + start date filter: Début du rendez-vous + From: Du + To: Au + Next calendars: Prochains rendez-vous + Add a document: Ajouter un document + Documents: Documents + Create and add a document: Créer et ajouter un document + Save and add a document: Enregistrer et ajouter un document + Create for me: Créer un rendez-vous pour moi-même + Edit a document: Modifier un document + Document title: Titre + Document object: Document + Add a document from template: Ajouter un document depuis un gabarit + Upload a document: Téléverser un document + Remove a calendar document: Supprimer un document d'un rendez-vous + Are you sure you want to remove the doc?: Êtes-vous sûr·e de vouloir supprimer le document associé ? + Document outdated: La date et l'heure du rendez-vous ont été modifiés après la création du document + + +remote_ms_graph: + freebusy_statuses: + busy: Occupé + free: Libre + tentative: En attente de confirmation + oof: En dehors du bureau + workingElsewhere: Travaille à l'extérieur + unknown: Inconnu + cancel_event_because_main_user_is_%label%: L'événement est transféré à l'utilisateur %label% + +remote_calendar: + calendar_range_title: Plage de disponibilité Chill + +invite: + accepted: Accepté + declined: Refusé + pending: En attente + tentative: Accepté provisoirement + # exports Exports of calendar: Exports des rendez-vous -Count appointments: Nombre de rendez-vous -Count appointments by various parameters.: Compte le nombre de rendez-vous en fonction de différents paramètres. +Count calendars: Nombre de rendez-vous +Count calendars by various parameters.: Compte le nombre de rendez-vous en fonction de différents paramètres. Average appointment duration: Moyenne de la durée des rendez-vous Get the average of appointment duration according to various filters: Calcule la moyenne des durées des rendez-vous en fonction de différents paramètres. @@ -51,23 +96,52 @@ Sum of appointment durations: Somme de la durée des rendez-vous Get the sum of appointment durations according to various filters: Calcule la somme des durées des rendez-vous en fonction de différents paramètres. 'Filtered by agent: only %agents%': "Filtré par agents: uniquement %agents%" -Filter by agent: Filtrer par agents -Filter by agent job: Filtrer par métiers des agents +Filter calendars by agent: Filtrer les rendez-vous par agents +Filter calendars by agent job: Filtrer les rendez-vous par métiers des agents 'Filtered by agent job: only %jobs%': 'Filtré par métiers des agents: uniquement les %jobs%' -Filter by agent scope: Filtrer par services des agents +Filter calendars by agent scope: Filtrer les rendez-vous par services des agents 'Filtered by agent scope: only %scopes%': 'Filtré par services des agents: uniquement les services %scopes%' -Filter by appointments between certain dates: Filtrer par date du rendez-vous -'Filtered by appointments between %dateFrom% and %dateTo%': 'Filtré par rendez-vous entre %dateFrom% et %dateTo%' +Filter calendars between certain dates: Filtrer les rendez-vous par date du rendez-vous +'Filtered by calendars between %dateFrom% and %dateTo%': 'Filtré par rendez-vous entre %dateFrom% et %dateTo%' +'Filtered by calendar range: only %calendarRange%': 'Filtré par rendez-vous par plage de disponibilité: uniquement les %calendarRange%' +Filter by calendar range: Filtrer par rendez-vous dans une plage de disponibilité ou non + +Group calendars by agent: Grouper les rendez-vous par agent +Group calendars by agent job: Grouper les rendez-vous par métier de l'agent +Group calendars by agent scope: Grouper les rendez-vous par service de l'agent +Group calendars by location type: Grouper les rendez-vous par type de localisation +Group calendars by location: Grouper les rendez-vous par lieu de rendez-vous +Group calendars by cancel reason: Grouper les rendez-vous par motif d'annulation +Group calendars by month and year: Grouper les rendez-vous par mois et année +Group calendars by urgency: Grouper les rendez-vous par urgent ou non -Group by agent: Grouper par agent -Group by agent job: Grouper par métier de l'agent -Group by agent scope: Grouper par service de l'agent -Group by location type: Grouper par type de localisation -Group by location: Grouper par lieu de rendez-vous -Group by cancel reason: Grouper par motif d'annulation -Group by month and year: Grouper par mois et année Scope: Service Job: Métier Location type: Type de localisation Location: Lieu de rendez-vous by month and year: Par mois et année + +is urgent: Urgent +is not urgent: Pas urgent +has calendar range: Dans une plage de disponibilité? +Not made within a calendar range: Rendez-vous dans une plage de disponibilité +Made within a calendar range: Rendez-vous en dehors d'une plage de disponibilité + +docgen: + calendar: + Base context for calendar: 'Rendez-vous: contexte de base' + A base context for generating document on calendar: Contexte pour générer des documents à partir des rendez-vous + Track changes on datetime and warn user if date time is updated after the doc generation: Suivre les changements sur le document et prévenir les utilisateurs que la date et l'heure ont été modifiée après la génération du document + Ask main person: Demander de choisir une personne parmi les participants aux rendez-vous + Main person label: Label pour choisir la personne + Ask third party: Demander de choisir un tiers parmi les participants aux rendez-vous + Third party label: Label pour choisir le tiers + Destinee: Destinataire + None: Aucun choix + title of the generated document: Titre du document généré + +CHILL_CALENDAR_CALENDAR_CREATE: Créer les rendez-vous +CHILL_CALENDAR_CALENDAR_EDIT: Modifier les rendez-vous +CHILL_CALENDAR_CALENDAR_DELETE: Supprimer les rendez-vous +CHILL_CALENDAR_CALENDAR_SEE: Voir les rendez-vous + diff --git a/src/Bundle/ChillCalendarBundle/translations/validators.fr.yml b/src/Bundle/ChillCalendarBundle/translations/validators.fr.yml new file mode 100644 index 000000000..7d9be7a91 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/translations/validators.fr.yml @@ -0,0 +1,7 @@ +calendar: + At least {{ limit }} person is required.: Au moins {{ limit }} personne doit être associée à ce rendez-vous + An end date is required: Indiquez une date et heure de fin + A start date is required: Indiquez une date et heure de début + A location is required: Indiquez un lieu + A main user is mandator: Indiquez un utilisateur principal + diff --git a/src/Bundle/ChillDocGeneratorBundle/Repository/DocGeneratorTemplateRepository.php b/src/Bundle/ChillDocGeneratorBundle/Repository/DocGeneratorTemplateRepository.php index 6a80c34dc..c00dc7474 100644 --- a/src/Bundle/ChillDocGeneratorBundle/Repository/DocGeneratorTemplateRepository.php +++ b/src/Bundle/ChillDocGeneratorBundle/Repository/DocGeneratorTemplateRepository.php @@ -36,6 +36,7 @@ final class DocGeneratorTemplateRepository implements ObjectRepository $builder ->select('count(t)') ->where('t.entity LIKE :entity') + ->andWhere($builder->expr()->eq('t.active', "'TRUE'")) ->setParameter('entity', addslashes($entity)); return $builder->getQuery()->getSingleScalarResult(); diff --git a/src/Bundle/ChillDocGeneratorBundle/Resources/public/api/pickTemplate.js b/src/Bundle/ChillDocGeneratorBundle/Resources/public/api/pickTemplate.js index a668fe29d..4c5673d05 100644 --- a/src/Bundle/ChillDocGeneratorBundle/Resources/public/api/pickTemplate.js +++ b/src/Bundle/ChillDocGeneratorBundle/Resources/public/api/pickTemplate.js @@ -1,4 +1,4 @@ -import { fetchResults } from "ChillMainAssets/lib/api/apiMethods.js"; +import { fetchResults } from "ChillMainAssets/lib/api/apiMethods.ts"; const fetchTemplates = (entityClass) => { let fqdnEntityClass = encodeURI(entityClass); diff --git a/src/Bundle/ChillDocStoreBundle/Entity/AccompanyingCourseDocument.php b/src/Bundle/ChillDocStoreBundle/Entity/AccompanyingCourseDocument.php index 1fc09a907..d7bd1f274 100644 --- a/src/Bundle/ChillDocStoreBundle/Entity/AccompanyingCourseDocument.php +++ b/src/Bundle/ChillDocStoreBundle/Entity/AccompanyingCourseDocument.php @@ -34,7 +34,7 @@ class AccompanyingCourseDocument extends Document implements HasScopesInterface public function getScopes(): iterable { - if (null !== $this->course) { + if (null === $this->course) { return []; } diff --git a/src/Bundle/ChillDocStoreBundle/Resources/public/module/async_upload/downloader.js b/src/Bundle/ChillDocStoreBundle/Resources/public/module/async_upload/downloader.js index 0e889c373..2b6a1b8ae 100644 --- a/src/Bundle/ChillDocStoreBundle/Resources/public/module/async_upload/downloader.js +++ b/src/Bundle/ChillDocStoreBundle/Resources/public/module/async_upload/downloader.js @@ -1,4 +1,4 @@ -var mime = require('mime-types'); +var mime = require('mime'); var algo = 'AES-CBC'; @@ -28,7 +28,7 @@ var download = (button) => { labelPreparing = button.dataset.labelPreparing, labelReady = button.dataset.labelReady, mimeType = button.dataset.mimeType, - extension = mime.extension(mimeType), + extension = mime.getExtension(mimeType), decryptError = "Error while decrypting file", fetchError = "Error while fetching file", key, url @@ -93,4 +93,4 @@ window.addEventListener('load', function(e) { initializeButtons(e.target); }); -export { initializeButtons, download }; \ No newline at end of file +export { initializeButtons, download }; diff --git a/src/Bundle/ChillDocStoreBundle/Resources/views/AccompanyingCourseDocument/_workflow.html.twig b/src/Bundle/ChillDocStoreBundle/Resources/views/AccompanyingCourseDocument/_workflow.html.twig index e9147ed4a..299183cca 100644 --- a/src/Bundle/ChillDocStoreBundle/Resources/views/AccompanyingCourseDocument/_workflow.html.twig +++ b/src/Bundle/ChillDocStoreBundle/Resources/views/AccompanyingCourseDocument/_workflow.html.twig @@ -1,64 +1,72 @@ {% import "@ChillDocStore/Macro/macro.html.twig" as m %} {% import "@ChillDocStore/Macro/macro_mimeicon.html.twig" as mm %} -
      -
      -
      -
      - -
      -
      -

      {{ document.title }}

      +{% if document is null %} +
      + {{ 'workflow.Document deleted'|trans }} +
      +{% else %} +
      +
      +
      +
      + +
      +
      +

      {{ document.title }}

      - {{ mm.mimeIcon(document.object.type) }} + {{ mm.mimeIcon(document.object.type) }} - {% if document.description is not empty %} -
      - {{ document.description }} -
      - {% endif %} + {% if document.description is not empty %} +
      + {{ document.description }} +
      + {% endif %} +
      -
      -{% set freezed = false %} -{% for step in entity_workflow.stepsChained %} - {% if loop.last %} - {% if step.previous is not null and step.previous.freezeAfter == true %} - {% set freezed = true %} - {% endif %} - {% endif %} -{% endfor %} - -{% if display_action is defined and display_action == true %} -
        - {% if document.course != null and is_granted('CHILL_PERSON_ACCOMPANYING_PERIOD_SEE', document.course) %} -
      • - - {{ 'Course number'|trans }} {{ document.course.id }} - -
      • - {% endif %} -
      • - {{ m.download_button(document.object, document.title) }} -
      • -
      • - {% if chill_document_is_editable(document.object) %} - {% if not freezed %} - {{ document.object|chill_document_edit_button({'title': document.title|e('html') }) }} - {% else %} - - {{ 'Update document'|trans }} - + {% set freezed = false %} + {% for step in entity_workflow.stepsChained %} + {% if loop.last %} + {% if step.previous is not null and step.previous.freezeAfter == true %} + {% set freezed = true %} {% endif %} {% endif %} - {% if is_granted('CHILL_ACCOMPANYING_COURSE_DOCUMENT_SEE', document) and document.course != null %} + {% endfor %} + + {% if display_action is defined and display_action == true %} + + {% if chill_document_is_editable(document.object) %} + {% if not freezed %} +
      • + {{ document.object|chill_document_edit_button({'title': document.title|e('html') }) }} +
      • + {% else %} +
      • + + {{ 'Update document'|trans }} + +
      • + {% endif %} + {% endif %} + {% if is_granted('CHILL_ACCOMPANYING_COURSE_DOCUMENT_SEE', document) and document.course != null %} +
      • + +
      • + {% endif %} +
      + {% endif %} {% endif %} diff --git a/src/Bundle/ChillDocStoreBundle/Workflow/AccompanyingCourseDocumentWorkflowHandler.php b/src/Bundle/ChillDocStoreBundle/Workflow/AccompanyingCourseDocumentWorkflowHandler.php index 8e94dbd83..96787a6fc 100644 --- a/src/Bundle/ChillDocStoreBundle/Workflow/AccompanyingCourseDocumentWorkflowHandler.php +++ b/src/Bundle/ChillDocStoreBundle/Workflow/AccompanyingCourseDocumentWorkflowHandler.php @@ -65,6 +65,10 @@ class AccompanyingCourseDocumentWorkflowHandler implements EntityWorkflowHandler { $doc = $this->getRelatedEntity($entityWorkflow); + if (null === $doc) { + return $this->translator->trans('workflow.Document deleted'); + } + return $this->translator->trans('workflow.Document (n°%doc%)', ['%doc%' => $entityWorkflow->getRelatedEntityId()]) . ' - ' . $doc->getTitle(); } diff --git a/src/Bundle/ChillDocStoreBundle/translations/messages.fr.yml b/src/Bundle/ChillDocStoreBundle/translations/messages.fr.yml index 17326b299..400e37236 100644 --- a/src/Bundle/ChillDocStoreBundle/translations/messages.fr.yml +++ b/src/Bundle/ChillDocStoreBundle/translations/messages.fr.yml @@ -63,3 +63,6 @@ Create new DocumentCategory: Créer une nouvelle catégorie de document # WOPI EDIT online_edit_document: Éditer en ligne + +workflow: + Document deleted: Document supprimé diff --git a/src/Bundle/ChillEventBundle/Tests/Controller/ParticipationControllerTest.php b/src/Bundle/ChillEventBundle/Tests/Controller/ParticipationControllerTest.php index dfb782a7d..fa1ecabbe 100644 --- a/src/Bundle/ChillEventBundle/Tests/Controller/ParticipationControllerTest.php +++ b/src/Bundle/ChillEventBundle/Tests/Controller/ParticipationControllerTest.php @@ -239,7 +239,9 @@ final class ParticipationControllerTest extends WebTestCase $this->personsIdsCache = array_merge( $this->personsIdsCache, $event->getParticipations()->map( - static function ($p) { return $p->getPerson()->getId(); } + static function ($p) { + return $p->getPerson()->getId(); + } ) ->toArray() ); @@ -303,7 +305,9 @@ final class ParticipationControllerTest extends WebTestCase $event = $this->getRandomEventWithMultipleParticipations(); $persons_id = implode(',', $event->getParticipations()->map( - static function ($p) { return $p->getPerson()->getId(); } + static function ($p) { + return $p->getPerson()->getId(); + } )->toArray()); $crawler = $this->client->request( @@ -329,7 +333,9 @@ final class ParticipationControllerTest extends WebTestCase $nbParticipations = $event->getParticipations()->count(); // get the persons_id participating on this event $persons_id = $event->getParticipations()->map( - static function ($p) { return $p->getPerson()->getId(); } + static function ($p) { + return $p->getPerson()->getId(); + } )->toArray(); // exclude the existing persons_ids from the new person $this->personsIdsCache = array_merge($this->personsIdsCache, $persons_id); diff --git a/src/Bundle/ChillMainBundle/ChillMainBundle.php b/src/Bundle/ChillMainBundle/ChillMainBundle.php index df3b8b1aa..9fd6a9891 100644 --- a/src/Bundle/ChillMainBundle/ChillMainBundle.php +++ b/src/Bundle/ChillMainBundle/ChillMainBundle.php @@ -11,6 +11,7 @@ declare(strict_types=1); namespace Chill\MainBundle; +use Chill\MainBundle\Cron\CronJobInterface; use Chill\MainBundle\CRUD\CompilerPass\CRUDControllerCompilerPass; use Chill\MainBundle\DependencyInjection\CompilerPass\ACLFlagsCompilerPass; use Chill\MainBundle\DependencyInjection\CompilerPass\ExportsCompilerPass; @@ -18,6 +19,7 @@ use Chill\MainBundle\DependencyInjection\CompilerPass\GroupingCenterCompilerPass use Chill\MainBundle\DependencyInjection\CompilerPass\MenuCompilerPass; use Chill\MainBundle\DependencyInjection\CompilerPass\NotificationCounterCompilerPass; use Chill\MainBundle\DependencyInjection\CompilerPass\SearchableServicesCompilerPass; +use Chill\MainBundle\DependencyInjection\CompilerPass\ShortMessageCompilerPass; use Chill\MainBundle\DependencyInjection\CompilerPass\TimelineCompilerClass; use Chill\MainBundle\DependencyInjection\CompilerPass\WidgetsCompilerPass; use Chill\MainBundle\DependencyInjection\ConfigConsistencyCompilerPass; @@ -58,6 +60,8 @@ class ChillMainBundle extends Bundle ->addTag('chill.count_notification.user'); $container->registerForAutoconfiguration(EntityWorkflowHandlerInterface::class) ->addTag('chill_main.workflow_handler'); + $container->registerForAutoconfiguration(CronJobInterface::class) + ->addTag('chill_main.cron_job'); $container->addCompilerPass(new SearchableServicesCompilerPass()); $container->addCompilerPass(new ConfigConsistencyCompilerPass()); @@ -70,5 +74,6 @@ class ChillMainBundle extends Bundle $container->addCompilerPass(new ACLFlagsCompilerPass()); $container->addCompilerPass(new GroupingCenterCompilerPass()); $container->addCompilerPass(new CRUDControllerCompilerPass()); + $container->addCompilerPass(new ShortMessageCompilerPass()); } } diff --git a/src/Bundle/ChillMainBundle/Command/ExecuteCronJobCommand.php b/src/Bundle/ChillMainBundle/Command/ExecuteCronJobCommand.php new file mode 100644 index 000000000..0e81177dc --- /dev/null +++ b/src/Bundle/ChillMainBundle/Command/ExecuteCronJobCommand.php @@ -0,0 +1,55 @@ +cronManager = $cronManager; + } + + protected function configure() + { + $this + ->setDescription('Execute the cronjob(s) given as argument, or one cronjob scheduled by system.') + ->setHelp("If no job is specified, the next available cronjob will be executed by system.\nThis command should be execute every 15 minutes (more or less)") + ->addArgument('job', InputArgument::OPTIONAL | InputArgument::IS_ARRAY, 'one or more job to force execute (by default, all jobs are executed)', []) + ->addUsage(''); + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + if ([] === $input->getArgument('job')) { + $this->cronManager->run(); + + return 0; + } + + foreach ($input->getArgument('job') as $jobName) { + $this->cronManager->run($jobName); + } + + return 0; + } +} diff --git a/src/Bundle/ChillMainBundle/Controller/ExportController.php b/src/Bundle/ChillMainBundle/Controller/ExportController.php index 4add7030c..84bf80c6b 100644 --- a/src/Bundle/ChillMainBundle/Controller/ExportController.php +++ b/src/Bundle/ChillMainBundle/Controller/ExportController.php @@ -11,21 +11,32 @@ declare(strict_types=1); namespace Chill\MainBundle\Controller; +use Chill\MainBundle\Entity\SavedExport; +use Chill\MainBundle\Entity\User; use Chill\MainBundle\Export\ExportManager; +use Chill\MainBundle\Form\SavedExportType; use Chill\MainBundle\Form\Type\Export\ExportType; use Chill\MainBundle\Form\Type\Export\FormatterType; use Chill\MainBundle\Form\Type\Export\PickCenterType; use Chill\MainBundle\Redis\ChillRedis; +use Chill\MainBundle\Security\Authorization\SavedExportVoter; +use Doctrine\ORM\EntityManagerInterface; use LogicException; use Psr\Log\LoggerInterface; +use RedisException; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\Form\Extension\Core\Type\FormType; use Symfony\Component\Form\Extension\Core\Type\SubmitType; use Symfony\Component\Form\FormFactoryInterface; +use Symfony\Component\Form\FormInterface; +use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Session\SessionInterface; -use Symfony\Contracts\Translation\TranslatorInterface; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; +use Symfony\Component\Routing\Annotation\Route; +use Symfony\Contracts\Translation\TranslatorInterface; use function count; use function serialize; use function unserialize; @@ -36,35 +47,37 @@ use function unserialize; */ class ExportController extends AbstractController { + private EntityManagerInterface $entityManager; + /** * @var ExportManager */ - protected $exportManager; + private $exportManager; /** * @var FormFactoryInterface */ - protected $formFactory; + private $formFactory; /** * @var LoggerInterface */ - protected $logger; + private $logger; /** * @var ChillRedis */ - protected $redis; + private $redis; /** * @var SessionInterface */ - protected $session; + private $session; /** * @var TranslatorInterface */ - protected $translator; + private $translator; public function __construct( ChillRedis $chillRedis, @@ -72,8 +85,10 @@ class ExportController extends AbstractController FormFactoryInterface $formFactory, LoggerInterface $logger, SessionInterface $session, - TranslatorInterface $translator + TranslatorInterface $translator, + EntityManagerInterface $entityManager ) { + $this->entityManager = $entityManager; $this->redis = $chillRedis; $this->exportManager = $exportManager; $this->formFactory = $formFactory; @@ -141,11 +156,32 @@ class ExportController extends AbstractController } /** - * Render the list of available exports. + * @Route("/{_locale}/exports/generate-from-saved/{id}", name="chill_main_export_generate_from_saved") * - * @return \Symfony\Component\HttpFoundation\Response + * @throws RedisException */ - public function indexAction(Request $request) + public function generateFromSavedExport(SavedExport $savedExport): RedirectResponse + { + $this->denyAccessUnlessGranted(SavedExportVoter::GENERATE, $savedExport); + + $key = md5(uniqid((string) mt_rand(), false)); + + $this->redis->setEx($key, 3600, serialize($savedExport->getOptions())); + + return $this->redirectToRoute( + 'chill_main_export_download', + [ + 'alias' => $savedExport->getExportAlias(), + 'key' => $key, 'prevent_save' => true, + 'returnPath' => $this->generateUrl('chill_main_export_saved_list_my'), + ] + ); + } + + /** + * Render the list of available exports. + */ + public function indexAction(): Response { $exportManager = $this->exportManager; @@ -192,33 +228,65 @@ class ExportController extends AbstractController case 'export': return $this->exportFormStep($request, $export, $alias); - break; - case 'formatter': return $this->formatterFormStep($request, $export, $alias); - break; - case 'generate': return $this->forwardToGenerate($request, $export, $alias); - break; - default: throw $this->createNotFoundException("The given step '{$step}' is invalid"); } } + /** + * @Route("/{_locale}/export/save-from-key/{alias}/{key}", name="chill_main_export_save_from_key") + */ + public function saveFromKey(string $alias, string $key, Request $request): Response + { + $this->denyAccessUnlessGranted('ROLE_USER'); + $user = $this->getUser(); + + if (!$user instanceof User) { + throw new AccessDeniedHttpException(); + } + + $data = $this->rebuildRawData($key); + + $savedExport = new SavedExport(); + $savedExport + ->setOptions($data) + ->setExportAlias($alias) + ->setUser($user); + + $form = $this->createForm(SavedExportType::class, $savedExport); + + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $this->entityManager->persist($savedExport); + $this->entityManager->flush(); + + return $this->redirectToRoute('chill_main_export_index'); + } + + return $this->render( + '@ChillMain/SavedExport/new.html.twig', + [ + 'form' => $form->createView(), + 'saved_export' => $savedExport, + ] + ); + } + /** * create a form to show on different steps. * * @param string $alias * @param array $data the data from previous step. Required for steps 'formatter' and 'generate_formatter' * @param mixed $step - * - * @return \Symfony\Component\Form\Form */ - protected function createCreateFormExport($alias, $step, $data = []) + protected function createCreateFormExport($alias, $step, $data = []): FormInterface { /** @var \Chill\MainBundle\Export\ExportManager $exportManager */ $exportManager = $this->exportManager; @@ -426,28 +494,7 @@ class ExportController extends AbstractController protected function rebuildData($key) { - if (null === $key) { - throw $this->createNotFoundException('key does not exists'); - } - - if ($this->redis->exists($key) !== 1) { - $this->addFlash('error', $this->translator->trans('This report is not available any more')); - - throw $this->createNotFoundException('key does not exists'); - } - - $serialized = $this->redis->get($key); - - if (false === $serialized) { - throw new LogicException('the key could not be reached from redis'); - } - - $rawData = unserialize($serialized); - - $this->logger->notice('[export] choices for an export unserialized', [ - 'key' => $key, - 'rawData' => json_encode($rawData), - ]); + $rawData = $this->rebuildRawData($key); $alias = $rawData['alias']; @@ -476,8 +523,6 @@ class ExportController extends AbstractController * @param \Chill\MainBundle\Export\DirectExportInterface|\Chill\MainBundle\Export\ExportInterface $export * @param string $alias * - * @throws type - * * @return Response */ protected function selectCentersStep(Request $request, $export, $alias) @@ -544,6 +589,8 @@ class ExportController extends AbstractController } } } + + return ''; } /** @@ -593,4 +640,32 @@ class ExportController extends AbstractController throw new LogicException("the step {$step} is not defined."); } } + + private function rebuildRawData(string $key): array + { + if (null === $key) { + throw $this->createNotFoundException('key does not exists'); + } + + if ($this->redis->exists($key) !== 1) { + $this->addFlash('error', $this->translator->trans('This report is not available any more')); + + throw $this->createNotFoundException('key does not exists'); + } + + $serialized = $this->redis->get($key); + + if (false === $serialized) { + throw new LogicException('the key could not be reached from redis'); + } + + $rawData = unserialize($serialized); + + $this->logger->notice('[export] choices for an export unserialized', [ + 'key' => $key, + 'rawData' => json_encode($rawData), + ]); + + return $rawData; + } } diff --git a/src/Bundle/ChillMainBundle/Controller/SavedExportController.php b/src/Bundle/ChillMainBundle/Controller/SavedExportController.php new file mode 100644 index 000000000..197fe253d --- /dev/null +++ b/src/Bundle/ChillMainBundle/Controller/SavedExportController.php @@ -0,0 +1,185 @@ +exportManager = $exportManager; + $this->entityManager = $entityManager; + $this->formFactory = $formBuilder; + $this->savedExportRepository = $savedExportRepository; + $this->security = $security; + $this->session = $session; + $this->templating = $templating; + $this->translator = $translator; + $this->urlGenerator = $urlGenerator; + } + + /** + * @Route("/{_locale}/exports/saved/{id}/delete", name="chill_main_export_saved_delete") + */ + public function delete(SavedExport $savedExport, Request $request): Response + { + if (!$this->security->isGranted(SavedExportVoter::DELETE, $savedExport)) { + throw new AccessDeniedHttpException(); + } + + $form = $this->formFactory->create(); + $form->add('submit', SubmitType::class, ['label' => 'Delete']); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $this->entityManager->remove($savedExport); + $this->entityManager->flush(); + + $this->session->getFlashBag()->add('success', $this->translator->trans('saved_export.Export is deleted')); + + return new RedirectResponse( + $this->urlGenerator->generate('chill_main_export_saved_list_my') + ); + } + + return new Response( + $this->templating->render( + '@ChillMain/SavedExport/delete.html.twig', + [ + 'saved_export' => $savedExport, + 'delete_form' => $form->createView(), + ] + ) + ); + } + + /** + * @Route("/{_locale}/exports/saved/{id}/edit", name="chill_main_export_saved_edit") + */ + public function edit(SavedExport $savedExport, Request $request): Response + { + if (!$this->security->isGranted(SavedExportVoter::EDIT, $savedExport)) { + throw new AccessDeniedHttpException(); + } + + $form = $this->formFactory->create(SavedExportType::class, $savedExport); + + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $this->entityManager->flush(); + + $this->session->getFlashBag()->add('success', $this->translator->trans('saved_export.Saved export is saved!')); + + return new RedirectResponse( + $this->urlGenerator->generate('chill_main_export_saved_list_my') + ); + } + + return new Response( + $this->templating->render( + '@ChillMain/SavedExport/edit.html.twig', + [ + 'form' => $form->createView(), + ] + ) + ); + } + + /** + * @Route("/{_locale}/exports/saved/my", name="chill_main_export_saved_list_my") + */ + public function list(): Response + { + $user = $this->security->getUser(); + + if (!$this->security->isGranted('ROLE_USER') || !$user instanceof User) { + throw new AccessDeniedHttpException(); + } + + $exports = $this->savedExportRepository->findByUser($user, ['title' => 'ASC']); + + // group by center + /** @var array $exportsGrouped */ + $exportsGrouped = []; + + foreach ($exports as $savedExport) { + $export = $this->exportManager->getExport($savedExport->getExportAlias()); + + $exportsGrouped[ + $export instanceof GroupedExportInterface + ? $this->translator->trans($export->getGroup()) : '_' + ][] = ['saved' => $savedExport, 'export' => $export]; + } + + ksort($exportsGrouped); + + return new Response( + $this->templating->render( + '@ChillMain/SavedExport/index.html.twig', + [ + 'grouped_exports' => $exportsGrouped, + 'total' => count($exports), + ] + ) + ); + } +} diff --git a/src/Bundle/ChillMainBundle/Cron/CronJobInterface.php b/src/Bundle/ChillMainBundle/Cron/CronJobInterface.php new file mode 100644 index 000000000..4e1ca9ff6 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Cron/CronJobInterface.php @@ -0,0 +1,23 @@ + + */ + private iterable $jobs; + + private LoggerInterface $logger; + + /** + * @param CronJobInterface[] $jobs + */ + public function __construct( + CronJobExecutionRepositoryInterface $cronJobExecutionRepository, + EntityManagerInterface $entityManager, + iterable $jobs, + LoggerInterface $logger + ) { + $this->cronJobExecutionRepository = $cronJobExecutionRepository; + $this->entityManager = $entityManager; + $this->jobs = $jobs; + $this->logger = $logger; + } + + public function run(?string $forceJob = null): void + { + if (null !== $forceJob) { + $this->runForce($forceJob); + + return; + } + + [$orderedJobs, $lasts] = $this->getOrderedJobs(); + + foreach ($orderedJobs as $job) { + if ($job->canRun($lasts[$job->getKey()] ?? null)) { + if (array_key_exists($job->getKey(), $lasts)) { + $this->entityManager + ->createQuery(self::UPDATE_BEFORE_EXEC) + ->setParameters([ + 'now' => new DateTimeImmutable('now'), + 'key' => $job->getKey(), + ]); + } else { + $execution = new CronJobExecution($job->getKey()); + $this->entityManager->persist($execution); + $this->entityManager->flush(); + } + $this->entityManager->clear(); + + try { + $this->logger->info(sprintf('%sWill run job', self::LOG_PREFIX), ['job' => $job->getKey()]); + $job->run(); + + $this->entityManager + ->createQuery(self::UPDATE_AFTER_EXEC) + ->setParameters([ + 'now' => new DateTimeImmutable('now'), + 'status' => CronJobExecution::SUCCESS, + 'key' => $job->getKey(), + ]) + ->execute(); + + $this->logger->info(sprintf('%sSuccessfully run job', self::LOG_PREFIX), ['job' => $job->getKey()]); + + return; + } catch (Exception $e) { + $this->logger->error(sprintf('%sRunning job failed', self::LOG_PREFIX), ['job' => $job->getKey()]); + $this->entityManager + ->createQuery(self::UPDATE_AFTER_EXEC) + ->setParameters([ + 'now' => new DateTimeImmutable('now'), + 'status' => CronJobExecution::FAILURE, + 'key' => $job->getKey(), + ]) + ->execute(); + + return; + } + } + } + } + + /** + * @return array<0: CronJobInterface[], 1: array> + */ + private function getOrderedJobs(): array + { + /** @var array $lasts */ + $lasts = []; + + foreach ($this->cronJobExecutionRepository->findAll() as $execution) { + $lasts[$execution->getKey()] = $execution; + } + + // order by last, NULL first + $orderedJobs = iterator_to_array($this->jobs); + usort( + $orderedJobs, + static function (CronJobInterface $a, CronJobInterface $b) use ($lasts): int { + if ( + (!array_key_exists($a->getKey(), $lasts) && !array_key_exists($b->getKey(), $lasts)) + ) { + return 0; + } + + if (!array_key_exists($a->getKey(), $lasts) && array_key_exists($b->getKey(), $lasts)) { + return -1; + } + + if (!array_key_exists($b->getKey(), $lasts) && array_key_exists($a->getKey(), $lasts)) { + return 1; + } + + return $lasts[$a->getKey()]->getLastStart() <=> $lasts[$b->getKey()]->getLastStart(); + } + ); + + return [$orderedJobs, $lasts]; + } + + private function runForce(string $forceJob): void + { + foreach ($this->jobs as $job) { + if ($job->getKey() === $forceJob) { + $job->run(); + } + } + } +} diff --git a/src/Bundle/ChillMainBundle/Cron/CronManagerInterface.php b/src/Bundle/ChillMainBundle/Cron/CronManagerInterface.php new file mode 100644 index 000000000..d2292d455 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Cron/CronManagerInterface.php @@ -0,0 +1,20 @@ +load('services/search.yaml'); $loader->load('services/serializer.yaml'); $loader->load('services/mailer.yaml'); + $loader->load('services/short_message.yaml'); $this->configureCruds($container, $config['cruds'], $config['apis'], $loader); + $container->setParameter('chill_main.short_messages', $config['short_messages']); + //$this->configureSms($config['short_messages'], $container, $loader); } public function prepend(ContainerBuilder $container) @@ -247,10 +255,15 @@ class ChillMainExtension extends Extension implements 'STRICT_WORD_SIMILARITY_OPS' => StrictWordSimilarityOPS::class, 'ST_CONTAINS' => STContains::class, 'JSONB_ARRAY_LENGTH' => JsonbArrayLength::class, + 'ST_X' => STX::class, + 'ST_Y' => STY::class, + 'GREATEST' => Greatest::class, + 'LEAST' => LEAST::class, ], 'datetime_functions' => [ 'EXTRACT' => Extract::class, 'TO_CHAR' => ToChar::class, + 'AGE' => Age::class, ], ], 'hydrators' => [ diff --git a/src/Bundle/ChillMainBundle/DependencyInjection/CompilerPass/ExportsCompilerPass.php b/src/Bundle/ChillMainBundle/DependencyInjection/CompilerPass/ExportsCompilerPass.php index ae85ba22c..5c351728a 100644 --- a/src/Bundle/ChillMainBundle/DependencyInjection/CompilerPass/ExportsCompilerPass.php +++ b/src/Bundle/ChillMainBundle/DependencyInjection/CompilerPass/ExportsCompilerPass.php @@ -11,6 +11,7 @@ declare(strict_types=1); namespace Chill\MainBundle\DependencyInjection\CompilerPass; +use Chill\MainBundle\Export\ExportManager; use LogicException; use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; @@ -30,53 +31,19 @@ class ExportsCompilerPass implements CompilerPassInterface { public function process(ContainerBuilder $container) { - if (!$container->has('Chill\MainBundle\Export\ExportManager')) { - throw new LogicException('service Chill\MainBundle\Export\ExportManager ' + if (!$container->has(ExportManager::class)) { + throw new LogicException('service ' . ExportManager::class . ' ' . 'is not defined. It is required by ExportsCompilerPass'); } $chillManagerDefinition = $container->findDefinition( - 'Chill\MainBundle\Export\ExportManager' + ExportManager::class ); - $this->compileExports($chillManagerDefinition, $container); - $this->compileFilters($chillManagerDefinition, $container); - $this->compileAggregators($chillManagerDefinition, $container); $this->compileFormatters($chillManagerDefinition, $container); $this->compileExportElementsProvider($chillManagerDefinition, $container); } - private function compileAggregators( - Definition $chillManagerDefinition, - ContainerBuilder $container - ) { - $taggedServices = $container->findTaggedServiceIds( - 'chill.export_aggregator' - ); - - $knownAliases = []; - - foreach ($taggedServices as $id => $tagAttributes) { - foreach ($tagAttributes as $attributes) { - if (!isset($attributes['alias'])) { - throw new LogicException("the 'alias' attribute is missing in your " . - "service '{$id}' definition"); - } - - if (array_search($attributes['alias'], $knownAliases, true)) { - throw new LogicException('There is already a chill.export_aggregator service with alias ' - . $attributes['alias'] . '. Choose another alias.'); - } - $knownAliases[] = $attributes['alias']; - - $chillManagerDefinition->addMethodCall( - 'addAggregator', - [new Reference($id), $attributes['alias']] - ); - } - } - } - private function compileExportElementsProvider( Definition $chillManagerDefinition, ContainerBuilder $container @@ -108,68 +75,6 @@ class ExportsCompilerPass implements CompilerPassInterface } } - private function compileExports( - Definition $chillManagerDefinition, - ContainerBuilder $container - ) { - $taggedServices = $container->findTaggedServiceIds( - 'chill.export' - ); - - $knownAliases = []; - - foreach ($taggedServices as $id => $tagAttributes) { - foreach ($tagAttributes as $attributes) { - if (!isset($attributes['alias'])) { - throw new LogicException("the 'alias' attribute is missing in your " . - "service '{$id}' definition"); - } - - if (array_search($attributes['alias'], $knownAliases, true)) { - throw new LogicException('There is already a chill.export service with alias ' - . $attributes['alias'] . '. Choose another alias.'); - } - $knownAliases[] = $attributes['alias']; - - $chillManagerDefinition->addMethodCall( - 'addExport', - [new Reference($id), $attributes['alias']] - ); - } - } - } - - private function compileFilters( - Definition $chillManagerDefinition, - ContainerBuilder $container - ) { - $taggedServices = $container->findTaggedServiceIds( - 'chill.export_filter' - ); - - $knownAliases = []; - - foreach ($taggedServices as $id => $tagAttributes) { - foreach ($tagAttributes as $attributes) { - if (!isset($attributes['alias'])) { - throw new LogicException("the 'alias' attribute is missing in your " . - "service '{$id}' definition"); - } - - if (array_search($attributes['alias'], $knownAliases, true)) { - throw new LogicException('There is already a chill.export_filter service with alias ' - . $attributes['alias'] . '. Choose another alias.'); - } - $knownAliases[] = $attributes['alias']; - - $chillManagerDefinition->addMethodCall( - 'addFilter', - [new Reference($id), $attributes['alias']] - ); - } - } - } - private function compileFormatters( Definition $chillManagerDefinition, ContainerBuilder $container diff --git a/src/Bundle/ChillMainBundle/DependencyInjection/CompilerPass/ShortMessageCompilerPass.php b/src/Bundle/ChillMainBundle/DependencyInjection/CompilerPass/ShortMessageCompilerPass.php new file mode 100644 index 000000000..f75840c3a --- /dev/null +++ b/src/Bundle/ChillMainBundle/DependencyInjection/CompilerPass/ShortMessageCompilerPass.php @@ -0,0 +1,94 @@ +resolveEnvPlaceholders($container->getParameter('chill_main.short_messages', null), true); + // weird fix for special characters + $config['dsn'] = str_replace(['%%'], ['%'], $config['dsn']); + $dsn = parse_url($config['dsn']); + parse_str($dsn['query'] ?? '', $dsn['queries']); + + if ('null' === $dsn['scheme'] || false === $config['enabled']) { + $defaultTransporter = new Reference(NullShortMessageSender::class); + } elseif ('ovh' === $dsn['scheme']) { + if (!class_exists('\Ovh\Api')) { + throw new RuntimeException('Class \\Ovh\\Api not found'); + } + + foreach (['user', 'host', 'pass'] as $component) { + if (!array_key_exists($component, $dsn)) { + throw new RuntimeException(sprintf('The component %s does not exist in dsn. Please provide a dsn ' . + 'like ovh://applicationKey:applicationSecret@endpoint?consumerKey=xxxx&sender=yyyy&service_name=zzzz', $component)); + } + + $container->setParameter('chill_main.short_messages.ovh_config_' . $component, $dsn[$component]); + } + + foreach (['consumer_key', 'sender', 'service_name'] as $param) { + if (!array_key_exists($param, $dsn['queries'])) { + throw new RuntimeException(sprintf('The parameter %s does not exist in dsn. Please provide a dsn ' . + 'like ovh://applicationKey:applicationSecret@endpoint?consumerKey=xxxx&sender=yyyy&service_name=zzzz', $param)); + } + $container->setParameter('chill_main.short_messages.ovh_config_' . $param, $dsn['queries'][$param]); + } + + $ovh = new Definition(); + $ovh + ->setClass('\Ovh\Api') + ->setArgument(0, $dsn['user']) + ->setArgument(1, $dsn['pass']) + ->setArgument(2, $dsn['host']) + ->setArgument(3, $dsn['queries']['consumer_key']); + $container->setDefinition('Ovh\Api', $ovh); + + $ovhSender = new Definition(); + $ovhSender + ->setClass(OvhShortMessageSender::class) + ->setArgument(0, new Reference('Ovh\Api')) + ->setArgument(1, $dsn['queries']['service_name']) + ->setArgument(2, $dsn['queries']['sender']) + ->setArgument(3, new Reference(LoggerInterface::class)) + ->setArgument(4, new Reference(PhoneNumberUtil::class)); + $container->setDefinition(OvhShortMessageSender::class, $ovhSender); + + $defaultTransporter = new Reference(OvhShortMessageSender::class); + } else { + throw new RuntimeException(sprintf('Cannot find a sender for this dsn: %s', $config['dsn'])); + } + + $container->getDefinition(ShortMessageTransporter::class) + ->setArgument(0, $defaultTransporter); + } +} diff --git a/src/Bundle/ChillMainBundle/DependencyInjection/Configuration.php b/src/Bundle/ChillMainBundle/DependencyInjection/Configuration.php index 595321bed..db71d9df2 100644 --- a/src/Bundle/ChillMainBundle/DependencyInjection/Configuration.php +++ b/src/Bundle/ChillMainBundle/DependencyInjection/Configuration.php @@ -102,6 +102,14 @@ class Configuration implements ConfigurationInterface ->end() ->end() ->end() + ->arrayNode('short_messages') + ->canBeEnabled() + ->children() + ->scalarNode('dsn')->cannotBeEmpty()->defaultValue('null://null') + ->info('the dsn for sending short message. Example: ovh://applicationKey:secret@endpoint') + ->end() + ->end() + ->end() // end for 'short_messages' ->arrayNode('acl') ->addDefaultsIfNotSet() ->children() diff --git a/src/Bundle/ChillMainBundle/Doctrine/DQL/Age.php b/src/Bundle/ChillMainBundle/Doctrine/DQL/Age.php new file mode 100644 index 000000000..ad148fe99 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Doctrine/DQL/Age.php @@ -0,0 +1,54 @@ +value2) { + return sprintf( + 'AGE(%s, %s)', + $this->value1->dispatch($sqlWalker), + $this->value2->dispatch($sqlWalker) + ); + } + + return sprintf( + 'AGE(%s)', + $this->value1->dispatch($sqlWalker), + ); + } + + public function parse(Parser $parser) + { + $parser->match(Lexer::T_IDENTIFIER); + $parser->match(Lexer::T_OPEN_PARENTHESIS); + + $this->value1 = $parser->SimpleArithmeticExpression(); + + $parser->match(Lexer::T_COMMA); + + $this->value2 = $parser->SimpleArithmeticExpression(); + + $parser->match(Lexer::T_CLOSE_PARENTHESIS); + } +} diff --git a/src/Bundle/ChillMainBundle/Doctrine/DQL/Greatest.php b/src/Bundle/ChillMainBundle/Doctrine/DQL/Greatest.php new file mode 100644 index 000000000..b9ca9c4b6 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Doctrine/DQL/Greatest.php @@ -0,0 +1,57 @@ +dispatch($sqlWalker); + }, $this->exprs)) . ')'; + } + + public function parse(Parser $parser) + { + $this->exprs = []; + + $lexer = $parser->getLexer(); + $parser->match(Lexer::T_IDENTIFIER); + $parser->match(Lexer::T_OPEN_PARENTHESIS); + $this->exprs[] = $parser->ArithmeticPrimary(); + + while (Lexer::T_COMMA === $lexer->lookahead['type']) { + $parser->match(Lexer::T_COMMA); + $this->exprs[] = $parser->ArithmeticPrimary(); + } + + $parser->match(Lexer::T_CLOSE_PARENTHESIS); + } +} diff --git a/src/Bundle/ChillMainBundle/Doctrine/DQL/JsonbExistsInArray.php b/src/Bundle/ChillMainBundle/Doctrine/DQL/JsonbExistsInArray.php index 3dbe9145c..1556753b6 100644 --- a/src/Bundle/ChillMainBundle/Doctrine/DQL/JsonbExistsInArray.php +++ b/src/Bundle/ChillMainBundle/Doctrine/DQL/JsonbExistsInArray.php @@ -27,7 +27,7 @@ class JsonbExistsInArray extends FunctionNode return sprintf( '%s ?? %s', $this->expr1->dispatch($sqlWalker), - $sqlWalker->walkInputParameter($this->expr2) + $this->expr2->dispatch($sqlWalker) ); } diff --git a/src/Bundle/ChillMainBundle/Doctrine/DQL/Least.php b/src/Bundle/ChillMainBundle/Doctrine/DQL/Least.php new file mode 100644 index 000000000..0c8a1b17d --- /dev/null +++ b/src/Bundle/ChillMainBundle/Doctrine/DQL/Least.php @@ -0,0 +1,57 @@ +dispatch($sqlWalker); + }, $this->exprs)) . ')'; + } + + public function parse(Parser $parser) + { + $this->exprs = []; + + $lexer = $parser->getLexer(); + $parser->match(Lexer::T_IDENTIFIER); + $parser->match(Lexer::T_OPEN_PARENTHESIS); + $this->exprs[] = $parser->ArithmeticPrimary(); + + while (Lexer::T_COMMA === $lexer->lookahead['type']) { + $parser->match(Lexer::T_COMMA); + $this->exprs[] = $parser->ArithmeticPrimary(); + } + + $parser->match(Lexer::T_CLOSE_PARENTHESIS); + } +} diff --git a/src/Bundle/ChillMainBundle/Doctrine/DQL/STX.php b/src/Bundle/ChillMainBundle/Doctrine/DQL/STX.php new file mode 100644 index 000000000..d5d8d0a3f --- /dev/null +++ b/src/Bundle/ChillMainBundle/Doctrine/DQL/STX.php @@ -0,0 +1,37 @@ +field->dispatch($sqlWalker)); + } + + public function parse(Parser $parser) + { + $parser->match(Lexer::T_IDENTIFIER); + $parser->match(Lexer::T_OPEN_PARENTHESIS); + + $this->field = $parser->ArithmeticExpression(); + + $parser->match(Lexer::T_CLOSE_PARENTHESIS); + } +} diff --git a/src/Bundle/ChillMainBundle/Doctrine/DQL/STY.php b/src/Bundle/ChillMainBundle/Doctrine/DQL/STY.php new file mode 100644 index 000000000..e827da543 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Doctrine/DQL/STY.php @@ -0,0 +1,37 @@ +field->dispatch($sqlWalker)); + } + + public function parse(Parser $parser) + { + $parser->match(Lexer::T_IDENTIFIER); + $parser->match(Lexer::T_OPEN_PARENTHESIS); + + $this->field = $parser->ArithmeticExpression(); + + $parser->match(Lexer::T_CLOSE_PARENTHESIS); + } +} diff --git a/src/Bundle/ChillMainBundle/Doctrine/Type/NativeDateIntervalType.php b/src/Bundle/ChillMainBundle/Doctrine/Type/NativeDateIntervalType.php index 432c53aad..896ddb600 100644 --- a/src/Bundle/ChillMainBundle/Doctrine/Type/NativeDateIntervalType.php +++ b/src/Bundle/ChillMainBundle/Doctrine/Type/NativeDateIntervalType.php @@ -17,6 +17,7 @@ use Doctrine\DBAL\Types\ConversionException; use Doctrine\DBAL\Types\DateIntervalType; use Exception; +use LogicException; use function count; use function current; use function preg_match; @@ -40,7 +41,7 @@ class NativeDateIntervalType extends DateIntervalType return $value->format(self::FORMAT); } - throw ConversionException::conversionFailedInvalidType($value, $this->getName(), ['null', 'DateInterval']); + throw ConversionException::conversionFailedInvalidType($value, 'string', ['null', 'DateInterval']); } public function convertToPHPValue($value, AbstractPlatform $platform) @@ -80,7 +81,7 @@ class NativeDateIntervalType extends DateIntervalType protected function createConversionException($value, $exception = null) { - return ConversionException::conversionFailedFormat($value, $this->getName(), 'xx year xx mons xx days 01:02:03', $exception); + return ConversionException::conversionFailedFormat($value, 'string', 'xx year xx mons xx days 01:02:03', $exception); } private function convertEntry(&$strings) @@ -125,5 +126,7 @@ class NativeDateIntervalType extends DateIntervalType return $intervalSpec; } + + throw new LogicException(); } } diff --git a/src/Bundle/ChillMainBundle/Entity/Address.php b/src/Bundle/ChillMainBundle/Entity/Address.php index 2ddb48fda..9a0f8b7b3 100644 --- a/src/Bundle/ChillMainBundle/Entity/Address.php +++ b/src/Bundle/ChillMainBundle/Entity/Address.php @@ -15,6 +15,8 @@ use Chill\MainBundle\Doctrine\Model\Point; use Chill\ThirdPartyBundle\Entity\ThirdParty; use DateTime; use DateTimeInterface; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Serializer\Annotation\Groups; use Symfony\Component\Validator\Context\ExecutionContextInterface; @@ -97,6 +99,23 @@ class Address */ private $floor; + /** + * List of geographical units and addresses. + * + * This list is computed by a materialized view. It won't be populated until a refresh is done + * on the materialized view. + * + * @var Collection|GeographicalUnit[] + * @readonly + * @ORM\ManyToMany(targetEntity=GeographicalUnit::class) + * @ORM\JoinTable( + * name="view_chill_main_address_geographical_unit", + * joinColumns={@ORM\JoinColumn(name="address_id")}, + * inverseJoinColumns={@ORM\JoinColumn(name="geographical_unit_id")} + * ) + */ + private Collection $geographicalUnits; + /** * @var int * @@ -104,8 +123,9 @@ class Address * @ORM\Column(name="id", type="integer") * @ORM\GeneratedValue(strategy="AUTO") * @Groups({"write"}) + * @readonly */ - private $id; + private ?int $id = null; /** * True if the address is a "no address", aka homeless person, ... @@ -190,6 +210,7 @@ class Address public function __construct() { $this->validFrom = new DateTime(); + $this->geographicalUnits = new ArrayCollection(); } public static function createFromAddress(Address $original): Address @@ -273,6 +294,14 @@ class Address return $this->floor; } + /** + * @return Collection|GeographicalUnit[] + */ + public function getGeographicalUnits(): Collection + { + return $this->geographicalUnits; + } + /** * Get id. * @@ -359,6 +388,11 @@ class Address return $this->validTo; } + public function hasAddressReference(): bool + { + return null !== $this->getAddressReference(); + } + public function isNoAddress(): bool { return $this->getIsNoAddress(); diff --git a/src/Bundle/ChillMainBundle/Entity/AddressReference.php b/src/Bundle/ChillMainBundle/Entity/AddressReference.php index 115b36918..68cc32186 100644 --- a/src/Bundle/ChillMainBundle/Entity/AddressReference.php +++ b/src/Bundle/ChillMainBundle/Entity/AddressReference.php @@ -171,6 +171,11 @@ class AddressReference return $this->updatedAt; } + public function hasPoint(): bool + { + return null !== $this->getPoint(); + } + public function setCreatedAt(?DateTimeImmutable $createdAt): self { $this->createdAt = $createdAt; diff --git a/src/Bundle/ChillMainBundle/Entity/CronJobExecution.php b/src/Bundle/ChillMainBundle/Entity/CronJobExecution.php new file mode 100644 index 000000000..0cacffac9 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Entity/CronJobExecution.php @@ -0,0 +1,95 @@ +key = $key; + $this->lastStart = new DateTimeImmutable('now'); + } + + public function getKey(): string + { + return $this->key; + } + + public function getLastEnd(): DateTimeImmutable + { + return $this->lastEnd; + } + + public function getLastStart(): DateTimeImmutable + { + return $this->lastStart; + } + + public function getLastStatus(): ?int + { + return $this->lastStatus; + } + + public function setLastEnd(?DateTimeImmutable $lastEnd): CronJobExecution + { + $this->lastEnd = $lastEnd; + + return $this; + } + + public function setLastStart(DateTimeImmutable $lastStart): CronJobExecution + { + $this->lastStart = $lastStart; + + return $this; + } + + public function setLastStatus(?int $lastStatus): CronJobExecution + { + $this->lastStatus = $lastStatus; + + return $this; + } +} diff --git a/src/Bundle/ChillMainBundle/Entity/GeographicalUnit.php b/src/Bundle/ChillMainBundle/Entity/GeographicalUnit.php index ed3eceb01..37735bbc2 100644 --- a/src/Bundle/ChillMainBundle/Entity/GeographicalUnit.php +++ b/src/Bundle/ChillMainBundle/Entity/GeographicalUnit.php @@ -14,15 +14,17 @@ namespace Chill\MainBundle\Entity; use Doctrine\ORM\Mapping as ORM; /** - * @ORM\Table(name="chill_main_geographical_unit") - * @ORM\Entity + * @ORM\Table(name="chill_main_geographical_unit", uniqueConstraints={ + * @ORM\UniqueConstraint(name="geographical_unit_refid", columns={"layer_id", "unitRefId"}) + * }) + * @ORM\Entity(readOnly=true) */ class GeographicalUnit { /** * @ORM\Column(type="text", nullable=true) */ - private $geom; + private string $geom; /** * @ORM\Id @@ -32,23 +34,28 @@ class GeographicalUnit private ?int $id = null; /** - * @ORM\Column(type="string", length=255, nullable=true) + * @ORM\ManyToOne(targetEntity=GeographicalUnitLayer::class, inversedBy="units") */ - private $layerName; + private ?GeographicalUnitLayer $layer; /** - * @ORM\Column(type="string", length=255, nullable=true) + * @ORM\Column(type="text", nullable=false, options={"default": ""}) */ - private $unitName; + private string $unitName; + + /** + * @ORM\Column(type="text", nullable=false, options={"default": ""}) + */ + private string $unitRefId; public function getId(): ?int { return $this->id; } - public function getLayerName(): ?string + public function getLayer(): ?GeographicalUnitLayer { - return $this->layerName; + return $this->layer; } public function getUnitName(): ?string @@ -56,9 +63,9 @@ class GeographicalUnit return $this->unitName; } - public function setLayerName(?string $layerName): self + public function setLayer(?GeographicalUnitLayer $layer): GeographicalUnit { - $this->layerName = $layerName; + $this->layer = $layer; return $this; } @@ -69,4 +76,18 @@ class GeographicalUnit return $this; } + + public function setUnitRefId(string $unitRefId): GeographicalUnit + { + $this->unitRefId = $unitRefId; + + return $this; + } + + protected function setId(int $id): self + { + $this->id = $id; + + return $this; + } } diff --git a/src/Bundle/ChillMainBundle/Entity/GeographicalUnit/SimpleGeographicalUnitDTO.php b/src/Bundle/ChillMainBundle/Entity/GeographicalUnit/SimpleGeographicalUnitDTO.php new file mode 100644 index 000000000..34f16a0fb --- /dev/null +++ b/src/Bundle/ChillMainBundle/Entity/GeographicalUnit/SimpleGeographicalUnitDTO.php @@ -0,0 +1,52 @@ +id = $id; + $this->unitName = $unitName; + $this->unitRefId = $unitRefId; + $this->layerId = $layerId; + } +} diff --git a/src/Bundle/ChillMainBundle/Entity/GeographicalUnitLayer.php b/src/Bundle/ChillMainBundle/Entity/GeographicalUnitLayer.php new file mode 100644 index 000000000..bdea40563 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Entity/GeographicalUnitLayer.php @@ -0,0 +1,79 @@ +units = new ArrayCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getName(): array + { + return $this->name; + } + + public function getRefId(): string + { + return $this->refId; + } + + public function getUnits(): Collection + { + return $this->units; + } + + public function setName(array $name): GeographicalUnitLayer + { + $this->name = $name; + + return $this; + } +} diff --git a/src/Bundle/ChillMainBundle/Entity/Location.php b/src/Bundle/ChillMainBundle/Entity/Location.php index d176d5c9f..566cc7bb3 100644 --- a/src/Bundle/ChillMainBundle/Entity/Location.php +++ b/src/Bundle/ChillMainBundle/Entity/Location.php @@ -180,6 +180,11 @@ class Location implements TrackCreationInterface, TrackUpdateInterface return $this->updatedBy; } + public function hasAddress(): bool + { + return null !== $this->getAddress(); + } + public function setActive(bool $active): self { $this->active = $active; diff --git a/src/Bundle/ChillMainBundle/Entity/SavedExport.php b/src/Bundle/ChillMainBundle/Entity/SavedExport.php new file mode 100644 index 000000000..81ca33e72 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Entity/SavedExport.php @@ -0,0 +1,136 @@ +id = Uuid::uuid4(); + } + + public function getDescription(): string + { + return $this->description; + } + + public function getExportAlias(): string + { + return $this->exportAlias; + } + + public function getId(): UuidInterface + { + return $this->id; + } + + public function getOptions(): array + { + return $this->options; + } + + public function getTitle(): string + { + return $this->title; + } + + public function getUser(): User + { + return $this->user; + } + + public function setDescription(?string $description): SavedExport + { + $this->description = (string) $description; + + return $this; + } + + public function setExportAlias(string $exportAlias): SavedExport + { + $this->exportAlias = $exportAlias; + + return $this; + } + + public function setOptions(array $options): SavedExport + { + $this->options = $options; + + return $this; + } + + public function setTitle(?string $title): SavedExport + { + $this->title = (string) $title; + + return $this; + } + + public function setUser(User $user): SavedExport + { + $this->user = $user; + + return $this; + } +} diff --git a/src/Bundle/ChillMainBundle/Entity/Scope.php b/src/Bundle/ChillMainBundle/Entity/Scope.php index b0cb9046b..df4f75e4c 100644 --- a/src/Bundle/ChillMainBundle/Entity/Scope.php +++ b/src/Bundle/ChillMainBundle/Entity/Scope.php @@ -28,6 +28,11 @@ use Symfony\Component\Serializer\Annotation\Groups; */ class Scope { + /** + * @ORM\Column(type="boolean", nullable=false, options={"default": true}) + */ + private bool $active = true; + /** * @ORM\Id * @ORM\Column(name="id", type="integer") @@ -88,6 +93,18 @@ class Scope return $this->roleScopes; } + public function isActive(): bool + { + return $this->active; + } + + public function setActive(bool $active): Scope + { + $this->active = $active; + + return $this; + } + /** * @param $name * diff --git a/src/Bundle/ChillMainBundle/Entity/User.php b/src/Bundle/ChillMainBundle/Entity/User.php index c188a8e3f..80a0b5b2a 100644 --- a/src/Bundle/ChillMainBundle/Entity/User.php +++ b/src/Bundle/ChillMainBundle/Entity/User.php @@ -43,7 +43,7 @@ class User implements AdvancedUserInterface /** * Array where SAML attributes's data are stored. * - * @ORM\Column(type="json", nullable=true) + * @ORM\Column(type="json", nullable=false) */ private array $attributes = []; @@ -359,16 +359,21 @@ class User implements AdvancedUserInterface } } - /** - * Set attributes. - * - * @param array $attributes - * - * @return Report - */ - public function setAttributes($attributes) + public function setAttributeByDomain(string $domain, string $key, $value): self { - $this->attributes = $attributes; + $this->attributes[$domain][$key] = $value; + + return $this; + } + + /** + * Merge the attributes with existing attributes. + * + * Only the key provided will be created or updated. For a two-level array, use @see{User::setAttributeByDomain} + */ + public function setAttributes(array $attributes): self + { + $this->attributes = array_merge($this->attributes, $attributes); return $this; } @@ -506,4 +511,11 @@ class User implements AdvancedUserInterface return $this; } + + public function unsetAttribute($key): self + { + unset($this->attributes[$key]); + + return $this; + } } diff --git a/src/Bundle/ChillMainBundle/Entity/Workflow/EntityWorkflowStep.php b/src/Bundle/ChillMainBundle/Entity/Workflow/EntityWorkflowStep.php index 8b3175408..d86770560 100644 --- a/src/Bundle/ChillMainBundle/Entity/Workflow/EntityWorkflowStep.php +++ b/src/Bundle/ChillMainBundle/Entity/Workflow/EntityWorkflowStep.php @@ -133,8 +133,7 @@ class EntityWorkflowStep if (!$this->destUser->contains($user)) { $this->destUser[] = $user; $this->getEntityWorkflow() - ->addSubscriberToFinal($user) - ->addSubscriberToStep($user); + ->addSubscriberToFinal($user); } return $this; @@ -145,8 +144,7 @@ class EntityWorkflowStep if (!$this->destUserByAccessKey->contains($user) && !$this->destUser->contains($user)) { $this->destUserByAccessKey[] = $user; $this->getEntityWorkflow() - ->addSubscriberToFinal($user) - ->addSubscriberToStep($user); + ->addSubscriberToFinal($user); } return $this; diff --git a/src/Bundle/ChillMainBundle/Export/DirectExportInterface.php b/src/Bundle/ChillMainBundle/Export/DirectExportInterface.php index 9e89ebaad..9703a42de 100644 --- a/src/Bundle/ChillMainBundle/Export/DirectExportInterface.php +++ b/src/Bundle/ChillMainBundle/Export/DirectExportInterface.php @@ -11,28 +11,22 @@ declare(strict_types=1); namespace Chill\MainBundle\Export; -use Symfony\Component\Security\Core\Role\Role; +use Symfony\Component\HttpFoundation\Response; interface DirectExportInterface extends ExportElementInterface { /** * Generate the export. - * - * @return \Symfony\Component\HttpFoundation\Response */ - public function generate(array $acl, array $data = []); + public function generate(array $acl, array $data = []): Response; /** * get a description, which will be used in UI (and translated). - * - * @return string */ - public function getDescription(); + public function getDescription(): string; /** * authorized role. - * - * @return \Symfony\Component\Security\Core\Role\Role */ - public function requiredRole(); + public function requiredRole(): string; } diff --git a/src/Bundle/ChillMainBundle/Export/ExportInterface.php b/src/Bundle/ChillMainBundle/Export/ExportInterface.php index 8192f0199..be43ad47a 100644 --- a/src/Bundle/ChillMainBundle/Export/ExportInterface.php +++ b/src/Bundle/ChillMainBundle/Export/ExportInterface.php @@ -139,10 +139,8 @@ interface ExportInterface extends ExportElementInterface /** * Return the required Role to execute the Export. - * - * @return \Symfony\Component\Security\Core\Role\Role */ - public function requiredRole(); + public function requiredRole(): string; /** * Inform which ModifiersInterface (i.e. AggregatorInterface, FilterInterface) diff --git a/src/Bundle/ChillMainBundle/Export/ExportManager.php b/src/Bundle/ChillMainBundle/Export/ExportManager.php index ef7ac2a5c..f39926083 100644 --- a/src/Bundle/ChillMainBundle/Export/ExportManager.php +++ b/src/Bundle/ChillMainBundle/Export/ExportManager.php @@ -13,11 +13,9 @@ namespace Chill\MainBundle\Export; use Chill\MainBundle\Form\Type\Export\ExportType; use Chill\MainBundle\Form\Type\Export\PickCenterType; -use Chill\MainBundle\Security\Authorization\AuthorizationHelper; -use Doctrine\ORM\EntityManagerInterface; +use Chill\MainBundle\Security\Authorization\AuthorizationHelperInterface; use Doctrine\ORM\QueryBuilder; use Generator; -use InvalidArgumentException; use LogicException; use Psr\Log\LoggerInterface; use RuntimeException; @@ -42,52 +40,36 @@ class ExportManager /** * The collected aggregators, injected by DI. * - * @var AggregatorInterface[] + * @var array|AggregatorInterface[] */ - private $aggregators = []; + private array $aggregators = []; - /** - * @var AuthorizationChecker - */ - private $authorizationChecker; + private AuthorizationCheckerInterface $authorizationChecker; - /** - * @var AuthorizationHelper - */ - private $authorizationHelper; - - /** - * @var EntityManagerInterface - */ - private $em; + private AuthorizationHelperInterface $authorizationHelper; /** * Collected Exports, injected by DI. * - * @var ExportInterface[] + * @var array|ExportInterface[] */ - private $exports = []; + private array $exports = []; /** * The collected filters, injected by DI. * - * @var FilterInterface[] + * @var array|FilterInterface[] */ - private $filters = []; + private array $filters = []; /** * Collected Formatters, injected by DI. * - * @var FormatterInterface[] + * @var array|FormatterInterface[] */ - private $formatters = []; + private array $formatters = []; - /** - * a logger. - * - * @var LoggerInterface - */ - private $logger; + private LoggerInterface $logger; /** * @var \Symfony\Component\Security\Core\User\UserInterface @@ -96,16 +78,28 @@ class ExportManager public function __construct( LoggerInterface $logger, - EntityManagerInterface $em, AuthorizationCheckerInterface $authorizationChecker, - AuthorizationHelper $authorizationHelper, - TokenStorageInterface $tokenStorage + AuthorizationHelperInterface $authorizationHelper, + TokenStorageInterface $tokenStorage, + iterable $exports, + iterable $aggregators, + iterable $filters + //iterable $formatters, + //iterable $exportElementProvider ) { $this->logger = $logger; - $this->em = $em; $this->authorizationChecker = $authorizationChecker; $this->authorizationHelper = $authorizationHelper; $this->user = $tokenStorage->getToken()->getUser(); + $this->exports = iterator_to_array($exports); + $this->aggregators = iterator_to_array($aggregators); + $this->filters = iterator_to_array($filters); + // NOTE: PHP crashes on the next line (exit error code 11). This is desactivated until further investigation + //$this->formatters = iterator_to_array($formatters); + + //foreach ($exportElementProvider as $prefix => $provider) { + // $this->addExportElementsProvider($provider, $prefix); + //} } /** @@ -155,52 +149,17 @@ class ExportManager } } - /** - * add an aggregator. - * - * @internal used by DI - * - * @param string $alias - */ - public function addAggregator(AggregatorInterface $aggregator, $alias) - { - $this->aggregators[$alias] = $aggregator; - } - - /** - * add an export. - * - * @internal used by DI - * - * @param DirectExportInterface|ExportInterface $export - * @param type $alias - */ - public function addExport($export, $alias) - { - if ($export instanceof ExportInterface || $export instanceof DirectExportInterface) { - $this->exports[$alias] = $export; - } else { - throw new InvalidArgumentException(sprintf( - 'The export with alias %s ' - . 'does not implements %s or %s.', - $alias, - ExportInterface::class, - DirectExportInterface::class - )); - } - } - public function addExportElementsProvider(ExportElementsProviderInterface $provider, $prefix) { foreach ($provider->getExportElements() as $suffix => $element) { $alias = $prefix . '_' . $suffix; if ($element instanceof ExportInterface) { - $this->addExport($element, $alias); + $this->exports[$alias] = $element; } elseif ($element instanceof FilterInterface) { - $this->addFilter($element, $alias); + $this->filters[$alias] = $element; } elseif ($element instanceof AggregatorInterface) { - $this->addAggregator($element, $alias); + $this->aggregators[$alias] = $element; } elseif ($element instanceof FormatterInterface) { $this->addFormatter($element, $alias); } else { @@ -210,24 +169,12 @@ class ExportManager } } - /** - * add a Filter. - * - * @internal Normally used by the dependency injection - * - * @param string $alias - */ - public function addFilter(FilterInterface $filter, $alias) - { - $this->filters[$alias] = $filter; - } - /** * add a formatter. * * @internal used by DI * - * @param type $alias + * @param string $alias */ public function addFormatter(FormatterInterface $formatter, $alias) { @@ -245,7 +192,6 @@ class ExportManager public function generate($exportAlias, array $pickedCentersData, array $data, array $formatterData) { $export = $this->getExport($exportAlias); - //$qb = $this->em->createQueryBuilder(); $centers = $this->getPickedCenters($pickedCentersData); if ($export instanceof DirectExportInterface) { @@ -547,31 +493,24 @@ class ExportManager . 'an ExportInterface.'); } - if (null === $centers) { - $centers = $this->authorizationHelper->getReachableCenters( + if (null === $centers || [] !== $centers) { + // we want to try if at least one center is reachabler + return [] !== $this->authorizationHelper->getReachableCenters( $this->user, - $role->getRole(), + $role ); } - if (count($centers) === 0) { - return false; - } - foreach ($centers as $center) { - if ($this->authorizationChecker->isGranted($role->getRole(), $center) === false) { + if (false === $this->authorizationChecker->isGranted($role, $center)) { //debugging $this->logger->debug('user has no access to element', [ 'method' => __METHOD__, 'type' => get_class($element), 'center' => $center->getName(), - 'role' => $role->getRole(), + 'role' => $role, ]); - ///// Bypasse les autorisations qui empêche d'afficher les nouveaux exports - return true; - ///// TODO supprimer le return true - return false; } } @@ -594,7 +533,7 @@ class ExportManager 'center' => $center, 'circles' => $this->authorizationHelper->getReachableScopes( $this->user, - $element->requiredRole()->getRole(), + $element->requiredRole(), $center ), ]; diff --git a/src/Bundle/ChillMainBundle/Export/Formatter/SpreadSheetFormatter.php b/src/Bundle/ChillMainBundle/Export/Formatter/SpreadSheetFormatter.php index de9657282..6b77e5b2b 100644 --- a/src/Bundle/ChillMainBundle/Export/Formatter/SpreadSheetFormatter.php +++ b/src/Bundle/ChillMainBundle/Export/Formatter/SpreadSheetFormatter.php @@ -230,7 +230,8 @@ class SpreadSheetFormatter implements FormatterInterface $worksheet->fromArray( $sortedResults, null, - 'A' . $line + 'A' . $line, + true ); return $line + count($sortedResults) + 1; @@ -444,6 +445,8 @@ class SpreadSheetFormatter implements FormatterInterface $this->initializeCache($key); } + $value = null === $value ? '' : $value; + return call_user_func($this->cacheDisplayableResult[$key], $value); } @@ -495,8 +498,13 @@ class SpreadSheetFormatter implements FormatterInterface // 3. iterate on `keysExportElementAssociation` to store the callable // in cache foreach ($keysExportElementAssociation as $key => [$element, $data]) { - $this->cacheDisplayableResult[$key] = - $element->getLabels($key, array_unique($allValues[$key]), $data); + // handle the case when there is not results lines (query is empty) + if ([] === $allValues) { + $this->cacheDisplayableResult[$key] = $element->getLabels($key, ['_header'], $data); + } else { + $this->cacheDisplayableResult[$key] = + $element->getLabels($key, array_unique($allValues[$key]), $data); + } } // the cache is initialized ! diff --git a/src/Bundle/ChillMainBundle/Export/Formatter/SpreadsheetListFormatter.php b/src/Bundle/ChillMainBundle/Export/Formatter/SpreadsheetListFormatter.php index ab9c2e893..0e5e339ea 100644 --- a/src/Bundle/ChillMainBundle/Export/Formatter/SpreadsheetListFormatter.php +++ b/src/Bundle/ChillMainBundle/Export/Formatter/SpreadsheetListFormatter.php @@ -20,11 +20,12 @@ use PhpOffice\PhpSpreadsheet\Shared\Date; use PhpOffice\PhpSpreadsheet\Spreadsheet; use PhpOffice\PhpSpreadsheet\Style\NumberFormat; use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet; +use RuntimeException; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\HttpFoundation\Response; -use Symfony\Contracts\Translation\TranslatorInterface; +use Symfony\Contracts\Translation\TranslatorInterface; use function array_key_exists; use function array_keys; use function array_map; @@ -80,7 +81,7 @@ class SpreadsheetListFormatter implements FormatterInterface * * @uses appendAggregatorForm * - * @param type $exportAlias + * @param string $exportAlias */ public function buildForm( FormBuilderInterface $builder, @@ -144,8 +145,6 @@ class SpreadsheetListFormatter implements FormatterInterface $i = 1; foreach ($result as $row) { - $line = []; - if (true === $this->formatterData['numerotation']) { $worksheet->setCellValue('A' . ($i + 1), (string) $i); } @@ -155,13 +154,22 @@ class SpreadsheetListFormatter implements FormatterInterface foreach ($row as $key => $value) { $row = $a . ($i + 1); - if ($value instanceof DateTimeInterface) { - $worksheet->setCellValue($row, Date::PHPToExcel($value)); - $worksheet->getStyle($row) - ->getNumberFormat() - ->setFormatCode(NumberFormat::FORMAT_DATE_DDMMYYYY); + $formattedValue = $this->getLabel($key, $value); + + if ($formattedValue instanceof DateTimeInterface) { + $worksheet->setCellValue($row, Date::PHPToExcel($formattedValue)); + + if ($formattedValue->format('His') === '000000') { + $worksheet->getStyle($row) + ->getNumberFormat() + ->setFormatCode(NumberFormat::FORMAT_DATE_DDMMYYYY); + } else { + $worksheet->getStyle($row) + ->getNumberFormat() + ->setFormatCode(NumberFormat::FORMAT_DATE_DATETIME); + } } else { - $worksheet->setCellValue($row, $this->getLabel($key, $value)); + $worksheet->setCellValue($row, $formattedValue); } ++$a; } @@ -259,6 +267,10 @@ class SpreadsheetListFormatter implements FormatterInterface foreach ($keys as $key) { // get an array with all values for this key if possible $values = array_map(static function ($v) use ($key) { + if (!array_key_exists($key, $v)) { + throw new RuntimeException(sprintf('This key does not exists: %s. Available keys are %s', $key, implode(', ', array_keys($v)))); + } + return $v[$key]; }, $this->result); // store the label in the labelsCache property diff --git a/src/Bundle/ChillMainBundle/Export/Helper/DateTimeHelper.php b/src/Bundle/ChillMainBundle/Export/Helper/DateTimeHelper.php new file mode 100644 index 000000000..86a2458b2 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Export/Helper/DateTimeHelper.php @@ -0,0 +1,60 @@ +translator = $translator; + } + + public function getLabel($header): callable + { + return function ($value) use ($header) { + if ('_header' === $value) { + return $this->translator->trans($header); + } + + if (null === $value) { + return ''; + } + + // warning: won't work with DateTimeImmutable as we reset time a few lines later + $date = DateTime::createFromFormat('Y-m-d', $value); + $hasTime = false; + + if (false === $date) { + $date = DateTime::createFromFormat('Y-m-d H:i:s', $value); + $hasTime = true; + } + + // check that the creation could occurs. + if (false === $date) { + throw new Exception(sprintf('The value %s could ' + . 'not be converted to %s', $value, DateTime::class)); + } + + if (!$hasTime) { + $date->setTime(0, 0, 0); + } + + return $date; + }; + } +} diff --git a/src/Bundle/ChillMainBundle/Export/Helper/ExportAddressHelper.php b/src/Bundle/ChillMainBundle/Export/Helper/ExportAddressHelper.php new file mode 100644 index 000000000..9077b501a --- /dev/null +++ b/src/Bundle/ChillMainBundle/Export/Helper/ExportAddressHelper.php @@ -0,0 +1,426 @@ + self::F_COUNTRY, + 'postal_code' => self::F_POSTAL_CODE, + 'street' => self::F_STREET, + 'building' => self::F_BUILDING, + 'string' => self::F_AS_STRING, + 'geom' => self::F_GEOM, + 'attributes' => self::F_ATTRIBUTES, + 'geographical_units' => self::F_GEOGRAPHICAL_UNITS, + ]; + + private const COLUMN_MAPPING = [ + 'country' => ['country'], + 'postal_code' => ['postcode_code', 'postcode_name'], + 'street' => ['street', 'streetNumber'], + 'building' => ['buildingName', 'corridor', 'distribution', 'extra', 'flat', 'floor', 'steps'], + 'string' => ['_as_string'], + 'attributes' => ['isNoAddress', 'confidential', 'id'], + 'geom' => ['_lat', '_lon'], + 'geographical_units' => ['_unit_names', '_unit_refs'], + ]; + + private AddressRender $addressRender; + + private AddressRepository $addressRepository; + + private GeographicalUnitLayerRepositoryInterface $geographicalUnitLayerRepository; + + private TranslatableStringHelperInterface $translatableStringHelper; + + /** + * @var array>|null + */ + private ?array $unitNamesKeysCache = []; + + /** + * @var array>|null + */ + private ?array $unitRefsKeysCache = []; + + public function __construct( + AddressRender $addressRender, + AddressRepository $addressRepository, + GeographicalUnitLayerRepositoryInterface $geographicalUnitLayerRepository, + TranslatableStringHelperInterface $translatableStringHelper + ) { + $this->addressRepository = $addressRepository; + $this->geographicalUnitLayerRepository = $geographicalUnitLayerRepository; + $this->translatableStringHelper = $translatableStringHelper; + $this->addressRender = $addressRender; + } + + public function addSelectClauses(int $params, QueryBuilder $queryBuilder, $entityName = 'address', $prefix = 'add') + { + foreach (self::ALL as $key => $bitmask) { + if (($params & $bitmask) === $bitmask) { + foreach (self::COLUMN_MAPPING[$key] as $field) { + switch ($field) { + case 'id': + case '_as_string': + $queryBuilder->addSelect(sprintf('%s.id AS %s%s', $entityName, $prefix, $field)); + + break; + + case 'street': + case 'streetNumber': + case 'floor': + case 'corridor': + case 'steps': + case 'buildingName': + case 'flat': + case 'distribution': + case 'extra': + $queryBuilder->addSelect(sprintf('%s.%s AS %s%s', $entityName, $field, $prefix, $field)); + + break; + + case 'country': + case 'postcode_name': + case 'postcode_code': + $postCodeAlias = sprintf('%spostcode_t', $prefix); + + if (!in_array($postCodeAlias, $queryBuilder->getAllAliases(), true)) { + $queryBuilder->leftJoin($entityName . '.postcode', $postCodeAlias); + } + + if ('postcode_name' === $field) { + $queryBuilder->addSelect(sprintf('%s.%s AS %s%s', $postCodeAlias, 'name', $prefix, $field)); + + break; + } + + if ('postcode_code' === $field) { + $queryBuilder->addSelect(sprintf('%s.%s AS %s%s', $postCodeAlias, 'code', $prefix, $field)); + + break; + } + + $countryAlias = sprintf('%scountry_t', $prefix); + + if (!in_array($countryAlias, $queryBuilder->getAllAliases(), true)) { + $queryBuilder->leftJoin(sprintf('%s.country', $postCodeAlias), $countryAlias); + } + + $queryBuilder->addSelect(sprintf('%s.%s AS %s%s', $countryAlias, 'name', $prefix, $field)); + + break; + + case 'isNoAddress': + case 'confidential': + $queryBuilder->addSelect(sprintf('CASE WHEN %s.%s = \'TRUE\' THEN 1 ELSE 0 END AS %s%s', $entityName, $field, $prefix, $field)); + + break; + + case '_lat': + $queryBuilder->addSelect(sprintf('ST_Y(%s.point) AS %s%s', $entityName, $prefix, $field)); + + break; + + case '_lon': + $queryBuilder->addSelect(sprintf('ST_X(%s.point) AS %s%s', $entityName, $prefix, $field)); + + break; + + case '_unit_names': + foreach ($this->generateKeysForUnitsNames($prefix) as $alias => $layer) { + $queryBuilder + ->addSelect( + sprintf( + '(SELECT AGGREGATE(u_n_%s_%s.unitName) FROM %s u_n_%s_%s WHERE u_n_%s_%s MEMBER OF %s.geographicalUnits AND u_n_%s_%s.layer = :layer_%s_%s) AS %s', + $prefix, + $layer->getId(), + GeographicalUnit::class, + $prefix, + $layer->getId(), + $prefix, + $layer->getId(), + $entityName, + $prefix, + $layer->getId(), + $prefix, + $layer->getId(), + $alias + ) + ) + ->setParameter(sprintf('layer_%s_%s', $prefix, $layer->getId()), $layer); + } + + break; + + case '_unit_refs': + foreach ($this->generateKeysForUnitsRefs($prefix) as $alias => $layer) { + $queryBuilder + ->addSelect( + sprintf( + '(SELECT AGGREGATE(u_r_%s_%s.unitRefId) FROM %s u_r_%s_%s WHERE u_r_%s_%s MEMBER OF %s.geographicalUnits AND u_r_%s_%s.layer = :layer_%s_%s) AS %s', + $prefix, + $layer->getId(), + GeographicalUnit::class, + $prefix, + $layer->getId(), + $prefix, + $layer->getId(), + $entityName, + $prefix, + $layer->getId(), + $prefix, + $layer->getId(), + $alias + ) + ) + ->setParameter(sprintf('layer_%s_%s', $prefix, $layer->getId()), $layer); + } + + break; + + default: + throw new LogicException(sprintf('This key is not supported: %s, field %s', $key, $field)); + } + } + } + } + } + + /** + * @param self::F_* $params + * + * @return array|string[] + */ + public function getKeys(int $params, string $prefix = ''): array + { + $prefixes = []; + + foreach (self::ALL as $key => $bitmask) { + if (($params & $bitmask) === $bitmask) { + if ('geographical_units' === $key) { + // geographical unit generate keys dynamically, depending on layers + $prefixes = array_merge($prefixes, array_keys($this->generateKeysForUnitsNames($prefix)), array_keys($this->generateKeysForUnitsRefs($prefix))); + + continue; + } + + $prefixes = array_merge( + $prefixes, + array_map( + static function ($item) use ($prefix) { + return $prefix . $item; + }, + self::COLUMN_MAPPING[$key] + ) + ); + } + } + + return $prefixes; + } + + public function getLabel($key, array $values, $data, string $prefix = '', string $translationPrefix = 'export.address_helper.'): callable + { + $sanitizedKey = substr($key, strlen($prefix)); + + switch ($sanitizedKey) { + case 'id': + case 'street': + case 'streetNumber': + case 'buildingName': + case 'corridor': + case 'distribution': + case 'extra': + case 'flat': + case 'floor': + case '_lat': + case '_lon': + case 'steps': + case 'postcode_code': + case 'postcode_name': + return static function ($value) use ($sanitizedKey, $translationPrefix) { + if ('_header' === $value) { + return $translationPrefix . $sanitizedKey; + } + + if (null === $value) { + return ''; + } + + return $value; + }; + + case 'country': + return function ($value) use ($key) { + if ('_header' === $value) { + return 'export.list.acp' . $key; + } + + if (null === $value) { + return ''; + } + + return $this->translatableStringHelper->localize(json_decode($value, true)); + }; + + case 'isNoAddress': + case 'confidential': + return static function ($value) use ($sanitizedKey, $translationPrefix) { + if ('_header' === $value) { + return $translationPrefix . $sanitizedKey; + } + + switch ($value) { + case null: + return ''; + + case true: + return 1; + + case false: + return 0; + + default: + throw new LogicException('this value is not supported for ' . $sanitizedKey . ': ' . $value); + } + }; + + case '_as_string': + return function ($value) use ($sanitizedKey, $translationPrefix) { + if ('_header' === $value) { + return $translationPrefix . $sanitizedKey; + } + + if (null === $value) { + return ''; + } + + $address = $this->addressRepository->find($value); + + return $this->addressRender->renderString($address, []); + }; + + default: + $layerNamesKeys = array_merge($this->generateKeysForUnitsNames($prefix), $this->generateKeysForUnitsRefs($prefix)); + + if (array_key_exists($key, $layerNamesKeys)) { + return function ($value) use ($key, $layerNamesKeys) { + if ('_header' === $value) { + $header = $this->translatableStringHelper->localize($layerNamesKeys[$key]->getName()); + + if (str_contains($key, 'unit_ref')) { + $header .= ' (id)'; + } + + return $header; + } + + if (null === $value) { + return ''; + } + + $decodedValues = json_decode($value, true); + + switch (count($decodedValues)) { + case 0: + return ''; + + case 1: + return $decodedValues[0]; + + default: + return implode('|', $decodedValues); + } + }; + } + + throw new LogicException('this key is not supported: ' . $sanitizedKey); + } + } + + /** + * @return array + */ + private function generateKeysForUnitsNames(string $prefix): array + { + if (array_key_exists($prefix, $this->unitNamesKeysCache)) { + return $this->unitNamesKeysCache[$prefix]; + } + + $keys = []; + + foreach ($this->geographicalUnitLayerRepository->findAllHavingUnits() as $layer) { + $keys[$prefix . 'unit_names_' . $layer->getId()] = $layer; + } + + return $this->unitNamesKeysCache[$prefix] = $keys; + } + + /** + * @return array + */ + private function generateKeysForUnitsRefs(string $prefix): array + { + if (array_key_exists($prefix, $this->unitRefsKeysCache)) { + return $this->unitRefsKeysCache[$prefix]; + } + + $keys = []; + + foreach ($this->geographicalUnitLayerRepository->findAllHavingUnits() as $layer) { + $keys[$prefix . 'unit_refs_' . $layer->getId()] = $layer; + } + + return $this->unitRefsKeysCache[$prefix] = $keys; + } +} diff --git a/src/Bundle/ChillMainBundle/Export/Helper/TranslatableStringExportLabelHelper.php b/src/Bundle/ChillMainBundle/Export/Helper/TranslatableStringExportLabelHelper.php new file mode 100644 index 000000000..44ce2b194 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Export/Helper/TranslatableStringExportLabelHelper.php @@ -0,0 +1,70 @@ +translatableStringHelper = $translatableStringHelper; + } + + public function getLabel(string $key, array $values, string $header) + { + return function ($value) use ($header) { + if ('_header' === $value) { + return $header; + } + + if (null === $value) { + return ''; + } + + return $this->translatableStringHelper->localize(json_decode($value, true)); + }; + } + + public function getLabelMulti(string $key, array $values, string $header) + { + return function ($value) use ($header) { + if ('_header' === $value) { + return $header; + } + + if (null === $value) { + return ''; + } + + $decoded = json_decode($value, true); + + return implode( + '|', + array_unique( + array_map( + fn (array $translatableString) => $this->translatableStringHelper->localize($translatableString), + array_filter($decoded, static fn ($elem) => null !== $elem) + ) + ) + ); + }; + } +} diff --git a/src/Bundle/ChillMainBundle/Export/Helper/UserHelper.php b/src/Bundle/ChillMainBundle/Export/Helper/UserHelper.php new file mode 100644 index 000000000..d8eb7e9cc --- /dev/null +++ b/src/Bundle/ChillMainBundle/Export/Helper/UserHelper.php @@ -0,0 +1,84 @@ +userRender = $userRender; + $this->userRepository = $userRepository; + } + + public function getLabel($key, array $values, string $header): callable + { + return function ($value) use ($header) { + if ('_header' === $value) { + return $header; + } + + if (null === $value || null === $user = $this->userRepository->find($value)) { + return ''; + } + + return $this->userRender->renderString($user, []); + }; + } + + public function getLabelMulti($key, array $values, string $header): callable + { + return function ($value) { + if ('_header' === $value) { + return 'users name'; + } + + if (null === $value) { + return ''; + } + + $decoded = json_decode($value); + + if (0 === count($decoded)) { + return ''; + } + + return + implode( + '|', + array_map( + function (int $userId) { + $user = $this->userRepository->find($userId); + + if (null === $user) { + return ''; + } + + return $this->userRender->renderString($user, []); + }, + array_unique( + array_filter($decoded, static fn (?int $userId) => null !== $userId), + SORT_NUMERIC + ) + ) + ); + }; + } +} diff --git a/src/Bundle/ChillMainBundle/Export/ModifierInterface.php b/src/Bundle/ChillMainBundle/Export/ModifierInterface.php index b0bf48559..50cb17e09 100644 --- a/src/Bundle/ChillMainBundle/Export/ModifierInterface.php +++ b/src/Bundle/ChillMainBundle/Export/ModifierInterface.php @@ -26,9 +26,9 @@ interface ModifierInterface extends ExportElementInterface * If null, will used the ExportInterface::requiredRole role from * the current executing export. * - * @return \Symfony\Component\Security\Core\Role\Role|null A role required to execute this ModifiersInterface + * @return string|null A role required to execute this ModifiersInterface */ - public function addRole(); + public function addRole(): ?string; /** * Alter the query initiated by the export, to add the required statements diff --git a/src/Bundle/ChillMainBundle/Form/DataMapper/RollingDateDataMapper.php b/src/Bundle/ChillMainBundle/Form/DataMapper/RollingDateDataMapper.php new file mode 100644 index 000000000..21d0c3fde --- /dev/null +++ b/src/Bundle/ChillMainBundle/Form/DataMapper/RollingDateDataMapper.php @@ -0,0 +1,45 @@ +setData($viewData->getRoll()); + $forms['fixedDate']->setData($viewData->getFixedDate()); + } + + public function mapFormsToData($forms, &$viewData): void + { + $forms = iterator_to_array($forms); + + $viewData = new RollingDate( + $forms['roll']->getData(), + $forms['fixedDate']->getData() + ); + } +} diff --git a/src/Bundle/ChillMainBundle/Form/DataTransformer/IdToEntityDataTransformer.php b/src/Bundle/ChillMainBundle/Form/DataTransformer/IdToEntityDataTransformer.php new file mode 100644 index 000000000..5cc50f185 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Form/DataTransformer/IdToEntityDataTransformer.php @@ -0,0 +1,110 @@ +repository = $repository; + $this->multiple = $multiple; + $this->getId = $getId ?? static function (object $o) { return $o->getId(); }; + } + + /** + * @param string $value + * + * @return array|object[]|T[]|T|object + */ + public function reverseTransform($value) + { + if ($this->multiple) { + if (null === $value | '' === $value) { + return []; + } + + return array_map( + fn (string $id): ?object => $this->repository->findOneBy(['id' => (int) $id]), + explode(',', $value) + ); + } + + if (null === $value | '' === $value) { + return null; + } + + $object = $this->repository->findOneBy(['id' => (int) $value]); + + if (null === $object) { + throw new TransformationFailedException('could not find any object by object id'); + } + + return $object; + } + + /** + * @param object|T|object[]|T[] $value + */ + public function transform($value): string + { + if ($this->multiple) { + $ids = []; + + foreach ($value as $v) { + $ids[] = $id = call_user_func($this->getId, $v); + + if (null === $id) { + throw new TransformationFailedException('id is null'); + } + } + + return implode(',', $ids); + } + + if (null === $value) { + return ''; + } + + $id = call_user_func($this->getId, $value); + + if (null === $id) { + throw new TransformationFailedException('id is null'); + } + + return (string) $id; + } +} diff --git a/src/Bundle/ChillMainBundle/Form/DataTransformer/IdToLocationDataTransformer.php b/src/Bundle/ChillMainBundle/Form/DataTransformer/IdToLocationDataTransformer.php new file mode 100644 index 000000000..6dc6c0399 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Form/DataTransformer/IdToLocationDataTransformer.php @@ -0,0 +1,29 @@ +add('title', TextType::class, [ + 'required' => true, + ]) + ->add('description', ChillTextareaType::class, [ + 'required' => false, + ]); + } + + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'class' => SavedExport::class, + ]); + } +} diff --git a/src/Bundle/ChillMainBundle/Form/Type/DataTransformer/EntityToJsonTransformer.php b/src/Bundle/ChillMainBundle/Form/Type/DataTransformer/EntityToJsonTransformer.php index ecd668d2b..46fa8799f 100644 --- a/src/Bundle/ChillMainBundle/Form/Type/DataTransformer/EntityToJsonTransformer.php +++ b/src/Bundle/ChillMainBundle/Form/Type/DataTransformer/EntityToJsonTransformer.php @@ -51,7 +51,9 @@ class EntityToJsonTransformer implements DataTransformerInterface } return array_map( - function ($item) { return $this->denormalizeOne($item); }, + function ($item) { + return $this->denormalizeOne($item); + }, $denormalized ); } diff --git a/src/Bundle/ChillMainBundle/Form/Type/Export/PickCenterType.php b/src/Bundle/ChillMainBundle/Form/Type/Export/PickCenterType.php index a8b86404b..20db0b535 100644 --- a/src/Bundle/ChillMainBundle/Form/Type/Export/PickCenterType.php +++ b/src/Bundle/ChillMainBundle/Form/Type/Export/PickCenterType.php @@ -14,8 +14,7 @@ namespace Chill\MainBundle\Form\Type\Export; use Chill\MainBundle\Center\GroupingCenterInterface; use Chill\MainBundle\Entity\Center; use Chill\MainBundle\Export\ExportManager; -use Chill\MainBundle\Security\Authorization\AuthorizationHelper; -use Doctrine\ORM\EntityRepository; +use Chill\MainBundle\Security\Authorization\AuthorizationHelperInterface; use Symfony\Bridge\Doctrine\Form\Type\EntityType; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\CallbackTransformer; @@ -24,6 +23,7 @@ use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; +use Symfony\Component\Security\Core\User\UserInterface; use function array_intersect; use function array_key_exists; use function array_merge; @@ -38,30 +38,21 @@ class PickCenterType extends AbstractType { public const CENTERS_IDENTIFIERS = 'c'; - /** - * @var AuthorizationHelper - */ - protected $authorizationHelper; + protected AuthorizationHelperInterface $authorizationHelper; + + protected ExportManager $exportManager; /** - * @var ExportManager + * @var array|GroupingCenterInterface[] */ - protected $exportManager; + protected array $groupingCenters = []; - /** - * @var GroupingCenterInterface[] - */ - protected $groupingCenters = []; - - /** - * @var \Symfony\Component\Security\Core\User\UserInterface - */ - protected $user; + protected UserInterface $user; public function __construct( TokenStorageInterface $tokenStorage, ExportManager $exportManager, - AuthorizationHelper $authorizationHelper + AuthorizationHelperInterface $authorizationHelper ) { $this->exportManager = $exportManager; $this->user = $tokenStorage->getToken()->getUser(); @@ -78,22 +69,12 @@ class PickCenterType extends AbstractType $export = $this->exportManager->getExport($options['export_alias']); $centers = $this->authorizationHelper->getReachableCenters( $this->user, - (string) $export->requiredRole() + $export->requiredRole() ); $builder->add(self::CENTERS_IDENTIFIERS, EntityType::class, [ 'class' => Center::class, - 'query_builder' => static function (EntityRepository $er) use ($centers) { - $qb = $er->createQueryBuilder('c'); - $ids = array_map( - static function (Center $el) { - return $el->getId(); - }, - $centers - ); - - return $qb->where($qb->expr()->in('c.id', $ids)); - }, + 'choices' => $centers, 'multiple' => true, 'expanded' => true, 'choice_label' => static function (Center $c) { diff --git a/src/Bundle/ChillMainBundle/Form/Type/Listing/FilterOrderType.php b/src/Bundle/ChillMainBundle/Form/Type/Listing/FilterOrderType.php index 25b8dfece..aaa6afa24 100644 --- a/src/Bundle/ChillMainBundle/Form/Type/Listing/FilterOrderType.php +++ b/src/Bundle/ChillMainBundle/Form/Type/Listing/FilterOrderType.php @@ -11,6 +11,7 @@ declare(strict_types=1); namespace Chill\MainBundle\Form\Type\Listing; +use Chill\MainBundle\Form\Type\ChillDateType; use Chill\MainBundle\Templating\Listing\FilterOrderHelper; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\Extension\Core\Type\HiddenType; @@ -70,10 +71,38 @@ final class FilterOrderType extends \Symfony\Component\Form\AbstractType $builder->add($checkboxesBuilder); } + if (0 < count($helper->getDateRanges())) { + $dateRangesBuilder = $builder->create('dateRanges', null, ['compound' => true]); + + foreach ($helper->getDateRanges() as $name => $opts) { + $rangeBuilder = $dateRangesBuilder->create($name, null, [ + 'compound' => true, + 'label' => null === $opts['label'] ? false : $opts['label'] ?? $name, + ]); + + $rangeBuilder->add( + 'from', + ChillDateType::class, + ['input' => 'datetime_immutable', 'required' => false] + ); + $rangeBuilder->add( + 'to', + ChillDateType::class, + ['input' => 'datetime_immutable', 'required' => false] + ); + + $dateRangesBuilder->add($rangeBuilder); + } + + $builder->add($dateRangesBuilder); + } + foreach ($this->requestStack->getCurrentRequest()->query->getIterator() as $key => $value) { switch ($key) { case 'q': case 'checkboxes' . $key: + case $key . '_from': + case $key . '_to': break; case 'page': diff --git a/src/Bundle/ChillMainBundle/Form/Type/PickLocationTypeType.php b/src/Bundle/ChillMainBundle/Form/Type/PickLocationTypeType.php new file mode 100644 index 000000000..6774e0941 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Form/Type/PickLocationTypeType.php @@ -0,0 +1,50 @@ +translatableStringHelper = $translatableStringHelper; + } + + public function configureOptions(OptionsResolver $resolver) + { + $resolver + ->setDefaults([ + 'class' => LocationType::class, + 'choice_label' => function (LocationType $type) { + return $this->translatableStringHelper->localize($type->getTitle()); + }, + 'placeholder' => 'Pick a location type', + 'required' => false, + 'attr' => ['class' => 'select2'], + 'label' => 'Location type', + 'multiple' => false, + ]) + ->setAllowedTypes('multiple', ['bool']); + } + + public function getParent(): string + { + return EntityType::class; + } +} diff --git a/src/Bundle/ChillMainBundle/Form/Type/PickRollingDateType.php b/src/Bundle/ChillMainBundle/Form/Type/PickRollingDateType.php new file mode 100644 index 000000000..a4492526f --- /dev/null +++ b/src/Bundle/ChillMainBundle/Form/Type/PickRollingDateType.php @@ -0,0 +1,73 @@ +add('roll', ChoiceType::class, [ + 'choices' => array_combine( + array_map(static fn (string $item) => 'rolling_date.' . $item, RollingDate::ALL_T), + RollingDate::ALL_T + ), + 'multiple' => false, + 'expanded' => false, + 'label' => 'rolling_date.roll_movement', + ]) + ->add('fixedDate', ChillDateType::class, [ + 'input' => 'datetime_immutable', + 'label' => 'rolling_date.fixed_date_date', + ]); + + $builder->setDataMapper(new RollingDateDataMapper()); + } + + public function buildView(FormView $view, FormInterface $form, array $options) + { + $view->vars['uniqid'] = uniqid('rollingdate-'); + } + + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'class' => RollingDate::class, + 'empty_data' => new RollingDate(RollingDate::T_TODAY), + 'constraints' => [ + new Callback([$this, 'validate']), + ], + ]); + } + + public function validate($data, ExecutionContextInterface $context, $payload): void + { + /** @var RollingDate $data */ + if (RollingDate::T_FIXED_DATE === $data->getRoll() && null === $data->getFixedDate()) { + $context + ->buildViolation('rolling_date.When fixed date is selected, you must provide a date') + ->atPath('fixedDate') + ->addViolation(); + } + } +} diff --git a/src/Bundle/ChillMainBundle/Form/Type/PickUserLocationType.php b/src/Bundle/ChillMainBundle/Form/Type/PickUserLocationType.php new file mode 100644 index 000000000..792daa39e --- /dev/null +++ b/src/Bundle/ChillMainBundle/Form/Type/PickUserLocationType.php @@ -0,0 +1,57 @@ +translatableStringHelper = $translatableStringHelper; + $this->locationRepository = $locationRepository; + } + + public function configureOptions(OptionsResolver $resolver) + { + $resolver + ->setDefaults([ + 'class' => Location::class, + 'choices' => $this->locationRepository->findByPublicLocations(), + 'choice_label' => function (Location $entity) { + return $entity->getName() ? + $entity->getName() . ' (' . $this->translatableStringHelper->localize($entity->getLocationType()->getTitle()) . ')' : + $this->translatableStringHelper->localize($entity->getLocationType()->getTitle()); + }, + 'placeholder' => 'Pick a location', + 'required' => false, + 'attr' => ['class' => 'select2'], + 'label' => 'Current location', + 'multiple' => false, + ]) + ->setAllowedTypes('multiple', ['bool']); + } + + public function getParent(): string + { + return EntityType::class; + } +} diff --git a/src/Bundle/ChillMainBundle/Form/Type/ScopePickerType.php b/src/Bundle/ChillMainBundle/Form/Type/ScopePickerType.php index 06f6019d7..f65677dc7 100644 --- a/src/Bundle/ChillMainBundle/Form/Type/ScopePickerType.php +++ b/src/Bundle/ChillMainBundle/Form/Type/ScopePickerType.php @@ -15,9 +15,9 @@ use Chill\MainBundle\Entity\Center; use Chill\MainBundle\Entity\Scope; use Chill\MainBundle\Entity\User; use Chill\MainBundle\Form\DataMapper\ScopePickerDataMapper; -use Chill\MainBundle\Repository\ScopeRepository; use Chill\MainBundle\Security\Authorization\AuthorizationHelperInterface; -use Chill\MainBundle\Templating\TranslatableStringHelper; +use Chill\MainBundle\Templating\TranslatableStringHelperInterface; +use RuntimeException; use Symfony\Bridge\Doctrine\Form\Type\EntityType; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\HiddenType; @@ -26,11 +26,9 @@ use Symfony\Component\Form\FormInterface; use Symfony\Component\Form\FormView; use Symfony\Component\OptionsResolver\Options; use Symfony\Component\OptionsResolver\OptionsResolver; -use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; use Symfony\Component\Security\Core\Role\Role; -use Symfony\Component\Security\Core\Security; -use function array_map; +use Symfony\Component\Security\Core\Security; use function count; /** @@ -44,47 +42,39 @@ use function count; */ class ScopePickerType extends AbstractType { - protected AuthorizationHelperInterface $authorizationHelper; + private AuthorizationHelperInterface $authorizationHelper; - /** - * @var ScopeRepository - */ - protected $scopeRepository; + private Security $security; - protected Security $security; - - /** - * @var TokenStorageInterface - */ - protected $tokenStorage; - - /** - * @var TranslatableStringHelper - */ - protected $translatableStringHelper; + private TranslatableStringHelperInterface $translatableStringHelper; public function __construct( AuthorizationHelperInterface $authorizationHelper, - TokenStorageInterface $tokenStorage, - ScopeRepository $scopeRepository, Security $security, - TranslatableStringHelper $translatableStringHelper + TranslatableStringHelperInterface $translatableStringHelper ) { $this->authorizationHelper = $authorizationHelper; - $this->tokenStorage = $tokenStorage; - $this->scopeRepository = $scopeRepository; $this->security = $security; $this->translatableStringHelper = $translatableStringHelper; } public function buildForm(FormBuilderInterface $builder, array $options) { - $items = $this->authorizationHelper->getReachableScopes( - $this->security->getUser(), - $options['role'] instanceof Role ? $options['role']->getRole() : $options['role'], - $options['center'] + $items = array_filter( + $this->authorizationHelper->getReachableScopes( + $this->security->getUser(), + $options['role'] instanceof Role ? $options['role']->getRole() : $options['role'], + $options['center'] + ), + static function (Scope $s) { + return $s->isActive(); + } ); + if (0 === count($items)) { + throw new RuntimeException('no scopes are reachable. This form should not be shown to user'); + } + if (1 !== count($items)) { $builder->add('scope', EntityType::class, [ 'class' => Scope::class, @@ -123,35 +113,4 @@ class ScopePickerType extends AbstractType ->setRequired('role') ->setAllowedTypes('role', ['string', Role::class]); } - - /** - * @param array|Center|Center[] $center - * @param string $role - * - * @return \Doctrine\ORM\QueryBuilder - */ - protected function buildAccessibleScopeQuery($center, $role) - { - $roles = $this->authorizationHelper->getParentRoles($role); - $roles[] = $role; - $centers = $center instanceof Center ? [$center] : $center; - - $qb = $this->scopeRepository->createQueryBuilder('s'); - $qb - // jointure to center - ->join('s.roleScopes', 'rs') - ->join('rs.permissionsGroups', 'pg') - ->join('pg.groupCenters', 'gc') - // add center constraint - ->where($qb->expr()->in('IDENTITY(gc.center)', ':centers')) - ->setParameter('centers', array_map(static fn (Center $c) => $c->getId(), $centers)) - // role constraints - ->andWhere($qb->expr()->in('rs.role', ':roles')) - ->setParameter('roles', $roles) - // user contraint - ->andWhere(':user MEMBER OF gc.users') - ->setParameter('user', $this->tokenStorage->getToken()->getUser()); - - return $qb; - } } diff --git a/src/Bundle/ChillMainBundle/Form/UserCurrentLocationType.php b/src/Bundle/ChillMainBundle/Form/UserCurrentLocationType.php index 3f839d3ed..bf6a5d172 100644 --- a/src/Bundle/ChillMainBundle/Form/UserCurrentLocationType.php +++ b/src/Bundle/ChillMainBundle/Form/UserCurrentLocationType.php @@ -11,39 +11,14 @@ declare(strict_types=1); namespace Chill\MainBundle\Form; -use Chill\MainBundle\Entity\Location; -use Chill\MainBundle\Repository\LocationRepository; -use Chill\MainBundle\Templating\TranslatableStringHelper; -use Symfony\Bridge\Doctrine\Form\Type\EntityType; +use Chill\MainBundle\Form\Type\PickUserLocationType; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\FormBuilderInterface; class UserCurrentLocationType extends AbstractType { - private LocationRepository $locationRepository; - - private TranslatableStringHelper $translatableStringHelper; - - public function __construct(TranslatableStringHelper $translatableStringHelper, LocationRepository $locationRepository) - { - $this->translatableStringHelper = $translatableStringHelper; - $this->locationRepository = $locationRepository; - } - public function buildForm(FormBuilderInterface $builder, array $options) { - $builder - ->add('currentLocation', EntityType::class, [ - 'class' => Location::class, - 'choices' => $this->locationRepository->findByPublicLocations(), - 'choice_label' => function (Location $entity) { - return $entity->getName() ? - $entity->getName() . ' (' . $this->translatableStringHelper->localize($entity->getLocationType()->getTitle()) . ')' : - $this->translatableStringHelper->localize($entity->getLocationType()->getTitle()); - }, - 'placeholder' => 'Pick a location', - 'required' => false, - 'attr' => ['class' => 'select2'], - ]); + $builder->add('currentLocation', PickUserLocationType::class); } } diff --git a/src/Bundle/ChillMainBundle/Repository/CenterRepository.php b/src/Bundle/ChillMainBundle/Repository/CenterRepository.php index 027b0ddab..eaa0a6b1e 100644 --- a/src/Bundle/ChillMainBundle/Repository/CenterRepository.php +++ b/src/Bundle/ChillMainBundle/Repository/CenterRepository.php @@ -14,9 +14,8 @@ namespace Chill\MainBundle\Repository; use Chill\MainBundle\Entity\Center; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityRepository; -use Doctrine\Persistence\ObjectRepository; -final class CenterRepository implements ObjectRepository +final class CenterRepository implements CenterRepositoryInterface { private EntityRepository $repository; @@ -30,6 +29,11 @@ final class CenterRepository implements ObjectRepository return $this->repository->find($id, $lockMode, $lockVersion); } + public function findActive(): array + { + return $this->findAll(); + } + /** * @return Center[] */ diff --git a/src/Bundle/ChillMainBundle/Repository/CenterRepositoryInterface.php b/src/Bundle/ChillMainBundle/Repository/CenterRepositoryInterface.php new file mode 100644 index 000000000..b933a34b7 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Repository/CenterRepositoryInterface.php @@ -0,0 +1,27 @@ +repository = $entityManager->getRepository($this->getClassName()); + } + + public function find($id): ?Civility + { + return $this->repository->find($id); + } + + public function findAll(): array + { + return $this->repository->findAll(); + } + + public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array + { + return $this->repository->findBy($criteria, $orderBy, $limit, $offset); + } + + public function findOneBy(array $criteria): ?Civility + { + return $this->repository->findOneBy($criteria); + } + + public function getClassName(): string + { + return Civility::class; } } diff --git a/src/Bundle/ChillMainBundle/Repository/CivilityRepositoryInterface.php b/src/Bundle/ChillMainBundle/Repository/CivilityRepositoryInterface.php new file mode 100644 index 000000000..5d687ac7e --- /dev/null +++ b/src/Bundle/ChillMainBundle/Repository/CivilityRepositoryInterface.php @@ -0,0 +1,34 @@ +repository = $entityManager->getRepository($this->getClassName()); + } + + public function find($id): ?CronJobExecution + { + return $this->repository->find($id); + } + + /** + * @return array|CronJobExecution[] + */ + public function findAll(): array + { + return $this->repository->findAll(); + } + + /** + * @return array|CronJobExecution[] + */ + public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array + { + return $this->repository->findBy($criteria, $orderBy, $limit, $offset); + } + + public function findOneBy(array $criteria): ?CronJobExecution + { + return $this->repository->findOneBy($criteria); + } + + public function getClassName(): string + { + return CronJobExecution::class; + } +} diff --git a/src/Bundle/ChillMainBundle/Repository/CronJobExecutionRepositoryInterface.php b/src/Bundle/ChillMainBundle/Repository/CronJobExecutionRepositoryInterface.php new file mode 100644 index 000000000..df894bbfb --- /dev/null +++ b/src/Bundle/ChillMainBundle/Repository/CronJobExecutionRepositoryInterface.php @@ -0,0 +1,34 @@ +repository = $em->getRepository($this->getClassName()); + } + + public function find($id): ?GeographicalUnitLayer + { + return $this->repository->find($id); + } + + /** + * @return array|GeographicalUnitLayer[] + */ + public function findAll(): array + { + return $this->repository->findAll(); + } + + public function findAllHavingUnits(): array + { + $qb = $this->repository->createQueryBuilder('l'); + + return $qb->where($qb->expr()->gt('SIZE(l.units)', 0)) + ->getQuery() + ->getResult(); + } + + /** + * @return array|GeographicalUnitLayer[] + */ + public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array + { + return $this->repository->findBy($criteria, $orderBy, $limit, $offset); + } + + public function findOneBy(array $criteria): ?GeographicalUnitLayer + { + return $this->repository->findOneBy($criteria); + } + + public function getClassName(): string + { + return GeographicalUnitLayer::class; + } +} diff --git a/src/Bundle/ChillMainBundle/Repository/GeographicalUnitLayerRepositoryInterface.php b/src/Bundle/ChillMainBundle/Repository/GeographicalUnitLayerRepositoryInterface.php new file mode 100644 index 000000000..7d436b928 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Repository/GeographicalUnitLayerRepositoryInterface.php @@ -0,0 +1,23 @@ +repository = $em->getRepository($this->getClassName()); + $this->em = $em; + } + + public function find($id): ?GeographicalUnit + { + return $this->repository->find($id); + } + + /** + * Will return only partial object, where the @see{GeographicalUnit::geom} property is not loaded. + * + * @return array|GeographicalUnit[] + */ + public function findAll(): array + { + return $this->repository + ->createQueryBuilder('gu') + ->select(sprintf('NEW %s(gu.id, gu.unitName, gu.unitRefId, IDENTITY(gu.layer))', GeographicalUnit\SimpleGeographicalUnitDTO::class)) + ->addOrderBy('IDENTITY(gu.layer)') + ->addOrderBy(('gu.unitName')) + ->getQuery() + ->getResult(); + } + + public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): ?GeographicalUnit + { + return $this->repository->findBy($criteria, $orderBy, $limit, $offset); + } + + public function findOneBy(array $criteria): ?GeographicalUnit + { + return $this->repository->findOneBy($criteria); + } + + public function getClassName(): string + { + return GeographicalUnit::class; + } +} diff --git a/src/Bundle/ChillMainBundle/Repository/GeographicalUnitRepositoryInterface.php b/src/Bundle/ChillMainBundle/Repository/GeographicalUnitRepositoryInterface.php new file mode 100644 index 000000000..cdfb057e2 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Repository/GeographicalUnitRepositoryInterface.php @@ -0,0 +1,18 @@ +repository = $entityManager->getRepository(Language::class); + $this->repository = $entityManager->getRepository($this->getClassName()); } public function find($id, $lockMode = null, $lockVersion = null): ?Language @@ -54,7 +53,7 @@ final class LanguageRepository implements ObjectRepository return $this->repository->findOneBy($criteria, $orderBy); } - public function getClassName() + public function getClassName(): string { return Language::class; } diff --git a/src/Bundle/ChillMainBundle/Repository/LanguageRepositoryInterface.php b/src/Bundle/ChillMainBundle/Repository/LanguageRepositoryInterface.php new file mode 100644 index 000000000..397b264e4 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Repository/LanguageRepositoryInterface.php @@ -0,0 +1,37 @@ + + */ +class SavedExportRepository implements SavedExportRepositoryInterface +{ + private EntityRepository $repository; + + public function __construct(EntityManagerInterface $entityManager) + { + $this->repository = $entityManager->getRepository($this->getClassName()); + } + + public function find($id): ?SavedExport + { + return $this->repository->find($id); + } + + /** + * @return array|SavedExport[] + */ + public function findAll(): array + { + return $this->repository->findAll(); + } + + public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array + { + return $this->repository->findBy($criteria, $orderBy, $limit, $offset); + } + + public function findByUser(User $user, ?array $orderBy = [], ?int $limit = null, ?int $offset = null): array + { + $qb = $this->repository->createQueryBuilder('se'); + + $qb + ->where($qb->expr()->eq('se.user', ':user')) + ->setParameter('user', $user); + + if (null !== $limit) { + $qb->setMaxResults($limit); + } + + if (null !== $offset) { + $qb->setFirstResult($offset); + } + + foreach ($orderBy as $field => $order) { + $qb->addOrderBy('se.' . $field, $order); + } + + return $qb->getQuery()->getResult(); + } + + public function findOneBy(array $criteria): ?SavedExport + { + return $this->repository->findOneBy($criteria); + } + + public function getClassName(): string + { + return SavedExport::class; + } +} diff --git a/src/Bundle/ChillMainBundle/Repository/SavedExportRepositoryInterface.php b/src/Bundle/ChillMainBundle/Repository/SavedExportRepositoryInterface.php new file mode 100644 index 000000000..3b168505f --- /dev/null +++ b/src/Bundle/ChillMainBundle/Repository/SavedExportRepositoryInterface.php @@ -0,0 +1,40 @@ + + */ +interface SavedExportRepositoryInterface extends ObjectRepository +{ + public function find($id): ?SavedExport; + + /** + * @return array|SavedExport[] + */ + public function findAll(): array; + + public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array; + + /** + * @return array|SavedExport[] + */ + public function findByUser(User $user, ?array $orderBy = [], ?int $limit = null, ?int $offset = null): array; + + public function findOneBy(array $criteria): ?SavedExport; + + public function getClassName(): string; +} diff --git a/src/Bundle/ChillMainBundle/Repository/ScopeRepository.php b/src/Bundle/ChillMainBundle/Repository/ScopeRepository.php index b4ef7e486..158678fb6 100644 --- a/src/Bundle/ChillMainBundle/Repository/ScopeRepository.php +++ b/src/Bundle/ChillMainBundle/Repository/ScopeRepository.php @@ -14,9 +14,9 @@ namespace Chill\MainBundle\Repository; use Chill\MainBundle\Entity\Scope; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityRepository; -use Doctrine\Persistence\ObjectRepository; +use Doctrine\ORM\QueryBuilder; -final class ScopeRepository implements ObjectRepository +final class ScopeRepository implements ScopeRepositoryInterface { private EntityRepository $repository; @@ -25,7 +25,7 @@ final class ScopeRepository implements ObjectRepository $this->repository = $entityManager->getRepository(Scope::class); } - public function createQueryBuilder($alias, $indexBy = null) + public function createQueryBuilder($alias, $indexBy = null): QueryBuilder { return $this->repository->createQueryBuilder($alias, $indexBy); } @@ -43,6 +43,15 @@ final class ScopeRepository implements ObjectRepository return $this->repository->findAll(); } + public function findAllActive(): array + { + $qb = $this->repository->createQueryBuilder('s'); + + $qb->where('s.active = \'TRUE\''); + + return $qb->getQuery()->getResult(); + } + /** * @param mixed|null $limit * @param mixed|null $offset @@ -59,7 +68,7 @@ final class ScopeRepository implements ObjectRepository return $this->repository->findOneBy($criteria, $orderBy); } - public function getClassName() + public function getClassName(): string { return Scope::class; } diff --git a/src/Bundle/ChillMainBundle/Repository/ScopeRepositoryInterface.php b/src/Bundle/ChillMainBundle/Repository/ScopeRepositoryInterface.php new file mode 100644 index 000000000..3cfb6042d --- /dev/null +++ b/src/Bundle/ChillMainBundle/Repository/ScopeRepositoryInterface.php @@ -0,0 +1,45 @@ +repository->findAll(); } + public function findAllActive(): array + { + return $this->repository->findBy(['active' => true]); + } + /** * @param mixed|null $limit * @param mixed|null $offset @@ -49,12 +53,12 @@ class UserJobRepository implements ObjectRepository return $this->repository->findBy($criteria, $orderBy, $limit, $offset); } - public function findOneBy(array $criteria) + public function findOneBy(array $criteria): ?UserJob { return $this->repository->findOneBy($criteria); } - public function getClassName() + public function getClassName(): string { return UserJob::class; } diff --git a/src/Bundle/ChillMainBundle/Repository/UserJobRepositoryInterface.php b/src/Bundle/ChillMainBundle/Repository/UserJobRepositoryInterface.php new file mode 100644 index 000000000..a752f452b --- /dev/null +++ b/src/Bundle/ChillMainBundle/Repository/UserJobRepositoryInterface.php @@ -0,0 +1,42 @@ +countBy(['enabled' => true]); } + public function countByNotHavingAttribute(string $key): int + { + $rsm = new ResultSetMapping(); + $rsm->addScalarResult('count', 'count'); + + $sql = 'SELECT count(*) FROM users u WHERE NOT attributes ?? :key OR attributes IS NULL AND enabled IS TRUE'; + + return $this->entityManager->createNativeQuery($sql, $rsm)->setParameter(':key', $key)->getSingleScalarResult(); + } + public function countByUsernameOrEmail(string $pattern): int { $qb = $this->queryByUsernameOrEmail($pattern); @@ -83,6 +95,29 @@ final class UserRepository implements ObjectRepository return $this->findBy(['enabled' => true], $orderBy, $limit, $offset); } + /** + * Find users which does not have a key on attribute column. + * + * @return array|User[] + */ + public function findByNotHavingAttribute(string $key, ?int $limit = null, ?int $offset = null): array + { + $rsm = new ResultSetMappingBuilder($this->entityManager); + $rsm->addRootEntityFromClassMetadata(User::class, 'u'); + + $sql = 'SELECT ' . $rsm->generateSelectClause() . ' FROM users u WHERE NOT attributes ?? :key OR attributes IS NULL AND enabled IS TRUE'; + + if (null !== $limit) { + $sql .= " LIMIT {$limit}"; + } + + if (null !== $offset) { + $sql .= " OFFSET {$offset}"; + } + + return $this->entityManager->createNativeQuery($sql, $rsm)->setParameter(':key', $key)->getResult(); + } + public function findByUsernameOrEmail(string $pattern, ?array $orderBy = [], ?int $limit = null, ?int $offset = null): array { $qb = $this->queryByUsernameOrEmail($pattern); @@ -109,11 +144,15 @@ final class UserRepository implements ObjectRepository return $this->repository->findOneBy($criteria, $orderBy); } - public function findOneByUsernameOrEmail(string $pattern) + public function findOneByUsernameOrEmail(string $pattern): ?User { - $qb = $this->queryByUsernameOrEmail($pattern); + $qb = $this->queryByUsernameOrEmail($pattern)->select('u'); - return $qb->getQuery()->getSingleResult(); + try { + return $qb->getQuery()->getSingleResult(); + } catch (NoResultException $e) { + return null; + } } /** @@ -171,7 +210,7 @@ final class UserRepository implements ObjectRepository return $qb->getQuery()->getResult(); } - public function getClassName() + public function getClassName(): string { return User::class; } diff --git a/src/Bundle/ChillMainBundle/Repository/UserRepositoryInterface.php b/src/Bundle/ChillMainBundle/Repository/UserRepositoryInterface.php new file mode 100644 index 000000000..c869e1b82 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Repository/UserRepositoryInterface.php @@ -0,0 +1,73 @@ + { +export const dateToISO = (date: Date|null): string|null => { if (null === date) { return null; } @@ -29,7 +29,7 @@ const dateToISO = (date) => { * * **Experimental** */ -const ISOToDate = (str) => { +export const ISOToDate = (str: string|null): Date|null => { if (null === str) { return null; } @@ -38,25 +38,25 @@ const ISOToDate = (str) => { } let - [year, month, day] = str.split('-'); + [year, month, day] = str.split('-').map(p => parseInt(p)); - return new Date(year, month-1, day); + return new Date(year, month-1, day, 0, 0, 0, 0); } /** * Return a date object from iso string formatted as YYYY-mm-dd:HH:MM:ss+01:00 * */ -const ISOToDatetime = (str) => { +export const ISOToDatetime = (str: string|null): Date|null => { if (null === str) { return null; } let [cal, times] = str.split('T'), - [year, month, date] = cal.split('-'), + [year, month, date] = cal.split('-').map(s => parseInt(s)), [time, timezone] = times.split(times.charAt(8)), - [hours, minutes, seconds] = time.split(':') + [hours, minutes, seconds] = time.split(':').map(s => parseInt(s)); ; return new Date(year, month-1, date, hours, minutes, seconds); @@ -66,7 +66,7 @@ const ISOToDatetime = (str) => { * Convert a date to ISO8601, valid for usage in api * */ -const datetimeToISO = (date) => { +export const datetimeToISO = (date: Date): string => { let cal, time, offset; cal = [ date.getFullYear(), @@ -92,7 +92,7 @@ const datetimeToISO = (date) => { return x; }; -const intervalDaysToISO = (days) => { +export const intervalDaysToISO = (days: number|string|null): string => { if (null === days) { return 'P0D'; } @@ -100,7 +100,7 @@ const intervalDaysToISO = (days) => { return `P${days}D`; } -const intervalISOToDays = (str) => { +export const intervalISOToDays = (str: string|null): number|null => { if (null === str) { return null } @@ -154,12 +154,3 @@ const intervalISOToDays = (str) => { return days; } - -export { - dateToISO, - ISOToDate, - ISOToDatetime, - datetimeToISO, - intervalISOToDays, - intervalDaysToISO, -}; diff --git a/src/Bundle/ChillMainBundle/Resources/public/chill/scss/record_actions.scss b/src/Bundle/ChillMainBundle/Resources/public/chill/scss/record_actions.scss index d72b77e04..b8f60e250 100644 --- a/src/Bundle/ChillMainBundle/Resources/public/chill/scss/record_actions.scss +++ b/src/Bundle/ChillMainBundle/Resources/public/chill/scss/record_actions.scss @@ -5,6 +5,10 @@ ul.record_actions { justify-content: flex-end; padding: 0.5em 0; + &.inline { + display: inline-block; + } + &.column { flex-direction: column; } @@ -18,6 +22,13 @@ ul.record_actions { padding-right: 1em; } + &.small { + .btn { + padding: .25rem .5rem; + font-size: .75rem; + } + } + li { display: inline-block; list-style-type: none; diff --git a/src/Bundle/ChillMainBundle/Resources/public/lib/api/apiMethods.d.ts.backup b/src/Bundle/ChillMainBundle/Resources/public/lib/api/apiMethods.d.ts.backup new file mode 100644 index 000000000..07ce99e1a --- /dev/null +++ b/src/Bundle/ChillMainBundle/Resources/public/lib/api/apiMethods.d.ts.backup @@ -0,0 +1,4 @@ +export function fetchResults(uri: string, params: {item_per_page?: number}): Promise; + +export function makeFetch(method: "GET"|"POST"|"PATCH"|"DELETE", url: string, body: B, options: {[key: string]: string}): Promise; + diff --git a/src/Bundle/ChillMainBundle/Resources/public/lib/api/apiMethods.js b/src/Bundle/ChillMainBundle/Resources/public/lib/api/apiMethods.js deleted file mode 100644 index 80e59005f..000000000 --- a/src/Bundle/ChillMainBundle/Resources/public/lib/api/apiMethods.js +++ /dev/null @@ -1,110 +0,0 @@ -/** - * Generic api method that can be adapted to any fetch request - */ -const makeFetch = (method, url, body, options) => { - let opts = { - method: method, - headers: { - 'Content-Type': 'application/json;charset=utf-8' - }, - body: (body !== null) ? JSON.stringify(body) : null - }; - - if (typeof options !== 'undefined') { - opts = Object.assign(opts, options); - } - - return fetch(url, opts) - .then(response => { - if (response.ok) { - return response.json(); - } - - if (response.status === 422) { - return response.json().then(response => { - throw ValidationException(response) - }); - } - - if (response.status === 403) { - throw AccessException(response); - } - - throw { - name: 'Exception', - sta: response.status, - txt: response.statusText, - err: new Error(), - violations: response.body - }; - }); -} - -/** - * Fetch results with certain parameters - */ -const _fetchAction = (page, uri, params) => { - const item_per_page = 50; - if (params === undefined) { - params = {}; - } - let url = uri + '?' + new URLSearchParams({ item_per_page, page, ...params }); - - return fetch(url, { - method: 'GET', - headers: { - 'Content-Type': 'application/json;charset=utf-8' - }, - }).then(response => { - if (response.ok) { return response.json(); } - throw Error({ m: response.statusText }); - }); -}; - -const fetchResults = async (uri, params) => { - let promises = [], - page = 1; - let firstData = await _fetchAction(page, uri, params); - - promises.push(Promise.resolve(firstData.results)); - - if (firstData.pagination.more) { - do { - page = ++page; - promises.push(_fetchAction(page, uri, params).then(r => Promise.resolve(r.results))); - } while (page * firstData.pagination.items_per_page < firstData.count) - } - - return Promise.all(promises).then(values => values.flat()); -}; - -const fetchScopes = () => { - return fetchResults('/api/1.0/main/scope.json'); -}; - - -/** - * Error objects to be thrown - */ -const ValidationException = (response) => { - const error = {}; - error.name = 'ValidationException'; - error.violations = response.violations.map((violation) => `${violation.title}: ${violation.propertyPath}`); - error.titles = response.violations.map((violation) => violation.title); - error.propertyPaths = response.violations.map((violation) => violation.propertyPath); - return error; -} - -const AccessException = (response) => { - const error = {}; - error.name = 'AccessException'; - error.violations = ['You are not allowed to perform this action']; - - return error; -} - -export { - makeFetch, - fetchResults, - fetchScopes -} diff --git a/src/Bundle/ChillMainBundle/Resources/public/lib/api/apiMethods.ts b/src/Bundle/ChillMainBundle/Resources/public/lib/api/apiMethods.ts new file mode 100644 index 000000000..d12871bdd --- /dev/null +++ b/src/Bundle/ChillMainBundle/Resources/public/lib/api/apiMethods.ts @@ -0,0 +1,223 @@ +import {Scope} from '../../types'; + +export type body = {[key: string]: boolean|string|number|null}; +export type fetchOption = {[key: string]: boolean|string|number|null}; + +export interface Params { + [key: string]: number|string +} + +export interface PaginationResponse { + pagination: { + more: boolean; + items_per_page: number; + }; + results: T[]; + count: number; +} + +export interface FetchParams { + [K: string]: string|number|null; +}; + +export interface TransportExceptionInterface { + name: string; +} + +export interface ValidationExceptionInterface extends TransportExceptionInterface { + name: 'ValidationException'; + error: object; + violations: string[]; + titles: string[]; + propertyPaths: string[]; +} + +export interface ValidationErrorResponse extends TransportExceptionInterface { + violations: { + title: string; + propertyPath: string; + }[]; +} + +export interface AccessExceptionInterface extends TransportExceptionInterface { + name: 'AccessException'; + violations: string[]; +} + +export interface NotFoundExceptionInterface extends TransportExceptionInterface { + name: 'NotFoundException'; +} + +export interface ServerExceptionInterface extends TransportExceptionInterface { + name: 'ServerException'; + message: string; + code: number; + body: string; +} + + +/** + * Generic api method that can be adapted to any fetch request + */ +export const makeFetch = (method: 'POST'|'GET'|'PUT'|'PATCH'|'DELETE', url: string, body?: body | Input | null, options?: FetchParams): Promise => { + let opts = { + method: method, + headers: { + 'Content-Type': 'application/json;charset=utf-8' + }, + }; + + if (body !== null || typeof body !== 'undefined') { + Object.assign(opts, {body: JSON.stringify(body)}) + } + + if (typeof options !== 'undefined') { + opts = Object.assign(opts, options); + } + + return fetch(url, opts) + .then(response => { + if (response.ok) { + return response.json(); + } + + if (response.status === 422) { + return response.json().then(response => { + throw ValidationException(response) + }); + } + + if (response.status === 403) { + throw AccessException(response); + } + + throw { + name: 'Exception', + sta: response.status, + txt: response.statusText, + err: new Error(), + violations: response.body + }; + }); +} + +/** + * Fetch results with certain parameters + */ +function _fetchAction(page: number, uri: string, params?: FetchParams): Promise> { + const item_per_page: number = 50; + + let searchParams = new URLSearchParams(); + searchParams.append('item_per_page', item_per_page.toString()); + searchParams.append('page', page.toString()); + + if (params !== undefined) { + Object.keys(params).forEach(key => { + let v = params[key]; + if (typeof v === 'string') { + searchParams.append(key, v); + } else if (typeof v === 'number') { + searchParams.append(key, v.toString()); + } else if (v === null) { + searchParams.append(key, ''); + } + }); + } + + let url = uri + '?' + searchParams.toString(); + + return fetch(url, { + method: 'GET', + headers: { + 'Content-Type': 'application/json;charset=utf-8' + }, + }).then((response) => { + if (response.ok) { return response.json(); } + + if (response.status === 404) { + throw NotFoundException(response); + } + + if (response.status === 422) { + return response.json().then(response => { + throw ValidationException(response) + }); + } + + if (response.status === 403) { + throw AccessException(response); + } + + if (response.status >= 500) { + return response.text().then(body => { + throw ServerException(response.status, body); + }); + } + + throw new Error("other network error"); + }).catch((reason: any) => { + console.error(reason); + throw new Error(reason); + }); +}; + +export const fetchResults = async (uri: string, params?: FetchParams): Promise => { + let promises: Promise[] = [], + page = 1; + let firstData: PaginationResponse = await _fetchAction(page, uri, params) as PaginationResponse; + + promises.push(Promise.resolve(firstData.results)); + + if (firstData.pagination.more) { + do { + page = ++page; + promises.push( + _fetchAction(page, uri, params) + .then(r => Promise.resolve(r.results)) + ); + } while (page * firstData.pagination.items_per_page < firstData.count) + } + + return Promise.all(promises).then((values) => values.flat()); +}; + +export const fetchScopes = (): Promise => { + return fetchResults('/api/1.0/main/scope.json'); +}; + + +/** + * Error objects to be thrown + */ +const ValidationException = (response: ValidationErrorResponse): ValidationExceptionInterface => { + const error = {} as ValidationExceptionInterface; + error.name = 'ValidationException'; + error.violations = response.violations.map((violation) => `${violation.title}: ${violation.propertyPath}`); + error.titles = response.violations.map((violation) => violation.title); + error.propertyPaths = response.violations.map((violation) => violation.propertyPath); + return error; +} + +const AccessException = (response: Response): AccessExceptionInterface => { + const error = {} as AccessExceptionInterface; + error.name = 'AccessException'; + error.violations = ['You are not allowed to perform this action']; + + return error; +} + +const NotFoundException = (response: Response): NotFoundExceptionInterface => { + const error = {} as NotFoundExceptionInterface; + error.name = 'NotFoundException'; + + return error; +} + +const ServerException = (code: number, body: string): ServerExceptionInterface => { + const error = {} as ServerExceptionInterface; + error.name = 'ServerException'; + error.code = code; + error.body = body; + + return error; +} diff --git a/src/Bundle/ChillMainBundle/Resources/public/lib/api/locations.ts b/src/Bundle/ChillMainBundle/Resources/public/lib/api/locations.ts new file mode 100644 index 000000000..d5ab081c3 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Resources/public/lib/api/locations.ts @@ -0,0 +1,6 @@ +import {fetchResults} from "./apiMethods"; +import {Location, LocationType} from "../../types"; + +export const getLocations = (): Promise => fetchResults('/api/1.0/main/location.json'); + +export const getLocationTypes = (): Promise => fetchResults('/api/1.0/main/location-type.json'); diff --git a/src/Bundle/ChillMainBundle/Resources/public/lib/api/user.ts b/src/Bundle/ChillMainBundle/Resources/public/lib/api/user.ts new file mode 100644 index 000000000..730e6de66 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Resources/public/lib/api/user.ts @@ -0,0 +1,25 @@ +import {User} from "../../types"; +import {makeFetch} from "./apiMethods"; + +export const whoami = (): Promise => { + const url = `/api/1.0/main/whoami.json`; + return fetch(url) + .then(response => { + if (response.ok) { + return response.json(); + } + throw { + msg: 'Error while getting whoami.', + sta: response.status, + txt: response.statusText, + err: new Error(), + body: response.body + }; + }); +}; + +export const whereami = (): Promise => { + const url = `/api/1.0/main/user-current-location.json`; + + return makeFetch("GET", url); +} diff --git a/src/Bundle/ChillMainBundle/Resources/public/lib/download-report/download-report.js b/src/Bundle/ChillMainBundle/Resources/public/lib/download-report/download-report.js index c7b7bb59a..041e94c45 100644 --- a/src/Bundle/ChillMainBundle/Resources/public/lib/download-report/download-report.js +++ b/src/Bundle/ChillMainBundle/Resources/public/lib/download-report/download-report.js @@ -1,4 +1,4 @@ -/* +/* * Copyright (C) 2018 Champs Libres Cooperative * * This program is free software: you can redistribute it and/or modify @@ -15,12 +15,12 @@ * along with this program. If not, see . */ -var mime = require('mime-types') +var mime = require('mime') var download_report = (url, container) => { var download_text = container.dataset.downloadText, alias = container.dataset.alias; - + window.fetch(url, { credentials: 'same-origin' }) .then(response => { if (!response.ok) { @@ -29,21 +29,21 @@ var download_report = (url, container) => { return response.blob(); }).then(blob => { - + var content = URL.createObjectURL(blob), link = document.createElement("a"), type = blob.type, hasForcedType = 'mimeType' in container.dataset, extension; - + if (hasForcedType) { // force a type type = container.dataset.mimeType; blob = new Blob([ blob ], { 'type': type }); content = URL.createObjectURL(blob); } - - extension = mime.extension(type); + + extension = mime.getExtension(type); link.appendChild(document.createTextNode(download_text)); link.classList.add("btn", "btn-action"); @@ -56,7 +56,7 @@ var download_report = (url, container) => { container.appendChild(link); }).catch(function(error) { console.log(error); - var problem_text = + var problem_text = document.createTextNode("Problem during download"); container @@ -64,4 +64,4 @@ var download_report = (url, container) => { }); }; -module.exports = download_report; \ No newline at end of file +module.exports = download_report; diff --git a/src/Bundle/ChillMainBundle/Resources/public/module/bootstrap/_shared.scss b/src/Bundle/ChillMainBundle/Resources/public/module/bootstrap/_shared.scss index c216c5cf8..96da20779 100644 --- a/src/Bundle/ChillMainBundle/Resources/public/module/bootstrap/_shared.scss +++ b/src/Bundle/ChillMainBundle/Resources/public/module/bootstrap/_shared.scss @@ -14,9 +14,11 @@ // 4. Include any default map overrides here @import "custom/_maps"; +@import "bootstrap/scss/maps"; // 5. Include remainder of required parts @import "bootstrap/scss/mixins"; +@import "bootstrap/scss/utilities"; @import "bootstrap/scss/root"; diff --git a/src/Bundle/ChillMainBundle/Resources/public/module/pick-entity/index.js b/src/Bundle/ChillMainBundle/Resources/public/module/pick-entity/index.js index 511d2126e..890929c35 100644 --- a/src/Bundle/ChillMainBundle/Resources/public/module/pick-entity/index.js +++ b/src/Bundle/ChillMainBundle/Resources/public/module/pick-entity/index.js @@ -6,6 +6,7 @@ import { appMessages } from 'ChillMainAssets/vuejs/PickEntity/i18n'; const i18n = _createI18n(appMessages); let appsOnPage = new Map(); +let appsPerInput = new Map(); function loadDynamicPicker(element) { @@ -78,13 +79,14 @@ function loadDynamicPicker(element) { .mount(el); appsOnPage.set(uniqId, app); + appsPerInput.set(input.name, app); }); } document.addEventListener('show-hide-show', function(e) { loadDynamicPicker(e.detail.container) -}) +}); document.addEventListener('show-hide-hide', function(e) { console.log('hiding event caught') @@ -95,13 +97,25 @@ document.addEventListener('show-hide-hide', function(e) { appsOnPage.delete(uniqId); } }) -}) +}); + +document.addEventListener('pick-entity-type-action', function (e) { + console.log('pick entity event', e); + if (!appsPerInput.has(e.detail.name)) { + console.error('no app with this name'); + return; + } + const app = appsPerInput.get(e.detail.name); + if (e.detail.action === 'add') { + app.addNewEntity(e.detail.entity); + } else if (e.detail.action === 'remove') { + app.removeEntity(e.detail.entity); + } else { + console.error('action not supported: '+e.detail.action); + } +}); document.addEventListener('DOMContentLoaded', function(e) { loadDynamicPicker(document) }) - - - - diff --git a/src/Bundle/ChillMainBundle/Resources/public/module/pick-rolling-date/index.js b/src/Bundle/ChillMainBundle/Resources/public/module/pick-rolling-date/index.js new file mode 100644 index 000000000..2600ea40b --- /dev/null +++ b/src/Bundle/ChillMainBundle/Resources/public/module/pick-rolling-date/index.js @@ -0,0 +1,28 @@ +import {ShowHide} from 'ChillMainAssets/lib/show_hide/index'; + +document.addEventListener('DOMContentLoaded', function(_e) { + console.log('pick-rolling-date'); + document.querySelectorAll('div[data-rolling-date]').forEach( (picker) => { + const + roll_wrapper = picker.querySelector('div.roll-wrapper'), + fixed_wrapper = picker.querySelector('div.fixed-wrapper'); + + new ShowHide({ + froms: [roll_wrapper], + container: [fixed_wrapper], + test: function (elems) { + console.log('testing'); + console.log('elems', elems); + for (let el of elems) { + for (let select_roll of el.querySelectorAll('select[data-roll-picker]')) { + console.log('select_roll', select_roll); + console.log('value', select_roll.value); + return select_roll.value === 'fixed_date'; + } + } + return false; + } + }) + }); +}); + diff --git a/src/Bundle/ChillMainBundle/Resources/public/types.ts b/src/Bundle/ChillMainBundle/Resources/public/types.ts new file mode 100644 index 000000000..142864393 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Resources/public/types.ts @@ -0,0 +1,139 @@ +export interface DateTime { + datetime: string; + datetime8601: string +} + +export interface Civility { + id: number; + // TODO +} + +export interface Job { + id: number; + type: "user_job"; + label: { + "fr": string; // could have other key. How to do that in ts ? + } +} + +export interface Center { + id: number; + type: "center"; + name: string; +} + +export interface Scope { + id: number; + type: "scope"; + name: { + "fr": string + } +} + +export interface User { + type: "user"; + id: number; + username: string; + text: string; + email: string; + user_job: Job; + label: string; + // todo: mainCenter; mainJob; etc.. +} + +export interface UserAssociatedInterface { + type: "user"; + id: number; +}; + +export type TranslatableString = { + fr?: string; + nl?: string; +} + +export interface Postcode { + id: number; + name: string; + code: string; + center: Point; +} + +export type Point = { + type: "Point"; + coordinates: [lat: number, lon: number]; +} + +export interface Country { + id: number; + name: TranslatableString; + code: string; +} + +export interface Address { + type: "address"; + address_id: number; + text: string; + street: string; + streetNumber: string; + postcode: Postcode; + country: Country; + floor: string | null; + corridor: string | null; + steps: string | null; + flat: string | null; + buildingName: string | null; + distribution: string | null; + extra: string | null; + confidential: boolean; + lines: string[]; + addressReference: AddressReference | null; + validFrom: DateTime; + validTo: DateTime | null; +} + +export interface AddressReference { + id: number; + createdAt: DateTime | null; + deletedAt: DateTime | null; + municipalityCode: string; + point: Point; + postcode: Postcode; + refId: string; + source: string; + street: string; + streetNumber: string; + updatedAt: DateTime | null; +} + +export interface Location { + type: "location"; + id: number; + active: boolean; + address: Address | null; + availableForUsers: boolean; + createdAt: DateTime | null; + createdBy: User | null; + updatedAt: DateTime | null; + updatedBy: User | null; + email: string | null + name: string; + phonenumber1: string | null; + phonenumber2: string | null; + locationType: LocationType; +} + +export interface LocationAssociated { + type: "location"; + id: number; +} + +export interface LocationType { + type: "location-type"; + id: number; + active: boolean; + addressRequired: "optional" | "required"; + availableForUsers: boolean; + editableByUsers: boolean; + contactData: "optional" | "required"; + title: TranslatableString; +} diff --git a/src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/DatePane.vue b/src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/DatePane.vue index 6a83cbfe2..eb76604d4 100644 --- a/src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/DatePane.vue +++ b/src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/DatePane.vue @@ -54,7 +54,7 @@ \ No newline at end of file diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/ExportFormActionGoalResult/api.js b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/ExportFormActionGoalResult/api.js new file mode 100644 index 000000000..afcece4f4 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/ExportFormActionGoalResult/api.js @@ -0,0 +1,41 @@ +import { fetchResults } from 'ChillMainAssets/lib/api/apiMethods'; + +const getSocialActions = () => fetchResults( + '/api/1.0/person/social/social-action.json', { + item_per_page: 200 + } +); + +const getGoalByAction = (id) => { + let url = `/api/1.0/person/social-work/goal/by-social-action/${id}.json`; + return fetch(url) + .then(response => { + if (response.ok) { return response.json(); } + throw Error('Error with request resource response'); + }); +}; + +const getResultByAction = (id) => { + let url = `/api/1.0/person/social-work/result/by-social-action/${id}.json`; + return fetch(url) + .then(response => { + if (response.ok) { return response.json(); } + throw Error('Error with request resource response'); + }); +}; + +const getResultByGoal = (id) => { + let url = `/api/1.0/person/social-work/result/by-goal/${id}.json`; + return fetch(url) + .then(response => { + if (response.ok) { return response.json(); } + throw Error('Error with request resource response'); + }); +}; + +export { + getSocialActions, + getGoalByAction, + getResultByAction, + getResultByGoal, +} \ No newline at end of file diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/ExportFormActionGoalResult/index.js b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/ExportFormActionGoalResult/index.js new file mode 100644 index 000000000..d838fb0be --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/ExportFormActionGoalResult/index.js @@ -0,0 +1,13 @@ +import { createApp } from "vue"; +import { _createI18n } from 'ChillMainAssets/vuejs/_js/i18n'; +import App from './App.vue'; + +const i18n = _createI18n({}); + +const app = createApp({ + template: ``, +}) +.use(i18n) +.component('app', App) +.mount('#export_export') +; \ No newline at end of file diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/HouseholdMembersEditor/index.js b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/HouseholdMembersEditor/index.js index c9f5bb111..d2b271e17 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/HouseholdMembersEditor/index.js +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/HouseholdMembersEditor/index.js @@ -2,6 +2,8 @@ import { createApp } from 'vue'; import { _createI18n } from 'ChillMainAssets/vuejs/_js/i18n'; import { appMessages } from './js/i18n'; import { store } from './store'; +import VueToast from 'vue-toast-notification'; +import 'vue-toast-notification/dist/theme-sugar.css'; import App from './App.vue'; @@ -12,5 +14,11 @@ const app = createApp({ }) .use(store) .use(i18n) +.use(VueToast, { + position: "bottom-right", + type: "error", + duration: 5000, + dismissible: true +}) .component('app', App) .mount('#household_members_editor'); diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/HouseholdMembersEditor/store/index.js b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/HouseholdMembersEditor/store/index.js index bbdde0dfa..bfd684fce 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/HouseholdMembersEditor/store/index.js +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/HouseholdMembersEditor/store/index.js @@ -1,8 +1,8 @@ import { createStore } from 'vuex'; import { householdMove, fetchHouseholdSuggestionByAccompanyingPeriod, fetchAddressSuggestionByPerson} from './../api.js'; -import { fetchResults } from 'ChillMainAssets/lib/api/apiMethods.js' +import { fetchResults } from 'ChillMainAssets/lib/api/apiMethods.ts' import { fetchHouseholdByAddressReference } from 'ChillPersonAssets/lib/household.js'; -import { datetimeToISO, dateToISO, ISOToDate } from 'ChillMainAssets/chill/js/date.js'; +import { datetimeToISO, dateToISO, ISOToDate } from 'ChillMainAssets/chill/js/date'; const debug = process.env.NODE_ENV !== 'production'; //console.log('AJAJAJA', window.addaddress); diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/VisGraph/App.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/VisGraph/App.vue index cfc9f2a76..da597e522 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/VisGraph/App.vue +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/VisGraph/App.vue @@ -5,15 +5,12 @@
      - {{ $t('visgraph.screenshot') }} -
      diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/VisGraph/api.js b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/VisGraph/api.js index afa3d933e..8d04ec79b 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/VisGraph/api.js +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/VisGraph/api.js @@ -1,5 +1,5 @@ import { splitId } from './vis-network'; -import {makeFetch} from 'ChillMainAssets/lib/api/apiMethods.js'; +import {makeFetch} from 'ChillMainAssets/lib/api/apiMethods.ts'; /** * @function getFetch diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_api/OnTheFly.js b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_api/OnTheFly.js index 338094122..c9c77991f 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_api/OnTheFly.js +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_api/OnTheFly.js @@ -1,3 +1,5 @@ +import {makeFetch} from 'ChillMainAssets/lib/api/apiMethods'; + /* * GET a person by id */ @@ -22,6 +24,8 @@ const getCivilities = () => throw Error('Error with request resource response'); }); +const getCentersForPersonCreation = () => makeFetch('GET', '/api/1.0/person/creation/authorized-centers', null); + /* * POST a new person */ @@ -59,6 +63,7 @@ const patchPerson = (id, body) => { }; export { + getCentersForPersonCreation, getPerson, getPersonAltNames, getCivilities, diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/AccompanyingPeriod/SetReferrer.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/AccompanyingPeriod/SetReferrer.vue index 38418f305..96196f6fd 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/AccompanyingPeriod/SetReferrer.vue +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/AccompanyingPeriod/SetReferrer.vue @@ -6,7 +6,7 @@