Compare commits

..

34 Commits

Author SHA1 Message Date
88c6e0e0d3 Force adress ref status not to be null 2024-02-11 22:16:07 +01:00
f02c5bca13 release 2.16.1 2024-02-09 00:11:11 +01:00
0d56828ebd force boostrap version to 5.2.3 2024-02-09 00:10:26 +01:00
8b28667fe5 prepare release 2.16.0 2024-02-08 21:21:49 +01:00
72f73ec8e7 prepare release 2.16.0 2024-02-08 21:21:34 +01:00
b3d1320c94 Merge branch '149-150-events-improve' into 'master'
Modernisation fonctionnement module Evénement

Closes #149, #150, and #225

See merge request Chill-Projet/chill-bundles!621
2024-02-08 20:19:30 +00:00
2ed42e1a2c Fix cs with php-cs-fier version 3.49 2024-02-08 21:12:27 +01:00
d0e5ba16fe Merge branch 'issue115_social_action_versioning' into 'master'
Add versioning and optimistic locking on accompanying period work

Closes #115

See merge request Chill-Projet/chill-bundles!627
2024-02-08 20:02:05 +00:00
8e65ad9476 Add changie file 2024-02-08 21:01:53 +01:00
cf7338b690 Fix issues with inexisting fields 2024-02-08 21:00:16 +01:00
63dd71037a Add changie file 2024-02-08 14:33:30 +01:00
cc281762b3 Translate message on conflict in AccompanyingPeriodWorkEdit App 2024-02-08 14:33:30 +01:00
aa0cadfa84 Add conflict resolution for generated API + add test
Implemented additional code to handle version conflicts when editing accompanying period work. By keeping track of the current version and returning an HTTP conflict response when it doesn't match with the provided entity version, users are properly alerted to update their entity before continuing. Furthermore, adjusted BadRequestHttpException to match correct arguments order and introduced entity version as query parameter for the URL.

ensure kernel is shutdown after generating data
2024-02-08 14:33:30 +01:00
6e2cce9531 Event: add more fields: documents, organizationCost, note, and location 2024-02-08 12:59:52 +01:00
1fbbf2b2ad fixup! Fix migrations to take into account the change in table name for Person's entity 2024-02-08 12:59:52 +01:00
e586b8ee5e Event: move validation to annotation and add UniqueEntity constraint on Participation 2024-02-08 12:59:51 +01:00
6d04e477f8 Clean database, to avoid double participations on event 2024-02-08 12:59:51 +01:00
6b7b2ae522 fixup! Fix migrations to take into account the change in table name for Person's entity 2024-02-08 12:59:51 +01:00
9b9c2774ad Allow Pick*Type to submit the form when selection an entity, and apply inside Event 2024-02-08 12:59:50 +01:00
e902b6d409 Create a page which list events 2024-02-08 12:59:50 +01:00
d8bf6a195f add creation and update information on events and participation 2024-02-08 12:59:50 +01:00
7c3152f277 Fix migration when executed after the person entity table name change 2024-02-08 12:59:50 +01:00
cef218fed5 Add interface for pagination 2024-02-08 12:59:47 +01:00
930a76cc66 use a PickPersonDynamic type in event bundle 2024-02-08 12:57:17 +01:00
f11f7498d7 Add new option "as_id" to Pick*DynamicType
This option will make the app return a single id of the entity in data, and not the entity json.
2024-02-08 12:54:44 +01:00
1a9af6b0b1 activate Event Bundle in test app 2024-02-08 12:54:44 +01:00
d347f6ae60 Fix migrations to take into account the change in table name for Person's entity 2024-02-08 12:54:44 +01:00
3bb911b4d0 Update version within PUT request
Try to add api logic

check for version being the same instead of smaller

implementing optimistic locking and displaying correct message in frontend

rector fixes

adjust violation message and add translation in translation.yaml

add translator in apiController
2024-02-08 12:09:51 +01:00
f00b39980c Add version of the social action to the state
put correct serialization groups
2024-02-08 12:08:36 +01:00
09882bb4be Add translations that were missing according to console error 2024-02-08 12:08:35 +01:00
1d21499eab add version property to accompanyingperiodwork for optimistic locking 2024-02-08 12:08:35 +01:00
8ef001e67e Merge branch '260-order-centers-dropdown' into 'master'
Resolve "Mettre en ordre alphabétique la liste des centres dans le dropdown du section 'utilisateurs' dans l'admin"

Closes #260

See merge request Chill-Projet/chill-bundles!657
2024-02-08 10:33:09 +00:00
458df45fa5 Add changie 2024-02-08 11:22:48 +01:00
2b968b9a5b order centers dropdown alphabetically 2024-02-08 11:21:33 +01:00
143 changed files with 1854 additions and 4125 deletions

View File

@@ -1,6 +0,0 @@
kind: Feature
body: Create new filter for persons having a participation in an accompanying period
during a certain time span
time: 2023-12-18T15:31:51.489901829+01:00
custom:
Issue: "231"

View File

@@ -1,6 +0,0 @@
kind: Feature
body: '[Export][List of accompanyign period] Add two columns: the list of persons
participating to the period, and their ids'
time: 2024-01-22T12:48:49.824833412+01:00
custom:
Issue: "241"

View File

@@ -1,5 +0,0 @@
kind: Feature
body: 'Add capability to generate export about change of steps of accompanying period, and generate exports for this'
time: 2024-01-29T13:33:19.190365565+01:00
custom:
Issue: "244"

View File

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

View File

@@ -1,5 +0,0 @@
kind: Feature
body: 'Export: add filter for courses not linked to a reference address'
time: 2024-02-07T11:46:29.491027007+01:00
custom:
Issue: "243"

View File

@@ -1,5 +0,0 @@
kind: Feature
body: Allow to group activities linked with accompanying period by reason
time: 2024-02-07T16:40:38.408575109+01:00
custom:
Issue: "229"

View File

@@ -1,6 +0,0 @@
kind: Fixed
body: Fix error in logs about wrong typing of eventArgs in onEditNotificationComment
method
time: 2023-11-29T11:31:38.933538592+01:00
custom:
Issue: "220"

View File

@@ -1,6 +0,0 @@
kind: Fixed
body: Fix the conditions upon which social actions should be optional or required
in relation to social issues within the activity creation form
time: 2024-01-30T14:03:01.942955636+01:00
custom:
Issue: "256"

15
.changes/v2.16.0.md Normal file
View File

@@ -0,0 +1,15 @@
## v2.16.0 - 2024-02-08
### Feature
* ([#231](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/231)) Create new filter for persons having a participation in an accompanying period during a certain time span
* ([#241](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/241)) [Export][List of accompanyign period] Add two columns: the list of persons participating to the period, and their ids
* ([#244](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/244)) Add capability to generate export about change of steps of accompanying period, and generate exports for this
* ([#253](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/253)) Export: group accompanying period by person participating
* ([#243](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/243)) Export: add filter for courses not linked to a reference address
* ([#229](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/229)) Allow to group activities linked with accompanying period by reason
* ([#115](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/115)) Prevent social work to be saved when another user edited conccurently the social work
* Modernize the event bundle, with some new fields and multiple improvements
### Fixed
* ([#220](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/220)) Fix error in logs about wrong typing of eventArgs in onEditNotificationComment method
* ([#256](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/256)) Fix the conditions upon which social actions should be optional or required in relation to social issues within the activity creation form
### UX
* ([#260](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/260)) Order list of centers alphabetically in dropdown 'user' section admin.

3
.changes/v2.16.1.md Normal file
View File

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

View File

@@ -6,6 +6,26 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html),
and is generated by [Changie](https://github.com/miniscruff/changie).
## v2.16.1 - 2024-02-09
### Fixed
* Force bootstrap version to avoid error in builds with newer version
## v2.16.0 - 2024-02-08
### Feature
* ([#231](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/231)) Create new filter for persons having a participation in an accompanying period during a certain time span
* ([#241](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/241)) [Export][List of accompanyign period] Add two columns: the list of persons participating to the period, and their ids
* ([#244](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/244)) Add capability to generate export about change of steps of accompanying period, and generate exports for this
* ([#253](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/253)) Export: group accompanying period by person participating
* ([#243](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/243)) Export: add filter for courses not linked to a reference address
* ([#229](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/229)) Allow to group activities linked with accompanying period by reason
* ([#115](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/115)) Prevent social work to be saved when another user edited conccurently the social work
* Modernize the event bundle, with some new fields and multiple improvements
### Fixed
* ([#220](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/220)) Fix error in logs about wrong typing of eventArgs in onEditNotificationComment method
* ([#256](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/256)) Fix the conditions upon which social actions should be optional or required in relation to social issues within the activity creation form
### UX
* ([#260](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/260)) Order list of centers alphabetically in dropdown 'user' section admin.
## v2.15.2 - 2024-01-11
### Fixed
* Fix the id_seq used when creating a new accompanying period participation during fusion of two person files

View File

@@ -9,7 +9,6 @@
],
"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,7 +34,6 @@
"webpack-cli": "^5.0.1"
},
"dependencies": {
"bootstrap": "~5.2.0",
"@fullcalendar/core": "^6.1.4",
"@fullcalendar/daygrid": "^6.1.4",
"@fullcalendar/interaction": "^6.1.4",
@@ -43,11 +42,9 @@
"@fullcalendar/vue3": "^6.1.4",
"@popperjs/core": "^2.9.2",
"@types/leaflet": "^1.9.3",
"dompurify": "^3.0.6",
"dropzone": "^5.7.6",
"es6-promise": "^4.2.8",
"leaflet": "^1.7.1",
"marked": "^9.1.5",
"masonry-layout": "^4.2.2",
"mime": "^3.0.0",
"swagger-ui": "^4.15.5",

View File

@@ -1,66 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Tests\Security\Guard;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Security\Guard\DavTokenAuthenticationEventSubscriber;
use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTAuthenticatedEvent;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Security\Core\Authentication\Token\AbstractToken;
/**
* @internal
*
* @coversNothing
*/
class DavTokenAuthenticationEventSubscriberTest extends TestCase
{
public function testOnJWTAuthenticatedWithDavDataInPayload(): void
{
$eventSubscriber = new DavTokenAuthenticationEventSubscriber();
$token = new class () extends AbstractToken {
public function getCredentials()
{
return null;
}
};
$event = new JWTAuthenticatedEvent([
'dav' => 1,
'so' => '1234',
'e' => 1,
], $token);
$eventSubscriber->onJWTAuthenticated($event);
self::assertTrue($token->hasAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT));
self::assertTrue($token->hasAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS));
self::assertEquals('1234', $token->getAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT));
self::assertEquals(StoredObjectRoleEnum::EDIT, $token->getAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS));
}
public function testOnJWTAuthenticatedWithDavNoDataInPayload(): void
{
$eventSubscriber = new DavTokenAuthenticationEventSubscriber();
$token = new class () extends AbstractToken {
public function getCredentials()
{
return null;
}
};
$event = new JWTAuthenticatedEvent([], $token);
$eventSubscriber->onJWTAuthenticated($event);
self::assertFalse($token->hasAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT));
self::assertFalse($token->hasAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS));
}
}

View File

@@ -1,252 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Controller;
use Chill\DocStoreBundle\Dav\Request\PropfindRequestAnalyzer;
use Chill\DocStoreBundle\Dav\Response\DavResponse;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Security;
/**
* Provide endpoint for editing a document on the desktop using dav.
*
* This controller implements the minimal required methods to edit a document on a desktop software (i.e. LibreOffice)
* and save the document online.
*
* To avoid to ask for a password, the endpoints are protected using a JWT access token, which is inside the
* URL. This avoid the DAV Client (LibreOffice) to keep an access token in query parameter or in some header (which
* they are not able to understand). The JWT Guard is adapted with a dedicated token extractor which is going to read
* the segments (separation of "/"): the first segment must be the string "dav", and the second one must be the JWT.
*/
final readonly class WebdavController
{
private PropfindRequestAnalyzer $requestAnalyzer;
public function __construct(
private \Twig\Environment $engine,
private StoredObjectManagerInterface $storedObjectManager,
private Security $security,
) {
$this->requestAnalyzer = new PropfindRequestAnalyzer();
}
/**
* @Route("/dav/{access_token}/get/{uuid}/", methods={"GET", "HEAD"}, name="chill_docstore_dav_directory_get")
*/
public function getDirectory(StoredObject $storedObject, string $access_token): Response
{
if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) {
throw new AccessDeniedHttpException();
}
return new DavResponse(
$this->engine->render('@ChillDocStore/Webdav/directory.html.twig', [
'stored_object' => $storedObject,
'access_token' => $access_token,
])
);
}
/**
* @Route("/dav/{access_token}/get/{uuid}/", methods={"OPTIONS"})
*/
public function optionsDirectory(StoredObject $storedObject): Response
{
if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) {
throw new AccessDeniedHttpException();
}
$response = (new DavResponse(''))
->setEtag($this->storedObjectManager->etag($storedObject))
;
// $response->headers->add(['Allow' => 'OPTIONS,GET,HEAD,DELETE,PROPFIND,PUT,PROPPATCH,COPY,MOVE,REPORT,PATCH,POST,TRACE']);
$response->headers->add(['Allow' => 'OPTIONS,GET,HEAD,DELETE,PROPFIND,PUT']);
return $response;
}
/**
* @Route("/dav/{access_token}/get/{uuid}/", methods={"PROPFIND"})
*/
public function propfindDirectory(StoredObject $storedObject, string $access_token, Request $request): Response
{
if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) {
throw new AccessDeniedHttpException();
}
$depth = $request->headers->get('depth');
if ('0' !== $depth && '1' !== $depth) {
throw new BadRequestHttpException('only 1 and 0 are accepted for Depth header');
}
[$properties, $lastModified, $etag, $length] = $this->parseDavRequest($request->getContent(), $storedObject);
$response = new DavResponse(
$this->engine->render('@ChillDocStore/Webdav/directory_propfind.xml.twig', [
'stored_object' => $storedObject,
'properties' => $properties,
'last_modified' => $lastModified,
'etag' => $etag,
'content_length' => $length,
'depth' => (int) $depth,
'access_token' => $access_token,
]),
207
);
$response->headers->add([
'Content-Type' => 'text/xml',
]);
return $response;
}
/**
* @Route("/dav/{access_token}/get/{uuid}/d", name="chill_docstore_dav_document_get", methods={"GET"})
*/
public function getDocument(StoredObject $storedObject): Response
{
if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) {
throw new AccessDeniedHttpException();
}
return (new DavResponse($this->storedObjectManager->read($storedObject)))
->setEtag($this->storedObjectManager->etag($storedObject));
}
/**
* @Route("/dav/{access_token}/get/{uuid}/d", methods={"HEAD"})
*/
public function headDocument(StoredObject $storedObject): Response
{
if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) {
throw new AccessDeniedHttpException();
}
$response = new DavResponse('');
$response->headers->add(
[
'Content-Length' => $this->storedObjectManager->getContentLength($storedObject),
'Content-Type' => $storedObject->getType(),
'Etag' => $this->storedObjectManager->etag($storedObject),
]
);
return $response;
}
/**
* @Route("/dav/{access_token}/get/{uuid}/d", methods={"OPTIONS"})
*/
public function optionsDocument(StoredObject $storedObject): Response
{
if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) {
throw new AccessDeniedHttpException();
}
$response = (new DavResponse(''))
->setEtag($this->storedObjectManager->etag($storedObject))
;
$response->headers->add(['Allow' => 'OPTIONS,GET,HEAD,DELETE,PROPFIND,PUT']);
return $response;
}
/**
* @Route("/dav/{access_token}/get/{uuid}/d", methods={"PROPFIND"})
*/
public function propfindDocument(StoredObject $storedObject, string $access_token, Request $request): Response
{
if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) {
throw new AccessDeniedHttpException();
}
[$properties, $lastModified, $etag, $length] = $this->parseDavRequest($request->getContent(), $storedObject);
$response = new DavResponse(
$this->engine->render(
'@ChillDocStore/Webdav/doc_props.xml.twig',
[
'stored_object' => $storedObject,
'properties' => $properties,
'etag' => $etag,
'last_modified' => $lastModified,
'content_length' => $length,
'access_token' => $access_token,
]
),
207
);
$response
->headers->add([
'Content-Type' => 'text/xml',
]);
return $response;
}
/**
* @Route("/dav/{access_token}/get/{uuid}/d", methods={"PUT"})
*/
public function putDocument(StoredObject $storedObject, Request $request): Response
{
if (!$this->security->isGranted(StoredObjectRoleEnum::EDIT->value, $storedObject)) {
throw new AccessDeniedHttpException();
}
$this->storedObjectManager->write($storedObject, $request->getContent());
return new DavResponse('', Response::HTTP_NO_CONTENT);
}
/**
* @return array{0: array, 1: \DateTimeInterface, 2: string, 3: int} properties, lastModified, etag, length
*/
private function parseDavRequest(string $content, StoredObject $storedObject): array
{
$xml = new \DOMDocument();
$xml->loadXML($content);
$properties = $this->requestAnalyzer->getRequestedProperties($xml);
$requested = array_keys(array_filter($properties, fn ($item) => true === $item));
if (
in_array('lastModified', $requested, true)
|| in_array('etag', $requested, true)
) {
$lastModified = $this->storedObjectManager->getLastModified($storedObject);
$etag = $this->storedObjectManager->etag($storedObject);
}
if (in_array('contentLength', $requested, true)) {
$length = $this->storedObjectManager->getContentLength($storedObject);
}
return [
$properties,
$lastModified ?? null,
$etag ?? null,
$length ?? null,
];
}
}

View File

@@ -1,16 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Dav\Exception;
class ParseRequestException extends \UnexpectedValueException
{
}

View File

@@ -1,103 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Dav\Request;
use Chill\DocStoreBundle\Dav\Exception\ParseRequestException;
/**
* @phpstan-type davProperties array{resourceType: bool, contentType: bool, lastModified: bool, creationDate: bool, contentLength: bool, etag: bool, supportedLock: bool, unknowns: list<array{xmlns: string, prop: string}>}
*/
class PropfindRequestAnalyzer
{
private const KNOWN_PROPS = [
'resourceType',
'contentType',
'lastModified',
'creationDate',
'contentLength',
'etag',
'supportedLock',
];
/**
* @return davProperties
*/
public function getRequestedProperties(\DOMDocument $request): array
{
$propfinds = $request->getElementsByTagNameNS('DAV:', 'propfind');
if (0 === $propfinds->count()) {
throw new ParseRequestException('any propfind element found');
}
if (1 < $propfinds->count()) {
throw new ParseRequestException('too much propfind element found');
}
$propfind = $propfinds->item(0);
if (0 === $propfind->childNodes->count()) {
throw new ParseRequestException('no element under propfind');
}
$unknows = [];
$props = [];
foreach ($propfind->childNodes->getIterator() as $prop) {
/** @var \DOMNode $prop */
if (XML_ELEMENT_NODE !== $prop->nodeType) {
continue;
}
if ('propname' === $prop->nodeName) {
return $this->baseProps(true);
}
foreach ($prop->childNodes->getIterator() as $getProp) {
if (XML_ELEMENT_NODE !== $getProp->nodeType) {
continue;
}
if ('DAV:' !== $getProp->lookupNamespaceURI(null)) {
$unknows[] = ['xmlns' => $getProp->lookupNamespaceURI(null), 'prop' => $getProp->nodeName];
continue;
}
$props[] = match ($getProp->nodeName) {
'resourcetype' => 'resourceType',
'getcontenttype' => 'contentType',
'getlastmodified' => 'lastModified',
default => '',
};
}
}
$props = array_filter(array_values($props), fn (string $item) => '' !== $item);
return [...$this->baseProps(false), ...array_combine($props, array_fill(0, count($props), true)), 'unknowns' => $unknows];
}
/**
* @return davProperties
*/
private function baseProps(bool $default = false): array
{
return
[
...array_combine(
self::KNOWN_PROPS,
array_fill(0, count(self::KNOWN_PROPS), $default)
),
'unknowns' => [],
];
}
}

View File

@@ -1,24 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Dav\Response;
use Symfony\Component\HttpFoundation\Response;
class DavResponse extends Response
{
public function __construct($content = '', int $status = 200, array $headers = [])
{
parent::__construct($content, $status, $headers);
$this->headers->add(['DAV' => '1']);
}
}

View File

@@ -17,22 +17,18 @@ window.addEventListener('DOMContentLoaded', function (e) {
canEdit: string,
storedObject: string,
buttonSmall: string,
davLink: string,
davLinkExpiration: string,
};
const
storedObject = JSON.parse(datasets.storedObject) as StoredObject,
filename = datasets.filename,
canEdit = datasets.canEdit === '1',
small = datasets.buttonSmall === '1',
davLink = 'davLink' in datasets && datasets.davLink !== '' ? datasets.davLink : null,
davLinkExpiration = 'davLinkExpiration' in datasets ? Number.parseInt(datasets.davLinkExpiration) : null
small = datasets.buttonSmall === '1'
;
return { storedObject, filename, canEdit, small, davLink, davLinkExpiration };
return { storedObject, filename, canEdit, small };
},
template: '<document-action-buttons-group :can-edit="canEdit" :filename="filename" :stored-object="storedObject" :small="small" :dav-link="davLink" :dav-link-expiration="davLinkExpiration" @on-stored-object-status-change="onStoredObjectStatusChange"></document-action-buttons-group>',
template: '<document-action-buttons-group :can-edit="canEdit" :filename="filename" :stored-object="storedObject" :small="small" @on-stored-object-status-change="onStoredObjectStatusChange"></document-action-buttons-group>',
methods: {
onStoredObjectStatusChange: function(newStatus: StoredObjectStatusChange): void {
this.$data.storedObject.status = newStatus.status;

View File

@@ -7,9 +7,6 @@
<li v-if="props.canEdit && is_extension_editable(props.storedObject.type)">
<wopi-edit-button :stored-object="props.storedObject" :classes="{'dropdown-item': true}" :execute-before-leave="props.executeBeforeLeave"></wopi-edit-button>
</li>
<li v-if="props.canEdit && is_extension_editable(props.storedObject.type) && props.davLink !== undefined && props.davLinkExpiration !== undefined">
<desktop-edit-button :classes="{'dropdown-item': true}" :edit-link="props.davLink" :expiration-link="props.davLinkExpiration"></desktop-edit-button>
</li>
<li v-if="props.storedObject.type != 'application/pdf' && is_extension_viewable(props.storedObject.type) && props.canConvertPdf">
<convert-button :stored-object="props.storedObject" :filename="filename" :classes="{'dropdown-item': true}"></convert-button>
</li>
@@ -39,7 +36,6 @@ import {
StoredObjectStatusChange,
WopiEditButtonExecutableBeforeLeaveFunction
} from "../types";
import DesktopEditButton from "ChillDocStoreAssets/vuejs/StoredObjectButton/DesktopEditButton.vue";
interface DocumentActionButtonsGroupConfig {
storedObject: StoredObject,
@@ -61,16 +57,6 @@ interface DocumentActionButtonsGroupConfig {
* If set, will execute this function before leaving to the editor
*/
executeBeforeLeave?: WopiEditButtonExecutableBeforeLeaveFunction,
/**
* a link to download and edit file using webdav
*/
davLink?: string,
/**
* the expiration date of the download, as a unix timestamp
*/
davLinkExpiration?: number,
}
const emit = defineEmits<{
@@ -82,7 +68,7 @@ const props = withDefaults(defineProps<DocumentActionButtonsGroupConfig>(), {
canEdit: true,
canDownload: true,
canConvertPdf: true,
returnPath: window.location.pathname + window.location.search + window.location.hash
returnPath: window.location.pathname + window.location.search + window.location.hash,
});
/**

View File

@@ -1,66 +0,0 @@
<script setup lang="ts">
import Modal from "ChillMainAssets/vuejs/_components/Modal.vue";
import {computed, reactive} from "vue";
export interface DesktopEditButtonConfig {
editLink: null,
classes: { [k: string]: boolean },
expirationLink: number|Date,
}
interface DesktopEditButtonState {
modalOpened: boolean
};
const state: DesktopEditButtonState = reactive({modalOpened: false});
const props = defineProps<DesktopEditButtonConfig>();
const buildCommand = computed<string>(() => 'vnd.libreoffice.command:ofe|u|' + props.editLink);
const editionUntilFormatted = computed<string>(() => {
let d;
if (props.expirationLink instanceof Date) {
d = props.expirationLink;
} else {
d = new Date(props.expirationLink * 1000);
}
console.log(props.expirationLink);
return (new Intl.DateTimeFormat(undefined, {'dateStyle': 'long', 'timeStyle': 'medium'})).format(d);
});
</script>
<template>
<teleport to="body">
<modal v-if="state.modalOpened" @close="state.modalOpened=false">
<template v-slot:body>
<div class="desktop-edit">
<p class="center">Veuillez enregistrer vos modifications avant le</p>
<p><strong>{{ editionUntilFormatted }}</strong></p>
<p><a class="btn btn-primary" :href="buildCommand">Ouvrir le document pour édition</a></p>
<p><small>Le document peut être édité uniquement en utilisant Libre Office.</small></p>
<p><small>En cas d'échec lors de l'enregistrement, sauver le document sur le poste de travail avant de le déposer à nouveau ici.</small></p>
<p><small>Vous pouvez naviguez sur d'autres pages pendant l'édition.</small></p>
</div>
</template>
</modal>
</teleport>
<a :class="props.classes" @click="state.modalOpened = true">
<i class="fa fa-desktop"></i>
Éditer sur le bureau
</a>
</template>
<style scoped lang="scss">
.desktop-edit {
text-align: center;
}
</style>

View File

@@ -3,7 +3,5 @@
data-download-buttons
data-stored-object="{{ document_json|json_encode|escape('html_attr') }}"
data-can-edit="{{ can_edit ? '1' : '0' }}"
data-dav-link="{{ dav_link|escape('html_attr') }}"
data-dav-link-expiration="{{ dav_link_expiration|escape('html_attr') }}"
{% if options['small'] is defined %}data-button-small="{{ options['small'] ? '1' : '0' }}"{% endif %}
{% if title|default(document.title)|default(null) is not null %}data-filename="{{ title|default(document.title)|escape('html_attr') }}"{% endif %}></div>

View File

@@ -1,12 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Directory for {{ stored_object.uuid }}</title>
</head>
<body>
<ul>
<li><a href="{{ absolute_url(path('chill_docstore_dav_document_get', {'uuid': stored_object.uuid, 'access_token': access_token })) }}">d</a></li>
</ul>
</body>
</html>

View File

@@ -1,81 +0,0 @@
<?xml version="1.0" encoding="UTF-8" ?>
<d:multistatus xmlns:d="DAV:">
<d:response>
<d:href>{{ path('chill_docstore_dav_directory_get', { 'uuid': stored_object.uuid, 'access_token': access_token } ) }}</d:href>
{% if properties.resourceType or properties.contentType %}
<d:propstat>
<d:prop>
{% if properties.resourceType %}
<d:resourcetype><d:collection/></d:resourcetype>
{% endif %}
{% if properties.contentType %}
<d:getcontenttype>httpd/unix-directory</d:getcontenttype>
{% endif %}
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
{% endif %}
{% if properties.unknowns|length > 0 %}
<d:propstat>
{% for k,u in properties.unknowns %}
<d:prop {{ ('xmlns:ns' ~ k ~ '="' ~ u.xmlns|e('html_attr') ~ '"')|raw }}>
<{{ 'ns'~ k ~ ':' ~ u.prop }} />
</d:prop>
{% endfor %}
<d:status>HTTP/1.1 404 Not Found</d:status>
</d:propstat>
{% endif %}
</d:response>
{% if depth == 1 %}
<d:response>
<d:href>{{ path('chill_docstore_dav_document_get', {'uuid': stored_object.uuid, 'access_token':access_token}) }}</d:href>
{% if properties.lastModified or properties.contentLength or properties.resourceType or properties.etag or properties.contentType or properties.creationDate %}
<d:propstat>
<d:prop>
{% if properties.resourceType %}
<d:resourcetype/>
{% endif %}
{% if properties.creationDate %}
<d:creationdate />
{% endif %}
{% if properties.lastModified %}
{% if last_modified is not same as null %}
<d:getlastmodified>{{ last_modified.format(constant('DATE_RSS')) }}</d:getlastmodified>
{% else %}
<d:getlastmodified />
{% endif %}
{% endif %}
{% if properties.contentLength %}
{% if content_length is not same as null %}
<d:getcontentlength>{{ content_length }}</d:getcontentlength>
{% else %}
<d:getcontentlength />
{% endif %}
{% endif %}
{% if properties.etag %}
{% if etag is not same as null %}
<d:getetag>"{{ etag }}"</d:getetag>
{% else %}
<d:getetag />
{% endif %}
{% endif %}
{% if properties.contentType %}
<d:getcontenttype>{{ stored_object.type }}</d:getcontenttype>
{% endif %}
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
{% endif %}
{% if properties.unknowns|length > 0 %}
<d:propstat>
{% for k,u in properties.unknowns %}
<d:prop {{ ('xmlns:ns' ~ k ~ '="' ~ u.xmlns|e('html_attr') ~ '"')|raw }}>
<{{ 'ns'~ k ~ ':' ~ u.prop }} />
</d:prop>
{% endfor %}
<d:status>HTTP/1.1 404 Not Found</d:status>
</d:propstat>
{% endif %}
</d:response>
{% endif %}
</d:multistatus>

View File

@@ -1,53 +0,0 @@
<?xml version="1.0" encoding="UTF-8" ?>
<d:multistatus xmlns:d="DAV:">
<d:response>
<d:href>{{ path('chill_docstore_dav_document_get', {'uuid': stored_object.uuid, 'access_token': access_token}) }}</d:href>
{% if properties.lastModified or properties.contentLength or properties.resourceType or properties.etag or properties.contentType or properties.creationDate %}
<d:propstat>
<d:prop>
{% if properties.resourceType %}
<d:resourcetype/>
{% endif %}
{% if properties.creationDate %}
<d:creationdate />
{% endif %}
{% if properties.lastModified %}
{% if last_modified is not same as null %}
<d:getlastmodified>{{ last_modified.format(constant('DATE_RSS')) }}</d:getlastmodified>
{% else %}
<d:getlastmodified />
{% endif %}
{% endif %}
{% if properties.contentLength %}
{% if content_length is not same as null %}
<d:getcontentlength>{{ content_length }}</d:getcontentlength>
{% else %}
<d:getcontentlength />
{% endif %}
{% endif %}
{% if properties.etag %}
{% if etag is not same as null %}
<d:getetag>"{{ etag }}"</d:getetag>
{% else %}
<d:getetag />
{% endif %}
{% endif %}
{% if properties.contentType %}
<d:getcontenttype>{{ stored_object.type }}</d:getcontenttype>
{% endif %}
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
{% endif %}
{% if properties.unknowns|length > 0 %}
<d:propstat>
{% for k,u in properties.unknowns %}
<d:prop {{ ('xmlns:ns' ~ k ~ '="' ~ u.xmlns|e('html_attr') ~ '"')|raw }}>
<{{ 'ns'~ k ~ ':' ~ u.prop }} />
</d:prop>
{% endfor %}
<d:status>HTTP/1.1 404 Not Found</d:status>
</d:propstat>
{% endif %}
</d:response>
</d:multistatus>

View File

@@ -1,7 +0,0 @@
{% extends '@ChillMain/layout.html.twig' %}
{% block content %}
<p>document uuid: {{ stored_object.uuid }}</p>
<p>{{ absolute_url(path('chill_docstore_dav_document_get', {'uuid': stored_object.uuid, 'access_token': access_token })) }}</p>
<a href="vnd.libreoffice.command:ofe|u|{{ absolute_url(path('chill_docstore_dav_document_get', {'uuid': stored_object.uuid, 'access_token': access_token })) }}">Open document</a>
{% endblock %}

View File

@@ -1,22 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Security\Authorization;
/**
* Role to edit or see the stored object content.
*/
enum StoredObjectRoleEnum: string
{
case SEE = 'SEE';
case EDIT = 'SEE_AND_EDIT';
}

View File

@@ -1,55 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Security\Authorization;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Security\Guard\DavTokenAuthenticationEventSubscriber;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
* Voter for the content of a stored object.
*
* This is in use to allow or disallow the edition of the stored object's content.
*/
class StoredObjectVoter extends Voter
{
protected function supports($attribute, $subject): bool
{
return StoredObjectRoleEnum::tryFrom($attribute) instanceof StoredObjectRoleEnum
&& $subject instanceof StoredObject;
}
protected function voteOnAttribute($attribute, $subject, TokenInterface $token): bool
{
/** @var StoredObject $subject */
if (
!$token->hasAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT)
|| $subject->getUuid()->toString() !== $token->getAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT)
) {
return false;
}
if (!$token->hasAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS)) {
return false;
}
$askedRole = StoredObjectRoleEnum::from($attribute);
$tokenRoleAuthorization =
$token->getAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS);
return match ($askedRole) {
StoredObjectRoleEnum::SEE => StoredObjectRoleEnum::EDIT === $tokenRoleAuthorization || StoredObjectRoleEnum::SEE === $tokenRoleAuthorization,
StoredObjectRoleEnum::EDIT => StoredObjectRoleEnum::EDIT === $tokenRoleAuthorization
};
}
}

View File

@@ -1,58 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Security\Guard;
use Lexik\Bundle\JWTAuthenticationBundle\TokenExtractor\TokenExtractorInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\Request;
/**
* Extract the JWT Token from the segment of the dav endpoints.
*
* A segment is a separation inside the string, using the character "/".
*
* For recognizing the JWT, the first segment must be "dav", and the second one must be
* the JWT endpoint.
*/
final readonly class DavOnUrlTokenExtractor implements TokenExtractorInterface
{
public function __construct(
private LoggerInterface $logger,
) {
}
public function extract(Request $request): false|string
{
$uri = $request->getRequestUri();
$segments = array_values(
array_filter(
explode('/', $uri),
fn ($item) => '' !== trim($item)
)
);
if (2 > count($segments)) {
$this->logger->info('not enough segment for parsing URL');
return false;
}
if ('dav' !== $segments[0]) {
$this->logger->info('the first segment of the url must be DAV');
return false;
}
return $segments[1];
}
}

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\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

@@ -1,48 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Security\Guard;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface;
use Symfony\Component\Security\Core\Security;
/**
* Provide a JWT Token which will be valid for viewing or editing a document.
*/
final readonly class JWTDavTokenProvider implements JWTDavTokenProviderInterface
{
public function __construct(
private JWTTokenManagerInterface $JWTTokenManager,
private Security $security,
) {
}
public function createToken(StoredObject $storedObject, StoredObjectRoleEnum $roleEnum): string
{
return $this->JWTTokenManager->createFromPayload($this->security->getUser(), [
'dav' => 1,
'e' => match ($roleEnum) {
StoredObjectRoleEnum::SEE => 0,
StoredObjectRoleEnum::EDIT => 1,
},
'so' => $storedObject->getUuid(),
]);
}
public function getTokenExpiration(string $tokenString): \DateTimeImmutable
{
$jwt = $this->JWTTokenManager->parse($tokenString);
return \DateTimeImmutable::createFromFormat('U', (string) $jwt['exp']);
}
}

View File

@@ -1,25 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Security\Guard;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
/**
* Provide a JWT Token which will be valid for viewing or editing a document.
*/
interface JWTDavTokenProviderInterface
{
public function createToken(StoredObject $storedObject, StoredObjectRoleEnum $roleEnum): string;
public function getTokenExpiration(string $tokenString): \DateTimeImmutable;
}

View File

@@ -1,41 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Security\Guard;
use Lexik\Bundle\JWTAuthenticationBundle\Security\Guard\JWTTokenAuthenticator;
use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface;
use Lexik\Bundle\JWTAuthenticationBundle\TokenExtractor\TokenExtractorInterface;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* Alter the base JWTTokenAuthenticator to add the special extractor for dav url endpoints.
*/
class JWTOnDavUrlAuthenticator extends JWTTokenAuthenticator
{
public function __construct(
JWTTokenManagerInterface $jwtManager,
EventDispatcherInterface $dispatcher,
TokenExtractorInterface $tokenExtractor,
private readonly DavOnUrlTokenExtractor $davOnUrlTokenExtractor,
TokenStorageInterface $preAuthenticationTokenStorage,
TranslatorInterface $translator = null,
) {
parent::__construct($jwtManager, $dispatcher, $tokenExtractor, $preAuthenticationTokenStorage, $translator);
}
protected function getTokenExtractor()
{
return $this->davOnUrlTokenExtractor;
}
}

View File

@@ -57,62 +57,6 @@ final class StoredObjectManager implements StoredObjectManagerInterface
return $this->extractLastModifiedFromResponse($response);
}
public function getContentLength(StoredObject $document): int
{
if ([] === $document->getKeyInfos()) {
if ($this->hasCache($document)) {
$response = $this->getResponseFromCache($document);
} else {
try {
$response = $this
->client
->request(
Request::METHOD_HEAD,
$this
->tempUrlGenerator
->generate(
Request::METHOD_HEAD,
$document->getFilename()
)
->url
);
} catch (TransportExceptionInterface $exception) {
throw StoredObjectManagerException::errorDuringHttpRequest($exception);
}
}
return $this->extractContentLengthFromResponse($response);
}
return strlen($this->read($document));
}
public function etag(StoredObject $document): string
{
if ($this->hasCache($document)) {
$response = $this->getResponseFromCache($document);
} else {
try {
$response = $this
->client
->request(
Request::METHOD_HEAD,
$this
->tempUrlGenerator
->generate(
Request::METHOD_HEAD,
$document->getFilename()
)
->url
);
} catch (TransportExceptionInterface $exception) {
throw StoredObjectManagerException::errorDuringHttpRequest($exception);
}
}
return $this->extractEtagFromResponse($response, $document);
}
public function read(StoredObject $document): string
{
$response = $this->getResponseFromCache($document);
@@ -202,22 +146,6 @@ final class StoredObjectManager implements StoredObjectManagerInterface
return $date;
}
private function extractContentLengthFromResponse(ResponseInterface $response): int
{
return (int) ($response->getHeaders()['content-length'] ?? ['0'])[0];
}
private function extractEtagFromResponse(ResponseInterface $response, StoredObject $storedObject): ?string
{
$etag = ($response->getHeaders()['etag'] ?? [''])[0];
if ('' === $etag) {
return null;
}
return $etag;
}
private function fillCache(StoredObject $document): void
{
try {

View File

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

View File

@@ -13,9 +13,6 @@ namespace Chill\DocStoreBundle\Templating;
use ChampsLibres\WopiLib\Contract\Service\Discovery\DiscoveryInterface;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Security\Guard\JWTDavTokenProviderInterface;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Twig\Environment;
@@ -123,12 +120,8 @@ final readonly class WopiEditTwigExtensionRuntime implements RuntimeExtensionInt
private const TEMPLATE_BUTTON_GROUP = '@ChillDocStore/Button/button_group.html.twig';
public function __construct(
private DiscoveryInterface $discovery,
private NormalizerInterface $normalizer,
private JWTDavTokenProviderInterface $davTokenProvider,
private UrlGeneratorInterface $urlGenerator,
) {
public function __construct(private DiscoveryInterface $discovery, private NormalizerInterface $normalizer)
{
}
/**
@@ -139,7 +132,7 @@ final readonly class WopiEditTwigExtensionRuntime implements RuntimeExtensionInt
*/
public function isEditable(StoredObject $document): bool
{
return in_array($document->getType(), self::SUPPORTED_MIMES, true);
return \in_array($document->getType(), self::SUPPORTED_MIMES, true);
}
/**
@@ -151,26 +144,12 @@ final readonly class WopiEditTwigExtensionRuntime implements RuntimeExtensionInt
*/
public function renderButtonGroup(Environment $environment, StoredObject $document, ?string $title = null, bool $canEdit = true, array $options = []): string
{
$accessToken = $this->davTokenProvider->createToken(
$document,
$canEdit ? StoredObjectRoleEnum::EDIT : StoredObjectRoleEnum::SEE
);
return $environment->render(self::TEMPLATE_BUTTON_GROUP, [
'document' => $document,
'document_json' => $this->normalizer->normalize($document, 'json', [AbstractNormalizer::GROUPS => ['read']]),
'title' => $title,
'can_edit' => $canEdit,
'options' => [...self::DEFAULT_OPTIONS_TEMPLATE_BUTTON_GROUP, ...$options],
'dav_link' => $this->urlGenerator->generate(
'chill_docstore_dav_document_get',
[
'uuid' => $document->getUuid(),
'access_token' => $accessToken,
],
UrlGeneratorInterface::ABSOLUTE_URL,
),
'dav_link_expiration' => $this->davTokenProvider->getTokenExpiration($accessToken)->format('U'),
]);
}

View File

@@ -1,410 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Tests\Controller;
use Chill\DocStoreBundle\Controller\WebdavController;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Ramsey\Uuid\Uuid;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Security\Core\Security;
/**
* @internal
*
* @coversNothing
*/
class WebdavControllerTest extends KernelTestCase
{
use ProphecyTrait;
private \Twig\Environment $engine;
protected function setUp(): void
{
self::bootKernel();
$this->engine = self::$container->get(\Twig\Environment::class);
}
private function buildController(): WebdavController
{
$storedObjectManager = new MockedStoredObjectManager();
$security = $this->prophesize(Security::class);
$security->isGranted(Argument::in(['EDIT', 'SEE']), Argument::type(StoredObject::class))
->willReturn(true);
return new WebdavController($this->engine, $storedObjectManager, $security->reveal());
}
private function buildDocument(): StoredObject
{
$object = (new StoredObject())
->setType('application/vnd.oasis.opendocument.text');
$reflectionObject = new \ReflectionClass($object);
$reflectionObjectUuid = $reflectionObject->getProperty('uuid');
$reflectionObjectUuid->setValue($object, Uuid::fromString('716e6688-4579-4938-acf3-c4ab5856803b'));
return $object;
}
public function testGet(): void
{
$controller = $this->buildController();
$response = $controller->getDocument($this->buildDocument());
self::assertEquals(200, $response->getStatusCode());
self::assertEquals('abcde', $response->getContent());
self::assertContains('etag', $response->headers->keys());
self::assertStringContainsString('ab56b4', $response->headers->get('etag'));
}
public function testOptionsOnDocument(): void
{
$controller = $this->buildController();
$response = $controller->optionsDocument($this->buildDocument());
self::assertEquals(200, $response->getStatusCode());
self::assertContains('allow', $response->headers->keys());
foreach (explode(',', 'OPTIONS,GET,HEAD,PROPFIND') as $method) {
self::assertStringContainsString($method, $response->headers->get('allow'));
}
self::assertContains('dav', $response->headers->keys());
self::assertStringContainsString('1', $response->headers->get('dav'));
}
public function testOptionsOnDirectory(): void
{
$controller = $this->buildController();
$response = $controller->optionsDirectory($this->buildDocument());
self::assertEquals(200, $response->getStatusCode());
self::assertContains('allow', $response->headers->keys());
foreach (explode(',', 'OPTIONS,GET,HEAD,PROPFIND') as $method) {
self::assertStringContainsString($method, $response->headers->get('allow'));
}
self::assertContains('dav', $response->headers->keys());
self::assertStringContainsString('1', $response->headers->get('dav'));
}
/**
* @dataProvider generateDataPropfindDocument
*/
public function testPropfindDocument(string $requestContent, int $expectedStatusCode, string $expectedXmlResponse, string $message): void
{
$controller = $this->buildController();
$request = new Request([], [], [], [], [], [], $requestContent);
$request->setMethod('PROPFIND');
$response = $controller->propfindDocument($this->buildDocument(), '1234', $request);
self::assertEquals($expectedStatusCode, $response->getStatusCode());
self::assertContains('content-type', $response->headers->keys());
self::assertStringContainsString('text/xml', $response->headers->get('content-type'));
self::assertTrue((new \DOMDocument())->loadXML($response->getContent()), $message.' test that the xml response is a valid xml');
self::assertXmlStringEqualsXmlString($expectedXmlResponse, $response->getContent(), $message);
}
/**
* @dataProvider generateDataPropfindDirectory
*/
public function testPropfindDirectory(string $requestContent, int $expectedStatusCode, string $expectedXmlResponse, string $message): void
{
$controller = $this->buildController();
$request = new Request([], [], [], [], [], [], $requestContent);
$request->setMethod('PROPFIND');
$request->headers->add(['Depth' => '0']);
$response = $controller->propfindDirectory($this->buildDocument(), '1234', $request);
self::assertEquals($expectedStatusCode, $response->getStatusCode());
self::assertContains('content-type', $response->headers->keys());
self::assertStringContainsString('text/xml', $response->headers->get('content-type'));
self::assertTrue((new \DOMDocument())->loadXML($response->getContent()), $message.' test that the xml response is a valid xml');
self::assertXmlStringEqualsXmlString($expectedXmlResponse, $response->getContent(), $message);
}
public function testHeadDocument(): void
{
$controller = $this->buildController();
$response = $controller->headDocument($this->buildDocument());
self::assertEquals(200, $response->getStatusCode());
self::assertContains('content-length', $response->headers->keys());
self::assertContains('content-type', $response->headers->keys());
self::assertContains('etag', $response->headers->keys());
self::assertEquals('ab56b4d92b40713acc5af89985d4b786', $response->headers->get('etag'));
self::assertEquals('application/vnd.oasis.opendocument.text', $response->headers->get('content-type'));
self::assertEquals(5, $response->headers->get('content-length'));
}
public static function generateDataPropfindDocument(): iterable
{
$content =
<<<'XML'
<?xml version="1.0" encoding="UTF-8"?>
<propfind xmlns="DAV:"><prop><resourcetype xmlns="DAV:"/><IsReadOnly xmlns="http://ucb.openoffice.org/dav/props/"/><getcontenttype xmlns="DAV:"/><supportedlock xmlns="DAV:"/></prop></propfind>
XML;
$response =
<<<'XML'
<?xml version="1.0" encoding="utf-8"?>
<d:multistatus xmlns:d="DAV:" >
<d:response>
<d:href>/dav/1234/get/716e6688-4579-4938-acf3-c4ab5856803b/d</d:href>
<d:propstat>
<d:prop>
<d:resourcetype/>
<d:getcontenttype>application/vnd.oasis.opendocument.text</d:getcontenttype>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
<d:propstat>
<d:prop xmlns:ns0="http://ucb.openoffice.org/dav/props/">
<ns0:IsReadOnly/>
</d:prop>
<d:status>HTTP/1.1 404 Not Found</d:status>
</d:propstat>
</d:response>
</d:multistatus>
XML;
yield [$content, 207, $response, 'get IsReadOnly and contenttype from server'];
$content =
<<<'XML'
<?xml version="1.0" encoding="UTF-8"?>
<propfind xmlns="DAV:">
<prop>
<IsReadOnly xmlns="http://ucb.openoffice.org/dav/props/"/>
</prop>
</propfind>
XML;
$response =
<<<'XML'
<?xml version="1.0" encoding="utf-8"?>
<d:multistatus xmlns:d="DAV:">
<d:response>
<d:href>/dav/1234/get/716e6688-4579-4938-acf3-c4ab5856803b/d</d:href>
<d:propstat>
<d:prop xmlns:ns0="http://ucb.openoffice.org/dav/props/">
<ns0:IsReadOnly/>
</d:prop>
<d:status>HTTP/1.1 404 Not Found</d:status>
</d:propstat>
</d:response>
</d:multistatus>
XML;
yield [$content, 207, $response, 'get property IsReadOnly'];
yield [
<<<'XML'
<?xml version="1.0" encoding="UTF-8"?>
<propfind xmlns="DAV:">
<prop>
<BaseURI xmlns="http://ucb.openoffice.org/dav/props/"/>
</prop>
</propfind>
XML,
207,
<<<'XML'
<?xml version="1.0" encoding="utf-8"?>
<d:multistatus xmlns:d="DAV:">
<d:response>
<d:href>/dav/1234/get/716e6688-4579-4938-acf3-c4ab5856803b/d</d:href>
<d:propstat>
<d:prop xmlns:ns0="http://ucb.openoffice.org/dav/props/">
<ns0:BaseURI/>
</d:prop>
<d:status>HTTP/1.1 404 Not Found</d:status>
</d:propstat>
</d:response>
</d:multistatus>
XML,
'Test requesting an unknow property',
];
yield [
<<<'XML'
<?xml version="1.0" encoding="UTF-8"?>
<propfind xmlns="DAV:">
<prop>
<getlastmodified xmlns="DAV:"/>
</prop>
</propfind>
XML,
207,
<<<'XML'
<?xml version="1.0" encoding="utf-8"?>
<d:multistatus xmlns:d="DAV:">
<d:response>
<d:href>/dav/1234/get/716e6688-4579-4938-acf3-c4ab5856803b/d</d:href>
<d:propstat>
<d:prop>
<!-- the date scraped from a webserver is >Sun, 10 Sep 2023 14:10:23 GMT -->
<d:getlastmodified>Wed, 13 Sep 2023 14:15:00 +0200</d:getlastmodified>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
</d:multistatus>
XML,
'test getting the last modified date',
];
yield [
<<<'XML'
<?xml version="1.0" encoding="UTF-8"?>
<propfind xmlns="DAV:">
<propname/>
</propfind>
XML,
207,
<<<'XML'
<?xml version="1.0" encoding="utf-8"?>
<d:multistatus xmlns:d="DAV:">
<d:response>
<d:href>/dav/1234/get/716e6688-4579-4938-acf3-c4ab5856803b/d</d:href>
<d:propstat>
<d:prop>
<d:resourcetype/>
<d:creationdate/>
<d:getlastmodified>Wed, 13 Sep 2023 14:15:00 +0200</d:getlastmodified>
<!-- <d:getcontentlength/> -->
<d:getcontentlength>5</d:getcontentlength>
<!-- <d:getlastmodified/> -->
<d:getetag>"ab56b4d92b40713acc5af89985d4b786"</d:getetag>
<!--
<d:supportedlock/>
<d:lockdiscovery/>
-->
<!-- <d:getcontenttype/> -->
<d:getcontenttype>application/vnd.oasis.opendocument.text</d:getcontenttype>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
</d:multistatus>
XML,
'test finding all properties',
];
}
public static function generateDataPropfindDirectory(): iterable
{
yield [
<<<'XML'
<?xml version="1.0" encoding="UTF-8"?>
<propfind xmlns="DAV:"><prop><resourcetype xmlns="DAV:"/><IsReadOnly xmlns="http://ucb.openoffice.org/dav/props/"/><getcontenttype xmlns="DAV:"/><supportedlock xmlns="DAV:"/></prop></propfind>
XML,
207,
<<<'XML'
<?xml version="1.0" encoding="utf-8"?>
<d:multistatus xmlns:d="DAV:">
<d:response>
<d:href>/dav/1234/get/716e6688-4579-4938-acf3-c4ab5856803b/</d:href>
<d:propstat>
<d:prop>
<d:resourcetype><d:collection/></d:resourcetype>
<d:getcontenttype>httpd/unix-directory</d:getcontenttype>
<!--
<d:supportedlock>
<d:lockentry>
<d:lockscope><d:exclusive/></d:lockscope>
<d:locktype><d:write/></d:locktype>
</d:lockentry>
<d:lockentry>
<d:lockscope><d:shared/></d:lockscope>
<d:locktype><d:write/></d:locktype>
</d:lockentry>
</d:supportedlock>
-->
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
<d:propstat>
<d:prop xmlns:ns0="http://ucb.openoffice.org/dav/props/">
<ns0:IsReadOnly/>
</d:prop>
<d:status>HTTP/1.1 404 Not Found</d:status>
</d:propstat>
</d:response>
</d:multistatus>
XML,
'test resourceType and IsReadOnly ',
];
yield [
<<<'XML'
<?xml version="1.0" encoding="UTF-8"?>
<propfind xmlns="DAV:"><prop><CreatableContentsInfo xmlns="http://ucb.openoffice.org/dav/props/"/></prop></propfind>
XML,
207,
<<<'XML'
<?xml version="1.0" encoding="utf-8"?>
<d:multistatus xmlns:d="DAV:">
<d:response>
<d:href>/dav/1234/get/716e6688-4579-4938-acf3-c4ab5856803b/</d:href>
<d:propstat>
<d:prop xmlns:ns0="http://ucb.openoffice.org/dav/props/" >
<ns0:CreatableContentsInfo/>
</d:prop>
<d:status>HTTP/1.1 404 Not Found</d:status>
</d:propstat>
</d:response>
</d:multistatus>
XML,
'test creatableContentsInfo',
];
}
}
class MockedStoredObjectManager implements StoredObjectManagerInterface
{
public function getLastModified(StoredObject $document): \DateTimeInterface
{
return new \DateTimeImmutable('2023-09-13T14:15');
}
public function getContentLength(StoredObject $document): int
{
return 5;
}
public function read(StoredObject $document): string
{
return 'abcde';
}
public function write(StoredObject $document, string $clearContent): void
{
}
public function etag(StoredObject $document): string
{
return 'ab56b4d92b40713acc5af89985d4b786';
}
}

View File

@@ -1,134 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Tests\Dav\Request;
use Chill\DocStoreBundle\Dav\Request\PropfindRequestAnalyzer;
use PHPUnit\Framework\TestCase;
/**
* @internal
*
* @coversNothing
*/
class PropfindRequestAnalyzerTest extends TestCase
{
/**
* @dataProvider provideRequestedProperties
*/
public function testGetRequestedProperties(string $xml, array $expected): void
{
$analyzer = new PropfindRequestAnalyzer();
$request = new \DOMDocument();
$request->loadXML($xml);
$actual = $analyzer->getRequestedProperties($request);
foreach ($expected as $key => $value) {
if ('unknowns' === $key) {
continue;
}
self::assertArrayHasKey($key, $actual, "Check that key {$key} does exists in list of expected values");
self::assertEquals($value, $actual[$key], "Does the value match expected for key {$key}");
}
if (array_key_exists('unknowns', $expected)) {
self::assertEquals(count($expected['unknowns']), count($actual['unknowns']));
self::assertEqualsCanonicalizing($expected['unknowns'], $actual['unknowns']);
}
}
public function provideRequestedProperties(): iterable
{
yield [
<<<'XML'
<?xml version="1.0" encoding="UTF-8"?>
<propfind xmlns="DAV:">
<prop>
<BaseURI xmlns="http://ucb.openoffice.org/dav/props/"/>
</prop>
</propfind>
XML,
[
'resourceType' => false,
'contentType' => false,
'lastModified' => false,
'creationDate' => false,
'contentLength' => false,
'etag' => false,
'supportedLock' => false,
'unknowns' => [
['xmlns' => 'http://ucb.openoffice.org/dav/props/', 'prop' => 'BaseURI'],
],
],
];
yield [
<<<'XML'
<?xml version="1.0" encoding="UTF-8"?>
<propfind xmlns="DAV:">
<propname/>
</propfind>
XML,
[
'resourceType' => true,
'contentType' => true,
'lastModified' => true,
'creationDate' => true,
'contentLength' => true,
'etag' => true,
'supportedLock' => true,
'unknowns' => [],
],
];
yield [
<<<'XML'
<?xml version="1.0" encoding="UTF-8"?>
<propfind xmlns="DAV:">
<prop>
<getlastmodified xmlns="DAV:"/>
</prop>
</propfind>
XML,
[
'resourceType' => false,
'contentType' => false,
'lastModified' => true,
'creationDate' => false,
'contentLength' => false,
'etag' => false,
'supportedLock' => false,
'unknowns' => [],
],
];
yield [
<<<'XML'
<?xml version="1.0" encoding="UTF-8"?>
<propfind xmlns="DAV:"><prop><resourcetype xmlns="DAV:"/><IsReadOnly xmlns="http://ucb.openoffice.org/dav/props/"/><getcontenttype xmlns="DAV:"/><supportedlock xmlns="DAV:"/></prop></propfind>
XML,
[
'resourceType' => true,
'contentType' => true,
'lastModified' => false,
'creationDate' => false,
'contentLength' => false,
'etag' => false,
'supportedLock' => false,
'unknowns' => [
['xmlns' => 'http://ucb.openoffice.org/dav/props/', 'prop' => 'IsReadOnly'],
],
],
];
}
}

View File

@@ -1,123 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Tests\Security\Authorization;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectVoter;
use Chill\DocStoreBundle\Security\Guard\DavTokenAuthenticationEventSubscriber;
use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
/**
* @internal
*
* @coversNothing
*/
class StoredObjectVoterTest extends TestCase
{
use ProphecyTrait;
/**
* @dataProvider provideDataVote
*/
public function testVote(TokenInterface $token, null|object $subject, string $attribute, mixed $expected): void
{
$voter = new StoredObjectVoter();
self::assertEquals($expected, $voter->vote($token, $subject, [$attribute]));
}
public function provideDataVote(): iterable
{
yield [
$this->buildToken(StoredObjectRoleEnum::EDIT, new StoredObject()),
new \stdClass(),
'SOMETHING',
VoterInterface::ACCESS_ABSTAIN,
];
yield [
$this->buildToken(StoredObjectRoleEnum::EDIT, $so = new StoredObject()),
$so,
'SOMETHING',
VoterInterface::ACCESS_ABSTAIN,
];
yield [
$this->buildToken(StoredObjectRoleEnum::EDIT, $so = new StoredObject()),
$so,
StoredObjectRoleEnum::SEE->value,
VoterInterface::ACCESS_GRANTED,
];
yield [
$this->buildToken(StoredObjectRoleEnum::EDIT, $so = new StoredObject()),
$so,
StoredObjectRoleEnum::EDIT->value,
VoterInterface::ACCESS_GRANTED,
];
yield [
$this->buildToken(StoredObjectRoleEnum::SEE, $so = new StoredObject()),
$so,
StoredObjectRoleEnum::EDIT->value,
VoterInterface::ACCESS_DENIED,
];
yield [
$this->buildToken(StoredObjectRoleEnum::SEE, $so = new StoredObject()),
$so,
StoredObjectRoleEnum::SEE->value,
VoterInterface::ACCESS_GRANTED,
];
yield [
$this->buildToken(null, null),
new StoredObject(),
StoredObjectRoleEnum::SEE->value,
VoterInterface::ACCESS_DENIED,
];
yield [
$this->buildToken(null, null),
new StoredObject(),
StoredObjectRoleEnum::SEE->value,
VoterInterface::ACCESS_DENIED,
];
}
private function buildToken(StoredObjectRoleEnum $storedObjectRoleEnum = null, StoredObject $storedObject = null): TokenInterface
{
$token = $this->prophesize(TokenInterface::class);
if (null !== $storedObjectRoleEnum) {
$token->hasAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS)->willReturn(true);
$token->getAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS)->willReturn($storedObjectRoleEnum);
} else {
$token->hasAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS)->willReturn(false);
$token->getAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS)->willThrow(new \InvalidArgumentException());
}
if (null !== $storedObject) {
$token->hasAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT)->willReturn(true);
$token->getAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT)->willReturn($storedObject->getUuid()->toString());
} else {
$token->hasAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT)->willReturn(false);
$token->getAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT)->willThrow(new \InvalidArgumentException());
}
return $token->reveal();
}
}

View File

@@ -1,55 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Tests\Security\Guard;
use Chill\DocStoreBundle\Security\Guard\DavOnUrlTokenExtractor;
use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Psr\Log\NullLogger;
use Symfony\Component\HttpFoundation\Request;
/**
* @internal
*
* @coversNothing
*/
class DavOnUrlTokenExtractorTest extends TestCase
{
use ProphecyTrait;
/**
* @dataProvider provideDataUri
*/
public function testExtract(string $uri, false|string $expected): void
{
$request = $this->prophesize(Request::class);
$request->getRequestUri()->willReturn($uri);
$extractor = new DavOnUrlTokenExtractor(new NullLogger());
$actual = $extractor->extract($request->reveal());
self::assertEquals($expected, $actual);
}
/**
* @phpstan-pure
*/
public static function provideDataUri(): iterable
{
yield ['/dav/123456789/get/d07d2230-5326-11ee-8fd4-93696acf5ea1/d', '123456789'];
yield ['/dav/123456789', '123456789'];
yield ['/not-dav/123456978', false];
yield ['/dav', false];
yield ['/', false];
}
}

View File

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

View File

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

View File

@@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\EventBundle\Controller;
use Chill\EventBundle\Entity\Event;
use Chill\EventBundle\Entity\EventType;
use Chill\EventBundle\Repository\EventACLAwareRepositoryInterface;
use Chill\EventBundle\Repository\EventTypeRepository;
use Chill\MainBundle\Pagination\PaginatorFactoryInterface;
use Chill\MainBundle\Templating\Listing\FilterOrderHelper;
use Chill\MainBundle\Templating\Listing\FilterOrderHelperFactory;
use Chill\MainBundle\Templating\TranslatableStringHelperInterface;
use Chill\PersonBundle\Form\Type\PickPersonDynamicType;
use Symfony\Component\Form\Extension\Core\Type\FormType;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Twig\Environment;
final readonly class EventListController
{
public function __construct(
private Environment $environment,
private EventACLAwareRepositoryInterface $eventACLAwareRepository,
private EventTypeRepository $eventTypeRepository,
private FilterOrderHelperFactory $filterOrderHelperFactory,
private FormFactoryInterface $formFactory,
private PaginatorFactoryInterface $paginatorFactory,
private TranslatableStringHelperInterface $translatableStringHelper,
private UrlGeneratorInterface $urlGenerator,
) {
}
/**
* @Route("{_locale}/event/event/list", name="chill_event_event_list")
*/
public function __invoke(): Response
{
$filter = $this->buildFilterOrder();
$filterData = [
'q' => (string) $filter->getQueryString(),
'dates' => $filter->getDateRangeData('dates'),
'event_types' => $filter->getEntityChoiceData('event_types'),
];
$total = $this->eventACLAwareRepository->countAllViewable($filterData);
$pagination = $this->paginatorFactory->create($total);
$events = $this->eventACLAwareRepository->findAllViewable($filterData, $pagination->getCurrentPageFirstItemNumber(), $pagination->getItemsPerPage());
$eventForms = [];
foreach ($events as $event) {
$eventForms[$event->getId()] = $this->createAddParticipationByPersonForm($event)->createView();
}
return new Response($this->environment->render(
'@ChillEvent/Event/page_list.html.twig',
[
'events' => $events,
'pagination' => $pagination,
'eventForms' => $eventForms,
'filter' => $filter,
]
));
}
private function buildFilterOrder(): FilterOrderHelper
{
$types = $this->eventTypeRepository->findAllActive();
$builder = $this->filterOrderHelperFactory->create(__METHOD__);
$builder
->addDateRange('dates', 'event.filter.event_dates')
->addSearchBox(['name'])
->addEntityChoice('event_types', 'event.filter.event_types', EventType::class, $types, [
'choice_label' => fn (EventType $e) => $this->translatableStringHelper->localize($e->getName()),
]);
return $builder->build();
}
private function createAddParticipationByPersonForm(Event $event): FormInterface
{
$builder = $this->formFactory
->createNamedBuilder(
'',
FormType::class,
null,
[
'method' => 'GET',
'action' => $this->urlGenerator->generate('chill_event_participation_new'),
'csrf_protection' => false,
]
);
$builder->add('person_id', PickPersonDynamicType::class, [
'as_id' => true,
'multiple' => false,
'submit_on_adding_new_entity' => true,
'label' => 'Add a participation',
]);
$builder->add('event_id', HiddenType::class, [
'data' => $event->getId(),
]);
return $builder->getForm();
}
}

View File

@@ -14,7 +14,10 @@ namespace Chill\EventBundle\Controller;
use Chill\EventBundle\Entity\Event;
use Chill\EventBundle\Entity\Participation;
use Chill\EventBundle\Form\ParticipationType;
use Chill\EventBundle\Repository\EventRepository;
use Chill\EventBundle\Security\Authorization\ParticipationVoter;
use Chill\PersonBundle\Repository\PersonRepository;
use Chill\PersonBundle\Security\Authorization\PersonVoter;
use Doctrine\Common\Collections\Collection;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -28,13 +31,17 @@ use Symfony\Contracts\Translation\TranslatorInterface;
/**
* Class ParticipationController.
*/
class ParticipationController extends AbstractController
final class ParticipationController extends AbstractController
{
/**
* ParticipationController constructor.
*/
public function __construct(private readonly LoggerInterface $logger, private readonly TranslatorInterface $translator)
{
public function __construct(
private readonly LoggerInterface $logger,
private readonly TranslatorInterface $translator,
private readonly EventRepository $eventRepository,
private readonly PersonRepository $personRepository,
) {
}
/**
@@ -230,6 +237,7 @@ class ParticipationController extends AbstractController
return $this->render('@ChillEvent/Participation/new.html.twig', [
'form' => $form->createView(),
'participation' => $participation,
'ignored_participations' => [],
]);
}
@@ -539,7 +547,7 @@ class ParticipationController extends AbstractController
* If the request is multiple, the $participation object is cloned.
* Limitations: the $participation should not be persisted.
*
* @return Participation|Participation[] return one single participation if $multiple == false
* @return Participation|list<Participation> return one single participation if $multiple == false
*
* @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException if the event/person is not found
* @throws \Symfony\Component\Security\Core\Exception\AccessDeniedException if the user does not have access to event/person
@@ -556,30 +564,25 @@ class ParticipationController extends AbstractController
}
$event_id = $request->query->getInt('event_id', 0); // sf4 check:
// prevent error: `Argument 2 passed to ::getInt() must be of the type int, null given`
if (null !== $event_id) {
$event = $em->getRepository(Event::class)
->find($event_id);
$event = $this->eventRepository->find($event_id);
if (null === $event) {
throw $this->createNotFoundException('The event with id '.$event_id.' is not found');
}
$this->denyAccessUnlessGranted(
'CHILL_EVENT_SEE',
$event,
'The user is not allowed to see the event'
);
$participation->setEvent($event);
if (null === $event) {
throw $this->createNotFoundException('The event with id '.$event_id.' is not found');
}
$this->denyAccessUnlessGranted(
'CHILL_EVENT_SEE',
$event,
'The user is not allowed to see the event'
);
$participation->setEvent($event);
// this script should be able to handle multiple, so we translate
// single person_id in an array
$persons_ids = $request->query->has('person_id') ?
[$request->query->getInt('person_id', 0)] // sf4 check:
// prevent error: `Argument 2 passed to ::getInt() must be of the type int, null given`
[$request->query->get('person_id', 0)]
: explode(',', (string) $request->query->get('persons_ids'));
$participations = [];
@@ -588,15 +591,14 @@ class ParticipationController extends AbstractController
$participation = \count($persons_ids) > 1 ? clone $participation : $participation;
if (null !== $person_id) {
$person = $em->getRepository(\Chill\PersonBundle\Entity\Person::class)
->find($person_id);
$person = $this->personRepository->find($person_id);
if (null === $person) {
throw $this->createNotFoundException('The person with id '.$person_id.' is not found');
}
$this->denyAccessUnlessGranted(
'CHILL_PERSON_SEE',
PersonVoter::SEE,
$person,
'The user is not allowed to see the person'
);

View File

@@ -12,6 +12,7 @@ declare(strict_types=1);
namespace Chill\EventBundle\DependencyInjection;
use Chill\EventBundle\Security\Authorization\EventVoter;
use Chill\EventBundle\Security\Authorization\ParticipationVoter;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface;
@@ -33,10 +34,8 @@ class ChillEventExtension extends Extension implements PrependExtensionInterface
$loader = new Loader\YamlFileLoader($container, new FileLocator(__DIR__.'/../config'));
$loader->load('services.yaml');
$loader->load('services/authorization.yaml');
$loader->load('services/controller.yaml');
$loader->load('services/fixtures.yaml');
$loader->load('services/forms.yaml');
$loader->load('services/menu.yaml');
$loader->load('services/repositories.yaml');
$loader->load('services/search.yaml');
$loader->load('services/timeline.yaml');
@@ -61,6 +60,8 @@ class ChillEventExtension extends Extension implements PrependExtensionInterface
EventVoter::SEE_DETAILS => [EventVoter::SEE],
EventVoter::UPDATE => [EventVoter::SEE_DETAILS],
EventVoter::CREATE => [EventVoter::SEE_DETAILS],
ParticipationVoter::SEE_DETAILS => [ParticipationVoter::SEE],
ParticipationVoter::UPDATE => [ParticipationVoter::SEE_DETAILS],
],
]);
}

View File

@@ -11,15 +11,23 @@ declare(strict_types=1);
namespace Chill\EventBundle\Entity;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\MainBundle\Doctrine\Model\TrackCreationInterface;
use Chill\MainBundle\Doctrine\Model\TrackCreationTrait;
use Chill\MainBundle\Doctrine\Model\TrackUpdateInterface;
use Chill\MainBundle\Doctrine\Model\TrackUpdateTrait;
use Chill\MainBundle\Entity\Center;
use Chill\MainBundle\Entity\Embeddable\CommentEmbeddable;
use Chill\MainBundle\Entity\HasCenterInterface;
use Chill\MainBundle\Entity\HasScopeInterface;
use Chill\MainBundle\Entity\Location;
use Chill\MainBundle\Entity\Scope;
use Chill\MainBundle\Entity\User;
use DateTime;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
/**
* Class Event.
@@ -30,10 +38,15 @@ use Doctrine\ORM\Mapping as ORM;
*
* @ORM\HasLifecycleCallbacks
*/
class Event implements HasCenterInterface, HasScopeInterface
class Event implements HasCenterInterface, HasScopeInterface, TrackCreationInterface, TrackUpdateInterface
{
use TrackCreationTrait;
use TrackUpdateTrait;
/**
* @ORM\ManyToOne(targetEntity="Chill\MainBundle\Entity\Center")
* @ORM\ManyToOne(targetEntity="Chill\MainBundle\Entity\Center")A
*
* @Assert\NotNull()
*/
private ?Center $center = null;
@@ -63,6 +76,8 @@ class Event implements HasCenterInterface, HasScopeInterface
/**
* @ORM\Column(type="string", length=150)
*
* @Assert\NotBlank()
*/
private ?string $name = null;
@@ -77,15 +92,45 @@ class Event implements HasCenterInterface, HasScopeInterface
/**
* @ORM\ManyToOne(targetEntity="Chill\EventBundle\Entity\EventType")
*
* @Assert\NotNull()
*/
private ?EventType $type = null;
/**
* @ORM\Embedded(class=CommentEmbeddable::class, columnPrefix="comment_")
*/
private CommentEmbeddable $comment;
/**
* @ORM\ManyToOne(targetEntity=Location::class)
*
* @ORM\JoinColumn(nullable=true)
*/
private ?Location $location = null;
/**
* @var Collection<StoredObject>
*
* @ORM\ManyToMany(targetEntity=StoredObject::class, cascade={"persist","refresh"})
*
* @ORM\JoinTable("chill_event_event_documents")
*/
private Collection $documents;
/**
* @ORM\Column(type="decimal", precision=10, scale=4, nullable=true, options={"default": null})
*/
private string $organizationCost = '0.0';
/**
* Event constructor.
*/
public function __construct()
{
$this->participations = new ArrayCollection();
$this->documents = new ArrayCollection();
$this->comment = new CommentEmbeddable();
}
/**
@@ -100,6 +145,22 @@ class Event implements HasCenterInterface, HasScopeInterface
return $this;
}
public function addDocument(StoredObject $storedObject): self
{
if ($this->documents->contains($storedObject)) {
$this->documents[] = $storedObject;
}
return $this;
}
public function removeDocument(StoredObject $storedObject): self
{
$this->documents->removeElement($storedObject);
return $this;
}
/**
* @return Center
*/
@@ -259,4 +320,44 @@ class Event implements HasCenterInterface, HasScopeInterface
return $this;
}
public function getComment(): CommentEmbeddable
{
return $this->comment;
}
public function setComment(CommentEmbeddable $comment): void
{
$this->comment = $comment;
}
public function getLocation(): ?Location
{
return $this->location;
}
public function setLocation(?Location $location): void
{
$this->location = $location;
}
public function getDocuments(): Collection
{
return $this->documents;
}
public function setDocuments(Collection $documents): void
{
$this->documents = $documents;
}
public function getOrganizationCost(): string
{
return $this->organizationCost;
}
public function setOrganizationCost(string $organizationCost): void
{
$this->organizationCost = $organizationCost;
}
}

View File

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

View File

@@ -11,12 +11,18 @@ declare(strict_types=1);
namespace Chill\EventBundle\Form;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Form\StoredObjectType;
use Chill\EventBundle\Form\Type\PickEventTypeType;
use Chill\MainBundle\Entity\Center;
use Chill\MainBundle\Form\Type\ChillCollectionType;
use Chill\MainBundle\Form\Type\ChillDateTimeType;
use Chill\MainBundle\Form\Type\CommentType;
use Chill\MainBundle\Form\Type\PickUserLocationType;
use Chill\MainBundle\Form\Type\ScopePickerType;
use Chill\MainBundle\Form\Type\UserPickerType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\MoneyType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
@@ -47,6 +53,28 @@ class EventType extends AbstractType
'class' => '',
],
'required' => false,
])
->add('location', PickUserLocationType::class, [
'label' => 'event.fields.location',
])
->add('comment', CommentType::class, [
'label' => 'Comment',
'required' => false,
])
->add('documents', ChillCollectionType::class, [
'entry_type' => StoredObjectType::class,
'entry_options' => [
'has_title' => true,
],
'allow_add' => true,
'allow_delete' => true,
'delete_empty' => fn (StoredObject $storedObject): bool => '' === $storedObject->getFilename(),
'button_remove_label' => 'event.form.remove_document',
'button_add_label' => 'event.form.add_document',
])
->add('organizationCost', MoneyType::class, [
'label' => 'event.fields.organizationCost',
'help' => 'event.form.organisationCost_help',
]);
}

View File

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

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\EventBundle\Menu;
use Chill\EventBundle\Security\Authorization\EventVoter;
use Chill\MainBundle\Routing\LocalMenuBuilderInterface;
use Knp\Menu\MenuItem;
use Symfony\Component\Security\Core\Security;
use Symfony\Contracts\Translation\TranslatorInterface;
final readonly class SectionMenuBuilder implements LocalMenuBuilderInterface
{
public function __construct(
private Security $security,
private TranslatorInterface $translator,
) {
}
public function buildMenu($menuId, MenuItem $menu, array $parameters)
{
if ($this->security->isGranted(EventVoter::SEE)) {
$menu->addChild(
$this->translator->trans('Events'),
[
'route' => 'chill_event_event_list',
]
)->setExtras([
'order' => 250,
]);
}
}
public static function getMenuIds(): array
{
return ['section'];
}
}

View File

@@ -0,0 +1,142 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\EventBundle\Repository;
use Chill\EventBundle\Entity\Event;
use Chill\EventBundle\Entity\Participation;
use Chill\EventBundle\Security\Authorization\EventVoter;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Security\Authorization\AuthorizationHelperForCurrentUserInterface;
use Chill\PersonBundle\Entity\Person;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\NonUniqueResultException;
use Doctrine\ORM\NoResultException;
use Doctrine\ORM\QueryBuilder;
use Symfony\Component\Security\Core\Security;
final readonly class EventACLAwareRepository implements EventACLAwareRepositoryInterface
{
public function __construct(
private AuthorizationHelperForCurrentUserInterface $authorizationHelperForCurrentUser,
private EntityManagerInterface $entityManager,
private Security $security,
) {
}
/**
* @throws NonUniqueResultException
* @throws NoResultException
*/
public function countAllViewable(array $filters): int
{
if (!$this->security->getUser() instanceof User) {
return 0;
}
$qb = $this->buildQueryByAllViewable($filters);
$this->addFilters($filters, $qb);
$qb->select('COUNT(event.id)');
return $qb->getQuery()->getSingleScalarResult();
}
public function findAllViewable(array $filters, int $offset = 0, int $limit = 50): array
{
if (!$this->security->getUser() instanceof User) {
return [];
}
$qb = $this->buildQueryByAllViewable($filters)->select('event');
$this->addFilters($filters, $qb);
$qb->setFirstResult($offset)->setMaxResults($limit);
$qb->addOrderBy('event.date', 'DESC');
return $qb->getQuery()->getResult();
}
private function addFilters(array $filters, QueryBuilder $qb): void
{
if (($filters['q'] ?? '') !== '') {
$qb->andWhere('event.name LIKE :content');
$qb->setParameter('content', '%'.$filters['q'].'%');
}
if (array_key_exists('dates', $filters)) {
$dates = $filters['dates'];
if (null !== ($dates['from'] ?? null)) {
$qb->andWhere('event.date >= :date_from');
$qb->setParameter('date_from', $dates['from']);
}
if (null !== ($dates['to'] ?? null)) {
$qb->andWhere('event.date <= :date_to');
$qb->setParameter('date_to', $dates['to']);
}
}
if (0 < count($filters['event_types'] ?? [])) {
$qb->andWhere('event.type IN (:event_types)');
$qb->setParameter('event_types', $filters['event_types']);
}
}
public function buildQueryByAllViewable(array $filters): QueryBuilder
{
$qb = $this->entityManager->createQueryBuilder();
$qb->from(Event::class, 'event');
$aclConditions = $qb->expr()->orX();
$i = 0;
foreach ($this->authorizationHelperForCurrentUser->getReachableCenters(EventVoter::SEE) as $center) {
foreach ($this->authorizationHelperForCurrentUser->getReachableScopes(EventVoter::SEE, $center) as $scopes) {
$aclConditions->add(
$qb->expr()->andX(
'event.circle IN (:scopes_'.$i.')',
$qb->expr()->orX(
'event.center = :center_'.$i,
$qb->expr()->exists(
'SELECT 1 FROM '.Participation::class.' participation_'.$i.' JOIN participation_'.$i.'.event event_'.$i.
' JOIN '.Person\PersonCenterHistory::class.' person_center_history_'.$i.
' WITH IDENTITY(person_center_history_'.$i.'.person) = IDENTITY(participation_'.$i.'.person) '.
' AND event_'.$i.'.date <= person_center_history_'.$i.'.startDate AND (person_center_history_'.$i.'.endDate IS NULL OR person_center_history_'.$i.'.endDate > event_'.$i.'.date) '.
' WHERE participation_'.$i.'.event = event'
)
)
)
);
$qb->setParameter('scopes_'.$i, $scopes);
$qb->setParameter('center_'.$i, $center);
++$i;
}
}
if (0 === $i) {
$aclConditions->add('FALSE = TRUE');
}
$qb
->andWhere(
$qb->expr()->orX(
'event.createdBy = :user',
$aclConditions
)
);
$qb->setParameter('user', $this->security->getUser());
return $qb;
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\EventBundle\Repository;
use Chill\EventBundle\Entity\Event;
use Chill\EventBundle\Entity\EventType;
interface EventACLAwareRepositoryInterface
{
/**
* @param array{q?: string, dates?: array{from?: \DateTimeImmutable|null, to?: \DateTimeImmutable|null}, event_types?: list<EventType>} $filters
*/
public function countAllViewable(array $filters): int;
/**
* @param array{q?: string, dates?: array{from?: \DateTimeImmutable|null, to?: \DateTimeImmutable|null}, event_types?: list<EventType>} $filters
*
* @return list<Event>
*/
public function findAllViewable(array $filters, int $offset = 0, int $limit = 50): array;
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\EventBundle\Repository;
use Chill\EventBundle\Entity\EventType;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\Persistence\ManagerRegistry;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* @extends ServiceEntityRepository<EventType>
*/
final class EventTypeRepository extends ServiceEntityRepository
{
public function __construct(
ManagerRegistry $registry,
private readonly EntityManagerInterface $entityManager,
private readonly TranslatorInterface $translator
) {
parent::__construct($registry, EventType::class);
}
/**
* @return list<EventType>
*/
public function findAllActive(): array
{
$dql = 'SELECT et FROM '.EventType::class.' et WHERE et.active = TRUE ORDER BY JSON_EXTRACT(et.name, :lang)';
return $this->entityManager->createQuery($dql)
->setParameter('lang', $this->translator->getLocale())
->getResult();
}
}

View File

@@ -14,15 +14,16 @@
{{ form_row(edit_form.type, { 'label': 'Event type' }) }}
{{ form_row(edit_form.moderator) }}
{{ form_row(edit_form.location) }}
{{ form_row(edit_form.organizationCost) }}
<ul class="record_actions">
{{ form_row(edit_form.comment) }}
{{ form_row(edit_form.documents) }}
<ul class="record_actions sticky-form-buttons">
<li class="cancel">
{% set returnPath = app.request.get('return_path') %}
{% set returnLabel = app.request.get('return_label') %}
<a href="{{ returnPath |default( path('chill_event_list_most_recent') ) }}" class="btn btn-cancel">
{{ returnLabel |default('Back to the most recent events'|trans) }}
<a href="{{ chill_return_path_or('chill_event_event_list') }}" class="btn btn-cancel">
{{ 'List of events'|trans|chill_return_path_label }}
</a>
</li>
<li>

View File

@@ -24,85 +24,89 @@
{% block content %}
<h2>{{ 'Events participation' |trans }}</h2>
<table class="table table-striped table-bordered border-dark align-middle mt-3 events">
<thead>
<tr>
<th class="chill-green">{{ 'Date'|trans }}</th>
<th class="chill-red">{{ 'Name'|trans }}</th>
<th class="chill-orange">{{ 'Event type'|trans }}</th>
<th class="chill-red">{{ 'Role'|trans }}</th>
<th class="chill-green">{{ 'Status'|trans }}</th>
<th> </th>
</tr>
</thead>
<tbody>
{% for participation in participations %}
<tr>
<td>{{ participation.event.date|format_date('short') }}</td>
<td>{{ participation.event.name }}</td>
<td>{{ participation.event.type.name|localize_translatable_string }}</td>
<td>{{ participation.role.name|localize_translatable_string }}</td>
<td>{{ participation.status.name|localize_translatable_string }}</td>
<td>
<div class="btn-group" role="group" aria-label="Button group actions">
{% if participations|length == 0 %}
<p class="chill-no-data-statement">{{ 'Any participation for this person'|trans }}</p>
{% else %}
<table class="table table-striped table-bordered border-dark align-middle mt-3 events">
<thead>
<tr>
<th class="chill-green">{{ 'Date'|trans }}</th>
<th class="chill-red">{{ 'Name'|trans }}</th>
<th class="chill-orange">{{ 'Event type'|trans }}</th>
<th class="chill-red">{{ 'Role'|trans }}</th>
<th class="chill-green">{{ 'Status'|trans }}</th>
<th> </th>
</tr>
</thead>
<tbody>
{% for participation in participations %}
<tr>
<td>{{ participation.event.date|format_date('short') }}</td>
<td>{{ participation.event.name }}</td>
<td>{{ participation.event.type.name|localize_translatable_string }}</td>
<td>{{ participation.role.name|localize_translatable_string }}</td>
<td>{{ participation.status.name|localize_translatable_string }}</td>
<td>
<div class="btn-group" role="group" aria-label="Button group actions">
{% set currentPath = path(app.request.attributes.get('_route'), app.request.attributes.get('_route_params')) %}
{% set returnLabel = 'Back to %person% events'|trans({ '%person%' : currentPerson } ) %}
{% set currentPath = path(app.request.attributes.get('_route'), app.request.attributes.get('_route_params')) %}
{% set returnLabel = 'Back to %person% events'|trans({ '%person%' : currentPerson } ) %}
{% if is_granted('CHILL_EVENT_SEE_DETAILS', participation.event) %}
<a href="{{ path('chill_event__event_show', { 'event_id' : participation.event.id, 'return_path' : currentPath, 'return_label' : returnLabel } ) }}"
class="btn btn-primary btn-sm" title="{{ 'See details of the event'|trans }}">
<i class="fa fa-fw fa-eye"></i>
</a>
{% endif %}
{% if is_granted('CHILL_EVENT_SEE_DETAILS', participation.event) %}
<a href="{{ path('chill_event__event_show', { 'event_id' : participation.event.id, 'return_path' : currentPath, 'return_label' : returnLabel } ) }}"
class="btn btn-primary btn-sm" title="{{ 'See details of the event'|trans }}">
<i class="fa fa-fw fa-eye"></i>
</a>
{% endif %}
{% if is_granted('CHILL_EVENT_UPDATE', participation.event)
and is_granted('CHILL_EVENT_PARTICIPATION_UPDATE', participation) %}
{% if is_granted('CHILL_EVENT_UPDATE', participation.event)
and is_granted('CHILL_EVENT_PARTICIPATION_UPDATE', participation) %}
<div class="btn-group" role="group">
<button class="btn btn-sm btn-warning dropdown-toggle" type="button" id="dropdownEdit" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fa fa-pencil"></i>
</button>
<ul class="dropdown-menu" aria-labelledby="dropdownEdit">
<li>
<div class="btn-group" role="group">
<button class="btn btn-sm btn-warning dropdown-toggle" type="button" id="dropdownEdit" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fa fa-pencil"></i>
</button>
<ul class="dropdown-menu" aria-labelledby="dropdownEdit">
<li>
<a href="{{ path('chill_event__event_edit', { 'event_id' : participation.event.id, 'return_path' : currentPath, 'return_label' : returnLabel }) }}"
class="dropdown-item">
{{ 'Edit the event'|trans }}
</a>
</li>
<li>
<a href="{{ path('chill_event_participation_edit', { 'participation_id' : participation.id, 'return_path' : currentPath, 'return_label' : returnLabel }) }}"
class="dropdown-item">
{{ 'Edit the participation'|trans }}
</a>
</li>
</ul>
</div>
{% else %}
{% if is_granted('CHILL_EVENT_UPDATE', participation.event) %}
<a href="{{ path('chill_event__event_edit', { 'event_id' : participation.event.id, 'return_path' : currentPath, 'return_label' : returnLabel }) }}"
class="dropdown-item">
class="btn btn-warning btn-sm">
{{ 'Edit the event'|trans }}
</a>
</li>
<li>
{% endif %}
{% if is_granted('CHILL_EVENT_PARTICIPATION_UPDATE', participation) %}
<a href="{{ path('chill_event_participation_edit', { 'participation_id' : participation.id, 'return_path' : currentPath, 'return_label' : returnLabel }) }}"
class="dropdown-item">
class="btn btn-warning btn-sm">
{{ 'Edit the participation'|trans }}
</a>
</li>
</ul>
</div>
{% else %}
{% endif %}
{% if is_granted('CHILL_EVENT_UPDATE', participation.event) %}
<a href="{{ path('chill_event__event_edit', { 'event_id' : participation.event.id, 'return_path' : currentPath, 'return_label' : returnLabel }) }}"
class="btn btn-warning btn-sm">
{{ 'Edit the event'|trans }}
</a>
{% endif %}
{% if is_granted('CHILL_EVENT_PARTICIPATION_UPDATE', participation) %}
<a href="{{ path('chill_event_participation_edit', { 'participation_id' : participation.id, 'return_path' : currentPath, 'return_label' : returnLabel }) }}"
class="btn btn-warning btn-sm">
{{ 'Edit the participation'|trans }}
</a>
{% endif %}
{% endif %}
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</tbody>
</table>
{% endif %}
{% if participations|length < paginator.getTotalItems %}
{{ chill_pagination(paginator) }}

View File

@@ -1,5 +1,13 @@
{% extends '@ChillEvent/layout.html.twig' %}
{% block js %}
{{ encore_entry_script_tags('mod_async_upload') }}
{% endblock %}
{% block css %}
{{ encore_entry_link_tags('mod_async_upload') }}
{% endblock %}
{% block title 'Event creation'|trans %}
{% block event_content -%}
@@ -14,8 +22,13 @@
{{ form_row(form.type, { 'label': 'Event type' }) }}
{{ form_row(form.moderator) }}
{{ form_row(form.location) }}
{{ form_row(form.organizationCost) }}
<ul class="record_actions">
{{ form_row(form.comment) }}
{{ form_row(form.documents) }}
<ul class="record_actions sticky-form-buttons">
<li class="cancel">
<a href="{{ path('chill_event_list_most_recent') }}" class="btn btn-cancel">
{{ 'Back to the most recent events'|trans }}
@@ -25,7 +38,7 @@
{{ form_widget(form.submit, { 'attr' : { 'class' : 'btn btn-create' } }) }}
</li>
</ul>
{{ form_end(form) }}
</div>
{% endblock %}

View File

@@ -0,0 +1,92 @@
{% extends '@ChillEvent/layout.html.twig' %}
{% block title 'Events'|trans %}
{% block js %}
{{ parent() }}
{{ encore_entry_script_tags('mod_pickentity_type') }}
{% endblock %}
{% block css %}
{{ parent() }}
{{ encore_entry_link_tags('mod_pickentity_type') }}
{% endblock %}
{% block content %}
<h1>{{ block('title') }}</h1>
{{ filter|chill_render_filter_order_helper }}
{# {% if is_granted('CHILL_EVENT_CREATE') %} #}
<ul class="record_actions">
<li><a class="btn btn-create" href="{{ chill_path_add_return_path('chill_event__event_new_pickcenter') }}">{{ 'Add an event'|trans }}</a></li>
</ul>
{# {% endif %} #}
{% if events|length > 0 %}
<div class="flex-table">
{% for e in events %}
<div class="item-bloc">
<div class="item-row">
<div class="item-col">
<div class="denomination h2">
{{ e.name }}
</div>
<p>{{ e.type.name|localize_translatable_string }}</p>
{% if e.moderator is not null %}
<p>{{ 'Moderator'|trans }}: {{ e.moderator|chill_entity_render_box }}</p>
{% endif %}
</div>
<div class="item-col">
<div class="container" style="text-align: right;">
<p>{{ e.date|format_datetime('medium', 'medium') }}</p>
<p>{{ 'count participations to this event'|trans({'count': e.participations|length}) }}</p>
</div>
</div>
</div>
{% if e.participations|length > 0 %}
<div class="item-row separator">
<strong>{{ 'Participations'|trans }}&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,12 +4,28 @@
{% import '@ChillPerson/Person/macro.html.twig' as person_macro %}
{% block js %}
{{ parent() }}
{{ encore_entry_script_tags('mod_pickentity_type') }}
{{ encore_entry_script_tags('mod_document_action_buttons_group') }}
{% endblock %}
{% block css %}
{{ parent() }}
{{ encore_entry_link_tags('mod_pickentity_type') }}
{{ encore_entry_link_tags('mod_document_action_buttons_group') }}
{% endblock %}
{% block event_content -%}
<div class="col-10">
<h1>{{ 'Details of an event'|trans }}</h1>
<table class="table table-bordered border-dark align-middle">
<tbody>
<tr>
<th>{{ 'Circle'|trans }}</th>
<td>{{ event.circle.name|localize_translatable_string }}</td>
</tr>
<tr>
<th>{{ 'Name'|trans }}</th>
<td>{{ event.name }}</td>
@@ -22,42 +38,62 @@
<th>{{ 'Event type'|trans }}</th>
<td>{{ event.type.name|localize_translatable_string }}</td>
</tr>
<tr>
<th>{{ 'Circle'|trans }}</th>
<td>{{ event.circle.name|localize_translatable_string }}</td>
</tr>
<tr>
<th>{{ 'Moderator'|trans }}</th>
<td>{{ event.moderator|trans|default('-') }}</td>
</tr>
<tr>
<th>{{ 'event.fields.organizationCost'|trans }}</th>
<td>{{ event.organizationCost|format_currency('EUR') }}</td>
</tr>
<tr>
<th>{{ 'event.fields.location'|trans }}</th>
<td>
{% if event.location is not null %}
{{ event.location.name }}
{% if event.location.address is not same as(null) %}{{ event.location.address|chill_entity_render_box({'multiline': false, 'with_picto': (event.location.name is empty), 'details_button': true}) }}{% endif %}
{% else %}
<span class="chill-no-data-statement">{{ 'Any location for this event'|trans }}</span>
{% endif %}
</td>
</tr>
</tbody>
</table>
{% if event.documents|length > 0 %}
<div>
<p><strong>{{ 'event.fields.documents'|trans }}</strong></p>
<ul>
{% for d in event.documents %}
<li class="document-list-item">{{ d.title|chill_print_or_message('document.Any title') }} {{ d|chill_document_button_group(d.title, is_granted('CHILL_EVENT_SEE_DETAILS', event), {small: false}) }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% if not event.comment.empty %}
<div>
{{ event.comment|chill_entity_render_box({
'disable_markdown': false,
'metadata': true,
}) }}
</div>
{% endif %}
<ul class="record_actions">
{% set returnPath = app.request.get('return_path') %}
{% set returnLabel = app.request.get('return_label') %}
{% if returnPath and returnLabel %}
<li class="cancel">
<a href="{{ returnPath }}" class="btn btn-cancel">{{ returnLabel }}</a>
</li>
<li>
<a href="{{ path('chill_event__event_edit', {
'event_id': event.id,
'return_path': app.request.getRequestUri,
'return_label': 'Back to details of the event'|trans
}) }}" class="btn btn-edit">{{ 'Edit'|trans }}
</a>
</li>
{% else %}
<li>
<a href="{{ path('chill_event__event_edit', {'event_id': event.id }) }}" class="btn btn-edit">
{{ 'Edit'|trans }}
</a>
</li>
{% endif %}
<li class="cancel">
<a href="{{ chill_return_path_or('chill_event_event_list') }}" class="btn btn-cancel">{{ 'Back to the list'|trans|chill_return_path_label }}</a>
</li>
<li>
<a href="{{ chill_path_add_return_path('chill_event__event_edit', {'event_id': event.id }, false, 'See'|trans) }}" class="btn btn-edit">
{{ 'Edit'|trans }}
</a>
</li>
<li>
<a href="{{ path('chill_event__event_delete', {'event_id' : event.id } ) }}"
class="btn btn-delete">{{ 'Delete event'|trans }}</a>
@@ -83,7 +119,15 @@
<tbody>
{% for participation in event.participations %}
<tr>
<td>{{ person_macro.render(participation.person) }}</td>
<td>
{% include '@ChillMain/OnTheFly/_insert_vue_onthefly.html.twig' with {
targetEntity: { name: 'person', id: participation.person.id },
action: 'show',
displayBadge: true,
buttonText: participation.person|chill_entity_render_string,
isDead: participation.person.deathdate is not null
} %}
</td>
<td>{{ participation.role.name|localize_translatable_string }}</td>
<td>{{ participation.status.name|localize_translatable_string }}</td>
<td>{{ participation.lastUpdate|ago }} {# sf4 check: filter 'time_diff' is abandoned,
@@ -94,7 +138,7 @@
<ul class="record_actions">
{% if is_granted('CHILL_EVENT_PARTICIPATION_UPDATE', participation) %}
<li>
<a href="{{ path('chill_event_participation_edit', { 'participation_id' : participation.id } ) }}"
<a href="{{ chill_path_add_return_path('chill_event_participation_edit', { 'participation_id' : participation.id }, false, 'See'|trans ) }}"
class="btn btn-edit" title="{{ 'Edit'|trans }}"></a>
</li>
<li>
@@ -126,11 +170,8 @@
'class' : 'custom-select',
'style': 'min-width: 15em; max-width: 18em; display: inline-block;'
}} ) }}
<div class="input-group-append">
{{ form_widget(form_add_participation_by_person.submit, { 'attr' : { 'class' : 'btn btn-create' } } ) }}
</div>
</div>
{{ form_rest(form_add_participation_by_person) }}
<input type="hidden" name="returnPath" value="{{ app.request.requestUri }}" />
{{ form_end(form_add_participation_by_person) }}
</div>

View File

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

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\EventBundle\Tests\Controller;
use Chill\MainBundle\Pagination\PaginatorFactory;
use Chill\MainBundle\Test\PrepareClientTrait;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Twig\Environment;
/**
* @internal
*
* @coversNothing
*/
class EventListControllerTest extends WebTestCase
{
use ProphecyTrait;
use PrepareClientTrait;
private readonly PaginatorFactory $paginatorFactory;
private readonly Environment $environment;
protected function setUp(): void
{
}
public function testList(): void
{
$client = $this->getClientAuthenticated();
$client->request('GET', '/fr/event/event/list');
self::assertResponseIsSuccessful();
}
}

View File

@@ -11,6 +11,11 @@ declare(strict_types=1);
namespace Chill\EventBundle\Tests\Controller;
use Chill\EventBundle\Entity\Event;
use Chill\EventBundle\Repository\EventRepository;
use Chill\MainBundle\Test\PrepareClientTrait;
use Chill\PersonBundle\DataFixtures\Helper\PersonRandomHelper;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use function count;
@@ -23,15 +28,12 @@ use function count;
*/
final class ParticipationControllerTest extends WebTestCase
{
/**
* @var \Symfony\Component\BrowserKit\AbstractBrowser
*/
protected $client;
use PersonRandomHelper;
use PrepareClientTrait;
/**
* @var \Doctrine\ORM\EntityManagerInterface
*/
protected $em;
private EntityManagerInterface $em;
private EventRepository $eventRepository;
/**
* Keep a cache for each person id given by the function getRandomPerson.
@@ -44,23 +46,21 @@ final class ParticipationControllerTest extends WebTestCase
*/
private array $personsIdsCache = [];
protected function setUp(): void
protected function prepareDI(): void
{
self::bootKernel();
$this->client = self::createClient([], [
'PHP_AUTH_USER' => 'center a_social',
'PHP_AUTH_PW' => 'password',
'HTTP_ACCEPT_LANGUAGE' => 'fr_FR',
]);
$container = self::$kernel->getContainer();
$this->em = $container->get('doctrine.orm.entity_manager');
$this->em = self::$container->get(EntityManagerInterface::class);
$this->eventRepository = self::$container->get(EventRepository::class);
$this->personsIdsCache = [];
}
protected function tearDown(): void
{
parent::tearDown();
self::ensureKernelShutdown();
}
/**
* This method test participation creation with wrong parameters.
*
@@ -68,11 +68,13 @@ final class ParticipationControllerTest extends WebTestCase
*/
public function testCreateActionWrongParameters()
{
$client = $this->getClientAuthenticated();
$this->prepareDI();
$event = $this->getRandomEvent();
$person = $this->getRandomPerson();
$person = $this->getRandomPerson($this->em);
// missing person_id or persons_ids
$this->client->request(
$client->request(
'GET',
'/fr/event/participation/create',
[
@@ -81,33 +83,33 @@ final class ParticipationControllerTest extends WebTestCase
);
$this->assertEquals(
400,
$this->client->getResponse()->getStatusCode(),
$client->getResponse()->getStatusCode(),
'Test that /fr/event/participation/create fail if '
.'both person_id and persons_ids are missing'
);
// having both person_id and persons_ids
$this->client->request(
$client->request(
'GET',
'/fr/event/participation/create',
[
'event_id' => $event->getId(),
'persons_ids' => implode(',', [
$this->getRandomPerson()->getId(),
$this->getRandomPerson()->getId(),
$this->getRandomPerson($this->em)->getId(),
$this->getRandomPerson($this->em)->getId(),
]),
'person_id' => $person->getId(),
]
);
$this->assertEquals(
400,
$this->client->getResponse()->getStatusCode(),
$client->getResponse()->getStatusCode(),
'test that /fr/event/participation/create fail if both person_id and '
.'persons_ids are set'
);
// missing event_id
$this->client->request(
$client->request(
'GET',
'/fr/event/participation/create',
[
@@ -116,12 +118,12 @@ final class ParticipationControllerTest extends WebTestCase
);
$this->assertEquals(
400,
$this->client->getResponse()->getStatusCode(),
$client->getResponse()->getStatusCode(),
'Test that /fr/event/participation/create fails if event_id is missing'
);
// persons_ids with wrong content
$this->client->request(
$client->request(
'GET',
'/fr/event/participation/create',
[
@@ -131,42 +133,47 @@ final class ParticipationControllerTest extends WebTestCase
);
$this->assertEquals(
400,
$this->client->getResponse()->getStatusCode(),
$client->getResponse()->getStatusCode(),
'Test that /fr/event/participation/create fails if persons_ids has wrong content'
);
}
public function testEditMultipleAction()
{
/** @var \Chill\EventBundle\Entity\Event $event */
$client = $this->getClientAuthenticated();
$this->prepareDI();
/** @var Event $event */
$event = $this->getRandomEventWithMultipleParticipations();
$crawler = $this->client->request('GET', '/fr/event/participation/'.$event->getId().
$crawler = $client->request('GET', '/fr/event/participation/'.$event->getId().
'/edit_multiple');
$this->assertEquals(200, $this->client->getResponse()->getStatusCode());
$this->assertEquals(200, $client->getResponse()->getStatusCode());
$button = $crawler->selectButton('Mettre à jour');
$this->assertEquals(1, $button->count(), "test the form with button 'mettre à jour' exists ");
$this->client->submit($button->form(), [
$client->submit($button->form(), [
'form[participations][0][role]' => $event->getType()->getRoles()->first()->getId(),
'form[participations][0][status]' => $event->getType()->getStatuses()->first()->getId(),
'form[participations][1][role]' => $event->getType()->getRoles()->last()->getId(),
'form[participations][1][status]' => $event->getType()->getStatuses()->last()->getId(),
]);
$this->assertTrue($this->client->getResponse()
$this->assertTrue($client->getResponse()
->isRedirect('/fr/event/event/'.$event->getId().'/show'));
}
public function testNewActionWrongParameters()
{
$client = $this->getClientAuthenticated();
$this->prepareDI();
$event = $this->getRandomEvent();
$person = $this->getRandomPerson();
$person = $this->getRandomPerson($this->em);
// missing person_id or persons_ids
$this->client->request(
$client->request(
'GET',
'/fr/event/participation/new',
[
@@ -175,33 +182,33 @@ final class ParticipationControllerTest extends WebTestCase
);
$this->assertEquals(
400,
$this->client->getResponse()->getStatusCode(),
$client->getResponse()->getStatusCode(),
'Test that /fr/event/participation/new fail if '
.'both person_id and persons_ids are missing'
);
// having both person_id and persons_ids
$this->client->request(
$client->request(
'GET',
'/fr/event/participation/new',
[
'event_id' => $event->getId(),
'persons_ids' => implode(',', [
$this->getRandomPerson()->getId(),
$this->getRandomPerson()->getId(),
$this->getRandomPerson($this->em)->getId(),
$this->getRandomPerson($this->em)->getId(),
]),
'person_id' => $person->getId(),
]
);
$this->assertEquals(
400,
$this->client->getResponse()->getStatusCode(),
$client->getResponse()->getStatusCode(),
'test that /fr/event/participation/new fail if both person_id and '
.'persons_ids are set'
);
// missing event_id
$this->client->request(
$client->request(
'GET',
'/fr/event/participation/new',
[
@@ -210,12 +217,12 @@ final class ParticipationControllerTest extends WebTestCase
);
$this->assertEquals(
400,
$this->client->getResponse()->getStatusCode(),
$client->getResponse()->getStatusCode(),
'Test that /fr/event/participation/new fails if event_id is missing'
);
// persons_ids with wrong content
$this->client->request(
$client->request(
'GET',
'/fr/event/participation/new',
[
@@ -225,13 +232,15 @@ final class ParticipationControllerTest extends WebTestCase
);
$this->assertEquals(
400,
$this->client->getResponse()->getStatusCode(),
$client->getResponse()->getStatusCode(),
'Test that /fr/event/participation/new fails if persons_ids has wrong content'
);
}
public function testNewMultipleAction()
{
$client = $this->getClientAuthenticated();
$this->prepareDI();
$event = $this->getRandomEvent();
// record the number of participation for the event (used later in this test)
$nbParticipations = $event->getParticipations()->count();
@@ -244,10 +253,10 @@ final class ParticipationControllerTest extends WebTestCase
->toArray()
);
// get some random people
$person1 = $this->getRandomPerson();
$person2 = $this->getRandomPerson();
$person1 = $this->getRandomPerson($this->em);
$person2 = $this->getRandomPerson($this->em);
$crawler = $this->client->request(
$crawler = $client->request(
'GET',
'/fr/event/participation/new',
[
@@ -258,7 +267,7 @@ final class ParticipationControllerTest extends WebTestCase
$this->assertEquals(
200,
$this->client->getResponse()->getStatusCode(),
$client->getResponse()->getStatusCode(),
'test that /fr/event/participation/new is successful'
);
@@ -266,7 +275,7 @@ final class ParticipationControllerTest extends WebTestCase
$this->assertNotNull($button, "test the form with button 'Créer' exists");
$this->client->submit($button->form(), [
$client->submit($button->form(), [
'form' => [
'participations' => [
0 => [
@@ -281,8 +290,8 @@ final class ParticipationControllerTest extends WebTestCase
],
]);
$this->assertTrue($this->client->getResponse()->isRedirect());
$crawler = $this->client->followRedirect();
$this->assertTrue($client->getResponse()->isRedirect());
$crawler = $client->followRedirect();
$span1 = $crawler->filter('table td span.entity-person a:contains("'
.$person1->getFirstName().'"):contains("'.$person1->getLastname().'")');
@@ -292,7 +301,7 @@ final class ParticipationControllerTest extends WebTestCase
$this->assertGreaterThan(0, \count($span2));
// as the container has reloaded, reload the event
$event = $this->em->getRepository(\Chill\EventBundle\Entity\Event::class)->find($event->getId());
$event = $this->em->getRepository(Event::class)->find($event->getId());
$this->em->refresh($event);
$this->assertEquals($nbParticipations + 2, $event->getParticipations()->count());
@@ -300,13 +309,15 @@ final class ParticipationControllerTest extends WebTestCase
public function testNewMultipleWithAllPeopleParticipating()
{
$client = $this->getClientAuthenticated();
$this->prepareDI();
$event = $this->getRandomEventWithMultipleParticipations();
$persons_id = implode(',', $event->getParticipations()->map(
static fn ($p) => $p->getPerson()->getId()
)->toArray());
$crawler = $this->client->request(
$crawler = $client->request(
'GET',
'/fr/event/participation/new',
[
@@ -317,13 +328,15 @@ final class ParticipationControllerTest extends WebTestCase
$this->assertEquals(
302,
$this->client->getResponse()->getStatusCode(),
$client->getResponse()->getStatusCode(),
'test that /fr/event/participation/new is redirecting'
);
}
public function testNewMultipleWithSomePeopleParticipating()
{
$client = $this->getClientAuthenticated();
$this->prepareDI();
$event = $this->getRandomEventWithMultipleParticipations();
// record the number of participation for the event (used later in this test)
$nbParticipations = $event->getParticipations()->count();
@@ -335,12 +348,12 @@ final class ParticipationControllerTest extends WebTestCase
$this->personsIdsCache = array_merge($this->personsIdsCache, $persons_id);
// get a random person
$newPerson = $this->getRandomPerson();
$newPerson = $this->getRandomPerson($this->em);
// build the `persons_ids` parameter
$persons_ids_string = implode(',', [...$persons_id, $newPerson->getId()]);
$crawler = $this->client->request(
$crawler = $client->request(
'GET',
'/fr/event/participation/new',
[
@@ -351,7 +364,7 @@ final class ParticipationControllerTest extends WebTestCase
$this->assertEquals(
200,
$this->client->getResponse()->getStatusCode(),
$client->getResponse()->getStatusCode(),
'test that /fr/event/participation/new is successful'
);
@@ -377,15 +390,15 @@ final class ParticipationControllerTest extends WebTestCase
$this->assertNotNull($button, "test the form with button 'Créer' exists");
// submit the form
$this->client->submit($button->form(), [
$client->submit($button->form(), [
'participation[role]' => $event->getType()->getRoles()->first()->getId(),
'participation[status]' => $event->getType()->getStatuses()->first()->getId(),
]);
$this->assertTrue($this->client->getResponse()->isRedirect());
$this->assertTrue($client->getResponse()->isRedirect());
// reload the event and test there is a new participation
$event = $this->em->getRepository(\Chill\EventBundle\Entity\Event::class)
$event = $this->em->getRepository(Event::class)
->find($event->getId());
$this->em->refresh($event);
@@ -398,12 +411,14 @@ final class ParticipationControllerTest extends WebTestCase
public function testNewSingleAction()
{
$client = $this->getClientAuthenticated();
$this->prepareDI();
$event = $this->getRandomEvent();
// record the number of participation for the event
$nbParticipations = $event->getParticipations()->count();
$person = $this->getRandomPerson();
$person = $this->getRandomPerson($this->em);
$crawler = $this->client->request(
$crawler = $client->request(
'GET',
'/fr/event/participation/new',
[
@@ -414,7 +429,7 @@ final class ParticipationControllerTest extends WebTestCase
$this->assertEquals(
200,
$this->client->getResponse()->getStatusCode(),
$client->getResponse()->getStatusCode(),
'test that /fr/event/participation/new is successful'
);
@@ -422,13 +437,13 @@ final class ParticipationControllerTest extends WebTestCase
$this->assertNotNull($button, "test the form with button 'Créer' exists");
$this->client->submit($button->form(), [
$client->submit($button->form(), [
'participation[role]' => $event->getType()->getRoles()->first()->getId(),
'participation[status]' => $event->getType()->getStatuses()->first()->getId(),
]);
$this->assertTrue($this->client->getResponse()->isRedirect());
$crawler = $this->client->followRedirect();
$this->assertTrue($client->getResponse()->isRedirect());
$crawler = $client->followRedirect();
$span = $crawler->filter('table td span.entity-person a:contains("'
.$person->getFirstName().'"):contains("'.$person->getLastname().'")');
@@ -436,29 +451,23 @@ final class ParticipationControllerTest extends WebTestCase
$this->assertGreaterThan(0, \count($span));
// as the container has reloaded, reload the event
$event = $this->em->getRepository(\Chill\EventBundle\Entity\Event::class)->find($event->getId());
$event = $this->em->getRepository(Event::class)->find($event->getId());
$this->em->refresh($event);
$this->assertEquals($nbParticipations + 1, $event->getParticipations()->count());
}
/**
* @return \Chill\EventBundle\Entity\Event
*/
protected function getRandomEvent(mixed $centerName = 'Center A', mixed $circleName = 'social')
private function getRandomEvent(string $centerName = 'Center A', string $circleName = 'social'): Event
{
$center = $this->em->getRepository(\Chill\MainBundle\Entity\Center::class)
->findByName($centerName);
$dql = 'FROM '.Event::class.' e JOIN e.center center JOIN e.circle scope WHERE center.name LIKE :cname AND JSON_EXTRACT(scope.name, \'fr\') LIKE :sname';
$circles = $this->em->getRepository(\Chill\MainBundle\Entity\Scope::class)
->findAll();
array_filter($circles, static fn ($circle) => \in_array($circleName, $circle->getName(), true));
$circle = $circles[0];
$ids = $this->em->createQuery(
'SELECT DISTINCT e.id '.$dql
)
->setParameters(['cname' => $centerName, 'sname' => $circleName])
->getResult();
$events = $this->em->getRepository(\Chill\EventBundle\Entity\Event::class)
->findBy(['center' => $center, 'circle' => $circle]);
return $events[array_rand($events)];
return $this->eventRepository->find($ids[array_rand($ids)]['id']);
}
/**
@@ -467,7 +476,7 @@ final class ParticipationControllerTest extends WebTestCase
* @param string $centerName
* @param type $circleName
*
* @return \Chill\EventBundle\Entity\Event
* @return Event
*/
protected function getRandomEventWithMultipleParticipations(
$centerName = 'Center A',
@@ -479,35 +488,4 @@ final class ParticipationControllerTest extends WebTestCase
$event :
$this->getRandomEventWithMultipleParticipations($centerName, $circleName);
}
/**
* Returns a person randomly.
*
* This function does not give the same person twice
* for each test.
*
* You may ask to ignore some people by adding their id to the property
* `$this->personsIdsCache`
*
* @param string $centerName
*
* @return \Chill\PersonBundle\Entity\Person
*/
protected function getRandomPerson($centerName = 'Center A')
{
$center = $this->em->getRepository(\Chill\MainBundle\Entity\Center::class)
->findByName($centerName);
$persons = $this->em->getRepository(\Chill\PersonBundle\Entity\Person::class)
->findBy(['center' => $center]);
$person = $persons[array_rand($persons)];
if (\in_array($person->getId(), $this->personsIdsCache, true)) {
return $this->getRandomPerson($centerName); // we try another time
}
$this->personsIdsCache[] = $person->getId();
return $person;
}
}

View File

@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\EventBundle\Tests\Repository;
use Chill\EventBundle\Repository\EventACLAwareRepository;
use Chill\EventBundle\Security\Authorization\EventVoter;
use Chill\MainBundle\Entity\Center;
use Chill\MainBundle\Entity\Scope;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Security\Authorization\AuthorizationHelperForCurrentUserInterface;
use Doctrine\ORM\EntityManagerInterface;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Security\Core\Security;
/**
* @internal
*
* @coversNothing
*/
class EventACLAwareRepositoryTest extends KernelTestCase
{
use ProphecyTrait;
protected function setUp(): void
{
self::bootKernel();
}
/**
* @dataProvider generateFilters
*
* @throws \Doctrine\ORM\NoResultException
* @throws \Doctrine\ORM\NonUniqueResultException
*/
public function testCountAllViewable(array $filters): void
{
$repository = $this->buildEventACLAwareRepository();
$this->assertGreaterThanOrEqual(0, $repository->countAllViewable($filters));
}
/**
* @dataProvider generateFilters
*/
public function testFindAllViewable(array $filters): void
{
$repository = $this->buildEventACLAwareRepository();
$this->assertIsArray($repository->findAllViewable($filters));
}
public function generateFilters(): iterable
{
yield [[]];
}
public function buildEventACLAwareRepository(): EventACLAwareRepository
{
$em = self::$container->get(EntityManagerInterface::class);
$user = $em->createQuery('SELECT u FROM '.User::class.' u')
->setMaxResults(1)
->getSingleResult()
;
$scopes = $em->createQuery('SELECT s FROM '.Scope::class.' s')
->setMaxResults(3)
->getResult();
$centers = $em->createQuery('SELECT c FROM '.Center::class.' c')
->setMaxResults(3)
->getResult();
$security = $this->prophesize(Security::class);
$security->getUser()->willReturn($user);
$authorizationHelper = $this->prophesize(AuthorizationHelperForCurrentUserInterface::class);
$authorizationHelper->getReachableCenters(EventVoter::SEE)->willReturn($centers);
$authorizationHelper->getReachableScopes(EventVoter::SEE, Argument::type(Center::class))->willReturn($scopes);
return new EventACLAwareRepository(
$authorizationHelper->reveal(),
$em,
$security->reveal()
);
}
}

View File

@@ -1,16 +0,0 @@
services:
Chill\EventBundle\Controller\EventController:
arguments:
$eventDispatcher: '@Symfony\Contracts\EventDispatcher\EventDispatcherInterface'
$authorizationHelper: '@Chill\MainBundle\Security\Authorization\AuthorizationHelper'
$formFactoryInterface: '@Symfony\Component\Form\FormFactoryInterface'
$translator: '@Symfony\Contracts\Translation\TranslatorInterface'
$paginator: '@chill_main.paginator_factory'
public: true
tags: ['controller.service_arguments']
Chill\EventBundle\Controller\ParticipationController:
arguments:
$logger: '@Psr\Log\LoggerInterface'
tags: ['controller.service_arguments']

View File

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

View File

@@ -1,26 +0,0 @@
Chill\EventBundle\Entity\Participation:
properties:
event:
- NotNull: ~
status:
- NotNull: ~
person:
- NotNull: ~
constraints:
- Callback: isConsistent
Chill\EventBundle\Entity\Event:
properties:
name:
- Length:
min: 3
max: 75
minMessage: The event name must have at least {{ limit }} characters.
maxMessage: The event name must have maximum {{ limit }} characters.
type:
- NotNull: ~
circle:
- NotNull: ~
center:
- NotNull: ~

View File

@@ -19,11 +19,13 @@ use Doctrine\Migrations\AbstractMigration;
*/
class Version20160318111334 extends AbstractMigration
{
public function getDescription(): string
{
return 'initialize the bundle chill event';
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->abortIf('postgresql' !== $this->connection->getDatabasePlatform()->getName(), 'Migration can only be executed safely on \'postgresql\'.');
$this->addSql('ALTER TABLE chill_event_role DROP CONSTRAINT FK_AA714E54C54C8C93');
$this->addSql('ALTER TABLE chill_event_status DROP CONSTRAINT FK_A6CC85D0C54C8C93');
$this->addSql('ALTER TABLE chill_event_participation DROP CONSTRAINT FK_4E7768ACD60322AC');
@@ -50,9 +52,6 @@ class Version20160318111334 extends AbstractMigration
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->abortIf('postgresql' !== $this->connection->getDatabasePlatform()->getName(), 'Migration can only be executed safely on \'postgresql\'.');
$this->addSql('CREATE SEQUENCE chill_event_event_type_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
$this->addSql('CREATE SEQUENCE chill_event_role_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
$this->addSql('CREATE SEQUENCE chill_event_status_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
@@ -123,11 +122,26 @@ class Version20160318111334 extends AbstractMigration
.'FOREIGN KEY (event_id) '
.'REFERENCES chill_event_event (id) '
.'NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE chill_event_participation '
// before adding fk constraint to person, check what is the table name
$results = $this->connection->executeQuery('SELECT EXISTS (SELECT 1 FROM pg_tables WHERE tablename = \'chill_person_person\')');
/** @var bool $isChillPersonPersonTable */
$isChillPersonPersonTable = $results->fetchFirstColumn()[0];
if ($isChillPersonPersonTable) {
$this->addSql('ALTER TABLE chill_event_participation '
.'ADD CONSTRAINT FK_4E7768AC217BBB47 '
.'FOREIGN KEY (person_id) '
.'REFERENCES chill_person_person (id) '
.'NOT DEFERRABLE INITIALLY IMMEDIATE');
} else {
$this->addSql('ALTER TABLE chill_event_participation '
.'ADD CONSTRAINT FK_4E7768AC217BBB47 '
.'FOREIGN KEY (person_id) '
.'REFERENCES Person (id) '
.'NOT DEFERRABLE INITIALLY IMMEDIATE');
}
$this->addSql('ALTER TABLE chill_event_participation '
.'ADD CONSTRAINT FK_4E7768ACD60322AC '
.'FOREIGN KEY (role_id) '

View File

@@ -19,18 +19,19 @@ use Doctrine\Migrations\AbstractMigration;
*/
final class Version20190110140538 extends AbstractMigration
{
public function getDescription(): string
{
return 'switch event date to datetime';
}
public function down(Schema $schema): void
{
$this->abortIf('postgresql' !== $this->connection->getDatabasePlatform()->getName(), 'Migration can only be executed safely on \'postgresql\'.');
$this->addSql('ALTER TABLE chill_event_event ALTER date TYPE DATE');
$this->addSql('ALTER TABLE chill_event_event ALTER date DROP DEFAULT');
}
public function up(Schema $schema): void
{
$this->abortIf('postgresql' !== $this->connection->getDatabasePlatform()->getName(), 'Migration can only be executed safely on \'postgresql\'.');
$this->addSql('ALTER TABLE chill_event_event ALTER date TYPE TIMESTAMP(0) WITHOUT TIME ZONE');
$this->addSql('ALTER TABLE chill_event_event ALTER date DROP DEFAULT');
}

View File

@@ -19,11 +19,13 @@ use Doctrine\Migrations\AbstractMigration;
*/
final class Version20190115140042 extends AbstractMigration
{
public function getDescription(): string
{
return 'add a moderator field to events';
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->abortIf('postgresql' !== $this->connection->getDatabasePlatform()->getName(), 'Migration can only be executed safely on \'postgresql\'.');
$this->addSql('ALTER TABLE chill_event_event DROP CONSTRAINT FK_FA320FC8D0AFA354');
$this->addSql('DROP INDEX IDX_FA320FC8D0AFA354');
$this->addSql('ALTER TABLE chill_event_event DROP moderator_id');
@@ -31,9 +33,6 @@ final class Version20190115140042 extends AbstractMigration
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->abortIf('postgresql' !== $this->connection->getDatabasePlatform()->getName(), 'Migration can only be executed safely on \'postgresql\'.');
$this->addSql('ALTER TABLE chill_event_event ADD moderator_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE chill_event_event ADD CONSTRAINT FK_FA320FC8D0AFA354 FOREIGN KEY (moderator_id) REFERENCES chill_person_person (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('CREATE INDEX IDX_FA320FC8D0AFA354 ON chill_event_event (moderator_id)');

View File

@@ -19,20 +19,19 @@ use Doctrine\Migrations\AbstractMigration;
*/
final class Version20190201143121 extends AbstractMigration
{
public function getDescription(): string
{
return 'fix moderator: relation with user (not person)';
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->abortIf('postgresql' !== $this->connection->getDatabasePlatform()->getName(), 'Migration can only be executed safely on \'postgresql\'.');
$this->addSql('ALTER TABLE chill_event_event DROP CONSTRAINT fk_fa320fc8d0afa354');
$this->addSql('ALTER TABLE chill_event_event ADD CONSTRAINT fk_fa320fc8d0afa354 FOREIGN KEY (moderator_id) REFERENCES chill_person_person (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->abortIf('postgresql' !== $this->connection->getDatabasePlatform()->getName(), 'Migration can only be executed safely on \'postgresql\'.');
$this->addSql('ALTER TABLE chill_event_event DROP CONSTRAINT FK_FA320FC8D0AFA354');
$this->addSql('ALTER TABLE chill_event_event ADD CONSTRAINT FK_FA320FC8D0AFA354 FOREIGN KEY (moderator_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
}

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\Migrations\Event;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20231127134244 extends AbstractMigration
{
public function getDescription(): string
{
return 'add creation - update information on event and event participation';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE chill_event_event ADD createdAt TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
$this->addSql('ALTER TABLE chill_event_event ADD updatedAt TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
$this->addSql('ALTER TABLE chill_event_event ADD createdBy_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE chill_event_event ADD updatedBy_id INT DEFAULT NULL');
$this->addSql('COMMENT ON COLUMN chill_event_event.createdAt IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN chill_event_event.updatedAt IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('ALTER TABLE chill_event_event ADD CONSTRAINT FK_FA320FC83174800F FOREIGN KEY (createdBy_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE chill_event_event ADD CONSTRAINT FK_FA320FC865FF1AEC FOREIGN KEY (updatedBy_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('CREATE INDEX IDX_FA320FC83174800F ON chill_event_event (createdBy_id)');
$this->addSql('CREATE INDEX IDX_FA320FC865FF1AEC ON chill_event_event (updatedBy_id)');
$this->addSql('ALTER TABLE chill_event_participation ADD createdAt TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
$this->addSql('ALTER TABLE chill_event_participation ADD updatedAt TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
$this->addSql('ALTER TABLE chill_event_participation ADD createdBy_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE chill_event_participation ADD updatedBy_id INT DEFAULT NULL');
$this->addSql('COMMENT ON COLUMN chill_event_participation.createdAt IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN chill_event_participation.updatedAt IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('ALTER TABLE chill_event_participation ADD CONSTRAINT FK_4E7768AC3174800F FOREIGN KEY (createdBy_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE chill_event_participation ADD CONSTRAINT FK_4E7768AC65FF1AEC FOREIGN KEY (updatedBy_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('CREATE INDEX IDX_4E7768AC3174800F ON chill_event_participation (createdBy_id)');
$this->addSql('CREATE INDEX IDX_4E7768AC65FF1AEC ON chill_event_participation (updatedBy_id)');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE chill_event_event DROP createdAt');
$this->addSql('ALTER TABLE chill_event_event DROP updatedAt');
$this->addSql('ALTER TABLE chill_event_event DROP createdBy_id');
$this->addSql('ALTER TABLE chill_event_event DROP updatedBy_id');
$this->addSql('ALTER TABLE chill_event_participation DROP createdAt');
$this->addSql('ALTER TABLE chill_event_participation DROP updatedAt');
$this->addSql('ALTER TABLE chill_event_participation DROP createdBy_id');
$this->addSql('ALTER TABLE chill_event_participation DROP updatedBy_id');
}
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\Migrations\Event;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20231128114959 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add unique index on participation and drop column participation::lastUpdate';
}
public function up(Schema $schema): void
{
$this->addSql('UPDATE chill_event_participation SET updatedAt=lastupdate WHERE updatedat IS NULL');
$this->addSql('ALTER TABLE chill_event_participation DROP lastupdate');
$this->addSql('WITH ordering AS (SELECT id, event_id, person_id, rank() OVER (PARTITION BY event_id, person_id ORDER BY id DESC) as ranked FROM chill_event_participation),
not_last AS (SELECT * FROM ordering where ranked > 1)
DELETE FROM chill_event_participation WHERE id IN (select id FROM not_last)');
$this->addSql('CREATE UNIQUE INDEX chill_event_participation_event_person_unique_idx ON chill_event_participation (event_id, person_id)');
}
public function down(Schema $schema): void
{
$this->addSql('DROP INDEX chill_event_participation_event_person_unique_idx');
$this->addSql('ALTER TABLE chill_event_participation ADD lastupdate TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL');
$this->addSql('UPDATE chill_event_participation set lastupdate = updatedat');
}
}

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\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,3 +11,11 @@ count participations to this event: >-
one {Un participant à l'événement}
other {# participants à l'événement}
}
events:
and_other_count_participants: >-
{ count, plural,
=0 {Aucun autre participant}
one {et un autre participant}
other {et # autres participants}
}

View File

@@ -26,6 +26,8 @@ Event edit: Modifier un événement
Edit the event: Modifier l'événement
The event was updated: L'événement a été modifié
The event was created: L'événement a été créé
List of events: Liste des événements
Any location for this event: Aucune localisation pour cet événement
#crud participation
Edit all the participations: Modifier toutes les participations
@@ -50,6 +52,7 @@ Remove participation: Supprimer la participation
Delete event: Supprimer l'événement
Are you sure you want to remove that participation ?: Êtes-vous certain de vouloir supprimer cette participation ?
Are you sure you want to remove that event ?: Êtes-vous certain de vouloir supprimer cet événement, ainsi que toutes les participations associées ?
Any participation for this person: Cet usager ne participe à aucun évenements
#search
Event search: Recherche d'événements
@@ -107,3 +110,17 @@ csv: csv
Create a new role: Créer un nouveau rôle
Create a new type: Créer un nouveau type
Create a new status: Créer un nouveau statut
event:
fields:
organizationCost: Coût d'organisation
location: Localisation
documents: Documents
form:
organisationCost_help: Coût d'organisation pour la structure. Utile pour les statistiques.
add_document: Ajouter un document
remove_document: Supprimer le document
filter:
event_types: Par types d'événement
event_dates: Par date d'événement

View File

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

View File

@@ -15,10 +15,14 @@ use Chill\MainBundle\CRUD\Resolver\Resolver;
use Chill\MainBundle\Pagination\PaginatorFactory;
use Chill\MainBundle\Pagination\PaginatorInterface;
use Chill\MainBundle\Security\Authorization\AuthorizationHelper;
use Doctrine\DBAL\LockMode;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\OptimisticLockException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
@@ -173,6 +177,21 @@ abstract class AbstractCRUDController extends AbstractController
if (null === $e) {
throw $this->createNotFoundException(sprintf('The object %s for id %s is not found', $this->getEntityClass(), $id));
}
if ($request->query->has('entity_version')) {
$expectedVersion = $request->query->getInt('entity_version');
try {
$manager = $this->getDoctrine()->getManagerForClass($this->getEntityClass());
if ($manager instanceof EntityManagerInterface) {
$manager->lock($e, LockMode::OPTIMISTIC, $expectedVersion);
} else {
throw new \LogicException('This manager does not allow locking.');
}
} catch (OptimisticLockException $e) {
throw new ConflictHttpException('Sorry, but someone else has already changed this entity. Please refresh the page and apply the changes again', $e);
}
}
return $e;
}

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', 400, $e);
throw new BadRequestHttpException('invalid json', $e, 400);
}
$errors = $this->validate($action, $request, $_format, $entity);
@@ -153,7 +153,7 @@ class ApiController extends AbstractCRUDController
return $response;
}
$this->getDoctrine()->getManager()->flush();
$this->getDoctrine()->getManagerForClass($this->getEntityClass())->flush();
$response = $this->onAfterFlush($action, $request, $_format, $entity, $errors);

View File

@@ -47,12 +47,4 @@ class AdminController extends AbstractController
{
return $this->render('@ChillMain/Admin/indexUser.html.twig');
}
/**
* @Route("/{_locale}/admin/dashboard", name="chill_main_dashboard_admin")
*/
public function indexDashboardAction()
{
return $this->render('@ChillMain/Admin/indexDashboard.html.twig');
}
}

View File

@@ -1,41 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Controller;
use Chill\MainBundle\Entity\User;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Annotation\Route;
class DashboardApiController
{
/**
* Get user dashboard config (not yet based on user id and still hardcoded for now).
*
* @Route("/api/1.0/main/dashboard-config-item.json", methods={"get"})
*/
public function getDashboardConfiguration(): JsonResponse
{
$data = [
[
'position' => 'top-left',
'id' => 1,
'type' => 'news',
'metadata' => [
// arbitrary data that will be store "some time"
'only_unread' => false,
],
],
];
return new JsonResponse($data, JsonResponse::HTTP_OK, []);
}
}

View File

@@ -1,53 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Controller;
use Chill\MainBundle\Pagination\PaginatorFactory;
use Chill\MainBundle\Repository\NewsItemRepository;
use Chill\MainBundle\Serializer\Model\Collection;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
use Symfony\Component\Serializer\SerializerInterface;
class NewsItemApiController
{
public function __construct(
private readonly NewsItemRepository $newsItemRepository,
private readonly SerializerInterface $serializer,
private readonly PaginatorFactory $paginatorFactory
) {
}
/**
* Get list of news items filtered on start and end date.
*
* @Route("/api/1.0/main/news/current.json", methods={"get"})
*/
public function listCurrentNewsItems(): JsonResponse
{
$total = $this->newsItemRepository->countCurrentNews();
$paginator = $this->paginatorFactory->create($total);
$newsItems = $this->newsItemRepository->findCurrentNews(
$paginator->getItemsPerPage(),
$paginator->getCurrentPage()->getFirstItemNumber()
);
return new JsonResponse($this->serializer->serialize(
new Collection(array_values($newsItems), $paginator),
'json',
[
AbstractNormalizer::GROUPS => ['read'],
]
), JsonResponse::HTTP_OK, [], true);
}
}

View File

@@ -1,27 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Controller;
use Chill\MainBundle\CRUD\Controller\CRUDController;
use Chill\MainBundle\Pagination\PaginatorInterface;
use Symfony\Component\HttpFoundation\Request;
class NewsItemController extends CRUDController
{
protected function orderQuery(string $action, $query, Request $request, PaginatorInterface $paginator)
{
$query->addOrderBy('e.startDate', 'DESC');
$query->addOrderBy('e.id', 'DESC');
return parent::orderQuery($action, $query, $request, $paginator);
}
}

View File

@@ -1,73 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Controller;
use Chill\MainBundle\Entity\NewsItem;
use Chill\MainBundle\Pagination\PaginatorFactory;
use Chill\MainBundle\Repository\NewsItemRepository;
use Chill\MainBundle\Templating\Listing\FilterOrderHelper;
use Chill\MainBundle\Templating\Listing\FilterOrderHelperFactoryInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Twig\Environment;
final readonly class NewsItemHistoryController
{
public function __construct(
private readonly NewsItemRepository $newsItemRepository,
private readonly PaginatorFactory $paginatorFactory,
private readonly FilterOrderHelperFactoryInterface $filterOrderHelperFactory,
private readonly Environment $environment,
) {
}
/**
* @Route("/{_locale}/news-items/history", name="chill_main_news_items_history")
*/
public function list(): Response
{
$filter = $this->buildFilterOrder();
$total = $this->newsItemRepository->countAllFilteredBySearchTerm($filter->getQueryString());
$newsItems = $this->newsItemRepository->findAllFilteredBySearchTerm($filter->getQueryString());
$pagination = $this->paginatorFactory->create($total);
return new Response($this->environment->render('@ChillMain/NewsItem/news_items_history.html.twig', [
'entities' => $newsItems,
'paginator' => $pagination,
'filter_order' => $filter,
]));
}
/**
* @Route("/{_locale}/news-items/{id}", name="chill_main_single_news_item")
*/
public function showSingleItem(NewsItem $newsItem, Request $request): Response
{
return new Response($this->environment->render(
'@ChillMain/NewsItem/show.html.twig',
[
'entity' => $newsItem,
]
));
}
private function buildFilterOrder(): FilterOrderHelper
{
$filterBuilder = $this->filterOrderHelperFactory
->create(self::class)
->addSearchBox();
return $filterBuilder->build();
}
}

View File

@@ -19,7 +19,6 @@ use Chill\MainBundle\Controller\CountryController;
use Chill\MainBundle\Controller\LanguageController;
use Chill\MainBundle\Controller\LocationController;
use Chill\MainBundle\Controller\LocationTypeController;
use Chill\MainBundle\Controller\NewsItemController;
use Chill\MainBundle\Controller\RegroupmentController;
use Chill\MainBundle\Controller\UserController;
use Chill\MainBundle\Controller\UserJobApiController;
@@ -54,7 +53,6 @@ use Chill\MainBundle\Entity\GeographicalUnitLayer;
use Chill\MainBundle\Entity\Language;
use Chill\MainBundle\Entity\Location;
use Chill\MainBundle\Entity\LocationType;
use Chill\MainBundle\Entity\NewsItem;
use Chill\MainBundle\Entity\Regroupment;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\UserJob;
@@ -64,7 +62,6 @@ use Chill\MainBundle\Form\CountryType;
use Chill\MainBundle\Form\LanguageType;
use Chill\MainBundle\Form\LocationFormType;
use Chill\MainBundle\Form\LocationTypeType;
use Chill\MainBundle\Form\NewsItemType;
use Chill\MainBundle\Form\RegroupmentType;
use Chill\MainBundle\Form\UserJobType;
use Chill\MainBundle\Form\UserType;
@@ -547,35 +544,6 @@ class ChillMainExtension extends Extension implements
],
],
],
[
'class' => NewsItem::class,
'name' => 'news_item',
'base_path' => '/admin/news_item',
'form_class' => NewsItemType::class,
'controller' => NewsItemController::class,
'actions' => [
'index' => [
'role' => 'ROLE_ADMIN',
'template' => '@ChillMain/NewsItem/index.html.twig',
],
'new' => [
'role' => 'ROLE_ADMIN',
'template' => '@ChillMain/NewsItem/new.html.twig',
],
'view' => [
'role' => 'ROLE_ADMIN',
'template' => '@ChillMain/NewsItem/view_admin.html.twig',
],
'edit' => [
'role' => 'ROLE_ADMIN',
'template' => '@ChillMain/NewsItem/edit.html.twig',
],
'delete' => [
'role' => 'ROLE_ADMIN',
'template' => '@ChillMain/NewsItem/delete.html.twig',
],
],
],
],
'apis' => [
[

View File

@@ -1,112 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation as Serializer;
use Symfony\Component\Validator\Constraints as Assert;
/**
* @ORM\Entity
*
* @ORM\Table(name="chill_main_dashboard_config_item")
*/
class DashboardConfigItem
{
/**
* @ORM\Id
*
* @ORM\GeneratedValue
*
* @ORM\Column(type="integer")
*
* @Serializer\Groups({"dashboardConfigItem:read", "read"})
*/
private ?int $id = null;
/**
* @ORM\Column(type="string")
*
* @Serializer\Groups({"dashboardConfigItem:read", "read"})
*
* @Assert\NotNull
*/
private string $type = '';
/**
* @ORM\Column(type="string")
*
* @Serializer\Groups({"dashboardConfigItem:read", "read"})
*
* @Assert\NotNull
*/
private string $position = '';
/**
* @ORM\ManyToOne(targetEntity=User::class)
*/
private ?User $user = null;
/**
* @ORM\Column(type="json", options={"default": "[]", "jsonb": true})
*
* @Serializer\Groups({"dashboardConfigItem:read"})
*/
private array $metadata = [];
public function getId(): ?int
{
return $this->id;
}
public function getType(): string
{
return $this->type;
}
public function setType(string $type): self
{
$this->type = $type;
return $this;
}
public function getPosition(): string
{
return $this->position;
}
public function setPosition(string $position): void
{
$this->position = $position;
}
public function getUser(): User
{
return $this->user;
}
public function setUser(User $user): void
{
$this->user = $user;
}
public function getMetadata(): array
{
return $this->metadata;
}
public function setMetadata(array $metadata): void
{
$this->metadata = $metadata;
}
}

View File

@@ -1,128 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Entity;
use Chill\MainBundle\Doctrine\Model\TrackCreationInterface;
use Chill\MainBundle\Doctrine\Model\TrackCreationTrait;
use Chill\MainBundle\Doctrine\Model\TrackUpdateInterface;
use Chill\MainBundle\Doctrine\Model\TrackUpdateTrait;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;
/**
* @ORM\Entity
*
* @ORM\Table(name="chill_main_news")
*/
class NewsItem implements TrackCreationInterface, TrackUpdateInterface
{
use TrackCreationTrait;
use TrackUpdateTrait;
/**
* @ORM\Id
*
* @ORM\GeneratedValue
*
* @ORM\Column(type="integer")
*
* @Groups({"read"})
*/
private ?int $id = null;
/**
* @ORM\Column(type="text")
*
* @Groups({"read"})
*
* @Assert\NotBlank
*
* @Assert\NotNull
*/
private string $title = '';
/**
* @ORM\Column(type="text")
*
* @Groups({"read"})
*
* @Assert\NotBlank
*
* @Assert\NotNull
*/
private string $content = '';
/**
* @ORM\Column(type="date_immutable", nullable=false)
*
* @Assert\NotNull
*
* @Groups({"read"})
*/
private ?\DateTimeImmutable $startDate = null;
/**
* @ORM\Column(type="date_immutable", nullable=true, options={"default": null})
*
* @Assert\GreaterThanOrEqual(propertyPath="startDate")
*
* @Groups({"read"})
*/
private ?\DateTimeImmutable $endDate = null;
public function getTitle(): string
{
return $this->title;
}
public function setTitle(string $title): void
{
$this->title = $title;
}
public function getContent(): string
{
return $this->content;
}
public function setContent(string $content): void
{
$this->content = $content;
}
public function getStartDate(): ?\DateTimeImmutable
{
return $this->startDate;
}
public function setStartDate(?\DateTimeImmutable $startDate): void
{
$this->startDate = $startDate;
}
public function getEndDate(): ?\DateTimeImmutable
{
return $this->endDate;
}
public function setEndDate(?\DateTimeImmutable $endDate): void
{
$this->endDate = $endDate;
}
public function getId(): ?int
{
return $this->id;
}
}

View File

@@ -1,54 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Form;
use Chill\MainBundle\Entity\NewsItem;
use Chill\MainBundle\Form\Type\ChillDateType;
use Chill\MainBundle\Form\Type\ChillTextareaType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class NewsItemType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('title', TextType::class, [
'required' => true,
])
->add('content', ChillTextareaType::class, [
'required' => false,
])
->add(
'startDate',
ChillDateType::class,
[
'required' => true,
'input' => 'datetime_immutable',
]
)
->add('endDate', ChillDateType::class, [
'required' => false,
'input' => 'datetime_immutable',
]);
}
/**
* @return void
*/
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefault('data_class', NewsItem::class);
}
}

View File

@@ -13,6 +13,7 @@ namespace Chill\MainBundle\Form\Type;
use Chill\MainBundle\Entity\Center;
use Chill\MainBundle\Entity\PermissionsGroup;
use Doctrine\ORM\EntityRepository;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
@@ -27,7 +28,13 @@ class ComposedGroupCenterType extends AbstractType
'choice_label' => static fn (PermissionsGroup $group) => $group->getName(),
])->add('center', EntityType::class, [
'class' => Center::class,
'choice_label' => static fn (Center $center) => $center->getName(),
'query_builder' => static function (EntityRepository $er) {
$qb = $er->createQueryBuilder('c');
$qb->where($qb->expr()->eq('c.isActive', 'TRUE'))
->orderBy('c.name', 'ASC');
return $qb;
},
]);
}

View File

@@ -42,6 +42,8 @@ class PickUserDynamicType extends AbstractType
$view->vars['types'] = ['user'];
$view->vars['uniqid'] = uniqid('pick_user_dyn');
$view->vars['suggested'] = [];
$view->vars['as_id'] = true === $options['as_id'] ? '1' : '0';
$view->vars['submit_on_adding_new_entity'] = true === $options['submit_on_adding_new_entity'] ? '1' : '0';
foreach ($options['suggested'] as $user) {
$view->vars['suggested'][] = $this->normalizer->normalize($user, 'json', ['groups' => 'read']);
@@ -54,7 +56,12 @@ class PickUserDynamicType extends AbstractType
->setDefault('multiple', false)
->setAllowedTypes('multiple', ['bool'])
->setDefault('compound', false)
->setDefault('suggested', []);
->setDefault('suggested', [])
// if set to true, only the id will be set inside the content. The denormalization will not work.
->setDefault('as_id', false)
->setAllowedTypes('as_id', ['bool'])
->setDefault('submit_on_adding_new_entity', false)
->setAllowedTypes('submit_on_adding_new_entity', ['bool']);
}
public function getBlockPrefix()

View File

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

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Pagination;
/**
* Create paginator instances.
*/
interface PaginatorFactoryInterface
{
/**
* create a paginator instance.
*
* The default route and route parameters are the current ones. If set,
* thos route are overriden.
*
* @param string|null $route the specific route to use in pages
* @param array|null $routeParameters the specific route parameters to use in pages
*/
public function create(int $totalItems, ?string $route = null, ?array $routeParameters = null): PaginatorInterface;
public function getCurrentItemsPerPage(): int;
public function getCurrentPageFirstItemNumber(): int;
public function getCurrentPageNumber(): int;
}

View File

@@ -1,144 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Repository;
use Chill\MainBundle\Entity\NewsItem;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ObjectRepository;
use Symfony\Component\Clock\ClockInterface;
class NewsItemRepository implements ObjectRepository
{
private readonly EntityRepository $repository;
public function __construct(EntityManagerInterface $entityManager, private readonly ClockInterface $clock)
{
$this->repository = $entityManager->getRepository(NewsItem::class);
}
public function createQueryBuilder(string $alias, ?string $indexBy = null): QueryBuilder
{
return $this->repository->createQueryBuilder($alias, $indexBy);
}
public function find($id)
{
return $this->repository->find($id);
}
public function findAll()
{
return $this->repository->findAll();
}
public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null)
{
return $this->repository->findBy($criteria, $orderBy, $limit, $offset);
}
public function findOneBy(array $criteria)
{
return $this->repository->findOneBy($criteria);
}
public function getClassName()
{
return NewsItem::class;
}
private function buildBaseQuery(
?string $pattern = null
): QueryBuilder {
$qb = $this->createQueryBuilder('n');
$qb->where('n.startDate <= :now');
$qb->setParameter('now', $this->clock->now());
if (null !== $pattern && '' !== $pattern) {
$qb->andWhere($qb->expr()->like('LOWER(UNACCENT(n.title))', 'LOWER(UNACCENT(:pattern))'))
->orWhere($qb->expr()->like('LOWER(UNACCENT(n.content))', 'LOWER(UNACCENT(:pattern))'))
->setParameter('pattern', '%'.$pattern.'%');
}
return $qb;
}
public function findAllFilteredBySearchTerm(?string $pattern = null)
{
$qb = $this->buildBaseQuery($pattern);
$qb
->addOrderBy('n.startDate', 'DESC')
->addOrderBy('n.id', 'DESC');
return $qb->getQuery()->getResult();
}
/**
* @return list<NewsItem>
*/
public function findCurrentNews(?int $limit = null, ?int $offset = null): array
{
$qb = $this->buildQueryCurrentNews();
if (null !== $limit) {
$qb->setMaxResults($limit);
}
if (null !== $offset) {
$qb->setFirstResult($offset);
}
return $qb
->getQuery()
->getResult();
}
public function countAllFilteredBySearchTerm(?string $pattern = null)
{
$qb = $this->buildBaseQuery($pattern);
return $qb
->select('COUNT(n)')
->getQuery()
->getSingleScalarResult();
}
public function countCurrentNews()
{
return $this->buildQueryCurrentNews()
->select('COUNT(n)')
->getQuery()
->getSingleScalarResult();
}
private function buildQueryCurrentNews(): QueryBuilder
{
$now = $this->clock->now();
$qb = $this->createQueryBuilder('n');
$qb
->where(
$qb->expr()->andX(
$qb->expr()->lte('n.startDate', ':now'),
$qb->expr()->orX(
$qb->expr()->gt('n.endDate', ':now'),
$qb->expr()->isNull('n.endDate')
)
)
)
->setParameter('now', $now);
return $qb;
}
}

View File

@@ -55,11 +55,20 @@ export interface ServerExceptionInterface extends TransportExceptionInterface {
body: string;
}
export interface ConflictHttpExceptionInterface extends TransportExceptionInterface {
name: 'ConflictHttpException';
violations: string[];
}
/**
* Generic api method that can be adapted to any fetch request
*/
export const makeFetch = <Input, Output>(method: 'POST'|'GET'|'PUT'|'PATCH'|'DELETE', url: string, body?: body | Input | null, options?: FetchParams): Promise<Output> => {
export const makeFetch = <Input, Output>(
method: 'POST'|'GET'|'PUT'|'PATCH'|'DELETE',
url: string, body?: body | Input | null,
options?: FetchParams
): Promise<Output> => {
let opts = {
method: method,
headers: {
@@ -67,6 +76,7 @@ export const makeFetch = <Input, Output>(method: 'POST'|'GET'|'PUT'|'PATCH'|'DEL
},
};
if (body !== null && typeof body !== 'undefined') {
Object.assign(opts, {body: JSON.stringify(body)})
}
@@ -90,6 +100,10 @@ export const makeFetch = <Input, Output>(method: 'POST'|'GET'|'PUT'|'PATCH'|'DEL
throw AccessException(response);
}
if (response.status === 409) {
throw ConflictHttpException(response);
}
throw {
name: 'Exception',
sta: response.status,
@@ -220,3 +234,12 @@ const ServerException = (code: number, body: string): ServerExceptionInterface =
return error;
}
const ConflictHttpException = (response: Response): ConflictHttpExceptionInterface => {
const error = {} as ConflictHttpExceptionInterface;
error.name = 'ConflictHttpException';
error.violations = ['Sorry, but someone else has already changed this entity. Please refresh the page and apply the changes again']
return error;
}

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -97,8 +97,6 @@ import MyNotifications from './MyNotifications';
import MyWorkflows from './MyWorkflows.vue';
import TabCounter from './TabCounter';
import { mapState } from "vuex";
import { makeFetch } from "ChillMainAssets/lib/api/apiMethods";
export default {
name: "App",
@@ -114,7 +112,7 @@ export default {
},
data() {
return {
activeTab: 'MyCustoms',
activeTab: 'MyCustoms'
}
},
computed: {
@@ -128,11 +126,8 @@ export default {
},
methods: {
selectTab(tab) {
if (tab !== 'MyCustoms') {
this.$store.dispatch('getByTab', { tab: tab });
}
this.$store.dispatch('getByTab', { tab: tab });
this.activeTab = tab;
console.log(this.activeTab)
}
},
mounted() {

View File

@@ -1,45 +0,0 @@
<template>
<div v-if="newsItems.length > 0">
<h1>{{ $t('widget.news.title') }}</h1>
<ul class="scrollable">
<NewsItem v-for="item in newsItems" :item="item" :key="item.id" />
</ul>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { fetchResults } from '../../../lib/api/apiMethods';
import Modal from '../../_components/Modal.vue';
import { NewsItemType } from '../../../types';
import NewsItem from './NewsItem.vue';
const newsItems = ref<NewsItemType[]>([])
onMounted(() => {
fetchResults<NewsItemType>('/api/1.0/main/news/current.json')
.then((news): Promise<void> => {
// console.log('news articles', response.results)
newsItems.value = news;
return Promise.resolve();
})
.catch((error: string) => {
console.error('Error fetching news items', error);
})
})
</script>
<style scoped>
ul {
list-style: none;
padding: 0;
}
h1 {
text-align: center;
}
</style>

View File

@@ -1,183 +0,0 @@
<template>
<li>
<h2>{{ props.item.title }}</h2>
<time class="createdBy" datetime="{{item.startDate.datetime}}">{{ $d(newsItemStartDate(), 'text') }}</time>
<div class="content" v-if="shouldTruncate(item.content)">
<div v-html="prepareContent(item.content)"></div>
<div class="float-end">
<button class="btn btn-sm btn-show read-more" @click="() => openModal(item)">{{ $t('widget.news.readMore') }}</button>
</div>
</div>
<div class="content" v-else>
<div v-html="convertMarkdownToHtml(item.content)"></div>
</div>
<modal v-if="showModal" @close="closeModal">
<template #header>
<p class="news-title">{{ item.title }}</p>
</template>
<template #body>
<p class="news-date">
<time class="createdBy" datetime="{{item.startDate.datetime}}">{{ $d(newsItemStartDate(), 'text') }}</time>
</p>
<div v-html="convertMarkdownToHtml(item.content)"></div>
</template>
</modal>
</li>
</template>
<script setup lang="ts">
import Modal from "ChillMainAssets/vuejs/_components/Modal.vue";
import { marked } from 'marked';
import DOMPurify from 'dompurify';
import { DateTime, NewsItemType } from "../../../types";
import type { PropType } from 'vue'
import { ref } from "vue";
import {ISOToDatetime} from '../../../chill/js/date';
const props = defineProps({
item: {
type: Object as PropType<NewsItemType>,
required: true
},
maxLength: {
type: Number,
required: false,
default: 350,
},
maxLines: {
type: Number,
required: false,
default: 3
}
})
const selectedArticle = ref<NewsItemType | null>(null);
const showModal = ref(false);
const openModal = (item: NewsItemType) => {
selectedArticle.value = item;
showModal.value = true;
};
const closeModal = () => {
selectedArticle.value = null;
showModal.value = false;
};
const shouldTruncate = (content: string): boolean => {
const lines = content.split('\n');
// Check if any line exceeds the maximum length
const tooManyLines = lines.length > props.maxLines;
return content.length > props.maxLength || tooManyLines;
};
const truncateContent = (content: string): string => {
let truncatedContent = content.slice(0, props.maxLength);
let linkDepth = 0;
let linkStartIndex = -1;
const lines = content.split('\n');
// Truncate if amount of lines are too many
if (lines.length > props.maxLines && content.length < props.maxLength) {
const truncatedContent = lines.slice(0, props.maxLines).join('\n').trim();
return truncatedContent + '...';
}
for (let i = 0; i < truncatedContent.length; i++) {
const char = truncatedContent[i];
if (char === '[') {
linkDepth++;
if (linkDepth === 1) {
linkStartIndex = i;
}
} else if (char === ']') {
linkDepth = Math.max(0, linkDepth - 1);
} else if (char === '(' && linkDepth === 0) {
truncatedContent = truncatedContent.slice(0, i);
break;
}
}
while (linkDepth > 0) {
truncatedContent += ']';
linkDepth--;
}
// If a link was found, append the URL inside the parentheses
if (linkStartIndex !== -1) {
const linkEndIndex = content.indexOf(')', linkStartIndex);
const url = content.slice(linkStartIndex + 1, linkEndIndex);
truncatedContent = truncatedContent.slice(0, linkStartIndex) + `(${url})`;
}
truncatedContent += '...';
return truncatedContent;
};
const preprocess = (markdown: string): string => {
return markdown;
}
const postprocess = (html: string): string => {
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
if ('target' in node) {
node.setAttribute('target', '_blank');
node.setAttribute('rel', 'noopener noreferrer');
}
if (!node.hasAttribute('target') && (node.hasAttribute('xlink:href') || node.hasAttribute('href'))) {
node.setAttribute('xlink:show', 'new');
}
})
return DOMPurify.sanitize(html);
}
const convertMarkdownToHtml = (markdown: string): string => {
marked.use({'hooks': {postprocess, preprocess}});
const rawHtml = marked(markdown);
return rawHtml;
};
const prepareContent = (content: string): string => {
const htmlContent = convertMarkdownToHtml(content);
return truncateContent(htmlContent);
};
const newsItemStartDate = (): null|Date => {
return ISOToDatetime(props.item?.startDate.datetime);
}
</script>
<style scoped>
li {
margin-bottom: 20px;
overflow: hidden;
padding: .8rem;
background-color: #fbfbfb;
border-radius: 4px;
}
h2 {
font-size: 1rem !important;
text-transform: uppercase;
}
.content {
overflow: hidden;
font-size: .9rem;
position: relative;
}
.news-title {
font-weight: bold;
}
</style>

Some files were not shown because too many files have changed in this diff Show More