Merge branch 'issue307_location' into 'master'

improve location encoding

See merge request Chill-Projet/chill-bundles!230
This commit is contained in:
Julien Fastré 2021-11-29 10:40:10 +00:00
commit 6d6f930afa
16 changed files with 290 additions and 83 deletions

View File

@ -21,6 +21,8 @@ and this project adheres to
* [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).
* 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
* [calendar] for a new rdv: suggest and create on-the-fly locations based on the accompanying course location + location of the suggested parties
## Test releases ## Test releases

View File

@ -137,7 +137,7 @@ final class ActivityController extends AbstractController
static fn (ActivityReason $ar): int => $ar->getId() static fn (ActivityReason $ar): int => $ar->getId()
) )
->toArray(), ->toArray(),
'type_id' => $activity->getType()->getId(), 'type_id' => $activity->getActivityType()->getId(),
'duration' => $activity->getDurationTime() ? $activity->getDurationTime()->format('U') : null, 'duration' => $activity->getDurationTime() ? $activity->getDurationTime()->format('U') : null,
'date' => $activity->getDate()->format('Y-m-d'), 'date' => $activity->getDate()->format('Y-m-d'),
'attendee' => $activity->getAttendee(), 'attendee' => $activity->getAttendee(),
@ -194,7 +194,7 @@ final class ActivityController extends AbstractController
$form = $this->createForm(ActivityType::class, $entity, [ $form = $this->createForm(ActivityType::class, $entity, [
'center' => $entity->getCenter(), 'center' => $entity->getCenter(),
'role' => new Role('CHILL_ACTIVITY_UPDATE'), 'role' => new Role('CHILL_ACTIVITY_UPDATE'),
'activityType' => $entity->getType(), 'activityType' => $entity->getActivityType(),
'accompanyingPeriod' => $accompanyingPeriod, 'accompanyingPeriod' => $accompanyingPeriod,
])->handleRequest($request); ])->handleRequest($request);
@ -327,7 +327,7 @@ final class ActivityController extends AbstractController
$entity->setAccompanyingPeriod($accompanyingPeriod); $entity->setAccompanyingPeriod($accompanyingPeriod);
} }
$entity->setType($activityType); $entity->setActivityType($activityType);
$entity->setDate(new DateTime('now')); $entity->setDate(new DateTime('now'));
if ($request->query->has('activityData')) { if ($request->query->has('activityData')) {
@ -385,7 +385,7 @@ final class ActivityController extends AbstractController
$form = $this->createForm(ActivityType::class, $entity, [ $form = $this->createForm(ActivityType::class, $entity, [
'center' => $entity->getCenter(), 'center' => $entity->getCenter(),
'role' => new Role('CHILL_ACTIVITY_CREATE'), 'role' => new Role('CHILL_ACTIVITY_CREATE'),
'activityType' => $entity->getType(), 'activityType' => $entity->getActivityType(),
'accompanyingPeriod' => $accompanyingPeriod, 'accompanyingPeriod' => $accompanyingPeriod,
])->handleRequest($request); ])->handleRequest($request);

View File

@ -1,4 +1,5 @@
import { getSocialIssues } from 'ChillPersonAssets/vuejs/AccompanyingCourse/api.js'; import { getSocialIssues } from 'ChillPersonAssets/vuejs/AccompanyingCourse/api.js';
import { fetchResults } from 'ChillMainAssets/lib/api/apiMethods';
/* /*
* Load socialActions by socialIssue (id) * Load socialActions by socialIssue (id)
@ -12,33 +13,20 @@ const getSocialActionByIssue = (id) => {
}); });
}; };
/* const getLocations = () => fetchResults('/api/1.0/main/location.json');
* Load Locations
*/ const getLocationTypes = () => fetchResults('/api/1.0/main/location-type.json');
const getLocations = () => {
const url = `/api/1.0/main/location.json`;
return fetch(url)
.then(response => {
if (response.ok) { return response.json(); }
throw Error('Error with request resource response');
});
};
/* /*
* Load Location Types * Load Location Type by defaultFor
* @param {string} entity - can be "person" or "thirdparty"
*/ */
const getLocationTypes = () => { const getLocationTypeByDefaultFor = (entity) => {
const url = `/api/1.0/main/location-type.json`; return getLocationTypes().then(results =>
return fetch(url) results.filter(t => t.defaultFor === entity)[0]
.then(response => { );
if (response.ok) { return response.json(); }
throw Error('Error with request resource response');
});
}; };
/*
* Post a Location
*/
const postLocation = (body) => { const postLocation = (body) => {
const url = `/api/1.0/main/location.json`; const url = `/api/1.0/main/location.json`;
return fetch(url, { return fetch(url, {
@ -59,5 +47,6 @@ export {
getSocialActionByIssue, getSocialActionByIssue,
getLocations, getLocations,
getLocationTypes, getLocationTypes,
getLocationTypeByDefaultFor,
postLocation postLocation
}; };

View File

@ -2,10 +2,9 @@
<teleport to="#location"> <teleport to="#location">
<div class="mb-3 row"> <div class="mb-3 row">
<label class="col-form-label col-sm-4"> <label class="col-form-label col-sm-4">
{{ $t('activity.location') }} {{ $t("activity.location") }}
</label> </label>
<div class="col-sm-8"> <div class="col-sm-8">
<VueMultiselect <VueMultiselect
name="selectLocation" name="selectLocation"
id="selectLocation" id="selectLocation"
@ -17,7 +16,10 @@
:placeholder="$t('activity.choose_location')" :placeholder="$t('activity.choose_location')"
:custom-label="customLabel" :custom-label="customLabel"
:options="locations" :options="locations"
v-model="location"> group-values="locations"
group-label="locationGroup"
v-model="location"
>
</VueMultiselect> </VueMultiselect>
<new-location v-bind:locations="locations"></new-location> <new-location v-bind:locations="locations"></new-location>
@ -27,49 +29,146 @@
</template> </template>
<script> <script>
import { mapState } from "vuex"; import { mapState, mapGetters } from "vuex";
import VueMultiselect from 'vue-multiselect'; import VueMultiselect from "vue-multiselect";
import NewLocation from './Location/NewLocation.vue'; import NewLocation from "./Location/NewLocation.vue";
import { getLocations } from '../api.js'; import { getLocations, getLocationTypeByDefaultFor } from "../api.js";
export default { export default {
name: "Location", name: "Location",
components: { components: {
NewLocation, NewLocation,
VueMultiselect VueMultiselect,
}, },
data() { data() {
return { return {
locations: [] locations: [],
} };
}, },
computed: { computed: {
...mapState(['activity']), ...mapState(["activity"]),
...mapGetters(["suggestedEntities"]),
location: { location: {
get() { get() {
return this.activity.location; return this.activity.location;
}, },
set(value) { set(value) {
this.$store.dispatch('updateLocation', value); this.$store.dispatch("updateLocation", value);
} },
} },
}, },
mounted() { mounted() {
getLocations().then(response => new Promise(resolve => { getLocations().then(
console.log('getLocations', response); (results) => {
this.locations = response.results; getLocationTypeByDefaultFor('person').then(
if (window.default_location_id) { (personLocationType) => {
let location = this.locations.filter(l => l.id === window.default_location_id); if (personLocationType) {
this.$store.dispatch('updateLocation', location); const personLocation = this.makeAccompanyingPeriodLocation(personLocationType);
const concernedPersonsLocation =
this.makeConcernedPersonsLocation(personLocationType);
getLocationTypeByDefaultFor('thirdparty').then(
thirdpartyLocationType => {
const concernedThirdPartiesLocation =
this.makeConcernedThirdPartiesLocation(thirdpartyLocationType);
this.locations = [
{
locationGroup: 'Localisation du parcours',
locations: [personLocation]
},
{
locationGroup: 'Parties concernées',
locations: [...concernedPersonsLocation, ...concernedThirdPartiesLocation]
},
{
locationGroup: 'Autres localisations',
locations: results
} }
resolve(); ];
})) }
)
} else {
this.locations = [
{
locationGroup: 'Localisations',
locations: response.results
}
];
}
if (window.default_location_id) {
let location = this.locations.filter(
(l) => l.id === window.default_location_id
);
this.$store.dispatch("updateLocation", location);
}
}
)
})
}, },
methods: { methods: {
labelAccompanyingCourseLocation(value) {
return `${value.address.text} (${value.locationType.title.fr})`
},
customLabel(value) { customLabel(value) {
return `${value.locationType.title.fr} ${value.name ? value.name : ''}`; return value.locationType
? value.name
? value.name === '__AccompanyingCourseLocation__'
? this.labelAccompanyingCourseLocation(value)
: `${value.name} (${value.locationType.title.fr})`
: value.locationType.title.fr
: '';
},
makeConcernedPersonsLocation(locationType) {
let locations = [];
this.suggestedEntities.forEach(
(e) => {
if (e.type === 'person' && e.current_household_address !== null){
locations.push({
type: 'location',
id: -this.suggestedEntities.indexOf(e)*10,
onthefly: true,
name: e.text,
address: {
id: e.current_household_address.address_id,
},
locationType: locationType
});
} }
} }
)
return locations;
},
makeConcernedThirdPartiesLocation(locationType) {
let locations = [];
this.suggestedEntities.forEach(
(e) => {
if (e.type === 'thirdparty' && e.address !== null){
locations.push({
type: 'location',
id: -this.suggestedEntities.indexOf(e)*10,
onthefly: true,
name: e.text,
address: { id: e.address.address_id },
locationType: locationType
});
} }
}
)
return locations;
},
makeAccompanyingPeriodLocation(locationType) {
const accPeriodLocation = this.activity.accompanyingPeriod.location;
return {
type: 'location',
id: -1,
onthefly: true,
name: '__AccompanyingCourseLocation__',
address: {
id: accPeriodLocation.address_id,
text: `${accPeriodLocation.text} - ${accPeriodLocation.postcode.code} ${accPeriodLocation.postcode.name}`
},
locationType: locationType
}
}
},
};
</script> </script>

View File

@ -214,11 +214,9 @@ export default {
return cond; return cond;
}, },
getLocationTypesList() { getLocationTypesList() {
getLocationTypes().then(response => new Promise(resolve => { getLocationTypes().then(results => {
console.log('getLocationTypes', response); this.locationTypes = results.filter(t => t.availableForUsers === true);
this.locationTypes = response.results.filter(t => t.availableForUsers === true); })
resolve();
}))
}, },
openModal() { openModal() {
this.modal.showModal = true; this.modal.showModal = true;
@ -247,7 +245,6 @@ export default {
postLocation(body) postLocation(body)
.then( .then(
location => new Promise(resolve => { location => new Promise(resolve => {
console.log('postLocation', location);
this.locations.push(location); this.locations.push(location);
this.$store.dispatch('updateLocation', location); this.$store.dispatch('updateLocation', location);
resolve(); resolve();

View File

@ -1,5 +1,6 @@
import 'es6-promise/auto'; import 'es6-promise/auto';
import { createStore } from 'vuex'; import { createStore } from 'vuex';
import { postLocation } from './api';
const debug = process.env.NODE_ENV !== 'production'; const debug = process.env.NODE_ENV !== 'production';
//console.log('window.activity', window.activity); //console.log('window.activity', window.activity);
@ -27,7 +28,6 @@ const store = createStore({
}, },
getters: { getters: {
suggestedEntities(state) { suggestedEntities(state) {
console.log(state.activity);
if (typeof state.activity.accompanyingPeriod === "undefined") { if (typeof state.activity.accompanyingPeriod === "undefined") {
return []; return [];
} }
@ -303,7 +303,33 @@ const store = createStore({
let hiddenLocation = document.getElementById( let hiddenLocation = document.getElementById(
"chill_activitybundle_activity_location" "chill_activitybundle_activity_location"
); );
if (value.onthefly) {
const body = {
"type": "location",
"name": value.name === '__AccompanyingCourseLocation__' ? null : value.name,
"locationType": {
"id": value.locationType.id,
"type": "location-type"
}
};
if (value.address.id) {
Object.assign(body, {
"address": {
"id": value.address.id
},
})
}
postLocation(body)
.then(
location => hiddenLocation.value = location.id
).catch(
err => {
console.log(err.message);
}
);
} else {
hiddenLocation.value = value.id; hiddenLocation.value = value.id;
}
commit("updateLocation", value); commit("updateLocation", value);
}, },
}, },

View File

@ -67,8 +67,8 @@
<dd> <dd>
{% if entity.location is not null %} {% if entity.location is not null %}
<p> <p>
<span>{{ entity.location.locationType.title|localize_translatable_string }}</span>
{{ entity.location.name }} {{ entity.location.name }}
<span> ({{ entity.location.locationType.title|localize_translatable_string }})</span>
</p> </p>
{{ entity.location.address|chill_entity_render_box }} {{ entity.location.address|chill_entity_render_box }}
{% else %} {% else %}

View File

@ -332,12 +332,12 @@ class CalendarController extends AbstractController
$personsId = array_map( $personsId = array_map(
static fn (Person $p): int => $p->getId(), static fn (Person $p): int => $p->getId(),
$entity->getPersons() $entity->getPersons()->toArray()
); );
$professionalsId = array_map( $professionalsId = array_map(
static fn (ThirdParty $thirdParty): ?int => $thirdParty->getId(), static fn (ThirdParty $thirdParty): ?int => $thirdParty->getId(),
$entity->getProfessionals() $entity->getProfessionals()->toArray()
); );
$durationTime = $entity->getEndDate()->diff($entity->getStartDate()); $durationTime = $entity->getEndDate()->diff($entity->getStartDate());

View File

@ -1,5 +1,6 @@
import 'es6-promise/auto'; import 'es6-promise/auto';
import { createStore } from 'vuex'; import { createStore } from 'vuex';
import { postLocation } from 'ChillActivityAssets/vuejs/Activity/api';
const debug = process.env.NODE_ENV !== 'production'; const debug = process.env.NODE_ENV !== 'production';
@ -33,7 +34,6 @@ const store = createStore({
}, },
getters: { getters: {
suggestedEntities(state) { suggestedEntities(state) {
console.log(state.activity)
if (typeof(state.activity.accompanyingPeriod) === 'undefined') { if (typeof(state.activity.accompanyingPeriod) === 'undefined') {
return []; return [];
} }
@ -189,8 +189,35 @@ const store = createStore({
updateLocation({ commit }, value) { updateLocation({ commit }, value) {
console.log('### action: updateLocation', value); console.log('### action: updateLocation', value);
let hiddenLocation = document.getElementById("chill_calendarbundle_calendar_location"); let hiddenLocation = document.getElementById("chill_calendarbundle_calendar_location");
if (value.onthefly) {
const body = {
"type": "location",
"name": value.name === '__AccompanyingCourseLocation__' ? null : value.name,
"locationType": {
"id": value.locationType.id,
"type": "location-type"
}
};
if (value.address.id) {
Object.assign(body, {
"address": {
"id": value.address.id
},
})
}
postLocation(body)
.then(
location => hiddenLocation.value = location.id
).catch(
err => {
console.log(err.message);
}
);
} else {
hiddenLocation.value = value.id; hiddenLocation.value = value.id;
commit('updateLocation', value); }
commit("updateLocation", value);
} }
} }

View File

@ -41,8 +41,8 @@
<dd> <dd>
{% if entity.location is not null %} {% if entity.location is not null %}
<p> <p>
<span>{{ entity.location.locationType.title|localize_translatable_string }}</span>
{{ entity.location.name }} {{ entity.location.name }}
<span> ({{ entity.location.locationType.title|localize_translatable_string }})</span>
</p> </p>
{{ entity.location.address|chill_entity_render_box }} {{ entity.location.address|chill_entity_render_box }}
{% else %} {% else %}

View File

@ -10,8 +10,6 @@
namespace Chill\MainBundle\Controller; namespace Chill\MainBundle\Controller;
use Chill\MainBundle\CRUD\Controller\ApiController; use Chill\MainBundle\CRUD\Controller\ApiController;
use DateInterval;
use DateTime;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
/** /**
@ -21,22 +19,11 @@ class LocationApiController extends ApiController
{ {
public function customizeQuery(string $action, Request $request, $query): void public function customizeQuery(string $action, Request $request, $query): void
{ {
$query->andWhere($query->expr()->orX( $query->andWhere(
$query->expr()->andX(
$query->expr()->eq('e.createdBy', ':user'),
$query->expr()->gte('e.createdAt', ':dateBefore')
),
$query->expr()->andX( $query->expr()->andX(
$query->expr()->eq('e.availableForUsers', "'TRUE'"), $query->expr()->eq('e.availableForUsers', "'TRUE'"),
$query->expr()->eq('e.active', "'TRUE'"), $query->expr()->eq('e.active', "'TRUE'"),
$query->expr()->isNotNull('e.name'),
$query->expr()->neq('e.name', ':emptyString'),
) )
)) );
->setParameters([
'user' => $this->getUser(),
'dateBefore' => (new DateTime())->sub(new DateInterval('P6M')),
'emptyString' => '',
]);
} }
} }

View File

@ -11,6 +11,7 @@ namespace Chill\MainBundle\Entity;
use Chill\MainBundle\Repository\LocationTypeRepository; use Chill\MainBundle\Repository\LocationTypeRepository;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Serializer\Annotation as Serializer; use Symfony\Component\Serializer\Annotation as Serializer;
use Symfony\Component\Serializer\Annotation\DiscriminatorMap; use Symfony\Component\Serializer\Annotation\DiscriminatorMap;
@ -20,9 +21,14 @@ use Symfony\Component\Serializer\Annotation\DiscriminatorMap;
* @DiscriminatorMap(typeProperty="type", mapping={ * @DiscriminatorMap(typeProperty="type", mapping={
* "location-type": LocationType::class * "location-type": LocationType::class
* }) * })
* @UniqueEntity({"defaultFor"})
*/ */
class LocationType class LocationType
{ {
public const DEFAULT_FOR_3PARTY = 'thirdparty';
public const DEFAULT_FOR_PERSON = 'person';
public const STATUS_NEVER = 'never'; public const STATUS_NEVER = 'never';
public const STATUS_OPTIONAL = 'optional'; public const STATUS_OPTIONAL = 'optional';
@ -53,6 +59,12 @@ class LocationType
*/ */
private string $contactData = self::STATUS_OPTIONAL; private string $contactData = self::STATUS_OPTIONAL;
/**
* @ORM\Column(type="string", nullable=true, length=32, unique=true)
* @Serializer\Groups({"read"})
*/
private ?string $defaultFor = null;
/** /**
* @ORM\Id * @ORM\Id
* @ORM\GeneratedValue * @ORM\GeneratedValue
@ -87,6 +99,11 @@ class LocationType
return $this->contactData; return $this->contactData;
} }
public function getDefaultFor(): ?string
{
return $this->defaultFor;
}
public function getId(): ?int public function getId(): ?int
{ {
return $this->id; return $this->id;
@ -125,6 +142,13 @@ class LocationType
return $this; return $this;
} }
public function setDefaultFor(?string $defaultFor): self
{
$this->defaultFor = $defaultFor;
return $this;
}
public function setTitle(array $title): self public function setTitle(array $title): self
{ {
$this->title = $title; $this->title = $title;

View File

@ -71,6 +71,18 @@ final class LocationTypeType extends AbstractType
], ],
'expanded' => true, 'expanded' => true,
] ]
)
->add(
'defaultFor',
ChoiceType::class,
[
'choices' => [
'none' => null,
'person' => LocationType::DEFAULT_FOR_PERSON,
'thirdparty' => LocationType::DEFAULT_FOR_3PARTY,
],
'expanded' => true,
]
); );
} }
} }

View File

@ -11,6 +11,7 @@
<th>{{ 'Address required'|trans }}</th> <th>{{ 'Address required'|trans }}</th>
<th>{{ 'Contact data'|trans }}</th> <th>{{ 'Contact data'|trans }}</th>
<th>{{ 'Active'|trans }}</th> <th>{{ 'Active'|trans }}</th>
<th>{{ 'Default for'|trans }}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -33,6 +34,7 @@
<i class="fa fa-square-o"></i> <i class="fa fa-square-o"></i>
{%- endif -%} {%- endif -%}
</td> </td>
<td>{{ entity.defaultFor|trans }}</td>
<td> <td>
<ul class="record_actions"> <ul class="record_actions">
<li> <li>

View File

@ -0,0 +1,38 @@
<?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.
*/
declare(strict_types=1);
namespace Chill\Migrations\Main;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Add defaultFor to LocationType.
*/
final class Version20211123093355 extends AbstractMigration
{
public function down(Schema $schema): void
{
$this->addSql('DROP INDEX UNIQ_A459B5CADD3E4105');
$this->addSql('ALTER TABLE chill_main_location_type DROP defaultFor');
}
public function getDescription(): string
{
return 'Add defaultFor to LocationType';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE chill_main_location_type ADD defaultFor VARCHAR(32) DEFAULT NULL');
$this->addSql('CREATE UNIQUE INDEX UNIQ_A459B5CADD3E4105 ON chill_main_location_type (defaultFor)');
}
}

View File

@ -212,6 +212,10 @@ Location type: Type de localisation
Phonenumber1: Numéro de téléphone Phonenumber1: Numéro de téléphone
Phonenumber2: Autre numéro de téléphone Phonenumber2: Autre numéro de téléphone
Configure location and location type: Configuration des localisations Configure location and location type: Configuration des localisations
Default for: Type de localisation par défaut pour
none: aucun
person: usager
thirdparty: tiers
# circles / scopes # circles / scopes
Choose the circle: Choisir le cercle Choose the circle: Choisir le cercle