diff --git a/composer.json b/composer.json index fb8c9c333..88e3c520a 100644 --- a/composer.json +++ b/composer.json @@ -22,7 +22,8 @@ "Chill\\ThirdPartyBundle\\": "src/Bundle/ChillThirdPartyBundle", "Chill\\AsideActivityBundle\\": "src/Bundle/ChillAsideActivityBundle/src", "Chill\\DocGeneratorBundle\\": "src/Bundle/ChillDocGeneratorBundle", - "Chill\\CalendarBundle\\": "src/Bundle/ChillCalendarBundle" + "Chill\\CalendarBundle\\": "src/Bundle/ChillCalendarBundle", + "Chill\\WopiBundle\\": "src/Bundle/ChillWopiBundle/src" } }, "autoload-dev": { @@ -30,8 +31,12 @@ "App\\": "tests/app/src/" } }, + "minimum-stability": "dev", + "prefer-stable": true, "require": { "champs-libres/async-uploader-bundle": "dev-sf4", + "champs-libres/wopi-bundle": "dev-master", + "nyholm/psr7": "^1.4", "graylog2/gelf-php": "^1.5", "symfony/form": "4.*", "symfony/twig-bundle": "^4.4", diff --git a/src/Bundle/ChillCalendarBundle/Controller/CalendarController.php b/src/Bundle/ChillCalendarBundle/Controller/CalendarController.php index 5e5746387..a894099e4 100644 --- a/src/Bundle/ChillCalendarBundle/Controller/CalendarController.php +++ b/src/Bundle/ChillCalendarBundle/Controller/CalendarController.php @@ -87,7 +87,8 @@ class CalendarController extends AbstractController // $view = 'ChillCalendarBundle:Calendar:listByUser.html.twig'; } elseif ($accompanyingPeriod instanceof AccompanyingPeriod) { $calendarItems = $em->getRepository(Calendar::class)->findBy( - ['accompanyingPeriod' => $accompanyingPeriod] + ['accompanyingPeriod' => $accompanyingPeriod], + ['startDate' => 'DESC'] ); $view = 'ChillCalendarBundle:Calendar:listByAccompanyingCourse.html.twig'; @@ -139,10 +140,9 @@ class CalendarController extends AbstractController $this->addFlash('success', $this->get('translator')->trans('Success : calendar item created!')); - $params = $this->buildParamsToUrl($user, $accompanyingPeriod); //TODO useful? - $params['id'] = $entity->getId(); + $params = $this->buildParamsToUrl($user, $accompanyingPeriod); - return $this->redirectToRoute('chill_calendar_calendar_show', $params); + return $this->redirectToRoute('chill_calendar_calendar', $params); } elseif ($form->isSubmitted() and !$form->isValid()) { $this->addFlash('error', $this->get('translator')->trans('This form contains errors')); } @@ -241,8 +241,7 @@ class CalendarController extends AbstractController $this->addFlash('success', $this->get('translator')->trans('Success : calendar item updated!')); $params = $this->buildParamsToUrl($user, $accompanyingPeriod); - $params['id'] = $id; - return $this->redirectToRoute('chill_calendar_calendar_show', $params); + return $this->redirectToRoute('chill_calendar_calendar', $params); } elseif ($form->isSubmitted() and !$form->isValid()) { $this->addFlash('error', $this->get('translator')->trans('This form contains errors')); } @@ -304,7 +303,7 @@ class CalendarController extends AbstractController $em->flush(); $this->addFlash('success', $this->get('translator') - ->trans("The calendar has been successfully removed.")); + ->trans("The calendar item has been successfully removed.")); $params = $this->buildParamsToUrl($user, $accompanyingPeriod); return $this->redirectToRoute('chill_calendar_calendar', $params); diff --git a/src/Bundle/ChillCalendarBundle/Form/CalendarType.php b/src/Bundle/ChillCalendarBundle/Form/CalendarType.php index c2f3ea6ba..990f7ea59 100644 --- a/src/Bundle/ChillCalendarBundle/Form/CalendarType.php +++ b/src/Bundle/ChillCalendarBundle/Form/CalendarType.php @@ -48,13 +48,13 @@ class CalendarType extends AbstractType ->add('comment', CommentType::class, [ 'required' => false ]) - ->add('cancelReason', EntityType::class, [ - 'required' => false, - 'class' => CancelReason::class, - 'choice_label' => function (CancelReason $entity) { - return $entity->getCanceledBy(); - }, - ]) + // ->add('cancelReason', EntityType::class, [ + // 'required' => false, + // 'class' => CancelReason::class, + // 'choice_label' => function (CancelReason $entity) { + // return $entity->getCanceledBy(); + // }, + // ]) ->add('sendSMS', ChoiceType::class, [ 'required' => false, 'choices' => [ diff --git a/src/Bundle/ChillCalendarBundle/Resources/public/chill/index.js b/src/Bundle/ChillCalendarBundle/Resources/public/chill/index.js new file mode 100644 index 000000000..c6e439991 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Resources/public/chill/index.js @@ -0,0 +1 @@ +require('./scss/calendar.scss'); \ No newline at end of file diff --git a/src/Bundle/ChillCalendarBundle/Resources/public/chill/scss/calendar.scss b/src/Bundle/ChillCalendarBundle/Resources/public/chill/scss/calendar.scss new file mode 100644 index 000000000..b224125f1 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Resources/public/chill/scss/calendar.scss @@ -0,0 +1,10 @@ +div#calendarControls { + height: 50%; + display: flex; + flex-direction: column; + justify-content: flex-end; +} + +div#fullCalendar{ + +} \ No newline at end of file diff --git a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/App.vue b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/App.vue index fb4429a1d..5999fdbd0 100644 --- a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/App.vue +++ b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/App.vue @@ -1,39 +1,35 @@ diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourseWorkEdit/components/FormEvaluation.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourseWorkEdit/components/FormEvaluation.vue index 721d15450..33436f222 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourseWorkEdit/components/FormEvaluation.vue +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourseWorkEdit/components/FormEvaluation.vue @@ -67,19 +67,15 @@
- + +
- - @@ -88,6 +84,7 @@ import {dateToISO, ISOToDate, ISOToDatetime} from 'ChillMainAssets/chill/js/date.js'; import CKEditor from '@ckeditor/ckeditor5-vue'; import ClassicEditor from 'ChillMainAssets/module/ckeditor5/index.js'; +import { mapGetters, mapState } from 'vuex'; const i18n = { messages: { @@ -119,25 +116,19 @@ export default { data() { return { editor: ClassicEditor, - //evaluation: { - // status: null, - // startDate: null, - // endDate: null, - // maxDate: null, - // warningInterval: null, - // comment: null, - // template: null, - // //documents: null - //} + template: null, } }, computed: { - /* - status: { - get() { return this.evaluation.status; }, - set(v) { this.evaluation.status = v; } + ...mapGetters([ + 'getTemplatesAvailaibleForEvaluation' + ]), + ...mapState([ + 'isPosting' + ]), + canGenerate() { + return !this.$store.state.isPosting && this.template !== null; }, - */ startDate: { get() { return dateToISO(this.evaluation.startDate); @@ -171,10 +162,6 @@ export default { get() { return this.evaluation.comment; }, set(v) { this.$store.commit('setEvaluationComment', { key: this.evaluation.key, comment: v }); } }, - template: { - get() { return this.evaluation.template; }, - set(v) { this.evaluation.template = v; } - }, }, methods: { listAllStatus() { @@ -189,6 +176,10 @@ export default { }) ; }, + generateDocument() { + console.log('template picked', this.template); + this.$store.dispatch('generateDocument', { key: this.evaluation.key, templateId: this.template}) + } }, mounted() { //this.listAllStatus(); diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourseWorkEdit/store.js b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourseWorkEdit/store.js index 87b7d03e4..22218f04f 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourseWorkEdit/store.js +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourseWorkEdit/store.js @@ -4,6 +4,7 @@ import { findSocialActionsBySocialIssue } from 'ChillPersonAssets/vuejs/_api/Soc import { create } from 'ChillPersonAssets/vuejs/_api/AccompanyingCourseWork.js'; const debug = process.env.NODE_ENV !== 'production'; +const evalFQDN = encodeURIComponent("Chill\\PersonBundle\\Entity\\AccompanyingPeriod\\AccompanyingPeriodWorkEvaluation"); const store = createStore({ strict: debug, @@ -32,6 +33,7 @@ const store = createStore({ return k; }), evaluationsForAction: [], + templatesAvailableForEvaluation: [], personsPicked: window.accompanyingCourseWork.persons, personsReachables: window.accompanyingCourseWork.accompanyingPeriod.participations.filter(p => p.endDate == null) .map(p => p.person), @@ -63,6 +65,9 @@ const store = createStore({ hasThirdParties(state) { return state.thirdParties.length > 0; }, + getTemplatesAvailaibleForEvaluation(state) { + return state.templatesAvailableForEvaluation; + }, buildPayload(state) { return { type: 'accompanying_period_work', @@ -99,6 +104,7 @@ const store = createStore({ accompanyingPeriodWorkEvaluations: state.evaluationsPicked.map(e => { let o = { type: e.type, + key: e.key, evaluation: { id: e.evaluation.id, type: e.evaluation.type @@ -216,6 +222,11 @@ const store = createStore({ let evaluation = state.evaluationsPicked.find(e => e.key === key); evaluation.editEvaluation = !evaluation.editEvaluation; }, + setTemplatesAvailableForEvaluation(state, templates) { + for (let i in templates) { + state.templatesAvailableForEvaluation.push(templates[i]); + } + }, setPersonsPickedIds(state, ids) { state.personsPicked = state.personsReachables .filter(p => ids.includes(p.id)) @@ -317,7 +328,38 @@ const store = createStore({ commit('setEvaluationsForAction', data.results); }); }, - submit({ getters, state, commit }) { + getReachableTemplatesForEvaluation({commit}) { + const + url = `/fr/doc/gen/templates/for/${evalFQDN}` + ; + window.fetch(url).then(r => { + if (r.ok) { + return r.json(); + } + throw new Error("not possible to load templates for evaluations") + }).then(data => { + commit('setTemplatesAvailableForEvaluation', data.results); + }).catch(e => { + console.error(e); + }) + }, + generateDocument({ dispatch }, {key, templateId}) { + const callback = function(data) { + // get the evaluation id from the data + const + evaluationId = data.accompanyingPeriodWorkEvaluations.find(e => e.key === key).id, + returnPath = encodeURIComponent(window.location.pathname + window.location.search + window.location.hash), + url = `/fr/doc/gen/generate/from/${templateId}/for/${evalFQDN}/${evaluationId}?returnPath=${returnPath}` + ; + //http://localhost:8001/fr/doc/gen/generate/from/12/for/Chill%5CPersonBundle%5CEntity%5CAccompanyingPeriod%5CAccompanyingPeriodWorkEvaluation/41 + + console.log('I will generate your doc at', url); + window.location.assign(url); + }; + + dispatch('submit', callback); + }, + submit({ getters, state, commit }, callback) { let payload = getters.buildPayload, url = `/api/1.0/person/accompanying-course/work/${state.work.id}.json`, @@ -345,6 +387,8 @@ const store = createStore({ } commit('setErrors', errors); commit('setIsPosting', false); + } else if (typeof(callback) !== 'undefined') { + callback(data); } else { console.info('nothing to do here, bye bye'); window.location.assign(`/fr/person/accompanying-period/${state.work.accompanyingPeriod.id}/work`); @@ -360,6 +404,7 @@ const store = createStore({ dispatch('getReachablesResultsForAction'); dispatch('getReachablesGoalsForAction'); dispatch('getReachablesEvaluationsForAction'); + dispatch('getReachableTemplatesForEvaluation'); }, } }); diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/HouseholdMembersEditor/components/Household.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/HouseholdMembersEditor/components/Household.vue index fa3dd64b6..05746429c 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/HouseholdMembersEditor/components/Household.vue +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/HouseholdMembersEditor/components/Household.vue @@ -7,35 +7,43 @@
-

À quelle adresse habite ce ménage ?

+

À quelle adresse habite ce ménage ?

- +
+ +
+ Aucune adresse à suggérer +
- +
- +
@@ -51,7 +59,7 @@ class="btn btn-misc" @click="toggleHouseholdSuggestion" > - {{ $tc('household_members_editor.show_household_suggestion', + {{ $tc('household_members_editor.show_household_suggestion', countHouseholdSuggestion) }} @@ -106,11 +114,24 @@
- + + +
+ + +
+ + + + diff --git a/src/Bundle/ChillWopiBundle/src/Resources/views/Editor/page.html.twig b/src/Bundle/ChillWopiBundle/src/Resources/views/Editor/page.html.twig new file mode 100644 index 000000000..6534dfd30 --- /dev/null +++ b/src/Bundle/ChillWopiBundle/src/Resources/views/Editor/page.html.twig @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + {{ encore_entry_link_tags('page_wopi_editor') }} + {{ encore_entry_script_tags('page_wopi_editor') }} + + + + +
+ + +
+ + + + + diff --git a/src/Bundle/ChillWopiBundle/src/Service/Controller/Responder.php b/src/Bundle/ChillWopiBundle/src/Service/Controller/Responder.php new file mode 100644 index 000000000..bc26be62d --- /dev/null +++ b/src/Bundle/ChillWopiBundle/src/Service/Controller/Responder.php @@ -0,0 +1,101 @@ +twig = $twig; + $this->urlGenerator = $urlGenerator; + $this->serializer = $serializer; + } + + public function file( + $file, + ?string $filename = null, + string $disposition = ResponseHeaderBag::DISPOSITION_ATTACHMENT + ): BinaryFileResponse { + $response = new BinaryFileResponse($file); + + $filename ??= $response->getFile()->getFilename(); + $response->setContentDisposition($disposition, $filename); + + return $response; + } + + public function json( + $data, + int $status = 200, + array $headers = [], + array $context = [] + ): JsonResponse { + return new JsonResponse( + $this + ->serializer + ->serialize( + $data, + 'json', + array_merge( + [ + 'json_encode_options' => JsonResponse::DEFAULT_ENCODING_OPTIONS, + ], + $context + ) + ), + $status, + $headers, + true + ); + } + + public function redirect(string $url, int $status = 302, array $headers = []): RedirectResponse + { + return new RedirectResponse($url, $status, $headers); + } + + public function redirectToRoute( + string $route, + array $parameters = [], + int $status = 302, + array $headers = [] + ): RedirectResponse { + return $this->redirect($this->urlGenerator->generate($route, $parameters), $status, $headers); + } + + public function render(string $template, array $context = [], int $status = 200, array $headers = []): Response + { + $response = new Response($this->twig->render($template, $context), $status, $headers); + + if (!$response->headers->has('Content-Type')) { + $response->headers->set('Content-Type', 'text/html; charset=UTF-8'); + } + + return $response; + } +} diff --git a/src/Bundle/ChillWopiBundle/src/Service/Controller/ResponderInterface.php b/src/Bundle/ChillWopiBundle/src/Service/Controller/ResponderInterface.php new file mode 100644 index 000000000..712b3cd59 --- /dev/null +++ b/src/Bundle/ChillWopiBundle/src/Service/Controller/ResponderInterface.php @@ -0,0 +1,74 @@ +|string> $headers + * @param array $context + */ + public function json( + mixed $data, + int $status = 200, + array $headers = [], + array $context = [] + ): JsonResponse; + + /** + * Returns a RedirectResponse to the given URL. + * + * @param array|string> $headers + */ + public function redirect(string $url, int $status = 302, array $headers = []): RedirectResponse; + + /** + * Returns a RedirectResponse to the given route with the given parameters. + * + * @param array $parameters + * @param array> $headers + */ + public function redirectToRoute( + string $route, + array $parameters = [], + int $status = 302, + array $headers = [] + ): RedirectResponse; + + /** + * Render the given twig template and return an HTML response. + * + * @param array|string> $headers + * + * @throws TwigError + */ + public function render(string $template, array $context = [], int $status = 200, array $headers = []): Response; +} diff --git a/src/Bundle/ChillWopiBundle/src/Service/Wopi/ChillWopi.php b/src/Bundle/ChillWopiBundle/src/Service/Wopi/ChillWopi.php new file mode 100644 index 000000000..bb0dfb8e2 --- /dev/null +++ b/src/Bundle/ChillWopiBundle/src/Service/Wopi/ChillWopi.php @@ -0,0 +1,305 @@ +psr17 = $psr17; + $this->wopiDiscovery = $wopiDiscovery; + $this->storedObjectRepository = $storedObjectRepository; + $this->httpClient = $httpClient; + $this->tempUrlGenerator = $tempUrlGenerator; + $this->userProvider = $userProvider; + } + + public function checkFileInfo( + string $fileId, + ?string $accessToken, + RequestInterface $request + ): ResponseInterface { + try { + $user = $this->userProvider->loadUserByUsername($accessToken); + } catch (UsernameNotFoundException $e) { + return $this + ->psr17 + ->createResponse(401); + } + + $storedObject = $this->storedObjectRepository->findOneBy(['filename' => $fileId]); + + if (null === $storedObject) { + throw new Exception(sprintf('Unable to find object named %s', $fileId)); + } + + $mimeType = $storedObject->getType(); + + if ([] === $this->wopiDiscovery->discoverMimeType($mimeType)) { + throw new Exception(sprintf('Unable to find mime type %s', $mimeType)); + } + + return $this + ->psr17 + ->createResponse() + ->withHeader('Content-Type', 'application/json') + ->withBody($this->psr17->createStream((string) json_encode( + [ + 'BaseFileName' => $storedObject->getFilename(), + 'OwnerId' => uniqid(), + 'Size' => 0, + 'UserId' => uniqid(), +// 'Version' => 'v' . uniqid(), + 'ReadOnly' => false, + 'UserCanWrite' => true, + 'UserCanNotWriteRelative' => true, + 'SupportsLocks' => false, + 'UserFriendlyName' => sprintf('User %s', $user->getUsername()), + 'UserExtraInfo' => [], + 'LastModifiedTime' => date('Y-m-d\TH:i:s.u\Z', $storedObject->getCreationDate()->getTimestamp()), + 'CloseButtonClosesWindow' => true, + 'EnableInsertRemoteImage' => true, + 'EnableShare' => false, + 'SupportsUpdate' => true, + 'SupportsRename' => false, + 'DisablePrint' => false, + 'DisableExport' => false, + 'DisableCopy' => false, + ] + ))); + } + + public function deleteFile(string $fileId, ?string $accessToken, RequestInterface $request): ResponseInterface + { + return $this->getDebugResponse(__FUNCTION__, $request); + } + + public function enumerateAncestors( + string $fileId, + ?string $accessToken, + RequestInterface $request + ): ResponseInterface { + return $this->getDebugResponse(__FUNCTION__, $request); + } + + public function getFile(string $fileId, ?string $accessToken, RequestInterface $request): ResponseInterface + { + try { + $user = $this->userProvider->loadUserByUsername($accessToken); + } catch (UsernameNotFoundException $e) { + return $this + ->psr17 + ->createResponse(401); + } + + $storedObject = $this->storedObjectRepository->findOneBy(['filename' => $fileId]); + + if (null === $storedObject) { + return $this + ->psr17 + ->createResponse(404); + } + + // TODO: Add strict typing in champs-libres/async-uploader-bundle + /** @var StdClass $object */ + $object = $this->tempUrlGenerator->generate('GET', $storedObject->getFilename()); + + $response = $this->httpClient->sendRequest($this->psr17->createRequest('GET', $object->url)); + + if (200 !== $response->getStatusCode()) + { + return $this + ->psr17 + ->createResponse(500); + } + + return $this + ->psr17 + ->createResponse() + ->withHeader( + 'Content-Type', + 'application/octet-stream', + ) + ->withHeader( + 'Content-Disposition', + sprintf('attachment; filename=%s', $storedObject->getFilename()) + ) + ->withBody($response->getBody()); + } + + public function getLock(string $fileId, ?string $accessToken, RequestInterface $request): ResponseInterface + { + return $this->getDebugResponse(__FUNCTION__, $request); + } + + public function getShareUrl( + string $fileId, + ?string $accessToken, + RequestInterface $request + ): ResponseInterface { + return $this->getDebugResponse(__FUNCTION__, $request); + } + + public function lock( + string $fileId, + ?string $accessToken, + string $xWopiLock, + RequestInterface $request + ): ResponseInterface { + return $this->getDebugResponse(__FUNCTION__, $request); + } + + public function putFile( + string $fileId, + ?string $accessToken, + string $xWopiLock, + string $xWopiEditors, + RequestInterface $request + ): ResponseInterface { + try { + $user = $this->userProvider->loadUserByUsername($accessToken); + } catch (UsernameNotFoundException $e) { + return $this + ->psr17 + ->createResponse(401); + } + + $storedObject = $this->storedObjectRepository->findOneBy(['filename' => $fileId]); + + if (null === $storedObject) { + throw new Exception(sprintf('Unable to find object named %s', $fileId)); + } + + // TODO: Add strict typing in champs-libres/async-uploader-bundle + /** @var StdClass $object */ + $object = $this->tempUrlGenerator->generate('PUT', $storedObject->getFilename()); + + $response = $this->httpClient->sendRequest($this->psr17->createRequest('PUT', $object->url)->withBody($request->getBody())); + + if (201 !== $response->getStatusCode()) + { + return $this + ->psr17 + ->createResponse(500); + } + + return $this + ->psr17 + ->createResponse() + ->withHeader('Content-Type', 'application/json') + ->withAddedHeader('X-WOPI-Lock', $xWopiLock) + ->withBody($this->psr17->createStream((string) json_encode([]))); + } + + public function putRelativeFile(string $fileId, ?string $accessToken, RequestInterface $request): ResponseInterface + { + return $this->getDebugResponse(__FUNCTION__, $request); + } + + public function putUserInfo(string $fileId, ?string $accessToken, RequestInterface $request): ResponseInterface + { + return $this->getDebugResponse(__FUNCTION__, $request); + } + + public function refreshLock( + string $fileId, + ?string $accessToken, + string $xWopiLock, + RequestInterface $request + ): ResponseInterface { + return $this->getDebugResponse(__FUNCTION__, $request); + } + + public function renameFile( + string $fileId, + ?string $accessToken, + string $xWopiLock, + string $xWopiRequestedName, + RequestInterface $request + ): ResponseInterface { + return $this->getDebugResponse(__FUNCTION__, $request); + } + + public function unlock( + string $fileId, + ?string $accessToken, + string $xWopiLock, + RequestInterface $request + ): ResponseInterface { + return $this->getDebugResponse(__FUNCTION__, $request); + } + + public function unlockAndRelock( + string $fileId, + ?string $accessToken, + string $xWopiLock, + string $xWopiOldLock, + RequestInterface $request + ): ResponseInterface { + return $this->getDebugResponse(__FUNCTION__, $request); + } + + private function getDebugResponse(string $method, RequestInterface $request): ResponseInterface + { + $params = []; + parse_str($request->getUri()->getQuery(), $params); + + $data = (string) json_encode(array_merge( + ['method' => $method], + $params, + $request->getHeaders() + )); + + return $this + ->psr17 + ->createResponse() + ->withHeader('content', 'application/json') + ->withBody($this->psr17->createStream($data)); + } + + private function getLockFilepath(string $fileId): string + { + return sprintf( + '%s/%s.lock', + $this->filesRepository, + $fileId + ); + } +}