add validation to accompanying periods

This commit is contained in:
Julien Fastré 2021-11-12 12:07:31 +00:00
parent 39ab7057ce
commit c8135e0741
22 changed files with 337 additions and 88 deletions

View File

@ -37,6 +37,18 @@ class DateRangeCoveringTest extends TestCase
$this->assertNotContains(3, $cover->getIntersections()[0][2]);
}
public function testCoveringWithMinCover1_NoCoveringWithNullDates()
{
$cover = new DateRangeCovering(1, new \DateTimeZone('Europe/Brussels'));
$cover
->add(new \DateTime('2021-10-05'), new \DateTime('2021-10-18'), 521)
->add(new \DateTime('2021-10-26'), null, 663)
->compute()
;
$this->assertFalse($cover->hasIntersections());
}
public function testCoveringWithMinCover1WithTwoIntersections()
{
$cover = new DateRangeCovering(1, new \DateTimeZone('Europe/Brussels'));

View File

@ -140,67 +140,6 @@ class DateRangeCovering
return $this;
}
private function process(array $intersections): array
{
$result = [];
$starts = [];
$ends = [];
$metadatas = [];
while (null !== ($current = \array_pop($intersections))) {
list($cStart, $cEnd, $cMetadata) = $current;
$n = count($cMetadata);
foreach ($intersections as list($iStart, $iEnd, $iMetadata)) {
$start = max($cStart, $iStart);
$end = min($cEnd, $iEnd);
if ($start <= $end) {
if (FALSE !== ($key = \array_search($start, $starts))) {
if ($ends[$key] === $end) {
$metadatas[$key] = \array_unique(\array_merge($metadatas[$key], $iMetadata));
continue;
}
}
$starts[] = $start;
$ends[] = $end;
$metadatas[] = \array_unique(\array_merge($iMetadata, $cMetadata));
}
}
}
// recompose results
foreach ($starts as $k => $start) {
$result[] = [$start, $ends[$k], \array_unique($metadatas[$k])];
}
return $result;
}
private function addToIntersections(array $intersections, array $intersection)
{
$foundExisting = false;
list($nStart, $nEnd, $nMetadata) = $intersection;
\array_walk($intersections,
function(&$i, $key) use ($nStart, $nEnd, $nMetadata, $foundExisting) {
if ($foundExisting) {
return;
};
if ($i[0] === $nStart && $i[1] === $nEnd) {
$foundExisting = true;
$i[2] = \array_merge($i[2], $nMetadata);
}
}
);
if (!$foundExisting) {
$intersections[] = $intersection;
}
return $intersections;
}
public function hasIntersections(): bool
{
if (!$this->computed) {

View File

@ -54,10 +54,14 @@ class AccompanyingCourseApiController extends ApiController
$accompanyingPeriod = $this->getEntity('participation', $id, $request);
$this->checkACL('confirm', $request, $_format, $accompanyingPeriod);
$workflow = $this->registry->get($accompanyingPeriod);
$workflow = $this->registry->get($accompanyingPeriod);
if (FALSE === $workflow->can($accompanyingPeriod, 'confirm')) {
throw new BadRequestException('It is not possible to confirm this period');
// throw new BadRequestException('It is not possible to confirm this period');
$errors = $this->validator->validate($accompanyingPeriod, null, [$accompanyingPeriod::STEP_CONFIRMED]);
if( count($errors) > 0 ){
return $this->json($errors, 422);
}
}
$workflow->apply($accompanyingPeriod, 'confirm');
@ -109,6 +113,13 @@ $workflow = $this->registry->get($accompanyingPeriod);
public function resourceApi($id, Request $request, string $_format): Response
{
$accompanyingPeriod = $this->getEntity('resource', $id, $request);
$errors = $this->validator->validate($accompanyingPeriod);
if ($errors->count() > 0) {
return $this->json($errors, 422);
}
return $this->addRemoveSomething('resource', $id, $request, $_format, 'resource', Resource::class);
}

View File

@ -40,7 +40,7 @@ class LoadAccompanyingPeriodOrigin extends AbstractFixture implements OrderedFix
public function getOrder()
{
return 10005;
return 9000;
}
private $phoneCall = ['en' => 'phone call', 'fr' => 'appel téléphonique'];

View File

@ -247,7 +247,9 @@ class LoadPeople extends AbstractFixture implements OrderedFixtureInterface, Con
if (\random_int(0, 10) > 3) {
// always add social scope:
$accompanyingPeriod->addScope($this->getReference('scope_social'));
$origin = $this->getReference(LoadAccompanyingPeriodOrigin::ACCOMPANYING_PERIOD_ORIGIN);
$accompanyingPeriod->setOrigin($origin);
$accompanyingPeriod->setIntensity('regular');
$accompanyingPeriod->setAddressLocation($this->createAddress());
$manager->persist($accompanyingPeriod->getAddressLocation());
$workflow = $this->workflowRegistry->get($accompanyingPeriod);

View File

@ -45,6 +45,9 @@ use Chill\MainBundle\Entity\User;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Serializer\Annotation\DiscriminatorMap;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\GroupSequenceProviderInterface;
use Chill\PersonBundle\Validator\Constraints\AccompanyingPeriod\ParticipationOverlap;
use Chill\PersonBundle\Validator\Constraints\AccompanyingPeriod\ResourceDuplicateCheck;
/**
* AccompanyingPeriod Class
@ -54,9 +57,10 @@ use Symfony\Component\Validator\Constraints as Assert;
* @DiscriminatorMap(typeProperty="type", mapping={
* "accompanying_period"=AccompanyingPeriod::class
* })
* @Assert\GroupSequenceProvider
*/
class AccompanyingPeriod implements TrackCreationInterface, TrackUpdateInterface,
HasScopesInterface, HasCentersInterface
HasScopesInterface, HasCentersInterface, GroupSequenceProviderInterface
{
/**
* Mark an accompanying period as "occasional"
@ -132,6 +136,7 @@ class AccompanyingPeriod implements TrackCreationInterface, TrackUpdateInterface
* cascade={"persist", "remove"},
* orphanRemoval=true
* )
* @Assert\NotBlank(groups={AccompanyingPeriod::STEP_DRAFT})
*/
private $comments;
@ -147,9 +152,10 @@ class AccompanyingPeriod implements TrackCreationInterface, TrackUpdateInterface
* @var Collection
*
* @ORM\OneToMany(targetEntity=AccompanyingPeriodParticipation::class,
* mappedBy="accompanyingPeriod",
* mappedBy="accompanyingPeriod", orphanRemoval=true,
* cascade={"persist", "refresh", "remove", "merge", "detach"})
* @Groups({"read"})
* @ParticipationOverlap(groups={AccompanyingPeriod::STEP_DRAFT, AccompanyingPeriod::STEP_CONFIRMED})
*/
private $participations;
@ -188,6 +194,7 @@ class AccompanyingPeriod implements TrackCreationInterface, TrackUpdateInterface
* @ORM\ManyToOne(targetEntity=Origin::class)
* @ORM\JoinColumn(nullable=true)
* @Groups({"read", "write"})
* @Assert\NotBlank(groups={AccompanyingPeriod::STEP_CONFIRMED})
*/
private $origin;
@ -195,8 +202,9 @@ class AccompanyingPeriod implements TrackCreationInterface, TrackUpdateInterface
* @var string
* @ORM\Column(type="string", nullable=true)
* @Groups({"read", "write"})
* @Assert\NotBlank(groups={AccompanyingPeriod::STEP_CONFIRMED})
*/
private $intensity;
private $intensity = self::INTENSITY_OCCASIONAL;
/**
* @var Collection
@ -210,6 +218,7 @@ class AccompanyingPeriod implements TrackCreationInterface, TrackUpdateInterface
* inverseJoinColumns={@ORM\JoinColumn(name="scope_id", referencedColumnName="id")}
* )
* @Groups({"read"})
* @Assert\Count(min=1, groups={AccompanyingPeriod::STEP_CONFIRMED})
*/
private $scopes;
@ -256,6 +265,7 @@ class AccompanyingPeriod implements TrackCreationInterface, TrackUpdateInterface
* orphanRemoval=true
* )
* @Groups({"read"})
* @ResourceDuplicateCheck(groups={AccompanyingPeriod::STEP_DRAFT, AccompanyingPeriod::STEP_CONFIRMED, "Default", "default"})
*/
private $resources;
@ -267,6 +277,7 @@ class AccompanyingPeriod implements TrackCreationInterface, TrackUpdateInterface
* name="chill_person_accompanying_period_social_issues"
* )
* @Groups({"read"})
* @Assert\Count(min=1, groups={AccompanyingPeriod::STEP_CONFIRMED})
*/
private Collection $socialIssues;
@ -606,6 +617,14 @@ class AccompanyingPeriod implements TrackCreationInterface, TrackUpdateInterface
return $participation;
}
/**
* Remove Participation
*/
public function removeParticipation(AccompanyingPeriodParticipation $participation)
{
$participation->setAccompanyingPeriod(null);
}
/**
* Remove Person
@ -1115,4 +1134,17 @@ class AccompanyingPeriod implements TrackCreationInterface, TrackUpdateInterface
return $centers ?? null;
}
public function getGroupSequence()
{
if($this->getStep() == self::STEP_DRAFT)
{
return [[self::STEP_DRAFT]];
}
if($this->getStep() == self::STEP_CONFIRMED)
{
return [[self::STEP_DRAFT, self::STEP_CONFIRMED]];
}
}
}

View File

@ -33,7 +33,13 @@ use Symfony\Component\Serializer\Annotation\Groups;
/**
* @ORM\Entity
* @ORM\Table(name="chill_person_accompanying_period_resource")
* @ORM\Table(
* name="chill_person_accompanying_period_resource",
* uniqueConstraints={
* @ORM\UniqueConstraint(name="person_unique", columns={"person_id", "accompanyingperiod_id"}),
* @ORM\UniqueConstraint(name="thirdparty_unique", columns={"thirdparty_id", "accompanyingperiod_id"})
* }
* )
* @DiscriminatorMap(typeProperty="type", mapping={
* "accompanying_period_resource"=Resource::class
* })

View File

@ -134,4 +134,11 @@ class AccompanyingPeriodParticipation
{
return $this->endDate === null;
}
private function checkSameStartEnd()
{
if($this->endDate == $this->startDate) {
$this->accompanyingPeriod->removeParticipation($this);
}
}
}

View File

@ -16,16 +16,16 @@
<comment v-if="accompanyingCourse.step === 'DRAFT'"></comment>
<confirm v-if="accompanyingCourse.step === 'DRAFT'"></confirm>
<div v-for="error in errorMsg" class="vue-component errors alert alert-danger">
<!-- <div v-for="error in errorMsg" v-bind:key="error.id" class="vue-component errors alert alert-danger">
<p>
<span>{{ error.sta }} {{ error.txt }}</span><br>
<span>{{ $t(error.msg) }}</span>
</p>
</div>
</div> -->
</template>
<script>
import { mapState } from 'vuex'
import { mapGetters, mapState } from 'vuex'
import Banner from './components/Banner.vue';
import StickyNav from './components/StickyNav.vue';
import OriginDemand from './components/OriginDemand.vue';
@ -55,11 +55,12 @@ export default {
Comment,
Confirm,
},
computed: mapState([
computed: {
...mapState([
'accompanyingCourse',
'addressContext',
'errorMsg'
])
'addressContext'
]),
},
};
</script>

View File

@ -86,7 +86,8 @@ const postParticipation = (id, payload, method) => {
})
.then(response => {
if (response.ok) { return response.json(); }
throw { msg: 'Error while sending AccompanyingPeriod Course participation.', sta: response.status, txt: response.statusText, err: new Error(), body: response.body };
// TODO: adjust message according to status code? Or how to access the message from the violation array?
throw { msg: 'Error while sending AccompanyingPeriod Course participation', sta: response.status, txt: response.statusText, err: new Error(), body: response.body };
});
};

View File

@ -10,13 +10,13 @@
<VueMultiselect
name="selectOrigin"
label="text"
v-bind:custom-label="transText"
:custom-label="transText"
track-by="id"
v-bind:multiple="false"
v-bind:searchable="true"
v-bind:placeholder="$t('origin.placeholder')"
:multiple="false"
:searchable="true"
:placeholder="$t('origin.placeholder')"
v-model="value"
v-bind:options="options"
:options="options"
@select="updateOrigin">
</VueMultiselect>
@ -47,18 +47,18 @@ export default {
},
methods: {
getOptions() {
//console.log('loading origins list');
getListOrigins().then(response => new Promise((resolve, reject) => {
this.options = response.results;
resolve();
}));
},
updateOrigin(value) {
//console.log('value', value);
console.log('value', value);
this.$store.dispatch('updateOrigin', value);
},
transText ({ text }) {
return text.fr //TODO multilang
const parsedText = JSON.parse(text);
return parsedText.fr;
},
}
}

View File

@ -2,6 +2,8 @@ import { createApp } from 'vue'
import { _createI18n } from 'ChillMainAssets/vuejs/_js/i18n'
import { appMessages } from './js/i18n'
import { initPromise } from './store'
import VueToast from 'vue-toast-notification';
import 'vue-toast-notification/dist/theme-sugar.css';
import App from './App.vue';
import Banner from './components/Banner.vue';
@ -21,6 +23,7 @@ if (root === 'app') {
})
.use(store)
.use(i18n)
.use(VueToast)
.component('app', App)
.mount('#accompanying-course');
});

View File

@ -77,7 +77,7 @@ let initPromise = Promise.all([scopesPromise, accompanyingCoursePromise])
},
mutations: {
catchError(state, error) {
console.log('### mutation: a new error have been catched and pushed in store !', error);
// console.log('### mutation: a new error have been catched and pushed in store !', error);
state.errorMsg.push(error);
},
removeParticipation(state, participation) {

View File

@ -11,7 +11,7 @@ class LocationValidity extends Constraint
{
public $messagePersonLocatedMustBeAssociated = "The person where the course is located must be associated to the course. Change course's location before removing the person.";
public $messagePeriodMustRemainsLocated = "The period must remains located";
public $messagePeriodMustRemainsLocated = "The period must remain located";
public function getTargets()
{

View File

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Chill\PersonBundle\Validator\Constraints\AccompanyingPeriod;
use Symfony\Component\Validator\Constraint;
/**
* @Annotation
*/
class ParticipationOverlap extends Constraint
{
public $message = 'This participation already exists.';
}

View File

@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace Chill\PersonBundle\Validator\Constraints\AccompanyingPeriod;
use Chill\MainBundle\Util\DateRangeCovering;
use Chill\PersonBundle\Entity\AccompanyingPeriodParticipation;
use Chill\PersonBundle\Validator\Constraints\AccompanyingPeriod\ParticipationOverlap;
use Doctrine\Common\Collections\Collection;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
class ParticipationOverlapValidator extends ConstraintValidator
{
private const MAX_PARTICIPATION = 1;
public function validate($participations, Constraint $constraint)
{
if (!$constraint instanceof ParticipationOverlap) {
throw new UnexpectedTypeException($constraint, ParticipationOverlap::class);
}
if (!$participations instanceof Collection) {
throw new UnexpectedTypeException($participations, 'This should be a collection');
}
if (count($participations) <= self::MAX_PARTICIPATION) {
return;
}
$overlaps = new DateRangeCovering(self::MAX_PARTICIPATION, $participations[0]->getStartDate()->getTimezone());
foreach ($participations as $participation) {
if (!$participation instanceof AccompanyingPeriodParticipation) {
throw new UnexpectedTypeException($participation, AccompanyingPeriodParticipation::class);
}
$personId = $participation->getPerson()->getId();
$particpationList[$personId][] = $participation;
}
foreach ($particpationList as $group) {
if (count($group) > 1) {
foreach ($group as $p) {
$overlaps->add($p->getStartDate(), $p->getEndDate(), $p->getId());
}
}
}
$overlaps->compute();
if ($overlaps->hasIntersections()) {
foreach ($overlaps->getIntersections() as list($start, $end, $ids)) {
$msg = $end === null ? $constraint->message :
$constraint->message;
$this->context->buildViolation($msg)
->setParameters([
'{{ start }}' => $start->format('d-m-Y'),
'{{ end }}' => $end === null ? null : $end->format('d-m-Y'),
'{{ ids }}' => $ids,
])
->addViolation();
}
}
}
}

View File

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Chill\PersonBundle\Validator\Constraints\AccompanyingPeriod;
use Symfony\Component\Validator\Constraint;
/**
* @Annotation
*/
class ResourceDuplicateCheck extends Constraint
{
public $message = '{{ name }} is already associated to this accompanying course.';
}

View File

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace Chill\PersonBundle\Validator\Constraints\AccompanyingPeriod;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Templating\Entity\PersonRender;
use Doctrine\Common\Collections\Collection;
use Symfony\Component\Form\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Chill\PersonBundle\Validator\Constraints\AccompanyingPeriod\ResourceDuplicateCheck;
use Chill\ThirdPartyBundle\Templating\Entity\ThirdPartyRender;
class ResourceDuplicateCheckValidator extends ConstraintValidator
{
private PersonRender $personRender;
private ThirdPartyRender $thirdpartyRender;
public function __construct(PersonRender $personRender, ThirdPartyRender $thirdPartyRender)
{
$this->personRender = $personRender;
$this->thirdpartyRender = $thirdPartyRender;
}
public function validate($resources, Constraint $constraint)
{
if (!$constraint instanceof ResourceDuplicateCheck) {
throw new UnexpectedTypeException($constraint, ParticipationOverlap::class);
}
if (!$resources instanceof Collection) {
throw new UnexpectedTypeException($resources, Collection::class);
}
$resourceList = [];
foreach ($resources as $resource) {
$id = ($resource->getResource() instanceof Person ? 'p' :
't').$resource->getResource()->getId();
if (\in_array($id, $resourceList, true)) {
$this->context->buildViolation($constraint->message)
->setParameter('{{ name }}', $resource->getResource() instanceof Person ? $this->personRender->renderString($resource->getResource(), []) :
$this->thirdpartyRender->renderString($resource->getResource(), []))
->addViolation();
}
$resourceList[] = $id;
}
}
}

View File

@ -0,0 +1,6 @@
services:
Chill\PersonBundle\Validator\Constraints\AccompanyingPeriod:
autowire: true
autoconfigure: true
tags: ['validator.service_arguments']

View File

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Chill\Migrations\Person;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Validation added to accompanying period resources and accompanying period.
*/
final class Version20211020131133 extends AbstractMigration
{
public function getDescription(): string
{
return 'Validation added to accompanying period resources and accompanying period.';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE UNIQUE INDEX person_unique ON chill_person_accompanying_period_resource (person_id, accompanyingperiod_id) WHERE person_id IS NOT NULL');
$this->addSql('CREATE UNIQUE INDEX thirdparty_unique ON chill_person_accompanying_period_resource (thirdparty_id, accompanyingperiod_id) WHERE thirdparty_id IS NOT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('DROP INDEX person_unique');
$this->addSql('DROP INDEX thirdparty_unique');
}
}

View File

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Chill\Migrations\Person;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Custom constraint added to database to prevent identical participations.
*/
final class Version20211021125359 extends AbstractMigration
{
public function getDescription(): string
{
return 'Custom constraint added to database to prevent identical participations.';
}
public function up(Schema $schema): void
{
// creates a constraint 'participations may not overlap'
$this->addSql('ALTER TABLE chill_person_accompanying_period_participation ADD CONSTRAINT '.
"participations_no_overlap EXCLUDE USING GIST(
-- extension btree_gist required to include comparaison with integer
person_id WITH =, accompanyingperiod_id WITH =,
daterange(startdate, enddate) WITH &&
)
INITIALLY DEFERRED");
}
public function down(Schema $schema): void
{
$this->addSql('CREATE UNIQUE INDEX participation_unique ON chill_person_accompanying_period_participation (accompanyingperiod_id, person_id)');
}
}

View File

@ -41,3 +41,6 @@ household:
household_membership:
The end date must be after start date: La date de la fin de l'appartenance doit être postérieure à la date de début.
Person with membership covering: Une personne ne peut pas appartenir à deux ménages simultanément. Or, avec cette modification, %person_name% appartiendrait à %nbHousehold% ménages à partir du %from%.
# Accompanying period
'{{ name }} is already associated to this accompanying course.': '{{ name }} est déjà associé avec ce parcours.'