mirror of
				https://gitlab.com/Chill-Projet/chill-bundles.git
				synced 2025-10-31 09:18:24 +00:00 
			
		
		
		
	Compare commits
	
		
			34 Commits
		
	
	
		
			testing-20
			...
			fix-compil
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 88c6e0e0d3 | |||
| f02c5bca13 | |||
| 0d56828ebd | |||
| 8b28667fe5 | |||
| 72f73ec8e7 | |||
| b3d1320c94 | |||
| 2ed42e1a2c | |||
| d0e5ba16fe | |||
| 8e65ad9476 | |||
| cf7338b690 | |||
| 63dd71037a | |||
| cc281762b3 | |||
| aa0cadfa84 | |||
| 6e2cce9531 | |||
| 1fbbf2b2ad | |||
| e586b8ee5e | |||
| 6d04e477f8 | |||
| 6b7b2ae522 | |||
| 9b9c2774ad | |||
| e902b6d409 | |||
| d8bf6a195f | |||
| 7c3152f277 | |||
| cef218fed5 | |||
| 930a76cc66 | |||
| f11f7498d7 | |||
| 1a9af6b0b1 | |||
| d347f6ae60 | |||
| 3bb911b4d0 | |||
| f00b39980c | |||
| 09882bb4be | |||
| 1d21499eab | |||
| 8ef001e67e | |||
| 458df45fa5 | |||
| 2b968b9a5b | 
| @@ -1,6 +0,0 @@ | ||||
| kind: Feature | ||||
| body: Create new filter for persons having a participation in an accompanying period | ||||
|   during a certain time span | ||||
| time: 2023-12-18T15:31:51.489901829+01:00 | ||||
| custom: | ||||
|   Issue: "231" | ||||
| @@ -1,6 +0,0 @@ | ||||
| kind: Feature | ||||
| body: '[Export][List of accompanyign period] Add two columns: the list of persons | ||||
|   participating to the period, and their ids' | ||||
| time: 2024-01-22T12:48:49.824833412+01:00 | ||||
| custom: | ||||
|   Issue: "241" | ||||
| @@ -1,5 +0,0 @@ | ||||
| kind: Feature | ||||
| body: 'Add capability to generate export about change of steps of accompanying period, and generate exports for this' | ||||
| time: 2024-01-29T13:33:19.190365565+01:00 | ||||
| custom: | ||||
|   Issue: "244" | ||||
| @@ -1,5 +0,0 @@ | ||||
| kind: Feature | ||||
| body: 'Export: group accompanying period by person participating' | ||||
| time: 2024-02-07T10:39:51.97331052+01:00 | ||||
| custom: | ||||
|   Issue: "253" | ||||
| @@ -1,5 +0,0 @@ | ||||
| kind: Feature | ||||
| body: 'Export: add filter for courses not linked to a reference address' | ||||
| time: 2024-02-07T11:46:29.491027007+01:00 | ||||
| custom: | ||||
|   Issue: "243" | ||||
| @@ -1,5 +0,0 @@ | ||||
| kind: Feature | ||||
| body: Allow to group activities linked with accompanying period by reason | ||||
| time: 2024-02-07T16:40:38.408575109+01:00 | ||||
| custom: | ||||
|   Issue: "229" | ||||
| @@ -1,6 +0,0 @@ | ||||
| kind: Fixed | ||||
| body: Fix error in logs about wrong typing of eventArgs in onEditNotificationComment | ||||
|   method | ||||
| time: 2023-11-29T11:31:38.933538592+01:00 | ||||
| custom: | ||||
|   Issue: "220" | ||||
| @@ -1,6 +0,0 @@ | ||||
| kind: Fixed | ||||
| body: Fix the conditions upon which social actions should be optional or required | ||||
|   in relation to social issues within the activity creation form | ||||
| time: 2024-01-30T14:03:01.942955636+01:00 | ||||
| custom: | ||||
|   Issue: "256" | ||||
							
								
								
									
										15
									
								
								.changes/v2.16.0.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								.changes/v2.16.0.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,15 @@ | ||||
| ## v2.16.0 - 2024-02-08 | ||||
| ### Feature | ||||
| * ([#231](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/231)) Create new filter for persons having a participation in an accompanying period during a certain time span  | ||||
| * ([#241](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/241)) [Export][List of accompanyign period] Add two columns: the list of persons participating to the period, and their ids  | ||||
| * ([#244](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/244)) Add capability to generate export about change of steps of accompanying period, and generate exports for this  | ||||
| * ([#253](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/253)) Export: group accompanying period by person participating  | ||||
| * ([#243](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/243)) Export: add filter for courses not linked to a reference address  | ||||
| * ([#229](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/229)) Allow to group activities linked with accompanying period by reason  | ||||
| * ([#115](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/115)) Prevent social work to be saved when another user edited conccurently the social work  | ||||
| * Modernize the event bundle, with some new fields and multiple improvements  | ||||
| ### Fixed | ||||
| * ([#220](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/220)) Fix error in logs about wrong typing of eventArgs in onEditNotificationComment method  | ||||
| * ([#256](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/256)) Fix the conditions upon which social actions should be optional or required in relation to social issues within the activity creation form  | ||||
| ### UX | ||||
| * ([#260](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/260)) Order list of centers alphabetically in dropdown 'user' section admin.  | ||||
							
								
								
									
										3
									
								
								.changes/v2.16.1.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								.changes/v2.16.1.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | ||||
| ## v2.16.1 - 2024-02-09 | ||||
| ### Fixed | ||||
| * Force bootstrap version to avoid error in builds with newer version  | ||||
							
								
								
									
										20
									
								
								CHANGELOG.md
									
									
									
									
									
								
							
							
						
						
									
										20
									
								
								CHANGELOG.md
									
									
									
									
									
								
							| @@ -6,6 +6,26 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html), | ||||
| and is generated by [Changie](https://github.com/miniscruff/changie). | ||||
|  | ||||
|  | ||||
| ## v2.16.1 - 2024-02-09 | ||||
| ### Fixed | ||||
| * Force bootstrap version to avoid error in builds with newer version  | ||||
|  | ||||
| ## v2.16.0 - 2024-02-08 | ||||
| ### Feature | ||||
| * ([#231](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/231)) Create new filter for persons having a participation in an accompanying period during a certain time span  | ||||
| * ([#241](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/241)) [Export][List of accompanyign period] Add two columns: the list of persons participating to the period, and their ids  | ||||
| * ([#244](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/244)) Add capability to generate export about change of steps of accompanying period, and generate exports for this  | ||||
| * ([#253](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/253)) Export: group accompanying period by person participating  | ||||
| * ([#243](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/243)) Export: add filter for courses not linked to a reference address  | ||||
| * ([#229](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/229)) Allow to group activities linked with accompanying period by reason  | ||||
| * ([#115](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/115)) Prevent social work to be saved when another user edited conccurently the social work  | ||||
| * Modernize the event bundle, with some new fields and multiple improvements  | ||||
| ### Fixed | ||||
| * ([#220](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/220)) Fix error in logs about wrong typing of eventArgs in onEditNotificationComment method  | ||||
| * ([#256](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/256)) Fix the conditions upon which social actions should be optional or required in relation to social issues within the activity creation form  | ||||
| ### UX | ||||
| * ([#260](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/260)) Order list of centers alphabetically in dropdown 'user' section admin.  | ||||
|  | ||||
| ## v2.15.2 - 2024-01-11 | ||||
| ### Fixed | ||||
| * Fix the id_seq used when creating a new accompanying period participation during fusion of two person files  | ||||
|   | ||||
| @@ -9,7 +9,6 @@ | ||||
|     ], | ||||
|     "require": { | ||||
|         "php": "^8.2", | ||||
|         "ext-dom": "*", | ||||
|         "ext-json": "*", | ||||
|         "ext-openssl": "*", | ||||
|         "ext-redis": "*", | ||||
|   | ||||
| @@ -14,8 +14,8 @@ | ||||
|     "@ckeditor/ckeditor5-vue": "^4.0.1", | ||||
|     "@symfony/webpack-encore": "^4.1.0", | ||||
|     "@tsconfig/node14": "^1.0.1", | ||||
|     "@types/dompurify": "^3.0.5", | ||||
|     "bindings": "^1.5.0", | ||||
|     "bootstrap": "5.2.3", | ||||
|     "chokidar": "^3.5.1", | ||||
|     "fork-awesome": "^1.1.7", | ||||
|     "jquery": "^3.6.0", | ||||
| @@ -34,7 +34,6 @@ | ||||
|     "webpack-cli": "^5.0.1" | ||||
|   }, | ||||
|   "dependencies": { | ||||
|     "bootstrap": "~5.2.0", | ||||
|     "@fullcalendar/core": "^6.1.4", | ||||
|     "@fullcalendar/daygrid": "^6.1.4", | ||||
|     "@fullcalendar/interaction": "^6.1.4", | ||||
| @@ -43,11 +42,9 @@ | ||||
|     "@fullcalendar/vue3": "^6.1.4", | ||||
|     "@popperjs/core": "^2.9.2", | ||||
|     "@types/leaflet": "^1.9.3", | ||||
|     "dompurify": "^3.0.6", | ||||
|     "dropzone": "^5.7.6", | ||||
|     "es6-promise": "^4.2.8", | ||||
|     "leaflet": "^1.7.1", | ||||
|     "marked": "^9.1.5", | ||||
|     "masonry-layout": "^4.2.2", | ||||
|     "mime": "^3.0.0", | ||||
|     "swagger-ui": "^4.15.5", | ||||
|   | ||||
| @@ -1,66 +0,0 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\DocStoreBundle\Tests\Security\Guard; | ||||
|  | ||||
| use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum; | ||||
| use Chill\DocStoreBundle\Security\Guard\DavTokenAuthenticationEventSubscriber; | ||||
| use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTAuthenticatedEvent; | ||||
| use PHPUnit\Framework\TestCase; | ||||
| use Symfony\Component\Security\Core\Authentication\Token\AbstractToken; | ||||
|  | ||||
| /** | ||||
|  * @internal | ||||
|  * | ||||
|  * @coversNothing | ||||
|  */ | ||||
| class DavTokenAuthenticationEventSubscriberTest extends TestCase | ||||
| { | ||||
|     public function testOnJWTAuthenticatedWithDavDataInPayload(): void | ||||
|     { | ||||
|         $eventSubscriber = new DavTokenAuthenticationEventSubscriber(); | ||||
|         $token = new class () extends AbstractToken { | ||||
|             public function getCredentials() | ||||
|             { | ||||
|                 return null; | ||||
|             } | ||||
|         }; | ||||
|         $event = new JWTAuthenticatedEvent([ | ||||
|             'dav' => 1, | ||||
|             'so' => '1234', | ||||
|             'e' => 1, | ||||
|         ], $token); | ||||
|  | ||||
|         $eventSubscriber->onJWTAuthenticated($event); | ||||
|  | ||||
|         self::assertTrue($token->hasAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT)); | ||||
|         self::assertTrue($token->hasAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS)); | ||||
|         self::assertEquals('1234', $token->getAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT)); | ||||
|         self::assertEquals(StoredObjectRoleEnum::EDIT, $token->getAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS)); | ||||
|     } | ||||
|  | ||||
|     public function testOnJWTAuthenticatedWithDavNoDataInPayload(): void | ||||
|     { | ||||
|         $eventSubscriber = new DavTokenAuthenticationEventSubscriber(); | ||||
|         $token = new class () extends AbstractToken { | ||||
|             public function getCredentials() | ||||
|             { | ||||
|                 return null; | ||||
|             } | ||||
|         }; | ||||
|         $event = new JWTAuthenticatedEvent([], $token); | ||||
|  | ||||
|         $eventSubscriber->onJWTAuthenticated($event); | ||||
|  | ||||
|         self::assertFalse($token->hasAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT)); | ||||
|         self::assertFalse($token->hasAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS)); | ||||
|     } | ||||
| } | ||||
| @@ -1,252 +0,0 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\DocStoreBundle\Controller; | ||||
|  | ||||
| use Chill\DocStoreBundle\Dav\Request\PropfindRequestAnalyzer; | ||||
| use Chill\DocStoreBundle\Dav\Response\DavResponse; | ||||
| use Chill\DocStoreBundle\Entity\StoredObject; | ||||
| use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum; | ||||
| use Chill\DocStoreBundle\Service\StoredObjectManagerInterface; | ||||
| use Symfony\Component\HttpFoundation\Request; | ||||
| use Symfony\Component\HttpFoundation\Response; | ||||
| use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; | ||||
| use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; | ||||
| use Symfony\Component\Routing\Annotation\Route; | ||||
| use Symfony\Component\Security\Core\Security; | ||||
|  | ||||
| /** | ||||
|  * Provide endpoint for editing a document on the desktop using dav. | ||||
|  * | ||||
|  * This controller implements the minimal required methods to edit a document on a desktop software (i.e. LibreOffice) | ||||
|  * and save the document online. | ||||
|  * | ||||
|  * To avoid to ask for a password, the endpoints are protected using a JWT access token, which is inside the | ||||
|  * URL. This avoid the DAV Client (LibreOffice) to keep an access token in query parameter or in some header (which | ||||
|  * they are not able to understand). The JWT Guard is adapted with a dedicated token extractor which is going to read | ||||
|  * the segments (separation of "/"): the first segment must be the string "dav", and the second one must be the JWT. | ||||
|  */ | ||||
| final readonly class WebdavController | ||||
| { | ||||
|     private PropfindRequestAnalyzer $requestAnalyzer; | ||||
|  | ||||
|     public function __construct( | ||||
|         private \Twig\Environment $engine, | ||||
|         private StoredObjectManagerInterface $storedObjectManager, | ||||
|         private Security $security, | ||||
|     ) { | ||||
|         $this->requestAnalyzer = new PropfindRequestAnalyzer(); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @Route("/dav/{access_token}/get/{uuid}/", methods={"GET", "HEAD"}, name="chill_docstore_dav_directory_get") | ||||
|      */ | ||||
|     public function getDirectory(StoredObject $storedObject, string $access_token): Response | ||||
|     { | ||||
|         if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) { | ||||
|             throw new AccessDeniedHttpException(); | ||||
|         } | ||||
|  | ||||
|         return new DavResponse( | ||||
|             $this->engine->render('@ChillDocStore/Webdav/directory.html.twig', [ | ||||
|                 'stored_object' => $storedObject, | ||||
|                 'access_token' => $access_token, | ||||
|             ]) | ||||
|         ); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @Route("/dav/{access_token}/get/{uuid}/", methods={"OPTIONS"}) | ||||
|      */ | ||||
|     public function optionsDirectory(StoredObject $storedObject): Response | ||||
|     { | ||||
|         if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) { | ||||
|             throw new AccessDeniedHttpException(); | ||||
|         } | ||||
|  | ||||
|         $response = (new DavResponse('')) | ||||
|             ->setEtag($this->storedObjectManager->etag($storedObject)) | ||||
|         ; | ||||
|  | ||||
|         // $response->headers->add(['Allow' =>  'OPTIONS,GET,HEAD,DELETE,PROPFIND,PUT,PROPPATCH,COPY,MOVE,REPORT,PATCH,POST,TRACE']); | ||||
|         $response->headers->add(['Allow' =>  'OPTIONS,GET,HEAD,DELETE,PROPFIND,PUT']); | ||||
|  | ||||
|         return $response; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @Route("/dav/{access_token}/get/{uuid}/", methods={"PROPFIND"}) | ||||
|      */ | ||||
|     public function propfindDirectory(StoredObject $storedObject, string $access_token, Request $request): Response | ||||
|     { | ||||
|         if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) { | ||||
|             throw new AccessDeniedHttpException(); | ||||
|         } | ||||
|  | ||||
|         $depth = $request->headers->get('depth'); | ||||
|  | ||||
|         if ('0' !== $depth && '1' !== $depth) { | ||||
|             throw new BadRequestHttpException('only 1 and 0 are accepted for Depth header'); | ||||
|         } | ||||
|  | ||||
|         [$properties, $lastModified, $etag, $length] = $this->parseDavRequest($request->getContent(), $storedObject); | ||||
|  | ||||
|         $response = new DavResponse( | ||||
|             $this->engine->render('@ChillDocStore/Webdav/directory_propfind.xml.twig', [ | ||||
|                 'stored_object' => $storedObject, | ||||
|                 'properties' => $properties, | ||||
|                 'last_modified' => $lastModified, | ||||
|                 'etag' => $etag, | ||||
|                 'content_length' => $length, | ||||
|                 'depth' => (int) $depth, | ||||
|                 'access_token' => $access_token, | ||||
|             ]), | ||||
|             207 | ||||
|         ); | ||||
|  | ||||
|         $response->headers->add([ | ||||
|             'Content-Type' => 'text/xml', | ||||
|         ]); | ||||
|  | ||||
|         return $response; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @Route("/dav/{access_token}/get/{uuid}/d", name="chill_docstore_dav_document_get", methods={"GET"}) | ||||
|      */ | ||||
|     public function getDocument(StoredObject $storedObject): Response | ||||
|     { | ||||
|         if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) { | ||||
|             throw new AccessDeniedHttpException(); | ||||
|         } | ||||
|  | ||||
|         return (new DavResponse($this->storedObjectManager->read($storedObject))) | ||||
|             ->setEtag($this->storedObjectManager->etag($storedObject)); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @Route("/dav/{access_token}/get/{uuid}/d", methods={"HEAD"}) | ||||
|      */ | ||||
|     public function headDocument(StoredObject $storedObject): Response | ||||
|     { | ||||
|         if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) { | ||||
|             throw new AccessDeniedHttpException(); | ||||
|         } | ||||
|  | ||||
|         $response = new DavResponse(''); | ||||
|  | ||||
|         $response->headers->add( | ||||
|             [ | ||||
|                 'Content-Length' => $this->storedObjectManager->getContentLength($storedObject), | ||||
|                 'Content-Type' => $storedObject->getType(), | ||||
|                 'Etag' => $this->storedObjectManager->etag($storedObject), | ||||
|             ] | ||||
|         ); | ||||
|  | ||||
|         return $response; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @Route("/dav/{access_token}/get/{uuid}/d", methods={"OPTIONS"}) | ||||
|      */ | ||||
|     public function optionsDocument(StoredObject $storedObject): Response | ||||
|     { | ||||
|         if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) { | ||||
|             throw new AccessDeniedHttpException(); | ||||
|         } | ||||
|  | ||||
|         $response = (new DavResponse('')) | ||||
|             ->setEtag($this->storedObjectManager->etag($storedObject)) | ||||
|         ; | ||||
|  | ||||
|         $response->headers->add(['Allow' =>  'OPTIONS,GET,HEAD,DELETE,PROPFIND,PUT']); | ||||
|  | ||||
|         return $response; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @Route("/dav/{access_token}/get/{uuid}/d", methods={"PROPFIND"}) | ||||
|      */ | ||||
|     public function propfindDocument(StoredObject $storedObject, string $access_token, Request $request): Response | ||||
|     { | ||||
|         if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) { | ||||
|             throw new AccessDeniedHttpException(); | ||||
|         } | ||||
|  | ||||
|         [$properties, $lastModified, $etag, $length] = $this->parseDavRequest($request->getContent(), $storedObject); | ||||
|  | ||||
|         $response = new DavResponse( | ||||
|             $this->engine->render( | ||||
|                 '@ChillDocStore/Webdav/doc_props.xml.twig', | ||||
|                 [ | ||||
|                     'stored_object' => $storedObject, | ||||
|                     'properties' => $properties, | ||||
|                     'etag' => $etag, | ||||
|                     'last_modified' => $lastModified, | ||||
|                     'content_length' => $length, | ||||
|                     'access_token' => $access_token, | ||||
|                 ] | ||||
|             ), | ||||
|             207 | ||||
|         ); | ||||
|  | ||||
|         $response | ||||
|             ->headers->add([ | ||||
|                 'Content-Type' => 'text/xml', | ||||
|             ]); | ||||
|  | ||||
|         return $response; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @Route("/dav/{access_token}/get/{uuid}/d", methods={"PUT"}) | ||||
|      */ | ||||
|     public function putDocument(StoredObject $storedObject, Request $request): Response | ||||
|     { | ||||
|         if (!$this->security->isGranted(StoredObjectRoleEnum::EDIT->value, $storedObject)) { | ||||
|             throw new AccessDeniedHttpException(); | ||||
|         } | ||||
|  | ||||
|         $this->storedObjectManager->write($storedObject, $request->getContent()); | ||||
|  | ||||
|         return new DavResponse('', Response::HTTP_NO_CONTENT); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @return array{0: array, 1: \DateTimeInterface, 2: string, 3: int} properties, lastModified, etag, length | ||||
|      */ | ||||
|     private function parseDavRequest(string $content, StoredObject $storedObject): array | ||||
|     { | ||||
|         $xml = new \DOMDocument(); | ||||
|         $xml->loadXML($content); | ||||
|  | ||||
|         $properties = $this->requestAnalyzer->getRequestedProperties($xml); | ||||
|         $requested = array_keys(array_filter($properties, fn ($item) => true === $item)); | ||||
|  | ||||
|         if ( | ||||
|             in_array('lastModified', $requested, true) | ||||
|             || in_array('etag', $requested, true) | ||||
|         ) { | ||||
|             $lastModified = $this->storedObjectManager->getLastModified($storedObject); | ||||
|             $etag = $this->storedObjectManager->etag($storedObject); | ||||
|         } | ||||
|         if (in_array('contentLength', $requested, true)) { | ||||
|             $length = $this->storedObjectManager->getContentLength($storedObject); | ||||
|         } | ||||
|  | ||||
|         return [ | ||||
|             $properties, | ||||
|             $lastModified ?? null, | ||||
|             $etag ?? null, | ||||
|             $length ?? null, | ||||
|         ]; | ||||
|     } | ||||
| } | ||||
| @@ -1,16 +0,0 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\DocStoreBundle\Dav\Exception; | ||||
|  | ||||
| class ParseRequestException extends \UnexpectedValueException | ||||
| { | ||||
| } | ||||
| @@ -1,103 +0,0 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\DocStoreBundle\Dav\Request; | ||||
|  | ||||
| use Chill\DocStoreBundle\Dav\Exception\ParseRequestException; | ||||
|  | ||||
| /** | ||||
|  * @phpstan-type davProperties array{resourceType: bool, contentType: bool, lastModified: bool, creationDate: bool, contentLength: bool, etag: bool, supportedLock: bool, unknowns: list<array{xmlns: string, prop: string}>} | ||||
|  */ | ||||
| class PropfindRequestAnalyzer | ||||
| { | ||||
|     private const KNOWN_PROPS = [ | ||||
|         'resourceType', | ||||
|         'contentType', | ||||
|         'lastModified', | ||||
|         'creationDate', | ||||
|         'contentLength', | ||||
|         'etag', | ||||
|         'supportedLock', | ||||
|     ]; | ||||
|  | ||||
|     /** | ||||
|      * @return davProperties | ||||
|      */ | ||||
|     public function getRequestedProperties(\DOMDocument $request): array | ||||
|     { | ||||
|         $propfinds = $request->getElementsByTagNameNS('DAV:', 'propfind'); | ||||
|  | ||||
|         if (0 === $propfinds->count()) { | ||||
|             throw new ParseRequestException('any propfind element found'); | ||||
|         } | ||||
|  | ||||
|         if (1 < $propfinds->count()) { | ||||
|             throw new ParseRequestException('too much propfind element found'); | ||||
|         } | ||||
|  | ||||
|         $propfind = $propfinds->item(0); | ||||
|  | ||||
|         if (0 === $propfind->childNodes->count()) { | ||||
|             throw new ParseRequestException('no element under propfind'); | ||||
|         } | ||||
|  | ||||
|         $unknows = []; | ||||
|         $props = []; | ||||
|  | ||||
|         foreach ($propfind->childNodes->getIterator() as $prop) { | ||||
|             /** @var \DOMNode $prop */ | ||||
|             if (XML_ELEMENT_NODE !== $prop->nodeType) { | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             if ('propname' === $prop->nodeName) { | ||||
|                 return $this->baseProps(true); | ||||
|             } | ||||
|  | ||||
|             foreach ($prop->childNodes->getIterator() as $getProp) { | ||||
|                 if (XML_ELEMENT_NODE !== $getProp->nodeType) { | ||||
|                     continue; | ||||
|                 } | ||||
|  | ||||
|                 if ('DAV:' !== $getProp->lookupNamespaceURI(null)) { | ||||
|                     $unknows[] = ['xmlns' => $getProp->lookupNamespaceURI(null), 'prop' => $getProp->nodeName]; | ||||
|                     continue; | ||||
|                 } | ||||
|  | ||||
|                 $props[] = match ($getProp->nodeName) { | ||||
|                     'resourcetype' => 'resourceType', | ||||
|                     'getcontenttype' => 'contentType', | ||||
|                     'getlastmodified' => 'lastModified', | ||||
|                     default => '', | ||||
|                 }; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         $props = array_filter(array_values($props), fn (string $item) => '' !== $item); | ||||
|  | ||||
|         return [...$this->baseProps(false), ...array_combine($props, array_fill(0, count($props), true)), 'unknowns' => $unknows]; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @return davProperties | ||||
|      */ | ||||
|     private function baseProps(bool $default = false): array | ||||
|     { | ||||
|         return | ||||
|             [ | ||||
|                 ...array_combine( | ||||
|                     self::KNOWN_PROPS, | ||||
|                     array_fill(0, count(self::KNOWN_PROPS), $default) | ||||
|                 ), | ||||
|                 'unknowns' => [], | ||||
|             ]; | ||||
|     } | ||||
| } | ||||
| @@ -1,24 +0,0 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\DocStoreBundle\Dav\Response; | ||||
|  | ||||
| use Symfony\Component\HttpFoundation\Response; | ||||
|  | ||||
| class DavResponse extends Response | ||||
| { | ||||
|     public function __construct($content = '', int $status = 200, array $headers = []) | ||||
|     { | ||||
|         parent::__construct($content, $status, $headers); | ||||
|  | ||||
|         $this->headers->add(['DAV' => '1']); | ||||
|     } | ||||
| } | ||||
| @@ -17,22 +17,18 @@ window.addEventListener('DOMContentLoaded', function (e) { | ||||
|            canEdit: string, | ||||
|            storedObject: string, | ||||
|            buttonSmall: string, | ||||
|            davLink: string, | ||||
|            davLinkExpiration: string, | ||||
|          }; | ||||
|  | ||||
|          const | ||||
|            storedObject = JSON.parse(datasets.storedObject) as StoredObject, | ||||
|            filename = datasets.filename, | ||||
|            canEdit = datasets.canEdit === '1', | ||||
|            small = datasets.buttonSmall === '1', | ||||
|            davLink = 'davLink' in datasets && datasets.davLink !== '' ? datasets.davLink : null, | ||||
|            davLinkExpiration = 'davLinkExpiration' in datasets  ? Number.parseInt(datasets.davLinkExpiration) : null | ||||
|            small = datasets.buttonSmall === '1' | ||||
|            ; | ||||
|  | ||||
|          return { storedObject, filename, canEdit, small, davLink, davLinkExpiration }; | ||||
|          return { storedObject, filename, canEdit, small }; | ||||
|        }, | ||||
|        template: '<document-action-buttons-group :can-edit="canEdit" :filename="filename" :stored-object="storedObject" :small="small" :dav-link="davLink" :dav-link-expiration="davLinkExpiration" @on-stored-object-status-change="onStoredObjectStatusChange"></document-action-buttons-group>', | ||||
|        template: '<document-action-buttons-group :can-edit="canEdit" :filename="filename" :stored-object="storedObject" :small="small" @on-stored-object-status-change="onStoredObjectStatusChange"></document-action-buttons-group>', | ||||
|        methods: { | ||||
|          onStoredObjectStatusChange: function(newStatus: StoredObjectStatusChange): void { | ||||
|            this.$data.storedObject.status = newStatus.status; | ||||
|   | ||||
| @@ -7,9 +7,6 @@ | ||||
|       <li v-if="props.canEdit && is_extension_editable(props.storedObject.type)"> | ||||
|         <wopi-edit-button :stored-object="props.storedObject" :classes="{'dropdown-item': true}" :execute-before-leave="props.executeBeforeLeave"></wopi-edit-button> | ||||
|       </li> | ||||
|       <li v-if="props.canEdit && is_extension_editable(props.storedObject.type) && props.davLink !== undefined && props.davLinkExpiration !== undefined"> | ||||
|         <desktop-edit-button :classes="{'dropdown-item': true}" :edit-link="props.davLink" :expiration-link="props.davLinkExpiration"></desktop-edit-button> | ||||
|       </li> | ||||
|       <li v-if="props.storedObject.type != 'application/pdf' && is_extension_viewable(props.storedObject.type) && props.canConvertPdf"> | ||||
|         <convert-button :stored-object="props.storedObject" :filename="filename" :classes="{'dropdown-item': true}"></convert-button> | ||||
|       </li> | ||||
| @@ -39,7 +36,6 @@ import { | ||||
|   StoredObjectStatusChange, | ||||
|   WopiEditButtonExecutableBeforeLeaveFunction | ||||
| } from "../types"; | ||||
| import DesktopEditButton from "ChillDocStoreAssets/vuejs/StoredObjectButton/DesktopEditButton.vue"; | ||||
|  | ||||
| interface DocumentActionButtonsGroupConfig { | ||||
|   storedObject: StoredObject, | ||||
| @@ -61,16 +57,6 @@ interface DocumentActionButtonsGroupConfig { | ||||
|    * If set, will execute this function before leaving to the editor | ||||
|    */ | ||||
|   executeBeforeLeave?: WopiEditButtonExecutableBeforeLeaveFunction, | ||||
|  | ||||
|   /** | ||||
|    * a link to download and edit file using webdav | ||||
|    */ | ||||
|   davLink?: string, | ||||
|  | ||||
|   /** | ||||
|    * the expiration date of the download, as a unix timestamp | ||||
|    */ | ||||
|   davLinkExpiration?: number, | ||||
| } | ||||
|  | ||||
| const emit = defineEmits<{ | ||||
| @@ -82,7 +68,7 @@ const props = withDefaults(defineProps<DocumentActionButtonsGroupConfig>(), { | ||||
|   canEdit: true, | ||||
|   canDownload: true, | ||||
|   canConvertPdf: true, | ||||
|   returnPath: window.location.pathname + window.location.search + window.location.hash | ||||
|   returnPath: window.location.pathname + window.location.search + window.location.hash, | ||||
| }); | ||||
|  | ||||
| /** | ||||
|   | ||||
| @@ -1,66 +0,0 @@ | ||||
| <script setup lang="ts"> | ||||
|  | ||||
| import Modal from "ChillMainAssets/vuejs/_components/Modal.vue"; | ||||
| import {computed, reactive} from "vue"; | ||||
|  | ||||
| export interface DesktopEditButtonConfig { | ||||
|     editLink: null, | ||||
|     classes: { [k: string]: boolean }, | ||||
|     expirationLink: number|Date, | ||||
| } | ||||
|  | ||||
| interface DesktopEditButtonState { | ||||
|     modalOpened: boolean | ||||
| }; | ||||
|  | ||||
| const state: DesktopEditButtonState = reactive({modalOpened: false}); | ||||
|  | ||||
| const props = defineProps<DesktopEditButtonConfig>(); | ||||
|  | ||||
| const buildCommand = computed<string>(() => 'vnd.libreoffice.command:ofe|u|' + props.editLink); | ||||
|  | ||||
| const editionUntilFormatted = computed<string>(() => { | ||||
|     let d; | ||||
|  | ||||
|     if (props.expirationLink instanceof Date) { | ||||
|         d = props.expirationLink; | ||||
|     } else { | ||||
|         d = new Date(props.expirationLink * 1000); | ||||
|     } | ||||
|     console.log(props.expirationLink); | ||||
|  | ||||
|     return (new Intl.DateTimeFormat(undefined, {'dateStyle': 'long', 'timeStyle': 'medium'})).format(d); | ||||
| }); | ||||
|  | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|     <teleport to="body"> | ||||
|         <modal v-if="state.modalOpened" @close="state.modalOpened=false"> | ||||
|             <template v-slot:body> | ||||
|                 <div class="desktop-edit"> | ||||
|                     <p class="center">Veuillez enregistrer vos modifications avant le</p> | ||||
|                     <p><strong>{{ editionUntilFormatted }}</strong></p> | ||||
|  | ||||
|                     <p><a class="btn btn-primary" :href="buildCommand">Ouvrir le document pour édition</a></p> | ||||
|  | ||||
|                     <p><small>Le document peut être édité uniquement en utilisant Libre Office.</small></p> | ||||
|  | ||||
|                     <p><small>En cas d'échec lors de l'enregistrement, sauver le document sur le poste de travail avant de le déposer à nouveau ici.</small></p> | ||||
|  | ||||
|                     <p><small>Vous pouvez naviguez sur d'autres pages pendant l'édition.</small></p> | ||||
|                 </div> | ||||
|             </template> | ||||
|         </modal> | ||||
|     </teleport> | ||||
|     <a :class="props.classes" @click="state.modalOpened = true"> | ||||
|         <i class="fa fa-desktop"></i> | ||||
|         Éditer sur le bureau | ||||
|     </a> | ||||
| </template> | ||||
|  | ||||
| <style scoped lang="scss"> | ||||
| .desktop-edit { | ||||
|     text-align: center; | ||||
| } | ||||
| </style> | ||||
| @@ -3,7 +3,5 @@ | ||||
|     data-download-buttons | ||||
|     data-stored-object="{{ document_json|json_encode|escape('html_attr') }}" | ||||
|     data-can-edit="{{ can_edit ? '1' : '0' }}" | ||||
|     data-dav-link="{{ dav_link|escape('html_attr') }}" | ||||
|     data-dav-link-expiration="{{ dav_link_expiration|escape('html_attr') }}" | ||||
|     {% if options['small'] is defined %}data-button-small="{{ options['small'] ? '1' : '0' }}"{% endif %} | ||||
|     {% if title|default(document.title)|default(null) is not null %}data-filename="{{ title|default(document.title)|escape('html_attr') }}"{% endif %}></div> | ||||
|   | ||||
| @@ -1,12 +0,0 @@ | ||||
| <!DOCTYPE html> | ||||
| <html lang="en"> | ||||
| <head> | ||||
|     <meta charset="utf-8"> | ||||
|     <title>Directory for {{ stored_object.uuid }}</title> | ||||
| </head> | ||||
| <body> | ||||
| <ul> | ||||
|     <li><a href="{{ absolute_url(path('chill_docstore_dav_document_get', {'uuid': stored_object.uuid, 'access_token': access_token })) }}">d</a></li> | ||||
| </ul> | ||||
| </body> | ||||
| </html> | ||||
| @@ -1,81 +0,0 @@ | ||||
| <?xml version="1.0" encoding="UTF-8" ?> | ||||
| <d:multistatus xmlns:d="DAV:"> | ||||
|     <d:response> | ||||
|         <d:href>{{ path('chill_docstore_dav_directory_get', { 'uuid': stored_object.uuid, 'access_token': access_token } ) }}</d:href> | ||||
|         {% if properties.resourceType or properties.contentType %} | ||||
|             <d:propstat> | ||||
|                 <d:prop> | ||||
|                     {% if properties.resourceType %} | ||||
|                         <d:resourcetype><d:collection/></d:resourcetype> | ||||
|                     {% endif %} | ||||
|                     {% if properties.contentType %} | ||||
|                         <d:getcontenttype>httpd/unix-directory</d:getcontenttype> | ||||
|                     {% endif %} | ||||
|                 </d:prop> | ||||
|                 <d:status>HTTP/1.1 200 OK</d:status> | ||||
|             </d:propstat> | ||||
|         {% endif %} | ||||
|         {% if properties.unknowns|length > 0 %} | ||||
|             <d:propstat> | ||||
|                 {% for k,u in properties.unknowns %} | ||||
|                     <d:prop {{ ('xmlns:ns' ~ k ~ '="' ~ u.xmlns|e('html_attr') ~ '"')|raw }}> | ||||
|                         <{{ 'ns'~ k ~ ':' ~ u.prop }} /> | ||||
|                     </d:prop> | ||||
|                 {% endfor %} | ||||
|                 <d:status>HTTP/1.1 404 Not Found</d:status> | ||||
|             </d:propstat> | ||||
|         {% endif %} | ||||
|     </d:response> | ||||
|     {% if depth == 1 %} | ||||
|         <d:response> | ||||
|             <d:href>{{ path('chill_docstore_dav_document_get', {'uuid': stored_object.uuid, 'access_token':access_token}) }}</d:href> | ||||
|             {% if properties.lastModified or properties.contentLength or properties.resourceType or properties.etag or properties.contentType or properties.creationDate %} | ||||
|                 <d:propstat> | ||||
|                     <d:prop> | ||||
|                         {% if properties.resourceType %} | ||||
|                             <d:resourcetype/> | ||||
|                         {% endif %} | ||||
|                         {% if properties.creationDate %} | ||||
|                             <d:creationdate /> | ||||
|                         {% endif %} | ||||
|                         {% if properties.lastModified %} | ||||
|                             {%  if last_modified is not same as null %} | ||||
|                                 <d:getlastmodified>{{  last_modified.format(constant('DATE_RSS')) }}</d:getlastmodified> | ||||
|                             {% else %} | ||||
|                                 <d:getlastmodified /> | ||||
|                             {% endif %} | ||||
|                         {% endif %} | ||||
|                         {% if properties.contentLength %} | ||||
|                             {%  if content_length is not same as null %} | ||||
|                                 <d:getcontentlength>{{ content_length }}</d:getcontentlength> | ||||
|                             {% else %} | ||||
|                                 <d:getcontentlength /> | ||||
|                             {% endif %} | ||||
|                         {% endif %} | ||||
|                         {% if properties.etag %} | ||||
|                             {% if etag is not same as null %} | ||||
|                                 <d:getetag>"{{ etag }}"</d:getetag> | ||||
|                             {% else %} | ||||
|                                 <d:getetag /> | ||||
|                             {% endif %} | ||||
|                         {% endif %} | ||||
|                         {% if properties.contentType %} | ||||
|                             <d:getcontenttype>{{ stored_object.type }}</d:getcontenttype> | ||||
|                         {% endif %} | ||||
|                     </d:prop> | ||||
|                     <d:status>HTTP/1.1 200 OK</d:status> | ||||
|                 </d:propstat> | ||||
|             {% endif %} | ||||
|             {% if properties.unknowns|length > 0 %} | ||||
|                 <d:propstat> | ||||
|                     {% for k,u in properties.unknowns %} | ||||
|                         <d:prop {{ ('xmlns:ns' ~ k ~ '="' ~ u.xmlns|e('html_attr') ~ '"')|raw }}> | ||||
|                             <{{ 'ns'~ k ~ ':' ~ u.prop }} /> | ||||
|                         </d:prop> | ||||
|                     {% endfor %} | ||||
|                     <d:status>HTTP/1.1 404 Not Found</d:status> | ||||
|                 </d:propstat> | ||||
|             {% endif %} | ||||
|         </d:response> | ||||
|     {% endif %} | ||||
| </d:multistatus> | ||||
| @@ -1,53 +0,0 @@ | ||||
| <?xml version="1.0" encoding="UTF-8" ?> | ||||
| <d:multistatus xmlns:d="DAV:"> | ||||
|     <d:response> | ||||
|         <d:href>{{ path('chill_docstore_dav_document_get', {'uuid': stored_object.uuid, 'access_token': access_token}) }}</d:href> | ||||
|         {% if properties.lastModified or properties.contentLength or properties.resourceType or properties.etag or properties.contentType or properties.creationDate %} | ||||
|             <d:propstat> | ||||
|                 <d:prop> | ||||
|                     {% if properties.resourceType %} | ||||
|                         <d:resourcetype/> | ||||
|                     {% endif %} | ||||
|                     {% if properties.creationDate %} | ||||
|                         <d:creationdate /> | ||||
|                     {% endif %} | ||||
|                     {% if properties.lastModified %} | ||||
|                         {%  if last_modified is not same as null %} | ||||
|                             <d:getlastmodified>{{  last_modified.format(constant('DATE_RSS')) }}</d:getlastmodified> | ||||
|                         {% else %} | ||||
|                             <d:getlastmodified /> | ||||
|                         {% endif %} | ||||
|                     {% endif %} | ||||
|                     {% if properties.contentLength %} | ||||
|                         {%  if content_length is not same as null %} | ||||
|                         <d:getcontentlength>{{ content_length }}</d:getcontentlength> | ||||
|                         {% else %} | ||||
|                         <d:getcontentlength /> | ||||
|                         {% endif %} | ||||
|                     {% endif %} | ||||
|                     {% if properties.etag %} | ||||
|                         {% if etag is not same as null %} | ||||
|                             <d:getetag>"{{ etag }}"</d:getetag> | ||||
|                         {% else %} | ||||
|                             <d:getetag /> | ||||
|                         {% endif %} | ||||
|                     {% endif %} | ||||
|                     {% if properties.contentType %} | ||||
|                         <d:getcontenttype>{{ stored_object.type }}</d:getcontenttype> | ||||
|                     {% endif %} | ||||
|                 </d:prop> | ||||
|                 <d:status>HTTP/1.1 200 OK</d:status> | ||||
|             </d:propstat> | ||||
|         {% endif %} | ||||
|         {% if properties.unknowns|length > 0 %} | ||||
|             <d:propstat> | ||||
|                 {% for k,u in properties.unknowns %} | ||||
|                     <d:prop {{ ('xmlns:ns' ~ k ~ '="' ~ u.xmlns|e('html_attr') ~ '"')|raw }}> | ||||
|                         <{{ 'ns'~ k ~ ':' ~ u.prop }} /> | ||||
|                     </d:prop> | ||||
|                 {% endfor %} | ||||
|                 <d:status>HTTP/1.1 404 Not Found</d:status> | ||||
|             </d:propstat> | ||||
|         {% endif %} | ||||
|     </d:response> | ||||
| </d:multistatus> | ||||
| @@ -1,7 +0,0 @@ | ||||
| {% extends '@ChillMain/layout.html.twig' %} | ||||
|  | ||||
| {% block content %} | ||||
|     <p>document uuid: {{ stored_object.uuid }}</p> | ||||
|     <p>{{ absolute_url(path('chill_docstore_dav_document_get', {'uuid': stored_object.uuid, 'access_token': access_token  })) }}</p> | ||||
| <a href="vnd.libreoffice.command:ofe|u|{{ absolute_url(path('chill_docstore_dav_document_get', {'uuid': stored_object.uuid, 'access_token': access_token })) }}">Open document</a> | ||||
| {% endblock %} | ||||
| @@ -1,22 +0,0 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\DocStoreBundle\Security\Authorization; | ||||
|  | ||||
| /** | ||||
|  * Role to edit or see the stored object content. | ||||
|  */ | ||||
| enum StoredObjectRoleEnum: string | ||||
| { | ||||
|     case SEE = 'SEE'; | ||||
|  | ||||
|     case EDIT = 'SEE_AND_EDIT'; | ||||
| } | ||||
| @@ -1,55 +0,0 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\DocStoreBundle\Security\Authorization; | ||||
|  | ||||
| use Chill\DocStoreBundle\Entity\StoredObject; | ||||
| use Chill\DocStoreBundle\Security\Guard\DavTokenAuthenticationEventSubscriber; | ||||
| use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; | ||||
| use Symfony\Component\Security\Core\Authorization\Voter\Voter; | ||||
|  | ||||
| /** | ||||
|  * Voter for the content of a stored object. | ||||
|  * | ||||
|  * This is in use to allow or disallow the edition of the stored object's content. | ||||
|  */ | ||||
| class StoredObjectVoter extends Voter | ||||
| { | ||||
|     protected function supports($attribute, $subject): bool | ||||
|     { | ||||
|         return StoredObjectRoleEnum::tryFrom($attribute) instanceof StoredObjectRoleEnum | ||||
|             && $subject instanceof StoredObject; | ||||
|     } | ||||
|  | ||||
|     protected function voteOnAttribute($attribute, $subject, TokenInterface $token): bool | ||||
|     { | ||||
|         /** @var StoredObject $subject */ | ||||
|         if ( | ||||
|             !$token->hasAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT) | ||||
|             || $subject->getUuid()->toString() !== $token->getAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT) | ||||
|         ) { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         if (!$token->hasAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS)) { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         $askedRole = StoredObjectRoleEnum::from($attribute); | ||||
|         $tokenRoleAuthorization = | ||||
|             $token->getAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS); | ||||
|  | ||||
|         return match ($askedRole) { | ||||
|             StoredObjectRoleEnum::SEE => StoredObjectRoleEnum::EDIT === $tokenRoleAuthorization || StoredObjectRoleEnum::SEE === $tokenRoleAuthorization, | ||||
|             StoredObjectRoleEnum::EDIT => StoredObjectRoleEnum::EDIT === $tokenRoleAuthorization | ||||
|         }; | ||||
|     } | ||||
| } | ||||
| @@ -1,58 +0,0 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\DocStoreBundle\Security\Guard; | ||||
|  | ||||
| use Lexik\Bundle\JWTAuthenticationBundle\TokenExtractor\TokenExtractorInterface; | ||||
| use Psr\Log\LoggerInterface; | ||||
| use Symfony\Component\HttpFoundation\Request; | ||||
|  | ||||
| /** | ||||
|  * Extract the JWT Token from the segment of the dav endpoints. | ||||
|  * | ||||
|  * A segment is a separation inside the string, using the character "/". | ||||
|  * | ||||
|  * For recognizing the JWT, the first segment must be "dav", and the second one must be | ||||
|  * the JWT endpoint. | ||||
|  */ | ||||
| final readonly class DavOnUrlTokenExtractor implements TokenExtractorInterface | ||||
| { | ||||
|     public function __construct( | ||||
|         private LoggerInterface $logger, | ||||
|     ) { | ||||
|     } | ||||
|  | ||||
|     public function extract(Request $request): false|string | ||||
|     { | ||||
|         $uri = $request->getRequestUri(); | ||||
|  | ||||
|         $segments = array_values( | ||||
|             array_filter( | ||||
|                 explode('/', $uri), | ||||
|                 fn ($item) => '' !== trim($item) | ||||
|             ) | ||||
|         ); | ||||
|  | ||||
|         if (2 > count($segments)) { | ||||
|             $this->logger->info('not enough segment for parsing URL'); | ||||
|  | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         if ('dav' !== $segments[0]) { | ||||
|             $this->logger->info('the first segment of the url must be DAV'); | ||||
|  | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         return $segments[1]; | ||||
|     } | ||||
| } | ||||
| @@ -1,51 +0,0 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\DocStoreBundle\Security\Guard; | ||||
|  | ||||
| use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum; | ||||
| use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTAuthenticatedEvent; | ||||
| use Lexik\Bundle\JWTAuthenticationBundle\Events; | ||||
| use Symfony\Component\EventDispatcher\EventSubscriberInterface; | ||||
|  | ||||
| /** | ||||
|  * Store some data from the JWT's payload inside the token's attributes. | ||||
|  */ | ||||
| class DavTokenAuthenticationEventSubscriber implements EventSubscriberInterface | ||||
| { | ||||
|     final public const STORED_OBJECT = 'stored_object'; | ||||
|     final public const ACTIONS = 'stored_objects_actions'; | ||||
|  | ||||
|     public static function getSubscribedEvents(): array | ||||
|     { | ||||
|         return [ | ||||
|             Events::JWT_AUTHENTICATED => ['onJWTAuthenticated', 0], | ||||
|         ]; | ||||
|     } | ||||
|  | ||||
|     public function onJWTAuthenticated(JWTAuthenticatedEvent $event): void | ||||
|     { | ||||
|         $payload = $event->getPayload(); | ||||
|  | ||||
|         if (!(array_key_exists('dav', $payload) && 1 === $payload['dav'])) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         $token = $event->getToken(); | ||||
|         $token->setAttribute(self::ACTIONS, match ($payload['e']) { | ||||
|             0 => StoredObjectRoleEnum::SEE, | ||||
|             1 => StoredObjectRoleEnum::EDIT, | ||||
|             default => throw new \UnexpectedValueException('unsupported value for e parameter') | ||||
|         }); | ||||
|  | ||||
|         $token->setAttribute(self::STORED_OBJECT, $payload['so']); | ||||
|     } | ||||
| } | ||||
| @@ -1,48 +0,0 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\DocStoreBundle\Security\Guard; | ||||
|  | ||||
| use Chill\DocStoreBundle\Entity\StoredObject; | ||||
| use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum; | ||||
| use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface; | ||||
| use Symfony\Component\Security\Core\Security; | ||||
|  | ||||
| /** | ||||
|  * Provide a JWT Token which will be valid for viewing or editing a document. | ||||
|  */ | ||||
| final readonly class JWTDavTokenProvider implements JWTDavTokenProviderInterface | ||||
| { | ||||
|     public function __construct( | ||||
|         private JWTTokenManagerInterface $JWTTokenManager, | ||||
|         private Security $security, | ||||
|     ) { | ||||
|     } | ||||
|  | ||||
|     public function createToken(StoredObject $storedObject, StoredObjectRoleEnum $roleEnum): string | ||||
|     { | ||||
|         return $this->JWTTokenManager->createFromPayload($this->security->getUser(), [ | ||||
|             'dav' => 1, | ||||
|             'e' => match ($roleEnum) { | ||||
|                 StoredObjectRoleEnum::SEE => 0, | ||||
|                 StoredObjectRoleEnum::EDIT => 1, | ||||
|             }, | ||||
|             'so' => $storedObject->getUuid(), | ||||
|         ]); | ||||
|     } | ||||
|  | ||||
|     public function getTokenExpiration(string $tokenString): \DateTimeImmutable | ||||
|     { | ||||
|         $jwt = $this->JWTTokenManager->parse($tokenString); | ||||
|  | ||||
|         return \DateTimeImmutable::createFromFormat('U', (string) $jwt['exp']); | ||||
|     } | ||||
| } | ||||
| @@ -1,25 +0,0 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\DocStoreBundle\Security\Guard; | ||||
|  | ||||
| use Chill\DocStoreBundle\Entity\StoredObject; | ||||
| use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum; | ||||
|  | ||||
| /** | ||||
|  * Provide a JWT Token which will be valid for viewing or editing a document. | ||||
|  */ | ||||
| interface JWTDavTokenProviderInterface | ||||
| { | ||||
|     public function createToken(StoredObject $storedObject, StoredObjectRoleEnum $roleEnum): string; | ||||
|  | ||||
|     public function getTokenExpiration(string $tokenString): \DateTimeImmutable; | ||||
| } | ||||
| @@ -1,41 +0,0 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\DocStoreBundle\Security\Guard; | ||||
|  | ||||
| use Lexik\Bundle\JWTAuthenticationBundle\Security\Guard\JWTTokenAuthenticator; | ||||
| use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface; | ||||
| use Lexik\Bundle\JWTAuthenticationBundle\TokenExtractor\TokenExtractorInterface; | ||||
| use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; | ||||
| use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; | ||||
| use Symfony\Contracts\Translation\TranslatorInterface; | ||||
|  | ||||
| /** | ||||
|  * Alter the base JWTTokenAuthenticator to add the special extractor for dav url endpoints. | ||||
|  */ | ||||
| class JWTOnDavUrlAuthenticator extends JWTTokenAuthenticator | ||||
| { | ||||
|     public function __construct( | ||||
|         JWTTokenManagerInterface $jwtManager, | ||||
|         EventDispatcherInterface $dispatcher, | ||||
|         TokenExtractorInterface $tokenExtractor, | ||||
|         private readonly DavOnUrlTokenExtractor $davOnUrlTokenExtractor, | ||||
|         TokenStorageInterface $preAuthenticationTokenStorage, | ||||
|         TranslatorInterface $translator = null, | ||||
|     ) { | ||||
|         parent::__construct($jwtManager, $dispatcher, $tokenExtractor, $preAuthenticationTokenStorage, $translator); | ||||
|     } | ||||
|  | ||||
|     protected function getTokenExtractor() | ||||
|     { | ||||
|         return $this->davOnUrlTokenExtractor; | ||||
|     } | ||||
| } | ||||
| @@ -57,62 +57,6 @@ final class StoredObjectManager implements StoredObjectManagerInterface | ||||
|         return $this->extractLastModifiedFromResponse($response); | ||||
|     } | ||||
|  | ||||
|     public function getContentLength(StoredObject $document): int | ||||
|     { | ||||
|         if ([] === $document->getKeyInfos()) { | ||||
|             if ($this->hasCache($document)) { | ||||
|                 $response = $this->getResponseFromCache($document); | ||||
|             } else { | ||||
|                 try { | ||||
|                     $response = $this | ||||
|                         ->client | ||||
|                         ->request( | ||||
|                             Request::METHOD_HEAD, | ||||
|                             $this | ||||
|                                 ->tempUrlGenerator | ||||
|                                 ->generate( | ||||
|                                     Request::METHOD_HEAD, | ||||
|                                     $document->getFilename() | ||||
|                                 ) | ||||
|                                 ->url | ||||
|                         ); | ||||
|                 } catch (TransportExceptionInterface $exception) { | ||||
|                     throw StoredObjectManagerException::errorDuringHttpRequest($exception); | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             return $this->extractContentLengthFromResponse($response); | ||||
|         } | ||||
|  | ||||
|         return strlen($this->read($document)); | ||||
|     } | ||||
|  | ||||
|     public function etag(StoredObject $document): string | ||||
|     { | ||||
|         if ($this->hasCache($document)) { | ||||
|             $response = $this->getResponseFromCache($document); | ||||
|         } else { | ||||
|             try { | ||||
|                 $response = $this | ||||
|                     ->client | ||||
|                     ->request( | ||||
|                         Request::METHOD_HEAD, | ||||
|                         $this | ||||
|                             ->tempUrlGenerator | ||||
|                             ->generate( | ||||
|                                 Request::METHOD_HEAD, | ||||
|                                 $document->getFilename() | ||||
|                             ) | ||||
|                             ->url | ||||
|                     ); | ||||
|             } catch (TransportExceptionInterface $exception) { | ||||
|                 throw StoredObjectManagerException::errorDuringHttpRequest($exception); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return $this->extractEtagFromResponse($response, $document); | ||||
|     } | ||||
|  | ||||
|     public function read(StoredObject $document): string | ||||
|     { | ||||
|         $response = $this->getResponseFromCache($document); | ||||
| @@ -202,22 +146,6 @@ final class StoredObjectManager implements StoredObjectManagerInterface | ||||
|         return $date; | ||||
|     } | ||||
|  | ||||
|     private function extractContentLengthFromResponse(ResponseInterface $response): int | ||||
|     { | ||||
|         return (int) ($response->getHeaders()['content-length'] ?? ['0'])[0]; | ||||
|     } | ||||
|  | ||||
|     private function extractEtagFromResponse(ResponseInterface $response, StoredObject $storedObject): ?string | ||||
|     { | ||||
|         $etag = ($response->getHeaders()['etag'] ?? [''])[0]; | ||||
|  | ||||
|         if ('' === $etag) { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         return $etag; | ||||
|     } | ||||
|  | ||||
|     private function fillCache(StoredObject $document): void | ||||
|     { | ||||
|         try { | ||||
|   | ||||
| @@ -17,8 +17,6 @@ interface StoredObjectManagerInterface | ||||
| { | ||||
|     public function getLastModified(StoredObject $document): \DateTimeInterface; | ||||
|  | ||||
|     public function getContentLength(StoredObject $document): int; | ||||
|  | ||||
|     /** | ||||
|      * Get the content of a StoredObject. | ||||
|      * | ||||
| @@ -35,6 +33,4 @@ interface StoredObjectManagerInterface | ||||
|      * @param              $clearContent The content to store in clear | ||||
|      */ | ||||
|     public function write(StoredObject $document, string $clearContent): void; | ||||
|  | ||||
|     public function etag(StoredObject $document): string; | ||||
| } | ||||
|   | ||||
| @@ -13,9 +13,6 @@ namespace Chill\DocStoreBundle\Templating; | ||||
|  | ||||
| use ChampsLibres\WopiLib\Contract\Service\Discovery\DiscoveryInterface; | ||||
| use Chill\DocStoreBundle\Entity\StoredObject; | ||||
| use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum; | ||||
| use Chill\DocStoreBundle\Security\Guard\JWTDavTokenProviderInterface; | ||||
| use Symfony\Component\Routing\Generator\UrlGeneratorInterface; | ||||
| use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; | ||||
| use Symfony\Component\Serializer\Normalizer\NormalizerInterface; | ||||
| use Twig\Environment; | ||||
| @@ -123,12 +120,8 @@ final readonly class WopiEditTwigExtensionRuntime implements RuntimeExtensionInt | ||||
|  | ||||
|     private const TEMPLATE_BUTTON_GROUP = '@ChillDocStore/Button/button_group.html.twig'; | ||||
|  | ||||
|     public function __construct( | ||||
|         private DiscoveryInterface $discovery, | ||||
|         private NormalizerInterface $normalizer, | ||||
|         private JWTDavTokenProviderInterface $davTokenProvider, | ||||
|         private UrlGeneratorInterface $urlGenerator, | ||||
|     ) { | ||||
|     public function __construct(private DiscoveryInterface $discovery, private NormalizerInterface $normalizer) | ||||
|     { | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @@ -139,7 +132,7 @@ final readonly class WopiEditTwigExtensionRuntime implements RuntimeExtensionInt | ||||
|      */ | ||||
|     public function isEditable(StoredObject $document): bool | ||||
|     { | ||||
|         return in_array($document->getType(), self::SUPPORTED_MIMES, true); | ||||
|         return \in_array($document->getType(), self::SUPPORTED_MIMES, true); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @@ -151,26 +144,12 @@ final readonly class WopiEditTwigExtensionRuntime implements RuntimeExtensionInt | ||||
|      */ | ||||
|     public function renderButtonGroup(Environment $environment, StoredObject $document, ?string $title = null, bool $canEdit = true, array $options = []): string | ||||
|     { | ||||
|         $accessToken = $this->davTokenProvider->createToken( | ||||
|             $document, | ||||
|             $canEdit ? StoredObjectRoleEnum::EDIT : StoredObjectRoleEnum::SEE | ||||
|         ); | ||||
|  | ||||
|         return $environment->render(self::TEMPLATE_BUTTON_GROUP, [ | ||||
|             'document' => $document, | ||||
|             'document_json' => $this->normalizer->normalize($document, 'json', [AbstractNormalizer::GROUPS => ['read']]), | ||||
|             'title' => $title, | ||||
|             'can_edit' => $canEdit, | ||||
|             'options' => [...self::DEFAULT_OPTIONS_TEMPLATE_BUTTON_GROUP, ...$options], | ||||
|             'dav_link' => $this->urlGenerator->generate( | ||||
|                 'chill_docstore_dav_document_get', | ||||
|                 [ | ||||
|                     'uuid' => $document->getUuid(), | ||||
|                     'access_token' => $accessToken, | ||||
|                 ], | ||||
|                 UrlGeneratorInterface::ABSOLUTE_URL, | ||||
|             ), | ||||
|             'dav_link_expiration' => $this->davTokenProvider->getTokenExpiration($accessToken)->format('U'), | ||||
|         ]); | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -1,410 +0,0 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\DocStoreBundle\Tests\Controller; | ||||
|  | ||||
| use Chill\DocStoreBundle\Controller\WebdavController; | ||||
| use Chill\DocStoreBundle\Entity\StoredObject; | ||||
| use Chill\DocStoreBundle\Service\StoredObjectManagerInterface; | ||||
| use Prophecy\Argument; | ||||
| use Prophecy\PhpUnit\ProphecyTrait; | ||||
| use Ramsey\Uuid\Uuid; | ||||
| use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; | ||||
| use Symfony\Component\HttpFoundation\Request; | ||||
| use Symfony\Component\Security\Core\Security; | ||||
|  | ||||
| /** | ||||
|  * @internal | ||||
|  * | ||||
|  * @coversNothing | ||||
|  */ | ||||
| class WebdavControllerTest extends KernelTestCase | ||||
| { | ||||
|     use ProphecyTrait; | ||||
|  | ||||
|     private \Twig\Environment $engine; | ||||
|  | ||||
|     protected function setUp(): void | ||||
|     { | ||||
|         self::bootKernel(); | ||||
|  | ||||
|         $this->engine = self::$container->get(\Twig\Environment::class); | ||||
|     } | ||||
|  | ||||
|     private function buildController(): WebdavController | ||||
|     { | ||||
|         $storedObjectManager = new MockedStoredObjectManager(); | ||||
|         $security = $this->prophesize(Security::class); | ||||
|         $security->isGranted(Argument::in(['EDIT', 'SEE']), Argument::type(StoredObject::class)) | ||||
|             ->willReturn(true); | ||||
|  | ||||
|         return new WebdavController($this->engine, $storedObjectManager, $security->reveal()); | ||||
|     } | ||||
|  | ||||
|     private function buildDocument(): StoredObject | ||||
|     { | ||||
|         $object = (new StoredObject()) | ||||
|             ->setType('application/vnd.oasis.opendocument.text'); | ||||
|  | ||||
|         $reflectionObject = new \ReflectionClass($object); | ||||
|         $reflectionObjectUuid = $reflectionObject->getProperty('uuid'); | ||||
|  | ||||
|         $reflectionObjectUuid->setValue($object, Uuid::fromString('716e6688-4579-4938-acf3-c4ab5856803b')); | ||||
|  | ||||
|         return $object; | ||||
|     } | ||||
|  | ||||
|     public function testGet(): void | ||||
|     { | ||||
|         $controller = $this->buildController(); | ||||
|  | ||||
|         $response = $controller->getDocument($this->buildDocument()); | ||||
|  | ||||
|         self::assertEquals(200, $response->getStatusCode()); | ||||
|         self::assertEquals('abcde', $response->getContent()); | ||||
|         self::assertContains('etag', $response->headers->keys()); | ||||
|         self::assertStringContainsString('ab56b4', $response->headers->get('etag')); | ||||
|     } | ||||
|  | ||||
|     public function testOptionsOnDocument(): void | ||||
|     { | ||||
|         $controller = $this->buildController(); | ||||
|  | ||||
|         $response = $controller->optionsDocument($this->buildDocument()); | ||||
|  | ||||
|         self::assertEquals(200, $response->getStatusCode()); | ||||
|         self::assertContains('allow', $response->headers->keys()); | ||||
|  | ||||
|         foreach (explode(',', 'OPTIONS,GET,HEAD,PROPFIND') as $method) { | ||||
|             self::assertStringContainsString($method, $response->headers->get('allow')); | ||||
|         } | ||||
|  | ||||
|         self::assertContains('dav', $response->headers->keys()); | ||||
|         self::assertStringContainsString('1', $response->headers->get('dav')); | ||||
|     } | ||||
|  | ||||
|     public function testOptionsOnDirectory(): void | ||||
|     { | ||||
|         $controller = $this->buildController(); | ||||
|  | ||||
|         $response = $controller->optionsDirectory($this->buildDocument()); | ||||
|  | ||||
|         self::assertEquals(200, $response->getStatusCode()); | ||||
|         self::assertContains('allow', $response->headers->keys()); | ||||
|  | ||||
|         foreach (explode(',', 'OPTIONS,GET,HEAD,PROPFIND') as $method) { | ||||
|             self::assertStringContainsString($method, $response->headers->get('allow')); | ||||
|         } | ||||
|  | ||||
|         self::assertContains('dav', $response->headers->keys()); | ||||
|         self::assertStringContainsString('1', $response->headers->get('dav')); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @dataProvider generateDataPropfindDocument | ||||
|      */ | ||||
|     public function testPropfindDocument(string $requestContent, int $expectedStatusCode, string $expectedXmlResponse, string $message): void | ||||
|     { | ||||
|         $controller = $this->buildController(); | ||||
|  | ||||
|         $request = new Request([], [], [], [], [], [], $requestContent); | ||||
|         $request->setMethod('PROPFIND'); | ||||
|         $response = $controller->propfindDocument($this->buildDocument(), '1234', $request); | ||||
|  | ||||
|         self::assertEquals($expectedStatusCode, $response->getStatusCode()); | ||||
|         self::assertContains('content-type', $response->headers->keys()); | ||||
|         self::assertStringContainsString('text/xml', $response->headers->get('content-type')); | ||||
|         self::assertTrue((new \DOMDocument())->loadXML($response->getContent()), $message.' test that the xml response is a valid xml'); | ||||
|         self::assertXmlStringEqualsXmlString($expectedXmlResponse, $response->getContent(), $message); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @dataProvider generateDataPropfindDirectory | ||||
|      */ | ||||
|     public function testPropfindDirectory(string $requestContent, int $expectedStatusCode, string $expectedXmlResponse, string $message): void | ||||
|     { | ||||
|         $controller = $this->buildController(); | ||||
|  | ||||
|         $request = new Request([], [], [], [], [], [], $requestContent); | ||||
|         $request->setMethod('PROPFIND'); | ||||
|         $request->headers->add(['Depth' => '0']); | ||||
|         $response = $controller->propfindDirectory($this->buildDocument(), '1234', $request); | ||||
|  | ||||
|         self::assertEquals($expectedStatusCode, $response->getStatusCode()); | ||||
|         self::assertContains('content-type', $response->headers->keys()); | ||||
|         self::assertStringContainsString('text/xml', $response->headers->get('content-type')); | ||||
|         self::assertTrue((new \DOMDocument())->loadXML($response->getContent()), $message.' test that the xml response is a valid xml'); | ||||
|         self::assertXmlStringEqualsXmlString($expectedXmlResponse, $response->getContent(), $message); | ||||
|     } | ||||
|  | ||||
|     public function testHeadDocument(): void | ||||
|     { | ||||
|         $controller = $this->buildController(); | ||||
|         $response = $controller->headDocument($this->buildDocument()); | ||||
|  | ||||
|         self::assertEquals(200, $response->getStatusCode()); | ||||
|         self::assertContains('content-length', $response->headers->keys()); | ||||
|         self::assertContains('content-type', $response->headers->keys()); | ||||
|         self::assertContains('etag', $response->headers->keys()); | ||||
|         self::assertEquals('ab56b4d92b40713acc5af89985d4b786', $response->headers->get('etag')); | ||||
|         self::assertEquals('application/vnd.oasis.opendocument.text', $response->headers->get('content-type')); | ||||
|         self::assertEquals(5, $response->headers->get('content-length')); | ||||
|     } | ||||
|  | ||||
|     public static function generateDataPropfindDocument(): iterable | ||||
|     { | ||||
|         $content = | ||||
|             <<<'XML' | ||||
|             <?xml version="1.0" encoding="UTF-8"?> | ||||
|             <propfind xmlns="DAV:"><prop><resourcetype xmlns="DAV:"/><IsReadOnly xmlns="http://ucb.openoffice.org/dav/props/"/><getcontenttype xmlns="DAV:"/><supportedlock xmlns="DAV:"/></prop></propfind> | ||||
|             XML; | ||||
|  | ||||
|         $response = | ||||
|             <<<'XML' | ||||
|             <?xml version="1.0" encoding="utf-8"?> | ||||
|             <d:multistatus xmlns:d="DAV:" > | ||||
|             <d:response> | ||||
|               <d:href>/dav/1234/get/716e6688-4579-4938-acf3-c4ab5856803b/d</d:href> | ||||
|               <d:propstat> | ||||
|                 <d:prop> | ||||
|                   <d:resourcetype/> | ||||
|                   <d:getcontenttype>application/vnd.oasis.opendocument.text</d:getcontenttype> | ||||
|                 </d:prop> | ||||
|                 <d:status>HTTP/1.1 200 OK</d:status> | ||||
|               </d:propstat> | ||||
|               <d:propstat> | ||||
|                 <d:prop xmlns:ns0="http://ucb.openoffice.org/dav/props/"> | ||||
|                   <ns0:IsReadOnly/> | ||||
|                 </d:prop> | ||||
|                 <d:status>HTTP/1.1 404 Not Found</d:status> | ||||
|               </d:propstat> | ||||
|             </d:response> | ||||
|             </d:multistatus> | ||||
|             XML; | ||||
|  | ||||
|         yield [$content, 207, $response, 'get IsReadOnly and contenttype from server']; | ||||
|  | ||||
|         $content = | ||||
|             <<<'XML' | ||||
|             <?xml version="1.0" encoding="UTF-8"?> | ||||
|               <propfind xmlns="DAV:"> | ||||
|                 <prop> | ||||
|                   <IsReadOnly xmlns="http://ucb.openoffice.org/dav/props/"/> | ||||
|                 </prop> | ||||
|               </propfind> | ||||
|             XML; | ||||
|  | ||||
|         $response = | ||||
|             <<<'XML' | ||||
|             <?xml version="1.0" encoding="utf-8"?> | ||||
|               <d:multistatus xmlns:d="DAV:"> | ||||
|                 <d:response> | ||||
|                   <d:href>/dav/1234/get/716e6688-4579-4938-acf3-c4ab5856803b/d</d:href> | ||||
|                   <d:propstat> | ||||
|                     <d:prop xmlns:ns0="http://ucb.openoffice.org/dav/props/"> | ||||
|                       <ns0:IsReadOnly/> | ||||
|                     </d:prop> | ||||
|                     <d:status>HTTP/1.1 404 Not Found</d:status> | ||||
|                   </d:propstat> | ||||
|                 </d:response> | ||||
|               </d:multistatus> | ||||
|             XML; | ||||
|  | ||||
|         yield [$content, 207, $response, 'get property IsReadOnly']; | ||||
|  | ||||
|         yield [ | ||||
|             <<<'XML' | ||||
|             <?xml version="1.0" encoding="UTF-8"?> | ||||
|               <propfind xmlns="DAV:"> | ||||
|                 <prop> | ||||
|                   <BaseURI xmlns="http://ucb.openoffice.org/dav/props/"/> | ||||
|                 </prop> | ||||
|               </propfind> | ||||
|             XML, | ||||
|             207, | ||||
|             <<<'XML' | ||||
|             <?xml version="1.0" encoding="utf-8"?> | ||||
|               <d:multistatus xmlns:d="DAV:"> | ||||
|                 <d:response> | ||||
|                   <d:href>/dav/1234/get/716e6688-4579-4938-acf3-c4ab5856803b/d</d:href> | ||||
|                   <d:propstat> | ||||
|                     <d:prop xmlns:ns0="http://ucb.openoffice.org/dav/props/"> | ||||
|                       <ns0:BaseURI/> | ||||
|                     </d:prop> | ||||
|                     <d:status>HTTP/1.1 404 Not Found</d:status> | ||||
|                   </d:propstat> | ||||
|                 </d:response> | ||||
|               </d:multistatus> | ||||
|             XML, | ||||
|             'Test requesting an unknow property', | ||||
|         ]; | ||||
|  | ||||
|         yield [ | ||||
|             <<<'XML' | ||||
|             <?xml version="1.0" encoding="UTF-8"?> | ||||
|               <propfind xmlns="DAV:"> | ||||
|                 <prop> | ||||
|                   <getlastmodified xmlns="DAV:"/> | ||||
|                 </prop> | ||||
|               </propfind> | ||||
|             XML, | ||||
|             207, | ||||
|             <<<'XML' | ||||
|             <?xml version="1.0" encoding="utf-8"?> | ||||
|               <d:multistatus xmlns:d="DAV:"> | ||||
|                 <d:response> | ||||
|                   <d:href>/dav/1234/get/716e6688-4579-4938-acf3-c4ab5856803b/d</d:href> | ||||
|                   <d:propstat> | ||||
|                     <d:prop> | ||||
|                     <!-- the date scraped from a webserver is >Sun, 10 Sep 2023 14:10:23 GMT --> | ||||
|                       <d:getlastmodified>Wed, 13 Sep 2023 14:15:00 +0200</d:getlastmodified> | ||||
|                     </d:prop> | ||||
|                     <d:status>HTTP/1.1 200 OK</d:status> | ||||
|                   </d:propstat> | ||||
|                 </d:response> | ||||
|               </d:multistatus> | ||||
|             XML, | ||||
|             'test getting the last modified date', | ||||
|         ]; | ||||
|  | ||||
|         yield [ | ||||
|             <<<'XML' | ||||
|             <?xml version="1.0" encoding="UTF-8"?> | ||||
|               <propfind xmlns="DAV:"> | ||||
|                 <propname/> | ||||
|               </propfind> | ||||
|             XML, | ||||
|             207, | ||||
|             <<<'XML' | ||||
|             <?xml version="1.0" encoding="utf-8"?> | ||||
|               <d:multistatus xmlns:d="DAV:"> | ||||
|                 <d:response> | ||||
|                   <d:href>/dav/1234/get/716e6688-4579-4938-acf3-c4ab5856803b/d</d:href> | ||||
|                   <d:propstat> | ||||
|                     <d:prop> | ||||
|                       <d:resourcetype/> | ||||
|                       <d:creationdate/> | ||||
|                       <d:getlastmodified>Wed, 13 Sep 2023 14:15:00 +0200</d:getlastmodified> | ||||
|                       <!-- <d:getcontentlength/> --> | ||||
|                       <d:getcontentlength>5</d:getcontentlength> | ||||
|                       <!-- <d:getlastmodified/> --> | ||||
|                       <d:getetag>"ab56b4d92b40713acc5af89985d4b786"</d:getetag> | ||||
|                       <!-- | ||||
|                       <d:supportedlock/> | ||||
|                       <d:lockdiscovery/> | ||||
|                       --> | ||||
|                      <!-- <d:getcontenttype/> --> | ||||
|                       <d:getcontenttype>application/vnd.oasis.opendocument.text</d:getcontenttype> | ||||
|                     </d:prop> | ||||
|                     <d:status>HTTP/1.1 200 OK</d:status> | ||||
|                   </d:propstat> | ||||
|                 </d:response> | ||||
|               </d:multistatus> | ||||
|             XML, | ||||
|             'test finding all properties', | ||||
|         ]; | ||||
|     } | ||||
|  | ||||
|     public static function generateDataPropfindDirectory(): iterable | ||||
|     { | ||||
|         yield [ | ||||
|             <<<'XML' | ||||
|             <?xml version="1.0" encoding="UTF-8"?> | ||||
|             <propfind xmlns="DAV:"><prop><resourcetype xmlns="DAV:"/><IsReadOnly xmlns="http://ucb.openoffice.org/dav/props/"/><getcontenttype xmlns="DAV:"/><supportedlock xmlns="DAV:"/></prop></propfind> | ||||
|             XML, | ||||
|             207, | ||||
|             <<<'XML' | ||||
|             <?xml version="1.0" encoding="utf-8"?> | ||||
|             <d:multistatus xmlns:d="DAV:"> | ||||
|                 <d:response> | ||||
|                     <d:href>/dav/1234/get/716e6688-4579-4938-acf3-c4ab5856803b/</d:href> | ||||
|                     <d:propstat> | ||||
|                         <d:prop> | ||||
|                             <d:resourcetype><d:collection/></d:resourcetype> | ||||
|                             <d:getcontenttype>httpd/unix-directory</d:getcontenttype> | ||||
|                             <!-- | ||||
|                             <d:supportedlock> | ||||
|                                 <d:lockentry> | ||||
|                                     <d:lockscope><d:exclusive/></d:lockscope> | ||||
|                                     <d:locktype><d:write/></d:locktype> | ||||
|                                 </d:lockentry> | ||||
|                                 <d:lockentry> | ||||
|                                     <d:lockscope><d:shared/></d:lockscope> | ||||
|                                     <d:locktype><d:write/></d:locktype> | ||||
|                                 </d:lockentry> | ||||
|                             </d:supportedlock> | ||||
|                             --> | ||||
|                         </d:prop> | ||||
|                         <d:status>HTTP/1.1 200 OK</d:status> | ||||
|                     </d:propstat> | ||||
|                     <d:propstat> | ||||
|                         <d:prop xmlns:ns0="http://ucb.openoffice.org/dav/props/"> | ||||
|                             <ns0:IsReadOnly/> | ||||
|                         </d:prop> | ||||
|                         <d:status>HTTP/1.1 404 Not Found</d:status> | ||||
|                     </d:propstat> | ||||
|                 </d:response> | ||||
|             </d:multistatus> | ||||
|             XML, | ||||
|             'test resourceType and IsReadOnly ', | ||||
|         ]; | ||||
|  | ||||
|         yield [ | ||||
|             <<<'XML' | ||||
|             <?xml version="1.0" encoding="UTF-8"?> | ||||
|             <propfind xmlns="DAV:"><prop><CreatableContentsInfo xmlns="http://ucb.openoffice.org/dav/props/"/></prop></propfind> | ||||
|             XML, | ||||
|             207, | ||||
|             <<<'XML' | ||||
|             <?xml version="1.0" encoding="utf-8"?> | ||||
|             <d:multistatus xmlns:d="DAV:"> | ||||
|                 <d:response> | ||||
|                     <d:href>/dav/1234/get/716e6688-4579-4938-acf3-c4ab5856803b/</d:href> | ||||
|                     <d:propstat> | ||||
|                         <d:prop xmlns:ns0="http://ucb.openoffice.org/dav/props/" > | ||||
|                             <ns0:CreatableContentsInfo/> | ||||
|                         </d:prop> | ||||
|                         <d:status>HTTP/1.1 404 Not Found</d:status> | ||||
|                     </d:propstat> | ||||
|                 </d:response> | ||||
|             </d:multistatus> | ||||
|             XML, | ||||
|             'test creatableContentsInfo', | ||||
|         ]; | ||||
|     } | ||||
| } | ||||
|  | ||||
| class MockedStoredObjectManager implements StoredObjectManagerInterface | ||||
| { | ||||
|     public function getLastModified(StoredObject $document): \DateTimeInterface | ||||
|     { | ||||
|         return new \DateTimeImmutable('2023-09-13T14:15'); | ||||
|     } | ||||
|  | ||||
|     public function getContentLength(StoredObject $document): int | ||||
|     { | ||||
|         return 5; | ||||
|     } | ||||
|  | ||||
|     public function read(StoredObject $document): string | ||||
|     { | ||||
|         return 'abcde'; | ||||
|     } | ||||
|  | ||||
|     public function write(StoredObject $document, string $clearContent): void | ||||
|     { | ||||
|     } | ||||
|  | ||||
|     public function etag(StoredObject $document): string | ||||
|     { | ||||
|         return 'ab56b4d92b40713acc5af89985d4b786'; | ||||
|     } | ||||
| } | ||||
| @@ -1,134 +0,0 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\DocStoreBundle\Tests\Dav\Request; | ||||
|  | ||||
| use Chill\DocStoreBundle\Dav\Request\PropfindRequestAnalyzer; | ||||
| use PHPUnit\Framework\TestCase; | ||||
|  | ||||
| /** | ||||
|  * @internal | ||||
|  * | ||||
|  * @coversNothing | ||||
|  */ | ||||
| class PropfindRequestAnalyzerTest extends TestCase | ||||
| { | ||||
|     /** | ||||
|      * @dataProvider provideRequestedProperties | ||||
|      */ | ||||
|     public function testGetRequestedProperties(string $xml, array $expected): void | ||||
|     { | ||||
|         $analyzer = new PropfindRequestAnalyzer(); | ||||
|  | ||||
|         $request = new \DOMDocument(); | ||||
|         $request->loadXML($xml); | ||||
|         $actual = $analyzer->getRequestedProperties($request); | ||||
|  | ||||
|         foreach ($expected as $key => $value) { | ||||
|             if ('unknowns' === $key) { | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             self::assertArrayHasKey($key, $actual, "Check that key {$key} does exists in list of expected values"); | ||||
|             self::assertEquals($value, $actual[$key], "Does the value match expected for key {$key}"); | ||||
|         } | ||||
|  | ||||
|         if (array_key_exists('unknowns', $expected)) { | ||||
|             self::assertEquals(count($expected['unknowns']), count($actual['unknowns'])); | ||||
|             self::assertEqualsCanonicalizing($expected['unknowns'], $actual['unknowns']); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public function provideRequestedProperties(): iterable | ||||
|     { | ||||
|         yield [ | ||||
|             <<<'XML' | ||||
|             <?xml version="1.0" encoding="UTF-8"?> | ||||
|             <propfind xmlns="DAV:"> | ||||
|                 <prop> | ||||
|                     <BaseURI xmlns="http://ucb.openoffice.org/dav/props/"/> | ||||
|                 </prop> | ||||
|             </propfind> | ||||
|             XML, | ||||
|             [ | ||||
|                 'resourceType' => false, | ||||
|                 'contentType' => false, | ||||
|                 'lastModified' => false, | ||||
|                 'creationDate' => false, | ||||
|                 'contentLength' => false, | ||||
|                 'etag' => false, | ||||
|                 'supportedLock' => false, | ||||
|                 'unknowns' => [ | ||||
|                     ['xmlns' => 'http://ucb.openoffice.org/dav/props/', 'prop' => 'BaseURI'], | ||||
|                 ], | ||||
|             ], | ||||
|         ]; | ||||
|  | ||||
|         yield [ | ||||
|             <<<'XML' | ||||
|             <?xml version="1.0" encoding="UTF-8"?> | ||||
|               <propfind xmlns="DAV:"> | ||||
|                 <propname/> | ||||
|               </propfind> | ||||
|             XML, | ||||
|             [ | ||||
|                 'resourceType' => true, | ||||
|                 'contentType' => true, | ||||
|                 'lastModified' => true, | ||||
|                 'creationDate' => true, | ||||
|                 'contentLength' => true, | ||||
|                 'etag' => true, | ||||
|                 'supportedLock' => true, | ||||
|                 'unknowns' => [], | ||||
|             ], | ||||
|         ]; | ||||
|  | ||||
|         yield [ | ||||
|             <<<'XML' | ||||
|             <?xml version="1.0" encoding="UTF-8"?> | ||||
|               <propfind xmlns="DAV:"> | ||||
|                 <prop> | ||||
|                   <getlastmodified xmlns="DAV:"/> | ||||
|                 </prop> | ||||
|               </propfind> | ||||
|             XML, | ||||
|             [ | ||||
|                 'resourceType' => false, | ||||
|                 'contentType' => false, | ||||
|                 'lastModified' => true, | ||||
|                 'creationDate' => false, | ||||
|                 'contentLength' => false, | ||||
|                 'etag' => false, | ||||
|                 'supportedLock' => false, | ||||
|                 'unknowns' => [], | ||||
|             ], | ||||
|         ]; | ||||
|  | ||||
|         yield [ | ||||
|             <<<'XML' | ||||
|             <?xml version="1.0" encoding="UTF-8"?> | ||||
|             <propfind xmlns="DAV:"><prop><resourcetype xmlns="DAV:"/><IsReadOnly xmlns="http://ucb.openoffice.org/dav/props/"/><getcontenttype xmlns="DAV:"/><supportedlock xmlns="DAV:"/></prop></propfind> | ||||
|             XML, | ||||
|             [ | ||||
|                 'resourceType' => true, | ||||
|                 'contentType' => true, | ||||
|                 'lastModified' => false, | ||||
|                 'creationDate' => false, | ||||
|                 'contentLength' => false, | ||||
|                 'etag' => false, | ||||
|                 'supportedLock' => false, | ||||
|                 'unknowns' => [ | ||||
|                     ['xmlns' => 'http://ucb.openoffice.org/dav/props/', 'prop' => 'IsReadOnly'], | ||||
|                 ], | ||||
|             ], | ||||
|         ]; | ||||
|     } | ||||
| } | ||||
| @@ -1,123 +0,0 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\DocStoreBundle\Tests\Security\Authorization; | ||||
|  | ||||
| use Chill\DocStoreBundle\Entity\StoredObject; | ||||
| use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum; | ||||
| use Chill\DocStoreBundle\Security\Authorization\StoredObjectVoter; | ||||
| use Chill\DocStoreBundle\Security\Guard\DavTokenAuthenticationEventSubscriber; | ||||
| use PHPUnit\Framework\TestCase; | ||||
| use Prophecy\PhpUnit\ProphecyTrait; | ||||
| use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; | ||||
| use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; | ||||
|  | ||||
| /** | ||||
|  * @internal | ||||
|  * | ||||
|  * @coversNothing | ||||
|  */ | ||||
| class StoredObjectVoterTest extends TestCase | ||||
| { | ||||
|     use ProphecyTrait; | ||||
|  | ||||
|     /** | ||||
|      * @dataProvider provideDataVote | ||||
|      */ | ||||
|     public function testVote(TokenInterface $token, null|object $subject, string $attribute, mixed $expected): void | ||||
|     { | ||||
|         $voter = new StoredObjectVoter(); | ||||
|  | ||||
|         self::assertEquals($expected, $voter->vote($token, $subject, [$attribute])); | ||||
|     } | ||||
|  | ||||
|     public function provideDataVote(): iterable | ||||
|     { | ||||
|         yield [ | ||||
|             $this->buildToken(StoredObjectRoleEnum::EDIT, new StoredObject()), | ||||
|             new \stdClass(), | ||||
|             'SOMETHING', | ||||
|             VoterInterface::ACCESS_ABSTAIN, | ||||
|         ]; | ||||
|  | ||||
|         yield [ | ||||
|             $this->buildToken(StoredObjectRoleEnum::EDIT, $so = new StoredObject()), | ||||
|             $so, | ||||
|             'SOMETHING', | ||||
|             VoterInterface::ACCESS_ABSTAIN, | ||||
|         ]; | ||||
|  | ||||
|         yield [ | ||||
|             $this->buildToken(StoredObjectRoleEnum::EDIT, $so = new StoredObject()), | ||||
|             $so, | ||||
|             StoredObjectRoleEnum::SEE->value, | ||||
|             VoterInterface::ACCESS_GRANTED, | ||||
|         ]; | ||||
|  | ||||
|         yield [ | ||||
|             $this->buildToken(StoredObjectRoleEnum::EDIT, $so = new StoredObject()), | ||||
|             $so, | ||||
|             StoredObjectRoleEnum::EDIT->value, | ||||
|             VoterInterface::ACCESS_GRANTED, | ||||
|         ]; | ||||
|  | ||||
|         yield [ | ||||
|             $this->buildToken(StoredObjectRoleEnum::SEE, $so = new StoredObject()), | ||||
|             $so, | ||||
|             StoredObjectRoleEnum::EDIT->value, | ||||
|             VoterInterface::ACCESS_DENIED, | ||||
|         ]; | ||||
|  | ||||
|         yield [ | ||||
|             $this->buildToken(StoredObjectRoleEnum::SEE, $so = new StoredObject()), | ||||
|             $so, | ||||
|             StoredObjectRoleEnum::SEE->value, | ||||
|             VoterInterface::ACCESS_GRANTED, | ||||
|         ]; | ||||
|  | ||||
|         yield [ | ||||
|             $this->buildToken(null, null), | ||||
|             new StoredObject(), | ||||
|             StoredObjectRoleEnum::SEE->value, | ||||
|             VoterInterface::ACCESS_DENIED, | ||||
|         ]; | ||||
|  | ||||
|         yield [ | ||||
|             $this->buildToken(null, null), | ||||
|             new StoredObject(), | ||||
|             StoredObjectRoleEnum::SEE->value, | ||||
|             VoterInterface::ACCESS_DENIED, | ||||
|         ]; | ||||
|     } | ||||
|  | ||||
|     private function buildToken(StoredObjectRoleEnum $storedObjectRoleEnum = null, StoredObject $storedObject = null): TokenInterface | ||||
|     { | ||||
|         $token = $this->prophesize(TokenInterface::class); | ||||
|  | ||||
|         if (null !== $storedObjectRoleEnum) { | ||||
|             $token->hasAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS)->willReturn(true); | ||||
|             $token->getAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS)->willReturn($storedObjectRoleEnum); | ||||
|         } else { | ||||
|             $token->hasAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS)->willReturn(false); | ||||
|             $token->getAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS)->willThrow(new \InvalidArgumentException()); | ||||
|         } | ||||
|  | ||||
|         if (null !== $storedObject) { | ||||
|             $token->hasAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT)->willReturn(true); | ||||
|             $token->getAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT)->willReturn($storedObject->getUuid()->toString()); | ||||
|         } else { | ||||
|             $token->hasAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT)->willReturn(false); | ||||
|             $token->getAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT)->willThrow(new \InvalidArgumentException()); | ||||
|         } | ||||
|  | ||||
|         return $token->reveal(); | ||||
|     } | ||||
| } | ||||
| @@ -1,55 +0,0 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\DocStoreBundle\Tests\Security\Guard; | ||||
|  | ||||
| use Chill\DocStoreBundle\Security\Guard\DavOnUrlTokenExtractor; | ||||
| use PHPUnit\Framework\TestCase; | ||||
| use Prophecy\PhpUnit\ProphecyTrait; | ||||
| use Psr\Log\NullLogger; | ||||
| use Symfony\Component\HttpFoundation\Request; | ||||
|  | ||||
| /** | ||||
|  * @internal | ||||
|  * | ||||
|  * @coversNothing | ||||
|  */ | ||||
| class DavOnUrlTokenExtractorTest extends TestCase | ||||
| { | ||||
|     use ProphecyTrait; | ||||
|  | ||||
|     /** | ||||
|      * @dataProvider provideDataUri | ||||
|      */ | ||||
|     public function testExtract(string $uri, false|string $expected): void | ||||
|     { | ||||
|         $request = $this->prophesize(Request::class); | ||||
|         $request->getRequestUri()->willReturn($uri); | ||||
|  | ||||
|         $extractor = new DavOnUrlTokenExtractor(new NullLogger()); | ||||
|  | ||||
|         $actual = $extractor->extract($request->reveal()); | ||||
|  | ||||
|         self::assertEquals($expected, $actual); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @phpstan-pure | ||||
|      */ | ||||
|     public static function provideDataUri(): iterable | ||||
|     { | ||||
|         yield ['/dav/123456789/get/d07d2230-5326-11ee-8fd4-93696acf5ea1/d', '123456789']; | ||||
|         yield ['/dav/123456789', '123456789']; | ||||
|         yield ['/not-dav/123456978', false]; | ||||
|         yield ['/dav', false]; | ||||
|         yield ['/', false]; | ||||
|     } | ||||
| } | ||||
| @@ -34,11 +34,6 @@ services: | ||||
|         autoconfigure: true | ||||
|         autowire: true | ||||
|  | ||||
|     Chill\DocStoreBundle\Security\: | ||||
|         resource: './../Security' | ||||
|         autoconfigure: true | ||||
|         autowire: true | ||||
|  | ||||
|     Chill\DocStoreBundle\Serializer\Normalizer\: | ||||
|         autowire: true | ||||
|         resource: '../Serializer/Normalizer/' | ||||
|   | ||||
| @@ -17,10 +17,11 @@ use Chill\EventBundle\Form\EventType; | ||||
| use Chill\EventBundle\Form\Type\PickEventType; | ||||
| use Chill\EventBundle\Security\Authorization\EventVoter; | ||||
| use Chill\MainBundle\Entity\Center; | ||||
| use Chill\MainBundle\Entity\User; | ||||
| use Chill\MainBundle\Pagination\PaginatorFactory; | ||||
| use Chill\MainBundle\Security\Authorization\AuthorizationHelper; | ||||
| use Chill\MainBundle\Security\Authorization\AuthorizationHelperInterface; | ||||
| use Chill\PersonBundle\Entity\Person; | ||||
| use Chill\PersonBundle\Form\Type\PickPersonType; | ||||
| use Chill\PersonBundle\Form\Type\PickPersonDynamicType; | ||||
| use Chill\PersonBundle\Privacy\PrivacyEvent; | ||||
| use PhpOffice\PhpSpreadsheet\Spreadsheet; | ||||
| use PhpOffice\PhpSpreadsheet\Writer\Csv; | ||||
| @@ -37,53 +38,26 @@ use Symfony\Component\Form\Extension\Core\Type\SubmitType; | ||||
| use Symfony\Component\Form\FormFactoryInterface; | ||||
| use Symfony\Component\HttpFoundation\Request; | ||||
| use Symfony\Component\HttpFoundation\StreamedResponse; | ||||
| use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; | ||||
| use Symfony\Component\Security\Core\Security; | ||||
| use Symfony\Contracts\Translation\TranslatorInterface; | ||||
|  | ||||
| /** | ||||
|  * Class EventController. | ||||
|  */ | ||||
| class EventController extends AbstractController | ||||
| final class EventController extends AbstractController | ||||
| { | ||||
|     /** | ||||
|      * @var AuthorizationHelper | ||||
|      */ | ||||
|     protected $authorizationHelper; | ||||
|  | ||||
|     /** | ||||
|      * @var EventDispatcherInterface | ||||
|      */ | ||||
|     protected $eventDispatcher; | ||||
|  | ||||
|     /** | ||||
|      * @var FormFactoryInterface | ||||
|      */ | ||||
|     protected $formFactoryInterface; | ||||
|  | ||||
|     /** | ||||
|      * @var PaginatorFactory | ||||
|      */ | ||||
|     protected $paginator; | ||||
|  | ||||
|     /** | ||||
|      * @var TranslatorInterface | ||||
|      */ | ||||
|     protected $translator; | ||||
|  | ||||
|     /** | ||||
|      * EventController constructor. | ||||
|      */ | ||||
|     public function __construct( | ||||
|         EventDispatcherInterface $eventDispatcher, | ||||
|         AuthorizationHelper $authorizationHelper, | ||||
|         FormFactoryInterface $formFactoryInterface, | ||||
|         TranslatorInterface $translator, | ||||
|         PaginatorFactory $paginator | ||||
|         private readonly EventDispatcherInterface $eventDispatcher, | ||||
|         private readonly AuthorizationHelperInterface $authorizationHelper, | ||||
|         private readonly FormFactoryInterface $formFactoryInterface, | ||||
|         private readonly TranslatorInterface $translator, | ||||
|         private readonly PaginatorFactory $paginator, | ||||
|         private readonly Security $security, | ||||
|     ) { | ||||
|         $this->eventDispatcher = $eventDispatcher; | ||||
|         $this->authorizationHelper = $authorizationHelper; | ||||
|         $this->formFactoryInterface = $formFactoryInterface; | ||||
|         $this->translator = $translator; | ||||
|         $this->paginator = $paginator; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @@ -181,7 +155,7 @@ class EventController extends AbstractController | ||||
|  | ||||
|         $this->denyAccessUnlessGranted('CHILL_PERSON_SEE', $person); | ||||
|  | ||||
|         $reachablesCircles = $this->authorizationHelper->getReachableCircles( | ||||
|         $reachablesCircles = $this->authorizationHelper->getReachableScopes( | ||||
|             $this->getUser(), | ||||
|             EventVoter::SEE, | ||||
|             $person->getCenter() | ||||
| @@ -233,6 +207,12 @@ class EventController extends AbstractController | ||||
|      */ | ||||
|     public function newAction(?Center $center, Request $request) | ||||
|     { | ||||
|         $user = $this->security->getUser(); | ||||
|  | ||||
|         if (!$user instanceof User) { | ||||
|             throw new AccessDeniedHttpException('not a regular user. Maybe an administrator ?'); | ||||
|         } | ||||
|  | ||||
|         if (null === $center) { | ||||
|             $center_id = $request->query->get('center_id'); | ||||
|             $center = $this->getDoctrine()->getRepository(Center::class)->find($center_id); | ||||
| @@ -240,6 +220,7 @@ class EventController extends AbstractController | ||||
|  | ||||
|         $entity = new Event(); | ||||
|         $entity->setCenter($center); | ||||
|         $entity->setLocation($user->getCurrentLocation()); | ||||
|  | ||||
|         $form = $this->createCreateForm($entity); | ||||
|         $form->handleRequest($request); | ||||
| @@ -282,7 +263,7 @@ class EventController extends AbstractController | ||||
|         } | ||||
|  | ||||
|         $form = $this->formFactoryInterface | ||||
|             ->createNamedBuilder(null, FormType::class, null, [ | ||||
|             ->createNamedBuilder('', FormType::class, null, [ | ||||
|                 'csrf_protection' => false, | ||||
|             ]) | ||||
|             ->setMethod('GET') | ||||
| @@ -323,7 +304,7 @@ class EventController extends AbstractController | ||||
|         } | ||||
|  | ||||
|         $this->denyAccessUnlessGranted( | ||||
|             'CHILL_EVENT_SEE_DETAILS', | ||||
|             EventVoter::SEE_DETAILS, | ||||
|             $event, | ||||
|             'You are not allowed to see details on this event' | ||||
|         ); | ||||
| @@ -367,7 +348,7 @@ class EventController extends AbstractController | ||||
|             $this->addFlash('success', $this->translator | ||||
|                 ->trans('The event was updated')); | ||||
|  | ||||
|             return $this->redirectToRoute('chill_event__event_edit', ['event_id' => $event_id]); | ||||
|             return $this->redirectToRoute('chill_event__event_show', ['event_id' => $event_id]); | ||||
|         } | ||||
|  | ||||
|         return $this->render('@ChillEvent/Event/edit.html.twig', [ | ||||
| @@ -385,7 +366,7 @@ class EventController extends AbstractController | ||||
|     { | ||||
|         /** @var \Symfony\Component\Form\FormBuilderInterface $builder */ | ||||
|         $builder = $this | ||||
|             ->get('form.factory') | ||||
|             ->formFactoryInterface | ||||
|             ->createNamedBuilder( | ||||
|                 null, | ||||
|                 FormType::class, | ||||
| @@ -430,11 +411,9 @@ class EventController extends AbstractController | ||||
|      */ | ||||
|     protected function createAddParticipationByPersonForm(Event $event) | ||||
|     { | ||||
|         /** @var \Symfony\Component\Form\FormBuilderInterface $builder */ | ||||
|         $builder = $this | ||||
|             ->get('form.factory') | ||||
|         $builder = $this->formFactoryInterface | ||||
|             ->createNamedBuilder( | ||||
|                 null, | ||||
|                 '', | ||||
|                 FormType::class, | ||||
|                 null, | ||||
|                 [ | ||||
| @@ -444,23 +423,17 @@ class EventController extends AbstractController | ||||
|                 ] | ||||
|             ); | ||||
|  | ||||
|         $builder->add('person_id', PickPersonType::class, [ | ||||
|             'role' => 'CHILL_EVENT_CREATE', | ||||
|             'centers' => $event->getCenter(), | ||||
|         $builder->add('person_id', PickPersonDynamicType::class, [ | ||||
|             'as_id' => true, | ||||
|             'multiple' => false, | ||||
|             'submit_on_adding_new_entity' => true, | ||||
|             'label' => 'Add a participation', | ||||
|         ]); | ||||
|  | ||||
|         $builder->add('event_id', HiddenType::class, [ | ||||
|             'data' => $event->getId(), | ||||
|         ]); | ||||
|  | ||||
|         $builder->add( | ||||
|             'submit', | ||||
|             SubmitType::class, | ||||
|             [ | ||||
|                 'label' => 'Add a participation', | ||||
|             ] | ||||
|         ); | ||||
|  | ||||
|         return $builder->getForm(); | ||||
|     } | ||||
|  | ||||
| @@ -469,7 +442,7 @@ class EventController extends AbstractController | ||||
|      */ | ||||
|     protected function createExportByFormatForm() | ||||
|     { | ||||
|         $builder = $this->createFormBuilder() | ||||
|         $builder = $this->createFormBuilder(['format' => 'xlsx']) | ||||
|             ->add('format', ChoiceType::class, [ | ||||
|                 'choices' => [ | ||||
|                     'xlsx' => 'xlsx', | ||||
|   | ||||
							
								
								
									
										118
									
								
								src/Bundle/ChillEventBundle/Controller/EventListController.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										118
									
								
								src/Bundle/ChillEventBundle/Controller/EventListController.php
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,118 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\EventBundle\Controller; | ||||
|  | ||||
| use Chill\EventBundle\Entity\Event; | ||||
| use Chill\EventBundle\Entity\EventType; | ||||
| use Chill\EventBundle\Repository\EventACLAwareRepositoryInterface; | ||||
| use Chill\EventBundle\Repository\EventTypeRepository; | ||||
| use Chill\MainBundle\Pagination\PaginatorFactoryInterface; | ||||
| use Chill\MainBundle\Templating\Listing\FilterOrderHelper; | ||||
| use Chill\MainBundle\Templating\Listing\FilterOrderHelperFactory; | ||||
| use Chill\MainBundle\Templating\TranslatableStringHelperInterface; | ||||
| use Chill\PersonBundle\Form\Type\PickPersonDynamicType; | ||||
| use Symfony\Component\Form\Extension\Core\Type\FormType; | ||||
| use Symfony\Component\Form\Extension\Core\Type\HiddenType; | ||||
| use Symfony\Component\Form\FormFactoryInterface; | ||||
| use Symfony\Component\Form\FormInterface; | ||||
| use Symfony\Component\HttpFoundation\Response; | ||||
| use Symfony\Component\Routing\Annotation\Route; | ||||
| use Symfony\Component\Routing\Generator\UrlGeneratorInterface; | ||||
| use Twig\Environment; | ||||
|  | ||||
| final readonly class EventListController | ||||
| { | ||||
|     public function __construct( | ||||
|         private Environment $environment, | ||||
|         private EventACLAwareRepositoryInterface $eventACLAwareRepository, | ||||
|         private EventTypeRepository $eventTypeRepository, | ||||
|         private FilterOrderHelperFactory $filterOrderHelperFactory, | ||||
|         private FormFactoryInterface $formFactory, | ||||
|         private PaginatorFactoryInterface $paginatorFactory, | ||||
|         private TranslatableStringHelperInterface $translatableStringHelper, | ||||
|         private UrlGeneratorInterface $urlGenerator, | ||||
|     ) { | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @Route("{_locale}/event/event/list", name="chill_event_event_list") | ||||
|      */ | ||||
|     public function __invoke(): Response | ||||
|     { | ||||
|         $filter = $this->buildFilterOrder(); | ||||
|         $filterData = [ | ||||
|             'q' => (string) $filter->getQueryString(), | ||||
|             'dates' => $filter->getDateRangeData('dates'), | ||||
|             'event_types' => $filter->getEntityChoiceData('event_types'), | ||||
|         ]; | ||||
|         $total = $this->eventACLAwareRepository->countAllViewable($filterData); | ||||
|         $pagination = $this->paginatorFactory->create($total); | ||||
|         $events = $this->eventACLAwareRepository->findAllViewable($filterData, $pagination->getCurrentPageFirstItemNumber(), $pagination->getItemsPerPage()); | ||||
|         $eventForms = []; | ||||
|         foreach ($events as $event) { | ||||
|             $eventForms[$event->getId()] = $this->createAddParticipationByPersonForm($event)->createView(); | ||||
|         } | ||||
|  | ||||
|         return new Response($this->environment->render( | ||||
|             '@ChillEvent/Event/page_list.html.twig', | ||||
|             [ | ||||
|                 'events' => $events, | ||||
|                 'pagination' => $pagination, | ||||
|                 'eventForms' => $eventForms, | ||||
|                 'filter' => $filter, | ||||
|             ] | ||||
|         )); | ||||
|     } | ||||
|  | ||||
|     private function buildFilterOrder(): FilterOrderHelper | ||||
|     { | ||||
|         $types = $this->eventTypeRepository->findAllActive(); | ||||
|  | ||||
|         $builder = $this->filterOrderHelperFactory->create(__METHOD__); | ||||
|         $builder | ||||
|             ->addDateRange('dates', 'event.filter.event_dates') | ||||
|             ->addSearchBox(['name']) | ||||
|             ->addEntityChoice('event_types', 'event.filter.event_types', EventType::class, $types, [ | ||||
|                 'choice_label' => fn (EventType $e) => $this->translatableStringHelper->localize($e->getName()), | ||||
|             ]); | ||||
|  | ||||
|         return $builder->build(); | ||||
|     } | ||||
|  | ||||
|     private function createAddParticipationByPersonForm(Event $event): FormInterface | ||||
|     { | ||||
|         $builder = $this->formFactory | ||||
|             ->createNamedBuilder( | ||||
|                 '', | ||||
|                 FormType::class, | ||||
|                 null, | ||||
|                 [ | ||||
|                     'method' => 'GET', | ||||
|                     'action' => $this->urlGenerator->generate('chill_event_participation_new'), | ||||
|                     'csrf_protection' => false, | ||||
|                 ] | ||||
|             ); | ||||
|  | ||||
|         $builder->add('person_id', PickPersonDynamicType::class, [ | ||||
|             'as_id' => true, | ||||
|             'multiple' => false, | ||||
|             'submit_on_adding_new_entity' => true, | ||||
|             'label' => 'Add a participation', | ||||
|         ]); | ||||
|  | ||||
|         $builder->add('event_id', HiddenType::class, [ | ||||
|             'data' => $event->getId(), | ||||
|         ]); | ||||
|  | ||||
|         return $builder->getForm(); | ||||
|     } | ||||
| } | ||||
| @@ -14,7 +14,10 @@ namespace Chill\EventBundle\Controller; | ||||
| use Chill\EventBundle\Entity\Event; | ||||
| use Chill\EventBundle\Entity\Participation; | ||||
| use Chill\EventBundle\Form\ParticipationType; | ||||
| use Chill\EventBundle\Repository\EventRepository; | ||||
| use Chill\EventBundle\Security\Authorization\ParticipationVoter; | ||||
| use Chill\PersonBundle\Repository\PersonRepository; | ||||
| use Chill\PersonBundle\Security\Authorization\PersonVoter; | ||||
| use Doctrine\Common\Collections\Collection; | ||||
| use Psr\Log\LoggerInterface; | ||||
| use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; | ||||
| @@ -28,13 +31,17 @@ use Symfony\Contracts\Translation\TranslatorInterface; | ||||
| /** | ||||
|  * Class ParticipationController. | ||||
|  */ | ||||
| class ParticipationController extends AbstractController | ||||
| final class ParticipationController extends AbstractController | ||||
| { | ||||
|     /** | ||||
|      * ParticipationController constructor. | ||||
|      */ | ||||
|     public function __construct(private readonly LoggerInterface $logger, private readonly TranslatorInterface $translator) | ||||
|     { | ||||
|     public function __construct( | ||||
|         private readonly LoggerInterface $logger, | ||||
|         private readonly TranslatorInterface $translator, | ||||
|         private readonly EventRepository $eventRepository, | ||||
|         private readonly PersonRepository $personRepository, | ||||
|     ) { | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @@ -230,6 +237,7 @@ class ParticipationController extends AbstractController | ||||
|         return $this->render('@ChillEvent/Participation/new.html.twig', [ | ||||
|             'form' => $form->createView(), | ||||
|             'participation' => $participation, | ||||
|             'ignored_participations' => [], | ||||
|         ]); | ||||
|     } | ||||
|  | ||||
| @@ -539,7 +547,7 @@ class ParticipationController extends AbstractController | ||||
|      * If the request is multiple, the $participation object is cloned. | ||||
|      * Limitations: the $participation should not be persisted. | ||||
|      * | ||||
|      * @return Participation|Participation[] return one single participation if $multiple == false | ||||
|      * @return Participation|list<Participation> return one single participation if $multiple == false | ||||
|      * | ||||
|      * @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException    if the event/person is not found | ||||
|      * @throws \Symfony\Component\Security\Core\Exception\AccessDeniedException if the user does not have access to event/person | ||||
| @@ -556,30 +564,25 @@ class ParticipationController extends AbstractController | ||||
|         } | ||||
|  | ||||
|         $event_id = $request->query->getInt('event_id', 0); // sf4 check: | ||||
|         // prevent error: `Argument 2 passed to ::getInt() must be of the type int, null given` | ||||
|  | ||||
|         if (null !== $event_id) { | ||||
|             $event = $em->getRepository(Event::class) | ||||
|                 ->find($event_id); | ||||
|         $event = $this->eventRepository->find($event_id); | ||||
|  | ||||
|             if (null === $event) { | ||||
|                 throw $this->createNotFoundException('The event with id '.$event_id.' is not found'); | ||||
|             } | ||||
|  | ||||
|             $this->denyAccessUnlessGranted( | ||||
|                 'CHILL_EVENT_SEE', | ||||
|                 $event, | ||||
|                 'The user is not allowed to see the event' | ||||
|             ); | ||||
|  | ||||
|             $participation->setEvent($event); | ||||
|         if (null === $event) { | ||||
|             throw $this->createNotFoundException('The event with id '.$event_id.' is not found'); | ||||
|         } | ||||
|  | ||||
|         $this->denyAccessUnlessGranted( | ||||
|             'CHILL_EVENT_SEE', | ||||
|             $event, | ||||
|             'The user is not allowed to see the event' | ||||
|         ); | ||||
|  | ||||
|         $participation->setEvent($event); | ||||
|  | ||||
|         // this script should be able to handle multiple, so we translate | ||||
|         // single person_id in an array | ||||
|         $persons_ids = $request->query->has('person_id') ? | ||||
|             [$request->query->getInt('person_id', 0)]  // sf4 check: | ||||
|                // prevent error: `Argument 2 passed to ::getInt() must be of the type int, null given` | ||||
|             [$request->query->get('person_id', 0)] | ||||
|             : explode(',', (string) $request->query->get('persons_ids')); | ||||
|         $participations = []; | ||||
|  | ||||
| @@ -588,15 +591,14 @@ class ParticipationController extends AbstractController | ||||
|             $participation = \count($persons_ids) > 1 ? clone $participation : $participation; | ||||
|  | ||||
|             if (null !== $person_id) { | ||||
|                 $person = $em->getRepository(\Chill\PersonBundle\Entity\Person::class) | ||||
|                     ->find($person_id); | ||||
|                 $person = $this->personRepository->find($person_id); | ||||
|  | ||||
|                 if (null === $person) { | ||||
|                     throw $this->createNotFoundException('The person with id '.$person_id.' is not found'); | ||||
|                 } | ||||
|  | ||||
|                 $this->denyAccessUnlessGranted( | ||||
|                     'CHILL_PERSON_SEE', | ||||
|                     PersonVoter::SEE, | ||||
|                     $person, | ||||
|                     'The user is not allowed to see the person' | ||||
|                 ); | ||||
|   | ||||
| @@ -12,6 +12,7 @@ declare(strict_types=1); | ||||
| namespace Chill\EventBundle\DependencyInjection; | ||||
|  | ||||
| use Chill\EventBundle\Security\Authorization\EventVoter; | ||||
| use Chill\EventBundle\Security\Authorization\ParticipationVoter; | ||||
| use Symfony\Component\Config\FileLocator; | ||||
| use Symfony\Component\DependencyInjection\ContainerBuilder; | ||||
| use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface; | ||||
| @@ -33,10 +34,8 @@ class ChillEventExtension extends Extension implements PrependExtensionInterface | ||||
|         $loader = new Loader\YamlFileLoader($container, new FileLocator(__DIR__.'/../config')); | ||||
|         $loader->load('services.yaml'); | ||||
|         $loader->load('services/authorization.yaml'); | ||||
|         $loader->load('services/controller.yaml'); | ||||
|         $loader->load('services/fixtures.yaml'); | ||||
|         $loader->load('services/forms.yaml'); | ||||
|         $loader->load('services/menu.yaml'); | ||||
|         $loader->load('services/repositories.yaml'); | ||||
|         $loader->load('services/search.yaml'); | ||||
|         $loader->load('services/timeline.yaml'); | ||||
| @@ -61,6 +60,8 @@ class ChillEventExtension extends Extension implements PrependExtensionInterface | ||||
|                 EventVoter::SEE_DETAILS => [EventVoter::SEE], | ||||
|                 EventVoter::UPDATE => [EventVoter::SEE_DETAILS], | ||||
|                 EventVoter::CREATE => [EventVoter::SEE_DETAILS], | ||||
|                 ParticipationVoter::SEE_DETAILS => [ParticipationVoter::SEE], | ||||
|                 ParticipationVoter::UPDATE => [ParticipationVoter::SEE_DETAILS], | ||||
|             ], | ||||
|         ]); | ||||
|     } | ||||
|   | ||||
| @@ -11,15 +11,23 @@ declare(strict_types=1); | ||||
|  | ||||
| namespace Chill\EventBundle\Entity; | ||||
|  | ||||
| use Chill\DocStoreBundle\Entity\StoredObject; | ||||
| use Chill\MainBundle\Doctrine\Model\TrackCreationInterface; | ||||
| use Chill\MainBundle\Doctrine\Model\TrackCreationTrait; | ||||
| use Chill\MainBundle\Doctrine\Model\TrackUpdateInterface; | ||||
| use Chill\MainBundle\Doctrine\Model\TrackUpdateTrait; | ||||
| use Chill\MainBundle\Entity\Center; | ||||
| use Chill\MainBundle\Entity\Embeddable\CommentEmbeddable; | ||||
| use Chill\MainBundle\Entity\HasCenterInterface; | ||||
| use Chill\MainBundle\Entity\HasScopeInterface; | ||||
| use Chill\MainBundle\Entity\Location; | ||||
| use Chill\MainBundle\Entity\Scope; | ||||
| use Chill\MainBundle\Entity\User; | ||||
| use DateTime; | ||||
| use Doctrine\Common\Collections\ArrayCollection; | ||||
| use Doctrine\Common\Collections\Collection; | ||||
| use Doctrine\ORM\Mapping as ORM; | ||||
| use Symfony\Component\Validator\Constraints as Assert; | ||||
|  | ||||
| /** | ||||
|  * Class Event. | ||||
| @@ -30,10 +38,15 @@ use Doctrine\ORM\Mapping as ORM; | ||||
|  * | ||||
|  * @ORM\HasLifecycleCallbacks | ||||
|  */ | ||||
| class Event implements HasCenterInterface, HasScopeInterface | ||||
| class Event implements HasCenterInterface, HasScopeInterface, TrackCreationInterface, TrackUpdateInterface | ||||
| { | ||||
|     use TrackCreationTrait; | ||||
|     use TrackUpdateTrait; | ||||
|  | ||||
|     /** | ||||
|      * @ORM\ManyToOne(targetEntity="Chill\MainBundle\Entity\Center") | ||||
|      * @ORM\ManyToOne(targetEntity="Chill\MainBundle\Entity\Center")A | ||||
|      * | ||||
|      * @Assert\NotNull() | ||||
|      */ | ||||
|     private ?Center $center = null; | ||||
|  | ||||
| @@ -63,6 +76,8 @@ class Event implements HasCenterInterface, HasScopeInterface | ||||
|  | ||||
|     /** | ||||
|      * @ORM\Column(type="string", length=150) | ||||
|      * | ||||
|      * @Assert\NotBlank() | ||||
|      */ | ||||
|     private ?string $name = null; | ||||
|  | ||||
| @@ -77,15 +92,45 @@ class Event implements HasCenterInterface, HasScopeInterface | ||||
|  | ||||
|     /** | ||||
|      * @ORM\ManyToOne(targetEntity="Chill\EventBundle\Entity\EventType") | ||||
|      * | ||||
|      * @Assert\NotNull() | ||||
|      */ | ||||
|     private ?EventType $type = null; | ||||
|  | ||||
|     /** | ||||
|      * @ORM\Embedded(class=CommentEmbeddable::class, columnPrefix="comment_") | ||||
|      */ | ||||
|     private CommentEmbeddable $comment; | ||||
|  | ||||
|     /** | ||||
|      * @ORM\ManyToOne(targetEntity=Location::class) | ||||
|      * | ||||
|      * @ORM\JoinColumn(nullable=true) | ||||
|      */ | ||||
|     private ?Location $location = null; | ||||
|  | ||||
|     /** | ||||
|      * @var Collection<StoredObject> | ||||
|      * | ||||
|      * @ORM\ManyToMany(targetEntity=StoredObject::class, cascade={"persist","refresh"}) | ||||
|      * | ||||
|      * @ORM\JoinTable("chill_event_event_documents") | ||||
|      */ | ||||
|     private Collection $documents; | ||||
|  | ||||
|     /** | ||||
|      * @ORM\Column(type="decimal", precision=10, scale=4, nullable=true, options={"default": null}) | ||||
|      */ | ||||
|     private string $organizationCost = '0.0'; | ||||
|  | ||||
|     /** | ||||
|      * Event constructor. | ||||
|      */ | ||||
|     public function __construct() | ||||
|     { | ||||
|         $this->participations = new ArrayCollection(); | ||||
|         $this->documents = new ArrayCollection(); | ||||
|         $this->comment = new CommentEmbeddable(); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @@ -100,6 +145,22 @@ class Event implements HasCenterInterface, HasScopeInterface | ||||
|         return $this; | ||||
|     } | ||||
|  | ||||
|     public function addDocument(StoredObject $storedObject): self | ||||
|     { | ||||
|         if ($this->documents->contains($storedObject)) { | ||||
|             $this->documents[] = $storedObject; | ||||
|         } | ||||
|  | ||||
|         return $this; | ||||
|     } | ||||
|  | ||||
|     public function removeDocument(StoredObject $storedObject): self | ||||
|     { | ||||
|         $this->documents->removeElement($storedObject); | ||||
|  | ||||
|         return $this; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @return Center | ||||
|      */ | ||||
| @@ -259,4 +320,44 @@ class Event implements HasCenterInterface, HasScopeInterface | ||||
|  | ||||
|         return $this; | ||||
|     } | ||||
|  | ||||
|     public function getComment(): CommentEmbeddable | ||||
|     { | ||||
|         return $this->comment; | ||||
|     } | ||||
|  | ||||
|     public function setComment(CommentEmbeddable $comment): void | ||||
|     { | ||||
|         $this->comment = $comment; | ||||
|     } | ||||
|  | ||||
|     public function getLocation(): ?Location | ||||
|     { | ||||
|         return $this->location; | ||||
|     } | ||||
|  | ||||
|     public function setLocation(?Location $location): void | ||||
|     { | ||||
|         $this->location = $location; | ||||
|     } | ||||
|  | ||||
|     public function getDocuments(): Collection | ||||
|     { | ||||
|         return $this->documents; | ||||
|     } | ||||
|  | ||||
|     public function setDocuments(Collection $documents): void | ||||
|     { | ||||
|         $this->documents = $documents; | ||||
|     } | ||||
|  | ||||
|     public function getOrganizationCost(): string | ||||
|     { | ||||
|         return $this->organizationCost; | ||||
|     } | ||||
|  | ||||
|     public function setOrganizationCost(string $organizationCost): void | ||||
|     { | ||||
|         $this->organizationCost = $organizationCost; | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -11,13 +11,17 @@ declare(strict_types=1); | ||||
|  | ||||
| namespace Chill\EventBundle\Entity; | ||||
|  | ||||
| use Chill\MainBundle\Entity\Center; | ||||
| use Chill\MainBundle\Doctrine\Model\TrackCreationInterface; | ||||
| use Chill\MainBundle\Doctrine\Model\TrackCreationTrait; | ||||
| use Chill\MainBundle\Doctrine\Model\TrackUpdateInterface; | ||||
| use Chill\MainBundle\Doctrine\Model\TrackUpdateTrait; | ||||
| use Chill\MainBundle\Entity\HasCenterInterface; | ||||
| use Chill\MainBundle\Entity\HasScopeInterface; | ||||
| use Chill\MainBundle\Entity\Scope; | ||||
| use Chill\PersonBundle\Entity\Person; | ||||
| use DateTime; | ||||
| use Doctrine\ORM\Mapping as ORM; | ||||
| use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; | ||||
| use Symfony\Component\Validator\Constraints as Assert; | ||||
| use Symfony\Component\Validator\Context\ExecutionContextInterface; | ||||
|  | ||||
| /** | ||||
| @@ -26,12 +30,20 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface; | ||||
|  * @ORM\Entity( | ||||
|  * repositoryClass="Chill\EventBundle\Repository\ParticipationRepository") | ||||
|  * | ||||
|  * @ORM\Table(name="chill_event_participation") | ||||
|  * @ORM\Table(name="chill_event_participation", uniqueConstraints={ | ||||
|  * | ||||
|  *     @ORM\UniqueConstraint(name="chill_event_participation_event_person_unique_idx", columns={"event_id", "person_id"}) | ||||
|  *     }) | ||||
|  * | ||||
|  * @ORM\HasLifecycleCallbacks | ||||
|  * | ||||
|  * @UniqueEntity({"event", "person"}, message="event.validation.person_already_participate_to_event") | ||||
|  */ | ||||
| class Participation implements \ArrayAccess, HasCenterInterface, HasScopeInterface | ||||
| class Participation implements \ArrayAccess, HasCenterInterface, HasScopeInterface, TrackUpdateInterface, TrackCreationInterface | ||||
| { | ||||
|     use TrackCreationTrait; | ||||
|     use TrackUpdateTrait; | ||||
|  | ||||
|     /** | ||||
|      * @ORM\ManyToOne( | ||||
|      *     targetEntity="Chill\EventBundle\Entity\Event", | ||||
| @@ -48,13 +60,10 @@ class Participation implements \ArrayAccess, HasCenterInterface, HasScopeInterfa | ||||
|      */ | ||||
|     private ?int $id = null; | ||||
|  | ||||
|     /** | ||||
|      * @ORM\Column(type="datetime") | ||||
|      */ | ||||
|     private ?\DateTime $lastUpdate = null; | ||||
|  | ||||
|     /** | ||||
|      * @ORM\ManyToOne(targetEntity="Chill\PersonBundle\Entity\Person") | ||||
|      * | ||||
|      * @Assert\NotNull() | ||||
|      */ | ||||
|     private ?Person $person = null; | ||||
|  | ||||
| @@ -65,12 +74,11 @@ class Participation implements \ArrayAccess, HasCenterInterface, HasScopeInterfa | ||||
|  | ||||
|     /** | ||||
|      * @ORM\ManyToOne(targetEntity="Chill\EventBundle\Entity\Status") | ||||
|      * | ||||
|      * @Assert\NotNull() | ||||
|      */ | ||||
|     private ?Status $status = null; | ||||
|  | ||||
|     /** | ||||
|      * @return Center | ||||
|      */ | ||||
|     public function getCenter() | ||||
|     { | ||||
|         if (null === $this->getEvent()) { | ||||
| @@ -90,10 +98,8 @@ class Participation implements \ArrayAccess, HasCenterInterface, HasScopeInterfa | ||||
|  | ||||
|     /** | ||||
|      * Get id. | ||||
|      * | ||||
|      * @return int | ||||
|      */ | ||||
|     public function getId() | ||||
|     public function getId(): int | ||||
|     { | ||||
|         return $this->id; | ||||
|     } | ||||
| @@ -101,11 +107,11 @@ class Participation implements \ArrayAccess, HasCenterInterface, HasScopeInterfa | ||||
|     /** | ||||
|      * Get lastUpdate. | ||||
|      * | ||||
|      * @return \DateTime | ||||
|      * @return \DateTimeInterface|null | ||||
|      */ | ||||
|     public function getLastUpdate() | ||||
|     { | ||||
|         return $this->lastUpdate; | ||||
|         return $this->getUpdatedAt(); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @@ -235,10 +241,6 @@ class Participation implements \ArrayAccess, HasCenterInterface, HasScopeInterfa | ||||
|      */ | ||||
|     public function setEvent(?Event $event = null) | ||||
|     { | ||||
|         if ($this->event !== $event) { | ||||
|             $this->update(); | ||||
|         } | ||||
|  | ||||
|         $this->event = $event; | ||||
|  | ||||
|         return $this; | ||||
| @@ -251,10 +253,6 @@ class Participation implements \ArrayAccess, HasCenterInterface, HasScopeInterfa | ||||
|      */ | ||||
|     public function setPerson(?Person $person = null) | ||||
|     { | ||||
|         if ($person !== $this->person) { | ||||
|             $this->update(); | ||||
|         } | ||||
|  | ||||
|         $this->person = $person; | ||||
|  | ||||
|         return $this; | ||||
| @@ -267,9 +265,6 @@ class Participation implements \ArrayAccess, HasCenterInterface, HasScopeInterfa | ||||
|      */ | ||||
|     public function setRole(?Role $role = null) | ||||
|     { | ||||
|         if ($role !== $this->role) { | ||||
|             $this->update(); | ||||
|         } | ||||
|         $this->role = $role; | ||||
|  | ||||
|         return $this; | ||||
| @@ -282,10 +277,6 @@ class Participation implements \ArrayAccess, HasCenterInterface, HasScopeInterfa | ||||
|      */ | ||||
|     public function setStatus(?Status $status = null) | ||||
|     { | ||||
|         if ($this->status !== $status) { | ||||
|             $this->update(); | ||||
|         } | ||||
|  | ||||
|         $this->status = $status; | ||||
|  | ||||
|         return $this; | ||||
| @@ -295,11 +286,11 @@ class Participation implements \ArrayAccess, HasCenterInterface, HasScopeInterfa | ||||
|      * Set lastUpdate. | ||||
|      * | ||||
|      * @return Participation | ||||
|      * | ||||
|      * @deprecated | ||||
|      */ | ||||
|     protected function update() | ||||
|     { | ||||
|         $this->lastUpdate = new \DateTime('now'); | ||||
|  | ||||
|         return $this; | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -11,12 +11,18 @@ declare(strict_types=1); | ||||
|  | ||||
| namespace Chill\EventBundle\Form; | ||||
|  | ||||
| use Chill\DocStoreBundle\Entity\StoredObject; | ||||
| use Chill\DocStoreBundle\Form\StoredObjectType; | ||||
| use Chill\EventBundle\Form\Type\PickEventTypeType; | ||||
| use Chill\MainBundle\Entity\Center; | ||||
| use Chill\MainBundle\Form\Type\ChillCollectionType; | ||||
| use Chill\MainBundle\Form\Type\ChillDateTimeType; | ||||
| use Chill\MainBundle\Form\Type\CommentType; | ||||
| use Chill\MainBundle\Form\Type\PickUserLocationType; | ||||
| use Chill\MainBundle\Form\Type\ScopePickerType; | ||||
| use Chill\MainBundle\Form\Type\UserPickerType; | ||||
| use Symfony\Component\Form\AbstractType; | ||||
| use Symfony\Component\Form\Extension\Core\Type\MoneyType; | ||||
| use Symfony\Component\Form\FormBuilderInterface; | ||||
| use Symfony\Component\OptionsResolver\OptionsResolver; | ||||
|  | ||||
| @@ -47,6 +53,28 @@ class EventType extends AbstractType | ||||
|                     'class' => '', | ||||
|                 ], | ||||
|                 'required' => false, | ||||
|             ]) | ||||
|             ->add('location', PickUserLocationType::class, [ | ||||
|                 'label' => 'event.fields.location', | ||||
|             ]) | ||||
|             ->add('comment', CommentType::class, [ | ||||
|                 'label' => 'Comment', | ||||
|                 'required' => false, | ||||
|             ]) | ||||
|             ->add('documents', ChillCollectionType::class, [ | ||||
|                 'entry_type' => StoredObjectType::class, | ||||
|                 'entry_options' => [ | ||||
|                     'has_title' => true, | ||||
|                 ], | ||||
|                 'allow_add' => true, | ||||
|                 'allow_delete' => true, | ||||
|                 'delete_empty' => fn (StoredObject $storedObject): bool => '' === $storedObject->getFilename(), | ||||
|                 'button_remove_label' => 'event.form.remove_document', | ||||
|                 'button_add_label' => 'event.form.add_document', | ||||
|             ]) | ||||
|             ->add('organizationCost', MoneyType::class, [ | ||||
|                 'label' => 'event.fields.organizationCost', | ||||
|                 'help' => 'event.form.organisationCost_help', | ||||
|             ]); | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -114,7 +114,7 @@ final class PickEventType extends AbstractType | ||||
|         } else { | ||||
|             $centers = $this->authorizationHelper->getReachableCenters( | ||||
|                 $user, | ||||
|                 (string) $options['role']->getRole() | ||||
|                 $options['role'] | ||||
|             ); | ||||
|         } | ||||
|  | ||||
|   | ||||
							
								
								
									
										46
									
								
								src/Bundle/ChillEventBundle/Menu/SectionMenuBuilder.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								src/Bundle/ChillEventBundle/Menu/SectionMenuBuilder.php
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,46 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\EventBundle\Menu; | ||||
|  | ||||
| use Chill\EventBundle\Security\Authorization\EventVoter; | ||||
| use Chill\MainBundle\Routing\LocalMenuBuilderInterface; | ||||
| use Knp\Menu\MenuItem; | ||||
| use Symfony\Component\Security\Core\Security; | ||||
| use Symfony\Contracts\Translation\TranslatorInterface; | ||||
|  | ||||
| final readonly class SectionMenuBuilder implements LocalMenuBuilderInterface | ||||
| { | ||||
|     public function __construct( | ||||
|         private Security $security, | ||||
|         private TranslatorInterface $translator, | ||||
|     ) { | ||||
|     } | ||||
|  | ||||
|     public function buildMenu($menuId, MenuItem $menu, array $parameters) | ||||
|     { | ||||
|         if ($this->security->isGranted(EventVoter::SEE)) { | ||||
|             $menu->addChild( | ||||
|                 $this->translator->trans('Events'), | ||||
|                 [ | ||||
|                     'route' => 'chill_event_event_list', | ||||
|                 ] | ||||
|             )->setExtras([ | ||||
|                 'order' => 250, | ||||
|             ]); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public static function getMenuIds(): array | ||||
|     { | ||||
|         return ['section']; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,142 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\EventBundle\Repository; | ||||
|  | ||||
| use Chill\EventBundle\Entity\Event; | ||||
| use Chill\EventBundle\Entity\Participation; | ||||
| use Chill\EventBundle\Security\Authorization\EventVoter; | ||||
| use Chill\MainBundle\Entity\User; | ||||
| use Chill\MainBundle\Security\Authorization\AuthorizationHelperForCurrentUserInterface; | ||||
| use Chill\PersonBundle\Entity\Person; | ||||
| use Doctrine\ORM\EntityManagerInterface; | ||||
| use Doctrine\ORM\NonUniqueResultException; | ||||
| use Doctrine\ORM\NoResultException; | ||||
| use Doctrine\ORM\QueryBuilder; | ||||
| use Symfony\Component\Security\Core\Security; | ||||
|  | ||||
| final readonly class EventACLAwareRepository implements EventACLAwareRepositoryInterface | ||||
| { | ||||
|     public function __construct( | ||||
|         private AuthorizationHelperForCurrentUserInterface $authorizationHelperForCurrentUser, | ||||
|         private EntityManagerInterface $entityManager, | ||||
|         private Security $security, | ||||
|     ) { | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @throws NonUniqueResultException | ||||
|      * @throws NoResultException | ||||
|      */ | ||||
|     public function countAllViewable(array $filters): int | ||||
|     { | ||||
|         if (!$this->security->getUser() instanceof User) { | ||||
|             return 0; | ||||
|         } | ||||
|  | ||||
|         $qb = $this->buildQueryByAllViewable($filters); | ||||
|         $this->addFilters($filters, $qb); | ||||
|  | ||||
|         $qb->select('COUNT(event.id)'); | ||||
|  | ||||
|         return $qb->getQuery()->getSingleScalarResult(); | ||||
|     } | ||||
|  | ||||
|     public function findAllViewable(array $filters, int $offset = 0, int $limit = 50): array | ||||
|     { | ||||
|         if (!$this->security->getUser() instanceof User) { | ||||
|             return []; | ||||
|         } | ||||
|  | ||||
|         $qb = $this->buildQueryByAllViewable($filters)->select('event'); | ||||
|         $this->addFilters($filters, $qb); | ||||
|  | ||||
|         $qb->setFirstResult($offset)->setMaxResults($limit); | ||||
|  | ||||
|         $qb->addOrderBy('event.date', 'DESC'); | ||||
|  | ||||
|         return $qb->getQuery()->getResult(); | ||||
|     } | ||||
|  | ||||
|     private function addFilters(array $filters, QueryBuilder $qb): void | ||||
|     { | ||||
|         if (($filters['q'] ?? '') !== '') { | ||||
|             $qb->andWhere('event.name LIKE :content'); | ||||
|             $qb->setParameter('content', '%'.$filters['q'].'%'); | ||||
|         } | ||||
|  | ||||
|         if (array_key_exists('dates', $filters)) { | ||||
|             $dates = $filters['dates']; | ||||
|             if (null !== ($dates['from'] ?? null)) { | ||||
|                 $qb->andWhere('event.date >= :date_from'); | ||||
|                 $qb->setParameter('date_from', $dates['from']); | ||||
|             } | ||||
|             if (null !== ($dates['to'] ?? null)) { | ||||
|                 $qb->andWhere('event.date <= :date_to'); | ||||
|                 $qb->setParameter('date_to', $dates['to']); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         if (0 < count($filters['event_types'] ?? [])) { | ||||
|             $qb->andWhere('event.type IN (:event_types)'); | ||||
|             $qb->setParameter('event_types', $filters['event_types']); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public function buildQueryByAllViewable(array $filters): QueryBuilder | ||||
|     { | ||||
|         $qb = $this->entityManager->createQueryBuilder(); | ||||
|         $qb->from(Event::class, 'event'); | ||||
|  | ||||
|         $aclConditions = $qb->expr()->orX(); | ||||
|  | ||||
|         $i = 0; | ||||
|         foreach ($this->authorizationHelperForCurrentUser->getReachableCenters(EventVoter::SEE) as $center) { | ||||
|             foreach ($this->authorizationHelperForCurrentUser->getReachableScopes(EventVoter::SEE, $center) as $scopes) { | ||||
|                 $aclConditions->add( | ||||
|                     $qb->expr()->andX( | ||||
|                         'event.circle IN (:scopes_'.$i.')', | ||||
|                         $qb->expr()->orX( | ||||
|                             'event.center = :center_'.$i, | ||||
|                             $qb->expr()->exists( | ||||
|                                 'SELECT 1 FROM '.Participation::class.' participation_'.$i.' JOIN participation_'.$i.'.event event_'.$i. | ||||
|                                 ' JOIN '.Person\PersonCenterHistory::class.' person_center_history_'.$i. | ||||
|                                 ' WITH IDENTITY(person_center_history_'.$i.'.person) = IDENTITY(participation_'.$i.'.person) '. | ||||
|                                 ' AND event_'.$i.'.date <= person_center_history_'.$i.'.startDate AND (person_center_history_'.$i.'.endDate IS NULL OR person_center_history_'.$i.'.endDate > event_'.$i.'.date) '. | ||||
|                                 ' WHERE participation_'.$i.'.event = event' | ||||
|                             ) | ||||
|                         ) | ||||
|                     ) | ||||
|                 ); | ||||
|                 $qb->setParameter('scopes_'.$i, $scopes); | ||||
|                 $qb->setParameter('center_'.$i, $center); | ||||
|  | ||||
|                 ++$i; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         if (0 === $i) { | ||||
|             $aclConditions->add('FALSE = TRUE'); | ||||
|         } | ||||
|  | ||||
|         $qb | ||||
|             ->andWhere( | ||||
|                 $qb->expr()->orX( | ||||
|                     'event.createdBy = :user', | ||||
|                     $aclConditions | ||||
|                 ) | ||||
|             ); | ||||
|  | ||||
|         $qb->setParameter('user', $this->security->getUser()); | ||||
|  | ||||
|         return $qb; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,30 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\EventBundle\Repository; | ||||
|  | ||||
| use Chill\EventBundle\Entity\Event; | ||||
| use Chill\EventBundle\Entity\EventType; | ||||
|  | ||||
| interface EventACLAwareRepositoryInterface | ||||
| { | ||||
|     /** | ||||
|      * @param array{q?: string, dates?: array{from?: \DateTimeImmutable|null, to?: \DateTimeImmutable|null}, event_types?: list<EventType>} $filters | ||||
|      */ | ||||
|     public function countAllViewable(array $filters): int; | ||||
|  | ||||
|     /** | ||||
|      * @param array{q?: string, dates?: array{from?: \DateTimeImmutable|null, to?: \DateTimeImmutable|null}, event_types?: list<EventType>} $filters | ||||
|      * | ||||
|      * @return list<Event> | ||||
|      */ | ||||
|     public function findAllViewable(array $filters, int $offset = 0, int $limit = 50): array; | ||||
| } | ||||
| @@ -0,0 +1,44 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\EventBundle\Repository; | ||||
|  | ||||
| use Chill\EventBundle\Entity\EventType; | ||||
| use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; | ||||
| use Doctrine\ORM\EntityManagerInterface; | ||||
| use Doctrine\Persistence\ManagerRegistry; | ||||
| use Symfony\Contracts\Translation\TranslatorInterface; | ||||
|  | ||||
| /** | ||||
|  * @extends ServiceEntityRepository<EventType> | ||||
|  */ | ||||
| final class EventTypeRepository extends ServiceEntityRepository | ||||
| { | ||||
|     public function __construct( | ||||
|         ManagerRegistry $registry, | ||||
|         private readonly EntityManagerInterface $entityManager, | ||||
|         private readonly TranslatorInterface $translator | ||||
|     ) { | ||||
|         parent::__construct($registry, EventType::class); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @return list<EventType> | ||||
|      */ | ||||
|     public function findAllActive(): array | ||||
|     { | ||||
|         $dql = 'SELECT et FROM '.EventType::class.' et WHERE et.active = TRUE ORDER BY JSON_EXTRACT(et.name, :lang)'; | ||||
|  | ||||
|         return $this->entityManager->createQuery($dql) | ||||
|             ->setParameter('lang', $this->translator->getLocale()) | ||||
|             ->getResult(); | ||||
|     } | ||||
| } | ||||
| @@ -14,15 +14,16 @@ | ||||
|  | ||||
|     {{ form_row(edit_form.type, { 'label': 'Event type' }) }} | ||||
|     {{ form_row(edit_form.moderator) }} | ||||
|     {{ form_row(edit_form.location) }} | ||||
|     {{ form_row(edit_form.organizationCost) }} | ||||
|  | ||||
|     <ul class="record_actions"> | ||||
|     {{ form_row(edit_form.comment) }} | ||||
|     {{ form_row(edit_form.documents) }} | ||||
|  | ||||
|     <ul class="record_actions sticky-form-buttons"> | ||||
|         <li class="cancel"> | ||||
|  | ||||
|             {% set returnPath = app.request.get('return_path') %} | ||||
|             {% set returnLabel = app.request.get('return_label') %} | ||||
|  | ||||
|             <a href="{{ returnPath |default( path('chill_event_list_most_recent') ) }}" class="btn btn-cancel"> | ||||
|                 {{ returnLabel |default('Back to the most recent events'|trans) }} | ||||
|             <a href="{{ chill_return_path_or('chill_event_event_list') }}" class="btn btn-cancel"> | ||||
|                 {{ 'List of events'|trans|chill_return_path_label }} | ||||
|             </a> | ||||
|         </li> | ||||
|         <li> | ||||
|   | ||||
| @@ -24,85 +24,89 @@ | ||||
| {% block content %} | ||||
| <h2>{{ 'Events participation' |trans }}</h2> | ||||
|  | ||||
| <table class="table table-striped table-bordered border-dark align-middle mt-3 events"> | ||||
|     <thead> | ||||
|         <tr> | ||||
|             <th class="chill-green">{{ 'Date'|trans }}</th> | ||||
|             <th class="chill-red">{{ 'Name'|trans }}</th> | ||||
|             <th class="chill-orange">{{ 'Event type'|trans }}</th> | ||||
|             <th class="chill-red">{{ 'Role'|trans }}</th> | ||||
|             <th class="chill-green">{{ 'Status'|trans }}</th> | ||||
|             <th> </th> | ||||
|         </tr> | ||||
|     </thead> | ||||
|     <tbody> | ||||
|         {% for participation in participations %} | ||||
|             <tr> | ||||
|                 <td>{{ participation.event.date|format_date('short') }}</td> | ||||
|                 <td>{{ participation.event.name }}</td> | ||||
|                 <td>{{ participation.event.type.name|localize_translatable_string }}</td> | ||||
|                 <td>{{ participation.role.name|localize_translatable_string }}</td> | ||||
|                 <td>{{ participation.status.name|localize_translatable_string }}</td> | ||||
|                 <td> | ||||
|                     <div class="btn-group" role="group" aria-label="Button group actions"> | ||||
|     {% if participations|length == 0 %} | ||||
|         <p class="chill-no-data-statement">{{ 'Any participation for this person'|trans }}</p> | ||||
|     {% else %} | ||||
|         <table class="table table-striped table-bordered border-dark align-middle mt-3 events"> | ||||
|             <thead> | ||||
|                 <tr> | ||||
|                     <th class="chill-green">{{ 'Date'|trans }}</th> | ||||
|                     <th class="chill-red">{{ 'Name'|trans }}</th> | ||||
|                     <th class="chill-orange">{{ 'Event type'|trans }}</th> | ||||
|                     <th class="chill-red">{{ 'Role'|trans }}</th> | ||||
|                     <th class="chill-green">{{ 'Status'|trans }}</th> | ||||
|                     <th> </th> | ||||
|                 </tr> | ||||
|             </thead> | ||||
|             <tbody> | ||||
|                 {% for participation in participations %} | ||||
|                     <tr> | ||||
|                         <td>{{ participation.event.date|format_date('short') }}</td> | ||||
|                         <td>{{ participation.event.name }}</td> | ||||
|                         <td>{{ participation.event.type.name|localize_translatable_string }}</td> | ||||
|                         <td>{{ participation.role.name|localize_translatable_string }}</td> | ||||
|                         <td>{{ participation.status.name|localize_translatable_string }}</td> | ||||
|                         <td> | ||||
|                             <div class="btn-group" role="group" aria-label="Button group actions"> | ||||
|  | ||||
|                         {% set currentPath = path(app.request.attributes.get('_route'), app.request.attributes.get('_route_params')) %} | ||||
|                         {% set returnLabel = 'Back to %person% events'|trans({ '%person%' : currentPerson } ) %} | ||||
|                                 {% set currentPath = path(app.request.attributes.get('_route'), app.request.attributes.get('_route_params')) %} | ||||
|                                 {% set returnLabel = 'Back to %person% events'|trans({ '%person%' : currentPerson } ) %} | ||||
|  | ||||
|                         {% if is_granted('CHILL_EVENT_SEE_DETAILS', participation.event) %} | ||||
|                             <a  href="{{ path('chill_event__event_show', { 'event_id' : participation.event.id, 'return_path' : currentPath, 'return_label' : returnLabel } ) }}" | ||||
|                             class="btn btn-primary btn-sm" title="{{ 'See details of the event'|trans }}"> | ||||
|                             <i class="fa fa-fw fa-eye"></i> | ||||
|                         </a> | ||||
|                         {% endif %} | ||||
|                                 {% if is_granted('CHILL_EVENT_SEE_DETAILS', participation.event) %} | ||||
|                                     <a  href="{{ path('chill_event__event_show', { 'event_id' : participation.event.id, 'return_path' : currentPath, 'return_label' : returnLabel } ) }}" | ||||
|                                     class="btn btn-primary btn-sm" title="{{ 'See details of the event'|trans }}"> | ||||
|                                     <i class="fa fa-fw fa-eye"></i> | ||||
|                                 </a> | ||||
|                                 {% endif %} | ||||
|  | ||||
|                         {% if  is_granted('CHILL_EVENT_UPDATE', participation.event) | ||||
|                            and is_granted('CHILL_EVENT_PARTICIPATION_UPDATE', participation) %} | ||||
|                                 {% if  is_granted('CHILL_EVENT_UPDATE', participation.event) | ||||
|                                    and is_granted('CHILL_EVENT_PARTICIPATION_UPDATE', participation) %} | ||||
|  | ||||
|                             <div class="btn-group" role="group"> | ||||
|                             <button class="btn btn-sm btn-warning dropdown-toggle" type="button" id="dropdownEdit" data-bs-toggle="dropdown" aria-expanded="false"> | ||||
|                                 <i class="fa fa-pencil"></i> | ||||
|                             </button> | ||||
|                             <ul class="dropdown-menu" aria-labelledby="dropdownEdit"> | ||||
|                                 <li> | ||||
|                                     <div class="btn-group" role="group"> | ||||
|                                     <button class="btn btn-sm btn-warning dropdown-toggle" type="button" id="dropdownEdit" data-bs-toggle="dropdown" aria-expanded="false"> | ||||
|                                         <i class="fa fa-pencil"></i> | ||||
|                                     </button> | ||||
|                                     <ul class="dropdown-menu" aria-labelledby="dropdownEdit"> | ||||
|                                         <li> | ||||
|                                             <a  href="{{ path('chill_event__event_edit', { 'event_id' : participation.event.id, 'return_path' : currentPath, 'return_label' : returnLabel }) }}" | ||||
|                                                 class="dropdown-item"> | ||||
|                                                 {{ 'Edit the event'|trans }} | ||||
|                                             </a> | ||||
|                                         </li> | ||||
|                                         <li> | ||||
|                                             <a  href="{{ path('chill_event_participation_edit', { 'participation_id' : participation.id, 'return_path' : currentPath, 'return_label' : returnLabel }) }}" | ||||
|                                                 class="dropdown-item"> | ||||
|                                                 {{ 'Edit the participation'|trans }} | ||||
|                                             </a> | ||||
|                                         </li> | ||||
|                                     </ul> | ||||
|                                 </div> | ||||
|                                 {% else %} | ||||
|  | ||||
|                                     {% if is_granted('CHILL_EVENT_UPDATE', participation.event) %} | ||||
|                                     <a  href="{{ path('chill_event__event_edit', { 'event_id' : participation.event.id, 'return_path' : currentPath, 'return_label' : returnLabel }) }}" | ||||
|                                         class="dropdown-item"> | ||||
|                                         class="btn btn-warning btn-sm"> | ||||
|                                         {{ 'Edit the event'|trans }} | ||||
|                                     </a> | ||||
|                                 </li> | ||||
|                                 <li> | ||||
|                                     {% endif %} | ||||
|                                     {% if is_granted('CHILL_EVENT_PARTICIPATION_UPDATE', participation) %} | ||||
|                                     <a  href="{{ path('chill_event_participation_edit', { 'participation_id' : participation.id, 'return_path' : currentPath, 'return_label' : returnLabel }) }}" | ||||
|                                         class="dropdown-item"> | ||||
|                                         class="btn btn-warning btn-sm"> | ||||
|                                         {{ 'Edit the participation'|trans }} | ||||
|                                     </a> | ||||
|                                 </li> | ||||
|                             </ul> | ||||
|                         </div> | ||||
|                         {% else %} | ||||
|                                     {% endif %} | ||||
|  | ||||
|                             {% if is_granted('CHILL_EVENT_UPDATE', participation.event) %} | ||||
|                             <a  href="{{ path('chill_event__event_edit', { 'event_id' : participation.event.id, 'return_path' : currentPath, 'return_label' : returnLabel }) }}" | ||||
|                                 class="btn btn-warning btn-sm"> | ||||
|                                 {{ 'Edit the event'|trans }} | ||||
|                             </a> | ||||
|                             {% endif %} | ||||
|                             {% if is_granted('CHILL_EVENT_PARTICIPATION_UPDATE', participation) %} | ||||
|                             <a  href="{{ path('chill_event_participation_edit', { 'participation_id' : participation.id, 'return_path' : currentPath, 'return_label' : returnLabel }) }}" | ||||
|                                 class="btn btn-warning btn-sm"> | ||||
|                                 {{ 'Edit the participation'|trans }} | ||||
|                             </a> | ||||
|                             {% endif %} | ||||
|  | ||||
|                         {% endif %} | ||||
|                                 {% endif %} | ||||
|  | ||||
|  | ||||
|                     </div> | ||||
|                 </td> | ||||
|             </tr> | ||||
|         {% endfor %} | ||||
|                             </div> | ||||
|                         </td> | ||||
|                     </tr> | ||||
|                 {% endfor %} | ||||
|  | ||||
|     </tbody> | ||||
| </table> | ||||
|             </tbody> | ||||
|         </table> | ||||
|     {% endif %} | ||||
|  | ||||
| {% if participations|length < paginator.getTotalItems %} | ||||
|     {{ chill_pagination(paginator) }} | ||||
|   | ||||
| @@ -1,5 +1,13 @@ | ||||
| {% extends '@ChillEvent/layout.html.twig' %} | ||||
|  | ||||
| {% block js %} | ||||
|     {{ encore_entry_script_tags('mod_async_upload') }} | ||||
| {% endblock %} | ||||
|  | ||||
| {% block css %} | ||||
|     {{ encore_entry_link_tags('mod_async_upload') }} | ||||
| {% endblock %} | ||||
|  | ||||
| {% block title 'Event creation'|trans %} | ||||
|  | ||||
| {% block event_content -%} | ||||
| @@ -14,8 +22,13 @@ | ||||
|  | ||||
|     {{ form_row(form.type, { 'label': 'Event type' }) }} | ||||
|     {{ form_row(form.moderator) }} | ||||
|     {{ form_row(form.location) }} | ||||
|     {{ form_row(form.organizationCost) }} | ||||
|  | ||||
|     <ul class="record_actions"> | ||||
|     {{ form_row(form.comment) }} | ||||
|     {{ form_row(form.documents) }} | ||||
|  | ||||
|     <ul class="record_actions sticky-form-buttons"> | ||||
|         <li class="cancel"> | ||||
|             <a href="{{ path('chill_event_list_most_recent') }}" class="btn btn-cancel"> | ||||
|                 {{ 'Back to the most recent events'|trans }} | ||||
| @@ -25,7 +38,7 @@ | ||||
|             {{ form_widget(form.submit, { 'attr' : { 'class' : 'btn btn-create' } }) }} | ||||
|         </li> | ||||
|     </ul> | ||||
|              | ||||
|  | ||||
|     {{ form_end(form) }} | ||||
| </div> | ||||
| {% endblock %} | ||||
|   | ||||
| @@ -0,0 +1,92 @@ | ||||
| {% extends '@ChillEvent/layout.html.twig' %} | ||||
|  | ||||
| {% block title 'Events'|trans %} | ||||
|  | ||||
| {% block js %} | ||||
|     {{ parent() }} | ||||
|     {{ encore_entry_script_tags('mod_pickentity_type') }} | ||||
| {% endblock %} | ||||
|  | ||||
| {% block css %} | ||||
|     {{ parent() }} | ||||
|     {{ encore_entry_link_tags('mod_pickentity_type') }} | ||||
| {% endblock %} | ||||
|  | ||||
| {% block content %} | ||||
|     <h1>{{ block('title') }}</h1> | ||||
|  | ||||
|     {{ filter|chill_render_filter_order_helper }} | ||||
|  | ||||
| {# {% if is_granted('CHILL_EVENT_CREATE') %} #} | ||||
|         <ul class="record_actions"> | ||||
|             <li><a class="btn btn-create" href="{{ chill_path_add_return_path('chill_event__event_new_pickcenter') }}">{{ 'Add an event'|trans }}</a></li> | ||||
|         </ul> | ||||
|    {#  {% endif %} #} | ||||
|     {% if events|length > 0 %} | ||||
|         <div class="flex-table"> | ||||
|             {% for e in events %} | ||||
|                 <div class="item-bloc"> | ||||
|                     <div class="item-row"> | ||||
|                         <div class="item-col"> | ||||
|                             <div class="denomination h2"> | ||||
|                                 {{ e.name }} | ||||
|                             </div> | ||||
|                             <p>{{ e.type.name|localize_translatable_string }}</p> | ||||
|                             {% if e.moderator is not null %} | ||||
|                                 <p>{{ 'Moderator'|trans }}: {{ e.moderator|chill_entity_render_box }}</p> | ||||
|                             {% endif %} | ||||
|                         </div> | ||||
|                         <div class="item-col"> | ||||
|                             <div class="container" style="text-align: right;"> | ||||
|                                 <p>{{ e.date|format_datetime('medium', 'medium') }}</p> | ||||
|                                 <p>{{ 'count participations to this event'|trans({'count': e.participations|length}) }}</p> | ||||
|                             </div> | ||||
|                         </div> | ||||
|                     </div> | ||||
|                     {% if e.participations|length > 0 %} | ||||
|                         <div class="item-row separator"> | ||||
|                             <strong>{{ 'Participations'|trans }} : </strong> | ||||
|                             {% for part in e.participations|slice(0, 20) %} | ||||
|                                 {% include '@ChillMain/OnTheFly/_insert_vue_onthefly.html.twig' with { | ||||
|                                     targetEntity: { name: 'person', id: part.person.id }, | ||||
|                                     action: 'show', | ||||
|                                     displayBadge: true, | ||||
|                                     buttonText: part.person|chill_entity_render_string, | ||||
|                                     isDead: part.person.deathdate is not null | ||||
|                                 } %} | ||||
|                             {% endfor %} | ||||
|                             {% if e.participations|length > 20 %} | ||||
|                                 {{ 'events.and_other_count_participants'|trans({'count': e.participations|length - 20}) }} | ||||
|                             {% endif %} | ||||
|                         </div> | ||||
|                     {% endif %} | ||||
|                     <div class="item-row"> | ||||
|                         <div class="item-col"> | ||||
|                             {{ form_start(eventForms[e.id]) }} | ||||
|                             {{ form_widget(eventForms[e.id].person_id) }} | ||||
|                             {{ form_end(eventForms[e.id]) }} | ||||
|                         </div> | ||||
|                     </div> | ||||
|                     <div class="item-row separator"> | ||||
|                         <div class="item-col item-meta"> | ||||
|                         </div> | ||||
|                         <div class="item-col"> | ||||
|                             <ul class="record_actions"> | ||||
|                                 {% if is_granted('CHILL_EVENT_UPDATE', e) %} | ||||
|                                     <li><a href="{{ chill_path_add_return_path('chill_event__event_delete', {'event_id': e.id}) }}" class="btn btn-delete"></a></li> | ||||
|                                 {% endif %} | ||||
|                                 {% if is_granted('CHILL_EVENT_UPDATE', e) %} | ||||
|                                     <li><a href="{{ chill_path_add_return_path('chill_event__event_edit', {'event_id': e.id}) }}" class="btn btn-edit"></a></li> | ||||
|                                 {% endif %} | ||||
|                                 <li><a href="{{ chill_path_add_return_path('chill_event__event_show', {'event_id': e.id}) }}" class="btn btn-show"></a></li> | ||||
|                             </ul> | ||||
|                         </div> | ||||
|                     </div> | ||||
|                 </div> | ||||
|             {% endfor %} | ||||
|         </div> | ||||
|     {% endif %} | ||||
|  | ||||
|     {{ chill_pagination(pagination) }} | ||||
|  | ||||
| {% endblock %} | ||||
| @@ -4,12 +4,28 @@ | ||||
|  | ||||
| {% import '@ChillPerson/Person/macro.html.twig' as person_macro %} | ||||
|  | ||||
| {% block js %} | ||||
|     {{ parent() }} | ||||
|     {{ encore_entry_script_tags('mod_pickentity_type') }} | ||||
|     {{ encore_entry_script_tags('mod_document_action_buttons_group') }} | ||||
| {% endblock %} | ||||
|  | ||||
| {% block css %} | ||||
|     {{ parent() }} | ||||
|     {{ encore_entry_link_tags('mod_pickentity_type') }} | ||||
|     {{ encore_entry_link_tags('mod_document_action_buttons_group') }} | ||||
| {% endblock %} | ||||
|  | ||||
| {% block event_content -%} | ||||
| <div class="col-10"> | ||||
|     <h1>{{ 'Details of an event'|trans }}</h1> | ||||
|  | ||||
|     <table class="table table-bordered border-dark align-middle"> | ||||
|         <tbody> | ||||
|             <tr> | ||||
|                 <th>{{ 'Circle'|trans }}</th> | ||||
|                 <td>{{ event.circle.name|localize_translatable_string }}</td> | ||||
|             </tr> | ||||
|             <tr> | ||||
|                 <th>{{ 'Name'|trans }}</th> | ||||
|                 <td>{{ event.name }}</td> | ||||
| @@ -22,42 +38,62 @@ | ||||
|                 <th>{{ 'Event type'|trans }}</th> | ||||
|                 <td>{{ event.type.name|localize_translatable_string }}</td> | ||||
|             </tr> | ||||
|             <tr> | ||||
|                 <th>{{ 'Circle'|trans }}</th> | ||||
|                 <td>{{ event.circle.name|localize_translatable_string }}</td> | ||||
|             </tr> | ||||
|             <tr> | ||||
|                 <th>{{ 'Moderator'|trans }}</th> | ||||
|                 <td>{{ event.moderator|trans|default('-') }}</td> | ||||
|             </tr> | ||||
|  | ||||
|             <tr> | ||||
|                 <th>{{ 'event.fields.organizationCost'|trans }}</th> | ||||
|                 <td>{{ event.organizationCost|format_currency('EUR') }}</td> | ||||
|             </tr> | ||||
|             <tr> | ||||
|                 <th>{{ 'event.fields.location'|trans }}</th> | ||||
|                 <td> | ||||
|                     {% if event.location is not null %} | ||||
|                         {{ event.location.name }} | ||||
|                         {% if event.location.address is not same as(null) %}{{ event.location.address|chill_entity_render_box({'multiline': false, 'with_picto': (event.location.name is empty), 'details_button': true}) }}{% endif %} | ||||
|                     {% else %} | ||||
|                         <span class="chill-no-data-statement">{{ 'Any location for this event'|trans }}</span> | ||||
|                     {% endif %} | ||||
|                 </td> | ||||
|             </tr> | ||||
|         </tbody> | ||||
|     </table> | ||||
|  | ||||
|     {% if event.documents|length > 0 %} | ||||
|         <div> | ||||
|             <p><strong>{{ 'event.fields.documents'|trans }}</strong></p> | ||||
|             <ul> | ||||
|                 {% for d in event.documents %} | ||||
|                     <li class="document-list-item">{{ d.title|chill_print_or_message('document.Any title') }} {{ d|chill_document_button_group(d.title, is_granted('CHILL_EVENT_SEE_DETAILS', event), {small: false}) }}</li> | ||||
|                 {% endfor %} | ||||
|             </ul> | ||||
|         </div> | ||||
|     {% endif %} | ||||
|  | ||||
|     {% if not event.comment.empty %} | ||||
|         <div> | ||||
|             {{ event.comment|chill_entity_render_box({ | ||||
|                 'disable_markdown': false, | ||||
|                 'metadata': true, | ||||
|             }) }} | ||||
|         </div> | ||||
|     {% endif %} | ||||
|  | ||||
|  | ||||
|     <ul class="record_actions"> | ||||
|  | ||||
|         {% set returnPath = app.request.get('return_path') %} | ||||
|         {% set returnLabel = app.request.get('return_label') %} | ||||
|  | ||||
|         {% if returnPath and returnLabel %} | ||||
|             <li class="cancel"> | ||||
|                 <a href="{{ returnPath }}" class="btn btn-cancel">{{ returnLabel }}</a> | ||||
|             </li> | ||||
|             <li> | ||||
|                 <a href="{{ path('chill_event__event_edit', { | ||||
|                     'event_id': event.id, | ||||
|                     'return_path': app.request.getRequestUri, | ||||
|                     'return_label': 'Back to details of the event'|trans | ||||
|                     }) }}" class="btn btn-edit">{{ 'Edit'|trans }} | ||||
|                 </a> | ||||
|             </li> | ||||
|         {% else %} | ||||
|             <li> | ||||
|                 <a href="{{ path('chill_event__event_edit', {'event_id': event.id }) }}" class="btn btn-edit"> | ||||
|                     {{ 'Edit'|trans }} | ||||
|                 </a> | ||||
|             </li> | ||||
|         {% endif %} | ||||
|         <li class="cancel"> | ||||
|             <a href="{{ chill_return_path_or('chill_event_event_list') }}" class="btn btn-cancel">{{ 'Back to the list'|trans|chill_return_path_label }}</a> | ||||
|         </li> | ||||
|         <li> | ||||
|             <a href="{{ chill_path_add_return_path('chill_event__event_edit', {'event_id': event.id }, false, 'See'|trans) }}" class="btn btn-edit"> | ||||
|                 {{ 'Edit'|trans }} | ||||
|             </a> | ||||
|         </li> | ||||
|         <li> | ||||
|             <a href="{{ path('chill_event__event_delete', {'event_id' : event.id } ) }}" | ||||
|                class="btn btn-delete">{{ 'Delete event'|trans }}</a> | ||||
| @@ -83,7 +119,15 @@ | ||||
|             <tbody> | ||||
|             {% for participation in event.participations %} | ||||
|                 <tr> | ||||
|                     <td>{{ person_macro.render(participation.person) }}</td> | ||||
|                     <td> | ||||
|                         {% include '@ChillMain/OnTheFly/_insert_vue_onthefly.html.twig' with { | ||||
|                             targetEntity: { name: 'person', id: participation.person.id }, | ||||
|                             action: 'show', | ||||
|                             displayBadge: true, | ||||
|                             buttonText: participation.person|chill_entity_render_string, | ||||
|                             isDead: participation.person.deathdate is not null | ||||
|                         } %} | ||||
|                     </td> | ||||
|                     <td>{{ participation.role.name|localize_translatable_string }}</td> | ||||
|                     <td>{{ participation.status.name|localize_translatable_string }}</td> | ||||
|                     <td>{{ participation.lastUpdate|ago }} {# sf4 check: filter 'time_diff' is abandoned, | ||||
| @@ -94,7 +138,7 @@ | ||||
|                         <ul class="record_actions"> | ||||
|                             {% if is_granted('CHILL_EVENT_PARTICIPATION_UPDATE', participation) %} | ||||
|                             <li> | ||||
|                                 <a href="{{ path('chill_event_participation_edit', { 'participation_id' : participation.id } ) }}" | ||||
|                                 <a href="{{ chill_path_add_return_path('chill_event_participation_edit', { 'participation_id' : participation.id }, false, 'See'|trans ) }}" | ||||
|                                    class="btn btn-edit" title="{{ 'Edit'|trans }}"></a> | ||||
|                             </li> | ||||
|                             <li> | ||||
| @@ -126,11 +170,8 @@ | ||||
|                     'class' : 'custom-select', | ||||
|                     'style': 'min-width: 15em; max-width: 18em; display: inline-block;' | ||||
|                 }} ) }} | ||||
|                 <div class="input-group-append"> | ||||
|                     {{ form_widget(form_add_participation_by_person.submit, { 'attr' : { 'class' : 'btn btn-create' } } ) }} | ||||
|                 </div> | ||||
|             </div> | ||||
|             {{ form_rest(form_add_participation_by_person) }} | ||||
|             <input type="hidden" name="returnPath" value="{{ app.request.requestUri }}" /> | ||||
|             {{ form_end(form_add_participation_by_person) }} | ||||
|         </div> | ||||
|  | ||||
|   | ||||
| @@ -32,7 +32,7 @@ | ||||
|  | ||||
|  | ||||
|  | ||||
|     <ul class="record_actions"> | ||||
|     <ul class="record_actions sticky-form-buttons"> | ||||
|         <li class="cancel"> | ||||
|             <a href="{{ path('chill_event__event_show', { 'event_id' : participation.event.id } ) }}" class="btn btn-cancel"> | ||||
|                 {{ 'Back to the event'|trans }} | ||||
|   | ||||
| @@ -0,0 +1,44 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\EventBundle\Tests\Controller; | ||||
|  | ||||
| use Chill\MainBundle\Pagination\PaginatorFactory; | ||||
| use Chill\MainBundle\Test\PrepareClientTrait; | ||||
| use Prophecy\PhpUnit\ProphecyTrait; | ||||
| use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; | ||||
| use Twig\Environment; | ||||
|  | ||||
| /** | ||||
|  * @internal | ||||
|  * | ||||
|  * @coversNothing | ||||
|  */ | ||||
| class EventListControllerTest extends WebTestCase | ||||
| { | ||||
|     use ProphecyTrait; | ||||
|     use PrepareClientTrait; | ||||
|  | ||||
|     private readonly PaginatorFactory $paginatorFactory; | ||||
|     private readonly Environment $environment; | ||||
|  | ||||
|     protected function setUp(): void | ||||
|     { | ||||
|     } | ||||
|  | ||||
|     public function testList(): void | ||||
|     { | ||||
|         $client = $this->getClientAuthenticated(); | ||||
|  | ||||
|         $client->request('GET', '/fr/event/event/list'); | ||||
|         self::assertResponseIsSuccessful(); | ||||
|     } | ||||
| } | ||||
| @@ -11,6 +11,11 @@ declare(strict_types=1); | ||||
|  | ||||
| namespace Chill\EventBundle\Tests\Controller; | ||||
|  | ||||
| use Chill\EventBundle\Entity\Event; | ||||
| use Chill\EventBundle\Repository\EventRepository; | ||||
| use Chill\MainBundle\Test\PrepareClientTrait; | ||||
| use Chill\PersonBundle\DataFixtures\Helper\PersonRandomHelper; | ||||
| use Doctrine\ORM\EntityManagerInterface; | ||||
| use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; | ||||
| use function count; | ||||
|  | ||||
| @@ -23,15 +28,12 @@ use function count; | ||||
|  */ | ||||
| final class ParticipationControllerTest extends WebTestCase | ||||
| { | ||||
|     /** | ||||
|      * @var \Symfony\Component\BrowserKit\AbstractBrowser | ||||
|      */ | ||||
|     protected $client; | ||||
|     use PersonRandomHelper; | ||||
|     use PrepareClientTrait; | ||||
|  | ||||
|     /** | ||||
|      * @var \Doctrine\ORM\EntityManagerInterface | ||||
|      */ | ||||
|     protected $em; | ||||
|     private EntityManagerInterface $em; | ||||
|  | ||||
|     private EventRepository $eventRepository; | ||||
|  | ||||
|     /** | ||||
|      * Keep a cache for each person id given by the function getRandomPerson. | ||||
| @@ -44,23 +46,21 @@ final class ParticipationControllerTest extends WebTestCase | ||||
|      */ | ||||
|     private array $personsIdsCache = []; | ||||
|  | ||||
|     protected function setUp(): void | ||||
|     protected function prepareDI(): void | ||||
|     { | ||||
|         self::bootKernel(); | ||||
|  | ||||
|         $this->client = self::createClient([], [ | ||||
|             'PHP_AUTH_USER' => 'center a_social', | ||||
|             'PHP_AUTH_PW' => 'password', | ||||
|             'HTTP_ACCEPT_LANGUAGE' => 'fr_FR', | ||||
|         ]); | ||||
|  | ||||
|         $container = self::$kernel->getContainer(); | ||||
|  | ||||
|         $this->em = $container->get('doctrine.orm.entity_manager'); | ||||
|         $this->em = self::$container->get(EntityManagerInterface::class); | ||||
|         $this->eventRepository = self::$container->get(EventRepository::class); | ||||
|  | ||||
|         $this->personsIdsCache = []; | ||||
|     } | ||||
|  | ||||
|     protected function tearDown(): void | ||||
|     { | ||||
|         parent::tearDown(); | ||||
|  | ||||
|         self::ensureKernelShutdown(); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * This method test participation creation with wrong parameters. | ||||
|      * | ||||
| @@ -68,11 +68,13 @@ final class ParticipationControllerTest extends WebTestCase | ||||
|      */ | ||||
|     public function testCreateActionWrongParameters() | ||||
|     { | ||||
|         $client = $this->getClientAuthenticated(); | ||||
|         $this->prepareDI(); | ||||
|         $event = $this->getRandomEvent(); | ||||
|         $person = $this->getRandomPerson(); | ||||
|         $person = $this->getRandomPerson($this->em); | ||||
|  | ||||
|         // missing person_id or persons_ids | ||||
|         $this->client->request( | ||||
|         $client->request( | ||||
|             'GET', | ||||
|             '/fr/event/participation/create', | ||||
|             [ | ||||
| @@ -81,33 +83,33 @@ final class ParticipationControllerTest extends WebTestCase | ||||
|         ); | ||||
|         $this->assertEquals( | ||||
|             400, | ||||
|             $this->client->getResponse()->getStatusCode(), | ||||
|             $client->getResponse()->getStatusCode(), | ||||
|             'Test that /fr/event/participation/create fail if ' | ||||
|                 .'both person_id and persons_ids are missing' | ||||
|         ); | ||||
|  | ||||
|         // having both person_id and persons_ids | ||||
|         $this->client->request( | ||||
|         $client->request( | ||||
|             'GET', | ||||
|             '/fr/event/participation/create', | ||||
|             [ | ||||
|                 'event_id' => $event->getId(), | ||||
|                 'persons_ids' => implode(',', [ | ||||
|                     $this->getRandomPerson()->getId(), | ||||
|                     $this->getRandomPerson()->getId(), | ||||
|                     $this->getRandomPerson($this->em)->getId(), | ||||
|                     $this->getRandomPerson($this->em)->getId(), | ||||
|                 ]), | ||||
|                 'person_id' => $person->getId(), | ||||
|             ] | ||||
|         ); | ||||
|         $this->assertEquals( | ||||
|             400, | ||||
|             $this->client->getResponse()->getStatusCode(), | ||||
|             $client->getResponse()->getStatusCode(), | ||||
|             'test that /fr/event/participation/create fail if both person_id and ' | ||||
|                 .'persons_ids are set' | ||||
|         ); | ||||
|  | ||||
|         // missing event_id | ||||
|         $this->client->request( | ||||
|         $client->request( | ||||
|             'GET', | ||||
|             '/fr/event/participation/create', | ||||
|             [ | ||||
| @@ -116,12 +118,12 @@ final class ParticipationControllerTest extends WebTestCase | ||||
|         ); | ||||
|         $this->assertEquals( | ||||
|             400, | ||||
|             $this->client->getResponse()->getStatusCode(), | ||||
|             $client->getResponse()->getStatusCode(), | ||||
|             'Test that /fr/event/participation/create fails if event_id is missing' | ||||
|         ); | ||||
|  | ||||
|         // persons_ids with wrong content | ||||
|         $this->client->request( | ||||
|         $client->request( | ||||
|             'GET', | ||||
|             '/fr/event/participation/create', | ||||
|             [ | ||||
| @@ -131,42 +133,47 @@ final class ParticipationControllerTest extends WebTestCase | ||||
|         ); | ||||
|         $this->assertEquals( | ||||
|             400, | ||||
|             $this->client->getResponse()->getStatusCode(), | ||||
|             $client->getResponse()->getStatusCode(), | ||||
|             'Test that /fr/event/participation/create fails if persons_ids has wrong content' | ||||
|         ); | ||||
|     } | ||||
|  | ||||
|     public function testEditMultipleAction() | ||||
|     { | ||||
|         /** @var \Chill\EventBundle\Entity\Event $event */ | ||||
|         $client = $this->getClientAuthenticated(); | ||||
|         $this->prepareDI(); | ||||
|  | ||||
|         /** @var Event $event */ | ||||
|         $event = $this->getRandomEventWithMultipleParticipations(); | ||||
|  | ||||
|         $crawler = $this->client->request('GET', '/fr/event/participation/'.$event->getId(). | ||||
|         $crawler = $client->request('GET', '/fr/event/participation/'.$event->getId(). | ||||
|                 '/edit_multiple'); | ||||
|  | ||||
|         $this->assertEquals(200, $this->client->getResponse()->getStatusCode()); | ||||
|         $this->assertEquals(200, $client->getResponse()->getStatusCode()); | ||||
|  | ||||
|         $button = $crawler->selectButton('Mettre à jour'); | ||||
|         $this->assertEquals(1, $button->count(), "test the form with button 'mettre à jour' exists "); | ||||
|  | ||||
|         $this->client->submit($button->form(), [ | ||||
|         $client->submit($button->form(), [ | ||||
|             'form[participations][0][role]' => $event->getType()->getRoles()->first()->getId(), | ||||
|             'form[participations][0][status]' => $event->getType()->getStatuses()->first()->getId(), | ||||
|             'form[participations][1][role]' => $event->getType()->getRoles()->last()->getId(), | ||||
|             'form[participations][1][status]' => $event->getType()->getStatuses()->last()->getId(), | ||||
|         ]); | ||||
|  | ||||
|         $this->assertTrue($this->client->getResponse() | ||||
|         $this->assertTrue($client->getResponse() | ||||
|             ->isRedirect('/fr/event/event/'.$event->getId().'/show')); | ||||
|     } | ||||
|  | ||||
|     public function testNewActionWrongParameters() | ||||
|     { | ||||
|         $client = $this->getClientAuthenticated(); | ||||
|         $this->prepareDI(); | ||||
|         $event = $this->getRandomEvent(); | ||||
|         $person = $this->getRandomPerson(); | ||||
|         $person = $this->getRandomPerson($this->em); | ||||
|  | ||||
|         // missing person_id or persons_ids | ||||
|         $this->client->request( | ||||
|         $client->request( | ||||
|             'GET', | ||||
|             '/fr/event/participation/new', | ||||
|             [ | ||||
| @@ -175,33 +182,33 @@ final class ParticipationControllerTest extends WebTestCase | ||||
|         ); | ||||
|         $this->assertEquals( | ||||
|             400, | ||||
|             $this->client->getResponse()->getStatusCode(), | ||||
|             $client->getResponse()->getStatusCode(), | ||||
|             'Test that /fr/event/participation/new fail if ' | ||||
|                 .'both person_id and persons_ids are missing' | ||||
|         ); | ||||
|  | ||||
|         // having both person_id and persons_ids | ||||
|         $this->client->request( | ||||
|         $client->request( | ||||
|             'GET', | ||||
|             '/fr/event/participation/new', | ||||
|             [ | ||||
|                 'event_id' => $event->getId(), | ||||
|                 'persons_ids' => implode(',', [ | ||||
|                     $this->getRandomPerson()->getId(), | ||||
|                     $this->getRandomPerson()->getId(), | ||||
|                     $this->getRandomPerson($this->em)->getId(), | ||||
|                     $this->getRandomPerson($this->em)->getId(), | ||||
|                 ]), | ||||
|                 'person_id' => $person->getId(), | ||||
|             ] | ||||
|         ); | ||||
|         $this->assertEquals( | ||||
|             400, | ||||
|             $this->client->getResponse()->getStatusCode(), | ||||
|             $client->getResponse()->getStatusCode(), | ||||
|             'test that /fr/event/participation/new fail if both person_id and ' | ||||
|                 .'persons_ids are set' | ||||
|         ); | ||||
|  | ||||
|         // missing event_id | ||||
|         $this->client->request( | ||||
|         $client->request( | ||||
|             'GET', | ||||
|             '/fr/event/participation/new', | ||||
|             [ | ||||
| @@ -210,12 +217,12 @@ final class ParticipationControllerTest extends WebTestCase | ||||
|         ); | ||||
|         $this->assertEquals( | ||||
|             400, | ||||
|             $this->client->getResponse()->getStatusCode(), | ||||
|             $client->getResponse()->getStatusCode(), | ||||
|             'Test that /fr/event/participation/new fails if event_id is missing' | ||||
|         ); | ||||
|  | ||||
|         // persons_ids with wrong content | ||||
|         $this->client->request( | ||||
|         $client->request( | ||||
|             'GET', | ||||
|             '/fr/event/participation/new', | ||||
|             [ | ||||
| @@ -225,13 +232,15 @@ final class ParticipationControllerTest extends WebTestCase | ||||
|         ); | ||||
|         $this->assertEquals( | ||||
|             400, | ||||
|             $this->client->getResponse()->getStatusCode(), | ||||
|             $client->getResponse()->getStatusCode(), | ||||
|             'Test that /fr/event/participation/new fails if persons_ids has wrong content' | ||||
|         ); | ||||
|     } | ||||
|  | ||||
|     public function testNewMultipleAction() | ||||
|     { | ||||
|         $client = $this->getClientAuthenticated(); | ||||
|         $this->prepareDI(); | ||||
|         $event = $this->getRandomEvent(); | ||||
|         // record the number of participation for the event (used later in this test) | ||||
|         $nbParticipations = $event->getParticipations()->count(); | ||||
| @@ -244,10 +253,10 @@ final class ParticipationControllerTest extends WebTestCase | ||||
|                 ->toArray() | ||||
|         ); | ||||
|         // get some random people | ||||
|         $person1 = $this->getRandomPerson(); | ||||
|         $person2 = $this->getRandomPerson(); | ||||
|         $person1 = $this->getRandomPerson($this->em); | ||||
|         $person2 = $this->getRandomPerson($this->em); | ||||
|  | ||||
|         $crawler = $this->client->request( | ||||
|         $crawler = $client->request( | ||||
|             'GET', | ||||
|             '/fr/event/participation/new', | ||||
|             [ | ||||
| @@ -258,7 +267,7 @@ final class ParticipationControllerTest extends WebTestCase | ||||
|  | ||||
|         $this->assertEquals( | ||||
|             200, | ||||
|             $this->client->getResponse()->getStatusCode(), | ||||
|             $client->getResponse()->getStatusCode(), | ||||
|             'test that /fr/event/participation/new is successful' | ||||
|         ); | ||||
|  | ||||
| @@ -266,7 +275,7 @@ final class ParticipationControllerTest extends WebTestCase | ||||
|  | ||||
|         $this->assertNotNull($button, "test the form with button 'Créer' exists"); | ||||
|  | ||||
|         $this->client->submit($button->form(), [ | ||||
|         $client->submit($button->form(), [ | ||||
|             'form' => [ | ||||
|                 'participations' => [ | ||||
|                     0 => [ | ||||
| @@ -281,8 +290,8 @@ final class ParticipationControllerTest extends WebTestCase | ||||
|             ], | ||||
|         ]); | ||||
|  | ||||
|         $this->assertTrue($this->client->getResponse()->isRedirect()); | ||||
|         $crawler = $this->client->followRedirect(); | ||||
|         $this->assertTrue($client->getResponse()->isRedirect()); | ||||
|         $crawler = $client->followRedirect(); | ||||
|  | ||||
|         $span1 = $crawler->filter('table td span.entity-person a:contains("' | ||||
|                 .$person1->getFirstName().'"):contains("'.$person1->getLastname().'")'); | ||||
| @@ -292,7 +301,7 @@ final class ParticipationControllerTest extends WebTestCase | ||||
|         $this->assertGreaterThan(0, \count($span2)); | ||||
|  | ||||
|         // as the container has reloaded, reload the event | ||||
|         $event = $this->em->getRepository(\Chill\EventBundle\Entity\Event::class)->find($event->getId()); | ||||
|         $event = $this->em->getRepository(Event::class)->find($event->getId()); | ||||
|         $this->em->refresh($event); | ||||
|  | ||||
|         $this->assertEquals($nbParticipations + 2, $event->getParticipations()->count()); | ||||
| @@ -300,13 +309,15 @@ final class ParticipationControllerTest extends WebTestCase | ||||
|  | ||||
|     public function testNewMultipleWithAllPeopleParticipating() | ||||
|     { | ||||
|         $client = $this->getClientAuthenticated(); | ||||
|         $this->prepareDI(); | ||||
|         $event = $this->getRandomEventWithMultipleParticipations(); | ||||
|  | ||||
|         $persons_id = implode(',', $event->getParticipations()->map( | ||||
|             static fn ($p) => $p->getPerson()->getId() | ||||
|         )->toArray()); | ||||
|  | ||||
|         $crawler = $this->client->request( | ||||
|         $crawler = $client->request( | ||||
|             'GET', | ||||
|             '/fr/event/participation/new', | ||||
|             [ | ||||
| @@ -317,13 +328,15 @@ final class ParticipationControllerTest extends WebTestCase | ||||
|  | ||||
|         $this->assertEquals( | ||||
|             302, | ||||
|             $this->client->getResponse()->getStatusCode(), | ||||
|             $client->getResponse()->getStatusCode(), | ||||
|             'test that /fr/event/participation/new is redirecting' | ||||
|         ); | ||||
|     } | ||||
|  | ||||
|     public function testNewMultipleWithSomePeopleParticipating() | ||||
|     { | ||||
|         $client = $this->getClientAuthenticated(); | ||||
|         $this->prepareDI(); | ||||
|         $event = $this->getRandomEventWithMultipleParticipations(); | ||||
|         // record the number of participation for the event (used later in this test) | ||||
|         $nbParticipations = $event->getParticipations()->count(); | ||||
| @@ -335,12 +348,12 @@ final class ParticipationControllerTest extends WebTestCase | ||||
|         $this->personsIdsCache = array_merge($this->personsIdsCache, $persons_id); | ||||
|  | ||||
|         // get a random person | ||||
|         $newPerson = $this->getRandomPerson(); | ||||
|         $newPerson = $this->getRandomPerson($this->em); | ||||
|  | ||||
|         // build the `persons_ids` parameter | ||||
|         $persons_ids_string = implode(',', [...$persons_id, $newPerson->getId()]); | ||||
|  | ||||
|         $crawler = $this->client->request( | ||||
|         $crawler = $client->request( | ||||
|             'GET', | ||||
|             '/fr/event/participation/new', | ||||
|             [ | ||||
| @@ -351,7 +364,7 @@ final class ParticipationControllerTest extends WebTestCase | ||||
|  | ||||
|         $this->assertEquals( | ||||
|             200, | ||||
|             $this->client->getResponse()->getStatusCode(), | ||||
|             $client->getResponse()->getStatusCode(), | ||||
|             'test that /fr/event/participation/new is successful' | ||||
|         ); | ||||
|  | ||||
| @@ -377,15 +390,15 @@ final class ParticipationControllerTest extends WebTestCase | ||||
|         $this->assertNotNull($button, "test the form with button 'Créer' exists"); | ||||
|  | ||||
|         // submit the form | ||||
|         $this->client->submit($button->form(), [ | ||||
|         $client->submit($button->form(), [ | ||||
|             'participation[role]' => $event->getType()->getRoles()->first()->getId(), | ||||
|             'participation[status]' => $event->getType()->getStatuses()->first()->getId(), | ||||
|         ]); | ||||
|  | ||||
|         $this->assertTrue($this->client->getResponse()->isRedirect()); | ||||
|         $this->assertTrue($client->getResponse()->isRedirect()); | ||||
|  | ||||
|         // reload the event and test there is a new participation | ||||
|         $event = $this->em->getRepository(\Chill\EventBundle\Entity\Event::class) | ||||
|         $event = $this->em->getRepository(Event::class) | ||||
|             ->find($event->getId()); | ||||
|         $this->em->refresh($event); | ||||
|  | ||||
| @@ -398,12 +411,14 @@ final class ParticipationControllerTest extends WebTestCase | ||||
|  | ||||
|     public function testNewSingleAction() | ||||
|     { | ||||
|         $client = $this->getClientAuthenticated(); | ||||
|         $this->prepareDI(); | ||||
|         $event = $this->getRandomEvent(); | ||||
|         // record the number of participation for the event | ||||
|         $nbParticipations = $event->getParticipations()->count(); | ||||
|         $person = $this->getRandomPerson(); | ||||
|         $person = $this->getRandomPerson($this->em); | ||||
|  | ||||
|         $crawler = $this->client->request( | ||||
|         $crawler = $client->request( | ||||
|             'GET', | ||||
|             '/fr/event/participation/new', | ||||
|             [ | ||||
| @@ -414,7 +429,7 @@ final class ParticipationControllerTest extends WebTestCase | ||||
|  | ||||
|         $this->assertEquals( | ||||
|             200, | ||||
|             $this->client->getResponse()->getStatusCode(), | ||||
|             $client->getResponse()->getStatusCode(), | ||||
|             'test that /fr/event/participation/new is successful' | ||||
|         ); | ||||
|  | ||||
| @@ -422,13 +437,13 @@ final class ParticipationControllerTest extends WebTestCase | ||||
|  | ||||
|         $this->assertNotNull($button, "test the form with button 'Créer' exists"); | ||||
|  | ||||
|         $this->client->submit($button->form(), [ | ||||
|         $client->submit($button->form(), [ | ||||
|             'participation[role]' => $event->getType()->getRoles()->first()->getId(), | ||||
|             'participation[status]' => $event->getType()->getStatuses()->first()->getId(), | ||||
|         ]); | ||||
|  | ||||
|         $this->assertTrue($this->client->getResponse()->isRedirect()); | ||||
|         $crawler = $this->client->followRedirect(); | ||||
|         $this->assertTrue($client->getResponse()->isRedirect()); | ||||
|         $crawler = $client->followRedirect(); | ||||
|  | ||||
|         $span = $crawler->filter('table td span.entity-person a:contains("' | ||||
|                 .$person->getFirstName().'"):contains("'.$person->getLastname().'")'); | ||||
| @@ -436,29 +451,23 @@ final class ParticipationControllerTest extends WebTestCase | ||||
|         $this->assertGreaterThan(0, \count($span)); | ||||
|  | ||||
|         // as the container has reloaded, reload the event | ||||
|         $event = $this->em->getRepository(\Chill\EventBundle\Entity\Event::class)->find($event->getId()); | ||||
|         $event = $this->em->getRepository(Event::class)->find($event->getId()); | ||||
|         $this->em->refresh($event); | ||||
|  | ||||
|         $this->assertEquals($nbParticipations + 1, $event->getParticipations()->count()); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @return \Chill\EventBundle\Entity\Event | ||||
|      */ | ||||
|     protected function getRandomEvent(mixed $centerName = 'Center A', mixed $circleName = 'social') | ||||
|     private function getRandomEvent(string $centerName = 'Center A', string $circleName = 'social'): Event | ||||
|     { | ||||
|         $center = $this->em->getRepository(\Chill\MainBundle\Entity\Center::class) | ||||
|             ->findByName($centerName); | ||||
|         $dql = 'FROM '.Event::class.' e JOIN e.center center JOIN e.circle scope WHERE center.name LIKE :cname AND JSON_EXTRACT(scope.name, \'fr\') LIKE :sname'; | ||||
|  | ||||
|         $circles = $this->em->getRepository(\Chill\MainBundle\Entity\Scope::class) | ||||
|             ->findAll(); | ||||
|         array_filter($circles, static fn ($circle) => \in_array($circleName, $circle->getName(), true)); | ||||
|         $circle = $circles[0]; | ||||
|         $ids = $this->em->createQuery( | ||||
|             'SELECT DISTINCT e.id '.$dql | ||||
|         ) | ||||
|             ->setParameters(['cname' => $centerName, 'sname' => $circleName]) | ||||
|             ->getResult(); | ||||
|  | ||||
|         $events = $this->em->getRepository(\Chill\EventBundle\Entity\Event::class) | ||||
|             ->findBy(['center' => $center, 'circle' => $circle]); | ||||
|  | ||||
|         return $events[array_rand($events)]; | ||||
|         return $this->eventRepository->find($ids[array_rand($ids)]['id']); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @@ -467,7 +476,7 @@ final class ParticipationControllerTest extends WebTestCase | ||||
|      * @param string $centerName | ||||
|      * @param type   $circleName | ||||
|      * | ||||
|      * @return \Chill\EventBundle\Entity\Event | ||||
|      * @return Event | ||||
|      */ | ||||
|     protected function getRandomEventWithMultipleParticipations( | ||||
|         $centerName = 'Center A', | ||||
| @@ -479,35 +488,4 @@ final class ParticipationControllerTest extends WebTestCase | ||||
|                 $event : | ||||
|                 $this->getRandomEventWithMultipleParticipations($centerName, $circleName); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns a person randomly. | ||||
|      * | ||||
|      * This function does not give the same person twice | ||||
|      * for each test. | ||||
|      * | ||||
|      * You may ask to ignore some people by adding their id to the property | ||||
|      * `$this->personsIdsCache` | ||||
|      * | ||||
|      * @param string $centerName | ||||
|      * | ||||
|      * @return \Chill\PersonBundle\Entity\Person | ||||
|      */ | ||||
|     protected function getRandomPerson($centerName = 'Center A') | ||||
|     { | ||||
|         $center = $this->em->getRepository(\Chill\MainBundle\Entity\Center::class) | ||||
|             ->findByName($centerName); | ||||
|  | ||||
|         $persons = $this->em->getRepository(\Chill\PersonBundle\Entity\Person::class) | ||||
|             ->findBy(['center' => $center]); | ||||
|  | ||||
|         $person = $persons[array_rand($persons)]; | ||||
|  | ||||
|         if (\in_array($person->getId(), $this->personsIdsCache, true)) { | ||||
|             return $this->getRandomPerson($centerName); // we try another time | ||||
|         } | ||||
|         $this->personsIdsCache[] = $person->getId(); | ||||
|  | ||||
|         return $person; | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -0,0 +1,97 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\EventBundle\Tests\Repository; | ||||
|  | ||||
| use Chill\EventBundle\Repository\EventACLAwareRepository; | ||||
| use Chill\EventBundle\Security\Authorization\EventVoter; | ||||
| use Chill\MainBundle\Entity\Center; | ||||
| use Chill\MainBundle\Entity\Scope; | ||||
| use Chill\MainBundle\Entity\User; | ||||
| use Chill\MainBundle\Security\Authorization\AuthorizationHelperForCurrentUserInterface; | ||||
| use Doctrine\ORM\EntityManagerInterface; | ||||
| use Prophecy\Argument; | ||||
| use Prophecy\PhpUnit\ProphecyTrait; | ||||
| use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; | ||||
| use Symfony\Component\Security\Core\Security; | ||||
|  | ||||
| /** | ||||
|  * @internal | ||||
|  * | ||||
|  * @coversNothing | ||||
|  */ | ||||
| class EventACLAwareRepositoryTest extends KernelTestCase | ||||
| { | ||||
|     use ProphecyTrait; | ||||
|  | ||||
|     protected function setUp(): void | ||||
|     { | ||||
|         self::bootKernel(); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @dataProvider generateFilters | ||||
|      * | ||||
|      * @throws \Doctrine\ORM\NoResultException | ||||
|      * @throws \Doctrine\ORM\NonUniqueResultException | ||||
|      */ | ||||
|     public function testCountAllViewable(array $filters): void | ||||
|     { | ||||
|         $repository = $this->buildEventACLAwareRepository(); | ||||
|  | ||||
|         $this->assertGreaterThanOrEqual(0, $repository->countAllViewable($filters)); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @dataProvider generateFilters | ||||
|      */ | ||||
|     public function testFindAllViewable(array $filters): void | ||||
|     { | ||||
|         $repository = $this->buildEventACLAwareRepository(); | ||||
|  | ||||
|         $this->assertIsArray($repository->findAllViewable($filters)); | ||||
|     } | ||||
|  | ||||
|     public function generateFilters(): iterable | ||||
|     { | ||||
|         yield [[]]; | ||||
|     } | ||||
|  | ||||
|     public function buildEventACLAwareRepository(): EventACLAwareRepository | ||||
|     { | ||||
|         $em = self::$container->get(EntityManagerInterface::class); | ||||
|         $user = $em->createQuery('SELECT u FROM '.User::class.' u') | ||||
|             ->setMaxResults(1) | ||||
|             ->getSingleResult() | ||||
|         ; | ||||
|  | ||||
|         $scopes = $em->createQuery('SELECT s FROM '.Scope::class.' s') | ||||
|             ->setMaxResults(3) | ||||
|             ->getResult(); | ||||
|  | ||||
|         $centers = $em->createQuery('SELECT c FROM '.Center::class.' c') | ||||
|             ->setMaxResults(3) | ||||
|             ->getResult(); | ||||
|  | ||||
|         $security = $this->prophesize(Security::class); | ||||
|         $security->getUser()->willReturn($user); | ||||
|  | ||||
|         $authorizationHelper = $this->prophesize(AuthorizationHelperForCurrentUserInterface::class); | ||||
|         $authorizationHelper->getReachableCenters(EventVoter::SEE)->willReturn($centers); | ||||
|         $authorizationHelper->getReachableScopes(EventVoter::SEE, Argument::type(Center::class))->willReturn($scopes); | ||||
|  | ||||
|         return new EventACLAwareRepository( | ||||
|             $authorizationHelper->reveal(), | ||||
|             $em, | ||||
|             $security->reveal() | ||||
|         ); | ||||
|     } | ||||
| } | ||||
| @@ -1,16 +0,0 @@ | ||||
| services: | ||||
|  | ||||
|     Chill\EventBundle\Controller\EventController: | ||||
|         arguments: | ||||
|             $eventDispatcher: '@Symfony\Contracts\EventDispatcher\EventDispatcherInterface' | ||||
|             $authorizationHelper: '@Chill\MainBundle\Security\Authorization\AuthorizationHelper' | ||||
|             $formFactoryInterface: '@Symfony\Component\Form\FormFactoryInterface' | ||||
|             $translator: '@Symfony\Contracts\Translation\TranslatorInterface' | ||||
|             $paginator: '@chill_main.paginator_factory' | ||||
|         public: true | ||||
|         tags: ['controller.service_arguments'] | ||||
|  | ||||
|     Chill\EventBundle\Controller\ParticipationController: | ||||
|         arguments: | ||||
|             $logger: '@Psr\Log\LoggerInterface' | ||||
|         tags: ['controller.service_arguments'] | ||||
| @@ -1,7 +0,0 @@ | ||||
| services: | ||||
|     Chill\EventBundle\Menu\PersonMenuBuilder: | ||||
|         arguments: | ||||
|             $authorizationChecker: '@Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface' | ||||
|             $translator: '@Symfony\Contracts\Translation\TranslatorInterface' | ||||
|         tags: | ||||
|             - { name: 'chill.menu_builder' } | ||||
| @@ -1,26 +0,0 @@ | ||||
| Chill\EventBundle\Entity\Participation: | ||||
|     properties: | ||||
|         event:  | ||||
|             - NotNull: ~ | ||||
|         status: | ||||
|             - NotNull: ~ | ||||
|         person: | ||||
|             - NotNull: ~ | ||||
|     constraints: | ||||
|         - Callback: isConsistent | ||||
|          | ||||
|         | ||||
| Chill\EventBundle\Entity\Event: | ||||
|     properties: | ||||
|         name: | ||||
|             - Length: | ||||
|                 min: 3 | ||||
|                 max: 75 | ||||
|                 minMessage: The event name must have at least {{ limit }} characters. | ||||
|                 maxMessage: The event name must have maximum {{ limit }} characters. | ||||
|         type: | ||||
|             - NotNull: ~ | ||||
|         circle: | ||||
|             - NotNull: ~ | ||||
|         center: | ||||
|             - NotNull: ~ | ||||
| @@ -19,11 +19,13 @@ use Doctrine\Migrations\AbstractMigration; | ||||
|  */ | ||||
| class Version20160318111334 extends AbstractMigration | ||||
| { | ||||
|     public function getDescription(): string | ||||
|     { | ||||
|         return 'initialize the bundle chill event'; | ||||
|     } | ||||
|  | ||||
|     public function down(Schema $schema): void | ||||
|     { | ||||
|         // this down() migration is auto-generated, please modify it to your needs | ||||
|         $this->abortIf('postgresql' !== $this->connection->getDatabasePlatform()->getName(), 'Migration can only be executed safely on \'postgresql\'.'); | ||||
|  | ||||
|         $this->addSql('ALTER TABLE chill_event_role DROP CONSTRAINT FK_AA714E54C54C8C93'); | ||||
|         $this->addSql('ALTER TABLE chill_event_status DROP CONSTRAINT FK_A6CC85D0C54C8C93'); | ||||
|         $this->addSql('ALTER TABLE chill_event_participation DROP CONSTRAINT FK_4E7768ACD60322AC'); | ||||
| @@ -50,9 +52,6 @@ class Version20160318111334 extends AbstractMigration | ||||
|  | ||||
|     public function up(Schema $schema): void | ||||
|     { | ||||
|         // this up() migration is auto-generated, please modify it to your needs | ||||
|         $this->abortIf('postgresql' !== $this->connection->getDatabasePlatform()->getName(), 'Migration can only be executed safely on \'postgresql\'.'); | ||||
|  | ||||
|         $this->addSql('CREATE SEQUENCE chill_event_event_type_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); | ||||
|         $this->addSql('CREATE SEQUENCE chill_event_role_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); | ||||
|         $this->addSql('CREATE SEQUENCE chill_event_status_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); | ||||
| @@ -123,11 +122,26 @@ class Version20160318111334 extends AbstractMigration | ||||
|                 .'FOREIGN KEY (event_id) ' | ||||
|                 .'REFERENCES chill_event_event (id) ' | ||||
|                 .'NOT DEFERRABLE INITIALLY IMMEDIATE'); | ||||
|         $this->addSql('ALTER TABLE chill_event_participation ' | ||||
|  | ||||
|         // before adding fk constraint to person, check what is the table name | ||||
|         $results = $this->connection->executeQuery('SELECT EXISTS (SELECT 1 FROM pg_tables WHERE tablename = \'chill_person_person\')'); | ||||
|         /** @var bool $isChillPersonPersonTable */ | ||||
|         $isChillPersonPersonTable = $results->fetchFirstColumn()[0]; | ||||
|  | ||||
|         if ($isChillPersonPersonTable) { | ||||
|             $this->addSql('ALTER TABLE chill_event_participation ' | ||||
|                 .'ADD CONSTRAINT FK_4E7768AC217BBB47 ' | ||||
|                 .'FOREIGN KEY (person_id) ' | ||||
|                 .'REFERENCES chill_person_person (id) ' | ||||
|                 .'NOT DEFERRABLE INITIALLY IMMEDIATE'); | ||||
|         } else { | ||||
|             $this->addSql('ALTER TABLE chill_event_participation ' | ||||
|                 .'ADD CONSTRAINT FK_4E7768AC217BBB47 ' | ||||
|                 .'FOREIGN KEY (person_id) ' | ||||
|                 .'REFERENCES Person (id) ' | ||||
|                 .'NOT DEFERRABLE INITIALLY IMMEDIATE'); | ||||
|         } | ||||
|  | ||||
|         $this->addSql('ALTER TABLE chill_event_participation ' | ||||
|                 .'ADD CONSTRAINT FK_4E7768ACD60322AC ' | ||||
|                 .'FOREIGN KEY (role_id) ' | ||||
|   | ||||
| @@ -19,18 +19,19 @@ use Doctrine\Migrations\AbstractMigration; | ||||
|  */ | ||||
| final class Version20190110140538 extends AbstractMigration | ||||
| { | ||||
|     public function getDescription(): string | ||||
|     { | ||||
|         return 'switch event date to datetime'; | ||||
|     } | ||||
|  | ||||
|     public function down(Schema $schema): void | ||||
|     { | ||||
|         $this->abortIf('postgresql' !== $this->connection->getDatabasePlatform()->getName(), 'Migration can only be executed safely on \'postgresql\'.'); | ||||
|  | ||||
|         $this->addSql('ALTER TABLE chill_event_event ALTER date TYPE DATE'); | ||||
|         $this->addSql('ALTER TABLE chill_event_event ALTER date DROP DEFAULT'); | ||||
|     } | ||||
|  | ||||
|     public function up(Schema $schema): void | ||||
|     { | ||||
|         $this->abortIf('postgresql' !== $this->connection->getDatabasePlatform()->getName(), 'Migration can only be executed safely on \'postgresql\'.'); | ||||
|  | ||||
|         $this->addSql('ALTER TABLE chill_event_event ALTER date TYPE TIMESTAMP(0) WITHOUT TIME ZONE'); | ||||
|         $this->addSql('ALTER TABLE chill_event_event ALTER date DROP DEFAULT'); | ||||
|     } | ||||
|   | ||||
| @@ -19,11 +19,13 @@ use Doctrine\Migrations\AbstractMigration; | ||||
|  */ | ||||
| final class Version20190115140042 extends AbstractMigration | ||||
| { | ||||
|     public function getDescription(): string | ||||
|     { | ||||
|         return 'add a moderator field to events'; | ||||
|     } | ||||
|  | ||||
|     public function down(Schema $schema): void | ||||
|     { | ||||
|         // this down() migration is auto-generated, please modify it to your needs | ||||
|         $this->abortIf('postgresql' !== $this->connection->getDatabasePlatform()->getName(), 'Migration can only be executed safely on \'postgresql\'.'); | ||||
|  | ||||
|         $this->addSql('ALTER TABLE chill_event_event DROP CONSTRAINT FK_FA320FC8D0AFA354'); | ||||
|         $this->addSql('DROP INDEX IDX_FA320FC8D0AFA354'); | ||||
|         $this->addSql('ALTER TABLE chill_event_event DROP moderator_id'); | ||||
| @@ -31,9 +33,6 @@ final class Version20190115140042 extends AbstractMigration | ||||
|  | ||||
|     public function up(Schema $schema): void | ||||
|     { | ||||
|         // this up() migration is auto-generated, please modify it to your needs | ||||
|         $this->abortIf('postgresql' !== $this->connection->getDatabasePlatform()->getName(), 'Migration can only be executed safely on \'postgresql\'.'); | ||||
|  | ||||
|         $this->addSql('ALTER TABLE chill_event_event ADD moderator_id INT DEFAULT NULL'); | ||||
|         $this->addSql('ALTER TABLE chill_event_event ADD CONSTRAINT FK_FA320FC8D0AFA354 FOREIGN KEY (moderator_id) REFERENCES chill_person_person (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); | ||||
|         $this->addSql('CREATE INDEX IDX_FA320FC8D0AFA354 ON chill_event_event (moderator_id)'); | ||||
|   | ||||
| @@ -19,20 +19,19 @@ use Doctrine\Migrations\AbstractMigration; | ||||
|  */ | ||||
| final class Version20190201143121 extends AbstractMigration | ||||
| { | ||||
|     public function getDescription(): string | ||||
|     { | ||||
|         return 'fix moderator: relation with user (not person)'; | ||||
|     } | ||||
|  | ||||
|     public function down(Schema $schema): void | ||||
|     { | ||||
|         // this down() migration is auto-generated, please modify it to your needs | ||||
|         $this->abortIf('postgresql' !== $this->connection->getDatabasePlatform()->getName(), 'Migration can only be executed safely on \'postgresql\'.'); | ||||
|  | ||||
|         $this->addSql('ALTER TABLE chill_event_event DROP CONSTRAINT fk_fa320fc8d0afa354'); | ||||
|         $this->addSql('ALTER TABLE chill_event_event ADD CONSTRAINT fk_fa320fc8d0afa354 FOREIGN KEY (moderator_id) REFERENCES chill_person_person (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); | ||||
|     } | ||||
|  | ||||
|     public function up(Schema $schema): void | ||||
|     { | ||||
|         // this up() migration is auto-generated, please modify it to your needs | ||||
|         $this->abortIf('postgresql' !== $this->connection->getDatabasePlatform()->getName(), 'Migration can only be executed safely on \'postgresql\'.'); | ||||
|  | ||||
|         $this->addSql('ALTER TABLE chill_event_event DROP CONSTRAINT FK_FA320FC8D0AFA354'); | ||||
|         $this->addSql('ALTER TABLE chill_event_event ADD CONSTRAINT FK_FA320FC8D0AFA354 FOREIGN KEY (moderator_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); | ||||
|     } | ||||
|   | ||||
| @@ -0,0 +1,59 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\Migrations\Event; | ||||
|  | ||||
| use Doctrine\DBAL\Schema\Schema; | ||||
| use Doctrine\Migrations\AbstractMigration; | ||||
|  | ||||
| final class Version20231127134244 extends AbstractMigration | ||||
| { | ||||
|     public function getDescription(): string | ||||
|     { | ||||
|         return 'add creation - update information on event and event participation'; | ||||
|     } | ||||
|  | ||||
|     public function up(Schema $schema): void | ||||
|     { | ||||
|         $this->addSql('ALTER TABLE chill_event_event ADD createdAt TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL'); | ||||
|         $this->addSql('ALTER TABLE chill_event_event ADD updatedAt TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL'); | ||||
|         $this->addSql('ALTER TABLE chill_event_event ADD createdBy_id INT DEFAULT NULL'); | ||||
|         $this->addSql('ALTER TABLE chill_event_event ADD updatedBy_id INT DEFAULT NULL'); | ||||
|         $this->addSql('COMMENT ON COLUMN chill_event_event.createdAt IS \'(DC2Type:datetime_immutable)\''); | ||||
|         $this->addSql('COMMENT ON COLUMN chill_event_event.updatedAt IS \'(DC2Type:datetime_immutable)\''); | ||||
|         $this->addSql('ALTER TABLE chill_event_event ADD CONSTRAINT FK_FA320FC83174800F FOREIGN KEY (createdBy_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); | ||||
|         $this->addSql('ALTER TABLE chill_event_event ADD CONSTRAINT FK_FA320FC865FF1AEC FOREIGN KEY (updatedBy_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); | ||||
|         $this->addSql('CREATE INDEX IDX_FA320FC83174800F ON chill_event_event (createdBy_id)'); | ||||
|         $this->addSql('CREATE INDEX IDX_FA320FC865FF1AEC ON chill_event_event (updatedBy_id)'); | ||||
|         $this->addSql('ALTER TABLE chill_event_participation ADD createdAt TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL'); | ||||
|         $this->addSql('ALTER TABLE chill_event_participation ADD updatedAt TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL'); | ||||
|         $this->addSql('ALTER TABLE chill_event_participation ADD createdBy_id INT DEFAULT NULL'); | ||||
|         $this->addSql('ALTER TABLE chill_event_participation ADD updatedBy_id INT DEFAULT NULL'); | ||||
|         $this->addSql('COMMENT ON COLUMN chill_event_participation.createdAt IS \'(DC2Type:datetime_immutable)\''); | ||||
|         $this->addSql('COMMENT ON COLUMN chill_event_participation.updatedAt IS \'(DC2Type:datetime_immutable)\''); | ||||
|         $this->addSql('ALTER TABLE chill_event_participation ADD CONSTRAINT FK_4E7768AC3174800F FOREIGN KEY (createdBy_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); | ||||
|         $this->addSql('ALTER TABLE chill_event_participation ADD CONSTRAINT FK_4E7768AC65FF1AEC FOREIGN KEY (updatedBy_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); | ||||
|         $this->addSql('CREATE INDEX IDX_4E7768AC3174800F ON chill_event_participation (createdBy_id)'); | ||||
|         $this->addSql('CREATE INDEX IDX_4E7768AC65FF1AEC ON chill_event_participation (updatedBy_id)'); | ||||
|     } | ||||
|  | ||||
|     public function down(Schema $schema): void | ||||
|     { | ||||
|         $this->addSql('ALTER TABLE chill_event_event DROP createdAt'); | ||||
|         $this->addSql('ALTER TABLE chill_event_event DROP updatedAt'); | ||||
|         $this->addSql('ALTER TABLE chill_event_event DROP createdBy_id'); | ||||
|         $this->addSql('ALTER TABLE chill_event_event DROP updatedBy_id'); | ||||
|         $this->addSql('ALTER TABLE chill_event_participation DROP createdAt'); | ||||
|         $this->addSql('ALTER TABLE chill_event_participation DROP updatedAt'); | ||||
|         $this->addSql('ALTER TABLE chill_event_participation DROP createdBy_id'); | ||||
|         $this->addSql('ALTER TABLE chill_event_participation DROP updatedBy_id'); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,40 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\Migrations\Event; | ||||
|  | ||||
| use Doctrine\DBAL\Schema\Schema; | ||||
| use Doctrine\Migrations\AbstractMigration; | ||||
|  | ||||
| final class Version20231128114959 extends AbstractMigration | ||||
| { | ||||
|     public function getDescription(): string | ||||
|     { | ||||
|         return 'Add unique index on participation and drop column participation::lastUpdate'; | ||||
|     } | ||||
|  | ||||
|     public function up(Schema $schema): void | ||||
|     { | ||||
|         $this->addSql('UPDATE chill_event_participation SET updatedAt=lastupdate WHERE updatedat IS NULL'); | ||||
|         $this->addSql('ALTER TABLE chill_event_participation DROP lastupdate'); | ||||
|         $this->addSql('WITH ordering AS (SELECT id, event_id, person_id, rank() OVER (PARTITION BY event_id, person_id ORDER BY id DESC) as ranked FROM chill_event_participation), | ||||
|      not_last AS (SELECT * FROM ordering where ranked > 1) | ||||
|      DELETE FROM chill_event_participation WHERE id IN (select id FROM not_last)'); | ||||
|         $this->addSql('CREATE UNIQUE INDEX chill_event_participation_event_person_unique_idx ON chill_event_participation (event_id, person_id)'); | ||||
|     } | ||||
|  | ||||
|     public function down(Schema $schema): void | ||||
|     { | ||||
|         $this->addSql('DROP INDEX chill_event_participation_event_person_unique_idx'); | ||||
|         $this->addSql('ALTER TABLE chill_event_participation ADD lastupdate TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL'); | ||||
|         $this->addSql('UPDATE chill_event_participation set lastupdate = updatedat'); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,51 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\Migrations\Event; | ||||
|  | ||||
| use Doctrine\DBAL\Schema\Schema; | ||||
| use Doctrine\Migrations\AbstractMigration; | ||||
|  | ||||
| final class Version20231128122635 extends AbstractMigration | ||||
| { | ||||
|     public function getDescription(): string | ||||
|     { | ||||
|         return 'Append more fields on event: location, documents, and comment'; | ||||
|     } | ||||
|  | ||||
|     public function up(Schema $schema): void | ||||
|     { | ||||
|         $this->addSql('CREATE TABLE chill_event_event_documents (event_id INT NOT NULL, storedobject_id INT NOT NULL, PRIMARY KEY(event_id, storedobject_id))'); | ||||
|         $this->addSql('CREATE INDEX IDX_5C1B638671F7E88B ON chill_event_event_documents (event_id)'); | ||||
|         $this->addSql('CREATE INDEX IDX_5C1B6386EE684399 ON chill_event_event_documents (storedobject_id)'); | ||||
|         $this->addSql('ALTER TABLE chill_event_event_documents ADD CONSTRAINT FK_5C1B638671F7E88B FOREIGN KEY (event_id) REFERENCES chill_event_event (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); | ||||
|         $this->addSql('ALTER TABLE chill_event_event_documents ADD CONSTRAINT FK_5C1B6386EE684399 FOREIGN KEY (storedobject_id) REFERENCES chill_doc.stored_object (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); | ||||
|         $this->addSql('ALTER TABLE chill_event_event ADD location_id INT DEFAULT NULL'); | ||||
|         $this->addSql('ALTER TABLE chill_event_event ADD organizationCost NUMERIC(10, 4) DEFAULT 0.0'); | ||||
|         $this->addSql('ALTER TABLE chill_event_event ADD comment_comment TEXT DEFAULT NULL'); | ||||
|         $this->addSql('ALTER TABLE chill_event_event ADD comment_date TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL'); | ||||
|         $this->addSql('ALTER TABLE chill_event_event ADD comment_userId INT DEFAULT NULL'); | ||||
|         $this->addSql('ALTER TABLE chill_event_event ADD CONSTRAINT FK_FA320FC864D218E FOREIGN KEY (location_id) REFERENCES chill_main_location (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); | ||||
|         $this->addSql('CREATE INDEX IDX_FA320FC864D218E ON chill_event_event (location_id)'); | ||||
|     } | ||||
|  | ||||
|     public function down(Schema $schema): void | ||||
|     { | ||||
|         $this->addSql('ALTER TABLE chill_event_event_documents DROP CONSTRAINT FK_5C1B638671F7E88B'); | ||||
|         $this->addSql('ALTER TABLE chill_event_event_documents DROP CONSTRAINT FK_5C1B6386EE684399'); | ||||
|         $this->addSql('DROP TABLE chill_event_event_documents'); | ||||
|         $this->addSql('ALTER TABLE chill_event_event DROP location_id'); | ||||
|         $this->addSql('ALTER TABLE chill_event_event DROP organizationCost'); | ||||
|         $this->addSql('ALTER TABLE chill_event_event DROP comment_comment'); | ||||
|         $this->addSql('ALTER TABLE chill_event_event DROP comment_date'); | ||||
|         $this->addSql('ALTER TABLE chill_event_event DROP comment_userId'); | ||||
|     } | ||||
| } | ||||
| @@ -11,3 +11,11 @@ count participations to this event: >- | ||||
|         one {Un participant à l'événement} | ||||
|         other {# participants à l'événement} | ||||
|     } | ||||
| 
 | ||||
| events: | ||||
|     and_other_count_participants: >- | ||||
|         { count, plural, | ||||
|             =0 {Aucun autre participant} | ||||
|             one {et un autre participant} | ||||
|             other {et # autres participants} | ||||
|         } | ||||
| @@ -26,6 +26,8 @@ Event edit: Modifier un événement | ||||
| Edit the event: Modifier l'événement | ||||
| The event was updated: L'événement a été modifié | ||||
| The event was created: L'événement a été créé | ||||
| List of events: Liste des événements | ||||
| Any location for this event: Aucune localisation pour cet événement | ||||
|  | ||||
| #crud participation | ||||
| Edit all the participations: Modifier toutes les participations | ||||
| @@ -50,6 +52,7 @@ Remove participation: Supprimer la participation | ||||
| Delete event: Supprimer l'événement | ||||
| Are you sure you want to remove that participation ?: Êtes-vous certain de vouloir supprimer cette participation ? | ||||
| Are you sure you want to remove that event ?: Êtes-vous certain de vouloir supprimer cet événement, ainsi que toutes les participations associées ? | ||||
| Any participation for this person: Cet usager ne participe à aucun évenements | ||||
|  | ||||
| #search | ||||
| Event search: Recherche d'événements | ||||
| @@ -107,3 +110,17 @@ csv: csv | ||||
| Create a new role: Créer un nouveau rôle | ||||
| Create a new type: Créer un nouveau type | ||||
| Create a new status: Créer un nouveau statut | ||||
|  | ||||
| event: | ||||
|     fields: | ||||
|         organizationCost: Coût d'organisation | ||||
|         location: Localisation | ||||
|         documents: Documents | ||||
|     form: | ||||
|         organisationCost_help: Coût d'organisation pour la structure. Utile pour les statistiques. | ||||
|         add_document: Ajouter un document | ||||
|         remove_document: Supprimer le document | ||||
|     filter: | ||||
|         event_types: Par types d'événement | ||||
|         event_dates: Par date d'événement | ||||
|  | ||||
|   | ||||
| @@ -0,0 +1,3 @@ | ||||
| event: | ||||
|     validation: | ||||
|         person_already_participate_to_event: L'usager est déjà inscrit à l'événement | ||||
| @@ -15,10 +15,14 @@ use Chill\MainBundle\CRUD\Resolver\Resolver; | ||||
| use Chill\MainBundle\Pagination\PaginatorFactory; | ||||
| use Chill\MainBundle\Pagination\PaginatorInterface; | ||||
| use Chill\MainBundle\Security\Authorization\AuthorizationHelper; | ||||
| use Doctrine\DBAL\LockMode; | ||||
| use Doctrine\ORM\EntityManagerInterface; | ||||
| use Doctrine\ORM\OptimisticLockException; | ||||
| use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; | ||||
| use Symfony\Component\EventDispatcher\EventDispatcherInterface; | ||||
| use Symfony\Component\HttpFoundation\Request; | ||||
| use Symfony\Component\HttpFoundation\Response; | ||||
| use Symfony\Component\HttpKernel\Exception\ConflictHttpException; | ||||
| use Symfony\Component\Serializer\SerializerInterface; | ||||
| use Symfony\Component\Validator\Validator\ValidatorInterface; | ||||
| use Symfony\Contracts\Translation\TranslatorInterface; | ||||
| @@ -173,6 +177,21 @@ abstract class AbstractCRUDController extends AbstractController | ||||
|         if (null === $e) { | ||||
|             throw $this->createNotFoundException(sprintf('The object %s for id %s is not found', $this->getEntityClass(), $id)); | ||||
|         } | ||||
|         if ($request->query->has('entity_version')) { | ||||
|             $expectedVersion = $request->query->getInt('entity_version'); | ||||
|  | ||||
|             try { | ||||
|                 $manager = $this->getDoctrine()->getManagerForClass($this->getEntityClass()); | ||||
|  | ||||
|                 if ($manager instanceof EntityManagerInterface) { | ||||
|                     $manager->lock($e, LockMode::OPTIMISTIC, $expectedVersion); | ||||
|                 } else { | ||||
|                     throw new \LogicException('This manager does not allow locking.'); | ||||
|                 } | ||||
|             } catch (OptimisticLockException $e) { | ||||
|                 throw new ConflictHttpException('Sorry, but someone else has already changed this entity. Please refresh the page and apply the changes again', $e); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return $e; | ||||
|     } | ||||
|   | ||||
| @@ -135,7 +135,7 @@ class ApiController extends AbstractCRUDController | ||||
|         try { | ||||
|             $entity = $this->deserialize($action, $request, $_format, $entity); | ||||
|         } catch (NotEncodableValueException $e) { | ||||
|             throw new BadRequestHttpException('invalid json', 400, $e); | ||||
|             throw new BadRequestHttpException('invalid json', $e, 400); | ||||
|         } | ||||
|  | ||||
|         $errors = $this->validate($action, $request, $_format, $entity); | ||||
| @@ -153,7 +153,7 @@ class ApiController extends AbstractCRUDController | ||||
|             return $response; | ||||
|         } | ||||
|  | ||||
|         $this->getDoctrine()->getManager()->flush(); | ||||
|         $this->getDoctrine()->getManagerForClass($this->getEntityClass())->flush(); | ||||
|  | ||||
|         $response = $this->onAfterFlush($action, $request, $_format, $entity, $errors); | ||||
|  | ||||
|   | ||||
| @@ -47,12 +47,4 @@ class AdminController extends AbstractController | ||||
|     { | ||||
|         return $this->render('@ChillMain/Admin/indexUser.html.twig'); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @Route("/{_locale}/admin/dashboard", name="chill_main_dashboard_admin") | ||||
|      */ | ||||
|     public function indexDashboardAction() | ||||
|     { | ||||
|         return $this->render('@ChillMain/Admin/indexDashboard.html.twig'); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -1,41 +0,0 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\MainBundle\Controller; | ||||
|  | ||||
| use Chill\MainBundle\Entity\User; | ||||
| use Symfony\Component\HttpFoundation\JsonResponse; | ||||
| use Symfony\Component\Routing\Annotation\Route; | ||||
|  | ||||
| class DashboardApiController | ||||
| { | ||||
|     /** | ||||
|      * Get user dashboard config (not yet based on user id and still hardcoded for now). | ||||
|      * | ||||
|      * @Route("/api/1.0/main/dashboard-config-item.json", methods={"get"}) | ||||
|      */ | ||||
|     public function getDashboardConfiguration(): JsonResponse | ||||
|     { | ||||
|         $data = [ | ||||
|             [ | ||||
|                 'position' => 'top-left', | ||||
|                 'id' => 1, | ||||
|                 'type' => 'news', | ||||
|                 'metadata' => [ | ||||
|                     // arbitrary data that will be store "some time" | ||||
|                     'only_unread' => false, | ||||
|                 ], | ||||
|             ], | ||||
|         ]; | ||||
|  | ||||
|         return new JsonResponse($data, JsonResponse::HTTP_OK, []); | ||||
|     } | ||||
| } | ||||
| @@ -1,53 +0,0 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\MainBundle\Controller; | ||||
|  | ||||
| use Chill\MainBundle\Pagination\PaginatorFactory; | ||||
| use Chill\MainBundle\Repository\NewsItemRepository; | ||||
| use Chill\MainBundle\Serializer\Model\Collection; | ||||
| use Symfony\Component\HttpFoundation\JsonResponse; | ||||
| use Symfony\Component\Routing\Annotation\Route; | ||||
| use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; | ||||
| use Symfony\Component\Serializer\SerializerInterface; | ||||
|  | ||||
| class NewsItemApiController | ||||
| { | ||||
|     public function __construct( | ||||
|         private readonly NewsItemRepository $newsItemRepository, | ||||
|         private readonly SerializerInterface $serializer, | ||||
|         private readonly PaginatorFactory $paginatorFactory | ||||
|     ) { | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Get list of news items filtered on start and end date. | ||||
|      * | ||||
|      * @Route("/api/1.0/main/news/current.json", methods={"get"}) | ||||
|      */ | ||||
|     public function listCurrentNewsItems(): JsonResponse | ||||
|     { | ||||
|         $total = $this->newsItemRepository->countCurrentNews(); | ||||
|         $paginator = $this->paginatorFactory->create($total); | ||||
|         $newsItems = $this->newsItemRepository->findCurrentNews( | ||||
|             $paginator->getItemsPerPage(), | ||||
|             $paginator->getCurrentPage()->getFirstItemNumber() | ||||
|         ); | ||||
|  | ||||
|         return new JsonResponse($this->serializer->serialize( | ||||
|             new Collection(array_values($newsItems), $paginator), | ||||
|             'json', | ||||
|             [ | ||||
|                 AbstractNormalizer::GROUPS => ['read'], | ||||
|             ] | ||||
|         ), JsonResponse::HTTP_OK, [], true); | ||||
|     } | ||||
| } | ||||
| @@ -1,27 +0,0 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\MainBundle\Controller; | ||||
|  | ||||
| use Chill\MainBundle\CRUD\Controller\CRUDController; | ||||
| use Chill\MainBundle\Pagination\PaginatorInterface; | ||||
| use Symfony\Component\HttpFoundation\Request; | ||||
|  | ||||
| class NewsItemController extends CRUDController | ||||
| { | ||||
|     protected function orderQuery(string $action, $query, Request $request, PaginatorInterface $paginator) | ||||
|     { | ||||
|         $query->addOrderBy('e.startDate', 'DESC'); | ||||
|         $query->addOrderBy('e.id', 'DESC'); | ||||
|  | ||||
|         return parent::orderQuery($action, $query, $request, $paginator); | ||||
|     } | ||||
| } | ||||
| @@ -1,73 +0,0 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\MainBundle\Controller; | ||||
|  | ||||
| use Chill\MainBundle\Entity\NewsItem; | ||||
| use Chill\MainBundle\Pagination\PaginatorFactory; | ||||
| use Chill\MainBundle\Repository\NewsItemRepository; | ||||
| use Chill\MainBundle\Templating\Listing\FilterOrderHelper; | ||||
| use Chill\MainBundle\Templating\Listing\FilterOrderHelperFactoryInterface; | ||||
| use Symfony\Component\HttpFoundation\Request; | ||||
| use Symfony\Component\HttpFoundation\Response; | ||||
| use Symfony\Component\Routing\Annotation\Route; | ||||
| use Twig\Environment; | ||||
|  | ||||
| final readonly class NewsItemHistoryController | ||||
| { | ||||
|     public function __construct( | ||||
|         private readonly NewsItemRepository $newsItemRepository, | ||||
|         private readonly PaginatorFactory $paginatorFactory, | ||||
|         private readonly FilterOrderHelperFactoryInterface $filterOrderHelperFactory, | ||||
|         private readonly Environment $environment, | ||||
|     ) { | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @Route("/{_locale}/news-items/history", name="chill_main_news_items_history") | ||||
|      */ | ||||
|     public function list(): Response | ||||
|     { | ||||
|         $filter = $this->buildFilterOrder(); | ||||
|         $total = $this->newsItemRepository->countAllFilteredBySearchTerm($filter->getQueryString()); | ||||
|         $newsItems = $this->newsItemRepository->findAllFilteredBySearchTerm($filter->getQueryString()); | ||||
|  | ||||
|         $pagination = $this->paginatorFactory->create($total); | ||||
|  | ||||
|         return new Response($this->environment->render('@ChillMain/NewsItem/news_items_history.html.twig', [ | ||||
|             'entities' => $newsItems, | ||||
|             'paginator' => $pagination, | ||||
|             'filter_order' => $filter, | ||||
|         ])); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @Route("/{_locale}/news-items/{id}", name="chill_main_single_news_item") | ||||
|      */ | ||||
|     public function showSingleItem(NewsItem $newsItem, Request $request): Response | ||||
|     { | ||||
|         return new Response($this->environment->render( | ||||
|             '@ChillMain/NewsItem/show.html.twig', | ||||
|             [ | ||||
|                 'entity' => $newsItem, | ||||
|             ] | ||||
|         )); | ||||
|     } | ||||
|  | ||||
|     private function buildFilterOrder(): FilterOrderHelper | ||||
|     { | ||||
|         $filterBuilder = $this->filterOrderHelperFactory | ||||
|             ->create(self::class) | ||||
|             ->addSearchBox(); | ||||
|  | ||||
|         return $filterBuilder->build(); | ||||
|     } | ||||
| } | ||||
| @@ -19,7 +19,6 @@ use Chill\MainBundle\Controller\CountryController; | ||||
| use Chill\MainBundle\Controller\LanguageController; | ||||
| use Chill\MainBundle\Controller\LocationController; | ||||
| use Chill\MainBundle\Controller\LocationTypeController; | ||||
| use Chill\MainBundle\Controller\NewsItemController; | ||||
| use Chill\MainBundle\Controller\RegroupmentController; | ||||
| use Chill\MainBundle\Controller\UserController; | ||||
| use Chill\MainBundle\Controller\UserJobApiController; | ||||
| @@ -54,7 +53,6 @@ use Chill\MainBundle\Entity\GeographicalUnitLayer; | ||||
| use Chill\MainBundle\Entity\Language; | ||||
| use Chill\MainBundle\Entity\Location; | ||||
| use Chill\MainBundle\Entity\LocationType; | ||||
| use Chill\MainBundle\Entity\NewsItem; | ||||
| use Chill\MainBundle\Entity\Regroupment; | ||||
| use Chill\MainBundle\Entity\User; | ||||
| use Chill\MainBundle\Entity\UserJob; | ||||
| @@ -64,7 +62,6 @@ use Chill\MainBundle\Form\CountryType; | ||||
| use Chill\MainBundle\Form\LanguageType; | ||||
| use Chill\MainBundle\Form\LocationFormType; | ||||
| use Chill\MainBundle\Form\LocationTypeType; | ||||
| use Chill\MainBundle\Form\NewsItemType; | ||||
| use Chill\MainBundle\Form\RegroupmentType; | ||||
| use Chill\MainBundle\Form\UserJobType; | ||||
| use Chill\MainBundle\Form\UserType; | ||||
| @@ -547,35 +544,6 @@ class ChillMainExtension extends Extension implements | ||||
|                         ], | ||||
|                     ], | ||||
|                 ], | ||||
|                 [ | ||||
|                     'class' => NewsItem::class, | ||||
|                     'name' => 'news_item', | ||||
|                     'base_path' => '/admin/news_item', | ||||
|                     'form_class' => NewsItemType::class, | ||||
|                     'controller' => NewsItemController::class, | ||||
|                     'actions' => [ | ||||
|                         'index' => [ | ||||
|                             'role' => 'ROLE_ADMIN', | ||||
|                             'template' => '@ChillMain/NewsItem/index.html.twig', | ||||
|                         ], | ||||
|                         'new' => [ | ||||
|                             'role' => 'ROLE_ADMIN', | ||||
|                             'template' => '@ChillMain/NewsItem/new.html.twig', | ||||
|                         ], | ||||
|                         'view' => [ | ||||
|                             'role' => 'ROLE_ADMIN', | ||||
|                             'template' => '@ChillMain/NewsItem/view_admin.html.twig', | ||||
|                         ], | ||||
|                         'edit' => [ | ||||
|                             'role' => 'ROLE_ADMIN', | ||||
|                             'template' => '@ChillMain/NewsItem/edit.html.twig', | ||||
|                         ], | ||||
|                         'delete' => [ | ||||
|                             'role' => 'ROLE_ADMIN', | ||||
|                             'template' => '@ChillMain/NewsItem/delete.html.twig', | ||||
|                         ], | ||||
|                     ], | ||||
|                 ], | ||||
|             ], | ||||
|             'apis' => [ | ||||
|                 [ | ||||
|   | ||||
| @@ -1,112 +0,0 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\MainBundle\Entity; | ||||
|  | ||||
| use Doctrine\ORM\Mapping as ORM; | ||||
| use Symfony\Component\Serializer\Annotation as Serializer; | ||||
| use Symfony\Component\Validator\Constraints as Assert; | ||||
|  | ||||
| /** | ||||
|  * @ORM\Entity | ||||
|  * | ||||
|  * @ORM\Table(name="chill_main_dashboard_config_item") | ||||
|  */ | ||||
| class DashboardConfigItem | ||||
| { | ||||
|     /** | ||||
|      * @ORM\Id | ||||
|      * | ||||
|      * @ORM\GeneratedValue | ||||
|      * | ||||
|      * @ORM\Column(type="integer") | ||||
|      * | ||||
|      * @Serializer\Groups({"dashboardConfigItem:read", "read"}) | ||||
|      */ | ||||
|     private ?int $id = null; | ||||
|  | ||||
|     /** | ||||
|      * @ORM\Column(type="string") | ||||
|      * | ||||
|      * @Serializer\Groups({"dashboardConfigItem:read", "read"}) | ||||
|      * | ||||
|      * @Assert\NotNull | ||||
|      */ | ||||
|     private string $type = ''; | ||||
|  | ||||
|     /** | ||||
|      * @ORM\Column(type="string") | ||||
|      * | ||||
|      * @Serializer\Groups({"dashboardConfigItem:read", "read"}) | ||||
|      * | ||||
|      * @Assert\NotNull | ||||
|      */ | ||||
|     private string $position = ''; | ||||
|  | ||||
|     /** | ||||
|      * @ORM\ManyToOne(targetEntity=User::class) | ||||
|      */ | ||||
|     private ?User $user = null; | ||||
|  | ||||
|     /** | ||||
|      * @ORM\Column(type="json", options={"default": "[]", "jsonb": true}) | ||||
|      * | ||||
|      * @Serializer\Groups({"dashboardConfigItem:read"}) | ||||
|      */ | ||||
|     private array $metadata = []; | ||||
|  | ||||
|     public function getId(): ?int | ||||
|     { | ||||
|         return $this->id; | ||||
|     } | ||||
|  | ||||
|     public function getType(): string | ||||
|     { | ||||
|         return $this->type; | ||||
|     } | ||||
|  | ||||
|     public function setType(string $type): self | ||||
|     { | ||||
|         $this->type = $type; | ||||
|  | ||||
|         return $this; | ||||
|     } | ||||
|  | ||||
|     public function getPosition(): string | ||||
|     { | ||||
|         return $this->position; | ||||
|     } | ||||
|  | ||||
|     public function setPosition(string $position): void | ||||
|     { | ||||
|         $this->position = $position; | ||||
|     } | ||||
|  | ||||
|     public function getUser(): User | ||||
|     { | ||||
|         return $this->user; | ||||
|     } | ||||
|  | ||||
|     public function setUser(User $user): void | ||||
|     { | ||||
|         $this->user = $user; | ||||
|     } | ||||
|  | ||||
|     public function getMetadata(): array | ||||
|     { | ||||
|         return $this->metadata; | ||||
|     } | ||||
|  | ||||
|     public function setMetadata(array $metadata): void | ||||
|     { | ||||
|         $this->metadata = $metadata; | ||||
|     } | ||||
| } | ||||
| @@ -1,128 +0,0 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\MainBundle\Entity; | ||||
|  | ||||
| use Chill\MainBundle\Doctrine\Model\TrackCreationInterface; | ||||
| use Chill\MainBundle\Doctrine\Model\TrackCreationTrait; | ||||
| use Chill\MainBundle\Doctrine\Model\TrackUpdateInterface; | ||||
| use Chill\MainBundle\Doctrine\Model\TrackUpdateTrait; | ||||
| use Doctrine\ORM\Mapping as ORM; | ||||
| use Symfony\Component\Serializer\Annotation\Groups; | ||||
| use Symfony\Component\Validator\Constraints as Assert; | ||||
|  | ||||
| /** | ||||
|  * @ORM\Entity | ||||
|  * | ||||
|  * @ORM\Table(name="chill_main_news") | ||||
|  */ | ||||
| class NewsItem implements TrackCreationInterface, TrackUpdateInterface | ||||
| { | ||||
|     use TrackCreationTrait; | ||||
|  | ||||
|     use TrackUpdateTrait; | ||||
|  | ||||
|     /** | ||||
|      * @ORM\Id | ||||
|      * | ||||
|      * @ORM\GeneratedValue | ||||
|      * | ||||
|      * @ORM\Column(type="integer") | ||||
|      * | ||||
|      * @Groups({"read"}) | ||||
|      */ | ||||
|     private ?int $id = null; | ||||
|  | ||||
|     /** | ||||
|      * @ORM\Column(type="text") | ||||
|      * | ||||
|      * @Groups({"read"}) | ||||
|      * | ||||
|      * @Assert\NotBlank | ||||
|      * | ||||
|      * @Assert\NotNull | ||||
|      */ | ||||
|     private string $title = ''; | ||||
|  | ||||
|     /** | ||||
|      * @ORM\Column(type="text") | ||||
|      * | ||||
|      * @Groups({"read"}) | ||||
|      * | ||||
|      * @Assert\NotBlank | ||||
|      * | ||||
|      * @Assert\NotNull | ||||
|      */ | ||||
|     private string $content = ''; | ||||
|  | ||||
|     /** | ||||
|      * @ORM\Column(type="date_immutable", nullable=false) | ||||
|      * | ||||
|      * @Assert\NotNull | ||||
|      * | ||||
|      * @Groups({"read"}) | ||||
|      */ | ||||
|     private ?\DateTimeImmutable $startDate = null; | ||||
|  | ||||
|     /** | ||||
|      * @ORM\Column(type="date_immutable", nullable=true, options={"default": null}) | ||||
|      * | ||||
|      * @Assert\GreaterThanOrEqual(propertyPath="startDate") | ||||
|      * | ||||
|      * @Groups({"read"}) | ||||
|      */ | ||||
|     private ?\DateTimeImmutable $endDate = null; | ||||
|  | ||||
|     public function getTitle(): string | ||||
|     { | ||||
|         return $this->title; | ||||
|     } | ||||
|  | ||||
|     public function setTitle(string $title): void | ||||
|     { | ||||
|         $this->title = $title; | ||||
|     } | ||||
|  | ||||
|     public function getContent(): string | ||||
|     { | ||||
|         return $this->content; | ||||
|     } | ||||
|  | ||||
|     public function setContent(string $content): void | ||||
|     { | ||||
|         $this->content = $content; | ||||
|     } | ||||
|  | ||||
|     public function getStartDate(): ?\DateTimeImmutable | ||||
|     { | ||||
|         return $this->startDate; | ||||
|     } | ||||
|  | ||||
|     public function setStartDate(?\DateTimeImmutable $startDate): void | ||||
|     { | ||||
|         $this->startDate = $startDate; | ||||
|     } | ||||
|  | ||||
|     public function getEndDate(): ?\DateTimeImmutable | ||||
|     { | ||||
|         return $this->endDate; | ||||
|     } | ||||
|  | ||||
|     public function setEndDate(?\DateTimeImmutable $endDate): void | ||||
|     { | ||||
|         $this->endDate = $endDate; | ||||
|     } | ||||
|  | ||||
|     public function getId(): ?int | ||||
|     { | ||||
|         return $this->id; | ||||
|     } | ||||
| } | ||||
| @@ -1,54 +0,0 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\MainBundle\Form; | ||||
|  | ||||
| use Chill\MainBundle\Entity\NewsItem; | ||||
| use Chill\MainBundle\Form\Type\ChillDateType; | ||||
| use Chill\MainBundle\Form\Type\ChillTextareaType; | ||||
| use Symfony\Component\Form\AbstractType; | ||||
| use Symfony\Component\Form\Extension\Core\Type\TextType; | ||||
| use Symfony\Component\Form\FormBuilderInterface; | ||||
| use Symfony\Component\OptionsResolver\OptionsResolver; | ||||
|  | ||||
| class NewsItemType extends AbstractType | ||||
| { | ||||
|     public function buildForm(FormBuilderInterface $builder, array $options) | ||||
|     { | ||||
|         $builder | ||||
|             ->add('title', TextType::class, [ | ||||
|                 'required' => true, | ||||
|             ]) | ||||
|             ->add('content', ChillTextareaType::class, [ | ||||
|                 'required' => false, | ||||
|             ]) | ||||
|             ->add( | ||||
|                 'startDate', | ||||
|                 ChillDateType::class, | ||||
|                 [ | ||||
|                     'required' => true, | ||||
|                     'input' => 'datetime_immutable', | ||||
|                 ] | ||||
|             ) | ||||
|             ->add('endDate', ChillDateType::class, [ | ||||
|                 'required' => false, | ||||
|                 'input' => 'datetime_immutable', | ||||
|             ]); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @return void | ||||
|      */ | ||||
|     public function configureOptions(OptionsResolver $resolver) | ||||
|     { | ||||
|         $resolver->setDefault('data_class', NewsItem::class); | ||||
|     } | ||||
| } | ||||
| @@ -13,6 +13,7 @@ namespace Chill\MainBundle\Form\Type; | ||||
|  | ||||
| use Chill\MainBundle\Entity\Center; | ||||
| use Chill\MainBundle\Entity\PermissionsGroup; | ||||
| use Doctrine\ORM\EntityRepository; | ||||
| use Symfony\Bridge\Doctrine\Form\Type\EntityType; | ||||
| use Symfony\Component\Form\AbstractType; | ||||
| use Symfony\Component\Form\FormBuilderInterface; | ||||
| @@ -27,7 +28,13 @@ class ComposedGroupCenterType extends AbstractType | ||||
|             'choice_label' => static fn (PermissionsGroup $group) => $group->getName(), | ||||
|         ])->add('center', EntityType::class, [ | ||||
|             'class' => Center::class, | ||||
|             'choice_label' => static fn (Center $center) => $center->getName(), | ||||
|             'query_builder' => static function (EntityRepository $er) { | ||||
|                 $qb = $er->createQueryBuilder('c'); | ||||
|                 $qb->where($qb->expr()->eq('c.isActive', 'TRUE')) | ||||
|                     ->orderBy('c.name', 'ASC'); | ||||
|  | ||||
|                 return $qb; | ||||
|             }, | ||||
|         ]); | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -42,6 +42,8 @@ class PickUserDynamicType extends AbstractType | ||||
|         $view->vars['types'] = ['user']; | ||||
|         $view->vars['uniqid'] = uniqid('pick_user_dyn'); | ||||
|         $view->vars['suggested'] = []; | ||||
|         $view->vars['as_id'] = true === $options['as_id'] ? '1' : '0'; | ||||
|         $view->vars['submit_on_adding_new_entity'] = true === $options['submit_on_adding_new_entity'] ? '1' : '0'; | ||||
|  | ||||
|         foreach ($options['suggested'] as $user) { | ||||
|             $view->vars['suggested'][] = $this->normalizer->normalize($user, 'json', ['groups' => 'read']); | ||||
| @@ -54,7 +56,12 @@ class PickUserDynamicType extends AbstractType | ||||
|             ->setDefault('multiple', false) | ||||
|             ->setAllowedTypes('multiple', ['bool']) | ||||
|             ->setDefault('compound', false) | ||||
|             ->setDefault('suggested', []); | ||||
|             ->setDefault('suggested', []) | ||||
|             // if set to true, only the id will be set inside the content. The denormalization will not work. | ||||
|             ->setDefault('as_id', false) | ||||
|             ->setAllowedTypes('as_id', ['bool']) | ||||
|             ->setDefault('submit_on_adding_new_entity', false) | ||||
|             ->setAllowedTypes('submit_on_adding_new_entity', ['bool']); | ||||
|     } | ||||
|  | ||||
|     public function getBlockPrefix() | ||||
|   | ||||
| @@ -17,7 +17,7 @@ use Symfony\Component\Routing\RouterInterface; | ||||
| /** | ||||
|  * Create paginator instances. | ||||
|  */ | ||||
| class PaginatorFactory | ||||
| final readonly class PaginatorFactory implements PaginatorFactoryInterface | ||||
| { | ||||
|     final public const DEFAULT_CURRENT_PAGE_KEY = 'page'; | ||||
|  | ||||
| @@ -25,23 +25,20 @@ class PaginatorFactory | ||||
|  | ||||
|     final public const DEFAULT_PAGE_NUMBER = 1; | ||||
|  | ||||
|     /** | ||||
|      * @param int $itemPerPage | ||||
|      */ | ||||
|     public function __construct( | ||||
|         /** | ||||
|          * the request stack. | ||||
|          */ | ||||
|         private readonly RequestStack $requestStack, | ||||
|         private RequestStack $requestStack, | ||||
|         /** | ||||
|          * the router and generator for url. | ||||
|          */ | ||||
|         private readonly RouterInterface $router, | ||||
|         private RouterInterface $router, | ||||
|         /** | ||||
|          * the default item per page. This may be overriden by | ||||
|          * the request or inside the paginator. | ||||
|          */ | ||||
|         private $itemPerPage = 20 | ||||
|         private int $itemPerPage = 20 | ||||
|     ) { | ||||
|     } | ||||
|  | ||||
| @@ -51,17 +48,14 @@ class PaginatorFactory | ||||
|      * The default route and route parameters are the current ones. If set, | ||||
|      * thos route are overriden. | ||||
|      * | ||||
|      * @param int         $totalItems | ||||
|      * @param string|null $route           the specific route to use in pages | ||||
|      * @param array|null  $routeParameters the specific route parameters to use in pages | ||||
|      * | ||||
|      * @return PaginatorInterface | ||||
|      */ | ||||
|     public function create( | ||||
|         $totalItems, | ||||
|         int $totalItems, | ||||
|         ?string $route = null, | ||||
|         ?array $routeParameters = null | ||||
|     ) { | ||||
|     ): PaginatorInterface { | ||||
|         return new Paginator( | ||||
|             $totalItems, | ||||
|             $this->getCurrentItemsPerPage(), | ||||
| @@ -74,7 +68,7 @@ class PaginatorFactory | ||||
|         ); | ||||
|     } | ||||
|  | ||||
|     public function getCurrentItemsPerPage() | ||||
|     public function getCurrentItemsPerPage(): int | ||||
|     { | ||||
|         return $this->requestStack | ||||
|             ->getCurrentRequest() | ||||
| @@ -82,16 +76,13 @@ class PaginatorFactory | ||||
|             ->getInt(self::DEFAULT_ITEM_PER_NUMBER_KEY, $this->itemPerPage); | ||||
|     } | ||||
|  | ||||
|     public function getCurrentPageFirstItemNumber() | ||||
|     public function getCurrentPageFirstItemNumber(): int | ||||
|     { | ||||
|         return ($this->getCurrentPageNumber() - 1) * | ||||
|             $this->getCurrentItemsPerPage(); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @return int | ||||
|      */ | ||||
|     public function getCurrentPageNumber() | ||||
|     public function getCurrentPageNumber(): int | ||||
|     { | ||||
|         return $this->requestStack | ||||
|             ->getCurrentRequest() | ||||
| @@ -99,14 +90,14 @@ class PaginatorFactory | ||||
|             ->getInt(self::DEFAULT_CURRENT_PAGE_KEY, self::DEFAULT_PAGE_NUMBER); | ||||
|     } | ||||
|  | ||||
|     protected function getCurrentRoute() | ||||
|     private function getCurrentRoute() | ||||
|     { | ||||
|         $request = $this->requestStack->getCurrentRequest(); | ||||
|  | ||||
|         return $request->get('_route'); | ||||
|     } | ||||
|  | ||||
|     protected function getCurrentRouteParameters() | ||||
|     private function getCurrentRouteParameters() | ||||
|     { | ||||
|         return array_merge( | ||||
|             $this->router->getContext()->getParameters(), | ||||
|   | ||||
| @@ -0,0 +1,35 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\MainBundle\Pagination; | ||||
|  | ||||
| /** | ||||
|  * Create paginator instances. | ||||
|  */ | ||||
| interface PaginatorFactoryInterface | ||||
| { | ||||
|     /** | ||||
|      * create a paginator instance. | ||||
|      * | ||||
|      * The default route and route parameters are the current ones. If set, | ||||
|      * thos route are overriden. | ||||
|      * | ||||
|      * @param string|null $route           the specific route to use in pages | ||||
|      * @param array|null  $routeParameters the specific route parameters to use in pages | ||||
|      */ | ||||
|     public function create(int $totalItems, ?string $route = null, ?array $routeParameters = null): PaginatorInterface; | ||||
|  | ||||
|     public function getCurrentItemsPerPage(): int; | ||||
|  | ||||
|     public function getCurrentPageFirstItemNumber(): int; | ||||
|  | ||||
|     public function getCurrentPageNumber(): int; | ||||
| } | ||||
| @@ -1,144 +0,0 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\MainBundle\Repository; | ||||
|  | ||||
| use Chill\MainBundle\Entity\NewsItem; | ||||
| use Doctrine\ORM\EntityManagerInterface; | ||||
| use Doctrine\ORM\EntityRepository; | ||||
| use Doctrine\ORM\QueryBuilder; | ||||
| use Doctrine\Persistence\ObjectRepository; | ||||
| use Symfony\Component\Clock\ClockInterface; | ||||
|  | ||||
| class NewsItemRepository implements ObjectRepository | ||||
| { | ||||
|     private readonly EntityRepository $repository; | ||||
|  | ||||
|     public function __construct(EntityManagerInterface $entityManager, private readonly ClockInterface $clock) | ||||
|     { | ||||
|         $this->repository = $entityManager->getRepository(NewsItem::class); | ||||
|     } | ||||
|  | ||||
|     public function createQueryBuilder(string $alias, ?string $indexBy = null): QueryBuilder | ||||
|     { | ||||
|         return $this->repository->createQueryBuilder($alias, $indexBy); | ||||
|     } | ||||
|  | ||||
|     public function find($id) | ||||
|     { | ||||
|         return $this->repository->find($id); | ||||
|     } | ||||
|  | ||||
|     public function findAll() | ||||
|     { | ||||
|         return $this->repository->findAll(); | ||||
|     } | ||||
|  | ||||
|     public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null) | ||||
|     { | ||||
|         return $this->repository->findBy($criteria, $orderBy, $limit, $offset); | ||||
|     } | ||||
|  | ||||
|     public function findOneBy(array $criteria) | ||||
|     { | ||||
|         return $this->repository->findOneBy($criteria); | ||||
|     } | ||||
|  | ||||
|     public function getClassName() | ||||
|     { | ||||
|         return NewsItem::class; | ||||
|     } | ||||
|  | ||||
|     private function buildBaseQuery( | ||||
|         ?string $pattern = null | ||||
|     ): QueryBuilder { | ||||
|         $qb = $this->createQueryBuilder('n'); | ||||
|  | ||||
|         $qb->where('n.startDate <= :now'); | ||||
|         $qb->setParameter('now', $this->clock->now()); | ||||
|  | ||||
|         if (null !== $pattern && '' !== $pattern) { | ||||
|             $qb->andWhere($qb->expr()->like('LOWER(UNACCENT(n.title))', 'LOWER(UNACCENT(:pattern))')) | ||||
|                 ->orWhere($qb->expr()->like('LOWER(UNACCENT(n.content))', 'LOWER(UNACCENT(:pattern))')) | ||||
|                 ->setParameter('pattern', '%'.$pattern.'%'); | ||||
|         } | ||||
|  | ||||
|         return $qb; | ||||
|     } | ||||
|  | ||||
|     public function findAllFilteredBySearchTerm(?string $pattern = null) | ||||
|     { | ||||
|         $qb = $this->buildBaseQuery($pattern); | ||||
|         $qb | ||||
|             ->addOrderBy('n.startDate', 'DESC') | ||||
|             ->addOrderBy('n.id', 'DESC'); | ||||
|  | ||||
|         return $qb->getQuery()->getResult(); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @return list<NewsItem> | ||||
|      */ | ||||
|     public function findCurrentNews(?int $limit = null, ?int $offset = null): array | ||||
|     { | ||||
|         $qb = $this->buildQueryCurrentNews(); | ||||
|  | ||||
|         if (null !== $limit) { | ||||
|             $qb->setMaxResults($limit); | ||||
|         } | ||||
|  | ||||
|         if (null !== $offset) { | ||||
|             $qb->setFirstResult($offset); | ||||
|         } | ||||
|  | ||||
|         return $qb | ||||
|             ->getQuery() | ||||
|             ->getResult(); | ||||
|     } | ||||
|  | ||||
|     public function countAllFilteredBySearchTerm(?string $pattern = null) | ||||
|     { | ||||
|         $qb = $this->buildBaseQuery($pattern); | ||||
|  | ||||
|         return $qb | ||||
|             ->select('COUNT(n)') | ||||
|             ->getQuery() | ||||
|             ->getSingleScalarResult(); | ||||
|     } | ||||
|  | ||||
|     public function countCurrentNews() | ||||
|     { | ||||
|         return $this->buildQueryCurrentNews() | ||||
|             ->select('COUNT(n)') | ||||
|             ->getQuery() | ||||
|             ->getSingleScalarResult(); | ||||
|     } | ||||
|  | ||||
|     private function buildQueryCurrentNews(): QueryBuilder | ||||
|     { | ||||
|         $now = $this->clock->now(); | ||||
|  | ||||
|         $qb = $this->createQueryBuilder('n'); | ||||
|         $qb | ||||
|             ->where( | ||||
|                 $qb->expr()->andX( | ||||
|                     $qb->expr()->lte('n.startDate', ':now'), | ||||
|                     $qb->expr()->orX( | ||||
|                         $qb->expr()->gt('n.endDate', ':now'), | ||||
|                         $qb->expr()->isNull('n.endDate') | ||||
|                     ) | ||||
|                 ) | ||||
|             ) | ||||
|             ->setParameter('now', $now); | ||||
|  | ||||
|         return $qb; | ||||
|     } | ||||
| } | ||||
| @@ -55,11 +55,20 @@ export interface ServerExceptionInterface extends TransportExceptionInterface { | ||||
|   body: string; | ||||
| } | ||||
|  | ||||
| export interface ConflictHttpExceptionInterface extends TransportExceptionInterface { | ||||
|     name: 'ConflictHttpException'; | ||||
|     violations: string[]; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Generic api method that can be adapted to any fetch request | ||||
|  */ | ||||
| export const makeFetch = <Input, Output>(method: 'POST'|'GET'|'PUT'|'PATCH'|'DELETE', url: string, body?: body | Input | null, options?: FetchParams): Promise<Output> => { | ||||
| export const makeFetch = <Input, Output>( | ||||
|     method: 'POST'|'GET'|'PUT'|'PATCH'|'DELETE', | ||||
|     url: string, body?: body | Input | null, | ||||
|     options?: FetchParams | ||||
| ): Promise<Output> => { | ||||
|  | ||||
|     let opts = { | ||||
|         method: method, | ||||
|         headers: { | ||||
| @@ -67,6 +76,7 @@ export const makeFetch = <Input, Output>(method: 'POST'|'GET'|'PUT'|'PATCH'|'DEL | ||||
|         }, | ||||
|     }; | ||||
|  | ||||
|  | ||||
|   if (body !== null && typeof body !== 'undefined') { | ||||
|       Object.assign(opts, {body: JSON.stringify(body)}) | ||||
|     } | ||||
| @@ -90,6 +100,10 @@ export const makeFetch = <Input, Output>(method: 'POST'|'GET'|'PUT'|'PATCH'|'DEL | ||||
|             throw AccessException(response); | ||||
|         } | ||||
|  | ||||
|         if (response.status === 409) { | ||||
|             throw ConflictHttpException(response); | ||||
|         } | ||||
|  | ||||
|         throw { | ||||
|             name: 'Exception', | ||||
|             sta: response.status, | ||||
| @@ -220,3 +234,12 @@ const ServerException = (code: number, body: string): ServerExceptionInterface = | ||||
|  | ||||
|   return error; | ||||
| } | ||||
|  | ||||
| const ConflictHttpException = (response: Response): ConflictHttpExceptionInterface => { | ||||
|     const error = {} as ConflictHttpExceptionInterface; | ||||
|  | ||||
|     error.name = 'ConflictHttpException'; | ||||
|     error.violations = ['Sorry, but someone else has already changed this entity. Please refresh the page and apply the changes again'] | ||||
|  | ||||
|     return error; | ||||
| } | ||||
|   | ||||
| @@ -2,14 +2,14 @@ import AddressDetailsButton from "../../vuejs/_components/AddressDetails/Address | ||||
| import {createApp} from "vue"; | ||||
| import {createI18n} from "vue-i18n"; | ||||
| import {_createI18n} from "../../vuejs/_js/i18n"; | ||||
| import {Address} from "../../types"; | ||||
| import {Address, AddressRefStatus} from "../../types"; | ||||
|  | ||||
| const i18n = _createI18n({}); | ||||
|  | ||||
| document.querySelectorAll<HTMLSpanElement>('span[data-address-details]').forEach((el) => { | ||||
|   const dataset = el.dataset as { | ||||
|     addressId: string, | ||||
|     addressRefStatus: string, | ||||
|     addressRefStatus: AddressRefStatus, | ||||
|   }; | ||||
|  | ||||
|   const app = createApp({ | ||||
|   | ||||
| @@ -1 +0,0 @@ | ||||
| import './index.scss'; | ||||
| @@ -1,7 +0,0 @@ | ||||
| div.flex-table { | ||||
|     .news-content { | ||||
|         p { | ||||
|             margin-top: 1rem; | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -24,7 +24,10 @@ function loadDynamicPicker(element) { | ||||
|                     (input.value === '[]' || input.value === '') ? | ||||
|                         null : [ JSON.parse(input.value) ] | ||||
|                 ) | ||||
|             suggested = JSON.parse(el.dataset.suggested) | ||||
|             suggested = JSON.parse(el.dataset.suggested), | ||||
|             as_id = parseInt(el.dataset.asId) === 1, | ||||
|             submit_on_adding_new_entity = parseInt(el.dataset.submitOnAddingNewEntity) === 1 | ||||
|             label = el.dataset.label; | ||||
|  | ||||
|         if (!isMultiple) { | ||||
|             if (input.value === '[]'){ | ||||
| @@ -39,6 +42,7 @@ function loadDynamicPicker(element) { | ||||
|                 ':picked="picked" ' + | ||||
|                 ':uniqid="uniqid" ' + | ||||
|                 ':suggested="notPickedSuggested" ' + | ||||
|                 ':label="label" ' + | ||||
|                 '@addNewEntity="addNewEntity" ' + | ||||
|                 '@removeEntity="removeEntity"></pick-entity>', | ||||
|             components: { | ||||
| @@ -50,7 +54,10 @@ function loadDynamicPicker(element) { | ||||
|                     types: JSON.parse(el.dataset.types), | ||||
|                     picked: picked === null ? [] : picked, | ||||
|                     uniqid: el.dataset.uniqid, | ||||
|                     suggested: suggested | ||||
|                     suggested, | ||||
|                     as_id, | ||||
|                     submit_on_adding_new_entity, | ||||
|                     label, | ||||
|                 } | ||||
|             }, | ||||
|             computed: { | ||||
| @@ -69,7 +76,12 @@ function loadDynamicPicker(element) { | ||||
|                             return el.type === entity.type && el.id === entity.id; | ||||
|                         })) { | ||||
|                             this.picked.push(entity); | ||||
|                             input.value = JSON.stringify(this.picked); | ||||
|                             if (!as_id) { | ||||
|                               input.value = JSON.stringify(this.picked); | ||||
|                             } else { | ||||
|                               const ids = this.picked.map(el => el.id); | ||||
|                               input.value = ids.join(','); | ||||
|                             } | ||||
|                             console.log(entity) | ||||
|                         } | ||||
|                     } else { | ||||
| @@ -78,9 +90,17 @@ function loadDynamicPicker(element) { | ||||
|                         })) { | ||||
|                             this.picked.splice(0, this.picked.length); | ||||
|                             this.picked.push(entity); | ||||
|                             input.value = JSON.stringify(this.picked[0]); | ||||
|                             if (!as_id) { | ||||
|                               input.value = JSON.stringify(this.picked[0]); | ||||
|                             } else { | ||||
|                               input.value = this.picked.map(el => el.id); | ||||
|                             } | ||||
|                         } | ||||
|                     } | ||||
|  | ||||
|                     if (this.submit_on_adding_new_entity) { | ||||
|                         input.form.submit(); | ||||
|                     } | ||||
|                 }, | ||||
|                 removeEntity({entity}) { | ||||
|                     if (-1 === this.suggested.findIndex(e => e.type === entity.type && e.id === entity.id)) { | ||||
|   | ||||
| @@ -160,11 +160,3 @@ export interface LocationType { | ||||
|   contactData: "optional" | "required"; | ||||
|   title: TranslatableString; | ||||
| } | ||||
|  | ||||
| export interface NewsItemType { | ||||
|     id: number; | ||||
|     title: string; | ||||
|     content: string; | ||||
|     startDate: DateTime; | ||||
|     endDate: DateTime | null; | ||||
| } | ||||
|   | ||||
| @@ -97,8 +97,6 @@ import MyNotifications from './MyNotifications'; | ||||
| import MyWorkflows from './MyWorkflows.vue'; | ||||
| import TabCounter from './TabCounter'; | ||||
| import { mapState } from "vuex"; | ||||
| import { makeFetch } from "ChillMainAssets/lib/api/apiMethods"; | ||||
|  | ||||
|  | ||||
| export default { | ||||
|    name: "App", | ||||
| @@ -114,7 +112,7 @@ export default { | ||||
|    }, | ||||
|    data() { | ||||
|       return { | ||||
|          activeTab: 'MyCustoms', | ||||
|          activeTab: 'MyCustoms' | ||||
|       } | ||||
|    }, | ||||
|    computed: { | ||||
| @@ -128,11 +126,8 @@ export default { | ||||
|    }, | ||||
|    methods: { | ||||
|       selectTab(tab) { | ||||
|           if (tab !== 'MyCustoms') { | ||||
|               this.$store.dispatch('getByTab', { tab: tab }); | ||||
|           } | ||||
|          this.$store.dispatch('getByTab', { tab: tab }); | ||||
|          this.activeTab = tab; | ||||
|           console.log(this.activeTab) | ||||
|       } | ||||
|    }, | ||||
|    mounted() { | ||||
|   | ||||
| @@ -1,45 +0,0 @@ | ||||
| <template> | ||||
|     <div v-if="newsItems.length > 0"> | ||||
|         <h1>{{ $t('widget.news.title') }}</h1> | ||||
|         <ul class="scrollable"> | ||||
|             <NewsItem v-for="item in newsItems" :item="item" :key="item.id" /> | ||||
|         </ul> | ||||
|     </div> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
| import { onMounted, ref } from 'vue' | ||||
| import { fetchResults } from '../../../lib/api/apiMethods'; | ||||
| import Modal from '../../_components/Modal.vue'; | ||||
| import { NewsItemType } from '../../../types'; | ||||
| import NewsItem from './NewsItem.vue'; | ||||
|  | ||||
| const newsItems = ref<NewsItemType[]>([]) | ||||
|  | ||||
| onMounted(() => { | ||||
|     fetchResults<NewsItemType>('/api/1.0/main/news/current.json') | ||||
|         .then((news): Promise<void> => { | ||||
|             // console.log('news articles', response.results) | ||||
|             newsItems.value = news; | ||||
|  | ||||
|             return Promise.resolve(); | ||||
|         }) | ||||
|         .catch((error: string) => { | ||||
|             console.error('Error fetching news items', error); | ||||
|         }) | ||||
| }) | ||||
|  | ||||
| </script> | ||||
|  | ||||
| <style scoped> | ||||
| ul { | ||||
|     list-style: none; | ||||
|     padding: 0; | ||||
| } | ||||
|  | ||||
| h1 { | ||||
|     text-align: center; | ||||
| } | ||||
|  | ||||
|  | ||||
| </style> | ||||
| @@ -1,183 +0,0 @@ | ||||
| <template> | ||||
|     <li> | ||||
|         <h2>{{ props.item.title }}</h2> | ||||
|         <time class="createdBy" datetime="{{item.startDate.datetime}}">{{ $d(newsItemStartDate(), 'text') }}</time> | ||||
|         <div class="content" v-if="shouldTruncate(item.content)"> | ||||
|             <div v-html="prepareContent(item.content)"></div> | ||||
|             <div class="float-end"> | ||||
|                 <button class="btn btn-sm btn-show read-more" @click="() => openModal(item)">{{ $t('widget.news.readMore') }}</button> | ||||
|             </div> | ||||
|         </div> | ||||
|         <div class="content" v-else> | ||||
|             <div v-html="convertMarkdownToHtml(item.content)"></div> | ||||
|         </div> | ||||
|  | ||||
|         <modal v-if="showModal" @close="closeModal"> | ||||
|             <template #header> | ||||
|                 <p class="news-title">{{ item.title }}</p> | ||||
|             </template> | ||||
|             <template #body> | ||||
|                 <p class="news-date"> | ||||
|                     <time class="createdBy" datetime="{{item.startDate.datetime}}">{{ $d(newsItemStartDate(), 'text') }}</time> | ||||
|                 </p> | ||||
|                 <div v-html="convertMarkdownToHtml(item.content)"></div> | ||||
|             </template> | ||||
|         </modal> | ||||
|     </li> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
| import Modal from "ChillMainAssets/vuejs/_components/Modal.vue"; | ||||
| import { marked } from 'marked'; | ||||
| import DOMPurify from 'dompurify'; | ||||
| import { DateTime, NewsItemType } from "../../../types"; | ||||
| import type { PropType } from 'vue' | ||||
| import { ref } from "vue"; | ||||
| import {ISOToDatetime} from '../../../chill/js/date'; | ||||
|  | ||||
|  | ||||
| const props = defineProps({ | ||||
|     item: { | ||||
|         type: Object as PropType<NewsItemType>, | ||||
|         required: true | ||||
|     }, | ||||
|     maxLength: { | ||||
|         type: Number, | ||||
|         required: false, | ||||
|         default: 350, | ||||
|     }, | ||||
|     maxLines: { | ||||
|         type: Number, | ||||
|         required: false, | ||||
|         default: 3 | ||||
|     } | ||||
| }) | ||||
|  | ||||
| const selectedArticle = ref<NewsItemType | null>(null); | ||||
| const showModal = ref(false); | ||||
|  | ||||
| const openModal = (item: NewsItemType) => { | ||||
|     selectedArticle.value = item; | ||||
|     showModal.value = true; | ||||
| }; | ||||
|  | ||||
| const closeModal = () => { | ||||
|     selectedArticle.value = null; | ||||
|     showModal.value = false; | ||||
| }; | ||||
|  | ||||
| const shouldTruncate = (content: string): boolean => { | ||||
|     const lines = content.split('\n'); | ||||
|  | ||||
|     // Check if any line exceeds the maximum length | ||||
|     const tooManyLines = lines.length > props.maxLines; | ||||
|  | ||||
|     return content.length > props.maxLength || tooManyLines; | ||||
| }; | ||||
|  | ||||
| const truncateContent = (content: string): string => { | ||||
|     let truncatedContent = content.slice(0, props.maxLength); | ||||
|     let linkDepth = 0; | ||||
|     let linkStartIndex = -1; | ||||
|     const lines = content.split('\n'); | ||||
|  | ||||
|     // Truncate if amount of lines are too many | ||||
|     if (lines.length > props.maxLines && content.length < props.maxLength) { | ||||
|         const truncatedContent = lines.slice(0, props.maxLines).join('\n').trim(); | ||||
|         return truncatedContent + '...'; | ||||
|     } | ||||
|  | ||||
|     for (let i = 0; i < truncatedContent.length; i++) { | ||||
|         const char = truncatedContent[i]; | ||||
|  | ||||
|         if (char === '[') { | ||||
|             linkDepth++; | ||||
|             if (linkDepth === 1) { | ||||
|                 linkStartIndex = i; | ||||
|             } | ||||
|         } else if (char === ']') { | ||||
|             linkDepth = Math.max(0, linkDepth - 1); | ||||
|         } else if (char === '(' && linkDepth === 0) { | ||||
|             truncatedContent = truncatedContent.slice(0, i); | ||||
|             break; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     while (linkDepth > 0) { | ||||
|         truncatedContent += ']'; | ||||
|         linkDepth--; | ||||
|     } | ||||
|  | ||||
|     // If a link was found, append the URL inside the parentheses | ||||
|     if (linkStartIndex !== -1) { | ||||
|         const linkEndIndex = content.indexOf(')', linkStartIndex); | ||||
|         const url = content.slice(linkStartIndex + 1, linkEndIndex); | ||||
|         truncatedContent = truncatedContent.slice(0, linkStartIndex) + `(${url})`; | ||||
|     } | ||||
|  | ||||
|     truncatedContent += '...'; | ||||
|  | ||||
|     return truncatedContent; | ||||
| }; | ||||
|  | ||||
| const preprocess = (markdown: string): string => { | ||||
|     return markdown; | ||||
| } | ||||
|  | ||||
| const postprocess = (html: string): string => { | ||||
|     DOMPurify.addHook('afterSanitizeAttributes', (node) => { | ||||
|         if ('target' in node) { | ||||
|             node.setAttribute('target', '_blank'); | ||||
|             node.setAttribute('rel', 'noopener noreferrer'); | ||||
|         } | ||||
|         if (!node.hasAttribute('target') && (node.hasAttribute('xlink:href') || node.hasAttribute('href'))) { | ||||
|             node.setAttribute('xlink:show', 'new'); | ||||
|         } | ||||
|     }) | ||||
|  | ||||
|     return DOMPurify.sanitize(html); | ||||
| } | ||||
|  | ||||
| const convertMarkdownToHtml = (markdown: string): string => { | ||||
|     marked.use({'hooks': {postprocess, preprocess}}); | ||||
|     const rawHtml = marked(markdown); | ||||
|     return rawHtml; | ||||
| }; | ||||
|  | ||||
| const prepareContent = (content: string): string => { | ||||
|     const htmlContent = convertMarkdownToHtml(content); | ||||
|     return truncateContent(htmlContent); | ||||
| }; | ||||
|  | ||||
| const newsItemStartDate = (): null|Date => { | ||||
|     return ISOToDatetime(props.item?.startDate.datetime); | ||||
| } | ||||
|  | ||||
| </script> | ||||
|  | ||||
| <style scoped> | ||||
|  | ||||
| li { | ||||
|     margin-bottom: 20px; | ||||
|     overflow: hidden; | ||||
|     padding: .8rem; | ||||
|     background-color: #fbfbfb; | ||||
|     border-radius: 4px; | ||||
| } | ||||
|  | ||||
| h2 { | ||||
|     font-size: 1rem !important; | ||||
|     text-transform: uppercase; | ||||
| } | ||||
|  | ||||
| .content { | ||||
|     overflow: hidden; | ||||
|     font-size: .9rem; | ||||
|     position: relative; | ||||
| } | ||||
|  | ||||
| .news-title { | ||||
|     font-weight: bold; | ||||
| } | ||||
|  | ||||
| </style> | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user