diff --git a/composer.json b/composer.json index 4181af68a..95074c18f 100644 --- a/composer.json +++ b/composer.json @@ -73,7 +73,8 @@ "symfony/web-profiler-bundle": "^5.0", "symfony/var-dumper": "4.*", "symfony/debug-bundle": "^5.1", - "symfony/phpunit-bridge": "^5.2" + "symfony/phpunit-bridge": "^5.2", + "nelmio/alice": "^3.8" }, "scripts": { "auto-scripts": { diff --git a/docs/source/development/api.rst b/docs/source/development/api.rst index 86eb6ff65..a5fef2372 100644 --- a/docs/source/development/api.rst +++ b/docs/source/development/api.rst @@ -13,6 +13,9 @@ API Chill provides a basic framework to build REST api. +Basic configuration +******************* + Configure a route ================= @@ -34,7 +37,7 @@ You can also: * `How to create your custom normalizer `_ Auto-loading the routes -*********************** +======================= Ensure that those lines are present in your file `app/config/routing.yml`: @@ -47,7 +50,7 @@ Ensure that those lines are present in your file `app/config/routing.yml`: Create your model -***************** +================= Create your model on the usual way: @@ -87,7 +90,7 @@ Create your model on the usual way: Configure api -************* +============= Configure the api using Yaml (see the full configuration: :ref:`api_full_configuration`): @@ -171,7 +174,7 @@ Configure the api using Yaml (see the full configuration: :ref:`api_full_configu } The :code:`_index` and :code:`_entity` action -============================================= +********************************************* The :code:`_index` and :code:`_entity` action are default actions: @@ -189,7 +192,7 @@ 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. @@ -216,7 +219,7 @@ You can also define a role for each method. In this case, this role is used for 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`. @@ -264,7 +267,7 @@ And set your controller in configuration: HEAD: true Create your own actions -======================= +*********************** You can add your own actions: @@ -361,8 +364,297 @@ Then, create the corresponding action into your controller: } } +Managing association +******************** + +ManyToOne association +===================== + +In ManyToOne association, you can add associated entities using the :code:`PATCH` request. By default, the serializer deserialize entities only with their id and discriminator type, if any. + +Example: + +.. code-block:: bash + + curl -X 'PATCH' \ + 'http://localhost:8001/api/1.0/person/accompanying-course/2668.json' \ + -H 'accept: */*' \ + -H 'Content-Type: application/json' \ + # see the data sent to the server: \ + -d '{ + "type": "accompanying_period", + "id": 2668, + "origin": { "id": 11 } + }' + +ManyToMany associations +======================= + +In OneToMany association, you can easily create route for adding and removing entities, using :code:`POST` and :code:`DELETE` requests. + +Prepare your entity, creating the methods :code:`addYourEntity` and :code:`removeYourEntity`: + +.. code-block:: php + + namespace Chill\PersonBundle\Entity; + + use Chill\MainBundle\Entity\Scope; + use Doctrine\Common\Collections\ArrayCollection; + use Doctrine\Common\Collections\Collection; + use Doctrine\ORM\Mapping as ORM; + use Symfony\Component\Serializer\Annotation\Groups; + use Symfony\Component\Serializer\Annotation\DiscriminatorMap; + + /** + * AccompanyingPeriod Class + * + * @ORM\Entity + * @ORM\Table(name="chill_person_accompanying_period") + * @DiscriminatorMap(typeProperty="type", mapping={ + * "accompanying_period"=AccompanyingPeriod::class + * }) + */ + class AccompanyingPeriod + { + /** + * @var Collection + * @ORM\ManyToMany( + * targetEntity=Scope::class, + * cascade={} + * ) + * @Groups({"read"}) + */ + private $scopes; + + public function addScope(Scope $scope): self + { + $this->scopes[] = $scope; + + return $this; + } + + public function removeScope(Scope $scope): void + { + $this->scopes->removeElement($scope); + } + + +Create your route into the configuration: + +.. 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: + scope: + methods: + POST: true + DELETE: true + GET: false + HEAD: false + PUT: false + PATCH: false + roles: + POST: CHILL_PERSON_ACCOMPANYING_PERIOD_SEE + DELETE: CHILL_PERSON_ACCOMPANYING_PERIOD_SEE + GET: null + HEAD: null + PUT: null + PATCH: null + controller_action: null + path: null + single-collection: single + +This will create a new route, which will accept two methods: DELETE and POST: + +.. code-block:: raw + + +--------------+---------------------------------------------------------------------------------------+ + | Property | Value | + +--------------+---------------------------------------------------------------------------------------+ + | Route Name | chill_api_single_accompanying_course_scope | + | Path | /api/1.0/person/accompanying-course/{id}/scope.{_format} | + | Path Regex | {^/api/1\.0/person/accompanying\-course/(?P[^/]++)/scope\.(?P<_format>[^/]++)$}sD | + | Host | ANY | + | Host Regex | | + | Scheme | ANY | + | Method | POST|DELETE | + | Requirements | {id}: \d+ | + | Class | Symfony\Component\Routing\Route | + | Defaults | _controller: csapi_accompanying_course_controller:scopeApi | + | Options | compiler_class: Symfony\Component\Routing\RouteCompiler | + +--------------+---------------------------------------------------------------------------------------+ + + + +Then, create the controller action. Call the method: + +.. 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\MainBundle\Entity\Scope; + + class MyController extends ApiController + { + public function scopeApi($id, Request $request, string $_format): Response + { + return $this->addRemoveSomething('scope', $id, $request, $_format, 'scope', Scope::class, [ 'groups' => [ 'read' ] ]); + } + } + +This will allow to add a scope by his id, and delete them. + +Curl requests: + +.. code-block:: bash + + # add a scope with id 5 + curl -X 'POST' \ + 'http://localhost:8001/api/1.0/person/accompanying-course/2868/scope.json' \ + -H 'accept: */*' \ + -H 'Content-Type: application/json' \ + -d '{ + "type": "scope", + "id": 5 + }' + + # remove a scope with id 5 + curl -X 'DELETE' \ + 'http://localhost:8001/api/1.0/person/accompanying-course/2868/scope.json' \ + -H 'accept: */*' \ + -H 'Content-Type: application/json' \ + -d '{ + "id": 5, + "type": "scope" + }' + +Deserializing an association where multiple types are allowed +============================================================= + +Sometimes, multiples types are allowed as association to one entity: + +.. code-block:: php + + namespace Chill\PersonBundle\Entity\AccompanyingPeriod; + + use Chill\PersonBundle\Entity\Person; + use Chill\ThirdPartyBundle\Entity\ThirdParty; + use Doctrine\ORM\Mapping as ORM; + + class Resource + { + + + /** + * @ORM\ManyToOne(targetEntity=ThirdParty::class) + * @ORM\JoinColumn(nullable=true) + */ + private $thirdParty; + + /** + * @ORM\ManyToOne(targetEntity=Person::class) + * @ORM\JoinColumn(nullable=true) + */ + private $person; + + + /** + * + * @param $resource Person|ThirdParty + */ + public function setResource($resource): self + { + // ... + } + + + /** + * @return ThirdParty|Person + * @Groups({"read", "write"}) + */ + public function getResource() + { + return $this->person ?? $this->thirdParty; + } + } + +This is not well taken into account by the Symfony serializer natively. + +You must, then, create your own CustomNormalizer. You can help yourself using this: + +.. code-block:: php + + namespace Chill\PersonBundle\Serializer\Normalizer; + + use Chill\PersonBundle\Entity\Person; + use Chill\ThirdPartyBundle\Entity\ThirdParty; + use Chill\PersonBundle\Entity\AccompanyingPeriod\Resource; + use Chill\PersonBundle\Repository\AccompanyingPeriod\ResourceRepository; + use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; + use Symfony\Component\Serializer\Normalizer\DenormalizerAwareInterface; + use Symfony\Component\Serializer\Normalizer\DenormalizerAwareTrait; + use Symfony\Component\Serializer\Normalizer\ObjectToPopulateTrait; + use Symfony\Component\Serializer\Exception; + use Chill\MainBundle\Serializer\Normalizer\DiscriminatedObjectDenormalizer; + + + class AccompanyingPeriodResourceNormalizer implements DenormalizerInterface, DenormalizerAwareInterface + { + use DenormalizerAwareTrait; + use ObjectToPopulateTrait; + + public function __construct(ResourceRepository $repository) + { + $this->repository = $repository; + } + + public function denormalize($data, string $type, string $format = null, array $context = []) + { + // .. snipped for brevity + + if ($resource === NULL) { + $resource = new Resource(); + } + + if (\array_key_exists('resource', $data)) { + $res = $this->denormalizer->denormalize( + $data['resource'], + // call for a "multiple type" + DiscriminatedObjectDenormalizer::TYPE, + $format, + // into the context, we add the list of allowed types: + [ + DiscriminatedObjectDenormalizer::ALLOWED_TYPES => + [ + Person::class, ThirdParty::class + ] + ] + ); + + $resource->setResource($res); + } + + return $resource; + } + + + public function supportsDenormalization($data, string $type, string $format = null) + { + return $type === Resource::class; + } + } + Serialization for collection -============================ +**************************** A specific model has been defined for returning collection: @@ -381,8 +673,9 @@ A specific model has been defined for returning collection: } } +Where this is relevant, this model should be re-used in custom controller actions. -This can be achieved quickly by assembling results into a :code:`Chill\MainBundle\Serializer\Model\Collection`. The pagination information is given by using :code:`Paginator` (see :ref:`Pagination `). +In custom actions, this can be achieved quickly by assembling results into a :code:`Chill\MainBundle\Serializer\Model\Collection`. The pagination information is given by using :code:`Paginator` (see :ref:`Pagination `). .. code-block:: php @@ -400,10 +693,11 @@ This can be achieved quickly by assembling results into a :code:`Chill\MainBundl } } + .. _api_full_configuration: Full configuration example -========================== +************************** .. code-block:: yaml diff --git a/docs/source/installation/index.rst b/docs/source/installation/index.rst index 6aee5c526..1312480e3 100644 --- a/docs/source/installation/index.rst +++ b/docs/source/installation/index.rst @@ -82,7 +82,7 @@ Chill will be available at ``http://localhost:8001.`` Currently, there isn't any .. code-block:: bash - docker-compose exec --user $(id -u) php bin/console doctrine:fixtures:load + docker-compose exec --user $(id -u) php bin/console doctrine:fixtures:load --purge-with-truncate There are several users available: diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 9d74dd61a..ab9e69052 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -18,11 +18,10 @@ src/Bundle/ChillMainBundle/Tests/ - diff --git a/src/Bundle/ChillMainBundle/CRUD/Controller/AbstractCRUDController.php b/src/Bundle/ChillMainBundle/CRUD/Controller/AbstractCRUDController.php index 5cc055f26..df7d065cb 100644 --- a/src/Bundle/ChillMainBundle/CRUD/Controller/AbstractCRUDController.php +++ b/src/Bundle/ChillMainBundle/CRUD/Controller/AbstractCRUDController.php @@ -5,7 +5,7 @@ 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 Symfony\Component\Validator\Validator\ValidatorInterface; use Chill\MainBundle\Pagination\PaginatorFactory; use Chill\MainBundle\Pagination\PaginatorInterface; @@ -25,12 +25,19 @@ class AbstractCRUDController extends AbstractController * * @param string $id * @return object + * @throw Symfony\Component\HttpKernel\Exception\NotFoundHttpException if the object is not found */ - protected function getEntity($action, $id, Request $request): ?object + protected function getEntity($action, $id, Request $request): object { - return $this->getDoctrine() + $e = $this->getDoctrine() ->getRepository($this->getEntityClass()) ->find($id); + + if (NULL === $e) { + throw $this->createNotFoundException(sprintf("The object %s for id %s is not found", $this->getEntityClass(), $id)); + } + + return $e; } /** @@ -222,4 +229,9 @@ class AbstractCRUDController extends AbstractController { return $this->container->get('chill_main.paginator_factory'); } + + protected function getValidator(): ValidatorInterface + { + return $this->get('validator'); + } } diff --git a/src/Bundle/ChillMainBundle/CRUD/Controller/ApiController.php b/src/Bundle/ChillMainBundle/CRUD/Controller/ApiController.php index 7643db0e9..14b0473da 100644 --- a/src/Bundle/ChillMainBundle/CRUD/Controller/ApiController.php +++ b/src/Bundle/ChillMainBundle/CRUD/Controller/ApiController.php @@ -8,6 +8,10 @@ use Symfony\Component\Routing\Annotation\Route; use Symfony\Component\Serializer\SerializerInterface; use Chill\MainBundle\Serializer\Model\Collection; use Chill\MainBundle\Pagination\PaginatorInterface; +use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; +use Symfony\Component\Validator\ConstraintViolationListInterface; +use Symfony\Component\Serializer\Exception\NotEncodableValueException; +use Symfony\Component\HttpFoundation\Exception\BadRequestException; class ApiController extends AbstractCRUDController { @@ -38,11 +42,6 @@ class ApiController extends AbstractCRUDController 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; @@ -81,13 +80,123 @@ class ApiController extends AbstractCRUDController { switch ($request->getMethod()) { case Request::METHOD_GET: - case REQUEST::METHOD_HEAD: + case Request::METHOD_HEAD: return $this->entityGet('_entity', $request, $id, $_format); + case Request::METHOD_PUT: + case Request::METHOD_PATCH: + return $this->entityPut('_entity', $request, $id, $_format); default: throw new \Symfony\Component\HttpFoundation\Exception\BadRequestException("This method is not implemented"); } } + public function entityPut($action, Request $request, $id, string $_format): 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; + } + + try { + $entity = $this->deserialize($action, $request, $_format, $entity); + } catch (NotEncodableValueException $e) { + throw new BadRequestException("invalid json", 400, $e); + } + + $errors = $this->validate($action, $request, $_format, $entity); + + $response = $this->onAfterValidation($action, $request, $_format, $entity, $errors); + if ($response instanceof Response) { + return $response; + } + + if ($errors->count() > 0) { + $response = $this->json($errors); + $response->setStatusCode(Response::HTTP_UNPROCESSABLE_ENTITY); + + return $response; + } + + $this->getDoctrine()->getManager()->flush(); + + $response = $this->onAfterFlush($action, $request, $_format, $entity, $errors); + if ($response instanceof Response) { + return $response; + } + + return $this->json( + $entity, + Response::HTTP_OK, + [], + $this->getContextForSerializationPostAlter($action, $request, $_format, $entity) + ); + } + + protected function onAfterValidation(string $action, Request $request, string $_format, $entity, ConstraintViolationListInterface $errors, array $more = []): ?Response + { + return null; + } + + + protected function onAfterFlush(string $action, Request $request, string $_format, $entity, ConstraintViolationListInterface $errors, array $more = []): ?Response + { + return null; + } + + protected function getValidationGroups(string $action, Request $request, string $_format, $entity): ?array + { + return null; + } + + protected function validate(string $action, Request $request, string $_format, $entity, array $more = []): ConstraintViolationListInterface + { + $validationGroups = $this->getValidationGroups($action, $request, $_format, $entity); + + return $this->getValidator()->validate($entity, null, $validationGroups); + } + + /** + * Deserialize the content of the request into the class associated with the curd + */ + protected function deserialize(string $action, Request $request, string $_format, $entity = null): object + { + $default = []; + + if (NULL !== $entity) { + $default[AbstractNormalizer::OBJECT_TO_POPULATE] = $entity; + } + + $context = \array_merge( + $default, + $this->getContextForSerialization($action, $request, $_format, $entity) + ); + + return $this->getSerializer()->deserialize($request->getContent(), $this->getEntityClass(), $_format, $context); + } + + /** * Base action for indexing entities */ @@ -172,6 +281,110 @@ class ApiController extends AbstractCRUDController return $this->serializeCollection($action, $request, $_format, $paginator, $entities); } + + /** + * Add or remove an associated entity, using `add` and `remove` methods. + * + * This method: + * + * 1. Fetch the base entity (throw 404 if not found) + * 2. checkACL, + * 3. run onPostCheckACL, return response if any, + * 4. deserialize posted data into the entity given by $postedDataType, with the context in $postedDataContext + * 5. run 'add+$property' for POST method, or 'remove+$property' for DELETE method + * 6. validate the base entity (not the deserialized one). Groups are fetched from getValidationGroups, validation is perform by `validate` + * 7. run onAfterValidation + * 8. if errors, return a 422 response with errors + * 9. flush the data + * 10. run onAfterFlush + * 11. return a 202 response for DELETE with empty body, or HTTP 200 for post with serialized posted entity + * + * @param string action + * @param mixed id + * @param Request $request + * @param string $_format + * @param string $property the name of the property. This will be used to make a `add+$property` and `remove+$property` method + * @param string $postedDataType the type of the posted data (the content) + * @param string $postedDataContext a context to deserialize posted data (the content) + * @throw BadRequestException if unable to deserialize the posted data + * @throw BadRequestException if the method is not POST or DELETE + * + */ + protected function addRemoveSomething(string $action, $id, Request $request, string $_format, string $property, string $postedDataType, $postedDataContext = []): Response + { + $entity = $this->getEntity($action, $id, $request); + + $postFetch = $this->onPostFetchEntity($action, $request, $entity, $_format); + if ($postFetch instanceof Response) { + return $postFetch; + } + + $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; + } + + try { + $postedData = $this->getSerializer()->deserialize($request->getContent(), $postedDataType, $_format, $postedDataContext); + } catch (\Symfony\Component\Serializer\Exception\UnexpectedValueException $e) { + throw new BadRequestException(sprintf("Unable to deserialize posted ". + "data: %s", $e->getMessage()), 0, $e); + } + + switch ($request->getMethod()) { + case Request::METHOD_DELETE: + // oups... how to use property accessor to remove element ? + $entity->{'remove'.\ucfirst($property)}($postedData); + break; + case Request::METHOD_POST: + $entity->{'add'.\ucfirst($property)}($postedData); + break; + default: + throw new BadRequestException("this method is not supported"); + } + + $errors = $this->validate($action, $request, $_format, $entity, [$postedData]); + + $response = $this->onAfterValidation($action, $request, $_format, $entity, $errors, [$postedData]); + if ($response instanceof Response) { + return $response; + } + + if ($errors->count() > 0) { + // only format accepted + return $this->json($errors, 422); + } + + $this->getDoctrine()->getManager()->flush(); + + + $response = $this->onAfterFlush($action, $request, $_format, $entity, $errors, [$postedData]); + if ($response instanceof Response) { + return $response; + } + + switch ($request->getMethod()) { + case Request::METHOD_DELETE: + return $this->json('', Response::HTTP_OK); + case Request::METHOD_POST: + return $this->json( + $postedData, + Response::HTTP_OK, + [], + $this->getContextForSerializationPostAlter($action, $request, $_format, $entity, [$postedData]) + ); + } + } /** * Serialize collections @@ -189,7 +402,26 @@ class ApiController extends AbstractCRUDController protected function getContextForSerialization(string $action, Request $request, string $_format, $entity): array { - return []; + switch ($request->getMethod()) { + case Request::METHOD_GET: + return [ 'groups' => [ 'read' ]]; + case Request::METHOD_PUT: + case Request::METHOD_PATCH: + return [ 'groups' => [ 'write' ]]; + default: + throw new \LogicException("get context for serialization is not implemented for this method"); + } + } + + /** + * Get the context for serialization post alter query (in case of + * PATCH, PUT, or POST method) + * + * This is called **after** the entity was altered. + */ + protected function getContextForSerializationPostAlter(string $action, Request $request, string $_format, $entity, array $more = []): array + { + return [ 'groups' => [ 'read' ]]; } /** diff --git a/src/Bundle/ChillMainBundle/Controller/SearchController.php b/src/Bundle/ChillMainBundle/Controller/SearchController.php index a44af8e7b..45390ceca 100644 --- a/src/Bundle/ChillMainBundle/Controller/SearchController.php +++ b/src/Bundle/ChillMainBundle/Controller/SearchController.php @@ -22,6 +22,7 @@ namespace Chill\MainBundle\Controller; +use Chill\MainBundle\Serializer\Model\Collection; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; use Chill\MainBundle\Search\UnknowSearchDomainException; @@ -34,6 +35,7 @@ use Symfony\Component\HttpFoundation\JsonResponse; use Chill\MainBundle\Search\SearchProvider; use Symfony\Contracts\Translation\TranslatorInterface; use Chill\MainBundle\Pagination\PaginatorFactory; +use Chill\MainBundle\Search\SearchApi; /** * Class SearchController @@ -42,32 +44,24 @@ use Chill\MainBundle\Pagination\PaginatorFactory; */ class SearchController extends AbstractController { - /** - * - * @var SearchProvider - */ - protected $searchProvider; + protected SearchProvider $searchProvider; - /** - * - * @var TranslatorInterface - */ - protected $translator; + protected TranslatorInterface $translator; - /** - * - * @var PaginatorFactory - */ - protected $paginatorFactory; + protected PaginatorFactory $paginatorFactory; + + protected SearchApi $searchApi; function __construct( SearchProvider $searchProvider, TranslatorInterface $translator, - PaginatorFactory $paginatorFactory + PaginatorFactory $paginatorFactory, + SearchApi $searchApi ) { $this->searchProvider = $searchProvider; $this->translator = $translator; $this->paginatorFactory = $paginatorFactory; + $this->searchApi = $searchApi; } @@ -152,6 +146,19 @@ class SearchController extends AbstractController array('results' => $results, 'pattern' => $pattern) ); } + + public function searchApi(Request $request, $_format): JsonResponse + { + //TODO this is an incomplete implementation + $query = $request->query->get('q', ''); + + $results = $this->searchApi->getResults($query, 0, 150); + $paginator = $this->paginatorFactory->create(count($results)); + + $collection = new Collection($results, $paginator); + + return $this->json($collection); + } public function advancedSearchListAction(Request $request) { diff --git a/src/Bundle/ChillMainBundle/DataFixtures/ORM/LoadCenters.php b/src/Bundle/ChillMainBundle/DataFixtures/ORM/LoadCenters.php index 1da1af2f3..a3c01fdf6 100644 --- a/src/Bundle/ChillMainBundle/DataFixtures/ORM/LoadCenters.php +++ b/src/Bundle/ChillMainBundle/DataFixtures/ORM/LoadCenters.php @@ -55,11 +55,11 @@ class LoadCenters extends AbstractFixture implements OrderedFixtureInterface public function load(ObjectManager $manager) { foreach (static::$centers as $new) { - $centerA = new Center(); - $centerA->setName($new['name']); + $center = new Center(); + $center->setName($new['name']); - $manager->persist($centerA); - $this->addReference($new['ref'], $centerA); + $manager->persist($center); + $this->addReference($new['ref'], $center); static::$refs[] = $new['ref']; } diff --git a/src/Bundle/ChillMainBundle/DependencyInjection/ChillMainExtension.php b/src/Bundle/ChillMainBundle/DependencyInjection/ChillMainExtension.php index a40221263..277e916a8 100644 --- a/src/Bundle/ChillMainBundle/DependencyInjection/ChillMainExtension.php +++ b/src/Bundle/ChillMainBundle/DependencyInjection/ChillMainExtension.php @@ -35,6 +35,7 @@ use Chill\MainBundle\Doctrine\DQL\OverlapsI; use Symfony\Component\DependencyInjection\Definition; use Symfony\Component\DependencyInjection\Reference; use Chill\MainBundle\Doctrine\DQL\Replace; +use Symfony\Component\HttpFoundation\Request; /** * Class ChillMainExtension @@ -133,7 +134,7 @@ class ChillMainExtension extends Extension implements PrependExtensionInterface, $loader->load('services/search.yaml'); $loader->load('services/serializer.yaml'); - $this->configureCruds($container, $config['cruds'], $config['apis'], $loader); + $this->configureCruds($container, $config['cruds'], $config['apis'], $loader); } /** @@ -212,6 +213,9 @@ class ChillMainExtension extends Extension implements PrependExtensionInterface, $container->prependExtensionConfig('monolog', array( 'channels' => array('chill') )); + + //add crud api + $this->prependCruds($container); } /** @@ -235,4 +239,97 @@ class ChillMainExtension extends Extension implements PrependExtensionInterface, // Note: the controller are loaded inside compiler pass } + + + /** + * @param ContainerBuilder $container + */ + protected function prependCruds(ContainerBuilder $container) + { + $container->prependExtensionConfig('chill_main', [ + 'apis' => [ + [ + 'class' => \Chill\MainBundle\Entity\Address::class, + 'name' => 'address', + 'base_path' => '/api/1.0/main/address', + 'base_role' => 'ROLE_USER', + 'actions' => [ + '_index' => [ + 'methods' => [ + Request::METHOD_GET => true, + Request::METHOD_HEAD => true + ], + ], + '_entity' => [ + 'methods' => [ + Request::METHOD_GET => true, + Request::METHOD_POST => true, + Request::METHOD_HEAD => true + ] + ], + ] + ], + [ + 'class' => \Chill\MainBundle\Entity\AddressReference::class, + 'name' => 'address_reference', + 'base_path' => '/api/1.0/main/address-reference', + '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 + ] + ], + ] + ], + [ + 'class' => \Chill\MainBundle\Entity\PostalCode::class, + 'name' => 'postal_code', + 'base_path' => '/api/1.0/main/postal-code', + '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 + ] + ], + ] + ], + [ + 'class' => \Chill\MainBundle\Entity\Country::class, + 'name' => 'country', + 'base_path' => '/api/1.0/main/country', + '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 + ] + ], + ] + ] + ] + ]); + } } diff --git a/src/Bundle/ChillMainBundle/DependencyInjection/Configuration.php b/src/Bundle/ChillMainBundle/DependencyInjection/Configuration.php index c7e4c00ef..4c90aaabb 100644 --- a/src/Bundle/ChillMainBundle/DependencyInjection/Configuration.php +++ b/src/Bundle/ChillMainBundle/DependencyInjection/Configuration.php @@ -221,6 +221,7 @@ class Configuration implements ConfigurationInterface ->booleanNode(Request::METHOD_POST)->defaultFalse()->end() ->booleanNode(Request::METHOD_DELETE)->defaultFalse()->end() ->booleanNode(Request::METHOD_PUT)->defaultFalse()->end() + ->booleanNode(Request::METHOD_PATCH)->defaultFalse()->end() ->end() ->end() ->arrayNode('roles') @@ -232,6 +233,7 @@ class Configuration implements ConfigurationInterface ->scalarNode(Request::METHOD_POST)->defaultNull()->end() ->scalarNode(Request::METHOD_DELETE)->defaultNull()->end() ->scalarNode(Request::METHOD_PUT)->defaultNull()->end() + ->scalarNode(Request::METHOD_PATCH)->defaultNull()->end() ->end() ->end() ->end() diff --git a/src/Bundle/ChillMainBundle/Doctrine/Event/TrackCreateUpdateSubscriber.php b/src/Bundle/ChillMainBundle/Doctrine/Event/TrackCreateUpdateSubscriber.php new file mode 100644 index 000000000..3926e2062 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Doctrine/Event/TrackCreateUpdateSubscriber.php @@ -0,0 +1,65 @@ +security = $security; + } + + /** + * {@inheritDoc} + */ + public function getSubscribedEvents() + { + return [ + Events::prePersist, + Events::preUpdate + ]; + } + + public function prePersist(LifecycleEventArgs $args): void + { + $object = $args->getObject(); + + if ($object instanceof TrackCreationInterface + && $this->security->getUser() instanceof User) { + $object->setCreatedBy($this->security->getUser()); + $object->setCreatedAt(new \DateTimeImmutable('now')); + } + + $this->onUpdate($object); + } + + public function preUpdate(LifecycleEventArgs $args): void + { + $object = $args->getObject(); + + $this->onUpdate($object); + } + + protected function onUpdate(object $object): void + { + if ($object instanceof TrackUpdateInterface + && $this->security->getUser() instanceof User) { + $object->setUpdatedBy($this->security->getUser()); + $object->setUpdatedAt(new \DateTimeImmutable('now')); + } + } +} diff --git a/src/Bundle/ChillMainBundle/Doctrine/Model/TrackCreationInterface.php b/src/Bundle/ChillMainBundle/Doctrine/Model/TrackCreationInterface.php new file mode 100644 index 000000000..192d4e7a9 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Doctrine/Model/TrackCreationInterface.php @@ -0,0 +1,12 @@ + + * @DiscriminatorMap(typeProperty="type", mapping={ + * "scope"=Scope::class + * }) */ class Scope { @@ -40,6 +43,7 @@ class Scope * @ORM\Id * @ORM\Column(name="id", type="integer") * @ORM\GeneratedValue(strategy="AUTO") + * @Groups({"read"}) */ private $id; @@ -49,6 +53,7 @@ class Scope * @var array * * @ORM\Column(type="json_array") + * @Groups({"read"}) */ private $name = []; diff --git a/src/Bundle/ChillMainBundle/Entity/User.php b/src/Bundle/ChillMainBundle/Entity/User.php index 1a46b30a5..11d168f84 100644 --- a/src/Bundle/ChillMainBundle/Entity/User.php +++ b/src/Bundle/ChillMainBundle/Entity/User.php @@ -7,6 +7,7 @@ use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\ArrayCollection; use Symfony\Component\Security\Core\User\AdvancedUserInterface; use Symfony\Component\Validator\Context\ExecutionContextInterface; +use Symfony\Component\Serializer\Annotation\DiscriminatorMap; /** * User @@ -14,6 +15,9 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface; * @ORM\Entity(repositoryClass="Chill\MainBundle\Repository\UserRepository") * @ORM\Table(name="users") * @ORM\Cache(usage="NONSTRICT_READ_WRITE", region="acl_cache_region") + * @DiscriminatorMap(typeProperty="type", mapping={ + * "user"=User::class + * }) */ class User implements AdvancedUserInterface { diff --git a/src/Bundle/ChillMainBundle/Resources/public/scss/chillmain.scss b/src/Bundle/ChillMainBundle/Resources/public/scss/chillmain.scss index a27dcdc42..f5eb1dc09 100644 --- a/src/Bundle/ChillMainBundle/Resources/public/scss/chillmain.scss +++ b/src/Bundle/ChillMainBundle/Resources/public/scss/chillmain.scss @@ -39,8 +39,17 @@ div.subheader { height: 130px; } -//// VUEJS //// +//// SCRATCH BUTTONS +.sc-button { + &.disabled { + cursor: default; + &.bt-remove { + background-color: #d9d9d9; + } + } +} +//// VUEJS //// div.vue-component { padding: 1.5em; margin: 2em 0; @@ -95,33 +104,47 @@ div.vue-component { } //// AddPersons modal -div.modal-body.up { - margin: auto 4em; - div.search { - position: relative; - input { - padding: 1.2em 1.5em 1.2em 2.5em; - margin: 1em 0; +div.body-head { + overflow-y: unset; + div.modal-body:first-child { + margin: auto 4em; + div.search { + position: relative; + input { + padding: 1.2em 1.5em 1.2em 2.5em; + margin: 1em 0; + } + i { + position: absolute; + opacity: 0.5; + padding: 0.65em 0; + top: 50%; + } + i.fa-search { + left: 0.5em; + } + i.fa-times { + right: 1em; + padding: 0.75em 0; + cursor: pointer; + } } - i { - position: absolute; - top: 50%; - left: 0.5em; - padding: 0.65em 0; - opacity: 0.5; - } - + } + div.modal-body:last-child { + padding-bottom: 0; } } -div.results { - div.count { - margin: -0.5em 0 0.7em; - display: flex; - justify-content: space-between; +div.count { + margin: -0.5em 0 0.7em; + display: flex; + justify-content: space-between; + a { + cursor: pointer; } +} +div.results { div.list-item { - line-height: 26pt; - padding: 0.3em 0.8em; + padding: 0.4em 0.8em; display: flex; flex-direction: row; &.checked { @@ -132,11 +155,20 @@ div.results { & > input { margin-right: 0.8em; } + span:not(.name) { + margin-left: 0.5em; + opacity: 0.5; + font-size: 90%; + font-style: italic; + } } div.right_actions { margin: 0 0 0 auto; + display: flex; + align-items: flex-end; & > * { margin-left: 0.5em; + align-self: baseline; } a.sc-button { border: 1px solid lightgrey; @@ -146,8 +178,19 @@ div.results { } } } - .discret { color: grey; margin-right: 1em; } + +a.flag-toggle { + color: white; + padding: 0 10px; + cursor: pointer; + &:hover { + color: white; + //border: 1px solid rgba(255,255,255,0.2); + text-decoration: underline; + border-radius: 20px; + } +} diff --git a/src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/App.vue b/src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/App.vue new file mode 100644 index 000000000..461b0038e --- /dev/null +++ b/src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/App.vue @@ -0,0 +1,41 @@ + + + diff --git a/src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/index.js b/src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/index.js new file mode 100644 index 000000000..27676369e --- /dev/null +++ b/src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/index.js @@ -0,0 +1,16 @@ +import { createApp } from 'vue' +import { _createI18n } from 'ChillMainAssets/vuejs/_js/i18n' +import { addressMessages } from './js/i18n' +import { store } from './store' + +import App from './App.vue'; + +const i18n = _createI18n(addressMessages); + +const app = createApp({ + template: ``, +}) +.use(store) +.use(i18n) +.component('app', App) +.mount('#address'); diff --git a/src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/js/i18n.js b/src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/js/i18n.js new file mode 100644 index 000000000..87ee9a5c0 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/js/i18n.js @@ -0,0 +1,22 @@ +const addressMessages = { + fr: { + add_an_address: 'Ajouter une adresse', + select_an_address: 'Sélectionner une adresse', + fill_an_address: 'Compléter l\'adresse', + select_country: 'Choisir le pays', + select_city: 'Choisir une localité', + select_address: 'Choisir une adresse', + isNoAddress: 'L\'adresse n\'est pas celle d\'un domicile fixe ?', + floor: 'Étage', + corridor: 'Couloir', + steps: 'Escalier', + flat: 'Appartement', + buildingName: 'Nom du batiment', + extra: 'Complément d\'adresse', + distribution: 'Service particulier de distribution' + } +}; + +export { + addressMessages +}; diff --git a/src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/store/index.js b/src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/store/index.js new file mode 100644 index 000000000..b9466b50c --- /dev/null +++ b/src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/store/index.js @@ -0,0 +1,43 @@ +import 'es6-promise/auto'; +import { createStore } from 'vuex'; + +// le fetch POST serait rangé dans la logique du composant qui appelle AddAddress +//import { postAddress } from '... api' + +const debug = process.env.NODE_ENV !== 'production'; + +const store = createStore({ + strict: debug, + state: { + address: {}, + errorMsg: {} + }, + getters: { + }, + mutations: { + addAddress(state, address) { + console.log('@M addAddress address', address); + state.address = address; + } + }, + actions: { + addAddress({ commit }, payload) { + console.log('@A addAddress payload', payload); + commit('addAddress', payload); // à remplacer par + + // fetch POST qui envoie l'adresse, et récupère la confirmation que c'est ok. + // La confirmation est l'adresse elle-même. + // + // postAddress(payload) + // .fetch(address => new Promise((resolve, reject) => { + // commit('addAddress', address); + // resolve(); + // })) + // .catch((error) => { + // state.errorMsg.push(error.message); + // }); + } + } +}); + +export { store }; diff --git a/src/Bundle/ChillMainBundle/Resources/public/vuejs/_api/AddAddress.js b/src/Bundle/ChillMainBundle/Resources/public/vuejs/_api/AddAddress.js new file mode 100644 index 000000000..4de0fcc19 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Resources/public/vuejs/_api/AddAddress.js @@ -0,0 +1,46 @@ +/* +* Endpoint countries GET +* TODO +*/ +const fetchCountries = () => { + console.log('<<< fetching countries'); + return [ + {id: 1, name: 'France', countryCode: 'FR'}, + {id: 2, name: 'Belgium', countryCode: 'BE'} + ]; +}; + +/* +* Endpoint cities GET +* TODO +*/ +const fetchCities = (country) => { + console.log('<<< fetching cities for', country); + return [ + {id: 1, name: 'Bruxelles', code: '1000', country: 'BE'}, + {id: 2, name: 'Aisne', code: '85045', country: 'FR'}, + {id: 3, name: 'Saint-Gervais', code: '85230', country: 'FR'} + ]; +}; + +/* +* Endpoint chill_main_address_reference_api_show +* method GET, get AddressReference Object +* @returns {Promise} a promise containing all AddressReference object +*/ +const fetchReferenceAddresses = (city) => { + console.log('<<< fetching references addresses for', city); // city n'est pas utilisé pour le moment + + const url = `/api/1.0/main/address-reference.json`; + return fetch(url) + .then(response => { + if (response.ok) { return response.json(); } + throw Error('Error with request resource response'); + }); +}; + +export { + fetchCountries, + fetchCities, + fetchReferenceAddresses +}; diff --git a/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/AddAddress.vue b/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/AddAddress.vue new file mode 100644 index 000000000..55cf2f098 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/AddAddress.vue @@ -0,0 +1,219 @@ + + + diff --git a/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/AddAddress/AddressMap.vue b/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/AddAddress/AddressMap.vue new file mode 100644 index 000000000..5b616819d --- /dev/null +++ b/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/AddAddress/AddressMap.vue @@ -0,0 +1,47 @@ + + + diff --git a/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/AddAddress/AddressMore.vue b/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/AddAddress/AddressMore.vue new file mode 100644 index 000000000..b216d5ed0 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/AddAddress/AddressMore.vue @@ -0,0 +1,112 @@ + + + diff --git a/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/AddAddress/AddressSelection.vue b/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/AddAddress/AddressSelection.vue new file mode 100644 index 000000000..71738079c --- /dev/null +++ b/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/AddAddress/AddressSelection.vue @@ -0,0 +1,38 @@ + + + diff --git a/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/AddAddress/CitySelection.vue b/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/AddAddress/CitySelection.vue new file mode 100644 index 000000000..99e46db08 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/AddAddress/CitySelection.vue @@ -0,0 +1,38 @@ + + + diff --git a/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/AddAddress/CountrySelection.vue b/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/AddAddress/CountrySelection.vue new file mode 100644 index 000000000..4eb591135 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/AddAddress/CountrySelection.vue @@ -0,0 +1,38 @@ + + + diff --git a/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/Modal.vue b/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/Modal.vue index cbd2d0738..e6c1475e2 100644 --- a/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/Modal.vue +++ b/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/Modal.vue @@ -9,8 +9,8 @@ -