Merge branch 'calendar/finalization' into calendar_changes

This commit is contained in:
Julien Fastré 2022-06-17 17:29:05 +02:00
commit 5c08abc2f6
465 changed files with 15427 additions and 3084 deletions

View File

@ -18,3 +18,8 @@ max_line_length = 80
[COMMIT_EDITMSG]
max_line_length = 0
[*.{js, vue, ts}]
indent_size = 2
indent_style = space

View File

@ -11,19 +11,51 @@ and this project adheres to
## Unreleased
<!-- write down unreleased development here -->
* [workflow]: added pagination to workflow list page
* [homepage_widget]: null error on tasks widget fixed
* [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
### 2022-05-30
* fix creating a new AccompanyingPeriodWorkEvaluationDocument when replacing the document (the workflow was lost)
### 2022-05-27
* [storedobject] add title field on StoredObject entity + use it in activity documents (https://gitlab.com/champs-libres/departement-de-la-vendee/chill/-/issues/604)
* [main] add a "read more..." on comment embeddable when overflown (https://gitlab.com/champs-libres/departement-de-la-vendee/chill/-/issues/604)
* [person] add closing motive to closed acc course (https://gitlab.com/champs-libres/departement-de-la-vendee/chill/-/issues/603)
* [person] household filiation: fetch person info when unfolding person (https://gitlab.com/champs-libres/departement-de-la-vendee/chill/-/issues/586)
* [admin] repair edit of social action in the admin (https://gitlab.com/champs-libres/departement-de-la-vendee/chill/-/issues/601)
* [admin]: add select2 to Goal form type entity fields (https://gitlab.com/champs-libres/departement-de-la-vendee/accent-suivi-developpement/-/issues/702)
* [main] allow hide permissions group list menu (https://gitlab.com/champs-libres/departement-de-la-vendee/chill/-/issues/577)
* [main] allow hide change user password menu (https://gitlab.com/champs-libres/departement-de-la-vendee/chill/-/issues/577)
* [main] filter user jobs by active jobs (https://gitlab.com/champs-libres/departement-de-la-vendee/chill/-/issues/577)
* [main] add civility to User (entity, migration and form type) (https://gitlab.com/champs-libres/departement-de-la-vendee/chill/-/issues/577)
* [admin] refactorisation of the admin section: reorganisation of the menu, translations, form types, new entities (https://gitlab.com/champs-libres/departement-de-la-vendee/chill/-/issues/592)
* [admin] add admin section for languages and countries (https://gitlab.com/champs-libres/departement-de-la-vendee/chill/-/issues/596)
* [activity] activity admin: translations + remove label field for comment on admin activity type (https://gitlab.com/champs-libres/departement-de-la-vendee/chill/-/issues/587)
* [main] admin user_job: improvements (https://gitlab.com/champs-libres/departement-de-la-vendee/chill/-/issues/588)
* [address] can add extra address info even if noAddress (https://gitlab.com/champs-libres/departement-de-la-vendee/chill/-/issues/576)
### 2022-05-06
* [person] add civility when creating a person (with the on-the-fly component or in the php form) (https://gitlab.com/champs-libres/departement-de-la-vendee/chill/-/issues/557)
* [person] add address when creating a person (with the on-the-fly component or in the php form) (https://gitlab.com/champs-libres/departement-de-la-vendee/chill/-/issues/557)
* [person] add household creation API point (https://gitlab.com/champs-libres/departement-de-la-vendee/chill/-/issues/557)
## Test releases
### 2021-04-29
* [person] prevent circular references in PersonDocGenNormalizer (https://gitlab.com/champs-libres/departement-de-la-vendee/chill/-/issues/527)
* [person] add maritalStatusComment to PersonDocGenNormalizer (https://gitlab.com/champs-libres/departement-de-la-vendee/chill/-/issues/582)
* Load relationships without gender in french fixtures
* Add command to remove old draft accompanying periods
* [parcours]: If users assings him/herself as referrer and job is not null. Update parcours job (https://gitlab.com/champs-libres/departement-de-la-vendee/chill/-/issues/578)
### 2021-04-28

View File

@ -2,7 +2,14 @@
en cours de rédaction
## Translations
Par bundle, toutes les traductions des pages twig se trouvent dans un seul fichier `translations/messages.fr.yaml`.
## Emplacement des fichiers
Les controllers, form type & templates twig sont placés à la racine des dossiers `Controller`, `Form` & `Ressources/views`, respectivement. Pour les pages Admin, on ne les mets plus dans des sous-dossiers Admin.
## Assets: nommage des entrypoints
Trois types d'entrypoint:
@ -146,19 +153,20 @@ Ces règles n'ont pas toujours été utilisées par le passé. Elles sont souhai
Les routes sont nommées de cette manière:
`chill_bundle_entite_action`
`chill_(api|crud)_bundle_(api)_entite_action`
1. d'abord chill_ (pour tous les modules chill)
2. ensuite une string qui est identique, par bundle
3. si le point est un point d'api (json), alors ajouter la string `api`
4. ensuite une string qui indique sur quelle entité porte la route, voire également les sous-entités
5. ensuite une action (`list`, `view`, `edit`, `new`, ...)
2. ensuite `crud` ou `api`, optionnel, automatiquement ajouté si la route est générée par la configuration
3. ensuite une string qui indique le bundle (`main`, `person`, `activity`, ...)
4. ensuite, `api`, si la route est une route d'api.
5. ensuite une string qui indique sur quelle entité porte la route, voire également les sous-entités
6. ensuite une action (`list`, `view`, `edit`, `new`, ...)
Le fait d'indiquer `api` en 3 permet de distinguer les routes d'api qui sont générées par la configuration (qui sont toutes préfixées par `chill_api`, de celles générées manuellement. (Exemple: `chill_api_household__index`, et `chill_person_api_household_members_move`)
Le fait d'indiquer `api` en quatrième position permet de distinguer les routes d'api qui sont générées par la configuration (qui sont toutes préfixées par `chill_api`, de celles générées manuellement. (Exemple: `chill_api_household__index`, et `chill_person_api_household_members_move`)
Si les points 4 et 5 sont inexistants, alors ils sont remplacés par d'autres éléments de manière à garantir l'unicité de la route, et sa bonne compréhension.
### URL
### Nommage des URL
Les URL respectent également une convention:
@ -219,6 +227,14 @@ Les éléments suivants devraient se trouver dans la liste:
Ces éléments peuvent être entrecoupés de l'identifiant d'une entité. Dans ce cas, cet identifiant se place juste après l'entité auquel il se rapporte.
#### Pour les URL de l'espace Admin
Même conventions que dans les autres pages html de l'application, **mais `admin` est ajouté en deuxième position**. Soit:
`/{_locale}/admin/bundle/entity/{id}/action`
## Règles UI chill
### Titre des pages

View File

@ -19,6 +19,7 @@
"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",
@ -36,6 +37,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",
@ -45,9 +47,11 @@
"symfony/translation": "^4.4",
"symfony/twig-bundle": "^4.4",
"symfony/validator": "^4.4",
"symfony/web-link": "*",
"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",

View File

@ -471,11 +471,6 @@ parameters:
count: 1
path: src/Bundle/ChillMainBundle/Form/Type/UserPickerType.php
-
message: "#^Only booleans are allowed in an if condition, mixed given\\.$#"
count: 1
path: src/Bundle/ChillMainBundle/Form/UserType.php
-
message: "#^Only booleans are allowed in an if condition, mixed given\\.$#"
count: 2

View File

@ -15,6 +15,7 @@ use Chill\ActivityBundle\Validator\Constraints as ActivityValidator;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\MainBundle\Entity\Center;
use Chill\MainBundle\Entity\Embeddable\CommentEmbeddable;
use Chill\MainBundle\Entity\Embeddable\PrivateCommentEmbeddable;
use Chill\MainBundle\Entity\HasCenterInterface;
use Chill\MainBundle\Entity\HasScopeInterface;
use Chill\MainBundle\Entity\Location;
@ -134,6 +135,11 @@ class Activity implements AccompanyingPeriodLinkedWithSocialIssuesEntityInterfac
*/
private ?Collection $persons = null;
/**
* @ORM\Embedded(class="Chill\MainBundle\Entity\Embeddable\PrivateCommentEmbeddable", columnPrefix="privateComment_")
*/
private PrivateCommentEmbeddable $privateComment;
/**
* @ORM\ManyToMany(targetEntity="Chill\ActivityBundle\Entity\ActivityReason")
* @Groups({"docgen:read"})
@ -193,6 +199,7 @@ class Activity implements AccompanyingPeriodLinkedWithSocialIssuesEntityInterfac
{
$this->reasons = new ArrayCollection();
$this->comment = new CommentEmbeddable();
$this->privateComment = new PrivateCommentEmbeddable();
$this->persons = new ArrayCollection();
$this->thirdParties = new ArrayCollection();
$this->documents = new ArrayCollection();
@ -400,6 +407,11 @@ class Activity implements AccompanyingPeriodLinkedWithSocialIssuesEntityInterfac
return [];
}
public function getPrivateComment(): PrivateCommentEmbeddable
{
return $this->privateComment;
}
public function getReasons(): Collection
{
return $this->reasons;
@ -586,6 +598,13 @@ class Activity implements AccompanyingPeriodLinkedWithSocialIssuesEntityInterfac
return $this;
}
public function setPrivateComment(PrivateCommentEmbeddable $privateComment): self
{
$this->privateComment = $privateComment;
return $this;
}
public function setReasons(?ArrayCollection $reasons): self
{
$this->reasons = $reasons;

View File

@ -167,6 +167,16 @@ class ActivityType
*/
private int $personVisible = self::FIELD_REQUIRED;
/**
* @ORM\Column(type="string", nullable=false, options={"default": ""})
*/
private string $privateCommentLabel = '';
/**
* @ORM\Column(type="smallint", nullable=false, options={"default": 1})
*/
private int $privateCommentVisible = self::FIELD_OPTIONAL;
/**
* @ORM\Column(type="string", nullable=false, options={"default": ""})
*/
@ -416,6 +426,16 @@ class ActivityType
return $this->personVisible;
}
public function getPrivateCommentLabel(): string
{
return $this->privateCommentLabel;
}
public function getPrivateCommentVisible(): int
{
return $this->privateCommentVisible;
}
public function getReasonsLabel(): string
{
return $this->reasonsLabel;
@ -688,6 +708,20 @@ class ActivityType
return $this;
}
public function setPrivateCommentLabel(string $privateCommentLabel): self
{
$this->privateCommentLabel = $privateCommentLabel;
return $this;
}
public function setPrivateCommentVisible(int $privateCommentVisible): self
{
$this->privateCommentVisible = $privateCommentVisible;
return $this;
}
public function setReasonsLabel(string $reasonsLabel): self
{
$this->reasonsLabel = $reasonsLabel;

View File

@ -20,6 +20,7 @@ use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Form\Type\ChillCollectionType;
use Chill\MainBundle\Form\Type\ChillDateType;
use Chill\MainBundle\Form\Type\CommentType;
use Chill\MainBundle\Form\Type\PrivateCommentType;
use Chill\MainBundle\Form\Type\ScopePickerType;
use Chill\MainBundle\Form\Type\UserPickerType;
use Chill\MainBundle\Security\Authorization\AuthorizationHelper;
@ -251,6 +252,13 @@ class ActivityType extends AbstractType
]);
}
if ($activityType->isVisible('privateComment')) {
$builder->add('privateComment', PrivateCommentType::class, [
'label' => '' === $activityType->getLabel('privateComment') ? 'private comment' : $activityType->getPrivateCommentLabel(),
'required' => false,
]);
}
if ($activityType->isVisible('persons')) {
$builder->add('persons', HiddenType::class);
$builder->get('persons')
@ -313,6 +321,7 @@ class ActivityType extends AbstractType
'button_add_label' => 'activity.Insert a document',
'button_remove_label' => 'activity.Remove a document',
'empty_collection_explain' => 'No documents',
'entry_options' => ['has_title' => true],
]);
}

View File

@ -57,7 +57,7 @@ class ActivityTypeType extends AbstractType
$fields = [
'persons', 'user', 'date', 'location', 'persons',
'thirdParties', 'durationTime', 'travelTime', 'attendee',
'reasons', 'comment', 'sentReceived', 'documents',
'reasons', 'comment', 'privateComment', 'sentReceived', 'documents',
'emergency', 'socialIssues', 'socialActions', 'users',
];
@ -69,6 +69,9 @@ class ActivityTypeType extends AbstractType
'empty_data' => '',
]);
}
$builder
->add('commentVisible', ActivityFieldPresence::class);
}
public function configureOptions(OptionsResolver $resolver)

View File

@ -15,8 +15,6 @@ use Chill\MainBundle\Routing\LocalMenuBuilderInterface;
use Knp\Menu\MenuItem;
use Symfony\Component\Security\Core\Security;
use function in_array;
final class AdminMenuBuilder implements LocalMenuBuilderInterface
{
private Security $security;
@ -32,27 +30,38 @@ final class AdminMenuBuilder implements LocalMenuBuilderInterface
return;
}
if (in_array($menuId, ['admin_index', 'admin_section'], true)) {
$menu->addChild('Activities', [
'route' => 'chill_admin_activity_index',
])
->setExtras([
'order' => 2000,
'explain' => 'Activity configuration',
]);
} else {
$menu
->addChild('Activities', [
'route' => 'chill_admin_activity_index',
])
->setExtras([
'order' => '60',
]);
}
$menu->addChild('Activities', [
'route' => 'chill_activity_admin_index',
])
->setAttribute('class', 'list-group-item-header')
->setExtras([
'order' => 5000,
'icons' => ['exchange'],
]);
$menu->addChild('Activity Reasons', [
'route' => 'chill_activity_activityreason',
])->setExtras(['order' => 5010]);
$menu->addChild('Activity Reasons Category', [
'route' => 'chill_activity_activityreasoncategory',
])->setExtras(['order' => 5020]);
$menu->addChild('Activity type', [
'route' => 'chill_activity_type_admin',
])->setExtras(['order' => 5030]);
$menu->addChild('Activity Presences', [
'route' => 'chill_crud_activity_presence_index',
])->setExtras(['order' => 5040]);
$menu->addChild('Activity Types Categories', [
'route' => 'chill_activity_type_category_admin',
])->setExtras(['order' => 5050]);
}
public static function getMenuIds(): array
{
return ['admin_index', 'admin_section', 'admin_activity'];
return ['admin_section', 'admin_activity'];
}
}

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

@ -83,6 +83,10 @@
{{ form_row(edit_form.comment) }}
{% endif %}
{%- if edit_form.privateComment is defined -%}
{{ form_row(edit_form.privateComment) }}
{% endif %}
{%- if edit_form.attendee is defined -%}
{{ form_row(edit_form.attendee) }}
{% endif %}

View File

@ -2,12 +2,30 @@
{% if is_granted('CHILL_ACTIVITY_SEE_DETAILS', activity) %}
{% if no_action is not defined or no_action == false %}
<li>
<a class="btn btn-misc" href="{{ chill_path_add_return_path('chill_main_notification_create', {
'entityClass': 'Chill\\ActivityBundle\\Entity\\Activity',
'entityId': activity.id
}) }}">
<i class="fa fa-paper-plane fa-fw"></i>
{{ 'notification.Notify'|trans }}</a>
{% set showGroup = activity.accompanyingPeriod is not null and activity.accompanyingPeriod.hasUser and activity.accompanyingPeriod.user is not same as(app.user) %}
<div class="{% if showGroup %}btn-group{% endif %}" {% if showGroup %}role="group"{% endif %}>
{% if showGroup %}
<button id="btnGroupNotifyButtons" type="button" class="btn btn-notify dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
{{ 'notification.Notify'|trans }}
</button>
<ul class="dropdown-menu" aria-labelledby="btnGroupNotifyButtons">
<li>
<a class="dropdown-item" href="{{ chill_path_add_return_path('chill_main_notification_create', {'entityClass': 'Chill\\ActivityBundle\\Entity\\Activity', 'entityId': activity.id, 'tos': [activity.accompanyingPeriod.user.id]}) }}">
{{ 'notification.Notify referrer'|trans }}
</a>
</li>
<li>
<a class="dropdown-item" href="{{ chill_path_add_return_path('chill_main_notification_create', {'entityClass': 'Chill\\ActivityBundle\\Entity\\Activity', 'entityId': activity.id}) }}">
{{ 'notification.Notify any'|trans }}
</a>
</li>
</ul>
{% else %}
<a class="btn btn-notify" href="{{ chill_path_add_return_path('chill_main_notification_create', {'entityClass': 'Chill\\ActivityBundle\\Entity\\Activity', 'entityId': activity.id}) }}">
{{ 'notification.Notify'|trans }}
</a>
{% endif %}
</div>
</li>
{% endif %}
{% if context == 'person' and activity.accompanyingPeriod is not empty %}

View File

@ -81,10 +81,13 @@
{% endif %}
{%- if form.comment is defined -%}
{# TODO .. public and private #}
{{ form_row(form.comment) }}
{% endif %}
{%- if form.privateComment is defined -%}
{{ form_row(form.privateComment) }}
{% endif %}
{%- if form.attendee is defined -%}
{{ form_row(form.attendee) }}
{% endif %}

View File

@ -1,4 +1,5 @@
{%- set t = entity.type -%}
{% set userId = app.user.id %}
{%- import "@ChillDocStore/Macro/macro.html.twig" as m -%}
<h1>{{ "Activity"|trans }}</h1>
@ -146,13 +147,28 @@
</dd>
{% endif %}
{% if t.privateCommentVisible and is_granted('CHILL_ACTIVITY_SEE_DETAILS', entity) and entity.privateComment.hasCommentForUser(app.user) %}
{% if t.privateCommentLabel is not empty %}
<dt class="inline">{{ t.privateCommentLabel }}</dt>
{% else %}
<dt class="inline">{{ 'Private comment'|trans }}</dt>
{% endif %}
<dd>
<section class="chill-entity entity-comment-embeddable">
<blockquote class="chill-user-quote private-quote">
{{ entity.privateComment.comments[userId] }}
</blockquote>
</section>
</dd>
{% endif %}
{% if t.documentsVisible and is_granted('CHILL_ACTIVITY_SEE_DETAILS', entity) %}
<dt class="inline">{{ 'Documents'|trans }}</dt>
<dd>
{% if entity.documents|length > 0 %}
<ul>
{% for d in entity.documents %}
<li>{{ m.download_button(d) }}</li>
<li>{{ d.title }}{{ m.download_button(d) }}</li>
{% endfor %}
</ul>
{% else %}
@ -201,9 +217,30 @@
</a>
</li>
<li>
<a class="btn btn-notify" href="{{ chill_path_add_return_path('chill_main_notification_create', {'entityClass': 'Chill\\ActivityBundle\\Entity\\Activity', 'entityId': entity.id}) }}">
{{ 'notification.Notify'|trans }}
</a>
{% set showGroup = entity.accompanyingPeriod is not null and entity.accompanyingPeriod.hasUser and entity.accompanyingPeriod.user is not same as(app.user) %}
<div class="{% if showGroup %}btn-group{% endif %}" {% if showGroup %}role="group"{% endif %}>
{% if showGroup %}
<button id="btnGroupNotifyButtons" type="button" class="btn btn-notify dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
{{ 'notification.Notify'|trans }}
</button>
<ul class="dropdown-menu" aria-labelledby="btnGroupNotifyButtons">
<li>
<a class="dropdown-item" href="{{ chill_path_add_return_path('chill_main_notification_create', {'entityClass': 'Chill\\ActivityBundle\\Entity\\Activity', 'entityId': entity.id, 'tos': [entity.accompanyingPeriod.user.id]}) }}">
{{ 'notification.Notify referrer'|trans }}
</a>
</li>
<li>
<a class="dropdown-item" href="{{ chill_path_add_return_path('chill_main_notification_create', {'entityClass': 'Chill\\ActivityBundle\\Entity\\Activity', 'entityId': entity.id}) }}">
{{ 'notification.Notify any'|trans }}
</a>
</li>
</ul>
{% else %}
<a class="btn btn-notify" href="{{ chill_path_add_return_path('chill_main_notification_create', {'entityClass': 'Chill\\ActivityBundle\\Entity\\Activity', 'entityId': entity.id}) }}">
{{ 'notification.Notify'|trans }}
</a>
{% endif %}
</div>
</li>
{% if is_granted('CHILL_ACTIVITY_UPDATE', entity) %}
<li>

View File

@ -1,10 +1,10 @@
{% extends "@ChillActivity/Admin/layout_activity.html.twig" %}
{% extends '@ChillMain/CRUD/Admin/index.html.twig' %}
{% block title %}
{% include('@ChillMain/CRUD/_edit_title.html.twig') %}
{% include('@ChillMain/CRUD/_edit_title.html.twig') %}
{% endblock %}
{% block layout_wvm_content %}
{% block admin_content %}
{% embed '@ChillMain/CRUD/_edit_content.html.twig' %}
{% block content_form_actions_view %}{% endblock %}
{% block content_form_actions_save_and_show %}{% endblock %}

View File

@ -1,4 +1,4 @@
{% extends "@ChillActivity/Admin/layout_activity.html.twig" %}
{% extends '@ChillMain/CRUD/Admin/index.html.twig' %}
{% block admin_content %}
<h1>{{ 'ActivityPresence list'|trans }}</h1>
@ -34,7 +34,7 @@
</tbody>
</table>
<ul class="record_actions">
<ul class="record_actions sticky-form-buttons">
<li>
<a href="{{ path('chill_crud_activity_presence_new') }}" class="btn btn-create">
{{ 'Create a new activity presence'|trans }}

View File

@ -1,11 +1,11 @@
{% extends "@ChillActivity/Admin/layout_activity.html.twig" %}
{% extends '@ChillMain/CRUD/Admin/index.html.twig' %}
{% block title %}
{% include('@ChillMain/CRUD/_new_title.html.twig') %}
{% include('@ChillMain/CRUD/_new_title.html.twig') %}
{% endblock %}
{% block layout_wvm_content %}
{% embed '@ChillMain/CRUD/_new_content.html.twig' %}
{% block content_form_actions_save_and_show %}{% endblock %}
{% endembed %}
{% block admin_content %}
{% embed '@ChillMain/CRUD/_new_content.html.twig' %}
{% block content_form_actions_save_and_show %}{% endblock %}
{% endembed %}
{% endblock %}

View File

@ -1,20 +1,4 @@
{#
* Copyright (C) 2014, Champs Libres Cooperative SCRLFS, <http://www.champs-libres.coop>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
#}
{% extends "@ChillActivity/Admin/layout_activity.html.twig" %}
{% extends '@ChillMain/Admin/layoutWithVerticalMenu.html.twig' %}
{% block admin_content %}
<h1>{{ 'ActivityReason edit'|trans }}</h1>
@ -24,7 +8,7 @@
{{ form_row(edit_form.active) }}
{{ form_row(edit_form.category) }}
<ul class="record_actions">
<ul class="record_actions sticky-form-buttons">
<li class="cancel">
<a href="{{ path('chill_activity_activityreason') }}" class="btn btn-cancel">{{ 'Back to the list'|trans }}</a>
</li>

View File

@ -1,20 +1,4 @@
{#
* Copyright (C) 2014, Champs Libres Cooperative SCRLFS, <http://www.champs-libres.coop>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
#}
{% extends "@ChillActivity/Admin/layout_activity.html.twig" %}
{% extends '@ChillMain/Admin/layoutWithVerticalMenu.html.twig' %}
{% block admin_content %}
<h1>{{ 'ActivityReason list'|trans }}</h1>
@ -45,7 +29,11 @@
</tbody>
</table>
<ul class="record_actions">
<ul class="record_actions sticky-form-buttons">
<li class='cancel'>
<a href="{{ path('chill_main_admin_central') }}" class="btn btn-cancel">{{'Back to the admin'|trans}}</a>
</li>
<li>
<a href="{{ path('chill_activity_activityreason_new') }}" class="btn btn-new">
{{ 'Create a new activity reason'|trans }}

View File

@ -1,20 +1,4 @@
{#
* Copyright (C) 2014, Champs Libres Cooperative SCRLFS, <http://www.champs-libres.coop>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
#}
{% extends "@ChillActivity/Admin/layout_activity.html.twig" %}
{% extends '@ChillMain/Admin/layoutWithVerticalMenu.html.twig' %}
{% block admin_content %}
<h1>{{ 'ActivityReason creation'|trans }}</h1>
@ -24,7 +8,7 @@
{{ form_row(form.active) }}
{{ form_row(form.category) }}
<ul class="record_actions">
<ul class="record_actions sticky-form-buttons">
<li class="cancel">
<a href="{{ path('chill_activity_activityreason') }}" class="btn btn-cancel">{{ 'Back to the list'|trans }}</a>
</li>

View File

@ -1,20 +1,4 @@
{#
* Copyright (C) 2014, Champs Libres Cooperative SCRLFS, <http://www.champs-libres.coop>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
#}
{% extends "@ChillActivity/Admin/layout_activity.html.twig" %}
{% extends '@ChillMain/Admin/layoutWithVerticalMenu.html.twig' %}
{% block admin_content %}
<h1>{{ 'ActivityReason'|trans }}</h1>
@ -38,7 +22,7 @@
</tbody>
</table>
<ul class="record_actions">
<ul class="record_actions sticky-form-buttons">
<li class="cancel">
<a href="{{ path('chill_activity_activityreason') }}" class="btn btn-cancel">
{{ 'Back to the list'|trans }}

View File

@ -1,20 +1,4 @@
{#
* Copyright (C) 2014, Champs Libres Cooperative SCRLFS, <http://www.champs-libres.coop>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
#}
{% extends "@ChillActivity/Admin/layout_activity.html.twig" %}
{% extends '@ChillMain/Admin/layoutWithVerticalMenu.html.twig' %}
{% block admin_content %}
<h1>{{ 'ActivityReasonCategory edit'|trans }}</h1>
@ -23,7 +7,7 @@
{{ form_row(edit_form.name) }}
{{ form_row(edit_form.active) }}
<ul class="record_actions">
<ul class="record_actions sticky-form-buttons">
<li class="cancel">
<a href="{{ path('chill_activity_activityreasoncategory') }}" class="btn btn-cancel">
{{ 'Back to the list'|trans }}

View File

@ -1,20 +1,4 @@
{#
* Copyright (C) 2014, Champs Libres Cooperative SCRLFS, <http://www.champs-libres.coop>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
#}
{% extends "@ChillActivity/Admin/layout_activity.html.twig" %}
{% extends '@ChillMain/Admin/layoutWithVerticalMenu.html.twig' %}
{% block admin_content %}
<h1>{{ 'ActivityReasonCategory list'|trans }}</h1>
@ -46,7 +30,7 @@
</tbody>
</table>
<ul class="record_actions">
<ul class="record_actions sticky-form-buttons">
<li>
<a href="{{ path('chill_activity_activityreasoncategory_new') }}" class="btn btn-new">
{{ 'Create a new activity category reason'|trans }}

View File

@ -1,20 +1,4 @@
{#
* Copyright (C) 2014, Champs Libres Cooperative SCRLFS, <http://www.champs-libres.coop>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
#}
{% extends "@ChillActivity/Admin/layout_activity.html.twig" %}
{% extends '@ChillMain/Admin/layoutWithVerticalMenu.html.twig' %}
{% block admin_content %}
<h1>{{ 'ActivityReasonCategory creation'|trans }}</h1>
@ -23,7 +7,7 @@
{{ form_row(form.name) }}
{{ form_row(form.active) }}
<ul class="record_actions">
<ul class="record_actions sticky-form-buttons">
<li class="cancel">
<a href="{{ path('chill_activity_activityreasoncategory') }}" class="btn btn-cancel">{{ 'Back to the list'|trans }}</a>
</li>

View File

@ -1,20 +1,4 @@
{#
* Copyright (C) 2014, Champs Libres Cooperative SCRLFS, <http://www.champs-libres.coop>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
#}
{% extends "@ChillActivity/Admin/layout_activity.html.twig" %}
{% extends '@ChillMain/Admin/layoutWithVerticalMenu.html.twig' %}
{% block admin_content %}
<h1>{{ 'ActivityReasonCategory'|trans }}</h1>
@ -37,7 +21,7 @@
</tr>
</tbody>
</table>
<ul class="record_actions">
<ul class="record_actions sticky-form-buttons">
<li class="cancel">
<a href="{{ path('chill_activity_activityreasoncategory') }}" class="btn btn-cancel">
{{ 'Back to the list'|trans }}

View File

@ -1,10 +1,10 @@
{% extends "@ChillActivity/Admin/layout_activity.html.twig" %}
{% extends '@ChillMain/CRUD/Admin/index.html.twig' %}
{% block title %}
{% include('@ChillMain/CRUD/_edit_title.html.twig') %}
{% endblock %}
{% block layout_wvm_content %}
{% block admin_content %}
{% embed '@ChillMain/CRUD/_edit_content.html.twig' %}
{% block content_form_actions_view %}{% endblock %}
{% block content_form_actions_save_and_show %}{% endblock %}

View File

@ -1,20 +1,4 @@
{#
* Copyright (C) 2014, Champs Libres Cooperative SCRLFS, <http://www.champs-libres.coop>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
#}
{% extends "@ChillActivity/Admin/layout_activity.html.twig" %}
{% extends '@ChillMain/CRUD/Admin/index.html.twig' %}
{% block admin_content %}
<h1>{{ 'ActivityType list'|trans }}</h1>
@ -50,7 +34,7 @@
</tbody>
</table>
<ul class="record_actions">
<ul class="record_actions sticky-form-buttons">
<li>
<a href="{{ path('chill_crud_activity_type_new') }}" class="btn btn-create">
{{ 'Create a new activity type'|trans }}

View File

@ -1,10 +1,10 @@
{% extends "@ChillActivity/Admin/layout_activity.html.twig" %}
{% extends '@ChillMain/CRUD/Admin/index.html.twig' %}
{% block title %}
{% include('@ChillMain/CRUD/_new_title.html.twig') %}
{% endblock %}
{% block layout_wvm_content %}
{% block admin_content %}
{% embed '@ChillMain/CRUD/_new_content.html.twig' %}
{% block content_form_actions_save_and_show %}{% endblock %}
{% endembed %}

View File

@ -1,12 +1,12 @@
{% extends "@ChillActivity/Admin/layout_activity.html.twig" %}
{% extends '@ChillMain/CRUD/Admin/index.html.twig' %}
{% block title %}
{% include('@ChillMain/CRUD/_edit_title.html.twig') %}
{% include('@ChillMain/CRUD/_edit_title.html.twig') %}
{% endblock %}
{% block layout_wvm_content %}
{% embed '@ChillMain/CRUD/_edit_content.html.twig' %}
{% block content_form_actions_view %}{% endblock %}
{% block content_form_actions_save_and_show %}{% endblock %}
{% endembed %}
{% block admin_content %}
{% embed '@ChillMain/CRUD/_edit_content.html.twig' %}
{% block content_form_actions_view %}{% endblock %}
{% block content_form_actions_save_and_show %}{% endblock %}
{% endembed %}
{% endblock %}

View File

@ -1,4 +1,4 @@
{% extends "@ChillActivity/Admin/layout_activity.html.twig" %}
{% extends '@ChillMain/CRUD/Admin/index.html.twig' %}
{% block admin_content %}
<h1>{{ 'ActivityTypeCategory list'|trans }}</h1>
@ -34,7 +34,7 @@
</tbody>
</table>
<ul class="record_actions">
<ul class="record_actions sticky-form-buttons">
<li>
<a href="{{ path('chill_crud_activity_type_category_new') }}" class="btn btn-create">
{{ 'Create a new activity type category'|trans }}

View File

@ -1,11 +1,11 @@
{% extends "@ChillActivity/Admin/layout_activity.html.twig" %}
{% extends '@ChillMain/CRUD/Admin/index.html.twig' %}
{% block title %}
{% include('@ChillMain/CRUD/_new_title.html.twig') %}
{% include('@ChillMain/CRUD/_new_title.html.twig') %}
{% endblock %}
{% block layout_wvm_content %}
{% embed '@ChillMain/CRUD/_new_content.html.twig' %}
{% block content_form_actions_save_and_show %}{% endblock %}
{% endembed %}
{% block admin_content %}
{% embed '@ChillMain/CRUD/_new_content.html.twig' %}
{% block content_form_actions_save_and_show %}{% endblock %}
{% endembed %}
{% endblock %}

View File

@ -1,5 +1,5 @@
{#
* Copyright (C) 2014-2015, Champs Libres Cooperative SCRLFS,
* Copyright (C) 2014-2015, Champs Libres Cooperative SCRLFS,
<info@champs-libres.coop> / <http://www.champs-libres.coop>
*
* This program is free software: you can redistribute it and/or modify
@ -20,7 +20,7 @@
{% block vertical_menu_content %}
{{ chill_menu('admin_activity', {
'layout': '@ChillActivity/Admin/menu_activity.html.twig',
'layout': '@ChillMain/Admin/menu_admin_section.html.twig',
}) }}
{% endblock %}

View File

@ -1,20 +0,0 @@
{#
* Copyright (C) 2014-2015, Champs Libres Cooperative SCRLFS,
<info@champs-libres.coop> / <http://www.champs-libres.coop>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
#}
{% extends "@ChillMain/Menu/verticalMenu.html.twig" %}
{% block v_menu_title %}{{ 'Activity configuration menu'|trans }}{% endblock %}

View File

@ -209,6 +209,7 @@ class ActivityContext implements
*/
public function storeGenerated(DocGeneratorTemplate $template, StoredObject $storedObject, object $entity, array $contextGenerationData): void
{
$storedObject->setTitle($this->translatableStringHelper->localize($template->getName()));
$entity->addDocument($storedObject);
$this->em->persist($storedObject);

View File

@ -10,48 +10,22 @@ chill_activity_activityreasoncategory:
resource: "@ChillActivityBundle/config/routes/activityreasoncategory.yaml"
prefix: /
chill_admin_activity_index:
chill_activity_admin_index:
path: /{_locale}/admin/activity
controller: Chill\ActivityBundle\Controller\AdminController::indexActivityAction
options:
menus:
admin_section:
label: Activities
order: 2000
icons: [tag]
chill_admin_activity_redirect_to_admin_index:
path: /{_locale}/admin/activity_redirect_to_main
controller: Chill\ActivityBundle\Controller\AdminController::redirectToAdminIndexAction
options:
menus:
admin_activity:
order: 0
label: Main admin menu
chill_activity_type_admin:
path: /{_locale}/admin/activity/type
controller: cscrud_activity_type_controller:index
options:
menus:
admin_activity:
order: 2020
label: 'Activity Types'
chill_activity_type_category_admin:
path: /{_locale}/admin/activity/type_category
controller: cscrud_activity_type_category_controller:index
options:
menus:
admin_activity:
order: 2999
label: 'Activity Types Categories'
chill_activity_presence_admin:
path: /{_locale}/admin/activity/presence
controller: cscrud_activity_presence_controller:index
options:
menus:
admin_activity:
order: 2021
label: 'Activity Presences'

View File

@ -1,11 +1,6 @@
chill_activity_activityreason:
path: /{_locale}/admin/activityreason/
controller: Chill\ActivityBundle\Controller\ActivityReasonController::indexAction
options:
menus:
admin_activity:
order: 2000
label: "Activity Reasons"
chill_activity_activityreason_show:
path: /{_locale}/admin/activityreason/{id}/show

View File

@ -1,11 +1,6 @@
chill_activity_activityreasoncategory:
path: /{_locale}/admin/activityreasoncategory/
controller: Chill\ActivityBundle\Controller\ActivityReasonCategoryController::indexAction
options:
menus:
admin_activity:
order: 2010
label: "Activity Reasons Category"
chill_activity_activityreasoncategory_show:
path: /{_locale}/admin/activityreasoncategory/{id}/show

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\Migrations\Activity;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20220425133027 extends AbstractMigration
{
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE activitytype DROP privateCommentLabel');
$this->addSql('ALTER TABLE activitytype DROP privateCommentVisible');
}
public function getDescription(): string
{
return 'add private comment option to activity types';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE activitytype ADD privateCommentLabel VARCHAR(255) DEFAULT \'\' NOT NULL');
$this->addSql('ALTER TABLE activitytype ADD privateCommentVisible SMALLINT DEFAULT 1 NOT NULL');
}
}

View File

@ -0,0 +1,33 @@
<?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\Migrations\Activity;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20220527124438 extends AbstractMigration
{
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE chill_person_accompanying_period_work DROP privateComment_comments');
}
public function getDescription(): string
{
return 'add private comment to activity';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE activity ADD privateComment_comments JSON DEFAULT \'{}\'');
}
}

View File

@ -44,6 +44,7 @@ Received: Recevoir
by: 'Par '
location: Lieu
Reasons: Sujets
Private comment: Commentaire privé
#forms
@ -113,7 +114,7 @@ Activity type configuration: Configuration des categories d'activités
Activity Reasons: Sujets d'une activité
Activity Reasons Category: Catégories de sujet d'activités
Activity Types Categories: Catégories des types d'activité
Activity Presences: Presences des activités
Activity Presences: Presences aux activités
# Crud
@ -124,6 +125,9 @@ crud:
activity_type_category:
title_new: Nouvelle catégorie de type d'activité
title_edit: Edition d'une catégorie de type d'activité
activity_presence:
title_new: Nouvelle Présence aux activités
title_edit: Edition d'une Présence aux activités
# activity reason admin
ActivityReason list: Liste des sujets
@ -145,6 +149,10 @@ ActivityReasonCategory: Catégorie de sujet d'activité
ActivityReasonCategory is active and will be proposed: La catégorie est active et sera proposée
ActivityReasonCategory is inactive and won't be proposed: La catégorie est inactive et ne sera pas proposée
#activity presence admin
ActivityPresence list: Liste des Présences aux activités
Create a new activity presence: Créer une nouvelle "Présence aux activités"
# activity type type admin
ActivityType list: Types d'activités
Create a new activity type: Créer un nouveau type d'activité
@ -168,6 +176,8 @@ Reasons visible: Visibilité du champ Sujet
Reasons label: Libellé du champ Sujet
Comment visible: Visibilité du champ Commentaire
Comment label: Libellé du champ Commentaire
Private comment visible: Visibilité du champ Commentaire Privé
Private comment label: Libellé du champ Commentaire Privé
Emergency visible: Visibilité du champ Urgent
Emergency label: Libellé du champ Urgent
Accompanying period visible: Visibilité du champ Période d'accompagnement

View File

@ -0,0 +1,29 @@
<?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\AsideActivityBundle\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Routing\Annotation\Route;
/**
* Class AdminController.
*/
class AdminController extends AbstractController
{
/**
* @Route("/{_locale}/admin/aside-activity", name="chill_aside_activity_admin")
*/
public function indexAdminAction()
{
return $this->render('ChillAsideActivityBundle:Admin:index.html.twig');
}
}

View File

@ -14,8 +14,6 @@ namespace Chill\AsideActivityBundle\Menu;
use Knp\Menu\MenuItem;
use Symfony\Component\Security\Core\Security;
use function in_array;
final class AdminMenuBuilder implements \Chill\MainBundle\Routing\LocalMenuBuilderInterface
{
private Security $security;
@ -32,27 +30,25 @@ final class AdminMenuBuilder implements \Chill\MainBundle\Routing\LocalMenuBuild
return;
}
if (in_array($menuId, ['admin_index', 'admin_section'], true)) {
$menu->addChild('Aside activities', [
$menu->addChild('Aside activities', [
'route' => 'chill_aside_activity_admin',
])
->setAttribute('class', 'list-group-item-header')
->setExtras([
'order' => 7000,
'explain' => 'Aside activity type configuration',
]);
$menu
->addChild('Aside activity categories', [
'route' => 'chill_crud_aside_activity_category_index',
])
->setExtras([
'order' => 900,
'explain' => 'Aside activity type configuration',
]);
} else {
$menu
->addChild('Aside activity categories', [
'route' => 'chill_crud_aside_activity_category_index',
])
->setExtras([
'order' => '50',
]);
}
->setExtras([
'order' => 7010,
]);
}
public static function getMenuIds(): array
{
return ['admin_index', 'admin_section', 'admin_aside_activity'];
return ['admin_section', 'admin_aside_activity'];
}
}

View File

@ -1,7 +1,9 @@
{% extends "@ChillMain/Admin/layoutWithVerticalMenu.html.twig" %}
{% block vertical_menu_content %}
{{ chill_menu('admin_aside_activity') }}
{{ chill_menu('admin_aside_activity', {
'layout': '@ChillMain/Admin/menu_admin_section.html.twig',
}) }}
{% endblock %}
{% block layout_wvm_content %}

View File

@ -1,10 +1,10 @@
{% extends "@ChillAsideActivity/Admin/layout_asideactivity.html.twig" %}
{% extends '@ChillMain/CRUD/Admin/index.html.twig' %}
{% block title %}
{% include('@ChillMain/CRUD/_edit_title.html.twig') %}
{% endblock %}
{% block layout_wvm_content %}
{% block admin_content %}
{% embed '@ChillMain/CRUD/_edit_content.html.twig' %}
{% block content_form_actions_view %}{% endblock %}
{% block content_form_actions_save_and_show %}{% endblock %}

View File

@ -1,7 +1,10 @@
{% extends "@ChillAsideActivity/Admin/layout_asideactivity.html.twig" %}
{% extends '@ChillMain/Admin/layoutWithVerticalMenu.html.twig' %}
{% block title %}{{ 'Aside Activity Type List'|trans }}{% endblock title %}
{% block admin_content %}
<h1>{{ 'Aside Activity Type List'|trans }}</h1>
<h1>{{ 'Aside Activity Type List'|trans }}</h1>
<table class="records_list table table-bordered border-dark">
<thead>
@ -36,7 +39,7 @@
</tbody>
</table>
<ul class="record_actions">
<ul class="record_actions sticky-form-buttons">
<li>
<a href="{{ path('chill_crud_aside_activity_category_new') }}" class="btn btn-create">
{{ 'Create a new aside activity type'|trans }}

View File

@ -1,10 +1,10 @@
{% extends "@ChillAsideActivity/Admin/layout_asideactivity.html.twig" %}
{% extends '@ChillMain/CRUD/Admin/index.html.twig' %}
{% block title %}
{% include('@ChillMain/CRUD/_new_title.html.twig') %}
{% endblock %}
{% block layout_wvm_content %}
{% block admin_content %}
{% embed '@ChillMain/CRUD/_new_content.html.twig' %}
{% block content_form_actions_save_and_show %}{% endblock %}
{% endembed %}

View File

@ -38,7 +38,7 @@ crud:
confirm_message_delete: Êtes-vous sûr de vouloir supprimer cette activité annexe?
aside_activity_category:
title_new: Nouvelle catégorie d'activité annexe
title_edit: Edition d'une catégorie de type d'activité
title_edit: Édition d'une catégorie de type d'activité
#forms
Create a new aside activity type: Nouvelle categorie d'activité annexe
@ -165,3 +165,4 @@ Phonecall: "Appel téléphonique"
Aside activities: Activités annexes
Aside activity types: Types d'activités annexes
Aside activity type configuration: Configuration des categories d'activités annexes
Aside activity configuration: Configuration des activités annexes

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('PT15M'));
$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

@ -0,0 +1,28 @@
<?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 Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Routing\Annotation\Route;
class AdminController extends AbstractController
{
/**
* Calendar admin.
*
* @Route("/{_locale}/admin/calendar", name="chill_calendar_admin_index")
*/
public function indexAdminAction()
{
return $this->render('ChillCalendarBundle:Admin:index.html.twig');
}
}

View File

@ -13,17 +13,21 @@ 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;
@ -35,32 +39,40 @@ 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;
}
/**
@ -127,8 +139,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();
@ -137,35 +153,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');
@ -178,7 +187,7 @@ class CalendarController extends AbstractController
'form' => $form->createView(),
'delete_form' => $deleteForm->createView(),
'accompanyingCourse' => $accompanyingPeriod,
'user' => $user,
// 'user' => $user,
'entity_json' => $entity_array,
]);
}
@ -207,48 +216,31 @@ 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()
);
/*
dead code ?
if ($user instanceof User) {
$calendarItems = $this->calendarRepository->findByUser($user);
$view = '@ChillCalendar/Calendar/listByUser.html.twig';
return $this->render($view, [
'calendarItems' => $calendarItems,
'user' => $user,
]);
}
*/
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.');
return $this->render('@ChillCalendar/Calendar/listByAccompanyingCourse.html.twig', [
'calendarItems' => $calendarItems,
'accompanyingCourse' => $accompanyingPeriod,
'paginator' => $paginator,
'filterOrder' => $filterOrder,
]);
}
/**
@ -258,6 +250,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();
@ -271,8 +267,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);
@ -282,9 +280,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);
@ -294,7 +291,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()) {
@ -387,6 +384,14 @@ class CalendarController extends AbstractController
]);
}
private function buildListFilterOrder(): FilterOrderHelper
{
$filterOrder = $this->filterOrderHelperFactory->create(self::class);
$filterOrder->addDateRange('startDate', 'chill_calendar.start date filter', new DateTimeImmutable('3 days ago'), null);
return $filterOrder->build();
}
private function buildParamsToUrl(?User $user, ?AccompanyingPeriod $accompanyingPeriod): array
{
$params = [];
@ -396,7 +401,7 @@ class CalendarController extends AbstractController
}
if (null !== $accompanyingPeriod) {
$params['accompanying_period_id'] = $accompanyingPeriod->getId();
$params['id'] = $accompanyingPeriod->getId();
}
return $params;

View File

@ -11,58 +11,72 @@ declare(strict_types=1);
namespace Chill\CalendarBundle\Controller;
use Chill\CalendarBundle\Repository\CalendarRangeRepository;
use Chill\MainBundle\CRUD\Controller\ApiController;
use DateTime;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Serializer\Model\Collection;
use DateTimeImmutable;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use function count;
use Symfony\Component\Routing\Annotation\Route;
class CalendarRangeAPIController extends ApiController
{
/**
* @Route("/api/1.0/calendar/calendar-range-available.{_format}", name="chill_api_single_calendar_range_available")
*/
public function availableRanges(Request $request, string $_format): JsonResponse
private CalendarRangeRepository $calendarRangeRepository;
public function __construct(CalendarRangeRepository $calendarRangeRepository)
{
$em = $this->getDoctrine()->getManager();
$this->calendarRangeRepository = $calendarRangeRepository;
}
$sql = 'SELECT c FROM ChillCalendarBundle:CalendarRange c
WHERE NOT EXISTS (SELECT cal.id FROM ChillCalendarBundle:Calendar cal WHERE cal.calendarRange = c.id)';
/**
* @Route("/api/1.0/calendar/calendar-range-available/{id}.{_format}",
* name="chill_api_single_calendar_range_available",
* requirements={"_format": "json"}
* )
*/
public function availableRanges(User $user, Request $request, string $_format): JsonResponse
{
//return new JsonResponse(['ok' => true], 200, [], false);
$this->denyAccessUnlessGranted('ROLE_USER');
if ($request->query->has('user')) {
$user = $request->query->get('user');
$sql = $sql . ' AND c.user = :user';
$query = $em->createQuery($sql)
->setParameter('user', $user);
if ($request->query->has('start') && $request->query->has('end')) {
$startDate = $request->query->get('start');
$endDate = $request->query->get('end');
$sql = $sql . ' AND c.startDate > :startDate AND c.endDate < :endDate';
$query = $em ->createQuery($sql)
->setParameter('startDate', $startDate)
->setParameter('endDate', $endDate)
->setParameter('user', $user);
}
if($request->query->has('start') && !$request->query->has('end')) {
$copyDate = $request->query->get('start');
$sql = $sql . ' AND DATE_DIFF(c.startDate, :copyDate) = 0';
$query = $em ->createQuery($sql)
->setParameter('copyDate', $copyDate)
->setParameter('user', $user);
}
} else {
$query = $em->createQuery($sql);
if (!$request->query->has('dateFrom')) {
throw new BadRequestHttpException('You must provide a dateFrom parameter');
}
$results = $query->getResult();
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 (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,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\Controller;
use Chill\MainBundle\CRUD\Controller\CRUDController;
use Chill\MainBundle\Pagination\PaginatorInterface;
use Symfony\Component\HttpFoundation\Request;
class CancelReasonController extends CRUDController
{
protected function orderQuery(string $action, $query, Request $request, PaginatorInterface $paginator)
{
$query->addOrderBy('e.id', 'ASC');
return parent::orderQuery($action, $query, $request, $paginator);
}
}

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,88 @@
<?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');
}
$events = $this->remoteCalendarConnector->listEventsForUser($user, $dateFrom, $dateTo);
$paginator = $this->paginatorFactory->create(count($events));
if (count($events) > 0) {
$paginator->setItemsPerPage($paginator->getTotalItems());
}
$collection = new Collection($events, $paginator);
return new JsonResponse(
$this->serializer->serialize($collection, 'json', ['groups' => ['read']]),
JsonResponse::HTTP_OK,
[],
true
);
}
}

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)
@ -47,6 +56,29 @@ class ChillCalendarExtension extends Extension implements PrependExtensionInterf
protected function prependCruds(ContainerBuilder $container)
{
$container->prependExtensionConfig('chill_main', [
'cruds' => [
[
'class' => \Chill\CalendarBundle\Entity\CancelReason::class,
'name' => 'calendar_cancel-reason',
'base_path' => '/admin/calendar/cancel-reason',
'form_class' => \Chill\CalendarBundle\Form\CancelReasonType::class,
'controller' => \Chill\CalendarBundle\Controller\CancelReasonController::class,
'actions' => [
'index' => [
'role' => 'ROLE_ADMIN',
'template' => '@ChillCalendar/CancelReason/index.html.twig',
],
'new' => [
'role' => 'ROLE_ADMIN',
'template' => '@ChillCalendar/CancelReason/new.html.twig',
],
'edit' => [
'role' => 'ROLE_ADMIN',
'template' => '@ChillCalendar/CancelReason/edit.html.twig',
],
],
],
],
'apis' => [
[
'controller' => \Chill\CalendarBundle\Controller\CalendarRangeAPIController::class,

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,40 +12,79 @@ 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;
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\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';
public const STATUS_MOVED = 'moved';
public const STATUS_VALID = 'valid';
/**
* 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")
* @Groups({"read"})
* @Serializer\Groups({"read"})
*/
private AccompanyingPeriod $accompanyingPeriod;
@ -55,7 +94,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;
@ -66,13 +106,13 @@ 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"})
*/
private ?DateTimeImmutable $endDate = null;
@ -80,104 +120,123 @@ class Calendar
* @ORM\Id
* @ORM\GeneratedValue
* @ORM\Column(type="integer")
* @Serializer\Groups({"calendar:read"})
* @Serializer\Groups({"calendar:read", "read"})
*/
private ?int $id;
private ?int $id = null;
/**
* @ORM\ManyToMany(
* targetEntity="Invite",
* cascade={"persist", "remove", "merge", "detach"})
* @ORM\OneToMany(
* targetEntity=Invite::class,
* mappedBy="calendar",
* orphanRemoval=true,
* cascade={"persist", "remove", "merge", "detach"}
* )
* @ORM\JoinTable(name="chill_calendar.calendar_to_invites")
* @Groups({"read"})
* @Serializer\Groups({"read"})
*/
private Collection $invites;
/**
* @ORM\ManyToOne(targetEntity="Chill\MainBundle\Entity\Location")
* @groups({"read"})
* @Serializer\Groups({"read"})
*/
private ?Location $location = null;
/**
* @ORM\ManyToOne(targetEntity="Chill\MainBundle\Entity\User")
* @Serializer\Groups({"calendar:read"})
* @Serializer\Groups({"calendar:read", "read"})
*/
private ?User $mainUser;
private ?User $mainUser = null;
/**
* @ORM\ManyToMany(
* targetEntity="Chill\PersonBundle\Entity\Person",
* cascade={"persist", "remove", "merge", "detach"})
* @ORM\JoinTable(name="chill_calendar.calendar_to_persons")
* @Groups({"read"})
* @Serializer\Groups({"calendar:read"})
* @Serializer\Groups({"calendar:read", "read"})
*/
private Collection $persons;
/**
* @ORM\Embedded(class=PrivateCommentEmbeddable::class, columnPrefix="privateComment_")
* @Serializer\Groups({"calendar:read"})
*/
private PrivateCommentEmbeddable $privateComment;
/**
* @ORM\ManyToMany(
* targetEntity="Chill\ThirdPartyBundle\Entity\ThirdParty",
* cascade={"persist", "remove", "merge", "detach"})
* @ORM\JoinTable(name="chill_calendar.calendar_to_thirdparties")
* @Groups({"read"})
* @Serializer\Groups({"calendar:read"})
* @Serializer\Groups({"calendar:read", "read"})
*/
private Collection $professionals;
/**
* @ORM\Column(type="boolean", nullable=true)
*/
private ?bool $sendSMS;
private ?bool $sendSMS = null;
/**
* @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"})
*/
private ?DateTimeImmutable $startDate = null;
/**
* @ORM\Column(type="string", length=255)
* @ORM\Column(type="string", length=255, nullable=false, options={"default": "valid"})
*/
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()
{
$this->comment = new CommentEmbeddable();
$this->privateComment = new PrivateCommentEmbeddable();
$this->persons = new ArrayCollection();
$this->professionals = new ArrayCollection();
$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) {
$this->invites[] = $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;
}
$this->persons[] = $person;
return $this;
}
public function addProfessional(?ThirdParty $professional): self
public function addProfessional(ThirdParty $professional): self
{
if (null !== $professional) {
$this->professionals[] = $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;
@ -208,6 +267,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;
@ -218,6 +286,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[]
*/
@ -278,6 +361,11 @@ class Calendar
return [];
}
public function getPrivateComment(): PrivateCommentEmbeddable
{
return $this->privateComment;
}
/**
* @return Collection|ThirdParty[]
*/
@ -291,6 +379,11 @@ class Calendar
return $this->sendSMS;
}
public function getSmsStatus(): string
{
return $this->smsStatus;
}
public function getStartDate(): ?DateTimeImmutable
{
return $this->startDate;
@ -306,14 +399,30 @@ 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;
}
/**
* 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
@ -330,9 +439,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;
}
@ -351,6 +466,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;
@ -367,8 +496,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;
}
@ -402,7 +543,19 @@ class Calendar
public function setMainUser(?User $mainUser): self
{
if ($this->mainUser !== $mainUser) {
$this->previousMainUser = $this->mainUser;
}
$this->mainUser = $mainUser;
$this->removeUser($mainUser);
return $this;
}
public function setPrivateComment(PrivateCommentEmbeddable $privateComment): self
{
$this->privateComment = $privateComment;
return $this;
}
@ -414,6 +567,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;
@ -425,12 +585,9 @@ class Calendar
{
$this->status = $status;
return $this;
}
public function setUser(?User $user): self
{
$this->user = $user;
if (self::STATUS_CANCELED === $status && $this->getSmsStatus() === self::SMS_SENT) {
$this->setSmsStatus(self::SMS_CANCEL_PENDING);
}
return $this;
}

View File

@ -11,29 +11,38 @@ 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\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;
/**
* @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"})
*/
private ?DateTimeImmutable $endDate = null;
@ -46,24 +55,24 @@ class CalendarRange
private $id;
/**
* @ORM\Column(type="datetimetz_immutable")
* @groups({"read", "write"})
* @ORM\Column(type="datetime_immutable", nullable=false)
* @groups({"read", "write", "calendar:read"})
*/
private ?DateTimeImmutable $startDate = null;
/**
* @ORM\ManyToOne(targetEntity="Chill\MainBundle\Entity\User")
* @groups({"read", "write"})
* @groups({"read", "write", "calendar:read"})
*/
private ?User $user = null;
//TODO Lieu
public function __construct()
public function getCalendar(): ?Calendar
{
$this->calendars = new ArrayCollection();
return $this->calendar;
}
//TODO Lieu
public function getEndDate(): ?DateTimeImmutable
{
return $this->endDate;
@ -84,6 +93,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;

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,17 +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\Templating\TranslatableStringHelper;
use Chill\PersonBundle\Entity\Person;
use Chill\ThirdPartyBundle\Entity\ThirdParty;
use Chill\MainBundle\Form\Type\PrivateCommentType;
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;
@ -33,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)
@ -51,6 +66,10 @@ class CalendarType extends AbstractType
->add('comment', CommentType::class, [
'required' => false,
])
->add('privateComment', PrivateCommentType::class, [
'required' => false,
'label' => 'private comment',
])
// ->add('cancelReason', EntityType::class, [
// 'required' => false,
// 'class' => CancelReason::class,
@ -68,36 +87,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) {
return null;
}
return DateTimeImmutable::createFromFormat(DateTimeImmutable::ATOM, $dateAsString);
}
));
@ -106,121 +115,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) {
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 {
if (null === $personsAsString) {
return [];
}
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 {
if (null === $thirdpartyAsString) {
return [];
}
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)
@ -228,10 +157,6 @@ class CalendarType extends AbstractType
$resolver->setDefaults([
'data_class' => Calendar::class,
]);
$resolver
->setRequired(['accompanyingPeriod'])
->setAllowedTypes('accompanyingPeriod', [\Chill\PersonBundle\Entity\AccompanyingPeriod::class, 'null']);
}
public function getBlockPrefix()

View File

@ -0,0 +1,39 @@
<?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;
use Chill\CalendarBundle\Entity\CancelReason;
use Chill\MainBundle\Form\Type\TranslatableStringFormType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class CancelReasonType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('name', TranslatableStringFormType::class)
->add('active', CheckboxType::class, [
'required' => false,
])
->add('canceledBy', TextType::class);
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver
->setDefault('class', CancelReason::class);
}
}

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

@ -13,20 +13,16 @@ 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\Bundle\SecurityBundle\SecurityBundle;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\Security;
use Symfony\Contracts\Translation\TranslatorInterface;
class AccompanyingCourseMenuBuilder implements LocalMenuBuilderInterface
{
private Security $security;
protected TranslatorInterface $translator;
private Security $security;
public function __construct(
Security $security,
TranslatorInterface $translator
@ -41,9 +37,9 @@ class AccompanyingCourseMenuBuilder implements LocalMenuBuilderInterface
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

@ -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\Menu;
use Chill\MainBundle\Routing\LocalMenuBuilderInterface;
use Knp\Menu\MenuItem;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
class AdminMenuBuilder implements LocalMenuBuilderInterface
{
/**
* @var AuthorizationCheckerInterface
*/
protected $authorizationChecker;
public function __construct(AuthorizationCheckerInterface $authorizationChecker)
{
$this->authorizationChecker = $authorizationChecker;
}
public function buildMenu($menuId, MenuItem $menu, array $parameters)
{
if (!$this->authorizationChecker->isGranted('ROLE_ADMIN')) {
return;
}
$menu->addChild('Calendar', [
'route' => 'chill_calendar_admin_index',
])
->setAttribute('class', 'list-group-item-header')
->setExtras([
'order' => 6000,
'icons' => ['calendar'],
]);
$menu->addChild('Cancel reason', [
'route' => 'chill_crud_calendar_cancel-reason_index',
])->setExtras(['order' => 6010]);
}
public static function getMenuIds(): array
{
return ['admin_section', 'admin_calendar'];
}
}

View File

@ -0,0 +1,62 @@
<?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 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
{
// TODO
}
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,43 @@
<?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;
/**
* @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,51 @@
<?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;
/**
* @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());
$this->remoteCalendarConnector->syncCalendarRange($range);
$range->preventEnqueueChanges = true;
$this->entityManager->flush();
}
}

View File

@ -0,0 +1,106 @@
<?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;
/**
* @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 !== $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,48 @@
<?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;
/**
* @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 chagnes from MSGraph.
*
* @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,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,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,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,184 @@
<?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 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 MachineHttpClient $machineHttpClient;
public function __construct(
MachineHttpClient $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 (!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 === $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,231 @@
<?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 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';
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 PersonRenderInterface $personRender;
private DateTimeZone $remoteDateTimeZone;
private TranslatorInterface $translator;
public function __construct(EngineInterface $engine, PersonRenderInterface $personRender, TranslatorInterface $translator)
{
$this->engine = $engine;
$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,
];
}
public function calendarToEvent(Calendar $calendar): array
{
return 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,
],
$this->calendarToEventAttendeesOnly($calendar)
);
}
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'])
->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'])
->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
);
}
public function getLastModifiedDate(array $event): DateTimeImmutable
{
return DateTimeImmutable::createFromFormat(self::REMOTE_DATETIMEZONE_FORMAT, $event['lastModifiedDateTime']);
}
/**
* 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,661 @@
<?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\MachineHttpClient;
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\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\Translation\TranslatorInterface;
use function count;
class MSGraphRemoteCalendarConnector implements RemoteCalendarConnectorInterface
{
private CalendarRangeRepository $calendarRangeRepository;
private LoggerInterface $logger;
private MachineHttpClient $machineHttpClient;
private MapCalendarToUser $mapCalendarToUser;
private RemoteEventConverter $remoteEventConverter;
private OnBehalfOfUserTokenStorage $tokenStorage;
private TranslatorInterface $translator;
private UrlGeneratorInterface $urlGenerator;
private OnBehalfOfUserHttpClient $userHttpClient;
public function __construct(
CalendarRangeRepository $calendarRangeRepository,
MachineHttpClient $machineHttpClient,
MapCalendarToUser $mapCalendarToUser,
LoggerInterface $logger,
OnBehalfOfUserTokenStorage $tokenStorage,
OnBehalfOfUserHttpClient $userHttpClient,
RemoteEventConverter $remoteEventConverter,
TranslatorInterface $translator,
UrlGeneratorInterface $urlGenerator
) {
$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 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();
}
public function listEventsForUser(User $user, DateTimeImmutable $startDate, DateTimeImmutable $endDate): 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',
],
]
)->toArray();
$ids = array_map(static function ($item) { return $item['id']; }, $bareEvents['value']);
$existingIdsInRange = $this->calendarRangeRepository->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) {
return (!$existingIdsInRange[$item['id']]) ?? true;
}
)
)
);
} catch (ClientExceptionInterface $e) {
if (403 === $e->getResponse()->getStatusCode()) {
return $this->getScheduleTimesForUser($user, $startDate, $endDate);
}
$this->logger->debug('Could not get list of event on MSGraph', [
'error_code' => $e->getResponse()->getStatusCode(),
'error' => $e->getResponse()->getInfo(),
]);
return [];
}
}
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 {
$v = $this->machineHttpClient->request(
'GET',
'users/' . $userId . '/calendar/events/' . $remoteId
)->toArray();
dump($v);
return $v;
} 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 (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 [];
}
return array_map(
function ($item) {
return $this->remoteEventConverter->convertAvailabilityToRemoteEvent($item);
},
$response['value'][0]['scheduleItems']
);
}
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,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\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 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): array
{
return [];
}
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,52 @@
<?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
{
/**
* 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): array;
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,49 @@
<?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 DateTimeImmutable $startDate;
/**
* @Serializer\Groups({"read"})
*/
public string $title;
public function __construct(string $id, string $title, string $description, DateTimeImmutable $startDate, DateTimeImmutable $endDate)
{
$this->id = $id;
$this->title = $title;
$this->description = $description;
$this->startDate = $startDate;
$this->endDate = $endDate;
}
}

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

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