Merge branch 'bootstrap-api' into 'master'

Bootstrap api

See merge request Chill-Projet/chill-bundles!35
This commit is contained in:
Julien Fastré 2021-05-07 09:14:11 +00:00
commit 1cce39bcb5
30 changed files with 1645 additions and 133 deletions

View File

@ -0,0 +1,412 @@
.. Copyright (C) 2014 Champs Libres Cooperative SCRLFS
Permission is granted to copy, distribute and/or modify this document
under the terms of the GNU Free Documentation License, Version 1.3
or any later version published by the Free Software Foundation;
with no Invariant Sections, no Front-Cover Texts, and no Back-Cover Texts.
A copy of the license is included in the section entitled "GNU
Free Documentation License".
.. _api
API
###
Chill provides a basic framework to build REST api.
Configure a route
=================
Follow those steps to build a REST api:
1. Create your model;
2. Configure the API;
You can also:
* hook into the controller to customize some steps;
* add more route and steps
.. read-also::
* `How to use annotation to configure serialization <https://symfony.com/doc/current/serializer.html>`_
* `How to create your custom normalizer <https://symfony.com/doc/current/serializer/custom_normalizer.html>`_
Auto-loading the routes
***********************
Ensure that those lines are present in your file `app/config/routing.yml`:
.. code-block:: yaml
chill_cruds:
resource: 'chill_main_crud_route_loader:load'
type: service
Create your model
*****************
Create your model on the usual way:
.. code-block:: php
namespace Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\PersonBundle\Entity\AccompanyingPeriod\OriginRepository;
use Doctrine\ORM\Mapping as ORM;
/**
* @ORM\Entity(repositoryClass=OriginRepository::class)
* @ORM\Table(name="chill_person_accompanying_period_origin")
*/
class Origin
{
/**
* @ORM\Id
* @ORM\GeneratedValue
* @ORM\Column(type="integer")
*/
private $id;
/**
* @ORM\Column(type="json")
*/
private $label;
/**
* @ORM\Column(type="date_immutable", nullable=true)
*/
private $noActiveAfter;
// .. getters and setters
}
Configure api
*************
Configure the api using Yaml (see the full configuration below):
.. code-block:: yaml
# config/packages/chill_main.yaml
chill_main:
apis:
accompanying_period_origin:
base_path: '/api/1.0/person/accompanying-period/origin'
class: 'Chill\PersonBundle\Entity\AccompanyingPeriod\Origin'
name: accompanying_period_origin
base_role: 'ROLE_USER'
actions:
_index:
methods:
GET: true
HEAD: true
_entity:
methods:
GET: true
HEAD: true
.. note::
If you are working on a shared bundle (aka "The chill bundles"), you should define your configuration inside the class :code:`ChillXXXXBundleExtension`, using the "prependConfig" feature:
.. code-block:: php
namespace Chill\PersonBundle\DependencyInjection;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface;
use Symfony\Component\HttpFoundation\Request;
/**
* Class ChillPersonExtension
* Loads and manages your bundle configuration
*
* To learn more see {@link http://symfony.com/doc/current/cookbook/bundles/extension.html}
* @package Chill\PersonBundle\DependencyInjection
*/
class ChillPersonExtension extends Extension implements PrependExtensionInterface
{
public function prepend(ContainerBuilder $container)
{
$this->prependCruds($container);
}
/**
* @param ContainerBuilder $container
*/
protected function prependCruds(ContainerBuilder $container)
{
$container->prependExtensionConfig('chill_main', [
'apis' => [
[
'class' => \Chill\PersonBundle\Entity\AccompanyingPeriod\Origin::class,
'name' => 'accompanying_period_origin',
'base_path' => '/api/1.0/person/accompanying-period/origin',
'controller' => \Chill\PersonBundle\Controller\OpeningApiController::class,
'base_role' => 'ROLE_USER',
'actions' => [
'_index' => [
'methods' => [
Request::METHOD_GET => true,
Request::METHOD_HEAD => true
],
],
'_entity' => [
'methods' => [
Request::METHOD_GET => true,
Request::METHOD_HEAD => true
]
],
]
]
]
]);
}
}
The :code:`_index` and :code:`_entity` action
=============================================
The :code:`_index` and :code:`_entity` action are default actions:
* they will call a specific method in the default controller;
* they will generate defined routes:
Index:
Name: :code:`chill_api_single_accompanying_period_origin__index`
Path: :code:`/api/1.0/person/accompanying-period/origin.{_format}`
Entity:
Name: :code:`chill_api_single_accompanying_period_origin__entity`
Path: :code:`/api/1.0/person/accompanying-period/origin/{id}.{_format}`
Role
====
By default, the key `base_role` is used to check ACL. Take care of creating the :code:`Voter` required to take that into account.
For index action, the role will be called with :code:`NULL` as :code:`$subject`. The retrieved entity will be the subject for single queries.
You can also define a role for each method. In this case, this role is used for the given method, and, if any, the base role is taken into account.
.. code-block:: yaml
# config/packages/chill_main.yaml
chill_main:
apis:
accompanying_period_origin:
base_path: '/api/1.0/person/bla/bla'
class: 'Chill\PersonBundle\Entity\Blah'
name: bla
actions:
_entity:
methods:
GET: true
HEAD: true
roles:
GET: MY_ROLE_SEE
HEAD: MY ROLE_SEE
Customize the controller
========================
You can customize the controller by hooking into the default actions. Take care of extending :code:`Chill\MainBundle\CRUD\Controller\ApiController`.
.. code-block:: php
namespace Chill\PersonBundle\Controller;
use Chill\MainBundle\CRUD\Controller\ApiController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
class OpeningApiController extends ApiController
{
protected function customizeQuery(string $action, Request $request, $qb): void
{
$qb->where($qb->expr()->gt('e.noActiveAfter', ':now'))
->orWhere($qb->expr()->isNull('e.noActiveAfter'));
$qb->setParameter('now', new \DateTime('now'));
}
}
And set your controller in configuration:
.. code-block:: yaml
chill_main:
apis:
accompanying_period_origin:
base_path: '/api/1.0/person/accompanying-period/origin'
class: 'Chill\PersonBundle\Entity\AccompanyingPeriod\Origin'
name: accompanying_period_origin
# add a controller
controller: 'Chill\PersonBundle\Controller\OpeningApiController'
base_role: 'ROLE_USER'
actions:
_index:
methods:
GET: true
HEAD: true
_entity:
methods:
GET: true
HEAD: true
Create your own actions
=======================
You can add your own actions:
.. code-block:: yaml
chill_main:
apis:
-
class: Chill\PersonBundle\Entity\AccompanyingPeriod
name: accompanying_course
base_path: /api/1.0/person/accompanying-course
controller: Chill\PersonBundle\Controller\AccompanyingCourseApiController
actions:
# add a custom participation:
participation:
methods:
POST: true
DELETE: true
GET: false
HEAD: false
PUT: false
roles:
POST: CHILL_PERSON_ACCOMPANYING_PERIOD_SEE
DELETE: CHILL_PERSON_ACCOMPANYING_PERIOD_SEE
GET: null
HEAD: null
PUT: null
single-collection: single
The key :code:`single-collection` with value :code:`single` will add a :code:`/{id}/ + "action name"` (in this example, :code:`/{id}/participation`) into the path, after the base path. If the value is :code:`collection`, no id will be set, but the action name will be append to the path.
Then, create the corresponding action into your controller:
.. code-block:: php
namespace Chill\PersonBundle\Controller;
use Chill\MainBundle\CRUD\Controller\ApiController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Symfony\Component\HttpFoundation\Exception\BadRequestException;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;
use Chill\PersonBundle\Privacy\AccompanyingPeriodPrivacyEvent;
use Chill\PersonBundle\Entity\Person;
class AccompanyingCourseApiController extends ApiController
{
protected EventDispatcherInterface $eventDispatcher;
protected ValidatorInterface $validator;
public function __construct(EventDispatcherInterface $eventDispatcher, $validator)
{
$this->eventDispatcher = $eventDispatcher;
$this->validator = $validator;
}
public function participationApi($id, Request $request, $_format)
{
/** @var AccompanyingPeriod $accompanyingPeriod */
$accompanyingPeriod = $this->getEntity('participation', $id, $request);
$person = $this->getSerializer()
->deserialize($request->getContent(), Person::class, $_format, []);
if (NULL === $person) {
throw new BadRequestException('person id not found');
}
$this->onPostCheckACL('participation', $request, $accompanyingPeriod, $_format);
switch ($request->getMethod()) {
case Request::METHOD_POST:
$participation = $accompanyingPeriod->addPerson($person);
break;
case Request::METHOD_DELETE:
$participation = $accompanyingPeriod->removePerson($person);
break;
default:
throw new BadRequestException("This method is not supported");
}
$errors = $this->validator->validate($accompanyingPeriod);
if ($errors->count() > 0) {
// only format accepted
return $this->json($errors);
}
$this->getDoctrine()->getManager()->flush();
return $this->json($participation);
}
}
.. api_full_config:
Full configuration example
==========================
.. code-block:: yaml
apis:
-
class: Chill\PersonBundle\Entity\AccompanyingPeriod
name: accompanying_course
base_path: /api/1.0/person/accompanying-course
controller: Chill\PersonBundle\Controller\AccompanyingCourseApiController
actions:
_entity:
roles:
GET: CHILL_PERSON_ACCOMPANYING_PERIOD_SEE
HEAD: null
POST: null
DELETE: null
PUT: null
controller_action: null
path: null
single-collection: single
methods:
GET: true
HEAD: true
POST: false
DELETE: false
PUT: false
participation:
methods:
POST: true
DELETE: true
GET: false
HEAD: false
PUT: false
roles:
POST: CHILL_PERSON_ACCOMPANYING_PERIOD_SEE
DELETE: CHILL_PERSON_ACCOMPANYING_PERIOD_SEE
GET: null
HEAD: null
PUT: null
controller_action: null
# the requirements for the route. Will be set to `[ 'id' => '\d+' ]` if left empty.
requirements: []
path: null
single-collection: single
base_role: null

View File

@ -0,0 +1,77 @@
<?php
/*
* Copyright (C) 2018 Champs Libres Cooperative <info@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/>.
*/
namespace Chill\MainBundle\CRUD\CompilerPass;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\Reference;
use Chill\MainBundle\Routing\MenuComposer;
use Symfony\Component\DependencyInjection\Definition;
/**
*
*
*/
class CRUDControllerCompilerPass implements CompilerPassInterface
{
public function process(ContainerBuilder $container)
{
$crudConfig = $container->getParameter('chill_main_crud_route_loader_config');
$apiConfig = $container->getParameter('chill_main_api_route_loader_config');
foreach ($crudConfig as $crudEntry) {
$this->configureCrudController($container, $crudEntry, 'crud');
}
foreach ($apiConfig as $crudEntry) {
$this->configureCrudController($container, $crudEntry, 'api');
}
}
/**
* Add a controller for each definition, and add a methodCall to inject crud configuration to controller
*/
private function configureCrudController(ContainerBuilder $container, array $crudEntry, string $apiOrCrud): void
{
$controllerClass = $crudEntry['controller'];
$controllerServiceName = 'cs'.$apiOrCrud.'_'.$crudEntry['name'].'_controller';
if ($container->hasDefinition($controllerClass)) {
$controller = $container->getDefinition($controllerClass);
$container->removeDefinition($controllerClass);
$alreadyDefined = true;
} else {
$controller = new Definition($controllerClass);
$alreadyDefined = false;
}
$controller->addTag('controller.service_arguments');
if (FALSE === $alreadyDefined) {
$controller->setAutoconfigured(true);
$controller->setPublic(true);
}
$param = 'chill_main_'.$apiOrCrud.'_config_'.$crudEntry['name'];
$container->setParameter($param, $crudEntry);
$controller->addMethodCall('setCrudConfig', ['%'.$param.'%']);
$container->setDefinition($controllerServiceName, $controller);
}
}

View File

@ -0,0 +1,225 @@
<?php
namespace Chill\MainBundle\CRUD\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Chill\MainBundle\Pagination\PaginatorFactory;
use Chill\MainBundle\Pagination\PaginatorInterface;
class AbstractCRUDController extends AbstractController
{
/**
* The crud configuration
*
* This configuration si defined by `chill_main['crud']` or `chill_main['apis']`
*
* @var array
*/
protected array $crudConfig = [];
/**
* get the instance of the entity with the given id
*
* @param string $id
* @return object
*/
protected function getEntity($action, $id, Request $request): ?object
{
return $this->getDoctrine()
->getRepository($this->getEntityClass())
->find($id);
}
/**
* Count the number of entities
*
* By default, count all entities. You can customize the query by
* using the method `customizeQuery`.
*
* @param string $action
* @param Request $request
* @return int
*/
protected function countEntities(string $action, Request $request, $_format): int
{
return $this->buildQueryEntities($action, $request)
->select('COUNT(e)')
->getQuery()
->getSingleScalarResult()
;
}
/**
* Query the entity.
*
* By default, get all entities. You can customize the query by using the
* method `customizeQuery`.
*
* The method `orderEntity` is called internally to order entities.
*
* It returns, by default, a query builder.
*
*/
protected function queryEntities(string $action, Request $request, string $_format, PaginatorInterface $paginator)
{
$query = $this->buildQueryEntities($action, $request)
->setFirstResult($paginator->getCurrentPage()->getFirstItemNumber())
->setMaxResults($paginator->getItemsPerPage());
// allow to order queries and return the new query
return $this->orderQuery($action, $query, $request, $paginator, $_format);
}
/**
* Add ordering fields in the query build by self::queryEntities
*
*/
protected function orderQuery(string $action, $query, Request $request, PaginatorInterface $paginator, $_format)
{
return $query;
}
/**
* Build the base query for listing all entities.
*
* This method is used internally by `countEntities` `queryEntities`
*
* This base query does not contains any `WHERE` or `SELECT` clauses. You
* can add some by using the method `customizeQuery`.
*
* The alias for the entity is "e".
*
* @param string $action
* @param Request $request
* @return QueryBuilder
*/
protected function buildQueryEntities(string $action, Request $request)
{
$qb = $this->getDoctrine()->getManager()
->createQueryBuilder()
->select('e')
->from($this->getEntityClass(), 'e')
;
$this->customizeQuery($action, $request, $qb);
return $qb;
}
protected function customizeQuery(string $action, Request $request, $query): void {}
/**
* Get the result of the query
*/
protected function getQueryResult(string $action, Request $request, string $_format, int $totalItems, PaginatorInterface $paginator, $query)
{
return $query->getQuery()->getResult();
}
protected function onPreIndex(string $action, Request $request, string $_format): ?Response
{
return null;
}
/**
* method used by indexAction
*/
protected function onPreIndexBuildQuery(string $action, Request $request, string $_format, int $totalItems, PaginatorInterface $paginator): ?Response
{
return null;
}
/**
* method used by indexAction
*/
protected function onPostIndexBuildQuery(string $action, Request $request, string $_format, int $totalItems, PaginatorInterface $paginator, $query): ?Response
{
return null;
}
/**
* method used by indexAction
*/
protected function onPostIndexFetchQuery(string $action, Request $request, string $_format, int $totalItems, PaginatorInterface $paginator, $entities): ?Response
{
return null;
}
/**
* Get the complete FQDN of the class
*
* @return string the complete fqdn of the class
*/
protected function getEntityClass(): string
{
return $this->crudConfig['class'];
}
/**
* called on post fetch entity
*/
protected function onPostFetchEntity(string $action, Request $request, $entity, $_format): ?Response
{
return null;
}
/**
* Called on post check ACL
*/
protected function onPostCheckACL(string $action, Request $request, string $_format, $entity): ?Response
{
return null;
}
/**
* check the acl. Called by every action.
*
* By default, check the role given by `getRoleFor` for the value given in
* $entity.
*
* Throw an \Symfony\Component\Security\Core\Exception\AccessDeniedHttpException
* if not accessible.
*
* @throws \Symfony\Component\Security\Core\Exception\AccessDeniedHttpException
*/
protected function checkACL(string $action, Request $request, string $_format, $entity = null)
{
$this->denyAccessUnlessGranted($this->getRoleFor($action, $request, $entity, $_format), $entity);
}
/**
*
* @return string the crud name
*/
protected function getCrudName(): string
{
return $this->crudConfig['name'];
}
protected function getActionConfig(string $action)
{
return $this->crudConfig['actions'][$action];
}
/**
* Set the crud configuration
*
* Used by the container to inject configuration for this crud.
*/
public function setCrudConfig(array $config): void
{
$this->crudConfig = $config;
}
/**
* @return PaginatorFactory
*/
protected function getPaginatorFactory(): PaginatorFactory
{
return $this->container->get('chill_main.paginator_factory');
}
}

View File

@ -0,0 +1,220 @@
<?php
namespace Chill\MainBundle\CRUD\Controller;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Serializer\SerializerInterface;
use Chill\MainBundle\Serializer\Model\Collection;
use Chill\MainBundle\Pagination\PaginatorInterface;
class ApiController extends AbstractCRUDController
{
/**
* The view action.
*
* Some steps may be overriden during this process of rendering:
*
* This method:
*
* 1. fetch the entity, using `getEntity`
* 2. launch `onPostFetchEntity`. If postfetch is an instance of Response,
* this response is returned.
* 2. throw an HttpNotFoundException if entity is null
* 3. check ACL using `checkACL` ;
* 4. launch `onPostCheckACL`. If the result is an instance of Response,
* this response is returned ;
* 5. Serialize the entity and return the result. The serialization context is given by `getSerializationContext`
*
*/
protected function entityGet(string $action, Request $request, $id, $_format = 'html'): Response
{
$entity = $this->getEntity($action, $id, $request, $_format);
$postFetch = $this->onPostFetchEntity($action, $request, $entity, $_format);
if ($postFetch instanceof Response) {
return $postFetch;
}
if (NULL === $entity) {
throw $this->createNotFoundException(sprintf("The %s with id %s "
. "is not found", $this->getCrudName(), $id));
}
$response = $this->checkACL($action, $request, $_format, $entity);
if ($response instanceof Response) {
return $response;
}
$response = $this->onPostCheckACL($action, $request, $_format, $entity);
if ($response instanceof Response) {
return $response;
}
$response = $this->onBeforeSerialize($action, $request, $_format, $entity);
if ($response instanceof Response) {
return $response;
}
if ($_format === 'json') {
$context = $this->getContextForSerialization($action, $request, $_format, $entity);
return $this->json($entity, Response::HTTP_OK, [], $context);
} else {
throw new \Symfony\Component\HttpFoundation\Exception\BadRequestException("This format is not implemented");
}
}
public function onBeforeSerialize(string $action, Request $request, $_format, $entity): ?Response
{
return null;
}
/**
* Base method for handling api action
*
* @return void
*/
public function entityApi(Request $request, $id, $_format): Response
{
switch ($request->getMethod()) {
case Request::METHOD_GET:
case REQUEST::METHOD_HEAD:
return $this->entityGet('_entity', $request, $id, $_format);
default:
throw new \Symfony\Component\HttpFoundation\Exception\BadRequestException("This method is not implemented");
}
}
/**
* Base action for indexing entities
*/
public function indexApi(Request $request, string $_format)
{
switch ($request->getMethod()) {
case Request::METHOD_GET:
case REQUEST::METHOD_HEAD:
return $this->indexApiAction('_index', $request, $_format);
default:
throw $this->createNotFoundException("This method is not supported");
}
}
/**
* Build an index page.
*
* Some steps may be overriden during this process of rendering.
*
* This method:
*
* 1. Launch `onPreIndex`
* x. check acl. If it does return a response instance, return it
* x. launch `onPostCheckACL`. If it does return a response instance, return it
* 1. count the items, using `countEntities`
* 2. build a paginator element from the the number of entities ;
* 3. Launch `onPreIndexQuery`. If it does return a response instance, return it
* 3. build a query, using `queryEntities`
* x. fetch the results, using `getQueryResult`
* x. Launch `onPostIndexFetchQuery`. If it does return a response instance, return it
* 4. Serialize the entities in a Collection, using `SerializeCollection`
*
* @param string $action
* @param Request $request
* @return type
*/
protected function indexApiAction($action, Request $request, $_format)
{
$this->onPreIndex($action, $request, $_format);
$response = $this->checkACL($action, $request, $_format);
if ($response instanceof Response) {
return $response;
}
if (!isset($entity)) {
$entity = '';
}
$response = $this->onPostCheckACL($action, $request, $_format, $entity);
if ($response instanceof Response) {
return $response;
}
$totalItems = $this->countEntities($action, $request, $_format);
$paginator = $this->getPaginatorFactory()->create($totalItems);
$response = $this->onPreIndexBuildQuery($action, $request, $_format, $totalItems,
$paginator);
if ($response instanceof Response) {
return $response;
}
$query = $this->queryEntities($action, $request, $_format, $paginator);
$response = $this->onPostIndexBuildQuery($action, $request, $_format, $totalItems,
$paginator, $query);
if ($response instanceof Response) {
return $response;
}
$entities = $this->getQueryResult($action, $request, $_format, $totalItems, $paginator, $query);
$response = $this->onPostIndexFetchQuery($action, $request, $_format, $totalItems,
$paginator, $entities);
if ($response instanceof Response) {
return $response;
}
return $this->serializeCollection($action, $request, $_format, $paginator, $entities);
}
/**
* Serialize collections
*
*/
protected function serializeCollection(string $action, Request $request, string $_format, PaginatorInterface $paginator, $entities): Response
{
$model = new Collection($entities, $paginator);
$context = $this->getContextForSerialization($action, $request, $_format, $entities);
return $this->json($model, Response::HTTP_OK, [], $context);
}
protected function getContextForSerialization(string $action, Request $request, string $_format, $entity): array
{
return [];
}
/**
* get the role given from the config.
*/
protected function getRoleFor(string $action, Request $request, $entity, $_format): string
{
$actionConfig = $this->getActionConfig($action);
if (NULL !== $actionConfig['roles'][$request->getMethod()]) {
return $actionConfig['roles'][$request->getMethod()];
}
if ($this->crudConfig['base_role']) {
return $this->crudConfig['base_role'];
}
throw new \RuntimeException(sprintf("the config does not have any role for the ".
"method %s nor a global role for the whole action. Add those to your ".
"configuration or override the required method", $request->getMethod()));
}
protected function getSerializer(): SerializerInterface
{
return $this->get('serializer');
}
}

View File

@ -34,6 +34,7 @@ use Chill\MainBundle\CRUD\Form\CRUDDeleteEntityForm;
use Chill\MainBundle\Pagination\PaginatorFactory;
use Chill\MainBundle\Pagination\PaginatorInterface;
use Chill\MainBundle\Security\Authorization\AuthorizationHelper;
use Symfony\Component\Serializer\SerializerInterface;
/**
* Class CRUDController
@ -484,7 +485,7 @@ class CRUDController extends AbstractController
* @param mixed $id
* @return Response
*/
protected function viewAction(string $action, Request $request, $id)
protected function viewAction(string $action, Request $request, $id, $_format = 'html'): Response
{
$entity = $this->getEntity($action, $id, $request);
@ -496,7 +497,7 @@ class CRUDController extends AbstractController
if (NULL === $entity) {
throw $this->createNotFoundException(sprintf("The %s with id %s "
. "is not found"), $this->getCrudName(), $id);
. "is not found", $this->getCrudName(), $id));
}
$response = $this->checkACL($action, $entity);
@ -508,17 +509,36 @@ class CRUDController extends AbstractController
if ($response instanceof Response) {
return $response;
}
$defaultTemplateParameters = [
'entity' => $entity,
'crud_name' => $this->getCrudName()
];
return $this->render(
$this->getTemplateFor($action, $entity, $request),
$this->generateTemplateParameter($action, $entity, $request, $defaultTemplateParameters)
if ($_format === 'html') {
$defaultTemplateParameters = [
'entity' => $entity,
'crud_name' => $this->getCrudName()
];
return $this->render(
$this->getTemplateFor($action, $entity, $request),
$this->generateTemplateParameter($action, $entity, $request, $defaultTemplateParameters)
);
} elseif ($_format === 'json') {
$context = $this->getContextForSerialization($action, $request, $entity, $_format);
return $this->json($entity, Response::HTTP_OK, [], $context);
} else {
throw new \Symfony\Component\HttpFoundation\Exception\BadRequestException("This format is not implemented");
}
}
/**
* Get the context for the serialization
*/
public function getContextForSerialization(string $action, Request $request, $entity, string $_format): array
{
return [];
}
/**
* The edit action.
@ -799,7 +819,7 @@ class CRUDController extends AbstractController
*/
protected function getRoleFor($action)
{
if (NULL !== ($this->getActionConfig($action)['role'])) {
if (\array_key_exists('role', $this->getActionConfig($action))) {
return $this->getActionConfig($action)['role'];
}
@ -1181,6 +1201,7 @@ class CRUDController extends AbstractController
AuthorizationHelper::class => AuthorizationHelper::class,
EventDispatcherInterface::class => EventDispatcherInterface::class,
Resolver::class => Resolver::class,
SerializerInterface::class => SerializerInterface::class,
]
);
}

View File

@ -23,6 +23,9 @@ namespace Chill\MainBundle\CRUD\Routing;
use Symfony\Component\Config\Loader\Loader;
use Symfony\Component\Routing\Route;
use Symfony\Component\Routing\RouteCollection;
use Symfony\Component\HttpFoundation\Request;
use Chill\MainBundle\CRUD\Controller\ApiController;
use Chill\MainBundle\CRUD\Controller\CRUDController;
/**
* Class CRUDRoutesLoader
@ -32,24 +35,34 @@ use Symfony\Component\Routing\RouteCollection;
*/
class CRUDRoutesLoader extends Loader
{
/**
* @var array
*/
protected $config = [];
protected array $crudConfig = [];
protected array $apiCrudConfig = [];
/**
* @var bool
*/
private $isLoaded = false;
private const ALL_SINGLE_METHODS = [
Request::METHOD_GET,
Request::METHOD_POST,
Request::METHOD_PUT,
Request::METHOD_DELETE
];
private const ALL_INDEX_METHODS = [ Request::METHOD_GET, Request::METHOD_HEAD ];
/**
* CRUDRoutesLoader constructor.
*
* @param $config
* @param $crudConfig the config from cruds
* @param $apicrudConfig the config from api_crud
*/
public function __construct($config)
public function __construct(array $crudConfig, array $apiConfig)
{
$this->config = $config;
$this->crudConfig = $crudConfig;
$this->apiConfig = $apiConfig;
}
/**
@ -63,53 +76,161 @@ class CRUDRoutesLoader extends Loader
}
/**
* @return RouteCollection
* Load routes for CRUD and CRUD Api
*/
public function load($resource, $type = null)
public function load($resource, $type = null): RouteCollection
{
if (true === $this->isLoaded) {
throw new \RuntimeException('Do not add the "CRUD" loader twice');
}
$collection = new RouteCollection();
foreach ($this->config as $config) {
$collection->addCollection($this->loadConfig($config));
foreach ($this->crudConfig as $crudConfig) {
$collection->addCollection($this->loadCrudConfig($crudConfig));
}
foreach ($this->apiConfig as $crudConfig) {
$collection->addCollection($this->loadApi($crudConfig));
}
return $collection;
}
/**
* @param $config
* Load routes for CRUD (without api)
*
* @param $crudConfig
* @return RouteCollection
*/
protected function loadConfig($config): RouteCollection
protected function loadCrudConfig($crudConfig): RouteCollection
{
$collection = new RouteCollection();
foreach ($config['actions'] as $name => $action) {
$controller ='cscrud_'.$crudConfig['name'].'_controller';
foreach ($crudConfig['actions'] as $name => $action) {
// defaults (controller name)
$defaults = [
'_controller' => 'cscrud_'.$config['name'].'_controller'.':'.($action['controller_action'] ?? $name)
'_controller' => $controller.':'.($action['controller_action'] ?? $name)
];
if ($name === 'index') {
$path = "{_locale}".$config['base_path'];
$path = "{_locale}".$crudConfig['base_path'];
$route = new Route($path, $defaults);
} elseif ($name === 'new') {
$path = "{_locale}".$config['base_path'].'/'.$name;
$path = "{_locale}".$crudConfig['base_path'].'/'.$name;
$route = new Route($path, $defaults);
} else {
$path = "{_locale}".$config['base_path'].($action['path'] ?? '/{id}/'.$name);
$path = "{_locale}".$crudConfig['base_path'].($action['path'] ?? '/{id}/'.$name);
$requirements = $action['requirements'] ?? [
'{id}' => '\d+'
];
$route = new Route($path, $defaults, $requirements);
}
$collection->add('chill_crud_'.$config['name'].'_'.$name, $route);
$collection->add('chill_crud_'.$crudConfig['name'].'_'.$name, $route);
}
return $collection;
}
/**
* Load routes for api single
*
* @param $crudConfig
* @return RouteCollection
*/
protected function loadApi(array $crudConfig): RouteCollection
{
$collection = new RouteCollection();
$controller ='csapi_'.$crudConfig['name'].'_controller';
foreach ($crudConfig['actions'] as $name => $action) {
// filter only on single actions
$singleCollection = $action['single-collection'] ?? $name === '_entity' ? 'single' : NULL;
if ('collection' === $singleCollection) {
// continue;
}
// compute default action
switch ($name) {
case '_entity':
$controllerAction = 'entityApi';
break;
case '_index':
$controllerAction = 'indexApi';
break;
default:
$controllerAction = $name.'Api';
break;
}
$defaults = [
'_controller' => $controller.':'.($action['controller_action'] ?? $controllerAction)
];
// path are rewritten
// if name === 'default', we rewrite it to nothing :-)
$localName = \in_array($name, [ '_entity', '_index' ]) ? '' : '/'.$name;
if ('collection' === $action['single-collection'] || '_index' === $name) {
$localPath = $action['path'] ?? $localName.'.{_format}';
} else {
$localPath = $action['path'] ?? '/{id}'.$localName.'.{_format}';
}
$path = $crudConfig['base_path'].$localPath;
$requirements = $action['requirements'] ?? [ '{id}' => '\d+' ];
$methods = \array_keys(\array_filter($action['methods'], function($value, $key) { return $value; },
ARRAY_FILTER_USE_BOTH));
$route = new Route($path, $defaults, $requirements);
$route->setMethods($methods);
$collection->add('chill_api_single_'.$crudConfig['name'].'_'.$name, $route);
}
return $collection;
}
/**
* Load routes for api multi
*
* @param $crudConfig
* @return RouteCollection
*/
protected function loadApiMultiConfig(array $crudConfig): RouteCollection
{
$collection = new RouteCollection();
$controller ='csapi_'.$crudConfig['name'].'_controller';
foreach ($crudConfig['actions'] as $name => $action) {
// filter only on single actions
$singleCollection = $action['single-collection'] ?? $name === '_index' ? 'collection' : NULL;
if ('single' === $singleCollection) {
continue;
}
$defaults = [
'_controller' => $controller.':'.($action['controller_action'] ?? '_entity' === $name ? 'entityApi' : $name.'Api')
];
// path are rewritten
// if name === 'default', we rewrite it to nothing :-)
$localName = '_entity' === $name ? '' : '/'.$name;
$localPath = $action['path'] ?? '/{id}'.$localName.'.{_format}';
$path = $crudConfig['base_path'].$localPath;
$requirements = $action['requirements'] ?? [ '{id}' => '\d+' ];
$methods = \array_keys(\array_filter($action['methods'], function($value, $key) { return $value; },
ARRAY_FILTER_USE_BOTH));
$route = new Route($path, $defaults, $requirements);
$route->setMethods($methods);
$collection->add('chill_api_single_'.$crudConfig['name'].'_'.$name, $route);
}
return $collection;
}
}

View File

@ -14,6 +14,7 @@ use Chill\MainBundle\DependencyInjection\CompilerPass\NotificationCounterCompile
use Chill\MainBundle\DependencyInjection\CompilerPass\MenuCompilerPass;
use Chill\MainBundle\DependencyInjection\CompilerPass\ACLFlagsCompilerPass;
use Chill\MainBundle\DependencyInjection\CompilerPass\GroupingCenterCompilerPass;
use Chill\MainBundle\CRUD\CompilerPass\CRUDControllerCompilerPass;
use Chill\MainBundle\Templating\Entity\CompilerPass as RenderEntityCompilerPass;
@ -33,5 +34,6 @@ class ChillMainBundle extends Bundle
$container->addCompilerPass(new ACLFlagsCompilerPass());
$container->addCompilerPass(new GroupingCenterCompilerPass());
$container->addCompilerPass(new RenderEntityCompilerPass());
$container->addCompilerPass(new CRUDControllerCompilerPass());
}
}

View File

@ -133,7 +133,7 @@ class ChillMainExtension extends Extension implements PrependExtensionInterface,
$loader->load('services/search.yaml');
$loader->load('services/serializer.yaml');
$this->configureCruds($container, $config['cruds'], $loader);
$this->configureCruds($container, $config['cruds'], $config['apis'], $loader);
}
/**
@ -210,51 +210,24 @@ class ChillMainExtension extends Extension implements PrependExtensionInterface,
}
/**
* @param ContainerBuilder $container
* @param array $config the config under 'cruds' key
* @return null
* Load parameter for configuration and set parameters for api
*/
protected function configureCruds(ContainerBuilder $container, $config, Loader\YamlFileLoader $loader)
protected function configureCruds(
ContainerBuilder $container,
array $crudConfig,
array $apiConfig,
Loader\YamlFileLoader $loader
): void
{
if (count($config) === 0) {
if (count($crudConfig) === 0) {
return;
}
$loader->load('services/crud.yaml');
$container->setParameter('chill_main_crud_route_loader_config', $config);
$container->setParameter('chill_main_crud_route_loader_config', $crudConfig);
$container->setParameter('chill_main_api_route_loader_config', $apiConfig);
$definition = new Definition();
$definition
->setClass(\Chill\MainBundle\CRUD\Routing\CRUDRoutesLoader::class)
->addArgument('%chill_main_crud_route_loader_config%')
;
$container->setDefinition('chill_main_crud_route_loader', $definition);
$alreadyExistingNames = [];
foreach ($config as $crudEntry) {
$controller = $crudEntry['controller'];
$controllerServiceName = 'cscrud_'.$crudEntry['name'].'_controller';
$name = $crudEntry['name'];
// check for existing crud names
if (\in_array($name, $alreadyExistingNames)) {
throw new LogicException(sprintf("the name %s is defined twice in CRUD", $name));
}
if (!$container->has($controllerServiceName)) {
$controllerDefinition = new Definition($controller);
$controllerDefinition->addTag('controller.service_arguments');
$controllerDefinition->setAutoconfigured(true);
$controllerDefinition->setClass($crudEntry['controller']);
$container->setDefinition($controllerServiceName, $controllerDefinition);
}
$container->setParameter('chill_main_crud_config_'.$name, $crudEntry);
$container->getDefinition($controllerServiceName)
->addMethodCall('setCrudConfig', ['%chill_main_crud_config_'.$name.'%']);
}
// Note: the controller are loaded inside compiler pass
}
}

View File

@ -8,6 +8,7 @@ use Chill\MainBundle\DependencyInjection\Widget\Factory\WidgetFactoryInterface;
use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition;
use Chill\MainBundle\DependencyInjection\Widget\AddWidgetConfigurationTrait;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\HttpFoundation\Request;
/**
@ -140,7 +141,7 @@ class Configuration implements ConfigurationInterface
->scalarNode('controller_action')
->defaultNull()
->info('the method name to call in the route. Will be set to the action name if left empty.')
->example("'action'")
->example("action")
->end()
->scalarNode('path')
->defaultNull()
@ -168,6 +169,78 @@ class Configuration implements ConfigurationInterface
->end()
->end()
->arrayNode('apis')
->defaultValue([])
->arrayPrototype()
->children()
->scalarNode('class')->cannotBeEmpty()->isRequired()->end()
->scalarNode('controller')
->cannotBeEmpty()
->defaultValue(\Chill\MainBundle\CRUD\Controller\ApiController::class)
->end()
->scalarNode('name')->cannotBeEmpty()->isRequired()->end()
->scalarNode('base_path')->cannotBeEmpty()->isRequired()->end()
->scalarNode('base_role')->defaultNull()->end()
->arrayNode('actions')
->useAttributeAsKey('name')
->arrayPrototype()
->children()
->scalarNode('controller_action')
->defaultNull()
->info('the method name to call in the controller. Will be set to the concatenation '.
'of action name + \'Api\' if left empty.')
->example("showApi")
->end()
->scalarNode('path')
->defaultNull()
->info('the path that will be **appended** after the base path. Do not forget to add ' .
'arguments for the method. By default, will set to the action name, including an `{id}` '.
'parameter. A suffix of action name will be appended, except if the action name '.
'is "_entity".')
->example('/{id}/my-action')
->end()
->arrayNode('requirements')
->ignoreExtraKeys(false)
->info('the requirements for the route. Will be set to `[ \'id\' => \'\d+\' ]` if left empty.')
->end()
->enumNode('single-collection')
->values(['single', 'collection'])
->defaultValue('single')
->info('indicates if the returned object is a single element or a collection. '.
'If the action name is `_index`, this value will always be considered as '.
'`collection`')
->end()
->arrayNode('methods')
->addDefaultsIfNotSet()
->info('the allowed methods')
->children()
->booleanNode(Request::METHOD_GET)->defaultTrue()->end()
->booleanNode(Request::METHOD_HEAD)->defaultTrue()->end()
->booleanNode(Request::METHOD_POST)->defaultFalse()->end()
->booleanNode(Request::METHOD_DELETE)->defaultFalse()->end()
->booleanNode(Request::METHOD_PUT)->defaultFalse()->end()
->end()
->end()
->arrayNode('roles')
->addDefaultsIfNotSet()
->info("The role require for each http method")
->children()
->scalarNode(Request::METHOD_GET)->defaultNull()->end()
->scalarNode(Request::METHOD_HEAD)->defaultNull()->end()
->scalarNode(Request::METHOD_POST)->defaultNull()->end()
->scalarNode(Request::METHOD_DELETE)->defaultNull()->end()
->scalarNode(Request::METHOD_PUT)->defaultNull()->end()
->end()
->end()
->end()
->end()
->end()
->end()
->end()
->end()
->end() // end of root/children
->end() // end of root
;

View File

@ -0,0 +1,28 @@
<?php
namespace Chill\MainBundle\Serializer\Model;
use Chill\MainBundle\Pagination\PaginatorInterface;
class Collection
{
private PaginatorInterface $paginator;
private $items;
public function __construct($items, PaginatorInterface $paginator)
{
$this->items = $items;
$this->paginator = $paginator;
}
public function getPaginator(): PaginatorInterface
{
return $this->paginator;
}
public function getItems()
{
return $this->items;
}
}

View File

@ -0,0 +1,39 @@
<?php
namespace Chill\MainBundle\Serializer\Normalizer;
use Chill\MainBundle\Serializer\Model\Collection;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait;
class CollectionNormalizer implements NormalizerInterface, NormalizerAwareInterface
{
use NormalizerAwareTrait;
public function supportsNormalization($data, string $format = null): bool
{
return $data instanceof Collection;
}
public function normalize($collection, string $format = null, array $context = [])
{
/** @var $collection Collection */
/** @var $collection Chill\MainBundle\Pagination\PaginatorInterface */
$paginator = $collection->getPaginator();
$data['count'] = $paginator->getTotalItems();
$data['first'] = $paginator->getCurrentPageFirstItemNumber();
$data['items_per_page'] = $paginator->getItemsPerPage();
$data['next'] = $paginator->hasNextPage() ?
$paginator->getNextPage()->generateUrl() : null;
$data['previous'] = $paginator->hasPreviousPage() ?
$paginator->getPreviousPage()->generateUrl() : null;
// normalize results
$data['results'] = $this->normalizer->normalize($collection->getItems(),
$format, $context);
return $data;
}
}

View File

@ -1,7 +1,8 @@
services:
Chill\MainBundle\CRUD\Routing\CRUDRoutesLoader:
arguments:
$config: '%chill_main_crud_route_loader_config%'
$crudConfig: '%chill_main_crud_route_loader_config%'
$apiConfig: '%chill_main_api_route_loader_config%'
tags: [ routing.loader ]
Chill\MainBundle\CRUD\Resolver\Resolver:
@ -13,4 +14,4 @@ services:
arguments:
$resolver: '@Chill\MainBundle\CRUD\Resolver\Resolver'
tags:
- { name: twig.extension }
- { name: twig.extension }

View File

@ -6,6 +6,7 @@ services:
- "@request_stack"
- "@router"
- "%chill_main.pagination.item_per_page%"
Chill\MainBundle\Pagination\PaginatorFactory: '@chill_main.paginator_factory'
chill_main.paginator.twig_extensions:

View File

@ -11,3 +11,7 @@ services:
Chill\MainBundle\Serializer\Normalizer\UserNormalizer:
tags:
- { name: 'serializer.normalizer', priority: 64 }
Chill\MainBundle\Serializer\Normalizer\CollectionNormalizer:
tags:
- { name: 'serializer.normalizer', priority: 64 }

View File

@ -0,0 +1,79 @@
<?php
namespace Chill\PersonBundle\Controller;
use Chill\MainBundle\CRUD\Controller\ApiController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Symfony\Component\HttpFoundation\Exception\BadRequestException;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;
use Chill\PersonBundle\Privacy\AccompanyingPeriodPrivacyEvent;
use Chill\PersonBundle\Entity\Person;
class AccompanyingCourseApiController extends ApiController
{
protected EventDispatcherInterface $eventDispatcher;
protected ValidatorInterface $validator;
public function __construct(EventDispatcherInterface $eventDispatcher, $validator)
{
$this->eventDispatcher = $eventDispatcher;
$this->validator = $validator;
}
public function participationApi($id, Request $request, $_format)
{
/** @var AccompanyingPeriod $accompanyingPeriod */
$accompanyingPeriod = $this->getEntity('participation', $id, $request);
$person = $this->getSerializer()
->deserialize($request->getContent(), Person::class, $_format, []);
if (NULL === $person) {
throw new BadRequestException('person id not found');
}
// TODO add acl
//
$this->onPostCheckACL('participation', $request, $_format, $accompanyingPeriod);
switch ($request->getMethod()) {
case Request::METHOD_POST:
$participation = $accompanyingPeriod->addPerson($person);
break;
case Request::METHOD_DELETE:
$participation = $accompanyingPeriod->removePerson($person);
$participation->setEndDate(new \DateTimeImmutable('now'));
break;
default:
throw new BadRequestException("This method is not supported");
}
$errors = $this->validator->validate($accompanyingPeriod);
if ($errors->count() > 0) {
// only format accepted
return $this->json($errors);
}
$this->getDoctrine()->getManager()->flush();
return $this->json($participation);
}
protected function onPostCheckACL(string $action, Request $request, string $_format, $entity): ?Response
{
$this->eventDispatcher->dispatch(
AccompanyingPeriodPrivacyEvent::ACCOMPANYING_PERIOD_PRIVACY_EVENT,
new AccompanyingPeriodPrivacyEvent($entity, [
'action' => $action,
'request' => $request->getMethod()
])
);
return null;
}
}

View File

@ -0,0 +1,17 @@
<?php
namespace Chill\PersonBundle\Controller;
use Chill\MainBundle\CRUD\Controller\ApiController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
class OpeningApiController extends ApiController
{
protected function customizeQuery(string $action, Request $request, $qb): void
{
$qb->where($qb->expr()->gt('e.noActiveAfter', ':now'))
->orWhere($qb->expr()->isNull('e.noActiveAfter'));
$qb->setParameter('now', new \DateTime('now'));
}
}

View File

@ -25,6 +25,7 @@ use Doctrine\Persistence\ObjectManager;
use Chill\MainBundle\DataFixtures\ORM\LoadPermissionsGroup;
use Chill\MainBundle\Entity\RoleScope;
use Chill\PersonBundle\Security\Authorization\PersonVoter;
use Chill\PersonBundle\Security\Authorization\AccompanyingPeriodVoter;
/**
* Add a role CHILL_PERSON_UPDATE & CHILL_PERSON_CREATE for all groups except administrative,
@ -44,6 +45,7 @@ class LoadPersonACL extends AbstractFixture implements OrderedFixtureInterface
{
foreach (LoadPermissionsGroup::$refs as $permissionsGroupRef) {
$permissionsGroup = $this->getReference($permissionsGroupRef);
$scopeSocial = $this->getReference('scope_social');
//create permission group
switch ($permissionsGroup->getName()) {
@ -51,6 +53,12 @@ class LoadPersonACL extends AbstractFixture implements OrderedFixtureInterface
case 'direction':
printf("Adding CHILL_PERSON_UPDATE & CHILL_PERSON_CREATE to %s permission group \n", $permissionsGroup->getName());
$permissionsGroup->addRoleScope(
(new RoleScope())
->setRole(AccompanyingPeriodVoter::SEE)
->setScope($scopeSocial)
);
$roleScopeUpdate = (new RoleScope())
->setRole('CHILL_PERSON_UPDATE')
->setScope(null);

View File

@ -28,6 +28,7 @@ use Chill\MainBundle\DependencyInjection\MissingBundleException;
use Chill\PersonBundle\Security\Authorization\PersonVoter;
use Chill\MainBundle\Security\Authorization\ChillExportVoter;
use Chill\PersonBundle\Doctrine\DQL\AddressPart;
use Symfony\Component\HttpFoundation\Request;
/**
* Class ChillPersonExtension
@ -76,6 +77,7 @@ class ChillPersonExtension extends Extension implements PrependExtensionInterfac
$loader->load('services/templating.yaml');
$loader->load('services/alt_names.yaml');
$loader->load('services/serializer.yaml');
$loader->load('services/security.yaml');
// load service advanced search only if configure
if ($config['search']['search_by_phone'] != 'never') {
@ -307,6 +309,55 @@ class ChillPersonExtension extends Extension implements PrependExtensionInterfac
'template' => '@ChillPerson/MaritalStatus/edit.html.twig',
]
]
],
],
'apis' => [
[
'class' => \Chill\PersonBundle\Entity\AccompanyingPeriod::class,
'name' => 'accompanying_course',
'base_path' => '/api/1.0/person/accompanying-course',
'controller' => \Chill\PersonBundle\Controller\AccompanyingCourseApiController::class,
'actions' => [
'_entity' => [
'roles' => [
Request::METHOD_GET => \Chill\PersonBundle\Security\Authorization\AccompanyingPeriodVoter::SEE
]
],
'participation' => [
'methods' => [
Request::METHOD_POST => true,
Request::METHOD_DELETE => true,
Request::METHOD_GET => false,
Request::METHOD_HEAD => false,
],
'roles' => [
Request::METHOD_POST => \Chill\PersonBundle\Security\Authorization\AccompanyingPeriodVoter::SEE,
Request::METHOD_DELETE=> \Chill\PersonBundle\Security\Authorization\AccompanyingPeriodVoter::SEE
]
]
]
],
[
'class' => \Chill\PersonBundle\Entity\AccompanyingPeriod\Origin::class,
'name' => 'accompanying_period_origin',
'base_path' => '/api/1.0/person/accompanying-period/origin',
'controller' => \Chill\PersonBundle\Controller\OpeningApiController::class,
'base_role' => 'ROLE_USER',
'actions' => [
'_index' => [
'methods' => [
Request::METHOD_GET => true,
Request::METHOD_HEAD => true
],
],
'_entity' => [
'methods' => [
Request::METHOD_GET => true,
Request::METHOD_HEAD => true
]
],
]
]
]
]);

View File

@ -118,7 +118,7 @@ class AccompanyingPeriod
*
* @ORM\OneToMany(targetEntity=AccompanyingPeriodParticipation::class,
* mappedBy="accompanyingPeriod",
* cascade={"persist", "remove", "merge", "detach"})
* cascade={"persist", "refresh", "remove", "merge", "detach"})
*/
private $participations;
@ -344,50 +344,68 @@ class AccompanyingPeriod
}
/**
* This private function scan Participations Collection,
* searching for a given Person
* Get the participation containing a person
*/
private function participationsContainsPerson(Person $person): ?AccompanyingPeriodParticipation
public function getParticipationsContainsPerson(Person $person): Collection
{
foreach ($this->participations as $participation) {
/** @var AccompanyingPeriodParticipation $participation */
if ($person === $participation->getPerson()) {
return $participation;
}}
return null;
return $this->getParticipations($person)->filter(
function(AccompanyingPeriodParticipation $participation) use ($person) {
if ($person === $participation->getPerson()) {
return $participation;
}
});
}
/**
* This public function is the same but return only true or false
* Get the opened participation containing a person
*
* "Open" means that the closed date is NULL
*/
public function getOpenParticipationContainsPerson(Person $person): ?AccompanyingPeriodParticipation
{
$collection = $this->getParticipationsContainsPerson($person)->filter(
function(AccompanyingPeriodParticipation $participation) use ($person) {
if (NULL === $participation->getEndDate()) {
return $participation;
}
});
return $collection->count() > 0 ? $collection->first() : NULL;
}
/**
* Return true if the accompanying period contains a person.
*
* **Note**: this participation can be opened or not.
*/
public function containsPerson(Person $person): bool
{
return ($this->participationsContainsPerson($person) === null) ? false : true;
return $this->getParticipationsContainsPerson($person)->count() > 0;
}
/**
* Add Person
*/
public function addPerson(Person $person = null): self
public function addPerson(Person $person = null): AccompanyingPeriodParticipation
{
$participation = new AccompanyingPeriodParticipation($this, $person);
$this->participations[] = $participation;
return $this;
return $participation;
}
/**
* Remove Person
*/
public function removePerson(Person $person): void
public function removePerson(Person $person): ?AccompanyingPeriodParticipation
{
$participation = $this->participationsContainsPerson($person);
$participation = $this->getOpenParticipationContainsPerson($person);
if (! null === $participation) {
if ($participation instanceof AccompanyingPeriodParticipation) {
$participation->setEndDate(new \DateTimeImmutable('now'));
$this->participations->removeElement($participation);
}
return $participation;
}

View File

@ -22,7 +22,7 @@
namespace Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\PersonBundle\Entity\AccompanyingPeriod\OriginRepository;
use Chill\PersonBundle\Repository\AccompanyingPeriod\OriginRepository;
use Doctrine\ORM\Mapping as ORM;
/**
@ -53,7 +53,7 @@ class Origin
return $this->id;
}
public function getLabel(): ?string
public function getLabel()
{
return $this->label;
}

View File

@ -390,7 +390,7 @@ class Person implements HasCenterInterface
*
* @deprecated since 1.1 use `getOpenedAccompanyingPeriod instead
*/
public function getCurrentAccompanyingPeriod() : AccompanyingPeriod
public function getCurrentAccompanyingPeriod() : ?AccompanyingPeriod
{
return $this->getOpenedAccompanyingPeriod();
}

View File

@ -0,0 +1,73 @@
<?php
namespace Chill\PersonBundle\Security\Authorization;
use Chill\MainBundle\Security\Authorization\AbstractChillVoter;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Security\Authorization\AuthorizationHelper;
use Chill\MainBundle\Security\ProvideRoleHierarchyInterface;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\MainBundle\Entity\Center;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Role\Role;
class AccompanyingPeriodVoter extends AbstractChillVoter implements ProvideRoleHierarchyInterface
{
protected AuthorizationHelper $helper;
public const SEE = 'CHILL_PERSON_ACCOMPANYING_PERIOD_SEE';
/**
* @param AuthorizationHelper $helper
*/
public function __construct(AuthorizationHelper $helper)
{
$this->helper = $helper;
}
protected function supports($attribute, $subject)
{
return $subject instanceof AccompanyingPeriod;
}
protected function voteOnAttribute($attribute, $subject, TokenInterface $token)
{
if (!$token->getUser() instanceof User) {
return false;
}
// TODO take scopes into account
foreach ($subject->getPersons() as $person) {
// give access as soon as on center is reachable
if ($this->helper->userHasAccess($token->getUser(), $person->getCenter(), $attribute)) {
return true;
}
return false;
}
}
private function getAttributes()
{
return [
self::SEE
];
}
public function getRoles()
{
return $this->getAttributes();
}
public function getRolesWithoutScope()
{
return [];
}
public function getRolesWithHierarchy()
{
return [ 'Person' => $this->getRoles() ];
}
}

View File

@ -55,7 +55,7 @@ class PersonNormalizer implements
'id' => $person->getId(),
'firstName' => $person->getFirstName(),
'lastName' => $person->getLastName(),
'birthdate' => $person->getBirthdate() ? $this->normalizer->normalize($person->getBirthdate()) : null,
'birthdate' => $this->normalizer->normalize($person->getBirthdate()),
'center' => $this->normalizer->normalize($person->getCenter())
];
}

View File

@ -36,7 +36,7 @@ use Symfony\Component\HttpFoundation\Request;
/**
* Test api for AccompanyingCourseControllerTest
*/
class AccompanyingCourseControllerTest extends WebTestCase
class AccompanyingCourseApiControllerTest extends WebTestCase
{
protected static EntityManagerInterface $em;
@ -65,7 +65,7 @@ class AccompanyingCourseControllerTest extends WebTestCase
*/
public function testAccompanyingCourseShow(int $personId, AccompanyingPeriod $period)
{
$this->client->request(Request::METHOD_GET, sprintf('/fr/person/api/1.0/accompanying-course/%d/show.json', $period->getId()));
$c = $this->client->request(Request::METHOD_GET, sprintf('/api/1.0/person/accompanying-course/%d.json', $period->getId()));
$response = $this->client->getResponse();
$this->assertEquals(200, $response->getStatusCode(), "Test that the response of rest api has a status code ok (200)");
@ -77,6 +77,14 @@ class AccompanyingCourseControllerTest extends WebTestCase
$this->assertGreaterThan(0, $data->participations);
}
public function testShow404()
{
$this->client->request(Request::METHOD_GET, sprintf('/api/1.0/person/accompanying-course/%d.json', 99999));
$response = $this->client->getResponse();
$this->assertEquals(404, $response->getStatusCode(), "Test that the response of rest api has a status code 'not found' (404)");
}
/**
*
* @dataProvider dataGenerateRandomAccompanyingCourse
@ -85,26 +93,55 @@ class AccompanyingCourseControllerTest extends WebTestCase
{
$this->client->request(
Request::METHOD_POST,
sprintf('/fr/person/api/1.0/accompanying-course/%d/participation.json', $period->getId()),
sprintf('/api/1.0/person/accompanying-course/%d/participation.json', $period->getId()),
[], // parameters
[], // files
[], // server parameters
\json_encode([ 'id' => $personId ])
);
$response = $this->client->getResponse();
$data = \json_decode($response->getContent(), true);
$this->assertEquals(200, $response->getStatusCode(), "Test that the response of rest api has a status code ok (200)");
$this->client->request(Request::METHOD_GET, sprintf('/fr/person/api/1.0/accompanying-course/%d/show.json', $period->getId()));
$this->assertArrayHasKey('id', $data);
$this->assertArrayHasKey('startDate', $data);
$this->assertNotNull($data['startDate']);
// check by deownloading the accompanying cours
$this->client->request(Request::METHOD_GET, sprintf('/api/1.0/person/accompanying-course/%d.json', $period->getId()));
$response = $this->client->getResponse();
$data = \json_decode($response->getContent());
// check that the person id is contained
$participationsPersonsIds = \array_map(
function($participation) { return $participation->person->id; },
$data->participations);
$this->assertContains($personId, $participationsPersonsIds);
// check removing the participation
$this->client->request(
Request::METHOD_DELETE,
sprintf('/api/1.0/person/accompanying-course/%d/participation.json', $period->getId()),
[], // parameters
[], // files
[], // server parameters
\json_encode([ 'id' => $personId ])
);
$response = $this->client->getResponse();
$data = \json_decode($response->getContent(), true);
$this->assertEquals(200, $response->getStatusCode(), "Test that the response of rest api has a status code ok (200)");
$this->assertArrayHasKey('id', $data);
$this->assertArrayHasKey('startDate', $data);
$this->assertNotNull($data['startDate']);
$this->assertArrayHasKey('endDate', $data);
$this->assertNotNull($data['endDate']);
// set to variable for tear down
$this->personId = $personId;
$this->period = $period;
}
@ -112,6 +149,7 @@ class AccompanyingCourseControllerTest extends WebTestCase
protected function tearDown()
{
// remove participation created during test 'testAccompanyingCourseAddParticipation'
// and if the test could not remove it
$testAddParticipationName = 'testAccompanyingCourseAddParticipation';
@ -126,8 +164,10 @@ class AccompanyingCourseControllerTest extends WebTestCase
->findOneBy(['person' => $this->personId, 'accompanyingPeriod' => $this->period])
;
$em->remove($participation);
$em->flush();
if (NULL !== $participation) {
$em->remove($participation);
$em->flush();
}
}
public function dataGenerateRandomAccompanyingCourse()
@ -139,9 +179,9 @@ class AccompanyingCourseControllerTest extends WebTestCase
// * one for getting the person, which will in turn provide his accompanying period;
// * one for getting the personId to populate to the data manager
//
// Ensure to keep always $maxGenerated to the double of $maxResults
$maxGenerated = 1;
$maxResults = 15 * 8;
// Ensure to keep always $maxGenerated to the double of $maxResults. x8 is a good compromize :)
$maxGenerated = 3;
$maxResults = $maxGenerated * 8;
static::bootKernel();
$em = static::$container->get(EntityManagerInterface::class);

View File

@ -27,7 +27,6 @@ use Chill\PersonBundle\Entity\Person;
class AccompanyingPeriodTest extends \PHPUnit\Framework\TestCase
{
public function testClosingIsAfterOpeningConsistency()
{
$datetime1 = new \DateTime('now');
@ -77,22 +76,32 @@ class AccompanyingPeriodTest extends \PHPUnit\Framework\TestCase
$this->assertFalse($period->isOpen());
}
public function testCanBeReOpened()
public function testPersonPeriod()
{
$person = new Person(\DateTime::createFromFormat('Y-m-d', '2010-01-01'));
$person->close($person->getAccompanyingPeriods()[0]
->setClosingDate(\DateTime::createFromFormat('Y-m-d', '2010-12-31')));
$firstAccompanygingPeriod = $person->getAccompanyingPeriodsOrdered()[0];
$this->assertTrue($firstAccompanygingPeriod->canBeReOpened());
$lastAccompanyingPeriod = (new AccompanyingPeriod(\DateTime::createFromFormat('Y-m-d', '2011-01-01')))
->setClosingDate(\DateTime::createFromFormat('Y-m-d', '2011-12-31'))
;
$person->addAccompanyingPeriod($lastAccompanyingPeriod);
$this->assertFalse($firstAccompanygingPeriod->canBeReOpened());
}
$person = new Person();
$person2 = new Person();
$person3 = new Person();
$period = new AccompanyingPeriod(new \DateTime());
$period->addPerson($person);
$period->addPerson($person2);
$period->addPerson($person3);
$this->assertEquals(3, $period->getParticipations()->count());
$this->assertTrue($period->containsPerson($person));
$this->assertFalse($period->containsPerson(new Person()));
$participation = $period->getOpenParticipationContainsPerson($person);
$participations = $period->getParticipationsContainsPerson($person);
$this->assertNotNull($participation);
$this->assertSame($person, $participation->getPerson());
$this->assertEquals(1, $participations->count());
$participationL = $period->removePerson($person);
$this->assertSame($participationL, $participation);
$this->assertTrue($participation->getEndDate() instanceof \DateTimeInterface);
$participation = $period->getOpenParticipationContainsPerson($person);
$this->assertNull($participation);
}
}

View File

@ -27,14 +27,6 @@ services:
public: true
tags:
- { name: chill.timeline, context: 'person' }
chill.person.security.authorization.person:
class: Chill\PersonBundle\Security\Authorization\PersonVoter
arguments:
- "@chill.main.security.authorization.helper"
tags:
- { name: security.voter }
- { name: chill.role }
chill.person.birthdate_validation:
class: Chill\PersonBundle\Validator\Constraints\BirthdateValidator

View File

@ -46,3 +46,9 @@ services:
$dispatcher: '@Symfony\Contracts\EventDispatcher\EventDispatcherInterface'
$validator: '@Symfony\Component\Validator\Validator\ValidatorInterface'
tags: ['controller.service_arguments']
Chill\PersonBundle\Controller\AccompanyingCourseApiController:
arguments:
$eventDispatcher: '@Symfony\Contracts\EventDispatcher\EventDispatcherInterface'
$validator: '@Symfony\Component\Validator\Validator\ValidatorInterface'
tags: ['controller.service_arguments']

View File

@ -23,3 +23,8 @@ services:
arguments:
- '@Doctrine\Persistence\ManagerRegistry'
tags: [ doctrine.repository_service ]
Chill\PersonBundle\Repository\AccompanyingPeriod\OriginRepository:
arguments:
- '@Doctrine\Persistence\ManagerRegistry'
tags: [ doctrine.repository_service ]

View File

@ -0,0 +1,16 @@
services:
chill.person.security.authorization.person:
class: Chill\PersonBundle\Security\Authorization\PersonVoter
arguments:
- "@chill.main.security.authorization.helper"
tags:
- { name: security.voter }
- { name: chill.role }
Chill\PersonBundle\Security\Authorization\AccompanyingPeriodVoter:
arguments:
- "@chill.main.security.authorization.helper"
tags:
- { name: security.voter }
- { name: chill.role }

View File

@ -190,6 +190,7 @@ CHILL_PERSON_CREATE: Ajouter des personnes
CHILL_PERSON_STATS: Statistiques sur les personnes
CHILL_PERSON_LISTS: Liste des personnes
CHILL_PERSON_DUPLICATE: Gérer les doublons de personnes
CHILL_PERSON_ACCOMPANYING_PERIOD_SEE: Voir les périodes d'accompagnement
#period
Period closed!: Période clôturée!