Compare commits

..

119 Commits

Author SHA1 Message Date
24eb13f440 Merge remote-tracking branch 'origin/issue159_page_acceuil' into testing-202401 2024-02-19 13:36:23 +01:00
2b14c132d5 Homogenize the styling between vuejs module and twig templates
New CSS styles are applied to news module for better layout. Relevant templates are updated to use the new styles. Also, entry point is added in webpack configuration file to import the CSS file through index.js.
2024-02-19 13:36:11 +01:00
ae6355e1e7 Merge remote-tracking branch 'origin/issue159_page_acceuil' into testing-202401 2024-02-19 13:10:10 +01:00
e96c246ef9 adapt cs to php-csfixer version 3.49 2024-02-19 13:09:54 +01:00
d0af191a00 add target=blank to each anchor 2024-02-19 13:08:11 +01:00
95ee573dc5 rationalize the newsitem widgets 2024-02-19 13:07:45 +01:00
1004e98acd Merge branch 'master' into testing-202401 2024-02-19 11:31:42 +01:00
4ed50979bd Merge remote-tracking branch 'origin/issue159_page_acceuil' into testing-202401 2024-02-19 11:30:42 +01:00
85b91250fb correct newsItemControllerTest 2024-02-07 11:58:56 +01:00
8f2409fc06 remove error causing line from phpunit.xml file 2024-02-07 11:58:36 +01:00
ea47d9ff09 php cs fixer with latest version 2024-02-07 10:12:13 +01:00
81e46f2b52 Remove blockquote styling 2024-02-01 16:22:11 +01:00
f4bbb1950b Change styling date 2024-02-01 16:21:49 +01:00
fd48d45872 Improve styling of actualités 2024-01-31 18:38:09 +01:00
92aa9af052 Fix truncating logic to take into account bulletpoints and text written on separate lines 2024-01-30 18:46:30 +01:00
49aeda86d4 Merge branch '145-permettre-de-visualiser-les-documents-dans-libreoffice-en-utilisant-webdav' into testing-202401 2024-01-15 21:22:21 +01:00
cf1df462dc optional parameter after the required one 2024-01-15 21:18:51 +01:00
dd62581226 Merge branch 'issue159_page_acceuil' into testing-202401 2024-01-15 21:02:50 +01:00
b369d94bc3 Append JSON_THROW_ON_ERROR on json_decode 2024-01-15 20:59:11 +01:00
f5879cf275 Apply new CS rules on the news-on-homepage feature 2024-01-15 20:57:51 +01:00
8cc5859a3b Merge remote-tracking branch 'origin/master' into issue159_page_acceuil 2024-01-15 20:57:18 +01:00
e86954143b Merge branch '145-permettre-de-visualiser-les-documents-dans-libreoffice-en-utilisant-webdav' into testing-202401 2024-01-15 20:47:38 +01:00
a0328b9d68 Apply new CS rules on the webdav feature 2024-01-15 20:38:03 +01:00
813a80d6f9 Dav: add UI to edit document 2024-01-15 20:22:14 +01:00
ab95bb157e Dav: add some documentation on classes 2024-01-15 20:19:03 +01:00
18fd1dbc4a Dav: Introduce access control inside de dav controller 2024-01-15 20:19:03 +01:00
a35f7656cb Dav: refactor WebdavController 2024-01-15 20:19:03 +01:00
ff05f9f48a Dav: implements JWT extraction from the URL, and add the access_token in dav urls 2024-01-15 20:19:02 +01:00
482c494034 Webdav: fully implements the controller and response
The controller is tested from real request scraped from apache mod_dav implementation. The requests were scraped using a wireshark-like tool. Those requests have been adapted to suit to our xml.
2024-01-15 20:19:02 +01:00
81eafde216 Update package.json for improved dependency management
This commit organizes dependencies listed in the package.json file for better manageability. It properly positions the recently added "@types/dompurify", "dompurify", and "marked" packages. This spatial reordering does not affect the functionality but provides clearer visual comprehension.
2024-01-15 20:15:11 +01:00
146f5ac80f Merge branch '145-permettre-de-visualiser-les-documents-dans-libreoffice-en-utilisant-webdav' into testing-202401 2024-01-15 20:13:18 +01:00
5f74682cba Merge branch 'issue159_page_acceuil' into testing-20301 2024-01-15 14:21:59 +01:00
49dbd09167 Change test into an integration test rather than unit test : extend KernelTestCase and get real services from the container. 2023-12-18 15:36:01 +01:00
726f71c8f1 Correct the NewsItemsApiController test : remove commented code and extend properly 2023-12-18 15:34:43 +01:00
f03ae2cabc Fix doctrine annotation for dashboard config item 2023-12-18 14:29:17 +01:00
3a080ebebe Silence deprecation warning in tests, log them 2023-12-18 14:28:57 +01:00
2402050f5f work on tests 2023-12-11 17:32:21 +01:00
a97a22d464 php cs fixer 2023-12-04 16:24:05 +01:00
de9251942c add missing translations 2023-12-04 16:19:37 +01:00
807ffb845a correct date format in admin 2023-12-04 16:15:05 +01:00
e0fc87ef58 add view admin page + some styling 2023-12-04 16:10:24 +01:00
e876b75d41 Add delete functionality in admin for news item 2023-12-04 15:13:12 +01:00
229cef8942 Adjust translation of endDate 2023-12-04 14:13:44 +01:00
6676e06fb5 remove extend of abstractController 2023-12-04 14:13:27 +01:00
6e48f8f7ea do not use an integer in a condition 2023-11-29 21:03:03 +01:00
e2efb267f5 Improve layout of the history page 2023-11-29 20:52:12 +01:00
684f1a3015 Fix ci issues 2023-11-29 16:15:40 +01:00
2af9ff7d00 reload masonry when dom is updated 2023-11-29 16:01:44 +01:00
ae2265df21 fix errors with data structure 2023-11-29 16:01:44 +01:00
6da297d1d2 rename method newsWithDateFilter => currentNews 2023-11-29 16:01:44 +01:00
6787612071 Take into account case when there are no active news 2023-11-29 16:01:43 +01:00
53d18c7748 Give a more obvious url for new/current api endpoint 2023-11-29 16:01:43 +01:00
8bbe094e70 fix error for props on AddressDetailsButton.vue 2023-11-29 16:01:43 +01:00
df16ca9a60 force bootstrap to be restricted to version 5.2 2023-11-29 16:01:42 +01:00
f1df2d5165 Update wrong translation key 2023-11-29 11:12:21 +01:00
4a58d7f300 attempt to write tests 2023-11-21 20:34:28 +01:00
d6b1216021 rename methods for more clarity 2023-11-21 15:10:14 +01:00
dadde29bc2 Fix navigation back to MyCustoms tab 2023-11-21 11:53:16 +01:00
693bf65721 Add single detail page to view entire article from within news item history page 2023-11-21 10:19:00 +01:00
8f3256e46e rename news item history controller 2023-11-21 09:39:59 +01:00
f7de5fe1ed remove custom serializer and adjust annotations of news item entity 2023-11-21 09:28:19 +01:00
6dd463a7b0 let news tile stretch over entire dashboard 2023-11-21 09:20:45 +01:00
ed271bed31 fix truncating of text to avoid cutting into link 2023-11-21 09:20:21 +01:00
502894ecea add limit and offset for apicontroller 2023-11-21 09:19:57 +01:00
c185c35c44 Add history page for all news items with a search filter on the basis of the title or content 2023-11-20 17:13:04 +01:00
e8b8f30e3c add validation on start- and enddate of news item 2023-11-20 15:52:30 +01:00
caa2bc1f3c Add admin translations and order items differently in admin 2023-11-20 15:52:08 +01:00
50a6cb5af6 fix: do no display expired news items 2023-11-20 15:51:07 +01:00
13c33567fd separate vue logic into different components 2023-11-20 15:50:02 +01:00
af3d06e7d3 Format date in dd-mm-yyyy 2023-11-20 12:00:27 +01:00
b74ab2fa0e Fix date typing issue 2023-11-20 11:52:56 +01:00
001fb269b3 Adapt pathes for importing ChillMainAssets in ts 2023-11-20 11:21:58 +01:00
262e76c993 Update bootstrap to version 5.3.0 2023-11-20 11:05:36 +01:00
caf45af4e5 add types dependency 2023-11-20 09:47:34 +01:00
cea801e620 add dependencies 2023-11-20 09:44:54 +01:00
19b53e4a4c php cs fixes 2023-11-13 14:06:47 +01:00
09f823ac08 process review 2023-11-13 14:06:05 +01:00
5be516b14e Merge branch 'issue159_page_acceuil' of gitlab.com:Chill-Projet/chill-bundles into issue159_page_acceuil 2023-11-09 20:47:19 +01:00
eb8dc441b9 add typing 2023-11-09 20:46:43 +01:00
32a103d86a php cs fixes 2023-11-09 20:46:43 +01:00
6d608ab35a fix phpstan issues 2023-11-09 20:46:43 +01:00
334d357189 uncomment sanitizing 2023-11-09 20:46:42 +01:00
8363c5c3cf Add use of DOMPurify to sanitize text from possible injection 2023-11-09 20:46:42 +01:00
cd793d6842 Convert markdown into html + minor style adjustments 2023-11-09 20:46:42 +01:00
3ae8e0c406 update controller not to extend apiController and make some changes in repository + serializer 2023-11-09 20:46:42 +01:00
6c93c8b8fa make frontend news widget 2023-11-09 20:46:42 +01:00
efdc84930b change logic of dashboard item to return user config, reinstate news items api 2023-11-09 20:46:42 +01:00
6cd6cb1000 fix admin form for news item 2023-11-09 20:46:42 +01:00
f4c08ee0d7 create dashboard item entity 2023-11-09 20:46:42 +01:00
b5f7f578da some setup in frontend 2023-11-09 20:46:42 +01:00
b172ebdf76 api point for dashboard items 2023-11-09 20:46:42 +01:00
312a43c093 news item normalizer - to be generalized to dashboard item? 2023-11-09 20:46:42 +01:00
e17b4da2a4 some adjustments to news item 2023-11-09 20:46:42 +01:00
003ca30c74 adjust news item entity and recreate migration 2023-11-09 20:46:42 +01:00
88447bbbf8 restructure json data 2023-11-09 20:46:42 +01:00
1c49eb492a create API for news item + testing if fetch works : to be generalized to accomodate other types of dashboard items 2023-11-09 20:46:42 +01:00
7bdb5bfce6 create news item entity and the admin for it 2023-11-09 20:46:42 +01:00
87615d179e create news item entity + migration 2023-11-09 20:46:42 +01:00
ed2d41c225 add typing 2023-11-09 20:22:31 +01:00
d828a6b9e0 php cs fixes 2023-11-09 19:25:41 +01:00
d6641f70c9 fix phpstan issues 2023-11-09 19:25:18 +01:00
7b4969e89d uncomment sanitizing 2023-11-09 19:23:04 +01:00
fc22bf1194 Add use of DOMPurify to sanitize text from possible injection 2023-11-09 19:21:52 +01:00
997a6ea419 Convert markdown into html + minor style adjustments 2023-11-09 19:07:35 +01:00
a55cd3b7e9 update controller not to extend apiController and make some changes in repository + serializer 2023-11-09 18:33:07 +01:00
6b966285a6 make frontend news widget 2023-11-09 13:49:32 +01:00
5a400fd162 change logic of dashboard item to return user config, reinstate news items api 2023-11-08 15:40:58 +01:00
01a5c291e0 fix admin form for news item 2023-11-08 14:00:53 +01:00
4646cd1cf0 create dashboard item entity 2023-11-08 12:59:01 +01:00
2997dff237 some setup in frontend 2023-11-08 12:23:10 +01:00
2624e44e2f api point for dashboard items 2023-11-08 12:18:11 +01:00
9ec1376d29 news item normalizer - to be generalized to dashboard item? 2023-11-08 12:16:57 +01:00
9591f1e49c some adjustments to news item 2023-11-08 12:16:33 +01:00
ddb90c2e41 adjust news item entity and recreate migration 2023-11-08 11:40:43 +01:00
e97571059c restructure json data 2023-11-07 15:16:40 +01:00
3a6d5fc22a create API for news item + testing if fetch works : to be generalized to accomodate other types of dashboard items 2023-11-01 16:26:19 +01:00
a542d319f7 create news item entity and the admin for it 2023-11-01 16:25:14 +01:00
4286a51bf4 create news item entity + migration 2023-10-26 11:31:39 +02:00
6893c833e4 WIP: first tests for building dav endpoints 2023-09-12 11:24:50 +02:00
143 changed files with 4125 additions and 1854 deletions

View File

@@ -0,0 +1,6 @@
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"

View File

@@ -0,0 +1,6 @@
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"

View File

@@ -0,0 +1,5 @@
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"

View File

@@ -0,0 +1,5 @@
kind: Feature
body: 'Export: group accompanying period by person participating'
time: 2024-02-07T10:39:51.97331052+01:00
custom:
Issue: "253"

View File

@@ -0,0 +1,5 @@
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"

View File

@@ -0,0 +1,5 @@
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"

View File

@@ -0,0 +1,6 @@
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"

View File

@@ -0,0 +1,6 @@
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"

View File

@@ -1,15 +0,0 @@
## 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.

View File

@@ -1,3 +0,0 @@
## v2.16.1 - 2024-02-09
### Fixed
* Force bootstrap version to avoid error in builds with newer version

View File

@@ -6,26 +6,6 @@ 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

View File

@@ -9,6 +9,7 @@
],
"require": {
"php": "^8.2",
"ext-dom": "*",
"ext-json": "*",
"ext-openssl": "*",
"ext-redis": "*",

View File

@@ -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,6 +34,7 @@
"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",
@@ -42,9 +43,11 @@
"@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",

View File

@@ -0,0 +1,66 @@
<?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));
}
}

View File

@@ -0,0 +1,252 @@
<?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,
];
}
}

View File

@@ -0,0 +1,16 @@
<?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
{
}

View File

@@ -0,0 +1,103 @@
<?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' => [],
];
}
}

View File

@@ -0,0 +1,24 @@
<?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']);
}
}

View File

@@ -17,18 +17,22 @@ 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'
small = datasets.buttonSmall === '1',
davLink = 'davLink' in datasets && datasets.davLink !== '' ? datasets.davLink : null,
davLinkExpiration = 'davLinkExpiration' in datasets ? Number.parseInt(datasets.davLinkExpiration) : null
;
return { storedObject, filename, canEdit, small };
return { storedObject, filename, canEdit, small, davLink, davLinkExpiration };
},
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>',
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>',
methods: {
onStoredObjectStatusChange: function(newStatus: StoredObjectStatusChange): void {
this.$data.storedObject.status = newStatus.status;

View File

@@ -7,6 +7,9 @@
<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>
@@ -36,6 +39,7 @@ import {
StoredObjectStatusChange,
WopiEditButtonExecutableBeforeLeaveFunction
} from "../types";
import DesktopEditButton from "ChillDocStoreAssets/vuejs/StoredObjectButton/DesktopEditButton.vue";
interface DocumentActionButtonsGroupConfig {
storedObject: StoredObject,
@@ -57,6 +61,16 @@ 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<{
@@ -68,7 +82,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
});
/**

View File

@@ -0,0 +1,66 @@
<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>

View File

@@ -3,5 +3,7 @@
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>

View File

@@ -0,0 +1,12 @@
<!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>

View File

@@ -0,0 +1,81 @@
<?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>

View File

@@ -0,0 +1,53 @@
<?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>

View File

@@ -0,0 +1,7 @@
{% 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 %}

View File

@@ -0,0 +1,22 @@
<?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';
}

View File

@@ -0,0 +1,55 @@
<?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
};
}
}

View File

@@ -0,0 +1,58 @@
<?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];
}
}

View File

@@ -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\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']);
}
}

View File

@@ -0,0 +1,48 @@
<?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']);
}
}

View File

@@ -0,0 +1,25 @@
<?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;
}

View File

@@ -0,0 +1,41 @@
<?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;
}
}

View File

@@ -57,6 +57,62 @@ 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);
@@ -146,6 +202,22 @@ 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 {

View File

@@ -17,6 +17,8 @@ interface StoredObjectManagerInterface
{
public function getLastModified(StoredObject $document): \DateTimeInterface;
public function getContentLength(StoredObject $document): int;
/**
* Get the content of a StoredObject.
*
@@ -33,4 +35,6 @@ interface StoredObjectManagerInterface
* @param $clearContent The content to store in clear
*/
public function write(StoredObject $document, string $clearContent): void;
public function etag(StoredObject $document): string;
}

View File

@@ -13,6 +13,9 @@ 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;
@@ -120,8 +123,12 @@ 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)
{
public function __construct(
private DiscoveryInterface $discovery,
private NormalizerInterface $normalizer,
private JWTDavTokenProviderInterface $davTokenProvider,
private UrlGeneratorInterface $urlGenerator,
) {
}
/**
@@ -132,7 +139,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);
}
/**
@@ -144,12 +151,26 @@ 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'),
]);
}

View File

@@ -0,0 +1,410 @@
<?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';
}
}

View File

@@ -0,0 +1,134 @@
<?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'],
],
],
];
}
}

View File

@@ -0,0 +1,123 @@
<?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();
}
}

View File

@@ -0,0 +1,55 @@
<?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];
}
}

View File

@@ -34,6 +34,11 @@ services:
autoconfigure: true
autowire: true
Chill\DocStoreBundle\Security\:
resource: './../Security'
autoconfigure: true
autowire: true
Chill\DocStoreBundle\Serializer\Normalizer\:
autowire: true
resource: '../Serializer/Normalizer/'

View File

@@ -17,11 +17,10 @@ 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\AuthorizationHelperInterface;
use Chill\MainBundle\Security\Authorization\AuthorizationHelper;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Form\Type\PickPersonDynamicType;
use Chill\PersonBundle\Form\Type\PickPersonType;
use Chill\PersonBundle\Privacy\PrivacyEvent;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Csv;
@@ -38,26 +37,53 @@ 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.
*/
final class EventController extends AbstractController
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(
private readonly EventDispatcherInterface $eventDispatcher,
private readonly AuthorizationHelperInterface $authorizationHelper,
private readonly FormFactoryInterface $formFactoryInterface,
private readonly TranslatorInterface $translator,
private readonly PaginatorFactory $paginator,
private readonly Security $security,
EventDispatcherInterface $eventDispatcher,
AuthorizationHelper $authorizationHelper,
FormFactoryInterface $formFactoryInterface,
TranslatorInterface $translator,
PaginatorFactory $paginator
) {
$this->eventDispatcher = $eventDispatcher;
$this->authorizationHelper = $authorizationHelper;
$this->formFactoryInterface = $formFactoryInterface;
$this->translator = $translator;
$this->paginator = $paginator;
}
/**
@@ -155,7 +181,7 @@ final class EventController extends AbstractController
$this->denyAccessUnlessGranted('CHILL_PERSON_SEE', $person);
$reachablesCircles = $this->authorizationHelper->getReachableScopes(
$reachablesCircles = $this->authorizationHelper->getReachableCircles(
$this->getUser(),
EventVoter::SEE,
$person->getCenter()
@@ -207,12 +233,6 @@ final 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);
@@ -220,7 +240,6 @@ final class EventController extends AbstractController
$entity = new Event();
$entity->setCenter($center);
$entity->setLocation($user->getCurrentLocation());
$form = $this->createCreateForm($entity);
$form->handleRequest($request);
@@ -263,7 +282,7 @@ final class EventController extends AbstractController
}
$form = $this->formFactoryInterface
->createNamedBuilder('', FormType::class, null, [
->createNamedBuilder(null, FormType::class, null, [
'csrf_protection' => false,
])
->setMethod('GET')
@@ -304,7 +323,7 @@ final class EventController extends AbstractController
}
$this->denyAccessUnlessGranted(
EventVoter::SEE_DETAILS,
'CHILL_EVENT_SEE_DETAILS',
$event,
'You are not allowed to see details on this event'
);
@@ -348,7 +367,7 @@ final class EventController extends AbstractController
$this->addFlash('success', $this->translator
->trans('The event was updated'));
return $this->redirectToRoute('chill_event__event_show', ['event_id' => $event_id]);
return $this->redirectToRoute('chill_event__event_edit', ['event_id' => $event_id]);
}
return $this->render('@ChillEvent/Event/edit.html.twig', [
@@ -366,7 +385,7 @@ final class EventController extends AbstractController
{
/** @var \Symfony\Component\Form\FormBuilderInterface $builder */
$builder = $this
->formFactoryInterface
->get('form.factory')
->createNamedBuilder(
null,
FormType::class,
@@ -411,9 +430,11 @@ final class EventController extends AbstractController
*/
protected function createAddParticipationByPersonForm(Event $event)
{
$builder = $this->formFactoryInterface
/** @var \Symfony\Component\Form\FormBuilderInterface $builder */
$builder = $this
->get('form.factory')
->createNamedBuilder(
'',
null,
FormType::class,
null,
[
@@ -423,17 +444,23 @@ final class EventController extends AbstractController
]
);
$builder->add('person_id', PickPersonDynamicType::class, [
'as_id' => true,
'multiple' => false,
'submit_on_adding_new_entity' => true,
'label' => 'Add a participation',
$builder->add('person_id', PickPersonType::class, [
'role' => 'CHILL_EVENT_CREATE',
'centers' => $event->getCenter(),
]);
$builder->add('event_id', HiddenType::class, [
'data' => $event->getId(),
]);
$builder->add(
'submit',
SubmitType::class,
[
'label' => 'Add a participation',
]
);
return $builder->getForm();
}
@@ -442,7 +469,7 @@ final class EventController extends AbstractController
*/
protected function createExportByFormatForm()
{
$builder = $this->createFormBuilder(['format' => 'xlsx'])
$builder = $this->createFormBuilder()
->add('format', ChoiceType::class, [
'choices' => [
'xlsx' => 'xlsx',

View File

@@ -1,118 +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\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();
}
}

View File

@@ -14,10 +14,7 @@ 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;
@@ -31,17 +28,13 @@ use Symfony\Contracts\Translation\TranslatorInterface;
/**
* Class ParticipationController.
*/
final class ParticipationController extends AbstractController
class ParticipationController extends AbstractController
{
/**
* ParticipationController constructor.
*/
public function __construct(
private readonly LoggerInterface $logger,
private readonly TranslatorInterface $translator,
private readonly EventRepository $eventRepository,
private readonly PersonRepository $personRepository,
) {
public function __construct(private readonly LoggerInterface $logger, private readonly TranslatorInterface $translator)
{
}
/**
@@ -237,7 +230,6 @@ final class ParticipationController extends AbstractController
return $this->render('@ChillEvent/Participation/new.html.twig', [
'form' => $form->createView(),
'participation' => $participation,
'ignored_participations' => [],
]);
}
@@ -547,7 +539,7 @@ final class ParticipationController extends AbstractController
* If the request is multiple, the $participation object is cloned.
* Limitations: the $participation should not be persisted.
*
* @return Participation|list<Participation> return one single participation if $multiple == false
* @return Participation|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
@@ -564,25 +556,30 @@ final 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`
$event = $this->eventRepository->find($event_id);
if (null !== $event_id) {
$event = $em->getRepository(Event::class)
->find($event_id);
if (null === $event) {
throw $this->createNotFoundException('The event with id '.$event_id.' is not found');
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->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->get('person_id', 0)]
[$request->query->getInt('person_id', 0)] // sf4 check:
// prevent error: `Argument 2 passed to ::getInt() must be of the type int, null given`
: explode(',', (string) $request->query->get('persons_ids'));
$participations = [];
@@ -591,14 +588,15 @@ final class ParticipationController extends AbstractController
$participation = \count($persons_ids) > 1 ? clone $participation : $participation;
if (null !== $person_id) {
$person = $this->personRepository->find($person_id);
$person = $em->getRepository(\Chill\PersonBundle\Entity\Person::class)
->find($person_id);
if (null === $person) {
throw $this->createNotFoundException('The person with id '.$person_id.' is not found');
}
$this->denyAccessUnlessGranted(
PersonVoter::SEE,
'CHILL_PERSON_SEE',
$person,
'The user is not allowed to see the person'
);

View File

@@ -12,7 +12,6 @@ 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;
@@ -34,8 +33,10 @@ 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');
@@ -60,8 +61,6 @@ 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],
],
]);
}

View File

@@ -11,23 +11,15 @@ 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.
@@ -38,15 +30,10 @@ use Symfony\Component\Validator\Constraints as Assert;
*
* @ORM\HasLifecycleCallbacks
*/
class Event implements HasCenterInterface, HasScopeInterface, TrackCreationInterface, TrackUpdateInterface
class Event implements HasCenterInterface, HasScopeInterface
{
use TrackCreationTrait;
use TrackUpdateTrait;
/**
* @ORM\ManyToOne(targetEntity="Chill\MainBundle\Entity\Center")A
*
* @Assert\NotNull()
* @ORM\ManyToOne(targetEntity="Chill\MainBundle\Entity\Center")
*/
private ?Center $center = null;
@@ -76,8 +63,6 @@ class Event implements HasCenterInterface, HasScopeInterface, TrackCreationInter
/**
* @ORM\Column(type="string", length=150)
*
* @Assert\NotBlank()
*/
private ?string $name = null;
@@ -92,45 +77,15 @@ class Event implements HasCenterInterface, HasScopeInterface, TrackCreationInter
/**
* @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();
}
/**
@@ -145,22 +100,6 @@ class Event implements HasCenterInterface, HasScopeInterface, TrackCreationInter
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
*/
@@ -320,44 +259,4 @@ class Event implements HasCenterInterface, HasScopeInterface, TrackCreationInter
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;
}
}

View File

@@ -11,17 +11,13 @@ declare(strict_types=1);
namespace Chill\EventBundle\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 Chill\MainBundle\Entity\Center;
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;
/**
@@ -30,20 +26,12 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
* @ORM\Entity(
* repositoryClass="Chill\EventBundle\Repository\ParticipationRepository")
*
* @ORM\Table(name="chill_event_participation", uniqueConstraints={
*
* @ORM\UniqueConstraint(name="chill_event_participation_event_person_unique_idx", columns={"event_id", "person_id"})
* })
* @ORM\Table(name="chill_event_participation")
*
* @ORM\HasLifecycleCallbacks
*
* @UniqueEntity({"event", "person"}, message="event.validation.person_already_participate_to_event")
*/
class Participation implements \ArrayAccess, HasCenterInterface, HasScopeInterface, TrackUpdateInterface, TrackCreationInterface
class Participation implements \ArrayAccess, HasCenterInterface, HasScopeInterface
{
use TrackCreationTrait;
use TrackUpdateTrait;
/**
* @ORM\ManyToOne(
* targetEntity="Chill\EventBundle\Entity\Event",
@@ -60,10 +48,13 @@ 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;
@@ -74,11 +65,12 @@ 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()) {
@@ -98,8 +90,10 @@ class Participation implements \ArrayAccess, HasCenterInterface, HasScopeInterfa
/**
* Get id.
*
* @return int
*/
public function getId(): int
public function getId()
{
return $this->id;
}
@@ -107,11 +101,11 @@ class Participation implements \ArrayAccess, HasCenterInterface, HasScopeInterfa
/**
* Get lastUpdate.
*
* @return \DateTimeInterface|null
* @return \DateTime
*/
public function getLastUpdate()
{
return $this->getUpdatedAt();
return $this->lastUpdate;
}
/**
@@ -241,6 +235,10 @@ class Participation implements \ArrayAccess, HasCenterInterface, HasScopeInterfa
*/
public function setEvent(?Event $event = null)
{
if ($this->event !== $event) {
$this->update();
}
$this->event = $event;
return $this;
@@ -253,6 +251,10 @@ class Participation implements \ArrayAccess, HasCenterInterface, HasScopeInterfa
*/
public function setPerson(?Person $person = null)
{
if ($person !== $this->person) {
$this->update();
}
$this->person = $person;
return $this;
@@ -265,6 +267,9 @@ class Participation implements \ArrayAccess, HasCenterInterface, HasScopeInterfa
*/
public function setRole(?Role $role = null)
{
if ($role !== $this->role) {
$this->update();
}
$this->role = $role;
return $this;
@@ -277,6 +282,10 @@ class Participation implements \ArrayAccess, HasCenterInterface, HasScopeInterfa
*/
public function setStatus(?Status $status = null)
{
if ($this->status !== $status) {
$this->update();
}
$this->status = $status;
return $this;
@@ -286,11 +295,11 @@ class Participation implements \ArrayAccess, HasCenterInterface, HasScopeInterfa
* Set lastUpdate.
*
* @return Participation
*
* @deprecated
*/
protected function update()
{
$this->lastUpdate = new \DateTime('now');
return $this;
}
}

View File

@@ -11,18 +11,12 @@ 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;
@@ -53,28 +47,6 @@ 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',
]);
}

View File

@@ -114,7 +114,7 @@ final class PickEventType extends AbstractType
} else {
$centers = $this->authorizationHelper->getReachableCenters(
$user,
$options['role']
(string) $options['role']->getRole()
);
}

View File

@@ -1,46 +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\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'];
}
}

View File

@@ -1,142 +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\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;
}
}

View File

@@ -1,30 +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\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;
}

View File

@@ -1,44 +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\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();
}
}

View File

@@ -14,16 +14,15 @@
{{ form_row(edit_form.type, { 'label': 'Event type' }) }}
{{ form_row(edit_form.moderator) }}
{{ form_row(edit_form.location) }}
{{ form_row(edit_form.organizationCost) }}
{{ form_row(edit_form.comment) }}
{{ form_row(edit_form.documents) }}
<ul class="record_actions sticky-form-buttons">
<ul class="record_actions">
<li class="cancel">
<a href="{{ chill_return_path_or('chill_event_event_list') }}" class="btn btn-cancel">
{{ 'List of events'|trans|chill_return_path_label }}
{% 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>
</li>
<li>

View File

@@ -24,89 +24,85 @@
{% block content %}
<h2>{{ 'Events participation' |trans }}</h2>
{% 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">
<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>
<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) %}
<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="btn btn-warning btn-sm">
class="dropdown-item">
{{ 'Edit the event'|trans }}
</a>
{% endif %}
{% if is_granted('CHILL_EVENT_PARTICIPATION_UPDATE', participation) %}
</li>
<li>
<a href="{{ path('chill_event_participation_edit', { 'participation_id' : participation.id, 'return_path' : currentPath, 'return_label' : returnLabel }) }}"
class="btn btn-warning btn-sm">
class="dropdown-item">
{{ 'Edit the participation'|trans }}
</a>
{% endif %}
</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 %}
</div>
</td>
</tr>
{% endfor %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</tbody>
</table>
{% if participations|length < paginator.getTotalItems %}
{{ chill_pagination(paginator) }}

View File

@@ -1,13 +1,5 @@
{% 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 -%}
@@ -22,13 +14,8 @@
{{ form_row(form.type, { 'label': 'Event type' }) }}
{{ form_row(form.moderator) }}
{{ form_row(form.location) }}
{{ form_row(form.organizationCost) }}
{{ form_row(form.comment) }}
{{ form_row(form.documents) }}
<ul class="record_actions sticky-form-buttons">
<ul class="record_actions">
<li class="cancel">
<a href="{{ path('chill_event_list_most_recent') }}" class="btn btn-cancel">
{{ 'Back to the most recent events'|trans }}
@@ -38,7 +25,7 @@
{{ form_widget(form.submit, { 'attr' : { 'class' : 'btn btn-create' } }) }}
</li>
</ul>
{{ form_end(form) }}
</div>
{% endblock %}

View File

@@ -1,92 +0,0 @@
{% 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 }}&nbsp;: </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 %}

View File

@@ -4,28 +4,12 @@
{% 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>
@@ -38,62 +22,42 @@
<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') %}
<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>
{% 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>
<a href="{{ path('chill_event__event_delete', {'event_id' : event.id } ) }}"
class="btn btn-delete">{{ 'Delete event'|trans }}</a>
@@ -119,15 +83,7 @@
<tbody>
{% for participation in event.participations %}
<tr>
<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>{{ person_macro.render(participation.person) }}</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,
@@ -138,7 +94,7 @@
<ul class="record_actions">
{% if is_granted('CHILL_EVENT_PARTICIPATION_UPDATE', participation) %}
<li>
<a href="{{ chill_path_add_return_path('chill_event_participation_edit', { 'participation_id' : participation.id }, false, 'See'|trans ) }}"
<a href="{{ path('chill_event_participation_edit', { 'participation_id' : participation.id } ) }}"
class="btn btn-edit" title="{{ 'Edit'|trans }}"></a>
</li>
<li>
@@ -170,8 +126,11 @@
'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>
<input type="hidden" name="returnPath" value="{{ app.request.requestUri }}" />
{{ form_rest(form_add_participation_by_person) }}
{{ form_end(form_add_participation_by_person) }}
</div>

View File

@@ -32,7 +32,7 @@
<ul class="record_actions sticky-form-buttons">
<ul class="record_actions">
<li class="cancel">
<a href="{{ path('chill_event__event_show', { 'event_id' : participation.event.id } ) }}" class="btn btn-cancel">
{{ 'Back to the event'|trans }}

View File

@@ -1,44 +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\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();
}
}

View File

@@ -11,11 +11,6 @@ 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;
@@ -28,12 +23,15 @@ use function count;
*/
final class ParticipationControllerTest extends WebTestCase
{
use PersonRandomHelper;
use PrepareClientTrait;
/**
* @var \Symfony\Component\BrowserKit\AbstractBrowser
*/
protected $client;
private EntityManagerInterface $em;
private EventRepository $eventRepository;
/**
* @var \Doctrine\ORM\EntityManagerInterface
*/
protected $em;
/**
* Keep a cache for each person id given by the function getRandomPerson.
@@ -46,21 +44,23 @@ final class ParticipationControllerTest extends WebTestCase
*/
private array $personsIdsCache = [];
protected function prepareDI(): void
protected function setUp(): void
{
$this->em = self::$container->get(EntityManagerInterface::class);
$this->eventRepository = self::$container->get(EventRepository::class);
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->personsIdsCache = [];
}
protected function tearDown(): void
{
parent::tearDown();
self::ensureKernelShutdown();
}
/**
* This method test participation creation with wrong parameters.
*
@@ -68,13 +68,11 @@ final class ParticipationControllerTest extends WebTestCase
*/
public function testCreateActionWrongParameters()
{
$client = $this->getClientAuthenticated();
$this->prepareDI();
$event = $this->getRandomEvent();
$person = $this->getRandomPerson($this->em);
$person = $this->getRandomPerson();
// missing person_id or persons_ids
$client->request(
$this->client->request(
'GET',
'/fr/event/participation/create',
[
@@ -83,33 +81,33 @@ final class ParticipationControllerTest extends WebTestCase
);
$this->assertEquals(
400,
$client->getResponse()->getStatusCode(),
$this->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
$client->request(
$this->client->request(
'GET',
'/fr/event/participation/create',
[
'event_id' => $event->getId(),
'persons_ids' => implode(',', [
$this->getRandomPerson($this->em)->getId(),
$this->getRandomPerson($this->em)->getId(),
$this->getRandomPerson()->getId(),
$this->getRandomPerson()->getId(),
]),
'person_id' => $person->getId(),
]
);
$this->assertEquals(
400,
$client->getResponse()->getStatusCode(),
$this->client->getResponse()->getStatusCode(),
'test that /fr/event/participation/create fail if both person_id and '
.'persons_ids are set'
);
// missing event_id
$client->request(
$this->client->request(
'GET',
'/fr/event/participation/create',
[
@@ -118,12 +116,12 @@ final class ParticipationControllerTest extends WebTestCase
);
$this->assertEquals(
400,
$client->getResponse()->getStatusCode(),
$this->client->getResponse()->getStatusCode(),
'Test that /fr/event/participation/create fails if event_id is missing'
);
// persons_ids with wrong content
$client->request(
$this->client->request(
'GET',
'/fr/event/participation/create',
[
@@ -133,47 +131,42 @@ final class ParticipationControllerTest extends WebTestCase
);
$this->assertEquals(
400,
$client->getResponse()->getStatusCode(),
$this->client->getResponse()->getStatusCode(),
'Test that /fr/event/participation/create fails if persons_ids has wrong content'
);
}
public function testEditMultipleAction()
{
$client = $this->getClientAuthenticated();
$this->prepareDI();
/** @var Event $event */
/** @var \Chill\EventBundle\Entity\Event $event */
$event = $this->getRandomEventWithMultipleParticipations();
$crawler = $client->request('GET', '/fr/event/participation/'.$event->getId().
$crawler = $this->client->request('GET', '/fr/event/participation/'.$event->getId().
'/edit_multiple');
$this->assertEquals(200, $client->getResponse()->getStatusCode());
$this->assertEquals(200, $this->client->getResponse()->getStatusCode());
$button = $crawler->selectButton('Mettre à jour');
$this->assertEquals(1, $button->count(), "test the form with button 'mettre à jour' exists ");
$client->submit($button->form(), [
$this->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($client->getResponse()
$this->assertTrue($this->client->getResponse()
->isRedirect('/fr/event/event/'.$event->getId().'/show'));
}
public function testNewActionWrongParameters()
{
$client = $this->getClientAuthenticated();
$this->prepareDI();
$event = $this->getRandomEvent();
$person = $this->getRandomPerson($this->em);
$person = $this->getRandomPerson();
// missing person_id or persons_ids
$client->request(
$this->client->request(
'GET',
'/fr/event/participation/new',
[
@@ -182,33 +175,33 @@ final class ParticipationControllerTest extends WebTestCase
);
$this->assertEquals(
400,
$client->getResponse()->getStatusCode(),
$this->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
$client->request(
$this->client->request(
'GET',
'/fr/event/participation/new',
[
'event_id' => $event->getId(),
'persons_ids' => implode(',', [
$this->getRandomPerson($this->em)->getId(),
$this->getRandomPerson($this->em)->getId(),
$this->getRandomPerson()->getId(),
$this->getRandomPerson()->getId(),
]),
'person_id' => $person->getId(),
]
);
$this->assertEquals(
400,
$client->getResponse()->getStatusCode(),
$this->client->getResponse()->getStatusCode(),
'test that /fr/event/participation/new fail if both person_id and '
.'persons_ids are set'
);
// missing event_id
$client->request(
$this->client->request(
'GET',
'/fr/event/participation/new',
[
@@ -217,12 +210,12 @@ final class ParticipationControllerTest extends WebTestCase
);
$this->assertEquals(
400,
$client->getResponse()->getStatusCode(),
$this->client->getResponse()->getStatusCode(),
'Test that /fr/event/participation/new fails if event_id is missing'
);
// persons_ids with wrong content
$client->request(
$this->client->request(
'GET',
'/fr/event/participation/new',
[
@@ -232,15 +225,13 @@ final class ParticipationControllerTest extends WebTestCase
);
$this->assertEquals(
400,
$client->getResponse()->getStatusCode(),
$this->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();
@@ -253,10 +244,10 @@ final class ParticipationControllerTest extends WebTestCase
->toArray()
);
// get some random people
$person1 = $this->getRandomPerson($this->em);
$person2 = $this->getRandomPerson($this->em);
$person1 = $this->getRandomPerson();
$person2 = $this->getRandomPerson();
$crawler = $client->request(
$crawler = $this->client->request(
'GET',
'/fr/event/participation/new',
[
@@ -267,7 +258,7 @@ final class ParticipationControllerTest extends WebTestCase
$this->assertEquals(
200,
$client->getResponse()->getStatusCode(),
$this->client->getResponse()->getStatusCode(),
'test that /fr/event/participation/new is successful'
);
@@ -275,7 +266,7 @@ final class ParticipationControllerTest extends WebTestCase
$this->assertNotNull($button, "test the form with button 'Créer' exists");
$client->submit($button->form(), [
$this->client->submit($button->form(), [
'form' => [
'participations' => [
0 => [
@@ -290,8 +281,8 @@ final class ParticipationControllerTest extends WebTestCase
],
]);
$this->assertTrue($client->getResponse()->isRedirect());
$crawler = $client->followRedirect();
$this->assertTrue($this->client->getResponse()->isRedirect());
$crawler = $this->client->followRedirect();
$span1 = $crawler->filter('table td span.entity-person a:contains("'
.$person1->getFirstName().'"):contains("'.$person1->getLastname().'")');
@@ -301,7 +292,7 @@ final class ParticipationControllerTest extends WebTestCase
$this->assertGreaterThan(0, \count($span2));
// as the container has reloaded, reload the event
$event = $this->em->getRepository(Event::class)->find($event->getId());
$event = $this->em->getRepository(\Chill\EventBundle\Entity\Event::class)->find($event->getId());
$this->em->refresh($event);
$this->assertEquals($nbParticipations + 2, $event->getParticipations()->count());
@@ -309,15 +300,13 @@ 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 = $client->request(
$crawler = $this->client->request(
'GET',
'/fr/event/participation/new',
[
@@ -328,15 +317,13 @@ final class ParticipationControllerTest extends WebTestCase
$this->assertEquals(
302,
$client->getResponse()->getStatusCode(),
$this->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();
@@ -348,12 +335,12 @@ final class ParticipationControllerTest extends WebTestCase
$this->personsIdsCache = array_merge($this->personsIdsCache, $persons_id);
// get a random person
$newPerson = $this->getRandomPerson($this->em);
$newPerson = $this->getRandomPerson();
// build the `persons_ids` parameter
$persons_ids_string = implode(',', [...$persons_id, $newPerson->getId()]);
$crawler = $client->request(
$crawler = $this->client->request(
'GET',
'/fr/event/participation/new',
[
@@ -364,7 +351,7 @@ final class ParticipationControllerTest extends WebTestCase
$this->assertEquals(
200,
$client->getResponse()->getStatusCode(),
$this->client->getResponse()->getStatusCode(),
'test that /fr/event/participation/new is successful'
);
@@ -390,15 +377,15 @@ final class ParticipationControllerTest extends WebTestCase
$this->assertNotNull($button, "test the form with button 'Créer' exists");
// submit the form
$client->submit($button->form(), [
$this->client->submit($button->form(), [
'participation[role]' => $event->getType()->getRoles()->first()->getId(),
'participation[status]' => $event->getType()->getStatuses()->first()->getId(),
]);
$this->assertTrue($client->getResponse()->isRedirect());
$this->assertTrue($this->client->getResponse()->isRedirect());
// reload the event and test there is a new participation
$event = $this->em->getRepository(Event::class)
$event = $this->em->getRepository(\Chill\EventBundle\Entity\Event::class)
->find($event->getId());
$this->em->refresh($event);
@@ -411,14 +398,12 @@ 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($this->em);
$person = $this->getRandomPerson();
$crawler = $client->request(
$crawler = $this->client->request(
'GET',
'/fr/event/participation/new',
[
@@ -429,7 +414,7 @@ final class ParticipationControllerTest extends WebTestCase
$this->assertEquals(
200,
$client->getResponse()->getStatusCode(),
$this->client->getResponse()->getStatusCode(),
'test that /fr/event/participation/new is successful'
);
@@ -437,13 +422,13 @@ final class ParticipationControllerTest extends WebTestCase
$this->assertNotNull($button, "test the form with button 'Créer' exists");
$client->submit($button->form(), [
$this->client->submit($button->form(), [
'participation[role]' => $event->getType()->getRoles()->first()->getId(),
'participation[status]' => $event->getType()->getStatuses()->first()->getId(),
]);
$this->assertTrue($client->getResponse()->isRedirect());
$crawler = $client->followRedirect();
$this->assertTrue($this->client->getResponse()->isRedirect());
$crawler = $this->client->followRedirect();
$span = $crawler->filter('table td span.entity-person a:contains("'
.$person->getFirstName().'"):contains("'.$person->getLastname().'")');
@@ -451,23 +436,29 @@ final class ParticipationControllerTest extends WebTestCase
$this->assertGreaterThan(0, \count($span));
// as the container has reloaded, reload the event
$event = $this->em->getRepository(Event::class)->find($event->getId());
$event = $this->em->getRepository(\Chill\EventBundle\Entity\Event::class)->find($event->getId());
$this->em->refresh($event);
$this->assertEquals($nbParticipations + 1, $event->getParticipations()->count());
}
private function getRandomEvent(string $centerName = 'Center A', string $circleName = 'social'): Event
/**
* @return \Chill\EventBundle\Entity\Event
*/
protected function getRandomEvent(mixed $centerName = 'Center A', mixed $circleName = 'social')
{
$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';
$center = $this->em->getRepository(\Chill\MainBundle\Entity\Center::class)
->findByName($centerName);
$ids = $this->em->createQuery(
'SELECT DISTINCT e.id '.$dql
)
->setParameters(['cname' => $centerName, 'sname' => $circleName])
->getResult();
$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];
return $this->eventRepository->find($ids[array_rand($ids)]['id']);
$events = $this->em->getRepository(\Chill\EventBundle\Entity\Event::class)
->findBy(['center' => $center, 'circle' => $circle]);
return $events[array_rand($events)];
}
/**
@@ -476,7 +467,7 @@ final class ParticipationControllerTest extends WebTestCase
* @param string $centerName
* @param type $circleName
*
* @return Event
* @return \Chill\EventBundle\Entity\Event
*/
protected function getRandomEventWithMultipleParticipations(
$centerName = 'Center A',
@@ -488,4 +479,35 @@ 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;
}
}

View File

@@ -1,97 +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\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()
);
}
}

View File

@@ -0,0 +1,16 @@
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']

View File

@@ -0,0 +1,7 @@
services:
Chill\EventBundle\Menu\PersonMenuBuilder:
arguments:
$authorizationChecker: '@Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface'
$translator: '@Symfony\Contracts\Translation\TranslatorInterface'
tags:
- { name: 'chill.menu_builder' }

View File

@@ -0,0 +1,26 @@
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: ~

View File

@@ -19,13 +19,11 @@ 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');
@@ -52,6 +50,9 @@ 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');
@@ -122,26 +123,11 @@ class Version20160318111334 extends AbstractMigration
.'FOREIGN KEY (event_id) '
.'REFERENCES chill_event_event (id) '
.'NOT DEFERRABLE INITIALLY IMMEDIATE');
// 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 '
$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) '

View File

@@ -19,19 +19,18 @@ 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');
}

View File

@@ -19,13 +19,11 @@ 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');
@@ -33,6 +31,9 @@ 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)');

View File

@@ -19,19 +19,20 @@ 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');
}

View File

@@ -1,59 +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\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');
}
}

View File

@@ -1,40 +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\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');
}
}

View File

@@ -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\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');
}
}

View File

@@ -11,11 +11,3 @@ 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}
}

View File

@@ -26,8 +26,6 @@ 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
@@ -52,7 +50,6 @@ 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
@@ -110,17 +107,3 @@ 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

View File

@@ -1,3 +0,0 @@
event:
validation:
person_already_participate_to_event: L'usager est déjà inscrit à l'événement

View File

@@ -15,14 +15,10 @@ 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;
@@ -177,21 +173,6 @@ 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;
}

View File

@@ -135,7 +135,7 @@ class ApiController extends AbstractCRUDController
try {
$entity = $this->deserialize($action, $request, $_format, $entity);
} catch (NotEncodableValueException $e) {
throw new BadRequestHttpException('invalid json', $e, 400);
throw new BadRequestHttpException('invalid json', 400, $e);
}
$errors = $this->validate($action, $request, $_format, $entity);
@@ -153,7 +153,7 @@ class ApiController extends AbstractCRUDController
return $response;
}
$this->getDoctrine()->getManagerForClass($this->getEntityClass())->flush();
$this->getDoctrine()->getManager()->flush();
$response = $this->onAfterFlush($action, $request, $_format, $entity, $errors);

View File

@@ -47,4 +47,12 @@ 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');
}
}

View File

@@ -0,0 +1,41 @@
<?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, []);
}
}

View File

@@ -0,0 +1,53 @@
<?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);
}
}

View File

@@ -0,0 +1,27 @@
<?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);
}
}

View File

@@ -0,0 +1,73 @@
<?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();
}
}

View File

@@ -19,6 +19,7 @@ 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;
@@ -53,6 +54,7 @@ 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;
@@ -62,6 +64,7 @@ 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;
@@ -544,6 +547,35 @@ 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' => [
[

View File

@@ -0,0 +1,112 @@
<?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;
}
}

View File

@@ -0,0 +1,128 @@
<?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;
}
}

View File

@@ -0,0 +1,54 @@
<?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);
}
}

View File

@@ -13,7 +13,6 @@ 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;
@@ -28,13 +27,7 @@ class ComposedGroupCenterType extends AbstractType
'choice_label' => static fn (PermissionsGroup $group) => $group->getName(),
])->add('center', EntityType::class, [
'class' => Center::class,
'query_builder' => static function (EntityRepository $er) {
$qb = $er->createQueryBuilder('c');
$qb->where($qb->expr()->eq('c.isActive', 'TRUE'))
->orderBy('c.name', 'ASC');
return $qb;
},
'choice_label' => static fn (Center $center) => $center->getName(),
]);
}

View File

@@ -42,8 +42,6 @@ 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']);
@@ -56,12 +54,7 @@ class PickUserDynamicType extends AbstractType
->setDefault('multiple', false)
->setAllowedTypes('multiple', ['bool'])
->setDefault('compound', false)
->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']);
->setDefault('suggested', []);
}
public function getBlockPrefix()

View File

@@ -17,7 +17,7 @@ use Symfony\Component\Routing\RouterInterface;
/**
* Create paginator instances.
*/
final readonly class PaginatorFactory implements PaginatorFactoryInterface
class PaginatorFactory
{
final public const DEFAULT_CURRENT_PAGE_KEY = 'page';
@@ -25,20 +25,23 @@ final readonly class PaginatorFactory implements PaginatorFactoryInterface
final public const DEFAULT_PAGE_NUMBER = 1;
/**
* @param int $itemPerPage
*/
public function __construct(
/**
* the request stack.
*/
private RequestStack $requestStack,
private readonly RequestStack $requestStack,
/**
* the router and generator for url.
*/
private RouterInterface $router,
private readonly RouterInterface $router,
/**
* the default item per page. This may be overriden by
* the request or inside the paginator.
*/
private int $itemPerPage = 20
private $itemPerPage = 20
) {
}
@@ -48,14 +51,17 @@ final readonly class PaginatorFactory implements PaginatorFactoryInterface
* 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(
int $totalItems,
$totalItems,
?string $route = null,
?array $routeParameters = null
): PaginatorInterface {
) {
return new Paginator(
$totalItems,
$this->getCurrentItemsPerPage(),
@@ -68,7 +74,7 @@ final readonly class PaginatorFactory implements PaginatorFactoryInterface
);
}
public function getCurrentItemsPerPage(): int
public function getCurrentItemsPerPage()
{
return $this->requestStack
->getCurrentRequest()
@@ -76,13 +82,16 @@ final readonly class PaginatorFactory implements PaginatorFactoryInterface
->getInt(self::DEFAULT_ITEM_PER_NUMBER_KEY, $this->itemPerPage);
}
public function getCurrentPageFirstItemNumber(): int
public function getCurrentPageFirstItemNumber()
{
return ($this->getCurrentPageNumber() - 1) *
$this->getCurrentItemsPerPage();
}
public function getCurrentPageNumber(): int
/**
* @return int
*/
public function getCurrentPageNumber()
{
return $this->requestStack
->getCurrentRequest()
@@ -90,14 +99,14 @@ final readonly class PaginatorFactory implements PaginatorFactoryInterface
->getInt(self::DEFAULT_CURRENT_PAGE_KEY, self::DEFAULT_PAGE_NUMBER);
}
private function getCurrentRoute()
protected function getCurrentRoute()
{
$request = $this->requestStack->getCurrentRequest();
return $request->get('_route');
}
private function getCurrentRouteParameters()
protected function getCurrentRouteParameters()
{
return array_merge(
$this->router->getContext()->getParameters(),

View File

@@ -1,35 +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\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;
}

View File

@@ -0,0 +1,144 @@
<?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;
}
}

View File

@@ -55,20 +55,11 @@ 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: {
@@ -76,7 +67,6 @@ export const makeFetch = <Input, Output>(
},
};
if (body !== null && typeof body !== 'undefined') {
Object.assign(opts, {body: JSON.stringify(body)})
}
@@ -100,10 +90,6 @@ export const makeFetch = <Input, Output>(
throw AccessException(response);
}
if (response.status === 409) {
throw ConflictHttpException(response);
}
throw {
name: 'Exception',
sta: response.status,
@@ -234,12 +220,3 @@ 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;
}

View File

@@ -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, AddressRefStatus} from "../../types";
import {Address} from "../../types";
const i18n = _createI18n({});
document.querySelectorAll<HTMLSpanElement>('span[data-address-details]').forEach((el) => {
const dataset = el.dataset as {
addressId: string,
addressRefStatus: AddressRefStatus,
addressRefStatus: string,
};
const app = createApp({

View File

@@ -0,0 +1 @@
import './index.scss';

View File

@@ -0,0 +1,7 @@
div.flex-table {
.news-content {
p {
margin-top: 1rem;
}
}
}

View File

@@ -24,10 +24,7 @@ function loadDynamicPicker(element) {
(input.value === '[]' || input.value === '') ?
null : [ JSON.parse(input.value) ]
)
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;
suggested = JSON.parse(el.dataset.suggested)
if (!isMultiple) {
if (input.value === '[]'){
@@ -42,7 +39,6 @@ function loadDynamicPicker(element) {
':picked="picked" ' +
':uniqid="uniqid" ' +
':suggested="notPickedSuggested" ' +
':label="label" ' +
'@addNewEntity="addNewEntity" ' +
'@removeEntity="removeEntity"></pick-entity>',
components: {
@@ -54,10 +50,7 @@ function loadDynamicPicker(element) {
types: JSON.parse(el.dataset.types),
picked: picked === null ? [] : picked,
uniqid: el.dataset.uniqid,
suggested,
as_id,
submit_on_adding_new_entity,
label,
suggested: suggested
}
},
computed: {
@@ -76,12 +69,7 @@ function loadDynamicPicker(element) {
return el.type === entity.type && el.id === entity.id;
})) {
this.picked.push(entity);
if (!as_id) {
input.value = JSON.stringify(this.picked);
} else {
const ids = this.picked.map(el => el.id);
input.value = ids.join(',');
}
input.value = JSON.stringify(this.picked);
console.log(entity)
}
} else {
@@ -90,17 +78,9 @@ function loadDynamicPicker(element) {
})) {
this.picked.splice(0, this.picked.length);
this.picked.push(entity);
if (!as_id) {
input.value = JSON.stringify(this.picked[0]);
} else {
input.value = this.picked.map(el => el.id);
}
input.value = JSON.stringify(this.picked[0]);
}
}
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)) {

View File

@@ -160,3 +160,11 @@ export interface LocationType {
contactData: "optional" | "required";
title: TranslatableString;
}
export interface NewsItemType {
id: number;
title: string;
content: string;
startDate: DateTime;
endDate: DateTime | null;
}

View File

@@ -97,6 +97,8 @@ 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",
@@ -112,7 +114,7 @@ export default {
},
data() {
return {
activeTab: 'MyCustoms'
activeTab: 'MyCustoms',
}
},
computed: {
@@ -126,8 +128,11 @@ export default {
},
methods: {
selectTab(tab) {
this.$store.dispatch('getByTab', { tab: tab });
if (tab !== 'MyCustoms') {
this.$store.dispatch('getByTab', { tab: tab });
}
this.activeTab = tab;
console.log(this.activeTab)
}
},
mounted() {

View File

@@ -0,0 +1,45 @@
<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>

View File

@@ -0,0 +1,183 @@
<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