mirror of
https://gitlab.com/Chill-Projet/chill-bundles.git
synced 2025-06-07 18:44:08 +00:00
Merge remote-tracking branch 'origin/master' into upgrade-sf5
This commit is contained in:
commit
84f515d451
3
.changes/v2.18.2.md
Normal file
3
.changes/v2.18.2.md
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
## v2.18.2 - 2024-04-12
|
||||||
|
### Fixed
|
||||||
|
* ([#250](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/250)) Postal codes import : fix the source URL and the keys to handle each record
|
20
.changes/v2.19.0.md
Normal file
20
.changes/v2.19.0.md
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
## v2.19.0 - 2024-05-14
|
||||||
|
### Feature
|
||||||
|
* ([#197](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/197)) Make the script which subscribe to microsoft calendars changes more tolerant to errors or missing configuration on the microsoft side
|
||||||
|
* ([#276](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/276)) Take closing date into account when computing the geographical unit on accompanying period. When a person moved after an accompanying period is closed, the date of closing accompanying period is took into account if it is earlier than the date given by the user.
|
||||||
|
### Fixed
|
||||||
|
* ([#270](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/270)) Fix broken link in homepage when a evaluation from a closed acc period was present in the homepage widget
|
||||||
|
* ([#275](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/275)) Allow the filter "filter accompanying period by geographical unit" to take period's location on address into account
|
||||||
|
### UX
|
||||||
|
* Form for document generation moved to the top of document list page
|
||||||
|
* ([#266](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/266)) Event bundle: adjust certain graphical issues for better user experience
|
||||||
|
|
||||||
|
|
||||||
|
### Traduction francophone des principaux changements
|
||||||
|
|
||||||
|
- script de synchronisation des agendas de microsoft Outlook: le script est plus tolérant aux erreurs de configuration côté serveur (manque de droit d'accès);
|
||||||
|
- dans les statistiques sur les parcours d'accompagnements, regroupement et filtre par unité géographique: lorsque la date de prise en compte de l'adresse est postérieure à la fermeture du parcours, c'est la date de fermeture du parcours qui est prise en compte (cela permet de tenir compte de la localisation de l'usager au moment de la fermeture dans le cas où celui-ci aurait déménagé par la suite);
|
||||||
|
- sur la page d'accueil, il n'y a plus de rappel pour les évaluations pour les parcours cloturés;
|
||||||
|
- correction du filtre "filtrer par zone géographique"
|
||||||
|
- répétition du bouton pour générer un document en haut de la page "liste des documents", quand il y a plus de cinq documents;
|
||||||
|
- module événement: améliorerations graphiques
|
25
CHANGELOG.md
25
CHANGELOG.md
@ -6,6 +6,31 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html),
|
|||||||
and is generated by [Changie](https://github.com/miniscruff/changie).
|
and is generated by [Changie](https://github.com/miniscruff/changie).
|
||||||
|
|
||||||
|
|
||||||
|
## v2.19.0 - 2024-05-14
|
||||||
|
### Feature
|
||||||
|
* ([#197](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/197)) Make the script which subscribe to microsoft calendars changes more tolerant to errors or missing configuration on the microsoft side
|
||||||
|
* ([#276](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/276)) Take closing date into account when computing the geographical unit on accompanying period. When a person moved after an accompanying period is closed, the date of closing accompanying period is took into account if it is earlier than the date given by the user.
|
||||||
|
### Fixed
|
||||||
|
* ([#270](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/270)) Fix broken link in homepage when a evaluation from a closed acc period was present in the homepage widget
|
||||||
|
* ([#275](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/275)) Allow the filter "filter accompanying period by geographical unit" to take period's location on address into account
|
||||||
|
### UX
|
||||||
|
* Form for document generation moved to the top of document list page
|
||||||
|
* ([#266](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/266)) Event bundle: adjust certain graphical issues for better user experience
|
||||||
|
|
||||||
|
|
||||||
|
### Traduction francophone des principaux changements
|
||||||
|
|
||||||
|
- script de synchronisation des agendas de microsoft Outlook: le script est plus tolérant aux erreurs de configuration côté serveur (manque de droit d'accès);
|
||||||
|
- dans les statistiques sur les parcours d'accompagnements, regroupement et filtre par unité géographique: lorsque la date de prise en compte de l'adresse est postérieure à la fermeture du parcours, c'est la date de fermeture du parcours qui est prise en compte (cela permet de tenir compte de la localisation de l'usager au moment de la fermeture dans le cas où celui-ci aurait déménagé par la suite);
|
||||||
|
- sur la page d'accueil, il n'y a plus de rappel pour les évaluations pour les parcours cloturés;
|
||||||
|
- correction du filtre "filtrer par zone géographique"
|
||||||
|
- répétition du bouton pour générer un document en haut de la page "liste des documents", quand il y a plus de cinq documents;
|
||||||
|
- module événement: améliorerations graphiques
|
||||||
|
|
||||||
|
## v2.18.2 - 2024-04-12
|
||||||
|
### Fixed
|
||||||
|
* ([#250](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/250)) Postal codes import : fix the source URL and the keys to handle each record
|
||||||
|
|
||||||
## v2.18.1 - 2024-03-26
|
## v2.18.1 - 2024-03-26
|
||||||
### Fixed
|
### Fixed
|
||||||
* Fix layout issue in document generation for admin (minor)
|
* Fix layout issue in document generation for admin (minor)
|
||||||
|
@ -9,6 +9,7 @@
|
|||||||
],
|
],
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^8.2",
|
"php": "^8.2",
|
||||||
|
"ext-dom": "*",
|
||||||
"ext-json": "*",
|
"ext-json": "*",
|
||||||
"ext-openssl": "*",
|
"ext-openssl": "*",
|
||||||
"ext-redis": "*",
|
"ext-redis": "*",
|
||||||
@ -92,7 +93,7 @@
|
|||||||
"phpstan/phpstan-deprecation-rules": "^1.1",
|
"phpstan/phpstan-deprecation-rules": "^1.1",
|
||||||
"phpstan/phpstan-strict-rules": "^1.0",
|
"phpstan/phpstan-strict-rules": "^1.0",
|
||||||
"phpunit/phpunit": ">= 7.5",
|
"phpunit/phpunit": ">= 7.5",
|
||||||
"rector/rector": "^1.0.0",
|
"rector/rector": "^1.1.0",
|
||||||
"symfony/debug-bundle": "^5.4",
|
"symfony/debug-bundle": "^5.4",
|
||||||
"symfony/dotenv": "^5.4",
|
"symfony/dotenv": "^5.4",
|
||||||
"symfony/maker-bundle": "^1.20",
|
"symfony/maker-bundle": "^1.20",
|
||||||
|
@ -8,6 +8,16 @@ Chill can store a list of geolocated address references, which are used to sugge
|
|||||||
|
|
||||||
Those addresses may be load from a dedicated source.
|
Those addresses may be load from a dedicated source.
|
||||||
|
|
||||||
|
Countries
|
||||||
|
=========
|
||||||
|
|
||||||
|
In order to load addresses into the chill application we first have to make sure that a list of countries is present.
|
||||||
|
To import the countries run the following command.
|
||||||
|
|
||||||
|
.. code-block:: bash
|
||||||
|
|
||||||
|
bin/console chill:main:countries:populate
|
||||||
|
|
||||||
In France
|
In France
|
||||||
=========
|
=========
|
||||||
|
|
||||||
|
6
phpstan-baseline-2024-05.neon
Normal file
6
phpstan-baseline-2024-05.neon
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
parameters:
|
||||||
|
ignoreErrors:
|
||||||
|
-
|
||||||
|
message: "#^Parameter \\#1 \\$records of method League\\\\Csv\\\\Writer\\:\\:insertAll\\(\\) expects iterable\\<array\\<float\\|int\\|string\\|Stringable\\|null\\>\\>, iterable\\<array\\<string, bool\\|int\\|string\\>\\> given\\.$#"
|
||||||
|
count: 1
|
||||||
|
path: src/Bundle/ChillMainBundle/Controller/UserExportController.php
|
@ -33,3 +33,5 @@ includes:
|
|||||||
- phpstan-baseline-level-5.neon
|
- phpstan-baseline-level-5.neon
|
||||||
- phpstan-deprecations-sf54.neon
|
- phpstan-deprecations-sf54.neon
|
||||||
- phpstan-baseline-deprecations-doctrine-orm.neon
|
- phpstan-baseline-deprecations-doctrine-orm.neon
|
||||||
|
- phpstan-baseline-2024-05.neon
|
||||||
|
|
||||||
|
@ -15,11 +15,10 @@ use Chill\ActivityBundle\Entity\Activity;
|
|||||||
use Chill\ActivityBundle\Entity\ActivityPresence;
|
use Chill\ActivityBundle\Entity\ActivityPresence;
|
||||||
use Chill\ActivityBundle\Form\Type\PickActivityReasonType;
|
use Chill\ActivityBundle\Form\Type\PickActivityReasonType;
|
||||||
use Chill\ActivityBundle\Security\Authorization\ActivityVoter;
|
use Chill\ActivityBundle\Security\Authorization\ActivityVoter;
|
||||||
use Chill\DocStoreBundle\Form\StoredObjectType;
|
use Chill\DocStoreBundle\Form\CollectionStoredObjectType;
|
||||||
use Chill\MainBundle\Entity\Center;
|
use Chill\MainBundle\Entity\Center;
|
||||||
use Chill\MainBundle\Entity\Location;
|
use Chill\MainBundle\Entity\Location;
|
||||||
use Chill\MainBundle\Entity\User;
|
use Chill\MainBundle\Entity\User;
|
||||||
use Chill\MainBundle\Form\Type\ChillCollectionType;
|
|
||||||
use Chill\MainBundle\Form\Type\ChillDateType;
|
use Chill\MainBundle\Form\Type\ChillDateType;
|
||||||
use Chill\MainBundle\Form\Type\CommentType;
|
use Chill\MainBundle\Form\Type\CommentType;
|
||||||
use Chill\MainBundle\Form\Type\PickUserDynamicType;
|
use Chill\MainBundle\Form\Type\PickUserDynamicType;
|
||||||
@ -276,16 +275,9 @@ class ActivityType extends AbstractType
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($activityType->isVisible('documents')) {
|
if ($activityType->isVisible('documents')) {
|
||||||
$builder->add('documents', ChillCollectionType::class, [
|
$builder->add('documents', CollectionStoredObjectType::class, [
|
||||||
'entry_type' => StoredObjectType::class,
|
|
||||||
'label' => $activityType->getLabel('documents'),
|
'label' => $activityType->getLabel('documents'),
|
||||||
'required' => $activityType->isRequired('documents'),
|
'required' => $activityType->isRequired('documents'),
|
||||||
'allow_add' => true,
|
|
||||||
'allow_delete' => true,
|
|
||||||
'button_add_label' => 'activity.Insert a document',
|
|
||||||
'button_remove_label' => 'activity.Remove a document',
|
|
||||||
'empty_collection_explain' => 'No documents',
|
|
||||||
'entry_options' => ['has_title' => true],
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -92,7 +92,9 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{%- if edit_form.documents is defined -%}
|
{%- if edit_form.documents is defined -%}
|
||||||
{{ form_row(edit_form.documents) }}
|
{{ form_label(edit_form.documents) }}
|
||||||
|
{{ form_errors(edit_form.documents) }}
|
||||||
|
{{ form_widget(edit_form.documents) }}
|
||||||
<div data-docgen-template-picker="data-docgen-template-picker" data-entity-class="Chill\ActivityBundle\Entity\Activity" data-entity-id="{{ entity.id }}"></div>
|
<div data-docgen-template-picker="data-docgen-template-picker" data-entity-class="Chill\ActivityBundle\Entity\Activity" data-entity-id="{{ entity.id }}"></div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Chill is a software for social workers
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view
|
||||||
|
* the LICENSE file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Chill\DocStoreBundle\Tests\Security\Guard;
|
||||||
|
|
||||||
|
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
|
||||||
|
use Chill\DocStoreBundle\Security\Guard\DavTokenAuthenticationEventSubscriber;
|
||||||
|
use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTAuthenticatedEvent;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Component\Security\Core\Authentication\Token\AbstractToken;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*
|
||||||
|
* @coversNothing
|
||||||
|
*/
|
||||||
|
class DavTokenAuthenticationEventSubscriberTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testOnJWTAuthenticatedWithDavDataInPayload(): void
|
||||||
|
{
|
||||||
|
$eventSubscriber = new DavTokenAuthenticationEventSubscriber();
|
||||||
|
$token = new class () extends AbstractToken {
|
||||||
|
public function getCredentials()
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
$event = new JWTAuthenticatedEvent([
|
||||||
|
'dav' => 1,
|
||||||
|
'so' => '1234',
|
||||||
|
'e' => 1,
|
||||||
|
], $token);
|
||||||
|
|
||||||
|
$eventSubscriber->onJWTAuthenticated($event);
|
||||||
|
|
||||||
|
self::assertTrue($token->hasAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT));
|
||||||
|
self::assertTrue($token->hasAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS));
|
||||||
|
self::assertEquals('1234', $token->getAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT));
|
||||||
|
self::assertEquals(StoredObjectRoleEnum::EDIT, $token->getAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testOnJWTAuthenticatedWithDavNoDataInPayload(): void
|
||||||
|
{
|
||||||
|
$eventSubscriber = new DavTokenAuthenticationEventSubscriber();
|
||||||
|
$token = new class () extends AbstractToken {
|
||||||
|
public function getCredentials()
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
$event = new JWTAuthenticatedEvent([], $token);
|
||||||
|
|
||||||
|
$eventSubscriber->onJWTAuthenticated($event);
|
||||||
|
|
||||||
|
self::assertFalse($token->hasAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT));
|
||||||
|
self::assertFalse($token->hasAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS));
|
||||||
|
}
|
||||||
|
}
|
@ -51,8 +51,6 @@ final class MapAndSubscribeUserCalendarCommand extends Command
|
|||||||
|
|
||||||
$limit = 50;
|
$limit = 50;
|
||||||
$offset = 0;
|
$offset = 0;
|
||||||
/** @var \DateInterval $interval the interval before the end of the expiration */
|
|
||||||
$interval = new \DateInterval('P1D');
|
|
||||||
$expiration = (new \DateTimeImmutable('now'))->add(new \DateInterval($input->getOption('subscription-duration')));
|
$expiration = (new \DateTimeImmutable('now'))->add(new \DateInterval($input->getOption('subscription-duration')));
|
||||||
$users = $this->userRepository->findAllAsArray('fr');
|
$users = $this->userRepository->findAllAsArray('fr');
|
||||||
$created = 0;
|
$created = 0;
|
||||||
@ -95,7 +93,6 @@ final class MapAndSubscribeUserCalendarCommand extends Command
|
|||||||
} catch (UserAbsenceSyncException $e) {
|
} catch (UserAbsenceSyncException $e) {
|
||||||
$this->logger->error('could not sync user absence', ['userId' => $user->getId(), 'email' => $user->getEmail(), 'exception' => $e->getTraceAsString(), 'message' => $e->getMessage()]);
|
$this->logger->error('could not sync user absence', ['userId' => $user->getId(), 'email' => $user->getEmail(), 'exception' => $e->getTraceAsString(), 'message' => $e->getMessage()]);
|
||||||
$output->writeln(sprintf('Could not sync user absence: id: %s and email: %s', $user->getId(), $user->getEmail()));
|
$output->writeln(sprintf('Could not sync user absence: id: %s and email: %s', $user->getId(), $user->getEmail()));
|
||||||
throw $e;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// we first try to renew an existing subscription, if any.
|
// we first try to renew an existing subscription, if any.
|
||||||
|
@ -49,15 +49,12 @@ interface CustomFieldInterface
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Return if the value can be considered as empty.
|
* Return if the value can be considered as empty.
|
||||||
*
|
|
||||||
* @param mixed $value the value passed throug the deserialize function
|
|
||||||
*/
|
*/
|
||||||
public function isEmptyValue(mixed $value, CustomField $customField);
|
public function isEmptyValue(mixed $value, CustomField $customField);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return a repsentation of the value of the CustomField.
|
* Return a repsentation of the value of the CustomField.
|
||||||
*
|
*
|
||||||
* @param mixed $value the raw value, **not deserialized** (= as stored in the db)
|
|
||||||
* @param \Chill\CustomFieldsBundle\CustomField\CustomField $customField
|
* @param \Chill\CustomFieldsBundle\CustomField\CustomField $customField
|
||||||
*
|
*
|
||||||
* @return string an html representation of the value
|
* @return string an html representation of the value
|
||||||
|
@ -399,8 +399,6 @@ final class CustomFieldsChoiceTest extends KernelTestCase
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @dataProvider emptyDataProvider
|
* @dataProvider emptyDataProvider
|
||||||
*
|
|
||||||
* @param mixed $data deserialized data
|
|
||||||
*/
|
*/
|
||||||
public function testIsEmptyValueEmpty(mixed $data)
|
public function testIsEmptyValueEmpty(mixed $data)
|
||||||
{
|
{
|
||||||
|
252
src/Bundle/ChillDocStoreBundle/Controller/WebdavController.php
Normal file
252
src/Bundle/ChillDocStoreBundle/Controller/WebdavController.php
Normal file
@ -0,0 +1,252 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Chill is a software for social workers
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view
|
||||||
|
* the LICENSE file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Chill\DocStoreBundle\Controller;
|
||||||
|
|
||||||
|
use Chill\DocStoreBundle\Dav\Request\PropfindRequestAnalyzer;
|
||||||
|
use Chill\DocStoreBundle\Dav\Response\DavResponse;
|
||||||
|
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||||
|
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
|
||||||
|
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||||
|
use Symfony\Component\Routing\Annotation\Route;
|
||||||
|
use Symfony\Component\Security\Core\Security;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provide endpoint for editing a document on the desktop using dav.
|
||||||
|
*
|
||||||
|
* This controller implements the minimal required methods to edit a document on a desktop software (i.e. LibreOffice)
|
||||||
|
* and save the document online.
|
||||||
|
*
|
||||||
|
* To avoid to ask for a password, the endpoints are protected using a JWT access token, which is inside the
|
||||||
|
* URL. This avoid the DAV Client (LibreOffice) to keep an access token in query parameter or in some header (which
|
||||||
|
* they are not able to understand). The JWT Guard is adapted with a dedicated token extractor which is going to read
|
||||||
|
* the segments (separation of "/"): the first segment must be the string "dav", and the second one must be the JWT.
|
||||||
|
*/
|
||||||
|
final readonly class WebdavController
|
||||||
|
{
|
||||||
|
private PropfindRequestAnalyzer $requestAnalyzer;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private \Twig\Environment $engine,
|
||||||
|
private StoredObjectManagerInterface $storedObjectManager,
|
||||||
|
private Security $security,
|
||||||
|
) {
|
||||||
|
$this->requestAnalyzer = new PropfindRequestAnalyzer();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @Route("/dav/{access_token}/get/{uuid}/", methods={"GET", "HEAD"}, name="chill_docstore_dav_directory_get")
|
||||||
|
*/
|
||||||
|
public function getDirectory(StoredObject $storedObject, string $access_token): Response
|
||||||
|
{
|
||||||
|
if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) {
|
||||||
|
throw new AccessDeniedHttpException();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new DavResponse(
|
||||||
|
$this->engine->render('@ChillDocStore/Webdav/directory.html.twig', [
|
||||||
|
'stored_object' => $storedObject,
|
||||||
|
'access_token' => $access_token,
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @Route("/dav/{access_token}/get/{uuid}/", methods={"OPTIONS"})
|
||||||
|
*/
|
||||||
|
public function optionsDirectory(StoredObject $storedObject): Response
|
||||||
|
{
|
||||||
|
if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) {
|
||||||
|
throw new AccessDeniedHttpException();
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = (new DavResponse(''))
|
||||||
|
->setEtag($this->storedObjectManager->etag($storedObject))
|
||||||
|
;
|
||||||
|
|
||||||
|
// $response->headers->add(['Allow' => 'OPTIONS,GET,HEAD,DELETE,PROPFIND,PUT,PROPPATCH,COPY,MOVE,REPORT,PATCH,POST,TRACE']);
|
||||||
|
$response->headers->add(['Allow' => 'OPTIONS,GET,HEAD,DELETE,PROPFIND,PUT']);
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @Route("/dav/{access_token}/get/{uuid}/", methods={"PROPFIND"})
|
||||||
|
*/
|
||||||
|
public function propfindDirectory(StoredObject $storedObject, string $access_token, Request $request): Response
|
||||||
|
{
|
||||||
|
if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) {
|
||||||
|
throw new AccessDeniedHttpException();
|
||||||
|
}
|
||||||
|
|
||||||
|
$depth = $request->headers->get('depth');
|
||||||
|
|
||||||
|
if ('0' !== $depth && '1' !== $depth) {
|
||||||
|
throw new BadRequestHttpException('only 1 and 0 are accepted for Depth header');
|
||||||
|
}
|
||||||
|
|
||||||
|
[$properties, $lastModified, $etag, $length] = $this->parseDavRequest($request->getContent(), $storedObject);
|
||||||
|
|
||||||
|
$response = new DavResponse(
|
||||||
|
$this->engine->render('@ChillDocStore/Webdav/directory_propfind.xml.twig', [
|
||||||
|
'stored_object' => $storedObject,
|
||||||
|
'properties' => $properties,
|
||||||
|
'last_modified' => $lastModified,
|
||||||
|
'etag' => $etag,
|
||||||
|
'content_length' => $length,
|
||||||
|
'depth' => (int) $depth,
|
||||||
|
'access_token' => $access_token,
|
||||||
|
]),
|
||||||
|
207
|
||||||
|
);
|
||||||
|
|
||||||
|
$response->headers->add([
|
||||||
|
'Content-Type' => 'text/xml',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @Route("/dav/{access_token}/get/{uuid}/d", name="chill_docstore_dav_document_get", methods={"GET"})
|
||||||
|
*/
|
||||||
|
public function getDocument(StoredObject $storedObject): Response
|
||||||
|
{
|
||||||
|
if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) {
|
||||||
|
throw new AccessDeniedHttpException();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (new DavResponse($this->storedObjectManager->read($storedObject)))
|
||||||
|
->setEtag($this->storedObjectManager->etag($storedObject));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @Route("/dav/{access_token}/get/{uuid}/d", methods={"HEAD"})
|
||||||
|
*/
|
||||||
|
public function headDocument(StoredObject $storedObject): Response
|
||||||
|
{
|
||||||
|
if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) {
|
||||||
|
throw new AccessDeniedHttpException();
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = new DavResponse('');
|
||||||
|
|
||||||
|
$response->headers->add(
|
||||||
|
[
|
||||||
|
'Content-Length' => $this->storedObjectManager->getContentLength($storedObject),
|
||||||
|
'Content-Type' => $storedObject->getType(),
|
||||||
|
'Etag' => $this->storedObjectManager->etag($storedObject),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @Route("/dav/{access_token}/get/{uuid}/d", methods={"OPTIONS"})
|
||||||
|
*/
|
||||||
|
public function optionsDocument(StoredObject $storedObject): Response
|
||||||
|
{
|
||||||
|
if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) {
|
||||||
|
throw new AccessDeniedHttpException();
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = (new DavResponse(''))
|
||||||
|
->setEtag($this->storedObjectManager->etag($storedObject))
|
||||||
|
;
|
||||||
|
|
||||||
|
$response->headers->add(['Allow' => 'OPTIONS,GET,HEAD,DELETE,PROPFIND,PUT']);
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @Route("/dav/{access_token}/get/{uuid}/d", methods={"PROPFIND"})
|
||||||
|
*/
|
||||||
|
public function propfindDocument(StoredObject $storedObject, string $access_token, Request $request): Response
|
||||||
|
{
|
||||||
|
if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) {
|
||||||
|
throw new AccessDeniedHttpException();
|
||||||
|
}
|
||||||
|
|
||||||
|
[$properties, $lastModified, $etag, $length] = $this->parseDavRequest($request->getContent(), $storedObject);
|
||||||
|
|
||||||
|
$response = new DavResponse(
|
||||||
|
$this->engine->render(
|
||||||
|
'@ChillDocStore/Webdav/doc_props.xml.twig',
|
||||||
|
[
|
||||||
|
'stored_object' => $storedObject,
|
||||||
|
'properties' => $properties,
|
||||||
|
'etag' => $etag,
|
||||||
|
'last_modified' => $lastModified,
|
||||||
|
'content_length' => $length,
|
||||||
|
'access_token' => $access_token,
|
||||||
|
]
|
||||||
|
),
|
||||||
|
207
|
||||||
|
);
|
||||||
|
|
||||||
|
$response
|
||||||
|
->headers->add([
|
||||||
|
'Content-Type' => 'text/xml',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @Route("/dav/{access_token}/get/{uuid}/d", methods={"PUT"})
|
||||||
|
*/
|
||||||
|
public function putDocument(StoredObject $storedObject, Request $request): Response
|
||||||
|
{
|
||||||
|
if (!$this->security->isGranted(StoredObjectRoleEnum::EDIT->value, $storedObject)) {
|
||||||
|
throw new AccessDeniedHttpException();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->storedObjectManager->write($storedObject, $request->getContent());
|
||||||
|
|
||||||
|
return new DavResponse('', Response::HTTP_NO_CONTENT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{0: array, 1: \DateTimeInterface, 2: string, 3: int} properties, lastModified, etag, length
|
||||||
|
*/
|
||||||
|
private function parseDavRequest(string $content, StoredObject $storedObject): array
|
||||||
|
{
|
||||||
|
$xml = new \DOMDocument();
|
||||||
|
$xml->loadXML($content);
|
||||||
|
|
||||||
|
$properties = $this->requestAnalyzer->getRequestedProperties($xml);
|
||||||
|
$requested = array_keys(array_filter($properties, fn ($item) => true === $item));
|
||||||
|
|
||||||
|
if (
|
||||||
|
in_array('lastModified', $requested, true)
|
||||||
|
|| in_array('etag', $requested, true)
|
||||||
|
) {
|
||||||
|
$lastModified = $this->storedObjectManager->getLastModified($storedObject);
|
||||||
|
$etag = $this->storedObjectManager->etag($storedObject);
|
||||||
|
}
|
||||||
|
if (in_array('contentLength', $requested, true)) {
|
||||||
|
$length = $this->storedObjectManager->getContentLength($storedObject);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
$properties,
|
||||||
|
$lastModified ?? null,
|
||||||
|
$etag ?? null,
|
||||||
|
$length ?? null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Chill is a software for social workers
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view
|
||||||
|
* the LICENSE file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Chill\DocStoreBundle\Dav\Exception;
|
||||||
|
|
||||||
|
class ParseRequestException extends \UnexpectedValueException
|
||||||
|
{
|
||||||
|
}
|
@ -0,0 +1,103 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Chill is a software for social workers
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view
|
||||||
|
* the LICENSE file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Chill\DocStoreBundle\Dav\Request;
|
||||||
|
|
||||||
|
use Chill\DocStoreBundle\Dav\Exception\ParseRequestException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @phpstan-type davProperties array{resourceType: bool, contentType: bool, lastModified: bool, creationDate: bool, contentLength: bool, etag: bool, supportedLock: bool, unknowns: list<array{xmlns: string, prop: string}>}
|
||||||
|
*/
|
||||||
|
class PropfindRequestAnalyzer
|
||||||
|
{
|
||||||
|
private const KNOWN_PROPS = [
|
||||||
|
'resourceType',
|
||||||
|
'contentType',
|
||||||
|
'lastModified',
|
||||||
|
'creationDate',
|
||||||
|
'contentLength',
|
||||||
|
'etag',
|
||||||
|
'supportedLock',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return davProperties
|
||||||
|
*/
|
||||||
|
public function getRequestedProperties(\DOMDocument $request): array
|
||||||
|
{
|
||||||
|
$propfinds = $request->getElementsByTagNameNS('DAV:', 'propfind');
|
||||||
|
|
||||||
|
if (0 === $propfinds->count()) {
|
||||||
|
throw new ParseRequestException('any propfind element found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (1 < $propfinds->count()) {
|
||||||
|
throw new ParseRequestException('too much propfind element found');
|
||||||
|
}
|
||||||
|
|
||||||
|
$propfind = $propfinds->item(0);
|
||||||
|
|
||||||
|
if (0 === $propfind->childNodes->count()) {
|
||||||
|
throw new ParseRequestException('no element under propfind');
|
||||||
|
}
|
||||||
|
|
||||||
|
$unknows = [];
|
||||||
|
$props = [];
|
||||||
|
|
||||||
|
foreach ($propfind->childNodes->getIterator() as $prop) {
|
||||||
|
/** @var \DOMNode $prop */
|
||||||
|
if (XML_ELEMENT_NODE !== $prop->nodeType) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('propname' === $prop->nodeName) {
|
||||||
|
return $this->baseProps(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($prop->childNodes->getIterator() as $getProp) {
|
||||||
|
if (XML_ELEMENT_NODE !== $getProp->nodeType) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('DAV:' !== $getProp->lookupNamespaceURI(null)) {
|
||||||
|
$unknows[] = ['xmlns' => $getProp->lookupNamespaceURI(null), 'prop' => $getProp->nodeName];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$props[] = match ($getProp->nodeName) {
|
||||||
|
'resourcetype' => 'resourceType',
|
||||||
|
'getcontenttype' => 'contentType',
|
||||||
|
'getlastmodified' => 'lastModified',
|
||||||
|
default => '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$props = array_filter(array_values($props), fn (string $item) => '' !== $item);
|
||||||
|
|
||||||
|
return [...$this->baseProps(false), ...array_combine($props, array_fill(0, count($props), true)), 'unknowns' => $unknows];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return davProperties
|
||||||
|
*/
|
||||||
|
private function baseProps(bool $default = false): array
|
||||||
|
{
|
||||||
|
return
|
||||||
|
[
|
||||||
|
...array_combine(
|
||||||
|
self::KNOWN_PROPS,
|
||||||
|
array_fill(0, count(self::KNOWN_PROPS), $default)
|
||||||
|
),
|
||||||
|
'unknowns' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
24
src/Bundle/ChillDocStoreBundle/Dav/Response/DavResponse.php
Normal file
24
src/Bundle/ChillDocStoreBundle/Dav/Response/DavResponse.php
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Chill is a software for social workers
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view
|
||||||
|
* the LICENSE file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Chill\DocStoreBundle\Dav\Response;
|
||||||
|
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class DavResponse extends Response
|
||||||
|
{
|
||||||
|
public function __construct($content = '', int $status = 200, array $headers = [])
|
||||||
|
{
|
||||||
|
parent::__construct($content, $status, $headers);
|
||||||
|
|
||||||
|
$this->headers->add(['DAV' => '1']);
|
||||||
|
}
|
||||||
|
}
|
@ -39,15 +39,15 @@ class StoredObject implements Document, TrackCreationInterface
|
|||||||
final public const STATUS_PENDING = 'pending';
|
final public const STATUS_PENDING = 'pending';
|
||||||
final public const STATUS_FAILURE = 'failure';
|
final public const STATUS_FAILURE = 'failure';
|
||||||
|
|
||||||
#[Serializer\Groups(['read', 'write'])]
|
#[Serializer\Groups(['write'])]
|
||||||
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, name: 'datas')]
|
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, name: 'datas')]
|
||||||
private array $datas = [];
|
private array $datas = [];
|
||||||
|
|
||||||
#[Serializer\Groups(['read', 'write'])]
|
#[Serializer\Groups(['write'])]
|
||||||
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT)]
|
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT)]
|
||||||
private string $filename = '';
|
private string $filename = '';
|
||||||
|
|
||||||
#[Serializer\Groups(['read', 'write'])]
|
#[Serializer\Groups(['write'])]
|
||||||
#[ORM\Id]
|
#[ORM\Id]
|
||||||
#[ORM\GeneratedValue]
|
#[ORM\GeneratedValue]
|
||||||
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER)]
|
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER)]
|
||||||
@ -56,23 +56,23 @@ class StoredObject implements Document, TrackCreationInterface
|
|||||||
/**
|
/**
|
||||||
* @var int[]
|
* @var int[]
|
||||||
*/
|
*/
|
||||||
#[Serializer\Groups(['read', 'write'])]
|
#[Serializer\Groups(['write'])]
|
||||||
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, name: 'iv')]
|
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, name: 'iv')]
|
||||||
private array $iv = [];
|
private array $iv = [];
|
||||||
|
|
||||||
#[Serializer\Groups(['read', 'write'])]
|
#[Serializer\Groups(['write'])]
|
||||||
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, name: 'key')]
|
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, name: 'key')]
|
||||||
private array $keyInfos = [];
|
private array $keyInfos = [];
|
||||||
|
|
||||||
#[Serializer\Groups(['read', 'write'])]
|
#[Serializer\Groups(['write'])]
|
||||||
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, name: 'title')]
|
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, name: 'title')]
|
||||||
private string $title = '';
|
private string $title = '';
|
||||||
|
|
||||||
#[Serializer\Groups(['read', 'write'])]
|
#[Serializer\Groups(['write'])]
|
||||||
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, name: 'type', options: ['default' => ''])]
|
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, name: 'type', options: ['default' => ''])]
|
||||||
private string $type = '';
|
private string $type = '';
|
||||||
|
|
||||||
#[Serializer\Groups(['read', 'write'])]
|
#[Serializer\Groups(['write'])]
|
||||||
#[ORM\Column(type: 'uuid', unique: true)]
|
#[ORM\Column(type: 'uuid', unique: true)]
|
||||||
private UuidInterface $uuid;
|
private UuidInterface $uuid;
|
||||||
|
|
||||||
@ -98,7 +98,7 @@ class StoredObject implements Document, TrackCreationInterface
|
|||||||
* @param StoredObject::STATUS_* $status
|
* @param StoredObject::STATUS_* $status
|
||||||
*/
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
#[Serializer\Groups(['read'])] #[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, options: ['default' => 'ready'])]
|
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, options: ['default' => 'ready'])]
|
||||||
private string $status = 'ready'
|
private string $status = 'ready'
|
||||||
) {
|
) {
|
||||||
$this->uuid = Uuid::uuid4();
|
$this->uuid = Uuid::uuid4();
|
||||||
@ -114,7 +114,7 @@ class StoredObject implements Document, TrackCreationInterface
|
|||||||
/**
|
/**
|
||||||
* @deprecated
|
* @deprecated
|
||||||
*/
|
*/
|
||||||
#[Serializer\Groups(['read', 'write'])]
|
#[Serializer\Groups(['write'])]
|
||||||
public function getCreationDate(): \DateTime
|
public function getCreationDate(): \DateTime
|
||||||
{
|
{
|
||||||
if (null === $this->createdAt) {
|
if (null === $this->createdAt) {
|
||||||
|
@ -24,7 +24,7 @@ use Symfony\Component\Form\Extension\Core\Type\TextType;
|
|||||||
use Symfony\Component\Form\FormBuilderInterface;
|
use Symfony\Component\Form\FormBuilderInterface;
|
||||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||||
|
|
||||||
class AccompanyingCourseDocumentType extends AbstractType
|
final class AccompanyingCourseDocumentType extends AbstractType
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly TranslatableStringHelperInterface $translatableStringHelper
|
private readonly TranslatableStringHelperInterface $translatableStringHelper
|
||||||
|
@ -0,0 +1,37 @@
|
|||||||
|
<?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\Form;
|
||||||
|
|
||||||
|
use Chill\MainBundle\Form\Type\ChillCollectionType;
|
||||||
|
use Symfony\Component\Form\AbstractType;
|
||||||
|
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||||
|
|
||||||
|
class CollectionStoredObjectType extends AbstractType
|
||||||
|
{
|
||||||
|
public function configureOptions(OptionsResolver $resolver)
|
||||||
|
{
|
||||||
|
$resolver
|
||||||
|
->setDefault('entry_type', StoredObjectType::class)
|
||||||
|
->setDefault('allow_add', true)
|
||||||
|
->setDefault('allow_delete', true)
|
||||||
|
->setDefault('button_add_label', 'stored_object.Insert a document')
|
||||||
|
->setDefault('button_remove_label', 'stored_object.Remove a document')
|
||||||
|
->setDefault('empty_collection_explain', 'No documents')
|
||||||
|
->setDefault('entry_options', ['has_title' => true])
|
||||||
|
->setDefault('js_caller', 'data-collection-stored-object');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getParent()
|
||||||
|
{
|
||||||
|
return ChillCollectionType::class;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,75 @@
|
|||||||
|
<?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\Form\DataMapper;
|
||||||
|
|
||||||
|
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||||
|
use Symfony\Component\Form\DataMapperInterface;
|
||||||
|
use Symfony\Component\Form\Exception;
|
||||||
|
use Symfony\Component\Form\FormInterface;
|
||||||
|
|
||||||
|
class StoredObjectDataMapper implements DataMapperInterface
|
||||||
|
{
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param FormInterface[]|\Traversable $forms A list of {@link FormInterface} instances
|
||||||
|
*/
|
||||||
|
public function mapDataToForms($viewData, $forms)
|
||||||
|
{
|
||||||
|
if (null === $viewData) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$viewData instanceof StoredObject) {
|
||||||
|
throw new Exception\UnexpectedTypeException($viewData, StoredObject::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
$forms = iterator_to_array($forms);
|
||||||
|
if (array_key_exists('title', $forms)) {
|
||||||
|
$forms['title']->setData($viewData->getTitle());
|
||||||
|
}
|
||||||
|
$forms['stored_object']->setData($viewData);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param FormInterface[]|\Traversable $forms A list of {@link FormInterface} instances
|
||||||
|
*/
|
||||||
|
public function mapFormsToData($forms, &$viewData)
|
||||||
|
{
|
||||||
|
$forms = iterator_to_array($forms);
|
||||||
|
|
||||||
|
if (!(null === $viewData || $viewData instanceof StoredObject)) {
|
||||||
|
throw new Exception\UnexpectedTypeException($viewData, StoredObject::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null === $forms['stored_object']->getData()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var StoredObject $viewData */
|
||||||
|
if ($viewData->getFilename() !== $forms['stored_object']->getData()['filename']) {
|
||||||
|
// we do not want to erase the previous object
|
||||||
|
$viewData = new StoredObject();
|
||||||
|
}
|
||||||
|
|
||||||
|
$viewData->setFilename($forms['stored_object']->getData()['filename']);
|
||||||
|
$viewData->setIv($forms['stored_object']->getData()['iv']);
|
||||||
|
$viewData->setKeyInfos($forms['stored_object']->getData()['keyInfos']);
|
||||||
|
$viewData->setType($forms['stored_object']->getData()['type']);
|
||||||
|
|
||||||
|
if (array_key_exists('title', $forms)) {
|
||||||
|
$viewData->setTitle($forms['title']->getData());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,52 @@
|
|||||||
|
<?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\Form\DataTransformer;
|
||||||
|
|
||||||
|
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||||
|
use Chill\DocStoreBundle\Serializer\Normalizer\StoredObjectNormalizer;
|
||||||
|
use Symfony\Component\Form\DataTransformerInterface;
|
||||||
|
use Symfony\Component\Form\Exception\UnexpectedTypeException;
|
||||||
|
use Symfony\Component\Serializer\SerializerInterface;
|
||||||
|
|
||||||
|
class StoredObjectDataTransformer implements DataTransformerInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly SerializerInterface $serializer
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function transform(mixed $value): mixed
|
||||||
|
{
|
||||||
|
if (null === $value) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($value instanceof StoredObject) {
|
||||||
|
return $this->serializer->serialize($value, 'json', [
|
||||||
|
'groups' => [
|
||||||
|
StoredObjectNormalizer::ADD_DAV_EDIT_LINK_CONTEXT,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new UnexpectedTypeException($value, StoredObject::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reverseTransform(mixed $value): mixed
|
||||||
|
{
|
||||||
|
if ('' === $value || null === $value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return json_decode((string) $value, true, 10, JSON_THROW_ON_ERROR);
|
||||||
|
}
|
||||||
|
}
|
@ -11,11 +11,10 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Chill\DocStoreBundle\Form;
|
namespace Chill\DocStoreBundle\Form;
|
||||||
|
|
||||||
use Chill\DocStoreBundle\Form\Type\AsyncUploaderType;
|
|
||||||
use Chill\DocStoreBundle\Entity\StoredObject;
|
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Chill\DocStoreBundle\Form\DataMapper\StoredObjectDataMapper;
|
||||||
|
use Chill\DocStoreBundle\Form\DataTransformer\StoredObjectDataTransformer;
|
||||||
use Symfony\Component\Form\AbstractType;
|
use Symfony\Component\Form\AbstractType;
|
||||||
use Symfony\Component\Form\CallbackTransformer;
|
|
||||||
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
|
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
|
||||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||||
use Symfony\Component\Form\FormBuilderInterface;
|
use Symfony\Component\Form\FormBuilderInterface;
|
||||||
@ -24,9 +23,13 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
|
|||||||
/**
|
/**
|
||||||
* Form type which allow to join a document.
|
* Form type which allow to join a document.
|
||||||
*/
|
*/
|
||||||
class StoredObjectType extends AbstractType
|
final class StoredObjectType extends AbstractType
|
||||||
{
|
{
|
||||||
public function __construct(private readonly EntityManagerInterface $em) {}
|
public function __construct(
|
||||||
|
private readonly StoredObjectDataTransformer $storedObjectDataTransformer,
|
||||||
|
private readonly StoredObjectDataMapper $storedObjectDataMapper,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
public function buildForm(FormBuilderInterface $builder, array $options)
|
public function buildForm(FormBuilderInterface $builder, array $options)
|
||||||
{
|
{
|
||||||
@ -37,30 +40,9 @@ class StoredObjectType extends AbstractType
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$builder
|
$builder->add('stored_object', HiddenType::class);
|
||||||
->add('filename', AsyncUploaderType::class)
|
$builder->get('stored_object')->addModelTransformer($this->storedObjectDataTransformer);
|
||||||
->add('type', HiddenType::class)
|
$builder->setDataMapper($this->storedObjectDataMapper);
|
||||||
->add('keyInfos', HiddenType::class)
|
|
||||||
->add('iv', HiddenType::class);
|
|
||||||
|
|
||||||
$builder
|
|
||||||
->get('keyInfos')
|
|
||||||
->addModelTransformer(new CallbackTransformer(
|
|
||||||
$this->transform(...),
|
|
||||||
$this->reverseTransform(...)
|
|
||||||
));
|
|
||||||
$builder
|
|
||||||
->get('iv')
|
|
||||||
->addModelTransformer(new CallbackTransformer(
|
|
||||||
$this->transform(...),
|
|
||||||
$this->reverseTransform(...)
|
|
||||||
));
|
|
||||||
|
|
||||||
$builder
|
|
||||||
->addModelTransformer(new CallbackTransformer(
|
|
||||||
$this->transformObject(...),
|
|
||||||
$this->reverseTransformObject(...)
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function configureOptions(OptionsResolver $resolver)
|
public function configureOptions(OptionsResolver $resolver)
|
||||||
@ -72,43 +54,4 @@ class StoredObjectType extends AbstractType
|
|||||||
->setDefault('has_title', false)
|
->setDefault('has_title', false)
|
||||||
->setAllowedTypes('has_title', ['bool']);
|
->setAllowedTypes('has_title', ['bool']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function reverseTransform($value)
|
|
||||||
{
|
|
||||||
if (null === $value) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return \json_decode((string) $value, true, 512, JSON_THROW_ON_ERROR);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function reverseTransformObject($object)
|
|
||||||
{
|
|
||||||
if (null === $object) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (null === $object->getFilename()) {
|
|
||||||
// remove the original object
|
|
||||||
$this->em->remove($object);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $object;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function transform($object)
|
|
||||||
{
|
|
||||||
if (null === $object) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return \json_encode($object, JSON_THROW_ON_ERROR);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function transformObject($object = null)
|
|
||||||
{
|
|
||||||
return $object;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,86 @@
|
|||||||
|
import {CollectionEventPayload} from "../../../../../ChillMainBundle/Resources/public/module/collection";
|
||||||
|
import {createApp} from "vue";
|
||||||
|
import DropFileWidget from "../../vuejs/DropFileWidget/DropFileWidget.vue"
|
||||||
|
import {StoredObject, StoredObjectCreated} from "../../types";
|
||||||
|
import {_createI18n} from "../../../../../ChillMainBundle/Resources/public/vuejs/_js/i18n";
|
||||||
|
const i18n = _createI18n({});
|
||||||
|
|
||||||
|
const startApp = (divElement: HTMLDivElement, collectionEntry: null|HTMLLIElement): void => {
|
||||||
|
console.log('app started', divElement);
|
||||||
|
const input_stored_object: HTMLInputElement|null = divElement.querySelector("input[data-stored-object]");
|
||||||
|
if (null === input_stored_object) {
|
||||||
|
throw new Error('input to stored object not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
let existingDoc: StoredObject|null = null;
|
||||||
|
if (input_stored_object.value !== "") {
|
||||||
|
existingDoc = JSON.parse(input_stored_object.value);
|
||||||
|
}
|
||||||
|
const app_container = document.createElement("div");
|
||||||
|
divElement.appendChild(app_container);
|
||||||
|
|
||||||
|
const app = createApp({
|
||||||
|
template: '<drop-file-widget :existingDoc="this.$data.existingDoc" :allowRemove="true" @addDocument="this.addDocument" @removeDocument="removeDocument"></drop-file-widget>',
|
||||||
|
data(vm) {
|
||||||
|
return {
|
||||||
|
existingDoc: existingDoc,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
DropFileWidget,
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
addDocument: function(object: StoredObjectCreated): void {
|
||||||
|
console.log('object added', object);
|
||||||
|
this.$data.existingDoc = object;
|
||||||
|
input_stored_object.value = JSON.stringify(object);
|
||||||
|
},
|
||||||
|
removeDocument: function(object: StoredObject): void {
|
||||||
|
console.log('catch remove document', object);
|
||||||
|
input_stored_object.value = "";
|
||||||
|
this.$data.existingDoc = null;
|
||||||
|
console.log('collectionEntry', collectionEntry);
|
||||||
|
|
||||||
|
if (null !== collectionEntry) {
|
||||||
|
console.log('will remove collection');
|
||||||
|
collectionEntry.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use(i18n).mount(app_container);
|
||||||
|
}
|
||||||
|
window.addEventListener('collection-add-entry', ((e: CustomEvent<CollectionEventPayload>) => {
|
||||||
|
const detail = e.detail;
|
||||||
|
const divElement: null|HTMLDivElement = detail.entry.querySelector('div[data-stored-object]');
|
||||||
|
|
||||||
|
if (null === divElement) {
|
||||||
|
throw new Error('div[data-stored-object] not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
startApp(divElement, detail.entry);
|
||||||
|
}) as EventListener);
|
||||||
|
|
||||||
|
window.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const upload_inputs: NodeListOf<HTMLDivElement> = document.querySelectorAll('div[data-stored-object]');
|
||||||
|
|
||||||
|
upload_inputs.forEach((input: HTMLDivElement): void => {
|
||||||
|
// test for a parent to check if this is a collection entry
|
||||||
|
let collectionEntry: null|HTMLLIElement = null;
|
||||||
|
let parent = input.parentElement;
|
||||||
|
console.log('parent', parent);
|
||||||
|
if (null !== parent) {
|
||||||
|
let grandParent = parent.parentElement;
|
||||||
|
console.log('grandParent', grandParent);
|
||||||
|
if (null !== grandParent) {
|
||||||
|
if (grandParent.tagName.toLowerCase() === 'li' && grandParent.classList.contains('entry')) {
|
||||||
|
collectionEntry = grandParent as HTMLLIElement;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
startApp(input, collectionEntry);
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
export {}
|
@ -17,18 +17,22 @@ window.addEventListener('DOMContentLoaded', function (e) {
|
|||||||
canEdit: string,
|
canEdit: string,
|
||||||
storedObject: string,
|
storedObject: string,
|
||||||
buttonSmall: string,
|
buttonSmall: string,
|
||||||
|
davLink: string,
|
||||||
|
davLinkExpiration: string,
|
||||||
};
|
};
|
||||||
|
|
||||||
const
|
const
|
||||||
storedObject = JSON.parse(datasets.storedObject) as StoredObject,
|
storedObject = JSON.parse(datasets.storedObject) as StoredObject,
|
||||||
filename = datasets.filename,
|
filename = datasets.filename,
|
||||||
canEdit = datasets.canEdit === '1',
|
canEdit = datasets.canEdit === '1',
|
||||||
small = datasets.buttonSmall === '1'
|
small = datasets.buttonSmall === '1',
|
||||||
|
davLink = 'davLink' in datasets && datasets.davLink !== '' ? datasets.davLink : null,
|
||||||
|
davLinkExpiration = 'davLinkExpiration' in datasets ? Number.parseInt(datasets.davLinkExpiration) : null
|
||||||
;
|
;
|
||||||
|
|
||||||
return { storedObject, filename, canEdit, small };
|
return { storedObject, filename, canEdit, small, davLink, davLinkExpiration };
|
||||||
},
|
},
|
||||||
template: '<document-action-buttons-group :can-edit="canEdit" :filename="filename" :stored-object="storedObject" :small="small" @on-stored-object-status-change="onStoredObjectStatusChange"></document-action-buttons-group>',
|
template: '<document-action-buttons-group :can-edit="canEdit" :filename="filename" :stored-object="storedObject" :small="small" :dav-link="davLink" :dav-link-expiration="davLinkExpiration" @on-stored-object-status-change="onStoredObjectStatusChange"></document-action-buttons-group>',
|
||||||
methods: {
|
methods: {
|
||||||
onStoredObjectStatusChange: function(newStatus: StoredObjectStatusChange): void {
|
onStoredObjectStatusChange: function(newStatus: StoredObjectStatusChange): void {
|
||||||
this.$data.storedObject.status = newStatus.status;
|
this.$data.storedObject.status = newStatus.status;
|
||||||
|
@ -17,6 +17,20 @@ export interface StoredObject {
|
|||||||
type: string,
|
type: string,
|
||||||
uuid: string,
|
uuid: string,
|
||||||
status: StoredObjectStatus,
|
status: StoredObjectStatus,
|
||||||
|
_links?: {
|
||||||
|
dav_link?: {
|
||||||
|
href: string
|
||||||
|
expiration: number
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoredObjectCreated {
|
||||||
|
status: "stored_object_created",
|
||||||
|
filename: string,
|
||||||
|
iv: Uint8Array,
|
||||||
|
keyInfos: object,
|
||||||
|
type: string,
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StoredObjectStatusChange {
|
export interface StoredObjectStatusChange {
|
||||||
@ -33,3 +47,18 @@ export type WopiEditButtonExecutableBeforeLeaveFunction = {
|
|||||||
(): Promise<void>
|
(): Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Object containing information for performering a POST request to a swift object store
|
||||||
|
*/
|
||||||
|
export interface PostStoreObjectSignature {
|
||||||
|
method: "POST",
|
||||||
|
max_file_size: number,
|
||||||
|
max_file_count: 1,
|
||||||
|
expires: number,
|
||||||
|
submit_delay: 180,
|
||||||
|
redirect: string,
|
||||||
|
prefix: string,
|
||||||
|
url: string,
|
||||||
|
signature: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="'ready' === props.storedObject.status" class="btn-group">
|
<div v-if="'ready' === props.storedObject.status || 'stored_object_created' === props.storedObject.status" class="btn-group">
|
||||||
<button :class="Object.assign({'btn': true, 'btn-outline-primary': true, 'dropdown-toggle': true, 'btn-sm': props.small})" type="button" data-bs-toggle="dropdown" aria-expanded="false">
|
<button :class="Object.assign({'btn': true, 'btn-outline-primary': true, 'dropdown-toggle': true, 'btn-sm': props.small})" type="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||||
Actions
|
Actions
|
||||||
</button>
|
</button>
|
||||||
@ -7,6 +7,9 @@
|
|||||||
<li v-if="props.canEdit && is_extension_editable(props.storedObject.type)">
|
<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>
|
<wopi-edit-button :stored-object="props.storedObject" :classes="{'dropdown-item': true}" :execute-before-leave="props.executeBeforeLeave"></wopi-edit-button>
|
||||||
</li>
|
</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">
|
<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>
|
<convert-button :stored-object="props.storedObject" :filename="filename" :classes="{'dropdown-item': true}"></convert-button>
|
||||||
</li>
|
</li>
|
||||||
@ -32,13 +35,14 @@ import DownloadButton from "./StoredObjectButton/DownloadButton.vue";
|
|||||||
import WopiEditButton from "./StoredObjectButton/WopiEditButton.vue";
|
import WopiEditButton from "./StoredObjectButton/WopiEditButton.vue";
|
||||||
import {is_extension_editable, is_extension_viewable, is_object_ready} from "./StoredObjectButton/helpers";
|
import {is_extension_editable, is_extension_viewable, is_object_ready} from "./StoredObjectButton/helpers";
|
||||||
import {
|
import {
|
||||||
StoredObject,
|
StoredObject, StoredObjectCreated,
|
||||||
StoredObjectStatusChange,
|
StoredObjectStatusChange,
|
||||||
WopiEditButtonExecutableBeforeLeaveFunction
|
WopiEditButtonExecutableBeforeLeaveFunction
|
||||||
} from "../types";
|
} from "../types";
|
||||||
|
import DesktopEditButton from "ChillDocStoreAssets/vuejs/StoredObjectButton/DesktopEditButton.vue";
|
||||||
|
|
||||||
interface DocumentActionButtonsGroupConfig {
|
interface DocumentActionButtonsGroupConfig {
|
||||||
storedObject: StoredObject,
|
storedObject: StoredObject|StoredObjectCreated,
|
||||||
small?: boolean,
|
small?: boolean,
|
||||||
canEdit?: boolean,
|
canEdit?: boolean,
|
||||||
canDownload?: boolean,
|
canDownload?: boolean,
|
||||||
@ -57,6 +61,16 @@ interface DocumentActionButtonsGroupConfig {
|
|||||||
* If set, will execute this function before leaving to the editor
|
* If set, will execute this function before leaving to the editor
|
||||||
*/
|
*/
|
||||||
executeBeforeLeave?: WopiEditButtonExecutableBeforeLeaveFunction,
|
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<{
|
const emit = defineEmits<{
|
||||||
@ -68,7 +82,7 @@ const props = withDefaults(defineProps<DocumentActionButtonsGroupConfig>(), {
|
|||||||
canEdit: true,
|
canEdit: true,
|
||||||
canDownload: true,
|
canDownload: true,
|
||||||
canConvertPdf: true,
|
canConvertPdf: true,
|
||||||
returnPath: window.location.pathname + window.location.search + window.location.hash,
|
returnPath: window.location.pathname + window.location.search + window.location.hash
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -85,6 +99,7 @@ const checkForReady = function(): void {
|
|||||||
if (
|
if (
|
||||||
'ready' === props.storedObject.status
|
'ready' === props.storedObject.status
|
||||||
|| 'failure' === props.storedObject.status
|
|| 'failure' === props.storedObject.status
|
||||||
|
|| 'stored_object_created' === props.storedObject.status
|
||||||
// stop reloading if the page stays opened for a long time
|
// stop reloading if the page stays opened for a long time
|
||||||
|| tryiesForReady > maxTryiesForReady
|
|| tryiesForReady > maxTryiesForReady
|
||||||
) {
|
) {
|
||||||
@ -97,6 +112,11 @@ const checkForReady = function(): void {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onObjectNewStatusCallback = async function(): Promise<void> {
|
const onObjectNewStatusCallback = async function(): Promise<void> {
|
||||||
|
|
||||||
|
if (props.storedObject.status === 'stored_object_created') {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
const new_status = await is_object_ready(props.storedObject);
|
const new_status = await is_object_ready(props.storedObject);
|
||||||
if (props.storedObject.status !== new_status.status) {
|
if (props.storedObject.status !== new_status.status) {
|
||||||
emit('onStoredObjectStatusChange', new_status);
|
emit('onStoredObjectStatusChange', new_status);
|
||||||
|
@ -0,0 +1,155 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
|
||||||
|
import {StoredObject, StoredObjectCreated} from "../../types";
|
||||||
|
import {encryptFile, uploadFile} from "../_components/helper";
|
||||||
|
import {computed, ref, Ref} from "vue";
|
||||||
|
|
||||||
|
interface DropFileConfig {
|
||||||
|
existingDoc?: StoredObjectCreated|StoredObject,
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<DropFileConfig>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'addDocument', stored_object: StoredObjectCreated): void,
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const is_dragging: Ref<boolean> = ref(false);
|
||||||
|
const uploading: Ref<boolean> = ref(false);
|
||||||
|
|
||||||
|
const has_existing_doc = computed<boolean>(() => {
|
||||||
|
return props.existingDoc !== undefined && props.existingDoc !== null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const onDragOver = (e: Event) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
is_dragging.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDragLeave = (e: Event) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
is_dragging.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDrop = (e: DragEvent) => {
|
||||||
|
console.log('on drop', e);
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const files = e.dataTransfer?.files;
|
||||||
|
|
||||||
|
if (null === files || undefined === files) {
|
||||||
|
console.error("no files transferred", e.dataTransfer);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (files.length === 0) {
|
||||||
|
console.error("no files given");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleFile(files[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
const onZoneClick = (e: Event) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.type = "file";
|
||||||
|
input.addEventListener("change", onFileChange);
|
||||||
|
|
||||||
|
input.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
const onFileChange = async (event: Event): Promise<void> => {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
console.log('event triggered', input);
|
||||||
|
|
||||||
|
if (input.files && input.files[0]) {
|
||||||
|
console.log('file added', input.files[0]);
|
||||||
|
const file = input.files[0];
|
||||||
|
await handleFile(file);
|
||||||
|
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
throw 'No file given';
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFile = async (file: File): Promise<void> => {
|
||||||
|
uploading.value = true;
|
||||||
|
const type = file.type;
|
||||||
|
const buffer = await file.arrayBuffer();
|
||||||
|
const [encrypted, iv, jsonWebKey] = await encryptFile(buffer);
|
||||||
|
const filename = await uploadFile(encrypted);
|
||||||
|
|
||||||
|
console.log(iv, jsonWebKey);
|
||||||
|
|
||||||
|
const storedObject: StoredObjectCreated = {
|
||||||
|
filename: filename,
|
||||||
|
iv,
|
||||||
|
keyInfos: jsonWebKey,
|
||||||
|
type: type,
|
||||||
|
status: "stored_object_created",
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('addDocument', storedObject);
|
||||||
|
uploading.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="drop-file">
|
||||||
|
<div v-if="!uploading" :class="{ area: true, dragging: is_dragging}" @click="onZoneClick" @dragover="onDragOver" @dragleave="onDragLeave" @drop="onDrop">
|
||||||
|
<p v-if="has_existing_doc">
|
||||||
|
<i class="fa fa-file-pdf-o" v-if="props.existingDoc?.type === 'application/pdf'"></i>
|
||||||
|
<i class="fa fa-file-word-o" v-else-if="props.existingDoc?.type === 'application/vnd.oasis.opendocument.text'"></i>
|
||||||
|
<i class="fa fa-file-word-o" v-else-if="props.existingDoc?.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'"></i>
|
||||||
|
<i class="fa fa-file-word-o" v-else-if="props.existingDoc?.type === 'application/msword'"></i>
|
||||||
|
<i class="fa fa-file-excel-o" v-else-if="props.existingDoc?.type === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'"></i>
|
||||||
|
<i class="fa fa-file-excel-o" v-else-if="props.existingDoc?.type === 'application/vnd.ms-excel'"></i>
|
||||||
|
<i class="fa fa-file-image-o" v-else-if="props.existingDoc?.type === 'image/jpeg'"></i>
|
||||||
|
<i class="fa fa-file-image-o" v-else-if="props.existingDoc?.type === 'image/png'"></i>
|
||||||
|
<i class="fa fa-file-archive-o" v-else-if="props.existingDoc?.type === 'application/x-zip-compressed'"></i>
|
||||||
|
<i class="fa fa-file-code-o" v-else ></i>
|
||||||
|
</p>
|
||||||
|
<!-- todo i18n -->
|
||||||
|
<p v-if="has_existing_doc">Déposez un document ou cliquez ici pour remplacer le document existant</p>
|
||||||
|
<p v-else>Déposez un document ou cliquez ici pour ouvrir le navigateur de fichier</p>
|
||||||
|
</div>
|
||||||
|
<div v-else class="waiting">
|
||||||
|
<i class="fa fa-cog fa-spin fa-3x fa-fw"></i>
|
||||||
|
<span class="sr-only">Loading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.drop-file {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
& > .area, & > .waiting {
|
||||||
|
width: 100%;
|
||||||
|
height: 8rem;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
& > .area {
|
||||||
|
border: 4px dashed #ccc;
|
||||||
|
|
||||||
|
&.dragging {
|
||||||
|
border: 4px dashed blue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
div.chill-collection ul.list-entry li.entry:nth-child(2n) {
|
||||||
|
|
||||||
|
}
|
||||||
|
</style>
|
@ -0,0 +1,83 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
|
||||||
|
import {StoredObject, StoredObjectCreated} from "../../types";
|
||||||
|
import {computed, ref, Ref} from "vue";
|
||||||
|
import DropFile from "ChillDocStoreAssets/vuejs/DropFileWidget/DropFile.vue";
|
||||||
|
import DocumentActionButtonsGroup from "ChillDocStoreAssets/vuejs/DocumentActionButtonsGroup.vue";
|
||||||
|
|
||||||
|
interface DropFileConfig {
|
||||||
|
allowRemove: boolean,
|
||||||
|
existingDoc?: StoredObjectCreated|StoredObject,
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<DropFileConfig>(), {
|
||||||
|
allowRemove: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'addDocument', stored_object: StoredObjectCreated): void,
|
||||||
|
(e: 'removeDocument', stored_object: null): void
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const has_existing_doc = computed<boolean>(() => {
|
||||||
|
return props.existingDoc !== undefined && props.existingDoc !== null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const dav_link_expiration = computed<number|undefined>(() => {
|
||||||
|
if (props.existingDoc === undefined || props.existingDoc === null) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (props.existingDoc.status !== 'ready') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return props.existingDoc._links?.dav_link?.expiration;
|
||||||
|
});
|
||||||
|
|
||||||
|
const dav_link_href = computed<string|undefined>(() => {
|
||||||
|
if (props.existingDoc === undefined || props.existingDoc === null) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (props.existingDoc.status !== 'ready') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return props.existingDoc._links?.dav_link?.href;
|
||||||
|
})
|
||||||
|
|
||||||
|
const onAddDocument = (s: StoredObjectCreated): void => {
|
||||||
|
emit('addDocument', s);
|
||||||
|
}
|
||||||
|
|
||||||
|
const onRemoveDocument = (e: Event): void => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
emit('removeDocument', null);
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<drop-file :existingDoc="props.existingDoc" @addDocument="onAddDocument"></drop-file>
|
||||||
|
|
||||||
|
<ul class="record_actions">
|
||||||
|
<li v-if="has_existing_doc">
|
||||||
|
<document-action-buttons-group
|
||||||
|
:stored-object="props.existingDoc"
|
||||||
|
:can-edit="props.existingDoc?.status === 'ready'"
|
||||||
|
:can-download="true"
|
||||||
|
:dav-link="dav_link_href"
|
||||||
|
:dav-link-expiration="dav_link_expiration"
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<button v-if="allowRemove" class="btn btn-delete" @click="onRemoveDocument($event)" ></button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
|
||||||
|
</style>
|
@ -10,10 +10,10 @@
|
|||||||
import {build_convert_link, download_and_decrypt_doc, download_doc} from "./helpers";
|
import {build_convert_link, download_and_decrypt_doc, download_doc} from "./helpers";
|
||||||
import mime from "mime";
|
import mime from "mime";
|
||||||
import {reactive} from "vue";
|
import {reactive} from "vue";
|
||||||
import {StoredObject} from "../../types";
|
import {StoredObject, StoredObjectCreated} from "../../types";
|
||||||
|
|
||||||
interface ConvertButtonConfig {
|
interface ConvertButtonConfig {
|
||||||
storedObject: StoredObject,
|
storedObject: StoredObject|StoredObjectCreated,
|
||||||
classes: { [key: string]: boolean},
|
classes: { [key: string]: boolean},
|
||||||
filename?: string,
|
filename?: string,
|
||||||
};
|
};
|
||||||
|
@ -0,0 +1,66 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
|
||||||
|
import Modal from "ChillMainAssets/vuejs/_components/Modal.vue";
|
||||||
|
import {computed, reactive} from "vue";
|
||||||
|
|
||||||
|
export interface DesktopEditButtonConfig {
|
||||||
|
editLink: null,
|
||||||
|
classes: { [k: string]: boolean },
|
||||||
|
expirationLink: number|Date,
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DesktopEditButtonState {
|
||||||
|
modalOpened: boolean
|
||||||
|
};
|
||||||
|
|
||||||
|
const state: DesktopEditButtonState = reactive({modalOpened: false});
|
||||||
|
|
||||||
|
const props = defineProps<DesktopEditButtonConfig>();
|
||||||
|
|
||||||
|
const buildCommand = computed<string>(() => 'vnd.libreoffice.command:ofe|u|' + props.editLink);
|
||||||
|
|
||||||
|
const editionUntilFormatted = computed<string>(() => {
|
||||||
|
let d;
|
||||||
|
|
||||||
|
if (props.expirationLink instanceof Date) {
|
||||||
|
d = props.expirationLink;
|
||||||
|
} else {
|
||||||
|
d = new Date(props.expirationLink * 1000);
|
||||||
|
}
|
||||||
|
console.log(props.expirationLink);
|
||||||
|
|
||||||
|
return (new Intl.DateTimeFormat(undefined, {'dateStyle': 'long', 'timeStyle': 'medium'})).format(d);
|
||||||
|
});
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<teleport to="body">
|
||||||
|
<modal v-if="state.modalOpened" @close="state.modalOpened=false">
|
||||||
|
<template v-slot:body>
|
||||||
|
<div class="desktop-edit">
|
||||||
|
<p class="center">Veuillez enregistrer vos modifications avant le</p>
|
||||||
|
<p><strong>{{ editionUntilFormatted }}</strong></p>
|
||||||
|
|
||||||
|
<p><a class="btn btn-primary" :href="buildCommand">Ouvrir le document pour édition</a></p>
|
||||||
|
|
||||||
|
<p><small>Le document peut être édité uniquement en utilisant Libre Office.</small></p>
|
||||||
|
|
||||||
|
<p><small>En cas d'échec lors de l'enregistrement, sauver le document sur le poste de travail avant de le déposer à nouveau ici.</small></p>
|
||||||
|
|
||||||
|
<p><small>Vous pouvez naviguez sur d'autres pages pendant l'édition.</small></p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</modal>
|
||||||
|
</teleport>
|
||||||
|
<a :class="props.classes" @click="state.modalOpened = true">
|
||||||
|
<i class="fa fa-desktop"></i>
|
||||||
|
Éditer sur le bureau
|
||||||
|
</a>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.desktop-edit {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
@ -13,10 +13,10 @@
|
|||||||
import {reactive, ref, nextTick, onMounted} from "vue";
|
import {reactive, ref, nextTick, onMounted} from "vue";
|
||||||
import {build_download_info_link, download_and_decrypt_doc} from "./helpers";
|
import {build_download_info_link, download_and_decrypt_doc} from "./helpers";
|
||||||
import mime from "mime";
|
import mime from "mime";
|
||||||
import {StoredObject} from "../../types";
|
import {StoredObject, StoredObjectCreated} from "../../types";
|
||||||
|
|
||||||
interface DownloadButtonConfig {
|
interface DownloadButtonConfig {
|
||||||
storedObject: StoredObject,
|
storedObject: StoredObject|StoredObjectCreated,
|
||||||
classes: { [k: string]: boolean },
|
classes: { [k: string]: boolean },
|
||||||
filename?: string,
|
filename?: string,
|
||||||
}
|
}
|
||||||
|
@ -8,10 +8,10 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import WopiEditButton from "./WopiEditButton.vue";
|
import WopiEditButton from "./WopiEditButton.vue";
|
||||||
import {build_wopi_editor_link} from "./helpers";
|
import {build_wopi_editor_link} from "./helpers";
|
||||||
import {StoredObject, WopiEditButtonExecutableBeforeLeaveFunction} from "../../types";
|
import {StoredObject, StoredObjectCreated, WopiEditButtonExecutableBeforeLeaveFunction} from "../../types";
|
||||||
|
|
||||||
interface WopiEditButtonConfig {
|
interface WopiEditButtonConfig {
|
||||||
storedObject: StoredObject,
|
storedObject: StoredObject|StoredObjectCreated,
|
||||||
returnPath?: string,
|
returnPath?: string,
|
||||||
classes: {[k: string] : boolean},
|
classes: {[k: string] : boolean},
|
||||||
executeBeforeLeave?: WopiEditButtonExecutableBeforeLeaveFunction,
|
executeBeforeLeave?: WopiEditButtonExecutableBeforeLeaveFunction,
|
||||||
|
@ -0,0 +1,60 @@
|
|||||||
|
import {makeFetch} from "../../../../../ChillMainBundle/Resources/public/lib/api/apiMethods";
|
||||||
|
import {PostStoreObjectSignature} from "../../types";
|
||||||
|
|
||||||
|
const algo = 'AES-CBC';
|
||||||
|
|
||||||
|
const URL_POST = '/asyncupload/temp_url/generate/post';
|
||||||
|
|
||||||
|
const keyDefinition = {
|
||||||
|
name: algo,
|
||||||
|
length: 256
|
||||||
|
};
|
||||||
|
|
||||||
|
const createFilename = (): string => {
|
||||||
|
var text = "";
|
||||||
|
var possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||||
|
|
||||||
|
for (let i = 0; i < 7; i++) {
|
||||||
|
text += possible.charAt(Math.floor(Math.random() * possible.length));
|
||||||
|
}
|
||||||
|
|
||||||
|
return text;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const uploadFile = async (uploadFile: ArrayBuffer): Promise<string> => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.append('expires_delay', "180");
|
||||||
|
params.append('submit_delay', "180");
|
||||||
|
const asyncData: PostStoreObjectSignature = await makeFetch("GET", URL_POST + "?" + params.toString());
|
||||||
|
const suffix = createFilename();
|
||||||
|
const filename = asyncData.prefix + suffix;
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("redirect", asyncData.redirect);
|
||||||
|
formData.append("max_file_size", asyncData.max_file_size.toString());
|
||||||
|
formData.append("max_file_count", asyncData.max_file_count.toString());
|
||||||
|
formData.append("expires", asyncData.expires.toString());
|
||||||
|
formData.append("signature", asyncData.signature);
|
||||||
|
formData.append(filename, new Blob([uploadFile]), suffix);
|
||||||
|
|
||||||
|
const response = await window.fetch(asyncData.url, {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error("Error while sending file to store", response);
|
||||||
|
throw new Error(response.statusText);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve(filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const encryptFile = async (originalFile: ArrayBuffer): Promise<[ArrayBuffer, Uint8Array, JsonWebKey]> => {
|
||||||
|
console.log('encrypt', originalFile);
|
||||||
|
const iv = crypto.getRandomValues(new Uint8Array(16));
|
||||||
|
const key = await window.crypto.subtle.generateKey(keyDefinition, true, [ "encrypt", "decrypt" ]);
|
||||||
|
const exportedKey = await window.crypto.subtle.exportKey('jwk', key);
|
||||||
|
const encrypted = await window.crypto.subtle.encrypt({ name: algo, iv: iv}, key, originalFile);
|
||||||
|
|
||||||
|
return Promise.resolve([encrypted, iv, exportedKey]);
|
||||||
|
};
|
@ -3,5 +3,7 @@
|
|||||||
data-download-buttons
|
data-download-buttons
|
||||||
data-stored-object="{{ document_json|json_encode|escape('html_attr') }}"
|
data-stored-object="{{ document_json|json_encode|escape('html_attr') }}"
|
||||||
data-can-edit="{{ can_edit ? '1' : '0' }}"
|
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 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>
|
{% if title|default(document.title)|default(null) is not null %}data-filename="{{ title|default(document.title)|escape('html_attr') }}"{% endif %}></div>
|
||||||
|
@ -1,23 +1,7 @@
|
|||||||
{% block stored_object_widget %}
|
{% block stored_object_widget %}
|
||||||
{% if form.title is defined %} {{ form_row(form.title) }} {% endif %}
|
{% if form.title is defined %} {{ form_row(form.title) }} {% endif %}
|
||||||
<div
|
<div
|
||||||
data-stored-object="data-stored-object"
|
data-stored-object="data-stored-object">
|
||||||
data-label-preparing="{{ ('Preparing'|trans ~ '...')|escape('html_attr') }}"
|
{{ form_widget(form.stored_object, { 'attr': { 'data-stored-object': 1 } }) }}
|
||||||
data-label-quiet-button="{{ 'Download existing file'|trans|escape('html_attr') }}"
|
|
||||||
data-label-ready="{{ 'Ready to show'|trans|escape('html_attr') }}"
|
|
||||||
data-dict-file-too-big="{{ 'File too big'|trans|escape('html_attr') }}"
|
|
||||||
data-dict-default-message="{{ "Drop your file or click here"|trans|escape('html_attr') }}"
|
|
||||||
data-dict-remove-file="{{ 'Remove file in order to upload a new one'|trans|escape('html_attr') }}"
|
|
||||||
data-dict-max-files-exceeded="{{ 'Max files exceeded. Remove previous files'|trans|escape('html_attr') }}"
|
|
||||||
data-dict-cancel-upload="{{ 'Cancel upload'|trans|escape('html_attr') }}"
|
|
||||||
data-dict-cancel-upload-confirm="{{ 'Are you sure you want to cancel this upload ?'|trans|escape('html_attr') }}"
|
|
||||||
data-dict-upload-canceled="{{ 'Upload canceled'|trans|escape('html_attr') }}"
|
|
||||||
data-dict-remove="{{ 'Remove existing file'|trans|escape('html_attr') }}"
|
|
||||||
data-allow-remove="{% if required %}false{% else %}true{% endif %}"
|
|
||||||
data-temp-url-generator="{{ path('async_upload.generate_url', { 'method': 'GET' })|escape('html_attr') }}">
|
|
||||||
{{ form_widget(form.filename) }}
|
|
||||||
{{ form_widget(form.keyInfos, { 'attr': { 'data-stored-object-key': 1 } }) }}
|
|
||||||
{{ form_widget(form.iv, { 'attr': { 'data-stored-object-iv': 1 } }) }}
|
|
||||||
{{ form_widget(form.type, { 'attr': { 'data-async-file-type': 1 } }) }}
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -1,54 +1,62 @@
|
|||||||
{% extends "@ChillPerson/AccompanyingCourse/layout.html.twig" %}
|
{% extends "@ChillPerson/AccompanyingCourse/layout.html.twig" %} {% set
|
||||||
|
activeRouteKey = '' %} {% block title %}
|
||||||
|
{{ "Documents" }}
|
||||||
|
{% endblock %} {% block js %}
|
||||||
|
{{ parent() }}
|
||||||
|
{{ encore_entry_script_tags("mod_docgen_picktemplate") }}
|
||||||
|
{{ encore_entry_script_tags("mod_entity_workflow_pick") }}
|
||||||
|
{{ encore_entry_script_tags("mod_document_action_buttons_group") }}
|
||||||
|
{% endblock %} {% block css %}
|
||||||
|
{{ parent() }}
|
||||||
|
{{ encore_entry_script_tags("mod_docgen_picktemplate") }}
|
||||||
|
{{ encore_entry_link_tags("mod_entity_workflow_pick") }}
|
||||||
|
{{ encore_entry_link_tags("mod_document_action_buttons_group") }}
|
||||||
|
{% endblock %} {% block content %}
|
||||||
|
<div class="document-list">
|
||||||
|
<h1>{{ "Documents" }}</h1>
|
||||||
|
|
||||||
{% set activeRouteKey = '' %}
|
{{ filter | chill_render_filter_order_helper }}
|
||||||
|
|
||||||
{% block title %}
|
|
||||||
{{ 'Documents' }}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block js %}
|
|
||||||
{{ parent() }}
|
|
||||||
{{ encore_entry_script_tags('mod_docgen_picktemplate') }}
|
|
||||||
{{ encore_entry_script_tags('mod_entity_workflow_pick') }}
|
|
||||||
{{ encore_entry_script_tags('mod_document_action_buttons_group') }}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block css %}
|
|
||||||
{{ parent() }}
|
|
||||||
{{ encore_entry_script_tags('mod_docgen_picktemplate') }}
|
|
||||||
{{ encore_entry_link_tags('mod_entity_workflow_pick') }}
|
|
||||||
{{ encore_entry_link_tags('mod_document_action_buttons_group') }}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="document-list">
|
|
||||||
<h1>{{ 'Documents' }}</h1>
|
|
||||||
|
|
||||||
{{ filter|chill_render_filter_order_helper }}
|
|
||||||
|
|
||||||
{% if documents|length == 0 %}
|
|
||||||
<p class="chill-no-data-statement">{{ 'No documents'|trans }}</p>
|
|
||||||
{% else %}
|
|
||||||
<div class="flex-table chill-task-list">
|
|
||||||
{% for document in documents %}
|
|
||||||
{{ document|chill_generic_doc_render }}
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{{ chill_pagination(pagination) }}
|
|
||||||
|
|
||||||
<div data-docgen-template-picker="data-docgen-template-picker" data-entity-class="Chill\PersonBundle\Entity\AccompanyingPeriod" data-entity-id="{{ accompanyingCourse.id }}"></div>
|
|
||||||
|
|
||||||
{% if is_granted('CHILL_ACCOMPANYING_COURSE_DOCUMENT_CREATE', accompanyingCourse) %}
|
|
||||||
<ul class="record_actions sticky-form-buttons">
|
|
||||||
<li class="create">
|
|
||||||
<a href="{{ path('accompanying_course_document_new', {'course': accompanyingCourse.id}) }}" class="btn btn-create">
|
|
||||||
{{ 'Create'|trans }}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
|
{% if documents|length > 5 %}
|
||||||
|
<div
|
||||||
|
data-docgen-template-picker="data-docgen-template-picker"
|
||||||
|
data-entity-class="Chill\PersonBundle\Entity\AccompanyingPeriod"
|
||||||
|
data-entity-id="{{ accompanyingCourse.id }}"
|
||||||
|
></div>
|
||||||
|
{% endif %} {% if documents|length == 0 %}
|
||||||
|
<p class="chill-no-data-statement">{{ "No documents" | trans }}</p>
|
||||||
|
{% else %}
|
||||||
|
<div class="flex-table chill-task-list">
|
||||||
|
{% for document in documents %}
|
||||||
|
{{ document | chill_generic_doc_render }}
|
||||||
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{{ chill_pagination(pagination) }}
|
||||||
|
|
||||||
|
<div
|
||||||
|
data-docgen-template-picker="data-docgen-template-picker"
|
||||||
|
data-entity-class="Chill\PersonBundle\Entity\AccompanyingPeriod"
|
||||||
|
data-entity-id="{{ accompanyingCourse.id }}"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
{% if is_granted('CHILL_ACCOMPANYING_COURSE_DOCUMENT_CREATE',
|
||||||
|
accompanyingCourse) %}
|
||||||
|
<ul class="record_actions sticky-form-buttons">
|
||||||
|
<li class="create">
|
||||||
|
<a
|
||||||
|
href="{{
|
||||||
|
path('accompanying_course_document_new', {
|
||||||
|
course: accompanyingCourse.id
|
||||||
|
})
|
||||||
|
}}"
|
||||||
|
class="btn btn-create"
|
||||||
|
>
|
||||||
|
{{ "Create" | trans }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -1,74 +1,70 @@
|
|||||||
{#
|
{# * Copyright (C) 2018, Champs Libres Cooperative SCRLFS,
|
||||||
* Copyright (C) 2018, Champs Libres Cooperative SCRLFS, <http://www.champs-libres.coop>
|
<http://www.champs-libres.coop> * * This program is free software: you can
|
||||||
*
|
redistribute it and/or modify * it under the terms of the GNU Affero General
|
||||||
* This program is free software: you can redistribute it and/or modify
|
Public License as * published by the Free Software Foundation, either version 3
|
||||||
* it under the terms of the GNU Affero General Public License as
|
of the * License, or (at your option) any later version. * * This program is
|
||||||
* published by the Free Software Foundation, either version 3 of the
|
distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY;
|
||||||
* License, or (at your option) any later version.
|
without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A
|
||||||
*
|
PARTICULAR PURPOSE. See the * GNU Affero General Public License for more
|
||||||
* This program is distributed in the hope that it will be useful,
|
details. * * You should have received a copy of the GNU Affero General Public
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
License * along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
#} {% extends "@ChillPerson/Person/layout.html.twig" %} {% set activeRouteKey =
|
||||||
* GNU Affero General Public License for more details.
|
'' %} {% import "@ChillDocStore/Macro/macro.html.twig" as m %} {% block title %}
|
||||||
*
|
{{ 'Documents for %name%'|trans({ '%name%': person|chill_entity_render_string } ) }}
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
{% endblock %} {% block js %}
|
||||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
{{ parent() }}
|
||||||
#}
|
{{ encore_entry_script_tags("mod_docgen_picktemplate") }}
|
||||||
|
{{ encore_entry_script_tags("mod_entity_workflow_pick") }}
|
||||||
{% extends "@ChillPerson/Person/layout.html.twig" %}
|
{{ encore_entry_script_tags("mod_document_action_buttons_group") }}
|
||||||
|
{% endblock %} {% block css %}
|
||||||
{% set activeRouteKey = '' %}
|
{{ parent() }}
|
||||||
|
{{ encore_entry_link_tags("mod_docgen_picktemplate") }}
|
||||||
{% import "@ChillDocStore/Macro/macro.html.twig" as m %}
|
{{ encore_entry_link_tags("mod_entity_workflow_pick") }}
|
||||||
|
{{ encore_entry_link_tags("mod_document_action_buttons_group") }}
|
||||||
{% block title %}
|
{% endblock %} {% block content %}
|
||||||
{{ 'Documents for %name%'|trans({ '%name%': person|chill_entity_render_string } ) }}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block js %}
|
|
||||||
{{ parent() }}
|
|
||||||
{{ encore_entry_script_tags('mod_docgen_picktemplate') }}
|
|
||||||
{{ encore_entry_script_tags('mod_entity_workflow_pick') }}
|
|
||||||
{{ encore_entry_script_tags('mod_document_action_buttons_group') }}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block css %}
|
|
||||||
{{ parent() }}
|
|
||||||
{{ encore_entry_link_tags('mod_docgen_picktemplate') }}
|
|
||||||
{{ encore_entry_link_tags('mod_entity_workflow_pick') }}
|
|
||||||
{{ encore_entry_link_tags('mod_document_action_buttons_group') }}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
|
|
||||||
<div class="col-md-10 col-xxl">
|
<div class="col-md-10 col-xxl">
|
||||||
<h1>{{ 'Documents for %name%'|trans({ '%name%': person|chill_entity_render_string } ) }}</h1>
|
<h1>
|
||||||
|
{{ 'Documents for %name%'|trans({ '%name%': person|chill_entity_render_string } ) }}
|
||||||
|
</h1>
|
||||||
|
|
||||||
{{ filter|chill_render_filter_order_helper }}
|
{{ filter | chill_render_filter_order_helper }}
|
||||||
|
|
||||||
{% if documents|length == 0 %}
|
{% if documents|length > 5 %}
|
||||||
<p class="chill-no-data-statement">{{ 'No documents'|trans }}</p>
|
<div
|
||||||
|
data-docgen-template-picker="data-docgen-template-picker"
|
||||||
|
data-entity-class="Chill\PersonBundle\Entity\Person"
|
||||||
|
data-entity-id="{{ person.id }}"
|
||||||
|
></div>
|
||||||
|
{% endif %} {% if documents|length == 0 %}
|
||||||
|
<p class="chill-no-data-statement">{{ "No documents" | trans }}</p>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="flex-table chill-task-list">
|
<div class="flex-table chill-task-list">
|
||||||
{% for document in documents %}
|
{% for document in documents %}
|
||||||
{{ document|chill_generic_doc_render }}
|
{{ document | chill_generic_doc_render }}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{{ chill_pagination(pagination) }}
|
{{ chill_pagination(pagination) }}
|
||||||
|
|
||||||
<div data-docgen-template-picker="data-docgen-template-picker" data-entity-class="Chill\PersonBundle\Entity\Person" data-entity-id="{{ person.id }}"></div>
|
<div
|
||||||
|
data-docgen-template-picker="data-docgen-template-picker"
|
||||||
{% if is_granted('CHILL_PERSON_DOCUMENT_CREATE', person) %}
|
data-entity-class="Chill\PersonBundle\Entity\Person"
|
||||||
<ul class="record_actions sticky-form-buttons">
|
data-entity-id="{{ person.id }}"
|
||||||
<li class="create">
|
></div>
|
||||||
<a href="{{ path('person_document_new', {'person': person.id}) }}" class="btn btn-create">
|
|
||||||
{{ 'Create new document' | trans }}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
|
{% if is_granted('CHILL_PERSON_DOCUMENT_CREATE', person) %}
|
||||||
|
<ul class="record_actions sticky-form-buttons">
|
||||||
|
<li class="create">
|
||||||
|
<a
|
||||||
|
href="{{ path('person_document_new', { person: person.id }) }}"
|
||||||
|
class="btn btn-create"
|
||||||
|
>
|
||||||
|
{{ "Create new document" | trans }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Directory for {{ stored_object.uuid }}</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<ul>
|
||||||
|
<li><a href="{{ absolute_url(path('chill_docstore_dav_document_get', {'uuid': stored_object.uuid, 'access_token': access_token })) }}">d</a></li>
|
||||||
|
</ul>
|
||||||
|
</body>
|
||||||
|
</html>
|
@ -0,0 +1,81 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
|
<d:multistatus xmlns:d="DAV:">
|
||||||
|
<d:response>
|
||||||
|
<d:href>{{ path('chill_docstore_dav_directory_get', { 'uuid': stored_object.uuid, 'access_token': access_token } ) }}</d:href>
|
||||||
|
{% if properties.resourceType or properties.contentType %}
|
||||||
|
<d:propstat>
|
||||||
|
<d:prop>
|
||||||
|
{% if properties.resourceType %}
|
||||||
|
<d:resourcetype><d:collection/></d:resourcetype>
|
||||||
|
{% endif %}
|
||||||
|
{% if properties.contentType %}
|
||||||
|
<d:getcontenttype>httpd/unix-directory</d:getcontenttype>
|
||||||
|
{% endif %}
|
||||||
|
</d:prop>
|
||||||
|
<d:status>HTTP/1.1 200 OK</d:status>
|
||||||
|
</d:propstat>
|
||||||
|
{% endif %}
|
||||||
|
{% if properties.unknowns|length > 0 %}
|
||||||
|
<d:propstat>
|
||||||
|
{% for k,u in properties.unknowns %}
|
||||||
|
<d:prop {{ ('xmlns:ns' ~ k ~ '="' ~ u.xmlns|e('html_attr') ~ '"')|raw }}>
|
||||||
|
<{{ 'ns'~ k ~ ':' ~ u.prop }} />
|
||||||
|
</d:prop>
|
||||||
|
{% endfor %}
|
||||||
|
<d:status>HTTP/1.1 404 Not Found</d:status>
|
||||||
|
</d:propstat>
|
||||||
|
{% endif %}
|
||||||
|
</d:response>
|
||||||
|
{% if depth == 1 %}
|
||||||
|
<d:response>
|
||||||
|
<d:href>{{ path('chill_docstore_dav_document_get', {'uuid': stored_object.uuid, 'access_token':access_token}) }}</d:href>
|
||||||
|
{% if properties.lastModified or properties.contentLength or properties.resourceType or properties.etag or properties.contentType or properties.creationDate %}
|
||||||
|
<d:propstat>
|
||||||
|
<d:prop>
|
||||||
|
{% if properties.resourceType %}
|
||||||
|
<d:resourcetype/>
|
||||||
|
{% endif %}
|
||||||
|
{% if properties.creationDate %}
|
||||||
|
<d:creationdate />
|
||||||
|
{% endif %}
|
||||||
|
{% if properties.lastModified %}
|
||||||
|
{% if last_modified is not same as null %}
|
||||||
|
<d:getlastmodified>{{ last_modified.format(constant('DATE_RSS')) }}</d:getlastmodified>
|
||||||
|
{% else %}
|
||||||
|
<d:getlastmodified />
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% if properties.contentLength %}
|
||||||
|
{% if content_length is not same as null %}
|
||||||
|
<d:getcontentlength>{{ content_length }}</d:getcontentlength>
|
||||||
|
{% else %}
|
||||||
|
<d:getcontentlength />
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% if properties.etag %}
|
||||||
|
{% if etag is not same as null %}
|
||||||
|
<d:getetag>"{{ etag }}"</d:getetag>
|
||||||
|
{% else %}
|
||||||
|
<d:getetag />
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% if properties.contentType %}
|
||||||
|
<d:getcontenttype>{{ stored_object.type }}</d:getcontenttype>
|
||||||
|
{% endif %}
|
||||||
|
</d:prop>
|
||||||
|
<d:status>HTTP/1.1 200 OK</d:status>
|
||||||
|
</d:propstat>
|
||||||
|
{% endif %}
|
||||||
|
{% if properties.unknowns|length > 0 %}
|
||||||
|
<d:propstat>
|
||||||
|
{% for k,u in properties.unknowns %}
|
||||||
|
<d:prop {{ ('xmlns:ns' ~ k ~ '="' ~ u.xmlns|e('html_attr') ~ '"')|raw }}>
|
||||||
|
<{{ 'ns'~ k ~ ':' ~ u.prop }} />
|
||||||
|
</d:prop>
|
||||||
|
{% endfor %}
|
||||||
|
<d:status>HTTP/1.1 404 Not Found</d:status>
|
||||||
|
</d:propstat>
|
||||||
|
{% endif %}
|
||||||
|
</d:response>
|
||||||
|
{% endif %}
|
||||||
|
</d:multistatus>
|
@ -0,0 +1,53 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
|
<d:multistatus xmlns:d="DAV:">
|
||||||
|
<d:response>
|
||||||
|
<d:href>{{ path('chill_docstore_dav_document_get', {'uuid': stored_object.uuid, 'access_token': access_token}) }}</d:href>
|
||||||
|
{% if properties.lastModified or properties.contentLength or properties.resourceType or properties.etag or properties.contentType or properties.creationDate %}
|
||||||
|
<d:propstat>
|
||||||
|
<d:prop>
|
||||||
|
{% if properties.resourceType %}
|
||||||
|
<d:resourcetype/>
|
||||||
|
{% endif %}
|
||||||
|
{% if properties.creationDate %}
|
||||||
|
<d:creationdate />
|
||||||
|
{% endif %}
|
||||||
|
{% if properties.lastModified %}
|
||||||
|
{% if last_modified is not same as null %}
|
||||||
|
<d:getlastmodified>{{ last_modified.format(constant('DATE_RSS')) }}</d:getlastmodified>
|
||||||
|
{% else %}
|
||||||
|
<d:getlastmodified />
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% if properties.contentLength %}
|
||||||
|
{% if content_length is not same as null %}
|
||||||
|
<d:getcontentlength>{{ content_length }}</d:getcontentlength>
|
||||||
|
{% else %}
|
||||||
|
<d:getcontentlength />
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% if properties.etag %}
|
||||||
|
{% if etag is not same as null %}
|
||||||
|
<d:getetag>"{{ etag }}"</d:getetag>
|
||||||
|
{% else %}
|
||||||
|
<d:getetag />
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% if properties.contentType %}
|
||||||
|
<d:getcontenttype>{{ stored_object.type }}</d:getcontenttype>
|
||||||
|
{% endif %}
|
||||||
|
</d:prop>
|
||||||
|
<d:status>HTTP/1.1 200 OK</d:status>
|
||||||
|
</d:propstat>
|
||||||
|
{% endif %}
|
||||||
|
{% if properties.unknowns|length > 0 %}
|
||||||
|
<d:propstat>
|
||||||
|
{% for k,u in properties.unknowns %}
|
||||||
|
<d:prop {{ ('xmlns:ns' ~ k ~ '="' ~ u.xmlns|e('html_attr') ~ '"')|raw }}>
|
||||||
|
<{{ 'ns'~ k ~ ':' ~ u.prop }} />
|
||||||
|
</d:prop>
|
||||||
|
{% endfor %}
|
||||||
|
<d:status>HTTP/1.1 404 Not Found</d:status>
|
||||||
|
</d:propstat>
|
||||||
|
{% endif %}
|
||||||
|
</d:response>
|
||||||
|
</d:multistatus>
|
@ -0,0 +1,7 @@
|
|||||||
|
{% extends '@ChillMain/layout.html.twig' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<p>document uuid: {{ stored_object.uuid }}</p>
|
||||||
|
<p>{{ absolute_url(path('chill_docstore_dav_document_get', {'uuid': stored_object.uuid, 'access_token': access_token })) }}</p>
|
||||||
|
<a href="vnd.libreoffice.command:ofe|u|{{ absolute_url(path('chill_docstore_dav_document_get', {'uuid': stored_object.uuid, 'access_token': access_token })) }}">Open document</a>
|
||||||
|
{% endblock %}
|
@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Chill is a software for social workers
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view
|
||||||
|
* the LICENSE file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Chill\DocStoreBundle\Security\Authorization;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Role to edit or see the stored object content.
|
||||||
|
*/
|
||||||
|
enum StoredObjectRoleEnum: string
|
||||||
|
{
|
||||||
|
case SEE = 'SEE';
|
||||||
|
|
||||||
|
case EDIT = 'SEE_AND_EDIT';
|
||||||
|
}
|
@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Chill is a software for social workers
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view
|
||||||
|
* the LICENSE file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Chill\DocStoreBundle\Security\Authorization;
|
||||||
|
|
||||||
|
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||||
|
use Chill\DocStoreBundle\Security\Guard\DavTokenAuthenticationEventSubscriber;
|
||||||
|
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||||
|
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Voter for the content of a stored object.
|
||||||
|
*
|
||||||
|
* This is in use to allow or disallow the edition of the stored object's content.
|
||||||
|
*/
|
||||||
|
class StoredObjectVoter extends Voter
|
||||||
|
{
|
||||||
|
protected function supports($attribute, $subject): bool
|
||||||
|
{
|
||||||
|
return StoredObjectRoleEnum::tryFrom($attribute) instanceof StoredObjectRoleEnum
|
||||||
|
&& $subject instanceof StoredObject;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function voteOnAttribute($attribute, $subject, TokenInterface $token): bool
|
||||||
|
{
|
||||||
|
/** @var StoredObject $subject */
|
||||||
|
if (
|
||||||
|
!$token->hasAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT)
|
||||||
|
|| $subject->getUuid()->toString() !== $token->getAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$token->hasAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$askedRole = StoredObjectRoleEnum::from($attribute);
|
||||||
|
$tokenRoleAuthorization =
|
||||||
|
$token->getAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS);
|
||||||
|
|
||||||
|
return match ($askedRole) {
|
||||||
|
StoredObjectRoleEnum::SEE => StoredObjectRoleEnum::EDIT === $tokenRoleAuthorization || StoredObjectRoleEnum::SEE === $tokenRoleAuthorization,
|
||||||
|
StoredObjectRoleEnum::EDIT => StoredObjectRoleEnum::EDIT === $tokenRoleAuthorization
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Chill is a software for social workers
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view
|
||||||
|
* the LICENSE file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Chill\DocStoreBundle\Security\Guard;
|
||||||
|
|
||||||
|
use Lexik\Bundle\JWTAuthenticationBundle\TokenExtractor\TokenExtractorInterface;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the JWT Token from the segment of the dav endpoints.
|
||||||
|
*
|
||||||
|
* A segment is a separation inside the string, using the character "/".
|
||||||
|
*
|
||||||
|
* For recognizing the JWT, the first segment must be "dav", and the second one must be
|
||||||
|
* the JWT endpoint.
|
||||||
|
*/
|
||||||
|
final readonly class DavOnUrlTokenExtractor implements TokenExtractorInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private LoggerInterface $logger,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function extract(Request $request): false|string
|
||||||
|
{
|
||||||
|
$uri = $request->getRequestUri();
|
||||||
|
|
||||||
|
$segments = array_values(
|
||||||
|
array_filter(
|
||||||
|
explode('/', $uri),
|
||||||
|
fn ($item) => '' !== trim($item)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (2 > count($segments)) {
|
||||||
|
$this->logger->info('not enough segment for parsing URL');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('dav' !== $segments[0]) {
|
||||||
|
$this->logger->info('the first segment of the url must be DAV');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $segments[1];
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Chill is a software for social workers
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view
|
||||||
|
* the LICENSE file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Chill\DocStoreBundle\Security\Guard;
|
||||||
|
|
||||||
|
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
|
||||||
|
use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTAuthenticatedEvent;
|
||||||
|
use Lexik\Bundle\JWTAuthenticationBundle\Events;
|
||||||
|
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store some data from the JWT's payload inside the token's attributes.
|
||||||
|
*/
|
||||||
|
class DavTokenAuthenticationEventSubscriber implements EventSubscriberInterface
|
||||||
|
{
|
||||||
|
final public const STORED_OBJECT = 'stored_object';
|
||||||
|
final public const ACTIONS = 'stored_objects_actions';
|
||||||
|
|
||||||
|
public static function getSubscribedEvents(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Events::JWT_AUTHENTICATED => ['onJWTAuthenticated', 0],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function onJWTAuthenticated(JWTAuthenticatedEvent $event): void
|
||||||
|
{
|
||||||
|
$payload = $event->getPayload();
|
||||||
|
|
||||||
|
if (!(array_key_exists('dav', $payload) && 1 === $payload['dav'])) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$token = $event->getToken();
|
||||||
|
$token->setAttribute(self::ACTIONS, match ($payload['e']) {
|
||||||
|
0 => StoredObjectRoleEnum::SEE,
|
||||||
|
1 => StoredObjectRoleEnum::EDIT,
|
||||||
|
default => throw new \UnexpectedValueException('unsupported value for e parameter')
|
||||||
|
});
|
||||||
|
|
||||||
|
$token->setAttribute(self::STORED_OBJECT, $payload['so']);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Chill is a software for social workers
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view
|
||||||
|
* the LICENSE file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Chill\DocStoreBundle\Security\Guard;
|
||||||
|
|
||||||
|
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||||
|
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
|
||||||
|
use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface;
|
||||||
|
use Symfony\Component\Security\Core\Security;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provide a JWT Token which will be valid for viewing or editing a document.
|
||||||
|
*/
|
||||||
|
final readonly class JWTDavTokenProvider implements JWTDavTokenProviderInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private JWTTokenManagerInterface $JWTTokenManager,
|
||||||
|
private Security $security,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createToken(StoredObject $storedObject, StoredObjectRoleEnum $roleEnum): string
|
||||||
|
{
|
||||||
|
return $this->JWTTokenManager->createFromPayload($this->security->getUser(), [
|
||||||
|
'dav' => 1,
|
||||||
|
'e' => match ($roleEnum) {
|
||||||
|
StoredObjectRoleEnum::SEE => 0,
|
||||||
|
StoredObjectRoleEnum::EDIT => 1,
|
||||||
|
},
|
||||||
|
'so' => $storedObject->getUuid(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTokenExpiration(string $tokenString): \DateTimeImmutable
|
||||||
|
{
|
||||||
|
$jwt = $this->JWTTokenManager->parse($tokenString);
|
||||||
|
|
||||||
|
return \DateTimeImmutable::createFromFormat('U', (string) $jwt['exp']);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Chill is a software for social workers
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view
|
||||||
|
* the LICENSE file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Chill\DocStoreBundle\Security\Guard;
|
||||||
|
|
||||||
|
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||||
|
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provide a JWT Token which will be valid for viewing or editing a document.
|
||||||
|
*/
|
||||||
|
interface JWTDavTokenProviderInterface
|
||||||
|
{
|
||||||
|
public function createToken(StoredObject $storedObject, StoredObjectRoleEnum $roleEnum): string;
|
||||||
|
|
||||||
|
public function getTokenExpiration(string $tokenString): \DateTimeImmutable;
|
||||||
|
}
|
@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Chill is a software for social workers
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view
|
||||||
|
* the LICENSE file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Chill\DocStoreBundle\Security\Guard;
|
||||||
|
|
||||||
|
use Lexik\Bundle\JWTAuthenticationBundle\Security\Guard\JWTTokenAuthenticator;
|
||||||
|
use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface;
|
||||||
|
use Lexik\Bundle\JWTAuthenticationBundle\TokenExtractor\TokenExtractorInterface;
|
||||||
|
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
|
||||||
|
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
|
||||||
|
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alter the base JWTTokenAuthenticator to add the special extractor for dav url endpoints.
|
||||||
|
*/
|
||||||
|
class JWTOnDavUrlAuthenticator extends JWTTokenAuthenticator
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
JWTTokenManagerInterface $jwtManager,
|
||||||
|
EventDispatcherInterface $dispatcher,
|
||||||
|
TokenExtractorInterface $tokenExtractor,
|
||||||
|
private readonly DavOnUrlTokenExtractor $davOnUrlTokenExtractor,
|
||||||
|
TokenStorageInterface $preAuthenticationTokenStorage,
|
||||||
|
?TranslatorInterface $translator = null,
|
||||||
|
) {
|
||||||
|
parent::__construct($jwtManager, $dispatcher, $tokenExtractor, $preAuthenticationTokenStorage, $translator);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getTokenExtractor()
|
||||||
|
{
|
||||||
|
return $this->davOnUrlTokenExtractor;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,90 @@
|
|||||||
|
<?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\Serializer\Normalizer;
|
||||||
|
|
||||||
|
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\NormalizerAwareInterface;
|
||||||
|
use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait;
|
||||||
|
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class StoredObjectNormalizer.
|
||||||
|
*
|
||||||
|
* Normalizes a StoredObject entity to an array of data.
|
||||||
|
*/
|
||||||
|
final class StoredObjectNormalizer implements NormalizerInterface, NormalizerAwareInterface
|
||||||
|
{
|
||||||
|
use NormalizerAwareTrait;
|
||||||
|
public const ADD_DAV_SEE_LINK_CONTEXT = 'dav-see-link-context';
|
||||||
|
public const ADD_DAV_EDIT_LINK_CONTEXT = 'dav-edit-link-context';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly JWTDavTokenProviderInterface $JWTDavTokenProvider,
|
||||||
|
private readonly UrlGeneratorInterface $urlGenerator
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function normalize($object, ?string $format = null, array $context = [])
|
||||||
|
{
|
||||||
|
/** @var StoredObject $object */
|
||||||
|
$datas = [
|
||||||
|
'datas' => $object->getDatas(),
|
||||||
|
'filename' => $object->getFilename(),
|
||||||
|
'id' => $object->getId(),
|
||||||
|
'iv' => $object->getIv(),
|
||||||
|
'keyInfos' => $object->getKeyInfos(),
|
||||||
|
'title' => $object->getTitle(),
|
||||||
|
'type' => $object->getType(),
|
||||||
|
'uuid' => $object->getUuid(),
|
||||||
|
'status' => $object->getStatus(),
|
||||||
|
'createdAt' => $this->normalizer->normalize($object->getCreatedAt(), $format, $context),
|
||||||
|
'createdBy' => $this->normalizer->normalize($object->getCreatedBy(), $format, $context),
|
||||||
|
];
|
||||||
|
|
||||||
|
// deprecated property
|
||||||
|
$datas['creationDate'] = $datas['createdAt'];
|
||||||
|
|
||||||
|
$canDavSee = in_array(self::ADD_DAV_SEE_LINK_CONTEXT, $context['groups'] ?? [], true);
|
||||||
|
$canDavEdit = in_array(self::ADD_DAV_EDIT_LINK_CONTEXT, $context['groups'] ?? [], true);
|
||||||
|
|
||||||
|
if ($canDavSee || $canDavEdit) {
|
||||||
|
$accessToken = $this->JWTDavTokenProvider->createToken(
|
||||||
|
$object,
|
||||||
|
$canDavEdit ? StoredObjectRoleEnum::EDIT : StoredObjectRoleEnum::SEE
|
||||||
|
);
|
||||||
|
|
||||||
|
$datas['_links'] = [
|
||||||
|
'dav_link' => [
|
||||||
|
'href' => $this->urlGenerator->generate(
|
||||||
|
'chill_docstore_dav_document_get',
|
||||||
|
[
|
||||||
|
'uuid' => $object->getUuid(),
|
||||||
|
'access_token' => $accessToken,
|
||||||
|
],
|
||||||
|
UrlGeneratorInterface::ABSOLUTE_URL,
|
||||||
|
),
|
||||||
|
'expiration' => $this->JWTDavTokenProvider->getTokenExpiration($accessToken)->format('U'),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $datas;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function supportsNormalization($data, ?string $format = null)
|
||||||
|
{
|
||||||
|
return $data instanceof StoredObject && 'json' === $format;
|
||||||
|
}
|
||||||
|
}
|
@ -58,6 +58,62 @@ final class StoredObjectManager implements StoredObjectManagerInterface
|
|||||||
return $this->extractLastModifiedFromResponse($response);
|
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
|
public function read(StoredObject $document): string
|
||||||
{
|
{
|
||||||
$response = $this->getResponseFromCache($document);
|
$response = $this->getResponseFromCache($document);
|
||||||
@ -159,6 +215,22 @@ final class StoredObjectManager implements StoredObjectManagerInterface
|
|||||||
return $date;
|
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
|
private function fillCache(StoredObject $document): void
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
@ -18,6 +18,8 @@ interface StoredObjectManagerInterface
|
|||||||
{
|
{
|
||||||
public function getLastModified(StoredObject $document): \DateTimeInterface;
|
public function getLastModified(StoredObject $document): \DateTimeInterface;
|
||||||
|
|
||||||
|
public function getContentLength(StoredObject $document): int;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the content of a StoredObject.
|
* Get the content of a StoredObject.
|
||||||
*
|
*
|
||||||
@ -39,5 +41,7 @@ interface StoredObjectManagerInterface
|
|||||||
*/
|
*/
|
||||||
public function write(StoredObject $document, string $clearContent): void;
|
public function write(StoredObject $document, string $clearContent): void;
|
||||||
|
|
||||||
|
public function etag(StoredObject $document): string;
|
||||||
|
|
||||||
public function clearCache(): void;
|
public function clearCache(): void;
|
||||||
}
|
}
|
||||||
|
@ -13,6 +13,9 @@ namespace Chill\DocStoreBundle\Templating;
|
|||||||
|
|
||||||
use ChampsLibres\WopiLib\Contract\Service\Discovery\DiscoveryInterface;
|
use ChampsLibres\WopiLib\Contract\Service\Discovery\DiscoveryInterface;
|
||||||
use Chill\DocStoreBundle\Entity\StoredObject;
|
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\AbstractNormalizer;
|
||||||
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
|
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
|
||||||
use Twig\Environment;
|
use Twig\Environment;
|
||||||
@ -120,7 +123,13 @@ final readonly class WopiEditTwigExtensionRuntime implements RuntimeExtensionInt
|
|||||||
|
|
||||||
private const TEMPLATE_BUTTON_GROUP = '@ChillDocStore/Button/button_group.html.twig';
|
private const TEMPLATE_BUTTON_GROUP = '@ChillDocStore/Button/button_group.html.twig';
|
||||||
|
|
||||||
public function __construct(private DiscoveryInterface $discovery, private NormalizerInterface $normalizer) {}
|
public function __construct(
|
||||||
|
private DiscoveryInterface $discovery,
|
||||||
|
private NormalizerInterface $normalizer,
|
||||||
|
private JWTDavTokenProviderInterface $davTokenProvider,
|
||||||
|
private UrlGeneratorInterface $urlGenerator,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* return true if the document is editable.
|
* return true if the document is editable.
|
||||||
@ -130,7 +139,7 @@ final readonly class WopiEditTwigExtensionRuntime implements RuntimeExtensionInt
|
|||||||
*/
|
*/
|
||||||
public function isEditable(StoredObject $document): bool
|
public function isEditable(StoredObject $document): bool
|
||||||
{
|
{
|
||||||
return \in_array($document->getType(), self::SUPPORTED_MIMES, true);
|
return in_array($document->getType(), self::SUPPORTED_MIMES, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -142,12 +151,26 @@ final readonly class WopiEditTwigExtensionRuntime implements RuntimeExtensionInt
|
|||||||
*/
|
*/
|
||||||
public function renderButtonGroup(Environment $environment, StoredObject $document, ?string $title = null, bool $canEdit = true, array $options = []): string
|
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, [
|
return $environment->render(self::TEMPLATE_BUTTON_GROUP, [
|
||||||
'document' => $document,
|
'document' => $document,
|
||||||
'document_json' => $this->normalizer->normalize($document, 'json', [AbstractNormalizer::GROUPS => ['read']]),
|
'document_json' => $this->normalizer->normalize($document, 'json', [AbstractNormalizer::GROUPS => ['read']]),
|
||||||
'title' => $title,
|
'title' => $title,
|
||||||
'can_edit' => $canEdit,
|
'can_edit' => $canEdit,
|
||||||
'options' => [...self::DEFAULT_OPTIONS_TEMPLATE_BUTTON_GROUP, ...$options],
|
'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'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -0,0 +1,414 @@
|
|||||||
|
<?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';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function clearCache(): void
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,134 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Chill is a software for social workers
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view
|
||||||
|
* the LICENSE file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Chill\DocStoreBundle\Tests\Dav\Request;
|
||||||
|
|
||||||
|
use Chill\DocStoreBundle\Dav\Request\PropfindRequestAnalyzer;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*
|
||||||
|
* @coversNothing
|
||||||
|
*/
|
||||||
|
class PropfindRequestAnalyzerTest extends TestCase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @dataProvider provideRequestedProperties
|
||||||
|
*/
|
||||||
|
public function testGetRequestedProperties(string $xml, array $expected): void
|
||||||
|
{
|
||||||
|
$analyzer = new PropfindRequestAnalyzer();
|
||||||
|
|
||||||
|
$request = new \DOMDocument();
|
||||||
|
$request->loadXML($xml);
|
||||||
|
$actual = $analyzer->getRequestedProperties($request);
|
||||||
|
|
||||||
|
foreach ($expected as $key => $value) {
|
||||||
|
if ('unknowns' === $key) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
self::assertArrayHasKey($key, $actual, "Check that key {$key} does exists in list of expected values");
|
||||||
|
self::assertEquals($value, $actual[$key], "Does the value match expected for key {$key}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('unknowns', $expected)) {
|
||||||
|
self::assertEquals(count($expected['unknowns']), count($actual['unknowns']));
|
||||||
|
self::assertEqualsCanonicalizing($expected['unknowns'], $actual['unknowns']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function provideRequestedProperties(): iterable
|
||||||
|
{
|
||||||
|
yield [
|
||||||
|
<<<'XML'
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<propfind xmlns="DAV:">
|
||||||
|
<prop>
|
||||||
|
<BaseURI xmlns="http://ucb.openoffice.org/dav/props/"/>
|
||||||
|
</prop>
|
||||||
|
</propfind>
|
||||||
|
XML,
|
||||||
|
[
|
||||||
|
'resourceType' => false,
|
||||||
|
'contentType' => false,
|
||||||
|
'lastModified' => false,
|
||||||
|
'creationDate' => false,
|
||||||
|
'contentLength' => false,
|
||||||
|
'etag' => false,
|
||||||
|
'supportedLock' => false,
|
||||||
|
'unknowns' => [
|
||||||
|
['xmlns' => 'http://ucb.openoffice.org/dav/props/', 'prop' => 'BaseURI'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
yield [
|
||||||
|
<<<'XML'
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<propfind xmlns="DAV:">
|
||||||
|
<propname/>
|
||||||
|
</propfind>
|
||||||
|
XML,
|
||||||
|
[
|
||||||
|
'resourceType' => true,
|
||||||
|
'contentType' => true,
|
||||||
|
'lastModified' => true,
|
||||||
|
'creationDate' => true,
|
||||||
|
'contentLength' => true,
|
||||||
|
'etag' => true,
|
||||||
|
'supportedLock' => true,
|
||||||
|
'unknowns' => [],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
yield [
|
||||||
|
<<<'XML'
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<propfind xmlns="DAV:">
|
||||||
|
<prop>
|
||||||
|
<getlastmodified xmlns="DAV:"/>
|
||||||
|
</prop>
|
||||||
|
</propfind>
|
||||||
|
XML,
|
||||||
|
[
|
||||||
|
'resourceType' => false,
|
||||||
|
'contentType' => false,
|
||||||
|
'lastModified' => true,
|
||||||
|
'creationDate' => false,
|
||||||
|
'contentLength' => false,
|
||||||
|
'etag' => false,
|
||||||
|
'supportedLock' => false,
|
||||||
|
'unknowns' => [],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
yield [
|
||||||
|
<<<'XML'
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<propfind xmlns="DAV:"><prop><resourcetype xmlns="DAV:"/><IsReadOnly xmlns="http://ucb.openoffice.org/dav/props/"/><getcontenttype xmlns="DAV:"/><supportedlock xmlns="DAV:"/></prop></propfind>
|
||||||
|
XML,
|
||||||
|
[
|
||||||
|
'resourceType' => true,
|
||||||
|
'contentType' => true,
|
||||||
|
'lastModified' => false,
|
||||||
|
'creationDate' => false,
|
||||||
|
'contentLength' => false,
|
||||||
|
'etag' => false,
|
||||||
|
'supportedLock' => false,
|
||||||
|
'unknowns' => [
|
||||||
|
['xmlns' => 'http://ucb.openoffice.org/dav/props/', 'prop' => 'IsReadOnly'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,105 @@
|
|||||||
|
<?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\Form;
|
||||||
|
|
||||||
|
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||||
|
use Chill\DocStoreBundle\Form\DataMapper\StoredObjectDataMapper;
|
||||||
|
use Chill\DocStoreBundle\Form\DataTransformer\StoredObjectDataTransformer;
|
||||||
|
use Chill\DocStoreBundle\Form\StoredObjectType;
|
||||||
|
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
|
||||||
|
use Chill\DocStoreBundle\Security\Guard\JWTDavTokenProviderInterface;
|
||||||
|
use Chill\DocStoreBundle\Serializer\Normalizer\StoredObjectNormalizer;
|
||||||
|
use Prophecy\Argument;
|
||||||
|
use Prophecy\PhpUnit\ProphecyTrait;
|
||||||
|
use Symfony\Component\Form\PreloadedExtension;
|
||||||
|
use Symfony\Component\Form\Test\TypeTestCase;
|
||||||
|
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||||
|
use Symfony\Component\Serializer\Encoder\JsonEncoder;
|
||||||
|
use Symfony\Component\Serializer\Serializer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*
|
||||||
|
* @coversNothing
|
||||||
|
*/
|
||||||
|
class StoredObjectTypeTest extends TypeTestCase
|
||||||
|
{
|
||||||
|
use ProphecyTrait;
|
||||||
|
|
||||||
|
public function testChangeTitleValue(): void
|
||||||
|
{
|
||||||
|
$formData = ['title' => $newTitle = 'new title', 'stored_object' => <<<'JSON'
|
||||||
|
{"datas":[],"filename":"","id":null,"iv":[],"keyInfos":[],"title":"","type":"","uuid":"3c6a28fe-f913-40b9-a201-5eccc4f2d312","status":"ready","createdAt":null,"createdBy":null,"creationDate":null,"_links":{"dav_link":{"href":"http:\/\/url\/fake","expiration":"1716889578"}}}
|
||||||
|
JSON];
|
||||||
|
$model = new StoredObject();
|
||||||
|
$form = $this->factory->create(StoredObjectType::class, $model, ['has_title' => true]);
|
||||||
|
|
||||||
|
$form->submit($formData);
|
||||||
|
|
||||||
|
$this->assertTrue($form->isSynchronized());
|
||||||
|
|
||||||
|
$this->assertEquals($newTitle, $model->getTitle());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testReplaceByAnotherObject(): void
|
||||||
|
{
|
||||||
|
$formData = ['title' => $newTitle = 'new title', 'stored_object' => <<<'JSON'
|
||||||
|
{"filename":"abcdef","iv":[10, 15, 20, 30],"keyInfos":[],"type":"text/html","status":"object_store_created"}
|
||||||
|
JSON];
|
||||||
|
$model = new StoredObject();
|
||||||
|
$originalObjectId = spl_object_id($model);
|
||||||
|
$form = $this->factory->create(StoredObjectType::class, $model, ['has_title' => true]);
|
||||||
|
|
||||||
|
$form->submit($formData);
|
||||||
|
|
||||||
|
$this->assertTrue($form->isSynchronized());
|
||||||
|
$model = $form->getData();
|
||||||
|
$this->assertNotEquals($originalObjectId, spl_object_hash($model));
|
||||||
|
$this->assertEquals('abcdef', $model->getFilename());
|
||||||
|
$this->assertEquals([10, 15, 20, 30], $model->getIv());
|
||||||
|
$this->assertEquals('text/html', $model->getType());
|
||||||
|
$this->assertEquals($newTitle, $model->getTitle());
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getExtensions()
|
||||||
|
{
|
||||||
|
$jwtTokenProvider = $this->prophesize(JWTDavTokenProviderInterface::class);
|
||||||
|
$jwtTokenProvider->createToken(Argument::type(StoredObject::class), Argument::type(StoredObjectRoleEnum::class))
|
||||||
|
->willReturn('token');
|
||||||
|
$jwtTokenProvider->getTokenExpiration('token')->willReturn(new \DateTimeImmutable());
|
||||||
|
$urlGenerator = $this->prophesize(UrlGeneratorInterface::class);
|
||||||
|
$urlGenerator->generate('chill_docstore_dav_document_get', Argument::type('array'), UrlGeneratorInterface::ABSOLUTE_URL)
|
||||||
|
->willReturn('http://url/fake');
|
||||||
|
|
||||||
|
$serializer = new Serializer(
|
||||||
|
[
|
||||||
|
new StoredObjectNormalizer(
|
||||||
|
$jwtTokenProvider->reveal(),
|
||||||
|
$urlGenerator->reveal(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
new JsonEncoder(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
$dataTransformer = new StoredObjectDataTransformer($serializer);
|
||||||
|
$dataMapper = new StoredObjectDataMapper();
|
||||||
|
$type = new StoredObjectType(
|
||||||
|
$dataTransformer,
|
||||||
|
$dataMapper,
|
||||||
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
new PreloadedExtension([$type], []),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,123 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Chill is a software for social workers
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view
|
||||||
|
* the LICENSE file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Chill\DocStoreBundle\Tests\Security\Authorization;
|
||||||
|
|
||||||
|
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||||
|
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
|
||||||
|
use Chill\DocStoreBundle\Security\Authorization\StoredObjectVoter;
|
||||||
|
use Chill\DocStoreBundle\Security\Guard\DavTokenAuthenticationEventSubscriber;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Prophecy\PhpUnit\ProphecyTrait;
|
||||||
|
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||||
|
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*
|
||||||
|
* @coversNothing
|
||||||
|
*/
|
||||||
|
class StoredObjectVoterTest extends TestCase
|
||||||
|
{
|
||||||
|
use ProphecyTrait;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @dataProvider provideDataVote
|
||||||
|
*/
|
||||||
|
public function testVote(TokenInterface $token, ?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();
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Chill is a software for social workers
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view
|
||||||
|
* the LICENSE file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Chill\DocStoreBundle\Tests\Security\Guard;
|
||||||
|
|
||||||
|
use Chill\DocStoreBundle\Security\Guard\DavOnUrlTokenExtractor;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Prophecy\PhpUnit\ProphecyTrait;
|
||||||
|
use Psr\Log\NullLogger;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*
|
||||||
|
* @coversNothing
|
||||||
|
*/
|
||||||
|
class DavOnUrlTokenExtractorTest extends TestCase
|
||||||
|
{
|
||||||
|
use ProphecyTrait;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @dataProvider provideDataUri
|
||||||
|
*/
|
||||||
|
public function testExtract(string $uri, false|string $expected): void
|
||||||
|
{
|
||||||
|
$request = $this->prophesize(Request::class);
|
||||||
|
$request->getRequestUri()->willReturn($uri);
|
||||||
|
|
||||||
|
$extractor = new DavOnUrlTokenExtractor(new NullLogger());
|
||||||
|
|
||||||
|
$actual = $extractor->extract($request->reveal());
|
||||||
|
|
||||||
|
self::assertEquals($expected, $actual);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @phpstan-pure
|
||||||
|
*/
|
||||||
|
public static function provideDataUri(): iterable
|
||||||
|
{
|
||||||
|
yield ['/dav/123456789/get/d07d2230-5326-11ee-8fd4-93696acf5ea1/d', '123456789'];
|
||||||
|
yield ['/dav/123456789', '123456789'];
|
||||||
|
yield ['/not-dav/123456978', false];
|
||||||
|
yield ['/dav', false];
|
||||||
|
yield ['/', false];
|
||||||
|
}
|
||||||
|
}
|
@ -3,6 +3,6 @@ module.exports = function(encore)
|
|||||||
encore.addAliases({
|
encore.addAliases({
|
||||||
ChillDocStoreAssets: __dirname + '/Resources/public'
|
ChillDocStoreAssets: __dirname + '/Resources/public'
|
||||||
});
|
});
|
||||||
encore.addEntry('mod_async_upload', __dirname + '/Resources/public/module/async_upload/index.js');
|
encore.addEntry('mod_async_upload', __dirname + '/Resources/public/module/async_upload/index.ts');
|
||||||
encore.addEntry('mod_document_action_buttons_group', __dirname + '/Resources/public/module/document_action_buttons_group/index');
|
encore.addEntry('mod_document_action_buttons_group', __dirname + '/Resources/public/module/document_action_buttons_group/index');
|
||||||
};
|
};
|
||||||
|
@ -15,6 +15,11 @@ services:
|
|||||||
Chill\DocStoreBundle\Workflow\:
|
Chill\DocStoreBundle\Workflow\:
|
||||||
resource: './../Workflow/'
|
resource: './../Workflow/'
|
||||||
|
|
||||||
|
Chill\DocStoreBundle\Security\:
|
||||||
|
resource: './../Security'
|
||||||
|
autoconfigure: true
|
||||||
|
autowire: true
|
||||||
|
|
||||||
Chill\DocStoreBundle\Serializer\Normalizer\:
|
Chill\DocStoreBundle\Serializer\Normalizer\:
|
||||||
resource: '../Serializer/Normalizer/'
|
resource: '../Serializer/Normalizer/'
|
||||||
tags:
|
tags:
|
||||||
|
@ -13,7 +13,7 @@ Update document: Modifier le document
|
|||||||
Edit attributes: Modifier les propriétés du document
|
Edit attributes: Modifier les propriétés du document
|
||||||
Existing document: Document existant
|
Existing document: Document existant
|
||||||
No document to download: Aucun document à télécharger
|
No document to download: Aucun document à télécharger
|
||||||
'Choose a document category': Choisissez une catégorie de document
|
"Choose a document category": Choisissez une catégorie de document
|
||||||
No document found: Aucun document trouvé
|
No document found: Aucun document trouvé
|
||||||
The document is successfully registered: Le document est enregistré
|
The document is successfully registered: Le document est enregistré
|
||||||
The document is successfully updated: Le document est mis à jour
|
The document is successfully updated: Le document est mis à jour
|
||||||
@ -36,7 +36,6 @@ Delete document ?: Supprimer le document ?
|
|||||||
Are you sure you want to remove this document ?: Êtes-vous sûr·e de vouloir supprimer ce document ?
|
Are you sure you want to remove this document ?: Êtes-vous sûr·e de vouloir supprimer ce document ?
|
||||||
The document is successfully removed: Le document a été supprimé
|
The document is successfully removed: Le document a été supprimé
|
||||||
|
|
||||||
|
|
||||||
# dropzone upload
|
# dropzone upload
|
||||||
File too big: Fichier trop volumineux
|
File too big: Fichier trop volumineux
|
||||||
Drop your file or click here: Cliquez ici ou faites glissez votre nouveau fichier dans cette zone
|
Drop your file or click here: Cliquez ici ou faites glissez votre nouveau fichier dans cette zone
|
||||||
@ -47,6 +46,9 @@ Are you sure you want to cancel this upload ?: Êtes-vous sûrs de vouloir annul
|
|||||||
Upload canceled: Téléversement annulé
|
Upload canceled: Téléversement annulé
|
||||||
Remove existing file: Supprimer le document existant
|
Remove existing file: Supprimer le document existant
|
||||||
|
|
||||||
|
stored_object:
|
||||||
|
Insert a document: Ajouter un document
|
||||||
|
|
||||||
# ROLES
|
# ROLES
|
||||||
PersonDocument: Documents
|
PersonDocument: Documents
|
||||||
CHILL_PERSON_DOCUMENT_CREATE: Ajouter un document
|
CHILL_PERSON_DOCUMENT_CREATE: Ajouter un document
|
||||||
|
@ -201,8 +201,6 @@ class EventTypeController extends AbstractController
|
|||||||
/**
|
/**
|
||||||
* Creates a form to delete a EventType entity by id.
|
* Creates a form to delete a EventType entity by id.
|
||||||
*
|
*
|
||||||
* @param mixed $id The entity id
|
|
||||||
*
|
|
||||||
* @return \Symfony\Component\Form\Form The form
|
* @return \Symfony\Component\Form\Form The form
|
||||||
*/
|
*/
|
||||||
private function createDeleteForm(mixed $id)
|
private function createDeleteForm(mixed $id)
|
||||||
|
@ -201,8 +201,6 @@ class RoleController extends AbstractController
|
|||||||
/**
|
/**
|
||||||
* Creates a form to delete a Role entity by id.
|
* Creates a form to delete a Role entity by id.
|
||||||
*
|
*
|
||||||
* @param mixed $id The entity id
|
|
||||||
*
|
|
||||||
* @return \Symfony\Component\Form\Form The form
|
* @return \Symfony\Component\Form\Form The form
|
||||||
*/
|
*/
|
||||||
private function createDeleteForm(mixed $id)
|
private function createDeleteForm(mixed $id)
|
||||||
|
@ -201,8 +201,6 @@ class StatusController extends AbstractController
|
|||||||
/**
|
/**
|
||||||
* Creates a form to delete a Status entity by id.
|
* Creates a form to delete a Status entity by id.
|
||||||
*
|
*
|
||||||
* @param mixed $id The entity id
|
|
||||||
*
|
|
||||||
* @return \Symfony\Component\Form\Form The form
|
* @return \Symfony\Component\Form\Form The form
|
||||||
*/
|
*/
|
||||||
private function createDeleteForm(mixed $id)
|
private function createDeleteForm(mixed $id)
|
||||||
|
@ -18,9 +18,9 @@ use Chill\MainBundle\Entity\Center;
|
|||||||
use Chill\MainBundle\Form\Type\ChillCollectionType;
|
use Chill\MainBundle\Form\Type\ChillCollectionType;
|
||||||
use Chill\MainBundle\Form\Type\ChillDateTimeType;
|
use Chill\MainBundle\Form\Type\ChillDateTimeType;
|
||||||
use Chill\MainBundle\Form\Type\CommentType;
|
use Chill\MainBundle\Form\Type\CommentType;
|
||||||
|
use Chill\MainBundle\Form\Type\PickUserDynamicType;
|
||||||
use Chill\MainBundle\Form\Type\PickUserLocationType;
|
use Chill\MainBundle\Form\Type\PickUserLocationType;
|
||||||
use Chill\MainBundle\Form\Type\ScopePickerType;
|
use Chill\MainBundle\Form\Type\ScopePickerType;
|
||||||
use Chill\MainBundle\Form\Type\UserPickerType;
|
|
||||||
use Symfony\Component\Form\AbstractType;
|
use Symfony\Component\Form\AbstractType;
|
||||||
use Symfony\Component\Form\Extension\Core\Type\MoneyType;
|
use Symfony\Component\Form\Extension\Core\Type\MoneyType;
|
||||||
use Symfony\Component\Form\FormBuilderInterface;
|
use Symfony\Component\Form\FormBuilderInterface;
|
||||||
@ -45,14 +45,8 @@ class EventType extends AbstractType
|
|||||||
'class' => '',
|
'class' => '',
|
||||||
],
|
],
|
||||||
])
|
])
|
||||||
->add('moderator', UserPickerType::class, [
|
->add('moderator', PickUserDynamicType::class, [
|
||||||
'center' => $options['center'],
|
'label' => 'Pick a moderator',
|
||||||
'role' => $options['role'],
|
|
||||||
'placeholder' => 'Pick a moderator',
|
|
||||||
'attr' => [
|
|
||||||
'class' => '',
|
|
||||||
],
|
|
||||||
'required' => false,
|
|
||||||
])
|
])
|
||||||
->add('location', PickUserLocationType::class, [
|
->add('location', PickUserLocationType::class, [
|
||||||
'label' => 'event.fields.location',
|
'label' => 'event.fields.location',
|
||||||
|
@ -1,10 +1,14 @@
|
|||||||
{% extends '@ChillEvent/layout.html.twig' %}
|
{% extends '@ChillEvent/layout.html.twig' %} {% block js %}
|
||||||
|
{{ encore_entry_script_tags("mod_async_upload") }}
|
||||||
|
{{ encore_entry_script_tags("mod_pickentity_type") }}
|
||||||
|
|
||||||
{% block title 'Event edit'|trans %}
|
{% endblock %} {% block css %}
|
||||||
|
{{ encore_entry_link_tags("mod_async_upload") }}
|
||||||
|
{{ encore_entry_link_tags("mod_pickentity_type") }}
|
||||||
|
|
||||||
{% block event_content -%}
|
{% endblock %} {% block title 'Event edit'|trans %} {% block event_content -%}
|
||||||
<div class="col-10">
|
<div class="col-10">
|
||||||
<h1>{{ 'Event edit'|trans }}</h1>
|
<h1>{{ "Event edit" | trans }}</h1>
|
||||||
|
|
||||||
{{ form_start(edit_form) }}
|
{{ form_start(edit_form) }}
|
||||||
{{ form_errors(edit_form) }}
|
{{ form_errors(edit_form) }}
|
||||||
@ -12,7 +16,7 @@
|
|||||||
{{ form_row(edit_form.name) }}
|
{{ form_row(edit_form.name) }}
|
||||||
{{ form_row(edit_form.date) }}
|
{{ form_row(edit_form.date) }}
|
||||||
|
|
||||||
{{ form_row(edit_form.type, { 'label': 'Event type' }) }}
|
{{ form_row(edit_form.type, { label: "Event type" }) }}
|
||||||
{{ form_row(edit_form.moderator) }}
|
{{ form_row(edit_form.moderator) }}
|
||||||
{{ form_row(edit_form.location) }}
|
{{ form_row(edit_form.location) }}
|
||||||
{{ form_row(edit_form.organizationCost) }}
|
{{ form_row(edit_form.organizationCost) }}
|
||||||
@ -22,16 +26,22 @@
|
|||||||
|
|
||||||
<ul class="record_actions sticky-form-buttons">
|
<ul class="record_actions sticky-form-buttons">
|
||||||
<li class="cancel">
|
<li class="cancel">
|
||||||
<a href="{{ chill_return_path_or('chill_event_event_list') }}" class="btn btn-cancel">
|
<a
|
||||||
{{ 'List of events'|trans|chill_return_path_label }}
|
href="{{ chill_return_path_or('chill_event_event_list') }}"
|
||||||
|
class="btn btn-cancel"
|
||||||
|
>
|
||||||
|
{{ "List of events" | trans | chill_return_path_label }}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
{{ form_widget(edit_form.submit, { 'attr' : { 'class' : 'btn btn-update' } }) }}
|
{{
|
||||||
|
form_widget(edit_form.submit, {
|
||||||
|
attr: { class: "btn btn-update" }
|
||||||
|
})
|
||||||
|
}}
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
{{ form_end(edit_form) }}
|
{{ form_end(edit_form) }}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -1,41 +1,41 @@
|
|||||||
{% extends '@ChillEvent/layout.html.twig' %}
|
{% extends '@ChillEvent/layout.html.twig' %} {% block js %}
|
||||||
|
{{ encore_entry_script_tags("mod_async_upload") }}
|
||||||
|
{{ encore_entry_script_tags("mod_pickentity_type") }}
|
||||||
|
|
||||||
{% block js %}
|
{% endblock %} {% block css %}
|
||||||
{{ encore_entry_script_tags('mod_async_upload') }}
|
{{ encore_entry_link_tags("mod_async_upload") }}
|
||||||
{% endblock %}
|
{{ encore_entry_link_tags("mod_pickentity_type") }}
|
||||||
|
|
||||||
{% block css %}
|
{% endblock %} {% block title 'Event creation'|trans %} {% block event_content
|
||||||
{{ encore_entry_link_tags('mod_async_upload') }}
|
-%}
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block title 'Event creation'|trans %}
|
|
||||||
|
|
||||||
{% block event_content -%}
|
|
||||||
<div class="col-10">
|
<div class="col-10">
|
||||||
<h1>{{ 'Event creation'|trans }}</h1>
|
<h1>{{ "Event creation" | trans }}</h1>
|
||||||
|
|
||||||
{{ form_start(form) }}
|
{{ form_start(form) }}
|
||||||
{{ form_errors(form) }}
|
{{ form_errors(form) }}
|
||||||
{{ form_row(form.circle) }}
|
{{ form_row(form.circle) }}
|
||||||
{{ form_row(form.name) }}
|
{{ form_row(form.name) }}
|
||||||
{{ form_row(form.date) }}
|
{{ form_row(form.date) }}
|
||||||
|
{{ form_row(form.type, { label: "Event type" }) }}
|
||||||
{{ form_row(form.type, { 'label': 'Event type' }) }}
|
|
||||||
{{ form_row(form.moderator) }}
|
{{ form_row(form.moderator) }}
|
||||||
{{ form_row(form.location) }}
|
{{ form_row(form.location) }}
|
||||||
{{ form_row(form.organizationCost) }}
|
{{ form_row(form.organizationCost) }}
|
||||||
|
|
||||||
{{ form_row(form.comment) }}
|
{{ form_row(form.comment) }}
|
||||||
{{ form_row(form.documents) }}
|
{{ form_row(form.documents) }}
|
||||||
|
|
||||||
<ul class="record_actions sticky-form-buttons">
|
<ul class="record_actions sticky-form-buttons">
|
||||||
<li class="cancel">
|
<li class="cancel">
|
||||||
<a href="{{ path('chill_event_list_most_recent') }}" class="btn btn-cancel">
|
<a
|
||||||
{{ 'Back to the most recent events'|trans }}
|
href="{{ path('chill_event_list_most_recent') }}"
|
||||||
|
class="btn btn-cancel"
|
||||||
|
>
|
||||||
|
{{ "Back to the most recent events" | trans }}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
{{ form_widget(form.submit, { 'attr' : { 'class' : 'btn btn-create' } }) }}
|
{{
|
||||||
|
form_widget(form.submit, { attr: { class: "btn btn-create" } })
|
||||||
|
}}
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
@ -1,92 +1,126 @@
|
|||||||
{% extends '@ChillEvent/layout.html.twig' %}
|
{% 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 %}
|
||||||
|
<div class="col-10">
|
||||||
|
<h1>{{ block("title") }}</h1>
|
||||||
|
|
||||||
{% block title 'Events'|trans %}
|
{{ filter | chill_render_filter_order_helper }}
|
||||||
|
|
||||||
{% block js %}
|
{# {% if is_granted('CHILL_EVENT_CREATE') %} #}
|
||||||
{{ parent() }}
|
<ul class="record_actions">
|
||||||
{{ encore_entry_script_tags('mod_pickentity_type') }}
|
<li>
|
||||||
{% endblock %}
|
<a
|
||||||
|
class="btn btn-create"
|
||||||
{% block css %}
|
href="{{
|
||||||
{{ parent() }}
|
chill_path_add_return_path(
|
||||||
{{ encore_entry_link_tags('mod_pickentity_type') }}
|
'chill_event__event_new_pickcenter'
|
||||||
{% endblock %}
|
)
|
||||||
|
}}"
|
||||||
{% block content %}
|
>{{ "Add an event" | trans }}</a
|
||||||
<h1>{{ block('title') }}</h1>
|
>
|
||||||
|
</li>
|
||||||
{{ filter|chill_render_filter_order_helper }}
|
</ul>
|
||||||
|
{# {% endif %} #} {% if events|length > 0 %}
|
||||||
{# {% if is_granted('CHILL_EVENT_CREATE') %} #}
|
<div class="flex-table">
|
||||||
<ul class="record_actions">
|
{% for e in events %}
|
||||||
<li><a class="btn btn-create" href="{{ chill_path_add_return_path('chill_event__event_new_pickcenter') }}">{{ 'Add an event'|trans }}</a></li>
|
<div class="item-bloc">
|
||||||
</ul>
|
<div class="item-row">
|
||||||
{# {% endif %} #}
|
<div class="item-col">
|
||||||
{% if events|length > 0 %}
|
<div class="denomination h2">
|
||||||
<div class="flex-table">
|
{{ e.name }}
|
||||||
{% 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>
|
</div>
|
||||||
{% if e.participations|length > 0 %}
|
<p>{{ e.type.name | localize_translatable_string }}</p>
|
||||||
<div class="item-row separator">
|
{% if e.moderator is not null %}
|
||||||
<strong>{{ 'Participations'|trans }} : </strong>
|
<p>
|
||||||
{% for part in e.participations|slice(0, 20) %}
|
{{ "Moderator" | trans }}:
|
||||||
{% include '@ChillMain/OnTheFly/_insert_vue_onthefly.html.twig' with {
|
{{ e.moderator | chill_entity_render_box }}
|
||||||
targetEntity: { name: 'person', id: part.person.id },
|
</p>
|
||||||
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 %}
|
{% endif %}
|
||||||
<div class="item-row">
|
</div>
|
||||||
<div class="item-col">
|
<div class="item-col">
|
||||||
{{ form_start(eventForms[e.id]) }}
|
<div class="container" style="text-align: right">
|
||||||
{{ form_widget(eventForms[e.id].person_id) }}
|
<p>{{ e.date|format_datetime('medium', 'medium') }}</p>
|
||||||
{{ form_end(eventForms[e.id]) }}
|
<p>
|
||||||
</div>
|
{{ 'count participations to this event'|trans({'count': e.participations|length}) }}
|
||||||
</div>
|
</p>
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
</div>
|
||||||
|
{% if e.participations|length > 0 %}
|
||||||
|
<div class="item-row separator">
|
||||||
|
<strong>{{ "Participations" | trans }} : </strong>
|
||||||
|
{% for part in e.participations|slice(0, 20) %} {% include
|
||||||
|
'@ChillMain/OnTheFly/_insert_vue_onthefly.html.twig' with {
|
||||||
|
targetEntity: { name: 'person', id: part.person.id }, action:
|
||||||
|
'show', displayBadge: true, buttonText:
|
||||||
|
part.person|chill_entity_render_string, isDead:
|
||||||
|
part.person.deathdate is not null } %} {% endfor %} {% if
|
||||||
|
e.participations|length > 20 %}
|
||||||
|
{{ 'events.and_other_count_participants'|trans({'count': e.participations|length - 20}) }}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="item-row">
|
||||||
|
<div class="item-col">
|
||||||
|
{{ form_start(eventForms[e.id]) }}
|
||||||
|
{{ form_widget(eventForms[e.id].person_id) }}
|
||||||
|
{{ form_end(eventForms[e.id]) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="item-row separator">
|
||||||
|
<div class="item-col item-meta"></div>
|
||||||
|
<div class="item-col">
|
||||||
|
<ul class="record_actions">
|
||||||
|
{% if is_granted('CHILL_EVENT_UPDATE', e) %}
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="{{
|
||||||
|
chill_path_add_return_path(
|
||||||
|
'chill_event__event_delete',
|
||||||
|
{ event_id: e.id }
|
||||||
|
)
|
||||||
|
}}"
|
||||||
|
class="btn btn-delete"
|
||||||
|
></a>
|
||||||
|
</li>
|
||||||
|
{% endif %} {% if is_granted('CHILL_EVENT_UPDATE', e) %}
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="{{
|
||||||
|
chill_path_add_return_path(
|
||||||
|
'chill_event__event_edit',
|
||||||
|
{ event_id: e.id }
|
||||||
|
)
|
||||||
|
}}"
|
||||||
|
class="btn btn-edit"
|
||||||
|
></a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="{{
|
||||||
|
chill_path_add_return_path(
|
||||||
|
'chill_event__event_show',
|
||||||
|
{ event_id: e.id }
|
||||||
|
)
|
||||||
|
}}"
|
||||||
|
class="btn btn-show"
|
||||||
|
></a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
{{ chill_pagination(pagination) }}
|
{{ chill_pagination(pagination) }}
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -725,7 +725,6 @@ class CRUDController extends AbstractController
|
|||||||
* and view.
|
* and view.
|
||||||
*
|
*
|
||||||
* @param string $action
|
* @param string $action
|
||||||
* @param mixed $entity the entity for the current request, or an array of entities
|
|
||||||
*
|
*
|
||||||
* @return string the path to the template
|
* @return string the path to the template
|
||||||
*
|
*
|
||||||
|
@ -442,8 +442,6 @@ final class PermissionsGroupController extends AbstractController
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a form to delete a link to roleScope.
|
* Creates a form to delete a link to roleScope.
|
||||||
*
|
|
||||||
* @param mixed $permissionsGroup The entity id
|
|
||||||
*/
|
*/
|
||||||
private function createDeleteRoleScopeForm(
|
private function createDeleteRoleScopeForm(
|
||||||
PermissionsGroup $permissionsGroup,
|
PermissionsGroup $permissionsGroup,
|
||||||
|
@ -71,6 +71,7 @@ final readonly class UserExportController
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
$csv->addFormatter(fn (array $row) => null !== ($row['absenceStart'] ?? null) ? array_merge($row, ['absenceStart' => $row['absenceStart']->format('Y-m-d')]) : $row);
|
$csv->addFormatter(fn (array $row) => null !== ($row['absenceStart'] ?? null) ? array_merge($row, ['absenceStart' => $row['absenceStart']->format('Y-m-d')]) : $row);
|
||||||
|
/* @phpstan-ignore-next-line as phpstan seem to ignore that we transform datetime into string */
|
||||||
$csv->insertAll($users);
|
$csv->insertAll($users);
|
||||||
|
|
||||||
return new StreamedResponse(
|
return new StreamedResponse(
|
||||||
|
@ -73,7 +73,6 @@ interface AggregatorInterface extends ModifierInterface
|
|||||||
*
|
*
|
||||||
* @param string $key The column key, as added in the query
|
* @param string $key The column key, as added in the query
|
||||||
* @param mixed[] $values The values from the result. if there are duplicates, those might be given twice. Example: array('FR', 'BE', 'CZ', 'FR', 'BE', 'FR')
|
* @param mixed[] $values The values from the result. if there are duplicates, those might be given twice. Example: array('FR', 'BE', 'CZ', 'FR', 'BE', 'FR')
|
||||||
* @param mixed $data The data from the export's form (as defined in `buildForm`
|
|
||||||
*
|
*
|
||||||
* @return \Closure where the first argument is the value, and the function should return the label to show in the formatted file. Example : `function($countryCode) use ($countries) { return $countries[$countryCode]->getName(); }`
|
* @return \Closure where the first argument is the value, and the function should return the label to show in the formatted file. Example : `function($countryCode) use ($countries) { return $countries[$countryCode]->getName(); }`
|
||||||
*/
|
*/
|
||||||
|
@ -30,8 +30,6 @@ interface ExportElementValidatedInterface
|
|||||||
/**
|
/**
|
||||||
* validate the form's data and, if required, build a contraint
|
* validate the form's data and, if required, build a contraint
|
||||||
* violation on the data.
|
* violation on the data.
|
||||||
*
|
|
||||||
* @param mixed $data the data, as returned by the user
|
|
||||||
*/
|
*/
|
||||||
public function validateForm(mixed $data, ExecutionContextInterface $context);
|
public function validateForm(mixed $data, ExecutionContextInterface $context);
|
||||||
}
|
}
|
||||||
|
@ -96,7 +96,6 @@ interface ExportInterface extends ExportElementInterface
|
|||||||
*
|
*
|
||||||
* @param string $key The column key, as added in the query
|
* @param string $key The column key, as added in the query
|
||||||
* @param mixed[] $values The values from the result. if there are duplicates, those might be given twice. Example: array('FR', 'BE', 'CZ', 'FR', 'BE', 'FR')
|
* @param mixed[] $values The values from the result. if there are duplicates, those might be given twice. Example: array('FR', 'BE', 'CZ', 'FR', 'BE', 'FR')
|
||||||
* @param mixed $data The data from the export's form (as defined in `buildForm`)
|
|
||||||
*
|
*
|
||||||
* @return (callable(string|int|float|'_header'|null $value): string|int|\DateTimeInterface) where the first argument is the value, and the function should return the label to show in the formatted file. Example : `function($countryCode) use ($countries) { return $countries[$countryCode]->getName(); }`
|
* @return (callable(string|int|float|'_header'|null $value): string|int|\DateTimeInterface) where the first argument is the value, and the function should return the label to show in the formatted file. Example : `function($countryCode) use ($countries) { return $countries[$countryCode]->getName(); }`
|
||||||
*/
|
*/
|
||||||
|
@ -552,7 +552,6 @@ class ExportManager
|
|||||||
*
|
*
|
||||||
* This function check the acl.
|
* This function check the acl.
|
||||||
*
|
*
|
||||||
* @param mixed $data the data under the initial 'filters' data
|
|
||||||
* @param \Chill\MainBundle\Entity\Center[] $centers the picked centers
|
* @param \Chill\MainBundle\Entity\Center[] $centers the picked centers
|
||||||
*
|
*
|
||||||
* @throw UnauthorizedHttpException if the user is not authorized
|
* @throw UnauthorizedHttpException if the user is not authorized
|
||||||
@ -615,9 +614,6 @@ class ExportManager
|
|||||||
return $usedTypes;
|
return $usedTypes;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param mixed $data the data from the filter key of the ExportType
|
|
||||||
*/
|
|
||||||
private function retrieveUsedFilters(mixed $data): iterable
|
private function retrieveUsedFilters(mixed $data): iterable
|
||||||
{
|
{
|
||||||
if (null === $data) {
|
if (null === $data) {
|
||||||
@ -634,8 +630,6 @@ class ExportManager
|
|||||||
/**
|
/**
|
||||||
* Retrieve the filter used in this export.
|
* Retrieve the filter used in this export.
|
||||||
*
|
*
|
||||||
* @param mixed $data the data from the `filters` key of the ExportType
|
|
||||||
*
|
|
||||||
* @return array an array with types
|
* @return array an array with types
|
||||||
*/
|
*/
|
||||||
private function retrieveUsedFiltersType(mixed $data): iterable
|
private function retrieveUsedFiltersType(mixed $data): iterable
|
||||||
|
@ -35,6 +35,7 @@ class ChillCollectionType extends AbstractType
|
|||||||
$view->vars['allow_add'] = (int) $options['allow_add'];
|
$view->vars['allow_add'] = (int) $options['allow_add'];
|
||||||
$view->vars['identifier'] = $options['identifier'];
|
$view->vars['identifier'] = $options['identifier'];
|
||||||
$view->vars['empty_collection_explain'] = $options['empty_collection_explain'];
|
$view->vars['empty_collection_explain'] = $options['empty_collection_explain'];
|
||||||
|
$view->vars['js_caller'] = $options['js_caller'];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function configureOptions(OptionsResolver $resolver)
|
public function configureOptions(OptionsResolver $resolver)
|
||||||
@ -45,6 +46,8 @@ class ChillCollectionType extends AbstractType
|
|||||||
'button_remove_label' => 'Remove entry',
|
'button_remove_label' => 'Remove entry',
|
||||||
'identifier' => '',
|
'identifier' => '',
|
||||||
'empty_collection_explain' => '',
|
'empty_collection_explain' => '',
|
||||||
|
'js_caller' => 'data-collection-regular',
|
||||||
|
'delete_empty' => true,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -41,8 +41,6 @@ require('./img/logo-chill-outil-accompagnement_white.png');
|
|||||||
* Some libs are only used in a few pages, they are loaded on a case by case basis
|
* Some libs are only used in a few pages, they are loaded on a case by case basis
|
||||||
*/
|
*/
|
||||||
|
|
||||||
require('../lib/collection/index.js');
|
|
||||||
|
|
||||||
require('../lib/breadcrumb/index.js');
|
require('../lib/breadcrumb/index.js');
|
||||||
require('../lib/download-report/index.js');
|
require('../lib/download-report/index.js');
|
||||||
require('../lib/select_interactive_loading/index.js');
|
require('../lib/select_interactive_loading/index.js');
|
||||||
|
@ -1,120 +0,0 @@
|
|||||||
/**
|
|
||||||
* Javascript file which handle ChillCollectionType
|
|
||||||
*
|
|
||||||
* Two events are emitted by this module, both on window and on collection / ul.
|
|
||||||
*
|
|
||||||
* Collection (an UL element) and entry (a li element) are associated with those
|
|
||||||
* events.
|
|
||||||
*
|
|
||||||
* ```
|
|
||||||
* window.addEventListener('collection-add-entry', function(e) {
|
|
||||||
* console.log(e.detail.collection);
|
|
||||||
* console.log(e.detail.entry);
|
|
||||||
* });
|
|
||||||
*
|
|
||||||
* window.addEventListener('collection-remove-entry', function(e) {
|
|
||||||
* console.log(e.detail.collection);
|
|
||||||
* console.log(e.detail.entry);
|
|
||||||
* });
|
|
||||||
*
|
|
||||||
* collection.addEventListener('collection-add-entry', function(e) {
|
|
||||||
* console.log(e.detail.collection);
|
|
||||||
* console.log(e.detail.entry);
|
|
||||||
* });
|
|
||||||
*
|
|
||||||
* collection.addEventListener('collection-remove-entry', function(e) {
|
|
||||||
* console.log(e.detail.collection);
|
|
||||||
* console.log(e.detail.entry);
|
|
||||||
* });
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
require('./collection.scss');
|
|
||||||
|
|
||||||
class CollectionEvent {
|
|
||||||
constructor(collection, entry) {
|
|
||||||
this.collection = collection;
|
|
||||||
this.entry = entry;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
* @param {type} button
|
|
||||||
* @returns {handleAdd}
|
|
||||||
*/
|
|
||||||
var handleAdd = function(button) {
|
|
||||||
var
|
|
||||||
form_name = button.dataset.collectionAddTarget,
|
|
||||||
prototype = button.dataset.formPrototype,
|
|
||||||
collection = document.querySelector('ul[data-collection-name="'+form_name+'"]'),
|
|
||||||
empty_explain = collection.querySelector('li[data-collection-empty-explain]'),
|
|
||||||
entry = document.createElement('li'),
|
|
||||||
event = new CustomEvent('collection-add-entry', { detail: { collection: collection, entry: entry } }),
|
|
||||||
counter = collection.childNodes.length + parseInt(Math.random() * 1000000)
|
|
||||||
content
|
|
||||||
;
|
|
||||||
content = prototype.replace(new RegExp('__name__', 'g'), counter);
|
|
||||||
entry.innerHTML = content;
|
|
||||||
entry.classList.add('entry');
|
|
||||||
initializeRemove(collection, entry);
|
|
||||||
if (empty_explain !== null) {
|
|
||||||
empty_explain.remove();
|
|
||||||
}
|
|
||||||
collection.appendChild(entry);
|
|
||||||
|
|
||||||
collection.dispatchEvent(event);
|
|
||||||
window.dispatchEvent(event);
|
|
||||||
};
|
|
||||||
|
|
||||||
var initializeRemove = function(collection, entry) {
|
|
||||||
var
|
|
||||||
button = document.createElement('button'),
|
|
||||||
isPersisted = entry.dataset.collectionIsPersisted,
|
|
||||||
content = collection.dataset.collectionButtonRemoveLabel,
|
|
||||||
allowDelete = collection.dataset.collectionAllowDelete,
|
|
||||||
event = new CustomEvent('collection-remove-entry', { detail: { collection: collection, entry: entry } })
|
|
||||||
;
|
|
||||||
|
|
||||||
if (allowDelete === '0' && isPersisted === '1') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
button.classList.add('btn', 'btn-delete', 'remove-entry');
|
|
||||||
button.textContent = content;
|
|
||||||
|
|
||||||
button.addEventListener('click', function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
entry.remove();
|
|
||||||
collection.dispatchEvent(event);
|
|
||||||
window.dispatchEvent(event);
|
|
||||||
});
|
|
||||||
|
|
||||||
entry.appendChild(button);
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('load', function() {
|
|
||||||
var
|
|
||||||
addButtons = document.querySelectorAll("button[data-collection-add-target]"),
|
|
||||||
collections = document.querySelectorAll("ul[data-collection-name]")
|
|
||||||
;
|
|
||||||
|
|
||||||
for (let i = 0; i < addButtons.length; i ++) {
|
|
||||||
let addButton = addButtons[i];
|
|
||||||
addButton.addEventListener('click', function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
handleAdd(e.target);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < collections.length; i ++) {
|
|
||||||
let entries = collections[i].querySelectorAll(':scope > li');
|
|
||||||
|
|
||||||
for (let j = 0; j < entries.length; j ++) {
|
|
||||||
console.log(entries[j].dataset);
|
|
||||||
if (entries[j].dataset.collectionEmptyExplain === "1") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
initializeRemove(collections[i], entries[j]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
@ -0,0 +1,128 @@
|
|||||||
|
/**
|
||||||
|
* Javascript file which handle ChillCollectionType
|
||||||
|
*
|
||||||
|
* Two events are emitted by this module, both on window and on collection / ul.
|
||||||
|
*
|
||||||
|
* Collection (an UL element) and entry (a li element) are associated with those
|
||||||
|
* events.
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* window.addEventListener('collection-add-entry', function(e) {
|
||||||
|
* console.log(e.detail.collection);
|
||||||
|
* console.log(e.detail.entry);
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* window.addEventListener('collection-remove-entry', function(e) {
|
||||||
|
* console.log(e.detail.collection);
|
||||||
|
* console.log(e.detail.entry);
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* collection.addEventListener('collection-add-entry', function(e) {
|
||||||
|
* console.log(e.detail.collection);
|
||||||
|
* console.log(e.detail.entry);
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* collection.addEventListener('collection-remove-entry', function(e) {
|
||||||
|
* console.log(e.detail.collection);
|
||||||
|
* console.log(e.detail.entry);
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
import './collection.scss';
|
||||||
|
|
||||||
|
export class CollectionEventPayload {
|
||||||
|
collection: HTMLUListElement;
|
||||||
|
entry: HTMLLIElement;
|
||||||
|
|
||||||
|
constructor(collection: HTMLUListElement, entry: HTMLLIElement) {
|
||||||
|
this.collection = collection;
|
||||||
|
this.entry = entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handleAdd = (button: any): void => {
|
||||||
|
let
|
||||||
|
form_name = button.dataset.collectionAddTarget,
|
||||||
|
prototype = button.dataset.formPrototype,
|
||||||
|
collection: HTMLUListElement | null = document.querySelector('ul[data-collection-name="' + form_name + '"]');
|
||||||
|
|
||||||
|
if (collection === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let
|
||||||
|
empty_explain: HTMLLIElement | null = collection.querySelector('li[data-collection-empty-explain]'),
|
||||||
|
entry = document.createElement('li'),
|
||||||
|
counter = collection.childNodes.length + 1,
|
||||||
|
content = prototype.replace(new RegExp('__name__', 'g'), counter.toString()),
|
||||||
|
event = new CustomEvent('collection-add-entry', {detail: new CollectionEventPayload(collection, entry)});
|
||||||
|
|
||||||
|
entry.innerHTML = content;
|
||||||
|
entry.classList.add('entry');
|
||||||
|
|
||||||
|
if ("dataCollectionRegular" in collection.dataset) {
|
||||||
|
initializeRemove(collection, entry);
|
||||||
|
if (empty_explain !== null) {
|
||||||
|
empty_explain.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
collection.appendChild(entry);
|
||||||
|
collection.dispatchEvent(event);
|
||||||
|
window.dispatchEvent(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
const initializeRemove = (collection: HTMLUListElement, entry: HTMLLIElement): void => {
|
||||||
|
const button = buildRemoveButton(collection, entry);
|
||||||
|
if (null === button) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
entry.appendChild(button);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildRemoveButton = (collection: HTMLUListElement, entry: HTMLLIElement): HTMLButtonElement|null => {
|
||||||
|
|
||||||
|
let
|
||||||
|
button = document.createElement('button'),
|
||||||
|
isPersisted = entry.dataset.collectionIsPersisted || '',
|
||||||
|
content = collection.dataset.collectionButtonRemoveLabel || '',
|
||||||
|
allowDelete = collection.dataset.collectionAllowDelete || '',
|
||||||
|
event = new CustomEvent('collection-remove-entry', {detail: new CollectionEventPayload(collection, entry)});
|
||||||
|
|
||||||
|
if (allowDelete === '0' && isPersisted === '1') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
button.classList.add('btn', 'btn-delete', 'remove-entry');
|
||||||
|
button.textContent = content;
|
||||||
|
button.addEventListener('click', (e: Event) => {
|
||||||
|
e.preventDefault();
|
||||||
|
entry.remove();
|
||||||
|
collection.dispatchEvent(event);
|
||||||
|
window.dispatchEvent(event);
|
||||||
|
});
|
||||||
|
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
let
|
||||||
|
addButtons: NodeListOf<HTMLButtonElement> = document.querySelectorAll("button[data-collection-add-target]"),
|
||||||
|
collections: NodeListOf<HTMLUListElement> = document.querySelectorAll("ul[data-collection-regular]");
|
||||||
|
|
||||||
|
for (let i = 0; i < addButtons.length; i++) {
|
||||||
|
let addButton = addButtons[i];
|
||||||
|
addButton.addEventListener('click', (e: Event) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleAdd(e.target);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (let i = 0; i < collections.length; i++) {
|
||||||
|
let entries: NodeListOf<HTMLLIElement> = collections[i].querySelectorAll(':scope > li');
|
||||||
|
for (let j = 0; j < entries.length; j++) {
|
||||||
|
if (entries[j].dataset.collectionEmptyExplain === "1") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
initializeRemove(collections[i], entries[j]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
@ -162,6 +162,7 @@
|
|||||||
{% block chill_collection_widget %}
|
{% block chill_collection_widget %}
|
||||||
<div class="chill-collection">
|
<div class="chill-collection">
|
||||||
<ul class="list-entry"
|
<ul class="list-entry"
|
||||||
|
{{ form.vars.js_caller }}="{{ form.vars.js_caller }}"
|
||||||
data-collection-name="{{ form.vars.name|escape('html_attr') }}"
|
data-collection-name="{{ form.vars.name|escape('html_attr') }}"
|
||||||
data-collection-identifier="{{ form.vars.identifier|escape('html_attr') }}"
|
data-collection-identifier="{{ form.vars.identifier|escape('html_attr') }}"
|
||||||
data-collection-button-remove-label="{{ form.vars.button_remove_label|trans|e }}"
|
data-collection-button-remove-label="{{ form.vars.button_remove_label|trans|e }}"
|
||||||
@ -176,7 +177,7 @@
|
|||||||
</li>
|
</li>
|
||||||
{% else %}
|
{% else %}
|
||||||
<li data-collection-empty-explain="1">
|
<li data-collection-empty-explain="1">
|
||||||
<span class="chill-no-data-statement">{{ form.vars.empty_collection_explain|default('No item')|trans }}</span>
|
<span class="chill-no-data-statement">{{ form.vars.empty_collection_explain|default('No entities')|trans }}</span>
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
|
@ -14,6 +14,7 @@
|
|||||||
window.addaddress = {{ add_address|json_encode|raw }};
|
window.addaddress = {{ add_address|json_encode|raw }};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
{{ encore_entry_link_tags('mod_collection') }}
|
||||||
{{ encore_entry_link_tags('mod_bootstrap') }}
|
{{ encore_entry_link_tags('mod_bootstrap') }}
|
||||||
{{ encore_entry_link_tags('mod_forkawesome') }}
|
{{ encore_entry_link_tags('mod_forkawesome') }}
|
||||||
{{ encore_entry_link_tags('mod_ckeditor5') }}
|
{{ encore_entry_link_tags('mod_ckeditor5') }}
|
||||||
@ -107,6 +108,7 @@
|
|||||||
|
|
||||||
{{ include('@ChillMain/Layout/_footer.html.twig') }}
|
{{ include('@ChillMain/Layout/_footer.html.twig') }}
|
||||||
|
|
||||||
|
{{ encore_entry_script_tags('mod_collection') }}
|
||||||
{{ encore_entry_script_tags('mod_bootstrap') }}
|
{{ encore_entry_script_tags('mod_bootstrap') }}
|
||||||
{{ encore_entry_script_tags('mod_forkawesome') }}
|
{{ encore_entry_script_tags('mod_forkawesome') }}
|
||||||
{{ encore_entry_script_tags('mod_ckeditor5') }}
|
{{ encore_entry_script_tags('mod_ckeditor5') }}
|
||||||
|
@ -257,10 +257,10 @@ class SearchProvider
|
|||||||
$this->mustBeExtracted[] = $matches[0][$key];
|
$this->mustBeExtracted[] = $matches[0][$key];
|
||||||
// strip parenthesis
|
// strip parenthesis
|
||||||
if (
|
if (
|
||||||
'"' === mb_substr((string) $match, 0, 1)
|
'"' === mb_substr($match, 0, 1)
|
||||||
&& '"' === mb_substr((string) $match, mb_strlen((string) $match) - 1)
|
&& '"' === mb_substr($match, mb_strlen($match) - 1)
|
||||||
) {
|
) {
|
||||||
$match = trim(mb_substr((string) $match, 1, mb_strlen((string) $match) - 2));
|
$match = trim(mb_substr($match, 1, mb_strlen($match) - 2));
|
||||||
}
|
}
|
||||||
$terms[$matches[1][$key]] = $match;
|
$terms[$matches[1][$key]] = $match;
|
||||||
}
|
}
|
||||||
|
@ -198,8 +198,6 @@ class AuthorizationHelper implements AuthorizationHelperInterface
|
|||||||
* if the entity implements Chill\MainBundle\Entity\HasScopeInterface,
|
* if the entity implements Chill\MainBundle\Entity\HasScopeInterface,
|
||||||
* the scope is taken into account.
|
* the scope is taken into account.
|
||||||
*
|
*
|
||||||
* @param mixed $entity the entity may also implement HasScopeInterface
|
|
||||||
*
|
|
||||||
* @return bool true if the user has access
|
* @return bool true if the user has access
|
||||||
*/
|
*/
|
||||||
public function userHasAccess(User $user, mixed $entity, string $attribute)
|
public function userHasAccess(User $user, mixed $entity, string $attribute)
|
||||||
|
@ -23,7 +23,7 @@ use Symfony\Contracts\HttpClient\HttpClientInterface;
|
|||||||
*/
|
*/
|
||||||
class PostalCodeFRFromOpenData
|
class PostalCodeFRFromOpenData
|
||||||
{
|
{
|
||||||
private const CSV = 'https://datanova.laposte.fr/data-fair/api/v1/datasets/laposte-hexasmal/data-files/019HexaSmal.csv';
|
private const CSV = 'https://datanova.laposte.fr/data-fair/api/v1/datasets/laposte-hexasmal/metadata-attachments/base-officielle-codes-postaux.csv';
|
||||||
|
|
||||||
public function __construct(private readonly PostalCodeBaseImporter $baseImporter, private readonly HttpClientInterface $client, private readonly LoggerInterface $logger) {}
|
public function __construct(private readonly PostalCodeBaseImporter $baseImporter, private readonly HttpClientInterface $client, private readonly LoggerInterface $logger) {}
|
||||||
|
|
||||||
@ -48,7 +48,7 @@ class PostalCodeFRFromOpenData
|
|||||||
fseek($tmpfile, 0);
|
fseek($tmpfile, 0);
|
||||||
|
|
||||||
$csv = Reader::createFromStream($tmpfile);
|
$csv = Reader::createFromStream($tmpfile);
|
||||||
$csv->setDelimiter(';');
|
$csv->setDelimiter(',');
|
||||||
$csv->setHeaderOffset(0);
|
$csv->setHeaderOffset(0);
|
||||||
|
|
||||||
foreach ($csv as $offset => $record) {
|
foreach ($csv as $offset => $record) {
|
||||||
@ -63,23 +63,23 @@ class PostalCodeFRFromOpenData
|
|||||||
|
|
||||||
private function handleRecord(array $record): void
|
private function handleRecord(array $record): void
|
||||||
{
|
{
|
||||||
if ('' !== trim($record['coordonnees_geographiques'] ?? $record['coordonnees_gps'])) {
|
if ('' !== trim((string) $record['_geopoint'])) {
|
||||||
[$lat, $lon] = array_map(static fn ($el) => (float) trim($el), explode(',', $record['coordonnees_geographiques'] ?? $record['coordonnees_gps']));
|
[$lat, $lon] = array_map(static fn ($el) => (float) trim($el), explode(',', (string) $record['_geopoint']));
|
||||||
} else {
|
} else {
|
||||||
$lat = $lon = 0.0;
|
$lat = $lon = 0.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
$ref = trim((string) $record['Code_commune_INSEE']);
|
$ref = trim((string) $record['code_commune_insee']);
|
||||||
|
|
||||||
if (str_starts_with($ref, '987')) {
|
if (str_starts_with($ref, '987')) {
|
||||||
// some differences in French Polynesia
|
// some differences in French Polynesia
|
||||||
$ref .= '.'.trim((string) $record['Libellé_d_acheminement']);
|
$ref .= '.'.trim((string) $record['libelle_d_acheminement']);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->baseImporter->importCode(
|
$this->baseImporter->importCode(
|
||||||
'FR',
|
'FR',
|
||||||
trim((string) $record['Libellé_d_acheminement']),
|
trim((string) $record['libelle_d_acheminement']),
|
||||||
trim((string) $record['Code_postal']),
|
trim((string) $record['code_postal']),
|
||||||
$ref,
|
$ref,
|
||||||
'INSEE',
|
'INSEE',
|
||||||
$lat,
|
$lat,
|
||||||
|
@ -62,6 +62,7 @@ module.exports = function(encore, entries)
|
|||||||
buildCKEditor(encore);
|
buildCKEditor(encore);
|
||||||
|
|
||||||
// Modules entrypoints
|
// Modules entrypoints
|
||||||
|
encore.addEntry('mod_collection', __dirname + '/Resources/public/module/collection/index.ts');
|
||||||
encore.addEntry('mod_forkawesome', __dirname + '/Resources/public/module/forkawesome/index.js');
|
encore.addEntry('mod_forkawesome', __dirname + '/Resources/public/module/forkawesome/index.js');
|
||||||
encore.addEntry('mod_bootstrap', __dirname + '/Resources/public/module/bootstrap/index.js');
|
encore.addEntry('mod_bootstrap', __dirname + '/Resources/public/module/bootstrap/index.js');
|
||||||
encore.addEntry('mod_ckeditor5', __dirname + '/Resources/public/module/ckeditor5/index.js');
|
encore.addEntry('mod_ckeditor5', __dirname + '/Resources/public/module/ckeditor5/index.js');
|
||||||
|
@ -11,6 +11,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Chill\PersonBundle\Controller;
|
namespace Chill\PersonBundle\Controller;
|
||||||
|
|
||||||
|
use Chill\DocStoreBundle\Serializer\Normalizer\StoredObjectNormalizer;
|
||||||
use Chill\MainBundle\Pagination\PaginatorFactory;
|
use Chill\MainBundle\Pagination\PaginatorFactory;
|
||||||
use Chill\MainBundle\Templating\Listing\FilterOrderHelper;
|
use Chill\MainBundle\Templating\Listing\FilterOrderHelper;
|
||||||
use Chill\MainBundle\Templating\Listing\FilterOrderHelperFactoryInterface;
|
use Chill\MainBundle\Templating\Listing\FilterOrderHelperFactoryInterface;
|
||||||
@ -115,7 +116,7 @@ final class AccompanyingCourseWorkController extends AbstractController
|
|||||||
{
|
{
|
||||||
$this->denyAccessUnlessGranted(AccompanyingPeriodWorkVoter::UPDATE, $work);
|
$this->denyAccessUnlessGranted(AccompanyingPeriodWorkVoter::UPDATE, $work);
|
||||||
|
|
||||||
$json = $this->serializer->normalize($work, 'json', ['groups' => ['read']]);
|
$json = $this->serializer->normalize($work, 'json', ['groups' => ['read', StoredObjectNormalizer::ADD_DAV_EDIT_LINK_CONTEXT]]);
|
||||||
|
|
||||||
return $this->render('@ChillPerson/AccompanyingCourseWork/edit.html.twig', [
|
return $this->render('@ChillPerson/AccompanyingCourseWork/edit.html.twig', [
|
||||||
'accompanyingCourse' => $work->getAccompanyingPeriod(),
|
'accompanyingCourse' => $work->getAccompanyingPeriod(),
|
||||||
|
@ -41,10 +41,10 @@ final readonly class GeographicalUnitStatAggregator implements AggregatorInterfa
|
|||||||
|
|
||||||
$qb->andWhere(
|
$qb->andWhere(
|
||||||
$qb->expr()->andX(
|
$qb->expr()->andX(
|
||||||
'acp_geog_agg_location_history.startDate <= :acp_geog_aggregator_date',
|
'acp_geog_agg_location_history.startDate <= LEAST(:acp_geog_aggregator_date, acp.closingDate)',
|
||||||
$qb->expr()->orX(
|
$qb->expr()->orX(
|
||||||
'acp_geog_agg_location_history.endDate IS NULL',
|
'acp_geog_agg_location_history.endDate IS NULL',
|
||||||
'acp_geog_agg_location_history.endDate > :acp_geog_aggregator_date'
|
'acp_geog_agg_location_history.endDate > LEAST(:acp_geog_aggregator_date, acp.closingDate)'
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@ -56,9 +56,9 @@ final readonly class GeographicalUnitStatAggregator implements AggregatorInterfa
|
|||||||
Join::WITH,
|
Join::WITH,
|
||||||
$qb->expr()->andX(
|
$qb->expr()->andX(
|
||||||
'IDENTITY(acp_geog_agg_address_person_location.person) = IDENTITY(acp_geog_agg_location_history.personLocation)',
|
'IDENTITY(acp_geog_agg_address_person_location.person) = IDENTITY(acp_geog_agg_location_history.personLocation)',
|
||||||
'acp_geog_agg_address_person_location.validFrom <= :acp_geog_aggregator_date',
|
'acp_geog_agg_address_person_location.validFrom <= LEAST(:acp_geog_aggregator_date, acp.closingDate)',
|
||||||
$qb->expr()->orX(
|
$qb->expr()->orX(
|
||||||
'acp_geog_agg_address_person_location.validTo > :acp_geog_aggregator_date',
|
'acp_geog_agg_address_person_location.validTo > LEAST(:acp_geog_aggregator_date, acp.closingDate)',
|
||||||
$qb->expr()->isNull('acp_geog_agg_address_person_location.validTo')
|
$qb->expr()->isNull('acp_geog_agg_address_person_location.validTo')
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
@ -33,7 +33,8 @@ use Symfony\Component\Form\FormBuilderInterface;
|
|||||||
*/
|
*/
|
||||||
class GeographicalUnitStatFilter implements FilterInterface
|
class GeographicalUnitStatFilter implements FilterInterface
|
||||||
{
|
{
|
||||||
public function __construct(private readonly GeographicalUnitRepositoryInterface $geographicalUnitRepository, private readonly GeographicalUnitLayerRepositoryInterface $geographicalUnitLayerRepository, private readonly TranslatableStringHelperInterface $translatableStringHelper, private readonly RollingDateConverterInterface $rollingDateConverter) {}
|
public function __construct(
|
||||||
|
private readonly GeographicalUnitRepositoryInterface $geographicalUnitRepository, private readonly GeographicalUnitLayerRepositoryInterface $geographicalUnitLayerRepository, private readonly TranslatableStringHelperInterface $translatableStringHelper, private readonly RollingDateConverterInterface $rollingDateConverter) {}
|
||||||
|
|
||||||
public function addRole(): ?string
|
public function addRole(): ?string
|
||||||
{
|
{
|
||||||
@ -46,18 +47,19 @@ class GeographicalUnitStatFilter implements FilterInterface
|
|||||||
'SELECT
|
'SELECT
|
||||||
1
|
1
|
||||||
FROM '.AccompanyingPeriod\AccompanyingPeriodLocationHistory::class.' acp_geog_filter_location_history
|
FROM '.AccompanyingPeriod\AccompanyingPeriodLocationHistory::class.' acp_geog_filter_location_history
|
||||||
|
JOIN acp_geog_filter_location_history.period acp_geog_filter_location_history_period
|
||||||
LEFT JOIN '.PersonHouseholdAddress::class.' acp_geog_filter_address_person_location
|
LEFT JOIN '.PersonHouseholdAddress::class.' acp_geog_filter_address_person_location
|
||||||
WITH IDENTITY(acp_geog_filter_location_history.personLocation) = IDENTITY(acp_geog_filter_address_person_location.person)
|
WITH IDENTITY(acp_geog_filter_location_history.personLocation) = IDENTITY(acp_geog_filter_address_person_location.person)
|
||||||
|
AND
|
||||||
|
(acp_geog_filter_address_person_location.validFrom < LEAST(:acp_geog_filter_date, acp_geog_filter_location_history_period.closingDate) AND (
|
||||||
|
acp_geog_filter_address_person_location.validTo IS NULL OR acp_geog_filter_address_person_location.validTo > LEAST(:acp_geog_filter_date, acp_geog_filter_location_history_period.closingDate)
|
||||||
|
))
|
||||||
LEFT JOIN '.Address::class.' acp_geog_filter_address
|
LEFT JOIN '.Address::class.' acp_geog_filter_address
|
||||||
WITH COALESCE(IDENTITY(acp_geog_filter_address_person_location.address), IDENTITY(acp_geog_filter_location_history.addressLocation)) = acp_geog_filter_address.id
|
WITH COALESCE(IDENTITY(acp_geog_filter_address_person_location.address), IDENTITY(acp_geog_filter_location_history.addressLocation)) = acp_geog_filter_address.id
|
||||||
LEFT JOIN acp_geog_filter_address.geographicalUnits acp_geog_filter_units
|
LEFT JOIN acp_geog_filter_address.geographicalUnits acp_geog_filter_units
|
||||||
WHERE
|
WHERE
|
||||||
(acp_geog_filter_location_history.startDate <= :acp_geog_filter_date AND (
|
(acp_geog_filter_location_history.startDate <= LEAST(:acp_geog_filter_date, acp_geog_filter_location_history_period.closingDate) AND (
|
||||||
acp_geog_filter_location_history.endDate IS NULL OR acp_geog_filter_location_history.endDate > :acp_geog_filter_date
|
acp_geog_filter_location_history.endDate IS NULL OR acp_geog_filter_location_history.endDate > LEAST(:acp_geog_filter_date, acp_geog_filter_location_history_period.closingDate)
|
||||||
))
|
|
||||||
AND
|
|
||||||
(acp_geog_filter_address_person_location.validFrom < :acp_geog_filter_date AND (
|
|
||||||
acp_geog_filter_address_person_location.validTo IS NULL OR acp_geog_filter_address_person_location.validTo > :acp_geog_filter_date
|
|
||||||
))
|
))
|
||||||
AND acp_geog_filter_units IN (:acp_geog_filter_units)
|
AND acp_geog_filter_units IN (:acp_geog_filter_units)
|
||||||
AND acp_geog_filter_location_history.period = acp.id
|
AND acp_geog_filter_location_history.period = acp.id
|
||||||
|
@ -12,6 +12,7 @@ declare(strict_types=1);
|
|||||||
namespace Chill\PersonBundle\Repository\AccompanyingPeriod;
|
namespace Chill\PersonBundle\Repository\AccompanyingPeriod;
|
||||||
|
|
||||||
use Chill\MainBundle\Entity\User;
|
use Chill\MainBundle\Entity\User;
|
||||||
|
use Chill\PersonBundle\Entity\AccompanyingPeriod;
|
||||||
use Chill\PersonBundle\Entity\AccompanyingPeriod\AccompanyingPeriodWork;
|
use Chill\PersonBundle\Entity\AccompanyingPeriod\AccompanyingPeriodWork;
|
||||||
use Chill\PersonBundle\Entity\AccompanyingPeriod\AccompanyingPeriodWorkEvaluation;
|
use Chill\PersonBundle\Entity\AccompanyingPeriod\AccompanyingPeriodWorkEvaluation;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
@ -88,6 +89,7 @@ class AccompanyingPeriodWorkEvaluationRepository implements ObjectRepository
|
|||||||
->where(
|
->where(
|
||||||
$qb->expr()->andX(
|
$qb->expr()->andX(
|
||||||
$qb->expr()->isNull('e.endDate'),
|
$qb->expr()->isNull('e.endDate'),
|
||||||
|
$qb->expr()->neq('period.step', ':closed'),
|
||||||
$qb->expr()->gte(':now', $qb->expr()->diff('e.maxDate', 'e.warningInterval')),
|
$qb->expr()->gte(':now', $qb->expr()->diff('e.maxDate', 'e.warningInterval')),
|
||||||
$qb->expr()->orX(
|
$qb->expr()->orX(
|
||||||
$qb->expr()->eq('period.user', ':user'),
|
$qb->expr()->eq('period.user', ':user'),
|
||||||
@ -100,6 +102,7 @@ class AccompanyingPeriodWorkEvaluationRepository implements ObjectRepository
|
|||||||
->setParameters([
|
->setParameters([
|
||||||
'user' => $user,
|
'user' => $user,
|
||||||
'now' => new \DateTimeImmutable('now'),
|
'now' => new \DateTimeImmutable('now'),
|
||||||
|
'closed' => AccompanyingPeriod::STEP_CLOSED,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return $qb;
|
return $qb;
|
||||||
|
@ -135,6 +135,8 @@
|
|||||||
:filename="d.title"
|
:filename="d.title"
|
||||||
:can-edit="true"
|
:can-edit="true"
|
||||||
:execute-before-leave="submitBeforeLeaveToEditor"
|
:execute-before-leave="submitBeforeLeaveToEditor"
|
||||||
|
:davLink="d.storedObject._links?.dav_link.href"
|
||||||
|
:davLinkExpiration="d.storedObject._links?.dav_link.expiration"
|
||||||
@on-stored-object-status-change="onStatusDocumentChanged"
|
@on-stored-object-status-change="onStatusDocumentChanged"
|
||||||
></document-action-buttons-group>
|
></document-action-buttons-group>
|
||||||
</li>
|
</li>
|
||||||
|
@ -598,10 +598,7 @@ class ThirdParty implements TrackCreationInterface, TrackUpdateInterface, \Strin
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public function setCenters(Collection $centers): self
|
||||||
* @return $this
|
|
||||||
*/
|
|
||||||
public function setCenters(Collection $centers)
|
|
||||||
{
|
{
|
||||||
$this->centers = $centers;
|
$this->centers = $centers;
|
||||||
|
|
||||||
|
16
utils/http/docstore/dav.http
Normal file
16
utils/http/docstore/dav.http
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
|
||||||
|
### Get a document
|
||||||
|
GET http://{{ host }}/dav/get/{{ uuid }}/d
|
||||||
|
|
||||||
|
### OPTIONS on a document
|
||||||
|
OPTIONS http://{{ host }}/dav/get/{{ uuid }}/d
|
||||||
|
|
||||||
|
### HEAD ona document
|
||||||
|
HEAD http://{{ host }}/dav/get/{{ uuid }}/d
|
||||||
|
|
||||||
|
### Get the directory of a document
|
||||||
|
GET http://{{ host }}/dav/get/{{ uuid }}/
|
||||||
|
|
||||||
|
### Option the directory of a document
|
||||||
|
OPTIONS http://{{ host }}/dav/get/{{ uuid }}/
|
||||||
|
|
6
utils/http/docstore/http-client.env.json
Normal file
6
utils/http/docstore/http-client.env.json
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"dev": {
|
||||||
|
"host": "localhost:8001",
|
||||||
|
"uuid": "0bf3b8e7-b25b-4227-aae9-a3263af0766f"
|
||||||
|
}
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user