diff --git a/src/Bundle/ChillMainBundle/Entity/Scope.php b/src/Bundle/ChillMainBundle/Entity/Scope.php index ba11a1842..2e75c5791 100644 --- a/src/Bundle/ChillMainBundle/Entity/Scope.php +++ b/src/Bundle/ChillMainBundle/Entity/Scope.php @@ -3,17 +3,17 @@ /* * Chill is a suite of a modules, Chill is a software for social workers * Copyright (C) 2014, Champs Libres Cooperative SCRLFS, - * + * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. - * + * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. - * + * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ @@ -46,17 +46,17 @@ class Scope * @Groups({"read"}) */ private $id; - + /** * translatable names - * + * * @var array * * @ORM\Column(type="json_array") * @Groups({"read"}) */ private $name = []; - + /** * @var Collection * @@ -66,8 +66,8 @@ class Scope * @ORM\Cache(usage="NONSTRICT_READ_WRITE") */ private $roleScopes; - - + + /** * Scope constructor. */ @@ -75,7 +75,7 @@ class Scope { $this->roleScopes = new ArrayCollection(); } - + /** * @return int */ @@ -91,7 +91,7 @@ class Scope { return $this->name; } - + /** * @param $name * @return $this @@ -101,7 +101,7 @@ class Scope $this->name = $name; return $this; } - + /** * @return Collection */ @@ -109,7 +109,7 @@ class Scope { return $this->roleScopes; } - + /** * @param RoleScope $roleScope */ diff --git a/src/Bundle/ChillMainBundle/Resources/public/lib/api/scope.js b/src/Bundle/ChillMainBundle/Resources/public/lib/api/scope.js new file mode 100644 index 000000000..a8df4ed88 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Resources/public/lib/api/scope.js @@ -0,0 +1,17 @@ +const fetchScopes = () => { + return window.fetch('/api/1.0/main/scope.json').then(response => { + if (response.ok) { + return response.json(); + } + }).then(data => { + console.log(data); + return new Promise((resolve, reject) => { + console.log(data); + resolve(data.results); + }); + }); +}; + +export { + fetchScopes +}; diff --git a/src/Bundle/ChillMainBundle/Security/Resolver/ScopeResolverInterface.php b/src/Bundle/ChillMainBundle/Security/Resolver/ScopeResolverInterface.php index 2f5669d0d..04e949a59 100644 --- a/src/Bundle/ChillMainBundle/Security/Resolver/ScopeResolverInterface.php +++ b/src/Bundle/ChillMainBundle/Security/Resolver/ScopeResolverInterface.php @@ -2,16 +2,21 @@ namespace Chill\MainBundle\Security\Resolver; -use Chill\MainBundle\Entity\Center; use Chill\MainBundle\Entity\Scope; +/** + * Interface to implement to define a ScopeResolver. + */ interface ScopeResolverInterface { + /** + * Return true if this resolve is able to decide "something" on this entity. + */ public function supports($entity, ?array $options = []): bool; /** - * @param $entity - * @param array|null $options + * Will return the scope for the entity + * * @return Scope|array|Scope[] */ public function resolveScope($entity, ?array $options = []); @@ -19,12 +24,12 @@ interface ScopeResolverInterface /** * Return true if the entity is concerned by scope, false otherwise. - * - * @param $entity - * @param array|null $options - * @return bool */ public function isConcerned($entity, ?array $options = []): bool; + /** + * get the default priority for this resolver. Resolver with an higher priority will be + * queried first. + */ public static function getDefaultPriority(): int; } diff --git a/src/Bundle/ChillMainBundle/chill.api.specs.yaml b/src/Bundle/ChillMainBundle/chill.api.specs.yaml index f6945e20b..8ee69e96d 100644 --- a/src/Bundle/ChillMainBundle/chill.api.specs.yaml +++ b/src/Bundle/ChillMainBundle/chill.api.specs.yaml @@ -491,3 +491,23 @@ paths: description: "ok" 401: description: "Unauthorized" + + /1.0/main/scope/{id}.json: + get: + tags: + - scope + summary: return a list of scopes + parameters: + - name: id + in: path + required: true + description: The scope id + schema: + type: integer + format: integer + minimum: 1 + responses: + 200: + description: "ok" + 401: + description: "Unauthorized" diff --git a/src/Bundle/ChillPersonBundle/DataFixtures/ORM/LoadPeople.php b/src/Bundle/ChillPersonBundle/DataFixtures/ORM/LoadPeople.php index d6bdce233..848e8f874 100644 --- a/src/Bundle/ChillPersonBundle/DataFixtures/ORM/LoadPeople.php +++ b/src/Bundle/ChillPersonBundle/DataFixtures/ORM/LoadPeople.php @@ -24,8 +24,12 @@ namespace Chill\PersonBundle\DataFixtures\ORM; use Chill\MainBundle\Entity\Center; use Chill\MainBundle\Entity\Country; use Chill\MainBundle\Entity\PostalCode; +use Chill\MainBundle\Entity\Scope; +use Chill\MainBundle\Entity\User; use Chill\MainBundle\Repository\CenterRepository; use Chill\MainBundle\Repository\CountryRepository; +use Chill\MainBundle\Repository\ScopeRepository; +use Chill\MainBundle\Repository\UserRepository; use Chill\PersonBundle\Entity\AccompanyingPeriod; use Chill\PersonBundle\Entity\MaritalStatus; use Chill\PersonBundle\Entity\SocialWork\SocialIssue; @@ -90,12 +94,26 @@ class LoadPeople extends AbstractFixture implements OrderedFixtureInterface, Con protected MaritalStatusRepository $maritalStatusRepository; + /** + * @var array|Scope[] + */ + protected array $cacheScopes = []; + + protected ScopeRepository $scopeRepository; + + /** @var array|User[] */ + protected array $cacheUsers = []; + + protected UserRepository $userRepository; + public function __construct( Registry $workflowRegistry, SocialIssueRepository $socialIssueRepository, CenterRepository $centerRepository, CountryRepository $countryRepository, - MaritalStatusRepository $maritalStatusRepository + MaritalStatusRepository $maritalStatusRepository, + ScopeRepository $scopeRepository, + UserRepository $userRepository ) { $this->faker = Factory::create('fr_FR'); $this->faker->addProvider($this); @@ -105,7 +123,8 @@ class LoadPeople extends AbstractFixture implements OrderedFixtureInterface, Con $this->countryRepository = $countryRepository; $this->maritalStatusRepository = $maritalStatusRepository; $this->loader = new NativeLoader($this->faker); - + $this->scopeRepository = $scopeRepository; + $this->userRepository = $userRepository; } public function getOrder() @@ -220,10 +239,16 @@ class LoadPeople extends AbstractFixture implements OrderedFixtureInterface, Con new \DateInterval('P' . \random_int(0, 180) . 'D') ) ); + $accompanyingPeriod->setCreatedBy($this->getRandomUser()) + ->setCreatedAt(new \DateTimeImmutable('now')); $person->addAccompanyingPeriod($accompanyingPeriod); $accompanyingPeriod->addSocialIssue($this->getRandomSocialIssue()); if (\random_int(0, 10) > 3) { + // always add social scope: + $accompanyingPeriod->addScope($this->getReference('scope_social')); + var_dump(count($accompanyingPeriod->getScopes())); + $accompanyingPeriod->setAddressLocation($this->createAddress()); $manager->persist($accompanyingPeriod->getAddressLocation()); $workflow = $this->workflowRegistry->get($accompanyingPeriod); @@ -231,9 +256,19 @@ class LoadPeople extends AbstractFixture implements OrderedFixtureInterface, Con } $manager->persist($person); + $manager->persist($accompanyingPeriod); echo "add person'".$person->__toString()."'\n"; } + private function getRandomUser(): User + { + if (0 === count($this->cacheUsers)) { + $this->cacheUsers = $this->userRepository->findAll(); + } + + return $this->cacheUsers[\array_rand($this->cacheUsers)]; + } + private function createAddress(): Address { $objectSet = $this->loader->loadData([ diff --git a/src/Bundle/ChillPersonBundle/DataFixtures/ORM/LoadPersonACL.php b/src/Bundle/ChillPersonBundle/DataFixtures/ORM/LoadPersonACL.php index c8593d1fd..18f8e5879 100644 --- a/src/Bundle/ChillPersonBundle/DataFixtures/ORM/LoadPersonACL.php +++ b/src/Bundle/ChillPersonBundle/DataFixtures/ORM/LoadPersonACL.php @@ -40,13 +40,13 @@ class LoadPersonACL extends AbstractFixture implements OrderedFixtureInterface return 9600; } - + public function load(ObjectManager $manager) { foreach (LoadPermissionsGroup::$refs as $permissionsGroupRef) { $permissionsGroup = $this->getReference($permissionsGroupRef); $scopeSocial = $this->getReference('scope_social'); - + //create permission group switch ($permissionsGroup->getName()) { case 'social': @@ -55,7 +55,7 @@ class LoadPersonACL extends AbstractFixture implements OrderedFixtureInterface $permissionsGroup->addRoleScope( (new RoleScope()) - ->setRole(AccompanyingPeriodVoter::SEE) + ->setRole(AccompanyingPeriodVoter::FULL) ->setScope($scopeSocial) ); @@ -87,7 +87,7 @@ class LoadPersonACL extends AbstractFixture implements OrderedFixtureInterface $manager->persist($roleScopeUpdate); $manager->persist($roleScopeCreate); $manager->persist($roleScopeDuplicate); - + break; case 'administrative': printf("Adding CHILL_PERSON_SEE to %s permission group \n", $permissionsGroup->getName()); @@ -98,9 +98,9 @@ class LoadPersonACL extends AbstractFixture implements OrderedFixtureInterface $manager->persist($roleScopeSee); break; } - + } - + $manager->flush(); } diff --git a/src/Bundle/ChillPersonBundle/Entity/AccompanyingPeriod.php b/src/Bundle/ChillPersonBundle/Entity/AccompanyingPeriod.php index d69a7a51a..14fb26f1a 100644 --- a/src/Bundle/ChillPersonBundle/Entity/AccompanyingPeriod.php +++ b/src/Bundle/ChillPersonBundle/Entity/AccompanyingPeriod.php @@ -824,7 +824,9 @@ class AccompanyingPeriod implements TrackCreationInterface, TrackUpdateInterface public function addScope(Scope $scope): self { - $this->scopes[] = $scope; + if (!$this->scopes->contains($scope)) { + $this->scopes[] = $scope; + } return $this; } diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/App.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/App.vue index e621feead..797f246fe 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/App.vue +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/App.vue @@ -10,6 +10,7 @@ + @@ -32,6 +33,7 @@ import PersonsAssociated from './components/PersonsAssociated.vue'; import Requestor from './components/Requestor.vue'; import SocialIssue from './components/SocialIssue.vue'; import CourseLocation from './components/CourseLocation.vue'; +import Scopes from './components/Scopes.vue'; import Referrer from './components/Referrer.vue'; import Resources from './components/Resources.vue'; import Comment from './components/Comment.vue'; @@ -47,6 +49,7 @@ export default { Requestor, SocialIssue, CourseLocation, + Scopes, Referrer, Resources, Comment, diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/api.js b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/api.js index 52e253ac1..ee4c3e11b 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/api.js +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/api.js @@ -191,7 +191,49 @@ const getListOrigins = () => { if (response.ok) { return response.json(); } throw { msg: 'Error while retriving origin\'s list.', sta: response.status, txt: response.statusText, err: new Error(), body: response.body }; }); -} +}; + +const addScope = (id, scope) => { + const url = `/api/1.0/person/accompanying-course/${id}/scope.json`; + console.log(url); + console.log(scope); + + return fetch(url, { + method: 'POST', + body: JSON.stringify({ + id: scope.id, + type: scope.type, + }), + headers: { + 'Content-Type': 'application/json;charset=utf-8' + }, + }) + .then(response => { + if (response.ok) { return response.json(); } + throw { msg: 'Error while adding scope', sta: response.status, txt: response.statusText, err: new Error(), body: response.body }; + }); +}; + +const removeScope = (id, scope) => { + const url = `/api/1.0/person/accompanying-course/${id}/scope.json`; + console.log(url); + console.log(scope); + + return fetch(url, { + method: 'DELETE', + body: JSON.stringify({ + id: scope.id, + type: scope.type, + }), + headers: { + 'Content-Type': 'application/json;charset=utf-8' + }, + }) + .then(response => { + if (response.ok) { return response.json(); } + throw { msg: 'Error while adding scope', sta: response.status, txt: response.statusText, err: new Error(), body: response.body }; + }); +}; export { getAccompanyingCourse, @@ -204,5 +246,7 @@ export { getUsers, whoami, getListOrigins, - postSocialIssue + postSocialIssue, + addScope, + removeScope, }; diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/Confirm.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/Confirm.vue index 44479831c..b7834039d 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/Confirm.vue +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/Confirm.vue @@ -88,6 +88,10 @@ export default { socialIssue: { msg: 'confirm.socialIssue_not_valid', anchor: '#section-50' + }, + scopes: { + msg: 'confirm.set_a_scope', + anchor: '#section-65' } } } diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/Scopes.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/Scopes.vue new file mode 100644 index 000000000..ca1b36770 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/Scopes.vue @@ -0,0 +1,47 @@ + + + + + diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/js/i18n.js b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/js/i18n.js index d7c01a174..cf3a0fb7e 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/js/i18n.js +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/js/i18n.js @@ -86,6 +86,10 @@ const appMessages = { person_locator: "Parcours localisé auprès de {0}", no_address: "Il n'y a pas d'adresse associée au parcours" }, + scopes: { + title: "Services", + add_at_least_one: "Indiquez au moins un service", + }, referrer: { title: "Référent du parcours", label: "Vous pouvez choisir un TMS ou vous assigner directement comme référent", @@ -113,6 +117,7 @@ const appMessages = { participation_not_valid: "sélectionnez au minimum 1 usager", socialIssue_not_valid: "sélectionnez au minimum une problématique sociale", location_not_valid: "indiquez au minimum une localisation temporaire du parcours", + set_a_scope: "indiquez au moins un service", sure: "Êtes-vous sûr ?", sure_description: "Une fois le changement confirmé, il ne sera plus possible de le remettre à l'état de brouillon !", ok: "Confirmer le parcours" diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/store/index.js b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/store/index.js index 34a6786fc..4deb86e40 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/store/index.js +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/store/index.js @@ -1,28 +1,41 @@ import 'es6-promise/auto'; import { createStore } from 'vuex'; +import { fetchScopes } from 'ChillMainAssets/lib/api/scope.js'; import { getAccompanyingCourse, patchAccompanyingCourse, confirmAccompanyingCourse, postParticipation, postRequestor, postResource, - postSocialIssue } from '../api'; + postSocialIssue, + addScope, + removeScope, +} from '../api'; const debug = process.env.NODE_ENV !== 'production'; const id = window.accompanyingCourseId; -let initPromise = getAccompanyingCourse(id) - .then(accompanying_course => new Promise((resolve, reject) => { +let scopesPromise = fetchScopes(); +let accompanyingCoursePromise = getAccompanyingCourse(id); + +let initPromise = Promise.all([scopesPromise, accompanyingCoursePromise]) + .then(([scopes, accompanyingCourse]) => new Promise((resolve, reject) => { const store = createStore({ strict: debug, modules: { }, state: { - accompanyingCourse: accompanying_course, + accompanyingCourse: accompanyingCourse, addressContext: {}, - errorMsg: [] + errorMsg: [], + // all the available scopes + scopes: scopes, + // the scopes at start. If the user remove all scopes, we re-add those scopes, by security + scopesAtStart: accompanyingCourse.scopes.map(scope => scope), + // the scope states at server side + scopesAtBackend: accompanyingCourse.scopes.map(scope => scope), }, getters: { isParticipationValid(state) { @@ -34,11 +47,16 @@ let initPromise = getAccompanyingCourse(id) isLocationValid(state) { return state.accompanyingCourse.location !== null; }, + isScopeValid(state) { + console.log('is scope valid', state.accompanyingCourse.scopes.length > 0); + return state.accompanyingCourse.scopes.length > 0; + }, validationKeys(state, getters) { let keys = []; if (!getters.isParticipationValid) { keys.push('participation'); } if (!getters.isLocationValid) { keys.push('location'); } if (!getters.isSocialIssueValid) { keys.push('socialIssue'); } + if (!getters.isScopeValid) { keys.push('scopes'); } //console.log('getter keys', keys); return keys; }, @@ -137,6 +155,21 @@ let initPromise = getAccompanyingCourse(id) setEditContextTrue(state) { //console.log('### mutation: set edit context = true'); state.addressContext.edit = true; + }, + setScopes(state, scopes) { + state.accompanyingCourse.scopes = scopes; + }, + addScopeAtBackend(state, scope) { + let scopeIds = state.scopesAtBackend.map(s => s.id); + if (!scopeIds.includes(scope.id)) { + state.scopesAtBackend.push(scope); + } + }, + removeScopeAtBackend(state, scope){ + let scopeIds = state.scopesAtBackend.map(s => s.id); + if (scopeIds.includes(scope.id)) { + state.scopesAtBackend = state.scopesAtBackend.filter(s => s.id !== scope.id); + } } }, actions: { @@ -223,6 +256,107 @@ let initPromise = getAccompanyingCourse(id) resolve(); })).catch((error) => { commit('catchError', error) }); }, + /** + * Handle the checked/unchecked scopes + * + * When the user set the scopes in a invalid situation (when no scopes are cheched), this + * method will internally re-add the scopes as they were originally when the page was loaded, but + * this does not appears for the user (they remains unchecked). When the user re-add a scope, the + * scope is back in a valid state, and the store synchronize with the new state (all the original scopes + * are removed if necessary, and the new checked scopes is backed). + * + * So, for instance: + * + * at load: + * + * [x] scope A (at backend: [x]) + * [x] scope B (at backend: [x]) + * [ ] scope C (at backend: [ ]) + * + * The user uncheck scope A: + * + * [ ] scope A (at backend: [ ] as soon as the operation finish) + * [x] scope B (at backend: [x]) + * [ ] scope C (at backend: [ ]) + * + * The user uncheck scope B. The state is invalid (no scope checked), so we go back to initial state when + * the page loaded): + * + * [ ] scope A (at backend: [x] as soon as the operation finish) + * [ ] scope B (at backend: [x] as soon as the operation finish) + * [ ] scope C (at backend: [ ]) + * + * The user check scope C. The scopes are back to valid state. So we go back to synchronization with UI and + * backend): + * + * [ ] scope A (at backend: [ ] as soon as the operation finish) + * [ ] scope B (at backend: [ ] as soon as the operation finish) + * [x] scope C (at backend: [x] as soon as the operation finish) + * + * **Warning** There is a problem if the user check/uncheck faster than the backend is synchronized. + * + * @param commit + * @param state + * @param dispatch + * @param scopes + * @returns Promise + */ + setScopes({ commit, state, dispatch }, scopes) { + let currentServerScopesIds = state.scopesAtBackend.map(scope => scope.id); + let checkedScopesIds = scopes.map(scope => scope.id); + let removedScopesIds = currentServerScopesIds.filter(id => !checkedScopesIds.includes(id)); + let addedScopesIds = checkedScopesIds.filter(id => !currentServerScopesIds.includes(id)); + let lengthAfterOperation = currentServerScopesIds.length + addedScopesIds.length + - removedScopesIds.length; + + if (lengthAfterOperation > 0 || (lengthAfterOperation === 0 && state.scopesAtStart.length === 0) ) { + return dispatch('updateScopes', { + addedScopesIds, removedScopesIds + }).then(() => { + // warning: when the operation of dispatch are too slow, the user may check / uncheck before + // the end of the synchronisation with the server (done by dispatch operation). Then, it leads to + // check/uncheck in the UI. I do not know of to avoid it. + commit('setScopes', scopes); + return Promise.resolve(); + }); + } else { + return dispatch('setScopes', state.scopesAtStart).then(() => { + commit('setScopes', scopes); + return Promise.resolve(); + }); + } + }, + /** + * Internal function for the store to effectively update scopes. + * + * Return a promise which resolves when all update operation are + * successful and finished. + * + * @param state + * @param commit + * @param addedScopesIds + * @param removedScopesIds + * @return Promise + */ + updateScopes({ state, commit }, { addedScopesIds, removedScopesIds }) { + let promises = []; + state.scopes.forEach(scope => { + if (addedScopesIds.includes(scope.id)) { + promises.push(addScope(state.accompanyingCourse.id, scope).then(() => { + commit('addScopeAtBackend', scope); + return Promise.resolve(); + })); + } + if (removedScopesIds.includes(scope.id)) { + promises.push(removeScope(state.accompanyingCourse.id, scope).then(() => { + commit('removeScopeAtBackend', scope); + return Promise.resolve(); + })); + } + }); + + return Promise.all(promises); + }, postFirstComment({ commit }, payload) { //console.log('## action: postFirstComment: payload', payload); patchAccompanyingCourse(id, { type: "accompanying_period", initialComment: payload })