confidential toggle rights

This commit is contained in:
LenaertsJ 2021-11-29 11:01:51 +00:00 committed by Julien Fastré
parent 6d6f930afa
commit e4e1edff68
11 changed files with 278 additions and 21 deletions

View File

@ -20,6 +20,7 @@ and this project adheres to
* [activity] check ACL on activity list in person context * [activity] check ACL on activity list in person context
* [list for accompanying course in person] filter list using ACL * [list for accompanying course in person] filter list using ACL
* [validation] toasts are displayed for errors when modifying accompanying course (generalization required). * [validation] toasts are displayed for errors when modifying accompanying course (generalization required).
* [period] only the user can enable confidentiality
* add an endpoint for checking permissions. See https://gitlab.com/Chill-Projet/chill-bundles/-/merge_requests/232 * add an endpoint for checking permissions. See https://gitlab.com/Chill-Projet/chill-bundles/-/merge_requests/232
* [activity] for a new activity: suggest and create on-the-fly locations based on the accompanying course location + location of the suggested parties * [activity] for a new activity: suggest and create on-the-fly locations based on the accompanying course location + location of the suggested parties
* [calendar] for a new rdv: suggest and create on-the-fly locations based on the accompanying course location + location of the suggested parties * [calendar] for a new rdv: suggest and create on-the-fly locations based on the accompanying course location + location of the suggested parties

View File

@ -10,7 +10,6 @@
body: (body !== null) ? JSON.stringify(body) : null body: (body !== null) ? JSON.stringify(body) : null
}) })
.then(response => { .then(response => {
if (response.ok) { if (response.ok) {
return response.json(); return response.json();
} }

View File

@ -249,6 +249,22 @@ final class AccompanyingCourseApiController extends ApiController
); );
} }
/**
* @Route("/api/1.0/person/accompanying-course/{id}/confidential.json", name="chill_api_person_accompanying_period_confidential")
* @ParamConverter("accompanyingCourse", options={"id": "id"})
*/
public function toggleConfidentialApi(AccompanyingPeriod $accompanyingCourse, Request $request)
{
if ($request->getMethod() === 'POST') {
$this->denyAccessUnlessGranted(AccompanyingPeriodVoter::CONFIDENTIAL, $accompanyingCourse);
$accompanyingCourse->setConfidential(!$accompanyingCourse->isConfidential());
$this->getDoctrine()->getManager()->flush();
}
return $this->json($accompanyingCourse->isConfidential(), Response::HTTP_OK, [], ['groups' => ['read']]);
}
public function workApi($id, Request $request, string $_format): Response public function workApi($id, Request $request, string $_format): Response
{ {
return $this->addRemoveSomething( return $this->addRemoveSomething(

View File

@ -378,7 +378,6 @@ class ChillPersonExtension extends Extension implements PrependExtensionInterfac
Request::METHOD_DELETE => 'ALWAYS_FAILS', Request::METHOD_DELETE => 'ALWAYS_FAILS',
], ],
], ],
'confirm' => [ 'confirm' => [
'methods' => [ 'methods' => [
Request::METHOD_POST => true, Request::METHOD_POST => true,
@ -389,6 +388,16 @@ class ChillPersonExtension extends Extension implements PrependExtensionInterfac
Request::METHOD_POST => \Chill\PersonBundle\Security\Authorization\AccompanyingPeriodVoter::SEE, Request::METHOD_POST => \Chill\PersonBundle\Security\Authorization\AccompanyingPeriodVoter::SEE,
], ],
], ],
'confidential' => [
'methods' => [
Request::METHOD_POST => true,
Request::METHOD_GET => true,
],
'controller_action' => 'toggleConfidentialApi',
'roles' => [
Request::METHOD_POST => \Chill\PersonBundle\Security\Authorization\AccompanyingPeriodVoter::TOGGLE_CONFIDENTIAL,
],
],
'findAccompanyingPeriodsByPerson' => [ 'findAccompanyingPeriodsByPerson' => [
'path' => '/by-person/{person_id}.{_format}', 'path' => '/by-person/{person_id}.{_format}',
'controller_action' => 'getAccompanyingPeriodsByPerson', 'controller_action' => 'getAccompanyingPeriodsByPerson',

View File

@ -49,6 +49,10 @@ use UnexpectedValueException;
* "accompanying_period": AccompanyingPeriod::class * "accompanying_period": AccompanyingPeriod::class
* }) * })
* @Assert\GroupSequenceProvider * @Assert\GroupSequenceProvider
* @Assert\Expression(
* "this.isConfidential and this.getUser === NULL",
* message="If the accompanying course is confirmed and confidential, a referrer must remain assigned."
* )
*/ */
class AccompanyingPeriod implements class AccompanyingPeriod implements
TrackCreationInterface, TrackCreationInterface,

View File

@ -48,6 +48,7 @@ const whoami = () => {
}); });
}; };
export { export {
whoami, whoami,
getSocialIssues, getSocialIssues,

View File

@ -21,6 +21,7 @@
<script> <script>
import { mapState } from 'vuex'; import { mapState } from 'vuex';
export default { export default {
name: "ToggleFlags", name: "ToggleFlags",
computed: { computed: {
@ -28,6 +29,7 @@ export default {
intensity: state => state.accompanyingCourse.intensity, intensity: state => state.accompanyingCourse.intensity,
emergency: state => state.accompanyingCourse.emergency, emergency: state => state.accompanyingCourse.emergency,
confidential: state => state.accompanyingCourse.confidential, confidential: state => state.accompanyingCourse.confidential,
permissions: state => state.permissions,
}), }),
isRegular() { isRegular() {
return (this.intensity === 'regular') ? true : false; return (this.intensity === 'regular') ? true : false;
@ -37,7 +39,7 @@ export default {
}, },
isConfidential() { isConfidential() {
return (this.confidential) ? true : false; return (this.confidential) ? true : false;
} },
}, },
methods: { methods: {
toggleIntensity() { toggleIntensity() {
@ -73,16 +75,15 @@ export default {
}); });
}, },
toggleConfidential() { toggleConfidential() {
this.$store.dispatch('toggleConfidential', (!this.isConfidential)) this.$store.dispatch('fetchPermissions').then(() => {
.catch(({name, violations}) => { if (!this.$store.getters.canTogglePermission) {
if (name === 'ValidationException' || name === 'AccessException') { this.$toast.open({message: "Seul le référent peut modifier la confidentialité"});
violations.forEach((violation) => this.$toast.open({message: violation})); } else {
} else { this.$store.dispatch('toggleConfidential', (!this.isConfidential));
this.$toast.open({message: 'An error occurred'}) }
} });
}); },
} },
}
} }
</script> </script>

View File

@ -37,6 +37,7 @@ let initPromise = Promise.all([scopesPromise, accompanyingCoursePromise])
referrersSuggested: [], referrersSuggested: [],
// all the users available // all the users available
users: [], users: [],
permissions: {}
}, },
getters: { getters: {
isParticipationValid(state) { isParticipationValid(state) {
@ -70,7 +71,14 @@ let initPromise = Promise.all([scopesPromise, accompanyingCoursePromise])
return true; return true;
} }
return false; return false;
} },
canTogglePermission(state) {
if (state.permissions.roles) {
return state.permissions.roles['CHILL_PERSON_ACCOMPANYING_PERIOD_TOGGLE_CONFIDENTIAL'];
}
return false;
},
}, },
mutations: { mutations: {
catchError(state, error) { catchError(state, error) {
@ -201,6 +209,10 @@ let initPromise = Promise.all([scopesPromise, accompanyingCoursePromise])
return u; return u;
}); });
}, },
setPermissions(state, permissions) {
state.permissions = permissions;
// console.log('permissions', state.permissions);
},
updateLocation(state, r) { updateLocation(state, r) {
//console.log('### mutation: set location attributes', r); //console.log('### mutation: set location attributes', r);
state.accompanyingCourse.locationStatus = r.locationStatus; state.accompanyingCourse.locationStatus = r.locationStatus;
@ -625,6 +637,33 @@ let initPromise = Promise.all([scopesPromise, accompanyingCoursePromise])
let users = await getUsers(); let users = await getUsers();
commit('setUsers', users); commit('setUsers', users);
}, },
/**
* By adding more roles to body['roles'], more permissions can be checked.
*/
fetchPermissions({commit}) {
const url = '/api/1.0/main/permissions/info.json';
const body = {
"object": {
"type": "accompanying_period",
"id": id
},
"class": "Chill\\PersonBundle\\Entity\\AccompanyingPeriod",
"roles": [
"CHILL_PERSON_ACCOMPANYING_PERIOD_TOGGLE_CONFIDENTIAL"
]
}
return makeFetch('POST', url, body)
.then((response) => {
commit('setPermissions', response);
return Promise.resolve();
})
.catch((error) => {
commit('catchError', error);
throw error;
})
},
updateLocation({ commit, dispatch }, payload) { updateLocation({ commit, dispatch }, payload) {
//console.log('## action: updateLocation', payload.locationStatusTo); //console.log('## action: updateLocation', payload.locationStatusTo);
const url = `/api/1.0/person/accompanying-course/${payload.targetId}.json`; const url = `/api/1.0/person/accompanying-course/${payload.targetId}.json`;
@ -642,12 +681,12 @@ let initPromise = Promise.all([scopesPromise, accompanyingCoursePromise])
Object.assign(body, location); Object.assign(body, location);
makeFetch('PATCH', url, body) makeFetch('PATCH', url, body)
.then((response) => { .then((response) => {
commit('updateLocation', { commit('updateLocation', {
location: response.location, location: response.location,
locationStatus: response.locationStatus, locationStatus: response.locationStatus,
personLocation: response.personLocation personLocation: response.personLocation
}); });
dispatch('fetchReferrersSuggested'); dispatch('fetchReferrersSuggested');
}) })
.catch((error) => { .catch((error) => {
commit('catchError', error); commit('catchError', error);

View File

@ -31,6 +31,7 @@ class AccompanyingPeriodVoter extends AbstractChillVoter implements ProvideRoleH
self::EDIT, self::EDIT,
self::DELETE, self::DELETE,
self::FULL, self::FULL,
self::TOGGLE_CONFIDENTIAL_ALL,
]; ];
public const CREATE = 'CHILL_PERSON_ACCOMPANYING_PERIOD_CREATE'; public const CREATE = 'CHILL_PERSON_ACCOMPANYING_PERIOD_CREATE';
@ -53,6 +54,13 @@ class AccompanyingPeriodVoter extends AbstractChillVoter implements ProvideRoleH
*/ */
public const SEE_DETAILS = 'CHILL_PERSON_ACCOMPANYING_PERIOD_SEE_DETAILS'; public const SEE_DETAILS = 'CHILL_PERSON_ACCOMPANYING_PERIOD_SEE_DETAILS';
public const TOGGLE_CONFIDENTIAL = 'CHILL_PERSON_ACCOMPANYING_PERIOD_TOGGLE_CONFIDENTIAL';
/**
* Right to toggle confidentiality.
*/
public const TOGGLE_CONFIDENTIAL_ALL = 'CHILL_PERSON_ACCOMPANYING_PERIOD_TOGGLE_CONFIDENTIAL_ALL';
private Security $security; private Security $security;
private VoterHelperInterface $voterHelper; private VoterHelperInterface $voterHelper;
@ -65,7 +73,7 @@ class AccompanyingPeriodVoter extends AbstractChillVoter implements ProvideRoleH
$this->voterHelper = $voterHelperFactory $this->voterHelper = $voterHelperFactory
->generate(self::class) ->generate(self::class)
->addCheckFor(null, [self::CREATE]) ->addCheckFor(null, [self::CREATE])
->addCheckFor(AccompanyingPeriod::class, self::ALL) ->addCheckFor(AccompanyingPeriod::class, [self::TOGGLE_CONFIDENTIAL, ...self::ALL])
->addCheckFor(Person::class, [self::SEE]) ->addCheckFor(Person::class, [self::SEE])
->build(); ->build();
} }
@ -113,6 +121,14 @@ class AccompanyingPeriodVoter extends AbstractChillVoter implements ProvideRoleH
return false; return false;
} }
if (self::TOGGLE_CONFIDENTIAL === $attribute) {
if ($subject->getUser() === $token->getUser()) {
return true;
}
return $this->voterHelper->voteOnAttribute(self::TOGGLE_CONFIDENTIAL_ALL, $subject, $token);
}
// if confidential, only the referent can see it // if confidential, only the referent can see it
if ($subject->isConfidential()) { if ($subject->isConfidential()) {
return $token->getUser() === $subject->getUser(); return $token->getUser() === $subject->getUser();

View File

@ -0,0 +1,133 @@
<?php
/**
* 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\PersonBundle\Tests\AccompanyingPeriod;
use Chill\MainBundle\Entity\Center;
use Chill\MainBundle\Entity\User;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\PersonBundle\Entity\Person;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Request;
/**
* @internal
* @coversNothing
*/
class AccompanyingPeriodConfidentialTest extends WebTestCase
{
/**
* Setup before the first test of this class (see phpunit doc).
*/
public static function setUpBeforeClass()
{
static::bootKernel();
}
/**
* Setup before each test method (see phpunit doc).
*/
public function setUp()
{
$this->client = static::createClient([], [
'PHP_AUTH_USER' => 'fred',
'PHP_AUTH_PW' => 'password',
]);
}
public function dataGenerateRandomAccompanyingCourse()
{
$maxGenerated = 3;
$maxResults = $maxGenerated * 8;
static::bootKernel();
$em = static::$kernel->getContainer()->get('doctrine.orm.entity_manager');
$center = $em->getRepository(Center::class)
->findOneBy(['name' => 'Center A']);
$qb = $em->createQueryBuilder();
$personIds = $qb
->select('p.id')
->distinct(true)
->from(Person::class, 'p')
->join('p.accompanyingPeriodParticipations', 'participation')
->join('participation.accompanyingPeriod', 'ap')
->andWhere(
$qb->expr()->eq('ap.step', ':step')
)
->andWhere(
$qb->expr()->eq('ap.confidential', ':confidential')
)
->setParameter('step', AccompanyingPeriod::STEP_CONFIRMED)
->setParameter('confidential', true)
->setMaxResults($maxResults)
->getQuery()
->getScalarResult();
// create a random order
shuffle($personIds);
$nbGenerated = 0;
while ($nbGenerated < $maxGenerated) {
$id = array_pop($personIds)['id'];
$person = $em->getRepository(Person::class)
->find($id);
$periods = $person->getAccompanyingPeriods();
yield [array_pop($personIds)['id'], $periods[array_rand($periods)]->getId()];
++$nbGenerated;
}
}
/**
* @dataProvider dataGenerateRandomAccompanyingCourse
*/
public function testRemoveUserWhenConfidential(int $periodId)
{
$period = self::$container->get(AccompanyingPeriodRepository::class)
->find($periodId);
$em = static::$kernel->getContainer()->get('doctrine.orm.entity_manager');
$isConfidential = $period->isConfidential();
$step = $period->getStep();
$initialUser = $period->getUser();
$user = new stdClass();
$user->id = 0;
$user->type = 'user';
dump($user);
$this->client->request(
Request::METHOD_PATCH,
sprintf('/api/1.0/person/accompanying-course/%d.json', $periodId),
[], // parameters
[], // files
[], // server parameters
json_encode(['type' => 'accompanying_period', 'user' => $user])
);
$response = $this->client->getResponse();
// if ($isConfidential === true && $step === 'CONFIRMED') {
$this->assertEquals(422, $response->getStatusCode());
// }
$this->assertEquals(200, $response->getStatusCode());
$period = $em->getRepository(AccompanyingPeriod::class)
->find($periodId);
$this->assertEquals($user, $period->getUser());
// assign initial user again
$period->setUser($initialUser);
$em->flush();
}
}

View File

@ -1113,6 +1113,44 @@ paths:
description: "OK" description: "OK"
400: 400:
description: "transition cannot be applyed" description: "transition cannot be applyed"
/1.0/person/accompanying-course/{id}/confidential.json:
post:
tags:
- person
summary: "Toggle confidentiality of accompanying course"
parameters:
- name: id
in: path
required: true
description: The accompanying period's id
schema:
type: integer
format: integer
minimum: 1
requestBody:
description: "Confidentiality toggle"
required: true
content:
application/json:
schema:
type: object
properties:
type:
type: string
enum:
- "accompanying_period"
confidential:
type: boolean
responses:
401:
description: "Unauthorized"
404:
description: "Not found"
200:
description: "OK"
422:
description: "object with validation errors"
/1.0/person/accompanying-course/by-person/{person_id}.json: /1.0/person/accompanying-course/by-person/{person_id}.json:
get: get: