Merge branch 'features/add-suggestion-household-member-editor' into 'master'

Add suggestion household member editor

See merge request Chill-Projet/chill-bundles!97
This commit is contained in:
Julien Fastré 2021-06-29 19:13:38 +00:00
commit 741ef7b40d
13 changed files with 427 additions and 11 deletions

View File

@ -4,15 +4,50 @@ namespace Chill\PersonBundle\Controller;
use Chill\MainBundle\CRUD\Controller\ApiController;
use Chill\MainBundle\Entity\Address;
use Chill\MainBundle\Serializer\Model\Collection;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Repository\Household\HouseholdRepository;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter;
class HouseholdApiController extends ApiController
{
private HouseholdRepository $householdRepository;
public function __construct(HouseholdRepository $householdRepository)
{
$this->householdRepository = $householdRepository;
}
public function householdAddressApi($id, Request $request, string $_format): Response
{
return $this->addRemoveSomething('address', $id, $request, $_format, 'address', Address::class, [ 'groups' => [ 'read' ] ]);
}
/**
* Find Household of people participating to the same AccompanyingPeriod
*
* @ParamConverter("person", options={"id" = "person_id"})
*/
public function suggestHouseholdByAccompanyingPeriodParticipationApi(Person $person, string $_format)
{
// TODO add acl
$count = $this->householdRepository->countByAccompanyingPeriodParticipation($person);
$paginator = $this->getPaginatorFactory()->create($count);
if ($count === 0) {
$households = [];
} else {
$households = $this->householdRepository->findByAccompanyingPeriodParticipation($person,
$paginator->getItemsPerPage(), $paginator->getCurrentPageFirstItemNumber());
}
$collection = new Collection($households, $paginator);
return $this->json($collection, Response::HTTP_OK, [],
[ "groups" => ["read"]]);
}
}

View File

@ -3,6 +3,7 @@
namespace Chill\PersonBundle\Controller;
use Chill\PersonBundle\Entity\Household\Position;
use Chill\PersonBundle\Repository\AccompanyingPeriodRepository;
use Chill\PersonBundle\Security\Authorization\PersonVoter;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Entity\Household\Household;
@ -25,11 +26,17 @@ class HouseholdMemberController extends ApiController
private TranslatorInterface $translator;
private AccompanyingPeriodRepository $periodRepository;
public function __construct(UrlGeneratorInterface $generator, TranslatorInterface $translator)
public function __construct(
UrlGeneratorInterface $generator,
TranslatorInterface $translator,
AccompanyingPeriodRepository $periodRepository
)
{
$this->generator = $generator;
$this->translator = $translator;
$this->periodRepository = $periodRepository;
}
/**
@ -144,8 +151,23 @@ class HouseholdMemberController extends ApiController
'allowLeaveWithoutHousehold' => $allowLeaveWithoutHousehold ?? $request->query->has('allow_leave_without_household'),
];
// context
if ($request->query->has('accompanying_period_id')) {
$period = $this->periodRepository->find(
$request->query->getInt('accompanying_period_id')
);
if ($period === null) {
throw $this->createNotFoundException('period not found');
}
// TODO add acl on accompanying Course
}
return $this->render('@ChillPerson/Household/members_editor.html.twig', [
'data' => $data
'data' => $data,
'expandSuggestions' => (int) $request->query->getBoolean('expand_suggestions', false),
'accompanyingCourse' => $period ?? null,
]);
}

View File

@ -565,6 +565,13 @@ class ChillPersonExtension extends Extension implements PrependExtensionInterfac
],
'controller_action' => 'householdAddressApi'
],
'suggestHouseholdByAccompanyingPeriodParticipation' => [
'path' => '/suggest/by-person/{person_id}/through-accompanying-period-participation.{_format}',
'methods' => [
Request::METHOD_GET => true,
Request::METHOD_HEAD => true,
]
]
]
],
[

View File

@ -25,8 +25,9 @@ namespace Chill\PersonBundle\Repository;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Doctrine\Persistence\ObjectRepository;
final class AccompanyingPeriodRepository
final class AccompanyingPeriodRepository implements ObjectRepository
{
private EntityRepository $repository;
@ -34,4 +35,32 @@ final class AccompanyingPeriodRepository
{
$this->repository = $entityManager->getRepository(AccompanyingPeriod::class);
}
public function find($id): ?AccompanyingPeriod
{
return $this->repository->find($id);
}
/**
* @return AccompanyingPeriod[]
*/
public function findAll(): array
{
return $this->repository->findAll();
}
public function findBy(array $criteria, ?array $orderBy = null, $limit = null, $offset = null): ?AccompanyingPeriod
{
return $this->repository->findBy($criteria, $orderBy, $limit, $offset);
}
public function findOneBy(array $criteria): ?AccompanyingPeriod
{
return $this->findOneBy($criteria);
}
public function getClassName()
{
return AccompanyingPeriod::class;
}
}

View File

@ -3,15 +3,102 @@
namespace Chill\PersonBundle\Repository\Household;
use Chill\PersonBundle\Entity\Household\Household;
use Chill\PersonBundle\Entity\Person;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\Query\ResultSetMappingBuilder;
use Doctrine\Persistence\ObjectRepository;
final class HouseholdRepository
final class HouseholdRepository implements ObjectRepository
{
private EntityRepository $repository;
private EntityManagerInterface $em;
public function __construct(EntityManagerInterface $entityManager)
{
$this->repository = $entityManager->getRepository(Household::class);
$this->em = $entityManager;
}
public function find($id)
{
return $this->repository->find($id);
}
public function findAll()
{
return $this->repository->findAll();
}
public function findBy(array $criteria, ?array $orderBy = null, $limit = null, $offset = null)
{
return $this->repository->findBy($criteria, $orderBy, $limit, $offset);
}
public function findOneBy(array $criteria)
{
return $this->findOneBy($criteria);
}
public function getClassName()
{
return Household::class;
}
public function countByAccompanyingPeriodParticipation(Person $person)
{
return $this->buildQueryByAccompanyingPeriodParticipation($person, true);
}
public function findByAccompanyingPeriodParticipation(Person $person, int $limit, int $offset)
{
return $this->buildQueryByAccompanyingPeriodParticipation($person, false, $limit, $offset);
}
private function buildQueryByAccompanyingPeriodParticipation(Person $person, bool $isCount = false, int $limit = 50, int $offset = 0)
{
$rsm = new ResultSetMappingBuilder($this->em);
$rsm->addRootEntityFromClassMetadata(Household::class, 'h');
if ($isCount) {
$rsm->addScalarResult('count', 'count');
$sql = \strtr(self::SQL_BY_ACCOMPANYING_PERIOD_PARTICIPATION, [
'{select}' => 'COUNT(households.*) AS count',
'{limits}' => ''
]);
} else {
$sql = \strtr(self::SQL_BY_ACCOMPANYING_PERIOD_PARTICIPATION, [
'{select}' => $rsm->generateSelectClause(['h' => 'households']),
'{limits}' => "OFFSET {$offset} LIMIT {$limit}"
]);
}
$native = $this->em->createNativeQuery($sql, $rsm);
$native->setParameters([0 => $person->getId(), 1 => $person->getId()]);
if ($isCount) {
return $native->getSingleScalarResult();
} else {
return $native->getResult();
}
}
private CONST SQL_BY_ACCOMPANYING_PERIOD_PARTICIPATION = <<<SQL
WITH participations AS (
SELECT DISTINCT part.accompanyingperiod_id
FROM chill_person_accompanying_period_participation AS part
WHERE person_id = ?),
other_participants AS (
SELECT person_id, startDate, endDate
FROM chill_person_accompanying_period_participation
JOIN participations USING (accompanyingperiod_id)
WHERE person_id != ?
),
households AS (SELECT DISTINCT household.*
FROM chill_person_household_members AS hmembers
JOIN other_participants AS op USING (person_id)
JOIN chill_person_household AS household ON hmembers.household_id = household.id
WHERE daterange(op.startDate, op.endDate) && daterange(hmembers.startDate, hmembers.endDate)
)
SELECT {select} FROM households {limits}
SQL;
}

View File

@ -23,6 +23,21 @@ const householdMove = (payload) => {
});
};
const fetchHouseholdSuggestionByAccompanyingPeriod = (personId) => {
const url = `/api/1.0/person/household/suggest/by-person/${personId}/through-accompanying-period-participation.json`;
return window.fetch(url)
.then(response => {
if (response.ok) {
return response.json();
}
throw Error ({m: 'Error while fetching household suggestion', status: response.status});
}).then(data => Promise.resolve(data.results))
.catch(e => console.err(e));
;
};
export {
householdMove,
fetchHouseholdSuggestionByAccompanyingPeriod,
};

View File

@ -14,6 +14,23 @@
</div>
<ul v-if="allowChangeHousehold" class="record_actions">
<li v-if="!showHouseholdSuggestion">
<button
class="sc-button"
@click="toggleHouseholdSuggestion"
>
{{ $tc('household_members_editor.show_household_suggestion',
countHouseholdSuggestion) }}
</button>
</li>
<li v-if="showHouseholdSuggestion">
<button
class="sc-button"
@click="toggleHouseholdSuggestion"
>
{{ $t('household_members_editor.hide_household_suggestion') }}
</button>
</li>
<li v-if="allowHouseholdCreate">
<button class="sc-button bt-create" @click="createHousehold">
{{ $t('household_members_editor.household.create_household') }}
@ -30,12 +47,56 @@
</button>
</li>
</ul>
<div class="householdSuggestions">
<div v-if="showHouseholdSuggestion">
<p>{{ $t('household_members_editor.household_for_participants_accompanying_period') }}:</p>
<div class="householdSuggestionList">
<div
v-for="h in filterHouseholdSuggestionByAccompanyingPeriod"
class="item"
>
<household-viewer :household="h"></household-viewer>
<ul class="record_actions">
<li>
<button class="sc-button" @click="selectHousehold(h)">
{{ $t('household_members_editor.select_household') }}
</button>
</li>
</ul>
</div >
</div>
</div>
</div>
</template>
<style lang="scss">
.householdSuggestionList {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: space-between;
& > .item {
margin-bottom: 0.8rem;
width: calc(50% - 1rem);
border: 1px solid var(--chill-light-gray);
padding: 0.5rem 0.5rem 0 0.5rem;
ul.record_actions {
margin-bottom: 0;
}
}
}
</style>
<script>
import { mapGetters } from 'vuex';
import { mapGetters, mapState } from 'vuex';
import HouseholdViewer from 'ChillPersonAssets/vuejs/_components/Household/Household.vue';
export default {
@ -47,6 +108,12 @@ export default {
...mapGetters([
'hasHousehold',
'isHouseholdNew',
'hasHouseholdSuggestion',
'countHouseholdSuggestion',
'filterHouseholdSuggestionByAccompanyingPeriod',
]),
...mapState([
'showHouseholdSuggestion',
]),
household() {
return this.$store.state.household;
@ -74,6 +141,12 @@ export default {
},
forceLeaveWithoutHousehold() {
this.$store.dispatch('forceLeaveWithoutHousehold');
},
toggleHouseholdSuggestion() {
this.$store.commit('toggleHouseholdSuggestion');
},
selectHousehold(h) {
this.$store.dispatch('selectHousehold', h);
}
},
};

View File

@ -5,7 +5,7 @@ const appMessages = {
fr: {
household_members_editor: {
household: {
no_household_choose_one: "Aucun ménage de destination. Choisissez un ménage.",
no_household_choose_one: "Aucun ménage de destination. Choisissez un ménage. Les usagers concernés par la modification apparaitront ensuite.",
new_household: "Nouveau ménage",
create_household: "Créer un ménage",
search_household: "Chercher un ménage",
@ -13,7 +13,7 @@ const appMessages = {
leave_without_household: "Sans nouveau ménage"
},
concerned: {
title: "Usagers concernés",
title: "Nouveaux membres du ménage",
add_persons: "Ajouter d'autres usagers",
search: "Rechercher des usagers",
move_to: "Déplacer vers",
@ -29,6 +29,10 @@ const appMessages = {
remove_position: "Retirer des {position}",
remove_concerned: "Ne plus transférer",
household_part: "Ménage de destination",
hide_household_suggestion: "Masquer les suggestions",
show_household_suggestion: 'Aucune suggestion | Afficher une suggestion | Afficher {count} suggestions',
household_for_participants_accompanying_period: "Ces ménages partagent le même parcours",
select_household: "Choisir ce ménage",
dates_title: "Période de validité",
dates: {
start_date: "Début de validité",

View File

@ -1,5 +1,5 @@
import { createStore } from 'vuex';
import { householdMove, householdMoveTest } from './../api.js';
import { householdMove, fetchHouseholdSuggestionByAccompanyingPeriod } from './../api.js';
import { datetimeToISO } from 'ChillMainAssets/js/date.js';
const debug = process.env.NODE_ENV !== 'production';
@ -14,6 +14,8 @@ const concerned = window.household_members_editor_data.persons.map(p => {
};
});
console.log('expand suggestions', window.household_members_editor_expand_suggestions === 1);
const store = createStore({
strict: debug,
state: {
@ -33,11 +35,16 @@ const store = createStore({
allowHouseholdSearch: window.household_members_editor_data.allowHouseholdSearch,
allowLeaveWithoutHousehold: window.household_members_editor_data.allowLeaveWithoutHousehold,
forceLeaveWithoutHousehold: false,
householdSuggestionByAccompanyingPeriod: [],
showHouseholdSuggestion: window.household_members_editor_expand_suggestions === 1,
warnings: [],
errors: []
},
getters: {
isHouseholdNew(state) {
if (state.household === null) {
return false;
}
return !Number.isInteger(state.household.id);
},
hasHousehold(state) {
@ -46,6 +53,21 @@ const store = createStore({
hasHouseholdOrLeave(state) {
return state.household !== null || state.forceLeaveWithoutHousehold;
},
hasHouseholdSuggestion(state, getters) {
return getters.filterHouseholdSuggestionByAccompanyingPeriod.length > 0;
},
countHouseholdSuggestion(state, getters) {
return getters.filterHouseholdSuggestionByAccompanyingPeriod.length;
},
filterHouseholdSuggestionByAccompanyingPeriod(state) {
if (state.household === null) {
return state.householdSuggestionByAccompanyingPeriod;
}
return state.householdSuggestionByAccompanyingPeriod
.filter(h => h.id !== state.household.id)
;
},
hasPersonsWellPositionnated(state, getters) {
return getters.needsPositionning === false
|| (getters.persons.length > 0 && getters.concUnpositionned.length === 0);
@ -172,9 +194,25 @@ const store = createStore({
state.household = null;
state.forceLeaveWithoutHousehold = true;
},
selectHousehold(state, household) {
state.household = household;
state.forceLeaveWithoutHousehold = false;
},
setHouseholdSuggestionByAccompanyingPeriod(state, households) {
let existingIds = state.householdSuggestionByAccompanyingPeriod
.map(h => h.id);
for (let i in households) {
if (!existingIds.includes(households[i].id)) {
state.householdSuggestionByAccompanyingPeriod.push(households[i]);
}
}
},
setStartDate(state, dateI) {
state.startDate = dateI;
},
toggleHouseholdSuggestion(state) {
state.showHouseholdSuggestion = !state.showHouseholdSuggestion;
},
setWarnings(state, warnings) {
state.warnings = warnings;
// reset errors, which should come from servers
@ -213,6 +251,10 @@ const store = createStore({
commit('forceLeaveWithoutHousehold');
dispatch('computeWarnings');
},
selectHousehold({ commit }, h) {
commit('selectHousehold', h);
dispatch('computeWarnings');
},
setStartDate({ commit, dispatch }, date) {
commit('setStartDate', date);
dispatch('computeWarnings');
@ -220,6 +262,12 @@ const store = createStore({
setComment({ commit }, payload) {
commit('setComment', payload);
},
fetchHouseholdSuggestionForConcerned({ commit, state }, person) {
fetchHouseholdSuggestionByAccompanyingPeriod(person.id)
.then(households => {
commit('setHouseholdSuggestionByAccompanyingPeriod', households);
});
},
computeWarnings({ commit, state, getters }) {
let warnings = [],
payload;
@ -255,7 +303,7 @@ const store = createStore({
if (household.type === 'household') {
household_id = household.id;
// nothing to do anymore here, bye-bye !
window.location.replace(`/fr/person/household/${household_id}/members`);
window.location.replace(`/fr/person/household/${household_id}/summary`);
} else {
// we assume the answer was 422...
error = household;
@ -274,4 +322,10 @@ const store = createStore({
store.dispatch('computeWarnings');
if (concerned.length > 0) {
concerned.forEach(c => {
store.dispatch('fetchHouseholdSuggestionForConcerned', c.person);
});
}
export { store };

View File

@ -39,12 +39,15 @@
<ul>
{% for p in withoutHousehold %}
<li>
<input type="checkbox" name="persons[]" value="{{ p.id }}" />
<input type="checkbox" name="persons[]" value="{{ p.id }}" checked />
{{ p|chill_entity_render_box }}
</li>
{% endfor %}
</ul>
<input type="hidden" name="expand_suggestions" value="true" />
<input type="hidden" name="accompanying_period_id", value="{{ accompanyingCourse.id }}" />
<ul class="record_actions">
<li>
<button type="submit" class="sc-button bt-edit">

View File

@ -1,4 +1,5 @@
{% extends '@ChillMain/layout.html.twig' %}
{% extends accompanyingCourse != null ? '@ChillPerson/AccompanyingCourse/layout.html.twig'
: '@ChillMain/layout.html.twig' %}
{% block title 'household.Edit household members'|trans %}
@ -14,6 +15,7 @@
{% block js %}
<script type="text/javascript">
window.household_members_editor_data = {{ data|json_encode|raw }};
window.household_members_editor_expand_suggestions = {{ expandSuggestions }};
</script>
{{ encore_entry_script_tags('household_members_editor') }}
{% endblock %}

View File

@ -0,0 +1,55 @@
<?php
namespace Chill\PersonBundle\Tests\Controller;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\MainBundle\Test\PrepareClientTrait;
use Symfony\Component\HttpFoundation\Request;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
class HouseholdApiControllerTest extends WebTestCase
{
use PrepareClientTrait;
/**
* @dataProvider generatePersonId
*/
public function testSuggestByAccompanyingPeriodParticipation(int $personId)
{
$client = $this->getClientAuthenticated();
$client->request(
Request::METHOD_GET,
"/api/1.0/person/household/suggest/by-person/{$personId}/through-accompanying-period-participation.json"
);
$this->assertResponseIsSuccessful();
}
public function generatePersonId()
{
self::bootKernel();
$qb = self::$container->get(EntityManagerInterface::class)
->createQueryBuilder();
$period = $qb
->select('ap')
->from(AccompanyingPeriod::class, 'ap')
->where(
$qb->expr()->gte('SIZE(ap.participations)', 2)
)
->getQuery()
->setMaxResults(1)
->getSingleResult()
;
$person = $period->getParticipations()
->first()->getPerson();
yield [ $person->getId() ];
}
}

View File

@ -967,6 +967,36 @@ paths:
401:
description: "Unauthorized"
/1.0/person/household/suggest/by-person/{person_id}/through-accompanying-period-participation.json:
get:
tags:
- household
summary: Return households associated with the given person through accompanying periods
description: |
Return households associated with the given person throught accompanying periods participation.
The current household of the given person is excluded.
parameters:
- name: person_id
in: path
required: true
description: The person's id
schema:
type: integer
format: integer
minimum: 1
responses:
200:
description: "ok"
content:
application/json:
schema:
$ref: '#/components/schemas/Household'
404:
description: "not found"
401:
description: "Unauthorized"
/1.0/person/household/members/move.json:
post:
tags: