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 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;
@ -212,7 +213,7 @@ class Calendar implements TrackCreationInterface, TrackUpdateInterface
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));
}
@ -263,6 +264,21 @@ class Calendar implements TrackCreationInterface, TrackUpdateInterface
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[]
*/
@ -365,6 +381,18 @@ class Calendar implements TrackCreationInterface, TrackUpdateInterface
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
{
$metadata->addPropertyConstraint('startDate', new NotBlank());
@ -485,6 +513,7 @@ class Calendar implements TrackCreationInterface, TrackUpdateInterface
}
$this->mainUser = $mainUser;
$this->removeUser($mainUser);
return $this;
}

View File

@ -36,6 +36,16 @@ class Invite implements TrackUpdateInterface, TrackCreationInterface
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';
/**

View File

@ -21,7 +21,6 @@ use DateTimeImmutable;
use DateTimeZone;
use Symfony\Component\Templating\EngineInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
use Twig\Environment;
/**
* Convert Chill Calendar event to Remote MS Graph event, and MS Graph
@ -35,14 +34,14 @@ class RemoteEventConverter
private DateTimeZone $defaultDateTimeZone;
private EngineInterface $engine;
private PersonRenderInterface $personRender;
private DateTimeZone $remoteDateTimeZone;
private TranslatorInterface $translator;
private EngineInterface $engine;
public function __construct(EngineInterface $engine, PersonRenderInterface $personRender, TranslatorInterface $translator)
{
$this->engine = $engine;
@ -112,7 +111,7 @@ class RemoteEventConverter
'@ChillCalendar/MSGraph/calendar_event_body.html.twig',
['calendar' => $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
{
$startDate =
@ -193,4 +181,15 @@ class RemoteEventConverter
{
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\Contracts\HttpClient\Exception\ClientExceptionInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
use function count;
class MSGraphRemoteCalendarConnector implements RemoteCalendarConnectorInterface
{
@ -41,10 +42,10 @@ class MSGraphRemoteCalendarConnector implements RemoteCalendarConnectorInterface
private RemoteEventConverter $remoteEventConverter;
private TranslatorInterface $translator;
private OnBehalfOfUserTokenStorage $tokenStorage;
private TranslatorInterface $translator;
private UrlGeneratorInterface $urlGenerator;
private OnBehalfOfUserHttpClient $userHttpClient;
@ -171,7 +172,7 @@ class MSGraphRemoteCalendarConnector implements RemoteCalendarConnectorInterface
$calendar->getRemoteId(),
$this->translator->trans('remote_ms_graph.cancel_event_because_main_user_is_%label%', ['%label%' => $calendar->getMainUser()]),
$previousMainUser,
'calendar_'.$calendar->getRemoteId()
'calendar_' . $calendar->getRemoteId()
);
$this->createCalendarOnRemote($calendar);
} else {
@ -189,10 +190,9 @@ class MSGraphRemoteCalendarConnector implements RemoteCalendarConnectorInterface
->addRemoteAttributes([
'lastModifiedDateTime' => null,
'changeKey' => null,
'previousId' => $calendar->getCalendarRange()->getRemoteId()
'previousId' => $calendar->getCalendarRange()->getRemoteId(),
])
->setRemoteId('')
;
->setRemoteId('');
}
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 = [];
$eventDatas[] = $this->remoteEventConverter->calendarToEvent($calendar);
$userId = $this->mapCalendarToUser->getUserId($user);
if (0 < count($newInvites)) {
$eventDatas[] = $this->remoteEventConverter->calendarToEventAttendeesOnly($calendar);
if (null === $userId) {
return;
}
foreach ($eventDatas as $eventData) {
[
'id' => $id,
'lastModifiedDateTime' => $lastModified,
'changeKey' => $changeKey
] = $this->patchOnRemote(
$calendar->getRemoteId(),
$eventData,
$calendar->getMainUser(),
'calendar_'.$calendar->getId()
try {
$this->machineHttpClient->request(
'POST',
"users/{$userId}/calendar/events/{$remoteId}/cancel",
[
'json' => ['Comment' => $comment],
]
);
$calendar->addRemoteAttributes([
'lastModifiedDateTime' => $lastModified,
'changeKey' => $changeKey,
} catch (ClientExceptionInterface $e) {
$this->logger->warning('could not update calendar range to remote', [
'exception' => $e->getTraceAsString(),
'content' => $e->getResponse()->getContent(),
'calendarRangeId' => $identifier,
]);
throw $e;
}
}
@ -330,7 +329,7 @@ class MSGraphRemoteCalendarConnector implements RemoteCalendarConnectorInterface
$calendarRange->setRemoteId($id)
->addRemoteAttributes([
'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) {
return;
if (0 < count($newInvites)) {
$eventDatas[] = $this->remoteEventConverter->calendarToEventAttendeesOnly($calendar);
}
try {
$this->machineHttpClient->request(
'POST',
"users/{$userId}/calendar/events/{$remoteId}/cancel",
[
'json' => ['Comment' => $comment]
]
foreach ($eventDatas as $eventData) {
[
'id' => $id,
'lastModifiedDateTime' => $lastModified,
'changeKey' => $changeKey
] = $this->patchOnRemote(
$calendar->getRemoteId(),
$eventData,
$calendar->getMainUser(),
'calendar_' . $calendar->getId()
);
} catch (ClientExceptionInterface $e) {
$this->logger->warning('could not update calendar range to remote', [
'exception' => $e->getTraceAsString(),
'content' => $e->getResponse()->getContent(),
'calendarRangeId' => $identifier,
$calendar->addRemoteAttributes([
'lastModifiedDateTime' => $lastModified,
'changeKey' => $changeKey,
]);
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"
info:
version: "1.0.0"
title: "Chill api"
description: "Api documentation for chill. Currently, work in progress"
servers:
- url: "/api"
description: "Your current dev server"
#openapi: "3.0.0"
#info:
# version: "1.0.0"
# title: "Chill api"
# description: "Api documentation for chill. Currently, work in progress"
#servers:
# - url: "/api"
# description: "Your current dev server"
components:
schemas:
Date:
type: object
properties:
datetime:
type: string
format: date-time
User:
type: object
properties:
id:
type: integer
type:
type: string
enum:
- user
username:
type: string
text:
type: string
schemas:
Date:
type: object
properties:
datetime:
type: string
format: date-time
User:
type: object
properties:
id:
type: integer
type:
type: string
enum:
- user
username:
type: string
text:
type: string
paths:
/1.0/calendar/calendar.json:
get:
tags:
- calendar
summary: Return a list of all calendar items
responses:
200:
description: "ok"
/1.0/calendar/calendar/{id}/answer/{answer}.json:
post:
tags:
- calendar
summary: Answer to a calendar's invite
parameters:
-
in: path
name: id
required: true
description: the calendar id
schema:
type: integer
format: integer
minimum: 0
-
in: path
name: answer
required: true
description: the answer
schema:
type: string
enum:
- accepted
- declined
- tentative
responses:
400:
description: bad answer
403:
description: not invited
404:
description: not found
202:
description: accepted
/1.0/calendar/calendar/{id}.json:
get:
tags:
- calendar
summary: Return an calendar item by id
parameters:
- name: id
in: path
required: true
description: The calendar id
schema:
type: integer
format: integer
minimum: 1
responses:
200:
description: "ok"
404:
description: "not found"
401:
description: "Unauthorized"
/1.0/calendar/calendar.json:
get:
tags:
- calendar
summary: Return a list of all calendar items
responses:
200:
description: "ok"
/1.0/calendar/calendar-range.json:
get:
tags:
- calendar
summary: Return a list of all calendar range items
responses:
200:
description: "ok"
post:
tags:
- calendar
summary: create a new calendar range
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
user:
$ref: '#/components/schemas/User'
startDate:
$ref: '#/components/schemas/Date'
endDate:
$ref: '#/components/schemas/Date'
responses:
401:
description: "Unauthorized"
404:
description: "Not found"
200:
description: "OK"
422:
description: "Unprocessable entity (validation errors)"
400:
description: "transition cannot be applyed"
/1.0/calendar/calendar/{id}.json:
get:
tags:
- calendar
summary: Return an calendar item by id
parameters:
- name: id
in: path
required: true
description: The calendar id
schema:
type: integer
format: integer
minimum: 1
responses:
200:
description: "ok"
404:
description: "not found"
401:
description: "Unauthorized"
/1.0/calendar/calendar-range/{id}.json:
get:
tags:
- calendar
summary: Return an calendar-range item by id
parameters:
- name: id
in: path
required: true
description: The calendar-range id
schema:
type: integer
format: integer
minimum: 1
responses:
200:
description: "ok"
404:
description: "not found"
401:
description: "Unauthorized"
patch:
tags:
- calendar
summary: update a calendar range
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
user:
$ref: '#/components/schemas/User'
startDate:
$ref: '#/components/schemas/Date'
endDate:
$ref: '#/components/schemas/Date'
responses:
401:
description: "Unauthorized"
404:
description: "Not found"
200:
description: "OK"
422:
description: "Unprocessable entity (validation errors)"
400:
description: "transition cannot be applyed"
delete:
tags:
- calendar
summary: "Remove a calendar range"
parameters:
- name: id
in: path
required: true
description: The calendar range id
schema:
type: integer
format: integer
minimum: 1
responses:
401:
description: "Unauthorized"
404:
description: "Not found"
200:
description: "OK"
422:
description: "object with validation errors"
/1.0/calendar/calendar-range.json:
get:
tags:
- calendar
summary: Return a list of all calendar range items
responses:
200:
description: "ok"
post:
tags:
- calendar
summary: create a new calendar range
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
user:
$ref: '#/components/schemas/User'
startDate:
$ref: '#/components/schemas/Date'
endDate:
$ref: '#/components/schemas/Date'
responses:
401:
description: "Unauthorized"
404:
description: "Not found"
200:
description: "OK"
422:
description: "Unprocessable entity (validation errors)"
400:
description: "transition cannot be applyed"
/1.0/calendar/calendar-range-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"
/1.0/calendar/calendar-range/{id}.json:
get:
tags:
- calendar
summary: Return an calendar-range item by id
parameters:
- name: id
in: path
required: true
description: The calendar-range id
schema:
type: integer
format: integer
minimum: 1
responses:
200:
description: "ok"
404:
description: "not found"
401:
description: "Unauthorized"
patch:
tags:
- calendar
summary: update a calendar range
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
user:
$ref: '#/components/schemas/User'
startDate:
$ref: '#/components/schemas/Date'
endDate:
$ref: '#/components/schemas/Date'
responses:
401:
description: "Unauthorized"
404:
description: "Not found"
200:
description: "OK"
422:
description: "Unprocessable entity (validation errors)"
400:
description: "transition cannot be applyed"
delete:
tags:
- calendar
summary: "Remove a calendar range"
parameters:
- name: id
in: path
required: true
description: The calendar range id
schema:
type: integer
format: integer
minimum: 1
responses:
401:
description: "Unauthorized"
404:
description: "Not found"
200:
description: "OK"
422:
description: "object with validation errors"
/1.0/calendar/calendar-range-available/{userId}.json:
get:
tags:
- calendar
summary: Return a list of available calendar range items. Available means calendar-range not being taken by a calendar entity
parameters:
- name: userId
in: path
required: true
description: The user id
schema:
type: integer
format: integer
minimum: 1
- name: dateFrom
in: query
required: true
description: The date from, formatted as ISO8601 string
schema:
type: string
format: date-time
- name: dateTo
in: query
required: true
description: The date to, formatted as ISO8601 string
schema:
type: string
format: date-time
responses:
200:
description: "ok"