some data in msgraph synchro and answer on calendar invite

This commit is contained in:
Julien Fastré 2022-05-26 19:25:59 +02:00
parent 59a64e9a62
commit 7c0bdc5abe
7 changed files with 433 additions and 257 deletions

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\Entity\Calendar;
use Chill\CalendarBundle\Entity\Invite;
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\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Security;
use function in_array;
class InviteApiController
{
private EntityManagerInterface $entityManager;
private Security $security;
public function __construct(Security $security, EntityManagerInterface $entityManager)
{
$this->security = $security;
$this->entityManager = $entityManager;
}
/**
* 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) || Invite::PENDING === $answer) {
throw new BadRequestHttpException('answer not valid');
}
$invite->setStatus($answer);
$this->entityManager->flush();
return new JsonResponse(null, Response::HTTP_ACCEPTED, [], false);
}
}

View File

@ -26,6 +26,7 @@ use DateInterval;
use DateTimeImmutable; use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use Doctrine\Common\Collections\Criteria;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use LogicException; use LogicException;
use Symfony\Component\Serializer\Annotation as Serializer; use Symfony\Component\Serializer\Annotation as Serializer;
@ -212,7 +213,7 @@ class Calendar implements TrackCreationInterface, TrackUpdateInterface
public function addUser(User $user): self public function addUser(User $user): self
{ {
if (!$this->getUsers()->contains($user)) { if (!$this->getUsers()->contains($user) && $this->getMainUser() !== $user) {
$this->addInvite((new Invite())->setUser($user)); $this->addInvite((new Invite())->setUser($user));
} }
@ -263,6 +264,21 @@ class Calendar implements TrackCreationInterface, TrackUpdateInterface
return $this->id; 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[] * @return Collection|Invite[]
*/ */
@ -365,6 +381,18 @@ class Calendar implements TrackCreationInterface, TrackUpdateInterface
return null !== $this->calendarRange; 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 public static function loadValidatorMetadata(ClassMetadata $metadata): void
{ {
$metadata->addPropertyConstraint('startDate', new NotBlank()); $metadata->addPropertyConstraint('startDate', new NotBlank());
@ -485,6 +513,7 @@ class Calendar implements TrackCreationInterface, TrackUpdateInterface
} }
$this->mainUser = $mainUser; $this->mainUser = $mainUser;
$this->removeUser($mainUser);
return $this; return $this;
} }

View File

@ -36,6 +36,16 @@ class Invite implements TrackUpdateInterface, TrackCreationInterface
public const PENDING = 'pending'; 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'; public const TENTATIVELY_ACCEPTED = 'tentative';
/** /**

View File

@ -21,7 +21,6 @@ use DateTimeImmutable;
use DateTimeZone; use DateTimeZone;
use Symfony\Component\Templating\EngineInterface; use Symfony\Component\Templating\EngineInterface;
use Symfony\Contracts\Translation\TranslatorInterface; use Symfony\Contracts\Translation\TranslatorInterface;
use Twig\Environment;
/** /**
* Convert Chill Calendar event to Remote MS Graph event, and MS Graph * Convert Chill Calendar event to Remote MS Graph event, and MS Graph
@ -35,14 +34,14 @@ class RemoteEventConverter
private DateTimeZone $defaultDateTimeZone; private DateTimeZone $defaultDateTimeZone;
private EngineInterface $engine;
private PersonRenderInterface $personRender; private PersonRenderInterface $personRender;
private DateTimeZone $remoteDateTimeZone; private DateTimeZone $remoteDateTimeZone;
private TranslatorInterface $translator; private TranslatorInterface $translator;
private EngineInterface $engine;
public function __construct(EngineInterface $engine, PersonRenderInterface $personRender, TranslatorInterface $translator) public function __construct(EngineInterface $engine, PersonRenderInterface $personRender, TranslatorInterface $translator)
{ {
$this->engine = $engine; $this->engine = $engine;
@ -112,7 +111,7 @@ class RemoteEventConverter
'@ChillCalendar/MSGraph/calendar_event_body.html.twig', '@ChillCalendar/MSGraph/calendar_event_body.html.twig',
['calendar' => $calendar] ['calendar' => $calendar]
), ),
] ],
], ],
$this->calendarToEventAttendeesOnly($calendar) $this->calendarToEventAttendeesOnly($calendar)
); );
@ -129,17 +128,6 @@ class RemoteEventConverter
]; ];
} }
private function buildInviteToAttendee(Invite $invite): array
{
return [
'emailAddress' => [
'address' => $invite->getUser()->getEmail(),
'name' => $invite->getUser()->getLabel(),
],
'type' => 'Required',
];
}
public function convertAvailabilityToRemoteEvent(array $event): RemoteEvent public function convertAvailabilityToRemoteEvent(array $event): RemoteEvent
{ {
$startDate = $startDate =
@ -193,4 +181,15 @@ class RemoteEventConverter
{ {
return new DateTimeZone('UTC'); return new DateTimeZone('UTC');
} }
private function buildInviteToAttendee(Invite $invite): array
{
return [
'emailAddress' => [
'address' => $invite->getUser()->getEmail(),
'name' => $invite->getUser()->getLabel(),
],
'type' => 'Required',
];
}
} }

View File

@ -28,6 +28,7 @@ use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface; use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
use Symfony\Contracts\Translation\TranslatorInterface; use Symfony\Contracts\Translation\TranslatorInterface;
use function count;
class MSGraphRemoteCalendarConnector implements RemoteCalendarConnectorInterface class MSGraphRemoteCalendarConnector implements RemoteCalendarConnectorInterface
{ {
@ -41,10 +42,10 @@ class MSGraphRemoteCalendarConnector implements RemoteCalendarConnectorInterface
private RemoteEventConverter $remoteEventConverter; private RemoteEventConverter $remoteEventConverter;
private TranslatorInterface $translator;
private OnBehalfOfUserTokenStorage $tokenStorage; private OnBehalfOfUserTokenStorage $tokenStorage;
private TranslatorInterface $translator;
private UrlGeneratorInterface $urlGenerator; private UrlGeneratorInterface $urlGenerator;
private OnBehalfOfUserHttpClient $userHttpClient; private OnBehalfOfUserHttpClient $userHttpClient;
@ -171,7 +172,7 @@ class MSGraphRemoteCalendarConnector implements RemoteCalendarConnectorInterface
$calendar->getRemoteId(), $calendar->getRemoteId(),
$this->translator->trans('remote_ms_graph.cancel_event_because_main_user_is_%label%', ['%label%' => $calendar->getMainUser()]), $this->translator->trans('remote_ms_graph.cancel_event_because_main_user_is_%label%', ['%label%' => $calendar->getMainUser()]),
$previousMainUser, $previousMainUser,
'calendar_'.$calendar->getRemoteId() 'calendar_' . $calendar->getRemoteId()
); );
$this->createCalendarOnRemote($calendar); $this->createCalendarOnRemote($calendar);
} else { } else {
@ -189,10 +190,9 @@ class MSGraphRemoteCalendarConnector implements RemoteCalendarConnectorInterface
->addRemoteAttributes([ ->addRemoteAttributes([
'lastModifiedDateTime' => null, 'lastModifiedDateTime' => null,
'changeKey' => null, 'changeKey' => null,
'previousId' => $calendar->getCalendarRange()->getRemoteId() 'previousId' => $calendar->getCalendarRange()->getRemoteId(),
]) ])
->setRemoteId('') ->setRemoteId('');
;
} }
if (null !== $previousCalendarRange) { if (null !== $previousCalendarRange) {
@ -209,31 +209,30 @@ class MSGraphRemoteCalendarConnector implements RemoteCalendarConnectorInterface
} }
} }
private function patchCalendarOnRemote(Calendar $calendar, array $newInvites): void private function cancelOnRemote(string $remoteId, string $comment, User $user, string $identifier): void
{ {
$eventDatas = []; $userId = $this->mapCalendarToUser->getUserId($user);
$eventDatas[] = $this->remoteEventConverter->calendarToEvent($calendar);
if (0 < count($newInvites)) { if (null === $userId) {
$eventDatas[] = $this->remoteEventConverter->calendarToEventAttendeesOnly($calendar); return;
} }
foreach ($eventDatas as $eventData) { try {
[ $this->machineHttpClient->request(
'id' => $id, 'POST',
'lastModifiedDateTime' => $lastModified, "users/{$userId}/calendar/events/{$remoteId}/cancel",
'changeKey' => $changeKey [
] = $this->patchOnRemote( 'json' => ['Comment' => $comment],
$calendar->getRemoteId(), ]
$eventData,
$calendar->getMainUser(),
'calendar_'.$calendar->getId()
); );
} catch (ClientExceptionInterface $e) {
$calendar->addRemoteAttributes([ $this->logger->warning('could not update calendar range to remote', [
'lastModifiedDateTime' => $lastModified, 'exception' => $e->getTraceAsString(),
'changeKey' => $changeKey, 'content' => $e->getResponse()->getContent(),
'calendarRangeId' => $identifier,
]); ]);
throw $e;
} }
} }
@ -330,7 +329,7 @@ class MSGraphRemoteCalendarConnector implements RemoteCalendarConnectorInterface
$calendarRange->setRemoteId($id) $calendarRange->setRemoteId($id)
->addRemoteAttributes([ ->addRemoteAttributes([
'lastModifiedDateTime' => $lastModified, 'lastModifiedDateTime' => $lastModified,
'changeKey' => $changeKey 'changeKey' => $changeKey,
]); ]);
} }
@ -404,33 +403,32 @@ class MSGraphRemoteCalendarConnector implements RemoteCalendarConnectorInterface
); );
} }
private function cancelOnRemote(string $remoteId, string $comment, User $user, string $identifier): void private function patchCalendarOnRemote(Calendar $calendar, array $newInvites): void
{ {
$userId = $this->mapCalendarToUser->getUserId($user); $eventDatas = [];
$eventDatas[] = $this->remoteEventConverter->calendarToEvent($calendar);
if (null === $userId) { if (0 < count($newInvites)) {
return; $eventDatas[] = $this->remoteEventConverter->calendarToEventAttendeesOnly($calendar);
} }
try { foreach ($eventDatas as $eventData) {
$this->machineHttpClient->request( [
'POST', 'id' => $id,
"users/{$userId}/calendar/events/{$remoteId}/cancel", 'lastModifiedDateTime' => $lastModified,
[ 'changeKey' => $changeKey
'json' => ['Comment' => $comment] ] = $this->patchOnRemote(
] $calendar->getRemoteId(),
$eventData,
$calendar->getMainUser(),
'calendar_' . $calendar->getId()
); );
} catch (ClientExceptionInterface $e) {
$this->logger->warning('could not update calendar range to remote', [ $calendar->addRemoteAttributes([
'exception' => $e->getTraceAsString(), 'lastModifiedDateTime' => $lastModified,
'content' => $e->getResponse()->getContent(), 'changeKey' => $changeKey,
'calendarRangeId' => $identifier,
]); ]);
throw $e;
} }
} }
/** /**

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\Security\Voter;
use Chill\CalendarBundle\Entity\Invite;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
class InviteVoter extends Voter
{
public const ANSWER = 'CHILL_CALENDAR_INVITE_ANSWER';
protected function supports($attribute, $subject): bool
{
return $subject instanceof Invite && self::ANSWER === $attribute;
}
/**
* @param string $attribute
* @param Invite $subject
*/
protected function voteOnAttribute($attribute, $subject, TokenInterface $token): bool
{
return $token->getUser() === $subject->getUser();
}
}

View File

@ -1,204 +1,240 @@
--- ---
openapi: "3.0.0" #openapi: "3.0.0"
info: #info:
version: "1.0.0" # version: "1.0.0"
title: "Chill api" # title: "Chill api"
description: "Api documentation for chill. Currently, work in progress" # description: "Api documentation for chill. Currently, work in progress"
servers: #servers:
- url: "/api" # - url: "/api"
description: "Your current dev server" # description: "Your current dev server"
components: components:
schemas: schemas:
Date: Date:
type: object type: object
properties: properties:
datetime: datetime:
type: string type: string
format: date-time format: date-time
User: User:
type: object type: object
properties: properties:
id: id:
type: integer type: integer
type: type:
type: string type: string
enum: enum:
- user - user
username: username:
type: string type: string
text: text:
type: string type: string
paths: paths:
/1.0/calendar/calendar.json: /1.0/calendar/calendar/{id}/answer/{answer}.json:
get: post:
tags: tags:
- calendar - calendar
summary: Return a list of all calendar items summary: Answer to a calendar's invite
responses: parameters:
200: -
description: "ok" in: path
name: id
required: true
description: the calendar id
schema:
type: integer
format: integer
minimum: 0
-
in: path
name: answer
required: true
description: the answer
schema:
type: string
enum:
- accepted
- declined
- tentative
responses:
400:
description: bad answer
403:
description: not invited
404:
description: not found
202:
description: accepted
/1.0/calendar/calendar/{id}.json: /1.0/calendar/calendar.json:
get: get:
tags: tags:
- calendar - calendar
summary: Return an calendar item by id summary: Return a list of all calendar items
parameters: responses:
- name: id 200:
in: path description: "ok"
required: true
description: The calendar id
schema:
type: integer
format: integer
minimum: 1
responses:
200:
description: "ok"
404:
description: "not found"
401:
description: "Unauthorized"
/1.0/calendar/calendar-range.json: /1.0/calendar/calendar/{id}.json:
get: get:
tags: tags:
- calendar - calendar
summary: Return a list of all calendar range items summary: Return an calendar item by id
responses: parameters:
200: - name: id
description: "ok" in: path
post: required: true
tags: description: The calendar id
- calendar schema:
summary: create a new calendar range type: integer
requestBody: format: integer
required: true minimum: 1
content: responses:
application/json: 200:
schema: description: "ok"
type: object 404:
properties: description: "not found"
user: 401:
$ref: '#/components/schemas/User' description: "Unauthorized"
startDate:
$ref: '#/components/schemas/Date'
endDate:
$ref: '#/components/schemas/Date'
responses:
401:
description: "Unauthorized"
404:
description: "Not found"
200:
description: "OK"
422:
description: "Unprocessable entity (validation errors)"
400:
description: "transition cannot be applyed"
/1.0/calendar/calendar-range/{id}.json: /1.0/calendar/calendar-range.json:
get: get:
tags: tags:
- calendar - calendar
summary: Return an calendar-range item by id summary: Return a list of all calendar range items
parameters: responses:
- name: id 200:
in: path description: "ok"
required: true post:
description: The calendar-range id tags:
schema: - calendar
type: integer summary: create a new calendar range
format: integer requestBody:
minimum: 1 required: true
responses: content:
200: application/json:
description: "ok" schema:
404: type: object
description: "not found" properties:
401: user:
description: "Unauthorized" $ref: '#/components/schemas/User'
patch: startDate:
tags: $ref: '#/components/schemas/Date'
- calendar endDate:
summary: update a calendar range $ref: '#/components/schemas/Date'
requestBody: responses:
required: true 401:
content: description: "Unauthorized"
application/json: 404:
schema: description: "Not found"
type: object 200:
properties: description: "OK"
user: 422:
$ref: '#/components/schemas/User' description: "Unprocessable entity (validation errors)"
startDate: 400:
$ref: '#/components/schemas/Date' description: "transition cannot be applyed"
endDate:
$ref: '#/components/schemas/Date'
responses:
401:
description: "Unauthorized"
404:
description: "Not found"
200:
description: "OK"
422:
description: "Unprocessable entity (validation errors)"
400:
description: "transition cannot be applyed"
delete:
tags:
- calendar
summary: "Remove a calendar range"
parameters:
- name: id
in: path
required: true
description: The calendar range id
schema:
type: integer
format: integer
minimum: 1
responses:
401:
description: "Unauthorized"
404:
description: "Not found"
200:
description: "OK"
422:
description: "object with validation errors"
/1.0/calendar/calendar-range-available/{userId}.json: /1.0/calendar/calendar-range/{id}.json:
get: get:
tags: tags:
- calendar - calendar
summary: Return a list of available calendar range items. Available means calendar-range not being taken by a calendar entity summary: Return an calendar-range item by id
parameters: parameters:
- name: userId - name: id
in: path in: path
required: true required: true
description: The user id description: The calendar-range id
schema: schema:
type: integer type: integer
format: integer format: integer
minimum: 1 minimum: 1
- name: dateFrom responses:
in: query 200:
required: true description: "ok"
description: The date from, formatted as ISO8601 string 404:
schema: description: "not found"
type: string 401:
format: date-time description: "Unauthorized"
- name: dateTo patch:
in: query tags:
required: true - calendar
description: The date to, formatted as ISO8601 string summary: update a calendar range
schema: requestBody:
type: string required: true
format: date-time content:
responses: application/json:
200: schema:
description: "ok" type: object
properties:
user:
$ref: '#/components/schemas/User'
startDate:
$ref: '#/components/schemas/Date'
endDate:
$ref: '#/components/schemas/Date'
responses:
401:
description: "Unauthorized"
404:
description: "Not found"
200:
description: "OK"
422:
description: "Unprocessable entity (validation errors)"
400:
description: "transition cannot be applyed"
delete:
tags:
- calendar
summary: "Remove a calendar range"
parameters:
- name: id
in: path
required: true
description: The calendar range id
schema:
type: integer
format: integer
minimum: 1
responses:
401:
description: "Unauthorized"
404:
description: "Not found"
200:
description: "OK"
422:
description: "object with validation errors"
/1.0/calendar/calendar-range-available/{userId}.json:
get:
tags:
- calendar
summary: Return a list of available calendar range items. Available means calendar-range not being taken by a calendar entity
parameters:
- name: userId
in: path
required: true
description: The user id
schema:
type: integer
format: integer
minimum: 1
- name: dateFrom
in: query
required: true
description: The date from, formatted as ISO8601 string
schema:
type: string
format: date-time
- name: dateTo
in: query
required: true
description: The date to, formatted as ISO8601 string
schema:
type: string
format: date-time
responses:
200:
description: "ok"