Merge branch 'calendar/finalization' into chill_amli

This commit is contained in:
Julie Lenaerts 2022-08-29 11:32:46 +02:00
commit 0d35dfc303
267 changed files with 13641 additions and 2060 deletions

View File

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

View File

@ -16,7 +16,6 @@ and this project adheres to
* [person-thirdparty]: fix quick-add of names that consist of multiple parts (eg. De Vlieger) within onthefly modal person/thirdparty
* [search]: Order of birthdate fields changed in advanced search to avoid confusion.
* [workflow]: Constraint added to workflow (https://gitlab.com/champs-libres/departement-de-la-vendee/accent-suivi-developpement/-/issues/675)
* [action]: Agents traitants should be prefilled with referrer of the parcours or left empty if there is no referrer (https://gitlab.com/champs-libres/departement-de-la-vendee/accent-suivi-developpement/-/issues/696)
## Test releases

View File

@ -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",
@ -96,7 +100,8 @@
"autoload-dev": {
"psr-4": {
"App\\": "tests/app/src/",
"Chill\\DocGeneratorBundle\\Tests\\": "src/Bundle/ChillDocGeneratorBundle/tests"
"Chill\\DocGeneratorBundle\\Tests\\": "src/Bundle/ChillDocGeneratorBundle/tests",
"Chill\\WopiBundle\\Tests\\": "src/Bundle/ChillDocGeneratorBundle/tests"
}
},
"config": {

View File

@ -14,6 +14,13 @@
Installation & Usage
####################
.. toctree::
:maxdepth: 2
prod.rst
prod-calendar-sms-sending.rst
msgraph-configure.rst
Requirements
************
@ -38,13 +45,36 @@ Clone or download the chill-app project and `cd` into the main directory.
As a developer, the code will stay on your computer and will be executed in docker container. To avoid permission problem, the code should be run with the same uid/gid from your current user. This is why we get your current user id with the command ``id -u`` in each following scripts.
2. Prepare your variables
=========================
2. Prepare composer download of sources
=======================================
Have a look at the variable in ``.env.dist`` and in ``app/config/parameters.yml.dist`` and check if you need to adapt them. If they do not adapt with your need, or if some are missing:
As you are running in dev, you must configure an auth token for getting the source code.
1. copy the file as ``.env``: ``cp .env.dist .env``
2. you may replace some variables inside ``.env``
.. warning
If you skip this part, the code will be downloaded from dist instead of source (with git repository). You will probably replace the source manually, but the next time you will run ```composer update```, your repository will be replaced and you might loose something.
1. Create a personal access token from https://gitlab.com/-/profile/personal_access_tokens, with the `read_api` scope.
2. add a file called ```.composer/auth.json```:
.. code-block:: json
{
"gitlab-token": {
"gitlab.com": "glXXX-XXXXXXXXXXXXXXXXXXXX"
}
}
2. Prepare your variables and environment
=========================================
Copy ```docker-compose.override.dev.yml``` into ```docker-compose.override.yml```
.. code-block:: bash
cp docker-compose.override.dev.template.yml docker-compose.override.yml
Configure your environment variables, by creating a .env.local file and override the desired variables.
**Note**: If you intend to use the bundle ``Chill-Doc-Store``, you will need to configure and install an openstack object storage container with temporary url middleware. You will have to configure `secret keys <https://docs.openstack.org/swift/latest/api/temporary_url_middleware.html#secret-keys>`_.
@ -65,6 +95,17 @@ This script will :
4. build assets
.. note::
In some cases it can happen that an old image (chill_base_php or chill_php) stored in the docker cache will make the script fail. To solve this problem you have to delete the image and the container, before the make init :
.. code-block:: bash
docker-compose images php
docker rmi -f chill_php:prod
docker-compose rm php
4. Start the project
====================

View File

@ -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 <https://gitea.champs-libres.be/Chill-project/manuals>`_).
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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

View File

@ -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 <https://docs.ovh.com/gb/en/api/first-steps-with-ovh-api/>`_.
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.

View File

@ -0,0 +1,48 @@
.. 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".
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 <https://docs.openstack.org/swift/latest/api/temporary_url_middleware.html>` 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.
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

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

View File

@ -40,6 +40,9 @@
<testsuite name="DocGeneratorBundle">
<directory suffix="Test.php">src/Bundle/ChillDocGeneratorBundle/tests/</directory>
</testsuite>
<testsuite name="WopiBundle">
<directory suffix="Test.php">src/Bundle/ChillWopiBundle/tests/</directory>
</testsuite>
</testsuites>
<listeners>

View File

@ -21,6 +21,7 @@ use Chill\ActivityBundle\Repository\ActivityTypeRepository;
use Chill\ActivityBundle\Security\Authorization\ActivityVoter;
use Chill\MainBundle\Entity\Embeddable\CommentEmbeddable;
use Chill\MainBundle\Repository\LocationRepository;
use Chill\MainBundle\Repository\UserRepositoryInterface;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Privacy\PrivacyEvent;
@ -70,6 +71,8 @@ final class ActivityController extends AbstractController
private ThirdPartyRepository $thirdPartyRepository;
private UserRepositoryInterface $userRepository;
public function __construct(
ActivityACLAwareRepositoryInterface $activityACLAwareRepository,
ActivityTypeRepository $activityTypeRepository,
@ -82,7 +85,8 @@ final class ActivityController extends AbstractController
EntityManagerInterface $entityManager,
EventDispatcherInterface $eventDispatcher,
LoggerInterface $logger,
SerializerInterface $serializer
SerializerInterface $serializer,
UserRepositoryInterface $userRepository
) {
$this->activityACLAwareRepository = $activityACLAwareRepository;
$this->activityTypeRepository = $activityTypeRepository;
@ -96,6 +100,7 @@ final class ActivityController extends AbstractController
$this->eventDispatcher = $eventDispatcher;
$this->logger = $logger;
$this->serializer = $serializer;
$this->userRepository = $userRepository;
}
/**
@ -366,7 +371,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;
@ -385,26 +390,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());

View File

@ -35,6 +35,13 @@ const getLocationTypeByDefaultFor = (entity) => {
);
};
/**
* Post a location
*
* **NOTE**: also in use for Calendar
* @param body
* @returns {Promise<T>}
*/
const postLocation = (body) => {
const url = `/api/1.0/main/location.json`;
return fetch(url, {

View File

@ -55,7 +55,7 @@ const makeAccompanyingPeriodLocation = (locationType, store) => {
export default function prepareLocations(store) {
// find the locations
// find the locations
let allLocations = getLocations().then(
(results) => {
store.commit('addAvailableLocationGroup', {
@ -111,7 +111,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;
}

View File

@ -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 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 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 entity.activityType.usersVisible %}
{% set blocks = blocks|merge([{
'title': 'Users concerned'|trans,
'items': entity.users,
@ -132,6 +143,12 @@
{% if bloc.type == 'user' %}
<span class="badge-user">
{{ item|chill_entity_render_box({'render': 'raw', 'addAltNames': false }) }}
{%- if context == 'calendar_accompanyingCourse' %}
{% set invite = entity.inviteForUser(item) %}
{% if invite is not null %}
{{ invite.invite_span(invite) }}
{% endif %}
{%- endif -%}
</span>
{% else %}
{{ _self.insert_onthefly(bloc.type, item) }}

View File

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

View File

@ -0,0 +1,77 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\CalendarBundle\Command;
use Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph\MachineTokenStorage;
use KnpU\OAuth2ClientBundle\Client\ClientRegistry;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\FormatterHelper;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\ConfirmationQuestion;
use TheNetworg\OAuth2\Client\Provider\Azure;
class AzureGrantAdminConsentAndAcquireToken extends Command
{
private Azure $azure;
private ClientRegistry $clientRegistry;
private MachineTokenStorage $machineTokenStorage;
public function __construct(Azure $azure, ClientRegistry $clientRegistry, MachineTokenStorage $machineTokenStorage)
{
parent::__construct('chill:calendar:msgraph-grant-admin-consent');
$this->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;
}
}

View File

@ -0,0 +1,164 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\CalendarBundle\Command;
use Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph\EventsOnUserSubscriptionCreator;
use Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph\MapCalendarToUser;
use Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph\MSGraphUserRepository;
use DateInterval;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
class MapAndSubscribeUserCalendarCommand extends Command
{
private EntityManagerInterface $em;
private EventsOnUserSubscriptionCreator $eventsOnUserSubscriptionCreator;
private LoggerInterface $logger;
private MapCalendarToUser $mapCalendarToUser;
private MSGraphUserRepository $userRepository;
public function __construct(
EntityManagerInterface $em,
EventsOnUserSubscriptionCreator $eventsOnUserSubscriptionCreator,
LoggerInterface $logger,
MapCalendarToUser $mapCalendarToUser,
MSGraphUserRepository $userRepository
) {
parent::__construct('chill:calendar:msgraph-user-map-subscribe');
$this->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'
);
}
}

View File

@ -0,0 +1,41 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\CalendarBundle\Command;
use Chill\CalendarBundle\Service\ShortMessageNotification\BulkCalendarShortMessageSender;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class SendShortMessageOnEligibleCalendar extends Command
{
private BulkCalendarShortMessageSender $messageSender;
public function __construct(BulkCalendarShortMessageSender $messageSender)
{
parent::__construct();
$this->messageSender = $messageSender;
}
public function getName()
{
return 'chill:calendar:send-short-messages';
}
protected function execute(InputInterface $input, OutputInterface $output)
{
$this->messageSender->sendBulkMessageToEligibleCalendars();
return 0;
}
}

View File

@ -0,0 +1,200 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\CalendarBundle\Command;
use Chill\CalendarBundle\Entity\Calendar;
use Chill\CalendarBundle\Service\ShortMessageNotification\ShortMessageForCalendarBuilderInterface;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Phonenumber\PhoneNumberHelperInterface;
use Chill\MainBundle\Repository\UserRepositoryInterface;
use Chill\MainBundle\Service\ShortMessage\ShortMessageTransporterInterface;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Repository\PersonRepository;
use DateInterval;
use DateTimeImmutable;
use libphonenumber\PhoneNumber;
use libphonenumber\PhoneNumberFormat;
use libphonenumber\PhoneNumberType;
use libphonenumber\PhoneNumberUtil;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\ConfirmationQuestion;
use Symfony\Component\Console\Question\Question;
use UnexpectedValueException;
use function count;
class SendTestShortMessageOnCalendarCommand extends Command
{
private ShortMessageForCalendarBuilderInterface $messageForCalendarBuilder;
private PersonRepository $personRepository;
private PhoneNumberHelperInterface $phoneNumberHelper;
private PhoneNumberUtil $phoneNumberUtil;
private ShortMessageTransporterInterface $transporter;
private UserRepositoryInterface $userRepository;
public function __construct(
PersonRepository $personRepository,
PhoneNumberUtil $phoneNumberUtil,
PhoneNumberHelperInterface $phoneNumberHelper,
ShortMessageForCalendarBuilderInterface $messageForCalendarBuilder,
ShortMessageTransporterInterface $transporter,
UserRepositoryInterface $userRepository
) {
parent::__construct();
$this->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;
}
}

View File

@ -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')) {

View File

@ -13,53 +13,67 @@ namespace Chill\CalendarBundle\Controller;
use Chill\CalendarBundle\Entity\Calendar;
use Chill\CalendarBundle\Form\CalendarType;
use Chill\CalendarBundle\RemoteCalendar\Connector\RemoteCalendarConnectorInterface;
use Chill\CalendarBundle\Repository\CalendarACLAwareRepositoryInterface;
use Chill\CalendarBundle\Repository\CalendarRepository;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Pagination\PaginatorFactory;
use Chill\MainBundle\Security\Authorization\AuthorizationHelper;
use Chill\MainBundle\Repository\UserRepository;
use Chill\MainBundle\Templating\Listing\FilterOrderHelper;
use Chill\MainBundle\Templating\Listing\FilterOrderHelperFactoryInterface;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\PersonBundle\Entity\Person;
use Chill\ThirdPartyBundle\Entity\ThirdParty;
use DateTimeImmutable;
use Exception;
use Psr\Log\LoggerInterface;
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;
protected EventDispatcherInterface $eventDispatcher;
protected LoggerInterface $logger;
protected PaginatorFactory $paginator;
protected SerializerInterface $serializer;
private CalendarACLAwareRepositoryInterface $calendarACLAwareRepository;
private CalendarRepository $calendarRepository;
private FilterOrderHelperFactoryInterface $filterOrderHelperFactory;
private LoggerInterface $logger;
private PaginatorFactory $paginator;
private RemoteCalendarConnectorInterface $remoteCalendarConnector;
private SerializerInterface $serializer;
private UserRepository $userRepository;
public function __construct(
EventDispatcherInterface $eventDispatcher,
AuthorizationHelper $authorizationHelper,
CalendarRepository $calendarRepository,
CalendarACLAwareRepositoryInterface $calendarACLAwareRepository,
FilterOrderHelperFactoryInterface $filterOrderHelperFactory,
LoggerInterface $logger,
SerializerInterface $serializer,
PaginatorFactory $paginator,
CalendarRepository $calendarRepository
RemoteCalendarConnectorInterface $remoteCalendarConnector,
SerializerInterface $serializer,
UserRepository $userRepository
) {
$this->eventDispatcher = $eventDispatcher;
$this->authorizationHelper = $authorizationHelper;
$this->logger = $logger;
$this->serializer = $serializer;
$this->paginator = $paginator;
$this->calendarRepository = $calendarRepository;
$this->calendarACLAwareRepository = $calendarACLAwareRepository;
$this->filterOrderHelperFactory = $filterOrderHelperFactory;
$this->logger = $logger;
$this->paginator = $paginator;
$this->remoteCalendarConnector = $remoteCalendarConnector;
$this->serializer = $serializer;
$this->userRepository = $userRepository;
}
/**
@ -67,12 +81,13 @@ 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);
$accompanyingPeriod = $entity->getAccompanyingPeriod();
$user = null; // TODO legacy code ? remove it ?
if ($accompanyingPeriod instanceof AccompanyingPeriod) {
$view = '@ChillCalendar/Calendar/confirm_deleteByAccompanyingCourse.html.twig';
@ -80,14 +95,7 @@ class CalendarController extends AbstractController
$view = '@ChillCalendar/Calendar/confirm_deleteByUser.html.twig';
}
/** @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->getId(), $user, $accompanyingPeriod);
if ($request->getMethod() === Request::METHOD_DELETE) {
$form->handleRequest($request);
@ -106,7 +114,7 @@ class CalendarController extends AbstractController
$params = $this->buildParamsToUrl($user, $accompanyingPeriod);
return $this->redirectToRoute('chill_calendar_calendar_list', $params);
return $this->redirectToRoute('chill_calendar_calendar_list_by_period', $params);
}
}
@ -126,8 +134,12 @@ 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
{
if (!$this->remoteCalendarConnector->isReady()) {
return $this->remoteCalendarConnector->getMakeReadyResponse($request->getUri());
}
$view = null;
$em = $this->getDoctrine()->getManager();
@ -136,35 +148,28 @@ class CalendarController extends AbstractController
if ($accompanyingPeriod instanceof AccompanyingPeriod) {
$view = '@ChillCalendar/Calendar/editByAccompanyingCourse.html.twig';
} elseif ($user instanceof User) {
throw new Exception('to analyze');
$view = '@ChillCalendar/Calendar/editByUser.html.twig';
}
$entity = $em->getRepository(\Chill\CalendarBundle\Entity\Calendar::class)->find($id);
if (!$entity) {
throw $this->createNotFoundException('Unable to find Calendar entity.');
}
$form = $this->createForm(CalendarType::class, $entity, [
'accompanyingPeriod' => $accompanyingPeriod,
])->handleRequest($request);
$form = $this->createForm(CalendarType::class, $entity);
$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);
return $this->redirectToRoute('chill_calendar_calendar_list', $params);
return $this->redirectToRoute('chill_calendar_calendar_list_by_period', $params);
}
if ($form->isSubmitted() && !$form->isValid()) {
$this->addFlash('error', $this->get('translator')->trans('This form contains errors'));
}
$deleteForm = $this->createDeleteForm($id, $user, $accompanyingPeriod);
$deleteForm = $this->createDeleteForm($entity->getId(), $user, $accompanyingPeriod);
if (null === $view) {
throw $this->createNotFoundException('Template not found');
@ -177,7 +182,7 @@ class CalendarController extends AbstractController
'form' => $form->createView(),
'delete_form' => $deleteForm->createView(),
'accompanyingCourse' => $accompanyingPeriod,
'user' => $user,
// 'user' => $user,
'entity_json' => $entity_array,
]);
}
@ -185,47 +190,55 @@ class CalendarController extends AbstractController
/**
* 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;
$filterOrder = $this->buildListFilterOrder();
['from' => $from, 'to' => $to] = $filterOrder->getDateRangeData('startDate');
[$user, $accompanyingPeriod] = $this->getEntity($request);
$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()
);
if ($user instanceof User) {
$calendarItems = $this->calendarRepository->findByUser($user);
return $this->render('@ChillCalendar/Calendar/listByAccompanyingCourse.html.twig', [
'calendarItems' => $calendarItems,
'accompanyingCourse' => $accompanyingPeriod,
'paginator' => $paginator,
'filterOrder' => $filterOrder,
]);
}
/**
* @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 (!$this->getUser() instanceof User) {
throw new UnauthorizedHttpException('you are not an user');
}
$view = '@ChillCalendar/Calendar/listByUser.html.twig';
return $this->render($view, [
'calendarItems' => $calendarItems,
'user' => $user,
'user' => $this->getUser(),
]);
}
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,
]);
}
throw new Exception('Unable to list actions.');
}
/**
* Create a new calendar item.
*
@ -233,6 +246,10 @@ 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();
@ -246,8 +263,10 @@ class CalendarController extends AbstractController
// }
$entity = new Calendar();
$entity->setUser($this->getUser());
$entity->setStatus($entity::STATUS_VALID);
if ($request->query->has('mainUser')) {
$entity->setMainUser($this->userRepository->find($request->query->getInt('mainUser')));
}
// if ($user instanceof User) {
// $entity->setPerson($user);
@ -257,9 +276,8 @@ class CalendarController extends AbstractController
$entity->setAccompanyingPeriod($accompanyingPeriod);
}
$form = $this->createForm(CalendarType::class, $entity, [
'accompanyingPeriod' => $accompanyingPeriod,
])->handleRequest($request);
$form = $this->createForm(CalendarType::class, $entity);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$em->persist($entity);
@ -269,7 +287,7 @@ class CalendarController extends AbstractController
$params = $this->buildParamsToUrl($user, $accompanyingPeriod);
return $this->redirectToRoute('chill_calendar_calendar_list', $params);
return $this->redirectToRoute('chill_calendar_calendar_list_by_period', $params);
}
if ($form->isSubmitted() && !$form->isValid()) {
@ -349,7 +367,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 +380,58 @@ 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
{
$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 +441,7 @@ class CalendarController extends AbstractController
}
if (null !== $accompanyingPeriod) {
$params['accompanying_period_id'] = $accompanyingPeriod->getId();
$params['id'] = $accompanyingPeriod->getId();
}
return $params;

View File

@ -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();
$sql = 'SELECT c FROM ChillCalendarBundle:CalendarRange c
WHERE NOT EXISTS (SELECT cal.id FROM ChillCalendarBundle:Calendar cal WHERE cal.calendarRange = c.id)';
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);
$this->calendarRangeRepository = $calendarRangeRepository;
}
$results = $query->getResult();
/**
* @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');
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('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->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']]);
}
}

View File

@ -0,0 +1,76 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\CalendarBundle\Controller;
use Chill\CalendarBundle\Entity\Calendar;
use Chill\CalendarBundle\Entity\Invite;
use Chill\CalendarBundle\Messenger\Message\InviteUpdateMessage;
use Chill\CalendarBundle\Security\Voter\InviteVoter;
use Chill\MainBundle\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Security;
use function in_array;
class InviteApiController
{
private EntityManagerInterface $entityManager;
private MessageBusInterface $messageBus;
private Security $security;
public function __construct(EntityManagerInterface $entityManager, MessageBusInterface $messageBus, Security $security)
{
$this->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);
}
}

View File

@ -0,0 +1,69 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\CalendarBundle\Controller;
use Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph\OnBehalfOfUserTokenStorage;
use KnpU\OAuth2ClientBundle\Client\ClientRegistry;
use League\OAuth2\Client\Provider\Exception\IdentityProviderException;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use TheNetworg\OAuth2\Client\Provider\Azure;
use TheNetworg\OAuth2\Client\Token\AccessToken;
class RemoteCalendarConnectAzureController
{
private ClientRegistry $clientRegistry;
private OnBehalfOfUserTokenStorage $MSGraphTokenStorage;
public function __construct(
ClientRegistry $clientRegistry,
OnBehalfOfUserTokenStorage $MSGraphTokenStorage
) {
$this->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', '/'));
}
}

View File

@ -0,0 +1,54 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\CalendarBundle\Controller;
use Chill\CalendarBundle\Messenger\Message\MSGraphChangeNotificationMessage;
use JsonException;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Annotation\Route;
use const JSON_THROW_ON_ERROR;
class RemoteCalendarMSGraphSyncController
{
private MessageBusInterface $messageBus;
public function __construct(MessageBusInterface $messageBus)
{
$this->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);
}
}

View File

@ -0,0 +1,107 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\CalendarBundle\Controller;
use Chill\CalendarBundle\RemoteCalendar\Connector\RemoteCalendarConnectorInterface;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Pagination\PaginatorFactory;
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;
use Symfony\Component\Serializer\SerializerInterface;
use function count;
/**
* Contains method to get events (Calendar) from remote calendar.
*/
class RemoteCalendarProxyController
{
private PaginatorFactory $paginatorFactory;
private RemoteCalendarConnectorInterface $remoteCalendarConnector;
private SerializerInterface $serializer;
public function __construct(PaginatorFactory $paginatorFactory, RemoteCalendarConnectorInterface $remoteCalendarConnector, SerializerInterface $serializer)
{
$this->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
);
}
}

View File

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

View File

@ -36,6 +36,15 @@ 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)

View File

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

View File

@ -12,7 +12,10 @@ 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\Location;
@ -20,33 +23,71 @@ 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
*/
class Calendar
class Calendar implements TrackCreationInterface, TrackUpdateInterface
{
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[]
*/
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;
@ -56,7 +97,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 +109,14 @@ class Calendar
/**
* @ORM\Embedded(class=CommentEmbeddable::class, columnPrefix="comment_")
* @Serializer\Groups({"calendar:read"})
* @Serializer\Groups({"calendar:read", "read"})
*/
private CommentEmbeddable $comment;
/**
* @ORM\Column(type="datetimetz_immutable")
* @Serializer\Groups({"calendar:read"})
* @ORM\Column(type="datetime_immutable", nullable=false)
* @Serializer\Groups({"calendar:read", "read", "calendar:light"})
* @Assert\NotNull(message="calendar.An end date is required")
*/
private ?DateTimeImmutable $endDate = null;
@ -81,38 +124,43 @@ class Calendar
* @ORM\Id
* @ORM\GeneratedValue
* @ORM\Column(type="integer")
* @Serializer\Groups({"calendar:read"})
* @Serializer\Groups({"calendar:read", "read", "calendar:light"})
*/
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"})
*/
private Collection $invites;
/**
* @ORM\ManyToOne(targetEntity="Chill\MainBundle\Entity\Location")
* @groups({"read"})
* @Serializer\Groups({"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"})
* @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\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"})
* @Serializer\Context(normalizationContext={"read"}, groups={"calendar:light"})
* @Assert\Count(min=1, minMessage="calendar.At least {{ limit }} person is required.")
*/
private Collection $persons;
@ -123,37 +171,37 @@ 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"})
* @Serializer\Context(normalizationContext={"read"}, groups={"calendar:light"})
*/
private Collection $professionals;
/**
* @ORM\Column(type="boolean", nullable=true)
*/
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"})
* @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;
/**
* @ORM\ManyToOne(targetEntity="Chill\MainBundle\Entity\User")
* @Groups({"read"})
* @Serializer\Groups({"calendar:read"})
*/
private ?User $user = null;
private string $status = self::STATUS_VALID;
public function __construct()
{
@ -164,28 +212,41 @@ class Calendar
$this->invites = new ArrayCollection();
}
public function addInvite(?Invite $invite): self
/**
* @internal Use {@link (Calendar::addUser)} instead
*/
public function addInvite(Invite $invite): self
{
if (null !== $invite) {
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 addPerson(?Person $person): self
public function addPerson(Person $person): self
{
if (null !== $person) {
$this->persons[] = $person;
}
return $this;
}
public function addProfessional(?ThirdParty $professional): self
public function addProfessional(ThirdParty $professional): self
{
if (null !== $professional) {
$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;
@ -216,6 +277,15 @@ class Calendar
return $this->comment;
}
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 +296,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[]
*/
@ -304,6 +389,11 @@ class Calendar
return $this->sendSMS;
}
public function getSmsStatus(): string
{
return $this->smsStatus;
}
public function getStartDate(): ?DateTimeImmutable
{
return $this->startDate;
@ -319,14 +409,35 @@ class Calendar
return $this->getProfessionals();
}
public function getUser(): ?User
/**
* @return Collection|User[]
* @Serializer\Groups({"calendar:read", "read"})
*/
public function getUsers(): Collection
{
return $this->user;
return $this->getInvites()->map(static function (Invite $i) { return $i->getUser(); });
}
public function getusers(): Collection
public function hasCalendarRange(): bool
{
return $this->getInvites(); //TODO get users of the invite
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 +454,15 @@ class Calendar
]));
}
/**
* @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 +481,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 +511,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;
}
@ -415,7 +558,12 @@ class Calendar
public function setMainUser(?User $mainUser): self
{
if ($this->mainUser !== $mainUser) {
$this->previousMainUser = $this->mainUser;
}
$this->mainUser = $mainUser;
$this->removeUser($mainUser);
return $this;
}
@ -434,6 +582,13 @@ class Calendar
return $this;
}
public function setSmsStatus(string $smsStatus): self
{
$this->smsStatus = $smsStatus;
return $this;
}
public function setStartDate(DateTimeImmutable $startDate): self
{
$this->startDate = $startDate;
@ -445,13 +600,10 @@ class Calendar
{
$this->status = $status;
return $this;
if (self::STATUS_CANCELED === $status && $this->getSmsStatus() === self::SMS_SENT) {
$this->setSmsStatus(self::SMS_CANCEL_PENDING);
}
public function setUser(?User $user): self
{
$this->user = $user;
return $this;
}
}

View File

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

View File

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

View File

@ -0,0 +1,64 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\CalendarBundle\Entity;
use Doctrine\ORM\Mapping as ORM;
trait RemoteCalendarTrait
{
/**
* If true, the changes won't be enqueued to remote.
*
* This is required to prevent update loop: a persist trigger an event creation on remote,
* which in turn change remoteId and, in turn, trigger an update change, ...
*/
public bool $preventEnqueueChanges = false;
/**
* @ORM\Column(type="json", options={"default": "[]"}, nullable=false)
*/
private array $remoteAttributes = [];
/**
* @ORM\Column(type="text", options={"default": ""}, nullable=false)
*/
private string $remoteId = '';
public function addRemoteAttributes(array $remoteAttributes): self
{
$this->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;
}
}

View File

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

View File

@ -0,0 +1,23 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\CalendarBundle\Form\DataTransformer;
use Chill\CalendarBundle\Repository\CalendarRangeRepository;
use Chill\MainBundle\Form\DataTransformer\IdToEntityDataTransformer;
class IdToCalendarRangeDataTransformer extends IdToEntityDataTransformer
{
public function __construct(CalendarRangeRepository $repository)
{
parent::__construct($repository, false);
}
}

View File

@ -11,44 +11,37 @@ declare(strict_types=1);
namespace Chill\CalendarBundle\Menu;
use Chill\CalendarBundle\Security\Voter\CalendarVoter;
use Chill\MainBundle\Routing\LocalMenuBuilderInterface;
use Chill\MainBundle\Security\Authorization\AuthorizationHelper;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Knp\Menu\MenuItem;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\Security;
use Symfony\Contracts\Translation\TranslatorInterface;
class AccompanyingCourseMenuBuilder implements LocalMenuBuilderInterface
{
protected AuthorizationHelper $authorizationHelper;
protected TokenStorageInterface $tokenStorage;
protected TranslatorInterface $translator;
private Security $security;
public function __construct(
TokenStorageInterface $tokenStorage,
AuthorizationHelper $authorizationHelper,
Security $security,
TranslatorInterface $translator
) {
$this->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]);
*/
}
}

View File

@ -58,7 +58,7 @@ class UserMenuBuilder implements LocalMenuBuilderInterface
if ($this->authorizationChecker->isGranted('ROLE_USER')) {
$menu->addChild('My calendar list', [
'route' => 'chill_calendar_calendar_list',
'route' => 'chill_calendar_calendar_list_my',
])
->setExtras([
'order' => 9,

View File

@ -0,0 +1,70 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\CalendarBundle\Messenger\Doctrine;
use Chill\CalendarBundle\Entity\Calendar;
use Chill\CalendarBundle\Messenger\Message\CalendarMessage;
use Chill\CalendarBundle\Messenger\Message\CalendarRemovedMessage;
use Doctrine\ORM\Event\LifecycleEventArgs;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Security\Core\Security;
class CalendarEntityListener
{
private MessageBusInterface $messageBus;
private Security $security;
public function __construct(MessageBusInterface $messageBus, Security $security)
{
$this->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()
)
);
}
}
}

View File

@ -0,0 +1,70 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\CalendarBundle\Messenger\Doctrine;
use Chill\CalendarBundle\Entity\CalendarRange;
use Chill\CalendarBundle\Messenger\Message\CalendarRangeMessage;
use Chill\CalendarBundle\Messenger\Message\CalendarRangeRemovedMessage;
use Doctrine\ORM\Event\LifecycleEventArgs;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Security\Core\Security;
class CalendarRangeEntityListener
{
private MessageBusInterface $messageBus;
private Security $security;
public function __construct(MessageBusInterface $messageBus, Security $security)
{
$this->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()
)
);
}
}
}

View File

@ -0,0 +1,45 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\CalendarBundle\Messenger\Handler;
use Chill\CalendarBundle\Messenger\Message\CalendarRangeRemovedMessage;
use Chill\CalendarBundle\RemoteCalendar\Connector\RemoteCalendarConnectorInterface;
use Chill\MainBundle\Repository\UserRepository;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\Handler\MessageHandlerInterface;
/**
* Remove a calendar range when it is removed from local calendar.
*
* @AsMessageHandler
*/
class CalendarRangeRemoveToRemoteHandler implements MessageHandlerInterface
{
private RemoteCalendarConnectorInterface $remoteCalendarConnector;
private UserRepository $userRepository;
public function __construct(RemoteCalendarConnectorInterface $remoteCalendarConnector, UserRepository $userRepository)
{
$this->remoteCalendarConnector = $remoteCalendarConnector;
$this->userRepository = $userRepository;
}
public function __invoke(CalendarRangeRemovedMessage $calendarRangeRemovedMessage)
{
$this->remoteCalendarConnector->removeCalendarRange(
$calendarRangeRemovedMessage->getRemoteId(),
$calendarRangeRemovedMessage->getRemoteAttributes(),
$this->userRepository->find($calendarRangeRemovedMessage->getCalendarRangeUserId())
);
}
}

View File

@ -0,0 +1,57 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\CalendarBundle\Messenger\Handler;
use Chill\CalendarBundle\Messenger\Message\CalendarRangeMessage;
use Chill\CalendarBundle\RemoteCalendar\Connector\RemoteCalendarConnectorInterface;
use Chill\CalendarBundle\Repository\CalendarRangeRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\Handler\MessageHandlerInterface;
/**
* Write calendar range creation / update to the remote calendar.
*
* @AsMessageHandler
*/
class CalendarRangeToRemoteHandler implements MessageHandlerInterface
{
private CalendarRangeRepository $calendarRangeRepository;
private EntityManagerInterface $entityManager;
private RemoteCalendarConnectorInterface $remoteCalendarConnector;
public function __construct(
CalendarRangeRepository $calendarRangeRepository,
RemoteCalendarConnectorInterface $remoteCalendarConnector,
EntityManagerInterface $entityManager
) {
$this->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();
}
}

View File

@ -0,0 +1,55 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\CalendarBundle\Messenger\Handler;
use Chill\CalendarBundle\Messenger\Message\CalendarRemovedMessage;
use Chill\CalendarBundle\RemoteCalendar\Connector\RemoteCalendarConnectorInterface;
use Chill\CalendarBundle\Repository\CalendarRangeRepository;
use Chill\MainBundle\Repository\UserRepositoryInterface;
use Symfony\Component\Messenger\Handler\MessageHandlerInterface;
/**
* Handle the deletion of calendar.
*
* @AsMessageHandler
*/
class CalendarRemoveHandler implements MessageHandlerInterface
{
private CalendarRangeRepository $calendarRangeRepository;
private RemoteCalendarConnectorInterface $remoteCalendarConnector;
private UserRepositoryInterface $userRepository;
public function __construct(RemoteCalendarConnectorInterface $remoteCalendarConnector, CalendarRangeRepository $calendarRangeRepository, UserRepositoryInterface $userRepository)
{
$this->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
);
}
}

View File

@ -0,0 +1,112 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\CalendarBundle\Messenger\Handler;
use Chill\CalendarBundle\Entity\CalendarRange;
use Chill\CalendarBundle\Entity\Invite;
use Chill\CalendarBundle\Messenger\Message\CalendarMessage;
use Chill\CalendarBundle\RemoteCalendar\Connector\RemoteCalendarConnectorInterface;
use Chill\CalendarBundle\Repository\CalendarRangeRepository;
use Chill\CalendarBundle\Repository\CalendarRepository;
use Chill\CalendarBundle\Repository\InviteRepository;
use Chill\MainBundle\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\Handler\MessageHandlerInterface;
/**
* Write calendar creation / update to the remote calendar.
*
* @AsMessageHandler
*/
class CalendarToRemoteHandler implements MessageHandlerInterface
{
private RemoteCalendarConnectorInterface $calendarConnector;
private CalendarRangeRepository $calendarRangeRepository;
private CalendarRepository $calendarRepository;
private EntityManagerInterface $entityManager;
private InviteRepository $inviteRepository;
private UserRepository $userRepository;
public function __construct(
CalendarRangeRepository $calendarRangeRepository,
CalendarRepository $calendarRepository,
EntityManagerInterface $entityManager,
InviteRepository $inviteRepository,
RemoteCalendarConnectorInterface $calendarConnector,
UserRepository $userRepository
) {
$this->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();
}
}

View File

@ -0,0 +1,50 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\CalendarBundle\Messenger\Handler;
use Chill\CalendarBundle\Messenger\Message\InviteUpdateMessage;
use Chill\CalendarBundle\RemoteCalendar\Connector\RemoteCalendarConnectorInterface;
use Chill\CalendarBundle\Repository\InviteRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Messenger\Handler\MessageHandlerInterface;
/**
* Sync the local invitation to the remote calendar.
*
* @AsMessageHandler
*/
class InviteUpdateHandler implements MessageHandlerInterface
{
private EntityManagerInterface $em;
private InviteRepository $inviteRepository;
private RemoteCalendarConnectorInterface $remoteCalendarConnector;
public function __construct(EntityManagerInterface $em, InviteRepository $inviteRepository, RemoteCalendarConnectorInterface $remoteCalendarConnector)
{
$this->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();
}
}

View File

@ -0,0 +1,103 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\CalendarBundle\Messenger\Handler;
use Chill\CalendarBundle\Messenger\Message\MSGraphChangeNotificationMessage;
use Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph\MapCalendarToUser;
use Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph\RemoteToLocalSync\CalendarRangeSyncer;
use Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph\RemoteToLocalSync\CalendarSyncer;
use Chill\CalendarBundle\Repository\CalendarRangeRepository;
use Chill\CalendarBundle\Repository\CalendarRepository;
use Chill\MainBundle\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\Handler\MessageHandlerInterface;
/**
* Handle notification of changes made by users directly on Outlook calendar.
*
* @AsMessageHandler
*/
class MSGraphChangeNotificationHandler implements MessageHandlerInterface
{
private CalendarRangeRepository $calendarRangeRepository;
private CalendarRangeSyncer $calendarRangeSyncer;
private CalendarRepository $calendarRepository;
private CalendarSyncer $calendarSyncer;
private EntityManagerInterface $em;
private LoggerInterface $logger;
private MapCalendarToUser $mapCalendarToUser;
private UserRepository $userRepository;
public function __construct(
CalendarRangeRepository $calendarRangeRepository,
CalendarRangeSyncer $calendarRangeSyncer,
CalendarRepository $calendarRepository,
CalendarSyncer $calendarSyncer,
EntityManagerInterface $em,
LoggerInterface $logger,
MapCalendarToUser $mapCalendarToUser,
UserRepository $userRepository
) {
$this->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();
}
}

View File

@ -0,0 +1,104 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\CalendarBundle\Messenger\Message;
use Chill\CalendarBundle\Entity\Calendar;
use Chill\CalendarBundle\Entity\Invite;
use Chill\MainBundle\Entity\User;
class CalendarMessage
{
public const CALENDAR_PERSIST = 'CHILL_CALENDAR_CALENDAR_PERSIST';
public const CALENDAR_UPDATE = 'CHILL_CALENDAR_CALENDAR_UPDATE';
private string $action;
private int $byUserId;
private int $calendarId;
private array $newInvitesIds = [];
/**
* @var array<array{inviteId: int, userId: int, userEmail: int, userLabel: string}>
*/
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<array{inviteId: int, userId: int, userEmail: int, userLabel: string}>
*/
public function getOldInvites(): array
{
return $this->oldInvites;
}
public function getPreviousCalendarRangeId(): ?int
{
return $this->previousCalendarRangeId;
}
public function getPreviousMainUserId(): ?int
{
return $this->previousMainUserId;
}
}

View File

@ -0,0 +1,53 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\CalendarBundle\Messenger\Message;
use Chill\CalendarBundle\Entity\CalendarRange;
use Chill\MainBundle\Entity\User;
class CalendarRangeMessage
{
public const CALENDAR_RANGE_PERSIST = 'CHILL_CALENDAR_CALENDAR_RANGE_PERSIST';
public const CALENDAR_RANGE_UPDATE = 'CHILL_CALENDAR_CALENDAR_RANGE_UPDATE';
private string $action;
private ?int $byUserId = null;
private int $calendarRangeId;
public function __construct(CalendarRange $calendarRange, string $action, ?User $byUser)
{
$this->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;
}
}

View File

@ -0,0 +1,57 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\CalendarBundle\Messenger\Message;
use Chill\CalendarBundle\Entity\CalendarRange;
use Chill\MainBundle\Entity\User;
class CalendarRangeRemovedMessage
{
private ?int $byUserId = null;
private int $calendarRangeUserId;
private array $remoteAttributes;
private string $remoteId;
public function __construct(CalendarRange $calendarRange, ?User $byUser)
{
$this->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;
}
}

View File

@ -0,0 +1,68 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\CalendarBundle\Messenger\Message;
use Chill\CalendarBundle\Entity\Calendar;
use Chill\MainBundle\Entity\User;
class CalendarRemovedMessage
{
private ?int $associatedCalendarRangeId = null;
private ?int $byUserId = null;
private int $calendarUserId;
private array $remoteAttributes;
private string $remoteId;
public function __construct(Calendar $calendar, ?User $byUser)
{
$this->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;
}
}

View File

@ -0,0 +1,38 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\CalendarBundle\Messenger\Message;
use Chill\CalendarBundle\Entity\Invite;
use Chill\MainBundle\Entity\User;
class InviteUpdateMessage
{
private int $byUserId;
private int $inviteId;
public function __construct(Invite $invite, User $byUser)
{
$this->inviteId = $invite->getId();
$this->byUserId = $byUser->getId();
}
public function getByUserId(): int
{
return $this->byUserId;
}
public function getInviteId(): int
{
return $this->inviteId;
}
}

View File

@ -0,0 +1,35 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\CalendarBundle\Messenger\Message;
class MSGraphChangeNotificationMessage
{
private array $content;
private int $userId;
public function __construct(array $content, int $userId)
{
$this->content = $content;
$this->userId = $userId;
}
public function getContent(): array
{
return $this->content;
}
public function getUserId(): int
{
return $this->userId;
}
}

View File

@ -0,0 +1,41 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph;
use Chill\MainBundle\Entity\Address;
use Chill\MainBundle\Templating\Entity\AddressRender;
use Chill\MainBundle\Templating\TranslatableStringHelperInterface;
class AddressConverter
{
private AddressRender $addressRender;
private TranslatableStringHelperInterface $translatableStringHelper;
public function __construct(AddressRender $addressRender, TranslatableStringHelperInterface $translatableStringHelper)
{
$this->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' => '',
];
}
}

View File

@ -0,0 +1,133 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph;
use Chill\MainBundle\Entity\User;
use DateTimeImmutable;
use LogicException;
use Psr\Log\LoggerInterface;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
/**
* Create a subscription for a user.
*/
class EventsOnUserSubscriptionCreator
{
private LoggerInterface $logger;
private MachineHttpClient $machineHttpClient;
private MapCalendarToUser $mapCalendarToUser;
private UrlGeneratorInterface $urlGenerator;
public function __construct(
LoggerInterface $logger,
MachineHttpClient $machineHttpClient,
MapCalendarToUser $mapCalendarToUser,
UrlGeneratorInterface $urlGenerator
) {
$this->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<secret: string, id: string, expiration: int>
*/
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<secret: string, id: string, expiration: int>
*/
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()];
}
}

View File

@ -0,0 +1,46 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph;
use Chill\MainBundle\Entity\Location;
class LocationConverter
{
private AddressConverter $addressConverter;
public function __construct(AddressConverter $addressConverter)
{
$this->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;
}
}

View File

@ -0,0 +1,77 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph;
use Chill\MainBundle\Entity\User;
use DateInterval;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Query\ResultSetMapping;
use Doctrine\ORM\Query\ResultSetMappingBuilder;
use function strtr;
/**
* Contains classes and methods for fetching users with some calendar metadatas.
*/
class MSGraphUserRepository
{
private const MOST_OLD_SUBSCRIPTION_OR_ANY_MS_GRAPH = <<<'SQL'
select
{select}
from users u
where
NOT attributes ?? 'msgraph'
OR NOT attributes->'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();
}
}

View File

@ -0,0 +1,75 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph;
use League\OAuth2\Client\Tool\BearerAuthorizationTrait;
use LogicException;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
use Symfony\Contracts\HttpClient\ResponseStreamInterface;
class MachineHttpClient implements HttpClientInterface
{
use BearerAuthorizationTrait;
private HttpClientInterface $decoratedClient;
private MachineTokenStorage $machineTokenStorage;
/**
* @param HttpClientInterface $decoratedClient
*/
public function __construct(MachineTokenStorage $machineTokenStorage, ?HttpClientInterface $decoratedClient = null)
{
$this->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);
}
}

View File

@ -0,0 +1,50 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph;
use Chill\MainBundle\Redis\ChillRedis;
use League\OAuth2\Client\Token\AccessTokenInterface;
use TheNetworg\OAuth2\Client\Provider\Azure;
use TheNetworg\OAuth2\Client\Token\AccessToken;
class MachineTokenStorage
{
private const KEY = 'msgraph_access_token';
private ?AccessTokenInterface $accessToken = null;
private Azure $azure;
private ChillRedis $chillRedis;
public function __construct(Azure $azure, ChillRedis $chillRedis)
{
$this->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));
}
}

View File

@ -0,0 +1,193 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph;
use Chill\MainBundle\Entity\User;
use DateTimeImmutable;
use LogicException;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use function array_key_exists;
/**
* Write metadata to user, which allow to find his default calendar.
*/
class MapCalendarToUser
{
public const EXPIRATION_SUBSCRIPTION_EVENT = 'subscription_events_expiration';
public const ID_SUBSCRIPTION_EVENT = 'subscription_events_id';
public const METADATA_KEY = 'msgraph';
public const SECRET_SUBSCRIPTION_EVENT = 'subscription_events_secret';
private LoggerInterface $logger;
private HttpClientInterface $machineHttpClient;
public function __construct(
HttpClientInterface $machineHttpClient,
LoggerInterface $logger
) {
$this->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() OR '' === $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);
}
}

View File

@ -0,0 +1,70 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph;
use League\OAuth2\Client\Tool\BearerAuthorizationTrait;
use LogicException;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
use Symfony\Contracts\HttpClient\ResponseStreamInterface;
class OnBehalfOfUserHttpClient
{
use BearerAuthorizationTrait;
private HttpClientInterface $decoratedClient;
private OnBehalfOfUserTokenStorage $tokenStorage;
/**
* @param HttpClientInterface $decoratedClient
*/
public function __construct(OnBehalfOfUserTokenStorage $tokenStorage, ?HttpClientInterface $decoratedClient = null)
{
$this->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);
}
}

View File

@ -0,0 +1,65 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph;
use LogicException;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use TheNetworg\OAuth2\Client\Provider\Azure;
use TheNetworg\OAuth2\Client\Token\AccessToken;
/**
* Store token obtained on behalf of a User.
*/
class OnBehalfOfUserTokenStorage
{
public const MS_GRAPH_ACCESS_TOKEN = 'msgraph_access_token';
private Azure $azure;
private SessionInterface $session;
public function __construct(Azure $azure, SessionInterface $session)
{
$this->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);
}
}

View File

@ -0,0 +1,277 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph;
use Chill\CalendarBundle\Entity\Calendar;
use Chill\CalendarBundle\Entity\CalendarRange;
use Chill\CalendarBundle\Entity\Invite;
use Chill\CalendarBundle\RemoteCalendar\Model\RemoteEvent;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Templating\Entity\PersonRenderInterface;
use DateTimeImmutable;
use DateTimeZone;
use Psr\Log\LoggerInterface;
use RuntimeException;
use Symfony\Component\Templating\EngineInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* Convert Chill Calendar event to Remote MS Graph event, and MS Graph
* event to RemoteEvent.
*/
class RemoteEventConverter
{
/**
* valid when the remote string contains also a timezone, like in
* lastModifiedDate.
*/
public const REMOTE_DATETIMEZONE_FORMAT = 'Y-m-d\\TH:i:s.u?P';
/**
* Same as above, but sometimes the date is expressed with only 6 milliseconds.
*/
public const REMOTE_DATETIMEZONE_FORMAT_ALT = 'Y-m-d\\TH:i:s.uP';
private const REMOTE_DATE_FORMAT = 'Y-m-d\TH:i:s.u0';
private const REMOTE_DATETIME_WITHOUT_TZ_FORMAT = 'Y-m-d\TH:i:s.u?';
private DateTimeZone $defaultDateTimeZone;
private EngineInterface $engine;
private LocationConverter $locationConverter;
private LoggerInterface $logger;
private PersonRenderInterface $personRender;
private DateTimeZone $remoteDateTimeZone;
private TranslatorInterface $translator;
public function __construct(
EngineInterface $engine,
LocationConverter $locationConverter,
LoggerInterface $logger,
PersonRenderInterface $personRender,
TranslatorInterface $translator
) {
$this->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',
];
}
}

View File

@ -0,0 +1,103 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph\RemoteToLocalSync;
use Chill\CalendarBundle\Entity\CalendarRange;
use Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph\MachineHttpClient;
use Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph\RemoteEventConverter;
use Chill\MainBundle\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use RuntimeException;
use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class CalendarRangeSyncer
{
private EntityManagerInterface $em;
private LoggerInterface $logger;
private HttpClientInterface $machineHttpClient;
/**
* @param MachineHttpClient $machineHttpClient
*/
public function __construct(
EntityManagerInterface $em,
LoggerInterface $logger,
HttpClientInterface $machineHttpClient
) {
$this->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']);
}
}
}

View File

@ -0,0 +1,182 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph\RemoteToLocalSync;
use Chill\CalendarBundle\Entity\Calendar;
use Chill\CalendarBundle\Entity\Invite;
use Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph\RemoteEventConverter;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Repository\UserRepositoryInterface;
use LogicException;
use Psr\Log\LoggerInterface;
use RuntimeException;
use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use function in_array;
class CalendarSyncer
{
private LoggerInterface $logger;
private HttpClientInterface $machineHttpClient;
private UserRepositoryInterface $userRepository;
public function __construct(LoggerInterface $logger, HttpClientInterface $machineHttpClient, UserRepositoryInterface $userRepository)
{
$this->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);
}
}
}
}

View File

@ -0,0 +1,733 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\CalendarBundle\RemoteCalendar\Connector;
use Chill\CalendarBundle\Entity\Calendar;
use Chill\CalendarBundle\Entity\CalendarRange;
use Chill\CalendarBundle\Entity\Invite;
use Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph\MapCalendarToUser;
use Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph\OnBehalfOfUserHttpClient;
use Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph\OnBehalfOfUserTokenStorage;
use Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph\RemoteEventConverter;
use Chill\CalendarBundle\Repository\CalendarRangeRepository;
use Chill\CalendarBundle\Repository\CalendarRepository;
use Chill\MainBundle\Entity\User;
use DateTimeImmutable;
use Exception;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
use function array_key_exists;
use function count;
class MSGraphRemoteCalendarConnector implements RemoteCalendarConnectorInterface
{
private array $cacheScheduleTimeForUser = [];
private CalendarRangeRepository $calendarRangeRepository;
private CalendarRepository $calendarRepository;
private LoggerInterface $logger;
private HttpClientInterface $machineHttpClient;
private MapCalendarToUser $mapCalendarToUser;
private RemoteEventConverter $remoteEventConverter;
private OnBehalfOfUserTokenStorage $tokenStorage;
private TranslatorInterface $translator;
private UrlGeneratorInterface $urlGenerator;
private OnBehalfOfUserHttpClient $userHttpClient;
public function __construct(
CalendarRepository $calendarRepository,
CalendarRangeRepository $calendarRangeRepository,
HttpClientInterface $machineHttpClient,
MapCalendarToUser $mapCalendarToUser,
LoggerInterface $logger,
OnBehalfOfUserTokenStorage $tokenStorage,
OnBehalfOfUserHttpClient $userHttpClient,
RemoteEventConverter $remoteEventConverter,
TranslatorInterface $translator,
UrlGeneratorInterface $urlGenerator
) {
$this->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'],
]);
}
}

View File

@ -0,0 +1,63 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\CalendarBundle\RemoteCalendar\Connector;
use Chill\CalendarBundle\Entity\Calendar;
use Chill\CalendarBundle\Entity\CalendarRange;
use Chill\CalendarBundle\Entity\Invite;
use Chill\MainBundle\Entity\User;
use DateTimeImmutable;
use LogicException;
use Symfony\Component\HttpFoundation\Response;
class NullRemoteCalendarConnector implements RemoteCalendarConnectorInterface
{
public function countEventsForUser(User $user, DateTimeImmutable $startDate, DateTimeImmutable $endDate): int
{
return 0;
}
public function getMakeReadyResponse(string $returnPath): Response
{
throw new LogicException('As this connector is always ready, this method should not be called');
}
public function isReady(): bool
{
return true;
}
public function listEventsForUser(User $user, DateTimeImmutable $startDate, DateTimeImmutable $endDate, ?int $offset = 0, ?int $limit = 50): array
{
return [];
}
public function removeCalendar(string $remoteId, array $remoteAttributes, User $user, ?CalendarRange $associatedCalendarRange = null): void
{
}
public function removeCalendarRange(string $remoteId, array $remoteAttributes, User $user): void
{
}
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
{
}
}

View File

@ -0,0 +1,56 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\CalendarBundle\RemoteCalendar\Connector;
use Chill\CalendarBundle\Entity\Calendar;
use Chill\CalendarBundle\Entity\CalendarRange;
use Chill\CalendarBundle\Entity\Invite;
use Chill\CalendarBundle\RemoteCalendar\Model\RemoteEvent;
use Chill\MainBundle\Entity\User;
use DateTimeImmutable;
use Symfony\Component\HttpFoundation\Response;
interface RemoteCalendarConnectorInterface
{
public function countEventsForUser(User $user, DateTimeImmutable $startDate, DateTimeImmutable $endDate): int;
/**
* Return a response, more probably a RedirectResponse, where the user
* will be able to fullfill requirements to prepare this connector and
* make it ready.
*/
public function getMakeReadyResponse(string $returnPath): Response;
/**
* Return true if the connector is ready to act as a proxy for reading
* remote calendars.
*/
public function isReady(): bool;
/**
* @return array|RemoteEvent[]
*/
public function listEventsForUser(User $user, DateTimeImmutable $startDate, DateTimeImmutable $endDate, ?int $offset = 0, ?int $limit = 50): array;
public function removeCalendar(string $remoteId, array $remoteAttributes, User $user, ?CalendarRange $associatedCalendarRange = null): void;
public function removeCalendarRange(string $remoteId, array $remoteAttributes, User $user): void;
/**
* @param array<array{inviteId: int, userId: int, userEmail: int, userLabel: string}> $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;
}

View File

@ -0,0 +1,75 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\CalendarBundle\RemoteCalendar\DependencyInjection;
use Chill\CalendarBundle\Command\AzureGrantAdminConsentAndAcquireToken;
use Chill\CalendarBundle\Command\MapAndSubscribeUserCalendarCommand;
use Chill\CalendarBundle\Controller\RemoteCalendarConnectAzureController;
use Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph\MachineHttpClient;
use Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph\MachineTokenStorage;
use Chill\CalendarBundle\RemoteCalendar\Connector\MSGraphRemoteCalendarConnector;
use Chill\CalendarBundle\RemoteCalendar\Connector\NullRemoteCalendarConnector;
use Chill\CalendarBundle\RemoteCalendar\Connector\RemoteCalendarConnectorInterface;
use RuntimeException;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use TheNetworg\OAuth2\Client\Provider\Azure;
class RemoteCalendarCompilerPass implements CompilerPassInterface
{
public function process(ContainerBuilder $container)
{
$config = $container->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);
}
}
}
}
}

View File

@ -0,0 +1,55 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\CalendarBundle\RemoteCalendar\Model;
use DateTimeImmutable;
use Symfony\Component\Serializer\Annotation as Serializer;
class RemoteEvent
{
public string $description;
/**
* @Serializer\Groups({"read"})
*/
public DateTimeImmutable $endDate;
/**
* @Serializer\Groups({"read"})
*/
public string $id;
/**
* @Serializer\Groups({"read"})
*/
public bool $isAllDay;
/**
* @Serializer\Groups({"read"})
*/
public DateTimeImmutable $startDate;
/**
* @Serializer\Groups({"read"})
*/
public string $title;
public function __construct(string $id, string $title, string $description, DateTimeImmutable $startDate, DateTimeImmutable $endDate, bool $isAllDay = false)
{
$this->id = $id;
$this->title = $title;
$this->description = $description;
$this->startDate = $startDate;
$this->endDate = $endDate;
$this->isAllDay = $isAllDay;
}
}

View File

@ -0,0 +1,80 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\CalendarBundle\Repository;
use Chill\CalendarBundle\Entity\Calendar;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\QueryBuilder;
class CalendarACLAwareRepository implements CalendarACLAwareRepositoryInterface
{
private EntityManagerInterface $em;
public function __construct(EntityManagerInterface $em)
{
$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 countByAccompanyingPeriod(AccompanyingPeriod $period, ?DateTimeImmutable $startDate, ?DateTimeImmutable $endDate): int
{
$qb = $this->buildQueryByAccompanyingPeriod($period, $startDate, $endDate)->select('count(c)');
return $qb->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();
}
}

View File

@ -0,0 +1,26 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\CalendarBundle\Repository;
use Chill\CalendarBundle\Entity\Calendar;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use DateTimeImmutable;
interface CalendarACLAwareRepositoryInterface
{
public function countByAccompanyingPeriod(AccompanyingPeriod $period, ?DateTimeImmutable $startDate, ?DateTimeImmutable $endDate): int;
/**
* @return array|Calendar[]
*/
public function findByAccompanyingPeriod(AccompanyingPeriod $period, ?DateTimeImmutable $startDate, ?DateTimeImmutable $endDate, ?array $orderBy = [], ?int $offset = null, ?int $limit = null): array;
}

View File

@ -12,48 +12,159 @@ declare(strict_types=1);
namespace Chill\CalendarBundle\Repository;
use Chill\CalendarBundle\Entity\CalendarRange;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
use Chill\MainBundle\Entity\User;
use DateTimeImmutable;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\Query\ResultSetMapping;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ObjectRepository;
use function count;
/**
* @method CalendarRange|null find($id, $lockMode = null, $lockVersion = null)
* @method CalendarRange|null findOneBy(array $criteria, array $orderBy = null)
* @method CalendarRange[] findAll()
* @method CalendarRange[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
*/
class CalendarRangeRepository extends ServiceEntityRepository
class CalendarRangeRepository implements ObjectRepository
{
public function __construct(ManagerRegistry $registry)
private EntityManagerInterface $em;
private EntityRepository $repository;
public function __construct(EntityManagerInterface $entityManager)
{
parent::__construct($registry, CalendarRange::class);
$this->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<int, string>|list<string> $remoteIds
*
* @return array<string, bool>
*/
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,
]);
}
}

View File

@ -12,52 +12,215 @@ 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 find($id): ?Calendar
{
return $this->createQueryBuilder('c')
->andWhere('c.exampleField = :val')
->setParameter('val', $value)
->getQuery()
->getOneOrNullResult()
;
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<int, string>|list<string> $remoteIds
*
* @return array<string, bool>
*/
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;
}
}

View File

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

View File

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

View File

@ -8,3 +8,36 @@ services:
event: 'postPersist'
entity: 'Chill\ActivityBundle\Entity\Activity'
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'

View File

@ -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 }
Chill\CalendarBundle\Form\:
resource: './../../Form'
autowire: true
autoconfigure: true

View File

@ -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/'

View File

@ -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: '<answer :calendarId="calendarId" :status="status" @statusChanged="onStatusChanged"></answer>',
methods: {
onStatusChanged: function(newStatus) {
this.$data.status = newStatus;
},
}
});
app.use(i18n).mount(el);
});
});

View File

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

View File

@ -1,21 +1,113 @@
<template>
<concerned-groups></concerned-groups>
<location></location>
<teleport to="#calendarControls">
<calendar-user-selector
v-bind:users="users"
v-bind:calendarEvents="calendarEvents"
v-bind:updateEventsSource="updateEventsSource"
v-bind:showMyCalendar="showMyCalendar"
v-bind:toggleMyCalendar="toggleMyCalendar"
v-bind:toggleWeekends="toggleWeekends" >
</calendar-user-selector>
<teleport to="#mainUser">
<h2 class="chill-red">Utilisateur principal</h2>
<div>
<div>
<div v-if="null !== this.$store.getters.getMainUser">
<calendar-active :user="this.$store.getters.getMainUser" ></calendar-active>
</div>
<pick-entity
:multiple="false"
:types="['user']"
:uniqid="'main_user_calendar'"
:picked="null !== this.$store.getters.getMainUser ? [this.$store.getters.getMainUser] : []"
:removableIfSet="false"
:displayPicked="false"
@addNewEntity="setMainUser"
></pick-entity>
</div>
</div>
</teleport>
<concerned-groups></concerned-groups>
<teleport to="#schedule">
<div class="row mb-3" v-if="activity.startDate !== null">
<label class="col-form-label col-sm-4">Date</label>
<div class="col-sm-8">
{{ $d(activity.startDate, 'long') }} - {{ $d(activity.endDate, 'hoursOnly') }}
<span v-if="activity.calendarRange === null">(Pas de plage de disponibilité sélectionnée)</span>
<span v-else>(Une plage de disponibilité sélectionnée)</span>
</div>
</div>
</teleport>
<location></location>
<teleport to="#fullCalendar">
<div class="calendar-actives">
<template class="" v-for="u in getActiveUsers" :key="u.id">
<calendar-active :user="u" :invite="this.$store.getters.getInviteForUser(u)"></calendar-active>
</template>
</div>
<div class="display-options row justify-content-between" style="margin-top: 1rem;">
<div class="col-sm-9 col-xs-12">
<div class="input-group mb-3">
<label class="input-group-text" for="slotDuration">Durée des créneaux</label>
<select v-model="slotDuration" id="slotDuration" class="form-select">
<option value="00:05:00">5 minutes</option>
<option value="00:10:00">10 minutes</option>
<option value="00:15:00">15 minutes</option>
<option value="00:30:00">30 minutes</option>
</select>
<label class="input-group-text" for="slotMinTime">De</label>
<select v-model="slotMinTime" id="slotMinTime" class="form-select">
<option value="00:00:00">0h</option>
<option value="01:00:00">1h</option>
<option value="02:00:00">2h</option>
<option value="03:00:00">3h</option>
<option value="04:00:00">4h</option>
<option value="05:00:00">5h</option>
<option value="06:00:00">6h</option>
<option value="07:00:00">7h</option>
<option value="08:00:00">8h</option>
<option value="09:00:00">9h</option>
<option value="10:00:00">10h</option>
<option value="11:00:00">11h</option>
<option value="12:00:00">12h</option>
</select>
<label class="input-group-text" for="slotMaxTime">À</label>
<select v-model="slotMaxTime" id="slotMaxTime" class="form-select">
<option value="12:00:00">12h</option>
<option value="13:00:00">13h</option>
<option value="14:00:00">14h</option>
<option value="15:00:00">15h</option>
<option value="16:00:00">16h</option>
<option value="17:00:00">17h</option>
<option value="18:00:00">18h</option>
<option value="19:00:00">19h</option>
<option value="20:00:00">20h</option>
<option value="21:00:00">21h</option>
<option value="22:00:00">22h</option>
<option value="23:00:00">23h</option>
<option value="23:59:59">24h</option>
</select>
</div>
</div>
<div class="col-sm-3 col-xs-12">
<div class="float-end">
<div class="form-check input-group">
<span class="input-group-text">
<input id="showHideWE" class="mt-0" type="checkbox" v-model="hideWeekends">
</span>
<label for="showHideWE" class="form-check-label input-group-text">Week-ends</label>
</div>
</div>
</div>
</div>
<FullCalendar ref="fullCalendar" :options="calendarOptions">
<template v-slot:eventContent='arg'>
<b>{{ arg.timeText }}</b>
<i>&nbsp;{{ arg.event.title }}</i>
<span>
<b v-if="arg.event.extendedProps.is === 'remote'">{{ arg.event.title}}</b>
<b v-else-if="arg.event.extendedProps.is === 'range'">{{ arg.timeText }} {{ arg.event.extendedProps.locationName }} <small>{{ arg.event.extendedProps.userLabel }}</small></b>
<b v-else-if="arg.event.extendedProps.is === 'current'">{{ arg.timeText }} {{ $t('current_selected')}} </b>
<b v-else-if="arg.event.extendedProps.is === 'local'">{{ arg.event.title}}</b>
<b v-else>{{ arg.timeText }} {{ $t('current_selected')}} </b>
</span>
</template>
</FullCalendar>
</teleport>
@ -24,133 +116,165 @@
<script>
import ConcernedGroups from 'ChillActivityAssets/vuejs/Activity/components/ConcernedGroups.vue';
import Location from 'ChillActivityAssets/vuejs/Activity/components/Location.vue';
import CalendarUserSelector from '../_components/CalendarUserSelector/CalendarUserSelector.vue';
import '@fullcalendar/core/vdom'; // solves problem with Vite
import frLocale from '@fullcalendar/core/locales/fr';
import FullCalendar from '@fullcalendar/vue3';
import dayGridPlugin from '@fullcalendar/daygrid';
import interactionPlugin from '@fullcalendar/interaction';
import timeGridPlugin from '@fullcalendar/timegrid';
// import listPlugin from '@fullcalendar/list';
import listPlugin from '@fullcalendar/list';
import CalendarActive from './Components/CalendarActive';
import PickEntity from 'ChillMainAssets/vuejs/PickEntity/PickEntity.vue';
import {mapGetters, mapState} from "vuex";
export default {
name: "App",
components: {
ConcernedGroups,
Location,
CalendarUserSelector,
FullCalendar
FullCalendar,
CalendarActive,
PickEntity,
},
data() {
return {
errorMsg: [],
users: {
loaded: [],
selected: [],
logged: null
},
calendarEvents: {
loaded: [],
selected: [],
user: [],
current: {
events: [{
title: 'plage prévue',
start: window.startDate,
end: window.endDate
}],
id: window.mainUser,
color: '#bbbbbb'
showMyCalendar: false,
slotDuration: '00:05:00',
slotMinTime: '09:00:00',
slotMaxTime: '18:00:00',
hideWeekEnds: true,
}
},
selectedEvent: null,
previousSelectedEvent: null,
previousSelectedEventColor: null,
showMyCalendar: false,
calendarOptions: {
computed: {
...mapGetters(['getMainUser']),
...mapState(['activity']),
events() {
return this.$store.getters.getEventSources;
},
calendarOptions() {
return {
locale: frLocale,
plugins: [ dayGridPlugin, interactionPlugin, timeGridPlugin ],
plugins: [dayGridPlugin, interactionPlugin, timeGridPlugin, dayGridPlugin, listPlugin],
initialView: 'timeGridWeek',
initialDate: window.startDate !== undefined ? window.startDate : new Date(),
eventSource: [],
initialDate: this.$store.getters.getInitialDate,
eventSources: this.events,
selectable: true,
slotMinTime: this.slotMinTime,
slotMaxTime: this.slotMaxTime,
scrollTimeReset: false,
datesSet: this.onDatesSet,
select: this.onDateSelect,
eventChange: this.onEventChange,
eventClick: this.onEventClick,
// eventMouseEnter: this.onEventMouseEnter,
// eventMouseLeave: this.onEventMouseLeave,
selectMirror: true,
editable: true,
weekends: false,
weekends: !this.hideWeekEnds,
headerToolbar: {
left: 'prev,next today',
center: 'title',
right: 'dayGridMonth,timeGridWeek,timeGridDay,listMonth,listWeek,listDay'
right: 'timeGridWeek,timeGridDay,listWeek',
},
views: {
timeGrid: {
slotEventOverlap: false,
slotDuration: this.slotDuration,
},
},
};
},
getActiveUsers() {
const users = [];
for (const id of this.$store.state.currentView.users.keys()) {
users.push(this.$store.getters.getUserDataById(id).user);
}
return users;
}
},
methods: {
init() {
this.updateEventsSource();
},
toggleMyCalendar(value) {
this.showMyCalendar = value;
},
toggleWeekends: function() {
this.calendarOptions.weekends = !this.calendarOptions.weekends;
},
updateEventsSource() {
this.calendarOptions.eventSources = [];
this.calendarOptions.eventSources.push(...this.calendarEvents.selected);
if (window.startDate !== undefined) {
this.calendarOptions.eventSources.push(this.calendarEvents.current);
}
if (this.showMyCalendar) {
this.calendarOptions.eventSources.push(this.calendarEvents.user);
}
},
unSelectPreviousEvent(event) {
if (event) {
if (typeof event.setProp === 'function') {
event.setProp('backgroundColor', this.previousSelectedEventColor);
event.setProp('borderColor', this.previousSelectedEventColor);
event.setProp('textColor','#444444');
event.setProp('title','');
setMainUser(user) {
console.log('setMainUser APP', user);
if (user.id !== this.$store.getters.getMainUser && (
this.$store.state.activity.calendarRange !== null
|| this.$store.state.activity.startDate !== null
|| this.$store.state.activity.endDate !== null
)
) {
if (!window.confirm(this.$t('change_main_user_will_reset_event_data'))) {
return;
}
}
this.$store.dispatch('setMainUser', user);
this.$store.commit('showUserOnCalendar', {user, ranges: true, remotes: true});
},
removeMainUser(user) {
console.log('removeMainUser APP', user);
window.alert(this.$t('main_user_is_mandatory'));
return;
},
onDatesSet(event) {
console.log('onDatesSet', event);
this.$store.dispatch('setCurrentDatesView', {start: event.start, end: event.end});
},
onDateSelect(payload) {
console.log(payload)
this.unSelectPreviousEvent(this.selectedEvent);
Object.assign(payload, {users: this.users});
Object.assign(payload, {title: 'Choisir cette plage'}); //TODO does not display
//payload.event.setProp('title', 'Choisir cette plage');
this.$store.dispatch('createEvent', payload);
console.log('onDateSelect', payload);
// show an alert if changing mainUser
if ((this.$store.getters.getMainUser !== null
&& this.$store.state.me.id !== this.$store.getters.getMainUser.id)
|| this.$store.getters.getMainUser === null) {
if (!window.confirm(this.$t('will_change_main_user_for_me'))) {
return;
} else {
this.$store.commit('showUserOnCalendar', {user: this.$store.state.me, remotes: true, ranges: true})
}
}
this.$store.dispatch('setEventTimes', {start: payload.start, end: payload.end});
},
onEventChange(payload) {
this.$store.dispatch('updateEvent', payload);
console.log('onEventChange', payload);
if (this.$store.state.activity.calendarRange !== null) {
throw new Error("not allowed to edit a calendar associated with a calendar range");
}
this.$store.dispatch('setEventTimes', {start: payload.event.start, end: payload.event.end});
},
onEventClick(payload) {
this.previousSelectedEvent = this.selectedEvent;
this.previousSelectedEventColor = payload.event.extendedProps.sourceColor;
this.selectedEvent = payload.event;
this.unSelectPreviousEvent(this.previousSelectedEvent);
payload.event.setProp('backgroundColor','#3788d8');
payload.event.setProp('borderColor','#3788d8');
payload.event.setProp('title', 'Choisir cette plage');
payload.event.setProp('textColor','#ffffff');
},
onEventMouseEnter(payload) {
payload.event.setProp('borderColor','#444444');
},
onEventMouseLeave(payload) {
payload.event.setProp('borderColor','#ffffff');
if (payload.event.extendedProps.is !== 'range') {
// do nothing when clicking on remote
return;
}
},
mounted() {
this.init();
// show an alert if changing mainUser
if (this.$store.getters.getMainUser !== null
&& payload.event.extendedProps.userId !== this.$store.getters.getMainUser.id) {
if (!window.confirm(this.$t('this_calendar_range_will_change_main_user'))) {
return;
}
}
this.$store.dispatch('associateCalendarToRange', {range: payload.event});
},
},
}
</script>
<style>
.calendar-actives {
display: flex;
flex-direction: row;
flex-wrap: wrap;
}
.display-options {
margin-top: 1rem;
}
/* for events which are range */
.fc-event.isrange {
border-width: 3px;
}
</style>

View File

@ -0,0 +1,80 @@
<template>
<div :style="style" class="calendar-active">
<span class="badge-user">
{{ user.text }}
<template v-if="invite !== null">
<i v-if="invite.status === 'accepted'" class="fa fa-check"></i>
<i v-else-if="invite.status === 'declined'" class="fa fa-times"></i>
<i v-else-if="invite.status === 'pending'" class="fa fa-question-o"></i>
<i v-else-if="invite.status === 'tentative'" class="fa fa-question"></i>
<span v-else="">{{ invite.status }}</span>
</template>
</span>
<span class="form-check-inline form-switch">
<input class="form-check-input" type="checkbox" id="flexSwitchCheckDefault" v-model="rangeShow">
&nbsp;<label class="form-check-label" for="flexSwitchCheckDefault" title="Disponibilités"><i class="fa fa-calendar-check-o"></i></label>
</span>
<span class="form-check-inline form-switch">
<input class="form-check-input" type="checkbox" id="flexSwitchCheckDefault" v-model="remoteShow">
&nbsp;<label class="form-check-label" for="flexSwitchCheckDefault" title="Agenda"><i class="fa fa-calendar"></i></label>
</span>
</div>
</template>
<script>
import {mapGetters} from 'vuex';
export default {
name: "CalendarActive",
props: {
user: {
type: Object,
required: true
},
invite: {
type: Object,
required: false,
default: null,
}
},
computed: {
style() {
return {
backgroundColor: this.$store.getters.getUserData(this.user).mainColor,
};
},
rangeShow: {
set (value) {
this.$store.commit('showUserOnCalendar', {user: this.user, ranges: value});
},
get() {
return this.$store.getters.isRangeShownOnCalendarForUser(this.user);
}
},
remoteShow: {
set (value) {
this.$store.commit('showUserOnCalendar', {user: this.user, remotes: value});
},
get() {
return this.$store.getters.isRemoteShownOnCalendarForUser(this.user);
}
},
}
}
</script>
<style scoped lang="scss">
.calendar-active {
margin: 0 0.25rem 0.25rem 0;
padding: 0.5rem;
border-radius: 0.5rem;
color: var(--bs-blue);
& > .badge-user {
margin-right: 0.5rem;
}
}
</style>

View File

@ -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<CalendarRange[]> => {
const uri = `/api/1.0/calendar/calendar-range-available/${user.id}.json`;
const dateFrom = datetimeToISO(start);
const dateTo = datetimeToISO(end);
return fetchResults<CalendarRange>(uri, {dateFrom, dateTo});
}
export const fetchCalendarRemoteForUser = (user: User, start: Date, end: Date): Promise<CalendarRemote[]> => {
const uri = `/api/1.0/calendar/proxy/calendar/by-user/${user.id}/events`;
const dateFrom = datetimeToISO(start);
const dateTo = datetimeToISO(end);
return fetchResults<CalendarRemote>(uri, {dateFrom, dateTo});
}
export const fetchCalendarLocalForUser = (user: User, start: Date, end: Date): Promise<CalendarLight[]> => {
const uri = `/api/1.0/calendar/calendar/by-user/${user.id}.json`;
const dateFrom = datetimeToISO(start);
const dateTo = datetimeToISO(end);
return fetchResults<CalendarLight>(uri, {dateFrom, dateTo});
}

View File

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

View File

@ -1,6 +1,6 @@
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: {
@ -12,7 +12,13 @@ const appMessages = {
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é',
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,95 @@
<template>
<div class="btn-group" role="group">
<button id="btnGroupDrop1" type="button" class="btn btn-misc dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
<template v-if="status === Statuses.PENDING">
<span class="fa fa-hourglass"></span> {{ $t('Give_an_answer')}}
</template>
<template v-else-if="status === Statuses.ACCEPTED">
<span class="fa fa-check"></span> {{ $t('Accepted')}}
</template>
<template v-else-if="status === Statuses.DECLINED">
<span class="fa fa-times"></span> {{ $t('Declined')}}
</template>
<template v-else-if="status === Statuses.TENTATIVELY_ACCEPTED">
<span class="fa fa-question"></span> {{ $t('Tentative')}}
</template>
</button>
<ul class="dropdown-menu" aria-labelledby="btnGroupDrop1">
<li v-if="status !== Statuses.ACCEPTED"><a class="dropdown-item" @click="changeStatus(Statuses.ACCEPTED)"><i class="fa fa-check" aria-hidden="true"></i> {{ $t('Accept') }}</a></li>
<li v-if="status !== Statuses.DECLINED"><a class="dropdown-item" @click="changeStatus(Statuses.DECLINED)"><i class="fa fa-times" aria-hidden="true"></i> {{ $t('Decline') }}</a></li>
<li v-if="status !== Statuses.TENTATIVELY_ACCEPTED"><a class="dropdown-item" @click="changeStatus(Statuses.TENTATIVELY_ACCEPTED)"><i class="fa fa-question"></i> {{ $t('Tentatively_accept') }}</a></li>
<li v-if="status !== Statuses.PENDING"><a class="dropdown-item" @click="changeStatus(Statuses.PENDING)"><i class="fa fa-hourglass-o"></i> {{ $t('Set_pending') }}</a></li>
</ul>
</div>
</template>
<script lang="ts">
import {defineComponent, PropType} from 'vue';
const ACCEPTED = 'accepted';
const DECLINED = 'declined';
const PENDING = 'pending';
const TENTATIVELY_ACCEPTED = 'tentative';
const i18n = {
messages: {
fr: {
"Give_an_answer": "Répondre",
"Accepted": "Accepté",
"Declined": "Refusé",
"Tentative": "Accepté provisoirement",
"Accept": "Accepter",
"Decline": "Refuser",
"Tentatively_accept": "Accepter provisoirement",
"Set_pending": "Ne pas répondre",
}
}
};
export default defineComponent({
name: "Answer",
i18n,
props: {
calendarId: { type: Number, required: true},
status: {type: String as PropType<"accepted" | "declined" | "pending" | "tentative">, required: true},
},
emits: {
statusChanged(payload: "accepted" | "declined" | "pending" | "tentative") {
return true;
},
},
data() {
return {
Statuses: {
ACCEPTED,
DECLINED,
PENDING,
TENTATIVELY_ACCEPTED,
},
}
},
methods: {
changeStatus: function (newStatus: "accepted" | "declined" | "pending" | "tentative") {
console.log('changeStatus', newStatus);
const url = `/api/1.0/calendar/calendar/${this.$props.calendarId}/answer/${newStatus}.json`;
window.fetch(url, {
method: 'POST',
}).then((r: Response) => {
if (!r.ok) {
console.error('could not confirm answer', newStatus);
return;
}
console.log('answer sent', newStatus);
this.$emit('statusChanged', newStatus);
});
},
}
})
</script>
<style scoped>
</style>

View File

@ -1,468 +0,0 @@
<template>
<div>
<h2 class="chill-red">{{ $t('edit_your_calendar_range') }}</h2>
<div class="form-check">
<input type="checkbox" id="myCalendar" class="form-check-input" v-model="showMyCalendarWidget" />
<label class="form-check-label" for="myCalendar">{{ $t('show_my_calendar') }}</label>
</div>
<div class="form-check">
<input type="checkbox" id="weekends" class="form-check-input" @click="toggleWeekends" />
<label class="form-check-label" for="weekends">{{ $t('show_weekends') }}</label>
</div>
<FullCalendar ref="fullCalendar" :options="calendarOptions">
<template v-slot:eventContent='arg' >
<span class='calendarRangeItems'>
<b v-if="arg.event.extendedProps.myCalendar" style="text-decoration: underline" >{{ arg.timeText }}</b>
<b v-else-if="!arg.event.extendedProps.myCalendar && arg.event.extendedProps.toDelete" style="text-decoration: line-through red" >{{ arg.timeText }}</b>
<b v-else >{{ arg.timeText }}</b>
<i>&nbsp;{{ arg.event.title }}</i>
<a v-if=!arg.event.extendedProps.myCalendar class="fa fa-fw fa-times"
@click.prevent="onClickDelete(arg.event)">
</a>
</span>
</template>
</FullCalendar>
<div>
<ul class="record_actions">
<li>
<button class="btn btn-save" :disabled="!dirty"
@click.prevent="onClickSave">
{{ $t('action.save')}}
</button>
<span v-if="flag.loading" class="loading">
<i class="fa fa-circle-o-notch fa-spin fa-fw"></i>
<span class="sr-only">{{ $t('loading') }}</span>
</span>
</li>
<li>
<button v-if="disableCopyDayButton" class="btn btn-action" disabled>
{{ $t('copy_range_to_next_day')}}
</button>
<button v-else class="btn btn-action"
@click.prevent="copyDay">
{{ $t('copy_range_from_day')}} {{this.lastNewDate.toLocaleDateString()}} {{ $t('to_the_next_day')}}
</button>
</li>
</ul>
</div>
<div>
<div v-if="newCalendarRanges.length > 0">
<h4>{{ $t('new_range_to_save') }}</h4>
<ul>
<li v-for="i in newCalendarRanges" :key="i.start">
{{ i.start.toLocaleString() }} - {{ i.end.toLocaleString() }}
</li>
</ul>
</div>
<div v-if="updateCalendarRanges.length > 0">
<h4>{{ $t('update_range_to_save') }}</h4>
<ul>
<li v-for="i in updateCalendarRanges" :key="i.start">
{{ i.start.toLocaleString() }} - {{ i.end.toLocaleString() }}
</li>
</ul>
</div>
<div v-if="deleteCalendarRanges.length > 0">
<h4>{{ $t('delete_range_to_save') }}</h4>
<ul>
<li v-for="i in deleteCalendarRanges" :key="i.start">
{{ i.start.toLocaleString() }} - {{ i.end.toLocaleString() }}
</li>
</ul>
</div>
</div>
</div>
<teleport to="body">
<modal v-if="modal.showModal"
:modalDialogClass="modal.modalDialogClass"
@close="modal.showModal = false">
<template v-slot:header>
<h2 class="modal-title">{{ this.renderEventDate() }}</h2>
</template>
<template v-slot:body>
<p>{{ $t('by')}} {{this.myCalendarClickedEvent.user.username }}</p>
<p>{{ $t('main_user_concerned') }} : {{ this.myCalendarClickedEvent.mainUser.username }}</p>
<p v-if="myCalendarClickedEvent.comment.length > 0" >{{ this.myCalendarClickedEvent.comment }}</p>
</template>
<template v-slot:footer>
<ul class="record_actions">
<li>
<a
class="btn btn-show"
:href=myCalendarEventShowLink() >
</a>
</li>
<li>
<a
class="btn btn-update"
:href=myCalendarEventUpdateLink() >
</a>
</li>
<li>
<a
class="btn btn-delete"
:href=myCalendarEventDeleteLink() >
</a>
</li>
</ul>
</template>
</modal>
</teleport>
</template>
<script>
import '@fullcalendar/core/vdom'; // solves problem with Vite
import frLocale from '@fullcalendar/core/locales/fr';
import FullCalendar from '@fullcalendar/vue3';
import dayGridPlugin from '@fullcalendar/daygrid';
import interactionPlugin from '@fullcalendar/interaction';
import timeGridPlugin from '@fullcalendar/timegrid';
import Modal from 'ChillMainAssets/vuejs/_components/Modal';
import { deleteCalendarRange, fetchCalendar, fetchCalendarRangesByUser, patchCalendarRange, postCalendarRange } from '../_api/api';
import { mapState } from 'vuex';
export default {
name: "App",
components: {
FullCalendar,
Modal
},
data() {
return {
errorMsg: [],
modal: {
showModal: false,
modalDialogClass: "modal-dialog-scrollable modal-m"
},
flag: {
loading: false
},
userId: window.userId,
showMyCalendar: true,
myCalendarClickedEvent: null,
calendarEvents: {
userCalendar: null,
userCalendarRange: null,
new: {
events: [],
color: "#3788d8"
}
},
lastNewDate: null,
disableCopyDayButton: true,
calendarOptions: {
locale: frLocale,
plugins: [ dayGridPlugin, interactionPlugin, timeGridPlugin ],
initialView: 'timeGridWeek',
initialDate: window.startDate !== undefined ? window.startDate : new Date(),
eventSource: [],
selectable: true,
select: this.onDateSelect,
eventChange: this.onEventChange,
eventDrop: this.onEventDropOrResize,
eventResize: this.onEventDropOrResize,
eventClick: this.onEventClick,
selectMirror: false,
editable: true,
weekends: false,
headerToolbar: {
left: 'prev,next today',
center: 'title',
right: 'dayGridMonth,timeGridWeek,timeGridDay'
},
},
}
},
computed: {
...mapState({
newCalendarRanges: state => state.newCalendarRanges,
updateCalendarRanges: state => state.updateCalendarRanges,
deleteCalendarRanges: state => state.deleteCalendarRanges,
dirty: state => state.newCalendarRanges.length > 0 || state.updateCalendarRanges.length > 0 || state.deleteCalendarRanges.length > 0
}),
showMyCalendarWidget: {
set(value) {
this.toggleMyCalendar(value);
this.updateEventsSource();
},
get() {
return this.showMyCalendar;
}
},
},
methods: {
init() {
this.fetchData();
},
openModal() {
this.modal.showModal = true;
},
myCalendarEventShowLink() {
return `/fr/calendar/calendar/${this.myCalendarClickedEvent.id}/show?user_id=${ this.userId }`
},
myCalendarEventUpdateLink() {
return `/fr/calendar/calendar/${this.myCalendarClickedEvent.id}/edit?user_id=${ this.userId }`
},
myCalendarEventDeleteLink() {
return `/fr/calendar/calendar/${this.myCalendarClickedEvent.id}/delete?user_id=${ this.userId }`
},
resetCalendar() {
this.fetchData();
this.calendarEvents.new = {
events: [],
color: "#3788d8"
};
this.updateEventsSource();
},
fetchData() {
this.flag.loading = true;
fetchCalendarRangesByUser(this.userId).then(calendarRanges => new Promise((resolve, reject) => {
let events = calendarRanges.results.map(i =>
({
start: i.startDate.datetime,
end: i.endDate.datetime,
calendarRangeId: i.id,
toDelete: false
})
);
let calendarRangeEvents = {
events: events,
borderColor: "#3788d8",
backgroundColor: '#ffffff',
textColor: '#444444',
};
this.calendarEvents.userCalendarRange = calendarRangeEvents;
fetchCalendar(this.userId).then(calendar => new Promise((resolve, reject) => {
let events = calendar.results.map(i =>
({
myCalendar: true,
calendarId: i.id,
start: i.startDate.datetime,
end: i.endDate.datetime,
user: i.user,
mainUser: i.mainUser,
persons: i.persons,
professionals: i.professionals,
comment: i.comment
})
);
let calendarEventsCurrentUser = {
events: events,
color: 'darkblue',
id: 1000,
editable: false
};
this.calendarEvents.userCalendar = calendarEventsCurrentUser;
this.updateEventsSource();
this.flag.loading = false;
resolve();
}));
resolve();
}));
},
updateEventsSource() {
this.calendarOptions.eventSources = [];
this.calendarOptions.eventSources.push(this.calendarEvents.new);
this.calendarOptions.eventSources.push(this.calendarEvents.userCalendarRange);
if (this.showMyCalendar) {
this.calendarOptions.eventSources.push(this.calendarEvents.userCalendar);
}
console.log(this.calendarOptions.eventSources);
},
toggleMyCalendar(value) {
this.showMyCalendar = value;
},
toggleWeekends: function() {
this.calendarOptions.weekends = !this.calendarOptions.weekends;
},
onDateSelect(payload) {
let events = this.calendarEvents.new.events;
events.push({
start: payload.startStr,
end: payload.endStr
});
this.calendarEvents.new = {
events: events,
borderColor: "#3788d8",
backgroundColor: '#fffadf ',
textColor: '#444444',
};
this.disableCopyDayButton = false;
this.lastNewDate = new Date(payload.startStr);
this.updateEventsSource();
this.$store.dispatch('createRange', payload);
},
onEventChange(payload) {
},
onEventDropOrResize(payload) {
payload.event.setProp('borderColor', '#3788d8');
payload.event.setProp('backgroundColor', '#fffadf');
payload.event.setProp('textColor', '#444444');
this.$store.dispatch('updateRange', payload);
},
onEventClick(payload) {
if (payload.event.extendedProps.myCalendar) {
this.myCalendarClickedEvent = {
id: payload.event.extendedProps.calendarId,
start: payload.event.start,
end: payload.event.end,
user: payload.event.extendedProps.user,
mainUser: payload.event.extendedProps.mainUser,
persons: payload.event.extendedProps.persons,
professionals: payload.event.extendedProps.professionals,
comment: payload.event.extendedProps.comment
};
console.log(this.myCalendarClickedEvent)
this.openModal();
}
},
onClickSave(payload) {
this.flag.loading = true;
if (this.$store.state.newCalendarRanges.length > 0){
Promise.all(this.$store.state.newCalendarRanges.map(cr => {
postCalendarRange({
user: {
type: 'user',
id: window.userId,
},
startDate: {
datetime: `${cr.start.toISOString().split('.')[0]}+0000`, //should be like "2021-08-20T15:00:00+0200",
},
endDate: {
datetime: `${cr.end.toISOString().split('.')[0]}+0000`, // TODO check if OK with time zone
},
})
})
).then((_r) => this.resetCalendar());
this.$store.dispatch('clearNewCalendarRanges', payload);
}
if (this.$store.state.updateCalendarRanges.length > 0){
Promise.all(this.$store.state.updateCalendarRanges.map(cr => {
patchCalendarRange(cr.id,
{
startDate: {
datetime: `${cr.start.toISOString().split('.')[0]}+0000`, //should be like "2021-08-20T15:00:00+0200",
},
endDate: {
datetime: `${cr.end.toISOString().split('.')[0]}+0000`, // TODO check if OK with time zone
},
})
})
).then((_r) => this.resetCalendar());
this.$store.dispatch('clearUpdateCalendarRanges', payload);
}
if (this.$store.state.deleteCalendarRanges.length > 0){
Promise.all(this.$store.state.deleteCalendarRanges.map(cr => {
deleteCalendarRange(cr.id)
})
).then((_r) => this.resetCalendar());
this.$store.dispatch('clearDeleteCalendarRanges', payload);
}
},
onClickDelete(payload) {
if (payload.extendedProps.hasOwnProperty("calendarRangeId")) {
if (payload.extendedProps.toDelete) {
payload.setExtendedProp('toDelete', false)
payload.setProp('borderColor', '#79bafc');
this.$store.dispatch('removeFromDeleteRange', payload);
} else {
payload.setExtendedProp('toDelete', true)
payload.setProp('borderColor', '#dddddd');
this.$store.dispatch('deleteRange', payload);
}
} else {
let newEvents = this.calendarEvents.new.events;
let filterEvents = newEvents.filter((e) =>
e.start !== payload.startStr && e.end !== payload.endStr
);
this.calendarEvents.new = {
events: filterEvents,
color: "#3788d8"
};
this.$store.dispatch('removeNewCalendarRanges', payload);
this.updateEventsSource();
}
},
isSameDay(date1, date2) {
return date1.getFullYear() === date2.getFullYear() &&
date1.getMonth() === date2.getMonth() &&
date1.getDate() === date2.getDate();
},
isFriday(date) {
return date.getDay() === 5
},
copyDay(_payload) {
console.log(this.calendarEvents.new);
if (this.calendarEvents.new.events.length > 0) {
// Create the copied events
let increment = !this.calendarOptions.weekends && this.isFriday(this.lastNewDate) ? 24*60*60*1000*3 : 24*60*60*1000;
let events = this.calendarEvents.new.events.filter(
i => this.isSameDay(new Date(i.start), this.lastNewDate)).map(
i => {
let startDate = new Date(new Date(i.start).getTime() + increment);
let endDate = new Date(new Date(i.end).getTime() + increment);
return ({
start: startDate.toISOString(),
end: endDate.toISOString()
})
}
);
let copiedEvents = {
events: events,
color: "#3788d8"
};
console.log(copiedEvents);
// Add to the calendar
let newEvents = this.calendarEvents.new.events;
newEvents.push(...copiedEvents.events);
this.calendarEvents.new = {
events: newEvents,
color: "#3788d8"
};
this.updateEventsSource();
// Set the last new date
this.lastNewDate = new Date(copiedEvents.events[copiedEvents.events.length - 1].start);
// Dispatch in store for saving
for (let i = 0; i < copiedEvents.events.length; i++) {
let eventObj = {
start: new Date(copiedEvents.events[i].start),
end: new Date(copiedEvents.events[i].end)
}
this.$store.dispatch('createRange', eventObj);
}
} else {
console.log('no new events to copy-paste!')
}
},
renderEventDate() {
let start = this.myCalendarClickedEvent.start;
let end = this.myCalendarClickedEvent.end;
return start.getDate() === end.getDate() ?
`${start.toLocaleDateString()}, ${start.toLocaleTimeString()} - ${end.toLocaleTimeString()}` :
`${start.toLocaleString()} - ${end.toLocaleString()}`;
}
},
mounted() {
this.init();
}
}
</script>

View File

@ -0,0 +1,304 @@
<template>
<div class="row">
<div class="col-sm">
<label class="form-label">Lieu des plages de disponibilités créées</label>
<vue-multiselect
v-model="pickedLocation"
:options="locations"
:label="'name'"
:track-by="'id'"
:selectLabel="'Presser \'Entrée\' pour choisir'"
:selectedLabel="'Choisir'"
:deselectLabel="'Presser \'Entrée\' pour enlever'"
:placeholder="'Choisir'"
></vue-multiselect>
</div>
</div>
<div class="display-options row justify-content-between" style="margin-top: 1rem;">
<div class="col-sm-9 col-xs-12">
<div class="input-group mb-3">
<label class="input-group-text" for="slotDuration">Durée des créneaux</label>
<select v-model="slotDuration" id="slotDuration" class="form-select">
<option value="00:05:00">5 minutes</option>
<option value="00:10:00">10 minutes</option>
<option value="00:15:00">15 minutes</option>
<option value="00:30:00">30 minutes</option>
</select>
<label class="input-group-text" for="slotMinTime">De</label>
<select v-model="slotMinTime" id="slotMinTime" class="form-select">
<option value="00:00:00">0h</option>
<option value="01:00:00">1h</option>
<option value="02:00:00">2h</option>
<option value="03:00:00">3h</option>
<option value="04:00:00">4h</option>
<option value="05:00:00">5h</option>
<option value="06:00:00">6h</option>
<option value="07:00:00">7h</option>
<option value="08:00:00">8h</option>
<option value="09:00:00">9h</option>
<option value="10:00:00">10h</option>
<option value="11:00:00">11h</option>
<option value="12:00:00">12h</option>
</select>
<label class="input-group-text" for="slotMaxTime">À</label>
<select v-model="slotMaxTime" id="slotMaxTime" class="form-select">
<option value="12:00:00">12h</option>
<option value="13:00:00">13h</option>
<option value="14:00:00">14h</option>
<option value="15:00:00">15h</option>
<option value="16:00:00">16h</option>
<option value="17:00:00">17h</option>
<option value="18:00:00">18h</option>
<option value="19:00:00">19h</option>
<option value="20:00:00">20h</option>
<option value="21:00:00">21h</option>
<option value="22:00:00">22h</option>
<option value="23:00:00">23h</option>
<option value="23:59:59">24h</option>
</select>
</div>
</div>
<div class="col-sm-3 col-xs-12">
<div class="float-end">
<div class="form-check input-group">
<span class="input-group-text">
<input id="showHideWE" class="mt-0" type="checkbox" v-model="showWeekends">
</span>
<label for="showHideWE" class="form-check-label input-group-text">Week-ends</label>
</div>
</div>
</div>
</div>
<FullCalendar :options="calendarOptions" ref="calendarRef">
<template v-slot:eventContent="arg: EventApi">
<span :class="eventClasses(arg.event)">
<b v-if="arg.event.extendedProps.is === 'remote'">{{ arg.event.title}}</b>
<b v-else-if="arg.event.extendedProps.is === 'range'">{{ arg.timeText }} - {{ arg.event.extendedProps.locationName }}</b>
<b v-else-if="arg.event.extendedProps.is === 'local'">{{ arg.event.title}}</b>
<b v-else >no 'is'</b>
<a v-if="arg.event.extendedProps.is === 'range'" class="fa fa-fw fa-times delete"
@click.prevent="onClickDelete(arg.event)">
</a>
</span>
</template>
</FullCalendar>
<div id="copy-widget">
<div class="container">
<div class="row align-items-center">
<div class="col-sm-4 col-xs-12">
<h6 class="chill-red">{{ $t('copy_range_from_to') }}</h6>
</div>
<div class="col-sm-3 col-xs-12">
<input class="form-control" type="date" v-model="copyFrom" />
</div>
<div class="col-sm-1 col-xs-12" style="text-align: center; font-size: x-large;">
<i class="fa fa-angle-double-right"></i>
</div>
<div class="col-sm-3 col-xs-12" >
<input class="form-control" type="date" v-model="copyTo" />
</div>
<div class="col-sm-1">
<button class="btn btn-action" @click="copyDay">
{{ $t('copy_range') }}
</button>
</div>
</div>
</div>
</div>
<!-- not directly seen, but include in a modal -->
<edit-location ref="editLocation"></edit-location>
</template>
<script setup lang="ts">
import type {
CalendarOptions,
DatesSetArg,
EventInput,
EventInstance
} from '@fullcalendar/vue3';
import {reactive, computed, ref} from "vue";
import {useStore} from "vuex";
import {key} from './store';
import '@fullcalendar/core/vdom'; // solves problem with Vite
import FullCalendar from '@fullcalendar/vue3';
import frLocale from '@fullcalendar/core/locales/fr';
import interactionPlugin, {DropArg, EventResizeDoneArg} from "@fullcalendar/interaction";
import timeGridPlugin from "@fullcalendar/timegrid";
import {EventApi, DateSelectArg, EventDropArg, EventClickArg} from "@fullcalendar/core";
import {ISOToDate} from "../../../../../ChillMainBundle/Resources/public/chill/js/date";
import VueMultiselect from "vue-multiselect";
import {Location} from "../../../../../ChillMainBundle/Resources/public/types";
import EditLocation from "./Components/EditLocation.vue";
import {useI18n} from "vue-i18n";
const store = useStore(key);
const {t} = useI18n();
const showWeekends = ref(false);
const slotDuration = ref('00:05:00');
const slotMinTime = ref('09:00:00');
const slotMaxTime = ref('18:00:00');
const copyFrom = ref<string | null>(null);
const copyTo = ref<string | null>(null);
const editLocation = ref<InstanceType<typeof EditLocation> | null>(null)
const baseOptions = ref<CalendarOptions>({
locale: frLocale,
plugins: [interactionPlugin, timeGridPlugin],
initialView: 'timeGridWeek',
initialDate: new Date(),
scrollTimeReset: false,
selectable: true,
// when the dates are changes in the fullcalendar view OR when new events are added
datesSet: onDatesSet,
// when a date is selected
select: onDateSelect,
// when a event is resized
eventResize: onEventDropOrResize,
// when an event is moved
eventDrop: onEventDropOrResize,
// when an event si clicked
eventClick: onEventClick,
selectMirror: false,
editable: true,
headerToolbar: {
left: 'prev,next today',
center: 'title',
right: 'timeGridWeek,timeGridDay'
},
});
const ranges = computed<EventInput[]>(() => {
return store.state.calendarRanges.ranges;
});
const locations = computed<Location[]>(() => {
return store.state.locations.locations;
});
const pickedLocation = computed<Location | null>({
get(): Location | null {
return store.state.locations.locationPicked || store.state.locations.currentLocation;
},
set(newLocation: Location | null): void {
store.commit('locations/setLocationPicked', newLocation, {root: true});
}
})
/**
* return the show classes for the event
* @param arg
*/
const eventClasses = function(arg: EventApi): object {
return {'calendarRangeItems': true};
}
/*
// currently, all events are stored into calendarRanges, due to reactivity bug
const remotes = computed<EventInput[]>(() => {
return store.state.calendarRemotes.remotes;
});
const sources = computed<EventSourceInput[]>(() => {
const sources = [];
const rangeSource: EventSourceInput = {
id: 'ranges',
events: ranges.value,
};
sources.push(rangeSource);
return sources;
});
*/
const calendarOptions = computed((): CalendarOptions => {
return {
...baseOptions.value,
weekends: showWeekends.value,
slotDuration: slotDuration.value,
events: ranges.value,
slotMinTime: slotMinTime.value,
slotMaxTime: slotMaxTime.value,
};
});
/**
* launched when the calendar range date change
*/
function onDatesSet(event: DatesSetArg): void {
store.dispatch('fullCalendar/setCurrentDatesView', {start: event.start, end: event.end});
}
function onDateSelect(event: DateSelectArg): void {
if (null === pickedLocation.value) {
window.alert("Indiquez une localisation avant de créer une période de disponibilité.");
return;
}
store.dispatch('calendarRanges/createRange', {start: event.start, end: event.end, location: pickedLocation.value});
}
/**
* When a calendar range is deleted
*/
function onClickDelete(event: EventApi): void {
console.log('onClickDelete', event);
if (event.extendedProps.is !== 'range') {
return;
}
store.dispatch('calendarRanges/deleteRange', event.extendedProps.calendarRangeId);
}
function onEventDropOrResize(payload: EventDropArg | EventResizeDoneArg) {
if (payload.event.extendedProps.is !== 'range') {
return;
}
const changedEvent = payload.event;
store.dispatch('calendarRanges/patchRangeTime', {
calendarRangeId: payload.event.extendedProps.calendarRangeId,
start: payload.event.start,
end: payload.event.end,
});
};
function onEventClick(payload: EventClickArg): void {
// @ts-ignore TS does not recognize the target. But it does exists.
if (payload.jsEvent.target.classList.contains('delete')) {
return;
}
if (payload.event.extendedProps.is !== 'range') {
return;
}
editLocation.value?.startEdit(payload.event);
}
function copyDay() {
if (null === copyFrom.value || null === copyTo.value) {
return;
}
store.dispatch('calendarRanges/copyFromDayToAnotherDay', {from: ISOToDate(copyFrom.value), to: ISOToDate(copyTo.value)})
}
</script>
<style scoped>
#copy-widget {
position: sticky;
bottom: 0px;
background-color: white;
z-index: 9999999999;
padding: 0.25rem 0 0.25rem;
}
</style>

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